@uchibeke/gallivant 0.0.0-stage → 0.2.0
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/CONTRIBUTING.md +26 -0
- package/LICENSE +204 -0
- package/README.md +201 -2
- package/SECURITY.md +19 -0
- package/bin/gallivant.mjs +257 -0
- package/bin/package-plugin.mjs +16 -0
- package/bin/release-live.mjs +29 -0
- package/bin/release-package.mjs +23 -0
- package/bin/session-runtime.mjs +13 -0
- package/bin/task-delivery-state.mjs +12 -0
- package/docs/README.md +70 -0
- package/docs/agent-setup.md +214 -0
- package/docs/app.md +111 -0
- package/docs/architecture.md +265 -0
- package/docs/chains.md +102 -0
- package/docs/codex-cloud.md +284 -0
- package/docs/commands.md +620 -0
- package/docs/config.md +430 -0
- package/docs/execution.md +796 -0
- package/docs/guardrails.md +311 -0
- package/docs/install.md +338 -0
- package/docs/launch.md +42 -0
- package/docs/learning.md +369 -0
- package/docs/network-recovery.md +101 -0
- package/docs/openai-agents.md +457 -0
- package/docs/prd.md +246 -0
- package/docs/preparation.md +110 -0
- package/docs/release-qualification.md +196 -0
- package/docs/releasing.md +140 -0
- package/docs/runtime-environment.md +113 -0
- package/docs/task-delivery.md +476 -0
- package/docs/tasks.md +739 -0
- package/lib/aport.mjs +95 -0
- package/lib/attribution.mjs +95 -0
- package/lib/block.mjs +89 -0
- package/lib/capabilities.mjs +3 -0
- package/lib/chains.mjs +222 -0
- package/lib/codex_cloud.mjs +160 -0
- package/lib/codex_cloud_config.mjs +20 -0
- package/lib/codex_cloud_input.mjs +49 -0
- package/lib/codex_cloud_result.mjs +74 -0
- package/lib/commands/doctor.mjs +457 -0
- package/lib/commands/init.mjs +34 -0
- package/lib/commands/learn.mjs +54 -0
- package/lib/commands/new.mjs +47 -0
- package/lib/commands/ready.mjs +45 -0
- package/lib/commands/register.mjs +195 -0
- package/lib/commands/resume.mjs +224 -0
- package/lib/commands/serve.mjs +349 -0
- package/lib/commands/start.mjs +247 -0
- package/lib/commands/start_services.mjs +22 -0
- package/lib/commands/status.mjs +150 -0
- package/lib/commands/task.mjs +73 -0
- package/lib/commands/up.mjs +228 -0
- package/lib/commands/why.mjs +143 -0
- package/lib/config.mjs +461 -0
- package/lib/context.mjs +100 -0
- package/lib/control.mjs +202 -0
- package/lib/control_contract.mjs +16 -0
- package/lib/coordinator.mjs +19 -0
- package/lib/credential.mjs +161 -0
- package/lib/daemon.mjs +156 -0
- package/lib/deliverable.mjs +357 -0
- package/lib/events.mjs +261 -0
- package/lib/executable.mjs +31 -0
- package/lib/execution.mjs +631 -0
- package/lib/execution_contract.mjs +15 -0
- package/lib/execution_input.mjs +4 -0
- package/lib/execution_io.mjs +94 -0
- package/lib/execution_journal.mjs +69 -0
- package/lib/execution_preparation.mjs +29 -0
- package/lib/execution_result.mjs +266 -0
- package/lib/fs.mjs +133 -0
- package/lib/gh.mjs +289 -0
- package/lib/git.mjs +434 -0
- package/lib/harness.mjs +521 -0
- package/lib/harness_log.mjs +432 -0
- package/lib/hash.mjs +16 -0
- package/lib/identity.mjs +98 -0
- package/lib/learning.mjs +654 -0
- package/lib/learning_contract.mjs +2 -0
- package/lib/learning_data.mjs +135 -0
- package/lib/learning_input.mjs +111 -0
- package/lib/learning_qualification.mjs +67 -0
- package/lib/learning_signer.mjs +151 -0
- package/lib/learning_store.mjs +88 -0
- package/lib/log.mjs +53 -0
- package/lib/network_diagnostics.mjs +64 -0
- package/lib/network_recovery.mjs +42 -0
- package/lib/notify.mjs +58 -0
- package/lib/openai_agents.mjs +373 -0
- package/lib/openai_agents_config.mjs +14 -0
- package/lib/openai_agents_http.mjs +87 -0
- package/lib/openai_agents_input.mjs +15 -0
- package/lib/openai_agents_sources.mjs +112 -0
- package/lib/owner_delivery.mjs +443 -0
- package/lib/plugin_package.mjs +340 -0
- package/lib/prd.mjs +635 -0
- package/lib/preparation_agents.mjs +33 -0
- package/lib/preparation_codex.mjs +212 -0
- package/lib/probe.mjs +126 -0
- package/lib/prompt.mjs +238 -0
- package/lib/provider_health.mjs +55 -0
- package/lib/public-format.mjs +31 -0
- package/lib/public_output.mjs +49 -0
- package/lib/release_package.mjs +154 -0
- package/lib/repository_files.mjs +86 -0
- package/lib/run.mjs +385 -0
- package/lib/run_log.mjs +94 -0
- package/lib/runtime-environment.mjs +87 -0
- package/lib/runtime_dependencies.mjs +15 -0
- package/lib/runtime_node.mjs +107 -0
- package/lib/runtime_state.mjs +174 -0
- package/lib/sources/index.mjs +27 -0
- package/lib/sources/prd.mjs +50 -0
- package/lib/start_contract.mjs +37 -0
- package/lib/state.mjs +246 -0
- package/lib/task_allowance.mjs +31 -0
- package/lib/task_completion.mjs +61 -0
- package/lib/task_contract.mjs +450 -0
- package/lib/task_delivery.mjs +219 -0
- package/lib/task_delivery_cancel.mjs +34 -0
- package/lib/task_delivery_contract.mjs +61 -0
- package/lib/task_delivery_mcp.mjs +40 -0
- package/lib/task_delivery_store.mjs +43 -0
- package/lib/task_delivery_view.mjs +88 -0
- package/lib/task_environment.mjs +281 -0
- package/lib/task_prepare.mjs +224 -0
- package/lib/task_store.mjs +38 -0
- package/lib/tasks.mjs +513 -0
- package/lib/tick.mjs +1635 -0
- package/lib/time.mjs +36 -0
- package/lib/tracker/assets.mjs +43 -0
- package/lib/tracker/pages.mjs +320 -0
- package/lib/tracker/runs.mjs +102 -0
- package/lib/tracker/stream.mjs +74 -0
- package/lib/tracker_process.mjs +107 -0
- package/lib/verification_sandbox.mjs +181 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +605 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +156 -0
- package/node_modules/smol-toml/dist/error.d.ts +37 -0
- package/node_modules/smol-toml/dist/error.js +67 -0
- package/node_modules/smol-toml/dist/extract.js +289 -0
- package/node_modules/smol-toml/dist/index.cjs +770 -0
- package/node_modules/smol-toml/dist/index.d.ts +44 -0
- package/node_modules/smol-toml/dist/index.js +34 -0
- package/node_modules/smol-toml/dist/parse.d.ts +73 -0
- package/node_modules/smol-toml/dist/parse.js +164 -0
- package/node_modules/smol-toml/dist/primitive.js +151 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
- package/node_modules/smol-toml/dist/stringify.js +219 -0
- package/node_modules/smol-toml/dist/struct.js +156 -0
- package/node_modules/smol-toml/dist/util.d.ts +43 -0
- package/node_modules/smol-toml/dist/util.js +57 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/npm-shrinkwrap.json +1823 -0
- package/package.json +105 -4
- package/templates/claude-settings.json +18 -0
- package/templates/passport.gallivant.json +83 -0
- package/templates/plugin/bundled-runtime.json +26 -0
- package/templates/plugin/schemas/mcp.schema.json +120 -0
- package/templates/plugin/schemas/plugin.schema.json +65 -0
- package/templates/plugin/task-delivery.SKILL.md +60 -0
- package/templates/prd.md +32 -0
- package/templates/tracker/tracker.css +301 -0
- package/templates/tracker/tracker.js +721 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { acquireCliMutation } from "../daemon.mjs";
|
|
2
|
+
// `gallivant serve`: the tracker. One local page showing every entry and every run, with the log tailing live.
|
|
3
|
+
//
|
|
4
|
+
// Reads remain GET-only. One protected POST queues a local operator request; only the daemon
|
|
5
|
+
// applies it through the existing resume and review loop. No shell commands come from the browser.
|
|
6
|
+
//
|
|
7
|
+
// It binds to 127.0.0.1 and takes no auth, which is the same trust boundary as the CLI: anything that can reach it
|
|
8
|
+
// can already read ~/.gallivant. That is exactly why it must never bind to 0.0.0.0.
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { requestControl, lastMessage } from "../control.mjs";
|
|
12
|
+
import { model } from "./status.mjs";
|
|
13
|
+
import { isOurProcess, alive } from "../run.mjs";
|
|
14
|
+
import { executionRunner, executionLive, unsubmittedInitialDispatch } from "../execution.mjs";
|
|
15
|
+
import { logWindow } from "../run_log.mjs";
|
|
16
|
+
import { executionCapabilities } from "../harness.mjs";
|
|
17
|
+
import { redact } from "../execution_io.mjs";
|
|
18
|
+
import { readJson } from "../fs.mjs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
// `handleFor` from state.mjs is the canonical handle. This file used to spell the rule again with its own fallback,
|
|
21
|
+
// and two spellings of one rule is how the canonical one kept the `undefined#3` bug that never showed up here.
|
|
22
|
+
import { handleFor } from "../state.mjs";
|
|
23
|
+
import { streamRun, sliceRun, TAIL } from "../tracker/stream.mjs";
|
|
24
|
+
import { listRuns, listFlatRuns, runDir, sourceRunDir, validRunId } from "../tracker/runs.mjs";
|
|
25
|
+
import { ASSETS, themeOf } from "../tracker/assets.mjs";
|
|
26
|
+
import { dashboard, runPage, runsPage, page, esc, CSP, GROUPINGS } from "../tracker/pages.mjs";
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_PORT = 8791;
|
|
29
|
+
const HOST = "127.0.0.1";
|
|
30
|
+
const TOKENS = new WeakMap();
|
|
31
|
+
const tokenFor = ctx => {
|
|
32
|
+
if (!TOKENS.has(ctx)) TOKENS.set(ctx, randomBytes(32).toString("hex"));
|
|
33
|
+
return TOKENS.get(ctx);
|
|
34
|
+
};
|
|
35
|
+
// Check the peer and Host independently: loopback binding alone does not stop DNS rebinding.
|
|
36
|
+
const localRequest = req => {
|
|
37
|
+
if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket?.remoteAddress)) return false;
|
|
38
|
+
try {
|
|
39
|
+
const host = req.headers.host;
|
|
40
|
+
const url = new URL(`http://${host}`);
|
|
41
|
+
return ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)
|
|
42
|
+
&& url.host === host && Number(url.port || 80) === req.socket.localPort;
|
|
43
|
+
} catch { return false; }
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Headers every response carries. The CSP is explained in tracker/pages.mjs. */
|
|
47
|
+
const SECURITY = {
|
|
48
|
+
"content-security-policy": CSP,
|
|
49
|
+
"referrer-policy": "no-referrer",
|
|
50
|
+
"x-content-type-options": "nosniff",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const send = (res, code, type, body, cache = "no-store") => { res.writeHead(code, { "content-type": type, "cache-control": cache, ...SECURITY }); res.end(body); };
|
|
54
|
+
const html = (res, code, body) => send(res, code, "text/html; charset=utf-8", body);
|
|
55
|
+
const json = (res, code, value) => send(res, code, "application/json; charset=utf-8", JSON.stringify(value, null, 2));
|
|
56
|
+
const notFound = (res, what, view) => html(res, 404, page({ title: "not found - gallivant", theme: view?.theme, base: view?.base || "",
|
|
57
|
+
body: `<header class="top"><h1>404</h1></header><section class="main"><p>${esc(what)}</p><p><a href="${esc(view?.base || "")}/">back to the dashboard</a></p></section>` }));
|
|
58
|
+
|
|
59
|
+
const liveness = job => job?.execution?.provider && !job.bin ? executionLive(job) : !!job && job.exit === null && isOurProcess(job.pid, job.bin);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every run the tracker knows about: Gallivant's own, plus any flat run trees the caller pointed it at.
|
|
63
|
+
*
|
|
64
|
+
* `ctx.runSources` is a list of `{ label, dir }`. The point of it is that a run folder is a run folder: command
|
|
65
|
+
* center writes the same job.json and job.log under `<project>/.content-pipeline/runs/`, so the same table can show
|
|
66
|
+
* those rows rather than the operator keeping two tabs open. They are listed, never opened: there is no registry to
|
|
67
|
+
* validate an id against outside Gallivant's own tree, so nothing here builds a path from a query string for them.
|
|
68
|
+
*/
|
|
69
|
+
const allRuns = (ctx, entries, { entry = null } = {}) => {
|
|
70
|
+
const mine = listRuns(ctx.home, entries, { isRunning: liveness, entry });
|
|
71
|
+
// A filter names a Gallivant entry, and no foreign run belongs to one, so a filtered list is Gallivant's alone.
|
|
72
|
+
if (entry) return mine;
|
|
73
|
+
const theirs = (ctx.runSources || []).flatMap(s => listFlatRuns(s.dir, { source: s.label, isRunning: liveness }));
|
|
74
|
+
return [...mine, ...theirs].sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0) || b.run.localeCompare(a.run));
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** What the URL asks for about presentation: which palette to force, which grouping, and where we are mounted. */
|
|
78
|
+
const viewOf = (url, base = "", home = null) => {
|
|
79
|
+
const by = url.searchParams.get("by") || "";
|
|
80
|
+
return {
|
|
81
|
+
theme: themeOf(url.searchParams.get("theme")),
|
|
82
|
+
by: GROUPINGS.includes(by) ? by : GROUPINGS[0],
|
|
83
|
+
// The raw values, so a link out of one page keeps the reader's choices rather than resetting them.
|
|
84
|
+
query: { theme: themeOf(url.searchParams.get("theme")) || "" },
|
|
85
|
+
base,
|
|
86
|
+
home,
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve `?id=` to a registry entry. The query string is never joined to a path: an id is accepted only when the
|
|
92
|
+
* registry has it, so a traversal attempt (`../../etc`, `..%2f..`) simply does not match an entry and is a 404.
|
|
93
|
+
*/
|
|
94
|
+
const entryFrom = (ctx, url) => {
|
|
95
|
+
const id = url.searchParams.get("id") || "";
|
|
96
|
+
const entry = id ? ctx.state.get(id) : null;
|
|
97
|
+
return entry ? { id, entry } : null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Which run a page or a stream is about: `?run=` when the reader picked one from the runs list, otherwise the run
|
|
102
|
+
* this entry last dispatched. A `?run=` that is not a valid run id, or that names no folder, is refused rather than
|
|
103
|
+
* silently falling back to the current run, because a reader following a link deserves to know it was wrong.
|
|
104
|
+
*/
|
|
105
|
+
const runFor = (ctx, id, wanted) => {
|
|
106
|
+
const runner = executionRunner(ctx, id);
|
|
107
|
+
if (!wanted) { const cur = runner.current(); return cur ? { id: cur.id, exit: cur.exit, running: cur.running } : null; }
|
|
108
|
+
if (!validRunId(wanted)) return null;
|
|
109
|
+
const job = runner.read(wanted);
|
|
110
|
+
return job ? { id: job.id, exit: job.exit, running: liveness(job) } : null;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Which run directory a log request is about, or null.
|
|
115
|
+
*
|
|
116
|
+
* The two log routes (the live stream and the history window) address a run exactly the same way, so they resolve it
|
|
117
|
+
* the same way, here, once. Two spellings of one rule is how a traversal gets into the second one after the first is
|
|
118
|
+
* fixed: a run from a configured flat tree is addressed by its source label, matched against the configured list; one
|
|
119
|
+
* of Gallivant's own is addressed by a registry entry id and a run id, and the directory is rebuilt from those rather
|
|
120
|
+
* than from the query string. Nothing a query string contains is ever joined to a path as given.
|
|
121
|
+
*/
|
|
122
|
+
const logDirFor = (ctx, url) => {
|
|
123
|
+
const source = url.searchParams.get("source") || "";
|
|
124
|
+
const wanted = url.searchParams.get("run") || "";
|
|
125
|
+
if (source) {
|
|
126
|
+
const dir = sourceRunDir(ctx.runSources, source, wanted);
|
|
127
|
+
return dir ? { dir, isRunning: job => !!job && job.exit === null && alive(job.pid) } : null;
|
|
128
|
+
}
|
|
129
|
+
const hit = entryFrom(ctx, url);
|
|
130
|
+
if (!hit) return null;
|
|
131
|
+
const run = runFor(ctx, hit.id, wanted);
|
|
132
|
+
if (!run) return null;
|
|
133
|
+
const dir = runDir(ctx.home, ctx.state.entries(), hit.id, run.id);
|
|
134
|
+
return dir ? { dir, id: hit.id, runId: run.id, isRunning: liveness } : null;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// The route table, keyed by method and path, as in command-center's server.mjs. Anything not listed is a 404, and
|
|
138
|
+
// GET never changes state; the one write queues a request for the daemon.
|
|
139
|
+
export const routes = {
|
|
140
|
+
"GET /": (ctx, _req, res, url, v) => html(res, 200, dashboard(model(ctx), { clock: ctx.clock, ...v })),
|
|
141
|
+
|
|
142
|
+
"GET /runs": (ctx, _req, res, url, v) => {
|
|
143
|
+
const entries = ctx.state.entries();
|
|
144
|
+
const filter = url.searchParams.get("entry") || "";
|
|
145
|
+
// A filter naming an entry the registry does not have lists nothing rather than everything, so a stale link
|
|
146
|
+
// never quietly widens what it shows.
|
|
147
|
+
const known = filter && entries.some(([id]) => id === filter);
|
|
148
|
+
const rows = filter && !known ? [] : allRuns(ctx, entries, { entry: filter || null });
|
|
149
|
+
// The run the reader picked in the sidebar, matched against the rows already listed rather than resolved from
|
|
150
|
+
// the query string, so `open` can name nothing that is not on this page anyway.
|
|
151
|
+
const want = url.searchParams.get("open") || "", from = url.searchParams.get("source") || "";
|
|
152
|
+
const open = want ? rows.find(r => r.run === want && (r.source || "") === from) || null : null;
|
|
153
|
+
html(res, 200, runsPage(rows, { entry: filter, open, ...v }));
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
"GET /run": (ctx, req, res, url, v) => {
|
|
157
|
+
const hit = entryFrom(ctx, url);
|
|
158
|
+
if (!hit) return notFound(res, "No entry with that id. The dashboard lists the ones the registry has.", v);
|
|
159
|
+
const { id, entry } = hit;
|
|
160
|
+
const wanted = url.searchParams.get("run") || "";
|
|
161
|
+
const run = runFor(ctx, id, wanted);
|
|
162
|
+
if (wanted && !run) return notFound(res, "No such run for that entry.", v);
|
|
163
|
+
// The divergence comes off the model's own row rather than being recomputed here: the model is built for this
|
|
164
|
+
// response anyway, and one comparison per request beats two that could disagree.
|
|
165
|
+
const m = model(ctx);
|
|
166
|
+
const row = [...m.needs_you, ...m.running, ...m.done].find(r => r.id === id) || null;
|
|
167
|
+
let initialLaunch = false;
|
|
168
|
+
try { initialLaunch = !!unsubmittedInitialDispatch(entry, executionRunner(ctx, id)); } catch { /* Unresolved or stopped ownership cannot enable a launch control. */ }
|
|
169
|
+
html(res, 200, runPage({ id, entry, handle: handleFor(id, entry), run, initialLaunch, maxIterations: ctx.config.max_iterations, model: m, divergence: row?.divergence ?? null, controlToken: localRequest(req) ? tokenFor(ctx) : null, ...v }));
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
"GET /api/run/control": (ctx, _req, res, url) => {
|
|
173
|
+
const hit = entryFrom(ctx, url);
|
|
174
|
+
if (!hit) return json(res, 404, { error: "unknown entry" });
|
|
175
|
+
json(res, 200, { control: hit.entry.control || null, lastMessage: lastMessage(hit.entry), state: hit.entry.state, reason: hit.entry.reason });
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
"POST /api/run/control": async (ctx, req, res, url) => {
|
|
179
|
+
if (!localRequest(req) || req.headers.origin !== `http://${req.headers.host}`
|
|
180
|
+
|| req.headers["x-gallivant-token"] !== tokenFor(ctx)) return json(res, 403, { error: "Open this page on localhost and reload before trying again." });
|
|
181
|
+
if (req.headers["content-type"]?.split(";")[0] !== "application/json") return json(res, 415, { error: "JSON required" });
|
|
182
|
+
const hit = entryFrom(ctx, url);
|
|
183
|
+
if (!hit) return json(res, 404, { error: "unknown entry" });
|
|
184
|
+
const chunks = [];
|
|
185
|
+
let size = 0;
|
|
186
|
+
for await (const chunk of req) {
|
|
187
|
+
size += Buffer.byteLength(chunk);
|
|
188
|
+
if (size > 32768) return json(res, 413, { error: "Request too large" });
|
|
189
|
+
chunks.push(Buffer.from(chunk));
|
|
190
|
+
}
|
|
191
|
+
let input;
|
|
192
|
+
try { input = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
|
|
193
|
+
catch { return json(res, 400, { error: "Invalid JSON" }); }
|
|
194
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return json(res, 400, { error: "Invalid request" });
|
|
195
|
+
try {
|
|
196
|
+
const owner = acquireCliMutation(ctx.home);
|
|
197
|
+
try { json(res, 202, requestControl(ctx, { handle: hit.id, action: input.action, note: input.note, interrupt: input.interrupt })); }
|
|
198
|
+
finally { owner.release(); }
|
|
199
|
+
}
|
|
200
|
+
catch (e) { json(res, 409, { error: e.message }); }
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
// The stylesheet and the script, served rather than inlined so the CSP needs no nonce and no hash. They cannot
|
|
204
|
+
// change under a running server, so they are the one thing here worth caching in the browser.
|
|
205
|
+
"GET /tracker.css": (_ctx, _req, res) => send(res, 200, ASSETS["/tracker.css"].type, ASSETS["/tracker.css"].body, "max-age=60"),
|
|
206
|
+
"GET /tracker.js": (_ctx, _req, res) => send(res, 200, ASSETS["/tracker.js"].type, ASSETS["/tracker.js"].body, "max-age=60"),
|
|
207
|
+
|
|
208
|
+
"GET /api/status": (ctx, _req, res) => json(res, 200, model(ctx)),
|
|
209
|
+
"GET /api/capabilities": (_ctx, _req, res) => json(res, 200, { version: 1, providers: executionCapabilities() }),
|
|
210
|
+
|
|
211
|
+
"GET /api/runs": (ctx, _req, res, url) => {
|
|
212
|
+
const filter = url.searchParams.get("entry") || "";
|
|
213
|
+
json(res, 200, { runs: allRuns(ctx, ctx.state.entries(), { entry: filter || null }) });
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
// The live log of a run, as server-sent events.
|
|
217
|
+
//
|
|
218
|
+
// The path is /output rather than /log on purpose. "log?id=" has the shape of a tracking pixel, and content
|
|
219
|
+
// blockers that filter on the URL (uBlock Origin Lite's default rules, via declarativeNetRequest) reject it
|
|
220
|
+
// inside the browser before the request is sent: Chrome reports blocked:other, the server sees nothing at all,
|
|
221
|
+
// and the pane waits on a stream that was never opened. Ordinary fetches to /api/run/status are untouched, which
|
|
222
|
+
// is what made this look like a streaming bug rather than a blocked request.
|
|
223
|
+
"GET /api/run/output": (ctx, req, res, url) => {
|
|
224
|
+
const hit = logDirFor(ctx, url);
|
|
225
|
+
if (!hit) return json(res, 404, { error: "unknown run" });
|
|
226
|
+
streamRun(res, hit.dir, { req, isRunning: hit.isRunning });
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
// A window of a run's log, older than what the pane is already showing. A read, not a stream, so it is its own
|
|
230
|
+
// path rather than a mode of /output: the tail on open exists because thousands of lines as individual
|
|
231
|
+
// server-sent events stall the browser until it drops the connection, and answering "give me the previous 400" as
|
|
232
|
+
// one JSON body is the shape that does not have that problem. Same addressing and the same validation as /output,
|
|
233
|
+
// through the same `logDirFor`, so a traversal that fails on one fails on the other by construction.
|
|
234
|
+
//
|
|
235
|
+
// `?before=<id>` is the id of the oldest line the pane has, so the answer is the window before it, and `before` in
|
|
236
|
+
// the answer is how many lines are older still. No `before` means the window the tail did not show.
|
|
237
|
+
"GET /api/run/history": (ctx, _req, res, url) => {
|
|
238
|
+
const hit = logDirFor(ctx, url);
|
|
239
|
+
if (!hit) return json(res, 404, { error: "unknown run" });
|
|
240
|
+
const raw = url.searchParams.get("before");
|
|
241
|
+
// A non-numeric `before` is not a request for the start of the file, it is a broken link, and answering it with
|
|
242
|
+
// the first 400 lines would look like the feature working. Absent is the one case that means "the default".
|
|
243
|
+
if (raw !== null && !/^-?\d+$/.test(raw)) return json(res, 400, { error: "before must be a line number" });
|
|
244
|
+
json(res, 200, { run: url.searchParams.get("run") || "", tail: TAIL, ...sliceRun(hit.dir, { end: raw === null ? null : Number(raw) }) });
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
// Kept so an old bookmark or a copied URL still resolves.
|
|
248
|
+
"GET /api/run/log": (_ctx, _req, res, url, v) => { res.writeHead(302, { location: `${v.base}/api/run/output${url.search || ""}`, ...SECURITY }); res.end(); },
|
|
249
|
+
|
|
250
|
+
"GET /api/run/status": (ctx, _req, res, url) => {
|
|
251
|
+
const hit = entryFrom(ctx, url);
|
|
252
|
+
if (!hit) return json(res, 404, { error: "unknown entry" });
|
|
253
|
+
const run = runFor(ctx, hit.id, url.searchParams.get("run") || "");
|
|
254
|
+
if (!run) return json(res, 404, { error: "no run" });
|
|
255
|
+
const window = logWindow(executionRunner(ctx, hit.id).dirOf(run.id));
|
|
256
|
+
json(res, 200, { entry: hit.id, run: run.id, running: run.running, exit: run.exit, lines: window.total, last: window.lines.at(-1) || "", ...(window.gap ? { gap: window.gap } : {}) });
|
|
257
|
+
},
|
|
258
|
+
"GET /api/run/result": (ctx, _req, res, url) => {
|
|
259
|
+
const hit = logDirFor(ctx, url);
|
|
260
|
+
if (!hit) return json(res, 404, { error: "unknown run" });
|
|
261
|
+
const result = readJson(join(hit.dir, "result.json"));
|
|
262
|
+
if (!result) return json(res, 404, { error: "result has not been collected" });
|
|
263
|
+
// Private results retain exact ownership and artifacts. Public responses
|
|
264
|
+
// use the summary and the run's supplied-secret redaction, never payloads.
|
|
265
|
+
const artifacts = (Array.isArray(result.artifacts) ? result.artifacts.slice(0, 100)
|
|
266
|
+
: Array.isArray(result.files) ? result.files.slice(0, 100).map(f => ({ path: f.path, hash: f.afterHash })) : [])
|
|
267
|
+
.map(f => ({ path: f.path, hash: f.hash }));
|
|
268
|
+
const publication = readJson(join(hit.dir, "result-application.json"));
|
|
269
|
+
if (Array.isArray(publication?.paths)) publication.paths = artifacts.map(f => f.path);
|
|
270
|
+
const execution = hit.id ? executionRunner(ctx, hit.id) : null;
|
|
271
|
+
const outcome = execution?.outcome?.(hit.runId) ?? result.outcome;
|
|
272
|
+
const view = { version: result.version, taskId: result.taskId, runId: result.runId, inputHead: result.inputHead ?? null,
|
|
273
|
+
providerJobId: result.providerJobId ?? null, sessionId: result.sessionId ?? null, turnId: result.turnId ?? null,
|
|
274
|
+
outcome, artifacts, publication, verification: readJson(join(hit.dir, "verification.json")) };
|
|
275
|
+
json(res, 200, execution?.sanitize ? execution.sanitize(view, hit.runId) : redact(view));
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Route one request. Exported so the tests can drive every handler with fake request and response objects, and so a
|
|
281
|
+
* host server can delegate a prefix to it (see `mount`).
|
|
282
|
+
*
|
|
283
|
+
* `base` is the mount path to strip from the request and to put back on every URL the pages emit. Empty means this
|
|
284
|
+
* tracker owns the origin, which is what `gallivant serve` does.
|
|
285
|
+
*/
|
|
286
|
+
export function handle(ctx, req, res, { base = "", home = null } = {}) {
|
|
287
|
+
const url = new URL(req.url, `http://${HOST}`);
|
|
288
|
+
// Under a mount, `/gallivant/runs` is this router's `/runs`, and `/gallivant` itself is its `/`.
|
|
289
|
+
const path = base && url.pathname.startsWith(base) ? url.pathname.slice(base.length) || "/" : url.pathname;
|
|
290
|
+
const route = routes[`${req.method} ${path}`];
|
|
291
|
+
const view = viewOf(url, base, home);
|
|
292
|
+
if (!route) return path.startsWith("/api/") ? json(res, 404, { error: "not found" }) : notFound(res, `No page at ${esc(url.pathname)}.`, view);
|
|
293
|
+
const failed = e => {
|
|
294
|
+
ctx.log?.warn?.(`serve ${req.method} ${url.pathname}: ${e.message}`);
|
|
295
|
+
// The reader gets the status, never the exception text: a message can carry a path or a token, and this page is
|
|
296
|
+
// the one thing in Gallivant a browser renders.
|
|
297
|
+
if (res.headersSent) return res.end();
|
|
298
|
+
return url.pathname.startsWith("/api/")
|
|
299
|
+
? json(res, 500, { error: "internal error" })
|
|
300
|
+
: html(res, 500, page({ title: "error - gallivant", body: `<header class="top"><h1>500</h1></header><section class="main"><p>Something went wrong. The terminal running <code>gallivant serve</code> has the reason.</p></section>` }));
|
|
301
|
+
};
|
|
302
|
+
try {
|
|
303
|
+
const result = route(ctx, req, res, url, view);
|
|
304
|
+
return result?.catch ? result.catch(failed) : result;
|
|
305
|
+
} catch (e) { return failed(e); }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Mount the tracker inside a server somebody else owns, so one process and one port serve both. The host matches the
|
|
310
|
+
* prefix and hands the request over; everything below that prefix is this router's, including its own 404.
|
|
311
|
+
*
|
|
312
|
+
* import { mount } from "@uchibeke/gallivant/lib/commands/serve.mjs";
|
|
313
|
+
* const tracker = mount(makeContext(), { base: "/gallivant" });
|
|
314
|
+
* createServer((req, res) => {
|
|
315
|
+
* if (tracker.owns(req)) return tracker.handle(req, res);
|
|
316
|
+
* ...the host's own routes
|
|
317
|
+
* }).listen(8790, "127.0.0.1");
|
|
318
|
+
*
|
|
319
|
+
* `home` is an optional `{ label, url }` link back out to the host, so the switch works both ways rather than being a
|
|
320
|
+
* one-way trip into the tracker.
|
|
321
|
+
*
|
|
322
|
+
* The host keeps its own auth and its own bind address. Two things follow from that, and they are the host's to get
|
|
323
|
+
* right rather than something this module can enforce: a host that answers on a public address publishes the
|
|
324
|
+
* tracker there too, and a host with auth should put this behind it, because nothing here asks who is calling.
|
|
325
|
+
*/
|
|
326
|
+
export function mount(ctx, { base = "/gallivant", home = null } = {}) {
|
|
327
|
+
const prefix = "/" + String(base).replace(/^\/+|\/+$/g, "");
|
|
328
|
+
if (prefix === "/") throw new Error("mount needs a non-empty base path, for example /gallivant");
|
|
329
|
+
return {
|
|
330
|
+
base: prefix,
|
|
331
|
+
/** Whether this request belongs to the tracker: the prefix itself, or anything under it. */
|
|
332
|
+
owns: req => { const p = new URL(req.url, `http://${HOST}`).pathname; return p === prefix || p.startsWith(prefix + "/"); },
|
|
333
|
+
handle: (req, res) => handle(ctx, req, res, { base: prefix, home }),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function serve(ctx, { port = DEFAULT_PORT, instance = null } = {}) {
|
|
338
|
+
const server = createServer((req, res) => {
|
|
339
|
+
if (instance && req.method === "GET" && req.url === "/api/health") return localRequest(req)
|
|
340
|
+
? json(res, 200, { instance }) : json(res, 403, { error: "local requests only" });
|
|
341
|
+
return handle(ctx, req, res);
|
|
342
|
+
});
|
|
343
|
+
return new Promise((resolve, reject) => {
|
|
344
|
+
server.on("error", reject);
|
|
345
|
+
// 127.0.0.1, never 0.0.0.0 and never a hostname that could resolve to a LAN address. There is no auth here and
|
|
346
|
+
// the pages show branch names, paths and log output, so reachability from another machine is the whole risk.
|
|
347
|
+
server.listen(port, HOST, () => { ctx.log.info(`serve: http://${HOST}:${port}`); resolve(server); });
|
|
348
|
+
});
|
|
349
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// `gallivant start <prd path>`: one brief becomes one running session in its own checkout.
|
|
2
|
+
//
|
|
3
|
+
// The brief arrives as a Request (lib/sources/), which already names the target repository. `start` no longer works
|
|
4
|
+
// the repo out from the file's directory: it reads `request.repo`, so the only thing that has to know where a brief
|
|
5
|
+
// lives is the source that read it.
|
|
6
|
+
import { verifyCommands, prdFingerprint, prdDecision, sourcePathsOf } from "../prd.mjs";
|
|
7
|
+
import { requestFor } from "../sources/index.mjs";
|
|
8
|
+
import { startPrompt } from "../prompt.mjs";
|
|
9
|
+
import { remoteInput } from "../execution_input.mjs";
|
|
10
|
+
import { learningInput, learningDispatch, learningDelivered, learningDeliveryPatch } from "../learning_input.mjs";
|
|
11
|
+
import { capabilitiesFor, adapterFor, buildRun } from "../harness.mjs";
|
|
12
|
+
import { newEntry, entryId, STATES } from "../state.mjs";
|
|
13
|
+
import { branchFor, runEnv, worktreePath } from "../git.mjs";
|
|
14
|
+
import { resolveCredentialFor, describe as describeCredential } from "../credential.mjs";
|
|
15
|
+
import { requireIdentity, describeIdentity } from "../identity.mjs";
|
|
16
|
+
import { rememberRepo, resolveContextPaths, resolveBase, configuredBase, repoNames } from "../config.mjs";
|
|
17
|
+
import { writeRepositoryText } from "../repository_files.mjs";
|
|
18
|
+
import { validateStartRequest, captureStartInput, checkBaseFiles } from "../start_contract.mjs";
|
|
19
|
+
import { nowIso, floorSecond } from "../time.mjs";
|
|
20
|
+
import { requireHarness } from "../probe.mjs";
|
|
21
|
+
import { criteriaFromPrd, receiptInstructions } from "../deliverable.mjs";
|
|
22
|
+
import { executionRunner, EXECUTION_VERSION } from "../execution.mjs";
|
|
23
|
+
import { taskExecutionConfig } from "../task_allowance.mjs";
|
|
24
|
+
|
|
25
|
+
export async function start(ctx, options) {
|
|
26
|
+
ctx = { ...ctx, config: taskExecutionConfig(ctx.config, options.task) };
|
|
27
|
+
const progress = { cloneAttempted: false, spawnAttempted: false };
|
|
28
|
+
try { return await startRequest(ctx, options, progress); }
|
|
29
|
+
catch (error) {
|
|
30
|
+
// A clone can leave an owned checkout even if it throws or source delivery
|
|
31
|
+
// fails before spawn. Only a pre-clone failure is a clean retry boundary.
|
|
32
|
+
error.startUncertain = progress.cloneAttempted || progress.spawnAttempted;
|
|
33
|
+
if (progress.remoteRegistered && !progress.spawnAttempted) error.message += `; the unsubmitted task and checkout at ${progress.clone.worktree} are retained; repair preparation and explicitly resume the registered task`;
|
|
34
|
+
else if (progress.cloneAttempted && !progress.spawnAttempted) error.message += `; start ownership and the clone at ${progress.clone.worktree} are retained; inspect its snapshots and start claim before recovery`;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function startRequest(ctx, { path, base: askedBase = null, chain = null, request: prepared = null, task = null }, progress) {
|
|
40
|
+
const { config, state, gh, git, log, runnerFor, writePrd, writeSource, readSource } = ctx;
|
|
41
|
+
// Intake supplies an immutable, repository-resolved draft snapshot. All gates,
|
|
42
|
+
// source capture and dispatch ownership below remain shared with file starts.
|
|
43
|
+
const request = prepared ?? await requestFor(path, { git, gh });
|
|
44
|
+
// The gate. Every rule that applies to this source, every time, no override: a brief that fails cannot start.
|
|
45
|
+
// A `context_paths:` key in the frontmatter adds to the config list for this brief alone. It is held to exactly
|
|
46
|
+
// the same rules, so a PRD cannot hand a session the home directory or the Gallivant home that the config file is
|
|
47
|
+
// refused for. This is the operator's list either way: the daemon's later runs read the snapshot written below,
|
|
48
|
+
// not the copy in the checkout the session can edit.
|
|
49
|
+
validateStartRequest(request, config, path);
|
|
50
|
+
|
|
51
|
+
// The source resolved these four by asking git which checkout the file is in and gh what that repository is
|
|
52
|
+
// called. A PRD outside a checkout fails there, with git's own message, before anything is recorded.
|
|
53
|
+
//
|
|
54
|
+
// `checkout` is the operator's own tree, and it is recorded on the entry from here on. The source has always known
|
|
55
|
+
// it (it is where `relPath` is relative TO) and `start` used to drop it on the floor, which is why nothing could
|
|
56
|
+
// compare the daemon's snapshot with the brief the operator is actually looking at: `entry.prd` is a relative path
|
|
57
|
+
// and there was no second half to join it to. It is the operator's path, never the session's, so nothing a session
|
|
58
|
+
// does can move it, and the daemon reads it for exactly one thing: the divergence check.
|
|
59
|
+
const { repo: nameWithOwner, defaultBranch, relPath: prdRel, checkout } = request;
|
|
60
|
+
const id = entryId(nameWithOwner, request.id);
|
|
61
|
+
if (state.get(id)) throw new Error(`${id} already exists; \`gallivant why ${id}\` explains it, \`gallivant remove ${id}\` forgets it`);
|
|
62
|
+
|
|
63
|
+
// The base branch this pull request will target, resolved once here and then carried everywhere it is needed: the
|
|
64
|
+
// branch the clone is cut from, the instruction the session is given, and the branch the daemon merges in before
|
|
65
|
+
// every later round. `resolveBase` owns the precedence (flag, then this repository's config entry, then the
|
|
66
|
+
// repository's default branch), so there is one definition rather than three agreeing by luck.
|
|
67
|
+
const selection = resolveBase({ config, repo: nameWithOwner, defaultBranch, asked: askedBase });
|
|
68
|
+
const base = selection.base;
|
|
69
|
+
// Intake pins the resolved base, including its original provenance. Passing
|
|
70
|
+
// that frozen value must not turn a default into a persisted operator override.
|
|
71
|
+
const baseSource = task?.baseSource || selection.source;
|
|
72
|
+
if (!base) throw new Error(`no base branch for ${nameWithOwner}: GitHub reported no default branch and none is configured. Set one with \`gallivant start ${path} --base <branch>\`.`);
|
|
73
|
+
// A base that is not on the remote must fail HERE. Left to `gh pr create --base`, it fails after the session has
|
|
74
|
+
// implemented the entire brief, run the tests and spent the budget, with the work stranded on a branch nobody asked
|
|
75
|
+
// for. One API call before anything is cloned is the whole cost of not doing that.
|
|
76
|
+
//
|
|
77
|
+
// `branchExists` returns false for HTTP404; transport/CLI failures throw a
|
|
78
|
+
// bounded credential-redacted diagnostic before any clone or provider dispatch.
|
|
79
|
+
if (!await gh.branchExists(nameWithOwner, base)) {
|
|
80
|
+
const how = baseSource === "flag" ? "--base names it" : baseSource === "config" ? `repos in config.json names it for ${nameWithOwner}` : `it is ${nameWithOwner}'s default branch`;
|
|
81
|
+
throw new Error(`base branch "${base}" is not on ${nameWithOwner} (${how}), or GitHub could not be asked. Nothing was cloned and nothing was started. Check the branch name, or run \`gh api repos/${nameWithOwner}/branches/${base}\` to see what GitHub says.`);
|
|
82
|
+
}
|
|
83
|
+
if (baseSource !== "default") log.info(`${id}: base branch ${base} (from ${baseSource === "flag" ? "--base" : "config"}), not ${nameWithOwner}'s default ${defaultBranch ?? "branch"}`);
|
|
84
|
+
|
|
85
|
+
// Nothing is recorded or cloned for a harness too old to honour its adapter, or one that is signed out.
|
|
86
|
+
await requireHarness(request.harness, config);
|
|
87
|
+
|
|
88
|
+
// A credential, before anything is cloned or spawned.
|
|
89
|
+
//
|
|
90
|
+
// This refusal is the defect being fixed. A run with no credential implements the whole brief, commits it, and then
|
|
91
|
+
// cannot push: the money is spent, the work is stranded in a worktree, and nothing warned anybody. So the credential
|
|
92
|
+
// is resolved here, where there is an operator reading the output, and a run that could not push never starts.
|
|
93
|
+
//
|
|
94
|
+
// There is no checkout yet, so the host reads as github.com, which is what `gh repo clone owner/name` resolves.
|
|
95
|
+
// An enterprise install is the case this cannot see from here, and `noCredentialMessage` says so by name: the
|
|
96
|
+
// answer there is `github_token_file`, rather than sending a github.com token to a host it cannot authenticate.
|
|
97
|
+
const credential = await resolveCredentialFor(ctx);
|
|
98
|
+
if (!credential.token) throw new Error(credential.why);
|
|
99
|
+
log.info(`${id}: GitHub credential from ${describeCredential(credential)}`);
|
|
100
|
+
// And an identity, refused here for the same reason and in the same place. A run commits as the operator, so with no
|
|
101
|
+
// identity on the machine every commit the session makes would fail; refusing before the clone means no money spent.
|
|
102
|
+
const identity = await requireIdentity(ctx);
|
|
103
|
+
log.info(`${id}: commits as ${describeIdentity(identity)}`);
|
|
104
|
+
|
|
105
|
+
const branch = branchFor(request.id);
|
|
106
|
+
// Everything the run needs, worked out BEFORE the clone, because the clone is the one step here that leaves
|
|
107
|
+
// something behind when a later one throws. `buildRun` refuses a harness that cannot grant the configured
|
|
108
|
+
// `context_paths` as read-only, and it used to be called four lines after `git.create`: the refusal arrived with
|
|
109
|
+
// the repository already cloned and nothing in the registry, so the next `gallivant start` failed on `git.create`
|
|
110
|
+
// ("worktree already exists") and the operator had to find and delete the directory themselves. Nothing in the
|
|
111
|
+
// spec needs the checkout to exist: `worktreePath` is a pure function of the config, the repo and the PRD id, and
|
|
112
|
+
// `git.create` clones to that same path, so the `cwd` the spec carries is knowable first.
|
|
113
|
+
const worktreePlanned = worktreePath(config, nameWithOwner, request.id);
|
|
114
|
+
const remote = capabilitiesFor(request.harness).transport === "remote";
|
|
115
|
+
let session = remote ? null : adapterFor(request.harness).newSession();
|
|
116
|
+
const receipt = config.deliverable ? receiptInstructions(criteriaFromPrd(request), id) : null;
|
|
117
|
+
// The list the brief declared, which the contract rule has already proved readable, so the prompt can name the files
|
|
118
|
+
// before they are snapshotted below and there is no second source of truth about what a brief carries.
|
|
119
|
+
const declared = sourcePathsOf(request);
|
|
120
|
+
const prompt = startPrompt({ prd: request, repo: nameWithOwner, branch, base, verify: verifyCommands(request), receipt, carried: declared });
|
|
121
|
+
// Read-only extras for this session: the config list plus this brief's own frontmatter, merged in one place.
|
|
122
|
+
const contextPaths = resolveContextPaths(config, request);
|
|
123
|
+
if (remote && contextPaths.length) throw Object.assign(new Error("hosted backend cannot enforce private read-only context_paths"), { code: "EXECUTION_POLICY" });
|
|
124
|
+
let spec = remote ? null : buildRun({ harness: request.harness, config, mode: "start", session, prompt, cwd: worktreePlanned, contextPaths });
|
|
125
|
+
const input = captureStartInput(request, base);
|
|
126
|
+
if (task && input.digest !== task.digest) throw Object.assign(new Error("approved task input changed; validate the current revision before starting"), { code: "TASK_CONFLICT" });
|
|
127
|
+
if (chain && input.digest !== chain.digest) throw new Error(`queued PRD or sources changed for ${id}; retry the chain with --resnapshot after reviewing the changes`);
|
|
128
|
+
const claim = state.claimStart(id, chain, task);
|
|
129
|
+
try {
|
|
130
|
+
|
|
131
|
+
// The daemon's own copy of the brief: fix rounds and the completion gate read this, never the checkout.
|
|
132
|
+
writePrd(id, request.text);
|
|
133
|
+
|
|
134
|
+
// The files the brief carries, snapshotted beside it and for the same reason. The contract rule has already read
|
|
135
|
+
// every one of them, so a failure here is a race (an edit or a delete between `ready` and now) rather than a mistyped
|
|
136
|
+
// path, and it must still be loud: the session would otherwise start against a brief citing a document that is not
|
|
137
|
+
// there. This is above the clone because everything that can throw belongs before the irreversible step.
|
|
138
|
+
const carried = [];
|
|
139
|
+
for (const [rel, text] of input.sources) {
|
|
140
|
+
writeSource(id, rel, text);
|
|
141
|
+
carried.push(rel);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
progress.clone = { worktree: worktreePlanned, base, branch };
|
|
145
|
+
state.markStartClone(id, claim.token, progress.clone);
|
|
146
|
+
progress.cloneAttempted = true;
|
|
147
|
+
let worktree;
|
|
148
|
+
try { worktree = await git.create({ nameWithOwner, prdId: request.id, base }); }
|
|
149
|
+
catch (error) {
|
|
150
|
+
// Keep ownership for unknown/partial failures. A known refusal before the
|
|
151
|
+
// clone executor is invoked is the same clean retry boundary as preflight.
|
|
152
|
+
if (error.cloneAttempted === false) progress.cloneAttempted = false;
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
if (chain) {
|
|
156
|
+
for (const sha of chain.after) {
|
|
157
|
+
if (!await git.containsCommit(worktree, sha)) throw new Error(`the fresh ${base} checkout does not contain predecessor merge ${sha}; nothing spawned`);
|
|
158
|
+
}
|
|
159
|
+
await checkBaseFiles(input, chain.baseFiles, rel => git.fileAt(worktree, "HEAD", rel));
|
|
160
|
+
}
|
|
161
|
+
// The clone is where the process actually runs, so it is what the spawn below is given. The spec's own `cwd` was
|
|
162
|
+
// `worktreePath` for the same repo and PRD id, which is what `git.create` returns; no adapter reads `ctx.cwd` today,
|
|
163
|
+
// so the two are only ever compared here. A relocating `git.create` would want this to be a refusal rather than a
|
|
164
|
+
// note, and if `cwd` ever reaches argv it has to become one.
|
|
165
|
+
if (worktree !== worktreePlanned) log.warn(`${id}: the checkout landed at ${worktree}, not the ${worktreePlanned} the run spec was built for`);
|
|
166
|
+
const publicSince = floorSecond(nowIso());
|
|
167
|
+
let remoteRegistered = false;
|
|
168
|
+
if (remote) {
|
|
169
|
+
state.validateStart(id, claim.token, chain);
|
|
170
|
+
const pending = newEntry({ prd: prdRel, harness: request.harness, session: null, repo: nameWithOwner, branch, worktree, state: STATES.needsHuman, nowIso: nowIso(), checkout, prdFingerprint: prdFingerprint(request), prdDecision: prdDecision(request), base, sources: carried });
|
|
171
|
+
// The clone is owned, but snapshot delivery and read-only API preparation
|
|
172
|
+
// can still fail. Consume the claim into an actionable entry before them;
|
|
173
|
+
// preserve the checkout rather than silently deleting unfinished files.
|
|
174
|
+
state.putStarted(id, { ...pending, execution: { version: EXECUTION_VERSION, operationId: claim.token }, initial_dispatch: { version: 1, operationId: claim.token, status: "unsubmitted" }, reason: "launch-preparation", public_since: publicSince, ...(task ? { task } : {}), ...(chain ? { chain: { name: chain.name, index: chain.index, dispatch: chain.dispatch } } : {}), last: { at: nowIso(), run: null, exit: null, event: "managed launch preparation pending; repair prerequisites and explicitly resume" } }, claim.token);
|
|
175
|
+
remoteRegistered = progress.remoteRegistered = true;
|
|
176
|
+
}
|
|
177
|
+
// The PRD usually only exists in the operator's own checkout, so the fresh clone off the base branch does not
|
|
178
|
+
// have it. Write it in at the same relative path before the session starts, so the branch carries the brief and
|
|
179
|
+
// the PR can link it. The daemon's snapshot above stays the authoritative copy for the completion gate; this one
|
|
180
|
+
// is writable by the session and is never read back for a decision.
|
|
181
|
+
writeRepositoryText(worktree, prdRel, request.text);
|
|
182
|
+
// The carried files, written from the daemon's snapshot rather than from the operator's tree. Reading the snapshot is
|
|
183
|
+
// the point: it is the copy the gate judges, so the session and the gate cannot be handed different bytes even if the
|
|
184
|
+
// operator edits the file in the second between the two writes. Same 0o644/0o755 as the brief, because these are
|
|
185
|
+
// files in a source tree and must not be clamped to daemon-state modes.
|
|
186
|
+
for (const rel of carried) writeRepositoryText(worktree, rel, readSource(id, rel));
|
|
187
|
+
if (carried.length) log.info(`${id}: carried ${carried.length} file${carried.length === 1 ? "" : "s"} from ${request.checkout} into the checkout`);
|
|
188
|
+
if (!remote) state.validateStart(id, claim.token, chain);
|
|
189
|
+
const execution = executionRunner(ctx, id, request.harness);
|
|
190
|
+
const managed = await learningInput(ctx, id, { repo: nameWithOwner, harness: request.harness, session: null, prd: prdRel, sources: carried }, { operationId: claim.token, sessionId: null, recordedSessionId: session, inputHead: git.head ? await git.head(worktree) : null, prompt });
|
|
191
|
+
const { prompt: managedPrompt, ...dispatchInput } = managed;
|
|
192
|
+
if (!remote && managedPrompt !== prompt) spec = buildRun({ harness: request.harness, config, mode: "start", session, prompt: managedPrompt, cwd: worktree, contextPaths });
|
|
193
|
+
let job;
|
|
194
|
+
// Register the remote ownership before I/O. A launcher crash or lost reply
|
|
195
|
+
// must leave an entry the existing coordinator can reconcile, with no second
|
|
196
|
+
// start claim. Keep it actionable until exact launch identities are known.
|
|
197
|
+
if (remote) {
|
|
198
|
+
const preparedInput = await remoteInput(ctx, id, { harness: request.harness, prd: prdRel, worktree, checkout, repo: nameWithOwner, branch, base, sources: carried, task }, { ...dispatchInput, name: "start", prompt: managedPrompt }, contextPaths);
|
|
199
|
+
await execution.prepare(preparedInput);
|
|
200
|
+
state.update(id, { initial_dispatch: { version: 1, operationId: claim.token, status: "dispatching" }, reason: "dispatch-intent", last: { at: nowIso(), run: null, exit: null, event: "managed dispatch pending" } });
|
|
201
|
+
progress.spawnAttempted = true;
|
|
202
|
+
try { job = await learningDispatch(ctx, id, managed, () => execution.submit(preparedInput)); session = job.execution.sessionId; }
|
|
203
|
+
catch (error) {
|
|
204
|
+
const current = execution.current();
|
|
205
|
+
if (error.dispatchNotSent === true) state.update(id, { initial_dispatch: { version: 1, operationId: claim.token, status: "unsubmitted" }, reason: "dispatch-refused", last: { at: nowIso(), run: null, exit: null, event: "managed dispatch refused before any provider request; repair prerequisites and explicitly resume" } });
|
|
206
|
+
else if (current) state.update(id, { execution: current.execution, session: current.execution.sessionId, last: { at: nowIso(), run: current.id, exit: null, event: "managed dispatch acceptance unresolved" } });
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
// Even a throwing local spawn can have created a child.
|
|
211
|
+
progress.spawnAttempted = true;
|
|
212
|
+
job = await learningDispatch(ctx, id, managed, () => execution.start("start", spec.cmd, spec.args, { cwd: worktree, runtimeSettings: spec.runtimeSettings, env: { ...runEnv(config, credential, identity, worktree), ...spec.env }, execution: dispatchInput }));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// `checkout` and the two prd fields are what the divergence check compares against: where the operator's copy lives,
|
|
216
|
+
// the contract the snapshot above carries, and the readiness decision the brief was blessed with. All three are
|
|
217
|
+
// recorded at the same moment as the snapshot, because a fingerprint of a brief other than the one snapshotted is
|
|
218
|
+
// worse than no fingerprint at all.
|
|
219
|
+
const entry = newEntry({
|
|
220
|
+
prd: prdRel, harness: request.harness, session, repo: nameWithOwner, branch, worktree, state: STATES.running, nowIso: nowIso(),
|
|
221
|
+
checkout, prdFingerprint: prdFingerprint(request), prdDecision: prdDecision(request), base, sources: carried,
|
|
222
|
+
});
|
|
223
|
+
const startedEntry = { ...entry, ...(remote ? { initial_dispatch: null } : {}), ...(job.execution ? { execution: job.execution } : {}), ...(task ? { task: { ...task, run: job.id } } : {}), public_since: publicSince, ...(chain ? { chain: { name: chain.name, index: chain.index, dispatch: chain.dispatch } } : {}), last: { at: nowIso(), run: job.id, exit: null, event: "start" } };
|
|
224
|
+
if (remoteRegistered) state.update(id, current => ({ ...startedEntry, ...learningDeliveryPatch(current, managed, job) }));
|
|
225
|
+
else state.putStarted(id, { ...startedEntry, ...learningDeliveryPatch(null, managed, job) }, claim.token);
|
|
226
|
+
await learningDelivered(ctx, id);
|
|
227
|
+
// The repository is recorded for recovery, and the base with it when one was chosen explicitly. `--base` is the
|
|
228
|
+
// "when initiating" half of configuring this: typed once, persisted for this repository, and every later start in it
|
|
229
|
+
// reads the same answer without the flag. A base that came from the repository's own default is NOT written, so an
|
|
230
|
+
// operator who configures nothing keeps the bare-string `repos` entry they have always had.
|
|
231
|
+
//
|
|
232
|
+
// `isNewRepo` is asked BEFORE the write, and it is a different question from "did the file change". The labels exist
|
|
233
|
+
// per repository and only want creating the first time one is seen; `rememberRepo` also returns true when it merely
|
|
234
|
+
// retargets the base of a repository already listed, so reading its return value for both would spend an
|
|
235
|
+
// `ensureLabels` call on every `--base` change.
|
|
236
|
+
const isNewRepo = configuredBase(config, nameWithOwner) === null && !repoNames(config).includes(nameWithOwner.toLowerCase());
|
|
237
|
+
const persistBase = baseSource === "flag" && !chain ? base : null;
|
|
238
|
+
rememberRepo(nameWithOwner, config.home, persistBase);
|
|
239
|
+
if (persistBase) log.info(`${nameWithOwner}: base branch ${base} recorded in ${config.home}/config.json; later starts in this repository need no --base`);
|
|
240
|
+
if (isNewRepo) await gh.ensureLabels(nameWithOwner).catch(e => log.warn(`${nameWithOwner}: labels not created: ${e.message}`));
|
|
241
|
+
log.info(`${id}: started ${request.harness} session in ${worktree}`);
|
|
242
|
+
return { id, worktree, branch, base, baseSource, session, run: job.id };
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (!progress.cloneAttempted && !progress.spawnAttempted) state.releaseStart(id, claim.token);
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { upInBackground } from "./up.mjs";
|
|
2
|
+
import { trackerInBackground, trackerForeground, trackerPort, trackerUrl, openTracker } from "../tracker_process.mjs";
|
|
3
|
+
|
|
4
|
+
// One startup path for start (with or without a PRD) and serve. A UI failure
|
|
5
|
+
// never resubmits a run or stops work already accepted by the daemon.
|
|
6
|
+
export async function startServices(ctx, options = {}, deps = {}) {
|
|
7
|
+
const port = trackerPort(options.port);
|
|
8
|
+
const { daemon = upInBackground, tracker = trackerInBackground, foreground = trackerForeground, open = openTracker } = deps;
|
|
9
|
+
const messages = [], errors = [];
|
|
10
|
+
if (!options.noDaemon) {
|
|
11
|
+
try { const d = await daemon(ctx); messages.push(d ? `Daemon running (pid ${d.pid}).` : "Daemon already running."); }
|
|
12
|
+
catch (error) { errors.push(`Daemon: ${error.message}. Retry with gallivant start; do not resubmit the PRD.`); }
|
|
13
|
+
} else messages.push("Daemon startup skipped; existing daemons are unchanged.");
|
|
14
|
+
if (!options.noServer) {
|
|
15
|
+
try {
|
|
16
|
+
await (options.foreground ? foreground : tracker)(ctx, { port });
|
|
17
|
+
messages.push(`Dashboard: ${trackerUrl(port)}${options.foreground ? " (Ctrl-C stops this tracker)" : " (background)"}`);
|
|
18
|
+
if (!options.noOpen && !await open(port)) messages.push("Browser could not be opened; use the dashboard URL above.");
|
|
19
|
+
} catch (error) { errors.push(`Dashboard: ${error.message}. Retry gallivant serve --port ${port}; existing runs are unchanged.`); }
|
|
20
|
+
}
|
|
21
|
+
return { messages, errors };
|
|
22
|
+
}
|