@loicngr/kobo 2.1.4 → 2.1.5
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/CHANGELOG.md +4 -0
- package/CONFIGURATION.md +11 -3
- package/dist/server/index.js +24 -91
- package/dist/server/routes/git.js +6 -7
- package/dist/server/routes/settings.js +21 -11
- package/dist/server/routes/workspaces.js +43 -41
- package/dist/server/services/agent/integration-mcp.js +4 -3
- package/dist/server/services/agent/orchestrator.js +1 -1
- package/dist/server/services/engine-handoff-service.js +120 -24
- package/dist/server/services/pr-checkout-service.js +90 -53
- package/dist/server/services/pr-watcher-service.js +13 -7
- package/dist/server/services/retention/worker.js +40 -0
- package/dist/server/services/review-service.js +8 -4
- package/dist/server/services/session-event-metrics.js +43 -0
- package/dist/server/services/session-handoff-service.js +6 -4
- package/dist/server/services/templates-service.js +67 -49
- package/dist/server/services/terminal-service.js +45 -14
- package/dist/server/services/terminal-websocket-service.js +143 -0
- package/dist/server/services/wakeup-service.js +23 -21
- package/dist/server/services/workspace-service.js +3 -44
- package/dist/server/services/workspace-template-service.js +16 -18
- package/dist/server/services/worktree-purge-service.js +1 -4
- package/dist/server/services/ws-events-retention-service.js +197 -104
- package/dist/server/services/ws-events-retention-worker-service.js +112 -0
- package/dist/server/utils/atomic-json-file.js +23 -0
- package/dist/server/utils/git-ops.js +262 -60
- package/dist/shared/codex-models.js +20 -3
- package/dist/shared/models.js +12 -0
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/{ActivityFeed-DCmiJP-z.css → ActivityFeed-B4lTKMVE.css} +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-DXWXioW9.js +8 -0
- package/src/client/dist/pwa/assets/ChangelogPage-BL5Jh8-v.js +1 -0
- package/src/client/dist/pwa/assets/ChangelogPage-DVyx9ZjV.css +1 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-Bv2SZrVZ.js +2 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-ByVbqYCs.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-CMyl2j8Z.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-d43ITOC1.js +2 -0
- package/src/client/dist/pwa/assets/DashboardPage-BEXXRguE.js +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-CtaHaVdf.css +1 -0
- package/src/client/dist/pwa/assets/DiffViewer-B92w_jd7.js +8 -0
- package/src/client/dist/pwa/assets/DocumentsPanel-C9JXyqHg.js +1 -0
- package/src/client/dist/pwa/assets/{DrawerToggleButton-Bh4FXdtJ.js → DrawerToggleButton-Btx8QwHo.js} +1 -1
- package/src/client/dist/pwa/assets/{GitPanel-C4wOcZV2.css → GitPanel-C3blk_Pd.css} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-Cgz9crLN.js +3 -0
- package/src/client/dist/pwa/assets/HealthPage-CDhgJQkL.js +1 -0
- package/src/client/dist/pwa/assets/HealthPage-DT8FGrKA.css +1 -0
- package/src/client/dist/pwa/assets/MainLayout-DTTtj9sq.js +2 -0
- package/src/client/dist/pwa/assets/{MainLayout-CNmwCnDD.css → MainLayout-knNNtkre.css} +1 -1
- package/src/client/dist/pwa/assets/QBanner-7EWRdc4U.js +1 -0
- package/src/client/dist/pwa/assets/{QBtnDropdown-DVbxGTNb.js → QBtnDropdown-vjat4GZl.js} +1 -1
- package/src/client/dist/pwa/assets/QChip-CG1kWhFm.js +1 -0
- package/src/client/dist/pwa/assets/QExpansionItem-BQuy89vv.js +1 -0
- package/src/client/dist/pwa/assets/QItemSection-BrVklYfM.js +1 -0
- package/src/client/dist/pwa/assets/QList-BVX-b8nc.js +1 -0
- package/src/client/dist/pwa/assets/QMarkupTable-B8_FKLvo.js +1 -0
- package/src/client/dist/pwa/assets/QSelect-DBW_TZri.js +1 -0
- package/src/client/dist/pwa/assets/{QTooltip-BLhkNT4s.js → QTooltip-DzYbguTR.js} +1 -1
- package/src/client/dist/pwa/assets/SchedulePanel-BX6iKh6S.js +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-CBM_GpS3.css +1 -0
- package/src/client/dist/pwa/assets/SearchPage-_In2gH2P.js +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-BqkNYzC5.js +9 -0
- package/src/client/dist/pwa/assets/{SettingsPage-BYdLXJEJ.css → SettingsPage-CpykRKq2.css} +1 -1
- package/src/client/dist/pwa/assets/SplitWorkspacePage-DRoXbSRP.js +1 -0
- package/src/client/dist/pwa/assets/{TerminalPanel-D2j5AA8S.js → TerminalPanel-CfqiuSuk.js} +2 -2
- package/src/client/dist/pwa/assets/{TerminalPanel-DbVZk0vD.css → TerminalPanel-Dz9EE-Ph.css} +1 -1
- package/src/client/dist/pwa/assets/{TouchPan-C0EqFcrr.js → TouchPan-DcOCRf7x.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-DlMvPblb.js → TourReplayButton-gp7m6tSV.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DSw8Upcp.css +1 -0
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-IcoMGkJh.js +1 -0
- package/src/client/dist/pwa/assets/{WorkspacePage--5HsnMQ1.css → WorkspacePage-BnW-gp3u.css} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-CR2xAzW_.js +4 -0
- package/src/client/dist/pwa/assets/{browser-DNEbLF-4.js → browser-BveHkbe-.js} +1 -1
- package/src/client/dist/pwa/assets/build-path-tree-Dtp7rlxS.js +1 -0
- package/src/client/dist/pwa/assets/custom-sounds-CBoWzJ85.js +1 -0
- package/src/client/dist/pwa/assets/{de-DgZ9jKq_.js → de-BJsWkZro.js} +2 -2
- package/src/client/dist/pwa/assets/defineProperty-BbfpZ9Tg.js +1 -0
- package/src/client/dist/pwa/assets/documents-sb2sQ4JC.js +1 -0
- package/src/client/dist/pwa/assets/{editor.api-PENN0io6.js → editor.api-CbkPxoJf.js} +1 -1
- package/src/client/dist/pwa/assets/efforts-BCli1Unj.js +9 -0
- package/src/client/dist/pwa/assets/{es-zvlgzw9H.js → es-tNYw7itB.js} +2 -2
- package/src/client/dist/pwa/assets/expand-template-CfaEhb2f.js +1 -0
- package/src/client/dist/pwa/assets/{format-CVI7mr7G.js → format-BRl2DUlm.js} +7 -7
- package/src/client/dist/pwa/assets/formatters-C5TjPSR_.js +1 -0
- package/src/client/dist/pwa/assets/{fr-BICzq59I.js → fr-B5sFski4.js} +2 -2
- package/src/client/dist/pwa/assets/{freemarker2-BxN_Aq6C.js → freemarker2-CDmvOQyn.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-C9UHXtO1.js → handlebars-Bx_w8mx1.js} +1 -1
- package/src/client/dist/pwa/assets/{html-CTH9wSJr.js → html-DVRfiRkS.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-Bm-L4nW3.js +1 -0
- package/src/client/dist/pwa/assets/i18n-D21JKsVt.js +19 -0
- package/src/client/dist/pwa/assets/index-C7vFf6p2.js +2 -0
- package/src/client/dist/pwa/assets/{it-BIGArQrS.js → it-Bc2FncM9.js} +2 -2
- package/src/client/dist/pwa/assets/{javascript-WT4BS8p4.js → javascript-CPvttU5J.js} +1 -1
- package/src/client/dist/pwa/assets/latest-request-CN4-B22z.js +1 -0
- package/src/client/dist/pwa/assets/{liquid-D6dfe-gU.js → liquid-CI0gQddb.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-B8pNXNeC.js → mdx-BveBwYJH.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-COLfslyi.js → monaco-contributions-CHXFh4P2.js} +1 -1
- package/src/client/dist/pwa/assets/monaco.contribution-DOK8t_9B.js +2 -0
- package/src/client/dist/pwa/assets/network-auth-CItnNZDh.js +1 -0
- package/src/client/dist/pwa/assets/{notify-theme-7MsG-MyN.js → notify-theme-BO6r1Rd7.js} +1 -1
- package/src/client/dist/pwa/assets/pending-diff-open-DpS4iyDM.js +1 -0
- package/src/client/dist/pwa/assets/{permissionModes-B-ciuUbz.js → permissionModes-Cc5VaOdC.js} +1 -1
- package/src/client/dist/pwa/assets/preload-helper-BZ1Pz5am.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-Bo6iEcqN.js → project-color-Bnv02P9D.js} +1 -1
- package/src/client/dist/pwa/assets/{python-D2gb6Jjz.js → python-BesLCxpi.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-dksrCQF2.js → razor-Dv1PMxuY.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-px_6WDIX.js → render-chat-markdown-CafzCcmu.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-DTPyRTrW.js → typescript-26L2Xsu9.js} +1 -1
- package/src/client/dist/pwa/assets/use-checkbox-BLeif5-t.js +1 -0
- package/src/client/dist/pwa/assets/use-search-index-status-Mv3pIO6p.js +1 -0
- package/src/client/dist/pwa/assets/{use-tours-b00RruX_.js → use-tours-B5uIqxRS.js} +2 -2
- package/src/client/dist/pwa/assets/use-worktree-restore-BBChALUM.js +1 -0
- package/src/client/dist/pwa/assets/workspace-templates-CDY-MiH2.js +1 -0
- package/src/client/dist/pwa/assets/{xml-mxUZCM4r.js → xml-CkRojTcc.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-BuN8sUWC.js → yaml-DKkQHwew.js} +1 -1
- package/src/client/dist/pwa/index.html +6 -3
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-DdGARu8o.js +0 -8
- package/src/client/dist/pwa/assets/ChangelogPage-CGenjiI5.js +0 -1
- package/src/client/dist/pwa/assets/ChangelogPage-DKJkFiU_.css +0 -1
- package/src/client/dist/pwa/assets/ComparisonPanel-CRRaDs2D.css +0 -1
- package/src/client/dist/pwa/assets/ComparisonPanel-aXgjEulK.js +0 -2
- package/src/client/dist/pwa/assets/CreatePage-B9C9J9Pw.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-BaB_30xJ.js +0 -2
- package/src/client/dist/pwa/assets/DashboardPage-DsbwmO2W.js +0 -1
- package/src/client/dist/pwa/assets/DashboardPage-XiCj24i2.css +0 -1
- package/src/client/dist/pwa/assets/DiffViewer--dUMf7b3.js +0 -8
- package/src/client/dist/pwa/assets/DocumentsPanel-DTjEUYUd.js +0 -1
- package/src/client/dist/pwa/assets/GitPanel-PUPLj0M8.js +0 -3
- package/src/client/dist/pwa/assets/HealthPage-BHgucifz.js +0 -1
- package/src/client/dist/pwa/assets/HealthPage-CFZ-tdKK.css +0 -1
- package/src/client/dist/pwa/assets/MainLayout-CQVLnTX3.js +0 -2
- package/src/client/dist/pwa/assets/QBanner-XE0EMe9L.js +0 -1
- package/src/client/dist/pwa/assets/QChip-Bt_JvnHb.js +0 -1
- package/src/client/dist/pwa/assets/QExpansionItem-CxEBY3jY.js +0 -1
- package/src/client/dist/pwa/assets/QItemSection-Cf6OZEH4.js +0 -1
- package/src/client/dist/pwa/assets/QList-Z90BspT4.js +0 -1
- package/src/client/dist/pwa/assets/QMarkupTable-Bwjknbmw.js +0 -1
- package/src/client/dist/pwa/assets/QSelect-CMzJDhi-.js +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-BYKSFoFj.css +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-DTVwe7sX.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CtrR7dNr.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-Bl_RNTGi.js +0 -9
- package/src/client/dist/pwa/assets/SplitWorkspacePage-Bac_Pzmm.js +0 -1
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-BAk5l4Ik.js +0 -1
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-FScR5YAD.js +0 -4
- package/src/client/dist/pwa/assets/build-path-tree-C0b61nxI.js +0 -1
- package/src/client/dist/pwa/assets/custom-sounds-DE4JLdGG.js +0 -1
- package/src/client/dist/pwa/assets/documents-CALwmao-.js +0 -1
- package/src/client/dist/pwa/assets/efforts-Detdm-FH.js +0 -9
- package/src/client/dist/pwa/assets/expand-template-ZIVLPdW2.js +0 -1
- package/src/client/dist/pwa/assets/formatters-C_zcfJBt.js +0 -1
- package/src/client/dist/pwa/assets/i18n-DG_vChZ3.js +0 -1
- package/src/client/dist/pwa/assets/index-fuLr-ZVa.js +0 -20
- package/src/client/dist/pwa/assets/monaco.contribution-CDhuQbzi.js +0 -2
- package/src/client/dist/pwa/assets/network-auth-QouPCx6A.js +0 -1
- package/src/client/dist/pwa/assets/pending-diff-open-CX24u9gW.js +0 -1
- package/src/client/dist/pwa/assets/pinia-BdFjQEqa.js +0 -1
- package/src/client/dist/pwa/assets/use-checkbox-Bh9EQhkV.js +0 -1
- package/src/client/dist/pwa/assets/use-search-index-status-_nCyACc7.js +0 -1
- package/src/client/dist/pwa/assets/use-worktree-restore-BDOZc9fk.js +0 -1
- package/src/client/dist/pwa/assets/workspace-templates-DV9Njz0T.js +0 -1
|
@@ -1,41 +1,137 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
1
|
import { getDb } from '../db/index.js';
|
|
3
|
-
|
|
2
|
+
import { getWorkingTreeDiffStatsAsync, getWorkingTreePorcelainAsync } from '../utils/git-ops.js';
|
|
3
|
+
const MAX_RECENT_MESSAGES = 6;
|
|
4
|
+
const MAX_HISTORY_ROWS = 10_000;
|
|
5
|
+
const MAX_MESSAGE_CHARS = 2_000;
|
|
6
|
+
const MAX_CONVERSATION_CHARS = 12_000;
|
|
7
|
+
const TRUNCATED = '[Earlier text omitted]\n';
|
|
8
|
+
/** Normalized text events are deltas; a non-streaming snapshot is complete. */
|
|
9
|
+
function readableFragment(row) {
|
|
4
10
|
try {
|
|
5
|
-
|
|
11
|
+
const payload = JSON.parse(row.payload);
|
|
12
|
+
if (!payload || typeof payload !== 'object')
|
|
13
|
+
return null;
|
|
14
|
+
if (row.type === 'user:message' &&
|
|
15
|
+
typeof payload.content === 'string' &&
|
|
16
|
+
(payload.sender === undefined || payload.sender === 'user')) {
|
|
17
|
+
return { key: `user:${row.id}`, role: 'user', text: payload.content, complete: true };
|
|
18
|
+
}
|
|
19
|
+
if (row.type === 'agent:event' && payload.kind === 'message:text' && typeof payload.text === 'string') {
|
|
20
|
+
return {
|
|
21
|
+
// A resumed/native session may reuse the same message id elsewhere.
|
|
22
|
+
key: JSON.stringify([row.session_id, typeof payload.messageId === 'string' ? payload.messageId : row.id]),
|
|
23
|
+
role: 'assistant',
|
|
24
|
+
text: payload.text,
|
|
25
|
+
complete: payload.streaming === false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
6
28
|
}
|
|
7
29
|
catch {
|
|
8
|
-
|
|
30
|
+
// Historical damaged rows are not evidence of a conversation message.
|
|
9
31
|
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
function boundedText(text, limit) {
|
|
35
|
+
if (text.length <= limit)
|
|
36
|
+
return text;
|
|
37
|
+
const marker = '\n[Middle text omitted]\n';
|
|
38
|
+
const available = Math.max(0, limit - marker.length);
|
|
39
|
+
const first = Math.ceil(available / 2);
|
|
40
|
+
return `${text.slice(0, first)}${marker}${text.slice(-(available - first))}`.slice(0, limit);
|
|
10
41
|
}
|
|
11
42
|
function recentConversation(workspaceId, sessionId) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
.
|
|
18
|
-
|
|
19
|
-
.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
43
|
+
const db = getDb();
|
|
44
|
+
const filter = 'workspace_id = ? AND (? IS NULL OR session_id = ?)';
|
|
45
|
+
const parameters = [workspaceId, sessionId ?? null, sessionId ?? null];
|
|
46
|
+
// Pin the most recent real user message independently of streaming volume.
|
|
47
|
+
const userRows = db
|
|
48
|
+
.prepare(`SELECT rowid AS ordinal, id, type, payload, session_id FROM ws_events
|
|
49
|
+
WHERE ${filter} AND type = 'user:message' ORDER BY rowid DESC`)
|
|
50
|
+
.iterate(...parameters);
|
|
51
|
+
let latestUser;
|
|
52
|
+
for (const row of userRows) {
|
|
53
|
+
const fragment = readableFragment(row);
|
|
54
|
+
if (!fragment?.text)
|
|
55
|
+
continue;
|
|
56
|
+
latestUser = { ...fragment, ordinal: row.ordinal, truncated: false };
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
const rows = db
|
|
60
|
+
.prepare(`SELECT rowid AS ordinal, id, type, payload, session_id FROM ws_events
|
|
61
|
+
WHERE ${filter} AND type IN ('user:message', 'agent:event')
|
|
62
|
+
ORDER BY rowid DESC LIMIT ?`)
|
|
63
|
+
.iterate(...parameters, MAX_HISTORY_ROWS + 1);
|
|
64
|
+
const messages = new Map();
|
|
65
|
+
let scanned = 0;
|
|
66
|
+
let historyTruncated = false;
|
|
67
|
+
for (const row of rows) {
|
|
68
|
+
if (++scanned > MAX_HISTORY_ROWS) {
|
|
69
|
+
historyTruncated = true;
|
|
70
|
+
break;
|
|
25
71
|
}
|
|
26
|
-
|
|
27
|
-
|
|
72
|
+
const fragment = readableFragment(row);
|
|
73
|
+
if (!fragment?.text)
|
|
74
|
+
continue;
|
|
75
|
+
const existing = messages.get(fragment.key);
|
|
76
|
+
if (existing) {
|
|
77
|
+
existing.ordinal = row.ordinal;
|
|
78
|
+
if (existing.complete)
|
|
79
|
+
continue;
|
|
80
|
+
const merged = fragment.text + existing.text;
|
|
81
|
+
existing.text = merged.slice(-MAX_MESSAGE_CHARS);
|
|
82
|
+
existing.truncated ||= merged.length > MAX_MESSAGE_CHARS;
|
|
83
|
+
existing.complete = fragment.complete;
|
|
28
84
|
}
|
|
29
|
-
|
|
30
|
-
|
|
85
|
+
else if (messages.size < MAX_RECENT_MESSAGES) {
|
|
86
|
+
messages.set(fragment.key, {
|
|
87
|
+
...fragment,
|
|
88
|
+
ordinal: row.ordinal,
|
|
89
|
+
text: fragment.text.slice(-MAX_MESSAGE_CHARS),
|
|
90
|
+
truncated: fragment.text.length > MAX_MESSAGE_CHARS,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const selected = [];
|
|
95
|
+
const warning = historyTruncated
|
|
96
|
+
? '\n\n[Conversation history truncated; recover older context from the source session.]'
|
|
97
|
+
: '';
|
|
98
|
+
let remaining = MAX_CONVERSATION_CHARS - warning.length;
|
|
99
|
+
if (latestUser) {
|
|
100
|
+
const pinned = `Latest user instruction:\n${boundedText(latestUser.text, 6_000)}`;
|
|
101
|
+
selected.push({ ordinal: latestUser.ordinal, text: pinned });
|
|
102
|
+
remaining -= pinned.length + 2;
|
|
103
|
+
messages.delete(latestUser.key);
|
|
104
|
+
}
|
|
105
|
+
const newestFirst = [...messages.values()].sort((a, b) => b.ordinal - a.ordinal);
|
|
106
|
+
for (const message of newestFirst) {
|
|
107
|
+
const label = message.role === 'assistant' ? 'Assistant:\n' : 'User:\n';
|
|
108
|
+
if (remaining <= label.length + TRUNCATED.length + 2)
|
|
109
|
+
break;
|
|
110
|
+
const text = message.truncated ? TRUNCATED + message.text : message.text;
|
|
111
|
+
const rendered = label + boundedText(text, Math.min(MAX_MESSAGE_CHARS, remaining - label.length - 2));
|
|
112
|
+
selected.push({ ordinal: message.ordinal, text: rendered });
|
|
113
|
+
remaining -= rendered.length + 2;
|
|
114
|
+
}
|
|
115
|
+
return (selected
|
|
116
|
+
.sort((a, b) => a.ordinal - b.ordinal)
|
|
117
|
+
.map((message) => message.text)
|
|
118
|
+
.join('\n\n') + warning);
|
|
31
119
|
}
|
|
32
120
|
/** Build a deterministic, secret-free handoff for a fresh session on another engine. */
|
|
33
|
-
export function buildEngineHandoff(workspace, sourceEngine, targetEngine, sourceSessionId) {
|
|
121
|
+
export async function buildEngineHandoff(workspace, sourceEngine, targetEngine, sourceSessionId) {
|
|
34
122
|
const taskLines = workspace.tasks.length
|
|
35
123
|
? workspace.tasks.map((task) => `- [${task.status === 'done' ? 'x' : ' '}] ${task.title}`).join('\n')
|
|
36
124
|
: '- No Kōbō task recorded.';
|
|
37
|
-
const
|
|
38
|
-
|
|
125
|
+
const [status, diff] = await Promise.allSettled([
|
|
126
|
+
getWorkingTreePorcelainAsync(workspace.worktreePath),
|
|
127
|
+
getWorkingTreeDiffStatsAsync(workspace.worktreePath),
|
|
128
|
+
]);
|
|
129
|
+
const changedFiles = status.status === 'fulfilled'
|
|
130
|
+
? status.value.trim() || 'No uncommitted changes detected.'
|
|
131
|
+
: 'Git state unavailable. Verify the working tree before making changes.';
|
|
132
|
+
const diffStat = diff.status === 'fulfilled'
|
|
133
|
+
? diff.value.trim() || 'No uncommitted diff stat available.'
|
|
134
|
+
: 'Git state unavailable. Run git diff --stat to recover the summary.';
|
|
39
135
|
const history = recentConversation(workspace.id, sourceSessionId) || 'No recent conversation message available.';
|
|
40
136
|
return `# Kōbō engine handoff\n\nYou are taking over this workspace from ${sourceEngine} using ${targetEngine}. Work in the existing worktree and verify the repository state before making changes.\n\n## Objective\n${workspace.description ?? workspace.name}\n\n## Tasks\n${taskLines}\n\n## Git state\n- Branch: ${workspace.workingBranch}\n- Base: ${workspace.sourceBranch}\n- Worktree: ${workspace.worktreePath}\n\nChanged files:\n\`\`\`\n${changedFiles}\n\`\`\`\n\nDiff summary:\n\`\`\`\n${diffStat}\n\`\`\`\n\n## Recent conversation\n${history}\n\n## Recover more context\nIf this handoff is insufficient, use \`kobo__read_workspace_events_csv\` to read the previous workspace conversation in pages (optionally filter by \`session_id\`). Use \`kobo__search_codebase\` to find a precise past decision or user request. Read only the context needed, then continue the task.\n`;
|
|
41
137
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
5
6
|
import * as gitOps from '../utils/git-ops.js';
|
|
6
7
|
import { GitConflictError } from '../utils/git-ops.js';
|
|
7
8
|
import { withGitRepoLock } from '../utils/git-repo-lock.js';
|
|
@@ -41,23 +42,41 @@ const EMPTY_CHANGES = { present: false, modified: 0, staged: 0, untracked: 0 };
|
|
|
41
42
|
// interactively over a pipe nobody can answer; the timeout is a backstop for
|
|
42
43
|
// anything else that could still stall (a slow filesystem, a stuck lock).
|
|
43
44
|
const GIT_TIMEOUT_MS = 60_000;
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
const execFileAsync = promisify(execFile);
|
|
46
|
+
async function runGitAsync(repoPath, args, timeout = GIT_TIMEOUT_MS) {
|
|
47
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
46
48
|
cwd: repoPath,
|
|
47
49
|
encoding: 'utf-8',
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
env:
|
|
51
|
-
})
|
|
50
|
+
timeout,
|
|
51
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
52
|
+
env: gitOps.buildNonInteractiveGitEnv(),
|
|
53
|
+
});
|
|
54
|
+
return stdout.trim();
|
|
55
|
+
}
|
|
56
|
+
function readGitAsync(repoPath, args) {
|
|
57
|
+
return runGitAsync(repoPath, args, 15_000);
|
|
58
|
+
}
|
|
59
|
+
async function conflictedFilesAsync(repoPath) {
|
|
60
|
+
try {
|
|
61
|
+
return (await readGitAsync(repoPath, ['diff', '--name-only', '--diff-filter=U']))
|
|
62
|
+
.split('\n')
|
|
63
|
+
.map((file) => file.trim())
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
52
69
|
}
|
|
53
70
|
/** Compare the local branch against `origin/<branch>`. */
|
|
54
|
-
function diagnoseBranch(projectPath, branch) {
|
|
55
|
-
if (!
|
|
71
|
+
async function diagnoseBranch(projectPath, branch, localExists, remoteExists) {
|
|
72
|
+
if (!localExists)
|
|
56
73
|
return { state: 'absent' };
|
|
57
|
-
if (!
|
|
74
|
+
if (!remoteExists)
|
|
58
75
|
return { state: 'in-sync' };
|
|
59
|
-
const ahead =
|
|
60
|
-
|
|
76
|
+
const [ahead, behind] = await Promise.all([
|
|
77
|
+
gitOps.getCommitCountAsync(projectPath, branch, branch),
|
|
78
|
+
gitOps.getCommitsBehindAsync(projectPath, branch, branch),
|
|
79
|
+
]);
|
|
61
80
|
if (ahead > 0 && behind > 0)
|
|
62
81
|
return { state: 'diverged', ahead, behind };
|
|
63
82
|
// Note: `hasCommonAncestor` below is what produces the `no-common-ancestor`
|
|
@@ -71,9 +90,9 @@ function diagnoseBranch(projectPath, branch) {
|
|
|
71
90
|
}
|
|
72
91
|
/** False when the local branch and its remote share no history at all — a name
|
|
73
92
|
* collision, not a divergence. Every realignment strategy is wrong for it. */
|
|
74
|
-
function hasCommonAncestor(repoPath, branch) {
|
|
93
|
+
async function hasCommonAncestor(repoPath, branch) {
|
|
75
94
|
try {
|
|
76
|
-
|
|
95
|
+
await readGitAsync(repoPath, ['merge-base', branch, `origin/${branch}`]);
|
|
77
96
|
return true;
|
|
78
97
|
}
|
|
79
98
|
catch {
|
|
@@ -107,8 +126,8 @@ async function diagnoseWorktree(projectPath, branch, targetPath, attachedPaths)
|
|
|
107
126
|
return { worktree: { state: 'none' }, blockers };
|
|
108
127
|
}
|
|
109
128
|
/** Count working-tree changes by category. */
|
|
110
|
-
function diagnoseChanges(worktreePath) {
|
|
111
|
-
const status = gitOps.
|
|
129
|
+
async function diagnoseChanges(worktreePath) {
|
|
130
|
+
const status = await gitOps.getWorkingTreeStatusAsync(worktreePath);
|
|
112
131
|
const modified = status.modified ?? 0;
|
|
113
132
|
const staged = status.staged ?? 0;
|
|
114
133
|
const untracked = status.untracked ?? 0;
|
|
@@ -130,9 +149,9 @@ export function resolveWorkspaceState(workspaces, branch, projectPath) {
|
|
|
130
149
|
return { state: 'active', id: match.id, name: match.name };
|
|
131
150
|
}
|
|
132
151
|
/** `git rev-parse <ref>`, or null when the ref does not resolve. */
|
|
133
|
-
function safeRevParse(repoPath, ref) {
|
|
152
|
+
async function safeRevParse(repoPath, ref) {
|
|
134
153
|
try {
|
|
135
|
-
return
|
|
154
|
+
return await readGitAsync(repoPath, ['rev-parse', ref]);
|
|
136
155
|
}
|
|
137
156
|
catch {
|
|
138
157
|
return null;
|
|
@@ -140,8 +159,10 @@ function safeRevParse(repoPath, ref) {
|
|
|
140
159
|
}
|
|
141
160
|
/** Hash the observed state so `/resolve` can refuse a plan built on stale facts. */
|
|
142
161
|
export async function computeFingerprint(report) {
|
|
143
|
-
const localHead =
|
|
144
|
-
|
|
162
|
+
const [localHead, remoteHead] = await Promise.all([
|
|
163
|
+
safeRevParse(report.projectPath, report.headBranch),
|
|
164
|
+
safeRevParse(report.projectPath, `origin/${report.headBranch}`),
|
|
165
|
+
]);
|
|
145
166
|
const payload = JSON.stringify({
|
|
146
167
|
contents: report.worktree.state === 'orphan' || report.worktree.state === 'attached'
|
|
147
168
|
? await fingerprintWorktree(report.worktree.path)
|
|
@@ -166,12 +187,15 @@ export async function computeFingerprint(report) {
|
|
|
166
187
|
export async function diagnoseLocalState(projectPath, headBranch, worktreesPath, attachedPaths = new Map(), workspaces = []) {
|
|
167
188
|
const targetWorktreePath = resolveWorkspaceWorktreePath(projectPath, headBranch, worktreesPath);
|
|
168
189
|
const blockers = [];
|
|
169
|
-
const lockPath =
|
|
190
|
+
const [lockPath, localExists, remoteBranches] = await Promise.all([
|
|
191
|
+
gitOps.getIndexLockPathAsync(projectPath),
|
|
192
|
+
gitOps.localBranchExistsAsync(projectPath, headBranch),
|
|
193
|
+
gitOps.listRemoteBranchesAsync(projectPath),
|
|
194
|
+
]);
|
|
195
|
+
const remoteExists = remoteBranches.includes(`origin/${headBranch}`);
|
|
170
196
|
if (lockPath)
|
|
171
197
|
blockers.push({ kind: 'index-lock', lockPath });
|
|
172
|
-
if (
|
|
173
|
-
gitOps.listRemoteBranches(projectPath).includes(`origin/${headBranch}`) &&
|
|
174
|
-
!hasCommonAncestor(projectPath, headBranch)) {
|
|
198
|
+
if (localExists && remoteExists && !(await hasCommonAncestor(projectPath, headBranch))) {
|
|
175
199
|
blockers.push({ kind: 'no-common-ancestor', branch: headBranch });
|
|
176
200
|
}
|
|
177
201
|
const { worktree, blockers: worktreeBlockers } = await diagnoseWorktree(projectPath, headBranch, targetWorktreePath, attachedPaths);
|
|
@@ -184,9 +208,9 @@ export async function diagnoseLocalState(projectPath, headBranch, worktreesPath,
|
|
|
184
208
|
blockers,
|
|
185
209
|
workspace: resolveWorkspaceState(workspaces, headBranch, projectPath),
|
|
186
210
|
worktree,
|
|
187
|
-
localChanges: inspectPath ? diagnoseChanges(inspectPath) : EMPTY_CHANGES,
|
|
188
|
-
ongoingOperation: inspectPath ? gitOps.
|
|
189
|
-
branch: diagnoseBranch(projectPath, headBranch),
|
|
211
|
+
localChanges: inspectPath ? await diagnoseChanges(inspectPath) : EMPTY_CHANGES,
|
|
212
|
+
ongoingOperation: inspectPath ? await gitOps.getOngoingGitOperationAsync(inspectPath) : null,
|
|
213
|
+
branch: await diagnoseBranch(projectPath, headBranch, localExists, remoteExists),
|
|
190
214
|
};
|
|
191
215
|
}
|
|
192
216
|
/**
|
|
@@ -198,18 +222,18 @@ export async function diagnoseLocalState(projectPath, headBranch, worktreesPath,
|
|
|
198
222
|
* Operates on the repository's branch ref, so the caller must hold the repo lock
|
|
199
223
|
* AND the branch must not be checked out anywhere (see the ordering note below).
|
|
200
224
|
*/
|
|
201
|
-
export function applyBranchStrategy(repoPath, branch, strategy) {
|
|
225
|
+
export async function applyBranchStrategy(repoPath, branch, strategy) {
|
|
202
226
|
const remoteRef = `origin/${branch}`;
|
|
203
227
|
if (strategy === 'keep')
|
|
204
228
|
return { strategy, backupBranch: null };
|
|
205
229
|
if (strategy === 'fast-forward') {
|
|
206
|
-
|
|
230
|
+
await runGitAsync(repoPath, ['branch', '--force', branch, remoteRef]);
|
|
207
231
|
return { strategy, backupBranch: null };
|
|
208
232
|
}
|
|
209
233
|
if (strategy === 'reset-hard') {
|
|
210
234
|
const backupBranch = `kobo-backup/${branch}-${Date.now()}`;
|
|
211
|
-
|
|
212
|
-
|
|
235
|
+
await runGitAsync(repoPath, ['branch', backupBranch, branch]);
|
|
236
|
+
await runGitAsync(repoPath, ['branch', '--force', branch, remoteRef]);
|
|
213
237
|
return { strategy, backupBranch };
|
|
214
238
|
}
|
|
215
239
|
// strategy === 'rebase': `git rebase <upstream> <branch>` is shorthand for
|
|
@@ -219,14 +243,14 @@ export function applyBranchStrategy(repoPath, branch, strategy) {
|
|
|
219
243
|
// can restore it once the rebase completes cleanly. If HEAD is detached,
|
|
220
244
|
// `--abbrev-ref` returns the literal string "HEAD", which isn't a valid ref
|
|
221
245
|
// to check back out — fall back to the SHA in that case.
|
|
222
|
-
const currentRef =
|
|
223
|
-
const originalCheckout = currentRef === 'HEAD' ?
|
|
246
|
+
const currentRef = await readGitAsync(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
247
|
+
const originalCheckout = currentRef === 'HEAD' ? await readGitAsync(repoPath, ['rev-parse', 'HEAD']) : currentRef;
|
|
224
248
|
try {
|
|
225
|
-
|
|
249
|
+
await runGitAsync(repoPath, ['rebase', remoteRef, branch]);
|
|
226
250
|
}
|
|
227
251
|
catch (err) {
|
|
228
|
-
const conflicted =
|
|
229
|
-
if (conflicted.length > 0 || gitOps.
|
|
252
|
+
const conflicted = await conflictedFilesAsync(repoPath);
|
|
253
|
+
if (conflicted.length > 0 || (await gitOps.getOngoingGitOperationAsync(repoPath)) === 'rebase') {
|
|
230
254
|
// Leave the rebase in progress on `branch` so the caller can abort or
|
|
231
255
|
// request agent-assisted resolution — do NOT restore the original checkout.
|
|
232
256
|
throw new GitConflictError('rebase', conflicted);
|
|
@@ -235,7 +259,7 @@ export function applyBranchStrategy(repoPath, branch, strategy) {
|
|
|
235
259
|
throw new Error(`Rebase of '${branch}' onto '${remoteRef}' failed: ${message}`);
|
|
236
260
|
}
|
|
237
261
|
// Clean rebase: restore the working copy to exactly what was checked out before.
|
|
238
|
-
|
|
262
|
+
await runGitAsync(repoPath, ['checkout', originalCheckout]);
|
|
239
263
|
return { strategy, backupBranch: null };
|
|
240
264
|
}
|
|
241
265
|
/** The repository moved between diagnosis and resolution. */
|
|
@@ -272,7 +296,7 @@ function resolvePrCheckoutLocked(input, owner) {
|
|
|
272
296
|
throw new Error('Stop the workspace agent before changing its checkout');
|
|
273
297
|
const applied = [];
|
|
274
298
|
// 1. Refresh, then refuse a plan built on facts that no longer hold.
|
|
275
|
-
gitOps.
|
|
299
|
+
await gitOps.fetchSourceBranchOrThrowAsync(input.projectPath, input.headBranch);
|
|
276
300
|
applied.push({ kind: 'fetch', detail: `origin/${input.headBranch}` });
|
|
277
301
|
const fresh = await diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths ?? new Map(), input.workspaces ?? []);
|
|
278
302
|
if ((await computeFingerprint(fresh)) !== input.fingerprint)
|
|
@@ -288,7 +312,7 @@ function resolvePrCheckoutLocked(input, owner) {
|
|
|
288
312
|
}
|
|
289
313
|
// 2. Clear stale worktree metadata before anything needs the path.
|
|
290
314
|
if (fresh.worktree.state === 'stale-metadata') {
|
|
291
|
-
|
|
315
|
+
await runGitAsync(input.projectPath, ['worktree', 'prune']);
|
|
292
316
|
applied.push({ kind: 'prune-stale-worktree', detail: fresh.worktree.path });
|
|
293
317
|
}
|
|
294
318
|
const reuse = (fresh.worktree.state === 'orphan' || fresh.worktree.state === 'attached') &&
|
|
@@ -297,7 +321,7 @@ function resolvePrCheckoutLocked(input, owner) {
|
|
|
297
321
|
// 3. In a reused worktree, settle the working tree before touching history.
|
|
298
322
|
if (existingPath) {
|
|
299
323
|
if (fresh.ongoingOperation && input.decisions.ongoingOperation === 'abort') {
|
|
300
|
-
|
|
324
|
+
await runGitAsync(existingPath, [fresh.ongoingOperation, '--abort']);
|
|
301
325
|
applied.push({ kind: 'abort-operation', detail: fresh.ongoingOperation });
|
|
302
326
|
}
|
|
303
327
|
if (fresh.ongoingOperation && input.decisions.ongoingOperation !== 'abort') {
|
|
@@ -306,16 +330,29 @@ function resolvePrCheckoutLocked(input, owner) {
|
|
|
306
330
|
if (fresh.localChanges.present) {
|
|
307
331
|
const choice = input.decisions.localChanges ?? 'keep';
|
|
308
332
|
if (choice === 'stash') {
|
|
309
|
-
|
|
333
|
+
await runGitAsync(existingPath, [
|
|
334
|
+
'stash',
|
|
335
|
+
'push',
|
|
336
|
+
'--include-untracked',
|
|
337
|
+
'-m',
|
|
338
|
+
`kobo-pr-checkout-${input.headBranch}`,
|
|
339
|
+
]);
|
|
310
340
|
applied.push({ kind: 'stash-changes' });
|
|
311
341
|
}
|
|
312
342
|
else if (choice === 'commit') {
|
|
313
|
-
|
|
343
|
+
await runGitAsync(existingPath, ['add', '-A']);
|
|
344
|
+
await runGitAsync(existingPath, ['commit', '-m', `chore: save work before checking out ${input.headBranch}`]);
|
|
314
345
|
applied.push({ kind: 'commit-changes' });
|
|
315
346
|
}
|
|
316
347
|
else if (choice === 'discard') {
|
|
317
348
|
// Deliberately a labelled stash, not `checkout -- .`: recoverable.
|
|
318
|
-
|
|
349
|
+
await runGitAsync(existingPath, [
|
|
350
|
+
'stash',
|
|
351
|
+
'push',
|
|
352
|
+
'--include-untracked',
|
|
353
|
+
'-m',
|
|
354
|
+
`kobo-pr-checkout-discard-${input.headBranch}`,
|
|
355
|
+
]);
|
|
319
356
|
applied.push({ kind: 'discard-changes', detail: 'kept as a labelled stash' });
|
|
320
357
|
}
|
|
321
358
|
}
|
|
@@ -325,10 +362,10 @@ function resolvePrCheckoutLocked(input, owner) {
|
|
|
325
362
|
const strategy = input.decisions.divergence ?? 'keep';
|
|
326
363
|
if (strategy !== 'keep') {
|
|
327
364
|
if (existingPath) {
|
|
328
|
-
alignInsideWorktree(input.projectPath, existingPath, input.headBranch, strategy, applied);
|
|
365
|
+
await alignInsideWorktree(input.projectPath, existingPath, input.headBranch, strategy, applied);
|
|
329
366
|
}
|
|
330
367
|
else {
|
|
331
|
-
const outcome = applyBranchStrategy(input.projectPath, input.headBranch, strategy);
|
|
368
|
+
const outcome = await applyBranchStrategy(input.projectPath, input.headBranch, strategy);
|
|
332
369
|
applied.push({ kind: 'align-branch', detail: outcome.backupBranch ?? strategy });
|
|
333
370
|
}
|
|
334
371
|
}
|
|
@@ -343,7 +380,7 @@ function resolvePrCheckoutLocked(input, owner) {
|
|
|
343
380
|
const explicitPath = input.decisions.pathCollision?.worktreePath ?? null;
|
|
344
381
|
if (explicitPath)
|
|
345
382
|
assertSafeExplicitWorktreePath(explicitPath);
|
|
346
|
-
const baseRef = gitOps.
|
|
383
|
+
const baseRef = (await gitOps.localBranchExistsAsync(input.projectPath, input.headBranch))
|
|
347
384
|
? input.headBranch
|
|
348
385
|
: `origin/${input.headBranch}`;
|
|
349
386
|
const target = await createWorktreeUnlocked(input.projectPath, input.headBranch, baseRef, input.worktreesPath, undefined, explicitPath);
|
|
@@ -376,20 +413,20 @@ function resolvePrCheckoutLocked(input, owner) {
|
|
|
376
413
|
});
|
|
377
414
|
}
|
|
378
415
|
/** Realign a branch from inside its own worktree, where ref surgery is refused. */
|
|
379
|
-
function alignInsideWorktree(repoPath, worktreePath, branch, strategy, applied) {
|
|
416
|
+
async function alignInsideWorktree(repoPath, worktreePath, branch, strategy, applied) {
|
|
380
417
|
const remoteRef = `origin/${branch}`;
|
|
381
418
|
if (strategy === 'fast-forward') {
|
|
382
|
-
|
|
419
|
+
await runGitAsync(worktreePath, ['merge', '--ff-only', remoteRef]);
|
|
383
420
|
applied.push({ kind: 'align-branch', detail: 'fast-forward' });
|
|
384
421
|
return;
|
|
385
422
|
}
|
|
386
423
|
if (strategy === 'rebase') {
|
|
387
424
|
try {
|
|
388
|
-
|
|
425
|
+
await runGitAsync(worktreePath, ['rebase', remoteRef]);
|
|
389
426
|
}
|
|
390
427
|
catch (err) {
|
|
391
|
-
const conflicted =
|
|
392
|
-
if (conflicted.length > 0 || gitOps.
|
|
428
|
+
const conflicted = await conflictedFilesAsync(worktreePath);
|
|
429
|
+
if (conflicted.length > 0 || (await gitOps.getOngoingGitOperationAsync(worktreePath)) === 'rebase') {
|
|
393
430
|
// Leave the rebase in progress so the caller can abort or request
|
|
394
431
|
// agent-assisted resolution — mirrors applyBranchStrategy's own rebase
|
|
395
432
|
// conflict handling and git-ops.ts's rebaseBranch.
|
|
@@ -403,7 +440,7 @@ function alignInsideWorktree(repoPath, worktreePath, branch, strategy, applied)
|
|
|
403
440
|
}
|
|
404
441
|
// reset-hard
|
|
405
442
|
const backupBranch = `kobo-backup/${branch}-${Date.now()}`;
|
|
406
|
-
|
|
407
|
-
|
|
443
|
+
await runGitAsync(repoPath, ['branch', backupBranch, branch]);
|
|
444
|
+
await runGitAsync(worktreePath, ['reset', '--hard', remoteRef]);
|
|
408
445
|
applied.push({ kind: 'align-branch', detail: backupBranch });
|
|
409
446
|
}
|
|
@@ -261,13 +261,19 @@ export async function checkPrStatuses() {
|
|
|
261
261
|
['extracting', 'brainstorming', 'executing', 'compacting'].includes(current.status) ||
|
|
262
262
|
hasController(ws.id))
|
|
263
263
|
return;
|
|
264
|
-
|
|
265
|
-
destroyTerminal(ws.id);
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
264
|
+
const archived = await withWorkspaceLifecycleGuard(ws.id, async () => {
|
|
265
|
+
await destroyTerminal(ws.id);
|
|
266
|
+
const latest = getWorkspace(ws.id);
|
|
267
|
+
if (!latest ||
|
|
268
|
+
check.invalidated ||
|
|
269
|
+
latest.archivedAt ||
|
|
270
|
+
hasController(ws.id) ||
|
|
271
|
+
['extracting', 'brainstorming', 'executing', 'compacting'].includes(latest.status))
|
|
272
|
+
return null;
|
|
273
|
+
return archiveWorkspace(ws.id);
|
|
274
|
+
});
|
|
275
|
+
if (!archived)
|
|
276
|
+
return;
|
|
271
277
|
lastKnownPr.delete(ws.id);
|
|
272
278
|
emitEphemeral(ws.id, 'workspace:archived', {
|
|
273
279
|
reason: `PR ${pr.state.toLowerCase()}`,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import Database from 'better-sqlite3';
|
|
3
|
+
import { countPrunableWsEvents, pruneWsEventsInBatches } from '../ws-events-retention-service.js';
|
|
4
|
+
const port = parentPort;
|
|
5
|
+
// The parent supplies the already-open database path, never a second home.
|
|
6
|
+
const db = new Database(workerData.dbPath);
|
|
7
|
+
db.pragma('journal_mode=WAL');
|
|
8
|
+
db.pragma('foreign_keys=ON');
|
|
9
|
+
db.pragma('busy_timeout=5000');
|
|
10
|
+
let stopped = false;
|
|
11
|
+
let queue = Promise.resolve();
|
|
12
|
+
port.on('message', (message) => {
|
|
13
|
+
if (message.stop) {
|
|
14
|
+
stopped = true;
|
|
15
|
+
void queue.finally(() => {
|
|
16
|
+
db.close();
|
|
17
|
+
port.close();
|
|
18
|
+
});
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
queue = queue.then(async () => {
|
|
22
|
+
if (stopped)
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
if (message.preview) {
|
|
26
|
+
const total = db.prepare('SELECT COUNT(*) AS count FROM ws_events').get().count;
|
|
27
|
+
const deletable = countPrunableWsEvents(db, message.config, message.nowMs);
|
|
28
|
+
port.postMessage({ id: message.id, result: { deletable, total } });
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
const result = await pruneWsEventsInBatches(db, message.config, message.nowMs, () => stopped);
|
|
32
|
+
if (!stopped)
|
|
33
|
+
port.postMessage({ id: message.id, result });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
port.postMessage({ id: message.id, error: error instanceof Error ? error.message : String(error) });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -88,7 +88,7 @@ export async function startWorkspaceReview(id, input) {
|
|
|
88
88
|
throw new ReviewRequestError('The current session cannot be resumed; start it before requesting an automatic return', 400);
|
|
89
89
|
const worktreePath = workspace.worktreePath;
|
|
90
90
|
try {
|
|
91
|
-
await
|
|
91
|
+
await gitOps.fetchSourceBranchOrThrowAsync(worktreePath, workspace.sourceBranch);
|
|
92
92
|
}
|
|
93
93
|
catch (err) {
|
|
94
94
|
console.warn(`[start-review] git fetch failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -97,15 +97,19 @@ export async function startWorkspaceReview(id, input) {
|
|
|
97
97
|
try {
|
|
98
98
|
const { stdout } = await execFileAsync('git', ['rev-parse', `origin/${workspace.sourceBranch}`], {
|
|
99
99
|
cwd: worktreePath,
|
|
100
|
+
timeout: 15_000,
|
|
101
|
+
env: gitOps.buildNonInteractiveGitEnv(),
|
|
100
102
|
});
|
|
101
103
|
baseCommit = stdout.trim();
|
|
102
104
|
}
|
|
103
105
|
catch (err) {
|
|
104
106
|
throw new Error(`Cannot resolve base commit for branch ${workspace.sourceBranch}: ${err instanceof Error ? err.message : String(err)}`);
|
|
105
107
|
}
|
|
106
|
-
const commits =
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
const [commits, committedStats, workingTreeStats] = await Promise.all([
|
|
109
|
+
gitOps.getCommitsBetweenAsync(worktreePath, workspace.sourceBranch, workspace.workingBranch),
|
|
110
|
+
gitOps.getDiffStatsBetweenAsync(worktreePath, workspace.sourceBranch, workspace.workingBranch),
|
|
111
|
+
gitOps.getWorkingTreeDiffStatsAsync(worktreePath),
|
|
112
|
+
]);
|
|
109
113
|
const diffStats = workingTreeStats.trim()
|
|
110
114
|
? `${committedStats}\n\n— Working tree (uncommitted) —\n${workingTreeStats}`
|
|
111
115
|
: committedStats;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recompute one session's persisted metrics from the events that remain, on an
|
|
3
|
+
* explicit database handle.
|
|
4
|
+
*
|
|
5
|
+
* Until v36 an `AFTER DELETE ... FOR EACH ROW` trigger did this per deleted
|
|
6
|
+
* row, re-aggregating the whole session every time — 79.7 s for 20 000 events,
|
|
7
|
+
* with the SQLite write lock held throughout. The trigger is gone; every code
|
|
8
|
+
* path that deletes `agent:event` rows while KEEPING the session calls this
|
|
9
|
+
* once, at the end of its transaction.
|
|
10
|
+
*/
|
|
11
|
+
export function recomputeSessionMetricsOn(db, workspaceId, sessionId) {
|
|
12
|
+
db.prepare('DELETE FROM session_event_metrics WHERE workspace_id = ? AND session_id = ?').run(workspaceId, sessionId);
|
|
13
|
+
db.prepare(`INSERT INTO session_event_metrics (
|
|
14
|
+
workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
|
|
15
|
+
)
|
|
16
|
+
SELECT
|
|
17
|
+
e.workspace_id,
|
|
18
|
+
e.session_id,
|
|
19
|
+
SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END),
|
|
20
|
+
SUM(CASE
|
|
21
|
+
WHEN json_extract(e.payload, '$.kind') = 'error'
|
|
22
|
+
OR (json_extract(e.payload, '$.kind') = 'tool:result'
|
|
23
|
+
AND json_extract(e.payload, '$.isError') = 1)
|
|
24
|
+
THEN 1 ELSE 0
|
|
25
|
+
END),
|
|
26
|
+
MAX(CASE
|
|
27
|
+
WHEN json_extract(e.payload, '$.kind') = 'usage'
|
|
28
|
+
AND json_type(e.payload, '$.inputTokens') IN ('integer', 'real')
|
|
29
|
+
THEN CAST(json_extract(e.payload, '$.inputTokens') AS INTEGER) ELSE 0
|
|
30
|
+
END),
|
|
31
|
+
MAX(CASE
|
|
32
|
+
WHEN json_extract(e.payload, '$.kind') = 'usage'
|
|
33
|
+
AND json_type(e.payload, '$.outputTokens') IN ('integer', 'real')
|
|
34
|
+
THEN CAST(json_extract(e.payload, '$.outputTokens') AS INTEGER) ELSE 0
|
|
35
|
+
END)
|
|
36
|
+
FROM ws_events e
|
|
37
|
+
JOIN agent_sessions s ON s.id = e.session_id AND s.workspace_id = e.workspace_id
|
|
38
|
+
WHERE e.workspace_id = ?
|
|
39
|
+
AND e.session_id = ?
|
|
40
|
+
AND e.type = 'agent:event'
|
|
41
|
+
AND json_valid(e.payload)
|
|
42
|
+
GROUP BY e.workspace_id, e.session_id`).run(workspaceId, sessionId);
|
|
43
|
+
}
|