@loicngr/kobo 1.11.39 → 1.11.41
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 +101 -32
- package/CHANGELOG.md +8 -0
- package/README.md +6 -4
- 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/images.js +65 -5
- 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 +250 -248
- 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/attachment-service.js +122 -0
- 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/image-service.js +16 -7
- 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/attachments.js +58 -0
- 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-DTIe6O9o.js +8 -0
- package/src/client/dist/pwa/assets/{ChangelogPage-D8563008.js → ChangelogPage-BWeu0YeI.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-Bd0X08sg.js → ComparisonPanel-o_EHlTwV.js} +2 -2
- package/src/client/dist/pwa/assets/CreatePage-CGi88k6z.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-DbY5gBv8.js +2 -0
- package/src/client/dist/pwa/assets/DashboardPage-CIo_gesD.js +1 -0
- package/src/client/dist/pwa/assets/{DiffViewer-DnR79ycb.css → DiffViewer-DEPOa3D7.css} +1 -1
- package/src/client/dist/pwa/assets/DiffViewer-DEcf48KI.js +8 -0
- package/src/client/dist/pwa/assets/{DocumentsPanel-BJsJ0fNs.js → DocumentsPanel-D9vcB91W.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-BVLO5K5d.js → DrawerToggleButton-Ca0_CxSl.js} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-DCmB2R5i.js +3 -0
- package/src/client/dist/pwa/assets/{HealthPage-RFZS7Lao.js → HealthPage-_q3vy_xn.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-Duwnq3cc.js +2 -0
- package/src/client/dist/pwa/assets/{QBtnDropdown-X8oNU5Mb.js → QBtnDropdown-BeiZIHVA.js} +1 -1
- package/src/client/dist/pwa/assets/{QDrawer-Cij17skX.js → QDrawer-Fj3M9QGD.js} +1 -1
- package/src/client/dist/pwa/assets/QSelect-DtSUuK8t.js +1 -0
- package/src/client/dist/pwa/assets/{QTooltip-CwcCGSWq.js → QTooltip-DwkX8eQ6.js} +1 -1
- package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-BlZDd7TK.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-BGc1oQSM.css +1 -0
- package/src/client/dist/pwa/assets/SearchPage-BGi1yRxU.js +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-DnO-ImWE.js +9 -0
- package/src/client/dist/pwa/assets/SettingsPage-sVlsJQ0b.css +1 -0
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-D7lGFRKs.js → SplitWorkspacePage-vz6jN1DM.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-DI8DHb9e.js → TerminalPanel-IdPIGp2_.js} +9 -9
- package/src/client/dist/pwa/assets/{TouchPan-B3nrkCY8.js → TouchPan-DTJqxVp9.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-DU1UShMY.js → TourReplayButton-Bj2GGyhU.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-C-yN5IX8.js → WorkspaceAttentionLabels-BBaGZlwv.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-Dy0spsfi.js +4 -0
- package/src/client/dist/pwa/assets/{browser-f9MHQjfi.js → browser-BFj5wwbS.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-BWBzP8os.js → build-path-tree-C-HSiySk.js} +1 -1
- package/src/client/dist/pwa/assets/de-BdLe7apJ.js +17 -0
- package/src/client/dist/pwa/assets/{documents-C2cLnEBQ.js → documents-C2Xnjh9Z.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-sw146MWN.js → editor.api-BbKfnNmv.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-C5ZZgCH4.js → efforts-BM7fdaaL.js} +1 -1
- package/src/client/dist/pwa/assets/engineFeatures-b74YRODT.js +1 -0
- package/src/client/dist/pwa/assets/es-C1LM-3aq.js +17 -0
- package/src/client/dist/pwa/assets/expand-template-DiDWiIkx.js +1 -0
- package/src/client/dist/pwa/assets/{format-BwWP6Aay.js → format-CnbUxAmw.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-DyWtO-nR.js → formatters-B2uUIbAY.js} +1 -1
- package/src/client/dist/pwa/assets/fr-KCK6r5ga.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-DZ5Hm8UH.js → freemarker2-Cu1vGY0x.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-BRUGpTA7.js → handlebars-CETuqLyz.js} +1 -1
- package/src/client/dist/pwa/assets/{html-DNOayNNs.js → html-BTNpr39P.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-BkUirOzl.js +1 -0
- package/src/client/dist/pwa/assets/index-mvh5w1cM.js +20 -0
- package/src/client/dist/pwa/assets/it-BM6t0IGU.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-C0tDQU3z.js → javascript-C7Ncb6V7.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-CN46O6OF.js → liquid-D0hUdg4x.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-DM-lrr8Q.js → mdx-BK5H1Xst.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-CkN7y-8c.js → monaco-contributions-BBW6JNgz.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-2BcAOLjc.js → monaco.contribution-1LZrAlY4.js} +2 -2
- package/src/client/dist/pwa/assets/network-auth-CVGS__6a.js +1 -0
- package/src/client/dist/pwa/assets/{notify-theme-a91Tcr6o.js → notify-theme-DIKLxr8F.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-DOtELZfB.js → pending-diff-open-B02zuH1j.js} +1 -1
- package/src/client/dist/pwa/assets/{permissionModes-C-9Crj-c.js → permissionModes-C3otN1Vk.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-BRe4oyNx.js → project-color-BUcW_eZA.js} +1 -1
- package/src/client/dist/pwa/assets/{python-BRpJG6sZ.js → python-DEeUVvnp.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-CehCmvHl.js → razor-CG6cL8ya.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-DlbQLZ02.js → render-chat-markdown-Ct_T5TPN.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-Dt_8mVuj.js → typescript-CSQAg5UC.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-_6HwJ_Rp.js +2 -0
- package/src/client/dist/pwa/assets/{workspace-templates-GCObcjO6.js → workspace-templates-B8WWsWEI.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-_gOwdzB4.js → xml-BSB4-DXR.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-BxeBqpGf.js → yaml-CSGKph85.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 +169 -9
- 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-A3pVradx.css +0 -1
- 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-BAC6u9OP.css +0 -1
- 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/expand-template-B6xAlAaM.js +0 -1
- 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
|
@@ -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.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
import { validateDialogueArguments } from '../../shared/workspace-dialogue-tools.js';
|
|
3
|
+
import { parseMessageSource } from '../../shared/workspace-message-types.js';
|
|
4
|
+
import { getDb } from '../db/index.js';
|
|
5
|
+
import { assertWorkspaceLifecycleAvailable } from '../utils/workspace-lifecycle-guard.js';
|
|
6
|
+
import { answerPendingQuestion, getPendingInputs } from './agent/orchestrator.js';
|
|
7
|
+
import { createMcpClientContext } from './mcp-client-context.js';
|
|
8
|
+
import { deliverKeyedMcpMessage } from './mcp-message-delivery-service.js';
|
|
9
|
+
import { deliverWorkspaceMessage } from './workspace-message-service.js';
|
|
10
|
+
import { getActiveSession, getWorkspace, listSessions, listTasks, listWorkspaces } from './workspace-service.js';
|
|
11
|
+
function readableMessage(row) {
|
|
12
|
+
let payload;
|
|
13
|
+
try {
|
|
14
|
+
payload = JSON.parse(row.payload);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
if (!payload || typeof payload !== 'object')
|
|
20
|
+
return [];
|
|
21
|
+
let text;
|
|
22
|
+
if (row.type === 'user:message' && typeof payload.content === 'string')
|
|
23
|
+
text = payload.content;
|
|
24
|
+
if (row.type === 'agent:event' && payload.kind === 'message:text' && typeof payload.text === 'string')
|
|
25
|
+
text = payload.text;
|
|
26
|
+
if (row.type === 'agent:output') {
|
|
27
|
+
const content = payload.message?.content;
|
|
28
|
+
if (Array.isArray(content))
|
|
29
|
+
text = content
|
|
30
|
+
.flatMap((block) => (block?.type === 'text' && typeof block.text === 'string' ? [block.text] : []))
|
|
31
|
+
.join('\n');
|
|
32
|
+
}
|
|
33
|
+
return text === undefined
|
|
34
|
+
? []
|
|
35
|
+
: [
|
|
36
|
+
{
|
|
37
|
+
id: row.id,
|
|
38
|
+
sessionId: row.session_id,
|
|
39
|
+
role: row.type === 'user:message' ? 'user' : 'assistant',
|
|
40
|
+
text,
|
|
41
|
+
messageId: typeof payload.messageId === 'string' ? payload.messageId : row.id,
|
|
42
|
+
createdAt: row.created_at,
|
|
43
|
+
...(parseMessageSource(payload.source) ? { source: parseMessageSource(payload.source) } : {}),
|
|
44
|
+
...(typeof payload.clientMessageId === 'string' ? { clientMessageId: payload.clientMessageId } : {}),
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
export async function executeWorkspaceDialogueTool(name, input, source = createMcpClientContext()) {
|
|
49
|
+
const args = validateDialogueArguments(name, input);
|
|
50
|
+
if (name === 'list_workspaces')
|
|
51
|
+
return listWorkspaces(args.include_archived === true).map((ws) => ({
|
|
52
|
+
id: ws.id,
|
|
53
|
+
name: ws.name,
|
|
54
|
+
status: ws.status,
|
|
55
|
+
projectPath: ws.projectPath,
|
|
56
|
+
archivedAt: ws.archivedAt,
|
|
57
|
+
}));
|
|
58
|
+
const workspaceId = args.workspace_id;
|
|
59
|
+
const workspace = getWorkspace(workspaceId);
|
|
60
|
+
if (!workspace)
|
|
61
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
62
|
+
if (name === 'get_workspace')
|
|
63
|
+
return { workspace, tasks: listTasks(workspaceId), activeSession: getActiveSession(workspaceId) };
|
|
64
|
+
if (name === 'list_workspace_sessions')
|
|
65
|
+
return listSessions(workspaceId);
|
|
66
|
+
if (name === 'get_workspace_questions') {
|
|
67
|
+
const pending = getPendingInputs(workspaceId);
|
|
68
|
+
return {
|
|
69
|
+
questions: pending.filter((item) => item.kind === 'question'),
|
|
70
|
+
requiresHumanApproval: pending[0]?.kind === 'permission',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (name === 'answer_workspace_question') {
|
|
74
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
75
|
+
if (workspace.archivedAt || workspace.worktreePurgedAt)
|
|
76
|
+
throw new Error('Restore the workspace before answering a question');
|
|
77
|
+
const head = getPendingInputs(workspaceId)[0];
|
|
78
|
+
if (head?.kind !== 'question' || head.toolCallId !== args.tool_call_id)
|
|
79
|
+
throw new Error('The requested question is not the current pending question');
|
|
80
|
+
await answerPendingQuestion(workspaceId, args.answers, args.tool_call_id, {
|
|
81
|
+
source,
|
|
82
|
+
});
|
|
83
|
+
return { accepted: true };
|
|
84
|
+
}
|
|
85
|
+
if (name === 'send_workspace_message' && args.idempotency_key)
|
|
86
|
+
return deliverKeyedMcpMessage(workspaceId, args.idempotency_key, {
|
|
87
|
+
content: args.content,
|
|
88
|
+
sessionId: args.session_id,
|
|
89
|
+
source,
|
|
90
|
+
});
|
|
91
|
+
if (name === 'send_workspace_message')
|
|
92
|
+
return {
|
|
93
|
+
accepted: true,
|
|
94
|
+
...(await deliverWorkspaceMessage(workspaceId, {
|
|
95
|
+
content: args.content,
|
|
96
|
+
sessionId: args.session_id,
|
|
97
|
+
clientMessageId: `mcp-${nanoid()}`,
|
|
98
|
+
source,
|
|
99
|
+
})),
|
|
100
|
+
};
|
|
101
|
+
const db = getDb();
|
|
102
|
+
let after = 0;
|
|
103
|
+
if (args.after_cursor) {
|
|
104
|
+
const cursor = db
|
|
105
|
+
.prepare('SELECT rowid FROM ws_events WHERE id = ? AND workspace_id = ?')
|
|
106
|
+
.get(args.after_cursor, workspaceId);
|
|
107
|
+
if (!cursor)
|
|
108
|
+
throw new Error('Unknown or expired history cursor for this workspace; restart reading without after_cursor');
|
|
109
|
+
after = cursor.rowid;
|
|
110
|
+
}
|
|
111
|
+
const limit = args.limit ?? 100;
|
|
112
|
+
const params = [workspaceId, after];
|
|
113
|
+
if (args.session_id)
|
|
114
|
+
params.push(args.session_id);
|
|
115
|
+
params.push(limit + 1);
|
|
116
|
+
const rows = db
|
|
117
|
+
.prepare(`SELECT id,session_id,type,payload,created_at FROM ws_events WHERE workspace_id = ? AND rowid > ?
|
|
118
|
+
${args.session_id ? 'AND (session_id = ? OR session_id IS NULL)' : ''} ORDER BY rowid LIMIT ?`)
|
|
119
|
+
.all(...params);
|
|
120
|
+
const page = rows.slice(0, limit);
|
|
121
|
+
return {
|
|
122
|
+
messages: page.flatMap(readableMessage),
|
|
123
|
+
nextCursor: page.at(-1)?.id ?? args.after_cursor ?? null,
|
|
124
|
+
hasMore: rows.length > limit,
|
|
125
|
+
workspaceStatus: workspace.status,
|
|
126
|
+
activeSessionId: getActiveSession(workspaceId)?.id ?? null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { assertWorkspaceLifecycleAvailable } from '../utils/workspace-lifecycle-guard.js';
|
|
2
|
+
import { isAgentUnavailableError, isShuttingDown, sendMessage, startAgent } from './agent/orchestrator.js';
|
|
3
|
+
import * as autoLoop from './auto-loop-service.js';
|
|
4
|
+
import { createChatReceipt } from './chat-receipt-service.js';
|
|
5
|
+
import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './workspace-service.js';
|
|
6
|
+
/** Shared delivery for WebSocket chat and external MCP clients. */
|
|
7
|
+
export async function deliverWorkspaceMessage(workspaceId, message, hooks) {
|
|
8
|
+
const receipt = createChatReceipt(workspaceId, message, hooks?.persist);
|
|
9
|
+
let reason;
|
|
10
|
+
try {
|
|
11
|
+
if (!message.content.trim())
|
|
12
|
+
throw new Error('Message content must not be empty');
|
|
13
|
+
if (isShuttingDown())
|
|
14
|
+
throw new Error('Kōbō is shutting down');
|
|
15
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
16
|
+
const workspace = getWorkspace(workspaceId);
|
|
17
|
+
if (!workspace)
|
|
18
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
19
|
+
if (workspace.archivedAt || workspace.worktreePurgedAt)
|
|
20
|
+
throw new Error('Restore the workspace before sending a message');
|
|
21
|
+
if (workspace.status === 'compacting') {
|
|
22
|
+
reason = 'compacting';
|
|
23
|
+
throw new Error('Workspace is compacting its context; wait until compaction finishes before sending a message');
|
|
24
|
+
}
|
|
25
|
+
if (workspace.status === 'awaiting-user') {
|
|
26
|
+
reason = 'awaiting-user';
|
|
27
|
+
throw new Error('Answer the pending question or permission request before sending a chat message');
|
|
28
|
+
}
|
|
29
|
+
const loop = autoLoop.getStatus(workspaceId);
|
|
30
|
+
if (loop.auto_loop && loop.auto_loop_ready)
|
|
31
|
+
autoLoop.disable(workspaceId, 'user-action');
|
|
32
|
+
let sessionId = message.sessionId ?? getActiveSession(workspaceId)?.id;
|
|
33
|
+
try {
|
|
34
|
+
if (hooks?.beforeDispatch)
|
|
35
|
+
await sendMessage(workspaceId, message.content, message.sessionId, hooks.beforeDispatch);
|
|
36
|
+
else
|
|
37
|
+
await sendMessage(workspaceId, message.content, message.sessionId);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (!isAgentUnavailableError(error instanceof Error ? error.message : String(error)))
|
|
41
|
+
throw error;
|
|
42
|
+
// Re-read after asynchronous delivery: metadata may have changed meanwhile.
|
|
43
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
44
|
+
const current = getWorkspace(workspaceId);
|
|
45
|
+
if (!current || current.archivedAt || current.worktreePurgedAt)
|
|
46
|
+
throw new Error('Workspace is unavailable');
|
|
47
|
+
const started = startAgent(workspaceId, current.worktreePath, message.content, current.model, true, message.agentPermissionModeOverride ?? current.agentPermissionMode, message.sessionId, current.reasoningEffort, ...(hooks?.beforeDispatch ? [hooks.beforeDispatch] : []));
|
|
48
|
+
updateWorkspaceStatus(workspaceId, 'executing');
|
|
49
|
+
sessionId = message.sessionId ?? started.agentSessionId;
|
|
50
|
+
}
|
|
51
|
+
receipt.accept(sessionId);
|
|
52
|
+
return { sessionId };
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
receipt.reject(error instanceof Error ? error.message : String(error), reason);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -27,8 +27,33 @@ function stable(value) {
|
|
|
27
27
|
export function permissionFingerprint(payload) {
|
|
28
28
|
return createHash('sha256').update(stable(payload)).digest('hex');
|
|
29
29
|
}
|
|
30
|
+
const CODEX_EDIT_FINGERPRINT_PREFIX = 'codex-edit-v2:';
|
|
31
|
+
function operationFingerprint(request) {
|
|
32
|
+
if (request.engine !== 'codex' || request.toolName !== 'Edit')
|
|
33
|
+
return permissionFingerprint(request.payload);
|
|
34
|
+
const payload = request.payload;
|
|
35
|
+
if (payload?.operationApprovalAvailable !== true || !Array.isArray(payload.changes) || payload.changes.length === 0)
|
|
36
|
+
return null;
|
|
37
|
+
if (typeof payload.cwd !== 'string' ||
|
|
38
|
+
payload.changes.some((change) => !change ||
|
|
39
|
+
typeof change.path !== 'string' ||
|
|
40
|
+
typeof change.diff !== 'string' ||
|
|
41
|
+
!change.kind ||
|
|
42
|
+
!['add', 'delete', 'update'].includes(change.kind.type)))
|
|
43
|
+
return null;
|
|
44
|
+
return (CODEX_EDIT_FINGERPRINT_PREFIX +
|
|
45
|
+
permissionFingerprint({
|
|
46
|
+
changes: payload.changes,
|
|
47
|
+
cwd: payload.cwd,
|
|
48
|
+
grantRoot: payload.grantRoot ?? null,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
30
51
|
function toRule(row) {
|
|
31
52
|
return {
|
|
53
|
+
active: !(row.scope === 'operation' &&
|
|
54
|
+
row.tool_name === 'Edit' &&
|
|
55
|
+
(row.engine ?? row.workspace_engine) === 'codex' &&
|
|
56
|
+
!String(row.fingerprint ?? '').startsWith(CODEX_EDIT_FINGERPRINT_PREFIX)),
|
|
32
57
|
id: String(row.id),
|
|
33
58
|
workspaceId: String(row.workspace_id),
|
|
34
59
|
engine: row.engine,
|
|
@@ -42,13 +67,17 @@ function toRule(row) {
|
|
|
42
67
|
}
|
|
43
68
|
export function listWorkspacePermissionRules(workspaceId) {
|
|
44
69
|
return getDb()
|
|
45
|
-
.prepare(
|
|
70
|
+
.prepare(`SELECT rules.*, workspaces.engine AS workspace_engine
|
|
71
|
+
FROM workspace_permission_rules AS rules JOIN workspaces ON workspaces.id=rules.workspace_id
|
|
72
|
+
WHERE workspace_id=? ORDER BY rules.created_at DESC`)
|
|
46
73
|
.all(workspaceId).map(toRule);
|
|
47
74
|
}
|
|
48
75
|
export function createWorkspacePermissionRule(workspaceId, request, scope) {
|
|
49
76
|
const now = new Date().toISOString();
|
|
50
77
|
const id = nanoid();
|
|
51
|
-
const fingerprint = scope === 'operation' ?
|
|
78
|
+
const fingerprint = scope === 'operation' ? operationFingerprint(request) : null;
|
|
79
|
+
if (scope === 'operation' && fingerprint === null)
|
|
80
|
+
throw new Error('Exact operation details are unavailable');
|
|
52
81
|
const displayLabel = scope === 'tool' ? request.toolName : `${request.toolName} — opération précise`;
|
|
53
82
|
getDb()
|
|
54
83
|
.prepare('INSERT INTO workspace_permission_rules (id,workspace_id,engine,tool_name,scope,fingerprint,display_label,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)')
|
|
@@ -61,6 +90,7 @@ export function createWorkspacePermissionRule(workspaceId, request, scope) {
|
|
|
61
90
|
scope,
|
|
62
91
|
fingerprint,
|
|
63
92
|
displayLabel,
|
|
93
|
+
active: true,
|
|
64
94
|
createdAt: now,
|
|
65
95
|
updatedAt: now,
|
|
66
96
|
};
|
|
@@ -72,7 +102,7 @@ export function removeWorkspacePermissionRule(workspaceId, ruleId) {
|
|
|
72
102
|
export function isWorkspacePermissionAllowed(workspaceId, request) {
|
|
73
103
|
if (turnRules.get(workspaceId)?.has(turnKey(request)))
|
|
74
104
|
return true;
|
|
75
|
-
const fingerprint =
|
|
105
|
+
const fingerprint = operationFingerprint(request);
|
|
76
106
|
try {
|
|
77
107
|
return Boolean(getDb()
|
|
78
108
|
.prepare(`SELECT 1 FROM workspace_permission_rules WHERE workspace_id=? AND tool_name=? AND (engine IS NULL OR engine=?) AND (scope='tool' OR (scope='operation' AND fingerprint=?)) LIMIT 1`)
|
|
@@ -43,13 +43,13 @@ export async function restorePurgedWorktree(id) {
|
|
|
43
43
|
headCommitSha: savedHead(workspace.worktreePurgeRestoreData),
|
|
44
44
|
};
|
|
45
45
|
if (!workspace.worktreePurgedAt) {
|
|
46
|
-
if (!isMatchingWorkspaceWorktree(input)) {
|
|
46
|
+
if (!(await isMatchingWorkspaceWorktree(input))) {
|
|
47
47
|
throw new WorktreeRestoreError('not-purged', 'Workspace is not marked as purged and its checkout is missing or different.');
|
|
48
48
|
}
|
|
49
49
|
return { workspace, outcome: 'already-restored', source: 'existing-worktree' };
|
|
50
50
|
}
|
|
51
51
|
const checkout = await restoreWorktreeCheckoutUnlocked(input);
|
|
52
|
-
if (!isMatchingWorkspaceWorktree(input)) {
|
|
52
|
+
if (!(await isMatchingWorkspaceWorktree(input))) {
|
|
53
53
|
throw new WorktreeRestoreError('path-conflict', 'The recreated checkout does not match this workspace.');
|
|
54
54
|
}
|
|
55
55
|
// Preserve a valid checkout on DB failure. A retry or manual detection can
|