@openchambery/web 2.1.7-beta.1 → 2.1.7-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/assets/{AgentAvatar-Ah1nMt7y.js → AgentAvatar-CAR4F5Sn.js} +1 -1
  2. package/dist/assets/AssistantView-gJYQLjJ3.js +3 -0
  3. package/dist/assets/{DiagramView-BAsKz2ga.js → DiagramView-BfOWoVlT.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-BssISEuf.js → FadeInOnReveal-CCVddfP5.js} +1 -1
  5. package/dist/assets/{Icon-DewMAW7N.js → Icon-DDB7S6Vz.js} +1 -1
  6. package/dist/assets/{ImageLoadingPlaceholder-D7q71Qgl.js → ImageLoadingPlaceholder-C6fDEcU1.js} +1 -1
  7. package/dist/assets/{LoadingImage-BBGT-9VQ.js → LoadingImage-ByBH-5WW.js} +1 -1
  8. package/dist/assets/MarkdownRenderer-DiQPj3zp.js +2 -0
  9. package/dist/assets/{MarkdownRendererImpl-CGvwpKNA.js → MarkdownRendererImpl-Bbiy-nFx.js} +1 -1
  10. package/dist/assets/{MarkstreamRendererImpl-DTY5OiNS.js → MarkstreamRendererImpl-Du_pQflq.js} +1 -1
  11. package/dist/assets/{MobileDetailNavigation-D32suw-7.js → MobileDetailNavigation-DHEof6Xp.js} +1 -1
  12. package/dist/assets/MobileShareBridge-RncdV5OG.js +4 -0
  13. package/dist/assets/MobileTabPageHeader-htBRNUU5.js +2 -0
  14. package/dist/assets/{MultiRunWindow-Bnj3u9C0.js → MultiRunWindow-ga6oMWY6.js} +1 -1
  15. package/dist/assets/OnboardingScreen-DINUboLT.js +1 -0
  16. package/dist/assets/{OverlayScrollbar-C24-p2ns.js → OverlayScrollbar-BkxmoyYl.js} +1 -1
  17. package/dist/assets/{SettingsView-C8r9JHpy.js → SettingsView-D5STpT-G.js} +9 -9
  18. package/dist/assets/{SettingsWindow-BX27BmQp.js → SettingsWindow-BjtKpUgd.js} +1 -1
  19. package/dist/assets/{TerminalView-BEzG-y0m.js → TerminalView-B73IPj8t.js} +1 -1
  20. package/dist/assets/ToolOutputDialog-Ol_NdZdw.js +10 -0
  21. package/dist/assets/{appThemeRegistry-MylXBsIT.js → appThemeRegistry-BpqASUSd.js} +1 -1
  22. package/dist/assets/{button-kJNzMb1G.js → button-DymteyMv.js} +1 -1
  23. package/dist/assets/{collapsible-MInYZ7dA.js → collapsible-Dzp5rO0Q.js} +1 -1
  24. package/dist/assets/{desktopHosts-BKeEWmL5.js → desktopHosts-DSBZ-9-L.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-FWm2fXr6.js → desktopRelayRestore-DK8TzHRx.js} +1 -1
  26. package/dist/assets/{diagram-BDSPghN-.js → diagram-CQs9px6y.js} +1 -1
  27. package/dist/assets/dialog-DfUeXqdE.js +1 -0
  28. package/dist/assets/dropdown-menu.styles-B6YMNOZz.js +1 -0
  29. package/dist/assets/{es-CLTWsqOO.js → es-2YvsazK2.js} +1 -1
  30. package/dist/assets/{fileMentionTouchSelection-CJy0j4r_.js → fileMentionTouchSelection-UGCePzqT.js} +1 -1
  31. package/dist/assets/{fileQueries-Dhgypzyl.js → fileQueries-BGE5l3Jd.js} +1 -1
  32. package/dist/assets/{fr-BNyfwida.js → fr-Cy0M48Gw.js} +1 -1
  33. package/dist/assets/{gitApi-BdCyq8OQ.js → gitApi-jesfo75O.js} +1 -1
  34. package/dist/assets/{image-loading-DDV3Ixi1.css → image-loading-CObM4tcX.css} +1 -1
  35. package/dist/assets/{image-loading-OgMEw4Jq.js → image-loading-qNtUCy69.js} +1 -1
  36. package/dist/assets/{input-02eTmC8w.js → input-BvpZiRHI.js} +1 -1
  37. package/dist/assets/{insertionBoundaries-s4XUrvUu.js → insertionBoundaries-BKaakPCA.js} +1 -1
  38. package/dist/assets/{ja-D7MYkMo9.js → ja-1zLWWJ2R.js} +1 -1
  39. package/dist/assets/{ko-CFNLrmLm.js → ko-BLk_5Dhc.js} +1 -1
  40. package/dist/assets/{main-D-ppVD1K.js → main-B70CpYy5.js} +21 -21
  41. package/dist/assets/main-D7axZ-68.js +2 -0
  42. package/dist/assets/{markdownHeightCache-CAVQhVdd.js → markdownHeightCache-MW1NYOaG.js} +1 -1
  43. package/dist/assets/{mcpQueries-BF4o-upn.js → mcpQueries-D0hGK5or.js} +1 -1
  44. package/dist/assets/miniChat-CHz-dfV6.js +2 -0
  45. package/dist/assets/mobile-IDzFxCKP.js +2 -0
  46. package/dist/assets/{multirun-BD8mVAtD.js → multirun-DJHCRK8q.js} +1 -1
  47. package/dist/assets/{persistence-DUPZl7Oy.js → persistence-B7ORJ371.js} +1 -1
  48. package/dist/assets/{pl-aKpW58gt.js → pl-BE8MC0XQ.js} +1 -1
  49. package/dist/assets/{platform-z2erC6Bs.js → platform-CiBU2bCp.js} +2 -2
  50. package/dist/assets/{projectMeta-BOVTQVcp.js → projectMeta-C2W05Ema.js} +1 -1
  51. package/dist/assets/{pt-BR-MliQk6-s.js → pt-BR-ubhNymyH.js} +1 -1
  52. package/dist/assets/rasterizePng-CpK-xCkB.js +1 -0
  53. package/dist/assets/renderElectronMiniChatApp-BVBUj0PS.js +2 -0
  54. package/dist/assets/renderMobileApp-DYUCAwQx.js +28 -0
  55. package/dist/assets/{runtimeConfig-TFPkErWH.js → runtimeConfig-qrx2Um5J.js} +1 -1
  56. package/dist/assets/runtimeEndpointReset-DjAuC4DZ.js +149 -0
  57. package/dist/assets/scheduledTaskNavigation-BnKGGUlX.js +1 -0
  58. package/dist/assets/sessionChangeSummary-Du4sCUoa.js +2 -0
  59. package/dist/assets/{sessionLookup-DIMsFch0.js → sessionLookup-qmXzah_M.js} +1 -1
  60. package/dist/assets/{sortable-tabs-strip-DSGwZHAT.js → sortable-tabs-strip-CWR8KhJB.js} +1 -1
  61. package/dist/assets/{speechSettingsQueries-Dn65Xgks.js → speechSettingsQueries-CDN4NwYP.js} +3 -3
  62. package/dist/assets/src-C9BNQK0O.css +2 -0
  63. package/dist/assets/{store-D5OeM4wB.js → store-n-H5RvRn.js} +1 -1
  64. package/dist/assets/{terminalApi-BnjU_lmF.js → terminalApi-bN5IYQau.js} +1 -1
  65. package/dist/assets/{textarea-yLPH5NYp.js → textarea-ClPlugKE.js} +1 -1
  66. package/dist/assets/{tooltip-B3eYZnNS.js → tooltip-D-1Pgmq1.js} +1 -1
  67. package/dist/assets/{uk-8Ovv1FvC.js → uk-BNyyigDo.js} +1 -1
  68. package/dist/assets/{url-DAyfGWPH.js → url-nbXEti6c.js} +1 -1
  69. package/dist/assets/{useAppFontEffects-DAAfbKWM.js → useAppFontEffects-CA6OGq9Z.js} +53 -53
  70. package/dist/assets/{useAssistantUIStore-Dt0l5AKe.js → useAssistantUIStore-Dlvb7P0q.js} +1 -1
  71. package/dist/assets/{useDesktopWindowControlsLayout-C8svR4l1.js → useDesktopWindowControlsLayout-XV0q6OV5.js} +1 -1
  72. package/dist/assets/{useEffectiveDirectory-nfFY0q8j.js → useEffectiveDirectory-DlNcsuzO.js} +1 -1
  73. package/dist/assets/{useFontPreferences-B4Cgu3bS.js → useFontPreferences-6LD7d7hP.js} +1 -1
  74. package/dist/assets/useMobileNavigationStore-BaX-xQnU.js +417 -0
  75. package/dist/assets/useUIStore-CDMI_Yzu.js +1 -0
  76. package/dist/assets/{useWorkerHighlightedLines-C3mcHRsU.js → useWorkerHighlightedLines-BrZrVBSe.js} +1 -1
  77. package/dist/assets/{zh-CN-DYC-d6-i.js → zh-CN-CNWzMPgL.js} +1 -1
  78. package/dist/assets/{zh-TW-OPu9_qbz.js → zh-TW-DrZ9S1yn.js} +1 -1
  79. package/dist/index.html +9 -9
  80. package/dist/mini-chat.html +9 -9
  81. package/dist/mobile.html +9 -9
  82. package/package.json +1 -1
  83. package/server/lib/assistants/DOCUMENTATION.md +39 -0
  84. package/server/lib/assistants/harness.js +20 -7
  85. package/server/lib/assistants/harness.test.js +21 -0
  86. package/server/lib/assistants/routes.js +2 -1
  87. package/server/lib/assistants/service.js +76 -13
  88. package/server/lib/assistants/service.test.js +53 -0
  89. package/server/lib/content-search/DOCUMENTATION.md +46 -0
  90. package/server/lib/content-search/routes.test.js +22 -0
  91. package/server/lib/content-search/runtime.js +17 -4
  92. package/server/lib/content-search/runtime.test.js +96 -0
  93. package/server/lib/content-search/service.js +25 -5
  94. package/server/lib/content-search/service.test.js +37 -0
  95. package/server/lib/notifications/DOCUMENTATION.md +5 -0
  96. package/server/lib/opencode/feature-routes-runtime.js +1 -0
  97. package/server/lib/scheduled-tasks/DOCUMENTATION.md +25 -0
  98. package/server/lib/scheduled-tasks/run-history-store.js +18 -5
  99. package/server/lib/scheduled-tasks/run-history-store.test.js +51 -0
  100. package/server/lib/scheduled-tasks/runtime.js +70 -12
  101. package/server/lib/scheduled-tasks/runtime.test.js +131 -0
  102. package/dist/assets/AssistantView-CqQWpm4I.js +0 -3
  103. package/dist/assets/MarkdownRenderer-B98_kQZx.js +0 -2
  104. package/dist/assets/MobileShareBridge-C8cNxHjB.js +0 -4
  105. package/dist/assets/MobileTabPageHeader-D5vUWHH1.js +0 -2
  106. package/dist/assets/OnboardingScreen-BjikXNcy.js +0 -1
  107. package/dist/assets/ToolOutputDialog-BagrLX0s.js +0 -10
  108. package/dist/assets/context-menu-BYq0LM7H.js +0 -1
  109. package/dist/assets/dropdown-menu.styles-BXvVFEkh.js +0 -1
  110. package/dist/assets/main-CgMxeYVa.js +0 -2
  111. package/dist/assets/miniChat-CBckjJnB.js +0 -2
  112. package/dist/assets/mobile-D1J0S_NY.js +0 -2
  113. package/dist/assets/rasterizePng-DCmIQQsI.js +0 -1
  114. package/dist/assets/renderElectronMiniChatApp-DOs_xLqY.js +0 -2
  115. package/dist/assets/renderMobileApp-CgdN0VOy.js +0 -28
  116. package/dist/assets/runtimeEndpointReset-DMFriRaH.js +0 -148
  117. package/dist/assets/sessionChangeSummary-BKGkX6sZ.js +0 -2
  118. package/dist/assets/src-BAWBlgDt.css +0 -2
  119. package/dist/assets/useMobileNavigationStore-DZ0uOB4a.js +0 -417
  120. package/dist/assets/useUIStore-pl39iO82.js +0 -1
