@uchibeke/gallivant 0.2.1 → 0.3.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/README.md +38 -16
- package/SECURITY.md +9 -0
- package/bin/gallivant.mjs +33 -10
- package/bin/promote-bitbucket.mjs +40 -0
- package/bin/task-pr.mjs +9 -0
- package/bin/task-review.mjs +15 -0
- package/docs/README.md +2 -0
- package/docs/architecture.md +11 -3
- package/docs/bitbucket-promotion.md +69 -0
- package/docs/bitbucket-review.md +330 -0
- package/docs/chains.md +30 -1
- package/docs/codex-cloud.md +7 -0
- package/docs/codex-review.md +50 -0
- package/docs/commands.md +65 -5
- package/docs/config.md +35 -10
- package/docs/first-party-review.md +266 -0
- package/docs/git-hosts.md +294 -0
- package/docs/guardrails.md +56 -0
- package/docs/install.md +18 -8
- package/docs/network-recovery.md +9 -0
- package/docs/openai-agents.md +2 -1
- package/docs/prd.md +23 -2
- package/docs/preparation.md +1 -1
- package/docs/release-qualification.md +1 -1
- package/docs/releasing.md +17 -2
- package/docs/task-delivery.md +4 -4
- package/docs/task-profiles.md +537 -0
- package/docs/tasks.md +59 -197
- package/lib/attribution.mjs +7 -3
- package/lib/block.mjs +10 -1
- package/lib/chains.mjs +101 -30
- package/lib/codex_cloud.mjs +2 -1
- package/lib/codex_cloud_input.mjs +15 -6
- package/lib/codex_cloud_result.mjs +1 -1
- package/lib/commands/doctor.mjs +22 -462
- package/lib/commands/init.mjs +72 -30
- package/lib/commands/register.mjs +25 -11
- package/lib/commands/resume.mjs +5 -1
- package/lib/commands/review.mjs +66 -0
- package/lib/commands/serve.mjs +8 -0
- package/lib/commands/start.mjs +30 -11
- package/lib/commands/status.mjs +18 -2
- package/lib/commands/task.mjs +9 -1
- package/lib/commands/up.mjs +19 -11
- package/lib/commands/why.mjs +7 -3
- package/lib/config.mjs +42 -9
- package/lib/context.mjs +23 -18
- package/lib/control.mjs +21 -6
- package/lib/credential.mjs +20 -14
- package/lib/deliverable.mjs +2 -0
- package/lib/events.mjs +96 -53
- package/lib/execution.mjs +48 -3
- package/lib/execution_preparation.mjs +16 -1
- package/lib/execution_result.mjs +26 -5
- package/lib/first_party_review.mjs +618 -0
- package/lib/gh.mjs +144 -56
- package/lib/git.mjs +133 -32
- package/lib/git_diff.mjs +121 -0
- package/lib/harness.mjs +14 -10
- package/lib/host_contract.mjs +71 -0
- package/lib/host_definitions.mjs +22 -0
- package/lib/hosts/bitbucket.mjs +553 -0
- package/lib/hosts/bitbucket_contract.mjs +57 -0
- package/lib/hosts/bitbucket_credential.mjs +62 -0
- package/lib/hosts/bitbucket_review.mjs +44 -0
- package/lib/hosts/bitbucket_review_producer.mjs +50 -0
- package/lib/hosts/github.mjs +57 -0
- package/lib/hosts/github_contract.mjs +27 -0
- package/lib/hosts/github_doctor.mjs +482 -0
- package/lib/hosts/github_review_summary.mjs +38 -0
- package/lib/hosts/transport_doctor.mjs +35 -0
- package/lib/hosts.mjs +130 -0
- package/lib/learning_qualification.mjs +1 -1
- package/lib/notify.mjs +1 -1
- package/lib/openai_agents_input.mjs +7 -2
- package/lib/openai_agents_sources.mjs +16 -8
- package/lib/plugin_package.mjs +13 -1
- package/lib/pr_operation.mjs +41 -0
- package/lib/prd.mjs +18 -3
- package/lib/preparation_codex.mjs +6 -1
- package/lib/probe.mjs +6 -4
- package/lib/process.mjs +24 -0
- package/lib/promotion.mjs +132 -0
- package/lib/promotion_git.mjs +63 -0
- package/lib/prompt.mjs +16 -13
- package/lib/provider_health.mjs +14 -3
- package/lib/repository.mjs +68 -0
- package/lib/repository_preflight.mjs +11 -0
- package/lib/review_evaluation.mjs +25 -0
- package/lib/review_evidence.mjs +500 -0
- package/lib/review_history.mjs +73 -0
- package/lib/review_image.mjs +145 -0
- package/lib/review_link.mjs +12 -0
- package/lib/review_policy.mjs +132 -0
- package/lib/review_policy_contract.mjs +18 -0
- package/lib/review_state.mjs +13 -0
- package/lib/review_visual.mjs +94 -0
- package/lib/runtime-environment.mjs +6 -0
- package/lib/runtime_node.mjs +3 -2
- package/lib/runtime_state.mjs +39 -5
- package/lib/sources/prd.mjs +6 -3
- package/lib/state.mjs +137 -24
- package/lib/task_completion.mjs +7 -3
- package/lib/task_contract.mjs +201 -27
- package/lib/task_delivery.mjs +9 -2
- package/lib/task_delivery_contract.mjs +13 -9
- package/lib/task_delivery_view.mjs +18 -2
- package/lib/task_environment.mjs +13 -1
- package/lib/task_limits.mjs +13 -0
- package/lib/task_model_config.mjs +99 -0
- package/lib/task_model_route.mjs +243 -0
- package/lib/task_prepare.mjs +21 -8
- package/lib/task_profile.mjs +1875 -0
- package/lib/task_repository_context.mjs +65 -0
- package/lib/tasks.mjs +252 -51
- package/lib/tick.mjs +353 -97
- package/lib/time.mjs +1 -0
- package/lib/tracker/pages.mjs +19 -5
- package/lib/tracker/review.mjs +21 -0
- package/lib/tracker_process.mjs +8 -1
- package/lib/verification_sandbox.mjs +2 -1
- package/lib/work_artifacts.mjs +79 -0
- package/npm-shrinkwrap.json +12 -3
- package/package.json +9 -3
- package/templates/plugin/task-delivery.SKILL.md +17 -10
- package/templates/plugin/task-intake-contract.json +18 -0
- package/templates/plugin/task-profile-requests.mjs +1448 -0
- package/templates/tracker/tracker.css +3 -0
- package/templates/tracker/tracker.js +38 -0
package/lib/commands/task.mjs
CHANGED
|
@@ -31,6 +31,7 @@ export async function taskCommand(ctx, { args, values, service = null, stdin = r
|
|
|
31
31
|
const shared = { taskId: id, expectedRevision, noDaemon: values["no-daemon"] };
|
|
32
32
|
if (values.context !== undefined && !["prepare", "answer"].includes(operation)) throw taskError("TASK_INPUT", "--context is supported by task prepare and task answer");
|
|
33
33
|
if (values["completion-target"] !== undefined && operation !== "prepare") throw taskError("TASK_INPUT", "--completion-target is selected only at task creation");
|
|
34
|
+
for (const flag of ["delivery", "route"]) if (values[flag] !== undefined && operation !== "prepare") throw taskError("TASK_INPUT", `--${flag} is selected only at task creation`);
|
|
34
35
|
if (operation === "list") return (service || createTaskService(ctx)).list();
|
|
35
36
|
if (operation === "inspect") return (service || createTaskService(ctx)).inspect(shared);
|
|
36
37
|
const owner = acquireCliMutation(ctx.home);
|
|
@@ -53,7 +54,10 @@ export async function taskCommand(ctx, { args, values, service = null, stdin = r
|
|
|
53
54
|
const textArgs = first === "prepare" ? args.slice(1) : args;
|
|
54
55
|
if (values.stdin && textArgs.length) throw taskError("TASK_INPUT", "use text or --stdin, not both");
|
|
55
56
|
const text = textArgs.length ? textArgs.join(" ") : await stdin();
|
|
56
|
-
|
|
57
|
+
// --delivery selects the typed profile action; --completion-target keeps its
|
|
58
|
+
// meaning as the profile's completion condition. Both are owner choices.
|
|
59
|
+
const delivery = values.delivery === undefined ? undefined : { action: values.delivery, ...(values["completion-target"] === undefined ? {} : { completion: values["completion-target"] }) };
|
|
60
|
+
return await service.prepare({ text, dir: values.dir, repo: values.repo, base: values.base, harness: values.harness, context: values.context || [], requestId: values["request-id"], run: values.run, noDaemon: values["no-daemon"], verifyCommands: values.verify || [], ...(values["completion-target"] === undefined ? {} : { completion: { target: values["completion-target"] } }), ...(delivery ? { delivery } : {}), route: values.route ?? null });
|
|
57
61
|
}); } finally { owner.release(); }
|
|
58
62
|
}
|
|
59
63
|
|
|
@@ -67,6 +71,10 @@ export function renderTask(result) {
|
|
|
67
71
|
result.dispatchRecovery && `Dispatch recovery: inspect the clone at ${result.dispatchRecovery.worktree}; start ownership is retained`,
|
|
68
72
|
result.implementation && `Implementation: ${result.implementation.id} (run ${result.implementation.run})`,
|
|
69
73
|
result.completion && `Completion target: ${result.completion.target} (${result.completion.status})`,
|
|
74
|
+
result.deliveryProfile && (result.deliveryProfile.legacy ? `Delivery profile: historical task (${result.deliveryProfile.label})` : result.deliveryProfile.supported ? `Delivery profile: ${result.deliveryProfile.action} → ${result.deliveryProfile.target.repository}@${result.deliveryProfile.target.base}, completion ${result.deliveryProfile.completion.condition} (v${result.deliveryProfile.version}); no merge or deployment authority` : `Delivery profile: unsupported (${result.deliveryProfile.error?.message})`),
|
|
75
|
+
result.modelRoute && `Planner route: ${result.modelRoute.kind === "model" ? `${result.modelRoute.adapter} ${result.modelRoute.model} via ${result.modelRoute.endpointHost}` : `harness ${result.modelRoute.harness}`}; calls ${result.modelRoute.calls}; cost ${result.cost.status}${Number.isFinite(result.cost.usd) ? ` $${result.cost.usd.toFixed(4)}` : ""}`,
|
|
76
|
+
result.repositoryContext && `Repository context: ${result.repositoryContext.status}${result.repositoryContext.revision ? ` at ${result.repositoryContext.revision.slice(0, 12)} (digest ${result.repositoryContext.digest.slice(0, 12)})` : result.repositoryContext.reason ? `: ${result.repositoryContext.reason}` : ""}`,
|
|
77
|
+
result.review && `Review: revision ${result.review.revision}, digest ${result.review.digest.slice(0, 12)}, ${result.review.requirementMap.length} clauses mapped, ${result.review.questions.length} open question(s)${result.approval ? `, approved at ${result.approval.at}` : ", not yet approved"}`,
|
|
70
78
|
result.daemonError,
|
|
71
79
|
`Next: ${result.nextAction}`,
|
|
72
80
|
].filter(Boolean).join("\n");
|
package/lib/commands/up.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hostApi, hosts } from "../hosts.mjs";
|
|
1
2
|
// `gallivant up`: the daemon. Reconciles with GitHub on boot, then ticks on a setTimeout chain so ticks never
|
|
2
3
|
// overlap. Health is a file (daemon.json) that `status` reads; a second daemon exits when the first is alive.
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -6,7 +7,7 @@ import { openSync, closeSync } from "node:fs";
|
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { makeTick } from "../tick.mjs";
|
|
8
9
|
import { checkGh, checkHarness, probeFix } from "../probe.mjs";
|
|
9
|
-
import { makeProviderHealth } from "../provider_health.mjs";
|
|
10
|
+
import { makeProviderHealth, coordinatorHealthLimits, scheduleProviderHealth } from "../provider_health.mjs";
|
|
10
11
|
import { EXECUTION_LIMITS } from "../execution_io.mjs";
|
|
11
12
|
import { readJson, writeJsonAtomic, VERSION, codeStamp, gallivantHome } from "../fs.mjs";
|
|
12
13
|
import { nowIso, later } from "../time.mjs";
|
|
@@ -17,6 +18,7 @@ import { newEntry, entryId, STATES } from "../state.mjs";
|
|
|
17
18
|
import { receiptHash } from "../deliverable.mjs";
|
|
18
19
|
import { worktreePath, isOurBranch, prdIdFromBranch } from "../git.mjs";
|
|
19
20
|
import { daemonPath, readDaemon, assertDaemonUsable, acquireDaemon, assertUnpinnedHome } from "../daemon.mjs";
|
|
21
|
+
import { repositoryReviewConfig } from "../review_policy.mjs";
|
|
20
22
|
import { repoNames } from "../config.mjs";
|
|
21
23
|
|
|
22
24
|
export { daemonPath, readDaemon } from "../daemon.mjs";
|
|
@@ -54,11 +56,12 @@ export function down(ctx, { home = ctx?.home ?? gallivantHome() } = {}) {
|
|
|
54
56
|
* the session, which the log says. Missing checkouts are recreated lazily by the tick when a run is needed.
|
|
55
57
|
*/
|
|
56
58
|
export async function reconcile(ctx, ownLogin) {
|
|
57
|
-
const { config, state,
|
|
59
|
+
const { config, state, log } = ctx;
|
|
60
|
+
const host = hostApi(ctx);
|
|
58
61
|
const added = [];
|
|
59
62
|
for (const repo of repoNames(config)) {
|
|
60
63
|
let prs;
|
|
61
|
-
try { prs = await
|
|
64
|
+
try { const selected = (ctx.hosts ?? hosts).select(repo); repositoryReviewConfig(config, repo, selected.adapter); prs = await host.prListMine(repo); } catch (e) { log.warn(`reconcile ${repo}: ${e.message}`); continue; }
|
|
62
65
|
for (const pr of prs || []) {
|
|
63
66
|
if (pr.isCrossRepository || !isOurBranch(pr.headRefName)) continue;
|
|
64
67
|
if (state.findByPr(repo, pr.number)) continue;
|
|
@@ -70,15 +73,15 @@ export async function reconcile(ctx, ownLogin) {
|
|
|
70
73
|
if (state.get(id)) { log.warn(`reconcile ${repo}#${pr.number}: id ${id} already in use, ignored`); continue; }
|
|
71
74
|
try { state.assertCanRegister(id); }
|
|
72
75
|
catch (e) { log.warn(`reconcile ${repo}#${pr.number}: ${e.message}, ignored`); continue; }
|
|
73
|
-
const [comments, headDate] = await Promise.all([
|
|
74
|
-
const lastOwn = lastOwnCommentAt(comments, ownLogin);
|
|
76
|
+
const [comments, headDate] = await Promise.all([host.issueComments(repo, pr.number), host.commitDate(repo, pr.headRefOid).catch(() => null)]);
|
|
77
|
+
const lastOwn = lastOwnCommentAt(comments, host.userFor ? await host.userFor(repo) : ownLogin);
|
|
75
78
|
await ensurePrdSnapshot(ctx, { id, repo, path: block.prd, ref: pr.headRefName });
|
|
76
79
|
// `base` is the branch the PR targets, read off the PR rather than guessed from config: a recovered entry has to merge
|
|
77
80
|
// in the branch this pull request will actually be merged into, and only GitHub knows which that is.
|
|
78
81
|
const entry = newEntry({ prd: block.prd, harness: block.harness, session: block.session, repo, branch: pr.headRefName, worktree: worktreePath(config, repo, prdId), pr: pr.number, url: pr.url, state: STATES.prOpen, nowIso: nowIso(), base: typeof pr.baseRefName === "string" && pr.baseRefName ? pr.baseRefName : null });
|
|
79
82
|
try { state.put(id, {
|
|
80
83
|
...entry,
|
|
81
|
-
iteration: countRounds(comments, ownLogin),
|
|
84
|
+
iteration: countRounds(comments, host.userFor ? await host.userFor(repo) : ownLogin),
|
|
82
85
|
// The receipt fingerprint comes from the body `prListMine` already returned, as every other writer of this
|
|
83
86
|
// cursor sets it. Left null, a receipt that was on the PR before the recovery read as a change the next time
|
|
84
87
|
// a run ended, so a run that did nothing counted as a round and posted "Completion receipt updated." to the
|
|
@@ -151,15 +154,18 @@ export async function upInBackground(ctx, { bin = process.execPath, script = fil
|
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
export async function up(ctx, { once = false } = {}) {
|
|
154
|
-
const { home, config,
|
|
157
|
+
const { home, config, notify, log, clock = Date } = ctx;
|
|
158
|
+
const host = hostApi(ctx);
|
|
155
159
|
const existing = readDaemon(home);
|
|
156
160
|
if (existing?.alive && existing.pid !== process.pid) throw new Error(`daemon already running (pid ${existing.pid})`);
|
|
157
161
|
if (existing?.unverified) throw new Error(`daemon pid ${existing.pid} has an unverifiable process identity; verify and stop the older daemon before restarting`);
|
|
158
162
|
const { owner, release } = acquireDaemon(home);
|
|
159
163
|
let ownLogin = null;
|
|
160
164
|
let timer = null;
|
|
165
|
+
let stopProviderHealth = null;
|
|
161
166
|
const cleanup = () => {
|
|
162
167
|
if (timer) clearTimeout(timer);
|
|
168
|
+
stopProviderHealth?.(); stopProviderHealth = null;
|
|
163
169
|
try { writeJsonAtomic(daemonPath(home), { ...readJson(daemonPath(home), {}), ...owner, stoppedAt: nowIso() }); } catch { /* exiting */ }
|
|
164
170
|
try { release(); } catch { /* stale lock can be recovered next boot */ }
|
|
165
171
|
process.off("SIGINT", stop); process.off("SIGTERM", stop); process.off("exit", cleanup);
|
|
@@ -181,11 +187,11 @@ export async function up(ctx, { once = false } = {}) {
|
|
|
181
187
|
catch (e) { log.warn(`cannot write ${daemonPath(home)} (${e.code || e.message}); status will not see this tick`); }
|
|
182
188
|
};
|
|
183
189
|
health({ starting: true });
|
|
184
|
-
ownLogin = await
|
|
185
|
-
const providerHealth = makeProviderHealth({ home, clock, limits: config.execution, probe: (provider, account, signal) => checkHarness(provider, config, { signal }) });
|
|
190
|
+
ownLogin = await host.user();
|
|
191
|
+
const providerHealth = makeProviderHealth({ home, clock, limits: coordinatorHealthLimits(config.execution), probe: (provider, account, signal) => checkHarness(provider, config, { signal }) });
|
|
186
192
|
const githubHealth = makeProviderHealth({ home, file: join(home, "github-health.json"), clock,
|
|
187
193
|
limits: { ...config.execution, progressMs: config.poll_seconds * 1000 },
|
|
188
|
-
probe: (_provider, _account, signal) => checkGh(
|
|
194
|
+
probe: (_provider, _account, signal) => checkGh(host, { signal }) });
|
|
189
195
|
const tickImpl = makeTick({ ...ctx, ownLogin, providerHealth });
|
|
190
196
|
health({ starting: false });
|
|
191
197
|
keepAwake();
|
|
@@ -193,11 +199,13 @@ export async function up(ctx, { once = false } = {}) {
|
|
|
193
199
|
const recovered = await reconcile(ctx, ownLogin);
|
|
194
200
|
if (recovered.length) log.info(`recovered ${recovered.length} PR(s)`);
|
|
195
201
|
const tickMs = Math.min(config.poll_seconds * 1000, config.execution?.progressMs || EXECUTION_LIMITS.progressMs);
|
|
202
|
+
const scopes = Object.keys(config.harness).map(h => [h, config.harness[h]?.account || "operator"]);
|
|
203
|
+
const providerInterval = Math.min(30_000, Math.max(1_000, Math.floor(coordinatorHealthLimits(config.execution).progressMs / 2)));
|
|
204
|
+
stopProviderHealth = scheduleProviderHealth(providerHealth, scopes, { intervalMs: providerInterval, onError: e => log.warn(`provider health refresh failed: ${e.code || "check unavailable"}`) });
|
|
196
205
|
|
|
197
206
|
/** One tick, with every failure caught: a daemon that dies on one bad tick is worse than one that logs it. */
|
|
198
207
|
const loop = async () => {
|
|
199
208
|
try {
|
|
200
|
-
const scopes = Object.keys(config.harness).map(h => [h, config.harness[h]?.account || "operator"]);
|
|
201
209
|
// Provider checks and GitHub/progress work use separate lanes. Failed or
|
|
202
210
|
// due/pending health reads fence only that account/provider, even before
|
|
203
211
|
// refresh gets a worker; the previous healthy cache cannot authorize work.
|
package/lib/commands/why.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// `gallivant why <handle>`: the reason a PR stopped, what was dispatched, what the harness last said, any permission
|
|
2
2
|
// denials, and APort audit lines from the run's window. Reads files; never calls GitHub.
|
|
3
|
+
import { reviewDisclosure } from "../review_policy.mjs";
|
|
3
4
|
import { resolveHandle, shortRepo } from "../state.mjs";
|
|
4
5
|
import { adapterFor } from "../harness.mjs";
|
|
5
6
|
import { prdDivergence, divergenceReason, renderDivergence, prdDecision, sourceDivergence } from "../prd.mjs";
|
|
6
7
|
import { contract } from "../fs.mjs";
|
|
7
8
|
import { aportDirFor, auditEntries } from "../aport.mjs";
|
|
8
9
|
import { READY_LABEL_WRITE, COMPLETION_GATE } from "../tick.mjs";
|
|
9
|
-
import { READY_LABEL } from "../
|
|
10
|
+
import { READY_LABEL } from "../host_contract.mjs";
|
|
10
11
|
import { executionRunner } from "../execution.mjs";
|
|
11
12
|
import { networkRecoveryDetail } from "../network_recovery.mjs";
|
|
12
13
|
import { redact } from "../execution_io.mjs";
|
|
@@ -103,10 +104,13 @@ export function why(ctx, { handle }) {
|
|
|
103
104
|
else if (e.prd_decision && now === e.prd_decision) out.push(` aport_decision is unchanged (${e.prd_decision}), so the edit has not been through \`gallivant ready\`.`);
|
|
104
105
|
}
|
|
105
106
|
if (e.retry) out.push(`waiting on ${e.retry.op === COMPLETION_GATE ? "completion verification" : "GitHub"}: ${e.retry.op} has failed ${e.retry.count} tick${e.retry.count === 1 ? "" : "s"} in a row; the entry stops with "stuck" if it keeps failing`);
|
|
106
|
-
|
|
107
|
+
out.push(` Review: ${reviewDisclosure(e)}`);
|
|
108
|
+
if (e.firstPartyReview?.error) out.push(` Review failure: ${e.firstPartyReview.error}`);
|
|
109
|
+
if (e.firstPartyReview?.unresolvedScope?.length) for (const item of e.firstPartyReview.unresolvedScope) out.push(` Unresolved: ${item}`);
|
|
110
|
+
if (e.retry?.op === COMPLETION_GATE) out.push(` checks settled; ${e.review_readiness?.note || reviewDisclosure(e)}; completion verification is pending: ${e.retry.error ?? "no decision from the gate"}`);
|
|
107
111
|
// The one retry whose op is not a step toward a fix round: everything a person waits for has already happened and
|
|
108
112
|
// only the label that says so will not write, which is worth spelling out where the operator is already reading.
|
|
109
|
-
if (e.retry?.op === READY_LABEL_WRITE) out.push(` this PR is otherwise ready: checks
|
|
113
|
+
if (e.retry?.op === READY_LABEL_WRITE) out.push(` this PR is otherwise ready: checks settled, ${e.review_readiness?.note || reviewDisclosure(e)}, receipt accepted. Only the ${READY_LABEL} label will not write.`);
|
|
110
114
|
// The round comment is the announcement of a round that is already counted, so the operator needs both halves: the
|
|
111
115
|
// work is on the branch and charged, and the PR does not say so. The second half matters because the count on GitHub
|
|
112
116
|
// is what a recovery rebuilds from, so an unposted marker is a round a recovery after this would not see.
|
package/lib/config.mjs
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { repositoryIdentity } from "./repository.mjs";
|
|
2
|
+
import { repositoryReviewConfig, reviewPolicyProblems } from "./review_policy.mjs";
|
|
3
|
+
import { HOST_DEFINITIONS } from "./host_definitions.mjs";
|
|
1
4
|
// Config: one JSON file the operator writes by hand, validated here so every other module can trust its shape.
|
|
2
5
|
//
|
|
3
6
|
// Defaults hold nothing machine-specific. Anything that identifies the operator (trusted logins, token file,
|
|
4
7
|
// binaries) has to be written explicitly, which is what `doctor` walks you through.
|
|
8
|
+
import { reviewSettings, validateReviewSettings } from "./review_policy.mjs";
|
|
5
9
|
import { join, relative, isAbsolute } from "node:path";
|
|
6
10
|
import { homedir } from "node:os";
|
|
7
11
|
import { realpathSync } from "node:fs";
|
|
@@ -10,6 +14,7 @@ import { validateAgentsConfig } from "./openai_agents_config.mjs";
|
|
|
10
14
|
import { validateCloudConfig } from "./codex_cloud_config.mjs";
|
|
11
15
|
import { adapters, executionRegistry } from "./harness.mjs";
|
|
12
16
|
import { findExecutable } from "./executable.mjs";
|
|
17
|
+
import { intakeModelProblems } from "./task_model_config.mjs";
|
|
13
18
|
|
|
14
19
|
// Recognition is independent of new-dispatch availability: a registered provider
|
|
15
20
|
// must retain configuration and recovery identities while its readiness is refused.
|
|
@@ -77,7 +82,7 @@ export const DEFAULT_DELIVERABLE = Object.freeze({
|
|
|
77
82
|
export const DEFAULTS = Object.freeze({
|
|
78
83
|
deliverable: DEFAULT_DELIVERABLE,
|
|
79
84
|
poll_seconds: 120,
|
|
80
|
-
max_iterations:
|
|
85
|
+
max_iterations: 60,
|
|
81
86
|
checks_grace_minutes: 5, // a head younger than this with no checks yet is not "green", it is "not reported yet"
|
|
82
87
|
// What this number is FOR, because the value only makes sense once that is settled. The timeout catches a session that
|
|
83
88
|
// is genuinely wedged: spinning on a command that never returns, waiting on a prompt that will never come, looping.
|
|
@@ -97,7 +102,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
97
102
|
// A repository with a different shape sets its own on its `repos` entry; this is only the fallback. The validation
|
|
98
103
|
// floor stays at 1, so an operator who deliberately wants a short timeout can have one.
|
|
99
104
|
run_timeout_minutes: 720,
|
|
100
|
-
max_budget_usd:
|
|
105
|
+
max_budget_usd: 50,
|
|
101
106
|
rereview_minutes: 6,
|
|
102
107
|
stall_hours: 6,
|
|
103
108
|
trusted_authors: [],
|
|
@@ -278,21 +283,23 @@ export function resolveTimeout({ config, repo }) {
|
|
|
278
283
|
*/
|
|
279
284
|
export function validateRepos(list) {
|
|
280
285
|
if (list === undefined || list === null) return [];
|
|
281
|
-
if (!Array.isArray(list)) return ['repos must be an array of "owner/name" strings or { repo, base, run_timeout_minutes } objects'];
|
|
286
|
+
if (!Array.isArray(list)) return ['repos must be an array of "owner/name" strings or { repo, base, run_timeout_minutes, review } objects'];
|
|
282
287
|
const problems = [];
|
|
283
288
|
const seen = new Set();
|
|
284
289
|
for (const raw of list) {
|
|
285
290
|
if (typeof raw === "string") {
|
|
286
|
-
if (
|
|
291
|
+
if (!validRepository(raw)) { problems.push("repos: invalid identity is not owner/name or host/owner/name; URL credentials are not accepted"); continue; }
|
|
287
292
|
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
288
|
-
if (typeof raw.repo !== "string" ||
|
|
293
|
+
if (typeof raw.repo !== "string" || !validRepository(raw.repo)) { problems.push('repos: needs a "repo" of the form owner/name or host/owner/name, without URL credentials'); continue; }
|
|
289
294
|
if (raw.base !== undefined && raw.base !== null && (typeof raw.base !== "string" || !raw.base.trim())) { problems.push(`repos: ${raw.repo} base must be a branch name`); continue; }
|
|
290
295
|
// Refused rather than ignored, for the reason the unknown-key check above exists: a timeout typed as "45" or as 0
|
|
291
296
|
// would read as "not configured" and the repository would keep the global forever, which is the silent failure.
|
|
292
297
|
if (raw.run_timeout_minutes !== undefined && raw.run_timeout_minutes !== null && (typeof raw.run_timeout_minutes !== "number" || raw.run_timeout_minutes < 1)) { problems.push(`repos: ${raw.repo} run_timeout_minutes must be a number >= 1`); continue; }
|
|
293
|
-
|
|
294
|
-
|
|
298
|
+
if (raw.review != null && (typeof raw.review !== "object" || Array.isArray(raw.review))) { problems.push(`repos: ${raw.repo} review must be an object of review settings`); continue; }
|
|
299
|
+
const extra = Object.keys(raw).filter(k => !["repo", "base", "run_timeout_minutes", "review", "reviewer", "trusted_authors", "required_checks"].includes(k));
|
|
300
|
+
if (extra.length) { problems.push(`repos: ${raw.repo} has unknown key${extra.length === 1 ? "" : "s"} ${extra.join(", ")} (use repo, base, run_timeout_minutes and explicit review policy)`); continue; }
|
|
295
301
|
} else { problems.push(`repos: ${JSON.stringify(raw)} is neither "owner/name" nor a { repo } object`); continue; }
|
|
302
|
+
if (typeof raw === "object") problems.push(...reviewPolicyProblems(raw, HOST_DEFINITIONS.find(d => d.host === repositoryIdentity(raw.repo).host)?.reviewPolicy));
|
|
296
303
|
const key = repoEntry(raw).repo;
|
|
297
304
|
if (seen.has(key)) problems.push(`repos: ${key} is listed twice`);
|
|
298
305
|
seen.add(key);
|
|
@@ -323,6 +330,14 @@ export function merge(raw, home = gallivantHome()) {
|
|
|
323
330
|
/** Problems as human sentences; empty means valid. Used by `load` (throws) and by `doctor` (prints). */
|
|
324
331
|
export function validate(cfg, home = gallivantHome()) {
|
|
325
332
|
const problems = [];
|
|
333
|
+
if (cfg.git_hosts != null) {
|
|
334
|
+
if (typeof cfg.git_hosts !== "object" || Array.isArray(cfg.git_hosts)) problems.push("git_hosts must be a host-scoped configuration object");
|
|
335
|
+
else for (const [host, settings] of Object.entries(cfg.git_hosts)) {
|
|
336
|
+
const adapter = HOST_DEFINITIONS.find(d => d.host === host);
|
|
337
|
+
if (!adapter?.validateConfig) problems.push("Unsupported git_hosts configuration; use a registered host's documented credential settings");
|
|
338
|
+
else problems.push(...adapter.validateConfig(settings));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
326
341
|
const num = (k, min) => { if (typeof cfg[k] !== "number" || cfg[k] < min) problems.push(`${k} must be a number >= ${min}`); };
|
|
327
342
|
num("poll_seconds", 30); num("max_iterations", 1); num("run_timeout_minutes", 1); num("max_budget_usd", 0);
|
|
328
343
|
num("rereview_minutes", 1); num("stall_hours", 1); num("checks_grace_minutes", 0);
|
|
@@ -333,7 +348,21 @@ export function validate(cfg, home = gallivantHome()) {
|
|
|
333
348
|
if (!range || !Number.isSafeInteger(value) || value < range[0] || value > range[1]) problems.push(`execution.${key} must be an integer in ${range ? range.join("..") : "the documented execution limits"}`);
|
|
334
349
|
}
|
|
335
350
|
}
|
|
336
|
-
|
|
351
|
+
const legacy = !Array.isArray(cfg.repos) || !cfg.repos.length || cfg.repos.some(r => {
|
|
352
|
+
try { return HOST_DEFINITIONS.find(d => d.host === repositoryIdentity(typeof r === "string" ? r : r.repo).host)?.features?.legacyReviewDefaults; } catch { return true; }
|
|
353
|
+
});
|
|
354
|
+
if (legacy && (!Array.isArray(cfg.trusted_authors) || cfg.trusted_authors.length === 0)) problems.push("trusted_authors must list at least one GitHub login");
|
|
355
|
+
for (const repo of Array.isArray(cfg.repos) ? cfg.repos : []) {
|
|
356
|
+
try { const r = repositoryIdentity(typeof repo === "string" ? repo : repo.repo); if (HOST_DEFINITIONS.find(d => d.host === r.host)?.reviewPolicy) repositoryReviewConfig(cfg, r); }
|
|
357
|
+
catch (error) { problems.push(error.message); }
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
for (const value of [cfg.review, ...(cfg.repos || []).map(r => r?.review)]) {
|
|
361
|
+
if (value !== undefined && (value === null || typeof value !== "object" || Array.isArray(value))) throw new Error("review must be a configuration object");
|
|
362
|
+
}
|
|
363
|
+
validateReviewSettings(reviewSettings(cfg));
|
|
364
|
+
for (const repo of cfg.repos || []) if (repo?.review) validateReviewSettings(reviewSettings(cfg, repo.repo));
|
|
365
|
+
} catch (error) { problems.push(error.message); }
|
|
337
366
|
if (cfg.reviewer !== null) {
|
|
338
367
|
for (const k of ["login", "rereview_comment", "blocking_pattern"]) if (typeof cfg.reviewer?.[k] !== "string") problems.push(`reviewer.${k} must be a string (or set reviewer to null)`);
|
|
339
368
|
// `reviewed_commit_pattern` is optional, and that is deliberate rather than an omission. It was added after configs
|
|
@@ -350,6 +379,7 @@ export function validate(cfg, home = gallivantHome()) {
|
|
|
350
379
|
if (cfg.deliverable !== null && (typeof cfg.deliverable !== "object" || Array.isArray(cfg.deliverable))) problems.push("deliverable must be an object of limits (or null to disable the completion gate)");
|
|
351
380
|
else if (cfg.deliverable && !["aport", "local"].includes(cfg.deliverable.evaluator)) problems.push('deliverable.evaluator must be "aport" or "local"');
|
|
352
381
|
else if (cfg.deliverable && !["wait", "local"].includes(cfg.deliverable.on_unreachable)) problems.push('deliverable.on_unreachable must be "wait" or "local"');
|
|
382
|
+
if (cfg.intake_model !== undefined && cfg.intake_model !== null) problems.push(...intakeModelProblems(cfg.intake_model));
|
|
353
383
|
if (!cfg.worktrees) problems.push("worktrees must be a directory path outside the Gallivant home");
|
|
354
384
|
else if (isInside(expand(cfg.worktrees), home)) problems.push("worktrees must not be under the Gallivant home directory");
|
|
355
385
|
problems.push(...validateContextPaths(cfg.context_paths, home));
|
|
@@ -401,7 +431,8 @@ export function repoEntry(raw) {
|
|
|
401
431
|
if (raw && typeof raw === "object" && typeof raw.repo === "string") {
|
|
402
432
|
const base = typeof raw.base === "string" && raw.base.trim() ? raw.base.trim() : null;
|
|
403
433
|
const t = typeof raw.run_timeout_minutes === "number" && raw.run_timeout_minutes >= 1 ? raw.run_timeout_minutes : null;
|
|
404
|
-
|
|
434
|
+
const policy = Object.fromEntries(["review", "reviewer", "trusted_authors", "required_checks"].filter(k => Object.hasOwn(raw, k)).map(k => [k, raw[k]]));
|
|
435
|
+
return { repo: raw.repo.toLowerCase(), base, run_timeout_minutes: t, ...policy };
|
|
405
436
|
}
|
|
406
437
|
return null;
|
|
407
438
|
}
|
|
@@ -459,3 +490,5 @@ export function rememberRepo(nameWithOwner, home = gallivantHome(), base = null)
|
|
|
459
490
|
writeJsonAtomic(configPath(home), { ...raw, repos: updated }, 0o600);
|
|
460
491
|
return true;
|
|
461
492
|
}
|
|
493
|
+
|
|
494
|
+
function validRepository(value) { try { repositoryIdentity(value); return true; } catch { return false; } }
|
package/lib/context.mjs
CHANGED
|
@@ -5,9 +5,9 @@ import { contentHash } from "./openai_agents_sources.mjs";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { load as loadConfig } from "./config.mjs";
|
|
7
7
|
import { makeState, idSlug } from "./state.mjs";
|
|
8
|
-
import {
|
|
8
|
+
import { makeHostRegistry } from "./hosts.mjs";
|
|
9
9
|
import { makeGit } from "./git.mjs";
|
|
10
|
-
import {
|
|
10
|
+
import { repositoryIdentity } from "./repository.mjs";
|
|
11
11
|
import { resolveIdentity } from "./identity.mjs";
|
|
12
12
|
import { makeNotifier } from "./notify.mjs";
|
|
13
13
|
import { makeLog } from "./log.mjs";
|
|
@@ -20,6 +20,7 @@ import { capabilitiesFor } from "./harness.mjs";
|
|
|
20
20
|
import { createLearningService } from "./learning.mjs";
|
|
21
21
|
import { makeTrust } from "./events.mjs";
|
|
22
22
|
import { revokeLearningAttempts } from "./learning_input.mjs";
|
|
23
|
+
import { createReviewService } from "./first_party_review.mjs";
|
|
23
24
|
import { silentLog } from "./log.mjs";
|
|
24
25
|
const { createTaskService } = await import("./tasks.mjs").catch(error => {
|
|
25
26
|
if (error.code === "ERR_MODULE_NOT_FOUND") assertExecutionDependency(null);
|
|
@@ -42,26 +43,24 @@ export function makeContext({ home = gallivantHome(), needConfig = true, readOnl
|
|
|
42
43
|
const log = readOnly ? silentLog : makeLog(home);
|
|
43
44
|
if (!needConfig) return { home, log };
|
|
44
45
|
const config = loadConfig(home);
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// under a long-lived daemon gets the new token without restarting it. A value cached at boot would expire under
|
|
50
|
-
// exactly the daemon this product is built around.
|
|
51
|
-
const credential = (opts = {}) => resolveCredential(config, opts);
|
|
46
|
+
// Each adapter resolves its credentials per operation, so reauthentication is
|
|
47
|
+
// picked up without restarting the coordinator or sharing another host's token.
|
|
48
|
+
const hosts = makeHostRegistry({ config, log });
|
|
49
|
+
const credential = (opts = {}) => hosts.credential(repositoryIdentity(opts.path ?? opts.repository ?? opts.repo ?? "owner/name", opts.host));
|
|
52
50
|
// The git identity a run commits as, resolved the same way and for the same reason: per call, so an operator who
|
|
53
51
|
// changes their name or email under a long-lived daemon is committed as who they are now.
|
|
54
52
|
const identity = () => resolveIdentity();
|
|
55
|
-
const
|
|
53
|
+
const host = hosts.api;
|
|
54
|
+
const gh = hosts.select("owner/name").api;
|
|
56
55
|
const state = makeState(home);
|
|
57
56
|
const context = {
|
|
58
|
-
home, log, config, gh, credential, identity,
|
|
57
|
+
home, log, config, host, hosts, gh, credential, identity,
|
|
59
58
|
state, capabilitiesFor,
|
|
60
59
|
executionInputFor: (id, entry, input, contextPaths) => remoteInput(context, id, entry, input, contextPaths),
|
|
61
60
|
// `identity` as well as `credential`, because `git.mergeBase` commits: the merge that brings a base into a branch
|
|
62
61
|
// is the daemon's own commit, and with no identity pairs it is either attributed to a machine-derived address or
|
|
63
62
|
// refused outright, depending on whether the host's git can guess one. See makeGit for the two shapes.
|
|
64
|
-
git: makeGit({ config,
|
|
63
|
+
git: makeGit({ config, identity, hosts }),
|
|
65
64
|
notify: makeNotifier({ config, log }),
|
|
66
65
|
runnerFor: id => makeRunner(join(home, "runs", idSlug(id))),
|
|
67
66
|
executionFor: (id, provider) => {
|
|
@@ -72,9 +71,9 @@ export function makeContext({ home = gallivantHome(), needConfig = true, readOnl
|
|
|
72
71
|
return makeExecution({ root, taskId: id, provider, account, ledgerEpoch: epoch, ledgerFile: join(root, epoch ? `execution-${epoch}.json` : "execution.json"),
|
|
73
72
|
...(provider === "openai-agents" ? { transport: makeAgentsTransport({ root, taskId: id, account, config: selected }), capabilities: { ...capabilitiesFor(provider), models: [selected?.model] } } : {}),
|
|
74
73
|
...(provider === "codex-cloud" ? { transport: makeCloudTransport({ root, taskId: id, account, config: selected, verifyRemote: async input => {
|
|
75
|
-
if (await
|
|
74
|
+
if (await host.refSha(input.repo, input.sources.inputBranch) !== input.inputHead || await host.refSha(input.repo, input.branch) !== input.inputHead) throw new Error("Cloud remote input revision moved; reconcile before dispatch");
|
|
76
75
|
for (const [path, hash] of Object.entries(input.sources.hashes)) {
|
|
77
|
-
if (contentHash(await
|
|
76
|
+
if (contentHash(await host.fileAtStrict(input.repo, path, input.inputHead)) !== hash) throw new Error("Cloud remote snapshot hash differs");
|
|
78
77
|
}
|
|
79
78
|
} }), capabilities: { ...capabilitiesFor(provider), environments: [selected?.environment] } } : {}),
|
|
80
79
|
legacySessionId: state.get(id)?.harness === provider ? state.get(id).session : null,
|
|
@@ -92,11 +91,17 @@ export function makeContext({ home = gallivantHome(), needConfig = true, readOnl
|
|
|
92
91
|
const learningServices = new Map();
|
|
93
92
|
context.learningFor = (ownLogin = "learning-coordinator", { signerExecutable } = {}) => {
|
|
94
93
|
const cacheKey = JSON.stringify([ownLogin, signerExecutable ?? null]);
|
|
95
|
-
if (!learningServices.has(cacheKey))
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
if (!learningServices.has(cacheKey)) {
|
|
95
|
+
const service = createLearningService({
|
|
96
|
+
root: join(home, "learning"), signerExecutable, gh, trust: makeTrust(config, ownLogin),
|
|
97
|
+
onRevocation: intent => revokeLearningAttempts(context, intent),
|
|
98
|
+
});
|
|
99
|
+
learningServices.set(cacheKey, { ...service,
|
|
100
|
+
identityFor: name => hosts.select(name).adapter.features?.learning ? service.identityFor(name) : null,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
99
103
|
return learningServices.get(cacheKey);
|
|
100
104
|
};
|
|
105
|
+
if (!readOnly) context.reviewService = createReviewService(context);
|
|
101
106
|
return context;
|
|
102
107
|
}
|
package/lib/control.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hostApi } from "./hosts.mjs";
|
|
1
2
|
// Local operator requests. UI and CLI enqueue; the daemon is the only consumer.
|
|
2
3
|
// One outstanding request per entry avoids competing resumes and needs no second runner.
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -5,7 +6,7 @@ import { needsAgentResume } from "./network_recovery.mjs";
|
|
|
5
6
|
import { resolveHandle, STATES } from "./state.mjs";
|
|
6
7
|
import { readDaemon, assertDaemonUsable } from "./daemon.mjs";
|
|
7
8
|
import { nowIso } from "./time.mjs";
|
|
8
|
-
import { NEEDS_LABEL, READY_LABEL } from "./
|
|
9
|
+
import { NEEDS_LABEL, READY_LABEL } from "./host_contract.mjs";
|
|
9
10
|
import { adapterFor } from "./harness.mjs";
|
|
10
11
|
import { executionRunner, unsubmittedInitialDispatch, PROVIDER_STATES } from "./execution.mjs";
|
|
11
12
|
import { requestRunCancellation } from "./run.mjs";
|
|
@@ -163,21 +164,28 @@ export async function applyControl(ctx, id) {
|
|
|
163
164
|
}
|
|
164
165
|
const runner = executionRunner(ctx, id);
|
|
165
166
|
if (runner.current()?.running) return false;
|
|
167
|
+
if (["message", "resume"].includes(request.action)) {
|
|
168
|
+
const health = ctx.providerHealth?.get(entry.harness, ctx.config.harness[entry.harness]?.account || "operator");
|
|
169
|
+
if (health?.pending) {
|
|
170
|
+
if (request.message !== "Waiting for the provider readiness check.") ctx.state.update(id, current => current.control?.id === request.id ? { control: { ...request, message: "Waiting for the provider readiness check." } } : {});
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
166
174
|
ctx.state.update(id, { control: { ...request, status: "applying" } });
|
|
167
175
|
try {
|
|
168
176
|
if (entry.state === STATES.done) throw new Error("The task was closed or merged before this request ran.");
|
|
169
177
|
if (entry.pr) {
|
|
170
|
-
const pr = await ctx.
|
|
178
|
+
const pr = await hostApi(ctx).prView(entry.repo, entry.pr);
|
|
171
179
|
if (pr.state !== "OPEN") throw new Error("The PR is no longer open.");
|
|
172
180
|
}
|
|
173
181
|
let run = null, initialLaunch = false;
|
|
174
182
|
if (request.action === "recheck") {
|
|
175
183
|
if (needsAgentResume(entry)) throw Object.assign(new Error("The agent failure requires resume, not PR recheck."), { code: "AGENT_RESUME_REQUIRED" });
|
|
176
184
|
if (executionResultCheckpoint(runner)?.phase === "blocked") throw Object.assign(new Error("Result publication needs an explicit corrective resume."), { code: "EXECUTION_RESULT_BLOCKED" });
|
|
177
|
-
await ctx.
|
|
178
|
-
await ctx.
|
|
185
|
+
await hostApi(ctx).removeLabel(entry.repo, entry.pr, READY_LABEL);
|
|
186
|
+
await hostApi(ctx).removeLabel(entry.repo, entry.pr, NEEDS_LABEL);
|
|
179
187
|
ctx.state.update(id, {
|
|
180
|
-
state: STATES.prOpen, reason: null, retry: null, labels_pending: false, public_fix: null,
|
|
188
|
+
state: STATES.prOpen, reason: null, retry: null, labels_pending: false, public_fix: null, review_next_at: 0,
|
|
181
189
|
last: { ...entry.last, event: "operator requested PR recheck" }, updated_at: nowIso(ctx.clock),
|
|
182
190
|
});
|
|
183
191
|
// Preserve budgets, dispatch markers and the brief. Only the normal tick judges readiness.
|
|
@@ -187,13 +195,20 @@ export async function applyControl(ctx, id) {
|
|
|
187
195
|
run = result.run;
|
|
188
196
|
initialLaunch = result.initialLaunch === true;
|
|
189
197
|
// A cosmetic label failure after spawning must not invite another resume.
|
|
190
|
-
if (entry.pr) await ctx.
|
|
198
|
+
if (entry.pr) await hostApi(ctx).removeLabel(entry.repo, entry.pr, NEEDS_LABEL).catch(e => ctx.log.warn(`${id}: could not remove stopped label: ${e.message}`));
|
|
191
199
|
}
|
|
192
200
|
finish({ status: "applied", run, message: initialLaunch ? "Started the initial run after repairing launch prerequisites." : request.action === "message"
|
|
193
201
|
? "Started the next run in the same session with your message."
|
|
194
202
|
: run ? "Resumed the same agent session." : "Watching PR checks and review again." });
|
|
195
203
|
return request.action !== "recheck"; // A recheck continues through normal judgement in this same tick.
|
|
196
204
|
} catch (e) {
|
|
205
|
+
// PROVIDER_UNAVAILABLE is raised only before launch. Preserve the exact
|
|
206
|
+
// request when a due probe races with preparation; do not replay an
|
|
207
|
+
// ambiguous dispatch or turn a failed/auth check into automatic retries.
|
|
208
|
+
if (e.code === "PROVIDER_UNAVAILABLE" && e.retryableProviderPending === true) {
|
|
209
|
+
ctx.state.update(id, current => current.control?.id === request.id ? { control: { ...request, status: "queued", message: "Waiting for the provider readiness check." } } : {});
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
197
212
|
ctx.log.warn(`${id}: operator ${request.action} failed: ${e.message}`);
|
|
198
213
|
// Subprocess errors can carry credentials. Details stay in the local daemon log.
|
|
199
214
|
finish({ status: "failed", message: controlFailure(e) });
|
package/lib/credential.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseRemote, repositoryIdentity } from "./repository.mjs";
|
|
1
2
|
// The one place a GitHub credential for a run is resolved.
|
|
2
3
|
//
|
|
3
4
|
// The defect this exists for. A run is sandboxed: it cannot reach the operator's macOS keyring, their Linux secret
|
|
@@ -46,15 +47,11 @@ export const SOURCES = Object.freeze({
|
|
|
46
47
|
* The host a remote URL's credential belongs to. `gh` can be logged in to an enterprise host as well as
|
|
47
48
|
* github.com, and the token for github.com is the wrong one for `github.acme.com`.
|
|
48
49
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
50
|
+
* The strict repository parser supplies the host for every accepted transport. Malformed input throws
|
|
51
|
+
* without reflecting URL userinfo. The host adapter decides which transport may actually use a token.
|
|
51
52
|
*/
|
|
52
53
|
export function hostFromRemote(url) {
|
|
53
|
-
|
|
54
|
-
const m = s.match(/^https?:\/\/(?:[^@/]*@)?([a-z0-9.-]+)/i);
|
|
55
|
-
if (m) return m[1].toLowerCase();
|
|
56
|
-
const ssh = s.match(/^(?:ssh:\/\/)?(?:[^@/]+@)([a-z0-9.-]+)[:/]/i);
|
|
57
|
-
return ssh ? ssh[1].toLowerCase() : "github.com";
|
|
54
|
+
return parseRemote(url).host;
|
|
58
55
|
}
|
|
59
56
|
|
|
60
57
|
/** True when a host is github.com itself rather than an enterprise install. */
|
|
@@ -111,6 +108,7 @@ export function noCredentialMessage(host = "github.com") {
|
|
|
111
108
|
* putting a real token anywhere near a fixture.
|
|
112
109
|
*/
|
|
113
110
|
export async function resolveCredential(config, { host = "github.com", env = process.env, exec = execFileAsyncQuiet } = {}) {
|
|
111
|
+
if (host.toLowerCase() === "bitbucket.org") throw new Error("GitHub credentials cannot authorize Bitbucket; resolve its selected credential through the host registry");
|
|
114
112
|
// A configured file is an authority boundary, not merely the first candidate.
|
|
115
113
|
// Missing, unreadable or blank must not silently widen to an ambient login.
|
|
116
114
|
if (config?.github_token_file) {
|
|
@@ -134,12 +132,11 @@ export const describe = ({ token, source, host }) =>
|
|
|
134
132
|
token ? `${source}${isDotCom(host) ? "" : ` for ${host}`} (${token.length} characters)` : "none";
|
|
135
133
|
|
|
136
134
|
/**
|
|
137
|
-
* The host a checkout's credential belongs to, from its
|
|
138
|
-
*
|
|
139
|
-
* git operations will say so.
|
|
135
|
+
* The host a checkout's credential belongs to, from its verified origin. Unreadable origins throw before
|
|
136
|
+
* any credential is resolved; they never fall back to another host.
|
|
140
137
|
*/
|
|
141
138
|
export async function hostForWorktree(git, dir) {
|
|
142
|
-
|
|
139
|
+
return hostFromRemote(await git.originOf(dir));
|
|
143
140
|
}
|
|
144
141
|
|
|
145
142
|
/**
|
|
@@ -154,8 +151,17 @@ export async function hostForWorktree(git, dir) {
|
|
|
154
151
|
* from the config alone, so a test that wants a credential sets `github_token_file` and one that wants none sets
|
|
155
152
|
* nothing, and no test needs a `gh` on the machine.
|
|
156
153
|
*/
|
|
157
|
-
export async function resolveCredentialFor(ctx, { worktree = null } = {}) {
|
|
158
|
-
|
|
154
|
+
export async function resolveCredentialFor(ctx, { worktree = null, repo = null } = {}) {
|
|
155
|
+
let repository = repo ? repositoryIdentity(repo) : null;
|
|
156
|
+
if (worktree && ctx.git?.originOf) {
|
|
157
|
+
const actual = parseRemote(await ctx.git.originOf(worktree));
|
|
158
|
+
if (repository && actual.remoteIdentity !== repository.remoteIdentity) throw new Error("Checkout host/repository mismatch; restore the bound origin before resolving credentials");
|
|
159
|
+
repository = actual;
|
|
160
|
+
}
|
|
161
|
+
repository ??= repositoryIdentity("owner/name");
|
|
162
|
+
if (ctx.hosts) return ctx.hosts.credential(repository);
|
|
159
163
|
const resolve = ctx.credential ?? (opts => resolveCredential(ctx.config, opts));
|
|
160
|
-
|
|
164
|
+
const resolved = await resolve({ host: repository.host });
|
|
165
|
+
if (resolved?.host && resolved.host !== repository.host) throw new Error("Credential host mismatch");
|
|
166
|
+
return { ...resolved, host: repository.host, ...(repo || worktree ? { repository: repository.path } : {}) };
|
|
161
167
|
}
|
package/lib/deliverable.mjs
CHANGED
|
@@ -35,6 +35,8 @@ export const provenance = (framework = null) => ({ ...(framework ? { framework }
|
|
|
35
35
|
|
|
36
36
|
const RECEIPT_RE = new RegExp("^```(?:" + RECEIPT_TAGS.join("|") + ")\\s*\\n([\\s\\S]*?)\\n```[ \t]*\\r?(?=\\n|$)", "gm");
|
|
37
37
|
const receiptMatches = body => [...String(body || "").matchAll(RECEIPT_RE)];
|
|
38
|
+
/** A cross-host handoff must not carry the source task's machine receipt. */
|
|
39
|
+
export const withoutReceipt = body => String(body || "").replace(RECEIPT_RE, "");
|
|
38
40
|
export const publicTaskId = taskId => "task-" + createHash("sha256").update(String(taskId)).digest("hex").slice(0, 24);
|
|
39
41
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
40
42
|
// The hosted route limits the whole JSON body to 256 KiB, including the passport and JSON escaping.
|