@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.
Files changed (162) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/CONFIGURATION.md +11 -3
  3. package/dist/server/index.js +24 -91
  4. package/dist/server/routes/git.js +6 -7
  5. package/dist/server/routes/settings.js +21 -11
  6. package/dist/server/routes/workspaces.js +43 -41
  7. package/dist/server/services/agent/integration-mcp.js +4 -3
  8. package/dist/server/services/agent/orchestrator.js +1 -1
  9. package/dist/server/services/engine-handoff-service.js +120 -24
  10. package/dist/server/services/pr-checkout-service.js +90 -53
  11. package/dist/server/services/pr-watcher-service.js +13 -7
  12. package/dist/server/services/retention/worker.js +40 -0
  13. package/dist/server/services/review-service.js +8 -4
  14. package/dist/server/services/session-event-metrics.js +43 -0
  15. package/dist/server/services/session-handoff-service.js +6 -4
  16. package/dist/server/services/templates-service.js +67 -49
  17. package/dist/server/services/terminal-service.js +45 -14
  18. package/dist/server/services/terminal-websocket-service.js +143 -0
  19. package/dist/server/services/wakeup-service.js +23 -21
  20. package/dist/server/services/workspace-service.js +3 -44
  21. package/dist/server/services/workspace-template-service.js +16 -18
  22. package/dist/server/services/worktree-purge-service.js +1 -4
  23. package/dist/server/services/ws-events-retention-service.js +197 -104
  24. package/dist/server/services/ws-events-retention-worker-service.js +112 -0
  25. package/dist/server/utils/atomic-json-file.js +23 -0
  26. package/dist/server/utils/git-ops.js +262 -60
  27. package/dist/shared/codex-models.js +20 -3
  28. package/dist/shared/models.js +12 -0
  29. package/package.json +1 -1
  30. package/src/client/dist/pwa/assets/{ActivityFeed-DCmiJP-z.css → ActivityFeed-B4lTKMVE.css} +1 -1
  31. package/src/client/dist/pwa/assets/ActivityFeed-DXWXioW9.js +8 -0
  32. package/src/client/dist/pwa/assets/ChangelogPage-BL5Jh8-v.js +1 -0
  33. package/src/client/dist/pwa/assets/ChangelogPage-DVyx9ZjV.css +1 -0
  34. package/src/client/dist/pwa/assets/ComparisonPanel-Bv2SZrVZ.js +2 -0
  35. package/src/client/dist/pwa/assets/ComparisonPanel-ByVbqYCs.css +1 -0
  36. package/src/client/dist/pwa/assets/CreatePage-CMyl2j8Z.css +1 -0
  37. package/src/client/dist/pwa/assets/CreatePage-d43ITOC1.js +2 -0
  38. package/src/client/dist/pwa/assets/DashboardPage-BEXXRguE.js +1 -0
  39. package/src/client/dist/pwa/assets/DashboardPage-CtaHaVdf.css +1 -0
  40. package/src/client/dist/pwa/assets/DiffViewer-B92w_jd7.js +8 -0
  41. package/src/client/dist/pwa/assets/DocumentsPanel-C9JXyqHg.js +1 -0
  42. package/src/client/dist/pwa/assets/{DrawerToggleButton-Bh4FXdtJ.js → DrawerToggleButton-Btx8QwHo.js} +1 -1
  43. package/src/client/dist/pwa/assets/{GitPanel-C4wOcZV2.css → GitPanel-C3blk_Pd.css} +1 -1
  44. package/src/client/dist/pwa/assets/GitPanel-Cgz9crLN.js +3 -0
  45. package/src/client/dist/pwa/assets/HealthPage-CDhgJQkL.js +1 -0
  46. package/src/client/dist/pwa/assets/HealthPage-DT8FGrKA.css +1 -0
  47. package/src/client/dist/pwa/assets/MainLayout-DTTtj9sq.js +2 -0
  48. package/src/client/dist/pwa/assets/{MainLayout-CNmwCnDD.css → MainLayout-knNNtkre.css} +1 -1
  49. package/src/client/dist/pwa/assets/QBanner-7EWRdc4U.js +1 -0
  50. package/src/client/dist/pwa/assets/{QBtnDropdown-DVbxGTNb.js → QBtnDropdown-vjat4GZl.js} +1 -1
  51. package/src/client/dist/pwa/assets/QChip-CG1kWhFm.js +1 -0
  52. package/src/client/dist/pwa/assets/QExpansionItem-BQuy89vv.js +1 -0
  53. package/src/client/dist/pwa/assets/QItemSection-BrVklYfM.js +1 -0
  54. package/src/client/dist/pwa/assets/QList-BVX-b8nc.js +1 -0
  55. package/src/client/dist/pwa/assets/QMarkupTable-B8_FKLvo.js +1 -0
  56. package/src/client/dist/pwa/assets/QSelect-DBW_TZri.js +1 -0
  57. package/src/client/dist/pwa/assets/{QTooltip-BLhkNT4s.js → QTooltip-DzYbguTR.js} +1 -1
  58. package/src/client/dist/pwa/assets/SchedulePanel-BX6iKh6S.js +1 -0
  59. package/src/client/dist/pwa/assets/SchedulePanel-CBM_GpS3.css +1 -0
  60. package/src/client/dist/pwa/assets/SearchPage-_In2gH2P.js +1 -0
  61. package/src/client/dist/pwa/assets/SettingsPage-BqkNYzC5.js +9 -0
  62. package/src/client/dist/pwa/assets/{SettingsPage-BYdLXJEJ.css → SettingsPage-CpykRKq2.css} +1 -1
  63. package/src/client/dist/pwa/assets/SplitWorkspacePage-DRoXbSRP.js +1 -0
  64. package/src/client/dist/pwa/assets/{TerminalPanel-D2j5AA8S.js → TerminalPanel-CfqiuSuk.js} +2 -2
  65. package/src/client/dist/pwa/assets/{TerminalPanel-DbVZk0vD.css → TerminalPanel-Dz9EE-Ph.css} +1 -1
  66. package/src/client/dist/pwa/assets/{TouchPan-C0EqFcrr.js → TouchPan-DcOCRf7x.js} +1 -1
  67. package/src/client/dist/pwa/assets/{TourReplayButton-DlMvPblb.js → TourReplayButton-gp7m6tSV.js} +1 -1
  68. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DSw8Upcp.css +1 -0
  69. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-IcoMGkJh.js +1 -0
  70. package/src/client/dist/pwa/assets/{WorkspacePage--5HsnMQ1.css → WorkspacePage-BnW-gp3u.css} +1 -1
  71. package/src/client/dist/pwa/assets/WorkspacePage-CR2xAzW_.js +4 -0
  72. package/src/client/dist/pwa/assets/{browser-DNEbLF-4.js → browser-BveHkbe-.js} +1 -1
  73. package/src/client/dist/pwa/assets/build-path-tree-Dtp7rlxS.js +1 -0
  74. package/src/client/dist/pwa/assets/custom-sounds-CBoWzJ85.js +1 -0
  75. package/src/client/dist/pwa/assets/{de-DgZ9jKq_.js → de-BJsWkZro.js} +2 -2
  76. package/src/client/dist/pwa/assets/defineProperty-BbfpZ9Tg.js +1 -0
  77. package/src/client/dist/pwa/assets/documents-sb2sQ4JC.js +1 -0
  78. package/src/client/dist/pwa/assets/{editor.api-PENN0io6.js → editor.api-CbkPxoJf.js} +1 -1
  79. package/src/client/dist/pwa/assets/efforts-BCli1Unj.js +9 -0
  80. package/src/client/dist/pwa/assets/{es-zvlgzw9H.js → es-tNYw7itB.js} +2 -2
  81. package/src/client/dist/pwa/assets/expand-template-CfaEhb2f.js +1 -0
  82. package/src/client/dist/pwa/assets/{format-CVI7mr7G.js → format-BRl2DUlm.js} +7 -7
  83. package/src/client/dist/pwa/assets/formatters-C5TjPSR_.js +1 -0
  84. package/src/client/dist/pwa/assets/{fr-BICzq59I.js → fr-B5sFski4.js} +2 -2
  85. package/src/client/dist/pwa/assets/{freemarker2-BxN_Aq6C.js → freemarker2-CDmvOQyn.js} +1 -1
  86. package/src/client/dist/pwa/assets/{handlebars-C9UHXtO1.js → handlebars-Bx_w8mx1.js} +1 -1
  87. package/src/client/dist/pwa/assets/{html-CTH9wSJr.js → html-DVRfiRkS.js} +1 -1
  88. package/src/client/dist/pwa/assets/i18n-Bm-L4nW3.js +1 -0
  89. package/src/client/dist/pwa/assets/i18n-D21JKsVt.js +19 -0
  90. package/src/client/dist/pwa/assets/index-C7vFf6p2.js +2 -0
  91. package/src/client/dist/pwa/assets/{it-BIGArQrS.js → it-Bc2FncM9.js} +2 -2
  92. package/src/client/dist/pwa/assets/{javascript-WT4BS8p4.js → javascript-CPvttU5J.js} +1 -1
  93. package/src/client/dist/pwa/assets/latest-request-CN4-B22z.js +1 -0
  94. package/src/client/dist/pwa/assets/{liquid-D6dfe-gU.js → liquid-CI0gQddb.js} +1 -1
  95. package/src/client/dist/pwa/assets/{mdx-B8pNXNeC.js → mdx-BveBwYJH.js} +1 -1
  96. package/src/client/dist/pwa/assets/{monaco-contributions-COLfslyi.js → monaco-contributions-CHXFh4P2.js} +1 -1
  97. package/src/client/dist/pwa/assets/monaco.contribution-DOK8t_9B.js +2 -0
  98. package/src/client/dist/pwa/assets/network-auth-CItnNZDh.js +1 -0
  99. package/src/client/dist/pwa/assets/{notify-theme-7MsG-MyN.js → notify-theme-BO6r1Rd7.js} +1 -1
  100. package/src/client/dist/pwa/assets/pending-diff-open-DpS4iyDM.js +1 -0
  101. package/src/client/dist/pwa/assets/{permissionModes-B-ciuUbz.js → permissionModes-Cc5VaOdC.js} +1 -1
  102. package/src/client/dist/pwa/assets/preload-helper-BZ1Pz5am.js +1 -0
  103. package/src/client/dist/pwa/assets/{project-color-Bo6iEcqN.js → project-color-Bnv02P9D.js} +1 -1
  104. package/src/client/dist/pwa/assets/{python-D2gb6Jjz.js → python-BesLCxpi.js} +1 -1
  105. package/src/client/dist/pwa/assets/{razor-dksrCQF2.js → razor-Dv1PMxuY.js} +1 -1
  106. package/src/client/dist/pwa/assets/{render-chat-markdown-px_6WDIX.js → render-chat-markdown-CafzCcmu.js} +1 -1
  107. package/src/client/dist/pwa/assets/{typescript-DTPyRTrW.js → typescript-26L2Xsu9.js} +1 -1
  108. package/src/client/dist/pwa/assets/use-checkbox-BLeif5-t.js +1 -0
  109. package/src/client/dist/pwa/assets/use-search-index-status-Mv3pIO6p.js +1 -0
  110. package/src/client/dist/pwa/assets/{use-tours-b00RruX_.js → use-tours-B5uIqxRS.js} +2 -2
  111. package/src/client/dist/pwa/assets/use-worktree-restore-BBChALUM.js +1 -0
  112. package/src/client/dist/pwa/assets/workspace-templates-CDY-MiH2.js +1 -0
  113. package/src/client/dist/pwa/assets/{xml-mxUZCM4r.js → xml-CkRojTcc.js} +1 -1
  114. package/src/client/dist/pwa/assets/{yaml-BuN8sUWC.js → yaml-DKkQHwew.js} +1 -1
  115. package/src/client/dist/pwa/index.html +6 -3
  116. package/src/client/dist/pwa/sw.js +1 -1
  117. package/src/client/dist/pwa/assets/ActivityFeed-DdGARu8o.js +0 -8
  118. package/src/client/dist/pwa/assets/ChangelogPage-CGenjiI5.js +0 -1
  119. package/src/client/dist/pwa/assets/ChangelogPage-DKJkFiU_.css +0 -1
  120. package/src/client/dist/pwa/assets/ComparisonPanel-CRRaDs2D.css +0 -1
  121. package/src/client/dist/pwa/assets/ComparisonPanel-aXgjEulK.js +0 -2
  122. package/src/client/dist/pwa/assets/CreatePage-B9C9J9Pw.css +0 -1
  123. package/src/client/dist/pwa/assets/CreatePage-BaB_30xJ.js +0 -2
  124. package/src/client/dist/pwa/assets/DashboardPage-DsbwmO2W.js +0 -1
  125. package/src/client/dist/pwa/assets/DashboardPage-XiCj24i2.css +0 -1
  126. package/src/client/dist/pwa/assets/DiffViewer--dUMf7b3.js +0 -8
  127. package/src/client/dist/pwa/assets/DocumentsPanel-DTjEUYUd.js +0 -1
  128. package/src/client/dist/pwa/assets/GitPanel-PUPLj0M8.js +0 -3
  129. package/src/client/dist/pwa/assets/HealthPage-BHgucifz.js +0 -1
  130. package/src/client/dist/pwa/assets/HealthPage-CFZ-tdKK.css +0 -1
  131. package/src/client/dist/pwa/assets/MainLayout-CQVLnTX3.js +0 -2
  132. package/src/client/dist/pwa/assets/QBanner-XE0EMe9L.js +0 -1
  133. package/src/client/dist/pwa/assets/QChip-Bt_JvnHb.js +0 -1
  134. package/src/client/dist/pwa/assets/QExpansionItem-CxEBY3jY.js +0 -1
  135. package/src/client/dist/pwa/assets/QItemSection-Cf6OZEH4.js +0 -1
  136. package/src/client/dist/pwa/assets/QList-Z90BspT4.js +0 -1
  137. package/src/client/dist/pwa/assets/QMarkupTable-Bwjknbmw.js +0 -1
  138. package/src/client/dist/pwa/assets/QSelect-CMzJDhi-.js +0 -1
  139. package/src/client/dist/pwa/assets/SchedulePanel-BYKSFoFj.css +0 -1
  140. package/src/client/dist/pwa/assets/SchedulePanel-DTVwe7sX.js +0 -1
  141. package/src/client/dist/pwa/assets/SearchPage-CtrR7dNr.js +0 -1
  142. package/src/client/dist/pwa/assets/SettingsPage-Bl_RNTGi.js +0 -9
  143. package/src/client/dist/pwa/assets/SplitWorkspacePage-Bac_Pzmm.js +0 -1
  144. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-BAk5l4Ik.js +0 -1
  145. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +0 -1
  146. package/src/client/dist/pwa/assets/WorkspacePage-FScR5YAD.js +0 -4
  147. package/src/client/dist/pwa/assets/build-path-tree-C0b61nxI.js +0 -1
  148. package/src/client/dist/pwa/assets/custom-sounds-DE4JLdGG.js +0 -1
  149. package/src/client/dist/pwa/assets/documents-CALwmao-.js +0 -1
  150. package/src/client/dist/pwa/assets/efforts-Detdm-FH.js +0 -9
  151. package/src/client/dist/pwa/assets/expand-template-ZIVLPdW2.js +0 -1
  152. package/src/client/dist/pwa/assets/formatters-C_zcfJBt.js +0 -1
  153. package/src/client/dist/pwa/assets/i18n-DG_vChZ3.js +0 -1
  154. package/src/client/dist/pwa/assets/index-fuLr-ZVa.js +0 -20
  155. package/src/client/dist/pwa/assets/monaco.contribution-CDhuQbzi.js +0 -2
  156. package/src/client/dist/pwa/assets/network-auth-QouPCx6A.js +0 -1
  157. package/src/client/dist/pwa/assets/pending-diff-open-CX24u9gW.js +0 -1
  158. package/src/client/dist/pwa/assets/pinia-BdFjQEqa.js +0 -1
  159. package/src/client/dist/pwa/assets/use-checkbox-Bh9EQhkV.js +0 -1
  160. package/src/client/dist/pwa/assets/use-search-index-status-_nCyACc7.js +0 -1
  161. package/src/client/dist/pwa/assets/use-worktree-restore-BDOZc9fk.js +0 -1
  162. 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