@@ -2,6 +2,100 @@ import { expect, test } from 'vitest';
2
2
  import { createContentSearchService } from './service.js';
3
3
  import { createContentSearchRuntime } from './runtime.js';
4
4
 
5
+ test('persists initialization times only after all thirty-day history is covered and resumes partial work', async () => {
6
+ const service = createContentSearchService({ dbPath: ':memory:' });
7
+ const day = 86400000;
8
+ let clock = 40 * day;
9
+ let fail = true;
10
+ const reads = [];
11
+ const dependencies = { service, getRuntimeKey: () => 'host', now: () => clock,
12
+ listSessions: async () => ({ data: ['a', 'b'].map((id) => ({ id, time: { updated: 40 * day } })), cursor: {} }),
13
+ listMessages: async ({ sessionID }) => {
14
+ reads.push(sessionID);
15
+ if (sessionID === 'b' && fail) throw new Error('offline');
16
+ return { data: [{ type: 'user', id: sessionID, sessionID, time: { created: 12 * day }, text: 'old history' }], cursor: {} };
17
+ },
18
+ };
19
+ let runtime = createContentSearchRuntime(dependencies);
20
+ expect(runtime.status().initializationRequired).toBe(true);
21
+ await runtime.sync();
22
+ expect(runtime.status()).toMatchObject({ phase: 'partial', initializationRequired: true, indexStartedAt: clock, indexCompletedAt: null });
23
+ await runtime.stop();
24
+ clock += 1000;
25
+ fail = false;
26
+ runtime = createContentSearchRuntime(dependencies);
27
+ await runtime.sync();
28
+ expect(reads).toEqual(['a', 'b', 'b']);
29
+ expect(runtime.status()).toMatchObject({ phase: 'ready', initializationRequired: false, indexStartedAt: 40 * day, indexCompletedAt: clock });
30
+ expect(service.search('host', 'old history')).toHaveLength(2);
31
+ await runtime.stop();
32
+ runtime = createContentSearchRuntime(dependencies);
33
+ expect(runtime.status()).toMatchObject({ initializationRequired: false, indexCompletedAt: clock });
34
+ service.removeSession('host', 'a');
35
+ await runtime.sync();
36
+ expect(reads).toEqual(['a', 'b', 'b', 'a']);
37
+ expect(service.search('host', 'old history')).toHaveLength(2);
38
+ await runtime.stop(); service.close();
39
+ });
40
+
41
+ test('expired rows cannot leave a checkpoint claiming coverage that was pruned', async () => {
42
+ const service = createContentSearchService({ dbPath: ':memory:' });
43
+ service.replaceSession('host', { id: 's', time: { updated: 10 } }, [], 1);
44
+ service.prune('host', 5, new Set(['s']));
45
+ expect(service.updated('host', 's', 1)).toBeUndefined();
46
+ expect(service.updated('host', 's', 5)).toBe(10);
47
+ service.close();
48
+ });
49
+
50
+ test('catalog failure and malformed pages never certify coverage or remove existing results', async () => {
51
+ const service = createContentSearchService({ dbPath: ':memory:' });
52
+ service.replaceSession('host', { id: 's', time: { updated: 10 } }, [
53
+ { info: { id: 'm', role: 'user', time: { created: 10 } }, parts: [{ type: 'text', text: 'preserved' }] },
54
+ ], 0);
55
+ for (const data of [undefined, [{ id: 'bad' }]]) {
56
+ const runtime = createContentSearchRuntime({ service, getRuntimeKey: () => 'host', now: () => 100,
57
+ listSessions: async () => ({ data, cursor: {} }), listMessages: async () => { throw new Error('unexpected'); },
58
+ });
59
+ await runtime.sync();
60
+ expect(runtime.status()).toMatchObject({ phase: 'error', initializationRequired: true, indexCompletedAt: null });
61
+ expect(service.search('host', 'preserved')).toHaveLength(1);
62
+ await runtime.stop();
63
+ }
64
+ const empty = createContentSearchRuntime({ service, getRuntimeKey: () => 'host', now: () => 200,
65
+ listSessions: async () => ({ data: [], cursor: {} }), listMessages: async () => { throw new Error('unexpected'); },
66
+ });
67
+ await empty.sync();
68
+ expect(empty.status()).toMatchObject({ phase: 'ready', initializationRequired: false, indexCompletedAt: 200, messages: 0 });
69
+ await empty.stop(); service.close();
70
+ });
71
+
72
+ test('a large warm catalog verifies without rereading transcripts and backfill keeps two-worker concurrency', async () => {
73
+ const service = createContentSearchService({ dbPath: ':memory:' });
74
+ const sessions = Array.from({ length: 1000 }, (_, index) => ({ id: String(index), time: { updated: 10 } }));
75
+ let reads = 0;
76
+ let active = 0;
77
+ let peak = 0;
78
+ const runtime = createContentSearchRuntime({ service, getRuntimeKey: () => 'host', now: () => 100,
79
+ listSessions: async ({ cursor }) => {
80
+ const start = Number(cursor ?? 0);
81
+ return { data: sessions.slice(start, start + 100), cursor: { next: start + 100 < sessions.length ? String(start + 100) : undefined } };
82
+ },
83
+ listMessages: async () => {
84
+ reads++; peak = Math.max(peak, ++active);
85
+ await Promise.resolve();
86
+ active--;
87
+ return { data: [], cursor: {} };
88
+ },
89
+ });
90
+ await runtime.sync();
91
+ expect(runtime.status()).toMatchObject({ initializationRequired: false, completed: 1000 });
92
+ expect(peak).toBe(2);
93
+ expect(reads).toBe(1000);
94
+ await runtime.sync();
95
+ expect(reads).toBe(1000);
96
+ await runtime.stop(); service.close();
97
+ });
98
+
5
99
  test('expands existing seven-day checkpoints to thirty days even when sessions are unchanged', async () => {
6
100
  const service = createContentSearchService({ dbPath: ':memory:' });
7
101
  const day = 86400000;
@@ -105,5 +199,7 @@ test('runtime switch rejects in-flight message pages', async () => {
105
199
  await work;
106
200
  expect(service.search('one', 'stale')).toEqual([]);
107
201
  expect(service.search('two', 'stale')).toEqual([]);
202
+ expect(service.coverage('one').indexCompletedAt).toBeNull();
203
+ expect(runtime.status()).toMatchObject({ runtime: 'two', initializationRequired: true, indexStartedAt: null, indexCompletedAt: null });
108
204
  await runtime.stop(); service.close();
109
205
  });
@@ -21,15 +21,20 @@ export function createContentSearchService({ dbPath }) {
21
21
  CREATE TABLE IF NOT EXISTS search_session(runtime TEXT, id TEXT, directory TEXT, title TEXT, updated REAL, PRIMARY KEY(runtime,id));
22
22
  CREATE TABLE IF NOT EXISTS search_message(id INTEGER PRIMARY KEY, runtime TEXT, session TEXT, message TEXT, role TEXT, created REAL, body TEXT, UNIQUE(runtime,session,message));
23
23
  CREATE INDEX IF NOT EXISTS search_message_scope ON search_message(runtime,session);
24
+ CREATE TABLE IF NOT EXISTS search_coverage(runtime TEXT PRIMARY KEY, started_at REAL NOT NULL, completed_at REAL, since REAL, verified_at REAL);
24
25
  CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(body, content='search_message', content_rowid='id', tokenize='trigram');
25
26
  CREATE TRIGGER IF NOT EXISTS search_insert AFTER INSERT ON search_message BEGIN
26
27
  INSERT INTO search_fts(rowid,body) VALUES(new.id,new.body); END;
27
28
  CREATE TRIGGER IF NOT EXISTS search_delete AFTER DELETE ON search_message BEGIN
28
29
  INSERT INTO search_fts(search_fts,rowid,body) VALUES('delete',old.id,old.body); END;
29
30
  `);
30
- if (!db.prepare('PRAGMA table_info(search_session)').all().some((column) => column.name === 'since')) {
31
+ const sessionColumns = db.prepare('PRAGMA table_info(search_session)').all();
32
+ if (!sessionColumns.some((column) => column.name === 'since')) {
31
33
  db.exec('ALTER TABLE search_session ADD COLUMN since REAL');
32
34
  }
35
+ if (!sessionColumns.some((column) => column.name === 'indexed_at')) {
36
+ db.exec('ALTER TABLE search_session ADD COLUMN indexed_at REAL');
37
+ }
33
38
  const remove = db.prepare('DELETE FROM search_message WHERE runtime=? AND session=?');
34
39
  const write = db.prepare('INSERT INTO search_message(runtime,session,message,role,created,body) VALUES(?,?,?,?,?,?)');
35
40
  const removeSession = db.transaction((runtime, id) => {
@@ -37,23 +42,38 @@ export function createContentSearchService({ dbPath }) {
37
42
  db.prepare('DELETE FROM search_session WHERE runtime=? AND id=?').run(runtime, id);
38
43
  });
39
44
  return {
40
- replaceSession: db.transaction((runtime, session, records, since = null) => {
45
+ coverage(runtime) {
46
+ return db.prepare('SELECT started_at AS indexStartedAt, completed_at AS indexCompletedAt, since AS coveredSince, verified_at AS verifiedAt FROM search_coverage WHERE runtime=?').get(runtime)
47
+ ?? { indexStartedAt: null, indexCompletedAt: null, coveredSince: null, verifiedAt: null };
48
+ },
49
+ beginInitialization(runtime, timestamp) {
50
+ db.prepare(`INSERT INTO search_coverage(runtime,started_at) VALUES(?,?)
51
+ ON CONFLICT(runtime) DO UPDATE SET started_at=excluded.started_at, completed_at=NULL
52
+ WHERE search_coverage.completed_at IS NOT NULL`).run(runtime, timestamp);
53
+ },
54
+ completeInitialization(runtime, since, timestamp) {
55
+ db.prepare(`UPDATE search_coverage SET completed_at=COALESCE(completed_at,?), since=?, verified_at=? WHERE runtime=?`)
56
+ .run(timestamp, since, timestamp, runtime);
57
+ },
58
+ replaceSession: db.transaction((runtime, session, records, since = null, indexedAt = Date.now()) => {
41
59
  remove.run(runtime, session.id);
42
60
  for (const record of records) {
43
61
  const projected = projectSearchRecord(record);
44
62
  if (projected) write.run(runtime, session.id, projected.info.id, projected.info.role, projected.info.time.created, projected.parts[0].text);
45
63
  }
46
- db.prepare('INSERT OR REPLACE INTO search_session(runtime,id,directory,title,updated,since) VALUES(?,?,?,?,?,?)').run(runtime, session.id,
47
- session.location?.directory ?? session.directory ?? '', session.title ?? '', session.time?.updated ?? 0, since);
64
+ db.prepare('INSERT OR REPLACE INTO search_session(runtime,id,directory,title,updated,since,indexed_at) VALUES(?,?,?,?,?,?,?)').run(runtime, session.id,
65
+ session.location?.directory ?? session.directory ?? '', session.title ?? '', session.time?.updated ?? 0, since, indexedAt);
48
66
  }),
49
67
  removeSession,
50
68
  updated(runtime, id, since) {
51
- const row = db.prepare('SELECT updated,since FROM search_session WHERE runtime=? AND id=?').get(runtime, id);
69
+ const row = db.prepare('SELECT updated,since,indexed_at FROM search_session WHERE runtime=? AND id=?').get(runtime, id);
70
+ if (row?.indexed_at == null) return undefined;
52
71
  if (since !== undefined && (row?.since == null || row.since > since)) return undefined;
53
72
  return row?.updated;
54
73
  },
55
74
  prune: db.transaction((runtime, since, ids) => {
56
75
  db.prepare('DELETE FROM search_message WHERE runtime=? AND created<?').run(runtime, since);
76
+ db.prepare('UPDATE search_session SET since=MAX(since,?) WHERE runtime=? AND since IS NOT NULL').run(since, runtime);
57
77
  for (const row of db.prepare('SELECT id FROM search_session WHERE runtime=?').all(runtime)) {
58
78
  if (!ids.has(row.id)) removeSession(runtime, row.id);
59
79
  }
@@ -1,9 +1,46 @@
1
1
  import { afterEach, expect, test } from 'vitest';
2
2
  import { createContentSearchService } from './service.js';
3
+ import Database from 'better-sqlite3';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
3
7
 
4
8
  const services = [];
5
9
  afterEach(() => services.splice(0).forEach((service) => service.close()));
6
10
 
11
+ test.each([false, true])('upgrades old checkpoints (existing boundary: %s) without inventing coverage and persists initialization across database reopen', (hasBoundary) => {
12
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'content-index-'));
13
+ const dbPath = path.join(directory, 'index.sqlite');
14
+ let service;
15
+ try {
16
+ const old = new Database(dbPath);
17
+ old.exec(`CREATE TABLE search_session(runtime TEXT, id TEXT, directory TEXT, title TEXT, updated REAL, PRIMARY KEY(runtime,id));
18
+ INSERT INTO search_session VALUES('host','s','/repo','History',50);`);
19
+ if (hasBoundary) old.exec('ALTER TABLE search_session ADD COLUMN since REAL; UPDATE search_session SET since=0');
20
+ old.close();
21
+ service = createContentSearchService({ dbPath });
22
+ expect(service.updated('host', 's', 0)).toBeUndefined();
23
+ expect(service.coverage('host').indexCompletedAt).toBeNull();
24
+ service.beginInitialization('host', 100);
25
+ service.beginInitialization('host', 101);
26
+ service.close();
27
+ service = createContentSearchService({ dbPath });
28
+ expect(service.coverage('host')).toMatchObject({ indexStartedAt: 100, indexCompletedAt: null });
29
+ service.replaceSession('host', { id: 's', time: { updated: 50 } }, [], 0);
30
+ service.completeInitialization('host', 0, 200);
31
+ service.close();
32
+ service = createContentSearchService({ dbPath });
33
+ expect(service.coverage('host')).toEqual({ indexStartedAt: 100, indexCompletedAt: 200, coveredSince: 0, verifiedAt: 200 });
34
+ expect(service.updated('host', 's', 0)).toBe(50);
35
+ expect(service.coverage('other').indexCompletedAt).toBeNull();
36
+ service.beginInitialization('host', 300);
37
+ expect(service.coverage('host')).toMatchObject({ indexStartedAt: 300, indexCompletedAt: null, verifiedAt: 200 });
38
+ } finally {
39
+ service?.close();
40
+ fs.rmSync(directory, { recursive: true, force: true });
41
+ }
42
+ });
43
+
7
44
  test('indexes only admitted text and replaces FTS entries atomically', () => {
8
45
  const service = createContentSearchService({ dbPath: ':memory:' });
9
46
  services.push(service);
@@ -13,6 +13,11 @@ Android notification-only subscriptions send the App locale in `Accept-Language`
13
13
  - `packages/web/server/lib/notifications/apns-runtime.js`: native iOS APNs device-token persistence + delivery, plus a separate Live Activity token store and `end` send path. Two modes: **relay** (default — sign + POST tokens + generic text to the isolated Push Relay in `packages/relay-server/src/push/`, which holds the project APNs key) and **direct** (fallback — sign ES256 JWT with Node crypto + HTTP/2, when `OPENCHAMBER_PUSH_RELAY_DISABLED=true`). Unless `OPENCHAMBER_PUSH_RELAY_URL` is set, the Host maps the effective Relay `wss`/`ws` URL to the same host as `https`/`http` `/v1/push/send`. Before each relay send, the runtime checks an in-memory registration success cache (keyed by current register URL + token + platform) and only POSTs `/v1/push/register-token` for cache misses; only successfully bound tokens are sent. Switching Relay/Push URL clears that cache so persisted tokens re-bind to the new origin on the next send or `reRegisterAllTokens()`. Each server has an auto-generated ECDSA P-256 keypair (`getOrCreateRelayKeypair`, persisted in settings); it binds tokens on the relay (`/v1/push/register-token`) and signs every relay request, so the relay only delivers to tokens bound to that server. APNs is the iOS app's sole notification channel (no local notifications) and is NOT gated on UI visibility — iOS suppresses the foreground banner instead. APNs push carries only generic text (scenario title + session name) — see `APNS.md`. Android uses the separate native notification stream described above. Live Activity tokens are persisted beside alert tokens in the same file (`version` 2, `liveActivityTokensBySession`) without mixing the two token types; v1 files remain readable. Live Activity relay bind/send uses `/v1/push/register-live-activity-token`, `/v1/push/unregister-live-activity-token`, and `/v1/push/live-activity`.
14
14
  - `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime.
15
15
  - `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout.
16
+ - Assistant-owned scheduled results first enter the assistant's read-only result
17
+ review. They bypass generic scheduled-task fanout: a spoken review uses the
18
+ existing contact notification path, while an explicit silent review sends
19
+ nothing. Unowned tasks retain `notifyOnScheduledTasks` and the manual-run gate.
20
+ Result handoff/recovery belongs to `scheduled-tasks`, not this notification module.
16
21
  - `family-completion-gate.js` (with a TypeScript declaration): dependency-free, runtime-local completion barrier shared by Host fanout and the VS Code webview. The Host supplies authoritative HTTP reads; VS Code supplies official SDK reads.
17
22
  - `packages/web/server/lib/notifications/live-activity-refresh-runtime.js`: periodic Live Activity snapshot refresh (default 30s, `OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS` overrides, `0` disables). Each tick snapshots the authoritative session states once, recomputes every persisted Live Activity token's snapshot, and pushes updates only for real changes — busy→retry transitions, newly visible top-level sessions, idle completions the event path missed, and stale-row recovery. Entries whose recomputed snapshot is unchanged never send (APNs budgets Live Activity updates per hour). Rows for sessions without authoritative server state are preserved as-is; server-side-only sub-states (`permission`/`input`/`tool`) keep their last value while the session stays busy. When no row is working anymore the refresh sends `end` and clears accepted tokens.
18
23
  - `packages/web/server/lib/notifications/template-runtime.js`: notification template variables and session text/title enrichment runtime. Zen-model helpers are retained as compatibility stubs only.
@@ -337,6 +337,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
337
337
  onSystemSessionPersisted,
338
338
  upsertScheduledTask: (projectID, task) => projectConfigRuntime.upsertScheduledTask(projectID, task),
339
339
  syncScheduledTaskProject: (projectID) => scheduledTasksRuntime.syncProject(projectID),
340
+ setTaskResultHandler: (handler) => scheduledTasksRuntime.setTaskResultHandler(handler),
340
341
  refreshAllowedRoots: refreshAssistantAllowedRoots,
341
342
  globalEventHub: globalMessageStreamHub,
342
343
  onRevisionTip: (tip) => broadcastAssistantRevisionTip({
@@ -10,6 +10,31 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
10
10
  - This module is OpenChamber feature logic; it is intentionally separate from OpenCode proxy/runtime internals.
11
11
  - Prompt and goal execution preserve hash-prefixed text literally; no snippet discovery or expansion occurs before dispatch.
12
12
 
13
+ ## Assistant result handoff
14
+
15
+ `setTaskResultHandler(handler)` installs the Host's assistant result consumer and
16
+ returns its cleanup function. The feature-route composition wires it to the
17
+ assistant service. A handler returns `true` once associated contacts have reviewed
18
+ the result (including explicit silence); `false` retains ordinary scheduled-task
19
+ notification delivery. The scheduler releases its execution slot independently
20
+ of assistant inference. Handler failures keep the result pending.
21
+
22
+ Run history stores the original execution prompt privately alongside a
23
+ `result_pending` marker. The same terminal history update makes the result
24
+ eligible for delivery, including an in-flight run reconciled after restart.
25
+ `listPendingResults` uses bounded keyset pages of 20; the existing five-second
26
+ recovery pass retries handoffs with at most four in flight per runtime. A failing
27
+ page does not prevent the cursor from reaching later results. `acknowledgeResult`
28
+ clears only the delivery marker, never the execution result. Execution failure,
29
+ history-persistence failure, and assistant delivery failure remain distinct.
30
+
31
+ The additive migration leaves already-terminal old history acknowledged, avoiding
32
+ an upgrade notification backlog. Old running records enroll when they really
33
+ settle. Public history DTOs expose neither the prompt nor internal delivery state.
34
+ Assistant receipts deduplicate completed reviews, including silent ones; this is
35
+ a retryable handoff, not an exactly-once guarantee for model requests or device
36
+ pushes during crashes/concurrent Hosts. Result review is read-only.
37
+
13
38
  ## Built-in new-session skill
14
39
 
15
40
  `session-skill.js` intercepts new `POST /api/session` admissions on a Host using
@@ -113,6 +113,12 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
113
113
  if (!hasSlotAt) {
114
114
  db.exec('ALTER TABLE scheduled_task_run ADD COLUMN slot_at INTEGER');
115
115
  }
116
+ db.transaction(() => {
117
+ const columns = new Set(db.prepare("SELECT name FROM pragma_table_info('scheduled_task_run')").all().map((row) => row.name));
118
+ if (!columns.has('result_pending')) db.exec('ALTER TABLE scheduled_task_run ADD COLUMN result_pending INTEGER NOT NULL DEFAULT 0');
119
+ if (!columns.has('execution_prompt')) db.exec('ALTER TABLE scheduled_task_run ADD COLUMN execution_prompt TEXT');
120
+ db.exec("CREATE INDEX IF NOT EXISTS scheduled_task_run_pending_result ON scheduled_task_run(started_at DESC, run_id DESC) WHERE result_pending=1 AND status IN ('success','error')");
121
+ })();
116
122
  db.exec(`
117
123
  CREATE UNIQUE INDEX IF NOT EXISTS scheduled_task_run_scheduled_slot
118
124
  ON scheduled_task_run (project_id, task_id, slot_at)
@@ -161,9 +167,9 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
161
167
  db.prepare(`
162
168
  INSERT INTO scheduled_task_run (
163
169
  run_id, project_id, task_id, task_name, trigger, status,
164
- session_id, directory, error, started_at, finished_at, duration_ms, slot_at
165
- ) VALUES (?, ?, ?, ?, ?, 'running', NULL, ?, NULL, ?, NULL, NULL, ?)
166
- `).run(id, projectId, taskId, taskName, trigger, directory, startedAt, slotAt);
170
+ session_id, directory, error, started_at, finished_at, duration_ms, slot_at, result_pending, execution_prompt
171
+ ) VALUES (?, ?, ?, ?, ?, 'running', NULL, ?, NULL, ?, NULL, NULL, ?, 1, ?)
172
+ `).run(id, projectId, taskId, taskName, trigger, directory, startedAt, slotAt, typeof record.prompt === 'string' ? record.prompt : null);
167
173
  } catch (error) {
168
174
  if (slotAt !== null && SQLITE_UNIQUE_CODES.has(error?.code)) {
169
175
  const claimed = new Error('scheduled slot already claimed');
@@ -220,6 +226,7 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
220
226
  const updated = db.prepare(`
221
227
  UPDATE scheduled_task_run
222
228
  SET status = ?,
229
+ result_pending = CASE WHEN status='running' THEN 1 ELSE result_pending END,
223
230
  session_id = ?,
224
231
  error = ?,
225
232
  finished_at = ?,
@@ -231,7 +238,7 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
231
238
  return rowToDto(db.prepare('SELECT * FROM scheduled_task_run WHERE run_id = ?').get(id));
232
239
  };
233
240
 
234
- const listRuns = ({ before, limit, projectID, taskID, projectId, taskId } = {}, runningOnly = false) => {
241
+ const listRuns = ({ before, limit, projectID, taskID, projectId, taskId } = {}, runningOnly = false, pendingOnly = false) => {
235
242
  assertOpen();
236
243
  const cursor = decodeCursor(before);
237
244
  const resolvedLimit = (() => {
@@ -249,6 +256,7 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
249
256
  if (runningOnly) {
250
257
  filters.push("status = 'running'");
251
258
  }
259
+ if (pendingOnly) filters.push("result_pending=1 AND status IN ('success','error')");
252
260
  const params = [];
253
261
  const resolvedProjectID = asNonEmptyString(projectID ?? projectId);
254
262
  const resolvedTaskID = asNonEmptyString(taskID ?? taskId);
@@ -278,7 +286,7 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
278
286
  const hasMore = rows.length > resolvedLimit;
279
287
  const last = pageRows[pageRows.length - 1];
280
288
  return {
281
- runs: pageRows.map(rowToDto),
289
+ runs: pageRows.map((row) => pendingOnly ? { ...rowToDto(row), prompt: row.execution_prompt } : rowToDto(row)),
282
290
  nextCursor: hasMore && last
283
291
  ? encodeCursor({ startedAt: last.started_at, runID: last.run_id })
284
292
  : null,
@@ -300,6 +308,11 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
300
308
  finishRun,
301
309
  listRuns,
302
310
  listRunningRuns: (options) => listRuns(options, true),
311
+ listPendingResults: (options) => listRuns(options, false, true),
312
+ acknowledgeResult: (runID) => {
313
+ assertOpen();
314
+ db.prepare("UPDATE scheduled_task_run SET result_pending=0 WHERE run_id=? AND status IN ('success','error')").run(runID);
315
+ },
303
316
  close,
304
317
  };
305
318
  };
@@ -33,6 +33,57 @@ const startSample = (store, overrides = {}) => store.startRun({
33
33
  });
34
34
 
35
35
  describe('scheduled task run history store', () => {
36
+ it('keeps terminal result handoffs durable, with the execution prompt private and running runs excluded', () => {
37
+ const dbPath = createTempDbPath();
38
+ let store = createScheduledTaskRunHistoryStore({ dbPath });
39
+ try {
40
+ store.startRun({ id: 'report', projectId: 'p', taskId: 't', prompt: 'Deliver research findings' });
41
+ expect(store.listPendingResults().runs).toEqual([]);
42
+ store.finishRun('report', { status: 'success' });
43
+ expect(store.listRuns().runs[0]).not.toHaveProperty('prompt');
44
+ store.close();
45
+ store = createScheduledTaskRunHistoryStore({ dbPath });
46
+ expect(store.listPendingResults().runs).toEqual([expect.objectContaining({ id: 'report', prompt: 'Deliver research findings' })]);
47
+ store.acknowledgeResult('report');
48
+ store.close();
49
+ store = createScheduledTaskRunHistoryStore({ dbPath });
50
+ expect(store.listPendingResults().runs).toEqual([]);
51
+ } finally { store.close(); }
52
+ });
53
+
54
+ it('migrates old history without replaying completed runs and enrolls recovered in-flight work', () => {
55
+ const dbPath = createTempDbPath();
56
+ const first = createScheduledTaskRunHistoryStore({ dbPath });
57
+ startSample(first, { id: 'old-complete' });
58
+ first.finishRun('old-complete', { status: 'success' });
59
+ startSample(first, { id: 'old-running' });
60
+ first.close();
61
+ const Database = require('better-sqlite3');
62
+ const db = new Database(dbPath);
63
+ db.exec('DROP INDEX scheduled_task_run_pending_result; ALTER TABLE scheduled_task_run DROP COLUMN result_pending; ALTER TABLE scheduled_task_run DROP COLUMN execution_prompt');
64
+ db.close();
65
+ const store = createScheduledTaskRunHistoryStore({ dbPath });
66
+ try {
67
+ expect(store.listPendingResults().runs).toEqual([]);
68
+ store.finishRun('old-running', { status: 'error', error: 'actual failure' });
69
+ expect(store.listPendingResults().runs).toEqual([expect.objectContaining({ id: 'old-running', status: 'error', prompt: null })]);
70
+ } finally { store.close(); }
71
+ });
72
+
73
+ it('does not publish a result when the terminal history write rolls back', () => {
74
+ const dbPath = createTempDbPath();
75
+ const store = createScheduledTaskRunHistoryStore({ dbPath });
76
+ startSample(store, { id: 'failed-write' });
77
+ const Database = require('better-sqlite3');
78
+ const db = new Database(dbPath);
79
+ db.exec("CREATE TRIGGER reject_finish BEFORE UPDATE OF status ON scheduled_task_run BEGIN SELECT RAISE(ABORT, 'disk write failed'); END");
80
+ try {
81
+ expect(() => store.finishRun('failed-write', { status: 'success' })).toThrow('disk write failed');
82
+ expect(store.listRuns().runs[0].status).toBe('running');
83
+ expect(store.listPendingResults().runs).toEqual([]);
84
+ } finally { db.close(); store.close(); }
85
+ });
86
+
36
87
  it('lists runs newest-first and supports stable keyset pagination', () => {
37
88
  let now = 1_000;
38
89
  const store = createScheduledTaskRunHistoryStore({
@@ -1327,7 +1327,53 @@ export const createScheduledTasksRuntime = (deps) => {
1327
1327
  runID,
1328
1328
  error: safeErrorMessage(error),
1329
1329
  });
1330
- return { error };
1330
+ return { persistenceError: error };
1331
+ }
1332
+ };
1333
+
1334
+ let taskResultHandler = null;
1335
+ let resultRecoveryCursor;
1336
+ let resultRecoveryFlight = false;
1337
+ const deliveringResults = new Set();
1338
+ const deliverTaskResult = async (run) => {
1339
+ if (deliveringResults.has(run.id) || deliveringResults.size >= 4) return;
1340
+ deliveringResults.add(run.id);
1341
+ const handler = taskResultHandler;
1342
+ try {
1343
+ const handled = handler ? await handler(run) : false;
1344
+ if (handler !== taskResultHandler) return;
1345
+ if (!handled) {
1346
+ await notifyTaskRun?.({
1347
+ projectID: run.projectId, taskID: run.taskId, taskName: run.taskName,
1348
+ status: run.status, reason: run.trigger,
1349
+ ...(run.sessionId ? { sessionId: run.sessionId } : {}),
1350
+ ...(run.error ? { errorMessage: run.error } : {}),
1351
+ });
1352
+ }
1353
+ runHistoryStore?.acknowledgeResult?.(run.id);
1354
+ } catch (error) {
1355
+ logger.warn?.('[ScheduledTasks] result delivery failed', { runID: run.id, error: safeErrorMessage(error) });
1356
+ } finally {
1357
+ deliveringResults.delete(run.id);
1358
+ }
1359
+ };
1360
+ const recoverTaskResults = async () => {
1361
+ if (resultRecoveryFlight || !taskResultHandler || typeof runHistoryStore?.listPendingResults !== 'function') return;
1362
+ resultRecoveryFlight = true;
1363
+ try {
1364
+ const page = runHistoryStore.listPendingResults({ before: resultRecoveryCursor, limit: 20 });
1365
+ resultRecoveryCursor = page.nextCursor || undefined;
1366
+ let index = 0;
1367
+ await Promise.all(Array.from({ length: Math.min(4, page.runs.length) }, async () => {
1368
+ while (index < page.runs.length && taskResultHandler) {
1369
+ const run = page.runs[index++];
1370
+ await deliverTaskResult(run);
1371
+ }
1372
+ }));
1373
+ } catch (error) {
1374
+ logger.warn?.('[ScheduledTasks] result recovery failed', { error: safeErrorMessage(error) });
1375
+ } finally {
1376
+ resultRecoveryFlight = false;
1331
1377
  }
1332
1378
  };
1333
1379
 
@@ -1375,6 +1421,7 @@ export const createScheduledTasksRuntime = (deps) => {
1375
1421
  trigger: reason === 'manual' ? 'manual' : 'scheduled',
1376
1422
  directory: projectPath,
1377
1423
  startedAt: runStartedAt,
1424
+ prompt: task.execution?.prompt,
1378
1425
  ...(resolvedSlotAt !== undefined ? { slotAt: resolvedSlotAt } : {}),
1379
1426
  });
1380
1427
  } catch (error) {
@@ -1587,9 +1634,9 @@ export const createScheduledTasksRuntime = (deps) => {
1587
1634
  ...(sessionID ? { sessionId: sessionID } : {}),
1588
1635
  ...(status === 'error' ? { error: errorMessage || 'Unknown error' } : {}),
1589
1636
  });
1590
- if (historyFinalize?.error) {
1637
+ if (historyFinalize?.persistenceError) {
1591
1638
  status = 'error';
1592
- errorMessage = errorMessage || safeErrorMessage(historyFinalize.error);
1639
+ errorMessage = errorMessage || safeErrorMessage(historyFinalize.persistenceError);
1593
1640
  }
1594
1641
 
1595
1642
  try {
@@ -1606,15 +1653,21 @@ export const createScheduledTasksRuntime = (deps) => {
1606
1653
  // Notification fanout for scheduled (non-manual) runs; the notification
1607
1654
  // runtime owns every delivery gate (settings toggle, channels).
1608
1655
  try {
1609
- await notifyTaskRun?.({
1610
- projectID,
1611
- taskID,
1612
- taskName,
1613
- status,
1614
- reason,
1615
- ...(sessionID ? { sessionId: sessionID } : {}),
1616
- ...(status === 'error' ? { errorMessage } : {}),
1617
- });
1656
+ if (taskResultHandler && historyFinalize && !historyFinalize.persistenceError) {
1657
+ // Reviewing a result is independent of the scheduler's execution slot.
1658
+ void deliverTaskResult({ ...historyFinalize, prompt: task.execution?.prompt });
1659
+ } else {
1660
+ await notifyTaskRun?.({
1661
+ projectID,
1662
+ taskID,
1663
+ taskName,
1664
+ status,
1665
+ reason,
1666
+ ...(sessionID ? { sessionId: sessionID } : {}),
1667
+ ...(status === 'error' ? { errorMessage } : {}),
1668
+ });
1669
+ if (!historyFinalize?.persistenceError && runID) runHistoryStore?.acknowledgeResult?.(runID);
1670
+ }
1618
1671
  } catch (error) {
1619
1672
  logger.warn?.('[ScheduledTasks] task-run notification failed', {
1620
1673
  projectID,
@@ -1813,6 +1866,7 @@ export const createScheduledTasksRuntime = (deps) => {
1813
1866
  if (controller.signal.aborted) return;
1814
1867
  await recoverTaskStates(controller);
1815
1868
  if (controller.signal.aborted) return;
1869
+ void recoverTaskResults();
1816
1870
  historyRecoveryTimer = setTimeout(() => {
1817
1871
  void recoverRunHistory(controller, nextCursor || undefined);
1818
1872
  }, 5_000);
@@ -1994,5 +2048,9 @@ export const createScheduledTasksRuntime = (deps) => {
1994
2048
  runNow,
1995
2049
  getStatus,
1996
2050
  observeSessionEvent,
2051
+ setTaskResultHandler: (handler) => {
2052
+ taskResultHandler = handler;
2053
+ return () => { if (taskResultHandler === handler) taskResultHandler = null; };
2054
+ },
1997
2055
  };
1998
2056
  };