@loicngr/kobo 1.11.39 → 1.11.40
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/AGENTS.md +12 -0
- package/CHANGELOG.md +4 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +16 -9
- package/dist/mcp-server/kobo-tasks-server.js +13 -2
- package/dist/mcp-server/workspace-dialogue-client.js +28 -0
- package/dist/server/db/mcp-message-schema.js +16 -0
- package/dist/server/db/migrations.js +4 -0
- package/dist/server/db/schema.js +4 -0
- package/dist/server/db/search-schema.js +53 -0
- package/dist/server/index.js +17 -86
- package/dist/server/middleware/network-auth-middleware.js +5 -1
- package/dist/server/routes/changelog.js +4 -49
- package/dist/server/routes/git.js +2 -2
- package/dist/server/routes/mcp.js +47 -0
- package/dist/server/routes/pull-requests.js +1 -1
- package/dist/server/routes/search.js +13 -4
- package/dist/server/routes/settings.js +5 -0
- package/dist/server/routes/workspaces.js +225 -241
- package/dist/server/services/agent/engines/claude-code/engine.js +31 -33
- package/dist/server/services/agent/engines/codex/engine.js +37 -8
- package/dist/server/services/agent/engines/codex/event-mapper.js +3 -1
- package/dist/server/services/agent/engines/codex/options-builder.js +4 -3
- package/dist/server/services/agent/engines/codex/server-requests.js +8 -1
- package/dist/server/services/agent/orchestrator.js +20 -3
- package/dist/server/services/change-source-branch-service.js +10 -42
- package/dist/server/services/chat-receipt-service.js +33 -0
- package/dist/server/services/cron-service.js +27 -1
- package/dist/server/services/mcp-client-context.js +12 -0
- package/dist/server/services/mcp-connection-info-service.js +63 -0
- package/dist/server/services/mcp-message-delivery-service.js +59 -0
- package/dist/server/services/mcp-message-request-service.js +64 -0
- package/dist/server/services/pr-checkout-service.js +10 -10
- package/dist/server/services/pr-watcher-service.js +14 -9
- package/dist/server/services/search/indexer.js +232 -0
- package/dist/server/services/search/worker.js +76 -0
- package/dist/server/services/search-service.js +104 -110
- package/dist/server/services/update-check-service.js +119 -0
- package/dist/server/services/websocket-service.js +121 -35
- package/dist/server/services/workspace-dialogue-service.js +128 -0
- package/dist/server/services/workspace-message-service.js +58 -0
- package/dist/server/services/workspace-permission-policy-service.js +33 -3
- package/dist/server/services/worktree-restore-service.js +2 -2
- package/dist/server/services/worktree-service.js +197 -124
- package/dist/server/utils/bounded-process.js +137 -0
- package/dist/server/utils/git-ops.js +76 -128
- package/dist/server/utils/git-repo-lock.js +27 -13
- package/dist/shared/workspace-dialogue-tools.js +89 -0
- package/dist/shared/workspace-message-types.js +19 -0
- package/package.json +2 -1
- package/src/client/dist/pwa/assets/{ActivityFeed-Chsw1Mh7.css → ActivityFeed-BYR5x7aa.css} +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-MqsC-95i.js +8 -0
- package/src/client/dist/pwa/assets/{ChangelogPage-D8563008.js → ChangelogPage-BxURWYxa.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-Bd0X08sg.js → ComparisonPanel-DjiMDAUi.js} +2 -2
- package/src/client/dist/pwa/assets/CreatePage-B-rhBmqv.js +2 -0
- package/src/client/dist/pwa/assets/DashboardPage-mA5RZf1t.js +1 -0
- package/src/client/dist/pwa/assets/DiffViewer-Ci7ucmy3.js +8 -0
- package/src/client/dist/pwa/assets/{DiffViewer-DnR79ycb.css → DiffViewer-DEPOa3D7.css} +1 -1
- package/src/client/dist/pwa/assets/{DocumentsPanel-BJsJ0fNs.js → DocumentsPanel-RZTvcJkT.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-BVLO5K5d.js → DrawerToggleButton-CTOc3yA0.js} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-_kL2gMoy.js +3 -0
- package/src/client/dist/pwa/assets/{HealthPage-RFZS7Lao.js → HealthPage-D3__hKJ5.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-B3JKUm3n.js +2 -0
- package/src/client/dist/pwa/assets/{QBtnDropdown-X8oNU5Mb.js → QBtnDropdown-td0RAVPx.js} +1 -1
- package/src/client/dist/pwa/assets/{QDrawer-Cij17skX.js → QDrawer-BkyFPV7a.js} +1 -1
- package/src/client/dist/pwa/assets/QSelect-WspK3KC7.js +1 -0
- package/src/client/dist/pwa/assets/{QTooltip-CwcCGSWq.js → QTooltip-7pw7iuV0.js} +1 -1
- package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-CLNM13oo.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-BGc1oQSM.css +1 -0
- package/src/client/dist/pwa/assets/SearchPage-CmRgIvu4.js +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-Cj6Y_TlN.js +9 -0
- package/src/client/dist/pwa/assets/SettingsPage-sVlsJQ0b.css +1 -0
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-D7lGFRKs.js → SplitWorkspacePage-CCHGEsKf.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-DI8DHb9e.js → TerminalPanel-ijmsYk-2.js} +9 -9
- package/src/client/dist/pwa/assets/{TouchPan-B3nrkCY8.js → TouchPan-DuQFjlQF.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-DU1UShMY.js → TourReplayButton-C5RF0lOK.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-C-yN5IX8.js → WorkspaceAttentionLabels-BR_XElog.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-BGCildyP.js +4 -0
- package/src/client/dist/pwa/assets/{WorkspacePage-BAC6u9OP.css → WorkspacePage-CqcMweNL.css} +1 -1
- package/src/client/dist/pwa/assets/{browser-f9MHQjfi.js → browser-DUqzdkbg.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-BWBzP8os.js → build-path-tree-ImiSpzfI.js} +1 -1
- package/src/client/dist/pwa/assets/de-CaHID9mW.js +17 -0
- package/src/client/dist/pwa/assets/{documents-C2cLnEBQ.js → documents-2C1hn1pw.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-sw146MWN.js → editor.api-Bm1vVaBr.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-C5ZZgCH4.js → efforts-_D8DFYXo.js} +1 -1
- package/src/client/dist/pwa/assets/engineFeatures-CZ9qfBYq.js +1 -0
- package/src/client/dist/pwa/assets/es-ZhzlCmer.js +17 -0
- package/src/client/dist/pwa/assets/{expand-template-B6xAlAaM.js → expand-template-CbDNnTPP.js} +1 -1
- package/src/client/dist/pwa/assets/{format-BwWP6Aay.js → format-pPImdFUy.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-DyWtO-nR.js → formatters-dTX38ZsV.js} +1 -1
- package/src/client/dist/pwa/assets/fr-BfXw7L7J.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-DZ5Hm8UH.js → freemarker2-DGluDnAm.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-BRUGpTA7.js → handlebars-BgviTnpD.js} +1 -1
- package/src/client/dist/pwa/assets/{html-DNOayNNs.js → html-CV-MrunL.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-CmXBenUp.js +1 -0
- package/src/client/dist/pwa/assets/index-9tiw3NjK.js +20 -0
- package/src/client/dist/pwa/assets/it-CHAurcTF.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-C0tDQU3z.js → javascript-C2VFcHf2.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-CN46O6OF.js → liquid-Dg9ZhbP5.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-DM-lrr8Q.js → mdx-ImgrPYNl.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-CkN7y-8c.js → monaco-contributions-waWZXS1T.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-2BcAOLjc.js → monaco.contribution-BB2rtCHM.js} +2 -2
- package/src/client/dist/pwa/assets/network-auth-h6O0qCMA.js +1 -0
- package/src/client/dist/pwa/assets/{notify-theme-a91Tcr6o.js → notify-theme-Dyg-FGI6.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-DOtELZfB.js → pending-diff-open-Dfpg92li.js} +1 -1
- package/src/client/dist/pwa/assets/{permissionModes-C-9Crj-c.js → permissionModes-DDWmNBW1.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-BRe4oyNx.js → project-color-XUfY_zbw.js} +1 -1
- package/src/client/dist/pwa/assets/{python-BRpJG6sZ.js → python-DVLs-DBA.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-CehCmvHl.js → razor-D34BqMwB.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-DlbQLZ02.js → render-chat-markdown-DwUkPy-O.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-Dt_8mVuj.js → typescript-C7-UpNXx.js} +1 -1
- package/src/client/dist/pwa/assets/use-search-index-status-DtLzygcy.js +1 -0
- package/src/client/dist/pwa/assets/use-tours-DfE-kwrX.js +2 -0
- package/src/client/dist/pwa/assets/{workspace-templates-GCObcjO6.js → workspace-templates-DwwdOEAq.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-_gOwdzB4.js → xml-Cx1oQhEr.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-BxeBqpGf.js → yaml-BQOiJsdx.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/README.md +118 -2
- package/src/mcp-server/kobo-tasks-handlers.ts +14 -11
- package/src/mcp-server/kobo-tasks-server.ts +19 -2
- package/src/mcp-server/workspace-dialogue-client.ts +34 -0
- package/src/client/dist/pwa/assets/ActivityFeed-BzzvcOmq.js +0 -8
- package/src/client/dist/pwa/assets/CreatePage-Bon8qIka.js +0 -2
- package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +0 -1
- package/src/client/dist/pwa/assets/DiffViewer-BpsLNxnj.js +0 -8
- package/src/client/dist/pwa/assets/GitPanel-Cal_wzC0.js +0 -3
- package/src/client/dist/pwa/assets/MainLayout-vvyiRcql.js +0 -2
- package/src/client/dist/pwa/assets/QSelect-xd-AlGo_.js +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-CXRUsz6G.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CVgRa025.css +0 -1
- package/src/client/dist/pwa/assets/SearchPage-DopbgLxh.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-Do9d0Pov.css +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-Jx6TnOzR.js +0 -9
- package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +0 -4
- package/src/client/dist/pwa/assets/de-QkDLrcGq.js +0 -17
- package/src/client/dist/pwa/assets/engineFeatures-CAVMDawq.js +0 -1
- package/src/client/dist/pwa/assets/es-BgQFKlo8.js +0 -17
- package/src/client/dist/pwa/assets/fr-DAdLWjNM.js +0 -17
- package/src/client/dist/pwa/assets/i18n-CBez_vip.js +0 -1
- package/src/client/dist/pwa/assets/index-VL3fI9a3.js +0 -20
- package/src/client/dist/pwa/assets/it-Bumaewp1.js +0 -17
- package/src/client/dist/pwa/assets/network-auth-KYS4snQH.js +0 -1
- package/src/client/dist/pwa/assets/use-tours-CSVM_54K.js +0 -2
|
@@ -1,117 +1,111 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
1
2
|
import { getDb } from '../db/index.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
3
|
+
let active;
|
|
4
|
+
let requestId = 0;
|
|
5
|
+
/** Start lazily for service consumers, eagerly at server boot. Never opens another home directory. */
|
|
6
|
+
export function startSearchIndex() {
|
|
7
|
+
const dbPath = getDb().name;
|
|
8
|
+
if (active?.dbPath === dbPath)
|
|
9
|
+
return;
|
|
10
|
+
if (active)
|
|
11
|
+
void stopSearchIndex();
|
|
12
|
+
const development = import.meta.url.endsWith('.ts');
|
|
13
|
+
const entry = new URL(`./search/worker.${development ? 'ts' : 'js'}`, import.meta.url);
|
|
14
|
+
const worker = development
|
|
15
|
+
? new Worker(`const { workerData } = require('node:worker_threads'); import('tsx/esm/api').then(({ tsImport }) => tsImport(workerData.entry, workerData.parent));`, {
|
|
16
|
+
eval: true,
|
|
17
|
+
workerData: { dbPath, entry: entry.href, parent: import.meta.url },
|
|
18
|
+
})
|
|
19
|
+
: new Worker(entry, { workerData: { dbPath } });
|
|
20
|
+
const state = {
|
|
21
|
+
worker,
|
|
22
|
+
dbPath,
|
|
23
|
+
pending: new Map(),
|
|
24
|
+
status: { state: 'building', processed: 0, total: 0 },
|
|
25
|
+
};
|
|
26
|
+
active = state;
|
|
27
|
+
worker.unref();
|
|
28
|
+
const fail = (error) => {
|
|
29
|
+
state.status = { ...state.status, state: 'error', error: error.message };
|
|
30
|
+
for (const pending of state.pending.values()) {
|
|
31
|
+
pending.cleanup();
|
|
32
|
+
pending.reject(error);
|
|
30
33
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
state.pending.clear();
|
|
35
|
+
};
|
|
36
|
+
worker.on('error', fail);
|
|
37
|
+
worker.on('exit', (code) => {
|
|
38
|
+
if (active === state)
|
|
39
|
+
fail(new Error(`Search worker stopped (${code})`));
|
|
40
|
+
});
|
|
41
|
+
worker.on('message', (message) => {
|
|
42
|
+
if (message.status)
|
|
43
|
+
state.status = message.status;
|
|
44
|
+
if (message.id !== undefined) {
|
|
45
|
+
const pending = state.pending.get(message.id);
|
|
46
|
+
if (!pending)
|
|
47
|
+
return;
|
|
48
|
+
state.pending.delete(message.id);
|
|
49
|
+
pending.cleanup();
|
|
50
|
+
if (message.error)
|
|
51
|
+
pending.reject(new Error(message.error));
|
|
52
|
+
else
|
|
53
|
+
pending.resolve(message.results ?? []);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
36
56
|
}
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const prefix = start > 0 ? '…' : '';
|
|
41
|
-
const suffix = end < text.length ? '…' : '';
|
|
42
|
-
return `${prefix}${text.slice(start, end).trim()}${suffix}`;
|
|
57
|
+
export function getSearchIndexStatus() {
|
|
58
|
+
startSearchIndex();
|
|
59
|
+
return { ...active.status };
|
|
43
60
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*
|
|
47
|
-
* - Trimmed empty queries return `[]` without hitting the database.
|
|
48
|
-
* - SQLite does the first filter via `LIKE '%q%'` on the raw payload JSON.
|
|
49
|
-
* Results are then post-filtered in JS against the **readable** text
|
|
50
|
-
* (extracted from the JSON) to avoid false positives like matches inside
|
|
51
|
-
* field names or schema strings.
|
|
52
|
-
* - Snippets are 100 chars of context on either side of the first match.
|
|
53
|
-
*/
|
|
54
|
-
export function searchEvents(query, options = {}) {
|
|
55
|
-
const trimmed = query.trim();
|
|
56
|
-
if (trimmed.length === 0)
|
|
61
|
+
export async function searchEvents(query, options = {}, signal) {
|
|
62
|
+
if (!query.trim())
|
|
57
63
|
return [];
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
continue;
|
|
92
|
-
const idx = text.toLowerCase().indexOf(needle);
|
|
93
|
-
if (idx < 0)
|
|
94
|
-
continue;
|
|
95
|
-
results.push({
|
|
96
|
-
eventId: row.id,
|
|
97
|
-
sessionId: row.session_id,
|
|
98
|
-
workspaceId: row.workspace_id,
|
|
99
|
-
workspaceName: row.workspace_name,
|
|
100
|
-
archived: row.archived_at !== null,
|
|
101
|
-
type: row.type,
|
|
102
|
-
timestamp: row.created_at,
|
|
103
|
-
snippet: buildSnippet(text, idx, trimmed),
|
|
104
|
-
});
|
|
105
|
-
if (results.length >= limit)
|
|
106
|
-
break;
|
|
107
|
-
}
|
|
108
|
-
if (rows.length < batchSize)
|
|
109
|
-
break;
|
|
110
|
-
const last = rows.at(-1);
|
|
111
|
-
if (!last)
|
|
112
|
-
break;
|
|
113
|
-
cursorCreatedAt = last.created_at;
|
|
114
|
-
cursorId = last.id;
|
|
64
|
+
if (signal?.aborted)
|
|
65
|
+
throw new Error('Search cancelled');
|
|
66
|
+
startSearchIndex();
|
|
67
|
+
const state = active;
|
|
68
|
+
if (state.status.state === 'error')
|
|
69
|
+
throw new Error(state.status.error ?? 'Search index unavailable');
|
|
70
|
+
const id = ++requestId;
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const cancel = (message) => {
|
|
73
|
+
state.pending.delete(id);
|
|
74
|
+
cleanup();
|
|
75
|
+
state.worker.postMessage({ id, cancel: true });
|
|
76
|
+
reject(new Error(message));
|
|
77
|
+
};
|
|
78
|
+
const aborted = () => cancel('Search cancelled');
|
|
79
|
+
const timeout = setTimeout(() => cancel('Search timed out'), 10_000);
|
|
80
|
+
const cleanup = () => {
|
|
81
|
+
clearTimeout(timeout);
|
|
82
|
+
signal?.removeEventListener('abort', aborted);
|
|
83
|
+
};
|
|
84
|
+
state.pending.set(id, { resolve, reject, cleanup });
|
|
85
|
+
signal?.addEventListener('abort', aborted, { once: true });
|
|
86
|
+
state.worker.postMessage({ id, query, options });
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
export async function stopSearchIndex() {
|
|
90
|
+
const state = active;
|
|
91
|
+
if (!state)
|
|
92
|
+
return;
|
|
93
|
+
active = undefined;
|
|
94
|
+
for (const pending of state.pending.values()) {
|
|
95
|
+
pending.cleanup();
|
|
96
|
+
pending.reject(new Error('Search index stopped'));
|
|
115
97
|
}
|
|
116
|
-
|
|
98
|
+
state.pending.clear();
|
|
99
|
+
if (state.worker.threadId === -1)
|
|
100
|
+
return;
|
|
101
|
+
await new Promise((resolve) => {
|
|
102
|
+
const timeout = setTimeout(() => {
|
|
103
|
+
void state.worker.terminate();
|
|
104
|
+
}, 5_000);
|
|
105
|
+
state.worker.once('exit', () => {
|
|
106
|
+
clearTimeout(timeout);
|
|
107
|
+
resolve();
|
|
108
|
+
});
|
|
109
|
+
state.worker.postMessage({ stop: true });
|
|
110
|
+
});
|
|
117
111
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { getPackageVersion } from '../utils/paths.js';
|
|
2
|
+
import { broadcastAll } from './websocket-service.js';
|
|
3
|
+
export const UPDATE_CHECK_INTERVAL_MS = 20 * 60 * 1000;
|
|
4
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/@loicngr/kobo/latest';
|
|
5
|
+
const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
6
|
+
let latestVersion = null;
|
|
7
|
+
let lastAttempt = null;
|
|
8
|
+
let lastSuccessAt = null;
|
|
9
|
+
let checkStatus = 'unknown';
|
|
10
|
+
let inFlight = null;
|
|
11
|
+
let controller = null;
|
|
12
|
+
let timer = null;
|
|
13
|
+
let running = false;
|
|
14
|
+
let generation = 0;
|
|
15
|
+
let stopped = false;
|
|
16
|
+
export function getUpdateCheckSnapshot() {
|
|
17
|
+
return {
|
|
18
|
+
currentVersion: getPackageVersion(),
|
|
19
|
+
latestVersion,
|
|
20
|
+
lastAttemptAt: lastAttempt === null ? null : new Date(lastAttempt).toISOString(),
|
|
21
|
+
lastSuccessAt,
|
|
22
|
+
checkStatus,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Shared by HTTP loads and the single process-wide poller; failures are cached too. */
|
|
26
|
+
export function refreshUpdateCheck() {
|
|
27
|
+
if (inFlight)
|
|
28
|
+
return inFlight;
|
|
29
|
+
if (stopped || (lastAttempt !== null && Date.now() - lastAttempt < UPDATE_CHECK_INTERVAL_MS)) {
|
|
30
|
+
return Promise.resolve(getUpdateCheckSnapshot());
|
|
31
|
+
}
|
|
32
|
+
lastAttempt = Date.now();
|
|
33
|
+
const owner = generation;
|
|
34
|
+
const abort = new AbortController();
|
|
35
|
+
controller = abort;
|
|
36
|
+
const timeout = setTimeout(() => abort.abort(), 5000);
|
|
37
|
+
timeout.unref?.();
|
|
38
|
+
const aborted = new Promise((_, reject) => {
|
|
39
|
+
abort.signal.addEventListener('abort', () => reject(new Error('Update lookup cancelled')), { once: true });
|
|
40
|
+
});
|
|
41
|
+
const lookup = (async () => {
|
|
42
|
+
const res = await fetch(REGISTRY_URL, { signal: abort.signal });
|
|
43
|
+
if (!res.ok)
|
|
44
|
+
throw new Error('Registry lookup failed');
|
|
45
|
+
const body = await res.json();
|
|
46
|
+
if (!body ||
|
|
47
|
+
typeof body !== 'object' ||
|
|
48
|
+
!('version' in body) ||
|
|
49
|
+
typeof body.version !== 'string' ||
|
|
50
|
+
!STABLE_VERSION.test(body.version)) {
|
|
51
|
+
throw new Error('Invalid registry version');
|
|
52
|
+
}
|
|
53
|
+
return body.version;
|
|
54
|
+
})();
|
|
55
|
+
inFlight = (async () => {
|
|
56
|
+
try {
|
|
57
|
+
const version = await Promise.race([lookup, aborted]);
|
|
58
|
+
if (owner !== generation)
|
|
59
|
+
return getUpdateCheckSnapshot();
|
|
60
|
+
latestVersion = version;
|
|
61
|
+
lastSuccessAt = new Date().toISOString();
|
|
62
|
+
checkStatus = 'success';
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
if (owner !== generation)
|
|
66
|
+
return getUpdateCheckSnapshot();
|
|
67
|
+
checkStatus = 'failed';
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
if (owner === generation) {
|
|
72
|
+
inFlight = null;
|
|
73
|
+
controller = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const snapshot = getUpdateCheckSnapshot();
|
|
77
|
+
broadcastAll('kobo:update-checked', snapshot);
|
|
78
|
+
return snapshot;
|
|
79
|
+
})();
|
|
80
|
+
return inFlight;
|
|
81
|
+
}
|
|
82
|
+
function scheduleNextCheck(owner) {
|
|
83
|
+
if (!running || owner !== generation)
|
|
84
|
+
return;
|
|
85
|
+
const delay = lastAttempt === null ? 0 : Math.max(0, UPDATE_CHECK_INTERVAL_MS - (Date.now() - lastAttempt));
|
|
86
|
+
timer = setTimeout(() => {
|
|
87
|
+
timer = null;
|
|
88
|
+
void refreshUpdateCheck().finally(() => scheduleNextCheck(owner));
|
|
89
|
+
}, delay);
|
|
90
|
+
timer.unref?.();
|
|
91
|
+
}
|
|
92
|
+
export function startUpdateChecker() {
|
|
93
|
+
if (running)
|
|
94
|
+
return;
|
|
95
|
+
running = true;
|
|
96
|
+
stopped = false;
|
|
97
|
+
const owner = generation;
|
|
98
|
+
void refreshUpdateCheck().finally(() => scheduleNextCheck(owner));
|
|
99
|
+
}
|
|
100
|
+
export function stopUpdateChecker() {
|
|
101
|
+
running = false;
|
|
102
|
+
if (timer)
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
timer = null;
|
|
105
|
+
stopped = true;
|
|
106
|
+
generation++;
|
|
107
|
+
controller?.abort();
|
|
108
|
+
controller = null;
|
|
109
|
+
inFlight = null;
|
|
110
|
+
}
|
|
111
|
+
/** @internal test-only — clear the scheduler and registry cache. */
|
|
112
|
+
export function _clearLatestVersionCache() {
|
|
113
|
+
stopUpdateChecker();
|
|
114
|
+
stopped = false;
|
|
115
|
+
latestVersion = null;
|
|
116
|
+
lastAttempt = null;
|
|
117
|
+
lastSuccessAt = null;
|
|
118
|
+
checkStatus = 'unknown';
|
|
119
|
+
}
|
|
@@ -5,10 +5,69 @@ import { getAllPersistedSnapshots } from './usage/db.js';
|
|
|
5
5
|
// ── State ──────────────────────────────────────────────────────────────────────
|
|
6
6
|
/** Maps each WS client to the set of workspaceIds they are subscribed to */
|
|
7
7
|
const clients = new Map();
|
|
8
|
-
/** Above this many bytes queued for a single client, we
|
|
9
|
-
*
|
|
8
|
+
/** Above this many bytes queued for a single client, we terminate the connection
|
|
9
|
+
* before a later event can advance its cursor past an undelivered frame. It will catch up through
|
|
10
10
|
* `sync:request` on its next reconnect. */
|
|
11
11
|
const MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
12
|
+
/** A dropped frame must end the connection so its replay cursor cannot skip it. */
|
|
13
|
+
function sendFrame(ws, message) {
|
|
14
|
+
if (ws.readyState !== 1)
|
|
15
|
+
return;
|
|
16
|
+
if (ws.bufferedAmount > MAX_BUFFERED_BYTES) {
|
|
17
|
+
clients.delete(ws);
|
|
18
|
+
ws.terminate();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
ws.send(message);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
clients.delete(ws);
|
|
26
|
+
ws.terminate();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function isRecord(value) {
|
|
30
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
function validMessage(value) {
|
|
33
|
+
if (!isRecord(value) || typeof value.type !== 'string')
|
|
34
|
+
return false;
|
|
35
|
+
const p = value.payload;
|
|
36
|
+
if (!isRecord(p))
|
|
37
|
+
return false;
|
|
38
|
+
if (value.type === 'sync:request') {
|
|
39
|
+
return ((p.lastEventId === undefined || typeof p.lastEventId === 'string') &&
|
|
40
|
+
(p.workspaceIds === undefined ||
|
|
41
|
+
(Array.isArray(p.workspaceIds) && p.workspaceIds.every((id) => typeof id === 'string' && id.length > 0))));
|
|
42
|
+
}
|
|
43
|
+
if (![
|
|
44
|
+
'subscribe',
|
|
45
|
+
'unsubscribe',
|
|
46
|
+
'chat:message',
|
|
47
|
+
'workspace:start',
|
|
48
|
+
'workspace:stop',
|
|
49
|
+
'devserver:start',
|
|
50
|
+
'devserver:stop',
|
|
51
|
+
].includes(value.type))
|
|
52
|
+
return true;
|
|
53
|
+
if (typeof p.workspaceId !== 'string' || !p.workspaceId)
|
|
54
|
+
return false;
|
|
55
|
+
if (p.sessionId !== undefined && typeof p.sessionId !== 'string')
|
|
56
|
+
return false;
|
|
57
|
+
if (value.type === 'workspace:start' && p.prompt !== undefined && typeof p.prompt !== 'string')
|
|
58
|
+
return false;
|
|
59
|
+
if (value.type === 'chat:message') {
|
|
60
|
+
return (typeof p.content === 'string' &&
|
|
61
|
+
p.content.length > 0 &&
|
|
62
|
+
(p.sessionId === undefined || typeof p.sessionId === 'string') &&
|
|
63
|
+
(p.clientMessageId === undefined || (typeof p.clientMessageId === 'string' && p.clientMessageId.length > 0)) &&
|
|
64
|
+
(p.force === undefined || typeof p.force === 'boolean') &&
|
|
65
|
+
(p.agentPermissionModeOverride === undefined ||
|
|
66
|
+
(typeof p.agentPermissionModeOverride === 'string' &&
|
|
67
|
+
['plan', 'bypass', 'strict', 'interactive'].includes(p.agentPermissionModeOverride))));
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
12
71
|
let messageHandler = null;
|
|
13
72
|
/** Register the handler that processes routed WS messages (e.g. chat:message, workspace:start). */
|
|
14
73
|
export function setMessageHandler(handler) {
|
|
@@ -23,7 +82,7 @@ export function handleConnection(ws) {
|
|
|
23
82
|
// immediately instead of waiting up to POLL_INTERVAL_MS for the next tick.
|
|
24
83
|
try {
|
|
25
84
|
for (const snap of getAllPersistedSnapshots()) {
|
|
26
|
-
ws
|
|
85
|
+
sendFrame(ws, JSON.stringify({
|
|
27
86
|
type: 'usage:snapshot',
|
|
28
87
|
payload: { providerId: snap.providerId, snapshot: snap },
|
|
29
88
|
}));
|
|
@@ -35,10 +94,18 @@ export function handleConnection(ws) {
|
|
|
35
94
|
ws.on('message', (data) => {
|
|
36
95
|
let msg;
|
|
37
96
|
try {
|
|
38
|
-
|
|
97
|
+
const parsed = JSON.parse(data.toString());
|
|
98
|
+
if (!validMessage(parsed)) {
|
|
99
|
+
sendFrame(ws, JSON.stringify({
|
|
100
|
+
type: 'error',
|
|
101
|
+
payload: { message: 'Invalid message payload (check workspaceId and field types)' },
|
|
102
|
+
}));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
msg = parsed;
|
|
39
106
|
}
|
|
40
107
|
catch {
|
|
41
|
-
ws
|
|
108
|
+
sendFrame(ws, JSON.stringify({ type: 'error', payload: { message: 'Invalid JSON' } }));
|
|
42
109
|
return;
|
|
43
110
|
}
|
|
44
111
|
const { type, payload } = msg;
|
|
@@ -46,23 +113,23 @@ export function handleConnection(ws) {
|
|
|
46
113
|
case 'subscribe': {
|
|
47
114
|
const workspaceId = payload?.workspaceId;
|
|
48
115
|
if (!workspaceId) {
|
|
49
|
-
ws
|
|
116
|
+
sendFrame(ws, JSON.stringify({ type: 'error', payload: { message: 'Missing workspaceId' } }));
|
|
50
117
|
return;
|
|
51
118
|
}
|
|
52
119
|
const subs = clients.get(ws);
|
|
53
120
|
subs?.add(workspaceId);
|
|
54
|
-
ws
|
|
121
|
+
sendFrame(ws, JSON.stringify({ type: 'subscribed', payload: { workspaceId } }));
|
|
55
122
|
break;
|
|
56
123
|
}
|
|
57
124
|
case 'unsubscribe': {
|
|
58
125
|
const workspaceId = payload?.workspaceId;
|
|
59
126
|
if (!workspaceId) {
|
|
60
|
-
ws
|
|
127
|
+
sendFrame(ws, JSON.stringify({ type: 'error', payload: { message: 'Missing workspaceId' } }));
|
|
61
128
|
return;
|
|
62
129
|
}
|
|
63
130
|
const subs = clients.get(ws);
|
|
64
131
|
subs?.delete(workspaceId);
|
|
65
|
-
ws
|
|
132
|
+
sendFrame(ws, JSON.stringify({ type: 'unsubscribed', payload: { workspaceId } }));
|
|
66
133
|
break;
|
|
67
134
|
}
|
|
68
135
|
case 'sync:request': {
|
|
@@ -89,13 +156,26 @@ export function handleConnection(ws) {
|
|
|
89
156
|
// Every other branch of this switch answers on failure; a routed
|
|
90
157
|
// message that vanishes silently leaves the user waiting forever.
|
|
91
158
|
const message = err instanceof Error ? err.message : String(err);
|
|
92
|
-
|
|
159
|
+
if (type === 'chat:message' && isRecord(payload) && typeof payload.clientMessageId === 'string') {
|
|
160
|
+
sendFrame(ws, JSON.stringify({
|
|
161
|
+
type: 'chat:rejected',
|
|
162
|
+
workspaceId: payload.workspaceId,
|
|
163
|
+
payload: {
|
|
164
|
+
clientMessageId: payload.clientMessageId,
|
|
165
|
+
sessionId: payload.sessionId,
|
|
166
|
+
content: payload.content,
|
|
167
|
+
message,
|
|
168
|
+
},
|
|
169
|
+
}));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
sendFrame(ws, JSON.stringify({ type: 'error', payload: { message: `Failed to handle '${type}': ${message}` } }));
|
|
93
173
|
});
|
|
94
174
|
}
|
|
95
175
|
break;
|
|
96
176
|
}
|
|
97
177
|
default:
|
|
98
|
-
ws
|
|
178
|
+
sendFrame(ws, JSON.stringify({ type: 'error', payload: { message: `Unknown message type: ${type}` } }));
|
|
99
179
|
}
|
|
100
180
|
});
|
|
101
181
|
// Heartbeat with a verdict. Pinging without ever waiting for a pong detected
|
|
@@ -154,7 +234,7 @@ function insertEventStatement() {
|
|
|
154
234
|
}
|
|
155
235
|
return cachedInsert.statement;
|
|
156
236
|
}
|
|
157
|
-
export function emit(workspaceId, type, payload, sessionId) {
|
|
237
|
+
export function emit(workspaceId, type, payload, sessionId, options) {
|
|
158
238
|
recordActivity(workspaceId, type, payload, sessionId);
|
|
159
239
|
const id = nanoid();
|
|
160
240
|
const createdAt = new Date().toISOString();
|
|
@@ -165,35 +245,48 @@ export function emit(workspaceId, type, payload, sessionId) {
|
|
|
165
245
|
replayable = true;
|
|
166
246
|
}
|
|
167
247
|
catch (err) {
|
|
248
|
+
if (options?.requirePersistence)
|
|
249
|
+
throw err;
|
|
168
250
|
console.error(`[websocket-service] Failed to persist event (workspace=${workspaceId}, type=${type}):`, err);
|
|
169
251
|
}
|
|
170
252
|
// Build the event object to send
|
|
171
253
|
const event = { id, workspaceId, type, payload, sessionId, createdAt, replayable };
|
|
254
|
+
broadcastPersistedEvent(event);
|
|
255
|
+
return id;
|
|
256
|
+
}
|
|
257
|
+
/** Insert durably within the caller's transaction; broadcast only after commit. */
|
|
258
|
+
export function persistWorkspaceEvent(workspaceId, type, payload, sessionId) {
|
|
259
|
+
const event = {
|
|
260
|
+
id: nanoid(),
|
|
261
|
+
workspaceId,
|
|
262
|
+
type,
|
|
263
|
+
payload,
|
|
264
|
+
sessionId,
|
|
265
|
+
createdAt: new Date().toISOString(),
|
|
266
|
+
replayable: true,
|
|
267
|
+
};
|
|
268
|
+
insertEventStatement().run(event.id, workspaceId, type, JSON.stringify(payload), sessionId ?? null, event.createdAt);
|
|
269
|
+
recordActivity(workspaceId, type, payload, sessionId);
|
|
270
|
+
return event;
|
|
271
|
+
}
|
|
272
|
+
export function broadcastPersistedEvent(event) {
|
|
172
273
|
const message = JSON.stringify(event);
|
|
173
274
|
// Broadcast to subscribed clients. Wrap `.send` in try/catch so a dropped
|
|
174
275
|
// client doesn't throw and abort delivery to the remaining subscribers.
|
|
175
276
|
let emitSendErrorLogged = false;
|
|
176
277
|
for (const [ws, subs] of clients) {
|
|
177
|
-
if (subs.has(workspaceId) && ws.readyState === 1 /* WebSocket.OPEN */) {
|
|
178
|
-
if ((ws.bufferedAmount ?? 0) > MAX_BUFFERED_BYTES) {
|
|
179
|
-
if (!emitSendErrorLogged) {
|
|
180
|
-
console.warn(`[ws] client backlogged, dropping live events (workspace=${workspaceId}, type=${type})`);
|
|
181
|
-
emitSendErrorLogged = true;
|
|
182
|
-
}
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
278
|
+
if (subs.has(event.workspaceId) && ws.readyState === 1 /* WebSocket.OPEN */) {
|
|
185
279
|
try {
|
|
186
|
-
ws
|
|
280
|
+
sendFrame(ws, message);
|
|
187
281
|
}
|
|
188
282
|
catch (err) {
|
|
189
283
|
if (!emitSendErrorLogged) {
|
|
190
|
-
console.warn(`[ws] emit send failed (workspace=${workspaceId}, type=${type}):`, err);
|
|
284
|
+
console.warn(`[ws] emit send failed (workspace=${event.workspaceId}, type=${event.type}):`, err);
|
|
191
285
|
emitSendErrorLogged = true;
|
|
192
286
|
}
|
|
193
287
|
}
|
|
194
288
|
}
|
|
195
289
|
}
|
|
196
|
-
return id;
|
|
197
290
|
}
|
|
198
291
|
/**
|
|
199
292
|
* Broadcast an event to subscribed clients WITHOUT persisting to the database.
|
|
@@ -216,15 +309,8 @@ export function emitEphemeral(workspaceId, type, payload, sessionId) {
|
|
|
216
309
|
let sendErrorLogged = false;
|
|
217
310
|
for (const [ws, subs] of clients) {
|
|
218
311
|
if (subs.has(workspaceId) && ws.readyState === 1 /* WebSocket.OPEN */) {
|
|
219
|
-
if ((ws.bufferedAmount ?? 0) > MAX_BUFFERED_BYTES) {
|
|
220
|
-
if (!sendErrorLogged) {
|
|
221
|
-
console.warn(`[ws] client backlogged, dropping ephemeral event (workspace=${workspaceId}, type=${type})`);
|
|
222
|
-
sendErrorLogged = true;
|
|
223
|
-
}
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
226
312
|
try {
|
|
227
|
-
ws
|
|
313
|
+
sendFrame(ws, message);
|
|
228
314
|
}
|
|
229
315
|
catch (err) {
|
|
230
316
|
if (!sendErrorLogged) {
|
|
@@ -248,7 +334,7 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
|
|
|
248
334
|
return subs ? [...subs] : [];
|
|
249
335
|
})();
|
|
250
336
|
if (resolvedIds.length === 0) {
|
|
251
|
-
ws
|
|
337
|
+
sendFrame(ws, JSON.stringify({ type: 'sync:empty', payload: { message: 'No subscriptions' } }));
|
|
252
338
|
return;
|
|
253
339
|
}
|
|
254
340
|
// Initial window size: on a fresh connection (no lastEventId), we only
|
|
@@ -329,14 +415,14 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
|
|
|
329
415
|
replayable: true,
|
|
330
416
|
};
|
|
331
417
|
});
|
|
332
|
-
ws
|
|
418
|
+
sendFrame(ws, JSON.stringify({ type: 'sync:response', payload: { events, mode, truncated } }));
|
|
333
419
|
}
|
|
334
420
|
catch (err) {
|
|
335
421
|
// This handler runs inside ws's 'message' callback: an uncaught SQLite
|
|
336
422
|
// error here used to reach the process, not the client.
|
|
337
423
|
console.error('[ws] sync request failed:', err);
|
|
338
424
|
try {
|
|
339
|
-
ws
|
|
425
|
+
sendFrame(ws, JSON.stringify({
|
|
340
426
|
type: 'sync:error',
|
|
341
427
|
payload: { message: err instanceof Error ? err.message : String(err) },
|
|
342
428
|
}));
|
|
@@ -372,7 +458,7 @@ export function broadcastAll(type, payload) {
|
|
|
372
458
|
for (const client of clients.keys()) {
|
|
373
459
|
if (client.readyState === 1 /* WebSocket.OPEN */) {
|
|
374
460
|
try {
|
|
375
|
-
client
|
|
461
|
+
sendFrame(client, message);
|
|
376
462
|
}
|
|
377
463
|
catch (err) {
|
|
378
464
|
// client dropped; next iteration will fail its .readyState check.
|