@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,107 @@
|
|
|
1
|
+
import { join, isAbsolute } from "node:path";
|
|
2
|
+
import { readJson, writeJsonAtomic, ensureDir } from "./fs.mjs";
|
|
3
|
+
import { acquireRuntimeOwner } from "./daemon.mjs";
|
|
4
|
+
import { makeTick } from "./tick.mjs";
|
|
5
|
+
import { createCoordinator } from "./coordinator.mjs";
|
|
6
|
+
import { deliveryError } from "./task_delivery_contract.mjs";
|
|
7
|
+
import { runtimePinManifest, assertRuntimePin } from "./runtime_state.mjs";
|
|
8
|
+
|
|
9
|
+
export const NODE_RUNTIME_REQUIREMENTS = Object.freeze({ runtime: "node>=22", store: "durable single-host volume", replicas: 1,
|
|
10
|
+
dependencies: ["git", "gh", "isolated verification sandbox", "configured provider transports"],
|
|
11
|
+
pagesRemedy: "Keep Pages for OAuth/MCP/UI. Route authorized operations through an authenticated binding to one Node 22 coordinator with Git/gh, durable volume and external wake-up scheduling. Codex Cloud additionally needs the pinned CLI and its trusted operator account; environment repository verification remains blocked. Evaluate an existing Node-capable runtime or Cloudflare Container before provisioning; do not add a mandatory Worker." });
|
|
12
|
+
|
|
13
|
+
export function createNodeOwnerStore({ home, durable = false, replicas = 1 }) {
|
|
14
|
+
if (!isAbsolute(home || "") || !durable || replicas !== 1) throw deliveryError("COORDINATOR_STORE", "Configure an absolute durable volume and exactly one single-host replica; filesystem locks are not distributed leases");
|
|
15
|
+
ensureDir(home);
|
|
16
|
+
return { withOwner: async fn => {
|
|
17
|
+
const lock = acquireRuntimeOwner(home), file = join(home, "coordinator.json");
|
|
18
|
+
try {
|
|
19
|
+
const previous = readJson(file, { fence: 0, version: 1 });
|
|
20
|
+
if (previous.version !== 1) throw deliveryError("COORDINATOR_VERSION", "Stop wake-ups and restore the matching coordinator package/state backup");
|
|
21
|
+
const fence = previous.fence + 1, instance = lock.owner.instance;
|
|
22
|
+
writeJsonAtomic(file, { ...previous, version: 1, fence, instance });
|
|
23
|
+
const owner = { fence, processIdentity: `${lock.owner.pid}:${lock.owner.identity}`, assertCurrent: () => {
|
|
24
|
+
const current = readJson(file), locked = readJson(join(home, "daemon.lock"));
|
|
25
|
+
if (current?.fence !== fence || current.instance !== instance || locked?.instance !== instance) throw deliveryError("COORDINATOR_FENCE", "Coordinator ownership changed; stale effects are forbidden");
|
|
26
|
+
}, checkpoint: () => readJson(file), saveCheckpoint: patch => {
|
|
27
|
+
owner.assertCurrent(); writeJsonAtomic(file, { ...readJson(file), ...patch, version: 1, fence, instance });
|
|
28
|
+
} };
|
|
29
|
+
return await fn(owner);
|
|
30
|
+
} finally { lock.release(); }
|
|
31
|
+
} };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Fence each I/O boundary, including continuations after an await. Store adapters
|
|
35
|
+
// used across machines must enforce fencing in their own atomic transactions.
|
|
36
|
+
function fenced(value, owner) {
|
|
37
|
+
if (!value || typeof value !== "object") return value;
|
|
38
|
+
return new Proxy(value, { get(target, key) {
|
|
39
|
+
const member = target[key];
|
|
40
|
+
if (typeof member !== "function") return member;
|
|
41
|
+
return (...args) => { const pending = owner.assertCurrent(); if (pending?.then) throw deliveryError("COORDINATOR_STORE", "The Node adapter requires synchronous fence checks; use its durable single-host owner adapter"); return member.apply(target, args); };
|
|
42
|
+
} });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createNodeCoordinator({ ctx, store, schedule, manifest, maxEntries = 20, maxChains = 10, intervalMs = 10000 }) {
|
|
46
|
+
manifest = runtimePinManifest(manifest);
|
|
47
|
+
const validatePin = () => assertRuntimePin({ home: ctx.home, manifest });
|
|
48
|
+
validatePin();
|
|
49
|
+
// A delayed callback can outlive an idle migration. Validate under ownership
|
|
50
|
+
// before the portable envelope schedules or advances any task work.
|
|
51
|
+
if (typeof store?.withOwner !== "function") throw deliveryError("COORDINATOR_CONFIG", "Inject a durable fenced owner store");
|
|
52
|
+
const pinnedStore = { withOwner: fn => store.withOwner(async owner => {
|
|
53
|
+
await owner.assertCurrent();
|
|
54
|
+
validatePin();
|
|
55
|
+
return fn(owner);
|
|
56
|
+
}) };
|
|
57
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1 || !Number.isSafeInteger(maxChains) || maxChains < 0) throw deliveryError("COORDINATOR_CONFIG", "Specify bounded entry/chain batches");
|
|
58
|
+
let lastEntry = null;
|
|
59
|
+
return createCoordinator({ store: pinnedStore, schedule, clock: ctx.clock || Date, intervalMs, advance: async owner => {
|
|
60
|
+
// All workflow/review decisions still live in makeTick. Only the bounded
|
|
61
|
+
// registry window changes; context transports/credentials are injected.
|
|
62
|
+
const checkpoint = owner.checkpoint?.() || {};
|
|
63
|
+
const startupReconciliation = checkpoint.processIdentity !== owner.processIdentity;
|
|
64
|
+
// Ignore legacy numeric offsets: replay from the beginning rather than risk
|
|
65
|
+
// skipping an entry after removals. New processes also restart the sweep.
|
|
66
|
+
const after = startupReconciliation && checkpoint.startupProcessIdentity !== owner.processIdentity ? null :
|
|
67
|
+
Object.hasOwn(checkpoint, "entryCursor") ? checkpoint.entryCursor : lastEntry;
|
|
68
|
+
const entries = ctx.state.entries().sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
69
|
+
const window = entries.filter(([id]) => !after || id > after), selected = window.slice(0, maxEntries);
|
|
70
|
+
const entryCursor = window.length > maxEntries ? selected.at(-1)[0] : null;
|
|
71
|
+
// Keyset rotation remains fair when earlier chains finish or are cancelled.
|
|
72
|
+
// Retained history is never eligible for canonical tickChains processing.
|
|
73
|
+
owner.assertCurrent();
|
|
74
|
+
const activeChains = ctx.state.chains().filter(([, chain]) => chain.status === "active").sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
75
|
+
const chainWindow = activeChains.filter(([name]) => !checkpoint.chainCursor || name > checkpoint.chainCursor);
|
|
76
|
+
const selectedChains = chainWindow.slice(0, maxChains);
|
|
77
|
+
const chainCursor = maxChains === 0 ? checkpoint.chainCursor ?? null : chainWindow.length > maxChains ? selectedChains.at(-1)[0] : null;
|
|
78
|
+
const state = fenced(ctx.state, owner);
|
|
79
|
+
let chainsPolled = false;
|
|
80
|
+
// Plain makeContext() embedders have not gone through the CLI daemon boot,
|
|
81
|
+
// which normally resolves this same authenticated identity before makeTick.
|
|
82
|
+
const ownLogin = ctx.ownLogin || await fenced(ctx.gh, owner).user();
|
|
83
|
+
owner.assertCurrent();
|
|
84
|
+
const deps = { ...ctx, ownLogin, startupReconciliation,
|
|
85
|
+
// Complete an unfinished sweep before waiting; never revisit its prefix.
|
|
86
|
+
// Otherwise restore makeTick's own review deadline across process wakes.
|
|
87
|
+
nextChainAt: maxChains === 0 ? Infinity : checkpoint.chainCursor ? 0 : checkpoint.nextChainAt ?? 0,
|
|
88
|
+
state: new Proxy(state, { get(target, key) {
|
|
89
|
+
if (key === "entries") return () => { owner.assertCurrent(); return selected; };
|
|
90
|
+
if (key === "chains") return () => { owner.assertCurrent(); chainsPolled = true; return selectedChains; };
|
|
91
|
+
return target[key];
|
|
92
|
+
} }), gh: fenced(ctx.gh, owner), git: fenced(ctx.git, owner), notify: fenced(ctx.notify, owner),
|
|
93
|
+
credential: (...args) => { owner.assertCurrent(); return ctx.credential(...args); },
|
|
94
|
+
runnerFor: (...args) => { owner.assertCurrent(); return fenced(ctx.runnerFor(...args), owner); },
|
|
95
|
+
...(ctx.executionFor ? { executionFor: (...args) => { owner.assertCurrent(); return fenced(ctx.executionFor(...args), owner); } } : {}),
|
|
96
|
+
};
|
|
97
|
+
const tick = makeTick(deps), result = await tick.tick();
|
|
98
|
+
owner.saveCheckpoint?.({ cursor: 0, entryCursor,
|
|
99
|
+
...(chainsPolled ? { chainCursor, nextChainAt: tick.nextChainAt } : {}),
|
|
100
|
+
startupProcessIdentity: startupReconciliation && entryCursor !== null ? owner.processIdentity : null,
|
|
101
|
+
// Commit restart completion only after every bounded entry batch ran.
|
|
102
|
+
...(!startupReconciliation || entryCursor === null ? { processIdentity: owner.processIdentity } : {}),
|
|
103
|
+
});
|
|
104
|
+
lastEntry = entryCursor;
|
|
105
|
+
return result;
|
|
106
|
+
} });
|
|
107
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { join, dirname } from "node:path";
|
|
2
|
+
import { readdirSync, lstatSync, readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { readJson, writeJsonAtomic, canonicalPath } from "./fs.mjs";
|
|
5
|
+
import { acquireRuntimeOwner, acquireTaskMutation } from "./daemon.mjs";
|
|
6
|
+
import { assertCompatible, CORE_VERSION, deliveryError } from "./task_delivery_contract.mjs";
|
|
7
|
+
import { learningStore } from "./learning_store.mjs";
|
|
8
|
+
import { controlPending } from "./control.mjs";
|
|
9
|
+
import { executionLive } from "./execution.mjs";
|
|
10
|
+
import { preparationCancellationPending } from "./task_prepare.mjs";
|
|
11
|
+
import { completionStopsAutomation } from "./task_completion.mjs";
|
|
12
|
+
import { learningDeliveryPending } from "./learning_input.mjs";
|
|
13
|
+
const paths = ["state.json", "learning", "tasks", "prds", "runs", "delivery", "coordinator.json", "runtime-pin.json"];
|
|
14
|
+
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
15
|
+
// Repository snapshots can contain any of these basenames. Only the layouts
|
|
16
|
+
// written by taskStore, context.executionFor and createDeliveryStore are owned.
|
|
17
|
+
const intakePath = path => /^tasks\/task-[a-f0-9]{24}\/intake\.json$/.test(path);
|
|
18
|
+
const deliveryPath = path => /^delivery\/[a-f0-9]{64}\/delivery\.json$/.test(path);
|
|
19
|
+
const ledgerPath = path => /^runs\/[^/]+\/(?:execution|agents|cloud)\.json$/.test(path);
|
|
20
|
+
const processLockPath = path => /^learning\/operation\.lock(?:\.recovery)?$/.test(path) || /^(?:tasks\/task-[a-f0-9]{24}\/intake|runs\/[^/]+\/(?:execution|publication)|delivery\/[a-f0-9]{64}\/delivery)\.lock(?:\.recovery)?$/.test(path);
|
|
21
|
+
function files(root, prefix = "") {
|
|
22
|
+
const result = [];
|
|
23
|
+
for (const name of readdirSync(join(root, prefix))) {
|
|
24
|
+
const path = join(prefix, name), stat = lstatSync(join(root, path));
|
|
25
|
+
if (stat.isSymbolicLink()) throw deliveryError("RUNTIME_STATE", "State backup refuses symlinks; inspect the durable volume");
|
|
26
|
+
if (processLockPath(path)) continue;
|
|
27
|
+
if (stat.isDirectory()) result.push(...files(root, path));
|
|
28
|
+
else if (stat.isFile()) result.push(path);
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
function stateFiles(home) {
|
|
33
|
+
return paths.filter(p => existsSync(join(home, p))).flatMap(path => {
|
|
34
|
+
const stat = lstatSync(join(home, path));
|
|
35
|
+
if (stat.isSymbolicLink()) throw deliveryError("RUNTIME_STATE", "State backup refuses symlinks; inspect the durable volume");
|
|
36
|
+
if (stat.isDirectory()) return files(home, path);
|
|
37
|
+
if (!stat.isFile()) throw deliveryError("RUNTIME_STATE", "State contains an unsupported filesystem object");
|
|
38
|
+
return [path];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// Capture the process artifact once; never learn its identity from mutable state.
|
|
42
|
+
export function runtimePinManifest(manifest = {}) {
|
|
43
|
+
assertCompatible(manifest || {});
|
|
44
|
+
if (manifest.coreVersion !== CORE_VERSION || !/^[a-f0-9]{64}$/.test(manifest.coreSha256 || "")) throw deliveryError("COORDINATOR_PIN", "Supply this package artifact's exact emitted runtime.json; drain active jobs before changing packages");
|
|
45
|
+
return Object.freeze({ ...manifest });
|
|
46
|
+
}
|
|
47
|
+
export function assertRuntimePin({ home, manifest }) {
|
|
48
|
+
const pin = readJson(join(home, "runtime-pin.json"));
|
|
49
|
+
if (pin?.version !== 1 || pin.coreVersion !== manifest.coreVersion || pin.coreSha256 !== manifest.coreSha256) throw deliveryError("COORDINATOR_PIN", "Run task-delivery-state migrate with this package artifact's exact runtime.json before enabling mutations or wake-ups");
|
|
50
|
+
checkRuntimeState({ home, manifest });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function checkRuntimeState({ home, manifest }) {
|
|
54
|
+
assertCompatible(manifest);
|
|
55
|
+
const state = readJson(join(home, "state.json"), { version: 1, entries: {} });
|
|
56
|
+
if (state.version !== 1) throw deliveryError("RUNTIME_STATE", "Unsupported registry; restore its matching backup/package before restart");
|
|
57
|
+
const pin = readJson(join(home, "runtime-pin.json"));
|
|
58
|
+
// Qualified handoffs and confirmed cancellations retain their registry history.
|
|
59
|
+
// Unlike scheduling, migration still pins unresolved needs-human work. Pending
|
|
60
|
+
// controls and live execution pin the artifact independently of registry state.
|
|
61
|
+
const cancelled = e => e.state === "needs-human" && e.reason === "cancelled" && e.dispatch_stopped &&
|
|
62
|
+
!controlPending(e.control) && !executionLive(e);
|
|
63
|
+
let active = Object.values(state.entries).some(e => controlPending(e.control) || executionLive(e) || learningDeliveryPending(e) ||
|
|
64
|
+
e.state !== "done" && !cancelled(e) && !completionStopsAutomation(e)) || Object.keys(state.starts || {}).length > 0 ||
|
|
65
|
+
Object.values(state.chains || {}).some(c => !["done", "cancelled"].includes(c.status));
|
|
66
|
+
for (const rel of stateFiles(home)) {
|
|
67
|
+
if (intakePath(rel) || ledgerPath(rel) || deliveryPath(rel)) {
|
|
68
|
+
const record = readJson(join(home, rel));
|
|
69
|
+
if (record?.version !== 1) throw deliveryError("RUNTIME_STATE", `Unsupported ${rel}; preserve it and migrate with its original package`);
|
|
70
|
+
if (deliveryPath(rel)) {
|
|
71
|
+
// Delivery intent can precede intake creation; unfinished scheduling
|
|
72
|
+
// also belongs to the original artifact, including safety controls.
|
|
73
|
+
for (const key of ["operations", "cancellations"]) {
|
|
74
|
+
const receipts = record[key] ?? {};
|
|
75
|
+
if (typeof receipts !== "object" || Array.isArray(receipts)) throw deliveryError("RUNTIME_STATE", `Unsupported ${rel} receipts; preserve them and recover with their original package`);
|
|
76
|
+
active ||= Object.values(receipts).some(receipt => receipt?.status !== "acknowledged" || Boolean(receipt.wakePending));
|
|
77
|
+
}
|
|
78
|
+
} else if (intakePath(rel)) {
|
|
79
|
+
const attempts = record.attempts || [];
|
|
80
|
+
// Intake/readiness can own work before implementation registration,
|
|
81
|
+
// including blocked attempts whose cancellation is still unresolved.
|
|
82
|
+
// Successful starts retain historical intent alongside their acknowledged
|
|
83
|
+
// implementation; only unresolved intent independently pins the intake.
|
|
84
|
+
const dispatchPending = record.dispatchIntent && (record.status !== "started" || !record.dispatchIntent.id || record.implementation?.id !== record.dispatchIntent.id);
|
|
85
|
+
active ||= Boolean(dispatchPending) || ["preparing", "validating"].includes(record.status) || record.readiness?.pending === true ||
|
|
86
|
+
attempts.some(attempt => attempt.settled !== true || attempt.execution && executionLive({ execution: attempt.execution })) ||
|
|
87
|
+
preparationCancellationPending({ ...record, attempts });
|
|
88
|
+
} else if (/execution\.json$/.test(rel)) {
|
|
89
|
+
// Use the canonical liveness rule; unknown acceptance and uncollected
|
|
90
|
+
// terminal results cannot qualify an idle namespace.
|
|
91
|
+
active ||= executionLive(record.current) || Object.values(record.operations || {}).some(op => op.acceptance !== "accepted");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (active && (!pin || pin.coreSha256 !== manifest.coreSha256)) throw deliveryError("RUNTIME_ACTIVE_PIN", "Active jobs pin the old artifact. Keep their runtime, stop new intake and drain them; stage the update in another namespace. Do not replace active-job code.");
|
|
96
|
+
return { compatible: true, active, pinned: pin?.coreSha256 || null, entries: Object.keys(state.entries).length };
|
|
97
|
+
}
|
|
98
|
+
// CLI writes can coexist with the daemon, but maintenance excludes both owners.
|
|
99
|
+
function acquireMaintenance(home) {
|
|
100
|
+
const runtime = acquireRuntimeOwner(home);
|
|
101
|
+
try {
|
|
102
|
+
const task = acquireTaskMutation(home);
|
|
103
|
+
try {
|
|
104
|
+
const releaseLearning = learningStore(join(canonicalPath(home), "learning")).lock();
|
|
105
|
+
return { release() { try { releaseLearning(); } finally { try { task.release(); } finally { runtime.release(); } } } };
|
|
106
|
+
} catch (error) { task.release(); throw error; }
|
|
107
|
+
} catch (error) { runtime.release(); throw error; }
|
|
108
|
+
}
|
|
109
|
+
export function migrateRuntimeState({ home, manifest }) {
|
|
110
|
+
const lock = acquireMaintenance(home);
|
|
111
|
+
try {
|
|
112
|
+
const result = checkRuntimeState({ home, manifest });
|
|
113
|
+
if (!/^[a-f0-9]{64}$/.test(manifest.coreSha256 || "")) throw deliveryError("RUNTIME_STATE", "Use the exact emitted runtime.json with its artifact digest");
|
|
114
|
+
writeJsonAtomic(join(home, "runtime-pin.json"), { version: 1, coreVersion: manifest.coreVersion, coreSha256: manifest.coreSha256 });
|
|
115
|
+
return result;
|
|
116
|
+
} finally { lock.release(); }
|
|
117
|
+
}
|
|
118
|
+
function checkBackupPin(pin, manifest) {
|
|
119
|
+
if (!/^[a-f0-9]{64}$/.test(manifest.coreSha256 || "")) throw deliveryError("RUNTIME_STATE", "Use the exact emitted runtime.json with its artifact digest");
|
|
120
|
+
if (pin !== undefined && (pin?.version !== 1 || pin.coreSha256 !== manifest.coreSha256 || pin.coreVersion !== manifest.coreVersion)) {
|
|
121
|
+
throw deliveryError("RUNTIME_STATE", "Backup and rollback require the currently pinned artifact's runtime.json; use the pre-upgrade manifest, then migrate with the candidate separately");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function backupRuntimeState({ home, out, manifest }) {
|
|
125
|
+
home = canonicalPath(home); out = canonicalPath(out);
|
|
126
|
+
if (out === home || out.startsWith(home + "/") || home.startsWith(out + "/")) throw deliveryError("RUNTIME_STATE", "Backup must be outside the durable namespace");
|
|
127
|
+
const lock = acquireMaintenance(home);
|
|
128
|
+
try {
|
|
129
|
+
checkRuntimeState({ home, manifest });
|
|
130
|
+
const pinPath = join(home, "runtime-pin.json");
|
|
131
|
+
checkBackupPin(existsSync(pinPath) ? readJson(pinPath) : undefined, manifest);
|
|
132
|
+
mkdirSync(out, { mode: 0o700 });
|
|
133
|
+
const hashes = {};
|
|
134
|
+
for (const rel of stateFiles(home)) {
|
|
135
|
+
mkdirSync(dirname(join(out, rel)), { recursive: true, mode: 0o700 });
|
|
136
|
+
const bytes = readFileSync(join(home, rel));
|
|
137
|
+
writeFileSync(join(out, rel), bytes, { mode: 0o600 }); hashes[rel] = sha(bytes);
|
|
138
|
+
}
|
|
139
|
+
writeJsonAtomic(join(out, "backup.json"), { version: 1, coreSha256: manifest.coreSha256, hashes });
|
|
140
|
+
return { backup: out, files: Object.keys(hashes).length };
|
|
141
|
+
} finally { lock.release(); }
|
|
142
|
+
}
|
|
143
|
+
export function restoreRuntimeState({ backup, out, manifest }) {
|
|
144
|
+
if (lstatSync(backup).isSymbolicLink() || lstatSync(join(backup, "backup.json")).isSymbolicLink()) throw deliveryError("RUNTIME_STATE", "Unsafe backup path");
|
|
145
|
+
const record = readJson(join(backup, "backup.json"));
|
|
146
|
+
assertCompatible(manifest);
|
|
147
|
+
if (record?.version !== 1 || record.coreSha256 !== manifest.coreSha256) throw deliveryError("RUNTIME_STATE", "Restore the matching package artifact and backup; never mix state and code");
|
|
148
|
+
if (!record.hashes || typeof record.hashes !== "object" || Array.isArray(record.hashes)) throw deliveryError("RUNTIME_STATE", "Invalid backup manifest");
|
|
149
|
+
const validated = [];
|
|
150
|
+
for (const [path, hash] of Object.entries(record.hashes)) {
|
|
151
|
+
const parts = path.split("/");
|
|
152
|
+
if (processLockPath(path) || !paths.some(p => path === p || path.startsWith(p + "/")) || parts.some(p => ["..", ".", ""].includes(p)) || path.includes("\\") || !/^[a-f0-9]{64}$/.test(hash)) throw deliveryError("RUNTIME_STATE", "Unsafe backup path");
|
|
153
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
154
|
+
const stat = lstatSync(join(backup, ...parts.slice(0, i)));
|
|
155
|
+
if (stat.isSymbolicLink() || (i === parts.length ? !stat.isFile() : !stat.isDirectory())) throw deliveryError("RUNTIME_STATE", "Unsafe backup path");
|
|
156
|
+
}
|
|
157
|
+
const bytes = readFileSync(join(backup, path));
|
|
158
|
+
if (sha(bytes) !== hash) throw deliveryError("RUNTIME_STATE", "Backup hash changed; restore the original immutable backup");
|
|
159
|
+
validated.push([path, bytes]);
|
|
160
|
+
}
|
|
161
|
+
// The backup envelope and copied pin must identify the same artifact, even
|
|
162
|
+
// for idle state (the ordinary state check intentionally allows idle upgrades).
|
|
163
|
+
const savedPin = validated.find(([path]) => path === "runtime-pin.json");
|
|
164
|
+
checkBackupPin(savedPin ? JSON.parse(savedPin[1].toString("utf8")) : undefined, manifest);
|
|
165
|
+
// Validate every file before creating a fresh private namespace.
|
|
166
|
+
mkdirSync(out, { mode: 0o700 });
|
|
167
|
+
try {
|
|
168
|
+
for (const [path, bytes] of validated) {
|
|
169
|
+
mkdirSync(dirname(join(out, path)), { recursive: true, mode: 0o700 });
|
|
170
|
+
writeFileSync(join(out, path), bytes, { mode: 0o600 });
|
|
171
|
+
}
|
|
172
|
+
return checkRuntimeState({ home: out, manifest });
|
|
173
|
+
} catch (error) { rmSync(out, { recursive: true, force: true }); throw error; }
|
|
174
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// One gate, many sources. The contract in lib/prd.mjs takes a Request rather than a parsed file:
|
|
2
|
+
//
|
|
3
|
+
// { id, repo, title, body, harness, status, source: { kind, url, author }, raw }
|
|
4
|
+
//
|
|
5
|
+
// plus the parsed fields the rules read (sections, titles, words, meta). Today the only adapter is the PRD file, and
|
|
6
|
+
// the shape is identical to what `parsePrd` always returned, so nothing about reading a PRD changed. What changed is
|
|
7
|
+
// that the rules no longer reach for a file: `repo` is a field the source supplies, and `source.kind` is what a
|
|
8
|
+
// kind-specific rule is checked against.
|
|
9
|
+
//
|
|
10
|
+
// This registry exists so a second source (a GitHub issue, and later Slack or a ticket) is an adapter plus a place to
|
|
11
|
+
// record its verdict, not a second gate. None of those is built. There is exactly one adapter below.
|
|
12
|
+
import * as prd from "./prd.mjs";
|
|
13
|
+
|
|
14
|
+
export const ADAPTERS = Object.freeze([prd]);
|
|
15
|
+
|
|
16
|
+
export const adapterFor = input => ADAPTERS.find(a => a.matches(input)) ?? null;
|
|
17
|
+
|
|
18
|
+
export const adapterOfKind = kind => ADAPTERS.find(a => a.kind === kind) ?? null;
|
|
19
|
+
|
|
20
|
+
/** The Request for whatever the operator typed. `deps` carries the dependencies a source needs, `git` for this one. */
|
|
21
|
+
export async function requestFor(input, deps = {}) {
|
|
22
|
+
const adapter = adapterFor(input);
|
|
23
|
+
if (!adapter) throw new Error(`nothing can read "${input}": give a path to a PRD file`);
|
|
24
|
+
return adapter.load(input, deps);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export { prd };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The PRD source: a markdown file in the operator's own checkout.
|
|
2
|
+
//
|
|
3
|
+
// Behaviour is unchanged from before the Request shape existed: parse the frontmatter for `status` and `harness`,
|
|
4
|
+
// take the id from the filename, let the contract read the body's sections, and find the target repository by asking
|
|
5
|
+
// git which checkout the file is in. What is new is that the answer is a field on the Request instead of something
|
|
6
|
+
// `start` works out for itself, so the rest of the pipeline never has to know the brief was a file.
|
|
7
|
+
import { basename, dirname, relative, resolve } from "node:path";
|
|
8
|
+
import { parsePrd } from "../prd.mjs";
|
|
9
|
+
import { readText } from "../fs.mjs";
|
|
10
|
+
import { repositoryPath } from "../repository_files.mjs";
|
|
11
|
+
|
|
12
|
+
export const kind = "prd";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A filesystem path. `matches` is what the registry uses to pick this source, and it declines anything with a URL
|
|
16
|
+
* scheme so a future source that reads one is not shadowed by a stray relative path.
|
|
17
|
+
*/
|
|
18
|
+
export const matches = input => typeof input === "string" && input.length > 0 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(input);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The Request for one PRD file.
|
|
22
|
+
*
|
|
23
|
+
* `repo` is `owner/name` and comes from the file's own git checkout: a PRD lives inside the repository it describes,
|
|
24
|
+
* which is how this has always worked. `checkout` and `relPath` come with it, because the brief has to be copied into
|
|
25
|
+
* the session's fresh clone at the path it has in the operator's tree. A file outside a checkout fails here, with
|
|
26
|
+
* git's own message, which is the right place for it: the caller never has to hold a half-resolved Request.
|
|
27
|
+
*
|
|
28
|
+
* `git` and `gh` are injected. Omit them and you get the parsed Request with `repo` unset, which is what a caller
|
|
29
|
+
* that only wants to run the contract needs; `gallivant ready` takes that shape through `request` below.
|
|
30
|
+
*/
|
|
31
|
+
export async function load(input, { git = null, gh = null } = {}) {
|
|
32
|
+
const path = resolve(input);
|
|
33
|
+
const text = readText(path);
|
|
34
|
+
if (text === null) throw new Error(`cannot read ${path}`);
|
|
35
|
+
const req = request(text, path);
|
|
36
|
+
if (!git || !gh) return req;
|
|
37
|
+
const checkout = await git.toplevel(dirname(path));
|
|
38
|
+
repositoryPath(checkout, relative(checkout, path));
|
|
39
|
+
const { nameWithOwner, defaultBranch } = await gh.repoInfo(checkout);
|
|
40
|
+
return { ...req, repo: nameWithOwner, defaultBranch, checkout, relPath: relative(checkout, path) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The Request for PRD text at a path, with no filesystem, git or GitHub access. `parsePrd` already builds it: a
|
|
45
|
+
* parsed PRD file is a Request whose `source.kind` is "prd".
|
|
46
|
+
*/
|
|
47
|
+
export const request = (text, path) => parsePrd(text, path);
|
|
48
|
+
|
|
49
|
+
/** The filename, for messages that name the file rather than the id. */
|
|
50
|
+
export const fileOf = req => (req.path ? basename(req.path) : req.id);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Shared approval boundary for immediate starts and queued starts. Hash exactly the bytes start will copy.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { check, renderCheck, sourcePathsOf } from "./prd.mjs";
|
|
4
|
+
import { validateContextPaths, parseContextPathsMeta } from "./config.mjs";
|
|
5
|
+
import { readRepositoryText } from "./repository_files.mjs";
|
|
6
|
+
|
|
7
|
+
export function validateStartRequest(request, config, path) {
|
|
8
|
+
const verdict = check(request);
|
|
9
|
+
if (!verdict.ok) throw new Error(`PRD fails the contract; nothing started.\n\n${renderCheck(request, verdict)}`);
|
|
10
|
+
if (request.status !== "ready") throw new Error(`PRD status is "${request.status}". Run \`gallivant ready ${path}\` first.`);
|
|
11
|
+
if (!config.harness[request.harness]) throw new Error(`harness "${request.harness}" is not configured in config.json`);
|
|
12
|
+
const problems = validateContextPaths(parseContextPathsMeta(request.meta?.context_paths), config.home, `${request.id} frontmatter context_paths`);
|
|
13
|
+
if (problems.length) throw new Error(`PRD frontmatter problems; nothing started.\n - ${problems.join("\n - ")}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const contentDigest = text => text === null ? null : createHash("sha256").update(text).digest("hex");
|
|
17
|
+
|
|
18
|
+
export function captureStartInput(request, base) {
|
|
19
|
+
const sources = sourcePathsOf(request).map(rel => {
|
|
20
|
+
const text = readRepositoryText(request.checkout, rel);
|
|
21
|
+
if (text.includes("\u0000")) throw new Error(`${rel} is not text`);
|
|
22
|
+
return [rel, text];
|
|
23
|
+
});
|
|
24
|
+
const files = [[request.relPath, request.text], ...sources];
|
|
25
|
+
const digest = contentDigest(JSON.stringify([request.repo.toLowerCase(), request.checkout, base, files]));
|
|
26
|
+
return { digest, sources, files };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A carried planning file may override the queued base, but must not revert a newer integrated version.
|
|
30
|
+
export async function checkBaseFiles(input, baseFiles, readFile) {
|
|
31
|
+
for (const [path, text] of input.files) {
|
|
32
|
+
const current = contentDigest(await readFile(path));
|
|
33
|
+
if (current !== baseFiles[path] && current !== contentDigest(text)) {
|
|
34
|
+
throw Object.assign(new Error(`${path} changed on the base after this chain was approved; refusing to overwrite merged work. Reconcile the local input, then retry with --resnapshot.`), { code: "CHAIN_INPUT_CHANGED" });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|