@rallycry/conveyor-agent 10.0.0 → 10.0.1
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/dist/{chunk-34NA4BCK.js → chunk-QKRPJ7DB.js} +1016 -71
- package/dist/chunk-QKRPJ7DB.js.map +1 -0
- package/dist/cli.js +174 -1
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +105 -2
- package/dist/index.js +15 -3
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/runtime/entrypoint.sh +627 -0
- package/dist/chunk-34NA4BCK.js.map +0 -1
|
@@ -116,6 +116,44 @@ function applyBootstrapToEnv(config) {
|
|
|
116
116
|
|
|
117
117
|
// src/connection/agent-connection.ts
|
|
118
118
|
import { io } from "socket.io-client";
|
|
119
|
+
|
|
120
|
+
// src/setup/bootstrap-poll.ts
|
|
121
|
+
var PollUntilBoundHttpError = class extends Error {
|
|
122
|
+
constructor(status) {
|
|
123
|
+
super(`pollUntilBound got unexpected status ${status}`);
|
|
124
|
+
this.status = status;
|
|
125
|
+
this.name = "PollUntilBoundHttpError";
|
|
126
|
+
}
|
|
127
|
+
status;
|
|
128
|
+
};
|
|
129
|
+
async function sleep2(ms) {
|
|
130
|
+
await new Promise((resolve) => {
|
|
131
|
+
setTimeout(resolve, ms);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async function pollUntilBound(opts) {
|
|
135
|
+
const pollIntervalMs = opts.pollIntervalMs ?? 2e3;
|
|
136
|
+
const maxWaitMs = opts.maxWaitMs ?? 30 * 60 * 1e3;
|
|
137
|
+
const deadline = Date.now() + maxWaitMs;
|
|
138
|
+
while (true) {
|
|
139
|
+
const response = await fetch(`${opts.apiUrl}/api/v3/pods/bootstrap`, {
|
|
140
|
+
headers: { Authorization: `Bearer ${opts.bootstrapToken}` }
|
|
141
|
+
});
|
|
142
|
+
if (response.status === 200) {
|
|
143
|
+
return await response.json();
|
|
144
|
+
}
|
|
145
|
+
if (response.status === 204) {
|
|
146
|
+
if (Date.now() >= deadline) {
|
|
147
|
+
throw new Error(`pollUntilBound timed out after ${maxWaitMs}ms waiting for pod bind`);
|
|
148
|
+
}
|
|
149
|
+
await sleep2(pollIntervalMs);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
throw new PollUntilBoundHttpError(response.status);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/connection/agent-connection.ts
|
|
119
157
|
var EVENT_BATCH_MS = 500;
|
|
120
158
|
var MAX_EVENT_BUFFER = 5e3;
|
|
121
159
|
var TOKEN_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1e3;
|
|
@@ -147,6 +185,7 @@ var AgentConnection = class _AgentConnection {
|
|
|
147
185
|
modeChangeCallback = null;
|
|
148
186
|
apiKeyUpdateCallback = null;
|
|
149
187
|
pullBranchCallback = null;
|
|
188
|
+
finalizeSnapshotCallback = null;
|
|
150
189
|
earlyPullBranches = [];
|
|
151
190
|
// PTY relay (S5 terminal). Single-slot callbacks, set per PtySession run.
|
|
152
191
|
ptyInputCallback = null;
|
|
@@ -301,6 +340,9 @@ var AgentConnection = class _AgentConnection {
|
|
|
301
340
|
if (this.pullBranchCallback) this.pullBranchCallback(data);
|
|
302
341
|
else this.earlyPullBranches.push(data);
|
|
303
342
|
});
|
|
343
|
+
this.socket.on("session:finalizeSnapshot", () => {
|
|
344
|
+
this.finalizeSnapshotCallback?.();
|
|
345
|
+
});
|
|
304
346
|
this.socket.on("pty:input", (data) => {
|
|
305
347
|
if (data.sessionId && data.sessionId !== this.config.sessionId) return;
|
|
306
348
|
this.ptyInputCallback?.(data.data);
|
|
@@ -523,6 +565,9 @@ var AgentConnection = class _AgentConnection {
|
|
|
523
565
|
for (const data of this.earlyPullBranches) callback(data);
|
|
524
566
|
this.earlyPullBranches = [];
|
|
525
567
|
}
|
|
568
|
+
onFinalizeSnapshot(callback) {
|
|
569
|
+
this.finalizeSnapshotCallback = callback;
|
|
570
|
+
}
|
|
526
571
|
// ── PTY relay (S5 Connected-TUI terminal) ──────────────────────────
|
|
527
572
|
/**
|
|
528
573
|
* Forward a raw chunk of terminal output to the S2 relay (fire-and-forget).
|
|
@@ -671,6 +716,12 @@ var AgentConnection = class _AgentConnection {
|
|
|
671
716
|
}
|
|
672
717
|
);
|
|
673
718
|
}
|
|
719
|
+
/** Report the full current set of runtime-discovered listening ports.
|
|
720
|
+
* Throws on failure so the PortDiscovery poller can retry on its next
|
|
721
|
+
* tick (a swallowed error here would silently drop the delta). */
|
|
722
|
+
async reportDiscoveredPorts(ports) {
|
|
723
|
+
await this.call("reportDiscoveredPorts", { sessionId: this.config.sessionId, ports });
|
|
724
|
+
}
|
|
674
725
|
// ── Typing indicators ───────────────────────────────────────────────
|
|
675
726
|
sendTypingStart() {
|
|
676
727
|
this.sendEvent({ type: "agent_typing_start" });
|
|
@@ -738,15 +789,27 @@ ${q.question}${q.options.length ? "\n" + q.options.map((o) => `- ${o.label}: ${o
|
|
|
738
789
|
const result = await this.refreshFromBootstrap();
|
|
739
790
|
return result.refreshedTaskToken;
|
|
740
791
|
}
|
|
741
|
-
|
|
792
|
+
refreshFromBootstrap() {
|
|
793
|
+
const none = Promise.resolve({ refreshedClaude: false, refreshedTaskToken: false });
|
|
794
|
+
const podBootstrapToken = process.env.POD_BOOTSTRAP_TOKEN;
|
|
742
795
|
const codespaceName = process.env.CODESPACE_NAME || process.env.CLAUDESPACE_NAME;
|
|
743
796
|
const apiUrl = this.config.apiUrl;
|
|
744
|
-
if (!
|
|
797
|
+
if (!apiUrl || !podBootstrapToken && !codespaceName) {
|
|
798
|
+
return none;
|
|
799
|
+
}
|
|
745
800
|
const now = Date.now();
|
|
746
801
|
if (now - this.lastTaskTokenRefreshAt < 6e4) {
|
|
747
|
-
return
|
|
802
|
+
return none;
|
|
748
803
|
}
|
|
749
804
|
this.lastTaskTokenRefreshAt = now;
|
|
805
|
+
if (podBootstrapToken) {
|
|
806
|
+
return this.refreshFromV3Bootstrap(apiUrl, podBootstrapToken);
|
|
807
|
+
}
|
|
808
|
+
if (!codespaceName) return none;
|
|
809
|
+
return this.refreshFromCodespaceBootstrap(apiUrl, codespaceName);
|
|
810
|
+
}
|
|
811
|
+
/** Legacy GitHub Codespaces refresh path — keys on instance name. */
|
|
812
|
+
async refreshFromCodespaceBootstrap(apiUrl, codespaceName) {
|
|
750
813
|
const bootstrapToken = process.env.CONVEYOR_BOOTSTRAP_TOKEN;
|
|
751
814
|
const result = await fetchBootstrap({
|
|
752
815
|
apiUrl,
|
|
@@ -772,6 +835,61 @@ ${q.question}${q.options.length ? "\n" + q.options.map((o) => `- ${o.label}: ${o
|
|
|
772
835
|
const refreshedClaude = Boolean(result.config.envVars?.CLAUDE_CODE_OAUTH_TOKEN);
|
|
773
836
|
return { refreshedClaude, refreshedTaskToken };
|
|
774
837
|
}
|
|
838
|
+
/**
|
|
839
|
+
* v3 refresh: re-fetch the full bootstrap bundle from the pod's bound v3
|
|
840
|
+
* route and swap the credentials in place. The GitHub installation token
|
|
841
|
+
* dies at ~1h and the sessionJwt at 24h; re-polling the bootstrap GET with
|
|
842
|
+
* the same pod token is the designed refresh mechanism.
|
|
843
|
+
*/
|
|
844
|
+
async refreshFromV3Bootstrap(apiUrl, bootstrapToken) {
|
|
845
|
+
const bundle = await this.pollBundleWithRateLimitRetry(apiUrl, bootstrapToken);
|
|
846
|
+
if (!bundle) {
|
|
847
|
+
return { refreshedClaude: false, refreshedTaskToken: false };
|
|
848
|
+
}
|
|
849
|
+
const previousTaskToken = process.env.CONVEYOR_TASK_TOKEN;
|
|
850
|
+
for (const [key, value] of Object.entries(bundle.envVars ?? {})) {
|
|
851
|
+
process.env[key] = value;
|
|
852
|
+
}
|
|
853
|
+
if (bundle.githubToken) process.env.CONVEYOR_GITHUB_TOKEN = bundle.githubToken;
|
|
854
|
+
if (bundle.anthropicKey) process.env.ANTHROPIC_API_KEY = bundle.anthropicKey;
|
|
855
|
+
if (bundle.gcpToken) process.env.CLOUDSDK_AUTH_ACCESS_TOKEN = bundle.gcpToken;
|
|
856
|
+
const refreshedTaskToken = Boolean(bundle.sessionJwt) && bundle.sessionJwt !== previousTaskToken;
|
|
857
|
+
if (refreshedTaskToken) {
|
|
858
|
+
process.env.CONVEYOR_TASK_TOKEN = bundle.sessionJwt;
|
|
859
|
+
this.config.taskToken = bundle.sessionJwt;
|
|
860
|
+
if (this.socket) {
|
|
861
|
+
const auth = this.socket.auth;
|
|
862
|
+
if (auth && typeof auth === "object") {
|
|
863
|
+
auth.taskToken = bundle.sessionJwt;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
const refreshedClaude = Boolean(bundle.envVars?.CLAUDE_CODE_OAUTH_TOKEN);
|
|
868
|
+
return { refreshedClaude, refreshedTaskToken };
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Poll the bootstrap bundle once (maxWaitMs 0), retrying only on a transient
|
|
872
|
+
* 429 (standby-pool pods share one Cloud NAT IP against podBootstrapLimiter)
|
|
873
|
+
* with a short backoff. Returns null when the refresh should be abandoned —
|
|
874
|
+
* a non-429 error, or 429s past the retry budget — so the caller no-ops
|
|
875
|
+
* instead of parking a RUNNING pod in a poll loop.
|
|
876
|
+
*/
|
|
877
|
+
async pollBundleWithRateLimitRetry(apiUrl, bootstrapToken) {
|
|
878
|
+
const retryDelaysMs = [1e3, 3e3];
|
|
879
|
+
for (let attempt = 0; ; attempt++) {
|
|
880
|
+
try {
|
|
881
|
+
return await pollUntilBound({ apiUrl, bootstrapToken, maxWaitMs: 0 });
|
|
882
|
+
} catch (err) {
|
|
883
|
+
const isRateLimited = err instanceof PollUntilBoundHttpError && err.status === 429;
|
|
884
|
+
if (!isRateLimited || attempt >= retryDelaysMs.length) {
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
await new Promise((resolve) => {
|
|
888
|
+
setTimeout(resolve, retryDelaysMs[attempt]);
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
775
893
|
// ── Event buffering ────────────────────────────────────────────────
|
|
776
894
|
sendEvent(event) {
|
|
777
895
|
if (!this.socket) return;
|
|
@@ -1356,6 +1474,13 @@ function preToolUse(payload) {
|
|
|
1356
1474
|
);
|
|
1357
1475
|
process.exit(0);
|
|
1358
1476
|
};
|
|
1477
|
+
// MCP tools are auto-allowed locally \u2014 no socket round trip, no fail-mode.
|
|
1478
|
+
// Plan mode prompts for them despite permissions.allow (the CLI can't
|
|
1479
|
+
// classify MCP tools as read-only), and the pod is the sandbox.
|
|
1480
|
+
if (typeof payload.tool_name === "string" && payload.tool_name.startsWith("mcp__")) {
|
|
1481
|
+
respond("allow");
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1359
1484
|
// AskUserQuestion is observe-only (status reporting) \u2014 fail OPEN so a
|
|
1360
1485
|
// missing/slow socket never denies the questionnaire. Everything else
|
|
1361
1486
|
// (ExitPlanMode) is a Conveyor gate \u2014 fail CLOSED.
|
|
@@ -1459,6 +1584,15 @@ async function writeHookSettings(dir) {
|
|
|
1459
1584
|
// tool, so the questionnaire is never blocked by Conveyor.
|
|
1460
1585
|
matcher: "AskUserQuestion",
|
|
1461
1586
|
hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 30 }]
|
|
1587
|
+
},
|
|
1588
|
+
{
|
|
1589
|
+
// Plan-permission spawns prompt for MCP tools even when they're in
|
|
1590
|
+
// permissions.allow — the CLI can't classify them as read-only. A
|
|
1591
|
+
// hook allow bypasses the permission engine in every mode. Loose by
|
|
1592
|
+
// design: the pod is the sandbox, and planning agents must call
|
|
1593
|
+
// update_task etc. The helper answers locally (no socket).
|
|
1594
|
+
matcher: "mcp__.*",
|
|
1595
|
+
hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 30 }]
|
|
1462
1596
|
}
|
|
1463
1597
|
],
|
|
1464
1598
|
PostToolUse: [
|
|
@@ -1497,8 +1631,8 @@ var PlanSync = class {
|
|
|
1497
1631
|
for (const file of readdirSync(plansDir).filter((f) => f.endsWith(".md"))) {
|
|
1498
1632
|
try {
|
|
1499
1633
|
const fullPath = join2(plansDir, file);
|
|
1500
|
-
const
|
|
1501
|
-
this.planFileSnapshot.set(fullPath,
|
|
1634
|
+
const stat6 = statSync(fullPath);
|
|
1635
|
+
this.planFileSnapshot.set(fullPath, stat6.mtimeMs);
|
|
1502
1636
|
} catch {
|
|
1503
1637
|
continue;
|
|
1504
1638
|
}
|
|
@@ -1519,11 +1653,11 @@ var PlanSync = class {
|
|
|
1519
1653
|
for (const file of files) {
|
|
1520
1654
|
const fullPath = join2(plansDir, file);
|
|
1521
1655
|
try {
|
|
1522
|
-
const
|
|
1656
|
+
const stat6 = statSync(fullPath);
|
|
1523
1657
|
const prevMtime = this.planFileSnapshot.get(fullPath);
|
|
1524
|
-
const isNew = prevMtime === void 0 ||
|
|
1525
|
-
if (isNew && (!newest ||
|
|
1526
|
-
newest = { path: fullPath, mtime:
|
|
1658
|
+
const isNew = prevMtime === void 0 || stat6.mtimeMs > prevMtime;
|
|
1659
|
+
if (isNew && (!newest || stat6.mtimeMs > newest.mtime)) {
|
|
1660
|
+
newest = { path: fullPath, mtime: stat6.mtimeMs };
|
|
1527
1661
|
}
|
|
1528
1662
|
} catch {
|
|
1529
1663
|
continue;
|
|
@@ -1564,6 +1698,511 @@ var PlanSync = class {
|
|
|
1564
1698
|
}
|
|
1565
1699
|
};
|
|
1566
1700
|
|
|
1701
|
+
// src/setup/git-ready.ts
|
|
1702
|
+
import { access, stat } from "fs/promises";
|
|
1703
|
+
var DEFAULT_FAILED_PATH = "/workspaces/.conveyor-git-failed";
|
|
1704
|
+
var DEFAULT_TIMEOUT_MS = 6e5;
|
|
1705
|
+
var DEFAULT_POLL_MS = 200;
|
|
1706
|
+
async function fileExists(path4) {
|
|
1707
|
+
try {
|
|
1708
|
+
await access(path4);
|
|
1709
|
+
const s = await stat(path4);
|
|
1710
|
+
return s.isFile();
|
|
1711
|
+
} catch {
|
|
1712
|
+
return false;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
function awaitGitReady(opts = {}) {
|
|
1716
|
+
const markerPath = opts.markerPath ?? process.env.CONVEYOR_GIT_READY_MARKER;
|
|
1717
|
+
if (!markerPath) {
|
|
1718
|
+
return Promise.resolve("not-gated");
|
|
1719
|
+
}
|
|
1720
|
+
const failedPath = opts.failedPath ?? process.env.CONVEYOR_GIT_FAILED_MARKER ?? DEFAULT_FAILED_PATH;
|
|
1721
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1722
|
+
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
|
|
1723
|
+
opts.onLog?.(`waiting for workspace git (marker=${markerPath})`);
|
|
1724
|
+
return pollForMarkers(markerPath, failedPath, timeoutMs, pollMs, opts.onLog);
|
|
1725
|
+
}
|
|
1726
|
+
async function pollForMarkers(markerPath, failedPath, timeoutMs, pollMs, onLog) {
|
|
1727
|
+
const deadline = Date.now() + timeoutMs;
|
|
1728
|
+
for (; ; ) {
|
|
1729
|
+
if (await fileExists(markerPath)) {
|
|
1730
|
+
onLog?.("workspace git ready");
|
|
1731
|
+
return "ready";
|
|
1732
|
+
}
|
|
1733
|
+
if (await fileExists(failedPath)) {
|
|
1734
|
+
onLog?.("workspace git preparation failed (marker present)");
|
|
1735
|
+
return "failed";
|
|
1736
|
+
}
|
|
1737
|
+
if (Date.now() >= deadline) {
|
|
1738
|
+
onLog?.(`workspace git not ready after ${timeoutMs}ms \u2014 giving up`);
|
|
1739
|
+
return "timeout";
|
|
1740
|
+
}
|
|
1741
|
+
await new Promise((resolve) => {
|
|
1742
|
+
setTimeout(resolve, pollMs);
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// src/runner/work-preservation/index.ts
|
|
1748
|
+
import { rm as rm3 } from "fs/promises";
|
|
1749
|
+
|
|
1750
|
+
// src/runner/work-preservation/pg-dump.ts
|
|
1751
|
+
import { execSync as execSync2 } from "child_process";
|
|
1752
|
+
import { randomUUID } from "crypto";
|
|
1753
|
+
import { tmpdir } from "os";
|
|
1754
|
+
import path from "path";
|
|
1755
|
+
var PG_DUMP_FILENAME = ".conveyor-pgdump.sql";
|
|
1756
|
+
function connectionDefaults(opts) {
|
|
1757
|
+
return {
|
|
1758
|
+
host: opts.host ?? "localhost",
|
|
1759
|
+
port: opts.port ?? 5432,
|
|
1760
|
+
db: opts.db ?? "conveyor"
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
function buildPgDumpCommand(opts) {
|
|
1764
|
+
const { host, port, db } = connectionDefaults(opts);
|
|
1765
|
+
return `pg_dump -h ${host} -p ${port} -U postgres ${db} -f ${JSON.stringify(
|
|
1766
|
+
opts.outputPath
|
|
1767
|
+
)} --no-owner --no-privileges`;
|
|
1768
|
+
}
|
|
1769
|
+
function buildPsqlRestoreCommand(opts) {
|
|
1770
|
+
const { host, port, db } = connectionDefaults(opts);
|
|
1771
|
+
return `psql -h ${host} -p ${port} -U postgres -d ${db} -f ${JSON.stringify(opts.dumpPath)}`;
|
|
1772
|
+
}
|
|
1773
|
+
function hasPostgresDep() {
|
|
1774
|
+
return (process.env.CONVEYOR_DEPS ?? "").split(",").map((dep) => dep.trim()).includes("postgresql");
|
|
1775
|
+
}
|
|
1776
|
+
function execErrorDetail(err) {
|
|
1777
|
+
const withStreams = err;
|
|
1778
|
+
const stderr = withStreams.stderr instanceof Buffer ? withStreams.stderr.toString("utf8").trim() : typeof withStreams.stderr === "string" ? withStreams.stderr.trim() : "";
|
|
1779
|
+
const message = withStreams.message ?? String(err);
|
|
1780
|
+
return stderr ? `${message} \u2014 stderr: ${stderr}` : message;
|
|
1781
|
+
}
|
|
1782
|
+
function dumpPostgresIfPresent(exec = execSync2) {
|
|
1783
|
+
if (!hasPostgresDep()) return Promise.resolve({ dumped: false });
|
|
1784
|
+
const outputPath = path.join(tmpdir(), `conveyor-pgdump-${randomUUID()}.sql`);
|
|
1785
|
+
try {
|
|
1786
|
+
exec(buildPgDumpCommand({ outputPath }), {
|
|
1787
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1788
|
+
timeout: 6e4
|
|
1789
|
+
});
|
|
1790
|
+
return Promise.resolve({ dumped: true, path: outputPath });
|
|
1791
|
+
} catch (err) {
|
|
1792
|
+
process.stderr.write(`[conveyor-agent] pg_dump failed: ${execErrorDetail(err)}
|
|
1793
|
+
`);
|
|
1794
|
+
return Promise.resolve({ dumped: false });
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
function applyPgDumpIfConfigured(dumpPath, exec = execSync2) {
|
|
1798
|
+
if (!hasPostgresDep()) return false;
|
|
1799
|
+
try {
|
|
1800
|
+
exec(buildPsqlRestoreCommand({ dumpPath }), {
|
|
1801
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1802
|
+
timeout: 12e4
|
|
1803
|
+
});
|
|
1804
|
+
process.stderr.write("[conveyor-agent] Restored postgres state from snapshot pg dump\n");
|
|
1805
|
+
return true;
|
|
1806
|
+
} catch (err) {
|
|
1807
|
+
process.stderr.write(`[conveyor-agent] psql dump restore failed: ${execErrorDetail(err)}
|
|
1808
|
+
`);
|
|
1809
|
+
return false;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// src/runner/work-preservation/restore-precedence.ts
|
|
1814
|
+
function selectRestoreSource(probe) {
|
|
1815
|
+
if (probe.gcsAvailable) return "gcs";
|
|
1816
|
+
if (probe.wipRestoreResult === "applied") return "wip";
|
|
1817
|
+
return "clean";
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// src/runner/work-preservation/snapshot-tar.ts
|
|
1821
|
+
import { execSync as execSync3 } from "child_process";
|
|
1822
|
+
import { createReadStream, createWriteStream, mkdtempSync } from "fs";
|
|
1823
|
+
import { copyFile, mkdtemp, rm, stat as stat2, writeFile as writeFile2 } from "fs/promises";
|
|
1824
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
1825
|
+
import path2 from "path";
|
|
1826
|
+
import { pipeline } from "stream/promises";
|
|
1827
|
+
import { createGzip } from "zlib";
|
|
1828
|
+
import * as tar from "tar";
|
|
1829
|
+
|
|
1830
|
+
// src/runner/work-preservation/snapshot-artifact.ts
|
|
1831
|
+
function planSnapshotFiles(git) {
|
|
1832
|
+
const include = git.trackedAndUntracked();
|
|
1833
|
+
const includeSet = new Set(include);
|
|
1834
|
+
const deletions = [...new Set(git.deletedSinceHead())].filter((path4) => !includeSet.has(path4));
|
|
1835
|
+
return { include, deletions };
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// src/runner/work-preservation/snapshot-tar.ts
|
|
1839
|
+
var SNAPSHOT_MANIFEST_NAME = ".conveyor-snapshot-manifest.json";
|
|
1840
|
+
var STATUS_MAX_BUFFER = 64 * 1024 * 1024;
|
|
1841
|
+
function parseStatusPorcelainZ(raw) {
|
|
1842
|
+
const includes = [];
|
|
1843
|
+
const deletions = [];
|
|
1844
|
+
const tokens = raw.split("\0");
|
|
1845
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1846
|
+
const token = tokens[i];
|
|
1847
|
+
if (!token || token.length < 4) continue;
|
|
1848
|
+
const x = token[0];
|
|
1849
|
+
const y = token[1];
|
|
1850
|
+
const filePath = token.slice(3);
|
|
1851
|
+
if (x === "R" || x === "C") {
|
|
1852
|
+
const origPath = tokens[++i];
|
|
1853
|
+
if (x === "R" && origPath) deletions.push(origPath);
|
|
1854
|
+
}
|
|
1855
|
+
if (x === "!") continue;
|
|
1856
|
+
const missingFromWorktree = y === "D" || x === "D" && y === " ";
|
|
1857
|
+
if (missingFromWorktree) deletions.push(filePath);
|
|
1858
|
+
else includes.push(filePath);
|
|
1859
|
+
}
|
|
1860
|
+
return { includes, deletions };
|
|
1861
|
+
}
|
|
1862
|
+
function realGitSurface(cwd) {
|
|
1863
|
+
let cached = null;
|
|
1864
|
+
const load = () => {
|
|
1865
|
+
if (!cached) {
|
|
1866
|
+
const out = execSync3("git status --porcelain=v1 -z -uall", {
|
|
1867
|
+
cwd,
|
|
1868
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1869
|
+
maxBuffer: STATUS_MAX_BUFFER
|
|
1870
|
+
}).toString("utf8");
|
|
1871
|
+
cached = parseStatusPorcelainZ(out);
|
|
1872
|
+
}
|
|
1873
|
+
return cached;
|
|
1874
|
+
};
|
|
1875
|
+
return {
|
|
1876
|
+
trackedAndUntracked: () => load().includes,
|
|
1877
|
+
deletedSinceHead: () => load().deletions
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
function gitHead(cwd) {
|
|
1881
|
+
try {
|
|
1882
|
+
return execSync3("git rev-parse HEAD", { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || null;
|
|
1883
|
+
} catch {
|
|
1884
|
+
return null;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
async function buildSnapshotTar(cwd, opts) {
|
|
1888
|
+
const staging = await mkdtemp(path2.join(tmpdir2(), "conveyor-snapshot-"));
|
|
1889
|
+
const cleanup = async () => {
|
|
1890
|
+
await rm(staging, { recursive: true, force: true }).catch(() => {
|
|
1891
|
+
});
|
|
1892
|
+
};
|
|
1893
|
+
try {
|
|
1894
|
+
const plan = planSnapshotFiles(realGitSurface(cwd));
|
|
1895
|
+
const head = gitHead(cwd);
|
|
1896
|
+
if (!head) throw new Error("cannot snapshot a repo without a resolvable HEAD");
|
|
1897
|
+
const capturedAt = Date.now();
|
|
1898
|
+
const manifest = {
|
|
1899
|
+
head,
|
|
1900
|
+
deletions: plan.deletions,
|
|
1901
|
+
capturedAt,
|
|
1902
|
+
pgDump: Boolean(opts?.pgDumpPath)
|
|
1903
|
+
};
|
|
1904
|
+
await writeFile2(path2.join(staging, SNAPSHOT_MANIFEST_NAME), JSON.stringify(manifest), "utf8");
|
|
1905
|
+
const stagedEntries = [SNAPSHOT_MANIFEST_NAME];
|
|
1906
|
+
if (opts?.pgDumpPath) {
|
|
1907
|
+
await copyFile(opts.pgDumpPath, path2.join(staging, PG_DUMP_FILENAME));
|
|
1908
|
+
stagedEntries.push(PG_DUMP_FILENAME);
|
|
1909
|
+
}
|
|
1910
|
+
const rawTarPath = path2.join(staging, "snapshot.tar");
|
|
1911
|
+
await tar.create({ cwd: staging, file: rawTarPath, portable: true }, stagedEntries);
|
|
1912
|
+
if (plan.include.length > 0) {
|
|
1913
|
+
await tar.replace({ file: rawTarPath, cwd, portable: true }, plan.include);
|
|
1914
|
+
}
|
|
1915
|
+
const tarPath = path2.join(staging, "snapshot.tar.gz");
|
|
1916
|
+
await pipeline(createReadStream(rawTarPath), createGzip(), createWriteStream(tarPath));
|
|
1917
|
+
await rm(rawTarPath, { force: true });
|
|
1918
|
+
const { size } = await stat2(tarPath);
|
|
1919
|
+
return {
|
|
1920
|
+
tarPath,
|
|
1921
|
+
sizeBytes: size,
|
|
1922
|
+
fileCount: plan.include.length,
|
|
1923
|
+
deletionCount: plan.deletions.length,
|
|
1924
|
+
capturedAt,
|
|
1925
|
+
cleanup
|
|
1926
|
+
};
|
|
1927
|
+
} catch (err) {
|
|
1928
|
+
await cleanup();
|
|
1929
|
+
throw err;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
function isWithinWorkspace(cwd, rel) {
|
|
1933
|
+
if (!rel || path2.isAbsolute(rel)) return false;
|
|
1934
|
+
const root = path2.resolve(cwd);
|
|
1935
|
+
const abs = path2.resolve(root, rel);
|
|
1936
|
+
return abs !== root && abs.startsWith(root + path2.sep);
|
|
1937
|
+
}
|
|
1938
|
+
async function readSnapshotArchive(tarPath) {
|
|
1939
|
+
let manifestRaw = null;
|
|
1940
|
+
let pgDumpDir = null;
|
|
1941
|
+
let pgDumpPath;
|
|
1942
|
+
let pgDumpWritten = Promise.resolve();
|
|
1943
|
+
const cleanup = async () => {
|
|
1944
|
+
if (pgDumpDir) await rm(pgDumpDir, { recursive: true, force: true }).catch(() => {
|
|
1945
|
+
});
|
|
1946
|
+
};
|
|
1947
|
+
try {
|
|
1948
|
+
await tar.list({
|
|
1949
|
+
file: tarPath,
|
|
1950
|
+
onReadEntry: (entry) => {
|
|
1951
|
+
if (entry.path === SNAPSHOT_MANIFEST_NAME) {
|
|
1952
|
+
const chunks = [];
|
|
1953
|
+
entry.on("data", (chunk) => chunks.push(chunk));
|
|
1954
|
+
entry.on("end", () => {
|
|
1955
|
+
manifestRaw = Buffer.concat(chunks);
|
|
1956
|
+
});
|
|
1957
|
+
} else if (entry.path === PG_DUMP_FILENAME) {
|
|
1958
|
+
pgDumpDir = mkdtempSync(path2.join(tmpdir2(), "conveyor-pgdump-restore-"));
|
|
1959
|
+
pgDumpPath = path2.join(pgDumpDir, "dump.sql");
|
|
1960
|
+
const dest = createWriteStream(pgDumpPath);
|
|
1961
|
+
pgDumpWritten = new Promise((resolve, reject) => {
|
|
1962
|
+
dest.on("finish", () => resolve());
|
|
1963
|
+
dest.on("error", reject);
|
|
1964
|
+
});
|
|
1965
|
+
entry.pipe(dest);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
await pgDumpWritten;
|
|
1970
|
+
} catch {
|
|
1971
|
+
await cleanup();
|
|
1972
|
+
return null;
|
|
1973
|
+
}
|
|
1974
|
+
if (!manifestRaw) {
|
|
1975
|
+
await cleanup();
|
|
1976
|
+
return null;
|
|
1977
|
+
}
|
|
1978
|
+
try {
|
|
1979
|
+
const parsed = JSON.parse(manifestRaw.toString("utf8"));
|
|
1980
|
+
if (typeof parsed.head !== "string" || !parsed.head) {
|
|
1981
|
+
await cleanup();
|
|
1982
|
+
return null;
|
|
1983
|
+
}
|
|
1984
|
+
return {
|
|
1985
|
+
manifest: {
|
|
1986
|
+
head: parsed.head,
|
|
1987
|
+
deletions: Array.isArray(parsed.deletions) ? parsed.deletions : [],
|
|
1988
|
+
capturedAt: typeof parsed.capturedAt === "number" ? parsed.capturedAt : 0,
|
|
1989
|
+
pgDump: parsed.pgDump === true
|
|
1990
|
+
},
|
|
1991
|
+
...pgDumpPath ? { pgDumpPath } : {},
|
|
1992
|
+
cleanup
|
|
1993
|
+
};
|
|
1994
|
+
} catch {
|
|
1995
|
+
await cleanup();
|
|
1996
|
+
return null;
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
async function extractSnapshotTar(cwd, tarPath, opts) {
|
|
2000
|
+
const read = await readSnapshotArchive(tarPath);
|
|
2001
|
+
if (!read) return { status: "invalid" };
|
|
2002
|
+
try {
|
|
2003
|
+
const { manifest, pgDumpPath } = read;
|
|
2004
|
+
const currentHead = gitHead(cwd);
|
|
2005
|
+
if (manifest.head !== currentHead) {
|
|
2006
|
+
return { status: "stale-head", snapshotHead: manifest.head, currentHead };
|
|
2007
|
+
}
|
|
2008
|
+
let filesExtracted = 0;
|
|
2009
|
+
await tar.extract({
|
|
2010
|
+
file: tarPath,
|
|
2011
|
+
cwd,
|
|
2012
|
+
filter: (entryPath) => {
|
|
2013
|
+
if (entryPath === SNAPSHOT_MANIFEST_NAME || entryPath === PG_DUMP_FILENAME) return false;
|
|
2014
|
+
filesExtracted++;
|
|
2015
|
+
return true;
|
|
2016
|
+
}
|
|
2017
|
+
});
|
|
2018
|
+
let deletionsApplied = 0;
|
|
2019
|
+
for (const rel of manifest.deletions) {
|
|
2020
|
+
if (typeof rel !== "string" || !isWithinWorkspace(cwd, rel)) continue;
|
|
2021
|
+
await rm(path2.resolve(cwd, rel), { recursive: true, force: true });
|
|
2022
|
+
deletionsApplied++;
|
|
2023
|
+
}
|
|
2024
|
+
const pgDumpApplied = manifest.pgDump && pgDumpPath ? applyPgDumpIfConfigured(pgDumpPath, opts?.pgExec) : false;
|
|
2025
|
+
return { status: "extracted", filesExtracted, deletionsApplied, pgDumpApplied };
|
|
2026
|
+
} finally {
|
|
2027
|
+
await read.cleanup();
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// src/runner/work-preservation/snapshot-transfer.ts
|
|
2032
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2033
|
+
import { createReadStream as createReadStream2, createWriteStream as createWriteStream2 } from "fs";
|
|
2034
|
+
import { rm as rm2 } from "fs/promises";
|
|
2035
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
2036
|
+
import path3 from "path";
|
|
2037
|
+
import { Readable } from "stream";
|
|
2038
|
+
import { pipeline as pipeline2 } from "stream/promises";
|
|
2039
|
+
var SNAPSHOT_HTTP_TIMEOUT_MS = 6e4;
|
|
2040
|
+
var SNAPSHOT_CAPTURED_AT_HEADER = "x-snapshot-captured-at";
|
|
2041
|
+
async function putSnapshotToGcs(url, tarResult) {
|
|
2042
|
+
try {
|
|
2043
|
+
const body = Readable.toWeb(createReadStream2(tarResult.tarPath));
|
|
2044
|
+
const res = await fetch(url, {
|
|
2045
|
+
method: "PUT",
|
|
2046
|
+
headers: {
|
|
2047
|
+
"Content-Type": "application/gzip",
|
|
2048
|
+
"Content-Length": String(tarResult.sizeBytes),
|
|
2049
|
+
[SNAPSHOT_CAPTURED_AT_HEADER]: String(tarResult.capturedAt)
|
|
2050
|
+
},
|
|
2051
|
+
body,
|
|
2052
|
+
duplex: "half",
|
|
2053
|
+
signal: AbortSignal.timeout(SNAPSHOT_HTTP_TIMEOUT_MS)
|
|
2054
|
+
});
|
|
2055
|
+
return res.ok;
|
|
2056
|
+
} catch {
|
|
2057
|
+
return false;
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
async function downloadSnapshotToTemp(url) {
|
|
2061
|
+
const tmpTar = path3.join(tmpdir3(), `conveyor-snapshot-restore-${randomUUID2()}.tar.gz`);
|
|
2062
|
+
try {
|
|
2063
|
+
const res = await fetch(url, {
|
|
2064
|
+
method: "GET",
|
|
2065
|
+
signal: AbortSignal.timeout(SNAPSHOT_HTTP_TIMEOUT_MS)
|
|
2066
|
+
});
|
|
2067
|
+
if (!res.ok || !res.body) return null;
|
|
2068
|
+
await pipeline2(
|
|
2069
|
+
Readable.fromWeb(res.body),
|
|
2070
|
+
createWriteStream2(tmpTar)
|
|
2071
|
+
);
|
|
2072
|
+
return tmpTar;
|
|
2073
|
+
} catch {
|
|
2074
|
+
await rm2(tmpTar, { force: true }).catch(() => {
|
|
2075
|
+
});
|
|
2076
|
+
return null;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// src/runner/work-preservation/index.ts
|
|
2081
|
+
var periodicTimer = null;
|
|
2082
|
+
var inFlightCapture = null;
|
|
2083
|
+
var inFlightGcsUpload = null;
|
|
2084
|
+
var EMPTY_GCS_RESULT = {
|
|
2085
|
+
uploaded: false,
|
|
2086
|
+
fileCount: 0,
|
|
2087
|
+
deletionCount: 0,
|
|
2088
|
+
sizeBytes: 0,
|
|
2089
|
+
pgDumped: false
|
|
2090
|
+
};
|
|
2091
|
+
async function doUploadSnapshotToGcs(cwd, uploadUrl, opts) {
|
|
2092
|
+
let tarResult;
|
|
2093
|
+
try {
|
|
2094
|
+
tarResult = await buildSnapshotTar(cwd, { pgDumpPath: opts?.pgDumpPath });
|
|
2095
|
+
} catch {
|
|
2096
|
+
return EMPTY_GCS_RESULT;
|
|
2097
|
+
}
|
|
2098
|
+
try {
|
|
2099
|
+
const uploaded = (opts?.forceUpload || tarResult.sizeBytes > 0) && uploadUrl ? await putSnapshotToGcs(uploadUrl, tarResult) : false;
|
|
2100
|
+
return {
|
|
2101
|
+
uploaded,
|
|
2102
|
+
fileCount: tarResult.fileCount,
|
|
2103
|
+
deletionCount: tarResult.deletionCount,
|
|
2104
|
+
sizeBytes: tarResult.sizeBytes,
|
|
2105
|
+
pgDumped: Boolean(opts?.pgDumpPath)
|
|
2106
|
+
};
|
|
2107
|
+
} finally {
|
|
2108
|
+
await tarResult.cleanup();
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
async function uploadSnapshotToGcs(cwd, uploadUrl, opts) {
|
|
2112
|
+
const previous = inFlightGcsUpload;
|
|
2113
|
+
const run = (async () => {
|
|
2114
|
+
if (previous) await previous.catch(() => {
|
|
2115
|
+
});
|
|
2116
|
+
return doUploadSnapshotToGcs(cwd, uploadUrl, opts);
|
|
2117
|
+
})();
|
|
2118
|
+
inFlightGcsUpload = run;
|
|
2119
|
+
try {
|
|
2120
|
+
return await run;
|
|
2121
|
+
} finally {
|
|
2122
|
+
if (inFlightGcsUpload === run) inFlightGcsUpload = null;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
async function captureSnapshot(ctx) {
|
|
2126
|
+
const gcs = await uploadSnapshotToGcs(ctx.cwd, ctx.snapshotUploadUrl, {
|
|
2127
|
+
pgDumpPath: ctx.pgDumpPath,
|
|
2128
|
+
forceUpload: ctx.forceUpload
|
|
2129
|
+
});
|
|
2130
|
+
const wipResult = await flushPendingChanges(ctx.cwd, {
|
|
2131
|
+
wipMessage: ctx.wipMessage ?? "WIP: WorkPreservation periodic snapshot",
|
|
2132
|
+
refreshToken: ctx.refreshToken
|
|
2133
|
+
});
|
|
2134
|
+
return {
|
|
2135
|
+
fileCount: gcs.fileCount,
|
|
2136
|
+
deletionCount: gcs.deletionCount,
|
|
2137
|
+
sizeBytes: gcs.sizeBytes,
|
|
2138
|
+
gcsUploaded: gcs.uploaded,
|
|
2139
|
+
wipPushed: wipResult.committed || wipResult.pushed,
|
|
2140
|
+
pgDumped: gcs.pgDumped
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
function startPeriodic(intervalMs, ctx) {
|
|
2144
|
+
stop();
|
|
2145
|
+
periodicTimer = setInterval(() => {
|
|
2146
|
+
if (inFlightCapture) return;
|
|
2147
|
+
const run = captureSnapshot(ctx);
|
|
2148
|
+
inFlightCapture = run;
|
|
2149
|
+
void run.catch(() => {
|
|
2150
|
+
}).finally(() => {
|
|
2151
|
+
if (inFlightCapture === run) inFlightCapture = null;
|
|
2152
|
+
});
|
|
2153
|
+
}, intervalMs);
|
|
2154
|
+
}
|
|
2155
|
+
function stop() {
|
|
2156
|
+
if (periodicTimer) {
|
|
2157
|
+
clearInterval(periodicTimer);
|
|
2158
|
+
periodicTimer = null;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
async function finalizeForSleep(ctx) {
|
|
2162
|
+
stop();
|
|
2163
|
+
if (inFlightCapture) await inFlightCapture.catch(() => {
|
|
2164
|
+
});
|
|
2165
|
+
const dump = await dumpPostgresIfPresent();
|
|
2166
|
+
try {
|
|
2167
|
+
const artifact = await captureSnapshot({
|
|
2168
|
+
...ctx,
|
|
2169
|
+
pgDumpPath: dump.path,
|
|
2170
|
+
wipMessage: ctx.wipMessage ?? "WIP: WorkPreservation sleep finalize snapshot",
|
|
2171
|
+
// Always stamp snapshotAt on sleep — even an empty workspace must confirm
|
|
2172
|
+
// the finalize so teardown doesn't wait out the reconciler's cap.
|
|
2173
|
+
forceUpload: true
|
|
2174
|
+
});
|
|
2175
|
+
return { ...artifact, pgDumped: dump.dumped };
|
|
2176
|
+
} finally {
|
|
2177
|
+
if (dump.path) await rm3(dump.path, { force: true }).catch(() => {
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
async function restoreOnBoot(bundle, cwd) {
|
|
2182
|
+
let gcsAvailable = false;
|
|
2183
|
+
let gcsFileCount;
|
|
2184
|
+
if (bundle.snapshotUrl) {
|
|
2185
|
+
const tmpTar = await downloadSnapshotToTemp(bundle.snapshotUrl);
|
|
2186
|
+
if (tmpTar) {
|
|
2187
|
+
try {
|
|
2188
|
+
const result = await extractSnapshotTar(cwd, tmpTar);
|
|
2189
|
+
if (result.status === "extracted") {
|
|
2190
|
+
gcsAvailable = true;
|
|
2191
|
+
gcsFileCount = result.filesExtracted;
|
|
2192
|
+
}
|
|
2193
|
+
} catch {
|
|
2194
|
+
gcsAvailable = false;
|
|
2195
|
+
} finally {
|
|
2196
|
+
await rm3(tmpTar, { force: true }).catch(() => {
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
const wipRestoreResult = gcsAvailable ? "none" : restoreWipSnapshot(cwd, bundle.gitPlan.branch);
|
|
2202
|
+
const source = selectRestoreSource({ gcsAvailable, wipRestoreResult });
|
|
2203
|
+
return source === "gcs" ? { source, fileCount: gcsFileCount } : { source };
|
|
2204
|
+
}
|
|
2205
|
+
|
|
1567
2206
|
// ../shared/dist/index.js
|
|
1568
2207
|
import { z } from "zod";
|
|
1569
2208
|
import { z as z2 } from "zod";
|
|
@@ -1704,6 +2343,16 @@ var NotifyAgentVersionRequestSchema = z.object({
|
|
|
1704
2343
|
sessionId: z.string(),
|
|
1705
2344
|
agentVersion: z.string()
|
|
1706
2345
|
});
|
|
2346
|
+
var DiscoveredPortSchema = z.object({
|
|
2347
|
+
port: z.number().int().min(1).max(65535),
|
|
2348
|
+
label: z.string().min(1).max(64).optional(),
|
|
2349
|
+
protocol: z.enum(["http", "tcp"]).optional(),
|
|
2350
|
+
detectedAt: z.string()
|
|
2351
|
+
});
|
|
2352
|
+
var ReportDiscoveredPortsRequestSchema = z.object({
|
|
2353
|
+
sessionId: z.string(),
|
|
2354
|
+
ports: z.array(DiscoveredPortSchema).max(64)
|
|
2355
|
+
});
|
|
1707
2356
|
var CreateSubtaskRequestSchema = z.object({
|
|
1708
2357
|
sessionId: z.string(),
|
|
1709
2358
|
title: z.string().min(1),
|
|
@@ -2037,6 +2686,15 @@ var StopProjectBuildRequestSchema = z2.object({
|
|
|
2037
2686
|
taskId: z2.string(),
|
|
2038
2687
|
requestingUserId: z2.string().optional()
|
|
2039
2688
|
});
|
|
2689
|
+
var StartProjectWorkspaceRequestSchema = z2.object({
|
|
2690
|
+
projectId: z2.string(),
|
|
2691
|
+
requestingUserId: z2.string().optional()
|
|
2692
|
+
});
|
|
2693
|
+
var StopProjectWorkspaceRequestSchema = z2.object({
|
|
2694
|
+
projectId: z2.string(),
|
|
2695
|
+
destroy: z2.boolean().optional(),
|
|
2696
|
+
requestingUserId: z2.string().optional()
|
|
2697
|
+
});
|
|
2040
2698
|
var CreateProjectReleaseRequestSchema = z2.object({
|
|
2041
2699
|
projectId: z2.string(),
|
|
2042
2700
|
taskIds: z2.array(z2.string()).optional(),
|
|
@@ -2513,8 +3171,8 @@ var ClaudeCodeHarness = class {
|
|
|
2513
3171
|
};
|
|
2514
3172
|
|
|
2515
3173
|
// src/harness/pty/session.ts
|
|
2516
|
-
import { mkdtemp, mkdir as mkdir2, rm, stat } from "fs/promises";
|
|
2517
|
-
import { tmpdir } from "os";
|
|
3174
|
+
import { mkdtemp as mkdtemp2, mkdir as mkdir2, rm as rm4, stat as stat3 } from "fs/promises";
|
|
3175
|
+
import { tmpdir as tmpdir4 } from "os";
|
|
2518
3176
|
import { join as join4, dirname } from "path";
|
|
2519
3177
|
|
|
2520
3178
|
// src/harness/pty/event-queue.ts
|
|
@@ -2783,8 +3441,8 @@ function mapTranscriptRecord(raw) {
|
|
|
2783
3441
|
// src/harness/pty/jsonl-tailer.ts
|
|
2784
3442
|
var POLL_INTERVAL_MS = 25;
|
|
2785
3443
|
var JsonlTailer = class {
|
|
2786
|
-
constructor(
|
|
2787
|
-
this.path =
|
|
3444
|
+
constructor(path4, onEvent) {
|
|
3445
|
+
this.path = path4;
|
|
2788
3446
|
this.onEvent = onEvent;
|
|
2789
3447
|
}
|
|
2790
3448
|
path;
|
|
@@ -2981,7 +3639,7 @@ var PtyOutputCoalescer = class {
|
|
|
2981
3639
|
|
|
2982
3640
|
// src/harness/pty/tool-server.ts
|
|
2983
3641
|
import { createServer as createServer2 } from "http";
|
|
2984
|
-
import { writeFile as
|
|
3642
|
+
import { writeFile as writeFile3 } from "fs/promises";
|
|
2985
3643
|
import { join as join3 } from "path";
|
|
2986
3644
|
import { randomBytes } from "crypto";
|
|
2987
3645
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -3088,7 +3746,7 @@ async function startToolServers(mcpServers, tempDir) {
|
|
|
3088
3746
|
}
|
|
3089
3747
|
if (Object.keys(config).length === 0) return { servers, mcpConfigPath: null };
|
|
3090
3748
|
const mcpConfigPath = join3(tempDir, "mcp-config.json");
|
|
3091
|
-
await
|
|
3749
|
+
await writeFile3(mcpConfigPath, JSON.stringify({ mcpServers: config }, null, 2), "utf8");
|
|
3092
3750
|
return { servers, mcpConfigPath };
|
|
3093
3751
|
}
|
|
3094
3752
|
|
|
@@ -3126,14 +3784,14 @@ var SUBMIT_NUDGE_INTERVAL_MS = 2e3;
|
|
|
3126
3784
|
var SUBMIT_NUDGE_MAX_PRESSES = 5;
|
|
3127
3785
|
var SUBMIT_NUDGE_SLOW_INTERVAL_MS = 5e3;
|
|
3128
3786
|
var SUBMIT_NUDGE_WINDOW_MS = 9e4;
|
|
3129
|
-
function
|
|
3787
|
+
function sleep3(ms) {
|
|
3130
3788
|
return new Promise((resolve) => {
|
|
3131
3789
|
setTimeout(resolve, ms);
|
|
3132
3790
|
});
|
|
3133
3791
|
}
|
|
3134
|
-
async function transcriptSize(
|
|
3792
|
+
async function transcriptSize(path4) {
|
|
3135
3793
|
try {
|
|
3136
|
-
return (await
|
|
3794
|
+
return (await stat3(path4)).size;
|
|
3137
3795
|
} catch {
|
|
3138
3796
|
return 0;
|
|
3139
3797
|
}
|
|
@@ -3223,7 +3881,7 @@ var PtySession = class {
|
|
|
3223
3881
|
return;
|
|
3224
3882
|
}
|
|
3225
3883
|
try {
|
|
3226
|
-
this.tempDir = await
|
|
3884
|
+
this.tempDir = await mkdtemp2(join4(tmpdir4(), "conveyor-pty-"));
|
|
3227
3885
|
const socketPath = join4(this.tempDir, "hook.sock");
|
|
3228
3886
|
this.socket = new HookSocketServer(
|
|
3229
3887
|
socketPath,
|
|
@@ -3323,7 +3981,7 @@ var PtySession = class {
|
|
|
3323
3981
|
this.mcpConfigPath = null;
|
|
3324
3982
|
this.queue.close();
|
|
3325
3983
|
if (this.tempDir) {
|
|
3326
|
-
await
|
|
3984
|
+
await rm4(this.tempDir, { recursive: true, force: true });
|
|
3327
3985
|
this.tempDir = "";
|
|
3328
3986
|
}
|
|
3329
3987
|
}
|
|
@@ -3383,7 +4041,7 @@ var PtySession = class {
|
|
|
3383
4041
|
async deliverPrompt(text) {
|
|
3384
4042
|
this.writeStdin(buildPromptBytes(text));
|
|
3385
4043
|
if (this.options.promptDelivery === "prefill") return;
|
|
3386
|
-
await
|
|
4044
|
+
await sleep3(SUBMIT_SETTLE_MS);
|
|
3387
4045
|
if (this._toreDown) return;
|
|
3388
4046
|
this.writeStdin("\r");
|
|
3389
4047
|
this.armSubmitNudge();
|
|
@@ -3531,7 +4189,7 @@ var PtySession = class {
|
|
|
3531
4189
|
};
|
|
3532
4190
|
|
|
3533
4191
|
// src/harness/pty/credentials.ts
|
|
3534
|
-
import { chmod as chmod2, mkdir as mkdir3, readFile, rm as
|
|
4192
|
+
import { chmod as chmod2, mkdir as mkdir3, readFile, rm as rm5, writeFile as writeFile4 } from "fs/promises";
|
|
3535
4193
|
import { homedir as homedir2 } from "os";
|
|
3536
4194
|
import { join as join5 } from "path";
|
|
3537
4195
|
var SYNTH_TOKEN_TTL_MS = 365 * 24 * 60 * 60 * 1e3;
|
|
@@ -3582,26 +4240,26 @@ function planCredentialsWrite(input) {
|
|
|
3582
4240
|
if (fresh) return { action: "skip", reason: "current" };
|
|
3583
4241
|
return { action: "write", contents };
|
|
3584
4242
|
}
|
|
3585
|
-
async function readRaw(
|
|
4243
|
+
async function readRaw(path4) {
|
|
3586
4244
|
try {
|
|
3587
|
-
return await readFile(
|
|
4245
|
+
return await readFile(path4, "utf8");
|
|
3588
4246
|
} catch {
|
|
3589
4247
|
return null;
|
|
3590
4248
|
}
|
|
3591
4249
|
}
|
|
3592
4250
|
async function ensureClaudeCredentials(env = process.env) {
|
|
3593
4251
|
try {
|
|
3594
|
-
const
|
|
4252
|
+
const path4 = claudeCredentialsPath();
|
|
3595
4253
|
const plan = planCredentialsWrite({
|
|
3596
4254
|
isCloud: isConveyorCloudEnv(env),
|
|
3597
4255
|
token: env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
3598
|
-
existingRaw: await readRaw(
|
|
4256
|
+
existingRaw: await readRaw(path4),
|
|
3599
4257
|
now: Date.now()
|
|
3600
4258
|
});
|
|
3601
4259
|
if (plan.action === "skip") return;
|
|
3602
4260
|
await mkdir3(claudeConfigHome(), { recursive: true });
|
|
3603
|
-
await
|
|
3604
|
-
await chmod2(
|
|
4261
|
+
await writeFile4(path4, plan.contents, { encoding: "utf8", mode: 384 });
|
|
4262
|
+
await chmod2(path4, 384).catch(() => {
|
|
3605
4263
|
});
|
|
3606
4264
|
} catch (err) {
|
|
3607
4265
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -3657,11 +4315,11 @@ function planClaudeJsonSeed(existingRaw, trustCwd) {
|
|
|
3657
4315
|
async function ensureClaudeOnboarding(env = process.env, trustCwd) {
|
|
3658
4316
|
try {
|
|
3659
4317
|
if (!isConveyorCloudEnv(env)) return;
|
|
3660
|
-
const
|
|
3661
|
-
const contents = planClaudeJsonSeed(await readRaw(
|
|
4318
|
+
const path4 = claudeJsonPath();
|
|
4319
|
+
const contents = planClaudeJsonSeed(await readRaw(path4), trustCwd);
|
|
3662
4320
|
if (contents === null) return;
|
|
3663
|
-
await
|
|
3664
|
-
const verify = await readRaw(
|
|
4321
|
+
await writeFile4(path4, contents, "utf8");
|
|
4322
|
+
const verify = await readRaw(path4);
|
|
3665
4323
|
if (verify === contents) {
|
|
3666
4324
|
process.stderr.write(
|
|
3667
4325
|
`[conveyor-agent] claude onboarding seeded${trustCwd ? ` (trust: ${trustCwd})` : ""}
|
|
@@ -3669,7 +4327,7 @@ async function ensureClaudeOnboarding(env = process.env, trustCwd) {
|
|
|
3669
4327
|
);
|
|
3670
4328
|
} else {
|
|
3671
4329
|
process.stderr.write(
|
|
3672
|
-
`[conveyor-agent] claude onboarding seed read-back MISMATCH at ${
|
|
4330
|
+
`[conveyor-agent] claude onboarding seed read-back MISMATCH at ${path4}: wrote ${contents.length}B, read ${verify?.length ?? 0}B \u2014 CLI may see stale config and park at a startup dialog
|
|
3673
4331
|
`
|
|
3674
4332
|
);
|
|
3675
4333
|
}
|
|
@@ -3682,12 +4340,12 @@ async function ensureClaudeOnboarding(env = process.env, trustCwd) {
|
|
|
3682
4340
|
async function removeConveyorCredentials(env = process.env) {
|
|
3683
4341
|
try {
|
|
3684
4342
|
if (!isConveyorCloudEnv(env)) return;
|
|
3685
|
-
const
|
|
3686
|
-
const existing = parseClaudeAiOauth(await readRaw(
|
|
4343
|
+
const path4 = claudeCredentialsPath();
|
|
4344
|
+
const existing = parseClaudeAiOauth(await readRaw(path4));
|
|
3687
4345
|
if (existing && typeof existing.refreshToken === "string" && existing.refreshToken.length > 0) {
|
|
3688
4346
|
return;
|
|
3689
4347
|
}
|
|
3690
|
-
await
|
|
4348
|
+
await rm5(path4, { force: true });
|
|
3691
4349
|
} catch (err) {
|
|
3692
4350
|
const message = err instanceof Error ? err.message : String(err);
|
|
3693
4351
|
process.stderr.write(`[conveyor-agent] claude credentials removal failed: ${message}
|
|
@@ -4009,7 +4667,7 @@ function formatIncidents(incidents) {
|
|
|
4009
4667
|
}
|
|
4010
4668
|
|
|
4011
4669
|
// src/execution/tag-context-resolver.ts
|
|
4012
|
-
import { readFile as readFile2, readdir, stat as
|
|
4670
|
+
import { readFile as readFile2, readdir, stat as stat4 } from "fs/promises";
|
|
4013
4671
|
var TYPE_PRIORITY = { rule: 0, file: 1, folder: 2, doc: 3 };
|
|
4014
4672
|
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
4015
4673
|
".png",
|
|
@@ -4059,7 +4717,7 @@ async function readFileContent(filePath, maxChars) {
|
|
|
4059
4717
|
let rawContent;
|
|
4060
4718
|
let mtimeMs;
|
|
4061
4719
|
try {
|
|
4062
|
-
const st = await
|
|
4720
|
+
const st = await stat4(filePath);
|
|
4063
4721
|
mtimeMs = st.mtimeMs;
|
|
4064
4722
|
} catch {
|
|
4065
4723
|
return null;
|
|
@@ -4086,7 +4744,7 @@ async function readFolderListing(folderPath) {
|
|
|
4086
4744
|
try {
|
|
4087
4745
|
let mtimeMs;
|
|
4088
4746
|
try {
|
|
4089
|
-
const st = await
|
|
4747
|
+
const st = await stat4(folderPath);
|
|
4090
4748
|
mtimeMs = st.mtimeMs;
|
|
4091
4749
|
} catch {
|
|
4092
4750
|
return null;
|
|
@@ -5634,7 +6292,7 @@ function buildMutationTools(connection, config) {
|
|
|
5634
6292
|
}
|
|
5635
6293
|
|
|
5636
6294
|
// src/tools/attachment-tools.ts
|
|
5637
|
-
import { readFile as readFile3, stat as
|
|
6295
|
+
import { readFile as readFile3, stat as stat5 } from "fs/promises";
|
|
5638
6296
|
import { basename, extname, isAbsolute, join as join6 } from "path";
|
|
5639
6297
|
import { z as z6 } from "zod";
|
|
5640
6298
|
var IMAGE_MIME_BY_EXT = {
|
|
@@ -5652,16 +6310,16 @@ function buildUploadAttachmentTool(connection, config) {
|
|
|
5652
6310
|
path: z6.string().describe("Path to the image file \u2014 absolute, or relative to the workspace root"),
|
|
5653
6311
|
title: z6.string().optional().describe("Short caption posted with the image (defaults to the file name)")
|
|
5654
6312
|
},
|
|
5655
|
-
async ({ path, title }) => {
|
|
6313
|
+
async ({ path: path4, title }) => {
|
|
5656
6314
|
try {
|
|
5657
|
-
const filePath = isAbsolute(
|
|
6315
|
+
const filePath = isAbsolute(path4) ? path4 : join6(config.workspaceDir, path4);
|
|
5658
6316
|
const mimeType = IMAGE_MIME_BY_EXT[extname(filePath).toLowerCase()];
|
|
5659
6317
|
if (!mimeType) {
|
|
5660
6318
|
return textResult(
|
|
5661
6319
|
`Unsupported file type "${extname(filePath) || "(none)"}". Supported: ${Object.keys(IMAGE_MIME_BY_EXT).join(", ")}`
|
|
5662
6320
|
);
|
|
5663
6321
|
}
|
|
5664
|
-
const info = await
|
|
6322
|
+
const info = await stat5(filePath).catch(() => null);
|
|
5665
6323
|
if (!info?.isFile()) {
|
|
5666
6324
|
return textResult(`File not found: ${filePath}`);
|
|
5667
6325
|
}
|
|
@@ -6568,14 +7226,14 @@ function flushPendingToolCalls(host, turnToolCalls) {
|
|
|
6568
7226
|
}
|
|
6569
7227
|
const outputsByTool = /* @__PURE__ */ new Map();
|
|
6570
7228
|
for (const entry of host.pendingToolOutputs) {
|
|
6571
|
-
const
|
|
6572
|
-
|
|
6573
|
-
outputsByTool.set(entry.tool,
|
|
7229
|
+
const list2 = outputsByTool.get(entry.tool) ?? [];
|
|
7230
|
+
list2.push(entry.output);
|
|
7231
|
+
outputsByTool.set(entry.tool, list2);
|
|
6574
7232
|
}
|
|
6575
7233
|
for (const call of turnToolCalls) {
|
|
6576
|
-
const
|
|
6577
|
-
if (
|
|
6578
|
-
call.output =
|
|
7234
|
+
const list2 = outputsByTool.get(call.tool);
|
|
7235
|
+
if (list2 && list2.length > 0) {
|
|
7236
|
+
call.output = list2.shift();
|
|
6579
7237
|
}
|
|
6580
7238
|
}
|
|
6581
7239
|
host.connection.sendEvent({ type: "turn_end", toolCalls: [...turnToolCalls] });
|
|
@@ -7063,10 +7721,10 @@ function hasExistingSessionFile(taskId, cwd, lineage) {
|
|
|
7063
7721
|
const key = sessionLineageKey(taskId, lineage.agentMode, lineage.runnerMode);
|
|
7064
7722
|
return sessionFileExists(taskIdToSessionUuid(key), cwd);
|
|
7065
7723
|
}
|
|
7066
|
-
function repairTornSessionFile(
|
|
7724
|
+
function repairTornSessionFile(path4) {
|
|
7067
7725
|
try {
|
|
7068
|
-
if (!existsSync(
|
|
7069
|
-
const content = readFileSync2(
|
|
7726
|
+
if (!existsSync(path4)) return false;
|
|
7727
|
+
const content = readFileSync2(path4, "utf8");
|
|
7070
7728
|
if (content.length === 0) return false;
|
|
7071
7729
|
let keepEnd = content.length;
|
|
7072
7730
|
if (!content.endsWith("\n")) {
|
|
@@ -7085,9 +7743,9 @@ function repairTornSessionFile(path) {
|
|
|
7085
7743
|
keepEnd = prevNewline + 1;
|
|
7086
7744
|
}
|
|
7087
7745
|
if (keepEnd === content.length) return false;
|
|
7088
|
-
truncateSync(
|
|
7746
|
+
truncateSync(path4, Buffer.byteLength(content.slice(0, keepEnd), "utf8"));
|
|
7089
7747
|
logger2.warn("Repaired torn transcript before resume", {
|
|
7090
|
-
path,
|
|
7748
|
+
path: path4,
|
|
7091
7749
|
trimmedBytes: content.length - keepEnd
|
|
7092
7750
|
});
|
|
7093
7751
|
return true;
|
|
@@ -7412,12 +8070,14 @@ async function handleAuthError(context, host, options) {
|
|
|
7412
8070
|
host.connection.postChatMessage("Authentication expired. Re-bootstrapping credentials...");
|
|
7413
8071
|
const refreshed = await host.connection.refreshAuthToken();
|
|
7414
8072
|
if (!refreshed) {
|
|
7415
|
-
host.connection.postChatMessage(
|
|
8073
|
+
host.connection.postChatMessage(
|
|
8074
|
+
"\u26A0\uFE0F No Claude credential is configured for this project \u2014 the agent can't run and is now idle. Add a Claude Code OAuth token (or Anthropic API key) in Project Settings, then resume this task."
|
|
8075
|
+
);
|
|
7416
8076
|
host.connection.sendEvent({
|
|
7417
8077
|
type: "error",
|
|
7418
|
-
message: "
|
|
8078
|
+
message: "No Claude credential available \u2014 agent idle (dormant)"
|
|
7419
8079
|
});
|
|
7420
|
-
|
|
8080
|
+
return;
|
|
7421
8081
|
}
|
|
7422
8082
|
context.claudeSessionId = null;
|
|
7423
8083
|
host.connection.storeSessionId("");
|
|
@@ -7901,8 +8561,195 @@ function readAgentVersion() {
|
|
|
7901
8561
|
return null;
|
|
7902
8562
|
}
|
|
7903
8563
|
|
|
8564
|
+
// src/runner/port-discovery.ts
|
|
8565
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
8566
|
+
import { execFile } from "child_process";
|
|
8567
|
+
var PROC_TCP_LISTEN_STATE = "0A";
|
|
8568
|
+
function parseProcNetTcpListeners(content) {
|
|
8569
|
+
const ports = [];
|
|
8570
|
+
const lines = content.split("\n");
|
|
8571
|
+
for (let i = 1; i < lines.length; i++) {
|
|
8572
|
+
const line = lines[i];
|
|
8573
|
+
if (!line) continue;
|
|
8574
|
+
const cols = line.trim().split(/\s+/);
|
|
8575
|
+
if (cols.length < 4 || cols[3] !== PROC_TCP_LISTEN_STATE) continue;
|
|
8576
|
+
const local = cols[1];
|
|
8577
|
+
if (!local) continue;
|
|
8578
|
+
const portHex = local.split(":").pop();
|
|
8579
|
+
if (!portHex) continue;
|
|
8580
|
+
const port = Number.parseInt(portHex, 16);
|
|
8581
|
+
if (Number.isInteger(port) && port >= 1 && port <= 65535) ports.push(port);
|
|
8582
|
+
}
|
|
8583
|
+
return ports;
|
|
8584
|
+
}
|
|
8585
|
+
var DEFAULT_PROC_PATHS = ["/proc/net/tcp", "/proc/net/tcp6"];
|
|
8586
|
+
async function readProcListeningPorts(procPaths = DEFAULT_PROC_PATHS) {
|
|
8587
|
+
const ports = /* @__PURE__ */ new Set();
|
|
8588
|
+
let readable = false;
|
|
8589
|
+
for (const path4 of procPaths) {
|
|
8590
|
+
try {
|
|
8591
|
+
const content = await readFile4(path4, "utf8");
|
|
8592
|
+
readable = true;
|
|
8593
|
+
for (const port of parseProcNetTcpListeners(content)) ports.add(port);
|
|
8594
|
+
} catch {
|
|
8595
|
+
}
|
|
8596
|
+
}
|
|
8597
|
+
return readable ? ports : null;
|
|
8598
|
+
}
|
|
8599
|
+
async function readNetstatListeningPorts() {
|
|
8600
|
+
const output = await new Promise((resolve) => {
|
|
8601
|
+
execFile("netstat", ["-an", "-p", "tcp"], { timeout: 5e3 }, (err, stdout) => {
|
|
8602
|
+
resolve(err ? null : stdout);
|
|
8603
|
+
});
|
|
8604
|
+
});
|
|
8605
|
+
if (output === null) return null;
|
|
8606
|
+
const ports = /* @__PURE__ */ new Set();
|
|
8607
|
+
for (const line of output.split("\n")) {
|
|
8608
|
+
if (!line.includes("LISTEN")) continue;
|
|
8609
|
+
const cols = line.trim().split(/\s+/);
|
|
8610
|
+
const local = cols[3];
|
|
8611
|
+
if (!local) continue;
|
|
8612
|
+
const portStr = local.split(".").pop();
|
|
8613
|
+
const port = Number(portStr);
|
|
8614
|
+
if (Number.isInteger(port) && port >= 1 && port <= 65535) ports.add(port);
|
|
8615
|
+
}
|
|
8616
|
+
return ports;
|
|
8617
|
+
}
|
|
8618
|
+
async function readListeningPorts() {
|
|
8619
|
+
const proc = await readProcListeningPorts();
|
|
8620
|
+
if (proc !== null) return proc;
|
|
8621
|
+
if (process.platform !== "linux") return readNetstatListeningPorts();
|
|
8622
|
+
return null;
|
|
8623
|
+
}
|
|
8624
|
+
var DEFAULT_EXCLUDED_PORTS = [2222, 5432, 6379, 9200];
|
|
8625
|
+
var DEFAULT_EPHEMERAL_PORT_MIN = 32768;
|
|
8626
|
+
var DEFAULT_INTERVAL_MS = 15e3;
|
|
8627
|
+
var DEFAULT_MAX_PORTS = 16;
|
|
8628
|
+
var CONFIRM_SCANS = 2;
|
|
8629
|
+
var PortDiscovery = class {
|
|
8630
|
+
opts;
|
|
8631
|
+
intervalMs;
|
|
8632
|
+
maxPorts;
|
|
8633
|
+
excluded;
|
|
8634
|
+
ephemeralPortMin;
|
|
8635
|
+
scan;
|
|
8636
|
+
now;
|
|
8637
|
+
log;
|
|
8638
|
+
baseline = null;
|
|
8639
|
+
tracked = /* @__PURE__ */ new Map();
|
|
8640
|
+
timer = null;
|
|
8641
|
+
ticking = false;
|
|
8642
|
+
disabled = false;
|
|
8643
|
+
/** Set when the confirmed set changed (or a report failed) — cleared only
|
|
8644
|
+
* after a successful report, so transient RPC failures retry next tick. */
|
|
8645
|
+
reportPending = false;
|
|
8646
|
+
lastReportedKey = "";
|
|
8647
|
+
constructor(options) {
|
|
8648
|
+
this.opts = options;
|
|
8649
|
+
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
8650
|
+
this.maxPorts = options.maxPorts ?? DEFAULT_MAX_PORTS;
|
|
8651
|
+
this.excluded = new Set(options.excludedPorts ?? DEFAULT_EXCLUDED_PORTS);
|
|
8652
|
+
this.ephemeralPortMin = options.ephemeralPortMin ?? DEFAULT_EPHEMERAL_PORT_MIN;
|
|
8653
|
+
this.scan = options.scan ?? readListeningPorts;
|
|
8654
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
8655
|
+
this.log = options.log ?? ((m) => process.stderr.write(`[conveyor-agent] ${m}
|
|
8656
|
+
`));
|
|
8657
|
+
}
|
|
8658
|
+
/** Take the baseline scan and start polling. Safe to call once. */
|
|
8659
|
+
async start() {
|
|
8660
|
+
if (this.timer || this.disabled) return;
|
|
8661
|
+
const baseline = await this.scanSafe();
|
|
8662
|
+
if (baseline === null) {
|
|
8663
|
+
this.disabled = true;
|
|
8664
|
+
this.log("Port discovery disabled: no listening-socket source available");
|
|
8665
|
+
return;
|
|
8666
|
+
}
|
|
8667
|
+
this.baseline = baseline;
|
|
8668
|
+
this.timer = setInterval(() => void this.tick(), this.intervalMs);
|
|
8669
|
+
this.timer.unref?.();
|
|
8670
|
+
}
|
|
8671
|
+
stop() {
|
|
8672
|
+
if (this.timer) {
|
|
8673
|
+
clearInterval(this.timer);
|
|
8674
|
+
this.timer = null;
|
|
8675
|
+
}
|
|
8676
|
+
}
|
|
8677
|
+
/** One poll cycle. Exposed for tests (deterministic, no timers needed). */
|
|
8678
|
+
async tick() {
|
|
8679
|
+
if (this.ticking || this.disabled || !this.baseline) return;
|
|
8680
|
+
this.ticking = true;
|
|
8681
|
+
try {
|
|
8682
|
+
const current = await this.scanSafe();
|
|
8683
|
+
if (current === null) return;
|
|
8684
|
+
this.updateTracking(current);
|
|
8685
|
+
if (this.reportPending) await this.flushReport();
|
|
8686
|
+
} finally {
|
|
8687
|
+
this.ticking = false;
|
|
8688
|
+
}
|
|
8689
|
+
}
|
|
8690
|
+
async scanSafe() {
|
|
8691
|
+
try {
|
|
8692
|
+
return await this.scan();
|
|
8693
|
+
} catch {
|
|
8694
|
+
return null;
|
|
8695
|
+
}
|
|
8696
|
+
}
|
|
8697
|
+
isCandidate(port) {
|
|
8698
|
+
if (this.baseline?.has(port)) return false;
|
|
8699
|
+
if (this.excluded.has(port)) return false;
|
|
8700
|
+
if (port >= this.ephemeralPortMin) return false;
|
|
8701
|
+
return true;
|
|
8702
|
+
}
|
|
8703
|
+
updateTracking(current) {
|
|
8704
|
+
for (const port of current) {
|
|
8705
|
+
if (!this.isCandidate(port)) continue;
|
|
8706
|
+
const entry = this.tracked.get(port);
|
|
8707
|
+
if (!entry) {
|
|
8708
|
+
this.tracked.set(port, { seen: 1, missed: 0, confirmed: false, detectedAt: "" });
|
|
8709
|
+
continue;
|
|
8710
|
+
}
|
|
8711
|
+
entry.seen += 1;
|
|
8712
|
+
entry.missed = 0;
|
|
8713
|
+
if (!entry.confirmed && entry.seen >= CONFIRM_SCANS) {
|
|
8714
|
+
entry.confirmed = true;
|
|
8715
|
+
entry.detectedAt = this.now().toISOString();
|
|
8716
|
+
}
|
|
8717
|
+
}
|
|
8718
|
+
for (const [port, entry] of this.tracked) {
|
|
8719
|
+
if (current.has(port)) continue;
|
|
8720
|
+
entry.missed += 1;
|
|
8721
|
+
entry.seen = 0;
|
|
8722
|
+
if (entry.missed >= CONFIRM_SCANS || !entry.confirmed) this.tracked.delete(port);
|
|
8723
|
+
}
|
|
8724
|
+
const key = this.confirmedKey();
|
|
8725
|
+
if (key !== this.lastReportedKey) this.reportPending = true;
|
|
8726
|
+
}
|
|
8727
|
+
confirmedPorts() {
|
|
8728
|
+
const confirmed = [...this.tracked.entries()].filter(([, entry]) => entry.confirmed).sort(([a], [b]) => a - b).slice(0, this.maxPorts);
|
|
8729
|
+
return confirmed.map(([port, entry]) => ({
|
|
8730
|
+
port,
|
|
8731
|
+
protocol: "tcp",
|
|
8732
|
+
detectedAt: entry.detectedAt
|
|
8733
|
+
}));
|
|
8734
|
+
}
|
|
8735
|
+
confirmedKey() {
|
|
8736
|
+
return this.confirmedPorts().map(({ port }) => port).join(",");
|
|
8737
|
+
}
|
|
8738
|
+
async flushReport() {
|
|
8739
|
+
const ports = this.confirmedPorts();
|
|
8740
|
+
const key = ports.map(({ port }) => port).join(",");
|
|
8741
|
+
try {
|
|
8742
|
+
await this.opts.report(ports);
|
|
8743
|
+
this.lastReportedKey = key;
|
|
8744
|
+
this.reportPending = false;
|
|
8745
|
+
this.log(`Discovered preview ports: [${key || "none"}]`);
|
|
8746
|
+
} catch {
|
|
8747
|
+
}
|
|
8748
|
+
}
|
|
8749
|
+
};
|
|
8750
|
+
|
|
7904
8751
|
// src/runner/parent-pull-handler.ts
|
|
7905
|
-
import { execSync as
|
|
8752
|
+
import { execSync as execSync4 } from "child_process";
|
|
7906
8753
|
function handlePullBranch(workDir, branch) {
|
|
7907
8754
|
if (!branch) return;
|
|
7908
8755
|
const current = getCurrentBranch(workDir);
|
|
@@ -7921,14 +8768,14 @@ function handlePullBranch(workDir, branch) {
|
|
|
7921
8768
|
return;
|
|
7922
8769
|
}
|
|
7923
8770
|
try {
|
|
7924
|
-
|
|
8771
|
+
execSync4(`git fetch origin ${branch}`, { cwd: workDir, stdio: "ignore", timeout: 6e4 });
|
|
7925
8772
|
} catch {
|
|
7926
8773
|
process.stderr.write(`[conveyor-agent] pull_branch: fetch failed for ${branch}
|
|
7927
8774
|
`);
|
|
7928
8775
|
return;
|
|
7929
8776
|
}
|
|
7930
8777
|
try {
|
|
7931
|
-
|
|
8778
|
+
execSync4(`git pull --ff-only origin ${branch}`, {
|
|
7932
8779
|
cwd: workDir,
|
|
7933
8780
|
stdio: "ignore",
|
|
7934
8781
|
timeout: 6e4
|
|
@@ -7980,6 +8827,8 @@ var SessionRunner = class _SessionRunner {
|
|
|
7980
8827
|
agentSpokeThisTurn = false;
|
|
7981
8828
|
/** Guards overlapping runs of the periodic git flush. */
|
|
7982
8829
|
periodicFlushInFlight = false;
|
|
8830
|
+
/** Runtime preview-port poller (v3 dynamic port discovery). */
|
|
8831
|
+
portDiscovery = null;
|
|
7983
8832
|
constructor(config, callbacks) {
|
|
7984
8833
|
this.config = config;
|
|
7985
8834
|
this.callbacks = callbacks;
|
|
@@ -8036,7 +8885,6 @@ var SessionRunner = class _SessionRunner {
|
|
|
8036
8885
|
this.wireConnectionCallbacks();
|
|
8037
8886
|
this.lifecycle.startHeartbeat();
|
|
8038
8887
|
this.lifecycle.startTokenRefresh();
|
|
8039
|
-
this.lifecycle.startGitFlush();
|
|
8040
8888
|
const { pendingMessages: serverMessages } = await this.connection.call("connectAgent", {
|
|
8041
8889
|
sessionId: this.sessionId
|
|
8042
8890
|
});
|
|
@@ -8045,6 +8893,11 @@ var SessionRunner = class _SessionRunner {
|
|
|
8045
8893
|
this.pendingMessages.push({ content: msg.content, userId: msg.userId });
|
|
8046
8894
|
}
|
|
8047
8895
|
}
|
|
8896
|
+
this.portDiscovery = new PortDiscovery({
|
|
8897
|
+
report: (ports) => this.connection.reportDiscoveredPorts(ports)
|
|
8898
|
+
});
|
|
8899
|
+
void this.portDiscovery.start().catch(() => {
|
|
8900
|
+
});
|
|
8048
8901
|
const agentVersion = readAgentVersion();
|
|
8049
8902
|
if (agentVersion) {
|
|
8050
8903
|
this.connection.call("notifyAgentVersion", { sessionId: this.sessionId, agentVersion }).catch(() => {
|
|
@@ -8079,6 +8932,17 @@ var SessionRunner = class _SessionRunner {
|
|
|
8079
8932
|
await this.shutdown("error");
|
|
8080
8933
|
return;
|
|
8081
8934
|
}
|
|
8935
|
+
const gitState = await awaitGitReady({
|
|
8936
|
+
onLog: (m) => process.stderr.write(`[conveyor-agent] ${m}
|
|
8937
|
+
`)
|
|
8938
|
+
});
|
|
8939
|
+
if (gitState === "failed" || gitState === "timeout") {
|
|
8940
|
+
const message = gitState === "failed" ? "Workspace git preparation failed (see pod logs)" : "Workspace git preparation timed out (see pod logs)";
|
|
8941
|
+
this.connection.sendEvent({ type: "error", message });
|
|
8942
|
+
await this.callbacks.onEvent({ type: "error", message });
|
|
8943
|
+
await this.shutdown("error");
|
|
8944
|
+
return;
|
|
8945
|
+
}
|
|
8082
8946
|
if (process.env.CONVEYOR_GIT_READY !== "1") {
|
|
8083
8947
|
if (this.fullContext?.githubBranch) {
|
|
8084
8948
|
ensureOnTaskBranch(this.config.workspaceDir, this.fullContext.githubBranch);
|
|
@@ -8087,11 +8951,31 @@ var SessionRunner = class _SessionRunner {
|
|
|
8087
8951
|
syncWithBaseBranch(this.config.workspaceDir, this.fullContext.baseBranch);
|
|
8088
8952
|
}
|
|
8089
8953
|
}
|
|
8954
|
+
if (!this.stopped) {
|
|
8955
|
+
this.lifecycle.startGitFlush();
|
|
8956
|
+
}
|
|
8090
8957
|
if (this.fullContext?.githubBranch) {
|
|
8091
|
-
const
|
|
8092
|
-
if (
|
|
8093
|
-
|
|
8958
|
+
const snapshotUrl = process.env.CONVEYOR_SNAPSHOT_URL;
|
|
8959
|
+
if (snapshotUrl) {
|
|
8960
|
+
const restore = await restoreOnBoot(
|
|
8961
|
+
{ snapshotUrl, gitPlan: { branch: this.fullContext.githubBranch } },
|
|
8962
|
+
this.config.workspaceDir
|
|
8963
|
+
);
|
|
8964
|
+
if (restore.source !== "clean") {
|
|
8965
|
+
process.stderr.write(
|
|
8966
|
+
`[conveyor-agent] WorkPreservation restore: source=${restore.source}${restore.fileCount === void 0 ? "" : ` files=${restore.fileCount}`}
|
|
8967
|
+
`
|
|
8968
|
+
);
|
|
8969
|
+
}
|
|
8970
|
+
} else {
|
|
8971
|
+
const restored = restoreWipSnapshot(
|
|
8972
|
+
this.config.workspaceDir,
|
|
8973
|
+
this.fullContext.githubBranch
|
|
8974
|
+
);
|
|
8975
|
+
if (restored !== "none") {
|
|
8976
|
+
process.stderr.write(`[conveyor-agent] WIP snapshot restore: ${restored}
|
|
8094
8977
|
`);
|
|
8978
|
+
}
|
|
8095
8979
|
}
|
|
8096
8980
|
}
|
|
8097
8981
|
this.mode.applyServerMode(this.fullContext?.agentMode, this.fullContext?.isAuto);
|
|
@@ -8421,11 +9305,28 @@ var SessionRunner = class _SessionRunner {
|
|
|
8421
9305
|
`
|
|
8422
9306
|
);
|
|
8423
9307
|
}
|
|
9308
|
+
await this.uploadGcsSnapshotIfConfigured();
|
|
8424
9309
|
} catch {
|
|
8425
9310
|
} finally {
|
|
8426
9311
|
this.periodicFlushInFlight = false;
|
|
8427
9312
|
}
|
|
8428
9313
|
}
|
|
9314
|
+
/** PUT the WorkPreservation snapshot tar to the bundle's capability URL
|
|
9315
|
+
* when this pod is v3 (CONVEYOR_SNAPSHOT_UPLOAD_URL set). Never throws. */
|
|
9316
|
+
async uploadGcsSnapshotIfConfigured() {
|
|
9317
|
+
const uploadUrl = process.env.CONVEYOR_SNAPSHOT_UPLOAD_URL;
|
|
9318
|
+
if (!uploadUrl) return;
|
|
9319
|
+
try {
|
|
9320
|
+
const gcs = await uploadSnapshotToGcs(this.config.workspaceDir, uploadUrl);
|
|
9321
|
+
if (gcs.uploaded) {
|
|
9322
|
+
process.stderr.write(
|
|
9323
|
+
`[conveyor-agent] WorkPreservation GCS snapshot: files=${gcs.fileCount} bytes=${gcs.sizeBytes}
|
|
9324
|
+
`
|
|
9325
|
+
);
|
|
9326
|
+
}
|
|
9327
|
+
} catch {
|
|
9328
|
+
}
|
|
9329
|
+
}
|
|
8429
9330
|
/** Best-effort WIP commit + push on shutdown so in-flight work isn't lost
|
|
8430
9331
|
* when a claudespace pod is killed. Must be called BEFORE stop() so the
|
|
8431
9332
|
* connection is still alive for token refresh. Never throws. */
|
|
@@ -8450,6 +9351,7 @@ var SessionRunner = class _SessionRunner {
|
|
|
8450
9351
|
`
|
|
8451
9352
|
);
|
|
8452
9353
|
}
|
|
9354
|
+
await this.uploadGcsSnapshotIfConfigured();
|
|
8453
9355
|
} catch (err) {
|
|
8454
9356
|
const msg = err instanceof Error ? err.message : String(err);
|
|
8455
9357
|
process.stderr.write(`[conveyor-agent] Shutdown git flush failed: ${msg}
|
|
@@ -8459,6 +9361,7 @@ var SessionRunner = class _SessionRunner {
|
|
|
8459
9361
|
stop() {
|
|
8460
9362
|
this.stopped = true;
|
|
8461
9363
|
this.queryBridge?.stop();
|
|
9364
|
+
this.portDiscovery?.stop();
|
|
8462
9365
|
this.lifecycle.destroy();
|
|
8463
9366
|
this.connection.disconnect();
|
|
8464
9367
|
if (this.inputResolver) {
|
|
@@ -8684,6 +9587,38 @@ var SessionRunner = class _SessionRunner {
|
|
|
8684
9587
|
this.connection.onPullBranch(({ branch }) => {
|
|
8685
9588
|
handlePullBranch(this.config.workspaceDir, branch);
|
|
8686
9589
|
});
|
|
9590
|
+
this.connection.onFinalizeSnapshot(() => void this.finalizeSnapshotNow());
|
|
9591
|
+
}
|
|
9592
|
+
/** Eager finalize snapshot triggered by the reconciler's sleep signal
|
|
9593
|
+
* (session:finalizeSnapshot). Runs pg_dump + a full capture and uploads it
|
|
9594
|
+
* NOW so the sleep confirms on this snapshot instead of waiting for the
|
|
9595
|
+
* ~2min periodic flush — and so sidecar DB state (which ONLY finalizeForSleep
|
|
9596
|
+
* captures via pg_dump) actually lands. Best-effort: on failure the
|
|
9597
|
+
* periodic/shutdown path stays the fallback. No-op on non-v3 pods. */
|
|
9598
|
+
async finalizeSnapshotNow() {
|
|
9599
|
+
const uploadUrl = process.env.CONVEYOR_SNAPSHOT_UPLOAD_URL;
|
|
9600
|
+
if (!uploadUrl || this.stopped) return;
|
|
9601
|
+
try {
|
|
9602
|
+
await finalizeForSleep({
|
|
9603
|
+
cwd: this.config.workspaceDir,
|
|
9604
|
+
branch: this.fullContext?.githubBranch ?? "",
|
|
9605
|
+
snapshotUploadUrl: uploadUrl,
|
|
9606
|
+
refreshToken: async () => {
|
|
9607
|
+
try {
|
|
9608
|
+
const res = await this.connection.call("refreshGithubToken", {
|
|
9609
|
+
sessionId: this.connection.sessionId
|
|
9610
|
+
});
|
|
9611
|
+
return res.token;
|
|
9612
|
+
} catch {
|
|
9613
|
+
return void 0;
|
|
9614
|
+
}
|
|
9615
|
+
}
|
|
9616
|
+
});
|
|
9617
|
+
process.stderr.write(
|
|
9618
|
+
"[conveyor-agent] Finalize snapshot (pg_dump) uploaded on sleep signal\n"
|
|
9619
|
+
);
|
|
9620
|
+
} catch {
|
|
9621
|
+
}
|
|
8687
9622
|
}
|
|
8688
9623
|
/** Proactively refresh the GitHub token before the 1-hour expiry. */
|
|
8689
9624
|
async refreshGithubToken() {
|
|
@@ -8727,6 +9662,7 @@ var SessionRunner = class _SessionRunner {
|
|
|
8727
9662
|
process.stderr.write(`[conveyor-agent] Shutdown: reason=${finalState}
|
|
8728
9663
|
`);
|
|
8729
9664
|
this.connection.sendEvent({ type: "shutdown", reason: finalState });
|
|
9665
|
+
this.portDiscovery?.stop();
|
|
8730
9666
|
this.lifecycle.destroy();
|
|
8731
9667
|
try {
|
|
8732
9668
|
await this.setState(finalState);
|
|
@@ -8771,13 +9707,13 @@ var SessionRunner = class _SessionRunner {
|
|
|
8771
9707
|
};
|
|
8772
9708
|
|
|
8773
9709
|
// src/setup/config.ts
|
|
8774
|
-
import { readFile as
|
|
9710
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
8775
9711
|
import { join as join8 } from "path";
|
|
8776
9712
|
var DEVCONTAINER_PATH = ".devcontainer/conveyor/devcontainer.json";
|
|
8777
9713
|
var DEVCONTAINER_PORT_DENY_LIST = /* @__PURE__ */ new Set([5432, 6379, 9200]);
|
|
8778
9714
|
async function loadForwardPorts(workspaceDir) {
|
|
8779
9715
|
try {
|
|
8780
|
-
const raw = await
|
|
9716
|
+
const raw = await readFile5(join8(workspaceDir, DEVCONTAINER_PATH), "utf-8");
|
|
8781
9717
|
const parsed = JSON.parse(raw);
|
|
8782
9718
|
const ports = (parsed.forwardPorts ?? []).filter(
|
|
8783
9719
|
(p) => typeof p === "number" && !DEVCONTAINER_PORT_DENY_LIST.has(p)
|
|
@@ -8821,7 +9757,7 @@ function loadConveyorConfig() {
|
|
|
8821
9757
|
}
|
|
8822
9758
|
|
|
8823
9759
|
// src/setup/commands.ts
|
|
8824
|
-
import { spawn, execSync as
|
|
9760
|
+
import { spawn, execSync as execSync5 } from "child_process";
|
|
8825
9761
|
function runSetupCommand(cmd, cwd, onOutput) {
|
|
8826
9762
|
return new Promise((resolve, reject) => {
|
|
8827
9763
|
const child = spawn("sh", ["-c", cmd], {
|
|
@@ -8850,7 +9786,7 @@ function runSetupCommand(cmd, cwd, onOutput) {
|
|
|
8850
9786
|
var AUTH_TOKEN_TIMEOUT_MS = 3e4;
|
|
8851
9787
|
function runAuthTokenCommand(cmd, userEmail, cwd) {
|
|
8852
9788
|
try {
|
|
8853
|
-
const output =
|
|
9789
|
+
const output = execSync5(`${cmd} ${JSON.stringify(userEmail)}`, {
|
|
8854
9790
|
cwd,
|
|
8855
9791
|
timeout: AUTH_TOKEN_TIMEOUT_MS,
|
|
8856
9792
|
stdio: ["ignore", "pipe", "ignore"],
|
|
@@ -8880,10 +9816,10 @@ function runStartCommand(cmd, cwd, onOutput) {
|
|
|
8880
9816
|
}
|
|
8881
9817
|
|
|
8882
9818
|
// src/setup/codespace.ts
|
|
8883
|
-
import { execSync as
|
|
9819
|
+
import { execSync as execSync6 } from "child_process";
|
|
8884
9820
|
function unshallowRepo(workspaceDir) {
|
|
8885
9821
|
try {
|
|
8886
|
-
|
|
9822
|
+
execSync6("git fetch --unshallow", {
|
|
8887
9823
|
cwd: workspaceDir,
|
|
8888
9824
|
timeout: 6e4,
|
|
8889
9825
|
stdio: "ignore"
|
|
@@ -8896,6 +9832,8 @@ export {
|
|
|
8896
9832
|
fetchBootstrap,
|
|
8897
9833
|
applyBootstrapToEnv,
|
|
8898
9834
|
AgentConnection,
|
|
9835
|
+
DEFAULT_LIFECYCLE_CONFIG,
|
|
9836
|
+
Lifecycle,
|
|
8899
9837
|
createServiceLogger,
|
|
8900
9838
|
hasUncommittedChanges,
|
|
8901
9839
|
getCurrentBranch,
|
|
@@ -8905,6 +9843,13 @@ export {
|
|
|
8905
9843
|
flushPendingChanges,
|
|
8906
9844
|
pushToOrigin,
|
|
8907
9845
|
PlanSync,
|
|
9846
|
+
awaitGitReady,
|
|
9847
|
+
uploadSnapshotToGcs,
|
|
9848
|
+
captureSnapshot,
|
|
9849
|
+
startPeriodic,
|
|
9850
|
+
stop,
|
|
9851
|
+
finalizeForSleep,
|
|
9852
|
+
restoreOnBoot,
|
|
8908
9853
|
SessionRunner,
|
|
8909
9854
|
loadForwardPorts,
|
|
8910
9855
|
buildSessionPreviewPorts,
|
|
@@ -8914,4 +9859,4 @@ export {
|
|
|
8914
9859
|
runStartCommand,
|
|
8915
9860
|
unshallowRepo
|
|
8916
9861
|
};
|
|
8917
|
-
//# sourceMappingURL=chunk-
|
|
9862
|
+
//# sourceMappingURL=chunk-QKRPJ7DB.js.map
|