@loicngr/kobo 1.11.16 → 1.11.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +13 -0
- package/dist/mcp-server/kobo-tasks-server.js +8 -7
- package/dist/server/db/migrations.js +57 -0
- package/dist/server/db/schema.js +10 -39
- package/dist/server/index.js +55 -7
- package/dist/server/routes/git.js +8 -3
- package/dist/server/routes/health.js +19 -2
- package/dist/server/routes/settings.js +24 -0
- package/dist/server/routes/workspaces.js +379 -72
- package/dist/server/services/agent/engines/claude-code/engine.js +39 -35
- package/dist/server/services/agent/engines/codex/client.js +16 -4
- package/dist/server/services/agent/engines/codex/engine.js +23 -2
- package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +44 -5
- package/dist/server/services/agent/engines/types.js +13 -0
- package/dist/server/services/agent/orchestrator.js +354 -133
- package/dist/server/services/agent/session-controller.js +27 -0
- package/dist/server/services/agent/{engines/codex/turn-liveness.js → turn-liveness.js} +6 -2
- package/dist/server/services/change-source-branch-service.js +115 -11
- package/dist/server/services/db-backup-service.js +27 -16
- package/dist/server/services/pr-watcher-service.js +17 -2
- package/dist/server/services/settings-service.js +39 -0
- package/dist/server/services/websocket-service.js +114 -48
- package/dist/server/services/workspace-service.js +54 -2
- package/dist/server/services/worktree-purge-service.js +19 -5
- package/dist/server/services/worktree-service.js +38 -1
- package/dist/server/services/ws-events-retention-service.js +133 -0
- package/dist/server/utils/git-ops.js +209 -14
- package/dist/server/utils/git-repo-lock.js +45 -0
- package/dist/server/utils/logger.js +108 -0
- package/dist/server/utils/script-runner.js +11 -0
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-BlDBh5jB.js +8 -0
- package/src/client/dist/pwa/assets/ActivityFeed-tC8sR0GV.css +1 -0
- package/src/client/dist/pwa/assets/ChangelogPage-DACtknly.js +1 -0
- package/src/client/dist/pwa/assets/ChangelogPage-D_WOVByV.css +1 -0
- package/src/client/dist/pwa/assets/ClosePopup-DEy0yFXE.js +1 -0
- package/src/client/dist/pwa/assets/CreatePage-Bb-Qn-Rd.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-RMBX-Zjn.css +1 -0
- package/src/client/dist/pwa/assets/DiffViewer-DKt6gFi4.js +8 -0
- package/src/client/dist/pwa/assets/DiffViewer-ahWtFrRy.css +1 -0
- package/src/client/dist/pwa/assets/DocumentsPanel-B8QY7FTj.css +1 -0
- package/src/client/dist/pwa/assets/DocumentsPanel-DhP1jk11.js +1 -0
- package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +1 -0
- package/src/client/dist/pwa/assets/GitPanel-BKrtkPrV.css +1 -0
- package/src/client/dist/pwa/assets/GitPanel-DNCCB0G_.js +3 -0
- package/src/client/dist/pwa/assets/HealthPage-B1J4-dOH.js +1 -0
- package/src/client/dist/pwa/assets/HealthPage-BbqLoFQj.css +1 -0
- package/src/client/dist/pwa/assets/MainLayout-F8ShDnAy.css +1 -0
- package/src/client/dist/pwa/assets/MainLayout-qWwDy1B5.js +2 -0
- package/src/client/dist/pwa/assets/QBadge-BgS0-MXB.js +1 -0
- package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +1 -0
- package/src/client/dist/pwa/assets/QBtn-B7WFdfmy.js +1 -0
- package/src/client/dist/pwa/assets/QBtnGroup-BMQlX2YS.js +1 -0
- package/src/client/dist/pwa/assets/QBtnToggle-eMu_yinY.js +1 -0
- package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +1 -0
- package/src/client/dist/pwa/assets/QExpansionItem-C8CWo-aP.js +1 -0
- package/src/client/dist/pwa/assets/QItemSection-CDBweQnA.js +1 -0
- package/src/client/dist/pwa/assets/QList-Ccqhimcb.js +1 -0
- package/src/client/dist/pwa/assets/QPage-RpDxx5ah.js +1 -0
- package/src/client/dist/pwa/assets/QSelect-D9bHHxAA.js +1 -0
- package/src/client/dist/pwa/assets/QSlideTransition-6lWJvjLP.js +1 -0
- package/src/client/dist/pwa/assets/QSpace-D3PNLwa4.js +1 -0
- package/src/client/dist/pwa/assets/QSpinnerDots-DWOFEezR.js +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-Brtqtz1H.js +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +1 -0
- package/src/client/dist/pwa/assets/SearchPage-CvZxJiJs.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-DdWM01rq.css +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-JVje8OPs.js +16 -0
- package/src/client/dist/pwa/assets/SettingsPage-dMAg4JP8.css +1 -0
- package/src/client/dist/pwa/assets/TerminalPanel-B6xJJs8p.css +1 -0
- package/src/client/dist/pwa/assets/TerminalPanel-Dtc6B3US.js +18 -0
- package/src/client/dist/pwa/assets/TouchPan-DnEDaTFL.js +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-BVxtefbj.js +4 -0
- package/src/client/dist/pwa/assets/WorkspacePage-DN-A6XpI.css +1 -0
- package/src/client/dist/pwa/assets/build-path-tree-BU8Y8vRw.js +1 -0
- package/src/client/dist/pwa/assets/clipboard-t9qe6lK7.js +1 -0
- package/src/client/dist/pwa/assets/{cssMode-DNEi2cLH.js → cssMode-D_JDSbNP.js} +1 -1
- package/src/client/dist/pwa/assets/debounce-CDIE7CV6.js +1 -0
- package/src/client/dist/pwa/assets/documents-B6eOZeFK.js +1 -0
- package/src/client/dist/pwa/assets/{editor.api-_tFrtCR7.js → editor.api-Ccbr7NXr.js} +1 -1
- package/src/client/dist/pwa/assets/engineFeatures-DW9PydRL.js +1 -0
- package/src/client/dist/pwa/assets/expand-template-B4LAsXAF.css +1 -0
- package/src/client/dist/pwa/assets/expand-template-Xq6JwXsl.js +1 -0
- package/src/client/dist/pwa/assets/{freemarker2-kfVsFQjE.js → freemarker2-CUvQzJdx.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-EwBi9SHt.js → handlebars-BXiAPlES.js} +1 -1
- package/src/client/dist/pwa/assets/{html-BOOCP_vn.js → html-CLmfyTMC.js} +1 -1
- package/src/client/dist/pwa/assets/{htmlMode-IN9oEIXW.js → htmlMode-DWYo6_e_.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-CJclRByY.js +1 -0
- package/src/client/dist/pwa/assets/index-Bri8-39E.css +1 -0
- package/src/client/dist/pwa/assets/index-Dv1p-1sA.js +82 -0
- package/src/client/dist/pwa/assets/{javascript-CfV8qmeH.js → javascript-C9gWFLC_.js} +1 -1
- package/src/client/dist/pwa/assets/{jsonMode-C0b3m4VO.js → jsonMode-CbsjeFb8.js} +1 -1
- package/src/client/dist/pwa/assets/kobo-commands-DGygmOvc.js +9 -0
- package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +1 -0
- package/src/client/dist/pwa/assets/{liquid-D3YK93J-.js → liquid-BqJ3SIha.js} +1 -1
- package/src/client/dist/pwa/assets/{lspLanguageFeatures-PklP2gEH.js → lspLanguageFeatures-BB0y1B51.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-DjZcevPS.js → mdx-rIxQLZDB.js} +1 -1
- package/src/client/dist/pwa/assets/network-auth-pzKTXRYP.js +1 -0
- package/src/client/dist/pwa/assets/notify-theme-D7RrwvJa.js +1 -0
- package/src/client/dist/pwa/assets/pending-diff-open-Cia8sndE.js +1 -0
- package/src/client/dist/pwa/assets/private.use-form-D8Yuc3aU.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-Cc9yKGk3.js → project-color-Dur1z6Gn.js} +1 -1
- package/src/client/dist/pwa/assets/{python-BrgzhqrC.js → python-C6d5du9h.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-g8NeeeQc.js → razor-Aek1yDc0.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-CZyJL1-w.js → render-chat-markdown-B6sa44Xr.js} +2 -2
- package/src/client/dist/pwa/assets/skill-suite-prompts-DdoGRwhz.js +36 -0
- package/src/client/dist/pwa/assets/{tsMode-DAopv4hH.js → tsMode-rxNEyqyY.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-C2jZCVPF.js → typescript-DndxWYhC.js} +1 -1
- package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +1 -0
- package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +1 -0
- package/src/client/dist/pwa/assets/use-onboarding-BzSwN4xT.js +2 -0
- package/src/client/dist/pwa/assets/{vs-mk-rn9sP.js → vs-De8HKJ9r.js} +3 -3
- package/src/client/dist/pwa/assets/{workers-CP5iY9Kz.js → workers-DdNkyVm9.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-D3pYNERH.js → xml-Cl1k1mQr.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-BTRPY5XP.js → yaml-C8G_k5TR.js} +1 -1
- package/src/client/dist/pwa/fonts/GEIST-LICENSE.txt +92 -0
- package/src/client/dist/pwa/fonts/Geist-Variable.woff2 +0 -0
- package/src/client/dist/pwa/fonts/GeistMono-Variable.woff2 +0 -0
- package/src/client/dist/pwa/index.html +7 -5
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/kobo-tasks-server.ts +8 -7
- package/dist/server/utils/process-tracker.js +0 -49
- package/src/client/dist/pwa/assets/ActivityFeed-BrjeLlhi.css +0 -1
- package/src/client/dist/pwa/assets/ActivityFeed-WBBk7DgE.js +0 -8
- package/src/client/dist/pwa/assets/ChangelogPage-Cr5cW9Wx.js +0 -1
- package/src/client/dist/pwa/assets/ChangelogPage-DGhfnkax.css +0 -1
- package/src/client/dist/pwa/assets/ClosePopup-2KSSXX6P.js +0 -1
- package/src/client/dist/pwa/assets/CreatePage-Bl1Vjr27.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-DQk8TrUP.js +0 -2
- package/src/client/dist/pwa/assets/DiffViewer-C9xV8dh-.js +0 -8
- package/src/client/dist/pwa/assets/DiffViewer-CS3P9Ngi.css +0 -1
- package/src/client/dist/pwa/assets/DrawerToggleButton-CjM28eaw.js +0 -1
- package/src/client/dist/pwa/assets/HealthPage-BbLpLRjV.js +0 -1
- package/src/client/dist/pwa/assets/HealthPage-BqirO5le.css +0 -1
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BCOjigVR.woff +0 -0
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-Ocp2tNig.woff +0 -0
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-6jEn9xEl.woff +0 -0
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CCs8I9zJ.woff +0 -0
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-BLaboPi0.woff +0 -0
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-DHuK6FBl.woff +0 -0
- package/src/client/dist/pwa/assets/MainLayout-BUril7SK.js +0 -19
- package/src/client/dist/pwa/assets/MainLayout-u-AM87m3.css +0 -1
- package/src/client/dist/pwa/assets/QBadge-DS9cpnyx.js +0 -1
- package/src/client/dist/pwa/assets/QBanner-tANb95XA.js +0 -1
- package/src/client/dist/pwa/assets/QBtn-DJGkBel8.js +0 -1
- package/src/client/dist/pwa/assets/QBtnToggle-D-Hsqmcb.js +0 -1
- package/src/client/dist/pwa/assets/QChip-iLXQ5Khf.js +0 -1
- package/src/client/dist/pwa/assets/QItemSection-B-5iFRBZ.js +0 -1
- package/src/client/dist/pwa/assets/QList-D-yd1vEv.js +0 -1
- package/src/client/dist/pwa/assets/QSelect-TFRaHbzG.js +0 -36
- package/src/client/dist/pwa/assets/QSeparator-C_2BuEw9.js +0 -1
- package/src/client/dist/pwa/assets/QSpace-BuMMeXnK.js +0 -1
- package/src/client/dist/pwa/assets/QSpinnerDots-C-tv95pD.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CPM7L9_M.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CngtAqls.css +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-BRYycuYY.js +0 -16
- package/src/client/dist/pwa/assets/SettingsPage-D5BacmAr.css +0 -1
- package/src/client/dist/pwa/assets/TouchPan-79_u9-Fd.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-DB1n1bmg.js +0 -4
- package/src/client/dist/pwa/assets/WorkspacePage-hxEpXqBA.css +0 -1
- package/src/client/dist/pwa/assets/documents-BMvE9Q_U.js +0 -1
- package/src/client/dist/pwa/assets/engineFeatures-JkVb6hry.js +0 -1
- package/src/client/dist/pwa/assets/expand-template-D2yUa54D.css +0 -1
- package/src/client/dist/pwa/assets/expand-template-Dl4wUjF8.js +0 -1
- package/src/client/dist/pwa/assets/i18n-Dezgz2hU.js +0 -1
- package/src/client/dist/pwa/assets/index-B12jWMl8.js +0 -82
- package/src/client/dist/pwa/assets/index-CK1nk7qr.css +0 -1
- package/src/client/dist/pwa/assets/kobo-commands-DznaKlq6.js +0 -9
- package/src/client/dist/pwa/assets/layout-me0gwmBV.js +0 -1
- package/src/client/dist/pwa/assets/network-auth-BMTuTKHx.js +0 -1
- package/src/client/dist/pwa/assets/pending-diff-open-BG3IOnWw.js +0 -1
- package/src/client/dist/pwa/assets/use-checkbox-D-9LXk-S.js +0 -1
- package/src/client/dist/pwa/assets/use-is-mobile-DX2Fiee-.js +0 -1
- package/src/client/dist/pwa/assets/use-onboarding-BNo8mb65.js +0 -2
|
@@ -41,6 +41,7 @@ import * as purgeWorktreeService from '../services/worktree-purge-service.js';
|
|
|
41
41
|
import * as worktreeService from '../services/worktree-service.js';
|
|
42
42
|
import { resolveUniqueBranchAndPath } from '../utils/branch-resolver.js';
|
|
43
43
|
import * as gitOps from '../utils/git-ops.js';
|
|
44
|
+
import { logError } from '../utils/logger.js';
|
|
44
45
|
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
45
46
|
import * as safePath from '../utils/safe-path.js';
|
|
46
47
|
import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
@@ -48,6 +49,124 @@ import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js'
|
|
|
48
49
|
const app = new Hono();
|
|
49
50
|
/** Tracks workspaces currently running a setup script to prevent concurrent executions. */
|
|
50
51
|
const setupScriptRunning = new Set();
|
|
52
|
+
/**
|
|
53
|
+
* Ordered, named steps of `POST /api/workspaces`. The handler already runs
|
|
54
|
+
* these sequentially; naming them is what lets the create page say WHAT it is
|
|
55
|
+
* waiting on instead of showing an undifferentiated spinner for what can be
|
|
56
|
+
* several minutes (a user-supplied setup script runs inside this request).
|
|
57
|
+
*/
|
|
58
|
+
export const CREATE_WORKSPACE_STEPS = [
|
|
59
|
+
'validate',
|
|
60
|
+
'fetch-source-branch',
|
|
61
|
+
'inspect-worktree',
|
|
62
|
+
'extract-notion',
|
|
63
|
+
'extract-sentry',
|
|
64
|
+
'create-record',
|
|
65
|
+
'create-tasks',
|
|
66
|
+
'create-worktree',
|
|
67
|
+
'write-conventions',
|
|
68
|
+
'write-context-files',
|
|
69
|
+
'build-prompt',
|
|
70
|
+
'setup-script',
|
|
71
|
+
'start-agent',
|
|
72
|
+
// Never reached on a happy path. Emitted while the handler UNDOES a failed
|
|
73
|
+
// creation, so the page stops showing the last step it managed to reach.
|
|
74
|
+
'rollback',
|
|
75
|
+
'done',
|
|
76
|
+
];
|
|
77
|
+
/**
|
|
78
|
+
* Emit one creation-progress beat.
|
|
79
|
+
*
|
|
80
|
+
* The channel is the client-supplied `creationId`, NOT a workspace id: while
|
|
81
|
+
* this handler runs, the workspace row may not exist yet and the client has
|
|
82
|
+
* no id to subscribe to (the POST response is still in flight). A WebSocket
|
|
83
|
+
* subscription is just a string in a Set, and `emitEphemeral` never persists,
|
|
84
|
+
* so there is no foreign key to satisfy — the existing machinery works as-is.
|
|
85
|
+
*
|
|
86
|
+
* No `creationId` (API consumers, tests) → no-op.
|
|
87
|
+
*/
|
|
88
|
+
function emitCreateProgress(creationId, step) {
|
|
89
|
+
if (!creationId)
|
|
90
|
+
return;
|
|
91
|
+
try {
|
|
92
|
+
wsService.emitEphemeral(creationId, 'workspace:create-progress', {
|
|
93
|
+
creationId,
|
|
94
|
+
step,
|
|
95
|
+
index: CREATE_WORKSPACE_STEPS.indexOf(step),
|
|
96
|
+
total: CREATE_WORKSPACE_STEPS.length,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
// Best-effort: a broken progress beat must never fail workspace creation.
|
|
101
|
+
console.error(`[workspaces] emitCreateProgress failed for step '${step}':`, err);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* What a rollback provably cannot reach. The setup script may have installed
|
|
106
|
+
* dependencies in a global cache, started containers or written files outside
|
|
107
|
+
* the worktree — say so rather than implying a total undo.
|
|
108
|
+
*/
|
|
109
|
+
const SETUP_SCRIPT_ROLLBACK_CAVEAT = ' The setup script had already run: anything it created outside the worktree (containers, global caches, files elsewhere) was NOT undone.';
|
|
110
|
+
/** Terminal failure beat — names the step that broke, so the user never has to guess. */
|
|
111
|
+
function emitCreateFailed(creationId, step, message) {
|
|
112
|
+
if (!creationId)
|
|
113
|
+
return;
|
|
114
|
+
try {
|
|
115
|
+
wsService.emitEphemeral(creationId, 'workspace:create-failed', { creationId, step, message });
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
// Best-effort: a broken failure beat must never mask or replace the real error.
|
|
119
|
+
console.error(`[workspaces] emitCreateFailed failed for step '${step}':`, err);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Undo a creation that failed after the workspace row existed.
|
|
124
|
+
*
|
|
125
|
+
* Delegates to `deleteWorkspaceWithSideEffects` — the module's ONE demolition
|
|
126
|
+
* path — rather than growing a second cleanup that would drift from it. That
|
|
127
|
+
* function already stops the agent, stops the dev server, destroys the
|
|
128
|
+
* terminal, skips a worktree the user brought (`worktreeOwned === false`), and
|
|
129
|
+
* collects side-effect failures as warnings without ever throwing.
|
|
130
|
+
*
|
|
131
|
+
* It is declared with `async function` further down the file, so it is hoisted
|
|
132
|
+
* and callable from here.
|
|
133
|
+
*
|
|
134
|
+
* IMPORTANT: this is best-effort cleanup. It must NEVER throw, and it must
|
|
135
|
+
* NEVER become the error the caller reports — the original cause always wins.
|
|
136
|
+
*/
|
|
137
|
+
async function rollbackFailedCreation(workspace, opts) {
|
|
138
|
+
try {
|
|
139
|
+
return await deleteWorkspaceWithSideEffects(workspace, {
|
|
140
|
+
deleteLocalBranch: opts.deleteLocalBranch,
|
|
141
|
+
// Nothing was ever pushed at this point in the creation flow.
|
|
142
|
+
deleteRemoteBranch: false,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
147
|
+
console.error(`[workspaces] rollback of '${workspace.id}' failed:`, message);
|
|
148
|
+
return [`Rollback of workspace '${workspace.name}' failed: ${message}`];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Move a freshly created workspace to `brainstorming`, never throwing.
|
|
153
|
+
*
|
|
154
|
+
* This transition is cosmetic — the agent starts either way — but it sits
|
|
155
|
+
* inside the creation try block, so letting it throw would hand a live
|
|
156
|
+
* workspace to the rollback path and demolish it. The realistic trigger is a
|
|
157
|
+
* race: a long setup script leaves the row visible in the sidebar, the user
|
|
158
|
+
* clicks Start, the agent moves the workspace to `executing`, and
|
|
159
|
+
* `executing -> brainstorming` is then a rejected transition. Losing the
|
|
160
|
+
* status beat is a cosmetic bug; losing the worktree is data loss.
|
|
161
|
+
*/
|
|
162
|
+
function markBrainstormingBestEffort(workspaceId) {
|
|
163
|
+
try {
|
|
164
|
+
workspaceService.updateWorkspaceStatus(workspaceId, 'brainstorming');
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
console.error(`[workspaces] could not mark '${workspaceId}' as brainstorming:`, err);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
51
170
|
const VALID_AGENT_PERMISSION_MODES = ['plan', 'bypass', 'strict', 'interactive'];
|
|
52
171
|
const VALID_WORKSPACE_STATUSES = [
|
|
53
172
|
'created',
|
|
@@ -144,8 +263,10 @@ app.post('/:id/switch-engine', migrationGuard, async (c) => {
|
|
|
144
263
|
const handoff = typeof body.handoff === 'string' && body.handoff.trim()
|
|
145
264
|
? body.handoff.trim().slice(0, 30_000)
|
|
146
265
|
: buildEngineHandoff(workspace, workspace.engine, body.engine);
|
|
266
|
+
// A new engine is started three lines below: the previous one must be dead
|
|
267
|
+
// first, or two agents share the worktree.
|
|
147
268
|
if (agentManager.hasController(id))
|
|
148
|
-
agentManager.
|
|
269
|
+
await agentManager.stopAgentAndWait(id);
|
|
149
270
|
const updated = workspaceService.updateWorkspaceEngineConfiguration(id, body.engine, model, effort, body.agentPermissionMode);
|
|
150
271
|
const session = agentManager.startAgent(id, updated.worktreePath, handoff, updated.model, false, updated.agentPermissionMode, undefined, updated.reasoningEffort);
|
|
151
272
|
workspaceService.updateWorkspaceStatus(id, 'executing');
|
|
@@ -158,6 +279,25 @@ app.post('/:id/switch-engine', migrationGuard, async (c) => {
|
|
|
158
279
|
});
|
|
159
280
|
// POST /api/workspaces — create workspace
|
|
160
281
|
app.post('/', migrationGuard, async (c) => {
|
|
282
|
+
// Declared outside the try so the outermost catch (any unforeseen throw,
|
|
283
|
+
// including a malformed request body) can still name a step instead of
|
|
284
|
+
// leaving the create-progress channel silent. Updated in lockstep with
|
|
285
|
+
// every `emitCreateProgress` call below via the assignment-expression
|
|
286
|
+
// argument, so it always reflects the last beat actually emitted.
|
|
287
|
+
let creationId;
|
|
288
|
+
let currentStep = 'validate';
|
|
289
|
+
// The workspace row, as soon as it exists, so the outermost catch can undo a
|
|
290
|
+
// creation that broke on a step with no handler of its own. Only `name` ever
|
|
291
|
+
// changes afterwards (Notion / Sentry title), and the rollback uses it for
|
|
292
|
+
// log text alone — every field it acts on is fixed at creation time.
|
|
293
|
+
let createdWorkspace = null;
|
|
294
|
+
// Set only once WE created the worktree, which is also what created the
|
|
295
|
+
// branch. A failure before that point must not delete a branch we never made.
|
|
296
|
+
let createdBranch = false;
|
|
297
|
+
// Hoisted out of the `build-prompt` block (declared with `const` there) so
|
|
298
|
+
// both rollback paths — the `start-agent` branch and the outermost catch —
|
|
299
|
+
// can read it to decide whether the setup-script caveat applies.
|
|
300
|
+
let setupScriptConfigured = false;
|
|
161
301
|
try {
|
|
162
302
|
const body = await c.req.json();
|
|
163
303
|
// workingBranch is derived from git when worktreePath is provided, so
|
|
@@ -168,6 +308,17 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
168
308
|
if (!body.worktreePath && !body.workingBranch) {
|
|
169
309
|
return c.json({ error: 'Missing required field: workingBranch' }, 400);
|
|
170
310
|
}
|
|
311
|
+
// Only when the caller actually supplies it: when worktreePath is given
|
|
312
|
+
// instead, workingBranch is derived from git itself further down, not
|
|
313
|
+
// user input. A name starting with `-` would otherwise read as a git
|
|
314
|
+
// option (`--upload-pack=…`) rather than a branch name — see
|
|
315
|
+
// isValidBranchName's own doc comment for why this specific shape matters.
|
|
316
|
+
if (body.workingBranch && !gitOps.isValidBranchName(body.workingBranch)) {
|
|
317
|
+
return c.json({ error: `Invalid working branch name: ${body.workingBranch}` }, 400);
|
|
318
|
+
}
|
|
319
|
+
creationId = typeof body.creationId === 'string' && body.creationId.length > 0 ? body.creationId : undefined;
|
|
320
|
+
currentStep = 'validate';
|
|
321
|
+
emitCreateProgress(creationId, currentStep);
|
|
171
322
|
// Validate the engine id (if provided) against the registry. An unknown
|
|
172
323
|
// engine is rejected up-front so we don't create orphan workspaces that
|
|
173
324
|
// can't spawn an agent.
|
|
@@ -175,7 +326,9 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
175
326
|
const engines = listEngines();
|
|
176
327
|
const validEngineIds = engines.map((e) => e.id);
|
|
177
328
|
if (!validEngineIds.includes(body.engine)) {
|
|
178
|
-
|
|
329
|
+
const message = `Unknown engine '${body.engine}'. Valid engines: ${validEngineIds.join(', ')}`;
|
|
330
|
+
emitCreateFailed(creationId, 'validate', message);
|
|
331
|
+
return c.json({ error: message, step: 'validate' }, 400);
|
|
179
332
|
}
|
|
180
333
|
// Cross-validate engine × permission mode: each engine declares which
|
|
181
334
|
// modes it supports via `capabilities.permissionModes`. The UI already
|
|
@@ -186,9 +339,9 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
186
339
|
const engine = engines.find((e) => e.id === body.engine);
|
|
187
340
|
const supported = engine?.capabilities.permissionModes ?? [];
|
|
188
341
|
if (!supported.includes(body.agentPermissionMode)) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}, 400);
|
|
342
|
+
const message = `Engine '${body.engine}' does not support agentPermissionMode '${body.agentPermissionMode}'. Supported: ${supported.join(', ')}`;
|
|
343
|
+
emitCreateFailed(creationId, 'validate', message);
|
|
344
|
+
return c.json({ error: message, step: 'validate' }, 400);
|
|
192
345
|
}
|
|
193
346
|
}
|
|
194
347
|
}
|
|
@@ -197,6 +350,8 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
197
350
|
// and only hard-block (422) when neither origin nor a local branch is usable.
|
|
198
351
|
// The reuse path (body.worktreePath) needs no base ref, so a failed fetch is
|
|
199
352
|
// simply logged there.
|
|
353
|
+
currentStep = 'fetch-source-branch';
|
|
354
|
+
emitCreateProgress(creationId, currentStep);
|
|
200
355
|
const isReuseRequest = !!body.worktreePath;
|
|
201
356
|
let baseRef = `origin/${body.sourceBranch}`;
|
|
202
357
|
let usedLocalFallback = false;
|
|
@@ -214,7 +369,9 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
214
369
|
console.warn(`[workspaces] fetch of '${body.sourceBranch}' from origin failed; falling back to local branch: ${message}`);
|
|
215
370
|
}
|
|
216
371
|
else {
|
|
217
|
-
|
|
372
|
+
const failure = `${message} — and no local branch '${body.sourceBranch}' exists to fall back on`;
|
|
373
|
+
emitCreateFailed(creationId, 'fetch-source-branch', failure);
|
|
374
|
+
return c.json({ error: failure, step: 'fetch-source-branch' }, 422);
|
|
218
375
|
}
|
|
219
376
|
}
|
|
220
377
|
// Reuse-existing-worktree path. When the caller passes `worktreePath`,
|
|
@@ -224,9 +381,15 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
224
381
|
// the working branch from git itself — the body.workingBranch is ignored.
|
|
225
382
|
let useReusedWorktree = false;
|
|
226
383
|
let reusedDerivedBranch = null;
|
|
384
|
+
if (body.worktreePath) {
|
|
385
|
+
currentStep = 'inspect-worktree';
|
|
386
|
+
emitCreateProgress(creationId, currentStep);
|
|
387
|
+
}
|
|
227
388
|
if (body.worktreePath) {
|
|
228
389
|
if (!fs.existsSync(body.worktreePath)) {
|
|
229
|
-
|
|
390
|
+
const failure = `Worktree path does not exist: ${body.worktreePath}`;
|
|
391
|
+
emitCreateFailed(creationId, 'inspect-worktree', failure);
|
|
392
|
+
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
230
393
|
}
|
|
231
394
|
try {
|
|
232
395
|
const commonDir = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--git-common-dir'], {
|
|
@@ -234,25 +397,33 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
234
397
|
}).trim();
|
|
235
398
|
const expectedCommonDir = path.join(body.projectPath, '.git');
|
|
236
399
|
if (path.resolve(commonDir) !== path.resolve(expectedCommonDir)) {
|
|
237
|
-
|
|
400
|
+
const failure = `Worktree '${body.worktreePath}' belongs to a different repository`;
|
|
401
|
+
emitCreateFailed(creationId, 'inspect-worktree', failure);
|
|
402
|
+
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
238
403
|
}
|
|
239
404
|
const branch = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
240
405
|
encoding: 'utf-8',
|
|
241
406
|
}).trim();
|
|
242
407
|
if (!branch || branch === 'HEAD') {
|
|
243
|
-
|
|
408
|
+
const failure = 'Worktree is in detached HEAD state and cannot be attached';
|
|
409
|
+
emitCreateFailed(creationId, 'inspect-worktree', failure);
|
|
410
|
+
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
244
411
|
}
|
|
245
412
|
reusedDerivedBranch = branch;
|
|
246
413
|
}
|
|
247
414
|
catch (err) {
|
|
248
415
|
const message = err instanceof Error ? err.message : String(err);
|
|
249
|
-
|
|
416
|
+
const failure = `Failed to inspect worktree: ${message}`;
|
|
417
|
+
emitCreateFailed(creationId, 'inspect-worktree', failure);
|
|
418
|
+
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
250
419
|
}
|
|
251
420
|
// Validate the worktree isn't already attached to another workspace.
|
|
252
421
|
const dbForCheck = getDb();
|
|
253
422
|
const existing = dbForCheck.prepare('SELECT id FROM workspaces WHERE worktree_path = ?').get(body.worktreePath);
|
|
254
423
|
if (existing) {
|
|
255
|
-
|
|
424
|
+
const failure = 'This worktree is already attached to another Kōbō workspace';
|
|
425
|
+
emitCreateFailed(creationId, 'inspect-worktree', failure);
|
|
426
|
+
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
256
427
|
}
|
|
257
428
|
useReusedWorktree = true;
|
|
258
429
|
}
|
|
@@ -261,15 +432,21 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
261
432
|
// before createWorkspace and surface failures as 422.
|
|
262
433
|
let notionContent = null;
|
|
263
434
|
if (body.notionUrl) {
|
|
435
|
+
currentStep = 'extract-notion';
|
|
436
|
+
emitCreateProgress(creationId, currentStep);
|
|
264
437
|
if (!settingsService.getGlobalSettings().notionEnabled) {
|
|
265
|
-
|
|
438
|
+
const failure = 'Notion integration is disabled in Settings';
|
|
439
|
+
emitCreateFailed(creationId, 'extract-notion', failure);
|
|
440
|
+
return c.json({ error: failure, step: 'extract-notion' }, 403);
|
|
266
441
|
}
|
|
267
442
|
try {
|
|
268
443
|
notionContent = await notionService.extractNotionPage(body.notionUrl);
|
|
269
444
|
}
|
|
270
445
|
catch (err) {
|
|
271
446
|
const message = err instanceof Error ? err.message : String(err);
|
|
272
|
-
|
|
447
|
+
const failure = `Failed to extract Notion page: ${message}`;
|
|
448
|
+
emitCreateFailed(creationId, 'extract-notion', failure);
|
|
449
|
+
return c.json({ error: failure, step: 'extract-notion' }, 422);
|
|
273
450
|
}
|
|
274
451
|
const assigneeProperty = settingsService.getGlobalSettings().notionAssigneeProperty;
|
|
275
452
|
if (assigneeProperty && body.notionUrl) {
|
|
@@ -286,15 +463,21 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
286
463
|
}
|
|
287
464
|
let sentryContent = null;
|
|
288
465
|
if (body.sentryUrl) {
|
|
466
|
+
currentStep = 'extract-sentry';
|
|
467
|
+
emitCreateProgress(creationId, currentStep);
|
|
289
468
|
if (!settingsService.getGlobalSettings().sentryEnabled) {
|
|
290
|
-
|
|
469
|
+
const failure = 'Sentry integration is disabled in Settings';
|
|
470
|
+
emitCreateFailed(creationId, 'extract-sentry', failure);
|
|
471
|
+
return c.json({ error: failure, step: 'extract-sentry' }, 403);
|
|
291
472
|
}
|
|
292
473
|
try {
|
|
293
474
|
sentryContent = await sentryService.extractSentryIssue(body.sentryUrl);
|
|
294
475
|
}
|
|
295
476
|
catch (err) {
|
|
296
477
|
const message = err instanceof Error ? err.message : String(err);
|
|
297
|
-
|
|
478
|
+
const failure = `Failed to extract Sentry issue: ${message}`;
|
|
479
|
+
emitCreateFailed(creationId, 'extract-sentry', failure);
|
|
480
|
+
return c.json({ error: failure, step: 'extract-sentry' }, 422);
|
|
298
481
|
}
|
|
299
482
|
if (sentryContent && sentryContent.assignee.length === 0) {
|
|
300
483
|
const sentryUrl = body.sentryUrl;
|
|
@@ -358,6 +541,8 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
358
541
|
// request — keeps the user's flow smooth at the cost of a longer
|
|
359
542
|
// branch name. The same hash is applied to BOTH the branch and the
|
|
360
543
|
// worktree path so they stay aligned.
|
|
544
|
+
currentStep = 'create-record';
|
|
545
|
+
emitCreateProgress(creationId, currentStep);
|
|
361
546
|
let prospectiveWorktreePath;
|
|
362
547
|
let workingBranchAdjusted = false;
|
|
363
548
|
if (useReusedWorktree) {
|
|
@@ -377,7 +562,8 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
377
562
|
}
|
|
378
563
|
catch (err) {
|
|
379
564
|
const message = err instanceof Error ? err.message : String(err);
|
|
380
|
-
|
|
565
|
+
emitCreateFailed(creationId, 'create-record', message);
|
|
566
|
+
return c.json({ error: message, step: 'create-record' }, 409);
|
|
381
567
|
}
|
|
382
568
|
}
|
|
383
569
|
let workspace = workspaceService.createWorkspace({
|
|
@@ -397,6 +583,7 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
397
583
|
engine: body.engine,
|
|
398
584
|
...(useReusedWorktree ? {} : { worktreesPath: globalSettings.worktreesPath }),
|
|
399
585
|
});
|
|
586
|
+
createdWorkspace = workspace;
|
|
400
587
|
// Enable auto-loop before starting the initial brainstorming session so
|
|
401
588
|
// its model override is available when selecting that session's model.
|
|
402
589
|
if (body.autoLoop === true) {
|
|
@@ -443,6 +630,8 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
443
630
|
const prefix = sentryContent.issueId ? `${sentryContent.issueId} | ` : '';
|
|
444
631
|
workspace = workspaceService.updateWorkspaceName(workspace.id, `${prefix}${sentryContent.title}`);
|
|
445
632
|
}
|
|
633
|
+
currentStep = 'create-tasks';
|
|
634
|
+
emitCreateProgress(creationId, currentStep);
|
|
446
635
|
// Create tasks from extracted Notion data
|
|
447
636
|
if (notionContent) {
|
|
448
637
|
let sortOrder = 0;
|
|
@@ -493,6 +682,8 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
493
682
|
}
|
|
494
683
|
}
|
|
495
684
|
}
|
|
685
|
+
currentStep = 'create-worktree';
|
|
686
|
+
emitCreateProgress(creationId, currentStep);
|
|
496
687
|
// Create git worktree for the working branch — unless we're reusing an
|
|
497
688
|
// existing one, in which case the path is taken straight from the body.
|
|
498
689
|
let worktreePath;
|
|
@@ -503,27 +694,24 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
503
694
|
try {
|
|
504
695
|
const created = worktreeService.createWorktree(body.projectPath, workingBranch, baseRef, globalSettings.worktreesPath, projectSlug);
|
|
505
696
|
worktreePath = created.worktreePath;
|
|
697
|
+
// Not an assumption: `createWorktree` reports which git command it ran.
|
|
698
|
+
// It falls back to attaching an existing branch, and deleting that one
|
|
699
|
+
// on rollback would destroy commits the user made before Kobo existed.
|
|
700
|
+
createdBranch = created.branchCreated;
|
|
506
701
|
}
|
|
507
702
|
catch (err) {
|
|
508
703
|
const message = err instanceof Error ? err.message : String(err);
|
|
509
|
-
// Roll back the half-created workspace
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
// worktree may never have been registered — ignore
|
|
517
|
-
}
|
|
518
|
-
try {
|
|
519
|
-
workspaceService.deleteWorkspace(workspace.id);
|
|
520
|
-
}
|
|
521
|
-
catch (delErr) {
|
|
522
|
-
console.error('[workspaces] rollback deleteWorkspace failed:', delErr);
|
|
523
|
-
}
|
|
524
|
-
return c.json({ error: `Failed to create worktree: ${message}` }, 500);
|
|
704
|
+
// Roll back the half-created workspace through the SHARED demolition
|
|
705
|
+
// path — a second inline cleanup here would drift from it. The branch
|
|
706
|
+
// is not deleted: `createWorktree` failed, so we never created one.
|
|
707
|
+
const rollbackWarnings = await rollbackFailedCreation(workspace, { deleteLocalBranch: false });
|
|
708
|
+
const failure = `Failed to create worktree: ${message}`;
|
|
709
|
+
emitCreateFailed(creationId, 'create-worktree', failure);
|
|
710
|
+
return c.json({ error: failure, step: 'create-worktree', rollback: { done: true, warnings: rollbackWarnings } }, 500);
|
|
525
711
|
}
|
|
526
712
|
}
|
|
713
|
+
currentStep = 'write-conventions';
|
|
714
|
+
emitCreateProgress(creationId, currentStep);
|
|
527
715
|
// Ensure Kobo-generated files are gitignored. Check both the root
|
|
528
716
|
// .gitignore and .ai/.gitignore to avoid duplicate entries.
|
|
529
717
|
try {
|
|
@@ -554,7 +742,10 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
554
742
|
}
|
|
555
743
|
}
|
|
556
744
|
catch (err) {
|
|
557
|
-
|
|
745
|
+
logError('workspaces', 'Failed to update .gitignore', {
|
|
746
|
+
workspaceId: workspace.id,
|
|
747
|
+
error: err instanceof Error ? err.message : String(err),
|
|
748
|
+
});
|
|
558
749
|
}
|
|
559
750
|
// Write git conventions to the worktree if configured
|
|
560
751
|
const effectiveSettings = settingsService.getEffectiveSettings(body.projectPath);
|
|
@@ -566,13 +757,23 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
566
757
|
fs.writeFileSync(conventionsPath, effectiveSettings.gitConventions, 'utf-8');
|
|
567
758
|
}
|
|
568
759
|
catch (err) {
|
|
569
|
-
|
|
760
|
+
logError('workspaces', 'Failed to write .git-conventions.md', {
|
|
761
|
+
workspaceId: workspace.id,
|
|
762
|
+
error: err instanceof Error ? err.message : String(err),
|
|
763
|
+
});
|
|
570
764
|
}
|
|
571
765
|
}
|
|
572
766
|
// The setup script runs LATER (after the brainstorm prompt is built and
|
|
573
767
|
// persisted via setInitialPrompt). This guarantees the prompt survives a
|
|
574
768
|
// setup-script crash and can be replayed by /:id/start.
|
|
575
769
|
let setupScriptFailed = false;
|
|
770
|
+
// Captured, not swallowed: a failed engine start used to reach only the
|
|
771
|
+
// server console while the route still answered 201 "created". A
|
|
772
|
+
// half-created workspace lies to the user, so this now drives a full
|
|
773
|
+
// rollback below instead of merely flipping the status to 'error'.
|
|
774
|
+
let agentStartError = null;
|
|
775
|
+
currentStep = 'write-context-files';
|
|
776
|
+
emitCreateProgress(creationId, currentStep);
|
|
576
777
|
// Save Notion content as markdown in worktree
|
|
577
778
|
let notionFilePath = null;
|
|
578
779
|
if (notionContent && body.notionUrl) {
|
|
@@ -618,7 +819,10 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
618
819
|
fs.writeFileSync(notionFilePath, md, 'utf-8');
|
|
619
820
|
}
|
|
620
821
|
catch (err) {
|
|
621
|
-
|
|
822
|
+
logError('workspaces', 'Failed to save Notion content', {
|
|
823
|
+
workspaceId: workspace.id,
|
|
824
|
+
error: err instanceof Error ? err.message : String(err),
|
|
825
|
+
});
|
|
622
826
|
}
|
|
623
827
|
}
|
|
624
828
|
// --- Sentry file + task (extraction already done before worktree creation) --
|
|
@@ -667,7 +871,10 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
667
871
|
});
|
|
668
872
|
}
|
|
669
873
|
catch (err) {
|
|
670
|
-
|
|
874
|
+
logError('workspaces', 'Failed to save Sentry content', {
|
|
875
|
+
workspaceId: workspace.id,
|
|
876
|
+
error: err instanceof Error ? err.message : String(err),
|
|
877
|
+
});
|
|
671
878
|
sentryFilePath = null;
|
|
672
879
|
}
|
|
673
880
|
}
|
|
@@ -693,6 +900,8 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
693
900
|
// VALID_TRANSITIONS contract intact (`created → extracting → brainstorming`
|
|
694
901
|
// vs `created → brainstorming` when setup is skipped).
|
|
695
902
|
{
|
|
903
|
+
currentStep = 'build-prompt';
|
|
904
|
+
emitCreateProgress(creationId, currentStep);
|
|
696
905
|
// Resolve the per-feature initial-prompt templates with single-fallback
|
|
697
906
|
// semantics: project || global is already handled inside getEffectiveSettings,
|
|
698
907
|
// and a whitespace-only string acts as a user escape hatch (skip injection).
|
|
@@ -842,8 +1051,10 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
842
1051
|
// leaves the workspace in `error` state with `initial_prompt` ready for
|
|
843
1052
|
// a retry via POST /:id/start. Skipped when reusing an existing worktree
|
|
844
1053
|
// (rerunning could be destructive — drop a curated node_modules, etc.).
|
|
845
|
-
|
|
1054
|
+
setupScriptConfigured = Boolean(effectiveSettings.setupScript) && !body.skipSetupScript && !useReusedWorktree;
|
|
846
1055
|
if (setupScriptConfigured) {
|
|
1056
|
+
currentStep = 'setup-script';
|
|
1057
|
+
emitCreateProgress(creationId, currentStep);
|
|
847
1058
|
workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
|
|
848
1059
|
wsService.emit(workspace.id, 'setup:output', { text: '[kobo] Running setup script...' });
|
|
849
1060
|
try {
|
|
@@ -856,9 +1067,10 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
856
1067
|
if (result.exitCode !== 0) {
|
|
857
1068
|
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
858
1069
|
setupScriptFailed = true;
|
|
1070
|
+
emitCreateFailed(creationId, 'setup-script', `Setup script exited with code ${result.exitCode}`);
|
|
859
1071
|
}
|
|
860
1072
|
else {
|
|
861
|
-
|
|
1073
|
+
markBrainstormingBestEffort(workspace.id);
|
|
862
1074
|
}
|
|
863
1075
|
}
|
|
864
1076
|
catch (err) {
|
|
@@ -866,19 +1078,28 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
866
1078
|
console.error(`[workspaces] Setup script error: ${message}`);
|
|
867
1079
|
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
868
1080
|
setupScriptFailed = true;
|
|
1081
|
+
emitCreateFailed(creationId, 'setup-script', message);
|
|
869
1082
|
}
|
|
870
1083
|
}
|
|
871
1084
|
else {
|
|
872
1085
|
// No setup step → go straight from `created` to `brainstorming`.
|
|
873
|
-
|
|
1086
|
+
markBrainstormingBestEffort(workspace.id);
|
|
874
1087
|
}
|
|
875
1088
|
if (setupScriptFailed) {
|
|
876
1089
|
wsService.emit(workspace.id, 'setup:output', {
|
|
877
1090
|
text: '[kobo] Setup script failed — the agent was NOT started. Your initial prompt has been saved. ' +
|
|
878
1091
|
'Fix the setup script (Settings → Scripts) and click Start to retry with the original prompt.',
|
|
879
1092
|
});
|
|
1093
|
+
// This path keeps its deliberate recovery flow (workspace stays in
|
|
1094
|
+
// `error`, prompt persisted, 201 response) — nothing is undone here.
|
|
1095
|
+
// The step is already named on the create-progress channel by the
|
|
1096
|
+
// `emitCreateFailed(creationId, 'setup-script', …)` calls above, at
|
|
1097
|
+
// the point of detection (exit code / thrown error), so the create
|
|
1098
|
+
// page never shows a mute spinner on this path.
|
|
880
1099
|
}
|
|
881
1100
|
else {
|
|
1101
|
+
currentStep = 'start-agent';
|
|
1102
|
+
emitCreateProgress(creationId, currentStep);
|
|
882
1103
|
try {
|
|
883
1104
|
// The brainstorming session (this very first startAgent call) uses
|
|
884
1105
|
// brainstormModel when the workspace was created with auto-loop
|
|
@@ -906,15 +1127,35 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
906
1127
|
catch (err) {
|
|
907
1128
|
const message = err instanceof Error ? err.message : String(err);
|
|
908
1129
|
console.error(`[workspaces] Failed to start agent: ${message}`);
|
|
909
|
-
|
|
910
|
-
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
911
|
-
}
|
|
912
|
-
catch {
|
|
913
|
-
/* already logged */
|
|
914
|
-
}
|
|
1130
|
+
agentStartError = message;
|
|
915
1131
|
}
|
|
916
1132
|
}
|
|
917
1133
|
}
|
|
1134
|
+
// A workspace whose agent never started is a half-created object, and a
|
|
1135
|
+
// half-created object is exactly what lies to the user. Undo everything
|
|
1136
|
+
// and report the step that broke.
|
|
1137
|
+
if (agentStartError) {
|
|
1138
|
+
emitCreateProgress(creationId, 'rollback');
|
|
1139
|
+
// A worktree the user brought is not ours to remove, and neither is the
|
|
1140
|
+
// branch it was already sitting on. `deleteWorkspaceWithSideEffects`
|
|
1141
|
+
// already guards the worktree via `worktreeOwned`; the branch flag is
|
|
1142
|
+
// ours to set.
|
|
1143
|
+
const rollbackWarnings = await rollbackFailedCreation(workspace, {
|
|
1144
|
+
deleteLocalBranch: !useReusedWorktree,
|
|
1145
|
+
});
|
|
1146
|
+
// The setup script has already run by this point — see the caveat's own
|
|
1147
|
+
// comment for what that rollback cannot reach.
|
|
1148
|
+
const setupCaveat = setupScriptConfigured ? SETUP_SCRIPT_ROLLBACK_CAVEAT : '';
|
|
1149
|
+
const failure = `Failed to start the agent: ${agentStartError}. The workspace, its worktree and its branch were removed.${setupCaveat}`;
|
|
1150
|
+
emitCreateFailed(creationId, 'start-agent', failure);
|
|
1151
|
+
return c.json({ error: failure, step: 'start-agent', rollback: { done: true, warnings: rollbackWarnings } }, 500);
|
|
1152
|
+
}
|
|
1153
|
+
// The agent is running. From here the workspace is a live object, not a
|
|
1154
|
+
// half-created one, so it stops being the rollback's business: dropping the
|
|
1155
|
+
// handle means a later throw (the read below, a header, a status beat)
|
|
1156
|
+
// returns 500 without demolishing a worktree that already has an agent
|
|
1157
|
+
// working in it. Failures before this line still roll back in full.
|
|
1158
|
+
createdWorkspace = null;
|
|
918
1159
|
// Return created workspace with tasks
|
|
919
1160
|
const workspaceWithTasks = workspaceService.getWorkspaceWithTasks(workspace.id);
|
|
920
1161
|
if (workingBranchAdjusted) {
|
|
@@ -926,11 +1167,49 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
926
1167
|
if (usedLocalFallback) {
|
|
927
1168
|
c.header('X-Kobo-Source-Fallback', 'local');
|
|
928
1169
|
}
|
|
1170
|
+
// The `done` beat means "the sequence completed the way the user expects" —
|
|
1171
|
+
// NOT merely "the HTTP response is about to return 201". A workspace whose
|
|
1172
|
+
// setup script failed still gets a 201 (its record exists, in `error`
|
|
1173
|
+
// status, ready for retry via POST /:id/start) because that path has a
|
|
1174
|
+
// deliberate recovery flow — but the create-progress channel must not
|
|
1175
|
+
// claim success on it: the corresponding emitCreateFailed above already
|
|
1176
|
+
// named the real failure. A failed agent start never reaches this line at
|
|
1177
|
+
// all (see the rollback branch above).
|
|
1178
|
+
if (!setupScriptFailed) {
|
|
1179
|
+
currentStep = 'done';
|
|
1180
|
+
emitCreateProgress(creationId, currentStep);
|
|
1181
|
+
}
|
|
929
1182
|
return c.json(workspaceWithTasks, 201);
|
|
930
1183
|
}
|
|
931
1184
|
catch (err) {
|
|
932
1185
|
const message = err instanceof Error ? err.message : String(err);
|
|
933
|
-
|
|
1186
|
+
// Anything past `create-record` left a row behind. Leaving it there is a
|
|
1187
|
+
// half-created workspace whose existence lies to the user — and the client
|
|
1188
|
+
// already tells them "the server undoes everything it created". Make that
|
|
1189
|
+
// sentence true for every step, not only the two that had their own
|
|
1190
|
+
// handler. Best-effort: the rollback never throws and never becomes the
|
|
1191
|
+
// reported error.
|
|
1192
|
+
if (createdWorkspace) {
|
|
1193
|
+
emitCreateProgress(creationId, 'rollback');
|
|
1194
|
+
const rollbackWarnings = await rollbackFailedCreation(createdWorkspace, { deleteLocalBranch: createdBranch });
|
|
1195
|
+
logError('workspaces', 'Rolled back a creation that failed outside any per-step handler', {
|
|
1196
|
+
workspaceId: createdWorkspace.id,
|
|
1197
|
+
step: currentStep,
|
|
1198
|
+
error: message,
|
|
1199
|
+
...(rollbackWarnings.length > 0 ? { rollbackWarnings } : {}),
|
|
1200
|
+
});
|
|
1201
|
+
// Say exactly what was undone — a claim of "everything" would be the
|
|
1202
|
+
// very kind of comfortable lie this route is being fixed for.
|
|
1203
|
+
const removed = createdBranch
|
|
1204
|
+
? ' The workspace, its worktree and its branch were removed.'
|
|
1205
|
+
: ' The half-created workspace was removed.';
|
|
1206
|
+
const cause = message.endsWith('.') ? message : `${message}.`;
|
|
1207
|
+
const failure = `${cause}${removed}${setupScriptConfigured ? SETUP_SCRIPT_ROLLBACK_CAVEAT : ''}`;
|
|
1208
|
+
emitCreateFailed(creationId, currentStep, failure);
|
|
1209
|
+
return c.json({ error: failure, step: currentStep, rollback: { done: true, warnings: rollbackWarnings } }, 500);
|
|
1210
|
+
}
|
|
1211
|
+
emitCreateFailed(creationId, currentStep, message);
|
|
1212
|
+
return c.json({ error: message, step: currentStep }, 500);
|
|
934
1213
|
}
|
|
935
1214
|
});
|
|
936
1215
|
// POST /api/workspaces/:id/sessions — create a new idle agent session
|
|
@@ -994,6 +1273,9 @@ app.get('/info', (c) => {
|
|
|
994
1273
|
workspaces: workspaceService.listWorkspaces(false),
|
|
995
1274
|
prSnapshots: getAllPrSnapshots(),
|
|
996
1275
|
gitStats: getAllGitStats(),
|
|
1276
|
+
// In-memory truth, not the `status` column: a workspace missing from
|
|
1277
|
+
// this map has no agent, whatever its status claims.
|
|
1278
|
+
agentLiveness: agentManager.getAllAgentLiveness(),
|
|
997
1279
|
});
|
|
998
1280
|
}
|
|
999
1281
|
catch (err) {
|
|
@@ -1419,7 +1701,15 @@ app.delete('/:id/events/:eventId', (c) => {
|
|
|
1419
1701
|
return c.json({ error: `Workspace '${workspaceId}' not found` }, 404);
|
|
1420
1702
|
}
|
|
1421
1703
|
const db = getDb();
|
|
1704
|
+
// Capture the session BEFORE deleting: since v36 there is no AFTER DELETE
|
|
1705
|
+
// trigger, so the metrics of that session must be recomputed once, here.
|
|
1706
|
+
const row = db
|
|
1707
|
+
.prepare('SELECT session_id FROM ws_events WHERE id = ? AND workspace_id = ?')
|
|
1708
|
+
.get(eventId, workspaceId);
|
|
1422
1709
|
db.prepare('DELETE FROM ws_events WHERE id = ? AND workspace_id = ?').run(eventId, workspaceId);
|
|
1710
|
+
if (row?.session_id) {
|
|
1711
|
+
workspaceService.recomputeSessionMetrics(workspaceId, row.session_id);
|
|
1712
|
+
}
|
|
1423
1713
|
return c.json({ ok: true });
|
|
1424
1714
|
}
|
|
1425
1715
|
catch (err) {
|
|
@@ -1942,7 +2232,7 @@ app.get('/:id', (c) => {
|
|
|
1942
2232
|
if (!workspace) {
|
|
1943
2233
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1944
2234
|
}
|
|
1945
|
-
return c.json(workspace);
|
|
2235
|
+
return c.json({ ...workspace, agentLiveness: agentManager.getAgentLiveness(id) });
|
|
1946
2236
|
}
|
|
1947
2237
|
catch (err) {
|
|
1948
2238
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -2244,14 +2534,15 @@ app.post('/:id/run-setup-script', async (c) => {
|
|
|
2244
2534
|
if (setupScriptRunning.has(id)) {
|
|
2245
2535
|
return c.json({ error: 'Setup script is already running for this workspace' }, 409);
|
|
2246
2536
|
}
|
|
2247
|
-
// Stop the running agent before re-running the setup script
|
|
2537
|
+
// Stop the running agent before re-running the setup script — the script
|
|
2538
|
+
// rewrites the worktree the agent may still be writing to.
|
|
2248
2539
|
try {
|
|
2249
2540
|
if (agentManager.getAgentStatus(id)) {
|
|
2250
|
-
agentManager.
|
|
2541
|
+
await agentManager.stopAgentAndWait(id);
|
|
2251
2542
|
}
|
|
2252
2543
|
}
|
|
2253
|
-
catch {
|
|
2254
|
-
|
|
2544
|
+
catch (err) {
|
|
2545
|
+
console.error(`[workspaces] stopAgentAndWait before setup script failed for '${id}':`, err);
|
|
2255
2546
|
}
|
|
2256
2547
|
const effectiveSettings = settingsService.getEffectiveSettings(workspace.projectPath);
|
|
2257
2548
|
if (!effectiveSettings.setupScript) {
|
|
@@ -2295,10 +2586,10 @@ app.post('/:id/archive', migrationGuard, async (c) => {
|
|
|
2295
2586
|
return c.json({ error: 'Already archived' }, 400);
|
|
2296
2587
|
}
|
|
2297
2588
|
try {
|
|
2298
|
-
agentManager.
|
|
2589
|
+
await agentManager.stopAgentAndWait(id);
|
|
2299
2590
|
}
|
|
2300
|
-
catch {
|
|
2301
|
-
|
|
2591
|
+
catch (err) {
|
|
2592
|
+
console.error(`[workspaces] stopAgentAndWait during archive failed for '${id}':`, err);
|
|
2302
2593
|
}
|
|
2303
2594
|
try {
|
|
2304
2595
|
await devServerService.stopDevServer(id);
|
|
@@ -2377,12 +2668,14 @@ app.post('/:id/unarchive', migrationGuard, (c) => {
|
|
|
2377
2668
|
// rather than thrown, so a bulk delete never aborts mid-batch. Returns the
|
|
2378
2669
|
// list of user-facing warning messages (empty when everything was clean).
|
|
2379
2670
|
async function deleteWorkspaceWithSideEffects(workspace, opts) {
|
|
2380
|
-
// Stop agent
|
|
2671
|
+
// Stop the agent and WAIT for it to die. `git worktree remove --force` runs
|
|
2672
|
+
// a few lines below: firing and forgetting here pulled the directory from
|
|
2673
|
+
// under an agent still writing to it, destroying uncommitted work.
|
|
2381
2674
|
try {
|
|
2382
|
-
agentManager.
|
|
2675
|
+
await agentManager.stopAgentAndWait(workspace.id);
|
|
2383
2676
|
}
|
|
2384
|
-
catch {
|
|
2385
|
-
|
|
2677
|
+
catch (err) {
|
|
2678
|
+
console.error(`[workspaces] stopAgentAndWait during delete failed for '${workspace.name}':`, err);
|
|
2386
2679
|
}
|
|
2387
2680
|
// Stop dev server if it was running. The processSpawn would otherwise
|
|
2388
2681
|
// outlive the workspace (and keep its port + docker containers alive).
|
|
@@ -2412,7 +2705,7 @@ async function deleteWorkspaceWithSideEffects(workspace, opts) {
|
|
|
2412
2705
|
}
|
|
2413
2706
|
else if (workspace.worktreeOwned) {
|
|
2414
2707
|
try {
|
|
2415
|
-
worktreeService.removeWorktree(workspace.projectPath, worktreePath);
|
|
2708
|
+
await worktreeService.removeWorktree(workspace.projectPath, worktreePath);
|
|
2416
2709
|
}
|
|
2417
2710
|
catch (err) {
|
|
2418
2711
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -2549,12 +2842,12 @@ app.post('/:id/start', migrationGuard, async (c) => {
|
|
|
2549
2842
|
const prompt = body.prompt ?? pendingInitialPrompt ?? 'Continue the previous task where you left off.';
|
|
2550
2843
|
const agentSessionId = body.agentSessionId;
|
|
2551
2844
|
const resume = body.resume === true;
|
|
2552
|
-
// Stop existing agent
|
|
2845
|
+
// Stop the existing agent and wait: a fresh one starts right below.
|
|
2553
2846
|
try {
|
|
2554
|
-
agentManager.
|
|
2847
|
+
await agentManager.stopAgentAndWait(id);
|
|
2555
2848
|
}
|
|
2556
|
-
catch {
|
|
2557
|
-
|
|
2849
|
+
catch (err) {
|
|
2850
|
+
console.error(`[workspaces] stopAgentAndWait before start failed for '${id}':`, err);
|
|
2558
2851
|
}
|
|
2559
2852
|
const worktreePath = workspace.worktreePath;
|
|
2560
2853
|
const agent = agentManager.startAgent(id, worktreePath, prompt, workspace.model, resume, workspace.agentPermissionMode, agentSessionId, workspace.reasoningEffort);
|
|
@@ -2864,8 +3157,10 @@ app.post('/:id/rename-branch', async (c) => {
|
|
|
2864
3157
|
if (!newName) {
|
|
2865
3158
|
return c.json({ error: 'newName is required' }, 400);
|
|
2866
3159
|
}
|
|
2867
|
-
if (
|
|
2868
|
-
return c.json({
|
|
3160
|
+
if (!gitOps.isValidBranchName(newName)) {
|
|
3161
|
+
return c.json({
|
|
3162
|
+
error: 'Invalid branch name. It must start with a letter or a digit and may then contain letters, digits, /, _, - and . — no leading dash, no "..", no trailing "/", "." or ".lock".',
|
|
3163
|
+
}, 400);
|
|
2869
3164
|
}
|
|
2870
3165
|
const workspace = workspaceService.getWorkspace(id);
|
|
2871
3166
|
if (!workspace) {
|
|
@@ -3419,7 +3714,17 @@ app.post('/:id/cancel-source-change', async (c) => {
|
|
|
3419
3714
|
const workspace = workspaceService.getWorkspace(id);
|
|
3420
3715
|
if (!workspace)
|
|
3421
3716
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
3422
|
-
|
|
3717
|
+
// "No backup exists" and "I could not find out" are different answers.
|
|
3718
|
+
// Conflating them told the user restoration was impossible while the
|
|
3719
|
+
// backup branch was on disk, closing the only rollback path there is.
|
|
3720
|
+
let backups;
|
|
3721
|
+
try {
|
|
3722
|
+
backups = gitOps.listBackupBranches(workspace.worktreePath, workspace.workingBranch);
|
|
3723
|
+
}
|
|
3724
|
+
catch (err) {
|
|
3725
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3726
|
+
return c.json({ error: `Cannot list backup branches: ${message}`, code: 'backup_list_failed' }, 500);
|
|
3727
|
+
}
|
|
3423
3728
|
if (backups.length === 0) {
|
|
3424
3729
|
return c.json({ error: 'No backup branch found — cannot auto-restore', code: 'no_backup' }, 409);
|
|
3425
3730
|
}
|
|
@@ -3625,13 +3930,13 @@ app.post('/:id/start-review', async (c) => {
|
|
|
3625
3930
|
let messageSent = false;
|
|
3626
3931
|
let emitSessionId;
|
|
3627
3932
|
if (newSession) {
|
|
3628
|
-
// Stop current agent
|
|
3933
|
+
// Stop current agent, wait for it, then start fresh.
|
|
3629
3934
|
try {
|
|
3630
|
-
agentManager.
|
|
3935
|
+
await agentManager.stopAgentAndWait(workspace.id);
|
|
3631
3936
|
}
|
|
3632
3937
|
catch (err) {
|
|
3633
3938
|
const msg = err instanceof Error ? err.message : String(err);
|
|
3634
|
-
console.error(`[start-review]
|
|
3939
|
+
console.error(`[start-review] stopAgentAndWait failed (continuing): ${msg}`);
|
|
3635
3940
|
}
|
|
3636
3941
|
try {
|
|
3637
3942
|
const agent = agentManager.startAgent(workspace.id, worktreePath, rendered, workspace.model, false /* resume */, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
|
|
@@ -3807,7 +4112,7 @@ app.post('/:id/mark-read', (c) => {
|
|
|
3807
4112
|
}
|
|
3808
4113
|
});
|
|
3809
4114
|
// POST /api/workspaces/:id/stop — stop agent
|
|
3810
|
-
app.post('/:id/stop', migrationGuard, (c) => {
|
|
4115
|
+
app.post('/:id/stop', migrationGuard, async (c) => {
|
|
3811
4116
|
try {
|
|
3812
4117
|
const id = c.req.param('id');
|
|
3813
4118
|
const workspace = workspaceService.getWorkspace(id);
|
|
@@ -3815,10 +4120,12 @@ app.post('/:id/stop', migrationGuard, (c) => {
|
|
|
3815
4120
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
3816
4121
|
}
|
|
3817
4122
|
try {
|
|
3818
|
-
|
|
4123
|
+
// The response says "stopped": make that true before returning.
|
|
4124
|
+
await agentManager.stopAgentAndWait(id);
|
|
3819
4125
|
}
|
|
3820
|
-
catch {
|
|
4126
|
+
catch (err) {
|
|
3821
4127
|
// Agent may not be tracked (e.g. server restarted) — just update status
|
|
4128
|
+
console.error(`[workspaces] stopAgentAndWait on manual stop failed for '${id}':`, err);
|
|
3822
4129
|
}
|
|
3823
4130
|
// Always transition to idle so the UI reflects the stopped state
|
|
3824
4131
|
try {
|