Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7492206
feat(desktop): add Work Board Phase 3 start-task spike
somewan820 Sep 3, 2026
f3e88f7
fix: add ASF headers to Work Board files
somewan820 Sep 3, 2026
7c2c52d
fix(desktop): 修复 Work Board 启动任务的架构检查与主进程类型检查
somewan820 Sep 3, 2026
77109a4
chore(desktop): 移除 task-entry 未使用的 taskEntryDraftKey 公共导出
somewan820 Sep 3, 2026
452c956
refactor(desktop): 简化 Work Board start-task 解析链路
somewan820 Sep 3, 2026
6ecd8f0
fix(work-board): 拒绝非项目项链接 Session 并宽容解码坏链接
somewan820 Sep 3, 2026
26ad00f
style(core): 拆分 work-board.test.ts 超长对象字面量以通过 biome format
somewan820 Sep 3, 2026
0c6e572
fix(desktop): 保持 #4630 既有会话不报告已解析会话的契约
somewan820 Sep 3, 2026
cb0f8f2
chore(desktop): 同步 renderer architecture ledger(rebase 后 app-shell 无需…
somewan820 Sep 3, 2026
1cc0e13
chore(desktop): 重新生成 Astryx surface inventory 与 renderer architecture…
somewan820 Sep 4, 2026
9cdf605
fix(work-board): 落实 review 意见——绑定 claim、失败保留 Session、校验 project 所有权
somewan820 Sep 4, 2026
818f97b
fix(work-board): 落实二轮 review——surface owner nonce、link CAS、retry 生命周期说明
somewan820 Sep 4, 2026
34b29fb
fix(desktop): keep the work board nonce out of app-shell's debt budget
somewan820 Sep 4, 2026
3f79c12
fix(desktop): stay within app-shell's environment-capability budget
somewan820 Sep 4, 2026
a03dfd2
fix(desktop): simplify Work Board surface ownership
somewan820 Sep 4, 2026
d4f1c55
fix(desktop): keep surface ownership within architecture budget
somewan820 Sep 4, 2026
66b09ba
fix(desktop): bind Work Board claims to draft targets
somewan820 Sep 4, 2026
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
2 changes: 1 addition & 1 deletion apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -891,7 +891,7 @@
"react": 1
},
"importSpecifiers": 148,
"nonTriviaTokens": 15620
"nonTriviaTokens": 15613
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ describe('composer first-send cleanup', () => {

it('keeps the session once the first send lands', async () => {
const removed: string[] = [];
let currentDraftKey = 'draft:project-A';
const restoreWindow = installWindow({
newTasks: { create: async () => ({ id: 'session-1' }) },
sessions: {
Expand All @@ -295,14 +296,63 @@ describe('composer first-send cleanup', () => {
});

try {
assert.equal(await createAppShellChatActions(createActionsDeps()).send('hello'), true);
const actions = createAppShellChatActions({
...createActionsDeps(),
captureComposerImportOwner: () => ({
sessionId: undefined,
navSection: 'sessions',
newTaskDraftKey: currentDraftKey,
}),
checkTaskSubmissionReadiness: async () => {
currentDraftKey = 'draft:project-B';
return true;
},
});
let resolved: [string, string?] | undefined;
assert.equal(
await actions.send('hello', undefined, {
onSessionResolved: (...args) => {
resolved = args;
},
}),
true,
);
assert.deepEqual(resolved, ['session-1', 'draft:project-A']);
} finally {
restoreWindow();
}

assert.deepEqual(removed, []);
});

it('does not report a resolved session when the first send outcome is unknown', async () => {
let resolved = 0;
const restoreWindow = installWindow({
newTasks: { create: async () => ({ id: 'session-1' }) },
sessions: {
// `outcome_unknown`: the Host may have admitted the Message, so the
// Session is kept and the send counts as landed — but nothing proves
// the outcome, so it must not look like a resolved Session. The Work
// Board only links a task to a Session whose first send projected.
submitMessage: async () => ({ ok: false as const, reason: 'outcome_unknown' as const }),
},
});

try {
const actions = createAppShellChatActions(createActionsDeps());
const result = await actions.send('hello', undefined, {
onSessionResolved: () => {
resolved += 1;
},
});
assert.equal(result, true);
} finally {
restoreWindow();
}

assert.equal(resolved, 0);
});

it('projects the first message before activation while waiting to submit until observation', async () => {
const observation = deferred<void>();
const order: string[] = [];
Expand Down
229 changes: 225 additions & 4 deletions apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, test } from 'node:test';
import type { IpcMain } from 'electron';
import { normalizeWorkBoardLinkedSession } from '@maka/core/work-board';
import {
registerWorkBoardIpc,
type WorkBoardChangedEvent,
Expand Down Expand Up @@ -96,6 +97,7 @@ describe('Work Board IPC', () => {
ipcMain: ipc as unknown as Pick<IpcMain, 'handle'>,
workspaceRoot: root,
mainWindowController: window,
validateLinkedSession: async () => true,
});
try {
const created = await ipc.invoke<WorkBoardIpcResult<{ id: string; revision: number }>>(
Expand Down Expand Up @@ -128,6 +130,7 @@ describe('Work Board IPC', () => {
'workBoard:archive',
'workBoard:unarchive',
'workBoard:remove',
'workBoard:linkSession',
]);
} finally {
registration.close();
Expand All @@ -143,11 +146,17 @@ describe('Work Board IPC', () => {
ipcMain: ipc as unknown as Pick<IpcMain, 'handle'>,
workspaceRoot: root,
mainWindowController: window,
validateLinkedSession: async () => true,
});
try {
const created = await ipc.invoke<WorkBoardIpcResult<{ id: string; revision: number }>>(
'workBoard:create',
itemInput(),
{
scope: { kind: 'project', projectId: 'p1' },
title: 'Review auth',
creator: { kind: 'user' },
provenance: { kind: 'manual' },
},
);
assert.ok(created.ok);
const id = created.ok ? created.value.id : '';
Expand All @@ -156,7 +165,15 @@ describe('Work Board IPC', () => {
WorkBoardIpcResult<{ title: string; revision: number; state: string }>
>('workBoard:update', id, { title: 'Review auth v2' });
assert.ok(renamed.ok);
assert.equal(renamed.ok && renamed.value.revision, 2);
assert.equal(renamed.ok && renamed.value.revision, 2);

const linked = await ipc.invoke<WorkBoardIpcResult<{ linkedSessions: unknown[] }>>(
'workBoard:linkSession',
id,
{ profileId: 'profile-1', hostId: 'host-1', sessionId: 'session-1', linkedAt: 103 },
);
assert.equal(linked.ok, true);
assert.equal(linked.ok && linked.value.linkedSessions.length, 1);

const staleRename = await ipc.invoke<WorkBoardIpcResult<unknown>>(
'workBoard:update',
Expand Down Expand Up @@ -214,11 +231,215 @@ describe('Work Board IPC', () => {
assert.ok(page.ok);
assert.equal(page.ok && page.value.items.length, 0);

// create, update, archive, unarchive, archive, remove = 6 mutations
// create, update, link, archive, unarchive, archive, remove = 7 mutations
const changed = window.events.filter(
(event) => event.channel === 'workBoard:changed',
);
assert.equal(changed.length, 6);
assert.equal(changed.length, 7);
} finally {
registration.close();
}
});
});

test('rejects a linked Session that the Host validator cannot prove', async () => {
await withTempRoot(async (root) => {
const ipc = createFakeIpcMain();
const window = createFakeWindowController();
const registration = registerWorkBoardIpc({
ipcMain: ipc as unknown as Pick<IpcMain, 'handle'>,
workspaceRoot: root,
mainWindowController: window,
validateLinkedSession: async () => false,
});
try {
const created = await ipc.invoke<WorkBoardIpcResult<{ id: string }>>(
'workBoard:create',
itemInput(),
);
assert.ok(created.ok);
const linked = await ipc.invoke<WorkBoardIpcResult<unknown>>(
'workBoard:linkSession',
created.ok ? created.value.id : '',
{ profileId: 'profile-1', hostId: 'host-1', sessionId: 'missing', linkedAt: 1 },
);
assert.equal(linked.ok, false);
if (!linked.ok) assert.equal(linked.code, 'invalid_input');
} finally {
registration.close();
}
});
});

test('rejects linking a Session to an Inbox item even when the Host validates', async () => {
await withTempRoot(async (root) => {
const ipc = createFakeIpcMain();
const window = createFakeWindowController();
const registration = registerWorkBoardIpc({
ipcMain: ipc as unknown as Pick<IpcMain, 'handle'>,
workspaceRoot: root,
mainWindowController: window,
validateLinkedSession: async () => true,
});
try {
const created = await ipc.invoke<WorkBoardIpcResult<{ id: string }>>(
'workBoard:create',
itemInput(),
);
assert.ok(created.ok);
const linked = await ipc.invoke<WorkBoardIpcResult<unknown>>(
'workBoard:linkSession',
created.ok ? created.value.id : '',
{ profileId: 'profile-1', hostId: 'host-1', sessionId: 'session-1', linkedAt: 1 },
);
assert.equal(linked.ok, false);
if (!linked.ok) assert.equal(linked.code, 'invalid_input');
} finally {
registration.close();
}
});
});

test('passes the canonical board project to the Host validator for a project-scoped item', async () => {
await withTempRoot(async (root) => {
const ipc = createFakeIpcMain();
const window = createFakeWindowController();
const validated: Array<{ link: unknown; project: string | undefined }> = [];
const registration = registerWorkBoardIpc({
ipcMain: ipc as unknown as Pick<IpcMain, 'handle'>,
workspaceRoot: root,
mainWindowController: window,
validateLinkedSession: async (link, expectedProjectId) => {
validated.push({ link, project: expectedProjectId });
return true;
},
});
try {
const created = await ipc.invoke<WorkBoardIpcResult<{ id: string }>>(
'workBoard:create',
{
scope: { kind: 'project', projectId: 'p1' },
title: 'Review auth',
creator: { kind: 'user' },
provenance: { kind: 'manual' },
},
);
assert.ok(created.ok);
const link = {
profileId: 'profile-1',
hostId: 'host-1',
sessionId: 'session-1',
linkedAt: 1,
};
const linked = await ipc.invoke<WorkBoardIpcResult<unknown>>(
'workBoard:linkSession',
created.ok ? created.value.id : '',
link,
);
assert.equal(linked.ok, true);
assert.deepEqual(validated, [{ link, project: 'p1' }]);
} finally {
registration.close();
}
});
});

test('rejects a project-scoped link whose Session belongs to another project on the same Host', async () => {
await withTempRoot(async (root) => {
const ipc = createFakeIpcMain();
const window = createFakeWindowController();
// Production-shaped Host validation: a Session's workspace target must be
// a project matching the canonical board project, mirroring
// runtime-host-boot.ts.
const sessions = [
{ id: 's-p1', workspace: { target: { kind: 'project', projectId: 'p1' } } },
{ id: 's-p2', workspace: { target: { kind: 'project', projectId: 'p2' } } },
];
const registration = registerWorkBoardIpc({
ipcMain: ipc as unknown as Pick<IpcMain, 'handle'>,
workspaceRoot: root,
mainWindowController: window,
validateLinkedSession: async (link, expectedProjectId) => {
const normalized = normalizeWorkBoardLinkedSession(link);
if (!normalized.ok) return false;
const session = sessions.find((entry) => entry.id === normalized.value.sessionId);
if (!session) return false;
return (
session.workspace.target.kind === 'project' &&
session.workspace.target.projectId === expectedProjectId
);
},
});
try {
const created = await ipc.invoke<WorkBoardIpcResult<{ id: string }>>(
'workBoard:create',
{
scope: { kind: 'project', projectId: 'p1' },
title: 'Review auth',
creator: { kind: 'user' },
provenance: { kind: 'manual' },
},
);
assert.ok(created.ok);
// The same-Host Session from project p2 must not be linked to the p1
// board item.
const linked = await ipc.invoke<WorkBoardIpcResult<unknown>>(
'workBoard:linkSession',
created.ok ? created.value.id : '',
{ profileId: 'profile-1', hostId: 'host-1', sessionId: 's-p2', linkedAt: 1 },
);
assert.equal(linked.ok, false);
if (!linked.ok) assert.equal(linked.code, 'invalid_input');

// The p1 Session links cleanly.
const linkedOk = await ipc.invoke<WorkBoardIpcResult<unknown>>(
'workBoard:linkSession',
created.ok ? created.value.id : '',
{ profileId: 'profile-1', hostId: 'host-1', sessionId: 's-p1', linkedAt: 1 },
);
assert.equal(linkedOk.ok, true);
} finally {
registration.close();
}
});
});

test('fails closed when the item changes during Host validation (revision CAS)', async () => {
await withTempRoot(async (root) => {
const ipc = createFakeIpcMain();
const window = createFakeWindowController();
let itemId: string | undefined;
const registration = registerWorkBoardIpc({
ipcMain: ipc as unknown as Pick<IpcMain, 'handle'>,
workspaceRoot: root,
mainWindowController: window,
// Simulate a concurrent mutation (e.g. the item being moved to another
// project) racing the async Host validation: the revision read before
// validation must no longer match when linkSession commits.
validateLinkedSession: async () => {
await ipc.invoke('workBoard:update', itemId, { title: 'raced move' });
return true;
},
});
try {
const created = await ipc.invoke<WorkBoardIpcResult<{ id: string; revision: number }>>(
'workBoard:create',
{
scope: { kind: 'project', projectId: 'p1' },
title: 'Review auth',
creator: { kind: 'user' },
provenance: { kind: 'manual' },
},
);
assert.ok(created.ok);
itemId = created.ok ? created.value.id : undefined;
const linked = await ipc.invoke<WorkBoardIpcResult<unknown>>(
'workBoard:linkSession',
itemId,
{ profileId: 'profile-1', hostId: 'host-1', sessionId: 's-p1', linkedAt: 1 },
);
assert.equal(linked.ok, false);
if (!linked.ok) assert.equal(linked.code, 'operation_conflict');
} finally {
registration.close();
}
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/__tests__/work-board-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,13 @@ test('prevents a second Work Board create while the first request is pending', a
createCalls += 1;
return createResult.promise;
});
const input = harness.container.querySelector('input');
const input = harness.container.querySelector('textarea');
assert.ok(input);
input.value = 'Later';
const propsKey = Object.keys(input).find((key) => key.startsWith('__reactProps$'));
assert.ok(propsKey, 'missing React props on input');
const props = (input as unknown as Record<string, unknown>)[propsKey] as {
onChange?: (event: { target: HTMLInputElement; defaultPrevented: boolean }) => void;
onChange?: (event: { target: HTMLTextAreaElement; defaultPrevented: boolean }) => void;
};
assert.ok(props.onChange, 'missing React change handler');
await act(async () => {
Expand Down
Loading