agent-relay-server 0.120.1 → 0.120.3
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/docs/openapi.json +1 -1
- package/package.json +3 -3
- package/public/assets/MessageBubble-Cqi3XsZ7.js +2 -0
- package/public/assets/{MessageBubble-jpshgh3K.js.map → MessageBubble-Cqi3XsZ7.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CjVG-Jsq.js → PlanGraphPanel-D6Um05oW.js} +2 -2
- package/public/assets/{PlanGraphPanel-CjVG-Jsq.js.map → PlanGraphPanel-D6Um05oW.js.map} +1 -1
- package/public/assets/{activity-DLTFF8Dz.js → activity-B0LsQHEz.js} +2 -2
- package/public/assets/{activity-DLTFF8Dz.js.map → activity-B0LsQHEz.js.map} +1 -1
- package/public/assets/{agent-profiles-BcCLIrON.js → agent-profiles-O--pe5Ic.js} +2 -2
- package/public/assets/{agent-profiles-BcCLIrON.js.map → agent-profiles-O--pe5Ic.js.map} +1 -1
- package/public/assets/{agent-type-icon-BEz393ra.js → agent-type-icon-CeEoMouY.js} +2 -2
- package/public/assets/{agent-type-icon-BEz393ra.js.map → agent-type-icon-CeEoMouY.js.map} +1 -1
- package/public/assets/agents-Ii6XlVwR.js +2 -0
- package/public/assets/{agents-QemvuxKO.js.map → agents-Ii6XlVwR.js.map} +1 -1
- package/public/assets/{analytics-DMLoMwta.js → analytics-BCzdYWRT.js} +2 -2
- package/public/assets/{analytics-DMLoMwta.js.map → analytics-BCzdYWRT.js.map} +1 -1
- package/public/assets/{automation-DACSOffp.js → automation-Crr3Xzui.js} +2 -2
- package/public/assets/{automation-DACSOffp.js.map → automation-Crr3Xzui.js.map} +1 -1
- package/public/assets/{badge-BhUZ1cAb.js → badge-DCIT3Irj.js} +2 -2
- package/public/assets/{badge-BhUZ1cAb.js.map → badge-DCIT3Irj.js.map} +1 -1
- package/public/assets/{branch-state-badge-BYE2U_y4.js → branch-state-badge-Bhw2rloy.js} +2 -2
- package/public/assets/{branch-state-badge-BYE2U_y4.js.map → branch-state-badge-Bhw2rloy.js.map} +1 -1
- package/public/assets/{button-C-V4oij1.js → button-CzdTkRIf.js} +2 -2
- package/public/assets/{button-C-V4oij1.js.map → button-CzdTkRIf.js.map} +1 -1
- package/public/assets/{card-Br1JLjTH.js → card-C3BGuVC6.js} +2 -2
- package/public/assets/{card-Br1JLjTH.js.map → card-C3BGuVC6.js.map} +1 -1
- package/public/assets/{channels-3SlDQL3j.js → channels-CKs9rvsn.js} +2 -2
- package/public/assets/{channels-3SlDQL3j.js.map → channels-CKs9rvsn.js.map} +1 -1
- package/public/assets/chat-BCwEUj5R.js +2 -0
- package/public/assets/chat-BCwEUj5R.js.map +1 -0
- package/public/assets/{connectors-CrEW591Q.js → connectors-D_kQR_vw.js} +2 -2
- package/public/assets/{connectors-CrEW591Q.js.map → connectors-D_kQR_vw.js.map} +1 -1
- package/public/assets/{copy-button-BStzXgtd.js → copy-button-B3o1jpLu.js} +2 -2
- package/public/assets/{copy-button-BStzXgtd.js.map → copy-button-B3o1jpLu.js.map} +1 -1
- package/public/assets/{dist-ChDX_t12.js → dist-B-T7lg0a.js} +2 -2
- package/public/assets/{dist-ChDX_t12.js.map → dist-B-T7lg0a.js.map} +1 -1
- package/public/assets/{dist-DV83j9tm.js → dist-B3e4B9pg.js} +2 -2
- package/public/assets/{dist-DV83j9tm.js.map → dist-B3e4B9pg.js.map} +1 -1
- package/public/assets/{dist-y03IwOrd.js → dist-B6woQ9Tb.js} +2 -2
- package/public/assets/{dist-y03IwOrd.js.map → dist-B6woQ9Tb.js.map} +1 -1
- package/public/assets/{dist-CQ0Z-TDi.js → dist-B9G83DXv.js} +2 -2
- package/public/assets/{dist-CQ0Z-TDi.js.map → dist-B9G83DXv.js.map} +1 -1
- package/public/assets/{dist-Cs8X0P1d.js → dist-BIjlN-xB.js} +2 -2
- package/public/assets/{dist-Cs8X0P1d.js.map → dist-BIjlN-xB.js.map} +1 -1
- package/public/assets/{dist-DyNJNwpi.js → dist-BInckSJC.js} +2 -2
- package/public/assets/{dist-DyNJNwpi.js.map → dist-BInckSJC.js.map} +1 -1
- package/public/assets/{dist-CmFeEXMe.js → dist-BlzVGRhh.js} +2 -2
- package/public/assets/{dist-CmFeEXMe.js.map → dist-BlzVGRhh.js.map} +1 -1
- package/public/assets/{dist-CDZZxxJq.js → dist-C7aTlosb.js} +2 -2
- package/public/assets/{dist-CDZZxxJq.js.map → dist-C7aTlosb.js.map} +1 -1
- package/public/assets/{dist-D2jvo5oA.js → dist-DGfp5dLz.js} +2 -2
- package/public/assets/{dist-D2jvo5oA.js.map → dist-DGfp5dLz.js.map} +1 -1
- package/public/assets/{dist-mm7c7nBt.js → dist-DT-OV6sQ.js} +2 -2
- package/public/assets/{dist-mm7c7nBt.js.map → dist-DT-OV6sQ.js.map} +1 -1
- package/public/assets/{dist-CBMWGhWc.js → dist-OUPTEP6q.js} +2 -2
- package/public/assets/{dist-CBMWGhWc.js.map → dist-OUPTEP6q.js.map} +1 -1
- package/public/assets/{es2015-VnZ2kVIz.js → es2015-CnKXYKpf.js} +2 -2
- package/public/assets/{es2015-VnZ2kVIz.js.map → es2015-CnKXYKpf.js.map} +1 -1
- package/public/assets/formatted-body-impl-CSRGuMcM.js +3 -0
- package/public/assets/formatted-body-impl-CSRGuMcM.js.map +1 -0
- package/public/assets/formatted-body-q_qzt1L-.js +3 -0
- package/public/assets/{formatted-body-Dg9R3RD7.js.map → formatted-body-q_qzt1L-.js.map} +1 -1
- package/public/assets/{index-D_fNJnsI.js → index-Ceiwd02X.js} +10 -10
- package/public/assets/index-Ceiwd02X.js.map +1 -0
- package/public/assets/{input-CtLT7WXi.js → input-B6Q0NWCS.js} +2 -2
- package/public/assets/{input-CtLT7WXi.js.map → input-B6Q0NWCS.js.map} +1 -1
- package/public/assets/{insights-D48n2ojF.js → insights-DUhXqZTZ.js} +2 -2
- package/public/assets/{insights-D48n2ojF.js.map → insights-DUhXqZTZ.js.map} +1 -1
- package/public/assets/{integrations-B-bdliwU.js → integrations-lbg5CjtE.js} +2 -2
- package/public/assets/{integrations-B-bdliwU.js.map → integrations-lbg5CjtE.js.map} +1 -1
- package/public/assets/{lib-ByLQqcD1.js → lib-BHsWPMKB.js} +2 -2
- package/public/assets/{lib-ByLQqcD1.js.map → lib-BHsWPMKB.js.map} +1 -1
- package/public/assets/lucide-react-DuhlF2Sj.js +10 -0
- package/public/assets/lucide-react-DuhlF2Sj.js.map +1 -0
- package/public/assets/{maintenance-LQ_U3Nqy.js → maintenance-CQgneSVf.js} +2 -2
- package/public/assets/{maintenance-LQ_U3Nqy.js.map → maintenance-CQgneSVf.js.map} +1 -1
- package/public/assets/{managed-agents-Cfh8FvY8.js → managed-agents-DsnYf6fm.js} +4 -4
- package/public/assets/{managed-agents-Cfh8FvY8.js.map → managed-agents-DsnYf6fm.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BSa7zYx1.js → markdown-preview-impl-C1ZtGJp6.js} +2 -2
- package/public/assets/{markdown-preview-impl-BSa7zYx1.js.map → markdown-preview-impl-C1ZtGJp6.js.map} +1 -1
- package/public/assets/{memory-Dr1iYKkC.js → memory-SSLUTOI2.js} +2 -2
- package/public/assets/{memory-Dr1iYKkC.js.map → memory-SSLUTOI2.js.map} +1 -1
- package/public/assets/messages-BY26OP4K.js +2 -0
- package/public/assets/{messages-DRKulS-2.js.map → messages-BY26OP4K.js.map} +1 -1
- package/public/assets/{orchestrators-BguyeViG.js → orchestrators-OY7yPzVI.js} +2 -2
- package/public/assets/{orchestrators-BguyeViG.js.map → orchestrators-OY7yPzVI.js.map} +1 -1
- package/public/assets/overview-Dj0u1tPG.js +2 -0
- package/public/assets/{overview-Dbq2lr7Y.js.map → overview-Dj0u1tPG.js.map} +1 -1
- package/public/assets/{pairs-DD7i2scF.js → pairs-CrUR50ou.js} +2 -2
- package/public/assets/{pairs-DD7i2scF.js.map → pairs-CrUR50ou.js.map} +1 -1
- package/public/assets/{projects-DEn85LQb.js → projects-B_AtxHtH.js} +2 -2
- package/public/assets/{projects-DEn85LQb.js.map → projects-B_AtxHtH.js.map} +1 -1
- package/public/assets/{prompt-settings-DwgrPKY3.js → prompt-settings-CPtYSban.js} +2 -2
- package/public/assets/{prompt-settings-DwgrPKY3.js.map → prompt-settings-CPtYSban.js.map} +1 -1
- package/public/assets/{react-dom-cLkVFBsS.js → react-dom-DWBmcawP.js} +2 -2
- package/public/assets/{react-dom-cLkVFBsS.js.map → react-dom-DWBmcawP.js.map} +1 -1
- package/public/assets/{registry-DCR3xFpe.js → registry-BgUALWn5.js} +2 -2
- package/public/assets/{registry-DCR3xFpe.js.map → registry-BgUALWn5.js.map} +1 -1
- package/public/assets/security-5lHfGk7g.js +3 -0
- package/public/assets/security-5lHfGk7g.js.map +1 -0
- package/public/assets/{select-CpyCRBK3.js → select-Cgx4QSLj.js} +2 -2
- package/public/assets/{select-CpyCRBK3.js.map → select-Cgx4QSLj.js.map} +1 -1
- package/public/assets/settings-DFGgxyun.js +6 -0
- package/public/assets/{settings-DYNFGC2-.js.map → settings-DFGgxyun.js.map} +1 -1
- package/public/assets/store-BM4zM89B.js +9 -0
- package/public/assets/store-BM4zM89B.js.map +1 -0
- package/public/assets/{task-chip-kxk4eNm1.js → task-chip-DXfvXBDx.js} +2 -2
- package/public/assets/{task-chip-kxk4eNm1.js.map → task-chip-DXfvXBDx.js.map} +1 -1
- package/public/assets/{tasks-rdy9Nsbf.js → tasks-BvH5rluu.js} +2 -2
- package/public/assets/{tasks-rdy9Nsbf.js.map → tasks-BvH5rluu.js.map} +1 -1
- package/public/assets/{teams-DHloBagJ.js → teams-VZOIOdoE.js} +2 -2
- package/public/assets/{teams-DHloBagJ.js.map → teams-VZOIOdoE.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DWTuNejR.js → terminal-viewer-impl-DYY3Wmoe.js} +2 -2
- package/public/assets/{terminal-viewer-impl-DWTuNejR.js.map → terminal-viewer-impl-DYY3Wmoe.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{use-keyboard-viewport-B06XtRUZ.js → use-keyboard-viewport-CP109w9y.js} +2 -2
- package/public/assets/{use-keyboard-viewport-B06XtRUZ.js.map → use-keyboard-viewport-CP109w9y.js.map} +1 -1
- package/public/assets/{user-avatar-DuwCFrER.js → user-avatar-Dw6mzWhv.js} +2 -2
- package/public/assets/{user-avatar-DuwCFrER.js.map → user-avatar-Dw6mzWhv.js.map} +1 -1
- package/public/assets/{work-queue-CShT7nTC.js → work-queue-B6Xk7QNe.js} +2 -2
- package/public/assets/{work-queue-CShT7nTC.js.map → work-queue-B6Xk7QNe.js.map} +1 -1
- package/public/assets/{workspaces-DjQ6EdrZ.js → workspaces-CPIzAHAe.js} +2 -2
- package/public/assets/{workspaces-DjQ6EdrZ.js.map → workspaces-CPIzAHAe.js.map} +1 -1
- package/public/index.html +23 -23
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/automations/reconcile.ts +69 -4
- package/src/branch-landed.ts +18 -11
- package/src/bus.ts +25 -18
- package/src/db/connection.ts +53 -1
- package/src/db/maintenance-worker.ts +37 -0
- package/src/db/message-reads.ts +86 -45
- package/src/db/migrations.ts +1 -0
- package/src/db/provider-quotas.ts +6 -7
- package/src/db/schema.ts +2 -1
- package/src/maintenance/jobs.ts +19 -13
- package/src/maintenance/scheduler.ts +8 -3
- package/src/maintenance/types.ts +5 -1
- package/src/mcp/index.ts +5 -5
- package/src/mcp/tools-workspace.ts +8 -3
- package/src/routes/orchestrator-proxy.ts +7 -6
- package/src/routes/sse.ts +2 -1
- package/src/routes/tasks.ts +29 -0
- package/src/routes/workspaces.ts +9 -2
- package/src/runtime-tokens.ts +4 -0
- package/src/security.ts +31 -7
- package/src/server.ts +40 -18
- package/src/services/git-process.ts +61 -0
- package/src/services/git-remote.ts +87 -7
- package/src/services/issue-lifecycle.ts +6 -6
- package/src/services/project.ts +47 -1
- package/src/services/send-message.ts +2 -4
- package/src/services/spawn-agent.ts +2 -2
- package/src/services/task-lifecycle.ts +6 -9
- package/src/settings/registry.ts +6 -4
- package/src/spawn-targets.ts +2 -2
- package/src/sse.ts +131 -46
- package/src/steward.ts +10 -6
- package/src/terminal-authz.ts +50 -0
- package/src/token-db.ts +3 -3
- package/src/validation.ts +1 -1
- package/src/workspace-actions.ts +46 -4
- package/src/workspace-auto-merge.ts +120 -13
- package/src/workspace-orphans.ts +1 -1
- package/src/workspace-probe.ts +19 -10
- package/public/assets/MessageBubble-jpshgh3K.js +0 -2
- package/public/assets/agents-QemvuxKO.js +0 -2
- package/public/assets/chat-xKXCafN9.js +0 -2
- package/public/assets/chat-xKXCafN9.js.map +0 -1
- package/public/assets/formatted-body-Dg9R3RD7.js +0 -3
- package/public/assets/formatted-body-impl-DlasTD8X.js +0 -3
- package/public/assets/formatted-body-impl-DlasTD8X.js.map +0 -1
- package/public/assets/index-D_fNJnsI.js.map +0 -1
- package/public/assets/lucide-react-Dc3ZwCBi.js +0 -9
- package/public/assets/lucide-react-Dc3ZwCBi.js.map +0 -1
- package/public/assets/messages-DRKulS-2.js +0 -2
- package/public/assets/overview-Dbq2lr7Y.js +0 -2
- package/public/assets/security-BH8kTdA-.js +0 -3
- package/public/assets/security-BH8kTdA-.js.map +0 -1
- package/public/assets/settings-DYNFGC2-.js +0 -6
- package/public/assets/store-BuuJUn70.js +0 -9
- package/public/assets/store-BuuJUn70.js.map +0 -1
package/src/sse.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getAgent, getOrchestrator, matchingDeliveryAgents, onWorkspaceChange, sendMessageWithResult, setChannelSubscriberProbe } from "./db";
|
|
2
|
+
import { getOldestOutboxCursor, getOutboxCursor, replayEvents, type BusEvent } from "./bus-outbox";
|
|
2
3
|
import { projectAgentStatusData } from "./agent-card-projection";
|
|
3
4
|
import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
|
|
4
5
|
import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
|
|
@@ -11,20 +12,27 @@ interface Connection {
|
|
|
11
12
|
agentId: string | null;
|
|
12
13
|
controller: ReadableStreamDefaultController<Uint8Array>;
|
|
13
14
|
keepalive: Timer;
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
15
|
+
queuedBytes: number;
|
|
16
|
+
// Pending backpressure re-check. Set when a stream crosses the byte floor; cleared
|
|
17
|
+
// once it drains or is closed after making no progress during the grace interval.
|
|
17
18
|
drainCheck?: Timer;
|
|
19
|
+
drainCheckQueuedBytes?: number;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
const connections = new Map<string, Connection>();
|
|
21
23
|
const encoder = new TextEncoder();
|
|
22
24
|
const SSE_KEEPALIVE_MS = 15_000;
|
|
23
25
|
const SSE_RETRY_MS = 5_000;
|
|
24
|
-
const
|
|
26
|
+
const SSE_MAX_QUEUED_BYTES = 1024 * 1024;
|
|
27
|
+
const SSE_DRAIN_GRACE_MS = 2_000;
|
|
25
28
|
|
|
26
29
|
subscribeRelayEvents((event) => fanout(event));
|
|
27
30
|
|
|
31
|
+
function fanoutAgent(agentId: string, cache: Map<string, AgentCard | null>): AgentCard | null {
|
|
32
|
+
if (!cache.has(agentId)) cache.set(agentId, getAgent(agentId));
|
|
33
|
+
return cache.get(agentId) ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
// #797 — let the db layer derive outbound-channel liveness from live SSE subscriptions.
|
|
29
37
|
// A channel agent is "subscribed" iff the channels-host daemon holds an open
|
|
30
38
|
// GET /api/events?for=<channelAgentId> stream (createSSEStream stores `for` as agentId).
|
|
@@ -36,28 +44,36 @@ export function hasActiveSubscriber(agentId: string): boolean {
|
|
|
36
44
|
}
|
|
37
45
|
setChannelSubscriberProbe(hasActiveSubscriber);
|
|
38
46
|
|
|
39
|
-
export function createSSEStream(agentId: string | null): Response {
|
|
47
|
+
export function createSSEStream(agentId: string | null, lastEventId = 0): Response {
|
|
40
48
|
let connId: string;
|
|
41
49
|
|
|
42
|
-
const stream = new ReadableStream<Uint8Array>(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
const stream = new ReadableStream<Uint8Array>(
|
|
51
|
+
{
|
|
52
|
+
start(controller) {
|
|
53
|
+
connId = crypto.randomUUID();
|
|
54
|
+
const keepalive = setInterval(() => {
|
|
55
|
+
const conn = connections.get(connId);
|
|
56
|
+
if (!conn) return;
|
|
57
|
+
sendEncoded(conn, encoder.encode(": keepalive\n\n"));
|
|
58
|
+
}, SSE_KEEPALIVE_MS);
|
|
59
|
+
|
|
60
|
+
connections.set(connId, { id: connId, agentId, controller, keepalive, queuedBytes: 0 });
|
|
61
|
+
|
|
62
|
+
const replay = replayPlan(lastEventId);
|
|
63
|
+
controller.enqueue(encoder.encode(
|
|
64
|
+
`retry: ${SSE_RETRY_MS}\nevent: connected\ndata: ${JSON.stringify({ connectionId: connId, replay })}\n\n`
|
|
65
|
+
));
|
|
66
|
+
replayEventsToConnection(connections.get(connId), replay);
|
|
67
|
+
},
|
|
68
|
+
cancel() {
|
|
69
|
+
removeConnection(connId);
|
|
70
|
+
},
|
|
56
71
|
},
|
|
57
|
-
|
|
58
|
-
|
|
72
|
+
{
|
|
73
|
+
highWaterMark: 0,
|
|
74
|
+
size: (chunk) => chunk?.byteLength ?? 0,
|
|
59
75
|
},
|
|
60
|
-
|
|
76
|
+
);
|
|
61
77
|
|
|
62
78
|
return new Response(stream, {
|
|
63
79
|
headers: {
|
|
@@ -85,12 +101,19 @@ function closeConnection(id: string) {
|
|
|
85
101
|
try { conn?.controller.close(); } catch {}
|
|
86
102
|
}
|
|
87
103
|
|
|
88
|
-
function sseFrame(event: string, data: unknown): Uint8Array {
|
|
89
|
-
|
|
104
|
+
function sseFrame(event: string, data: unknown, id?: number): Uint8Array {
|
|
105
|
+
const idLine = id !== undefined ? `id: ${id}\n` : "";
|
|
106
|
+
return encoder.encode(`${idLine}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function bufferedBytes(conn: Connection): number {
|
|
110
|
+
const desiredSize = conn.controller.desiredSize;
|
|
111
|
+
if (desiredSize === null) return conn.queuedBytes;
|
|
112
|
+
return Math.max(0, -desiredSize);
|
|
90
113
|
}
|
|
91
114
|
|
|
92
115
|
function isBackpressured(conn: Connection): boolean {
|
|
93
|
-
return conn
|
|
116
|
+
return bufferedBytes(conn) >= SSE_MAX_QUEUED_BYTES;
|
|
94
117
|
}
|
|
95
118
|
|
|
96
119
|
function sendEncoded(conn: Connection, chunk: Uint8Array) {
|
|
@@ -101,59 +124,66 @@ function sendEncoded(conn: Connection, chunk: Uint8Array) {
|
|
|
101
124
|
removeConnection(conn.id);
|
|
102
125
|
return;
|
|
103
126
|
}
|
|
104
|
-
|
|
105
|
-
// synchronous fanout (a batch loop emitting >SSE_MAX_QUEUED_CHUNKS frames)
|
|
106
|
-
// desiredSize falls on liveness, not staleness. Closing inline here would kill
|
|
107
|
-
// healthy, actively-read dashboards mid-burst. Instead defer the verdict: mark
|
|
108
|
-
// the stream and re-check on a later tick, after the runtime has had a chance to
|
|
109
|
-
// flush to the socket. Recovered → keep it; still stuck across ticks → drop it.
|
|
127
|
+
conn.queuedBytes = bufferedBytes(conn);
|
|
110
128
|
if (isBackpressured(conn)) scheduleDrainCheck(conn);
|
|
111
129
|
}
|
|
112
130
|
|
|
113
131
|
function scheduleDrainCheck(conn: Connection) {
|
|
114
132
|
if (conn.drainCheck) return; // one deferred verdict per stream is enough
|
|
133
|
+
conn.drainCheckQueuedBytes = bufferedBytes(conn);
|
|
115
134
|
conn.drainCheck = setTimeout(() => {
|
|
116
135
|
conn.drainCheck = undefined;
|
|
117
|
-
// The connection may already have been removed (cancel/close)
|
|
136
|
+
// The connection may already have been removed (cancel/close) during the grace —
|
|
118
137
|
// closeConnection is idempotent, but bail early so we never touch a stale ref.
|
|
119
138
|
if (connections.get(conn.id) !== conn) return;
|
|
120
|
-
|
|
121
|
-
|
|
139
|
+
const queuedBytes = bufferedBytes(conn);
|
|
140
|
+
conn.queuedBytes = queuedBytes;
|
|
141
|
+
if (queuedBytes < SSE_MAX_QUEUED_BYTES) {
|
|
142
|
+
conn.drainCheckQueuedBytes = undefined;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const drained = conn.drainCheckQueuedBytes !== undefined && queuedBytes < conn.drainCheckQueuedBytes;
|
|
146
|
+
conn.drainCheckQueuedBytes = undefined;
|
|
147
|
+
if (drained) {
|
|
148
|
+
scheduleDrainCheck(conn);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
122
151
|
if (isBackpressured(conn)) closeConnection(conn.id);
|
|
123
|
-
},
|
|
152
|
+
}, SSE_DRAIN_GRACE_MS);
|
|
124
153
|
}
|
|
125
154
|
|
|
126
|
-
function send(conn: Connection, event: string, data: unknown) {
|
|
127
|
-
sendEncoded(conn, sseFrame(event, data));
|
|
155
|
+
function send(conn: Connection, event: string, data: unknown, id?: number) {
|
|
156
|
+
sendEncoded(conn, sseFrame(event, data, id));
|
|
128
157
|
}
|
|
129
158
|
|
|
130
159
|
function fanout(event: RelayEvent): void {
|
|
131
160
|
if (event.type === "message.new") {
|
|
132
|
-
sendNewMessage(event.data as unknown as Message);
|
|
161
|
+
sendNewMessage(event.data as unknown as Message, event.seq);
|
|
133
162
|
return;
|
|
134
163
|
}
|
|
135
164
|
if (event.type === "task.updated" || event.type === "task.created" || event.type === "task.claimed" || event.type === "task.status") {
|
|
136
|
-
sendTaskChanged(event.data as unknown as Task, event.type);
|
|
165
|
+
sendTaskChanged(event.data as unknown as Task, event.type, event.seq);
|
|
137
166
|
return;
|
|
138
167
|
}
|
|
139
|
-
const chunk = sseFrame(event.type, event.data);
|
|
168
|
+
const chunk = sseFrame(event.type, event.data, event.seq);
|
|
140
169
|
for (const conn of connections.values()) {
|
|
141
170
|
sendEncoded(conn, chunk);
|
|
142
171
|
}
|
|
143
172
|
}
|
|
144
173
|
|
|
145
|
-
function sendNewMessage(msg: Message): void {
|
|
174
|
+
function sendNewMessage(msg: Message, seq?: number): void {
|
|
146
175
|
const scopedBroadcastAgentIds = msg.to === "broadcast"
|
|
147
176
|
? new Set(matchingDeliveryAgents("broadcast", { from: msg.from, channel: msg.channel }).map((agent) => agent.id))
|
|
148
177
|
: null;
|
|
149
|
-
const chunk = sseFrame("message.new", msg);
|
|
178
|
+
const chunk = sseFrame("message.new", msg, seq);
|
|
179
|
+
const agentCache = new Map<string, AgentCard | null>();
|
|
150
180
|
for (const conn of connections.values()) {
|
|
151
181
|
if (!conn.agentId) {
|
|
152
182
|
sendEncoded(conn, chunk);
|
|
153
183
|
continue;
|
|
154
184
|
}
|
|
155
185
|
if (scopedBroadcastAgentIds && !scopedBroadcastAgentIds.has(conn.agentId)) continue;
|
|
156
|
-
if (!messageMatchesAgent(msg, conn.agentId,
|
|
186
|
+
if (!messageMatchesAgent(msg, conn.agentId, fanoutAgent(conn.agentId, agentCache))) continue;
|
|
157
187
|
sendEncoded(conn, chunk);
|
|
158
188
|
}
|
|
159
189
|
}
|
|
@@ -261,14 +291,69 @@ export function emitMessageReactionUpdated(msg: Message) {
|
|
|
261
291
|
emitRelayEvent({ type: "message.reaction_updated", source: "server", subject: String(msg.id), data: msg as unknown as Record<string, unknown> });
|
|
262
292
|
}
|
|
263
293
|
|
|
264
|
-
function sendTaskChanged(task: Task, eventType = "task.updated"): void {
|
|
265
|
-
const chunk = sseFrame(eventType, task);
|
|
294
|
+
function sendTaskChanged(task: Task, eventType = "task.updated", seq?: number): void {
|
|
295
|
+
const chunk = sseFrame(eventType, task, seq);
|
|
296
|
+
const agentCache = new Map<string, AgentCard | null>();
|
|
266
297
|
for (const conn of connections.values()) {
|
|
267
|
-
if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId,
|
|
298
|
+
if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId, fanoutAgent(conn.agentId, agentCache))) continue;
|
|
268
299
|
sendEncoded(conn, chunk);
|
|
269
300
|
}
|
|
270
301
|
}
|
|
271
302
|
|
|
303
|
+
interface ReplayPlan {
|
|
304
|
+
requested: boolean;
|
|
305
|
+
since: number;
|
|
306
|
+
available: boolean;
|
|
307
|
+
gap: boolean;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function replayPlan(lastEventId: number): ReplayPlan {
|
|
311
|
+
const since = Number.isSafeInteger(lastEventId) && lastEventId > 0 ? lastEventId : 0;
|
|
312
|
+
if (since === 0) return { requested: false, since: 0, available: false, gap: false };
|
|
313
|
+
const oldest = getOldestOutboxCursor();
|
|
314
|
+
const gap = oldest > 0 && oldest > since + 1;
|
|
315
|
+
return { requested: true, since, available: !gap, gap };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function replayEventsToConnection(conn: Connection | undefined, plan: ReplayPlan): void {
|
|
319
|
+
if (!conn || !plan.requested) return;
|
|
320
|
+
if (plan.gap) {
|
|
321
|
+
send(conn, "replay.gap", { since: plan.since }, getOutboxCursor());
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const agentCache = new Map<string, AgentCard | null>();
|
|
325
|
+
for (const event of replayEvents(plan.since)) {
|
|
326
|
+
sendReplayEvent(conn, outboxToRelayEvent(event), agentCache);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function sendReplayEvent(conn: Connection, event: RelayEvent, agentCache: Map<string, AgentCard | null>): void {
|
|
331
|
+
if (event.type === "message.new" && conn.agentId) {
|
|
332
|
+
const msg = event.data as unknown as Message;
|
|
333
|
+
if (!messageMatchesAgent(msg, conn.agentId, fanoutAgent(conn.agentId, agentCache))) return;
|
|
334
|
+
send(conn, "message.new", msg, event.seq);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (event.type.startsWith("task.") && conn.agentId) {
|
|
338
|
+
const task = event.data as unknown as Task;
|
|
339
|
+
if (!targetMatchesAgent(task.target, conn.agentId, fanoutAgent(conn.agentId, agentCache))) return;
|
|
340
|
+
send(conn, event.type, task, event.seq);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
send(conn, event.type, event.data, event.seq);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function outboxToRelayEvent(event: BusEvent): RelayEvent {
|
|
347
|
+
return {
|
|
348
|
+
seq: event.seq,
|
|
349
|
+
type: event.eventType,
|
|
350
|
+
source: event.source,
|
|
351
|
+
subject: event.subject,
|
|
352
|
+
data: event.data,
|
|
353
|
+
timestamp: event.timestamp,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
272
357
|
export function emitTaskChanged(task: Task, eventType = "task.updated") {
|
|
273
358
|
emitRelayEvent({ type: eventType, source: task.source, subject: String(task.id), data: task as unknown as Record<string, unknown> });
|
|
274
359
|
}
|
package/src/steward.ts
CHANGED
|
@@ -12,10 +12,11 @@ import type { Orchestrator, SpawnPolicy, StewardConfig } from "./types";
|
|
|
12
12
|
import { isPathWithinBase } from "./utils";
|
|
13
13
|
|
|
14
14
|
/** Stable, readable, collision-resistant policy name for a repo's steward. */
|
|
15
|
-
export function repoStewardPolicyName(repoRoot: string): string {
|
|
15
|
+
export function repoStewardPolicyName(repoRoot: string, orchestratorId?: string): string {
|
|
16
16
|
const slug = basename(repoRoot).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
|
|
17
17
|
const hash = createHash("sha1").update(resolve(repoRoot)).digest("hex").slice(0, 8);
|
|
18
|
-
|
|
18
|
+
const owner = orchestratorId?.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
19
|
+
return owner ? `steward-${slug}-${hash}-${owner}` : `steward-${slug}-${hash}`;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -54,7 +55,7 @@ function desiredStewardPolicy(repoRoot: string, owner: Orchestrator, config: Ste
|
|
|
54
55
|
const resolved = resolveInternalSpawnTarget("landing-steward", pin);
|
|
55
56
|
|
|
56
57
|
return {
|
|
57
|
-
name: repoStewardPolicyName(repoRoot),
|
|
58
|
+
name: repoStewardPolicyName(repoRoot, owner.id),
|
|
58
59
|
description: `Autonomous repo steward for ${repoRoot} (issue #167).`,
|
|
59
60
|
enabled: true,
|
|
60
61
|
orchestratorId: owner.id,
|
|
@@ -118,13 +119,16 @@ function stewardPolicyDiffers(existing: SpawnPolicy, desired: SpawnPolicy): bool
|
|
|
118
119
|
* owns the repo. Idempotent — refreshes the policy only when config-derived fields
|
|
119
120
|
* change, so it can be called on every sweep without churn.
|
|
120
121
|
*/
|
|
121
|
-
export function ensureRepoSteward(repoRoot: string): string | null {
|
|
122
|
+
export function ensureRepoSteward(repoRoot: string, owningOrchestrator?: Orchestrator): string | null {
|
|
122
123
|
const config = getStewardConfig();
|
|
123
124
|
if (!config.enabled) return null;
|
|
124
|
-
const owner = listOrchestrators().find((orch) =>
|
|
125
|
+
const owner = owningOrchestrator ?? listOrchestrators().find((orch) =>
|
|
126
|
+
orch.status === "online" && isPathWithinBase(repoRoot, orch.baseDir)
|
|
127
|
+
);
|
|
128
|
+
if (owner && (owner.status !== "online" || !isPathWithinBase(repoRoot, owner.baseDir))) return null;
|
|
125
129
|
if (!owner) return null;
|
|
126
130
|
|
|
127
|
-
const name = repoStewardPolicyName(repoRoot);
|
|
131
|
+
const name = repoStewardPolicyName(repoRoot, owner.id);
|
|
128
132
|
const desired = desiredStewardPolicy(repoRoot, owner, config);
|
|
129
133
|
const existing = getSpawnPolicy(name);
|
|
130
134
|
if (existing && !stewardPolicyDiffers(existing.value, desired)) return name;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { getOrchestrator } from "./db";
|
|
2
|
+
import { getIntegrationAuth, isFullReachRequest, isRequestAuthorizedFor } from "./security";
|
|
3
|
+
|
|
4
|
+
type TerminalAccessMode = "read" | "write";
|
|
5
|
+
|
|
6
|
+
interface TerminalOwner {
|
|
7
|
+
agentId: string;
|
|
8
|
+
policyName?: string;
|
|
9
|
+
spawnRequestId?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hasPresentedCredential(req: Request): boolean {
|
|
13
|
+
return Boolean(req.headers.get("authorization") || req.headers.get("x-agent-relay-token") || new URL(req.url).searchParams.get("token"));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function terminalOwner(orchestratorId: string, session: string): TerminalOwner | null {
|
|
17
|
+
const orch = getOrchestrator(orchestratorId);
|
|
18
|
+
if (!orch) return null;
|
|
19
|
+
for (const agent of orch.managedAgents ?? []) {
|
|
20
|
+
const sessions = [agent.terminalSession, agent.tmuxSession, agent.sessionName].filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
21
|
+
if (!sessions.includes(session)) continue;
|
|
22
|
+
if (!agent.agentId) return null;
|
|
23
|
+
return {
|
|
24
|
+
agentId: agent.agentId,
|
|
25
|
+
...(agent.policyName ? { policyName: agent.policyName } : {}),
|
|
26
|
+
...(agent.spawnRequestId ? { spawnRequestId: agent.spawnRequestId } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isTerminalSessionRequestAuthorized(req: Request, orchestratorId: string, session: string, mode: TerminalAccessMode): boolean {
|
|
33
|
+
if (!hasPresentedCredential(req)) return true;
|
|
34
|
+
if (isFullReachRequest(req)) return true;
|
|
35
|
+
if (getIntegrationAuth(req)) return false;
|
|
36
|
+
const owner = terminalOwner(orchestratorId, session);
|
|
37
|
+
if (!owner) return false;
|
|
38
|
+
const terminalCapability = mode === "read" ? { terminalRead: true } : { terminalWrite: true };
|
|
39
|
+
return isRequestAuthorizedFor(req, {
|
|
40
|
+
scope: mode === "read" ? "terminal:read" : "terminal:write",
|
|
41
|
+
resource: {
|
|
42
|
+
orchestratorId,
|
|
43
|
+
agentId: owner.agentId,
|
|
44
|
+
policyName: owner.policyName,
|
|
45
|
+
spawnRequestId: owner.spawnRequestId,
|
|
46
|
+
terminalOwner: true,
|
|
47
|
+
...terminalCapability,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
package/src/token-db.ts
CHANGED
|
@@ -67,8 +67,8 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
67
67
|
name: "Orchestrator Host",
|
|
68
68
|
description: "Host supervisor access for spawn, command, log, and terminal operations.",
|
|
69
69
|
role: "orchestrator",
|
|
70
|
-
scope: ["agent:read", "agent:write", "command:read", "command:write", "task:read", "task:write", "logs:read", "terminal:attach", "quota:write", "provisioning:read"],
|
|
71
|
-
constraints: { logsRead: true, terminalAttach: true },
|
|
70
|
+
scope: ["agent:read", "agent:write", "command:read", "command:write", "task:read", "task:write", "logs:read", "terminal:attach", "terminal:read", "terminal:write", "quota:write", "provisioning:read"],
|
|
71
|
+
constraints: { logsRead: true, terminalAttach: true, terminalRead: true, terminalWrite: true },
|
|
72
72
|
ttlSeconds: 30 * 24 * 60 * 60,
|
|
73
73
|
builtIn: true,
|
|
74
74
|
createdBy: "system",
|
|
@@ -100,7 +100,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
100
100
|
description: "User-launched provider runtime access constrained to its own agent and cwd for long interactive sessions.",
|
|
101
101
|
role: "provider",
|
|
102
102
|
scope: ["agent:read", "agent:write", "message:read", "message:send", "schedule:write", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "project:read", "project:write", "issue:read", "issue:write", "notifications:read", "notifications:write", "channel:egress", "insights:write", "quota:write"],
|
|
103
|
-
constraints: { terminalAttach: false, logsRead: false, canDelegate: false },
|
|
103
|
+
constraints: { terminalAttach: false, terminalRead: false, terminalWrite: false, logsRead: false, canDelegate: false },
|
|
104
104
|
ttlSeconds: 30 * 24 * 60 * 60,
|
|
105
105
|
builtIn: true,
|
|
106
106
|
createdBy: "system",
|
package/src/validation.ts
CHANGED
|
@@ -109,7 +109,7 @@ export function cleanTokenConstraints(value: unknown): TokenConstraints | undefi
|
|
|
109
109
|
if (teamId) constraints.teamId = teamId;
|
|
110
110
|
const tenantId = cleanString(value.tenantId, "constraints.tenantId", { max: 120 });
|
|
111
111
|
if (tenantId) constraints.tenantId = tenantId;
|
|
112
|
-
for (const key of ["terminalAttach", "logsRead", "canDelegate"] as const) {
|
|
112
|
+
for (const key of ["terminalAttach", "terminalRead", "terminalWrite", "logsRead", "canDelegate"] as const) {
|
|
113
113
|
if (value[key] !== undefined) {
|
|
114
114
|
if (typeof value[key] !== "boolean") throw new ValidationError(`constraints.${key} must be a boolean`);
|
|
115
115
|
constraints[key] = value[key];
|
package/src/workspace-actions.ts
CHANGED
|
@@ -26,8 +26,9 @@ import { isOwnerAlive, requestWorkspaceMerge } from "./workspace-merge";
|
|
|
26
26
|
// tuple as the HTTP route without a second import hop (one source: workspace-merge, #304).
|
|
27
27
|
export { LAND_STRATEGIES, DEFAULT_MERGE_STRATEGY } from "./workspace-merge";
|
|
28
28
|
import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
|
|
29
|
-
import { READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
29
|
+
import { DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES, worktreeReapable } from "./workspace-phase";
|
|
30
30
|
import { CLEAR_MERGE_NO_PROGRESS, scheduleRepoMerge } from "./workspace-auto-merge";
|
|
31
|
+
import { fetchHostMergePreview } from "./workspace-probe";
|
|
31
32
|
import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
32
33
|
|
|
33
34
|
// Single source of truth for the action verb set. The route's `optionalEnum` and
|
|
@@ -129,7 +130,28 @@ function recordWorkspaceAudit(a: {
|
|
|
129
130
|
}
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
|
|
133
|
+
function metadataNumber(metadata: Record<string, unknown>, key: string): number | undefined {
|
|
134
|
+
const value = metadata[key];
|
|
135
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function cleanupWorkSafety(workspace: WorkspaceRecord): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
139
|
+
const owner = workspaceOwnerOrchestrator(workspace, listOrchestrators(), { requireApiUrl: true });
|
|
140
|
+
if (!owner?.apiUrl) return { ok: false, error: "owning orchestrator unavailable for cleanup safety probe" };
|
|
141
|
+
const preview = await fetchHostMergePreview(owner.apiUrl, workspace, { checkPr: false });
|
|
142
|
+
if (!preview) return { ok: false, error: "cleanup safety probe unavailable" };
|
|
143
|
+
if ("available" in preview && preview.available === false) return { ok: false, error: "cleanup safety probe unavailable" };
|
|
144
|
+
const p = preview as Exclude<typeof preview, { available: false }>;
|
|
145
|
+
if (p.missing) return { ok: true };
|
|
146
|
+
if (p.error) return { ok: false, error: `cleanup safety probe error: ${p.error}` };
|
|
147
|
+
if (worktreeReapable({ landed: p.landed, ahead: p.ahead, unmergedAhead: p.unmergedAhead, dirtyCount: p.dirtyCount })) return { ok: true };
|
|
148
|
+
const dirtyCount = p.dirtyCount ?? 0;
|
|
149
|
+
if (dirtyCount > 0) return { ok: false, error: `${dirtyCount} uncommitted change(s)` };
|
|
150
|
+
const ahead = p.unmergedAhead ?? p.ahead ?? 0;
|
|
151
|
+
return { ok: false, error: `${ahead} unlanded commit(s)` };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWorkspaceActionInput): Promise<WorkspaceActionResult> {
|
|
133
155
|
const { action } = input;
|
|
134
156
|
const agentId = input.agentId;
|
|
135
157
|
const detail = input.detail;
|
|
@@ -228,6 +250,17 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
|
|
|
228
250
|
const nextStatus = STATUS_BY_ACTION[action];
|
|
229
251
|
if (!nextStatus) return { ok: false, httpStatus: 400, error: `unsupported action: ${action}` };
|
|
230
252
|
|
|
253
|
+
if (READY_TO_LAND_STATUSES.has(nextStatus)) {
|
|
254
|
+
const dirtyCount = metadataNumber(workspace.metadata, "gitDirtyCount") ?? 0;
|
|
255
|
+
if (dirtyCount > 0) {
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
httpStatus: 409,
|
|
259
|
+
error: `workspace ${workspace.id} cannot be marked ${nextStatus}: ${DIRTY_WORKTREE_LAND_SKIP_REASON} (${dirtyCount} dirty file${dirtyCount === 1 ? "" : "s"}); commit or stash first`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
231
264
|
if (
|
|
232
265
|
action === "cleanup" &&
|
|
233
266
|
input.force !== true &&
|
|
@@ -245,7 +278,15 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
|
|
|
245
278
|
let command: Command | undefined;
|
|
246
279
|
if (requiresCommand) {
|
|
247
280
|
// Only `cleanup` reaches here — `merge` returned early via the shared helper.
|
|
248
|
-
const
|
|
281
|
+
const safety = await cleanupWorkSafety(workspace);
|
|
282
|
+
if (!safety.ok && !(input.force === true && Boolean(detail?.trim()))) {
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
httpStatus: 409,
|
|
286
|
+
error: `workspace ${workspace.id} cannot be cleaned up safely: ${safety.error}; pass force:true with a detail reason only if intentionally discarding work`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard", { force: input.force === true, reason: detail });
|
|
249
290
|
if (!built.ok) return { ok: false, httpStatus: built.status, error: built.error };
|
|
250
291
|
command = built.command;
|
|
251
292
|
}
|
|
@@ -287,7 +328,7 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
|
|
|
287
328
|
export function buildWorkspaceCleanupCommand(
|
|
288
329
|
workspace: WorkspaceRecord,
|
|
289
330
|
requestedBy: string,
|
|
290
|
-
opts: { force?: boolean } = {},
|
|
331
|
+
opts: { force?: boolean; reason?: string } = {},
|
|
291
332
|
): { ok: true; command: Command } | { ok: false; status: number; error: string } {
|
|
292
333
|
if (
|
|
293
334
|
opts.force !== true &&
|
|
@@ -319,6 +360,7 @@ export function buildWorkspaceCleanupCommand(
|
|
|
319
360
|
requestedBy,
|
|
320
361
|
requestedAt: Date.now(),
|
|
321
362
|
deleteBranch: true,
|
|
363
|
+
...(opts.force === true && opts.reason ? { force: true, reason: opts.reason } : {}),
|
|
322
364
|
queued: false,
|
|
323
365
|
},
|
|
324
366
|
});
|