@openchambery/web 2.1.7-beta.9 → 2.1.8-beta.2
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.
- package/dist/assets/{AgentAvatar-DxS7U-Cp.js → AgentAvatar-ndlnVkjO.js} +1 -1
- package/dist/assets/AssistantView-6ta2Y6q7.js +3 -0
- package/dist/assets/{DiagramView-Bw2sCV3Y.js → DiagramView-DvZU3Yxr.js} +1 -1
- package/dist/assets/{FadeInOnReveal-kNFcAb-K.js → FadeInOnReveal-CcvpP9n1.js} +1 -1
- package/dist/assets/{Icon-DxBvAKXH.js → Icon-2Obqh9Fi.js} +1 -1
- package/dist/assets/{ImageLoadingPlaceholder-DQvRSYkt.js → ImageLoadingPlaceholder-D916bbhl.js} +1 -1
- package/dist/assets/{LoadingImage-CaXJqavb.js → LoadingImage-BeOr4G0q.js} +1 -1
- package/dist/assets/{MarkdownRenderer-DpXaDH86.js → MarkdownRenderer-Cl5V8BKz.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-EACzZK2L.js → MarkdownRendererImpl-losB1IeW.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-yohfk508.js → MarkstreamRendererImpl-PHsV91V_.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-ByVEkbBy.js → MobileDetailNavigation-W-DaFFg5.js} +1 -1
- package/dist/assets/{MobileShareBridge-DddCiczO.js → MobileShareBridge-Bjzb3TK6.js} +2 -2
- package/dist/assets/MobileTabPageHeader-qbGYMq9u.js +2 -0
- package/dist/assets/{MultiRunWindow-CQWEGqds.js → MultiRunWindow-BbfM6ISu.js} +1 -1
- package/dist/assets/{OnboardingScreen-BGZp5N1h.js → OnboardingScreen-DEolMFq_.js} +1 -1
- package/dist/assets/{OverlayScrollbar-OftHd9vR.js → OverlayScrollbar-DB7fWZUw.js} +1 -1
- package/dist/assets/{SettingsView-CDkq_RGv.js → SettingsView-Bjo-fXIC.js} +9 -9
- package/dist/assets/{SettingsWindow-CQhiLvNH.js → SettingsWindow-DdN8IePL.js} +1 -1
- package/dist/assets/{TerminalView-CEiDqWoI.js → TerminalView-BUHkhkkZ.js} +1 -1
- package/dist/assets/{ToolOutputDialog-jXsCdUsx.js → ToolOutputDialog-DGY_JzQg.js} +1 -1
- package/dist/assets/{appThemeRegistry-C90Vniux.js → appThemeRegistry-DiuhF7sJ.js} +1 -1
- package/dist/assets/{button-DfHNBl0U.js → button-Bdq9SUvZ.js} +1 -1
- package/dist/assets/{collapsible-BJ3u2c1m.js → collapsible-C7PP5l16.js} +1 -1
- package/dist/assets/{desktopHosts-BKeEWmL5.js → desktopHosts-DSBZ-9-L.js} +1 -1
- package/dist/assets/{desktopRelayRestore-FWm2fXr6.js → desktopRelayRestore-DK8TzHRx.js} +1 -1
- package/dist/assets/{diagram-KOjGEiKO.js → diagram-BBlwWp3m.js} +1 -1
- package/dist/assets/{dialog-DBNBD_KB.js → dialog-DHmUjm6M.js} +1 -1
- package/dist/assets/dropdown-menu.styles-XSr3iA4S.js +1 -0
- package/dist/assets/{es-2YvsazK2.js → es-BxLn_G_Q.js} +1 -1
- package/dist/assets/{fileMentionTouchSelection-zOe2SiY_.js → fileMentionTouchSelection-BHA_5cBo.js} +1 -1
- package/dist/assets/{fileQueries-Dhgypzyl.js → fileQueries-BGE5l3Jd.js} +1 -1
- package/dist/assets/{fr-Cy0M48Gw.js → fr-LROiZho7.js} +1 -1
- package/dist/assets/{gitApi-pv3A0rDM.js → gitApi-BdyssA-O.js} +1 -1
- package/dist/assets/{image-loading-DLh8WsH4.js → image-loading-qNtUCy69.js} +1 -1
- package/dist/assets/{input-CQ5f8Zqb.js → input-De00oOal.js} +1 -1
- package/dist/assets/{insertionBoundaries-CCxWV_ir.js → insertionBoundaries-DIohQnVE.js} +1 -1
- package/dist/assets/{ja-1zLWWJ2R.js → ja-DiErdLtA.js} +1 -1
- package/dist/assets/{ko-BLk_5Dhc.js → ko-srYeEs4u.js} +1 -1
- package/dist/assets/{main-KrnbhzQQ.js → main-BETi-fsf.js} +12 -12
- package/dist/assets/{main-BbqWl6PU.js → main-DUrFgqZL.js} +2 -2
- package/dist/assets/{markdownHeightCache-BJ7s_IuQ.js → markdownHeightCache-D44E3PrO.js} +1 -1
- package/dist/assets/{mcpQueries-BlrZ9jet.js → mcpQueries-qCvadKyt.js} +1 -1
- package/dist/assets/{miniChat-7ywVBnvf.js → miniChat-jC1_fkBZ.js} +2 -2
- package/dist/assets/mobile-D-5-hKu6.js +2 -0
- package/dist/assets/{multirun-CZOOUr7O.js → multirun-C2aiem4S.js} +1 -1
- package/dist/assets/{persistence-DUPZl7Oy.js → persistence-B7ORJ371.js} +1 -1
- package/dist/assets/{pl-BE8MC0XQ.js → pl-CzKfWMm9.js} +1 -1
- package/dist/assets/{platform-z2erC6Bs.js → platform-CiBU2bCp.js} +2 -2
- package/dist/assets/{projectMeta-DiTRU5Dp.js → projectMeta-DV6HExaM.js} +1 -1
- package/dist/assets/{pt-BR-ubhNymyH.js → pt-BR-CUmrnVae.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-CI-5mVAG.js +2 -0
- package/dist/assets/{renderMobileApp-fVXPQqNd.js → renderMobileApp-Ds-6h0OM.js} +5 -5
- package/dist/assets/{runtimeConfig-DHFwtNBi.js → runtimeConfig-CBy91BoB.js} +1 -1
- package/dist/assets/runtimeEndpointReset-C7wwKdBg.js +149 -0
- package/dist/assets/scheduledTaskNavigation-D7yhTPgz.js +1 -0
- package/dist/assets/sessionChangeSummary-BSFJiKHQ.js +2 -0
- package/dist/assets/{sessionLookup-BSK3URnU.js → sessionLookup-D0uMihAO.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-D655zJ3T.js → sortable-tabs-strip-aIcw2DJh.js} +1 -1
- package/dist/assets/speechSettingsQueries-Bd3icbFp.js +4 -0
- package/dist/assets/src-C9BNQK0O.css +2 -0
- package/dist/assets/{store-n-H5RvRn.js → store-BYLl4QL2.js} +2 -2
- package/dist/assets/{terminalApi-BnjU_lmF.js → terminalApi-bN5IYQau.js} +1 -1
- package/dist/assets/{textarea-HlFXg84f.js → textarea-DNadM0DL.js} +1 -1
- package/dist/assets/{tooltip-BQgoGUWx.js → tooltip-nHGca5md.js} +1 -1
- package/dist/assets/{uk-BNyyigDo.js → uk-DGZ3Hl33.js} +1 -1
- package/dist/assets/{url-DAyfGWPH.js → url-nbXEti6c.js} +1 -1
- package/dist/assets/{useAppFontEffects-BQbKLVsD.js → useAppFontEffects-ByC3DE9D.js} +53 -53
- package/dist/assets/{useAssistantUIStore-Dt0l5AKe.js → useAssistantUIStore-Dlvb7P0q.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-BIn2hI3V.js → useDesktopWindowControlsLayout-CbrmthFZ.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-BycgG8Ov.js → useEffectiveDirectory-BEpQGzay.js} +1 -1
- package/dist/assets/{useFontPreferences-B4Cgu3bS.js → useFontPreferences-6LD7d7hP.js} +1 -1
- package/dist/assets/useMobileNavigationStore-Cm0CuwA9.js +417 -0
- package/dist/assets/useUIStore-CDMI_Yzu.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-DmI50_3w.js → useWorkerHighlightedLines-D5mEoRi2.js} +1 -1
- package/dist/assets/{zh-CN-CNWzMPgL.js → zh-CN-UwxgbjmI.js} +1 -1
- package/dist/assets/{zh-TW-DrZ9S1yn.js → zh-TW-Bc9XoqY8.js} +1 -1
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +1 -1
- package/server/lib/assistants/DOCUMENTATION.md +39 -0
- package/server/lib/assistants/harness.js +20 -7
- package/server/lib/assistants/harness.test.js +21 -0
- package/server/lib/assistants/routes.js +2 -1
- package/server/lib/assistants/service.js +76 -13
- package/server/lib/assistants/service.test.js +53 -0
- package/server/lib/config-sync/DOCUMENTATION.md +33 -11
- package/server/lib/config-sync/config-sync.test.js +11 -11
- package/server/lib/config-sync/constants.js +0 -3
- package/server/lib/config-sync/contract.js +8 -8
- package/server/lib/config-sync/credentials.js +101 -0
- package/server/lib/config-sync/credentials.test.js +98 -0
- package/server/lib/config-sync/engine.js +15 -10
- package/server/lib/config-sync/index.js +0 -3
- package/server/lib/config-sync/local-backup.js +0 -13
- package/server/lib/config-sync/magpie.js +204 -0
- package/server/lib/config-sync/magpie.test.js +150 -0
- package/server/lib/config-sync/plan.js +32 -37
- package/server/lib/config-sync/receiver.js +26 -25
- package/server/lib/config-sync/routes.js +23 -2
- package/server/lib/config-sync/routes.test.js +63 -5
- package/server/lib/config-sync/scripts.js +2 -30
- package/server/lib/config-sync/selections.js +14 -10
- package/server/lib/notifications/DOCUMENTATION.md +5 -0
- package/server/lib/opencode/DOCUMENTATION.md +2 -1
- package/server/lib/opencode/auth.d.ts +1 -0
- package/server/lib/opencode/auth.js +1 -1
- package/server/lib/opencode/feature-routes-runtime.js +1 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +25 -0
- package/server/lib/scheduled-tasks/run-history-store.js +18 -5
- package/server/lib/scheduled-tasks/run-history-store.test.js +51 -0
- package/server/lib/scheduled-tasks/runtime.js +70 -12
- package/server/lib/scheduled-tasks/runtime.test.js +131 -0
- package/dist/assets/AssistantView-Bnaa8U5L.js +0 -3
- package/dist/assets/MobileTabPageHeader-CAtpMLFM.js +0 -2
- package/dist/assets/context-menu-DBNnM4a7.js +0 -1
- package/dist/assets/dropdown-menu.styles-Cbl6jSsR.js +0 -1
- package/dist/assets/mobile-_nwad-rW.js +0 -2
- package/dist/assets/renderElectronMiniChatApp-DfB14cCR.js +0 -2
- package/dist/assets/runtimeEndpointReset-DOHn9DSm.js +0 -149
- package/dist/assets/sessionChangeSummary-Y9NdeIoe.js +0 -2
- package/dist/assets/speechSettingsQueries-ojxskgsA.js +0 -4
- package/dist/assets/src-GOs_VnY-.css +0 -2
- package/dist/assets/useMobileNavigationStore-DZUur_4b.js +0 -417
- package/dist/assets/useUIStore-pl39iO82.js +0 -1
|
@@ -2,7 +2,7 @@ import { createConfigSyncReceiver } from './receiver.js';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Register HTTP config-sync receive/download routes for direct (and later relay) hosts.
|
|
5
|
-
* Auth is the global `/api` UI/client-token gate. Selected kinds (including
|
|
5
|
+
* Auth is the global `/api` UI/client-token gate. Selected kinds (including credentials)
|
|
6
6
|
* sync when present in the plan — no extra credential grant checks.
|
|
7
7
|
*
|
|
8
8
|
* @param {import('express').Express} app
|
|
@@ -70,11 +70,32 @@ export const registerConfigSyncRoutes = (app, deps = {}) => {
|
|
|
70
70
|
}
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
app.get('/api/openchamber/config-sync/magpie/inventory', async (_req, res) => {
|
|
74
|
+
try { res.json({ magpie: await receiver.magpieInventory() }); }
|
|
75
|
+
catch (error) { sendError(res, error); }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
app.put('/api/openchamber/config-sync/magpie', async (req, res) => {
|
|
79
|
+
try { res.json(await receiver.importMagpie(req)); }
|
|
80
|
+
catch (error) { sendError(res, error); }
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
app.get('/api/openchamber/config-sync/credentials/inventory', async (_req, res) => {
|
|
84
|
+
try { res.json({ providerCredentials: await receiver.credentialInventory() }); }
|
|
85
|
+
catch (error) { sendError(res, error); }
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
app.put('/api/openchamber/config-sync/credentials', async (req, res) => {
|
|
89
|
+
try { res.json(await receiver.importCredentials(req)); }
|
|
90
|
+
catch (error) { sendError(res, error); }
|
|
91
|
+
});
|
|
92
|
+
|
|
73
93
|
app.get('/api/openchamber/config-sync/download/:kind', async (req, res) => {
|
|
74
94
|
try {
|
|
75
95
|
const kind = String(req.params.kind || '').trim();
|
|
76
96
|
const buffer = await receiver.download({ kind });
|
|
77
|
-
res.setHeader('Content-Type', 'application/gzip');
|
|
97
|
+
res.setHeader('Content-Type', kind === 'credentials' || kind === 'magpie' ? 'application/octet-stream' : 'application/gzip');
|
|
98
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
78
99
|
res.setHeader('Content-Length', String(buffer.byteLength));
|
|
79
100
|
res.send(buffer);
|
|
80
101
|
} catch (error) {
|
|
@@ -27,7 +27,7 @@ afterEach(async () => {
|
|
|
27
27
|
}
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
const createApp = async () => {
|
|
30
|
+
const createApp = async (credentials, magpie) => {
|
|
31
31
|
const home = await makeTempDir();
|
|
32
32
|
const configDir = path.join(home, '.config', 'opencode');
|
|
33
33
|
await fsp.mkdir(configDir, { recursive: true });
|
|
@@ -36,6 +36,8 @@ const createApp = async () => {
|
|
|
36
36
|
|
|
37
37
|
const receiver = createConfigSyncReceiver({
|
|
38
38
|
homedir: () => home,
|
|
39
|
+
...(credentials ? { credentials } : {}),
|
|
40
|
+
...(magpie ? { magpie } : {}),
|
|
39
41
|
});
|
|
40
42
|
|
|
41
43
|
const app = express();
|
|
@@ -44,6 +46,62 @@ const createApp = async () => {
|
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
describe('config-sync HTTP routes', () => {
|
|
49
|
+
it('discovers Magpie without import and requires a selected transfer before finalizing', async () => {
|
|
50
|
+
const payload = Buffer.from('fixture-magpie-payload');
|
|
51
|
+
const received = [];
|
|
52
|
+
const { app } = await createApp(undefined, {
|
|
53
|
+
inventory: async () => ({ available: true, installed: false }),
|
|
54
|
+
download: async () => payload,
|
|
55
|
+
import: async (buffer) => { received.push(buffer); return { ok: true }; },
|
|
56
|
+
});
|
|
57
|
+
const probe = await request(app).post('/api/openchamber/config-sync/probe').send({ plan: {} });
|
|
58
|
+
expect(probe.body.inventory.magpie).toEqual({ available: true, installed: false });
|
|
59
|
+
expect(received).toEqual([]);
|
|
60
|
+
const download = await request(app).get('/api/openchamber/config-sync/download/magpie').expect(200);
|
|
61
|
+
expect(download.headers['cache-control']).toBe('no-store');
|
|
62
|
+
expect(download.body).toEqual(payload);
|
|
63
|
+
await request(app).put('/api/openchamber/config-sync/magpie')
|
|
64
|
+
.set('Content-Type', 'application/octet-stream').send(payload).expect(200);
|
|
65
|
+
const plan = { files: [], directories: [], deletes: [], magpie: { bytes: 0 } };
|
|
66
|
+
await request(app).post('/api/openchamber/config-sync/prepare').send({ plan, syncRunId: 'magpie-run' }).expect(200);
|
|
67
|
+
await request(app).post('/api/openchamber/config-sync/finalize').send({ syncRunId: 'magpie-run' }).expect(500);
|
|
68
|
+
await request(app).put('/api/openchamber/config-sync/put/magpie?syncRunId=magpie-run')
|
|
69
|
+
.set('Content-Type', 'application/octet-stream').send(payload).expect(200);
|
|
70
|
+
await request(app).post('/api/openchamber/config-sync/finalize').send({ syncRunId: 'magpie-run' }).expect(200);
|
|
71
|
+
expect(received).toEqual([payload, payload]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('transfers credential bytes through HTTP without putting values in inventory or filesystem backups', async () => {
|
|
75
|
+
const payload = Buffer.from(JSON.stringify({ format: 'opencode-credentials-v1', entries: [{ fixture: 'private-value' }] }));
|
|
76
|
+
const received = [];
|
|
77
|
+
const { app, home } = await createApp({
|
|
78
|
+
inventory: async () => ({ bytes: payload.length }),
|
|
79
|
+
download: async () => payload,
|
|
80
|
+
import: async (buffer) => { received.push(buffer); return { ok: true }; },
|
|
81
|
+
});
|
|
82
|
+
const plan = { direction: 'push', files: [], directories: [], deletes: [], providerCredentials: { bytes: payload.length } };
|
|
83
|
+
const inventory = await request(app).get('/api/openchamber/config-sync/credentials/inventory');
|
|
84
|
+
expect(inventory.body).toEqual({ providerCredentials: { bytes: payload.length } });
|
|
85
|
+
const download = await request(app).get('/api/openchamber/config-sync/download/credentials');
|
|
86
|
+
expect(download.headers['cache-control']).toBe('no-store');
|
|
87
|
+
expect(download.body).toEqual(payload);
|
|
88
|
+
await request(app).post('/api/openchamber/config-sync/prepare').send({ plan, syncRunId: 'credential-run' }).expect(200);
|
|
89
|
+
await request(app).post('/api/openchamber/config-sync/finalize').send({ syncRunId: 'credential-run' }).expect(500);
|
|
90
|
+
await request(app).put('/api/openchamber/config-sync/put/credentials?syncRunId=credential-run')
|
|
91
|
+
.set('Content-Type', 'application/octet-stream').send(download.body).expect(200);
|
|
92
|
+
await request(app).post('/api/openchamber/config-sync/finalize').send({ syncRunId: 'credential-run' }).expect(200);
|
|
93
|
+
await request(app).put('/api/openchamber/config-sync/credentials')
|
|
94
|
+
.set('Content-Type', 'application/octet-stream').send(payload).expect(200);
|
|
95
|
+
expect(received).toEqual([payload, payload]);
|
|
96
|
+
await expect(fsp.stat(path.join(home, '.openchamber.sync-backup-auth'))).rejects.toThrow();
|
|
97
|
+
await expect(fsp.stat(path.join(home, '.local', 'share', 'opencode', 'auth.json'))).rejects.toThrow();
|
|
98
|
+
await request(app).post('/api/openchamber/config-sync/prepare')
|
|
99
|
+
.send({ plan: { ...plan, providerCredentials: null }, syncRunId: 'unselected' }).expect(200);
|
|
100
|
+
await request(app).put('/api/openchamber/config-sync/put/credentials?syncRunId=unselected')
|
|
101
|
+
.set('Content-Type', 'application/octet-stream').send(payload).expect(500);
|
|
102
|
+
expect(received).toHaveLength(2);
|
|
103
|
+
});
|
|
104
|
+
|
|
47
105
|
it.each(['http-config', 'http-agents', 'local-pack', 'ssh-config', 'ssh-agents'])(
|
|
48
106
|
'%s archive excludes inventory-skipped files, including inside followed symlinks',
|
|
49
107
|
async (transport) => {
|
|
@@ -91,7 +149,7 @@ describe('config-sync HTTP routes', () => {
|
|
|
91
149
|
directories: [],
|
|
92
150
|
deletes: ['config.json', 'opencode.json'],
|
|
93
151
|
agentsRoot: null,
|
|
94
|
-
|
|
152
|
+
providerCredentials: null,
|
|
95
153
|
totalBytes: 3,
|
|
96
154
|
};
|
|
97
155
|
|
|
@@ -124,7 +182,7 @@ describe('config-sync HTTP routes', () => {
|
|
|
124
182
|
directories: [],
|
|
125
183
|
deletes: [],
|
|
126
184
|
agentsRoot: null,
|
|
127
|
-
|
|
185
|
+
providerCredentials: null,
|
|
128
186
|
totalBytes: 2,
|
|
129
187
|
};
|
|
130
188
|
const first = await request(app).post('/api/openchamber/config-sync/prepare').send({ plan, syncRunId: 'run-a' });
|
|
@@ -143,7 +201,7 @@ describe('config-sync HTTP routes', () => {
|
|
|
143
201
|
directories: [],
|
|
144
202
|
deletes: [],
|
|
145
203
|
agentsRoot: null,
|
|
146
|
-
|
|
204
|
+
providerCredentials: null,
|
|
147
205
|
totalBytes: 0,
|
|
148
206
|
};
|
|
149
207
|
await request(app).post('/api/openchamber/config-sync/prepare').send({ plan, syncRunId: 'run-stream' });
|
|
@@ -151,7 +209,7 @@ describe('config-sync HTTP routes', () => {
|
|
|
151
209
|
// Tiny invalid tar still proves chunk aggregation reached extract.
|
|
152
210
|
await expect(receiver.put({
|
|
153
211
|
syncRunId: 'run-stream',
|
|
154
|
-
kind: '
|
|
212
|
+
kind: 'config',
|
|
155
213
|
stream: Readable.from([Buffer.from('aa'), Buffer.from('bb')]),
|
|
156
214
|
})).rejects.toThrow(/tar|failed|Local tar extract/i);
|
|
157
215
|
|
|
@@ -3,8 +3,6 @@ import path from 'node:path';
|
|
|
3
3
|
import {
|
|
4
4
|
OPENCODE_AGENTS_ROOT_PROBE_MARKER,
|
|
5
5
|
OPENCODE_AGENTS_SYNC_BACKUP_DIR,
|
|
6
|
-
OPENCODE_AUTH_FILE_PROBE_MARKER,
|
|
7
|
-
OPENCODE_AUTH_SYNC_BACKUP_DIR,
|
|
8
6
|
OPENCODE_CONFIG_SYNC_ALLOWLIST,
|
|
9
7
|
OPENCODE_CONFIG_SYNC_BACKUP_DIR,
|
|
10
8
|
OPENCODE_CONFIG_SYNC_BACKUP_GENERATIONS,
|
|
@@ -60,7 +58,7 @@ const generationalBackupSetupLines = ({ backupVar, runVar, quotedRunId, generati
|
|
|
60
58
|
* {@link OPENCODE_CONFIG_SYNC_BACKUP_GENERATIONS} are pruned; prepare no longer
|
|
61
59
|
* `rm -rf`s the entire backup root at start.
|
|
62
60
|
*
|
|
63
|
-
* @param {{ files: { path: string }[], directories: { path: string }[], deletes: string[], agentsRoot?: { fileCount: number, bytes: number } | null
|
|
61
|
+
* @param {{ files: { path: string }[], directories: { path: string }[], deletes: string[], agentsRoot?: { fileCount: number, bytes: number } | null }} plan
|
|
64
62
|
* @param {{ syncRunId: string, generations?: number }} options
|
|
65
63
|
* @returns {string}
|
|
66
64
|
*/
|
|
@@ -134,19 +132,6 @@ export const buildRemoteSyncPrepareScript = (plan, options) => {
|
|
|
134
132
|
lines.push('rm -rf -- "$HOME/.agents"');
|
|
135
133
|
}
|
|
136
134
|
|
|
137
|
-
if (plan?.authFile) {
|
|
138
|
-
lines.push(`AUTH_BK="$HOME/${OPENCODE_AUTH_SYNC_BACKUP_DIR}"`);
|
|
139
|
-
lines.push(...generationalBackupSetupLines({
|
|
140
|
-
backupVar: 'AUTH_BK',
|
|
141
|
-
runVar: 'AUTH_RUN_BK',
|
|
142
|
-
quotedRunId,
|
|
143
|
-
generations,
|
|
144
|
-
}));
|
|
145
|
-
lines.push('if [ -e "$HOME/.local/share/opencode/auth.json" ]; then');
|
|
146
|
-
lines.push(' cp -a "$HOME/.local/share/opencode/auth.json" "$AUTH_RUN_BK/auth.json"');
|
|
147
|
-
lines.push('fi');
|
|
148
|
-
}
|
|
149
|
-
|
|
150
135
|
lines.push("printf 'SYNC_READY\\n'");
|
|
151
136
|
return lines.join('\n');
|
|
152
137
|
};
|
|
@@ -164,9 +149,6 @@ export const buildRemoteSyncProbeScript = (uniqueProbePaths) => {
|
|
|
164
149
|
lines.push(
|
|
165
150
|
`[ -e "$HOME/.agents" ] && printf '%s\\n' ${shellQuote(OPENCODE_AGENTS_ROOT_PROBE_MARKER)}`,
|
|
166
151
|
);
|
|
167
|
-
lines.push(
|
|
168
|
-
`[ -e "$HOME/.local/share/opencode/auth.json" ] && printf '%s\\n' ${shellQuote(OPENCODE_AUTH_FILE_PROBE_MARKER)}`,
|
|
169
|
-
);
|
|
170
152
|
lines.push('exit 0');
|
|
171
153
|
return lines.join('\n');
|
|
172
154
|
};
|
|
@@ -228,14 +210,9 @@ export const buildRemoteSyncInventoryScript = () => {
|
|
|
228
210
|
lines.push(" ABYTES=\"$(find \"$HOME/.agents\" -type f ! -path '*/node_modules/*' ! -path '*/.venv-asc/*' ! -name '*.backup' -exec wc -c {} + 2>/dev/null | awk 'END{print $1+0}')\"");
|
|
229
211
|
lines.push(' AGENTS="{\\"fileCount\\":$ACOUNT,\\"bytes\\":$ABYTES}"');
|
|
230
212
|
lines.push('fi');
|
|
231
|
-
lines.push('AUTH="null"');
|
|
232
|
-
lines.push('if [ -f "$HOME/.local/share/opencode/auth.json" ]; then');
|
|
233
|
-
lines.push(' AUBYTES="$(wc -c < "$HOME/.local/share/opencode/auth.json" | tr -d \' \')"');
|
|
234
|
-
lines.push(' AUTH="{\\"bytes\\":$AUBYTES}"');
|
|
235
|
-
lines.push('fi');
|
|
236
213
|
lines.push('FILES="${FILES%,}"');
|
|
237
214
|
lines.push('DIRS="${DIRS%,}"');
|
|
238
|
-
lines.push('printf \'SYNC_INVENTORY={"files":[%s],"directories":[%s],"agentsRoot":%s
|
|
215
|
+
lines.push('printf \'SYNC_INVENTORY={"files":[%s],"directories":[%s],"agentsRoot":%s}\\n\' "$FILES" "$DIRS" "$AGENTS"');
|
|
239
216
|
lines.push('exit 0');
|
|
240
217
|
return lines.join('\n');
|
|
241
218
|
};
|
|
@@ -265,9 +242,4 @@ export const buildRemoteAgentsTarScript = () => [
|
|
|
265
242
|
`cd "$HOME" && tar ${OPENCODE_CONFIG_SYNC_TAR_EXCLUDES.map(shellQuote).join(' ')} -h -czf - .agents`,
|
|
266
243
|
].join('\n');
|
|
267
244
|
|
|
268
|
-
export const buildRemoteAuthTarScript = () => [
|
|
269
|
-
'set -e',
|
|
270
|
-
'cd "$HOME/.local/share/opencode" && tar -h -czf - auth.json',
|
|
271
|
-
].join('\n');
|
|
272
|
-
|
|
273
245
|
export { shellQuote };
|
|
@@ -6,13 +6,14 @@ import { OPENCODE_CONFIG_SYNC_ALLOWLIST } from './constants.js';
|
|
|
6
6
|
* singleFiles: boolean[],
|
|
7
7
|
* directories: boolean[],
|
|
8
8
|
* agentsRoot: boolean,
|
|
9
|
-
*
|
|
9
|
+
* providerCredentials: boolean,
|
|
10
|
+
* magpie?: boolean,
|
|
10
11
|
* }} SyncSelections
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
* Default: everything selected except
|
|
15
|
-
* @param {{
|
|
15
|
+
* Default: everything selected except providerCredentials (credentials stay opt-in).
|
|
16
|
+
* @param {{ includeProviderCredentials?: boolean }} [options]
|
|
16
17
|
* @returns {SyncSelections}
|
|
17
18
|
*/
|
|
18
19
|
export const buildDefaultSyncSelections = (options = {}) => ({
|
|
@@ -20,13 +21,14 @@ export const buildDefaultSyncSelections = (options = {}) => ({
|
|
|
20
21
|
singleFiles: OPENCODE_CONFIG_SYNC_ALLOWLIST.singleFiles.map(() => true),
|
|
21
22
|
directories: OPENCODE_CONFIG_SYNC_ALLOWLIST.directories.map(() => true),
|
|
22
23
|
agentsRoot: true,
|
|
23
|
-
|
|
24
|
+
providerCredentials: options.includeProviderCredentials === true,
|
|
25
|
+
magpie: false,
|
|
24
26
|
});
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* Normalize a partial selections object against the allowlist shape.
|
|
28
30
|
* @param {Partial<SyncSelections> | null | undefined} raw
|
|
29
|
-
* @param {{
|
|
31
|
+
* @param {{ includeProviderCredentials?: boolean }} [options]
|
|
30
32
|
* @returns {SyncSelections}
|
|
31
33
|
*/
|
|
32
34
|
export const normalizeSyncSelections = (raw, options = {}) => {
|
|
@@ -48,7 +50,8 @@ export const normalizeSyncSelections = (raw, options = {}) => {
|
|
|
48
50
|
singleFiles,
|
|
49
51
|
directories,
|
|
50
52
|
agentsRoot: raw.agentsRoot !== false,
|
|
51
|
-
|
|
53
|
+
providerCredentials: raw.providerCredentials === true,
|
|
54
|
+
magpie: raw.magpie === true,
|
|
52
55
|
};
|
|
53
56
|
};
|
|
54
57
|
|
|
@@ -59,7 +62,7 @@ export const normalizeSyncSelections = (raw, options = {}) => {
|
|
|
59
62
|
* @param {SyncSelections} selections
|
|
60
63
|
*/
|
|
61
64
|
export const filterPlanBySelections = (plan, selections) => {
|
|
62
|
-
const selected = normalizeSyncSelections(selections, {
|
|
65
|
+
const selected = normalizeSyncSelections(selections, { includeProviderCredentials: selections?.providerCredentials === true });
|
|
63
66
|
const selectedFileNames = new Set();
|
|
64
67
|
const selectedDeleteNames = new Set();
|
|
65
68
|
|
|
@@ -91,13 +94,13 @@ export const filterPlanBySelections = (plan, selections) => {
|
|
|
91
94
|
const directories = (Array.isArray(plan?.directories) ? plan.directories : []).filter((entry) => selectedDirNames.has(entry.path));
|
|
92
95
|
const deletes = (Array.isArray(plan?.deletes) ? plan.deletes : []).filter((name) => selectedDeleteNames.has(name) || selectedDirLegacy.has(name));
|
|
93
96
|
const agentsRoot = selected.agentsRoot ? (plan?.agentsRoot ?? null) : null;
|
|
94
|
-
const
|
|
97
|
+
const providerCredentials = selected.providerCredentials ? (plan?.providerCredentials ?? null) : null;
|
|
95
98
|
|
|
96
99
|
let totalBytes = 0;
|
|
97
100
|
for (const entry of files) totalBytes += Number(entry.bytes) || 0;
|
|
98
101
|
for (const entry of directories) totalBytes += Number(entry.bytes) || 0;
|
|
99
102
|
if (agentsRoot) totalBytes += Number(agentsRoot.bytes) || 0;
|
|
100
|
-
if (
|
|
103
|
+
if (providerCredentials) totalBytes += Number(providerCredentials.bytes) || 0;
|
|
101
104
|
|
|
102
105
|
return {
|
|
103
106
|
...plan,
|
|
@@ -105,7 +108,8 @@ export const filterPlanBySelections = (plan, selections) => {
|
|
|
105
108
|
directories,
|
|
106
109
|
deletes,
|
|
107
110
|
agentsRoot,
|
|
108
|
-
|
|
111
|
+
providerCredentials,
|
|
112
|
+
magpie: selected.magpie && plan?.magpieAvailable ? { bytes: 0 } : null,
|
|
109
113
|
totalBytes,
|
|
110
114
|
selections: selected,
|
|
111
115
|
};
|
|
@@ -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.
|
|
@@ -73,11 +73,12 @@ This module provides OpenCode server integration utilities for the web server ru
|
|
|
73
73
|
|
|
74
74
|
## Public exports (auth.js)
|
|
75
75
|
- `configureOpenCodeCredentials(resolveClient)`: host composition injects a call-time SDK client resolver; `null` detaches it. Web/Electron share server wiring; VS Code wires its manager in extension activation. Mobile clients use their host.
|
|
76
|
+
- `currentClient()`: trusted host-only client resolver used by config-sync to retain full V2 credential account records instead of the active-account quota projection.
|
|
76
77
|
- `openCodeCredentialSource({ buildOpenCodeUrl, getOpenCodeAuthHeaders })`: resolves the current endpoint/auth at each read rather than capturing a previous runtime.
|
|
77
78
|
- `readOpenCodeCredentials()`: async SDK `credential.list()` read of the running service; validates entries and projects only active accounts into the quota/TTS credential shape. OAuth `metadata.accountID` becomes `accountId`. No local `auth.json`, local environment, or local provider-config fallback; external service environment-only keys are not exposed by the credential API.
|
|
78
79
|
- `getProviderAuth(providerId)`: async selected stored credential or authoritative `null`; network/auth/schema failures reject with a sanitized error, never empty success.
|
|
79
80
|
- `removeProviderAuth(providerId)`: deletes every stored account for that integration through SDK `credential.remove`, preserving unrelated integrations. Partial failure rejects explicitly; retry lists remaining accounts. Auth-only disconnect requires no process restart.
|
|
80
|
-
- Credential values remain host-only. `credential-boundary.js` blocks raw credential list/creation responses (GET/HEAD/POST `/api/credential`, including encoded/trailing-slash forms) in Web proxy and VS Code bridge because v2 replies contain keys. Provider-source responses expose only existence; errors never include upstream credential bodies/headers.
|
|
81
|
+
- Credential values remain host-only outside explicitly selected instance sync. `credential-boundary.js` blocks raw credential list/creation responses (GET/HEAD/POST `/api/credential`, including encoded/trailing-slash forms) in Web proxy and VS Code bridge because v2 replies contain keys. Provider-source responses expose only existence; errors never include upstream credential bodies/headers. The authenticated config-sync routes own explicit V2 credential transfer; previews and run logs contain only metadata.
|
|
81
82
|
|
|
82
83
|
## Public exports (shared.js)
|
|
83
84
|
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`, `CUSTOM_CONFIG_FILE`: Path constants.
|
|
@@ -4,6 +4,7 @@ export type ProviderCredential =
|
|
|
4
4
|
| { type: 'api'; key: string }
|
|
5
5
|
| { type: 'oauth'; access: string; refresh: string; expires: number; accountId?: string; enterpriseUrl?: string };
|
|
6
6
|
export function configureOpenCodeCredentials(resolveClient: (() => OpenCodeClient) | null): void;
|
|
7
|
+
export function currentClient(): OpenCodeClient;
|
|
7
8
|
export function openCodeCredentialSource(deps: {
|
|
8
9
|
buildOpenCodeUrl: (path: string, prefix?: string) => string;
|
|
9
10
|
getOpenCodeAuthHeaders: () => Record<string, string>;
|
|
@@ -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({
|