- function runGit(worktreePath, args) {
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
- return execFileSync('git', args.split(' '), { cwd: worktreePath, encoding: 'utf8', timeout: 10_000 }).trim();
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
- return '';
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 rows = getDb()
13
- .prepare(`SELECT type, payload FROM ws_events
14
- WHERE workspace_id = ? AND (? IS NULL OR session_id = ?)
15
- AND (type = 'user:message' OR (type = 'agent:event' AND json_extract(payload, '$.kind') = 'message:text'))
16
- ORDER BY rowid DESC LIMIT 6`)
17
- .all(workspaceId, sessionId ?? null, sessionId ?? null);
18
- return rows
19
- .reverse()
20
- .flatMap((row) => {
21
- try {
22
- const payload = JSON.parse(row.payload);
23
- const text = typeof payload.content === 'string' ? payload.content : typeof payload.text === 'string' ? payload.text : '';
24
- return text ? [text.slice(0, 1600)] : [];
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
- catch {
27
- return [];
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
- .join('\n\n');
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 changedFiles = runGit(workspace.worktreePath, 'status --short') || 'No uncommitted changes detected.';
38
- const diffStat = runGit(workspace.worktreePath, 'diff --stat') || 'No uncommitted diff stat available.';
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 { execFileSync } from 'node:child_process';
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
- function git(repoPath, args) {
45
- return execFileSync('git', args, {
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
- stdio: ['pipe', 'pipe', 'pipe'],
49
- timeout: GIT_TIMEOUT_MS,
50
- env: { ...process.env, LC_ALL: 'C', LANG: 'C', GIT_TERMINAL_PROMPT: '0' },
51
- }).trim();
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 (!gitOps.localBranchExists(projectPath, branch))
71
+ async function diagnoseBranch(projectPath, branch, localExists, remoteExists) {
72
+ if (!localExists)
56
73
  return { state: 'absent' };
57
- if (!gitOps.listRemoteBranches(projectPath).includes(`origin/${branch}`))
74
+ if (!remoteExists)
58
75
  return { state: 'in-sync' };
59
- const ahead = gitOps.getCommitCount(projectPath, branch, branch);
60
- const behind = gitOps.getCommitsBehind(projectPath, branch, branch);
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
- git(repoPath, ['merge-base', branch, `origin/${branch}`]);
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.getWorkingTreeStatus(worktreePath);
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 git(repoPath, ['rev-parse', ref]);
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 = safeRevParse(report.projectPath, report.headBranch);
144
- const remoteHead = safeRevParse(report.projectPath, `origin/${report.headBranch}`);
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 = gitOps.getIndexLockPath(projectPath);
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 (gitOps.localBranchExists(projectPath, headBranch) &&
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.getOngoingGitOperation(inspectPath) : null,
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
- git(repoPath, ['branch', '--force', branch, remoteRef]);
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
- git(repoPath, ['branch', backupBranch, branch]);
212
- git(repoPath, ['branch', '--force', branch, remoteRef]);
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 = git(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
223
- const originalCheckout = currentRef === 'HEAD' ? git(repoPath, ['rev-parse', 'HEAD']) : currentRef;
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
- git(repoPath, ['rebase', remoteRef, branch]);
249
+ await runGitAsync(repoPath, ['rebase', remoteRef, branch]);
226
250
  }
227
251
  catch (err) {
228
- const conflicted = gitOps.getConflictedFiles(repoPath);
229
- if (conflicted.length > 0 || gitOps.getOngoingGitOperation(repoPath) === 'rebase') {
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
- git(repoPath, ['checkout', originalCheckout]);
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.fetchSourceBranch(input.projectPath, input.headBranch);
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
- git(input.projectPath, ['worktree', 'prune']);
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
- gitOps.abortOngoingGitOperation(existingPath);
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
- gitOps.stashPush(existingPath, `kobo-pr-checkout-${input.headBranch}`);
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
- gitOps.commitAllChanges(existingPath, `chore: save work before checking out ${input.headBranch}`);
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
- gitOps.stashPush(existingPath, `kobo-pr-checkout-discard-${input.headBranch}`);
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.localBranchExists(input.projectPath, input.headBranch)
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
- git(worktreePath, ['merge', '--ff-only', remoteRef]);
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
- git(worktreePath, ['rebase', remoteRef]);
425
+ await runGitAsync(worktreePath, ['rebase', remoteRef]);
389
426
  }
390
427
  catch (err) {
391
- const conflicted = gitOps.getConflictedFiles(worktreePath);
392
- if (conflicted.length > 0 || gitOps.getOngoingGitOperation(worktreePath) === 'rebase') {
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
- git(repoPath, ['branch', backupBranch, branch]);
407
- git(worktreePath, ['reset', '--hard', remoteRef]);
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
- try {
265
- destroyTerminal(ws.id);
266
- }
267
- catch {
268
- // Terminal may not exist — ignore
269
- }
270
- const archived = archiveWorkspace(ws.id);
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 execFileAsync('git', ['fetch', 'origin', workspace.sourceBranch], { cwd: worktreePath });
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 = gitOps.getCommitsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
107
- const committedStats = gitOps.getDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
108
- const workingTreeStats = gitOps.getWorkingTreeDiffStats(worktreePath);
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
+ }