agent-relay-server 0.119.0 → 0.119.2
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 +6 -5
- package/public/assets/{MessageBubble-BdnzeQjY.js → MessageBubble-DbaaemH3.js} +2 -2
- package/public/assets/{MessageBubble-BdnzeQjY.js.map → MessageBubble-DbaaemH3.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-sG8nqUHw.js → PlanGraphPanel-C_hnK9yH.js} +2 -2
- package/public/assets/{PlanGraphPanel-sG8nqUHw.js.map → PlanGraphPanel-C_hnK9yH.js.map} +1 -1
- package/public/assets/{activity-CZpvbkAW.js → activity-Cbw43afW.js} +2 -2
- package/public/assets/{activity-CZpvbkAW.js.map → activity-Cbw43afW.js.map} +1 -1
- package/public/assets/{agent-profiles-BfZgLPvN.js → agent-profiles-BZZpTAZn.js} +2 -2
- package/public/assets/{agent-profiles-BfZgLPvN.js.map → agent-profiles-BZZpTAZn.js.map} +1 -1
- package/public/assets/{agent-type-icon-CG2v7tF0.js → agent-type-icon-2YWBWhYC.js} +2 -2
- package/public/assets/{agent-type-icon-CG2v7tF0.js.map → agent-type-icon-2YWBWhYC.js.map} +1 -1
- package/public/assets/{agents-DSOCIAV6.js → agents-nvHlibqo.js} +2 -2
- package/public/assets/{agents-DSOCIAV6.js.map → agents-nvHlibqo.js.map} +1 -1
- package/public/assets/{analytics-C3vhiVvA.js → analytics-B9bbDReP.js} +2 -2
- package/public/assets/{analytics-C3vhiVvA.js.map → analytics-B9bbDReP.js.map} +1 -1
- package/public/assets/{automation-RkZtUqxK.js → automation-CjtLgS3h.js} +2 -2
- package/public/assets/{automation-RkZtUqxK.js.map → automation-CjtLgS3h.js.map} +1 -1
- package/public/assets/{branch-state-badge-BG7WdFNG.js → branch-state-badge-DRFIs9q0.js} +2 -2
- package/public/assets/{branch-state-badge-BG7WdFNG.js.map → branch-state-badge-DRFIs9q0.js.map} +1 -1
- package/public/assets/{channels-CF3EsyIK.js → channels-CL00bLDh.js} +2 -2
- package/public/assets/{channels-CF3EsyIK.js.map → channels-CL00bLDh.js.map} +1 -1
- package/public/assets/{chat-E5MI0WxW.js → chat-DKf_9fgB.js} +2 -2
- package/public/assets/{chat-E5MI0WxW.js.map → chat-DKf_9fgB.js.map} +1 -1
- package/public/assets/{connectors-CitVhhON.js → connectors-QYtScfNz.js} +2 -2
- package/public/assets/{connectors-CitVhhON.js.map → connectors-QYtScfNz.js.map} +1 -1
- package/public/assets/{display-_5kFrHJh.js → display-CP3jEw1H.js} +2 -2
- package/public/assets/{display-_5kFrHJh.js.map → display-CP3jEw1H.js.map} +1 -1
- package/public/assets/{formatted-body-BnlRQjBi.js → formatted-body-BnSiFCc3.js} +3 -3
- package/public/assets/{formatted-body-BnlRQjBi.js.map → formatted-body-BnSiFCc3.js.map} +1 -1
- package/public/assets/{formatted-body-impl-9iNv975q.js → formatted-body-impl-Cnchy-Iv.js} +2 -2
- package/public/assets/{formatted-body-impl-9iNv975q.js.map → formatted-body-impl-Cnchy-Iv.js.map} +1 -1
- package/public/assets/{index-CUugubHc.js → index-kelZvgcV.js} +6 -6
- package/public/assets/{index-CUugubHc.js.map → index-kelZvgcV.js.map} +1 -1
- package/public/assets/{insights-D0bcKaNd.js → insights-Ccz5zFIQ.js} +2 -2
- package/public/assets/{insights-D0bcKaNd.js.map → insights-Ccz5zFIQ.js.map} +1 -1
- package/public/assets/{integrations-CGVHvoEv.js → integrations-BICEazmP.js} +2 -2
- package/public/assets/{integrations-CGVHvoEv.js.map → integrations-BICEazmP.js.map} +1 -1
- package/public/assets/{maintenance-C0vzzP7r.js → maintenance-DpYnmrVo.js} +2 -2
- package/public/assets/{maintenance-C0vzzP7r.js.map → maintenance-DpYnmrVo.js.map} +1 -1
- package/public/assets/{managed-agents-DUkT3MHx.js → managed-agents-qQtr531p.js} +2 -2
- package/public/assets/{managed-agents-DUkT3MHx.js.map → managed-agents-qQtr531p.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-CDHHjZr3.js → markdown-preview-impl-DhkWrgsW.js} +2 -2
- package/public/assets/{markdown-preview-impl-CDHHjZr3.js.map → markdown-preview-impl-DhkWrgsW.js.map} +1 -1
- package/public/assets/{memory-hrJcJKdD.js → memory-AsWF2gfa.js} +2 -2
- package/public/assets/{memory-hrJcJKdD.js.map → memory-AsWF2gfa.js.map} +1 -1
- package/public/assets/{messages-C3YD6xJ3.js → messages-D0vT-ovM.js} +2 -2
- package/public/assets/{messages-C3YD6xJ3.js.map → messages-D0vT-ovM.js.map} +1 -1
- package/public/assets/{orchestrators-CshVnsUu.js → orchestrators-Oo8U_GW2.js} +2 -2
- package/public/assets/{orchestrators-CshVnsUu.js.map → orchestrators-Oo8U_GW2.js.map} +1 -1
- package/public/assets/{overview-DEtzai33.js → overview-UTAK7pAB.js} +2 -2
- package/public/assets/{overview-DEtzai33.js.map → overview-UTAK7pAB.js.map} +1 -1
- package/public/assets/{pairs-DFpYOOlU.js → pairs-TZYezwEi.js} +2 -2
- package/public/assets/{pairs-DFpYOOlU.js.map → pairs-TZYezwEi.js.map} +1 -1
- package/public/assets/{projects-BAi5dytW.js → projects-ClgQ10Al.js} +2 -2
- package/public/assets/{projects-BAi5dytW.js.map → projects-ClgQ10Al.js.map} +1 -1
- package/public/assets/{prompt-settings-jpRG8tAv.js → prompt-settings-Tia685AV.js} +2 -2
- package/public/assets/{prompt-settings-jpRG8tAv.js.map → prompt-settings-Tia685AV.js.map} +1 -1
- package/public/assets/{registry-DFiSS4hB.js → registry-CAXZWvzr.js} +2 -2
- package/public/assets/{registry-DFiSS4hB.js.map → registry-CAXZWvzr.js.map} +1 -1
- package/public/assets/{security-D5tkecYh.js → security-BDAMCUzN.js} +2 -2
- package/public/assets/{security-D5tkecYh.js.map → security-BDAMCUzN.js.map} +1 -1
- package/public/assets/{select-DctZnR2Q.js → select-ytVYIxZP.js} +2 -2
- package/public/assets/{select-DctZnR2Q.js.map → select-ytVYIxZP.js.map} +1 -1
- package/public/assets/{settings-NlRG3Wvf.js → settings-CnalH4Zd.js} +2 -2
- package/public/assets/{settings-NlRG3Wvf.js.map → settings-CnalH4Zd.js.map} +1 -1
- package/public/assets/store-BDCUnpt5.js +9 -0
- package/public/assets/store-BDCUnpt5.js.map +1 -0
- package/public/assets/{tasks-Q9e41WzI.js → tasks-CHGO51En.js} +2 -2
- package/public/assets/{tasks-Q9e41WzI.js.map → tasks-CHGO51En.js.map} +1 -1
- package/public/assets/{teams-pyH9kHhS.js → teams-DDL-I-pP.js} +2 -2
- package/public/assets/{teams-pyH9kHhS.js.map → teams-DDL-I-pP.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-RPC_OOk0.js → terminal-viewer-impl-CnXpHy1R.js} +2 -2
- package/public/assets/{terminal-viewer-impl-RPC_OOk0.js.map → terminal-viewer-impl-CnXpHy1R.js.map} +1 -1
- package/public/assets/types-8ThujPP2.js +2 -0
- package/public/assets/types-8ThujPP2.js.map +1 -0
- package/public/assets/{user-avatar-Bd4txMth.js → user-avatar-Cw7jcQY-.js} +2 -2
- package/public/assets/{user-avatar-Bd4txMth.js.map → user-avatar-Cw7jcQY-.js.map} +1 -1
- package/public/assets/{work-queue-CWLzjgBV.js → work-queue-BwX92bVS.js} +2 -2
- package/public/assets/{work-queue-CWLzjgBV.js.map → work-queue-BwX92bVS.js.map} +1 -1
- package/public/assets/{workspaces-BV9-9JQr.js → workspaces-DSsvsOFa.js} +2 -2
- package/public/assets/{workspaces-BV9-9JQr.js.map → workspaces-DSsvsOFa.js.map} +1 -1
- package/public/index.html +6 -6
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/claude-statusline-probe.cjs +134 -0
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +14 -3
- package/src/cli/context-probe.ts +9 -7
- package/src/db/migrations.ts +2 -0
- package/src/db/schema.ts +3 -0
- package/src/index.ts +10 -434
- package/src/issue-tracker/github.ts +45 -10
- package/src/routing-policy-store.ts +5 -5
- package/src/server.ts +433 -0
- package/src/spawn-targets.ts +12 -4
- package/public/assets/store-D2BjyanL.js +0 -9
- package/public/assets/store-D2BjyanL.js.map +0 -1
- package/public/assets/types-DSwhOFcJ.js +0 -2
- package/public/assets/types-DSwhOFcJ.js.map +0 -1
|
@@ -71,7 +71,7 @@ var claudeProviderManifest = {
|
|
|
71
71
|
}
|
|
72
72
|
},
|
|
73
73
|
catalog: {
|
|
74
|
-
defaultModel: "sonnet-
|
|
74
|
+
defaultModel: "sonnet-5",
|
|
75
75
|
models: [
|
|
76
76
|
{ alias: "fable-5", label: "Fable 5", providerModel: "claude-fable-5", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", unavailable: "suspended by export-control directive, 2026-06-12", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
|
|
77
77
|
{ alias: "opus-4.8", label: "Opus 4.8", providerModel: "claude-opus-4-8", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
|
|
@@ -80,8 +80,8 @@ var claudeProviderManifest = {
|
|
|
80
80
|
{ alias: "opus-4.7[1m]", label: "Opus 4.7 1M", providerModel: "claude-opus-4-7[1m]", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
|
|
81
81
|
{ alias: "opus-4.6", label: "Opus 4.6", providerModel: "claude-opus-4-6", efforts: CLAUDE_LOW_TO_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
|
|
82
82
|
{ alias: "opus-4.6[1m]", label: "Opus 4.6 1M", providerModel: "claude-opus-4-6[1m]", efforts: CLAUDE_LOW_TO_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
|
|
83
|
-
{ alias: "sonnet-
|
|
84
|
-
{ alias: "sonnet-
|
|
83
|
+
{ alias: "sonnet-5", label: "Sonnet 5", providerModel: "claude-sonnet-5", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
|
|
84
|
+
{ alias: "sonnet-5[1m]", label: "Sonnet 5 1M", providerModel: "claude-sonnet-5[1m]", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
|
|
85
85
|
{ alias: "haiku", label: "Haiku", providerModel: "haiku", efforts: [], ...codeModel() }
|
|
86
86
|
]
|
|
87
87
|
},
|
|
@@ -344,6 +344,10 @@ function relayTokenFromEnv() {
|
|
|
344
344
|
}
|
|
345
345
|
// sdk/src/bus-client.ts
|
|
346
346
|
import { EventEmitter } from "events";
|
|
347
|
+
function unrefTimer(timer) {
|
|
348
|
+
timer?.unref?.();
|
|
349
|
+
}
|
|
350
|
+
|
|
347
351
|
class MemoryCursorStore {
|
|
348
352
|
value = null;
|
|
349
353
|
async load() {
|
|
@@ -655,6 +659,7 @@ class RelayBusClient extends EventEmitter {
|
|
|
655
659
|
this.heartbeat = setInterval(() => {
|
|
656
660
|
this.sendHeartbeat();
|
|
657
661
|
}, this.options.heartbeatIntervalMs ?? 30000);
|
|
662
|
+
unrefTimer(this.heartbeat);
|
|
658
663
|
}
|
|
659
664
|
stopHeartbeat() {
|
|
660
665
|
if (this.heartbeat)
|
|
@@ -667,6 +672,7 @@ class RelayBusClient extends EventEmitter {
|
|
|
667
672
|
this.stopHeartbeatWatchdog();
|
|
668
673
|
const intervalMs = Math.max(100, Math.min(5000, this.heartbeatIntervalMs()));
|
|
669
674
|
this.heartbeatWatchdog = setInterval(() => this.checkHeartbeatAck(), intervalMs);
|
|
675
|
+
unrefTimer(this.heartbeatWatchdog);
|
|
670
676
|
}
|
|
671
677
|
stopHeartbeatWatchdog() {
|
|
672
678
|
if (this.heartbeatWatchdog)
|
|
@@ -753,6 +759,10 @@ class RelayBusClient extends EventEmitter {
|
|
|
753
759
|
}
|
|
754
760
|
}
|
|
755
761
|
// sdk/src/provider-base.ts
|
|
762
|
+
function unrefTimer2(timer) {
|
|
763
|
+
timer?.unref?.();
|
|
764
|
+
}
|
|
765
|
+
|
|
756
766
|
class ProviderBase {
|
|
757
767
|
options;
|
|
758
768
|
client;
|
|
@@ -814,6 +824,7 @@ class ProviderBase {
|
|
|
814
824
|
this.client.status({ agentStatus: "online", ready: false });
|
|
815
825
|
}
|
|
816
826
|
}, 30000);
|
|
827
|
+
unrefTimer2(this.drain);
|
|
817
828
|
}
|
|
818
829
|
}
|
|
819
830
|
// sdk/src/claim-tracker.ts
|
package/src/cli/context-probe.ts
CHANGED
|
@@ -41,13 +41,15 @@ export async function handleContextProbeCommand(args: string[]): Promise<void> {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
if (printStatusLine) {
|
|
44
|
-
const command =
|
|
45
|
-
"agent-relay"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
const command = wrapRequested && !wrapCommand && !agentId && !stateDir
|
|
45
|
+
? "agent-relay-claude-statusline"
|
|
46
|
+
: [
|
|
47
|
+
"agent-relay",
|
|
48
|
+
"context-probe",
|
|
49
|
+
...(wrapRequested ? ["--wrap", ...(wrapCommand ? [shellQuote(wrapCommand)] : [])] : ["--standalone"]),
|
|
50
|
+
...(agentId ? ["--agent-id", shellQuote(agentId)] : []),
|
|
51
|
+
...(stateDir ? ["--state-dir", shellQuote(stateDir)] : []),
|
|
52
|
+
].join(" ");
|
|
51
53
|
console.log(command);
|
|
52
54
|
return;
|
|
53
55
|
}
|
package/src/db/migrations.ts
CHANGED
|
@@ -276,6 +276,7 @@ export function applyMigrations(): void {
|
|
|
276
276
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
|
|
277
277
|
getDb().run("CREATE UNIQUE INDEX IF NOT EXISTS idx_msg_idempotency ON messages(from_agent, idempotency_key) WHERE idempotency_key IS NOT NULL");
|
|
278
278
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_delivery_status ON messages(delivery_status)");
|
|
279
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_claimed_expires ON messages(claimed_by, claim_expires_at, id) WHERE claimed_by IS NOT NULL");
|
|
279
280
|
// (#846) composite replaces the old single-column idx_msg_resolved_to_agent; created
|
|
280
281
|
// here — AFTER the resolved_to_agent ALTER above — so existing DBs without the column
|
|
281
282
|
// don't crash (same hazard as idx_msg_from_kind_id / #483/#559).
|
|
@@ -1076,6 +1077,7 @@ export function applyMigrations(): void {
|
|
|
1076
1077
|
addTaskCol("owner_agent_label", "owner_agent_label TEXT");
|
|
1077
1078
|
addTaskCol("dispatch", "dispatch TEXT");
|
|
1078
1079
|
addTaskCol("reaped_at", "reaped_at INTEGER");
|
|
1080
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_tasks_message_status ON tasks(message_id, status) WHERE message_id IS NOT NULL");
|
|
1079
1081
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_tasks_kind ON tasks(kind, status, updated_at)");
|
|
1080
1082
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_tasks_branch ON tasks(branch) WHERE branch IS NOT NULL");
|
|
1081
1083
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_tasks_team ON tasks(team_id) WHERE team_id IS NOT NULL");
|
package/src/db/schema.ts
CHANGED
|
@@ -672,6 +672,9 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
672
672
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
673
673
|
CREATE INDEX IF NOT EXISTS idx_tasks_target ON tasks(target);
|
|
674
674
|
CREATE INDEX IF NOT EXISTS idx_tasks_updated ON tasks(updated_at);
|
|
675
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_message_status
|
|
676
|
+
ON tasks(message_id, status)
|
|
677
|
+
WHERE message_id IS NOT NULL;
|
|
675
678
|
-- idx_tasks_kind / idx_tasks_branch / idx_tasks_team reference the #834 columns and are
|
|
676
679
|
-- created in applyMigrations() AFTER the ALTERs — never inline here: an existing pre-#834
|
|
677
680
|
-- tasks table makes CREATE TABLE IF NOT EXISTS a no-op, so an inline index over a column
|
package/src/index.ts
CHANGED
|
@@ -1,443 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import
|
|
3
|
-
import { initDb, getOrchestrator } from "./db";
|
|
4
|
-
import { matchRoute } from "./routes";
|
|
5
|
-
import { emitNewMessage, emitTaskChanged } from "./sse";
|
|
6
|
-
import { busHandleClose, busHandleMessage, busHandleOpen, handleBusUpgrade } from "./bus";
|
|
7
|
-
import { emitRelayEvent } from "./events";
|
|
8
|
-
import { reconcileAutomationRuns, runDueAutomations, type AutomationDispatchResult, type AutomationReconcileResult } from "./automations";
|
|
9
|
-
import { getLifecycleManager } from "./lifecycle-manager";
|
|
10
|
-
import { getCompactionWatch } from "./compaction-watch";
|
|
11
|
-
import { getConfig, setConfig } from "./config-store";
|
|
12
|
-
import { startConnectorStatusPoller } from "./connectors";
|
|
13
|
-
import { installNotificationSubscriptionRule } from "./notification-subscription-rules";
|
|
14
|
-
import { installQuotaAdvisoryHandler } from "./quota-advisory";
|
|
15
|
-
import { startPlanLiveBindings } from "./services/plan-live-bindings";
|
|
16
|
-
import { startRelayScheduler } from "./services/scheduler";
|
|
17
|
-
import { recordWorkspaceIssueActivity } from "./services/spawn-issue-activity";
|
|
18
|
-
import { onWorkspaceCreate } from "./db";
|
|
19
|
-
import { resolve, sep } from "path";
|
|
20
|
-
import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
|
21
|
-
import {
|
|
22
|
-
AUTOMATION_INTERVAL_MS,
|
|
23
|
-
IDLE_TIMEOUT_SECONDS,
|
|
24
|
-
LOG_REQUESTS,
|
|
25
|
-
VERSION,
|
|
26
|
-
relayToken,
|
|
27
|
-
} from "./config";
|
|
28
|
-
import { CONTRACT_VERSIONS } from "./contracts";
|
|
29
|
-
import {
|
|
30
|
-
applyCors,
|
|
31
|
-
assertSafeNetworkConfig,
|
|
32
|
-
corsPreflight,
|
|
33
|
-
forbidden,
|
|
34
|
-
getComponentAuth,
|
|
35
|
-
getIntegrationAuth,
|
|
36
|
-
isAuthorized,
|
|
37
|
-
isScopedRequestAuthorized,
|
|
38
|
-
isOriginAllowed,
|
|
39
|
-
isRequestAuthorizedFor,
|
|
40
|
-
unauthorized,
|
|
41
|
-
} from "./security";
|
|
42
|
-
import { handleCli } from "./cli";
|
|
43
|
-
import { startMaintenanceScheduler } from "./maintenance";
|
|
44
|
-
import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
2
|
+
import { errMessage } from "agent-relay-sdk";
|
|
45
3
|
|
|
46
4
|
async function main(): Promise<void> {
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// Restart-on-update gating (issue #341).
|
|
53
|
-
//
|
|
54
|
-
// A `restartOnUpdate` policy should cycle its managed agent only when the agent's
|
|
55
|
-
// running runner code is now protocol-INCOMPATIBLE with the relay — i.e. on a
|
|
56
|
-
// runner-protocol bump — NOT on every package version change. Gating on the package
|
|
57
|
-
// version churned the always-on Telegram agent ~12×/day (one restart per patch
|
|
58
|
-
// redeploy), dropping in-flight work (surfaced #340). The runner protocol
|
|
59
|
-
// (CONTRACT_VERSIONS.runnerProtocol) only moves on a breaking runner change, so this
|
|
60
|
-
// fires ~never during normal releases.
|
|
61
|
-
//
|
|
62
|
-
// `relay-version` is still tracked for observability/telemetry, but it no longer
|
|
63
|
-
// triggers restarts.
|
|
64
|
-
export function reconcileRelayVersion(): void {
|
|
65
|
-
const storedVersion = getConfig<string>("system", "relay-version")?.value;
|
|
66
|
-
if (storedVersion !== VERSION) setConfig("system", "relay-version", VERSION, "server-startup");
|
|
67
|
-
|
|
68
|
-
const storedProtocol = getConfig<string>("system", "runner-protocol-version")?.value;
|
|
69
|
-
const currentProtocol = String(CONTRACT_VERSIONS.runnerProtocol);
|
|
70
|
-
if (storedProtocol === currentProtocol) return;
|
|
71
|
-
// Skip the very first run (no stored protocol yet) — that's initial bootstrap, not an upgrade.
|
|
72
|
-
if (storedProtocol) getLifecycleManager().onRelayUpdated(VERSION);
|
|
73
|
-
setConfig("system", "runner-protocol-version", currentProtocol, "server-startup");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function startServer(): void {
|
|
77
|
-
const PORT = Number(process.env.PORT) || 4850;
|
|
78
|
-
const HOST = process.env.HOST || "127.0.0.1";
|
|
79
|
-
const DB_PATH = process.env.DB_PATH || "agent-relay.db";
|
|
80
|
-
|
|
81
|
-
assertSafeNetworkConfig(HOST);
|
|
82
|
-
initDb(DB_PATH);
|
|
83
|
-
onWorkspaceCreate(recordWorkspaceIssueActivity);
|
|
84
|
-
installQuotaAdvisoryHandler();
|
|
85
|
-
installNotificationSubscriptionRule();
|
|
86
|
-
startPlanLiveBindings();
|
|
87
|
-
getLifecycleManager().start();
|
|
88
|
-
getCompactionWatch().start();
|
|
89
|
-
reconcileRelayVersion();
|
|
90
|
-
startConnectorStatusPoller();
|
|
91
|
-
startMaintenanceScheduler();
|
|
92
|
-
startRelayScheduler();
|
|
93
|
-
|
|
94
|
-
setInterval(() => {
|
|
95
|
-
emitAutomationReconciliations(reconcileAutomationRuns());
|
|
96
|
-
emitAutomationDispatches(runDueAutomations());
|
|
97
|
-
}, AUTOMATION_INTERVAL_MS);
|
|
98
|
-
|
|
99
|
-
Bun.serve<RelaySocketData>({
|
|
100
|
-
port: PORT,
|
|
101
|
-
hostname: HOST,
|
|
102
|
-
idleTimeout: IDLE_TIMEOUT_SECONDS,
|
|
103
|
-
fetch: createFetchHandler({ publicDir: process.env.PUBLIC_DIR, logRequests: LOG_REQUESTS }),
|
|
104
|
-
websocket: {
|
|
105
|
-
open(ws) {
|
|
106
|
-
if (ws.data?.kind === "terminal-proxy") return openTerminalProxy(ws as TerminalProxySocket);
|
|
107
|
-
return busHandleOpen(ws as any);
|
|
108
|
-
},
|
|
109
|
-
message(ws, data) {
|
|
110
|
-
if (ws.data?.kind === "terminal-proxy") return sendTerminalProxyFrame(ws as TerminalProxySocket, data);
|
|
111
|
-
return busHandleMessage(ws as any, data);
|
|
112
|
-
},
|
|
113
|
-
close(ws) {
|
|
114
|
-
if (ws.data?.kind === "terminal-proxy") return closeTerminalProxy(ws as TerminalProxySocket);
|
|
115
|
-
return busHandleClose(ws as any);
|
|
116
|
-
},
|
|
117
|
-
},
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
console.log(`agent-relay ${VERSION} running on http://${HOST}:${PORT}`);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
interface TerminalProxySocketData {
|
|
124
|
-
kind: "terminal-proxy";
|
|
125
|
-
upstreamUrl: string;
|
|
126
|
-
upstreamToken?: string;
|
|
127
|
-
upstream?: WebSocket;
|
|
128
|
-
pending: Array<string | Buffer>;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
type RelaySocketData = {
|
|
132
|
-
kind: "bus";
|
|
133
|
-
id: string;
|
|
134
|
-
componentScopes?: string[];
|
|
135
|
-
} | TerminalProxySocketData;
|
|
136
|
-
|
|
137
|
-
type TerminalProxySocket = ServerWebSocket<TerminalProxySocketData>;
|
|
138
|
-
|
|
139
|
-
function emitAutomationDispatches(results: AutomationDispatchResult[]): void {
|
|
140
|
-
for (const result of results) {
|
|
141
|
-
if (result.command) emitAutomationCommand(result.command);
|
|
142
|
-
if (result.message) emitNewMessage(result.message);
|
|
143
|
-
if (result.task) emitTaskChanged(result.task, "task.created");
|
|
144
|
-
emitRelayEvent({
|
|
145
|
-
type: "automation.run",
|
|
146
|
-
source: "server",
|
|
147
|
-
subject: result.run.id,
|
|
148
|
-
data: { automation: result.automation, run: result.run, task: result.task },
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function emitAutomationReconciliations(results: AutomationReconcileResult[]): void {
|
|
154
|
-
for (const result of results) {
|
|
155
|
-
if (result.command) emitAutomationCommand(result.command);
|
|
156
|
-
if (result.message) emitNewMessage(result.message);
|
|
157
|
-
if (result.task) emitTaskChanged(result.task, "task.updated");
|
|
158
|
-
emitRelayEvent({
|
|
159
|
-
type: "automation.run",
|
|
160
|
-
source: "server",
|
|
161
|
-
subject: result.run.id,
|
|
162
|
-
data: { run: result.run, task: result.task },
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function emitAutomationCommand(command: { id: string; source: string; status: string }): void {
|
|
168
|
-
emitRelayEvent({
|
|
169
|
-
type: command.status === "pending" ? "command.requested" : `command.${command.status}`,
|
|
170
|
-
source: command.source,
|
|
171
|
-
subject: command.id,
|
|
172
|
-
data: { command },
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function openTerminalProxy(ws: TerminalProxySocket): void {
|
|
177
|
-
const token = ws.data.upstreamToken;
|
|
178
|
-
// Bun's client WebSocket accepts a `headers` option (non-standard; absent from DOM types).
|
|
179
|
-
const upstream = token
|
|
180
|
-
? new WebSocket(ws.data.upstreamUrl, { headers: { [RELAY_TOKEN_HEADER]: token } } as unknown as string[])
|
|
181
|
-
: new WebSocket(ws.data.upstreamUrl);
|
|
182
|
-
ws.data.upstream = upstream;
|
|
183
|
-
|
|
184
|
-
upstream.onopen = () => {
|
|
185
|
-
const pending = ws.data.pending.splice(0);
|
|
186
|
-
for (const frame of pending) upstream.send(frame);
|
|
187
|
-
};
|
|
188
|
-
upstream.onmessage = (event) => {
|
|
189
|
-
if (typeof event.data === "string" || event.data instanceof Buffer) {
|
|
190
|
-
ws.send(event.data);
|
|
191
|
-
} else if (event.data instanceof ArrayBuffer) {
|
|
192
|
-
ws.send(event.data);
|
|
193
|
-
} else if (event.data instanceof Blob) {
|
|
194
|
-
void event.data.arrayBuffer().then((buffer) => ws.send(buffer)).catch(() => {});
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
upstream.onerror = () => {
|
|
198
|
-
ws.send(JSON.stringify({ type: "error", error: "terminal stream upstream error" }));
|
|
199
|
-
};
|
|
200
|
-
upstream.onclose = () => {
|
|
201
|
-
ws.close();
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function sendTerminalProxyFrame(ws: TerminalProxySocket, data: string | Buffer): void {
|
|
206
|
-
const upstream = ws.data.upstream;
|
|
207
|
-
if (!upstream || upstream.readyState === WebSocket.CONNECTING) {
|
|
208
|
-
ws.data.pending.push(data);
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
if (args[0] === "context-probe") {
|
|
7
|
+
const { handleContextProbeCommand } = await import("./cli/context-probe");
|
|
8
|
+
await handleContextProbeCommand(args.slice(1));
|
|
209
9
|
return;
|
|
210
10
|
}
|
|
211
|
-
if (upstream.readyState !== WebSocket.OPEN) return;
|
|
212
|
-
upstream.send(data);
|
|
213
|
-
}
|
|
214
11
|
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
export function createFetchHandler(
|
|
222
|
-
opts: { publicDir?: string; logRequests?: boolean } = {},
|
|
223
|
-
): {
|
|
224
|
-
(req: Request): Promise<Response>;
|
|
225
|
-
(req: Request, server: Bun.Server<RelaySocketData>): Promise<Response | undefined>;
|
|
226
|
-
} {
|
|
227
|
-
const publicDir = resolve(opts.publicDir ?? resolve(import.meta.dir, "../public"));
|
|
228
|
-
const publicDirPrefix = publicDir + sep;
|
|
229
|
-
const logRequests = opts.logRequests ?? false;
|
|
230
|
-
|
|
231
|
-
return async function fetch(req: Request, server?: Bun.Server<RelaySocketData>): Promise<Response | undefined> {
|
|
232
|
-
const url = new URL(req.url);
|
|
233
|
-
|
|
234
|
-
if (url.pathname === "/bus") {
|
|
235
|
-
if (!server) return new Response("WebSocket upgrade unavailable", { status: 400 });
|
|
236
|
-
return handleBusUpgrade(req, server);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const terminalStreamMatch = url.pathname.match(/^\/api\/orchestrators\/([^/]+)\/terminal\/([^/]+)\/stream$/);
|
|
240
|
-
if (req.method === "GET" && terminalStreamMatch) {
|
|
241
|
-
if (!server) return new Response("WebSocket upgrade unavailable", { status: 400 });
|
|
242
|
-
if (!isOriginAllowed(req)) return Response.json({ error: "origin not allowed" }, { status: 403 });
|
|
243
|
-
if (!isAuthorized(req)) return unauthorized(req);
|
|
244
|
-
const orchestratorId = decodeURIComponent(terminalStreamMatch[1]!);
|
|
245
|
-
const session = decodeURIComponent(terminalStreamMatch[2]!);
|
|
246
|
-
if (!isRequestAuthorizedFor(req, { scope: "terminal:attach", resource: { orchestratorId, terminalAttach: true } })) {
|
|
247
|
-
return forbidden(req);
|
|
248
|
-
}
|
|
249
|
-
const orch = getOrchestrator(orchestratorId);
|
|
250
|
-
if (!orch) return Response.json({ error: "orchestrator not found" }, { status: 404 });
|
|
251
|
-
if (!orch.apiUrl) return Response.json({ error: "orchestrator does not expose an API" }, { status: 422 });
|
|
252
|
-
if (orch.status !== "online") return Response.json({ error: "orchestrator is offline" }, { status: 422 });
|
|
253
|
-
const upstream = new URL(`/api/terminal/${encodeURIComponent(session)}/stream`, orch.apiUrl);
|
|
254
|
-
upstream.protocol = upstream.protocol === "https:" ? "wss:" : "ws:";
|
|
255
|
-
// Pass the relay token as a header (not a query param) so it never lands in
|
|
256
|
-
// access/proxy logs (#149). The orchestrator accepts both, but headers are safer.
|
|
257
|
-
const token = relayToken();
|
|
258
|
-
const upgraded = server.upgrade(req, {
|
|
259
|
-
data: { kind: "terminal-proxy", upstreamUrl: upstream.toString(), upstreamToken: token, pending: [] },
|
|
260
|
-
});
|
|
261
|
-
if (!upgraded) return new Response("WebSocket upgrade failed", { status: 400 });
|
|
262
|
-
return undefined;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
if (req.method === "OPTIONS") {
|
|
266
|
-
return corsPreflight(req);
|
|
267
|
-
}
|
|
268
|
-
if (!isOriginAllowed(req)) {
|
|
269
|
-
return Response.json({ error: "origin not allowed" }, { status: 403 });
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// API routes
|
|
273
|
-
const matched = matchRoute(req.method, url.pathname);
|
|
274
|
-
if (matched) {
|
|
275
|
-
const publicPaths = ["/api/spec", "/api/docs", "/api/health"];
|
|
276
|
-
// Pre-auth allowlist (#389): the auth endpoints must be reachable unauthenticated — they ARE
|
|
277
|
-
// the way a session is obtained. Each handler self-authenticates (login verifies credentials,
|
|
278
|
-
// refresh validates the refresh token, logout revokes the presented token). Everything else
|
|
279
|
-
// still flows through the component-token gate below.
|
|
280
|
-
const publicAuthPaths = ["/api/auth/providers", "/api/auth/login", "/api/auth/refresh", "/api/auth/logout"];
|
|
281
|
-
if (!publicPaths.includes(url.pathname) && !publicAuthPaths.includes(url.pathname)) {
|
|
282
|
-
const integrationAuth = getIntegrationAuth(req);
|
|
283
|
-
const componentAuth = getComponentAuth(req);
|
|
284
|
-
const handlerOwnsScopedAuth = url.pathname === "/api/tokens/me" ||
|
|
285
|
-
url.pathname === "/api/settings" ||
|
|
286
|
-
url.pathname.startsWith("/api/settings/");
|
|
287
|
-
if (!isAuthorized(req)) {
|
|
288
|
-
if (!integrationAuth) {
|
|
289
|
-
return unauthorized(req);
|
|
290
|
-
}
|
|
291
|
-
if (!handlerOwnsScopedAuth && !isScopedRequestAuthorized(req)) return forbidden(req);
|
|
292
|
-
}
|
|
293
|
-
if (componentAuth && !handlerOwnsScopedAuth && !isScopedRequestAuthorized(req)) return forbidden(req);
|
|
294
|
-
}
|
|
295
|
-
const response = await matched.handler(req, matched.params);
|
|
296
|
-
applyCors(req, response);
|
|
297
|
-
if (logRequests && url.pathname.startsWith("/api/")) {
|
|
298
|
-
console.log(`${req.method} ${url.pathname} → ${response.status}`);
|
|
299
|
-
}
|
|
300
|
-
return response;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Dashboard — serve static files, rejecting path traversal and directory requests
|
|
304
|
-
let requested = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
305
|
-
if (requested.endsWith("/")) requested += "index.html";
|
|
306
|
-
const resolved = resolve(publicDir, `.${requested}`);
|
|
307
|
-
if (!resolved.startsWith(publicDirPrefix)) {
|
|
308
|
-
return Response.json({ error: "not found" }, { status: 404 });
|
|
309
|
-
}
|
|
310
|
-
const file = Bun.file(resolved);
|
|
311
|
-
if (await file.exists()) {
|
|
312
|
-
return await serveStaticFile(req, resolved, requested, file);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
return Response.json({ error: "not found" }, { status: 404 });
|
|
316
|
-
} as {
|
|
317
|
-
(req: Request): Promise<Response>;
|
|
318
|
-
(req: Request, server: Bun.Server<RelaySocketData>): Promise<Response | undefined>;
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// In-memory compressed-variant cache, keyed by absolute path. Invalidated when
|
|
323
|
-
// the file's mtime/size changes, so a rebuilt bundle is recompressed on next
|
|
324
|
-
// request. The dashboard ships as a minified, code-split bundle (#200/#201);
|
|
325
|
-
// brotli/gzip on top cuts the wire size further, which is the dominant cost over
|
|
326
|
-
// high-latency links. Hashed /assets/* chunks are also served immutable so
|
|
327
|
-
// repeat visits skip them entirely (see staticHeaders).
|
|
328
|
-
type CompressedVariant = { body: Uint8Array; encoding: "br" | "gzip" };
|
|
329
|
-
const compressedCache = new Map<
|
|
330
|
-
string,
|
|
331
|
-
{ mtimeMs: number; size: number; br?: Uint8Array; gzip?: Uint8Array }
|
|
332
|
-
>();
|
|
333
|
-
|
|
334
|
-
const COMPRESSIBLE = /\.(html|js|css|svg|json|webmanifest|map)$/;
|
|
335
|
-
|
|
336
|
-
function isCompressible(pathname: string): boolean {
|
|
337
|
-
return pathname === "/" || pathname.endsWith("/") || COMPRESSIBLE.test(pathname);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function negotiateEncoding(req: Request): "br" | "gzip" | null {
|
|
341
|
-
const accept = req.headers.get("accept-encoding") ?? "";
|
|
342
|
-
if (/\bbr\b/.test(accept)) return "br";
|
|
343
|
-
if (/\bgzip\b/.test(accept)) return "gzip";
|
|
344
|
-
return null;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
async function getCompressedVariant(
|
|
348
|
-
resolved: string,
|
|
349
|
-
mtimeMs: number,
|
|
350
|
-
size: number,
|
|
351
|
-
encoding: "br" | "gzip",
|
|
352
|
-
raw: () => Promise<Uint8Array>,
|
|
353
|
-
): Promise<CompressedVariant> {
|
|
354
|
-
let entry = compressedCache.get(resolved);
|
|
355
|
-
if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) {
|
|
356
|
-
entry = { mtimeMs, size };
|
|
357
|
-
compressedCache.set(resolved, entry);
|
|
358
|
-
}
|
|
359
|
-
const cached = entry[encoding];
|
|
360
|
-
if (cached) return { body: cached, encoding };
|
|
361
|
-
const data = await raw();
|
|
362
|
-
const body =
|
|
363
|
-
encoding === "br"
|
|
364
|
-
? brotliCompressSync(data, {
|
|
365
|
-
params: {
|
|
366
|
-
// Quality 5 ~ near-gzip CPU, much better ratio; this runs once per
|
|
367
|
-
// file version then serves from cache, so cost is amortized away.
|
|
368
|
-
[zlibConstants.BROTLI_PARAM_QUALITY]: 5,
|
|
369
|
-
[zlibConstants.BROTLI_PARAM_SIZE_HINT]: data.byteLength,
|
|
370
|
-
},
|
|
371
|
-
})
|
|
372
|
-
: gzipSync(data, { level: 6 });
|
|
373
|
-
entry[encoding] = body;
|
|
374
|
-
return { body, encoding };
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
async function serveStaticFile(
|
|
378
|
-
req: Request,
|
|
379
|
-
resolved: string,
|
|
380
|
-
requested: string,
|
|
381
|
-
file: ReturnType<typeof Bun.file>,
|
|
382
|
-
): Promise<Response> {
|
|
383
|
-
const stat = await file.stat();
|
|
384
|
-
const headers = staticHeaders(requested);
|
|
385
|
-
// Strong-ish validator from mtime + size; encoding-suffixed so a client never
|
|
386
|
-
// gets a 304 for a variant it didn't store.
|
|
387
|
-
const baseTag = `${stat.mtime.getTime().toString(36)}-${stat.size.toString(36)}`;
|
|
388
|
-
|
|
389
|
-
const encoding = isCompressible(requested) ? negotiateEncoding(req) : null;
|
|
390
|
-
const etag = `"${baseTag}${encoding ? `-${encoding}` : ""}"`;
|
|
391
|
-
headers.set("ETag", etag);
|
|
392
|
-
if (req.headers.get("if-none-match") === etag) {
|
|
393
|
-
return new Response(null, { status: 304, headers });
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
if (encoding) {
|
|
397
|
-
const { body } = await getCompressedVariant(
|
|
398
|
-
resolved,
|
|
399
|
-
stat.mtime.getTime(),
|
|
400
|
-
stat.size,
|
|
401
|
-
encoding,
|
|
402
|
-
() => file.bytes(),
|
|
403
|
-
);
|
|
404
|
-
headers.set("Content-Encoding", encoding);
|
|
405
|
-
headers.set("Vary", "Accept-Encoding");
|
|
406
|
-
headers.set("Content-Length", String(body.byteLength));
|
|
407
|
-
// Uint8Array is a valid BodyInit at runtime in Bun; the DOM lib types omit it.
|
|
408
|
-
return new Response(body as unknown as BodyInit, { headers });
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
return new Response(file, { headers });
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function staticHeaders(pathname: string): Headers {
|
|
415
|
-
const headers = new Headers();
|
|
416
|
-
const contentType = staticContentType(pathname);
|
|
417
|
-
if (contentType) headers.set("Content-Type", contentType);
|
|
418
|
-
// Hashed build assets are content-addressed (a content change = a new
|
|
419
|
-
// filename), so they're safe to cache forever — the browser never needs to
|
|
420
|
-
// revalidate them. The shell + PWA control files must always revalidate
|
|
421
|
-
// instead (ETag makes that a cheap 304 when unchanged).
|
|
422
|
-
if (pathname.startsWith("/assets/")) {
|
|
423
|
-
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
424
|
-
} else if (pathname === "/sw.js" || pathname === "/manifest.webmanifest") {
|
|
425
|
-
headers.set("Cache-Control", "no-cache");
|
|
426
|
-
} else if (pathname === "/" || pathname.endsWith("/") || pathname.endsWith(".html")) {
|
|
427
|
-
headers.set("Cache-Control", "no-cache");
|
|
428
|
-
}
|
|
429
|
-
return headers;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function staticContentType(pathname: string): string | undefined {
|
|
433
|
-
if (pathname.endsWith(".webmanifest")) return "application/manifest+json; charset=utf-8";
|
|
434
|
-
if (pathname.endsWith(".js")) return "text/javascript; charset=utf-8";
|
|
435
|
-
if (pathname.endsWith(".css")) return "text/css; charset=utf-8";
|
|
436
|
-
if (pathname.endsWith(".map")) return "application/json; charset=utf-8";
|
|
437
|
-
if (pathname.endsWith(".html")) return "text/html; charset=utf-8";
|
|
438
|
-
if (pathname.endsWith(".svg")) return "image/svg+xml";
|
|
439
|
-
if (pathname.endsWith(".png")) return "image/png";
|
|
440
|
-
return undefined;
|
|
12
|
+
const { handleCli } = await import("./cli");
|
|
13
|
+
const result = await handleCli(args);
|
|
14
|
+
if (result === "handled") return;
|
|
15
|
+
const { startServer } = await import("./server");
|
|
16
|
+
startServer();
|
|
441
17
|
}
|
|
442
18
|
|
|
443
19
|
if (import.meta.main) {
|
|
@@ -3,8 +3,9 @@ import type { IssueTrackerAdapter, IssueTrackerCloseOptions, IssueTrackerIssueDe
|
|
|
3
3
|
|
|
4
4
|
export const GITHUB_FIXED_LABEL = "fixed";
|
|
5
5
|
const GITHUB_CREDENTIAL_REQUIREMENT = "GitHub issue tracker adapter requires gh authenticated with a token that has issues:write scope";
|
|
6
|
-
// #
|
|
7
|
-
|
|
6
|
+
// #935 — spawn-time issue hydration sits on the dashboard /agents/spawn -> 202 path.
|
|
7
|
+
// Keep it sub-second and degrade to "spec unavailable" if GitHub is slow.
|
|
8
|
+
const ISSUE_DETAIL_READ_TIMEOUT_MS = 750;
|
|
8
9
|
|
|
9
10
|
type GitHubApiMethod = "GET" | "PATCH" | "POST";
|
|
10
11
|
|
|
@@ -83,19 +84,19 @@ async function ghApi(request: GitHubApiRequest): Promise<unknown> {
|
|
|
83
84
|
// auth backed by a GitHub token with `issues:write` scope.
|
|
84
85
|
const args = ["gh", "api", "--method", request.method, request.path, "--header", "Accept: application/vnd.github+json"];
|
|
85
86
|
appendBodyArgs(args, request.body);
|
|
86
|
-
|
|
87
|
-
// a hung `gh`; the subprocess `timeout` (kills with SIGTERM on expiry) is the only effective bound.
|
|
88
|
-
const proc = Bun.spawnSync(args, {
|
|
87
|
+
const proc = Bun.spawn(args, {
|
|
89
88
|
stdin: "ignore",
|
|
90
89
|
stdout: "pipe",
|
|
91
90
|
stderr: "pipe",
|
|
92
91
|
env: process.env,
|
|
93
|
-
...(request.timeoutMs ? { timeout: request.timeoutMs } : {}),
|
|
94
92
|
});
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
93
|
+
const stdoutPromise = streamText(proc.stdout);
|
|
94
|
+
const stderrPromise = streamText(proc.stderr);
|
|
95
|
+
const timedOut = await waitForExitOrTimeout(proc, request.timeoutMs);
|
|
96
|
+
const [stdoutRaw, stderrRaw] = await Promise.all([stdoutPromise, stderrPromise]);
|
|
97
|
+
const stdout = stdoutRaw.trim();
|
|
98
|
+
if (timedOut || proc.exitCode !== 0) {
|
|
99
|
+
const stderr = stderrRaw.trim();
|
|
99
100
|
throw new Error(`${GITHUB_CREDENTIAL_REQUIREMENT}; gh api ${timedOut ? `timed out after ${request.timeoutMs}ms` : "failed"}: ${stderr || stdout || `exit ${proc.exitCode}`}`);
|
|
100
101
|
}
|
|
101
102
|
if (!stdout) return {};
|
|
@@ -106,6 +107,40 @@ async function ghApi(request: GitHubApiRequest): Promise<unknown> {
|
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
async function streamText(stream: ReadableStream<Uint8Array> | null): Promise<string> {
|
|
111
|
+
if (!stream) return "";
|
|
112
|
+
try {
|
|
113
|
+
return await new Response(stream).text();
|
|
114
|
+
} catch {
|
|
115
|
+
return "";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function waitForExitOrTimeout(proc: Bun.Subprocess<"ignore", "pipe", "pipe">, timeoutMs: number | undefined): Promise<boolean> {
|
|
120
|
+
if (!timeoutMs || timeoutMs <= 0) {
|
|
121
|
+
await proc.exited;
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
let timedOut = false;
|
|
125
|
+
let kill: ReturnType<typeof setTimeout> | undefined;
|
|
126
|
+
const timeout = setTimeout(() => {
|
|
127
|
+
timedOut = true;
|
|
128
|
+
try { proc.kill("SIGTERM"); } catch {}
|
|
129
|
+
kill = setTimeout(() => {
|
|
130
|
+
try { proc.kill("SIGKILL"); } catch {}
|
|
131
|
+
}, 1_000);
|
|
132
|
+
kill.unref?.();
|
|
133
|
+
}, timeoutMs);
|
|
134
|
+
timeout.unref?.();
|
|
135
|
+
try {
|
|
136
|
+
await proc.exited;
|
|
137
|
+
} finally {
|
|
138
|
+
clearTimeout(timeout);
|
|
139
|
+
if (kill) clearTimeout(kill);
|
|
140
|
+
}
|
|
141
|
+
return timedOut;
|
|
142
|
+
}
|
|
143
|
+
|
|
109
144
|
function appendBodyArgs(args: string[], body: Record<string, unknown> | undefined): void {
|
|
110
145
|
if (!body) return;
|
|
111
146
|
for (const [key, value] of Object.entries(body)) {
|