@loicngr/kobo 1.11.16 → 1.11.17
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 +9 -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 +371 -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,9 @@ 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
|
+
creationId = typeof body.creationId === 'string' && body.creationId.length > 0 ? body.creationId : undefined;
|
|
312
|
+
currentStep = 'validate';
|
|
313
|
+
emitCreateProgress(creationId, currentStep);
|
|
171
314
|
// Validate the engine id (if provided) against the registry. An unknown
|
|
172
315
|
// engine is rejected up-front so we don't create orphan workspaces that
|
|
173
316
|
// can't spawn an agent.
|
|
@@ -175,7 +318,9 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
175
318
|
const engines = listEngines();
|
|
176
319
|
const validEngineIds = engines.map((e) => e.id);
|
|
177
320
|
if (!validEngineIds.includes(body.engine)) {
|
|
178
|
-
|
|
321
|
+
const message = `Unknown engine '${body.engine}'. Valid engines: ${validEngineIds.join(', ')}`;
|
|
322
|
+
emitCreateFailed(creationId, 'validate', message);
|
|
323
|
+
return c.json({ error: message, step: 'validate' }, 400);
|
|
179
324
|
}
|
|
180
325
|
// Cross-validate engine × permission mode: each engine declares which
|
|
181
326
|
// modes it supports via `capabilities.permissionModes`. The UI already
|
|
@@ -186,9 +331,9 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
186
331
|
const engine = engines.find((e) => e.id === body.engine);
|
|
187
332
|
const supported = engine?.capabilities.permissionModes ?? [];
|
|
188
333
|
if (!supported.includes(body.agentPermissionMode)) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}, 400);
|
|
334
|
+
const message = `Engine '${body.engine}' does not support agentPermissionMode '${body.agentPermissionMode}'. Supported: ${supported.join(', ')}`;
|
|
335
|
+
emitCreateFailed(creationId, 'validate', message);
|
|
336
|
+
return c.json({ error: message, step: 'validate' }, 400);
|
|
192
337
|
}
|
|
193
338
|
}
|
|
194
339
|
}
|
|
@@ -197,6 +342,8 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
197
342
|
// and only hard-block (422) when neither origin nor a local branch is usable.
|
|
198
343
|
// The reuse path (body.worktreePath) needs no base ref, so a failed fetch is
|
|
199
344
|
// simply logged there.
|
|
345
|
+
currentStep = 'fetch-source-branch';
|
|
346
|
+
emitCreateProgress(creationId, currentStep);
|
|
200
347
|
const isReuseRequest = !!body.worktreePath;
|
|
201
348
|
let baseRef = `origin/${body.sourceBranch}`;
|
|
202
349
|
let usedLocalFallback = false;
|
|
@@ -214,7 +361,9 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
214
361
|
console.warn(`[workspaces] fetch of '${body.sourceBranch}' from origin failed; falling back to local branch: ${message}`);
|
|
215
362
|
}
|
|
216
363
|
else {
|
|
217
|
-
|
|
364
|
+
const failure = `${message} — and no local branch '${body.sourceBranch}' exists to fall back on`;
|
|
365
|
+
emitCreateFailed(creationId, 'fetch-source-branch', failure);
|
|
366
|
+
return c.json({ error: failure, step: 'fetch-source-branch' }, 422);
|
|
218
367
|
}
|
|
219
368
|
}
|
|
220
369
|
// Reuse-existing-worktree path. When the caller passes `worktreePath`,
|
|
@@ -224,9 +373,15 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
224
373
|
// the working branch from git itself — the body.workingBranch is ignored.
|
|
225
374
|
let useReusedWorktree = false;
|
|
226
375
|
let reusedDerivedBranch = null;
|
|
376
|
+
if (body.worktreePath) {
|
|
377
|
+
currentStep = 'inspect-worktree';
|
|
378
|
+
emitCreateProgress(creationId, currentStep);
|
|
379
|
+
}
|
|
227
380
|
if (body.worktreePath) {
|
|
228
381
|
if (!fs.existsSync(body.worktreePath)) {
|
|
229
|
-
|
|
382
|
+
const failure = `Worktree path does not exist: ${body.worktreePath}`;
|
|
383
|
+
emitCreateFailed(creationId, 'inspect-worktree', failure);
|
|
384
|
+
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
230
385
|
}
|
|
231
386
|
try {
|
|
232
387
|
const commonDir = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--git-common-dir'], {
|
|
@@ -234,25 +389,33 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
234
389
|
}).trim();
|
|
235
390
|
const expectedCommonDir = path.join(body.projectPath, '.git');
|
|
236
391
|
if (path.resolve(commonDir) !== path.resolve(expectedCommonDir)) {
|
|
237
|
-
|
|
392
|
+
const failure = `Worktree '${body.worktreePath}' belongs to a different repository`;
|
|
393
|
+
emitCreateFailed(creationId, 'inspect-worktree', failure);
|
|
394
|
+
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
238
395
|
}
|
|
239
396
|
const branch = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
240
397
|
encoding: 'utf-8',
|
|
241
398
|
}).trim();
|
|
242
399
|
if (!branch || branch === 'HEAD') {
|
|
243
|
-
|
|
400
|
+
const failure = 'Worktree is in detached HEAD state and cannot be attached';
|
|
401
|
+
emitCreateFailed(creationId, 'inspect-worktree', failure);
|
|
402
|
+
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
244
403
|
}
|
|
245
404
|
reusedDerivedBranch = branch;
|
|
246
405
|
}
|
|
247
406
|
catch (err) {
|
|
248
407
|
const message = err instanceof Error ? err.message : String(err);
|
|
249
|
-
|
|
408
|
+
const failure = `Failed to inspect worktree: ${message}`;
|
|
409
|
+
emitCreateFailed(creationId, 'inspect-worktree', failure);
|
|
410
|
+
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
250
411
|
}
|
|
251
412
|
// Validate the worktree isn't already attached to another workspace.
|
|
252
413
|
const dbForCheck = getDb();
|
|
253
414
|
const existing = dbForCheck.prepare('SELECT id FROM workspaces WHERE worktree_path = ?').get(body.worktreePath);
|
|
254
415
|
if (existing) {
|
|
255
|
-
|
|
416
|
+
const failure = 'This worktree is already attached to another Kōbō workspace';
|
|
417
|
+
emitCreateFailed(creationId, 'inspect-worktree', failure);
|
|
418
|
+
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
256
419
|
}
|
|
257
420
|
useReusedWorktree = true;
|
|
258
421
|
}
|
|
@@ -261,15 +424,21 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
261
424
|
// before createWorkspace and surface failures as 422.
|
|
262
425
|
let notionContent = null;
|
|
263
426
|
if (body.notionUrl) {
|
|
427
|
+
currentStep = 'extract-notion';
|
|
428
|
+
emitCreateProgress(creationId, currentStep);
|
|
264
429
|
if (!settingsService.getGlobalSettings().notionEnabled) {
|
|
265
|
-
|
|
430
|
+
const failure = 'Notion integration is disabled in Settings';
|
|
431
|
+
emitCreateFailed(creationId, 'extract-notion', failure);
|
|
432
|
+
return c.json({ error: failure, step: 'extract-notion' }, 403);
|
|
266
433
|
}
|
|
267
434
|
try {
|
|
268
435
|
notionContent = await notionService.extractNotionPage(body.notionUrl);
|
|
269
436
|
}
|
|
270
437
|
catch (err) {
|
|
271
438
|
const message = err instanceof Error ? err.message : String(err);
|
|
272
|
-
|
|
439
|
+
const failure = `Failed to extract Notion page: ${message}`;
|
|
440
|
+
emitCreateFailed(creationId, 'extract-notion', failure);
|
|
441
|
+
return c.json({ error: failure, step: 'extract-notion' }, 422);
|
|
273
442
|
}
|
|
274
443
|
const assigneeProperty = settingsService.getGlobalSettings().notionAssigneeProperty;
|
|
275
444
|
if (assigneeProperty && body.notionUrl) {
|
|
@@ -286,15 +455,21 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
286
455
|
}
|
|
287
456
|
let sentryContent = null;
|
|
288
457
|
if (body.sentryUrl) {
|
|
458
|
+
currentStep = 'extract-sentry';
|
|
459
|
+
emitCreateProgress(creationId, currentStep);
|
|
289
460
|
if (!settingsService.getGlobalSettings().sentryEnabled) {
|
|
290
|
-
|
|
461
|
+
const failure = 'Sentry integration is disabled in Settings';
|
|
462
|
+
emitCreateFailed(creationId, 'extract-sentry', failure);
|
|
463
|
+
return c.json({ error: failure, step: 'extract-sentry' }, 403);
|
|
291
464
|
}
|
|
292
465
|
try {
|
|
293
466
|
sentryContent = await sentryService.extractSentryIssue(body.sentryUrl);
|
|
294
467
|
}
|
|
295
468
|
catch (err) {
|
|
296
469
|
const message = err instanceof Error ? err.message : String(err);
|
|
297
|
-
|
|
470
|
+
const failure = `Failed to extract Sentry issue: ${message}`;
|
|
471
|
+
emitCreateFailed(creationId, 'extract-sentry', failure);
|
|
472
|
+
return c.json({ error: failure, step: 'extract-sentry' }, 422);
|
|
298
473
|
}
|
|
299
474
|
if (sentryContent && sentryContent.assignee.length === 0) {
|
|
300
475
|
const sentryUrl = body.sentryUrl;
|
|
@@ -358,6 +533,8 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
358
533
|
// request — keeps the user's flow smooth at the cost of a longer
|
|
359
534
|
// branch name. The same hash is applied to BOTH the branch and the
|
|
360
535
|
// worktree path so they stay aligned.
|
|
536
|
+
currentStep = 'create-record';
|
|
537
|
+
emitCreateProgress(creationId, currentStep);
|
|
361
538
|
let prospectiveWorktreePath;
|
|
362
539
|
let workingBranchAdjusted = false;
|
|
363
540
|
if (useReusedWorktree) {
|
|
@@ -377,7 +554,8 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
377
554
|
}
|
|
378
555
|
catch (err) {
|
|
379
556
|
const message = err instanceof Error ? err.message : String(err);
|
|
380
|
-
|
|
557
|
+
emitCreateFailed(creationId, 'create-record', message);
|
|
558
|
+
return c.json({ error: message, step: 'create-record' }, 409);
|
|
381
559
|
}
|
|
382
560
|
}
|
|
383
561
|
let workspace = workspaceService.createWorkspace({
|
|
@@ -397,6 +575,7 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
397
575
|
engine: body.engine,
|
|
398
576
|
...(useReusedWorktree ? {} : { worktreesPath: globalSettings.worktreesPath }),
|
|
399
577
|
});
|
|
578
|
+
createdWorkspace = workspace;
|
|
400
579
|
// Enable auto-loop before starting the initial brainstorming session so
|
|
401
580
|
// its model override is available when selecting that session's model.
|
|
402
581
|
if (body.autoLoop === true) {
|
|
@@ -443,6 +622,8 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
443
622
|
const prefix = sentryContent.issueId ? `${sentryContent.issueId} | ` : '';
|
|
444
623
|
workspace = workspaceService.updateWorkspaceName(workspace.id, `${prefix}${sentryContent.title}`);
|
|
445
624
|
}
|
|
625
|
+
currentStep = 'create-tasks';
|
|
626
|
+
emitCreateProgress(creationId, currentStep);
|
|
446
627
|
// Create tasks from extracted Notion data
|
|
447
628
|
if (notionContent) {
|
|
448
629
|
let sortOrder = 0;
|
|
@@ -493,6 +674,8 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
493
674
|
}
|
|
494
675
|
}
|
|
495
676
|
}
|
|
677
|
+
currentStep = 'create-worktree';
|
|
678
|
+
emitCreateProgress(creationId, currentStep);
|
|
496
679
|
// Create git worktree for the working branch — unless we're reusing an
|
|
497
680
|
// existing one, in which case the path is taken straight from the body.
|
|
498
681
|
let worktreePath;
|
|
@@ -503,27 +686,24 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
503
686
|
try {
|
|
504
687
|
const created = worktreeService.createWorktree(body.projectPath, workingBranch, baseRef, globalSettings.worktreesPath, projectSlug);
|
|
505
688
|
worktreePath = created.worktreePath;
|
|
689
|
+
// Not an assumption: `createWorktree` reports which git command it ran.
|
|
690
|
+
// It falls back to attaching an existing branch, and deleting that one
|
|
691
|
+
// on rollback would destroy commits the user made before Kobo existed.
|
|
692
|
+
createdBranch = created.branchCreated;
|
|
506
693
|
}
|
|
507
694
|
catch (err) {
|
|
508
695
|
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);
|
|
696
|
+
// Roll back the half-created workspace through the SHARED demolition
|
|
697
|
+
// path — a second inline cleanup here would drift from it. The branch
|
|
698
|
+
// is not deleted: `createWorktree` failed, so we never created one.
|
|
699
|
+
const rollbackWarnings = await rollbackFailedCreation(workspace, { deleteLocalBranch: false });
|
|
700
|
+
const failure = `Failed to create worktree: ${message}`;
|
|
701
|
+
emitCreateFailed(creationId, 'create-worktree', failure);
|
|
702
|
+
return c.json({ error: failure, step: 'create-worktree', rollback: { done: true, warnings: rollbackWarnings } }, 500);
|
|
525
703
|
}
|
|
526
704
|
}
|
|
705
|
+
currentStep = 'write-conventions';
|
|
706
|
+
emitCreateProgress(creationId, currentStep);
|
|
527
707
|
// Ensure Kobo-generated files are gitignored. Check both the root
|
|
528
708
|
// .gitignore and .ai/.gitignore to avoid duplicate entries.
|
|
529
709
|
try {
|
|
@@ -554,7 +734,10 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
554
734
|
}
|
|
555
735
|
}
|
|
556
736
|
catch (err) {
|
|
557
|
-
|
|
737
|
+
logError('workspaces', 'Failed to update .gitignore', {
|
|
738
|
+
workspaceId: workspace.id,
|
|
739
|
+
error: err instanceof Error ? err.message : String(err),
|
|
740
|
+
});
|
|
558
741
|
}
|
|
559
742
|
// Write git conventions to the worktree if configured
|
|
560
743
|
const effectiveSettings = settingsService.getEffectiveSettings(body.projectPath);
|
|
@@ -566,13 +749,23 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
566
749
|
fs.writeFileSync(conventionsPath, effectiveSettings.gitConventions, 'utf-8');
|
|
567
750
|
}
|
|
568
751
|
catch (err) {
|
|
569
|
-
|
|
752
|
+
logError('workspaces', 'Failed to write .git-conventions.md', {
|
|
753
|
+
workspaceId: workspace.id,
|
|
754
|
+
error: err instanceof Error ? err.message : String(err),
|
|
755
|
+
});
|
|
570
756
|
}
|
|
571
757
|
}
|
|
572
758
|
// The setup script runs LATER (after the brainstorm prompt is built and
|
|
573
759
|
// persisted via setInitialPrompt). This guarantees the prompt survives a
|
|
574
760
|
// setup-script crash and can be replayed by /:id/start.
|
|
575
761
|
let setupScriptFailed = false;
|
|
762
|
+
// Captured, not swallowed: a failed engine start used to reach only the
|
|
763
|
+
// server console while the route still answered 201 "created". A
|
|
764
|
+
// half-created workspace lies to the user, so this now drives a full
|
|
765
|
+
// rollback below instead of merely flipping the status to 'error'.
|
|
766
|
+
let agentStartError = null;
|
|
767
|
+
currentStep = 'write-context-files';
|
|
768
|
+
emitCreateProgress(creationId, currentStep);
|
|
576
769
|
// Save Notion content as markdown in worktree
|
|
577
770
|
let notionFilePath = null;
|
|
578
771
|
if (notionContent && body.notionUrl) {
|
|
@@ -618,7 +811,10 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
618
811
|
fs.writeFileSync(notionFilePath, md, 'utf-8');
|
|
619
812
|
}
|
|
620
813
|
catch (err) {
|
|
621
|
-
|
|
814
|
+
logError('workspaces', 'Failed to save Notion content', {
|
|
815
|
+
workspaceId: workspace.id,
|
|
816
|
+
error: err instanceof Error ? err.message : String(err),
|
|
817
|
+
});
|
|
622
818
|
}
|
|
623
819
|
}
|
|
624
820
|
// --- Sentry file + task (extraction already done before worktree creation) --
|
|
@@ -667,7 +863,10 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
667
863
|
});
|
|
668
864
|
}
|
|
669
865
|
catch (err) {
|
|
670
|
-
|
|
866
|
+
logError('workspaces', 'Failed to save Sentry content', {
|
|
867
|
+
workspaceId: workspace.id,
|
|
868
|
+
error: err instanceof Error ? err.message : String(err),
|
|
869
|
+
});
|
|
671
870
|
sentryFilePath = null;
|
|
672
871
|
}
|
|
673
872
|
}
|
|
@@ -693,6 +892,8 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
693
892
|
// VALID_TRANSITIONS contract intact (`created → extracting → brainstorming`
|
|
694
893
|
// vs `created → brainstorming` when setup is skipped).
|
|
695
894
|
{
|
|
895
|
+
currentStep = 'build-prompt';
|
|
896
|
+
emitCreateProgress(creationId, currentStep);
|
|
696
897
|
// Resolve the per-feature initial-prompt templates with single-fallback
|
|
697
898
|
// semantics: project || global is already handled inside getEffectiveSettings,
|
|
698
899
|
// and a whitespace-only string acts as a user escape hatch (skip injection).
|
|
@@ -842,8 +1043,10 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
842
1043
|
// leaves the workspace in `error` state with `initial_prompt` ready for
|
|
843
1044
|
// a retry via POST /:id/start. Skipped when reusing an existing worktree
|
|
844
1045
|
// (rerunning could be destructive — drop a curated node_modules, etc.).
|
|
845
|
-
|
|
1046
|
+
setupScriptConfigured = Boolean(effectiveSettings.setupScript) && !body.skipSetupScript && !useReusedWorktree;
|
|
846
1047
|
if (setupScriptConfigured) {
|
|
1048
|
+
currentStep = 'setup-script';
|
|
1049
|
+
emitCreateProgress(creationId, currentStep);
|
|
847
1050
|
workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
|
|
848
1051
|
wsService.emit(workspace.id, 'setup:output', { text: '[kobo] Running setup script...' });
|
|
849
1052
|
try {
|
|
@@ -856,9 +1059,10 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
856
1059
|
if (result.exitCode !== 0) {
|
|
857
1060
|
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
858
1061
|
setupScriptFailed = true;
|
|
1062
|
+
emitCreateFailed(creationId, 'setup-script', `Setup script exited with code ${result.exitCode}`);
|
|
859
1063
|
}
|
|
860
1064
|
else {
|
|
861
|
-
|
|
1065
|
+
markBrainstormingBestEffort(workspace.id);
|
|
862
1066
|
}
|
|
863
1067
|
}
|
|
864
1068
|
catch (err) {
|
|
@@ -866,19 +1070,28 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
866
1070
|
console.error(`[workspaces] Setup script error: ${message}`);
|
|
867
1071
|
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
868
1072
|
setupScriptFailed = true;
|
|
1073
|
+
emitCreateFailed(creationId, 'setup-script', message);
|
|
869
1074
|
}
|
|
870
1075
|
}
|
|
871
1076
|
else {
|
|
872
1077
|
// No setup step → go straight from `created` to `brainstorming`.
|
|
873
|
-
|
|
1078
|
+
markBrainstormingBestEffort(workspace.id);
|
|
874
1079
|
}
|
|
875
1080
|
if (setupScriptFailed) {
|
|
876
1081
|
wsService.emit(workspace.id, 'setup:output', {
|
|
877
1082
|
text: '[kobo] Setup script failed — the agent was NOT started. Your initial prompt has been saved. ' +
|
|
878
1083
|
'Fix the setup script (Settings → Scripts) and click Start to retry with the original prompt.',
|
|
879
1084
|
});
|
|
1085
|
+
// This path keeps its deliberate recovery flow (workspace stays in
|
|
1086
|
+
// `error`, prompt persisted, 201 response) — nothing is undone here.
|
|
1087
|
+
// The step is already named on the create-progress channel by the
|
|
1088
|
+
// `emitCreateFailed(creationId, 'setup-script', …)` calls above, at
|
|
1089
|
+
// the point of detection (exit code / thrown error), so the create
|
|
1090
|
+
// page never shows a mute spinner on this path.
|
|
880
1091
|
}
|
|
881
1092
|
else {
|
|
1093
|
+
currentStep = 'start-agent';
|
|
1094
|
+
emitCreateProgress(creationId, currentStep);
|
|
882
1095
|
try {
|
|
883
1096
|
// The brainstorming session (this very first startAgent call) uses
|
|
884
1097
|
// brainstormModel when the workspace was created with auto-loop
|
|
@@ -906,15 +1119,35 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
906
1119
|
catch (err) {
|
|
907
1120
|
const message = err instanceof Error ? err.message : String(err);
|
|
908
1121
|
console.error(`[workspaces] Failed to start agent: ${message}`);
|
|
909
|
-
|
|
910
|
-
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
911
|
-
}
|
|
912
|
-
catch {
|
|
913
|
-
/* already logged */
|
|
914
|
-
}
|
|
1122
|
+
agentStartError = message;
|
|
915
1123
|
}
|
|
916
1124
|
}
|
|
917
1125
|
}
|
|
1126
|
+
// A workspace whose agent never started is a half-created object, and a
|
|
1127
|
+
// half-created object is exactly what lies to the user. Undo everything
|
|
1128
|
+
// and report the step that broke.
|
|
1129
|
+
if (agentStartError) {
|
|
1130
|
+
emitCreateProgress(creationId, 'rollback');
|
|
1131
|
+
// A worktree the user brought is not ours to remove, and neither is the
|
|
1132
|
+
// branch it was already sitting on. `deleteWorkspaceWithSideEffects`
|
|
1133
|
+
// already guards the worktree via `worktreeOwned`; the branch flag is
|
|
1134
|
+
// ours to set.
|
|
1135
|
+
const rollbackWarnings = await rollbackFailedCreation(workspace, {
|
|
1136
|
+
deleteLocalBranch: !useReusedWorktree,
|
|
1137
|
+
});
|
|
1138
|
+
// The setup script has already run by this point — see the caveat's own
|
|
1139
|
+
// comment for what that rollback cannot reach.
|
|
1140
|
+
const setupCaveat = setupScriptConfigured ? SETUP_SCRIPT_ROLLBACK_CAVEAT : '';
|
|
1141
|
+
const failure = `Failed to start the agent: ${agentStartError}. The workspace, its worktree and its branch were removed.${setupCaveat}`;
|
|
1142
|
+
emitCreateFailed(creationId, 'start-agent', failure);
|
|
1143
|
+
return c.json({ error: failure, step: 'start-agent', rollback: { done: true, warnings: rollbackWarnings } }, 500);
|
|
1144
|
+
}
|
|
1145
|
+
// The agent is running. From here the workspace is a live object, not a
|
|
1146
|
+
// half-created one, so it stops being the rollback's business: dropping the
|
|
1147
|
+
// handle means a later throw (the read below, a header, a status beat)
|
|
1148
|
+
// returns 500 without demolishing a worktree that already has an agent
|
|
1149
|
+
// working in it. Failures before this line still roll back in full.
|
|
1150
|
+
createdWorkspace = null;
|
|
918
1151
|
// Return created workspace with tasks
|
|
919
1152
|
const workspaceWithTasks = workspaceService.getWorkspaceWithTasks(workspace.id);
|
|
920
1153
|
if (workingBranchAdjusted) {
|
|
@@ -926,11 +1159,49 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
926
1159
|
if (usedLocalFallback) {
|
|
927
1160
|
c.header('X-Kobo-Source-Fallback', 'local');
|
|
928
1161
|
}
|
|
1162
|
+
// The `done` beat means "the sequence completed the way the user expects" —
|
|
1163
|
+
// NOT merely "the HTTP response is about to return 201". A workspace whose
|
|
1164
|
+
// setup script failed still gets a 201 (its record exists, in `error`
|
|
1165
|
+
// status, ready for retry via POST /:id/start) because that path has a
|
|
1166
|
+
// deliberate recovery flow — but the create-progress channel must not
|
|
1167
|
+
// claim success on it: the corresponding emitCreateFailed above already
|
|
1168
|
+
// named the real failure. A failed agent start never reaches this line at
|
|
1169
|
+
// all (see the rollback branch above).
|
|
1170
|
+
if (!setupScriptFailed) {
|
|
1171
|
+
currentStep = 'done';
|
|
1172
|
+
emitCreateProgress(creationId, currentStep);
|
|
1173
|
+
}
|
|
929
1174
|
return c.json(workspaceWithTasks, 201);
|
|
930
1175
|
}
|
|
931
1176
|
catch (err) {
|
|
932
1177
|
const message = err instanceof Error ? err.message : String(err);
|
|
933
|
-
|
|
1178
|
+
// Anything past `create-record` left a row behind. Leaving it there is a
|
|
1179
|
+
// half-created workspace whose existence lies to the user — and the client
|
|
1180
|
+
// already tells them "the server undoes everything it created". Make that
|
|
1181
|
+
// sentence true for every step, not only the two that had their own
|
|
1182
|
+
// handler. Best-effort: the rollback never throws and never becomes the
|
|
1183
|
+
// reported error.
|
|
1184
|
+
if (createdWorkspace) {
|
|
1185
|
+
emitCreateProgress(creationId, 'rollback');
|
|
1186
|
+
const rollbackWarnings = await rollbackFailedCreation(createdWorkspace, { deleteLocalBranch: createdBranch });
|
|
1187
|
+
logError('workspaces', 'Rolled back a creation that failed outside any per-step handler', {
|
|
1188
|
+
workspaceId: createdWorkspace.id,
|
|
1189
|
+
step: currentStep,
|
|
1190
|
+
error: message,
|
|
1191
|
+
...(rollbackWarnings.length > 0 ? { rollbackWarnings } : {}),
|
|
1192
|
+
});
|
|
1193
|
+
// Say exactly what was undone — a claim of "everything" would be the
|
|
1194
|
+
// very kind of comfortable lie this route is being fixed for.
|
|
1195
|
+
const removed = createdBranch
|
|
1196
|
+
? ' The workspace, its worktree and its branch were removed.'
|
|
1197
|
+
: ' The half-created workspace was removed.';
|
|
1198
|
+
const cause = message.endsWith('.') ? message : `${message}.`;
|
|
1199
|
+
const failure = `${cause}${removed}${setupScriptConfigured ? SETUP_SCRIPT_ROLLBACK_CAVEAT : ''}`;
|
|
1200
|
+
emitCreateFailed(creationId, currentStep, failure);
|
|
1201
|
+
return c.json({ error: failure, step: currentStep, rollback: { done: true, warnings: rollbackWarnings } }, 500);
|
|
1202
|
+
}
|
|
1203
|
+
emitCreateFailed(creationId, currentStep, message);
|
|
1204
|
+
return c.json({ error: message, step: currentStep }, 500);
|
|
934
1205
|
}
|
|
935
1206
|
});
|
|
936
1207
|
// POST /api/workspaces/:id/sessions — create a new idle agent session
|
|
@@ -994,6 +1265,9 @@ app.get('/info', (c) => {
|
|
|
994
1265
|
workspaces: workspaceService.listWorkspaces(false),
|
|
995
1266
|
prSnapshots: getAllPrSnapshots(),
|
|
996
1267
|
gitStats: getAllGitStats(),
|
|
1268
|
+
// In-memory truth, not the `status` column: a workspace missing from
|
|
1269
|
+
// this map has no agent, whatever its status claims.
|
|
1270
|
+
agentLiveness: agentManager.getAllAgentLiveness(),
|
|
997
1271
|
});
|
|
998
1272
|
}
|
|
999
1273
|
catch (err) {
|
|
@@ -1419,7 +1693,15 @@ app.delete('/:id/events/:eventId', (c) => {
|
|
|
1419
1693
|
return c.json({ error: `Workspace '${workspaceId}' not found` }, 404);
|
|
1420
1694
|
}
|
|
1421
1695
|
const db = getDb();
|
|
1696
|
+
// Capture the session BEFORE deleting: since v36 there is no AFTER DELETE
|
|
1697
|
+
// trigger, so the metrics of that session must be recomputed once, here.
|
|
1698
|
+
const row = db
|
|
1699
|
+
.prepare('SELECT session_id FROM ws_events WHERE id = ? AND workspace_id = ?')
|
|
1700
|
+
.get(eventId, workspaceId);
|
|
1422
1701
|
db.prepare('DELETE FROM ws_events WHERE id = ? AND workspace_id = ?').run(eventId, workspaceId);
|
|
1702
|
+
if (row?.session_id) {
|
|
1703
|
+
workspaceService.recomputeSessionMetrics(workspaceId, row.session_id);
|
|
1704
|
+
}
|
|
1423
1705
|
return c.json({ ok: true });
|
|
1424
1706
|
}
|
|
1425
1707
|
catch (err) {
|
|
@@ -1942,7 +2224,7 @@ app.get('/:id', (c) => {
|
|
|
1942
2224
|
if (!workspace) {
|
|
1943
2225
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1944
2226
|
}
|
|
1945
|
-
return c.json(workspace);
|
|
2227
|
+
return c.json({ ...workspace, agentLiveness: agentManager.getAgentLiveness(id) });
|
|
1946
2228
|
}
|
|
1947
2229
|
catch (err) {
|
|
1948
2230
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -2244,14 +2526,15 @@ app.post('/:id/run-setup-script', async (c) => {
|
|
|
2244
2526
|
if (setupScriptRunning.has(id)) {
|
|
2245
2527
|
return c.json({ error: 'Setup script is already running for this workspace' }, 409);
|
|
2246
2528
|
}
|
|
2247
|
-
// Stop the running agent before re-running the setup script
|
|
2529
|
+
// Stop the running agent before re-running the setup script — the script
|
|
2530
|
+
// rewrites the worktree the agent may still be writing to.
|
|
2248
2531
|
try {
|
|
2249
2532
|
if (agentManager.getAgentStatus(id)) {
|
|
2250
|
-
agentManager.
|
|
2533
|
+
await agentManager.stopAgentAndWait(id);
|
|
2251
2534
|
}
|
|
2252
2535
|
}
|
|
2253
|
-
catch {
|
|
2254
|
-
|
|
2536
|
+
catch (err) {
|
|
2537
|
+
console.error(`[workspaces] stopAgentAndWait before setup script failed for '${id}':`, err);
|
|
2255
2538
|
}
|
|
2256
2539
|
const effectiveSettings = settingsService.getEffectiveSettings(workspace.projectPath);
|
|
2257
2540
|
if (!effectiveSettings.setupScript) {
|
|
@@ -2295,10 +2578,10 @@ app.post('/:id/archive', migrationGuard, async (c) => {
|
|
|
2295
2578
|
return c.json({ error: 'Already archived' }, 400);
|
|
2296
2579
|
}
|
|
2297
2580
|
try {
|
|
2298
|
-
agentManager.
|
|
2581
|
+
await agentManager.stopAgentAndWait(id);
|
|
2299
2582
|
}
|
|
2300
|
-
catch {
|
|
2301
|
-
|
|
2583
|
+
catch (err) {
|
|
2584
|
+
console.error(`[workspaces] stopAgentAndWait during archive failed for '${id}':`, err);
|
|
2302
2585
|
}
|
|
2303
2586
|
try {
|
|
2304
2587
|
await devServerService.stopDevServer(id);
|
|
@@ -2377,12 +2660,14 @@ app.post('/:id/unarchive', migrationGuard, (c) => {
|
|
|
2377
2660
|
// rather than thrown, so a bulk delete never aborts mid-batch. Returns the
|
|
2378
2661
|
// list of user-facing warning messages (empty when everything was clean).
|
|
2379
2662
|
async function deleteWorkspaceWithSideEffects(workspace, opts) {
|
|
2380
|
-
// Stop agent
|
|
2663
|
+
// Stop the agent and WAIT for it to die. `git worktree remove --force` runs
|
|
2664
|
+
// a few lines below: firing and forgetting here pulled the directory from
|
|
2665
|
+
// under an agent still writing to it, destroying uncommitted work.
|
|
2381
2666
|
try {
|
|
2382
|
-
agentManager.
|
|
2667
|
+
await agentManager.stopAgentAndWait(workspace.id);
|
|
2383
2668
|
}
|
|
2384
|
-
catch {
|
|
2385
|
-
|
|
2669
|
+
catch (err) {
|
|
2670
|
+
console.error(`[workspaces] stopAgentAndWait during delete failed for '${workspace.name}':`, err);
|
|
2386
2671
|
}
|
|
2387
2672
|
// Stop dev server if it was running. The processSpawn would otherwise
|
|
2388
2673
|
// outlive the workspace (and keep its port + docker containers alive).
|
|
@@ -2412,7 +2697,7 @@ async function deleteWorkspaceWithSideEffects(workspace, opts) {
|
|
|
2412
2697
|
}
|
|
2413
2698
|
else if (workspace.worktreeOwned) {
|
|
2414
2699
|
try {
|
|
2415
|
-
worktreeService.removeWorktree(workspace.projectPath, worktreePath);
|
|
2700
|
+
await worktreeService.removeWorktree(workspace.projectPath, worktreePath);
|
|
2416
2701
|
}
|
|
2417
2702
|
catch (err) {
|
|
2418
2703
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -2549,12 +2834,12 @@ app.post('/:id/start', migrationGuard, async (c) => {
|
|
|
2549
2834
|
const prompt = body.prompt ?? pendingInitialPrompt ?? 'Continue the previous task where you left off.';
|
|
2550
2835
|
const agentSessionId = body.agentSessionId;
|
|
2551
2836
|
const resume = body.resume === true;
|
|
2552
|
-
// Stop existing agent
|
|
2837
|
+
// Stop the existing agent and wait: a fresh one starts right below.
|
|
2553
2838
|
try {
|
|
2554
|
-
agentManager.
|
|
2839
|
+
await agentManager.stopAgentAndWait(id);
|
|
2555
2840
|
}
|
|
2556
|
-
catch {
|
|
2557
|
-
|
|
2841
|
+
catch (err) {
|
|
2842
|
+
console.error(`[workspaces] stopAgentAndWait before start failed for '${id}':`, err);
|
|
2558
2843
|
}
|
|
2559
2844
|
const worktreePath = workspace.worktreePath;
|
|
2560
2845
|
const agent = agentManager.startAgent(id, worktreePath, prompt, workspace.model, resume, workspace.agentPermissionMode, agentSessionId, workspace.reasoningEffort);
|
|
@@ -2864,8 +3149,10 @@ app.post('/:id/rename-branch', async (c) => {
|
|
|
2864
3149
|
if (!newName) {
|
|
2865
3150
|
return c.json({ error: 'newName is required' }, 400);
|
|
2866
3151
|
}
|
|
2867
|
-
if (
|
|
2868
|
-
return c.json({
|
|
3152
|
+
if (!gitOps.isValidBranchName(newName)) {
|
|
3153
|
+
return c.json({
|
|
3154
|
+
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".',
|
|
3155
|
+
}, 400);
|
|
2869
3156
|
}
|
|
2870
3157
|
const workspace = workspaceService.getWorkspace(id);
|
|
2871
3158
|
if (!workspace) {
|
|
@@ -3419,7 +3706,17 @@ app.post('/:id/cancel-source-change', async (c) => {
|
|
|
3419
3706
|
const workspace = workspaceService.getWorkspace(id);
|
|
3420
3707
|
if (!workspace)
|
|
3421
3708
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
3422
|
-
|
|
3709
|
+
// "No backup exists" and "I could not find out" are different answers.
|
|
3710
|
+
// Conflating them told the user restoration was impossible while the
|
|
3711
|
+
// backup branch was on disk, closing the only rollback path there is.
|
|
3712
|
+
let backups;
|
|
3713
|
+
try {
|
|
3714
|
+
backups = gitOps.listBackupBranches(workspace.worktreePath, workspace.workingBranch);
|
|
3715
|
+
}
|
|
3716
|
+
catch (err) {
|
|
3717
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3718
|
+
return c.json({ error: `Cannot list backup branches: ${message}`, code: 'backup_list_failed' }, 500);
|
|
3719
|
+
}
|
|
3423
3720
|
if (backups.length === 0) {
|
|
3424
3721
|
return c.json({ error: 'No backup branch found — cannot auto-restore', code: 'no_backup' }, 409);
|
|
3425
3722
|
}
|
|
@@ -3625,13 +3922,13 @@ app.post('/:id/start-review', async (c) => {
|
|
|
3625
3922
|
let messageSent = false;
|
|
3626
3923
|
let emitSessionId;
|
|
3627
3924
|
if (newSession) {
|
|
3628
|
-
// Stop current agent
|
|
3925
|
+
// Stop current agent, wait for it, then start fresh.
|
|
3629
3926
|
try {
|
|
3630
|
-
agentManager.
|
|
3927
|
+
await agentManager.stopAgentAndWait(workspace.id);
|
|
3631
3928
|
}
|
|
3632
3929
|
catch (err) {
|
|
3633
3930
|
const msg = err instanceof Error ? err.message : String(err);
|
|
3634
|
-
console.error(`[start-review]
|
|
3931
|
+
console.error(`[start-review] stopAgentAndWait failed (continuing): ${msg}`);
|
|
3635
3932
|
}
|
|
3636
3933
|
try {
|
|
3637
3934
|
const agent = agentManager.startAgent(workspace.id, worktreePath, rendered, workspace.model, false /* resume */, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
|
|
@@ -3807,7 +4104,7 @@ app.post('/:id/mark-read', (c) => {
|
|
|
3807
4104
|
}
|
|
3808
4105
|
});
|
|
3809
4106
|
// POST /api/workspaces/:id/stop — stop agent
|
|
3810
|
-
app.post('/:id/stop', migrationGuard, (c) => {
|
|
4107
|
+
app.post('/:id/stop', migrationGuard, async (c) => {
|
|
3811
4108
|
try {
|
|
3812
4109
|
const id = c.req.param('id');
|
|
3813
4110
|
const workspace = workspaceService.getWorkspace(id);
|
|
@@ -3815,10 +4112,12 @@ app.post('/:id/stop', migrationGuard, (c) => {
|
|
|
3815
4112
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
3816
4113
|
}
|
|
3817
4114
|
try {
|
|
3818
|
-
|
|
4115
|
+
// The response says "stopped": make that true before returning.
|
|
4116
|
+
await agentManager.stopAgentAndWait(id);
|
|
3819
4117
|
}
|
|
3820
|
-
catch {
|
|
4118
|
+
catch (err) {
|
|
3821
4119
|
// Agent may not be tracked (e.g. server restarted) — just update status
|
|
4120
|
+
console.error(`[workspaces] stopAgentAndWait on manual stop failed for '${id}':`, err);
|
|
3822
4121
|
}
|
|
3823
4122
|
// Always transition to idle so the UI reflects the stopped state
|
|
3824
4123
|
try {
|