Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions apps/desktop/src/main/__tests__/task-catalog-rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,49 @@ describe('archivedTaskRows', () => {
});
});

describe('archived task order', () => {
/**
* Store order here is deliberately none of the orders under test: not
* archive order, not activity order, and the two unknown rows sit on either
* side of known ones. Only an archive-time sort with unknowns last and a
* stable tie-break yields the expected list.
*/
it('puts the most recently archived first, unknown times last, ties in store order', () => {
const sessions = [
summary('unknown-a', { isArchived: true, lastMessageAt: 900 }),
summary('older', { isArchived: true, lastMessageAt: 800, archivedAt: 1_000 }),
summary('tie-first', { isArchived: true, lastMessageAt: 100, archivedAt: 3_000 }),
summary('unknown-b', { isArchived: true, lastMessageAt: 50 }),
summary('newest', { isArchived: true, lastMessageAt: 10, archivedAt: 5_000 }),
summary('tie-second', { isArchived: true, lastMessageAt: 700, archivedAt: 3_000 }),
];

assert.deepEqual(
archivedTaskRows(sessions).map((session) => session.id),
['newest', 'tie-first', 'tie-second', 'older', 'unknown-a', 'unknown-b'],
);
});

it('orders a revision family by the archive time of the row that represents it', () => {
const sessions = [
summary('solo', { isArchived: true, lastMessageAt: 900, archivedAt: 2_000 }),
summary('v1', { isArchived: true, lastMessageAt: 100, archivedAt: 4_000 }),
summary('v2', {
isArchived: true,
lastMessageAt: 200,
archivedAt: 4_000,
revisionRootSessionId: 'v1',
revisionParentSessionId: 'v1',
}),
];

assert.deepEqual(
archivedTaskRows(sessions).map((session) => session.id),
['v2', 'solo'],
);
});
});

