@loicngr/kobo 2.1.4 → 2.1.6
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 +8 -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 +84 -74
- package/dist/server/services/agent/engines/claude-code/event-mapper.js +33 -0
- package/dist/server/services/agent/engines/codex/engine.js +10 -1
- package/dist/server/services/agent/engines/codex/event-mapper.js +2 -0
- package/dist/server/services/agent/integration-mcp.js +4 -3
- package/dist/server/services/agent/orchestrator.js +1 -1
- package/dist/server/services/agent/streaming-batcher.js +14 -3
- 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 +6 -6
- package/src/client/dist/pwa/assets/ActivityFeed-BO1mKMJ0.js +1 -0
- package/src/client/dist/pwa/assets/ActivityFeed-ZTxXVmNM.css +1 -0
- package/src/client/dist/pwa/assets/ChangelogPage-BXNz7PGb.js +1 -0
- package/src/client/dist/pwa/assets/ChangelogPage-DVyx9ZjV.css +1 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-ByVbqYCs.css +1 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-CfmJHeH_.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-6_AP5zso.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-CMyl2j8Z.css +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-BKCrV6u0.js +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-CtaHaVdf.css +1 -0
- package/src/client/dist/pwa/assets/DiffViewer-BMzrb9fB.js +8 -0
- package/src/client/dist/pwa/assets/DocumentsPanel-CqGg38Oi.js +1 -0
- package/src/client/dist/pwa/assets/{DrawerToggleButton-Bh4FXdtJ.js → DrawerToggleButton-DKytIBlk.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-DRzhrs_V.js +3 -0
- package/src/client/dist/pwa/assets/HealthPage-DT8FGrKA.css +1 -0
- package/src/client/dist/pwa/assets/HealthPage-DWotmfkh.js +1 -0
- package/src/client/dist/pwa/assets/MainLayout-DkSavoiV.css +1 -0
- package/src/client/dist/pwa/assets/MainLayout-fRVZ01LN.js +2 -0
- package/src/client/dist/pwa/assets/QBanner-mzrnn82R.js +1 -0
- package/src/client/dist/pwa/assets/{QBtnDropdown-DVbxGTNb.js → QBtnDropdown-BGGvQIvH.js} +1 -1
- package/src/client/dist/pwa/assets/QChip-Chu7r6RZ.js +1 -0
- package/src/client/dist/pwa/assets/QExpansionItem-C2XXHOHk.js +1 -0
- package/src/client/dist/pwa/assets/QItemSection-CR3Kfibl.js +1 -0
- package/src/client/dist/pwa/assets/QList-DnezW6gW.js +1 -0
- package/src/client/dist/pwa/assets/QMarkupTable-wuDf50Oh.js +1 -0
- package/src/client/dist/pwa/assets/QScrollArea-BnmQ9US7.js +1 -0
- package/src/client/dist/pwa/assets/QSelect-BskTzVCM.js +1 -0
- package/src/client/dist/pwa/assets/{QTooltip-BLhkNT4s.js → QTooltip-CO84H0cq.js} +1 -1
- package/src/client/dist/pwa/assets/SchedulePanel-CBM_GpS3.css +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-CPirTYw5.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-CnT0-Hpe.js +1 -0
- package/src/client/dist/pwa/assets/{SettingsPage-BYdLXJEJ.css → SettingsPage-CpykRKq2.css} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-DCMZL7GO.js +9 -0
- package/src/client/dist/pwa/assets/SplitWorkspacePage-DwWsD7f5.js +1 -0
- package/src/client/dist/pwa/assets/{TerminalPanel-D2j5AA8S.js → TerminalPanel-C4dYabx6.js} +2 -2
- package/src/client/dist/pwa/assets/{TerminalPanel-DbVZk0vD.css → TerminalPanel-Dz9EE-Ph.css} +1 -1
- package/src/client/dist/pwa/assets/ToolCallItem-CjXWiKJc.js +8 -0
- package/src/client/dist/pwa/assets/ToolCallItem-DhjdG8vp.css +1 -0
- package/src/client/dist/pwa/assets/{TouchPan-C0EqFcrr.js → TouchPan-6fI8xFPq.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-DlMvPblb.js → TourReplayButton-DhbNI6O5.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CZxW7QSq.js +1 -0
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DSw8Upcp.css +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-B0wCQdev.js +4 -0
- package/src/client/dist/pwa/assets/{WorkspacePage--5HsnMQ1.css → WorkspacePage-BnW-gp3u.css} +1 -1
- package/src/client/dist/pwa/assets/{browser-DNEbLF-4.js → browser-CTkGHrN5.js} +1 -1
- package/src/client/dist/pwa/assets/build-path-tree-Bd50EZMW.js +1 -0
- package/src/client/dist/pwa/assets/custom-sounds-DoPDmcY5.js +1 -0
- package/src/client/dist/pwa/assets/de-BPtzBQu0.js +17 -0
- package/src/client/dist/pwa/assets/defineProperty-BbfpZ9Tg.js +1 -0
- package/src/client/dist/pwa/assets/{editor.api-PENN0io6.js → editor.api-CKcTKXcM.js} +1 -1
- package/src/client/dist/pwa/assets/efforts-CDsBbk8o.js +9 -0
- package/src/client/dist/pwa/assets/es-BoNOfG0w.js +17 -0
- package/src/client/dist/pwa/assets/expand-template-D6zFPzzB.js +1 -0
- package/src/client/dist/pwa/assets/{format-CVI7mr7G.js → format-MtzcnR3z.js} +7 -7
- package/src/client/dist/pwa/assets/formatters-DCjBZ47F.js +1 -0
- package/src/client/dist/pwa/assets/fr-Bu92fK5U.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-BxN_Aq6C.js → freemarker2-JGUTjYvR.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-C9UHXtO1.js → handlebars-BWStl2Hc.js} +1 -1
- package/src/client/dist/pwa/assets/{html-CTH9wSJr.js → html-CjGCxUEe.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-DKj-umtn.js +19 -0
- package/src/client/dist/pwa/assets/i18n-DtmbeJu8.js +1 -0
- package/src/client/dist/pwa/assets/index-BPmUmc6D.js +2 -0
- package/src/client/dist/pwa/assets/it-CrA4Q7oc.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-WT4BS8p4.js → javascript-C_jd0j2i.js} +1 -1
- package/src/client/dist/pwa/assets/latest-request-CBPb8UWQ.js +1 -0
- package/src/client/dist/pwa/assets/{liquid-D6dfe-gU.js → liquid-DMyQVGkp.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-B8pNXNeC.js → mdx-DeW5qzOU.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-COLfslyi.js → monaco-contributions-Be_o6s3m.js} +1 -1
- package/src/client/dist/pwa/assets/monaco.contribution-VUGNbVME.js +2 -0
- package/src/client/dist/pwa/assets/network-auth-CSx0jS5X.js +1 -0
- package/src/client/dist/pwa/assets/{notify-theme-7MsG-MyN.js → notify-theme-uhSG2r09.js} +1 -1
- package/src/client/dist/pwa/assets/{permissionModes-B-ciuUbz.js → permissionModes-Cc5VaOdC.js} +1 -1
- package/src/client/dist/pwa/assets/preload-helper-DjzSQ1t3.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-Bo6iEcqN.js → project-color-BQbOnYBN.js} +1 -1
- package/src/client/dist/pwa/assets/{python-D2gb6Jjz.js → python-C1v3VovI.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-dksrCQF2.js → razor-DMEmKSdO.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-rKyiSQID.js} +1 -1
- package/src/client/dist/pwa/assets/use-checkbox-BqDMQ6x7.js +1 -0
- package/src/client/dist/pwa/assets/use-search-index-status-BL3cb04x.js +1 -0
- package/src/client/dist/pwa/assets/{use-tours-b00RruX_.js → use-tours-Dum-bOyQ.js} +2 -2
- package/src/client/dist/pwa/assets/use-worktree-restore-BQyoz3Qa.js +1 -0
- package/src/client/dist/pwa/assets/workspace-templates-YRwFjkUh.js +1 -0
- package/src/client/dist/pwa/assets/{xml-mxUZCM4r.js → xml-U_pDcywM.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-BuN8sUWC.js → yaml-CRCd_ET9.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-DCmiJP-z.css +0 -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-CNmwCnDD.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/de-DgZ9jKq_.js +0 -17
- 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/es-zvlgzw9H.js +0 -17
- 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/fr-BICzq59I.js +0 -17
- 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/it-BIGArQrS.js +0 -17
- 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
|
@@ -247,7 +247,7 @@ Call kobo__submit_session_handoff with a Markdown report (at most ${HANDOFF_REPO
|
|
|
247
247
|
5. Open questions, uncertainties, and the concrete next action.
|
|
248
248
|
Do not claim checks you did not run. Reference source history where needed. After successful submission, end your turn immediately. The backend will start the fresh session; do not do that yourself.`;
|
|
249
249
|
}
|
|
250
|
-
function buildPrompt(row) {
|
|
250
|
+
async function buildPrompt(row) {
|
|
251
251
|
const workspace = workspaces.getWorkspaceWithTasks(row.workspace_id);
|
|
252
252
|
if (!workspace)
|
|
253
253
|
throw new SessionHandoffError('Workspace not found', 404);
|
|
@@ -265,7 +265,7 @@ function buildPrompt(row) {
|
|
|
265
265
|
const messages = getDb()
|
|
266
266
|
.prepare("SELECT id, state FROM auto_loop_messages WHERE workspace_id = ? AND state != 'delivered'")
|
|
267
267
|
.all(row.workspace_id);
|
|
268
|
-
return `# Kōbō session handoff\n\nContinue the current mission automatically in this fresh conversation. First verify the actual repository and Kōbō task state, then perform the next unfinished action. Preserve user constraints; this report is context, not authorization to override them. Agent-reported checks do not replace Kōbō verification requirements. Uncertain message deliveries require explicit acknowledgement; do not silently treat them as completed.\n\nSource session: ${row.source_session_id ?? 'none'}\nRead its history using kobo__read_workspace_events_csv with session_id=${row.source_session_id ?? '(no prior session)'}.\n\n## Initial mission\n${mission.slice(0, 32_000)}\n\n## Agent handoff\n${row.report ?? 'No LLM summary was generated. Recover missing decisions from the source conversation.'}\n\n## Current Kōbō tasks and reported evidence\n${tasks.slice(0, 48_000)}\n\n## Auto-loop and pending instructions\nThese are identifiers and statuses only. Do not execute or acknowledge queued instructions during this transfer. Kōbō will dispatch pending instructions separately; unknown deliveries require an explicit user decision.\nIntent: ${workspace.autoLoop}\n${JSON.stringify(run ?? null)}\n${JSON.stringify(messages).slice(0, 24_000)}\n\n${buildEngineHandoff(workspace, configurations.source.engine, configurations.target.engine, row.source_session_id)}`;
|
|
268
|
+
return `# Kōbō session handoff\n\nContinue the current mission automatically in this fresh conversation. First verify the actual repository and Kōbō task state, then perform the next unfinished action. Preserve user constraints; this report is context, not authorization to override them. Agent-reported checks do not replace Kōbō verification requirements. Uncertain message deliveries require explicit acknowledgement; do not silently treat them as completed.\n\nSource session: ${row.source_session_id ?? 'none'}\nRead its history using kobo__read_workspace_events_csv with session_id=${row.source_session_id ?? '(no prior session)'}.\n\n## Initial mission\n${mission.slice(0, 32_000)}\n\n## Agent handoff\n${row.report ?? 'No LLM summary was generated. Recover missing decisions from the source conversation.'}\n\n## Current Kōbō tasks and reported evidence\n${tasks.slice(0, 48_000)}\n\n## Auto-loop and pending instructions\nThese are identifiers and statuses only. Do not execute or acknowledge queued instructions during this transfer. Kōbō will dispatch pending instructions separately; unknown deliveries require an explicit user decision.\nIntent: ${workspace.autoLoop}\n${JSON.stringify(run ?? null)}\n${JSON.stringify(messages).slice(0, 24_000)}\n\n${await buildEngineHandoff(workspace, configurations.source.engine, configurations.target.engine, row.source_session_id)}`;
|
|
269
269
|
}
|
|
270
270
|
function writeReport(row, content) {
|
|
271
271
|
const workspace = workspaces.getWorkspace(row.workspace_id);
|
|
@@ -368,14 +368,16 @@ async function startTarget(runtime) {
|
|
|
368
368
|
if (runtime.cancelled || read(row.id).state === 'starting')
|
|
369
369
|
return;
|
|
370
370
|
clearTimer(runtime);
|
|
371
|
-
|
|
371
|
+
state(row.id, 'starting');
|
|
372
|
+
const prompt = await buildPrompt(row);
|
|
373
|
+
if (runtime.cancelled || read(row.id).state !== 'starting')
|
|
374
|
+
return;
|
|
372
375
|
writeReport(row, prompt);
|
|
373
376
|
configure(row.workspace_id, map(row).target);
|
|
374
377
|
const session = workspaces.createIdleSession(row.workspace_id);
|
|
375
378
|
getDb()
|
|
376
379
|
.prepare('UPDATE session_handoffs SET target_session_id = ?, generation_token = NULL WHERE id = ?')
|
|
377
380
|
.run(session.id, row.id);
|
|
378
|
-
state(row.id, 'starting');
|
|
379
381
|
let startError;
|
|
380
382
|
const completed = () => {
|
|
381
383
|
if (runtime.cancelled || read(row.id).state !== 'starting')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants, copyFileSync, existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { writeJsonFileAtomically } from '../utils/atomic-json-file.js';
|
|
4
4
|
import { getTemplatesPath } from '../utils/paths.js';
|
|
5
5
|
const LEGACY_DEFAULT_CONTENT_HASHES = {
|
|
6
6
|
'kobo-context': 'ee29ebb56186ce9537e682c25d9d7737ff8d824ab6f29c095c925b27eed70552',
|
|
@@ -13,42 +13,35 @@ const MAX_CONTENT_LENGTH = 4096;
|
|
|
13
13
|
const MAX_DESCRIPTION_LENGTH = 120;
|
|
14
14
|
/**
|
|
15
15
|
* Read the templates list from disk. Seeds with defaults if the file does
|
|
16
|
-
* not exist yet.
|
|
16
|
+
* not exist yet. Refuses damaged data so ordinary mutations cannot erase it.
|
|
17
17
|
*/
|
|
18
18
|
export function listTemplates() {
|
|
19
19
|
const filePath = getTemplatesPath();
|
|
20
20
|
if (!existsSync(filePath)) {
|
|
21
21
|
seedTemplates();
|
|
22
22
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (parsed.version !== CURRENT_FILE_VERSION) {
|
|
27
|
-
console.warn(`[templates-service] templates.json has version ${parsed.version}, expected ${CURRENT_FILE_VERSION}. Reading best-effort.`);
|
|
28
|
-
}
|
|
29
|
-
const templates = Array.isArray(parsed.templates) ? parsed.templates : [];
|
|
30
|
-
let changed = false;
|
|
31
|
-
for (const template of templates) {
|
|
32
|
-
const previousHash = LEGACY_DEFAULT_CONTENT_HASHES[template.slug];
|
|
33
|
-
if (!previousHash || typeof template.content !== 'string')
|
|
34
|
-
continue;
|
|
35
|
-
if (createHash('sha256').update(template.content).digest('hex') !== previousHash)
|
|
36
|
-
continue;
|
|
37
|
-
const next = DEFAULT_TEMPLATES.find((value) => value.slug === template.slug);
|
|
38
|
-
if (!next || next.content === template.content)
|
|
39
|
-
continue;
|
|
40
|
-
template.content = next.content;
|
|
41
|
-
template.updatedAt = new Date().toISOString();
|
|
42
|
-
changed = true;
|
|
43
|
-
}
|
|
44
|
-
if (changed)
|
|
45
|
-
writeTemplates(templates, parsed.seededDefaultSlugs);
|
|
46
|
-
return templates;
|
|
23
|
+
const parsed = readTemplatesFile();
|
|
24
|
+
if (parsed.version !== CURRENT_FILE_VERSION) {
|
|
25
|
+
console.warn(`[templates-service] templates.json has version ${parsed.version}, expected ${CURRENT_FILE_VERSION}. Reading best-effort.`);
|
|
47
26
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
27
|
+
const templates = parsed.templates;
|
|
28
|
+
let changed = false;
|
|
29
|
+
for (const template of templates) {
|
|
30
|
+
const previousHash = LEGACY_DEFAULT_CONTENT_HASHES[template.slug];
|
|
31
|
+
if (!previousHash)
|
|
32
|
+
continue;
|
|
33
|
+
if (createHash('sha256').update(template.content).digest('hex') !== previousHash)
|
|
34
|
+
continue;
|
|
35
|
+
const next = DEFAULT_TEMPLATES.find((value) => value.slug === template.slug);
|
|
36
|
+
if (!next || next.content === template.content)
|
|
37
|
+
continue;
|
|
38
|
+
template.content = next.content;
|
|
39
|
+
template.updatedAt = new Date().toISOString();
|
|
40
|
+
changed = true;
|
|
51
41
|
}
|
|
42
|
+
if (changed)
|
|
43
|
+
writeTemplates(templates, parsed.seededDefaultSlugs);
|
|
44
|
+
return templates;
|
|
52
45
|
}
|
|
53
46
|
/** Create a new template. Throws on invalid input or duplicate slug. */
|
|
54
47
|
export function createTemplate(input) {
|
|
@@ -140,31 +133,41 @@ function validateTemplateInput(input) {
|
|
|
140
133
|
throw new Error(`Invalid content: must be 1..${MAX_CONTENT_LENGTH} chars`);
|
|
141
134
|
}
|
|
142
135
|
}
|
|
143
|
-
function
|
|
144
|
-
const filePath = getTemplatesPath();
|
|
145
|
-
if (!existsSync(filePath))
|
|
146
|
-
return undefined;
|
|
136
|
+
function readTemplatesFile() {
|
|
147
137
|
try {
|
|
148
|
-
const parsed = JSON.parse(readFileSync(
|
|
149
|
-
|
|
138
|
+
const parsed = JSON.parse(readFileSync(getTemplatesPath(), 'utf-8'));
|
|
139
|
+
if (!parsed || !Array.isArray(parsed.templates))
|
|
140
|
+
throw new Error('Expected a templates array');
|
|
141
|
+
if (parsed.templates.some((template) => !template ||
|
|
142
|
+
typeof template.slug !== 'string' ||
|
|
143
|
+
typeof template.description !== 'string' ||
|
|
144
|
+
typeof template.content !== 'string' ||
|
|
145
|
+
typeof template.createdAt !== 'string' ||
|
|
146
|
+
typeof template.updatedAt !== 'string')) {
|
|
147
|
+
throw new Error('A template has missing or malformed fields');
|
|
148
|
+
}
|
|
149
|
+
if (parsed.seededDefaultSlugs !== undefined &&
|
|
150
|
+
(!Array.isArray(parsed.seededDefaultSlugs) || parsed.seededDefaultSlugs.some((slug) => typeof slug !== 'string'))) {
|
|
151
|
+
throw new Error('Expected seededDefaultSlugs to be an array of strings');
|
|
152
|
+
}
|
|
153
|
+
return parsed;
|
|
150
154
|
}
|
|
151
|
-
catch {
|
|
152
|
-
|
|
155
|
+
catch (error) {
|
|
156
|
+
throw new Error('Failed to read templates.json. Existing data was preserved; restore a valid file or import templates.', {
|
|
157
|
+
cause: error,
|
|
158
|
+
});
|
|
153
159
|
}
|
|
154
160
|
}
|
|
161
|
+
function readSeededSlugs() {
|
|
162
|
+
return existsSync(getTemplatesPath()) ? readTemplatesFile().seededDefaultSlugs : undefined;
|
|
163
|
+
}
|
|
155
164
|
function writeTemplates(templates, seededDefaultSlugs) {
|
|
156
|
-
const filePath = getTemplatesPath();
|
|
157
|
-
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
158
165
|
const seeded = seededDefaultSlugs ?? readSeededSlugs() ?? [...LEGACY_DEFAULT_SLUGS];
|
|
159
166
|
const file = { version: CURRENT_FILE_VERSION, templates, seededDefaultSlugs: seeded };
|
|
160
|
-
|
|
167
|
+
writeJsonFileAtomically(getTemplatesPath(), file);
|
|
161
168
|
}
|
|
162
|
-
/**
|
|
163
|
-
|
|
164
|
-
* rejects the whole write on any invalid row — do not partially accept.
|
|
165
|
-
* Used by config import.
|
|
166
|
-
*/
|
|
167
|
-
export function replaceAllTemplates(templates) {
|
|
169
|
+
/** Validate a whole import before any file in the configuration bundle changes. */
|
|
170
|
+
export function validateTemplatesForReplacement(templates) {
|
|
168
171
|
if (!Array.isArray(templates)) {
|
|
169
172
|
throw new Error('Invalid templates payload: expected an array');
|
|
170
173
|
}
|
|
@@ -188,7 +191,22 @@ export function replaceAllTemplates(templates) {
|
|
|
188
191
|
const updatedAt = typeof t.updatedAt === 'string' ? t.updatedAt : now;
|
|
189
192
|
validated.push({ slug, description, content, createdAt, updatedAt });
|
|
190
193
|
}
|
|
191
|
-
|
|
194
|
+
return validated;
|
|
195
|
+
}
|
|
196
|
+
/** Replace the list atomically, preserving damaged existing data before recovery. */
|
|
197
|
+
export function replaceAllTemplates(templates) {
|
|
198
|
+
const validated = validateTemplatesForReplacement(templates);
|
|
199
|
+
let seededDefaultSlugs;
|
|
200
|
+
try {
|
|
201
|
+
seededDefaultSlugs = readSeededSlugs();
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Import is an explicit replacement. Retain damaged bytes for recovery,
|
|
205
|
+
// and abort the replacement if the backup cannot be written.
|
|
206
|
+
const filePath = getTemplatesPath();
|
|
207
|
+
copyFileSync(filePath, `${filePath}.recovery-${randomUUID()}.bak`, constants.COPYFILE_EXCL);
|
|
208
|
+
}
|
|
209
|
+
writeTemplates(validated, seededDefaultSlugs ?? [...LEGACY_DEFAULT_SLUGS]);
|
|
192
210
|
}
|
|
193
211
|
/**
|
|
194
212
|
* The default slugs that existed BEFORE the seededDefaultSlugs watermark shipped.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import * as pty from 'node-pty';
|
|
3
3
|
import { ensureSpawnHelperExecutable } from '../utils/node-pty-spawn-helper.js';
|
|
4
|
+
export class TerminalStopError extends Error {
|
|
5
|
+
}
|
|
4
6
|
const terminals = new Map();
|
|
5
7
|
let spawnHelperChecked = false;
|
|
6
8
|
function prepareSpawnHelper() {
|
|
@@ -17,8 +19,11 @@ function prepareSpawnHelper() {
|
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
21
|
export function createTerminal(workspaceId, cwd) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
const existing = terminals.get(workspaceId);
|
|
23
|
+
if (existing) {
|
|
24
|
+
if (existing.closing)
|
|
25
|
+
throw new TerminalStopError('Terminal is closing; wait for confirmed exit before reopening it');
|
|
26
|
+
return existing.pty;
|
|
22
27
|
}
|
|
23
28
|
if (!fs.existsSync(cwd)) {
|
|
24
29
|
throw new Error(`Worktree directory does not exist: ${cwd}`);
|
|
@@ -32,29 +37,55 @@ export function createTerminal(workspaceId, cwd) {
|
|
|
32
37
|
cwd,
|
|
33
38
|
env: process.env,
|
|
34
39
|
});
|
|
35
|
-
|
|
40
|
+
let resolveClosed;
|
|
41
|
+
const closed = new Promise((resolve) => {
|
|
42
|
+
resolveClosed = resolve;
|
|
43
|
+
});
|
|
44
|
+
const instance = { pty: term, closing: false, closed, resolveClosed };
|
|
45
|
+
terminals.set(workspaceId, instance);
|
|
36
46
|
term.onExit(() => {
|
|
37
|
-
terminals.
|
|
47
|
+
if (terminals.get(workspaceId) === instance)
|
|
48
|
+
terminals.delete(workspaceId);
|
|
49
|
+
instance.resolveClosed();
|
|
38
50
|
});
|
|
39
51
|
return term;
|
|
40
52
|
}
|
|
41
53
|
export function getTerminal(workspaceId) {
|
|
42
|
-
|
|
54
|
+
const instance = terminals.get(workspaceId);
|
|
55
|
+
return instance && !instance.closing ? instance.pty : null;
|
|
43
56
|
}
|
|
44
|
-
export function destroyTerminal(workspaceId) {
|
|
57
|
+
export function destroyTerminal(workspaceId, timeoutMs = 2000) {
|
|
45
58
|
const instance = terminals.get(workspaceId);
|
|
46
|
-
if (instance)
|
|
59
|
+
if (!instance)
|
|
60
|
+
return Promise.resolve();
|
|
61
|
+
if (instance.stop)
|
|
62
|
+
return instance.stop;
|
|
63
|
+
instance.closing = true;
|
|
64
|
+
const stopped = new Promise((resolve, reject) => {
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
reject(new TerminalStopError(`Terminal exit could not be confirmed for workspace ${workspaceId}`));
|
|
67
|
+
}, timeoutMs);
|
|
68
|
+
instance.closed.then(() => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
resolve();
|
|
71
|
+
});
|
|
47
72
|
try {
|
|
48
73
|
instance.pty.kill();
|
|
49
74
|
}
|
|
50
75
|
catch (err) {
|
|
51
|
-
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
reject(new TerminalStopError(`Failed to stop terminal: ${err instanceof Error ? err.message : String(err)}`));
|
|
52
78
|
}
|
|
53
|
-
|
|
54
|
-
|
|
79
|
+
});
|
|
80
|
+
instance.stop = stopped.catch((error) => {
|
|
81
|
+
instance.stop = undefined;
|
|
82
|
+
throw error;
|
|
83
|
+
});
|
|
84
|
+
return instance.stop;
|
|
55
85
|
}
|
|
56
|
-
export function destroyAllTerminals() {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
86
|
+
export async function destroyAllTerminals() {
|
|
87
|
+
const results = await Promise.allSettled([...terminals.keys()].map((id) => destroyTerminal(id)));
|
|
88
|
+
const failures = results.filter((result) => result.status === 'rejected');
|
|
89
|
+
if (failures.length)
|
|
90
|
+
throw new AggregateError(failures.map((result) => result.reason), 'Some terminals did not stop');
|
|
60
91
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { assertWorkspaceLifecycleAvailable, isWorkspaceLifecycleBusy } from '../utils/workspace-lifecycle-guard.js';
|
|
2
|
+
import { createTerminal, getTerminal } from './terminal-service.js';
|
|
3
|
+
import { getWorkspace } from './workspace-service.js';
|
|
4
|
+
const MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
5
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
6
|
+
/** Each client owns its subscriptions and output budget, never the shared PTY. */
|
|
7
|
+
export function handleTerminalConnection(ws, workspaceId, isShuttingDown = () => false) {
|
|
8
|
+
let currentPty = null;
|
|
9
|
+
let dataDisposable = null;
|
|
10
|
+
let exitDisposable = null;
|
|
11
|
+
let disposed = false;
|
|
12
|
+
let alive = true;
|
|
13
|
+
function detachTerminal() {
|
|
14
|
+
dataDisposable?.dispose();
|
|
15
|
+
exitDisposable?.dispose();
|
|
16
|
+
dataDisposable = null;
|
|
17
|
+
exitDisposable = null;
|
|
18
|
+
currentPty = null;
|
|
19
|
+
}
|
|
20
|
+
function dispose() {
|
|
21
|
+
if (disposed)
|
|
22
|
+
return;
|
|
23
|
+
disposed = true;
|
|
24
|
+
clearInterval(heartbeat);
|
|
25
|
+
detachTerminal();
|
|
26
|
+
}
|
|
27
|
+
function disconnect() {
|
|
28
|
+
if (disposed)
|
|
29
|
+
return;
|
|
30
|
+
dispose();
|
|
31
|
+
ws.terminate();
|
|
32
|
+
}
|
|
33
|
+
function send(data) {
|
|
34
|
+
if (disposed || ws.readyState !== 1)
|
|
35
|
+
return false;
|
|
36
|
+
if (ws.bufferedAmount + Buffer.byteLength(data) > MAX_BUFFERED_BYTES) {
|
|
37
|
+
disconnect();
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
ws.send(data, { binary: Buffer.isBuffer(data) }, (error) => {
|
|
42
|
+
if (error)
|
|
43
|
+
disconnect();
|
|
44
|
+
});
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
disconnect();
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function sendError(error) {
|
|
53
|
+
send(JSON.stringify({ type: 'error', message: error instanceof Error ? error.message : String(error) }));
|
|
54
|
+
}
|
|
55
|
+
const heartbeat = setInterval(() => {
|
|
56
|
+
if (ws.readyState !== 1 || !alive) {
|
|
57
|
+
disconnect();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
alive = false;
|
|
61
|
+
try {
|
|
62
|
+
ws.ping();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
disconnect();
|
|
66
|
+
}
|
|
67
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
68
|
+
heartbeat.unref?.();
|
|
69
|
+
ws.on('pong', () => {
|
|
70
|
+
alive = true;
|
|
71
|
+
});
|
|
72
|
+
ws.on('close', dispose);
|
|
73
|
+
ws.on('error', disconnect);
|
|
74
|
+
ws.on('message', (data, isBinary) => {
|
|
75
|
+
if (disposed || ws.readyState !== 1 || isShuttingDown())
|
|
76
|
+
return;
|
|
77
|
+
if (isBinary) {
|
|
78
|
+
if (currentPty && getTerminal(workspaceId) === currentPty && !isWorkspaceLifecycleBusy(workspaceId)) {
|
|
79
|
+
try {
|
|
80
|
+
currentPty.write(data.toString());
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
sendError(error);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
let message;
|
|
89
|
+
try {
|
|
90
|
+
message = JSON.parse(data.toString());
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!message || typeof message !== 'object' || !('type' in message))
|
|
96
|
+
return;
|
|
97
|
+
if (message.type === 'create') {
|
|
98
|
+
try {
|
|
99
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
100
|
+
const workspace = getWorkspace(workspaceId);
|
|
101
|
+
if (!workspace)
|
|
102
|
+
throw new Error('Workspace not found');
|
|
103
|
+
if (workspace.archivedAt)
|
|
104
|
+
throw new Error('Workspace is archived');
|
|
105
|
+
if (workspace.worktreePurgedAt)
|
|
106
|
+
throw new Error('Workspace worktree is purged');
|
|
107
|
+
detachTerminal();
|
|
108
|
+
const terminal = createTerminal(workspaceId, workspace.worktreePath);
|
|
109
|
+
currentPty = terminal;
|
|
110
|
+
dataDisposable = terminal.onData((output) => {
|
|
111
|
+
send(Buffer.from(output));
|
|
112
|
+
});
|
|
113
|
+
exitDisposable = terminal.onExit(({ exitCode }) => {
|
|
114
|
+
if (send(JSON.stringify({ type: 'exited', code: exitCode })))
|
|
115
|
+
ws.close();
|
|
116
|
+
dispose();
|
|
117
|
+
});
|
|
118
|
+
send(JSON.stringify({ type: 'ready' }));
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
sendError(error);
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (message.type === 'resize' &&
|
|
126
|
+
'cols' in message &&
|
|
127
|
+
'rows' in message &&
|
|
128
|
+
typeof message.cols === 'number' &&
|
|
129
|
+
typeof message.rows === 'number' &&
|
|
130
|
+
Number.isFinite(message.cols) &&
|
|
131
|
+
Number.isFinite(message.rows) &&
|
|
132
|
+
currentPty &&
|
|
133
|
+
getTerminal(workspaceId) === currentPty &&
|
|
134
|
+
!isWorkspaceLifecycleBusy(workspaceId)) {
|
|
135
|
+
try {
|
|
136
|
+
currentPty.resize(Math.max(1, Math.min(1000, Math.floor(message.cols))), Math.max(1, Math.min(1000, Math.floor(message.rows))));
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
sendError(error);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -53,28 +53,30 @@ function defer(workspaceId, row) {
|
|
|
53
53
|
}
|
|
54
54
|
/** Schedule a wakeup for the given workspace. Replaces any existing pending wakeup. */
|
|
55
55
|
export function schedule(workspaceId, delaySeconds, prompt, reason, agentSessionId) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
56
|
+
const clampedSeconds = clamp(Math.floor(delaySeconds), MIN_DELAY_SECONDS, MAX_DELAY_SECONDS);
|
|
57
|
+
const effectivePrompt = prompt === AUTONOMOUS_LOOP_SENTINEL ? AUTONOMOUS_LOOP_FALLBACK_PROMPT : prompt;
|
|
58
|
+
const targetAtIso = new Date(Date.now() + clampedSeconds * 1000).toISOString();
|
|
59
|
+
// Commit before changing the live timer. A failed replacement must leave
|
|
60
|
+
// the original durable wakeup and its timer intact, and fail the MCP receipt.
|
|
61
|
+
const row = getDb()
|
|
62
|
+
.prepare(`INSERT OR REPLACE INTO pending_wakeups
|
|
63
|
+
(workspace_id, target_at, prompt, reason, created_at, agent_session_id)
|
|
64
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
65
|
+
RETURNING target_at, reason`)
|
|
66
|
+
.get(workspaceId, targetAtIso, effectivePrompt, reason ?? null, new Date().toISOString(), agentSessionId ?? null);
|
|
67
|
+
const pending = { targetAt: row.target_at, reason: row.reason ?? undefined };
|
|
68
|
+
const existing = timers.get(workspaceId);
|
|
69
|
+
if (existing)
|
|
70
|
+
clearTimeout(existing);
|
|
71
|
+
timers.delete(workspaceId);
|
|
72
|
+
failedRetries.delete(workspaceId);
|
|
73
|
+
if (!suspended) {
|
|
74
|
+
const timeout = setTimeout(() => fire(workspaceId), clampedSeconds * 1000);
|
|
75
|
+
timeout.unref?.();
|
|
76
|
+
timers.set(workspaceId, timeout);
|
|
77
77
|
}
|
|
78
|
+
emitEphemeral(workspaceId, 'wakeup:scheduled', pending);
|
|
79
|
+
return pending;
|
|
78
80
|
}
|
|
79
81
|
/** Move a source conversation's pending check only once its handoff succeeds. */
|
|
80
82
|
export function transferSession(workspaceId, sourceSessionId, targetSessionId) {
|
|
@@ -9,6 +9,7 @@ import * as autoLoopService from './auto-loop-service.js';
|
|
|
9
9
|
import * as cronService from './cron-service.js';
|
|
10
10
|
import * as quotaBackoffService from './quota-backoff-service.js';
|
|
11
11
|
import { SESSION_RECENCY_ORDER } from './session-activity-service.js';
|
|
12
|
+
import { recomputeSessionMetricsOn } from './session-event-metrics.js';
|
|
12
13
|
import { createTaskRecord, deleteTaskRecord, updateTaskRecord } from './task-mutations.js';
|
|
13
14
|
import * as wakeupService from './wakeup-service.js';
|
|
14
15
|
import { emitEphemeral } from './websocket-service.js';
|
|
@@ -153,7 +154,7 @@ export function createWorkspace(data) {
|
|
|
153
154
|
notion_url, notion_page_id, sentry_url, pr_url, worktree_path, worktree_owned,
|
|
154
155
|
model, brainstorm_model, reasoning_effort, permission_mode, permission_profile, agent_permission_mode, engine, comparison_id, created_at, updated_at, workflow_policy
|
|
155
156
|
) VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
156
|
-
`).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.sentryUrl ?? null, data.prUrl ?? null, computedWorktreePath, owned ? 1 : 0, data.model ?? '
|
|
157
|
+
`).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.sentryUrl ?? null, data.prUrl ?? null, computedWorktreePath, owned ? 1 : 0, data.model ?? 'auto', data.brainstormModel ?? null, data.reasoningEffort ?? 'auto', legacyMode, legacyProfile, unifiedMode, data.engine ?? 'claude-code',
|
|
157
158
|
// Empty string is not a group: it would silently gather every workspace
|
|
158
159
|
// created with a blank id into one comparison.
|
|
159
160
|
data.comparisonId?.trim() ? data.comparisonId : null, now, now, JSON.stringify(workflowPolicy));
|
|
@@ -899,49 +900,7 @@ export function deleteSession(sessionId, workspaceId) {
|
|
|
899
900
|
transaction();
|
|
900
901
|
return true;
|
|
901
902
|
}
|
|
902
|
-
|
|
903
|
-
* Recompute one session's persisted metrics from the events that remain, on an
|
|
904
|
-
* explicit database handle.
|
|
905
|
-
*
|
|
906
|
-
* Until v36 an `AFTER DELETE ... FOR EACH ROW` trigger did this per deleted
|
|
907
|
-
* row, re-aggregating the whole session every time — 79.7 s for 20 000 events,
|
|
908
|
-
* with the SQLite write lock held throughout. The trigger is gone; every code
|
|
909
|
-
* path that deletes `agent:event` rows while KEEPING the session calls this
|
|
910
|
-
* once, at the end of its transaction.
|
|
911
|
-
*/
|
|
912
|
-
export function recomputeSessionMetricsOn(db, workspaceId, sessionId) {
|
|
913
|
-
db.prepare('DELETE FROM session_event_metrics WHERE workspace_id = ? AND session_id = ?').run(workspaceId, sessionId);
|
|
914
|
-
db.prepare(`INSERT INTO session_event_metrics (
|
|
915
|
-
workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
|
|
916
|
-
)
|
|
917
|
-
SELECT
|
|
918
|
-
e.workspace_id,
|
|
919
|
-
e.session_id,
|
|
920
|
-
SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END),
|
|
921
|
-
SUM(CASE
|
|
922
|
-
WHEN json_extract(e.payload, '$.kind') = 'error'
|
|
923
|
-
OR (json_extract(e.payload, '$.kind') = 'tool:result'
|
|
924
|
-
AND json_extract(e.payload, '$.isError') = 1)
|
|
925
|
-
THEN 1 ELSE 0
|
|
926
|
-
END),
|
|
927
|
-
MAX(CASE
|
|
928
|
-
WHEN json_extract(e.payload, '$.kind') = 'usage'
|
|
929
|
-
AND json_type(e.payload, '$.inputTokens') IN ('integer', 'real')
|
|
930
|
-
THEN CAST(json_extract(e.payload, '$.inputTokens') AS INTEGER) ELSE 0
|
|
931
|
-
END),
|
|
932
|
-
MAX(CASE
|
|
933
|
-
WHEN json_extract(e.payload, '$.kind') = 'usage'
|
|
934
|
-
AND json_type(e.payload, '$.outputTokens') IN ('integer', 'real')
|
|
935
|
-
THEN CAST(json_extract(e.payload, '$.outputTokens') AS INTEGER) ELSE 0
|
|
936
|
-
END)
|
|
937
|
-
FROM ws_events e
|
|
938
|
-
JOIN agent_sessions s ON s.id = e.session_id AND s.workspace_id = e.workspace_id
|
|
939
|
-
WHERE e.workspace_id = ?
|
|
940
|
-
AND e.session_id = ?
|
|
941
|
-
AND e.type = 'agent:event'
|
|
942
|
-
AND json_valid(e.payload)
|
|
943
|
-
GROUP BY e.workspace_id, e.session_id`).run(workspaceId, sessionId);
|
|
944
|
-
}
|
|
903
|
+
export { recomputeSessionMetricsOn } from './session-event-metrics.js';
|
|
945
904
|
/** Same, on the singleton connection. */
|
|
946
905
|
export function recomputeSessionMetrics(workspaceId, sessionId) {
|
|
947
906
|
recomputeSessionMetricsOn(getDb(), workspaceId, sessionId);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import path from 'node:path';
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
2
|
import { nanoid } from 'nanoid';
|
|
4
3
|
import { getDb } from '../db/index.js';
|
|
4
|
+
import { writeJsonFileAtomically } from '../utils/atomic-json-file.js';
|
|
5
5
|
import { getWorkspaceTemplatesPath } from '../utils/paths.js';
|
|
6
6
|
const FILE_VERSION = 1;
|
|
7
7
|
export const MAX_WORKSPACE_TEMPLATES = 100;
|
|
@@ -71,32 +71,30 @@ function readFile() {
|
|
|
71
71
|
return [];
|
|
72
72
|
try {
|
|
73
73
|
const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
74
|
+
if (!parsed || !Array.isArray(parsed.templates))
|
|
75
|
+
throw new Error('Expected a templates array');
|
|
74
76
|
if (parsed.version !== FILE_VERSION) {
|
|
75
77
|
console.warn(`[workspace-template-service] workspace-templates.json has version ${parsed.version}, expected ${FILE_VERSION}. Reading best-effort.`);
|
|
76
78
|
}
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
+
if (parsed.templates.some((t) => !t ||
|
|
80
|
+
typeof t.id !== 'string' ||
|
|
81
|
+
typeof t.name !== 'string' ||
|
|
82
|
+
typeof t.createdAt !== 'string' ||
|
|
83
|
+
typeof t.updatedAt !== 'string')) {
|
|
84
|
+
throw new Error('A workspace template has missing or malformed fields');
|
|
85
|
+
}
|
|
79
86
|
// Re-sanitise on read: the file may have been edited by hand.
|
|
80
|
-
return parsed.templates
|
|
81
|
-
.filter((t) => t &&
|
|
82
|
-
typeof t.id === 'string' &&
|
|
83
|
-
typeof t.name === 'string' &&
|
|
84
|
-
typeof t.createdAt === 'string' &&
|
|
85
|
-
typeof t.updatedAt === 'string')
|
|
86
|
-
.map((t) => ({ ...t, preset: sanitizePreset(t.preset) }));
|
|
87
|
+
return parsed.templates.map((t) => ({ ...t, preset: sanitizePreset(t.preset) }));
|
|
87
88
|
}
|
|
88
89
|
catch (err) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return [];
|
|
90
|
+
throw new Error('Failed to read workspace-templates.json. Existing data was preserved; restore a valid file.', {
|
|
91
|
+
cause: err,
|
|
92
|
+
});
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
function writeFile(templates) {
|
|
96
|
-
const filePath = getWorkspaceTemplatesPath();
|
|
97
|
-
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
98
96
|
const file = { version: FILE_VERSION, templates };
|
|
99
|
-
|
|
97
|
+
writeJsonFileAtomically(getWorkspaceTemplatesPath(), file);
|
|
100
98
|
}
|
|
101
99
|
export function listWorkspaceTemplates() {
|
|
102
100
|
return readFile();
|
|
@@ -39,10 +39,7 @@ async function purgeWorktreeGuarded(workspaceId, expectedArchivedAt) {
|
|
|
39
39
|
logError('purge', `stopDevServer failed for '${workspace.name}'`, { workspaceId: workspace.id, error: msg });
|
|
40
40
|
throw err;
|
|
41
41
|
}
|
|
42
|
-
|
|
43
|
-
destroyTerminal(workspaceId);
|
|
44
|
-
}
|
|
45
|
-
catch { }
|
|
42
|
+
await destroyTerminal(workspaceId);
|
|
46
43
|
// Snapshot the forge BEFORE removing the worktree — the PR lookup uses
|
|
47
44
|
// `worktreePath` as cwd.
|
|
48
45
|
const restoreData = await captureRestoreData(workspace);
|