@loicngr/kobo 1.11.26 → 1.11.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/dist/server/routes/workspaces.js +28 -11
- package/dist/server/services/agent/engines/claude-code/engine.js +109 -15
- package/dist/server/services/agent/engines/codex/server-requests.js +5 -20
- package/dist/server/services/agent/orchestrator.js +16 -2
- package/dist/server/services/agent/streaming-batcher.js +1 -0
- package/dist/server/services/agent/turn-liveness.js +12 -1
- package/dist/server/services/file-editor-service.js +2 -46
- package/dist/server/services/forge/bitbucket/provider.js +9 -8
- package/dist/server/services/forge/github/provider.js +4 -3
- package/dist/server/services/forge/gitlab/provider.js +4 -3
- package/dist/server/services/forge/parse-cli-json.js +14 -0
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/{ActivityFeed-DUjY6lCm.css → ActivityFeed-LEd9NEbI.css} +1 -1
- package/src/client/dist/pwa/assets/{ActivityFeed-CGMEOMn5.js → ActivityFeed-XJeDwa82.js} +5 -5
- package/src/client/dist/pwa/assets/{ChangelogPage-rvISZfJb.js → ChangelogPage-Bjls89ZX.js} +1 -1
- package/src/client/dist/pwa/assets/CreatePage-BzHQd4uE.js +2 -0
- package/src/client/dist/pwa/assets/DiffViewer-BV8SvUIi.css +1 -0
- package/src/client/dist/pwa/assets/DiffViewer-DNPD0uVB.js +8 -0
- package/src/client/dist/pwa/assets/{DocumentsPanel-DuWbZiID.js → DocumentsPanel-Osdun-Om.js} +1 -1
- package/src/client/dist/pwa/assets/{GitPanel-BiIXQPfL.js → GitPanel-8_xQ7V2r.js} +3 -3
- package/src/client/dist/pwa/assets/{GitPanel-BKrtkPrV.css → GitPanel-DVs5BCU-.css} +1 -1
- package/src/client/dist/pwa/assets/{HealthPage-DTrmhh3X.js → HealthPage-CSEPHslq.js} +1 -1
- package/src/client/dist/pwa/assets/{MainLayout-Vq1iUPjx.css → MainLayout-Bdh2JQfn.css} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-X1T4sh65.js +2 -0
- package/src/client/dist/pwa/assets/{QBtnDropdown-Ckvd3IVR.js → QBtnDropdown-aUlDUJBU.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-DJWMsAoh.js → SchedulePanel-DIoiKpwZ.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-BI8w9utc.js → SearchPage-CKNNtnwv.js} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-njeIse66.js +16 -0
- package/src/client/dist/pwa/assets/{TerminalPanel-B6xJJs8p.css → TerminalPanel-DbVZk0vD.css} +1 -1
- package/src/client/dist/pwa/assets/TerminalPanel-HIuH2z-Y.js +18 -0
- package/src/client/dist/pwa/assets/{TouchPan-pqksSni0.js → TouchPan-CMDAeqj6.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-Bk7pIvC0.js +4 -0
- package/src/client/dist/pwa/assets/{build-path-tree-CQ4zZqo0.js → build-path-tree-B07ATM5W.js} +1 -1
- package/src/client/dist/pwa/assets/{cssMode-MGWKgqj7.js → cssMode-BvD76b3P.js} +1 -1
- package/src/client/dist/pwa/assets/{documents-B6eOZeFK.js → documents-D7AZJAiR.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-Dq41Mm99.js → editor.api-GJX9JYVf.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-BPZzMyrc.js → efforts-Dn1yRgeo.js} +1 -1
- package/src/client/dist/pwa/assets/{engineFeatures-Dd_dxoOS.js → engineFeatures-C4wJRlwO.js} +1 -1
- package/src/client/dist/pwa/assets/{expand-template-BfZqON3q.js → expand-template-0hMEF4rZ.js} +1 -1
- package/src/client/dist/pwa/assets/expand-template-CTUNyJrv.css +1 -0
- package/src/client/dist/pwa/assets/{freemarker2-BqVyQSGe.js → freemarker2-qQC_1HDe.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-9K762gzW.js → handlebars-pyc5TcAt.js} +1 -1
- package/src/client/dist/pwa/assets/{html-CRmp4AC_.js → html-DOaftBCT.js} +1 -1
- package/src/client/dist/pwa/assets/{htmlMode-CFxFF9iH.js → htmlMode-DRXGGPOb.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-mEG61A5h.js +1 -0
- package/src/client/dist/pwa/assets/{index-BpmxQlZi.css → index-6H3m6vH7.css} +1 -1
- package/src/client/dist/pwa/assets/{index-xTf0VfFG.js → index-DZ4aWwQs.js} +2 -2
- package/src/client/dist/pwa/assets/{javascript-DB9a_ZGm.js → javascript-BTsYFFLK.js} +1 -1
- package/src/client/dist/pwa/assets/{jsonMode-JhLTE1-C.js → jsonMode-3Glcck88.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-C67ZTBj7.js → liquid-BdTymdAY.js} +1 -1
- package/src/client/dist/pwa/assets/{lspLanguageFeatures-BIDd3ml0.js → lspLanguageFeatures-CvwRMMY9.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-C_K9rc9_.js → mdx-DzJktAFj.js} +1 -1
- package/src/client/dist/pwa/assets/network-auth-D59LKtK4.js +1 -0
- package/src/client/dist/pwa/assets/{notify-theme-thUiJn9n.js → notify-theme-D1FTBWWF.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-BdA8vrDx.js → pending-diff-open-DrdnFOI2.js} +1 -1
- package/src/client/dist/pwa/assets/permissionModes-_rSNmU9M.js +36 -0
- package/src/client/dist/pwa/assets/{project-color-hgNWqp0x.js → project-color-eoGJvY_F.js} +1 -1
- package/src/client/dist/pwa/assets/{python-D1CBO9Uf.js → python-onIjs5sJ.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-BCiKSj6_.js → razor-DqQGiaiD.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-Cw1_NLqs.js → render-chat-markdown-BwbknTum.js} +1 -1
- package/src/client/dist/pwa/assets/{tsMode-Db77HzVu.js → tsMode-B24qEjDH.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-D-8UENST.js → typescript-BnI60_wU.js} +1 -1
- package/src/client/dist/pwa/assets/{use-onboarding-BafrD9nc.js → use-onboarding-WR8vvxBw.js} +1 -1
- package/src/client/dist/pwa/assets/{vs-Bb18eQC8.js → vs-DVt5kpUB.js} +3 -3
- package/src/client/dist/pwa/assets/{workers-CImko7IX.js → workers-D28S2iKq.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-BBPvJ9QP.js → xml-DQUlBYuZ.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-D4q1p7HX.js → yaml-D3GiqGbP.js} +1 -1
- package/src/client/dist/pwa/index.html +2 -2
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/client/dist/pwa/assets/CreatePage-BptAgVvu.js +0 -2
- package/src/client/dist/pwa/assets/DiffViewer-ZrvQ0YWN.js +0 -8
- package/src/client/dist/pwa/assets/DiffViewer-ahWtFrRy.css +0 -1
- package/src/client/dist/pwa/assets/MainLayout-PSmlvfJd.js +0 -2
- package/src/client/dist/pwa/assets/SettingsPage-Dr8oSevA.js +0 -16
- package/src/client/dist/pwa/assets/TerminalPanel-DVC58DJb.js +0 -18
- package/src/client/dist/pwa/assets/WorkspacePage-DRP9Ypmz.js +0 -4
- package/src/client/dist/pwa/assets/expand-template-B4LAsXAF.css +0 -1
- package/src/client/dist/pwa/assets/i18n-BsWCIFmf.js +0 -1
- package/src/client/dist/pwa/assets/network-auth-BPxfeLcl.js +0 -1
- package/src/client/dist/pwa/assets/permissionModes-D09rnNgv.js +0 -36
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,10 @@ All notable changes to Kōbō are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
|
|
5
5
|
section — the in-app "What's new" dialog reads this file.
|
|
6
6
|
|
|
7
|
+
## 1.11.27
|
|
8
|
+
|
|
9
|
+
- fix: session watchdog false-positives, sync drain, and terminal leaks, audit npm
|
|
10
|
+
|
|
7
11
|
## 1.11.26
|
|
8
12
|
|
|
9
13
|
- fix(client): resume the interrupted session on manual quota resume
|
|
@@ -1821,13 +1821,18 @@ app.delete('/:id/events/:eventId', (c) => {
|
|
|
1821
1821
|
const db = getDb();
|
|
1822
1822
|
// Capture the session BEFORE deleting: since v36 there is no AFTER DELETE
|
|
1823
1823
|
// trigger, so the metrics of that session must be recomputed once, here.
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1824
|
+
// Transactional for the same reason deleteSession is: a crash between the
|
|
1825
|
+
// delete and the recompute must not leave stale metrics behind.
|
|
1826
|
+
const transaction = db.transaction(() => {
|
|
1827
|
+
const row = db
|
|
1828
|
+
.prepare('SELECT session_id FROM ws_events WHERE id = ? AND workspace_id = ?')
|
|
1829
|
+
.get(eventId, workspaceId);
|
|
1830
|
+
db.prepare('DELETE FROM ws_events WHERE id = ? AND workspace_id = ?').run(eventId, workspaceId);
|
|
1831
|
+
if (row?.session_id) {
|
|
1832
|
+
workspaceService.recomputeSessionMetrics(workspaceId, row.session_id);
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
transaction();
|
|
1831
1836
|
return c.json({ ok: true });
|
|
1832
1837
|
}
|
|
1833
1838
|
catch (err) {
|
|
@@ -1992,6 +1997,10 @@ app.post('/:id/tasks/:taskId/notify-done', (c) => {
|
|
|
1992
1997
|
return c.json({ error: message }, 500);
|
|
1993
1998
|
}
|
|
1994
1999
|
});
|
|
2000
|
+
// Cap how many recent rows one search may scan: the LIKE below cannot use an
|
|
2001
|
+
// index past the workspace_id prefix, and better-sqlite3 is synchronous — an
|
|
2002
|
+
// unbounded scan of a months-old workspace blocks every request and WS emit.
|
|
2003
|
+
const HISTORY_SEARCH_SCAN_CAP = 20_000;
|
|
1995
2004
|
// GET /api/workspaces/:id/history-search — full-workspace persisted conversation search.
|
|
1996
2005
|
app.get('/:id/history-search', (c) => {
|
|
1997
2006
|
try {
|
|
@@ -2004,10 +2013,13 @@ app.get('/:id/history-search', (c) => {
|
|
|
2004
2013
|
const limit = Math.min(parseInt(c.req.query('limit') ?? '30', 10) || 30, 100);
|
|
2005
2014
|
const needle = `%${query.replace(/[%_]/g, '\\$&')}%`;
|
|
2006
2015
|
const rows = getDb()
|
|
2007
|
-
.prepare(`SELECT id, session_id, type, payload, created_at FROM
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2016
|
+
.prepare(`SELECT id, session_id, type, payload, created_at FROM (
|
|
2017
|
+
SELECT rowid AS rid, id, session_id, type, payload, created_at FROM ws_events
|
|
2018
|
+
WHERE workspace_id = ? ORDER BY rowid DESC LIMIT ?
|
|
2019
|
+
)
|
|
2020
|
+
WHERE payload LIKE ? ESCAPE '\\'
|
|
2021
|
+
ORDER BY rid DESC LIMIT ?`)
|
|
2022
|
+
.all(id, HISTORY_SEARCH_SCAN_CAP, needle, limit * 4);
|
|
2011
2023
|
const normalized = rows.flatMap((row) => {
|
|
2012
2024
|
const result = readableHistorySearchResult(row.type, row.payload, query);
|
|
2013
2025
|
if (!result)
|
|
@@ -2866,6 +2878,11 @@ async function deleteWorkspaceWithSideEffects(workspace, opts) {
|
|
|
2866
2878
|
}
|
|
2867
2879
|
// Delete workspace from DB (cascades to tasks, sessions, events)
|
|
2868
2880
|
workspaceService.deleteWorkspace(workspace.id);
|
|
2881
|
+
// Announce the deletion so any other tab with this workspace open (or a
|
|
2882
|
+
// live terminal attached to it) can react — mirrors workspace:archived's
|
|
2883
|
+
// cross-tab-sync purpose. Ephemeral: a deleted workspace has no future
|
|
2884
|
+
// ws_events history to replay, so persisting this would be pointless.
|
|
2885
|
+
wsService.emitEphemeral(workspace.id, 'workspace:deleted', { workspaceId: workspace.id });
|
|
2869
2886
|
return warnings;
|
|
2870
2887
|
}
|
|
2871
2888
|
// DELETE /api/workspaces/archived — bulk-delete every archived workspace.
|
|
@@ -17,7 +17,7 @@ import { buildStopHookOutput } from './stop-hook.js';
|
|
|
17
17
|
* post-result drain watchdog force-emits `session:ended` so the orchestrator
|
|
18
18
|
* and auto-loop are not frozen forever.
|
|
19
19
|
*/
|
|
20
|
-
const RESULT_DRAIN_TIMEOUT_MS = 15_000;
|
|
20
|
+
export const RESULT_DRAIN_TIMEOUT_MS = 15_000;
|
|
21
21
|
/**
|
|
22
22
|
* Deadline for a stream that stops reporting ANY activity. The former
|
|
23
23
|
* text-only guard was re-armed solely on a message with text and no tool call
|
|
@@ -26,12 +26,23 @@ const RESULT_DRAIN_TIMEOUT_MS = 15_000;
|
|
|
26
26
|
* forever with zero trace. Exported so tests never hard-code the value.
|
|
27
27
|
*/
|
|
28
28
|
export const CLAUDE_STREAM_IDLE_TIMEOUT_MS = 120_000;
|
|
29
|
+
/**
|
|
30
|
+
* Idle ceiling while a FOREGROUND tool call is in flight (tool:call seen, no
|
|
31
|
+
* tool:result yet). A long Bash run — a test suite, a CI-poll sleep — emits no
|
|
32
|
+
* SDK message until its tool_result, so the short deadline would kill a
|
|
33
|
+
* healthy session mid-tool (the "random session stops" bug). A genuinely dead
|
|
34
|
+
* stream is still reaped once this longer ceiling elapses.
|
|
35
|
+
*/
|
|
36
|
+
export const CLAUDE_TOOL_IDLE_TIMEOUT_MS = 30 * 60_000;
|
|
37
|
+
/** Ceiling on a context compaction: past this, assume it wedged and let the
|
|
38
|
+
* liveness deadline take over instead of pausing forever. */
|
|
39
|
+
export const COMPACTION_STALL_TIMEOUT_MS = 10 * 60_000;
|
|
29
40
|
// Safety net for a subagent whose terminal `task_notification` carries a
|
|
30
41
|
// status outside the mapper's known-terminal set (SDK schema drift) — it
|
|
31
42
|
// never clears `activeSubagentToolCallIds`, which otherwise blocks
|
|
32
43
|
// `inputStream.close()`/the result-drain watchdog forever. Generous window:
|
|
33
44
|
// legitimate subagents can run for several minutes.
|
|
34
|
-
const SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
|
|
45
|
+
export const SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
|
|
35
46
|
const MAX_PENDING_USER_MESSAGES = 20;
|
|
36
47
|
function toMcpServersMap(specs) {
|
|
37
48
|
if (!specs || specs.length === 0)
|
|
@@ -106,6 +117,9 @@ export function createClaudeCodeEngine() {
|
|
|
106
117
|
// UI/tool ids can differ from the SDK task id. Key by the stable UI id
|
|
107
118
|
// so a terminal notification that omits task_id still clears the task.
|
|
108
119
|
const activeSubagentTaskIds = new Map();
|
|
120
|
+
// Foreground tool calls currently awaiting their tool_result. Cleared on
|
|
121
|
+
// every `result` message: a turn cannot end with a tool still in flight.
|
|
122
|
+
const pendingToolCallIds = new Set();
|
|
109
123
|
// Pending canUseTool callbacks, keyed by SDK ctx.toolUseID.
|
|
110
124
|
const pendingResolvers = new Map();
|
|
111
125
|
const isInteractive = options.agentPermissionMode === 'interactive';
|
|
@@ -283,16 +297,74 @@ export function createClaudeCodeEngine() {
|
|
|
283
297
|
const turnLiveness = createTurnLiveness({
|
|
284
298
|
timeoutMs: CLAUDE_STREAM_IDLE_TIMEOUT_MS,
|
|
285
299
|
onTimeout() {
|
|
286
|
-
|
|
300
|
+
// The armed deadline tracks whether a foreground tool call is in
|
|
301
|
+
// flight (see `turnLiveness.setTimeoutMs` below) — log the duration
|
|
302
|
+
// that actually elapsed, not always the shorter baseline, so the
|
|
303
|
+
// watchdog's own diagnostic message stays trustworthy.
|
|
304
|
+
const timeoutMs = pendingToolCallIds.size > 0 ? CLAUDE_TOOL_IDLE_TIMEOUT_MS : CLAUDE_STREAM_IDLE_TIMEOUT_MS;
|
|
305
|
+
console.warn(`[claude-engine] SDK stream reported no activity for ${timeoutMs}ms — forcing session:ended`);
|
|
287
306
|
if (userInterrupted)
|
|
288
307
|
emitSessionEnded('killed', null);
|
|
289
308
|
else if (mapperState.sawErrorResult)
|
|
290
309
|
emitSessionEnded('error', null);
|
|
291
|
-
else
|
|
310
|
+
else {
|
|
311
|
+
safeEmit({
|
|
312
|
+
kind: 'error',
|
|
313
|
+
category: 'other',
|
|
314
|
+
message: 'Session force-ended by the liveness watchdog: no SDK activity within the deadline. If the agent was legitimately busy, this is a bug worth reporting.',
|
|
315
|
+
});
|
|
292
316
|
emitSessionEnded('watchdog', null);
|
|
317
|
+
}
|
|
293
318
|
abortController.abort();
|
|
294
319
|
},
|
|
295
320
|
});
|
|
321
|
+
// The single source of truth for "should the idle deadline be paused
|
|
322
|
+
// right now" — a background subagent, a pending permission/question
|
|
323
|
+
// card, or an in-progress compaction can each legitimately keep the
|
|
324
|
+
// stream quiet for minutes; their own dedicated watchdogs own those
|
|
325
|
+
// windows. Every call site that changes one of these three conditions
|
|
326
|
+
// must re-evaluate through this helper, not duplicate the check.
|
|
327
|
+
const reevaluateLivenessPause = () => {
|
|
328
|
+
if (activeSubagentTaskIds.size > 0 || pendingResolvers.size > 0 || isCompacting)
|
|
329
|
+
turnLiveness.pause();
|
|
330
|
+
else
|
|
331
|
+
turnLiveness.resume();
|
|
332
|
+
};
|
|
333
|
+
// Backstop for a compaction that never reports completion (stuck
|
|
334
|
+
// `session:compacting` with `active: true` forever): `isCompacting`
|
|
335
|
+
// pauses `turnLiveness` indefinitely, and a wedged-but-alive generator
|
|
336
|
+
// never trips `isAlive()`'s own sweep either. Past this ceiling, assume
|
|
337
|
+
// it wedged and resume the liveness deadline so the existing tool-aware
|
|
338
|
+
// / short deadline can reap it, mirroring `armSubagentStallWatchdog`.
|
|
339
|
+
let compactionStallTimer;
|
|
340
|
+
const clearCompactionStallTimer = () => {
|
|
341
|
+
if (!compactionStallTimer)
|
|
342
|
+
return;
|
|
343
|
+
clearTimeout(compactionStallTimer);
|
|
344
|
+
compactionStallTimer = undefined;
|
|
345
|
+
};
|
|
346
|
+
const armCompactionStallTimer = () => {
|
|
347
|
+
clearCompactionStallTimer();
|
|
348
|
+
compactionStallTimer = setTimeout(() => {
|
|
349
|
+
compactionStallTimer = undefined;
|
|
350
|
+
if (!isCompacting)
|
|
351
|
+
return;
|
|
352
|
+
console.warn(`[claude-engine] Compaction still reported active ${COMPACTION_STALL_TIMEOUT_MS}ms after it started — resuming the liveness deadline.`);
|
|
353
|
+
isCompacting = false;
|
|
354
|
+
reevaluateLivenessPause();
|
|
355
|
+
if (activeSubagentTaskIds.size > 0 && !subagentStallTimer) {
|
|
356
|
+
// Resume declined because a subagent is still tracked — but the
|
|
357
|
+
// subagent stall net is only armed from the `result` branch, which
|
|
358
|
+
// a fully wedged generator may never reach. Arm it here as a
|
|
359
|
+
// second stage so this combination can't hang forever. The
|
|
360
|
+
// `!subagentStallTimer` guard matters: arming re-sets the
|
|
361
|
+
// deadline, and a net already armed by a prior `result` must not
|
|
362
|
+
// be extended by this backstop (it is not new activity).
|
|
363
|
+
armSubagentStallWatchdog();
|
|
364
|
+
}
|
|
365
|
+
}, COMPACTION_STALL_TIMEOUT_MS);
|
|
366
|
+
compactionStallTimer.unref?.();
|
|
367
|
+
};
|
|
296
368
|
const armResultDrainWatchdog = () => {
|
|
297
369
|
if (resultDrainTimer)
|
|
298
370
|
return;
|
|
@@ -302,8 +374,14 @@ export function createClaudeCodeEngine() {
|
|
|
302
374
|
emitSessionEnded('killed', null);
|
|
303
375
|
else if (mapperState.sawErrorResult)
|
|
304
376
|
emitSessionEnded('error', null);
|
|
305
|
-
else
|
|
377
|
+
else {
|
|
378
|
+
safeEmit({
|
|
379
|
+
kind: 'error',
|
|
380
|
+
category: 'other',
|
|
381
|
+
message: 'Session force-ended: the SDK generator stayed open after its final result (drain watchdog).',
|
|
382
|
+
});
|
|
306
383
|
emitSessionEnded('watchdog', null);
|
|
384
|
+
}
|
|
307
385
|
abortController.abort();
|
|
308
386
|
}, RESULT_DRAIN_TIMEOUT_MS);
|
|
309
387
|
resultDrainTimer.unref?.();
|
|
@@ -369,12 +447,25 @@ export function createClaudeCodeEngine() {
|
|
|
369
447
|
activeSubagentTaskIds.delete(ev.toolCallId);
|
|
370
448
|
}
|
|
371
449
|
for (const ev of events) {
|
|
372
|
-
if (ev.kind === 'session:compacting')
|
|
450
|
+
if (ev.kind === 'session:compacting') {
|
|
373
451
|
isCompacting = ev.active;
|
|
452
|
+
if (ev.active)
|
|
453
|
+
armCompactionStallTimer();
|
|
454
|
+
else
|
|
455
|
+
clearCompactionStallTimer();
|
|
456
|
+
}
|
|
374
457
|
// Older SDKs can emit only compact_boundary, without a trailing
|
|
375
458
|
// status update. This boundary marks compaction as complete.
|
|
376
|
-
else if (ev.kind === 'session:compacted')
|
|
459
|
+
else if (ev.kind === 'session:compacted') {
|
|
377
460
|
isCompacting = false;
|
|
461
|
+
clearCompactionStallTimer();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
for (const ev of events) {
|
|
465
|
+
if (ev.kind === 'tool:call')
|
|
466
|
+
pendingToolCallIds.add(ev.toolCallId);
|
|
467
|
+
else if (ev.kind === 'tool:result')
|
|
468
|
+
pendingToolCallIds.delete(ev.toolCallId);
|
|
378
469
|
}
|
|
379
470
|
// The stall watchdog is only armed while we're waiting purely on
|
|
380
471
|
// background subagents (no further SDK turn expected unless one
|
|
@@ -398,11 +489,12 @@ export function createClaudeCodeEngine() {
|
|
|
398
489
|
// A background subagent or a pending permission card can stay
|
|
399
490
|
// legitimately quiet for minutes; their own dedicated watchdogs
|
|
400
491
|
// own those windows, so suspend the turn deadline meanwhile.
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
492
|
+
// A FOREGROUND tool call in flight keeps the deadline armed but
|
|
493
|
+
// stretched to the tool-aware ceiling.
|
|
494
|
+
turnLiveness.setTimeoutMs(pendingToolCallIds.size > 0 ? CLAUDE_TOOL_IDLE_TIMEOUT_MS : CLAUDE_STREAM_IDLE_TIMEOUT_MS);
|
|
495
|
+
reevaluateLivenessPause();
|
|
405
496
|
if (msg.type === 'result') {
|
|
497
|
+
pendingToolCallIds.clear();
|
|
406
498
|
completedResponses++;
|
|
407
499
|
emitTurnCompletedIfSettled();
|
|
408
500
|
// A queued forced message starts the next response on this same SDK stream.
|
|
@@ -457,6 +549,7 @@ export function createClaudeCodeEngine() {
|
|
|
457
549
|
clearResultDrainWatchdog();
|
|
458
550
|
turnLiveness.stop();
|
|
459
551
|
clearSubagentStallWatchdog();
|
|
552
|
+
clearCompactionStallTimer();
|
|
460
553
|
// Drain any callback still pending (SDK terminated while awaiting an
|
|
461
554
|
// answer). canUseTool's abort path covers signalled stops; this
|
|
462
555
|
// covers natural iterator completion.
|
|
@@ -541,10 +634,11 @@ export function createClaudeCodeEngine() {
|
|
|
541
634
|
if (!resolver)
|
|
542
635
|
return false;
|
|
543
636
|
pendingResolvers.delete(toolCallId);
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
637
|
+
// Re-evaluate rather than unconditionally resuming: a sibling
|
|
638
|
+
// request, a still-active subagent, or an in-progress compaction
|
|
639
|
+
// may each still have their own legitimate reason to keep the
|
|
640
|
+
// deadline paused.
|
|
641
|
+
reevaluateLivenessPause();
|
|
548
642
|
if (response.kind === 'question') {
|
|
549
643
|
// Echo the original questions array + answers so the SDK
|
|
550
644
|
// reconstructs the AskUserQuestion tool input.
|
|
@@ -67,14 +67,11 @@ export function handleServerRequest(args) {
|
|
|
67
67
|
return true;
|
|
68
68
|
}
|
|
69
69
|
if (method === 'item/permissions/requestApproval') {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
toolName: 'Permissions',
|
|
76
|
-
payload: p,
|
|
77
|
-
});
|
|
70
|
+
// Kōbō doesn't model granular permission grants: the only answer we can
|
|
71
|
+
// send is the empty grant (Codex then falls back to the turn policy), so
|
|
72
|
+
// an Allow/Deny card would be a lie — "Allow" and "Deny" produced the
|
|
73
|
+
// same decline. Answer immediately and keep the session moving.
|
|
74
|
+
respond?.(requestId, { permissions: {}, scope: 'turn' });
|
|
78
75
|
return true;
|
|
79
76
|
}
|
|
80
77
|
return false; // unknown method
|
|
@@ -87,12 +84,6 @@ export function handleServerRequest(args) {
|
|
|
87
84
|
* (and the matching `FileChangeApprovalDecision`): `'accept' | 'acceptForSession' | 'decline' | 'cancel'`.
|
|
88
85
|
* NOT `'approve' / 'reject'` — those would be silently rejected as unknown
|
|
89
86
|
* variants, which breaks the strict and interactive permission modes.
|
|
90
|
-
*
|
|
91
|
-
* `PermissionsRequestApprovalResponse` has a completely different shape:
|
|
92
|
-
* `{ permissions, scope, strictAutoReview? }` — no `decision` field. Since
|
|
93
|
-
* Kōbō doesn't yet model permission grants, we deny the request by sending
|
|
94
|
-
* an empty permissions response. A future iteration could add a UI for
|
|
95
|
-
* granular permission grants.
|
|
96
87
|
*/
|
|
97
88
|
export function buildResponseForResolve(pending, response) {
|
|
98
89
|
if (pending.kind === 'command' || pending.kind === 'file_change') {
|
|
@@ -100,12 +91,6 @@ export function buildResponseForResolve(pending, response) {
|
|
|
100
91
|
return { decision: 'accept' };
|
|
101
92
|
return { decision: 'decline' };
|
|
102
93
|
}
|
|
103
|
-
if (pending.kind === 'permissions') {
|
|
104
|
-
// Codex's PermissionsRequestApprovalResponse shape — not { decision }.
|
|
105
|
-
// We don't yet model granular permission grants, so deny by returning an
|
|
106
|
-
// empty permissions object; Codex falls back to the existing turn policy.
|
|
107
|
-
return { permissions: {}, scope: 'turn' };
|
|
108
|
-
}
|
|
109
94
|
if (pending.kind === 'user_input') {
|
|
110
95
|
if (response.kind === 'question') {
|
|
111
96
|
const answers = {};
|
|
@@ -957,7 +957,16 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
957
957
|
// `reason` is authoritative (with the SDK engine `exitCode` is often null,
|
|
958
958
|
// so reason='error'+exitCode=null would otherwise map wrongly to 'completed').
|
|
959
959
|
// `resumeFailed` is benign: stale id cleared, next iteration starts fresh.
|
|
960
|
-
|
|
960
|
+
// 'watchdog' is a forced kill, never a success. For an auto-loop workspace,
|
|
961
|
+
// handleTransientAutoLoopFailure (called above, before onSessionEnded) runs
|
|
962
|
+
// synchronously up to its first await and writes status 'quota' as its very
|
|
963
|
+
// first statement — that write lands before `currentWorkspace` is read above
|
|
964
|
+
// (line ~1144), so `preserveQuotaBackoff` is already true and this function
|
|
965
|
+
// returns early at the `if (preserveQuotaBackoff) return true` branch above.
|
|
966
|
+
// This computation is therefore never reached at all for that path, not
|
|
967
|
+
// merely short-circuited afterwards — it only runs on session ends that
|
|
968
|
+
// don't go through the auto-loop transient-failure path.
|
|
969
|
+
const isErrorOutcome = !resumeFailed && (reason === 'error' || reason === 'watchdog' || (exitCode !== null && exitCode !== 0));
|
|
961
970
|
const targetStatus = isErrorOutcome ? 'error' : 'completed';
|
|
962
971
|
// Skip the transition when the workspace is already in a terminal state.
|
|
963
972
|
// This happens when stopAgent (or an equivalent caller) synchronously
|
|
@@ -1801,6 +1810,10 @@ async function handleQuota(workspaceId, _agentSessionId) {
|
|
|
1801
1810
|
// 'agent:quota-backoff' WS emit. Hand off everything to it.
|
|
1802
1811
|
quotaBackoffService.arm(workspaceId, delayMs, { resetsAt: resetsAt ?? null, source, reason: 'quota' });
|
|
1803
1812
|
}
|
|
1813
|
+
/** First retry after a transient failure (watchdog recovery, HTTP 500). The
|
|
1814
|
+
* quota ladder's 15-minute floor is tuned for real quota windows — a forced
|
|
1815
|
+
* kill or a server blip deserves a much faster first attempt. */
|
|
1816
|
+
const TRANSIENT_FIRST_RETRY_MS = 2 * 60_000;
|
|
1804
1817
|
/**
|
|
1805
1818
|
* Use the same persisted retry path for temporary upstream failures (HTTP 500
|
|
1806
1819
|
* and overloads) while deliberately ignoring quota-reset data: a server error
|
|
@@ -1826,8 +1839,9 @@ async function handleTransientAutoLoopFailure(workspaceId) {
|
|
|
1826
1839
|
return;
|
|
1827
1840
|
}
|
|
1828
1841
|
const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount, false);
|
|
1842
|
+
const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
|
|
1829
1843
|
retryCounts.set(workspaceId, retryCount + 1);
|
|
1830
|
-
quotaBackoffService.arm(workspaceId,
|
|
1844
|
+
quotaBackoffService.arm(workspaceId, effectiveDelayMs, { resetsAt: resetsAt ?? null, source, reason: 'transient' });
|
|
1831
1845
|
}
|
|
1832
1846
|
/** @internal test-only — re-export of `handleQuota` for direct testing. */
|
|
1833
1847
|
export const _handleQuota = handleQuota;
|
|
@@ -11,6 +11,7 @@ export function createTurnLiveness(input) {
|
|
|
11
11
|
let active = false;
|
|
12
12
|
let paused = false;
|
|
13
13
|
let timedOut = false;
|
|
14
|
+
let currentTimeoutMs = input.timeoutMs;
|
|
14
15
|
const arm = () => {
|
|
15
16
|
if (!active || paused || timedOut)
|
|
16
17
|
return;
|
|
@@ -22,7 +23,8 @@ export function createTurnLiveness(input) {
|
|
|
22
23
|
return;
|
|
23
24
|
timedOut = true;
|
|
24
25
|
input.onTimeout();
|
|
25
|
-
},
|
|
26
|
+
}, currentTimeoutMs);
|
|
27
|
+
timer.unref?.();
|
|
26
28
|
};
|
|
27
29
|
return {
|
|
28
30
|
start() {
|
|
@@ -53,5 +55,14 @@ export function createTurnLiveness(input) {
|
|
|
53
55
|
clearTimeout(timer);
|
|
54
56
|
timer = undefined;
|
|
55
57
|
},
|
|
58
|
+
setTimeoutMs(ms) {
|
|
59
|
+
if (ms === currentTimeoutMs)
|
|
60
|
+
return;
|
|
61
|
+
currentTimeoutMs = ms;
|
|
62
|
+
// Re-arm only if a deadline is currently pending; a paused/stopped
|
|
63
|
+
// liveness picks the new duration up on its next arm().
|
|
64
|
+
if (timer)
|
|
65
|
+
arm();
|
|
66
|
+
},
|
|
56
67
|
};
|
|
57
68
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { realpathSync } from 'node:fs';
|
|
3
|
-
import * as path from 'node:path';
|
|
4
2
|
import { getFileContent, writeFileInWorktree } from '../utils/git-ops.js';
|
|
3
|
+
import { resolvePathInside } from '../utils/safe-path.js';
|
|
5
4
|
const MAX_FILE_BYTES = 1024 * 1024;
|
|
6
5
|
export function shaOf(content) {
|
|
7
6
|
return createHash('sha256').update(content, 'utf8').digest('hex');
|
|
@@ -12,7 +11,7 @@ export function shaOf(content) {
|
|
|
12
11
|
* escapes the worktree (including via symlinks), or when content exceeds 1 MB.
|
|
13
12
|
*/
|
|
14
13
|
export function saveWorkspaceFile(worktreePath, relativePath, content, baseSha) {
|
|
15
|
-
const absPath =
|
|
14
|
+
const absPath = resolvePathInside(worktreePath, relativePath);
|
|
16
15
|
if (Buffer.byteLength(content, 'utf8') > MAX_FILE_BYTES) {
|
|
17
16
|
throw new Error(`File too large (max ${MAX_FILE_BYTES / 1024 / 1024} MB)`);
|
|
18
17
|
}
|
|
@@ -24,46 +23,3 @@ export function saveWorkspaceFile(worktreePath, relativePath, content, baseSha)
|
|
|
24
23
|
writeFileInWorktree(absPath, content);
|
|
25
24
|
return { status: 'saved' };
|
|
26
25
|
}
|
|
27
|
-
function resolveSafe(worktreePath, relativePath) {
|
|
28
|
-
const abs = path.resolve(worktreePath, relativePath);
|
|
29
|
-
const lexicalRoot = path.resolve(worktreePath);
|
|
30
|
-
// Lexical containment check FIRST: a path that escapes the worktree via `..`
|
|
31
|
-
// (or an absolute path) must be reported as an escape deterministically —
|
|
32
|
-
// independent of whether the traversal happens to land on a directory that
|
|
33
|
-
// exists on disk. Without this, `../../etc/passwd` surfaces as "parent
|
|
34
|
-
// directory does not exist" instead of "escapes the worktree" whenever the
|
|
35
|
-
// worktree sits deep enough that the `..` chain lands on a missing dir.
|
|
36
|
-
if (abs !== lexicalRoot && !abs.startsWith(lexicalRoot + path.sep)) {
|
|
37
|
-
throw new Error(`Path '${relativePath}' escapes the worktree`);
|
|
38
|
-
}
|
|
39
|
-
const root = realpathSync(lexicalRoot);
|
|
40
|
-
const rootWithSep = root + path.sep;
|
|
41
|
-
// Resolve the parent's realpath (parent must exist; if it doesn't, the path
|
|
42
|
-
// is invalid anyway and we surface that). Then join the lexical basename so
|
|
43
|
-
// a non-existent leaf doesn't trigger ENOENT.
|
|
44
|
-
let realParent;
|
|
45
|
-
try {
|
|
46
|
-
realParent = realpathSync(path.dirname(abs));
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
throw new Error(`Path '${relativePath}' is invalid (parent directory does not exist)`);
|
|
50
|
-
}
|
|
51
|
-
const realAbs = path.join(realParent, path.basename(abs));
|
|
52
|
-
if (realAbs !== root && !realAbs.startsWith(rootWithSep)) {
|
|
53
|
-
throw new Error(`Path '${relativePath}' escapes the worktree`);
|
|
54
|
-
}
|
|
55
|
-
// If the leaf exists and is itself a symlink, follow it and re-check
|
|
56
|
-
// containment so we never write through a symlink that escapes the worktree.
|
|
57
|
-
try {
|
|
58
|
-
const leafReal = realpathSync(realAbs);
|
|
59
|
-
if (leafReal !== root && !leafReal.startsWith(rootWithSep)) {
|
|
60
|
-
throw new Error(`Path '${relativePath}' escapes the worktree`);
|
|
61
|
-
}
|
|
62
|
-
return leafReal;
|
|
63
|
-
}
|
|
64
|
-
catch (err) {
|
|
65
|
-
if (err.code === 'ENOENT')
|
|
66
|
-
return realAbs;
|
|
67
|
-
throw err;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { getGlobalSettings } from '../../settings-service.js';
|
|
4
|
+
import { parseCliJson } from '../parse-cli-json.js';
|
|
4
5
|
import { deriveReadyToMerge } from '../types.js';
|
|
5
6
|
const execFileAsync = promisify(execFile);
|
|
6
7
|
function record(value) {
|
|
@@ -233,14 +234,14 @@ async function resolveTarget(repoPath) {
|
|
|
233
234
|
if (bktEnv().BKT_TOKEN)
|
|
234
235
|
return { ...target, context: null };
|
|
235
236
|
const { stdout: contextOutput } = await execFileAsync('bkt', ['context', 'list', '--json'], bktOptions(repoPath));
|
|
236
|
-
const contexts = record(
|
|
237
|
+
const contexts = record(parseCliJson(contextOutput, 'bkt context list')).contexts;
|
|
237
238
|
const existing = (Array.isArray(contexts) ? contexts : [])
|
|
238
239
|
.map(record)
|
|
239
240
|
.find((context) => string(context.workspace) === target.workspace && string(context.default_repo) === target.repo);
|
|
240
241
|
if (existing && string(existing.name))
|
|
241
242
|
return { ...target, context: string(existing.name) };
|
|
242
243
|
const { stdout: authOutput } = await execFileAsync('bkt', ['auth', 'status', '--json'], bktOptions(repoPath));
|
|
243
|
-
const hosts = record(
|
|
244
|
+
const hosts = record(parseCliJson(authOutput, 'bkt auth status')).hosts;
|
|
244
245
|
const host = (Array.isArray(hosts) ? hosts : []).map(record).find((item) => string(item.kind) === 'cloud');
|
|
245
246
|
const hostKey = string(host?.key);
|
|
246
247
|
if (!hostKey)
|
|
@@ -255,7 +256,7 @@ function targetArgs(target) {
|
|
|
255
256
|
async function fetchCi(repoPath, number, target) {
|
|
256
257
|
try {
|
|
257
258
|
const { stdout } = await execFileAsync('bkt', bktArgs(target, ['pr', 'checks', String(number), ...targetArgs(target), '--json']), bktOptions(repoPath));
|
|
258
|
-
const statuses = record(
|
|
259
|
+
const statuses = record(parseCliJson(stdout, 'bkt pr checks')).statuses;
|
|
259
260
|
const checks = (Array.isArray(statuses) ? statuses : []).map(mapCheck);
|
|
260
261
|
const rollup = checks.length === 0
|
|
261
262
|
? null
|
|
@@ -286,7 +287,7 @@ export const bitbucketProvider = {
|
|
|
286
287
|
async isAvailable(repoPath) {
|
|
287
288
|
try {
|
|
288
289
|
const { stdout } = await execFileAsync('bkt', ['auth', 'status', '--json'], bktOptions(repoPath));
|
|
289
|
-
const status = record(
|
|
290
|
+
const status = record(parseCliJson(stdout, 'bkt auth status'));
|
|
290
291
|
const hosts = status.hosts;
|
|
291
292
|
if ((Array.isArray(hosts) && hosts.length > 0) || bktEnv().BKT_TOKEN)
|
|
292
293
|
return { available: true };
|
|
@@ -304,7 +305,7 @@ export const bitbucketProvider = {
|
|
|
304
305
|
// branch deletion, so do not stop at OPEN requests.
|
|
305
306
|
for (const state of ['OPEN', 'MERGED', 'DECLINED']) {
|
|
306
307
|
const { stdout: listOutput } = await execFileAsync('bkt', bktArgs(target, ['pr', 'list', '--state', state, '--limit', '50', ...targetArgs(target), '--json']), bktOptions(repoPath));
|
|
307
|
-
const pullRequests = record(
|
|
308
|
+
const pullRequests = record(parseCliJson(listOutput, 'bkt pr list')).pull_requests;
|
|
308
309
|
match = (Array.isArray(pullRequests) ? pullRequests : [])
|
|
309
310
|
.map(record)
|
|
310
311
|
.find((pullRequest) => branch(pullRequest, 'source', 'fromRef') === branchName);
|
|
@@ -314,7 +315,7 @@ export const bitbucketProvider = {
|
|
|
314
315
|
if (!match)
|
|
315
316
|
return null;
|
|
316
317
|
const { stdout: viewOutput } = await execFileAsync('bkt', bktArgs(target, ['pr', 'view', String(number(match.id)), ...targetArgs(target), '--json']), bktOptions(repoPath));
|
|
317
|
-
const raw = record(record(
|
|
318
|
+
const raw = record(record(parseCliJson(viewOutput, 'bkt pr view')).pull_request);
|
|
318
319
|
if (!number(raw.id))
|
|
319
320
|
return null;
|
|
320
321
|
const ci = await fetchCi(repoPath, number(raw.id), target);
|
|
@@ -340,7 +341,7 @@ export const bitbucketProvider = {
|
|
|
340
341
|
...targetArgs(target),
|
|
341
342
|
'--json',
|
|
342
343
|
]), bktOptions(repoPath));
|
|
343
|
-
const raw = record(
|
|
344
|
+
const raw = record(parseCliJson(stdout, 'bkt pr create'));
|
|
344
345
|
const prNumber = number(raw.id);
|
|
345
346
|
const prUrl = string(raw.url);
|
|
346
347
|
if (!prNumber || !prUrl)
|
|
@@ -363,7 +364,7 @@ export const bitbucketProvider = {
|
|
|
363
364
|
if (opts.cursor)
|
|
364
365
|
args.push('--page', opts.cursor);
|
|
365
366
|
const { stdout } = await execFileAsync('bkt', bktArgs(target, args), bktOptions(repoPath));
|
|
366
|
-
const page = mapBitbucketPage(
|
|
367
|
+
const page = mapBitbucketPage(parseCliJson(stdout || '{}', 'bkt pr list'));
|
|
367
368
|
if (opts.filter === 'all' && !opts.search?.trim())
|
|
368
369
|
return page;
|
|
369
370
|
const currentUser = getGlobalSettings().bitbucketUsername || undefined;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/server/services/forge/github/provider.ts
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
|
+
import { parseCliJson } from '../parse-cli-json.js';
|
|
4
5
|
import { deriveReadyToMerge } from '../types.js';
|
|
5
6
|
const execFileAsync = promisify(execFile);
|
|
6
7
|
const CLI_TIMEOUT_MS = 30_000;
|
|
@@ -174,7 +175,7 @@ export const githubProvider = {
|
|
|
174
175
|
const raw = stdout.trim();
|
|
175
176
|
if (!raw)
|
|
176
177
|
return null;
|
|
177
|
-
return mapGhPrToSnapshot(
|
|
178
|
+
return mapGhPrToSnapshot(parseCliJson(raw, 'gh pr view'));
|
|
178
179
|
}
|
|
179
180
|
catch {
|
|
180
181
|
return null;
|
|
@@ -198,7 +199,7 @@ export const githubProvider = {
|
|
|
198
199
|
},
|
|
199
200
|
async listPullRequests(repoPath, opts) {
|
|
200
201
|
const { stdout: repoJson } = await execFileAsync('gh', ['repo', 'view', '--json', 'nameWithOwner'], cliOptions(repoPath));
|
|
201
|
-
const nameWithOwner = String(
|
|
202
|
+
const nameWithOwner = String(parseCliJson(repoJson, 'gh repo view').nameWithOwner ?? '');
|
|
202
203
|
if (!nameWithOwner)
|
|
203
204
|
throw new Error('Could not determine the GitHub repository for this path');
|
|
204
205
|
const args = [
|
|
@@ -214,6 +215,6 @@ export const githubProvider = {
|
|
|
214
215
|
if (opts.cursor)
|
|
215
216
|
args.push('-f', `after=${opts.cursor}`);
|
|
216
217
|
const { stdout } = await execFileAsync('gh', args, cliOptions(repoPath));
|
|
217
|
-
return mapGithubSearchPage(
|
|
218
|
+
return mapGithubSearchPage(parseCliJson(stdout, 'gh api graphql (list PRs)'));
|
|
218
219
|
},
|
|
219
220
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/server/services/forge/gitlab/provider.ts
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
|
+
import { parseCliJson } from '../parse-cli-json.js';
|
|
4
5
|
import { deriveReadyToMerge } from '../types.js';
|
|
5
6
|
const execFileAsync = promisify(execFile);
|
|
6
7
|
const CLI_TIMEOUT_MS = 30_000;
|
|
@@ -108,7 +109,7 @@ async function fetchGlabCi(repoPath, branch) {
|
|
|
108
109
|
const raw = stdout.trim();
|
|
109
110
|
if (!raw)
|
|
110
111
|
return { rollup: null, checks: [] };
|
|
111
|
-
const pipeline =
|
|
112
|
+
const pipeline = parseCliJson(raw, 'glab ci get');
|
|
112
113
|
return {
|
|
113
114
|
rollup: mapPipelineRollup(pipeline.status),
|
|
114
115
|
checks: (pipeline.jobs ?? []).map(mapGlabJobToCheck),
|
|
@@ -192,7 +193,7 @@ export const gitlabProvider = {
|
|
|
192
193
|
const raw = stdout.trim();
|
|
193
194
|
if (!raw)
|
|
194
195
|
return null;
|
|
195
|
-
const snapshot = mapGlabMrToSnapshot(
|
|
196
|
+
const snapshot = mapGlabMrToSnapshot(parseCliJson(raw, 'glab mr view'));
|
|
196
197
|
snapshot.ci = await fetchGlabCi(repoPath, branch);
|
|
197
198
|
snapshot.readyToMerge = deriveReadyToMerge(snapshot);
|
|
198
199
|
return snapshot;
|
|
@@ -231,6 +232,6 @@ export const gitlabProvider = {
|
|
|
231
232
|
},
|
|
232
233
|
async listPullRequests(repoPath, opts) {
|
|
233
234
|
const { stdout } = await execFileAsync('glab', buildGitlabListArgs(opts), cliOptions(repoPath));
|
|
234
|
-
return mapGitlabPage(
|
|
235
|
+
return mapGitlabPage(parseCliJson(stdout || '[]', 'glab mr list'), opts);
|
|
235
236
|
},
|
|
236
237
|
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** JSON.parse with a message a user can act on: a truncated or non-JSON CLI
|
|
2
|
+
* response otherwise surfaces as a raw "Unexpected token…" 500. */
|
|
3
|
+
export function parseCliJson(raw, context) {
|
|
4
|
+
try {
|
|
5
|
+
return JSON.parse(raw);
|
|
6
|
+
}
|
|
7
|
+
catch (err) {
|
|
8
|
+
// Keep the diagnostic trail: the user-facing message stays terse, but the
|
|
9
|
+
// original parse error and a sample of the offending output land in the
|
|
10
|
+
// server log so "unparsable output" reports can actually be investigated.
|
|
11
|
+
console.error(`[forge] ${context} returned unparsable output:`, err, raw.slice(0, 200));
|
|
12
|
+
throw new Error(`${context} returned unparsable output`);
|
|
13
|
+
}
|
|
14
|
+
}
|