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
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ describe('bot remote resource projection', () => {
});
});

// Mobile task rows draw the follow mark from these links; the revision must move when they do.
it('links the open tasks a teammate looks after and revises the row when they change', () => {
const before = botRemoteCollectionItemFromSource(source);
const after = botRemoteCollectionItemFromSource({ ...source, followingSessionIds: ['task-a', 'task-b'] });
expect(after.links).toEqual([
{ rel: 'conversation', target: { kind: 'session', sessionId: 'session-1' } },
{ rel: 'following', target: { kind: 'session', sessionId: 'task-a' } },
{ rel: 'following', target: { kind: 'session', sessionId: 'task-b' } },
]);
expect(after.revision).not.toBe(before.revision);
});

it('uses generic attention presentation without exposing Bot internals', () => {
const projected = botRemoteCollectionItemFromSource({
...source,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createHash } from 'node:crypto';

import type {
RemoteCollectionItem,
RemoteLocalizedText,
Expand All @@ -12,6 +14,8 @@ import { isManagedBotAvatarUrl } from '../../../shared/botAvatarValue.js';

export const TEAMMATES_REMOTE_COLLECTION_ID = 'teammates';
export const BOT_REMOTE_RESOURCE_KIND = 'bot';
/** Link to an open task this teammate looks after; controllers draw the follow mark from it. */
export const BOT_FOLLOWING_LINK_REL = 'following';

export const TEAMMATES_TITLE: RemoteLocalizedText = {
fallback: 'Teammates',
Expand Down Expand Up @@ -91,9 +95,16 @@ function sourceRevision(source: BotRemoteResourceSource): string {
source.activityAt,
source.canonicalSessionId ?? '',
source.needsAttention ? 1 : 0,
followingDigest(source.followingSessionIds ?? []),
].join(':');
}

/** Short stand-in for the followed task list, so revisions stay small however many tasks. */
function followingDigest(sessionIds: readonly string[]): string {
if (!sessionIds.length) return '';
return createHash('sha256').update(sessionIds.join('\n')).digest('hex').slice(0, 16);
}

export function botRemoteCollectionItemFromSource(
source: BotRemoteResourceSource,
): RemoteCollectionItem {
Expand Down Expand Up @@ -126,12 +137,15 @@ export function botRemoteCollectionItemFromSource(
? { badges: [{ accessibilityLabel: ATTENTION_COPY, tone: 'warning' as const }] }
: {}),
},
links: source.canonicalSessionId
? [{
rel: 'conversation',
target: { kind: 'session', sessionId: source.canonicalSessionId },
}]
: [],
links: [
...(source.canonicalSessionId
? [{ rel: 'conversation', target: { kind: 'session' as const, sessionId: source.canonicalSessionId } }]
: []),
...(source.followingSessionIds ?? []).map((sessionId) => ({
rel: BOT_FOLLOWING_LINK_REL,
target: { kind: 'session' as const, sessionId },
})),
],
revision: sourceRevision(source),
};
}
Expand Down
43 changes: 43 additions & 0 deletions apps/desktop/src/main/localDb/ipc/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1435,6 +1435,46 @@ export interface BotRemoteResourceSource {
activityAt: number;
currentVersion: number;
updatedAt: number;
/** Open tasks this Bot looks after, for the follow mark on remote task rows. Absent = none. */
followingSessionIds?: string[];
}

const MAX_FOLLOWED_TASK_LINKS = 200;

/**
* The tasks the desktop sidebar marks as followed by this Bot (`useSessionFollowers`):
* active, local, ordinary tasks only, so archived history never grows the roster.
*/
async function readFollowedTaskIds(
db: ReturnType<typeof getDbClient>['drizzle'],
userDataDir: string,
botId: string,
): Promise<string[]> {
let ids: string[];
try {
ids = (await readBotResponsibilityScope(userDataDir, botId)).sessionIds;
} catch {
// A damaged store may hold releases: show no mark rather than guess (collectTaskResponsibilityScopes).
return [];
}
const followed: Array<{ id: string; updatedAt: number }> = [];
for (let start = 0; start < ids.length; start += 500) {
const rows = await db.select({ id: sessions.id, updatedAt: sessions.updatedAt }).from(sessions).where(and(
inArray(sessions.id, ids.slice(start, start + 500)),
eq(sessions.status, 'active'),
Comment thread
zqchris marked this conversation as resolved.
Resolved
Comment thread
zqchris marked this conversation as resolved.
Resolved
isNull(sessions.remoteHostId),
// isWorkbenchTaskSource: ordinary tasks only, never a Bot's own or a channel conversation.
inArray(sessions.source, ['desktop', 'plugin']),
or(isNull(sessions.orcaRole), ne(sessions.orcaRole, 'worker')),
));
followed.push(...rows);
}
// Keep the roster row small: the most recently active tasks, in a stable order.
return followed
.sort((left, right) => right.updatedAt - left.updatedAt)
.slice(0, MAX_FOLLOWED_TASK_LINKS)
.map((row) => row.id)
.sort();
}

async function readBotRemoteResourceSource(
Expand All @@ -1455,6 +1495,8 @@ async function readBotRemoteResourceSource(
.from(botProfileVersions).where(and(eq(botProfileVersions.botId, botId), eq(botProfileVersions.version, profile.currentVersion))).limit(1);
const invitation = botInvitationProgress(parseJson(version?.capabilitiesJson ?? '{}').invitation);
const reply = latest.role === 'assistant' ? latest : await readCanonicalChatPreview(db, canonicalSessionId, canonical?.clearedAt ?? null, true);
// Paused or archived companions show no mark, as on the desktop sidebar.
const followingSessionIds = profile.status === 'active' ? await readFollowedTaskIds(db, owner.userDataDir, botId) : [];
owner.assertCurrent();
return {
...(invitation ? { invitation } : {}),
Expand All @@ -1477,6 +1519,7 @@ async function readBotRemoteResourceSource(
activityAt: Math.max(profile.createdAt, latest.createdAt ?? 0, canonical?.updatedAt ?? 0),
currentVersion: profile.currentVersion,
updatedAt: profile.updatedAt,
followingSessionIds,
};
}

Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/main/localDb/ipc/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,13 @@ function getSafeOwnerPushStamp(): ReturnType<typeof broadcastTap.getSafeDataOwne
* 广播 sessions:patched 到本机所有窗口 + device-link tap。tap 让该 patch 经 topic 路由
* 转发给订阅了 `sessions` 的控制端(push 驱动:控制端 applyPatch 即时镜像,无需重拉)。
*/
let sessionStatusPatchedListener: ((sessionId: string) => void) | null = null;

/** Archive / restore observer, set at registration so this module does not load teammate dependencies. */
export function setSessionStatusPatchedListener(next: ((sessionId: string) => void) | null): void {
sessionStatusPatchedListener = next;
}

export function broadcastSessionPatched(
sessionId: string,
patch: Record<string, unknown>,
Expand Down Expand Up @@ -501,6 +508,14 @@ export function broadcastSessionPatched(
error: error instanceof Error ? error.message : String(error),
});
}
// Archive / restore changes which open tasks a teammate's roster row links as followed.
if (patch.status !== undefined && !isProvisionalSession(sessionId)) {
try {
sessionStatusPatchedListener?.(sessionId);
} catch {
// Teammate roster refresh never blocks the task patch itself.
}
}
let windows: ReturnType<typeof BrowserWindow.getAllWindows> = [];
try {
windows = BrowserWindow.getAllWindows();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const h = vi.hoisted(() => ({
condition: undefined as SQL | undefined,
ownerScope: 'owner-a',
tap: vi.fn(),
realDb: false,
records: {} as Record<string, Record<string, unknown>>,
}));

vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }));
Expand All @@ -20,9 +22,16 @@ vi.mock('../../device-link/broadcast-tap.js', () => ({
vi.mock('../../appSessionState.js', () => ({
activeOwnerScopeKey: () => h.ownerScope,
isAppSessionBoundaryPending: () => h.boundaryPending,
ownerScopedUserDataPath: () => '/owner-a',
}));
vi.mock('../botTaskResponsibility.js', () => ({
readTaskResponsibilities: async (_root: string, botId: string) => {
if (botId === 'bot-damaged') throw new Error('Invalid task responsibility store');
return h.records[botId] ?? {};
},
}));
vi.mock('../../localDb/client/current.js', () => ({
getDbClient: () => ({
getDbClient: () => (h.realDb ? { drizzle: db } : {
drizzle: {
select: () => ({
from: () => ({
Expand All @@ -34,11 +43,14 @@ vi.mock('../../localDb/client/current.js', () => ({
}));

import {
scheduleBotRemoteResourceChangedForFollowedSession,
scheduleBotRemoteResourceChangedForSession,
} from '../botRemoteResourceInvalidation.js';

const sqlite = new Database(':memory:');
sqlite.exec("CREATE TABLE bot_session_links (id TEXT PRIMARY KEY, bot_id TEXT, session_id TEXT, role TEXT, archived_at INTEGER); INSERT INTO bot_session_links VALUES ('c', 'bot-1', 'session-1', 'canonical', NULL), ('d', 'bot-1', 'delegated', 'delegation', NULL), ('a', 'bot-1', 'archived', 'delegation', 1), ('h', 'bot-1', 'history', 'history', NULL)");
sqlite.exec("CREATE TABLE bot_profiles (id TEXT PRIMARY KEY, status TEXT); INSERT INTO bot_profiles VALUES ('bot-starter', 'active'), ('bot-taker', 'active'), ('bot-other', 'active'), ('bot-damaged', 'active'), ('bot-paused', 'paused')");
sqlite.exec("CREATE TABLE bot_delegations (id TEXT PRIMARY KEY, requesting_bot_id TEXT, child_session_id TEXT); INSERT INTO bot_delegations VALUES ('d1', 'bot-starter', 'task-1')");
const db = drizzle(sqlite);
afterAll(() => sqlite.close());

Expand Down Expand Up @@ -84,4 +96,19 @@ describe('Bot remote resource message invalidation', () => {
await new Promise(resolve => setTimeout(resolve, 0));
expect(h.tap).not.toHaveBeenCalled();
});

// Roster rows link only open tasks: restoring one must refresh its followers' rows (#7066 review).
it('refreshes the teammates that started or took over a task whose archive state changed', async () => {
h.realDb = true;
h.records = { 'bot-taker': { 'task-1': { responsible: true, updatedAt: '2026-10-24T00:00:00Z' } },
'bot-paused': { 'task-1': { responsible: true, updatedAt: '2026-10-24T00:00:00Z' } } };
try {
scheduleBotRemoteResourceChangedForFollowedSession('task-1');
await vi.waitFor(() => expect(h.tap).toHaveBeenCalledTimes(2));
expect(h.tap.mock.calls.map((call) => call[1].resourceRefs[0].id).sort()).toEqual(['bot-starter', 'bot-taker']);
} finally {
h.realDb = false;
h.records = {};
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ function fixture(role: string) {
sqlite.prepare("INSERT INTO bot_session_links VALUES ('link', ?, ?, ?, NULL)").run('caller', 'hihi', role);
const save = vi.fn(async () => {});
const broadcast = vi.fn();
const remoteBroadcast = vi.fn();
const deps = {
getDbClient: () => ({ drizzle: drizzle(sqlite) }),
and, desc, eq, inArray, isNotNull, isNull, botDelegations, botProfiles, botSessionLinks, sessions,
Expand All @@ -84,12 +85,13 @@ function fixture(role: string) {
ownerScopedUserDataPath: () => '/synthetic-owner-home',
setTaskResponsibility: save,
broadcastBotWorkbenchChanged: broadcast,
broadcastBotRemoteResourceChanged: remoteBroadcast,
};
const api = new Function(...Object.keys(deps), js)(...Object.values(deps)) as {
captureBotCreatedTaskResponsibility(id: string): Promise<null | ((id: string) => Promise<void>)>;
resolveWorkbenchCaller(id: string): Promise<{ ok: boolean; errorCode?: string }>;
};
return { sqlite, save, broadcast, ...api };
return { sqlite, save, broadcast, remoteBroadcast, ...api };
}

it.each(['canonical', 'thread', 'channel', 'group'])(
Expand All @@ -101,6 +103,8 @@ it.each(['canonical', 'thread', 'channel', 'group'])(
await record!('created');
expect(f.save).toHaveBeenCalledWith('/synthetic-owner-home', 'hihi', 'created', true, expect.any(Function));
expect(f.broadcast).toHaveBeenCalledWith('hihi');
// Phones re-read the roster's follow links, so the new task shows the teammate's mark there too.
expect(f.remoteBroadcast).toHaveBeenCalledWith('hihi');
// Recognizing a creator must not grant a channel/group conversation workbench access.
if (role === 'channel' || role === 'group') {
expect(await f.resolveWorkbenchCaller('caller')).toMatchObject({ ok: false, errorCode: 'BOT_MAIN_TASK_REQUIRED' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import {
} from '../device-link/broadcast-tap.js';
import { createLogger } from '../logger.js';
import { getDbClient } from '../localDb/client/current.js';
import { botSessionLinks } from '../localDb/schema.js';
import { botDelegations, botProfiles, botSessionLinks } from '../localDb/schema.js';
import {
activeOwnerScopeKey,
isAppSessionBoundaryPending,
ownerScopedUserDataPath,
} from '../appSessionState.js';
import { readTaskResponsibilities } from './botTaskResponsibility.js';

const log = createLogger('maker-ipc:bot-remote-resource');

Expand Down Expand Up @@ -108,3 +110,66 @@ export function scheduleBotRemoteResourceChangedForSession(
});
});
}

/**
* A background task the teammate started is one it looks after: once the task exists,
* remote task rows need the roster's follow links again.
*/
export function scheduleBotRemoteResourceChangedForDelegation(delegationId: string, childSessionId: string | null): void {
if (!childSessionId) return;
let ownerScope: string;
try {
ownerScope = activeOwnerScopeKey();
} catch {
return;
}
void getDbClient().drizzle
.select({ botId: botDelegations.requestingBotId })
.from(botDelegations)
.where(eq(botDelegations.id, delegationId))
.limit(1)
.then(([row]) => {
if (!row?.botId || !isOwnerScopeCurrent(ownerScope)) return;
broadcastBotRemoteResourceChanged(row.botId);
})
.catch((error: unknown) => {
log.warn('resolve teammate delegation invalidation failed', {
error: error instanceof Error ? error.message : String(error),
});
});
}

/**
* Roster rows link only open tasks, so archiving or restoring a task changes the rows of the
* teammates that started it or took it over. Extra rows re-read harmlessly; a missed one
* would leave the mark stale.
*/
export function scheduleBotRemoteResourceChangedForFollowedSession(sessionId: string): void {
let ownerScope: string;
try {
ownerScope = activeOwnerScopeKey();
} catch {
return;
}
void (async () => {
const db = getDbClient().drizzle;
const [started, bots] = await Promise.all([
db.select({ botId: botDelegations.requestingBotId }).from(botDelegations)
.where(eq(botDelegations.childSessionId, sessionId)),
db.select({ id: botProfiles.id }).from(botProfiles).where(eq(botProfiles.status, 'active')),
]);
const botIds = new Set(started.map((row) => row.botId));
const root = ownerScopedUserDataPath();
await Promise.all(bots.filter(({ id }) => !botIds.has(id)).map(async ({ id }) => {
// A damaged store follows nothing (readFollowedTaskIds), so there is no row to refresh.
const records = await readTaskResponsibilities(root, id).catch(() => ({}));
if (Object.hasOwn(records, sessionId)) botIds.add(id);
}));
if (!isOwnerScopeCurrent(ownerScope)) return;
for (const botId of botIds) broadcastBotRemoteResourceChanged(botId);
})().catch((error: unknown) => {
log.warn('resolve followed task invalidation failed', {
error: error instanceof Error ? error.message : String(error),
});
});
}
11 changes: 10 additions & 1 deletion apps/desktop/src/main/maker-ipc/botWorkbenchTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from '../appSessionState.js';
import type { WorkbenchDelegationStatus, WorkbenchTranscriptItem } from '../../shared/botWorkbench.js';
import { broadcastBotWorkbenchChanged } from './botWorkbenchService.js';
import { broadcastBotRemoteResourceChanged } from './botRemoteResourceInvalidation.js';
import { botLinkedProjectAccess } from './botLinkedProjects.js';
import { boundTranscript, readSessionTranscript } from './botWorkbenchTranscripts.js';
import { readTaskResponsibilities, responsibleSessionIds, setTaskResponsibility } from './botTaskResponsibility.js';
Expand Down Expand Up @@ -203,6 +204,8 @@ export async function captureBotCreatedTaskResponsibility(callerSessionId?: stri
assertCurrent();
await setTaskResponsibility(root, botId, sessionId, true, assertCurrent);
broadcastBotWorkbenchChanged(botId);
// Remote task rows draw the same mark from the teammate roster's links.
broadcastBotRemoteResourceChanged(botId);
};
}

Expand All @@ -219,6 +222,12 @@ export interface BotWorkbenchHostDeps {
readRemoteMessages(deviceId: string, sessionId: string): Promise<{ ok: true; items: WorkbenchTranscriptItem[] } | Failure>;
}

/** Desktop sidebar marks re-read the scopes; remote task rows re-read the teammate roster links. */
function broadcastResponsibilityChanged(botId: string): void {
broadcastBotWorkbenchChanged(botId);
broadcastBotRemoteResourceChanged(botId);
}

/**
* 按调用时的账号作用域组装一次工具服务:作用域在调用期间切换则中止,不做投递。
*/
Expand All @@ -237,7 +246,7 @@ function createDesktopBotWorkbenchAccess(host: BotWorkbenchHostDeps): BotWorkben
const result = await host.readRemoteMessages(deviceId, sessionId);
return result.ok ? { ok: true as const, transcript: boundTranscript(result.items) } : result;
},
notifyChanged: broadcastBotWorkbenchChanged,
notifyChanged: broadcastResponsibilityChanged,
listDelegations: listBotDelegationChildren,
readResponsibleSessionIds: (botId) => readBotResponsibleSessionIds(userDataDir, botId),
saveResponsibility: (botId, sessionId, responsible) => setTaskResponsibility(
Expand Down
Loading
Loading