@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.
Files changed (175) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +9 -0
  3. package/dist/mcp-server/kobo-tasks-server.js +8 -7
  4. package/dist/server/db/migrations.js +57 -0
  5. package/dist/server/db/schema.js +10 -39
  6. package/dist/server/index.js +55 -7
  7. package/dist/server/routes/git.js +8 -3
  8. package/dist/server/routes/health.js +19 -2
  9. package/dist/server/routes/settings.js +24 -0
  10. package/dist/server/routes/workspaces.js +371 -72
  11. package/dist/server/services/agent/engines/claude-code/engine.js +39 -35
  12. package/dist/server/services/agent/engines/codex/client.js +16 -4
  13. package/dist/server/services/agent/engines/codex/engine.js +23 -2
  14. package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +44 -5
  15. package/dist/server/services/agent/engines/types.js +13 -0
  16. package/dist/server/services/agent/orchestrator.js +354 -133
  17. package/dist/server/services/agent/session-controller.js +27 -0
  18. package/dist/server/services/agent/{engines/codex/turn-liveness.js → turn-liveness.js} +6 -2
  19. package/dist/server/services/change-source-branch-service.js +115 -11
  20. package/dist/server/services/db-backup-service.js +27 -16
  21. package/dist/server/services/pr-watcher-service.js +17 -2
  22. package/dist/server/services/settings-service.js +39 -0
  23. package/dist/server/services/websocket-service.js +114 -48
  24. package/dist/server/services/workspace-service.js +54 -2
  25. package/dist/server/services/worktree-purge-service.js +19 -5
  26. package/dist/server/services/worktree-service.js +38 -1
  27. package/dist/server/services/ws-events-retention-service.js +133 -0
  28. package/dist/server/utils/git-ops.js +209 -14
  29. package/dist/server/utils/git-repo-lock.js +45 -0
  30. package/dist/server/utils/logger.js +108 -0
  31. package/dist/server/utils/script-runner.js +11 -0
  32. package/package.json +1 -1
  33. package/src/client/dist/pwa/assets/ActivityFeed-BlDBh5jB.js +8 -0
  34. package/src/client/dist/pwa/assets/ActivityFeed-tC8sR0GV.css +1 -0
  35. package/src/client/dist/pwa/assets/ChangelogPage-DACtknly.js +1 -0
  36. package/src/client/dist/pwa/assets/ChangelogPage-D_WOVByV.css +1 -0
  37. package/src/client/dist/pwa/assets/ClosePopup-DEy0yFXE.js +1 -0
  38. package/src/client/dist/pwa/assets/CreatePage-Bb-Qn-Rd.js +2 -0
  39. package/src/client/dist/pwa/assets/CreatePage-RMBX-Zjn.css +1 -0
  40. package/src/client/dist/pwa/assets/DiffViewer-DKt6gFi4.js +8 -0
  41. package/src/client/dist/pwa/assets/DiffViewer-ahWtFrRy.css +1 -0
  42. package/src/client/dist/pwa/assets/DocumentsPanel-B8QY7FTj.css +1 -0
  43. package/src/client/dist/pwa/assets/DocumentsPanel-DhP1jk11.js +1 -0
  44. package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +1 -0
  45. package/src/client/dist/pwa/assets/GitPanel-BKrtkPrV.css +1 -0
  46. package/src/client/dist/pwa/assets/GitPanel-DNCCB0G_.js +3 -0
  47. package/src/client/dist/pwa/assets/HealthPage-B1J4-dOH.js +1 -0
  48. package/src/client/dist/pwa/assets/HealthPage-BbqLoFQj.css +1 -0
  49. package/src/client/dist/pwa/assets/MainLayout-F8ShDnAy.css +1 -0
  50. package/src/client/dist/pwa/assets/MainLayout-qWwDy1B5.js +2 -0
  51. package/src/client/dist/pwa/assets/QBadge-BgS0-MXB.js +1 -0
  52. package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +1 -0
  53. package/src/client/dist/pwa/assets/QBtn-B7WFdfmy.js +1 -0
  54. package/src/client/dist/pwa/assets/QBtnGroup-BMQlX2YS.js +1 -0
  55. package/src/client/dist/pwa/assets/QBtnToggle-eMu_yinY.js +1 -0
  56. package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +1 -0
  57. package/src/client/dist/pwa/assets/QExpansionItem-C8CWo-aP.js +1 -0
  58. package/src/client/dist/pwa/assets/QItemSection-CDBweQnA.js +1 -0
  59. package/src/client/dist/pwa/assets/QList-Ccqhimcb.js +1 -0
  60. package/src/client/dist/pwa/assets/QPage-RpDxx5ah.js +1 -0
  61. package/src/client/dist/pwa/assets/QSelect-D9bHHxAA.js +1 -0
  62. package/src/client/dist/pwa/assets/QSlideTransition-6lWJvjLP.js +1 -0
  63. package/src/client/dist/pwa/assets/QSpace-D3PNLwa4.js +1 -0
  64. package/src/client/dist/pwa/assets/QSpinnerDots-DWOFEezR.js +1 -0
  65. package/src/client/dist/pwa/assets/SchedulePanel-Brtqtz1H.js +1 -0
  66. package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +1 -0
  67. package/src/client/dist/pwa/assets/SearchPage-CvZxJiJs.js +1 -0
  68. package/src/client/dist/pwa/assets/SearchPage-DdWM01rq.css +1 -0
  69. package/src/client/dist/pwa/assets/SettingsPage-JVje8OPs.js +16 -0
  70. package/src/client/dist/pwa/assets/SettingsPage-dMAg4JP8.css +1 -0
  71. package/src/client/dist/pwa/assets/TerminalPanel-B6xJJs8p.css +1 -0
  72. package/src/client/dist/pwa/assets/TerminalPanel-Dtc6B3US.js +18 -0
  73. package/src/client/dist/pwa/assets/TouchPan-DnEDaTFL.js +1 -0
  74. package/src/client/dist/pwa/assets/WorkspacePage-BVxtefbj.js +4 -0
  75. package/src/client/dist/pwa/assets/WorkspacePage-DN-A6XpI.css +1 -0
  76. package/src/client/dist/pwa/assets/build-path-tree-BU8Y8vRw.js +1 -0
  77. package/src/client/dist/pwa/assets/clipboard-t9qe6lK7.js +1 -0
  78. package/src/client/dist/pwa/assets/{cssMode-DNEi2cLH.js → cssMode-D_JDSbNP.js} +1 -1
  79. package/src/client/dist/pwa/assets/debounce-CDIE7CV6.js +1 -0
  80. package/src/client/dist/pwa/assets/documents-B6eOZeFK.js +1 -0
  81. package/src/client/dist/pwa/assets/{editor.api-_tFrtCR7.js → editor.api-Ccbr7NXr.js} +1 -1
  82. package/src/client/dist/pwa/assets/engineFeatures-DW9PydRL.js +1 -0
  83. package/src/client/dist/pwa/assets/expand-template-B4LAsXAF.css +1 -0
  84. package/src/client/dist/pwa/assets/expand-template-Xq6JwXsl.js +1 -0
  85. package/src/client/dist/pwa/assets/{freemarker2-kfVsFQjE.js → freemarker2-CUvQzJdx.js} +1 -1
  86. package/src/client/dist/pwa/assets/{handlebars-EwBi9SHt.js → handlebars-BXiAPlES.js} +1 -1
  87. package/src/client/dist/pwa/assets/{html-BOOCP_vn.js → html-CLmfyTMC.js} +1 -1
  88. package/src/client/dist/pwa/assets/{htmlMode-IN9oEIXW.js → htmlMode-DWYo6_e_.js} +1 -1
  89. package/src/client/dist/pwa/assets/i18n-CJclRByY.js +1 -0
  90. package/src/client/dist/pwa/assets/index-Bri8-39E.css +1 -0
  91. package/src/client/dist/pwa/assets/index-Dv1p-1sA.js +82 -0
  92. package/src/client/dist/pwa/assets/{javascript-CfV8qmeH.js → javascript-C9gWFLC_.js} +1 -1
  93. package/src/client/dist/pwa/assets/{jsonMode-C0b3m4VO.js → jsonMode-CbsjeFb8.js} +1 -1
  94. package/src/client/dist/pwa/assets/kobo-commands-DGygmOvc.js +9 -0
  95. package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +1 -0
  96. package/src/client/dist/pwa/assets/{liquid-D3YK93J-.js → liquid-BqJ3SIha.js} +1 -1
  97. package/src/client/dist/pwa/assets/{lspLanguageFeatures-PklP2gEH.js → lspLanguageFeatures-BB0y1B51.js} +1 -1
  98. package/src/client/dist/pwa/assets/{mdx-DjZcevPS.js → mdx-rIxQLZDB.js} +1 -1
  99. package/src/client/dist/pwa/assets/network-auth-pzKTXRYP.js +1 -0
  100. package/src/client/dist/pwa/assets/notify-theme-D7RrwvJa.js +1 -0
  101. package/src/client/dist/pwa/assets/pending-diff-open-Cia8sndE.js +1 -0
  102. package/src/client/dist/pwa/assets/private.use-form-D8Yuc3aU.js +1 -0
  103. package/src/client/dist/pwa/assets/{project-color-Cc9yKGk3.js → project-color-Dur1z6Gn.js} +1 -1
  104. package/src/client/dist/pwa/assets/{python-BrgzhqrC.js → python-C6d5du9h.js} +1 -1
  105. package/src/client/dist/pwa/assets/{razor-g8NeeeQc.js → razor-Aek1yDc0.js} +1 -1
  106. package/src/client/dist/pwa/assets/{render-chat-markdown-CZyJL1-w.js → render-chat-markdown-B6sa44Xr.js} +2 -2
  107. package/src/client/dist/pwa/assets/skill-suite-prompts-DdoGRwhz.js +36 -0
  108. package/src/client/dist/pwa/assets/{tsMode-DAopv4hH.js → tsMode-rxNEyqyY.js} +1 -1
  109. package/src/client/dist/pwa/assets/{typescript-C2jZCVPF.js → typescript-DndxWYhC.js} +1 -1
  110. package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +1 -0
  111. package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +1 -0
  112. package/src/client/dist/pwa/assets/use-onboarding-BzSwN4xT.js +2 -0
  113. package/src/client/dist/pwa/assets/{vs-mk-rn9sP.js → vs-De8HKJ9r.js} +3 -3
  114. package/src/client/dist/pwa/assets/{workers-CP5iY9Kz.js → workers-DdNkyVm9.js} +1 -1
  115. package/src/client/dist/pwa/assets/{xml-D3pYNERH.js → xml-Cl1k1mQr.js} +1 -1
  116. package/src/client/dist/pwa/assets/{yaml-BTRPY5XP.js → yaml-C8G_k5TR.js} +1 -1
  117. package/src/client/dist/pwa/fonts/GEIST-LICENSE.txt +92 -0
  118. package/src/client/dist/pwa/fonts/Geist-Variable.woff2 +0 -0
  119. package/src/client/dist/pwa/fonts/GeistMono-Variable.woff2 +0 -0
  120. package/src/client/dist/pwa/index.html +7 -5
  121. package/src/client/dist/pwa/sw.js +1 -1
  122. package/src/mcp-server/kobo-tasks-server.ts +8 -7
  123. package/dist/server/utils/process-tracker.js +0 -49
  124. package/src/client/dist/pwa/assets/ActivityFeed-BrjeLlhi.css +0 -1
  125. package/src/client/dist/pwa/assets/ActivityFeed-WBBk7DgE.js +0 -8
  126. package/src/client/dist/pwa/assets/ChangelogPage-Cr5cW9Wx.js +0 -1
  127. package/src/client/dist/pwa/assets/ChangelogPage-DGhfnkax.css +0 -1
  128. package/src/client/dist/pwa/assets/ClosePopup-2KSSXX6P.js +0 -1
  129. package/src/client/dist/pwa/assets/CreatePage-Bl1Vjr27.css +0 -1
  130. package/src/client/dist/pwa/assets/CreatePage-DQk8TrUP.js +0 -2
  131. package/src/client/dist/pwa/assets/DiffViewer-C9xV8dh-.js +0 -8
  132. package/src/client/dist/pwa/assets/DiffViewer-CS3P9Ngi.css +0 -1
  133. package/src/client/dist/pwa/assets/DrawerToggleButton-CjM28eaw.js +0 -1
  134. package/src/client/dist/pwa/assets/HealthPage-BbLpLRjV.js +0 -1
  135. package/src/client/dist/pwa/assets/HealthPage-BqirO5le.css +0 -1
  136. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BCOjigVR.woff +0 -0
  137. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-Ocp2tNig.woff +0 -0
  138. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-6jEn9xEl.woff +0 -0
  139. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CCs8I9zJ.woff +0 -0
  140. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-BLaboPi0.woff +0 -0
  141. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-DHuK6FBl.woff +0 -0
  142. package/src/client/dist/pwa/assets/MainLayout-BUril7SK.js +0 -19
  143. package/src/client/dist/pwa/assets/MainLayout-u-AM87m3.css +0 -1
  144. package/src/client/dist/pwa/assets/QBadge-DS9cpnyx.js +0 -1
  145. package/src/client/dist/pwa/assets/QBanner-tANb95XA.js +0 -1
  146. package/src/client/dist/pwa/assets/QBtn-DJGkBel8.js +0 -1
  147. package/src/client/dist/pwa/assets/QBtnToggle-D-Hsqmcb.js +0 -1
  148. package/src/client/dist/pwa/assets/QChip-iLXQ5Khf.js +0 -1
  149. package/src/client/dist/pwa/assets/QItemSection-B-5iFRBZ.js +0 -1
  150. package/src/client/dist/pwa/assets/QList-D-yd1vEv.js +0 -1
  151. package/src/client/dist/pwa/assets/QSelect-TFRaHbzG.js +0 -36
  152. package/src/client/dist/pwa/assets/QSeparator-C_2BuEw9.js +0 -1
  153. package/src/client/dist/pwa/assets/QSpace-BuMMeXnK.js +0 -1
  154. package/src/client/dist/pwa/assets/QSpinnerDots-C-tv95pD.js +0 -1
  155. package/src/client/dist/pwa/assets/SearchPage-CPM7L9_M.js +0 -1
  156. package/src/client/dist/pwa/assets/SearchPage-CngtAqls.css +0 -1
  157. package/src/client/dist/pwa/assets/SettingsPage-BRYycuYY.js +0 -16
  158. package/src/client/dist/pwa/assets/SettingsPage-D5BacmAr.css +0 -1
  159. package/src/client/dist/pwa/assets/TouchPan-79_u9-Fd.js +0 -1
  160. package/src/client/dist/pwa/assets/WorkspacePage-DB1n1bmg.js +0 -4
  161. package/src/client/dist/pwa/assets/WorkspacePage-hxEpXqBA.css +0 -1
  162. package/src/client/dist/pwa/assets/documents-BMvE9Q_U.js +0 -1
  163. package/src/client/dist/pwa/assets/engineFeatures-JkVb6hry.js +0 -1
  164. package/src/client/dist/pwa/assets/expand-template-D2yUa54D.css +0 -1
  165. package/src/client/dist/pwa/assets/expand-template-Dl4wUjF8.js +0 -1
  166. package/src/client/dist/pwa/assets/i18n-Dezgz2hU.js +0 -1
  167. package/src/client/dist/pwa/assets/index-B12jWMl8.js +0 -82
  168. package/src/client/dist/pwa/assets/index-CK1nk7qr.css +0 -1
  169. package/src/client/dist/pwa/assets/kobo-commands-DznaKlq6.js +0 -9
  170. package/src/client/dist/pwa/assets/layout-me0gwmBV.js +0 -1
  171. package/src/client/dist/pwa/assets/network-auth-BMTuTKHx.js +0 -1
  172. package/src/client/dist/pwa/assets/pending-diff-open-BG3IOnWw.js +0 -1
  173. package/src/client/dist/pwa/assets/use-checkbox-D-9LXk-S.js +0 -1
  174. package/src/client/dist/pwa/assets/use-is-mobile-DX2Fiee-.js +0 -1
  175. 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.stopAgent(id);
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
- return c.json({ error: `Unknown engine '${body.engine}'. Valid engines: ${validEngineIds.join(', ')}` }, 400);
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
- return c.json({
190
- error: `Engine '${body.engine}' does not support agentPermissionMode '${body.agentPermissionMode}'. Supported: ${supported.join(', ')}`,
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
- return c.json({ error: `${message} — and no local branch '${body.sourceBranch}' exists to fall back on` }, 422);
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
- return c.json({ error: `Worktree path does not exist: ${body.worktreePath}` }, 422);
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
- return c.json({ error: `Worktree '${body.worktreePath}' belongs to a different repository` }, 422);
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
- return c.json({ error: 'Worktree is in detached HEAD state and cannot be attached' }, 422);
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
- return c.json({ error: `Failed to inspect worktree: ${message}` }, 422);
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
- return c.json({ error: 'This worktree is already attached to another Kōbō workspace' }, 422);
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
- return c.json({ error: 'Notion integration is disabled in Settings' }, 403);
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
- return c.json({ error: `Failed to extract Notion page: ${message}` }, 422);
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
- return c.json({ error: 'Sentry integration is disabled in Settings' }, 403);
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
- return c.json({ error: `Failed to extract Sentry issue: ${message}` }, 422);
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
- return c.json({ error: message }, 409);
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: a failed worktree creation must NOT
510
- // leave an orphan record behind. Best-effort cleanup of any partial worktree
511
- // dir git may have produced, then delete the just-inserted workspace row.
512
- try {
513
- worktreeService.removeWorktree(body.projectPath, prospectiveWorktreePath);
514
- }
515
- catch {
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
- console.error('[workspaces] Failed to update .gitignore:', err);
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
- console.error('[workspaces] Failed to write .git-conventions.md:', err);
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
- console.error('[workspaces] Failed to save Notion content:', err);
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
- console.error('[workspaces] Failed to save Sentry content:', err);
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
- const setupScriptConfigured = effectiveSettings.setupScript && !body.skipSetupScript && !useReusedWorktree;
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
- workspaceService.updateWorkspaceStatus(workspace.id, 'brainstorming');
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
- workspaceService.updateWorkspaceStatus(workspace.id, 'brainstorming');
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
- try {
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
- return c.json({ error: message }, 500);
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.stopAgent(id);
2533
+ await agentManager.stopAgentAndWait(id);
2251
2534
  }
2252
2535
  }
2253
- catch {
2254
- /* best-effort — agent may already be stopped */
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.stopAgent(id);
2581
+ await agentManager.stopAgentAndWait(id);
2299
2582
  }
2300
- catch {
2301
- // Agent may not be running — ignore
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 if running (best-effort)
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.stopAgent(workspace.id);
2667
+ await agentManager.stopAgentAndWait(workspace.id);
2383
2668
  }
2384
- catch {
2385
- // Agent may not be running — ignore
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 if running
2837
+ // Stop the existing agent and wait: a fresh one starts right below.
2553
2838
  try {
2554
- agentManager.stopAgent(id);
2839
+ await agentManager.stopAgentAndWait(id);
2555
2840
  }
2556
- catch {
2557
- // Agent may not be running — ignore
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 (!/^[A-Za-z0-9/_\-.]+$/.test(newName)) {
2868
- return c.json({ error: 'Invalid branch name (only letters, digits, /, _, -, . allowed)' }, 400);
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
- const backups = gitOps.listBackupBranches(workspace.worktreePath, workspace.workingBranch);
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 (best-effort) then start fresh.
3925
+ // Stop current agent, wait for it, then start fresh.
3629
3926
  try {
3630
- agentManager.stopAgent(workspace.id);
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] stopAgent failed (continuing): ${msg}`);
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
- agentManager.stopAgent(id);
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 {