@loicngr/kobo 1.4.4 → 1.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/README.md +2 -2
- package/dist/mcp-server/kobo-tasks-handlers.js +12 -1
- package/dist/mcp-server/kobo-tasks-server.js +14 -2
- package/dist/server/db/index.js +5 -0
- package/dist/server/db/migrations.js +9 -0
- package/dist/server/db/schema.js +2 -0
- package/dist/server/index.js +17 -18
- package/dist/server/routes/dev-server.js +1 -0
- package/dist/server/routes/git.js +1 -0
- package/dist/server/routes/images.js +3 -0
- package/dist/server/routes/notion.js +1 -0
- package/dist/server/routes/settings.js +1 -0
- package/dist/server/routes/workspaces.js +128 -31
- package/dist/server/services/agent-manager.js +32 -8
- package/dist/server/services/dev-server-service.js +7 -2
- package/dist/server/services/image-service.js +2 -1
- package/dist/server/services/notion-service.js +3 -16
- package/dist/server/services/pr-watcher-service.js +2 -0
- package/dist/server/services/settings-service.js +17 -6
- package/dist/server/services/setup-script-service.js +1 -0
- package/dist/server/services/websocket-service.js +8 -9
- package/dist/server/services/workspace-service.js +33 -2
- package/dist/server/services/worktree-service.js +4 -2
- package/dist/server/utils/git-ops.js +19 -5
- package/dist/server/utils/process-tracker.js +7 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/ActivityFeed-CLqD89Cm.css +1 -0
- package/src/client/dist/spa/assets/ActivityFeed-DTH_Ij7C.js +68 -0
- package/src/client/dist/spa/assets/CreatePage-BB7McDfT.js +2 -0
- package/src/client/dist/spa/assets/CreatePage-DAZADSsw.css +1 -0
- package/src/client/dist/spa/assets/DiffViewer-DpdWjInq.css +1 -0
- package/src/client/dist/spa/assets/DiffViewer-DsVIBuje.js +2 -0
- package/src/client/dist/spa/assets/MainLayout-BVmmyrJW.js +2 -0
- package/src/client/dist/spa/assets/MainLayout-POre8X3k.css +1 -0
- package/src/client/dist/spa/assets/QBadge-C_R3Tjb9.js +1 -0
- package/src/client/dist/spa/assets/QExpansionItem-BPtEjGYj.js +1 -0
- package/src/client/dist/spa/assets/QPage-BX_DOfKi.js +1 -0
- package/src/client/dist/spa/assets/QSeparator-y-UWrZSp.js +1 -0
- package/src/client/dist/spa/assets/QSpinnerDots-vpiOHlmN.js +1 -0
- package/src/client/dist/spa/assets/QTooltip-D5Om2o3Y.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-BKDbZp9_.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-Dv9gCOw8.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-Cxt0YZv0.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-g-Y3BuoI.js +2 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-BZV6EEeb.js +1 -0
- package/src/client/dist/spa/assets/{cssMode-Dsa4Lydc.js → cssMode-CbCp8SFU.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-qmVdKoc0.js → editor.api-Bm9nrcuM.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-D2fZAQLs.js → editor.main-DCE1BHWQ.js} +3 -3
- package/src/client/dist/spa/assets/{freemarker2-e-FYsZTq.js → freemarker2-BEroNSFG.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-CAwfoT2m.js → handlebars-C8QC92C9.js} +1 -1
- package/src/client/dist/spa/assets/{html-BTRUpMfA.js → html-Dh4rYZTt.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-mzgQeoHf.js → htmlMode-BUP5qnTw.js} +1 -1
- package/src/client/dist/spa/assets/i18n-CvzmE5dV.js +1 -0
- package/src/client/dist/spa/assets/index-BRIQl1ry.js +5 -0
- package/src/client/dist/spa/assets/{javascript-DDeQuxhB.js → javascript-C3VBJatM.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-Ch5vu2Iw.js → jsonMode-fNrYYkV5.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-qVj3a9kh.js → liquid-BnBhHusK.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-Cb_a7RXe.js → mdx-CTu1vGiw.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-Rz70-mfd.js → monaco.contribution-Byxe-pOH.js} +2 -2
- package/src/client/dist/spa/assets/nodes-THUz-Chh.js +1 -0
- package/src/client/dist/spa/assets/{python-Cz0peIkX.js → python-CBrfgeGm.js} +1 -1
- package/src/client/dist/spa/assets/{razor-CU8Xe-4p.js → razor-CaMsFgrW.js} +1 -1
- package/src/client/dist/spa/assets/settings-CYWSNYAA.js +1 -0
- package/src/client/dist/spa/assets/{tsMode-CP1svEaN.js → tsMode-CrwxLvLR.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-aPJGGIsI.js → typescript-BOH3igZy.js} +1 -1
- package/src/client/dist/spa/assets/use-checkbox-ts4I7GAt.js +1 -0
- package/src/client/dist/spa/assets/use-quasar-CIVlxSZ-.js +1 -0
- package/src/client/dist/spa/assets/{xml-CZM1zQhV.js → xml-BtA9_-M9.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-B8qPirw0.js → yaml-vYOBELrg.js} +1 -1
- package/src/client/dist/spa/index.html +3 -3
- package/src/mcp-server/kobo-tasks-handlers.ts +21 -5
- package/src/mcp-server/kobo-tasks-server.ts +14 -2
- package/src/client/dist/spa/assets/ActivityFeed-Bx7maW4r.css +0 -1
- package/src/client/dist/spa/assets/ActivityFeed-DoQIjq5C.js +0 -60
- package/src/client/dist/spa/assets/CreatePage-6J0aDFtf.js +0 -2
- package/src/client/dist/spa/assets/CreatePage-DOr3puTt.css +0 -1
- package/src/client/dist/spa/assets/DiffViewer-7dck6mJc.css +0 -1
- package/src/client/dist/spa/assets/DiffViewer-dvGJaxu-.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-DGzPKBi9.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-gjAr74zR.css +0 -1
- package/src/client/dist/spa/assets/QBadge-Y5QfSDtm.js +0 -1
- package/src/client/dist/spa/assets/QBtn-D_bkYnrl.js +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-D5gm2xc8.js +0 -1
- package/src/client/dist/spa/assets/QPage-Bg3Rohl6.js +0 -1
- package/src/client/dist/spa/assets/QSeparator-MRAsTeNf.js +0 -1
- package/src/client/dist/spa/assets/QSpinnerDots-Bu0GloxK.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-Cuj49WIu.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-BNA4jJYf.css +0 -1
- package/src/client/dist/spa/assets/SettingsPage-BOkWnRl2.js +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-9gRnhdjv.js +0 -2
- package/src/client/dist/spa/assets/WorkspacePage-CFC48jKO.css +0 -1
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-Bo392ayB.js +0 -1
- package/src/client/dist/spa/assets/i18n-B1eQvEGk.js +0 -1
- package/src/client/dist/spa/assets/index-C0u2YcnZ.js +0 -5
- package/src/client/dist/spa/assets/nodes-Bo-5xQjA.js +0 -1
- package/src/client/dist/spa/assets/position-engine-CDz__T_5.js +0 -1
- package/src/client/dist/spa/assets/use-checkbox-B8W131xl.js +0 -1
- package/src/client/dist/spa/assets/use-quasar-sc8fDqi0.js +0 -1
- /package/src/client/dist/spa/assets/{formatters-CXx5Gzsp.js → formatters-B3FG1fMI.js} +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFile as execFileCb } from 'node:child_process';
|
|
1
|
+
import { execFile as execFileCb, spawn } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
const execFileAsync = promisify(execFileCb);
|
|
4
4
|
import fs from 'node:fs';
|
|
@@ -15,8 +15,10 @@ import * as wsService from '../services/websocket-service.js';
|
|
|
15
15
|
import * as workspaceService from '../services/workspace-service.js';
|
|
16
16
|
import * as worktreeService from '../services/worktree-service.js';
|
|
17
17
|
import * as gitOps from '../utils/git-ops.js';
|
|
18
|
+
/** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
|
|
18
19
|
const app = new Hono();
|
|
19
|
-
|
|
20
|
+
/** Tracks workspaces currently running a setup script to prevent concurrent executions. */
|
|
21
|
+
const setupScriptRunning = new Set();
|
|
20
22
|
app.get('/', (c) => {
|
|
21
23
|
try {
|
|
22
24
|
const workspaces = workspaceService.listWorkspaces();
|
|
@@ -34,7 +36,7 @@ app.post('/', async (c) => {
|
|
|
34
36
|
if (!body.name || !body.projectPath || !body.sourceBranch || !body.workingBranch) {
|
|
35
37
|
return c.json({ error: 'Missing required fields: name, projectPath, sourceBranch, workingBranch' }, 400);
|
|
36
38
|
}
|
|
37
|
-
//
|
|
39
|
+
// Create workspace record
|
|
38
40
|
let workspace = workspaceService.createWorkspace({
|
|
39
41
|
name: body.name,
|
|
40
42
|
projectPath: body.projectPath,
|
|
@@ -45,7 +47,7 @@ app.post('/', async (c) => {
|
|
|
45
47
|
model: body.model,
|
|
46
48
|
});
|
|
47
49
|
let notionContent = null;
|
|
48
|
-
//
|
|
50
|
+
// Extract Notion page content if a URL was provided
|
|
49
51
|
if (body.notionUrl) {
|
|
50
52
|
workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
|
|
51
53
|
try {
|
|
@@ -56,7 +58,7 @@ app.post('/', async (c) => {
|
|
|
56
58
|
console.error(`[workspaces] Failed to extract Notion page: ${message}`);
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
|
-
//
|
|
61
|
+
// Create tasks from extracted Notion data
|
|
60
62
|
if (notionContent) {
|
|
61
63
|
let sortOrder = 0;
|
|
62
64
|
for (const todo of notionContent.todos) {
|
|
@@ -104,7 +106,7 @@ app.post('/', async (c) => {
|
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
|
-
//
|
|
109
|
+
// Create git worktree for the working branch
|
|
108
110
|
let worktreePath;
|
|
109
111
|
try {
|
|
110
112
|
worktreePath = worktreeService.createWorktree(body.projectPath, body.workingBranch, body.sourceBranch);
|
|
@@ -114,21 +116,23 @@ app.post('/', async (c) => {
|
|
|
114
116
|
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
115
117
|
return c.json({ error: `Failed to create worktree: ${message}` }, 500);
|
|
116
118
|
}
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
+
// Ensure Kobo-generated files inside .ai/ are gitignored (the .ai/ dir
|
|
120
|
+
// itself may contain project files that SHOULD be committed).
|
|
119
121
|
try {
|
|
120
122
|
const gitignorePath = path.join(worktreePath, '.gitignore');
|
|
121
123
|
const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf-8') : '';
|
|
122
124
|
const lines = existing.split('\n').map((l) => l.trim());
|
|
123
125
|
const toAdd = [];
|
|
124
|
-
if (!lines.includes('.ai
|
|
125
|
-
toAdd.push('.ai
|
|
126
|
+
if (!lines.includes('.ai/.git-conventions.md'))
|
|
127
|
+
toAdd.push('.ai/.git-conventions.md');
|
|
126
128
|
if (!lines.includes('.ai/thoughts/'))
|
|
127
129
|
toAdd.push('.ai/thoughts/');
|
|
128
130
|
if (!lines.includes('.ai/images/'))
|
|
129
131
|
toAdd.push('.ai/images/');
|
|
130
132
|
if (!lines.includes('.ai/.setup-script.tmp'))
|
|
131
133
|
toAdd.push('.ai/.setup-script.tmp');
|
|
134
|
+
if (!lines.includes('.mcp.json'))
|
|
135
|
+
toAdd.push('.mcp.json');
|
|
132
136
|
if (toAdd.length > 0) {
|
|
133
137
|
const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
|
134
138
|
fs.appendFileSync(gitignorePath, `${separator}${toAdd.join('\n')}\n`, 'utf-8');
|
|
@@ -137,20 +141,20 @@ app.post('/', async (c) => {
|
|
|
137
141
|
catch (err) {
|
|
138
142
|
console.error('[workspaces] Failed to update .gitignore:', err);
|
|
139
143
|
}
|
|
140
|
-
//
|
|
144
|
+
// Write git conventions to the worktree if configured
|
|
141
145
|
const effectiveSettings = settingsService.getEffectiveSettings(body.projectPath);
|
|
142
146
|
if (effectiveSettings.gitConventions) {
|
|
143
147
|
try {
|
|
144
148
|
const aiDir = path.join(worktreePath, '.ai');
|
|
145
149
|
fs.mkdirSync(aiDir, { recursive: true });
|
|
146
|
-
const conventionsPath = path.join(aiDir, 'git-conventions.md');
|
|
150
|
+
const conventionsPath = path.join(aiDir, '.git-conventions.md');
|
|
147
151
|
fs.writeFileSync(conventionsPath, effectiveSettings.gitConventions, 'utf-8');
|
|
148
152
|
}
|
|
149
153
|
catch (err) {
|
|
150
|
-
console.error('[workspaces] Failed to write git-conventions.md:', err);
|
|
154
|
+
console.error('[workspaces] Failed to write .git-conventions.md:', err);
|
|
151
155
|
}
|
|
152
156
|
}
|
|
153
|
-
//
|
|
157
|
+
// Run setup script if configured
|
|
154
158
|
let setupScriptFailed = false;
|
|
155
159
|
if (effectiveSettings.setupScript) {
|
|
156
160
|
workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
|
|
@@ -174,7 +178,7 @@ app.post('/', async (c) => {
|
|
|
174
178
|
setupScriptFailed = true;
|
|
175
179
|
}
|
|
176
180
|
}
|
|
177
|
-
//
|
|
181
|
+
// Save Notion content as markdown in worktree
|
|
178
182
|
let notionFilePath = null;
|
|
179
183
|
if (notionContent && body.notionUrl) {
|
|
180
184
|
try {
|
|
@@ -215,9 +219,9 @@ app.post('/', async (c) => {
|
|
|
215
219
|
}
|
|
216
220
|
// Skip agent launch if setup script failed — workspace stays in 'error' status
|
|
217
221
|
if (!setupScriptFailed) {
|
|
218
|
-
//
|
|
222
|
+
// Transition to brainstorming and build the initial agent prompt
|
|
219
223
|
workspaceService.updateWorkspaceStatus(workspace.id, 'brainstorming');
|
|
220
|
-
//
|
|
224
|
+
// Build prompt with tasks and acceptance criteria
|
|
221
225
|
const allTasks = workspaceService.listTasks(workspace.id);
|
|
222
226
|
const todos = allTasks.filter((t) => !t.isAcceptanceCriterion);
|
|
223
227
|
const criteria = allTasks.filter((t) => t.isAcceptanceCriterion);
|
|
@@ -243,7 +247,7 @@ app.post('/', async (c) => {
|
|
|
243
247
|
brainstormPrompt += `\nAs you implement the work and validate each criterion, call mark_task_done with the corresponding task_id. Call list_tasks first to see the current IDs.\n`;
|
|
244
248
|
}
|
|
245
249
|
if (effectiveSettings.gitConventions) {
|
|
246
|
-
brainstormPrompt += `\n# Git conventions\nIMPORTANT: Before any git operation (commit, branch, rebase, merge, push), read and apply the conventions defined in \`.ai
|
|
250
|
+
brainstormPrompt += `\n# Git conventions\nIMPORTANT: Before any git operation (commit, branch, rebase, merge, push), read and apply the conventions defined in \`.ai/.git-conventions.md\`. They are project-specific and override any default behavior. Re-read this file if you're unsure or if context was compacted.\n`;
|
|
247
251
|
}
|
|
248
252
|
brainstormPrompt += `\nIMPORTANT: Start by reading CLAUDE.md and/or AGENTS.md at the project root if they exist — they contain project conventions and instructions you must follow.`;
|
|
249
253
|
brainstormPrompt += `\n\nThen brainstorm the implementation approach. Explore the codebase to understand the existing structure. Ask clarifying questions if needed. When you're done brainstorming and have a clear plan, create a plan file and proceed with implementation. Once you have completed the brainstorming phase, output [BRAINSTORM_COMPLETE] on its own line.`;
|
|
@@ -576,6 +580,82 @@ app.patch('/:id', async (c) => {
|
|
|
576
580
|
return c.json({ error: message }, 500);
|
|
577
581
|
}
|
|
578
582
|
});
|
|
583
|
+
/** Open the workspace worktree in the user's configured editor. */
|
|
584
|
+
app.post('/:id/open-editor', (c) => {
|
|
585
|
+
try {
|
|
586
|
+
const id = c.req.param('id');
|
|
587
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
588
|
+
if (!workspace)
|
|
589
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
590
|
+
const globalSettings = settingsService.getGlobalSettings();
|
|
591
|
+
if (!globalSettings.editorCommand) {
|
|
592
|
+
return c.json({ error: 'No editor command configured' }, 400);
|
|
593
|
+
}
|
|
594
|
+
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
595
|
+
if (!fs.existsSync(worktreePath)) {
|
|
596
|
+
return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
|
|
597
|
+
}
|
|
598
|
+
const child = spawn(globalSettings.editorCommand, [worktreePath], {
|
|
599
|
+
detached: true,
|
|
600
|
+
stdio: 'ignore',
|
|
601
|
+
});
|
|
602
|
+
child.unref();
|
|
603
|
+
return c.json({ success: true });
|
|
604
|
+
}
|
|
605
|
+
catch (err) {
|
|
606
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
607
|
+
return c.json({ error: message }, 500);
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
/** Re-run the project setup script in the workspace worktree. */
|
|
611
|
+
app.post('/:id/run-setup-script', async (c) => {
|
|
612
|
+
try {
|
|
613
|
+
const id = c.req.param('id');
|
|
614
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
615
|
+
if (!workspace)
|
|
616
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
617
|
+
if (setupScriptRunning.has(id)) {
|
|
618
|
+
return c.json({ error: 'Setup script is already running for this workspace' }, 409);
|
|
619
|
+
}
|
|
620
|
+
// Stop the running agent before re-running the setup script
|
|
621
|
+
try {
|
|
622
|
+
if (agentManager.getAgentStatus(id)) {
|
|
623
|
+
agentManager.stopAgent(id);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
/* best-effort — agent may already be stopped */
|
|
628
|
+
}
|
|
629
|
+
const effectiveSettings = settingsService.getEffectiveSettings(workspace.projectPath);
|
|
630
|
+
if (!effectiveSettings.setupScript) {
|
|
631
|
+
return c.json({ error: 'No setup script configured' }, 400);
|
|
632
|
+
}
|
|
633
|
+
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
634
|
+
if (!fs.existsSync(worktreePath)) {
|
|
635
|
+
return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
|
|
636
|
+
}
|
|
637
|
+
setupScriptRunning.add(id);
|
|
638
|
+
try {
|
|
639
|
+
const result = await runSetupScript(workspace.id, worktreePath, effectiveSettings.setupScript, {
|
|
640
|
+
workspaceName: workspace.name,
|
|
641
|
+
branchName: workspace.workingBranch,
|
|
642
|
+
sourceBranch: workspace.sourceBranch,
|
|
643
|
+
projectPath: workspace.projectPath,
|
|
644
|
+
});
|
|
645
|
+
if (result.exitCode !== 0) {
|
|
646
|
+
return c.json({ error: `Setup script failed with exit code ${result.exitCode}` }, 500);
|
|
647
|
+
}
|
|
648
|
+
return c.json({ success: true });
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
setupScriptRunning.delete(id);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
catch (err) {
|
|
655
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
656
|
+
return c.json({ error: message }, 500);
|
|
657
|
+
}
|
|
658
|
+
});
|
|
579
659
|
// POST /api/workspaces/:id/archive — mark workspace as archived (soft-delete)
|
|
580
660
|
app.post('/:id/archive', (c) => {
|
|
581
661
|
try {
|
|
@@ -641,14 +721,14 @@ app.delete('/:id', async (c) => {
|
|
|
641
721
|
const body = await c.req
|
|
642
722
|
.json()
|
|
643
723
|
.catch(() => ({}));
|
|
644
|
-
//
|
|
724
|
+
// Stop agent if running (best-effort)
|
|
645
725
|
try {
|
|
646
726
|
agentManager.stopAgent(id);
|
|
647
727
|
}
|
|
648
728
|
catch {
|
|
649
729
|
// Agent may not be running — ignore
|
|
650
730
|
}
|
|
651
|
-
//
|
|
731
|
+
// Remove worktree
|
|
652
732
|
const worktreesDir = `${workspace.projectPath}/.worktrees`;
|
|
653
733
|
const worktreePath = `${worktreesDir}/${workspace.workingBranch}`;
|
|
654
734
|
try {
|
|
@@ -658,7 +738,7 @@ app.delete('/:id', async (c) => {
|
|
|
658
738
|
const message = err instanceof Error ? err.message : String(err);
|
|
659
739
|
console.error(`[workspaces] Failed to remove worktree: ${message}`);
|
|
660
740
|
}
|
|
661
|
-
//
|
|
741
|
+
// Delete local branch if requested
|
|
662
742
|
if (body.deleteLocalBranch) {
|
|
663
743
|
try {
|
|
664
744
|
gitOps.deleteLocalBranch(workspace.projectPath, workspace.workingBranch);
|
|
@@ -668,7 +748,7 @@ app.delete('/:id', async (c) => {
|
|
|
668
748
|
console.error(`[workspaces] Failed to delete local branch: ${message}`);
|
|
669
749
|
}
|
|
670
750
|
}
|
|
671
|
-
//
|
|
751
|
+
// Delete remote branch if requested
|
|
672
752
|
if (body.deleteRemoteBranch) {
|
|
673
753
|
try {
|
|
674
754
|
gitOps.deleteRemoteBranch(workspace.projectPath, workspace.workingBranch);
|
|
@@ -678,7 +758,7 @@ app.delete('/:id', async (c) => {
|
|
|
678
758
|
console.error(`[workspaces] Failed to delete remote branch: ${message}`);
|
|
679
759
|
}
|
|
680
760
|
}
|
|
681
|
-
//
|
|
761
|
+
// Delete workspace from DB (cascades to tasks, sessions, events)
|
|
682
762
|
workspaceService.deleteWorkspace(id);
|
|
683
763
|
return new Response(null, { status: 204 });
|
|
684
764
|
}
|
|
@@ -823,7 +903,7 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
823
903
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
824
904
|
}
|
|
825
905
|
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
826
|
-
//
|
|
906
|
+
// Verify branch exists on remote
|
|
827
907
|
let lsRemoteOut = '';
|
|
828
908
|
try {
|
|
829
909
|
const { stdout } = await execFileAsync('git', ['ls-remote', '--heads', 'origin', workspace.workingBranch], {
|
|
@@ -837,7 +917,7 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
837
917
|
if (!lsRemoteOut.trim()) {
|
|
838
918
|
return c.json({ error: 'Branch is not on remote', code: 'branch_not_pushed' }, 409);
|
|
839
919
|
}
|
|
840
|
-
//
|
|
920
|
+
// Ensure all local commits are pushed
|
|
841
921
|
try {
|
|
842
922
|
const { stdout } = await execFileAsync('git', ['rev-list', '@{u}..HEAD', '--count'], { cwd: worktreePath });
|
|
843
923
|
const countStr = stdout.trim();
|
|
@@ -855,7 +935,7 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
855
935
|
}
|
|
856
936
|
return c.json({ error: `Failed to check branch state: ${message}` }, 500);
|
|
857
937
|
}
|
|
858
|
-
//
|
|
938
|
+
// Create PR via GitHub CLI
|
|
859
939
|
let ghOutput;
|
|
860
940
|
try {
|
|
861
941
|
const placeholderBody = 'Automated PR — description will be updated by the agent.';
|
|
@@ -878,7 +958,7 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
878
958
|
const stderr = err.stderr?.toString() ?? '';
|
|
879
959
|
return c.json({ error: `gh pr create failed: ${message} ${stderr}`.trim() }, 500);
|
|
880
960
|
}
|
|
881
|
-
//
|
|
961
|
+
// Parse PR URL and number from gh output
|
|
882
962
|
const urlMatch = ghOutput.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/);
|
|
883
963
|
if (!urlMatch) {
|
|
884
964
|
return c.json({ error: 'Could not parse PR URL from gh output' }, 500);
|
|
@@ -886,12 +966,12 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
886
966
|
const prUrl = urlMatch[0];
|
|
887
967
|
const prNumber = parseInt(urlMatch[1], 10);
|
|
888
968
|
// ── From here on, PR exists. No more 5xx responses. ──
|
|
889
|
-
//
|
|
969
|
+
// Resolve the PR prompt template; skip message steps if empty
|
|
890
970
|
const effective = settingsService.getEffectiveSettings(workspace.projectPath);
|
|
891
971
|
if (!effective.prPromptTemplate) {
|
|
892
972
|
return c.json({ ok: true, prNumber, prUrl, messageSent: false });
|
|
893
973
|
}
|
|
894
|
-
//
|
|
974
|
+
// Build context and render the PR prompt template
|
|
895
975
|
const commits = gitOps.getCommitsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
896
976
|
const diffStats = gitOps.getDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
897
977
|
const tasks = workspaceService.listTasks(workspace.id);
|
|
@@ -903,11 +983,11 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
903
983
|
diffStats,
|
|
904
984
|
tasks,
|
|
905
985
|
});
|
|
906
|
-
//
|
|
986
|
+
// Emit user:message into the chat feed
|
|
907
987
|
const session = workspaceService.getLatestSession(workspace.id);
|
|
908
988
|
const sessionId = session?.claudeSessionId ?? undefined;
|
|
909
989
|
wsService.emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, sessionId);
|
|
910
|
-
//
|
|
990
|
+
// Send to the running agent, or resume the agent with the PR prompt
|
|
911
991
|
let messageSent = false;
|
|
912
992
|
try {
|
|
913
993
|
agentManager.sendMessage(workspace.id, rendered);
|
|
@@ -933,6 +1013,23 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
933
1013
|
return c.json({ error: message }, 500);
|
|
934
1014
|
}
|
|
935
1015
|
});
|
|
1016
|
+
/** POST /api/workspaces/:id/mark-read — mark workspace as read (clear unread indicator). */
|
|
1017
|
+
app.post('/:id/mark-read', (c) => {
|
|
1018
|
+
try {
|
|
1019
|
+
const id = c.req.param('id');
|
|
1020
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
1021
|
+
if (!workspace) {
|
|
1022
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1023
|
+
}
|
|
1024
|
+
workspaceService.markWorkspaceRead(id);
|
|
1025
|
+
wsService.emitEphemeral(id, 'workspace:unread', { hasUnread: false });
|
|
1026
|
+
return c.json({ success: true });
|
|
1027
|
+
}
|
|
1028
|
+
catch (err) {
|
|
1029
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1030
|
+
return c.json({ error: message }, 500);
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
936
1033
|
// POST /api/workspaces/:id/stop — stop agent
|
|
937
1034
|
app.post('/:id/stop', (c) => {
|
|
938
1035
|
try {
|
|
@@ -7,8 +7,8 @@ import { getDb } from '../db/index.js';
|
|
|
7
7
|
import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../utils/paths.js';
|
|
8
8
|
import { registerProcess, unregisterProcess } from '../utils/process-tracker.js';
|
|
9
9
|
import { getEffectiveSettings } from './settings-service.js';
|
|
10
|
-
import { emit } from './websocket-service.js';
|
|
11
|
-
import { getWorkspace as getWs, listTasks, updateWorkspaceStatus } from './workspace-service.js';
|
|
10
|
+
import { emit, emitEphemeral } from './websocket-service.js';
|
|
11
|
+
import { getWorkspace as getWs, listTasks, markWorkspaceUnread, updateWorkspaceStatus } from './workspace-service.js';
|
|
12
12
|
// ── State ──────────────────────────────────────────────────────────────────────
|
|
13
13
|
/** Actual bound port of the running backend — set at startup via setBackendPort() */
|
|
14
14
|
let backendPort = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
|
|
@@ -82,6 +82,13 @@ function runWatchdog() {
|
|
|
82
82
|
catch {
|
|
83
83
|
// Transition may not be valid — ignore
|
|
84
84
|
}
|
|
85
|
+
try {
|
|
86
|
+
markWorkspaceUnread(workspaceId);
|
|
87
|
+
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// best-effort
|
|
91
|
+
}
|
|
85
92
|
emit(workspaceId, 'agent:status', { status: 'error', message: 'Agent process died unexpectedly' }, agent.claudeSessionId);
|
|
86
93
|
}
|
|
87
94
|
}
|
|
@@ -100,6 +107,7 @@ export function stopWatchdog() {
|
|
|
100
107
|
}
|
|
101
108
|
}
|
|
102
109
|
// ── Start agent ────────────────────────────────────────────────────────────────
|
|
110
|
+
/** Spawn a Claude Code CLI process for a workspace and wire up stdout/stderr/exit handling. */
|
|
103
111
|
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, permissionMode = 'auto-accept') {
|
|
104
112
|
// Check if agent already running for this workspace
|
|
105
113
|
if (agents.has(workspaceId)) {
|
|
@@ -301,7 +309,6 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
301
309
|
});
|
|
302
310
|
// ── stderr — detect quota / rate limit errors ──
|
|
303
311
|
proc.stderr?.on('data', (data) => {
|
|
304
|
-
// I1: Don't process quota errors if the agent is already stopping or gone
|
|
305
312
|
const currentAgent = agents.get(workspaceId);
|
|
306
313
|
if (!currentAgent || currentAgent.status === 'stopping')
|
|
307
314
|
return;
|
|
@@ -322,7 +329,6 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
322
329
|
catch {
|
|
323
330
|
// File may not exist (spawn failed) — ignore
|
|
324
331
|
}
|
|
325
|
-
// I3: Close readline interface to release the stream reference
|
|
326
332
|
agent.rl.close();
|
|
327
333
|
unregisterProcess(workspaceId);
|
|
328
334
|
// Only remove from the map if this exact agent instance is still current.
|
|
@@ -334,7 +340,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
334
340
|
}
|
|
335
341
|
// Clean up retry state and inactivity timer
|
|
336
342
|
retryCounts.delete(workspaceId);
|
|
337
|
-
//
|
|
343
|
+
// Clear the kill timer if it's still pending (process exited naturally before SIGKILL)
|
|
338
344
|
const pendingKillTimer = killTimers.get(workspaceId);
|
|
339
345
|
if (pendingKillTimer) {
|
|
340
346
|
clearTimeout(pendingKillTimer);
|
|
@@ -350,7 +356,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
350
356
|
emit(workspaceId, 'agent:status', { status: 'stopped' }, agent.claudeSessionId);
|
|
351
357
|
return;
|
|
352
358
|
}
|
|
353
|
-
//
|
|
359
|
+
// Also clear backoff timers on non-stopping exit
|
|
354
360
|
const pendingBackoff = backoffTimers.get(workspaceId);
|
|
355
361
|
if (pendingBackoff) {
|
|
356
362
|
clearTimeout(pendingBackoff);
|
|
@@ -363,6 +369,13 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
363
369
|
catch (err) {
|
|
364
370
|
console.error('[agent] Failed to update workspace status on exit:', err);
|
|
365
371
|
}
|
|
372
|
+
try {
|
|
373
|
+
markWorkspaceUnread(workspaceId);
|
|
374
|
+
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// best-effort
|
|
378
|
+
}
|
|
366
379
|
emit(workspaceId, 'agent:status', { status: 'error', exitCode: code }, agent.claudeSessionId);
|
|
367
380
|
}
|
|
368
381
|
else {
|
|
@@ -372,6 +385,13 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
372
385
|
catch (err) {
|
|
373
386
|
console.error('[agent] Failed to update workspace status on exit:', err);
|
|
374
387
|
}
|
|
388
|
+
try {
|
|
389
|
+
markWorkspaceUnread(workspaceId);
|
|
390
|
+
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// best-effort
|
|
394
|
+
}
|
|
375
395
|
emit(workspaceId, 'agent:status', { status: 'completed' }, agent.claudeSessionId);
|
|
376
396
|
}
|
|
377
397
|
});
|
|
@@ -382,6 +402,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
382
402
|
return agent;
|
|
383
403
|
}
|
|
384
404
|
// ── Stop agent ─────────────────────────────────────────────────────────────────
|
|
405
|
+
/** Gracefully stop an agent (SIGTERM, then SIGKILL after 5s). */
|
|
385
406
|
export function stopAgent(workspaceId) {
|
|
386
407
|
const agent = agents.get(workspaceId);
|
|
387
408
|
if (!agent) {
|
|
@@ -398,7 +419,6 @@ export function stopAgent(workspaceId) {
|
|
|
398
419
|
clearTimeout(timer);
|
|
399
420
|
backoffTimers.delete(workspaceId);
|
|
400
421
|
}
|
|
401
|
-
// I3: Close readline interface now that we're stopping
|
|
402
422
|
try {
|
|
403
423
|
agent.rl.close();
|
|
404
424
|
}
|
|
@@ -414,7 +434,7 @@ export function stopAgent(workspaceId) {
|
|
|
414
434
|
}
|
|
415
435
|
// After 5s timeout, send SIGKILL if still running
|
|
416
436
|
const killTimer = setTimeout(() => {
|
|
417
|
-
//
|
|
437
|
+
// If a new agent has been started for this workspace in the meantime,
|
|
418
438
|
// don't kill the old process — it's handled by the new lifecycle.
|
|
419
439
|
const currentAgent = agents.get(workspaceId);
|
|
420
440
|
if (currentAgent && currentAgent !== agent) {
|
|
@@ -436,6 +456,7 @@ export function stopAgent(workspaceId) {
|
|
|
436
456
|
killTimers.set(workspaceId, killTimer);
|
|
437
457
|
}
|
|
438
458
|
// ── Send message to agent stdin ────────────────────────────────────────────────
|
|
459
|
+
/** Write a user message to the running agent's stdin. */
|
|
439
460
|
export function sendMessage(workspaceId, content) {
|
|
440
461
|
const agent = agents.get(workspaceId);
|
|
441
462
|
if (!agent) {
|
|
@@ -447,13 +468,16 @@ export function sendMessage(workspaceId, content) {
|
|
|
447
468
|
agent.process.stdin.write(`${content}\n`);
|
|
448
469
|
}
|
|
449
470
|
// ── Status queries ─────────────────────────────────────────────────────────────
|
|
471
|
+
/** Get the in-memory status of the agent for a workspace, or null if not running. */
|
|
450
472
|
export function getAgentStatus(workspaceId) {
|
|
451
473
|
const agent = agents.get(workspaceId);
|
|
452
474
|
return agent?.status ?? null;
|
|
453
475
|
}
|
|
476
|
+
/** Return the number of currently running agents. */
|
|
454
477
|
export function getRunningCount() {
|
|
455
478
|
return agents.size;
|
|
456
479
|
}
|
|
480
|
+
/** Return the cached list of slash commands discovered from the last agent init. */
|
|
457
481
|
export function getAvailableSkills() {
|
|
458
482
|
return availableSkills;
|
|
459
483
|
}
|
|
@@ -8,6 +8,11 @@ import { getWorkspace, updateDevServerStatus } from './workspace-service.js';
|
|
|
8
8
|
function getWorktreePath(projectPath, workingBranch) {
|
|
9
9
|
return path.join(projectPath, '.worktrees', workingBranch);
|
|
10
10
|
}
|
|
11
|
+
/** Build a clean env for child processes, stripping Kobo-specific variables. */
|
|
12
|
+
function cleanEnv() {
|
|
13
|
+
const { PORT, SERVER_PORT, ...rest } = process.env;
|
|
14
|
+
return rest;
|
|
15
|
+
}
|
|
11
16
|
// ── State ──────────────────────────────────────────────────────────────────────
|
|
12
17
|
/** workspaceId -> spawned dev-server process */
|
|
13
18
|
const trackedProcesses = new Map();
|
|
@@ -143,7 +148,7 @@ export function startDevServer(workspaceId) {
|
|
|
143
148
|
const proc = spawn('bash', ['-c', settings.devServer.startCommand], {
|
|
144
149
|
cwd,
|
|
145
150
|
env: {
|
|
146
|
-
...
|
|
151
|
+
...cleanEnv(),
|
|
147
152
|
INSTANCE: instanceName,
|
|
148
153
|
DEV_DOCKER_NO_FOLLOW: '1',
|
|
149
154
|
},
|
|
@@ -229,7 +234,7 @@ export function stopDevServer(workspaceId) {
|
|
|
229
234
|
execSync(settings.devServer.stopCommand, {
|
|
230
235
|
cwd,
|
|
231
236
|
env: {
|
|
232
|
-
...
|
|
237
|
+
...cleanEnv(),
|
|
233
238
|
INSTANCE: instanceName,
|
|
234
239
|
PROJECT_NAME: config?.projectName ?? '',
|
|
235
240
|
},
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// src/server/services/image-service.ts
|
|
2
1
|
import fs from 'node:fs';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { nanoid } from 'nanoid';
|
|
@@ -30,6 +29,7 @@ function readIndex(imagesDir) {
|
|
|
30
29
|
function writeIndex(imagesDir, entries) {
|
|
31
30
|
fs.writeFileSync(path.join(imagesDir, INDEX_FILE), JSON.stringify(entries, null, 2));
|
|
32
31
|
}
|
|
32
|
+
/** Save an image buffer to `.ai/images/` and update the index. Returns the UID and relative path. */
|
|
33
33
|
export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
34
34
|
const ext = path.extname(originalName).toLowerCase().replace('.', '');
|
|
35
35
|
if (!ext) {
|
|
@@ -53,6 +53,7 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
|
53
53
|
});
|
|
54
54
|
return { uid, relativePath: `${IMAGES_DIR}/${filename}` };
|
|
55
55
|
}
|
|
56
|
+
/** Delete an image by UID from disk and the index. */
|
|
56
57
|
export async function deleteImage(worktreePath, uid) {
|
|
57
58
|
const imagesDir = path.join(worktreePath, IMAGES_DIR);
|
|
58
59
|
await withLock(worktreePath, () => {
|
|
@@ -3,7 +3,6 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { getPackageVersion } from '../utils/paths.js';
|
|
4
4
|
// Gherkin keywords (French and English)
|
|
5
5
|
const GHERKIN_PATTERN = /^(Scénario|Étant donné|Quand|Alors|Scenario|Given|When|Then|Feature|Fonctionnalité|And|Et|But|Mais)/i;
|
|
6
|
-
// C2: rpcIdCounter encapsulated in a closure to avoid module-level mutable state
|
|
7
6
|
const nextRpcId = (() => {
|
|
8
7
|
let counter = 1;
|
|
9
8
|
return () => counter++;
|
|
@@ -32,11 +31,7 @@ export function parseNotionUrl(url) {
|
|
|
32
31
|
// Convert 32 hex chars to UUID format: 8-4-4-4-12
|
|
33
32
|
return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`;
|
|
34
33
|
}
|
|
35
|
-
/**
|
|
36
|
-
* Send a JSON-RPC request to the MCP process and read the response.
|
|
37
|
-
* M4: parameter renamed from `process` to `mcpProcess` to avoid shadowing global `process`.
|
|
38
|
-
* C1: 30s timeout added to prevent hanging indefinitely.
|
|
39
|
-
*/
|
|
34
|
+
/** Send a JSON-RPC request to the MCP process and read the response (30s timeout). */
|
|
40
35
|
export async function callMcpTool(mcpProcess, toolName, args) {
|
|
41
36
|
const id = nextRpcId();
|
|
42
37
|
const request = JSON.stringify({
|
|
@@ -54,7 +49,6 @@ export async function callMcpTool(mcpProcess, toolName, args) {
|
|
|
54
49
|
return;
|
|
55
50
|
}
|
|
56
51
|
let buffer = '';
|
|
57
|
-
// C1: 30s timeout — I7: kill the MCP process on timeout to avoid zombie
|
|
58
52
|
const timeout = setTimeout(() => {
|
|
59
53
|
mcpProcess.stdout?.removeListener('data', onData);
|
|
60
54
|
mcpProcess.stdout?.removeListener('error', onError);
|
|
@@ -142,12 +136,7 @@ function spawnMcpProcess() {
|
|
|
142
136
|
});
|
|
143
137
|
return mcpProcess;
|
|
144
138
|
}
|
|
145
|
-
/**
|
|
146
|
-
* Initialize the MCP server by sending an initialize request.
|
|
147
|
-
* I1: notifications/initialized is sent after receiving the initialize response.
|
|
148
|
-
* I4: onData listener is removed in the reject path.
|
|
149
|
-
* C1: 10s timeout added.
|
|
150
|
-
*/
|
|
139
|
+
/** Initialize the MCP server by sending an initialize handshake (10s timeout). */
|
|
151
140
|
async function initializeMcp(mcpProcess) {
|
|
152
141
|
const id = nextRpcId();
|
|
153
142
|
const request = JSON.stringify({
|
|
@@ -166,7 +155,6 @@ async function initializeMcp(mcpProcess) {
|
|
|
166
155
|
return;
|
|
167
156
|
}
|
|
168
157
|
let buffer = '';
|
|
169
|
-
// C1: 10s timeout for initialization — I7: kill the MCP process on timeout
|
|
170
158
|
const timeout = setTimeout(() => {
|
|
171
159
|
mcpProcess.stdout?.removeListener('data', onData);
|
|
172
160
|
mcpProcess.kill();
|
|
@@ -185,7 +173,6 @@ async function initializeMcp(mcpProcess) {
|
|
|
185
173
|
if (parsed.id === id) {
|
|
186
174
|
clearTimeout(timeout);
|
|
187
175
|
mcpProcess.stdout?.removeListener('data', onData);
|
|
188
|
-
// I1: Send notifications/initialized AFTER receiving the initialize response
|
|
189
176
|
const initialized = JSON.stringify({
|
|
190
177
|
jsonrpc: '2.0',
|
|
191
178
|
method: 'notifications/initialized',
|
|
@@ -199,7 +186,6 @@ async function initializeMcp(mcpProcess) {
|
|
|
199
186
|
}
|
|
200
187
|
}
|
|
201
188
|
};
|
|
202
|
-
// I4: onError handler to clean up listener on error
|
|
203
189
|
const onError = (err) => {
|
|
204
190
|
clearTimeout(timeout);
|
|
205
191
|
mcpProcess.stdout?.removeListener('data', onData);
|
|
@@ -244,6 +230,7 @@ function extractTextFromRichText(richText) {
|
|
|
244
230
|
})
|
|
245
231
|
.join('');
|
|
246
232
|
}
|
|
233
|
+
/** Parse Notion block children into structured goal, todos, and Gherkin features. */
|
|
247
234
|
export function parseBlocks(blocks) {
|
|
248
235
|
const todos = [];
|
|
249
236
|
const gherkinFeatures = [];
|
|
@@ -69,11 +69,13 @@ function scheduleNext() {
|
|
|
69
69
|
}, POLL_INTERVAL_MS);
|
|
70
70
|
timer.unref?.();
|
|
71
71
|
}
|
|
72
|
+
/** Start polling GitHub for merged/closed PRs to auto-archive workspaces. */
|
|
72
73
|
export function startPrWatcher() {
|
|
73
74
|
if (timer)
|
|
74
75
|
return;
|
|
75
76
|
scheduleNext();
|
|
76
77
|
}
|
|
78
|
+
/** Stop the PR watcher polling loop. */
|
|
77
79
|
export function stopPrWatcher() {
|
|
78
80
|
if (timer) {
|
|
79
81
|
clearTimeout(timer);
|