describe('matchesArchivedTaskQuery', () => {
const projectLabelOf = (session: SessionSummary) =>
session.projectId === 'p1' ? 'astryx-design' : undefined;
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/renderer/locales/settings-tasks-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export type SettingsTasksCopy = {
listAria: string;
noProject: string;
deletedParent: string;
/** Row detail: when the task was archived, from a compact timestamp. */
archivedAt(when: string): string;
/** Row detail for a task archived before the time was recorded. */
archiveTimeUnknown: string;
searchLabel: string;
purgeAll: string;
purgeMatches(count: number): string;
Expand Down Expand Up @@ -64,6 +68,8 @@ const SETTINGS_TASKS_COPY_BY_LOCALE = {
listAria: '已归档任务',
noProject: '无项目',
deletedParent: '原父任务已删除',
archivedAt: (when: string) => `${when}归档`,
archiveTimeUnknown: '归档时间未知',
searchLabel: '搜索已归档任务',
purgeAll: '清空全部',
purgeMatches: (count: number) => `删除这 ${count} 条`,
Expand Down Expand Up @@ -91,6 +97,8 @@ const SETTINGS_TASKS_COPY_BY_LOCALE = {
listAria: '已歸檔任務',
noProject: '無專案',
deletedParent: '原父任務已刪除',
archivedAt: (when: string) => `${when}歸檔`,
archiveTimeUnknown: '歸檔時間未知',
searchLabel: '搜尋已歸檔任務',
purgeAll: '清空全部',
purgeMatches: (count: number) => `刪除這 ${count} 條`,
Expand Down Expand Up @@ -118,6 +126,8 @@ const SETTINGS_TASKS_COPY_BY_LOCALE = {
listAria: 'Archived tasks',
noProject: 'No project',
deletedParent: 'Parent task deleted',
archivedAt: (when: string) => `Archived ${when}`,
archiveTimeUnknown: 'Archive time unknown',
searchLabel: 'Search archived tasks',
purgeAll: 'Clear all',
purgeMatches: (count: number) => (count === 1 ? 'Delete this 1' : `Delete these ${count}`),
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/renderer/settings/task-catalog-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,15 @@ import { deriveSessionRail } from '../features/session-navigation/index.js';
* position here for a reason this page never shows. The rail additionally hides
* in-flight companion forks, another property of its own view rather than of
* the archived catalog.
*
* Rows are then ordered by when they were archived, most recent first: this
* page is where you look for what you just put away. A task archived before
* the Host recorded the time has no place in that order, so those go last,
* and any tie keeps the rail's store order (`sort` is stable).
*/
export function archivedTaskRows<T extends SessionSummary>(sessions: readonly T[]): T[] {
return deriveSessionRail(sessions, undefined, (session) => session.isArchived).sessions;
const rows = deriveSessionRail(sessions, undefined, (session) => session.isArchived).sessions;
return [...rows].sort((a, b) => (b.archivedAt ?? -1) - (a.archivedAt ?? -1));
}

/** Whether an archived row remains because its ordinary parent task was deleted. */
Expand Down
11 changes: 8 additions & 3 deletions apps/desktop/src/renderer/settings/tasks-settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,7 @@ export function TasksSettingsPage(
[copy.noProject, projectNames],
);

// Store order is already recency-first with a stable id tie-break, and the
// projection preserves it, so there is nothing left to sort here.
// Most recently archived first; `archivedTaskRows` owns the order.
const archived = useMemo(() => archivedTaskRows(props.sessions), [props.sessions]);
const knownSessionIds = useMemo(
() => new Set(props.sessions.map((session) => session.id)),
Expand Down Expand Up @@ -221,15 +220,21 @@ export function TasksSettingsPage(
) : (
<List density="balanced" hasDividers aria-label={copy.listAria}>
{visible.map((session) => {
const now = Date.now();
const updated = session.lastMessageAt
? formatCompactTimestamp(session.lastMessageAt, Date.now(), locale)
? formatCompactTimestamp(session.lastMessageAt, now, locale)
: undefined;
const description = [
isOrphanedSubagentTask(session, knownSessionIds)
? copy.deletedParent
: undefined,
projectLabelOf(session),
updated,
// An unknown time is said as such, never borrowed from the
// last message, which the row already shows as its own fact.
session.archivedAt === undefined
? copy.archiveTimeUnknown
: copy.archivedAt(formatCompactTimestamp(session.archivedAt, now, locale)),
]
.filter(Boolean)
.join(' · ');
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,17 @@ export interface SessionSummary {
name: string;
isFlagged: boolean;
isArchived: boolean;
/**
* When the Session last entered the archive — display metadata, never the
* archive state. Whether a Session is archived is `isArchived` and nothing
* else: this field is also absent for a Session archived before the Host
* recorded the time, so `archivedAt === undefined` must never be read as
* "not archived". (Projects do use `archivedAt` presence as their archive
* state; Sessions deliberately do not, after #3074 removed a session
* `archivedAt` that competed with `isArchived` as a second authority.)
* An unknown time is unknown, not derivable from any other timestamp.
*/
archivedAt?: number;
labels: string[];
hasUnread: boolean;
/** Host-owned recency, including creation before the first message; present on catalog rows. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { HostWorkspaceResolver } from '../server/workspace-resolver.js';
import {
HostSessionCatalogCoordinator,
NoUsableImportModelError,
projectSessionCatalogRecord,
SessionOperationFailure,
WorkHubDefaultModelRequiredError,
type HostSessionCatalogCoordinatorOptions,
Expand Down Expand Up @@ -2631,3 +2632,25 @@ test('reselecting the persisted executor model still confirms the Agent state an
assert.equal(fixture.header().executorConfig?.model, 'same');
}
});

test('projects the archive time of an archived Session and drops one on an active Session', () => {
const archivedHeader = { ...sessionHeader('archived', []), isArchived: true };
const archived = catalogRecord(archivedHeader, 2);
const projected = projectSessionCatalogRecord({
...archived,
summary: { ...archived.summary, archivedAt: 1_234 },
});
if ('kind' in projected) assert.fail('Expected a supported Session projection');
assert.equal(projected.archivedAt, 1_234);

// A time left on an active row must not turn the task into an
// unsupported-record placeholder.
const active = catalogRecord(sessionHeader('active', []), 2);
const stray = projectSessionCatalogRecord({
...active,
summary: { ...active.summary, archivedAt: 1_234 },
});
if ('kind' in stray) assert.fail('Expected a supported Session projection');
assert.equal(stray.isArchived, false);
assert.equal(Object.hasOwn(stray, 'archivedAt'), false);
});
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,29 @@ describe('Session catalog protocol', () => {
}
});

test('carries an optional archive time only on an archived Session', () => {
const archived = { ...projection(), isArchived: true, archivedAt: 1_700_000_000_000 };
assert.deepEqual(decodeSessionCatalogItem(archived), archived);

// Absent means the Host does not know when the Session was archived.
const unknown = { ...projection(), isArchived: true };
const decoded = decodeSessionCatalogItem(unknown);
assert.deepEqual(decoded, unknown);
assert.equal(Object.hasOwn(decoded, 'archivedAt'), false);

for (const archivedAt of ['1700000000000', -1, 1.5, null, Number.MAX_SAFE_INTEGER + 1]) {
assert.throws(
() => decodeSessionCatalogItem({ ...projection(), isArchived: true, archivedAt }),
isProtocolError,
`archivedAt ${String(archivedAt)} must be rejected`,
);
}
assert.throws(
() => decodeSessionCatalogItem({ ...projection(), isArchived: false, archivedAt: 1 }),
isProtocolError,
);
});

test('bounds the live running-turn collection explicitly', () => {
const atLimit = Array.from(
{ length: SESSION_CATALOG_RUNNING_TURN_MAX_ITEMS },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { projectSessionCatalogSummary } from '../client/session-catalog-summary.js';
import type { SessionCatalogProjection } from '../protocol/index.js';

function projection(overrides: Partial<SessionCatalogProjection> = {}): SessionCatalogProjection {
return {
id: 'session-1',
revision: 1,
workspace: { target: { kind: 'host_path', path: '/workspace' }, hostCwd: '/workspace' },
createdAt: 1,
activityAt: 2,
name: 'Session',
isFlagged: false,
isArchived: false,
labels: [],
labelsTruncated: false,
hasUnread: false,
status: 'active',
backend: 'ai-sdk',
llmConnectionId: 'connection-1',
llmConnectionSlug: 'openai-main',
connectionLocked: true,
model: 'gpt-5',
permissionMode: 'ask',
collaborationMode: 'agent',
orchestrationMode: 'default',
...overrides,
};
}

// Every Desktop and CLI session row comes through this projection, so the
// archive time reaches the archived-tasks page only if it is carried here.
test('carries the archive time of an archived Session into its summary', () => {
const summary = projectSessionCatalogSummary(
projection({ isArchived: true, archivedAt: 1_700_000_000_000 }),
);
assert.equal(summary.isArchived, true);
assert.equal(summary.archivedAt, 1_700_000_000_000);
});

test('adds no archive time key when the Host reports none', () => {
const summary = projectSessionCatalogSummary(projection({ isArchived: true }));
assert.equal(summary.isArchived, true);
assert.equal(Object.hasOwn(summary, 'archivedAt'), false);
});
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,15 @@ describe('Host Session retirement coordinator', () => {
if ('kind' in archived.result) assert.fail('Expected a supported Session projection');
assert.equal(archived.result.id, harness.revisionId);
assert.equal(archived.result.isArchived, true);
// The whole family entered the archive in one write, at one time.
const archivedAt = archived.result.archivedAt;
assert.equal(typeof archivedAt, 'number');
for (const sessionId of harness.familyIds) {
assert.equal(
(await harness.store.readCatalogRecord(sessionId)).summary.archivedAt,
archivedAt,
);
}
await assertFamilyLifecycle(harness, true);
assert.deepEqual(new Set(harness.actions.disposed), new Set(harness.familyIds));
assert.deepEqual(new Set(harness.actions.refreshed), new Set(harness.familyIds));
Expand All @@ -280,6 +289,13 @@ describe('Host Session retirement coordinator', () => {
if (!restored.ok) return;
if ('kind' in restored.result) assert.fail('Expected a supported Session projection');
assert.equal(restored.result.isArchived, false);
assert.equal(Object.hasOwn(restored.result, 'archivedAt'), false);
for (const sessionId of harness.familyIds) {
assert.equal(
(await harness.store.readCatalogRecord(sessionId)).summary.archivedAt,
undefined,
);
}
await assertFamilyLifecycle(harness, false);
assert.deepEqual(harness.actions.disposed, []);
assert.deepEqual(new Set(harness.actions.refreshed), new Set(harness.familyIds));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function projectSessionCatalogSummary(
name: session.name,
isFlagged: session.isFlagged,
isArchived: session.isArchived,
...(session.archivedAt === undefined ? {} : { archivedAt: session.archivedAt }),
labels: [...session.labels],
hasUnread: session.hasUnread,
...(session.lastMessageAt === undefined ? {} : { lastMessageAt: session.lastMessageAt }),
Expand Down
5 changes: 4 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
// Increment when the same protocol version no longer guarantees safe Client-Host
// interoperability. Mismatches are rejected before domain commands are admitted.
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 200 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 201 as const;
// 201: Session catalog projections may carry `archivedAt`, the time the
// Session last entered the archive. Epoch-200 peers reject the unknown key, so
// a newer Desktop against an older Host would lose session catalog reads.
// 200: Agent Graph operator snapshots carry bounded output previews and metrics.
// Older peers reject the additional `output` field on strict operator shapes.
// 199: `storage.usage.query` and `storage.usage.sessions.query` report storage
Expand Down
20 changes: 20 additions & 0 deletions packages/runtime-host/src/protocol/session-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ const PROJECTION_REQUIRED_FIELDS = [
] as const;
const PROJECTION_FIELDS = [
...PROJECTION_REQUIRED_FIELDS,
'archivedAt',
'lastMessageAt',
'lastMessagePreview',
'blockedReason',
Expand Down Expand Up @@ -254,6 +255,11 @@ export interface SessionCatalogProjection {
readonly name: string;
readonly isFlagged: boolean;
readonly isArchived: boolean;
/**
* When the Session last entered the archive. Absent when it is not archived,
* and when the Host has no record of the time (archived before it was kept).
*/
readonly archivedAt?: number;
readonly labels: readonly string[];
readonly labelsTruncated: boolean;
readonly hasUnread: boolean;
Expand Down Expand Up @@ -843,6 +849,7 @@ export function decodeSessionCatalogProjection(value: unknown): SessionCatalogPr
name: sessionName(record.name),
isFlagged: boolean(record.isFlagged, 'Session flagged state'),
isArchived: boolean(record.isArchived, 'Session archived state'),
...optionalArchivedAt(record),
labels: labels(record.labels),
labelsTruncated: boolean(record.labelsTruncated, 'Session labels truncated state'),
hasUnread: boolean(record.hasUnread, 'Session unread state'),
Expand Down Expand Up @@ -882,6 +889,9 @@ export function decodeSessionCatalogProjection(value: unknown): SessionCatalogPr
if ((projection.backend === 'plugin-executor') !== (projection.executorId !== undefined)) {
throw invalidProtocolFrame('Session executor identity does not match its backend');
}
if (projection.archivedAt !== undefined && !projection.isArchived) {
throw invalidProtocolFrame('Session archive time requires an archived Session');
}
requireEncodedByteLimit(
projection,
'Session catalog projection',
Expand Down Expand Up @@ -974,6 +984,16 @@ function optionalTimestamp<Field extends 'lastMessageAt' | 'statusUpdatedAt'>(
: {};
}

// One object type with an optional key rather than a union, so this spread
// does not multiply the projection literal's inferred union past TS2590.
function optionalArchivedAt(
record: Record<string, unknown>,
): Pick<SessionCatalogProjection, 'archivedAt'> {
return Object.hasOwn(record, 'archivedAt')
? { archivedAt: timestamp(record.archivedAt, 'Session archivedAt') }
: {};
}

function optionalText<Field extends 'lastMessagePreview'>(
record: Record<string, unknown>,
field: Field,
Expand Down
Loading