@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
package/lib/state.mjs
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// The registry: one entry per PRD or registered PR, keyed by `<owner>/<repo>/<prd id>`.
|
|
2
|
+
//
|
|
3
|
+
// The file is a cache with cursors. Identity (which PR, which session) can be rebuilt from GitHub; counters and
|
|
4
|
+
// cursors can be rebuilt by counting the daemon's own comments. Every write is atomic and every read-modify-write
|
|
5
|
+
// holds a lock file, so the CLI and the daemon cannot lose each other's updates.
|
|
6
|
+
import { join, basename } from "node:path";
|
|
7
|
+
import { openSync, closeSync, unlinkSync, statSync } from "node:fs";
|
|
8
|
+
import { readJson, writeJsonAtomic, gallivantHome } from "./fs.mjs";
|
|
9
|
+
import { later } from "./time.mjs";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
|
|
12
|
+
export const STATES = Object.freeze({
|
|
13
|
+
running: "running", // start run in progress, no PR yet
|
|
14
|
+
prOpen: "pr-open", // watched
|
|
15
|
+
ready: "ready-for-human", // watched, waiting for a person to merge
|
|
16
|
+
needsHuman: "needs-human", // stopped with a reason
|
|
17
|
+
done: "done", // merged or closed
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const statePath = (home = gallivantHome()) => join(home, "state.json");
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The repository name without its owner, for a handle a person reads: `acme/widget` is `widget`. Anything that is
|
|
24
|
+
* not `owner/name` degrades to what it was given rather than to `undefined`, and a missing repo to the empty string
|
|
25
|
+
* rather than a throw. Both used to reach the operator: the ntfy title said `undefined#3` and told them to run
|
|
26
|
+
* `gallivant why undefined#3`, and the throw happened inside `needsHuman` after the labels were written but before
|
|
27
|
+
* the notification, where the tick's per-entry catch swallowed it and a stopped PR notified nobody at all.
|
|
28
|
+
*/
|
|
29
|
+
export const shortRepo = nameWithOwner => { const s = String(nameWithOwner ?? ""); return s.split("/")[1] || s; };
|
|
30
|
+
|
|
31
|
+
/** `<repo>#<pr>` when there is a PR, else the registry id. */
|
|
32
|
+
export const handleFor = (id, entry) => (entry.pr ? `${shortRepo(entry.repo)}#${entry.pr}` : id);
|
|
33
|
+
|
|
34
|
+
/** Registry id: a pure function of the repository and the PRD, so every path keyed by it agrees. */
|
|
35
|
+
export const entryId = (nameWithOwner, prdId) => `${nameWithOwner.toLowerCase()}/${prdId}`;
|
|
36
|
+
|
|
37
|
+
/** The PRD id is the last segment of the registry id. */
|
|
38
|
+
export const prdIdOf = id => id.split("/").pop();
|
|
39
|
+
|
|
40
|
+
/** A filesystem-safe form of the id for run and snapshot folders. */
|
|
41
|
+
export const idSlug = id => id.replaceAll("/", "__");
|
|
42
|
+
|
|
43
|
+
const LOCK_WAIT_MS = 5_000;
|
|
44
|
+
const LOCK_STALE_MS = 30_000;
|
|
45
|
+
|
|
46
|
+
/** Hold an exclusive lock file for the duration of `fn`. Waits up to five seconds; a lock older than thirty is stale. */
|
|
47
|
+
function withLock(file, fn) {
|
|
48
|
+
const lock = `${file}.lock`;
|
|
49
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
50
|
+
for (;;) {
|
|
51
|
+
try {
|
|
52
|
+
const fd = openSync(lock, "wx");
|
|
53
|
+
try { return fn(); } finally { closeSync(fd); try { unlinkSync(lock); } catch { /* gone */ } }
|
|
54
|
+
} catch (e) {
|
|
55
|
+
if (e.code !== "EEXIST") throw e;
|
|
56
|
+
try { if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) { unlinkSync(lock); continue; } } catch { continue; }
|
|
57
|
+
if (Date.now() > deadline) throw new Error(`state is locked by another gallivant process (${lock})`);
|
|
58
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function makeState(home = gallivantHome()) {
|
|
64
|
+
const file = statePath(home);
|
|
65
|
+
const load = () => {
|
|
66
|
+
const s = readJson(file, { version: 1, entries: {} });
|
|
67
|
+
if (s.version !== 1) throw Object.assign(new Error(`unsupported registry version ${s.version}; stop the coordinator, back up ${home}, and install a compatible package or restore its matching backup; do not redispatch`), { code: "STATE_VERSION" });
|
|
68
|
+
for (const e of Object.values(s.entries)) if (e.execution && e.execution.version !== 1) throw Object.assign(new Error("unsupported execution state version; restore a compatible package/backup before restarting"), { code: "EXECUTION_VERSION" });
|
|
69
|
+
return s;
|
|
70
|
+
};
|
|
71
|
+
const save = s => writeJsonAtomic(file, s, 0o600);
|
|
72
|
+
const locked = fn => withLock(file, fn);
|
|
73
|
+
const owner = (s, id) => Object.values(s.chains || {}).find(c => !["done", "cancelled"].includes(c.status) && c.steps.some(step => step.id === id));
|
|
74
|
+
const assertCanPut = (s, id) => {
|
|
75
|
+
if (s.starts?.[id] || (!s.entries[id] && owner(s, id))) throw new Error(`${id} is reserved for a start or chain`);
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
file,
|
|
79
|
+
load,
|
|
80
|
+
entries: () => Object.entries(load().entries),
|
|
81
|
+
get: id => load().entries[id] || null,
|
|
82
|
+
/** The entry that watches this PR, if any. */
|
|
83
|
+
findByPr: (repo, pr) => Object.entries(load().entries).find(([, e]) => e.repo === repo.toLowerCase() && e.pr === pr) || null,
|
|
84
|
+
put: (id, entry) => locked(() => {
|
|
85
|
+
const s = load();
|
|
86
|
+
assertCanPut(s, id);
|
|
87
|
+
s.entries[id] = entry; save(s); return entry;
|
|
88
|
+
}),
|
|
89
|
+
update: (id, patch) => locked(() => { const s = load(); const cur = s.entries[id]; if (!cur) throw new Error(`no entry ${id}`); s.entries[id] = { ...cur, ...(typeof patch === "function" ? patch(cur) : patch) }; save(s); return s.entries[id]; }),
|
|
90
|
+
remove: id => locked(() => {
|
|
91
|
+
const s = load();
|
|
92
|
+
const chain = owner(s, id);
|
|
93
|
+
if (chain) throw new Error(`${id} belongs to chain ${chain.name}; cancel the chain before forgetting its run`);
|
|
94
|
+
delete s.entries[id]; save(s);
|
|
95
|
+
}),
|
|
96
|
+
chains: () => Object.entries(load().chains || {}),
|
|
97
|
+
getChain: name => load().chains?.[name] || null,
|
|
98
|
+
createChain: chain => locked(() => {
|
|
99
|
+
const s = load();
|
|
100
|
+
if (s.chains?.[chain.name]) throw new Error(`chain ${chain.name} already exists`);
|
|
101
|
+
for (const step of chain.steps) {
|
|
102
|
+
if (s.entries[step.id] || s.starts?.[step.id] || owner(s, step.id)) throw new Error(`${step.id} already exists, is starting, or belongs to another chain`);
|
|
103
|
+
}
|
|
104
|
+
s.chains = { ...s.chains, [chain.name]: chain }; save(s); return chain;
|
|
105
|
+
}),
|
|
106
|
+
updateChain: (name, patch) => locked(() => {
|
|
107
|
+
const s = load(), cur = s.chains?.[name];
|
|
108
|
+
if (!cur) throw new Error(`no chain ${name}`);
|
|
109
|
+
s.chains[name] = { ...cur, ...(typeof patch === "function" ? patch(cur) : patch) };
|
|
110
|
+
save(s); return s.chains[name];
|
|
111
|
+
}),
|
|
112
|
+
startClaim: id => load().starts?.[id] || null,
|
|
113
|
+
assertCanRegister: id => assertCanPut(load(), id),
|
|
114
|
+
// This lock covers CLI starts and chain dispatches. Never hold it across a probe, clone or spawn.
|
|
115
|
+
claimStart: (id, chain = null, task = null) => locked(() => {
|
|
116
|
+
const s = load(), reserved = owner(s, id);
|
|
117
|
+
if (s.entries[id] || s.starts?.[id]) throw new Error(`${id} already exists or has an unresolved start; inspect it before retrying`);
|
|
118
|
+
if (chain) {
|
|
119
|
+
const c = s.chains?.[chain.name], step = c?.steps[chain.index];
|
|
120
|
+
if (c?.status !== "active" || c.index !== chain.index || step?.id !== id || step.dispatch !== chain.dispatch) throw new Error(`chain ${chain.name} no longer authorizes this start`);
|
|
121
|
+
} else if (reserved) throw new Error(`${id} is reserved by chain ${reserved.name}`);
|
|
122
|
+
const claim = { token: randomUUID(), chain: chain?.name || null, ...(task ? { task } : {}), at: new Date().toISOString() };
|
|
123
|
+
s.starts = { ...s.starts, [id]: claim }; save(s); return claim;
|
|
124
|
+
}),
|
|
125
|
+
releaseStart: (id, token) => locked(() => {
|
|
126
|
+
const s = load();
|
|
127
|
+
if (s.starts?.[id]?.token === token) { delete s.starts[id]; save(s); }
|
|
128
|
+
}),
|
|
129
|
+
markStartClone: (id, token, clone) => locked(() => {
|
|
130
|
+
const s = load();
|
|
131
|
+
if (s.starts?.[id]?.token !== token || s.entries[id]) throw new Error(`${id}: start claim changed before clone`);
|
|
132
|
+
s.starts[id] = { ...s.starts[id], clone: { ...clone, at: new Date().toISOString() } };
|
|
133
|
+
save(s);
|
|
134
|
+
}),
|
|
135
|
+
validateStart: (id, token, chain = null) => locked(() => {
|
|
136
|
+
const s = load(), c = chain && s.chains?.[chain.name];
|
|
137
|
+
if (s.starts?.[id]?.token !== token || s.entries[id]) throw new Error(`${id}: start claim changed before spawn`);
|
|
138
|
+
if (chain && (c?.status !== "active" || c.index !== chain.index || c.steps[chain.index]?.dispatch !== chain.dispatch)) throw new Error(`chain ${chain.name} no longer authorizes this start`);
|
|
139
|
+
}),
|
|
140
|
+
putStarted: (id, entry, token) => locked(() => {
|
|
141
|
+
const s = load();
|
|
142
|
+
if (s.starts?.[id]?.token !== token || s.entries[id]) throw new Error(`${id}: start claim changed; inspect the spawned run`);
|
|
143
|
+
s.entries[id] = { ...entry, start_token: token }; delete s.starts[id]; save(s); return s.entries[id];
|
|
144
|
+
}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve what the operator typed to a registry id. Accepts the id itself, a PRD path, or `repo#pr` where repo is
|
|
150
|
+
* the short name or owner/name. Bare PR numbers are refused because they are ambiguous across repos.
|
|
151
|
+
*/
|
|
152
|
+
export function resolveHandle(entries, handle) {
|
|
153
|
+
if (!handle) throw new Error("handle required: <owner>/<repo>/<prd id>, <prd path>, or <repo>#<pr>");
|
|
154
|
+
if (/^\d+$/.test(handle)) throw new Error(`"${handle}" is ambiguous across repos; use <repo>#${handle}`);
|
|
155
|
+
const direct = entries.find(([id]) => id === handle.toLowerCase() || id === handle);
|
|
156
|
+
if (direct) return direct[0];
|
|
157
|
+
const m = handle.match(/^([\w.-]+(?:\/[\w.-]+)?)#(\d+)$/);
|
|
158
|
+
if (m) {
|
|
159
|
+
const [, repo, n] = m;
|
|
160
|
+
const key = repo.toLowerCase();
|
|
161
|
+
const hit = entries.find(([, e]) => e.pr === Number(n) && (e.repo === key || shortRepo(e.repo) === key));
|
|
162
|
+
if (hit) return hit[0];
|
|
163
|
+
throw new Error(`no entry for ${handle}`);
|
|
164
|
+
}
|
|
165
|
+
const prdId = basename(handle).replace(/\.md$/, "");
|
|
166
|
+
const byPrd = entries.filter(([id]) => id.endsWith(`/${prdId}`));
|
|
167
|
+
if (byPrd.length === 1) return byPrd[0][0];
|
|
168
|
+
if (byPrd.length > 1) throw new Error(`"${handle}" matches ${byPrd.map(([id]) => id).join(", ")}; use the full id`);
|
|
169
|
+
throw new Error(`no entry for ${handle}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The event cursor, in one place, so every writer obeys the same rule.
|
|
174
|
+
*
|
|
175
|
+
* `since` is the boundary and `dispatched` the keys already handled *at* that boundary. GitHub timestamps have
|
|
176
|
+
* one-second precision, so the comparison in `deriveEvents` is inclusive of `since` and `dispatched` is the only
|
|
177
|
+
* thing that keeps an event from being sent twice. The two therefore move together: any move to a different
|
|
178
|
+
* instant clears the list, and a move that stays inside the same second keeps it and adds to it. Everything else
|
|
179
|
+
* about the cursor (`fixed_sha`, `checks_sha`, `receipt_hash`) is carried through untouched.
|
|
180
|
+
*
|
|
181
|
+
* The cursor never goes backwards, and that is enforced here rather than asked of each caller. Three callers pass a
|
|
182
|
+
* candidate boundary from a different source: the newest event dispatched, the head commit's committer date, and a
|
|
183
|
+
* local clock reading. Only the first was guarded. A committer date older than the cursor (a force-push to an
|
|
184
|
+
* earlier commit, a push of a commit made days ago) or a clock a second behind GitHub's would move `since` back,
|
|
185
|
+
* and `advanceCursor` would then clear `dispatched` because the instant changed, so the event that started the
|
|
186
|
+
* round became new again and was dispatched a second time in the same pass, with a round burned for it. A candidate
|
|
187
|
+
* before the cursor leaves both fields alone: nothing new was read, so nothing is newly handled.
|
|
188
|
+
*/
|
|
189
|
+
export function advanceCursor(cursor = {}, candidate, keys = []) {
|
|
190
|
+
const since = later(candidate, cursor.since);
|
|
191
|
+
// A candidate strictly before the cursor is refused outright: the boundary does not move and the keys are not
|
|
192
|
+
// recorded either, because nothing was newly read to record. Only a candidate at the cursor's own instant unions
|
|
193
|
+
// into `dispatched`; one past it replaces the list, since a different second has nothing to remember.
|
|
194
|
+
if (candidate && cursor.since && Date.parse(candidate) < Date.parse(cursor.since)) return { ...cursor, since };
|
|
195
|
+
const same = cursor.since && since && Date.parse(cursor.since) === Date.parse(since);
|
|
196
|
+
const dispatched = same ? [...new Set([...(cursor.dispatched || []), ...keys])] : [...new Set(keys)];
|
|
197
|
+
return { ...cursor, since, dispatched };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A fresh entry, so every field exists and `status` never has to guess.
|
|
202
|
+
* cursor.since: events at or after this time are new. cursor.dispatched: the event keys already dispatched at
|
|
203
|
+
* exactly `since`, which is what makes the inclusive comparison safe. cursor.fixed_sha: the head the loop last acted
|
|
204
|
+
* on, so a change in it is a push nobody told the loop about. It is the head when the last run started, which is how
|
|
205
|
+
* a run that pushed is recognised, and the head an entry was judged ready on, which is how a push after that is.
|
|
206
|
+
* cursor.checks_sha: the head whose failing checks were already dispatched. cursor.receipt_hash: the completion
|
|
207
|
+
* receipt when the last run started. last.run: the run this entry dispatched; the tick settles that run and no other.
|
|
208
|
+
* retry: `{ op, count }` while one GitHub write the tick declines a step for keeps failing, null otherwise. It is the
|
|
209
|
+
* bound on those waits: `tick.mjs` stops the entry with `stuck` once one operation has failed enough ticks in a row,
|
|
210
|
+
* and clears the field the moment the write lands, so an entry that recovers is never stopped for the ticks it lost.
|
|
211
|
+
*/
|
|
212
|
+
export function newEntry({ prd, harness, session = null, repo, branch, worktree, pr = null, url = null, state, nowIso, checkout = null, prdFingerprint = null, prdDecision = null, base = null, sources = [] }) {
|
|
213
|
+
return {
|
|
214
|
+
prd, harness, session, repo: repo.toLowerCase(), branch, worktree, pr, url, state,
|
|
215
|
+
// The branch this pull request targets, recorded because it is what the daemon merges in before every fix round
|
|
216
|
+
// and what `status` and `why` print so an operator can see what a PR will target before it is opened. Null on an
|
|
217
|
+
// entry that never learned one, which is every entry `register` and `reconcile` build before they read the PR's own
|
|
218
|
+
// `baseRefName`; null means "ask the PR", never "assume the default branch".
|
|
219
|
+
base,
|
|
220
|
+
// Where the OPERATOR's copy of the brief lives, and what the snapshot's contract looked like when it was taken.
|
|
221
|
+
// `checkout` is what makes the comparison possible at all: `join(checkout, prd)` is the file to compare the snapshot
|
|
222
|
+
// against, and `entry.prd` is relative, so without this half there is nothing to join it to. Null on an entry that
|
|
223
|
+
// never saw the operator's tree, which is every entry `register` and `reconcile` build, and null is what makes the
|
|
224
|
+
// comparison answer "unknown" rather than stopping a recovered PR for a tree it was never told about.
|
|
225
|
+
//
|
|
226
|
+
// `prd_fingerprint` and `prd_decision` are the contract and the readiness decision the snapshot carries, recorded at
|
|
227
|
+
// the same moment as the snapshot itself. They are a record rather than an input: the comparison recomputes both
|
|
228
|
+
// fingerprints from the two files, because the snapshot on disk is the thing the gate actually reads and a stored
|
|
229
|
+
// fingerprint that disagreed with it would be worse than no fingerprint at all. What they are for is telling an
|
|
230
|
+
// operator what moved: `why` compares `prd_decision` with the brief's current `aport_decision` to say whether the edit
|
|
231
|
+
// went through `gallivant ready`.
|
|
232
|
+
checkout, prd_fingerprint: prdFingerprint, prd_decision: prdDecision,
|
|
233
|
+
// The files this brief carries, recorded so the daemon can compare them without re-reading the brief. Empty on
|
|
234
|
+
// every entry `register` and `reconcile` build, which is correct: a PR adopted from GitHub carries nothing, because
|
|
235
|
+
// nothing was carried into it.
|
|
236
|
+
sources,
|
|
237
|
+
iteration: 0,
|
|
238
|
+
cursor: { since: nowIso, dispatched: [], fixed_sha: null, checks_sha: null, receipt_hash: null },
|
|
239
|
+
rereview: { at: null, nudged: false },
|
|
240
|
+
reason: null,
|
|
241
|
+
retry: null,
|
|
242
|
+
last: null,
|
|
243
|
+
created_at: nowIso,
|
|
244
|
+
updated_at: nowIso,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { executionError } from "./execution_io.mjs";
|
|
2
|
+
// Scoped consumers pin their authorized implementation allowance in task metadata.
|
|
3
|
+
// Legacy/operator entries keep their existing configured behavior.
|
|
4
|
+
export function taskExecutionConfig(config, task, execution = null, { excludeRunId = null } = {}) {
|
|
5
|
+
const allowance = task?.deliveryAllowance;
|
|
6
|
+
if (!allowance) return config;
|
|
7
|
+
if (!Number.isFinite(allowance.budgetUsd) || allowance.budgetUsd <= 0 || !Number.isFinite(allowance.runtimeMs) || allowance.runtimeMs <= 0) throw executionError("EXECUTION_ALLOWANCE", "Saved task allowance is exhausted or invalid; inspect its original authorization");
|
|
8
|
+
let spent = 0, elapsedMs = 0;
|
|
9
|
+
if (execution) {
|
|
10
|
+
const ledger = execution.state?.();
|
|
11
|
+
if (!ledger && execution.current()) throw executionError("EXECUTION_ALLOWANCE", "Scoped continuation requires the durable execution ledger; inspect the original runtime binding");
|
|
12
|
+
for (const op of Object.values(ledger?.operations || {})) {
|
|
13
|
+
if (op.runId === excludeRunId) continue;
|
|
14
|
+
if (execution.capabilities?.transport !== "remote") {
|
|
15
|
+
if (!Number.isFinite(op.costUsd)) throw executionError("EXECUTION_ALLOWANCE", "Previous local cost is unknown; reconcile it before authorizing another run");
|
|
16
|
+
spent += op.costUsd;
|
|
17
|
+
}
|
|
18
|
+
const job = execution.read(op.runId);
|
|
19
|
+
if (!job || !Number.isFinite(job.endedAt) || !Number.isFinite(job.startedAt) || job.endedAt < job.startedAt) throw executionError("EXECUTION_ALLOWANCE", "Previous execution duration is unknown; reconcile it before authorizing another run");
|
|
20
|
+
elapsedMs += job.endedAt - job.startedAt;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (spent >= allowance.budgetUsd || elapsedMs >= allowance.runtimeMs) throw executionError("EXECUTION_ALLOWANCE", "Saved task allowance is exhausted; no further run was dispatched");
|
|
24
|
+
return { ...config, max_budget_usd: Math.min(config.max_budget_usd, allowance.budgetUsd - spent), run_timeout_minutes: Math.min(config.run_timeout_minutes, (allowance.runtimeMs - elapsedMs) / 60000) };
|
|
25
|
+
}
|
|
26
|
+
export function taskProviderAllowance(allowance, task) {
|
|
27
|
+
if (!task?.deliveryAllowance) return allowance;
|
|
28
|
+
const cap = task.deliveryAllowance.budgetUsd;
|
|
29
|
+
if (!Number.isFinite(cap) || cap <= 0 || !allowance || allowance.reservedUsd > cap) throw executionError("EXECUTION_ALLOWANCE", "Provider reservation exceeds the saved task allowance; configure a smaller bounded reservation");
|
|
30
|
+
return { ...allowance, remainingUsd: Math.min(allowance.remainingUsd, cap) };
|
|
31
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Portable milestones, with one currently supported observation adapter. A
|
|
2
|
+
// target selects what is fulfilled; it never grants merge/deployment authority.
|
|
3
|
+
import { ownerDeliveryEvidence, matchingDeliveryEvidence } from "./owner_delivery.mjs";
|
|
4
|
+
import { controlPending } from "./control_contract.mjs";
|
|
5
|
+
import { executionLive } from "./execution_contract.mjs";
|
|
6
|
+
import { learningDeliveryPending } from "./learning_contract.mjs";
|
|
7
|
+
import { networkRecoveryPending, networkRecoveryDetail } from "./network_recovery.mjs";
|
|
8
|
+
export const COMPLETION_SCHEMA = Object.freeze({ type: "object", properties: {
|
|
9
|
+
target: { type: "string", enum: ["delivery_ready", "integrated"] },
|
|
10
|
+
}, required: ["target"], additionalProperties: false });
|
|
11
|
+
export const COMPLETION_CAPABILITIES = Object.freeze({ version: 1, defaultTarget: "delivery_ready", targets: [
|
|
12
|
+
{ id: "delivery_ready", label: "Ready for handoff", description: "The current delivery passes the existing checks, reviewer and completion gate.", adapter: "github-pull-request" },
|
|
13
|
+
{ id: "integrated", label: "Merged", description: "Keep watching the qualified delivery until an authorized person or system merges it. This does not authorize a merge.", adapter: "github-pull-request" },
|
|
14
|
+
] });
|
|
15
|
+
export function completionPolicy(value) {
|
|
16
|
+
if (value === undefined) return { version: 1, target: "delivery_ready" };
|
|
17
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== 1 || !Object.hasOwn(value, "target") || !COMPLETION_SCHEMA.properties.target.enum.includes(value.target)) {
|
|
18
|
+
throw Object.assign(new Error("completion must select a supported target: delivery_ready or integrated; it does not grant integration authority"), { code: "TASK_INPUT" });
|
|
19
|
+
}
|
|
20
|
+
return { version: 1, target: value.target };
|
|
21
|
+
}
|
|
22
|
+
export function completionView(policy, entry = null, ownerDelivery = null, current = null) {
|
|
23
|
+
const selected = policy || entry?.task?.completion || { version: 1, target: "delivery_ready" };
|
|
24
|
+
const descriptor = COMPLETION_CAPABILITIES.targets.find(target => target.id === selected.target);
|
|
25
|
+
const evidence = entry?.completionEvidence || {};
|
|
26
|
+
const legacy = !policy && !entry?.task?.completion;
|
|
27
|
+
let status = "pending", reason = null;
|
|
28
|
+
if (entry?.dispatch_stopped || ["cancelled", "cancellation-requested"].includes(entry?.reason)) { status = "cancelled"; reason = "Further dispatch is stopped; provider cancellation may still require confirmation."; }
|
|
29
|
+
else if (evidence.terminal?.state === "CLOSED") { status = "blocked"; reason = "The delivery was closed without integration."; }
|
|
30
|
+
else if (networkRecoveryPending(entry)) { status = "pending"; reason = networkRecoveryDetail(entry.network_retry); }
|
|
31
|
+
else if (evidence.integration?.qualified === true) status = "fulfilled";
|
|
32
|
+
else if (evidence.terminal?.state === "MERGED") { status = "unverified"; reason = evidence.integration?.reason || "Integration was observed without a matching qualified delivery."; }
|
|
33
|
+
else if (entry?.state === "ready-for-human" && matchingDeliveryEvidence(entry)) status = selected.target === "integrated" ? "awaiting_integration" : "fulfilled";
|
|
34
|
+
else if (entry?.state === "needs-human") { status = "blocked"; reason = entry.reason || "The delivery needs human attention."; }
|
|
35
|
+
else if (entry && ["ready-for-human", "done"].includes(entry.state)) { status = "unverified"; reason = "No canonical completion evidence is recorded for this delivery."; }
|
|
36
|
+
const ownership = ownerDelivery || entry?.task?.ownerDelivery;
|
|
37
|
+
const obligations = ownerDeliveryEvidence(ownership, entry, status);
|
|
38
|
+
if (obligations.some(o => o.status !== "fulfilled") && ["fulfilled", "awaiting_integration"].includes(status)) { status = "unverified"; reason = "The owner publication obligation lacks matching coordinator delivery evidence."; }
|
|
39
|
+
if (["fulfilled", "awaiting_integration"].includes(status) && completionWorkPending(entry, current)) { status = "pending"; reason = "Owned execution still requires reconciliation or collection."; }
|
|
40
|
+
return { ...selected, label: descriptor?.label || null, description: descriptor?.description || null, status, adapter: "github-pull-request", legacy, evidence, reason,
|
|
41
|
+
...(ownership ? { obligations } : {}),
|
|
42
|
+
monitoring: Boolean(entry && (networkRecoveryPending(entry) || ["pr-open", "ready-for-human", "running"].includes(entry.state)) && !stopsAutomation(entry, selected, status, legacy, current)), mergeAuthorized: false };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const completionWorkPending = (entry, current) => Boolean(executionLive(entry) || executionLive(current) || current?.running ||
|
|
46
|
+
current && current.id === entry?.last?.run && entry.last.exit == null);
|
|
47
|
+
const stopsAutomation = (entry, selected, status, legacy, current) => Boolean(entry?.state === "ready-for-human" && !legacy &&
|
|
48
|
+
selected.version === 1 && selected.target === "delivery_ready" && status === "fulfilled" && matchingDeliveryEvidence(entry) && !controlPending(entry.control) && !completionWorkPending(entry, current));
|
|
49
|
+
export function completionStopsAutomation(entry, current = null) {
|
|
50
|
+
// Malformed saved ownership remains unqualified and is handled per entry by
|
|
51
|
+
// the normal tick. It must neither suppress work nor abort the entire sweep.
|
|
52
|
+
try {
|
|
53
|
+
const view = completionView(null, entry, null, current);
|
|
54
|
+
return stopsAutomation(entry, view, view.status, view.legacy, current);
|
|
55
|
+
} catch { return false; }
|
|
56
|
+
}
|
|
57
|
+
// Hosts consume the engine's decision, rather than interpreting ready as active
|
|
58
|
+
// forever. Other terminal/paused states retain the existing scheduling contract.
|
|
59
|
+
export function coordinatorEntryActive(entry, current = null) {
|
|
60
|
+
return Boolean(entry && (learningDeliveryPending(entry) || networkRecoveryPending(entry) || !["done", "needs-human"].includes(entry.state) && !completionStopsAutomation(entry, current)));
|
|
61
|
+
}
|