feat: stabilize activity sessions for journal review

This commit is contained in:
2026-07-12 20:49:13 +08:00
parent 50c4fc5e68
commit e93584c37c
5 changed files with 292 additions and 65 deletions
+54 -3
View File
@@ -287,7 +287,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
const candidateNode = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-candidate'));
if (!candidateNode) throw new Error('work session candidate data attribute was not rendered');
if (!candidateNode.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not rendered');
if (!candidateNode.textContent.includes('Estimated duration: 30 min')) throw new Error('candidate duration was not rendered');
if (!candidateNode.textContent.includes('Estimated duration: 20 min')) throw new Error('candidate duration was not rendered');
if (!candidateNode.textContent.includes('Activities: 3')) throw new Error('candidate activity count was not rendered');
if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'review')) throw new Error('candidate review action was not rendered');
if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss')) throw new Error('candidate dismiss action was not rendered');
@@ -299,7 +299,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
if (!candidate.candidateId) throw new Error('candidate id is missing');
if (candidate.workspaceRootPath !== 'Project') throw new Error('candidate workspace mismatch');
if (candidate.startedAt !== '2026-06-27T00:00:00.000Z' || candidate.endedAt !== '2026-06-27T00:30:00.000Z') throw new Error('candidate range mismatch');
if (candidate.estimatedMinutes !== 30) throw new Error(`expected 30 candidate minutes, got ${candidate.estimatedMinutes}`);
if (candidate.estimatedMinutes !== 20) throw new Error(`expected 20 candidate minutes, got ${candidate.estimatedMinutes}`);
if (candidate.activityCount !== 3) throw new Error(`expected three candidate activities, got ${candidate.activityCount}`);
if (candidate.activityIds.join(',') !== 'capture-1,note-1,capture-1:browser.capture.converted') throw new Error('candidate activity ids mismatch');
if (!Array.isArray(candidate.activities) || candidate.activities.length !== 3) throw new Error('candidate activity list is missing');
@@ -365,7 +365,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
if (api.storedEvents('work-session-candidates:workspace:Project').length !== 0) throw new Error('clear action did not remove cached candidates');
component.unmount && component.unmount(container);
if (api.unsubscribed.length !== 36) throw new Error(`expected 36 unsubscribers, got ${api.unsubscribed.length}`);
if (api.unsubscribed.length !== 39) throw new Error(`expected 39 unsubscribers, got ${api.unsubscribed.length}`);
const persistedApi = makeApi({
'events:workspace:Project': [{
@@ -461,6 +461,57 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
}
component.unmount && component.unmount(sessionView.container);
const lateApi = makeApi({
'events:workspace:Project': [
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
],
});
const lateView = await mountWithApi(lateApi);
const lateCommand = lateApi.commandHandlers.get(WORKLOG_COMMAND_ID);
const firstLateCandidate = (await lateCommand({ workspaceRootPath: 'Project' })).candidates[0];
if (!firstLateCandidate || !firstLateCandidate.sessionId || firstLateCandidate.estimatedMinutes !== 10) {
throw new Error('file activity duration must use the capped adjacent-event algorithm');
}
await lateApi.settings.write('events:workspace:Project', [
{ activityId: 'late-before', type: 'note.saved', occurredAt: '2026-07-12T09:55:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
]);
await lateApi.handlers['note.saved']({ name: 'note.saved', payload: {} });
await flush();
const afterLateCandidate = (await lateCommand({ workspaceRootPath: 'Project' })).candidates[0];
if (!afterLateCandidate || afterLateCandidate.sessionId !== firstLateCandidate.sessionId) {
throw new Error('late events changed an immutable session identity');
}
const dismissLate = walk(lateView.container, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss');
dismissLate.click();
await flush();
await lateApi.settings.write('events:workspace:Project', [
{ activityId: 'late-before', type: 'note.saved', occurredAt: '2026-07-12T09:55:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-one', type: 'note.saved', occurredAt: '2026-07-12T10:15:00Z', workspaceRootPath: 'Project' },
]);
await lateApi.handlers['note.saved']({ name: 'note.saved', payload: {} });
await flush();
if ((await lateCommand({ workspaceRootPath: 'Project' })).candidates.length !== 0) {
throw new Error('dismissed session was re-offered without substantial new activity');
}
await lateApi.settings.write('events:workspace:Project', [
{ activityId: 'late-before', type: 'note.saved', occurredAt: '2026-07-12T09:55:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-one', type: 'note.saved', occurredAt: '2026-07-12T10:15:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-two', type: 'file.changed', occurredAt: '2026-07-12T10:25:00Z', workspaceRootPath: 'Project' },
]);
await lateApi.handlers['file.changed']({ name: 'file.changed', payload: {} });
await flush();
if ((await lateCommand({ workspaceRootPath: 'Project' })).candidates.length !== 1) {
throw new Error('dismissed session was not re-offered after substantial new activity');
}
component.unmount && component.unmount(lateView.container);
const rawApi = makeApi({}, {
'activity-events': [{
activityId: 'browser-domain:batch-1:0',
+14
View File
@@ -133,7 +133,9 @@ function loadComponent(document) {
function makeApi(initialSettings = {}) {
const settings = { ...initialSettings };
const publishedEvents = [];
return {
publishedEvents,
settings: {
read: async (key) => (key ? settings[key] : { ...settings }),
write: async (key, value) => {
@@ -141,6 +143,11 @@ function makeApi(initialSettings = {}) {
return { ...settings };
},
},
events: {
publish: async (name, payload) => {
publishedEvents.push({ name, payload });
},
},
storedEntries(key) {
return settings[key] || [];
},
@@ -172,6 +179,7 @@ function byData(container, attr, value) {
}
if ((manifest.optionalRequires || []).includes('activity.reconstruction')) throw new Error('Journal must remain available without Activity');
if (!manifest.permissions.includes('storage.namespace')) throw new Error('journal manifest must request storage.namespace');
if (!manifest.permissions.includes('events.publish')) throw new Error('journal manifest must request events.publish');
if (!manifest.permissions.includes('ui.register')) throw new Error('journal manifest must request ui.register');
if (!(manifest.contributes.workspaceItems || []).some((item) => item.component === 'JournalView')) throw new Error('journal workspace item missing');
if (!(manifest.contributes.sidebarItems || []).some((item) => item.view === 'verstak.journal.view')) throw new Error('journal sidebar item missing');
@@ -212,6 +220,8 @@ function byData(container, attr, value) {
const candidate = {
candidateId: 'work-session:Project:capture-1:note-1',
sessionId: 'session-journal-1',
handledThrough: '2026-06-27T11:03:00.000Z',
workspaceRootPath: 'Project',
startedAt: '2026-06-27T10:12:00.000Z',
endedAt: '2026-06-27T11:03:00.000Z',
@@ -249,6 +259,10 @@ function byData(container, attr, value) {
throw new Error('candidate review did not keep the user-authored entry fields');
}
if (linkedEntry.activityIds.join(',') !== 'capture-1') throw new Error('candidate review did not persist selected activity ids');
const handledEvent = api.publishedEvents.find((event) => event.name === 'activity.session.handled');
if (!handledEvent || handledEvent.payload.sessionId !== 'session-journal-1' || handledEvent.payload.handledThrough !== '2026-06-27T11:03:00.000Z' || handledEvent.payload.status !== 'accepted') {
throw new Error('accepted Journal candidate did not persist a session watermark');
}
if (walk(candidateView.container, (node) => node.getAttribute && node.getAttribute('data-journal-action') === 'view-activity')) {
throw new Error('journal rows must not navigate to Activity by default');
}