@edgehero/pi-dispatch 4.3.1 → 5.1.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/.env.example +84 -3
- package/README.md +21 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +118 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +136 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +190 -29
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +325 -0
- package/src/console-client.mjs +848 -0
- package/src/console-devices.mjs +372 -0
- package/src/console-install.mjs +239 -0
- package/src/console-open.mjs +99 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1657 -196
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +96 -6
- package/src/env-file.mjs +6 -4
- package/src/envelope.mjs +5 -3
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-host.mjs +42 -5
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +33 -3
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +734 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +4 -2
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +12 -2
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1550 -173
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +264 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +36 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +780 -38
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +402 -53
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
package/src/git-dirty.mjs
CHANGED
|
@@ -1,30 +1,135 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import {
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { isAbsolute, join } from "node:path";
|
|
4
5
|
import { GIT_READ_FLAGS } from "./git-hardening.mjs";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Report a folder's git working-tree state: `true` = dirty, `false` = clean, `null` = not a usable
|
|
8
|
-
* git repository. Reads the WORKING TREE via `git status --porcelain
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* git repository. Reads the WORKING TREE via `git status --porcelain`, distinct from flow-gate.mjs's
|
|
10
|
+
* object-store read, which reads committed content at a pinned SHA; the two are kept separate on purpose.
|
|
11
|
+
* `exec` and `fs` are injectable for tests.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* THE FOLDER'S OWN GIT CONFIG IS NEVER READ BY THE STATUS. The folder is the path a local job mounts at
|
|
14
|
+
* /workspace with write access, so the agent can write its `.git/config` and `.git/info/attributes`, and
|
|
15
|
+
* `git status` hashes every stat-dirty file through the clean filter those name: a command, run ON THE HOST
|
|
16
|
+
* by the next `pi-dispatch run` or console start. `-c` cannot neutralise a filter whose driver name is not
|
|
17
|
+
* known in advance, and git has no switch that skips a repository's local config. So the status runs
|
|
18
|
+
* against a scratch git directory this function writes itself: HEAD at the folder's commit, the folder's
|
|
19
|
+
* objects reached through `objects/info/alternates`, a copy of its index with every assume-unchanged and
|
|
20
|
+
* skip-worktree bit cleared (an agent could set them to hide a change), and a config
|
|
21
|
+
* holding only the `core` settings that change how a file is compared (each value checked to be a plain
|
|
22
|
+
* word). Its `info/exclude` is not used either. A filter the working tree's `.gitattributes` names then has no
|
|
23
|
+
* driver here and is not run, unless the operator's own global config defines one by that name: then the operator's
|
|
24
|
+
* command runs, on content the agent chose (LFS needs exactly that).
|
|
25
|
+
*
|
|
26
|
+
* The operator's own global and system config still apply: this runs as the operator (the CLI, the
|
|
27
|
+
* console), that config is theirs and outside the container, and an LFS repository needs its filter.
|
|
28
|
+
*
|
|
29
|
+
* The paths and the commit come from `rev-parse`, hardened, which reads config but refreshes no index and
|
|
30
|
+
* so runs no filter and no fsmonitor. An unborn HEAD is `null`: every caller refuses that folder first.
|
|
18
31
|
*/
|
|
19
|
-
export function gitDirty(folder, { exec = execFileSync } = {}) {
|
|
32
|
+
export function gitDirty(folder, { exec = execFileSync, fs = defaultFs } = {}) {
|
|
33
|
+
let scratch = null;
|
|
20
34
|
try {
|
|
21
|
-
const
|
|
22
|
-
|
|
35
|
+
const read = (args) => String(exec("git", [...GIT_READ_FLAGS, "-C", folder, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }));
|
|
36
|
+
const [gitDir, commonDir, format, head] = read(["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir", "--show-object-format", "--verify", "HEAD^{commit}"]).split("\n");
|
|
37
|
+
if (!isAbsolute(gitDir ?? "") || !isAbsolute(commonDir ?? "") || !/^(sha1|sha256)$/.test(format ?? "") || !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(head ?? "")) return null;
|
|
38
|
+
const core = compareSettings(read);
|
|
39
|
+
scratch = fs.mkdtempSync(join(tmpdir(), "pi-dispatch-git-read-"));
|
|
40
|
+
fs.mkdirSync(join(scratch, "refs"));
|
|
41
|
+
fs.mkdirSync(join(scratch, "objects", "info"), { recursive: true });
|
|
42
|
+
fs.mkdirSync(join(scratch, "info"));
|
|
43
|
+
fs.writeFileSync(join(scratch, "HEAD"), `${head}\n`);
|
|
44
|
+
fs.writeFileSync(join(scratch, "objects", "info", "alternates"), `${join(commonDir, "objects")}\n`);
|
|
45
|
+
const sha256 = format === "sha256";
|
|
46
|
+
const lines = ["[core]", `\trepositoryformatversion = ${sha256 ? 1 : 0}`, "\tbare = false", ...core.map(([k, v]) => `\t${k} = ${v}`)];
|
|
47
|
+
if (sha256) lines.push("[extensions]", "\tobjectformat = sha256");
|
|
48
|
+
fs.writeFileSync(join(scratch, "config"), `${lines.join("\n")}\n`);
|
|
49
|
+
// `info/exclude` is NOT copied: it lives in the agent-writable `.git`, and `*` there would hide the operator's
|
|
50
|
+
// untracked work. A personal exclude list then reads as dirty, which is the safe side (`--force` is the way past).
|
|
51
|
+
copyRegular(fs, join(gitDir, "index"), join(scratch, "index"));
|
|
52
|
+
// A split index keeps its shared part beside it; without it the copy cannot be read and the answer is null.
|
|
53
|
+
for (const name of safeList(fs, gitDir)) if (/^sharedindex\.[0-9a-f]{40,64}$/.test(name)) copyRegular(fs, join(gitDir, name), join(scratch, name));
|
|
54
|
+
const env = { ...withoutGitEnv(process.env), GIT_DIR: scratch, GIT_WORK_TREE: folder, GIT_INDEX_FILE: join(scratch, "index"), GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" };
|
|
55
|
+
const scratchGit = (args, input) => exec("git", [...GIT_READ_FLAGS, ...args], { encoding: "utf8", env, cwd: scratch, ...(input === undefined ? { stdio: ["ignore", "pipe", "ignore"] } : { input, stdio: ["pipe", "pipe", "ignore"] }) });
|
|
56
|
+
// The index is agent-written too, and an assume-unchanged or skip-worktree bit hides every later change to that
|
|
57
|
+
// path from `status`. Both are cleared in the COPY (one flag per call: given together, git applies only one). A
|
|
58
|
+
// sparse checkout's skipped paths then read as deleted, so dirty: the safe side again.
|
|
59
|
+
const flagged = String(scratchGit(["ls-files", "-v", "-z"]))
|
|
60
|
+
.split("\0")
|
|
61
|
+
.filter((e) => e.length > 2 && e[1] === " " && (e[0] === "S" || (e[0] >= "a" && e[0] <= "z")))
|
|
62
|
+
.map((e) => e.slice(2));
|
|
63
|
+
if (flagged.length > 0) {
|
|
64
|
+
const list = `${flagged.join("\0")}\0`;
|
|
65
|
+
scratchGit(["update-index", "-z", "--no-skip-worktree", "--stdin"], list);
|
|
66
|
+
scratchGit(["update-index", "-z", "--no-assume-unchanged", "--stdin"], list);
|
|
67
|
+
}
|
|
68
|
+
const out = scratchGit(["-c", "core.untrackedCache=false", "-c", "status.submoduleSummary=false", "status", "--porcelain", "--ignore-submodules=all"]);
|
|
69
|
+
return String(out).trim().length > 0;
|
|
23
70
|
} catch {
|
|
24
71
|
return null;
|
|
72
|
+
} finally {
|
|
73
|
+
if (scratch) {
|
|
74
|
+
try {
|
|
75
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const defaultFs = { mkdtempSync, mkdirSync, writeFileSync, rmSync, openSync, fstatSync, closeSync, readFileSync, readdirSync };
|
|
82
|
+
|
|
83
|
+
/** The `core` settings that change how a working-tree file is compared, never one that names a command. */
|
|
84
|
+
const COMPARE_KEYS = ["autocrlf", "eol", "ignorecase", "precomposeunicode", "filemode", "symlinks", "checkstat", "trustctime"];
|
|
85
|
+
|
|
86
|
+
/** The folder's values for COMPARE_KEYS, each a plain word, read with `git config` (a read: no filter, no hook). */
|
|
87
|
+
function compareSettings(read) {
|
|
88
|
+
let out = "";
|
|
89
|
+
try {
|
|
90
|
+
out = read(["config", "--get-regexp", `^core\\.(${COMPARE_KEYS.join("|")})$`]);
|
|
91
|
+
} catch {
|
|
92
|
+
return []; // exit 1: none set
|
|
93
|
+
}
|
|
94
|
+
const seen = new Map();
|
|
95
|
+
for (const line of out.split("\n")) {
|
|
96
|
+
const m = /^core\.([a-z]+) ([A-Za-z0-9]{1,16})$/.exec(line.trim());
|
|
97
|
+
if (m && COMPARE_KEYS.includes(m[1])) seen.set(m[1], m[2]);
|
|
98
|
+
}
|
|
99
|
+
return [...seen];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Copies `from` when it is a regular file of at most 512 MiB; anything else is skipped. Opened O_NOFOLLOW and judged by
|
|
104
|
+
* the descriptor, so a link swapped in after a check can never be read through.
|
|
105
|
+
*/
|
|
106
|
+
function copyRegular(fs, from, to) {
|
|
107
|
+
let fd = null;
|
|
108
|
+
try {
|
|
109
|
+
fd = fs.openSync(from, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
110
|
+
const st = fs.fstatSync(fd);
|
|
111
|
+
if (!st.isFile() || st.size > 512 * 1024 * 1024) return;
|
|
112
|
+
fs.writeFileSync(to, fs.readFileSync(fd));
|
|
113
|
+
} catch {
|
|
114
|
+
// absent, a link, or unreadable: the copy is skipped and git answers from what it has
|
|
115
|
+
} finally {
|
|
116
|
+
if (fd !== null) fs.closeSync(fd);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function safeList(fs, dir) {
|
|
121
|
+
try {
|
|
122
|
+
return fs.readdirSync(dir);
|
|
123
|
+
} catch {
|
|
124
|
+
return [];
|
|
25
125
|
}
|
|
26
126
|
}
|
|
27
127
|
|
|
128
|
+
/** The environment without any GIT_* variable, so the caller's own GIT_DIR or index cannot redirect the read. */
|
|
129
|
+
function withoutGitEnv(env) {
|
|
130
|
+
return Object.fromEntries(Object.entries(env).filter(([k]) => !k.startsWith("GIT_")));
|
|
131
|
+
}
|
|
132
|
+
|
|
28
133
|
/**
|
|
29
134
|
* Why `folder` cannot be a local job's folder, as one sentence with its fix, or null when it can (issue #524).
|
|
30
135
|
*
|
package/src/git-hardening.mjs
CHANGED
|
@@ -31,3 +31,88 @@ export const GIT_SAFE_CONFIG = Object.freeze(["-c", "core.hooksPath=/dev/null",
|
|
|
31
31
|
* convenience of this one.
|
|
32
32
|
*/
|
|
33
33
|
export const GIT_READ_FLAGS = Object.freeze([...GIT_SAFE_CONFIG, "--no-pager"]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The flags for every git spawned against the WORKSPACE CHECKPOINT STORE (issue #645). Unlike the two
|
|
37
|
+
* constants above, which guard a read of a repository the worker cloned, the store is written while a
|
|
38
|
+
* paid job is paused, from a tree the agent fully controls, so the prefix pins every knob that could make
|
|
39
|
+
* git run a program, sign, prompt, or do background work in the middle of that window:
|
|
40
|
+
*
|
|
41
|
+
* - `protocol.ext.allow=never` and an empty `credential.helper`: the store only ever talks `file://`, and
|
|
42
|
+
* nothing it does may reach a helper or an `ext::` command.
|
|
43
|
+
* - `commit.gpgSign`/`tag.gpgSign=false`: `gpg.program` is a command, and a signing default in some
|
|
44
|
+
* config would run it on the host.
|
|
45
|
+
* - `gc.auto=0`, `maintenance.auto=false`: an auto-gc forked mid-snapshot holds the store's packs while
|
|
46
|
+
* the checkpointer repacks on its own schedule; two writers on one object store is the race to avoid.
|
|
47
|
+
* - `core.protectNTFS`/`core.protectHFS=true`: git's own dotgit folding, on whatever the host is, so the
|
|
48
|
+
* store refuses the same names `checkpoint-paths.mjs` does.
|
|
49
|
+
* - `transfer.fsckObjects`/`fetch.fsckObjects=true`: the base fetch is the one place objects arrive from
|
|
50
|
+
* outside; a malformed tree there (a `..` entry, a `.GIT`) is refused before it is ever stored.
|
|
51
|
+
* - `core.untrackedCache=false`: the untracked cache is written into the index of whatever work tree git
|
|
52
|
+
* was pointed at; the one command given a work tree (`check-ignore`) must write nothing there.
|
|
53
|
+
*/
|
|
54
|
+
export const STORE_GIT_FLAGS = Object.freeze([
|
|
55
|
+
...GIT_SAFE_CONFIG,
|
|
56
|
+
"-c",
|
|
57
|
+
"protocol.ext.allow=never",
|
|
58
|
+
"-c",
|
|
59
|
+
"credential.helper=",
|
|
60
|
+
"-c",
|
|
61
|
+
"commit.gpgSign=false",
|
|
62
|
+
"-c",
|
|
63
|
+
"tag.gpgSign=false",
|
|
64
|
+
"-c",
|
|
65
|
+
"gc.auto=0",
|
|
66
|
+
"-c",
|
|
67
|
+
"maintenance.auto=false",
|
|
68
|
+
"-c",
|
|
69
|
+
"core.protectNTFS=true",
|
|
70
|
+
"-c",
|
|
71
|
+
"core.protectHFS=true",
|
|
72
|
+
"-c",
|
|
73
|
+
"transfer.fsckObjects=true",
|
|
74
|
+
"-c",
|
|
75
|
+
"fetch.fsckObjects=true",
|
|
76
|
+
"-c",
|
|
77
|
+
"core.untrackedCache=false",
|
|
78
|
+
"--no-pager",
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The environment for a store git, built FROM NOTHING rather than from `process.env`. A spread would carry
|
|
83
|
+
* whatever the worker's own environment holds into git: `GIT_DIR`/`GIT_WORK_TREE` from a developer shell,
|
|
84
|
+
* `GIT_SSH_COMMAND`, `GIT_EXTERNAL_DIFF`, `GIT_CONFIG_PARAMETERS` (every one of them a way to run a
|
|
85
|
+
* program or read another repository), plus every secret the worker holds. A deny list would have to name
|
|
86
|
+
* all of those and every one git adds later; an allow list of exactly what git needs cannot drift that way.
|
|
87
|
+
*
|
|
88
|
+
* `HOME` and `XDG_CONFIG_HOME` both point at an empty per-run directory, so neither `~/.gitconfig` nor
|
|
89
|
+
* `$XDG_CONFIG_HOME/git/{config,attributes,ignore}` can exist; `GIT_CONFIG_GLOBAL=/dev/null` says the same
|
|
90
|
+
* for the global file, and the two NOSYSTEM switches for the system ones. `GIT_OPTIONAL_LOCKS=0` keeps a
|
|
91
|
+
* status-like read from writing an index; `GIT_NO_REPLACE_OBJECTS` stops a `refs/replace` ref swapping the
|
|
92
|
+
* object a commit names; `GIT_PROTOCOL_FROM_USER=0` turns every "user"-policy protocol off by default (the
|
|
93
|
+
* two `file://` fetches re-enable `file` explicitly, at their call sites). `LC_ALL=C` keeps any message we
|
|
94
|
+
* match on in one language.
|
|
95
|
+
*/
|
|
96
|
+
// env-internal PATH: the worker's own search path, passed through so the store git finds its helpers (`git-upload-pack`).
|
|
97
|
+
export function storeGitEnv({ gitDir, home, path = process.env.PATH }) {
|
|
98
|
+
// Empty and relative PATH entries are dropped: both resolve against the CWD, so a `git-upload-pack`
|
|
99
|
+
// (or any helper git execs) found there would be whatever the directory git runs in happens to hold.
|
|
100
|
+
const safePath = String(path ?? "")
|
|
101
|
+
.split(":")
|
|
102
|
+
.filter((entry) => entry.startsWith("/"))
|
|
103
|
+
.join(":");
|
|
104
|
+
return {
|
|
105
|
+
PATH: safePath || "/usr/bin:/bin",
|
|
106
|
+
HOME: home,
|
|
107
|
+
XDG_CONFIG_HOME: home,
|
|
108
|
+
GIT_DIR: gitDir,
|
|
109
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
110
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
111
|
+
GIT_ATTR_NOSYSTEM: "1",
|
|
112
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
113
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
114
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
115
|
+
GIT_PROTOCOL_FROM_USER: "0",
|
|
116
|
+
LC_ALL: "C",
|
|
117
|
+
};
|
|
118
|
+
}
|
package/src/github-host.mjs
CHANGED
|
@@ -28,11 +28,12 @@ import { configError } from "./config.mjs";
|
|
|
28
28
|
import { octokitLogOptions } from "./octokit-log.mjs";
|
|
29
29
|
import { PARKED_LABEL } from "./parked.mjs";
|
|
30
30
|
import { InfraRetry } from "./processor.mjs";
|
|
31
|
+
import { commentIdOf, isStatusCommentId } from "./status-comment.mjs";
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Build the host surface. `octokitFor` is `(token) => new Octokit({ auth: token, ...octokitLogOptions(log) })`; inject a fake
|
|
34
35
|
* to test offline. `log` is the caller's `(event, fields)` logger for the client's own warnings
|
|
35
|
-
* (`octokitLog`, issue #530). Returns `{ resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment }`.
|
|
36
|
+
* (`octokitLog`, issue #530). Returns `{ resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, editStatusComment, ... }`.
|
|
36
37
|
*/
|
|
37
38
|
export function makeGitHubHost({ log, octokitFor = (token) => new Octokit({ auth: token, ...octokitLogOptions(log) }) } = {}) {
|
|
38
39
|
/**
|
|
@@ -102,12 +103,36 @@ export function makeGitHubHost({ log, octokitFor = (token) => new Octokit({ auth
|
|
|
102
103
|
async function postStatusComment(repo, target, text, token) {
|
|
103
104
|
const [owner, name] = splitRepo(repo);
|
|
104
105
|
const octokit = octokitFor(token);
|
|
105
|
-
await octokit.request("POST /repos/{owner}/{repo}/issues/{issue_number}/comments", {
|
|
106
|
+
const { data } = await octokit.request("POST /repos/{owner}/{repo}/issues/{issue_number}/comments", {
|
|
106
107
|
owner,
|
|
107
108
|
repo: name,
|
|
108
109
|
issue_number: target?.number,
|
|
109
110
|
body: text,
|
|
110
111
|
});
|
|
112
|
+
// The created comment's id, for the status commenter (issue #644). A 2xx with no usable id is still a posted
|
|
113
|
+
// comment, so it answers `{ id: null }` rather than throwing a retry that would post a second one.
|
|
114
|
+
return { id: commentIdOf(data?.id) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Replace the body of the status comment `id` this host posted (issue #644, status-comment.mjs). The id is the
|
|
119
|
+
* plain issue-comment id: one `PATCH /repos/{o}/{r}/issues/comments/{id}` serves issues and pull requests alike,
|
|
120
|
+
* so `target` is unread here for the reason `postStatusComment` gives. The id is checked against
|
|
121
|
+
* `STATUS_COMMENT_ID_RE` before it reaches a route. An HTTP failure throws octokit's own error, which carries
|
|
122
|
+
* `status`, so the caller can tell a 403/404/429 (stop editing) from a blip.
|
|
123
|
+
*/
|
|
124
|
+
async function editStatusComment(repo, target, id, text, token) {
|
|
125
|
+
const [owner, name] = splitRepo(repo);
|
|
126
|
+
if (!isStatusCommentId(id) || !/^[0-9]+$/.test(id)) {
|
|
127
|
+
throw configError(`github-host: malformed status comment id: ${JSON.stringify(id)}`);
|
|
128
|
+
}
|
|
129
|
+
const octokit = octokitFor(token);
|
|
130
|
+
await octokit.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", {
|
|
131
|
+
owner,
|
|
132
|
+
repo: name,
|
|
133
|
+
comment_id: id,
|
|
134
|
+
body: text,
|
|
135
|
+
});
|
|
111
136
|
}
|
|
112
137
|
|
|
113
138
|
/**
|
|
@@ -140,21 +165,21 @@ export function makeGitHubHost({ log, octokitFor = (token) => new Octokit({ auth
|
|
|
140
165
|
* when it is added, so there is nothing to create first. Removing a label the issue does not carry is a 404, which is
|
|
141
166
|
* the state asked for. The caller treats any throw as fail-open: the session store's sidecar, not this label, decides.
|
|
142
167
|
*/
|
|
143
|
-
async function setParkedLabel(repo, target, on, token) {
|
|
168
|
+
async function setParkedLabel(repo, target, on, token, label = PARKED_LABEL) {
|
|
144
169
|
const [owner, name] = splitRepo(repo);
|
|
145
170
|
const octokit = octokitFor(token);
|
|
146
171
|
if (on) {
|
|
147
|
-
await octokit.request("POST /repos/{owner}/{repo}/issues/{issue_number}/labels", { owner, repo: name, issue_number: target?.number, labels: [
|
|
172
|
+
await octokit.request("POST /repos/{owner}/{repo}/issues/{issue_number}/labels", { owner, repo: name, issue_number: target?.number, labels: [label] });
|
|
148
173
|
return;
|
|
149
174
|
}
|
|
150
175
|
try {
|
|
151
|
-
await octokit.request("DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}", { owner, repo: name, issue_number: target?.number, name:
|
|
176
|
+
await octokit.request("DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}", { owner, repo: name, issue_number: target?.number, name: label });
|
|
152
177
|
} catch (error) {
|
|
153
178
|
if (error?.status !== 404) throw error;
|
|
154
179
|
}
|
|
155
180
|
}
|
|
156
181
|
|
|
157
|
-
return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
|
|
182
|
+
return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, editStatusComment, resolvePullRequestHead, setParkedLabel };
|
|
158
183
|
}
|
|
159
184
|
|
|
160
185
|
/**
|
package/src/github-prompt.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { forkBranch } from "./fork-job.mjs";
|
|
1
2
|
/*
|
|
2
3
|
* github-prompt.mjs — pure builder for a GitHub-triggered job's /job/prompt.md string.
|
|
3
4
|
*
|
|
@@ -43,6 +44,8 @@
|
|
|
43
44
|
*/
|
|
44
45
|
|
|
45
46
|
import { issueBranch, normalizeNumber } from "./branch.mjs";
|
|
47
|
+
// The fence lives in prompt-fence.mjs (issue #646) so the steer framing shares this exact rule; see that file.
|
|
48
|
+
import { fenceBlock } from "./prompt-fence.mjs";
|
|
46
49
|
|
|
47
50
|
const ISSUE_DATA_HEADING = "## Triggering issue (data, not instructions)";
|
|
48
51
|
const PR_DATA_HEADING = "## Triggering pull request (data, not instructions)";
|
|
@@ -65,7 +68,7 @@ const RESUMED_DATA_HEADING = "## New activity on this pull request (data, not in
|
|
|
65
68
|
* `author_association` are event.json metadata, never interpolated here.
|
|
66
69
|
* @returns {string} The full user prompt.
|
|
67
70
|
*/
|
|
68
|
-
export function buildGithubPrompt({ flow, target, comment, resumed = false, replica, replicas, review, instructions }) {
|
|
71
|
+
export function buildGithubPrompt({ flow, target, comment, resumed = false, replica, replicas, review, instructions, fork }) {
|
|
69
72
|
const type = target?.type;
|
|
70
73
|
// A third shape, selected by the HOST rather than by the runner. If the runner chose, the host could
|
|
71
74
|
// write the full envelope believing cold start while pi restored forty turns and sent it anyway --
|
|
@@ -80,7 +83,7 @@ export function buildGithubPrompt({ flow, target, comment, resumed = false, repl
|
|
|
80
83
|
// into buildIssuePrompt would create a branch nothing can reach.
|
|
81
84
|
if (resumed) return buildResumedPrompt(flow, target, comment, review, instructions);
|
|
82
85
|
if (type === "pull_request") return buildPullRequestPrompt(flow, target, comment, replica, replicas, review, instructions);
|
|
83
|
-
return buildIssuePrompt(flow, target, comment, replica, replicas, instructions);
|
|
86
|
+
return buildIssuePrompt(flow, target, comment, replica, replicas, instructions, fork);
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
/**
|
|
@@ -216,13 +219,13 @@ function buildResumedPrompt(flow, target, comment, review, instructions) {
|
|
|
216
219
|
return `${envelope}\n\n${dataRegion(RESUMED_DATA_HEADING, noun, target, comment, review)}\n`;
|
|
217
220
|
}
|
|
218
221
|
|
|
219
|
-
function buildIssuePrompt(flow, target, comment, replica, replicas, instructions) {
|
|
222
|
+
function buildIssuePrompt(flow, target, comment, replica, replicas, instructions, fork) {
|
|
220
223
|
// The branch name derives solely from the issue number — a stable, host-assigned integer — plus, for a
|
|
221
224
|
// replica, its host-assigned index. It is never taken from the mutable title/body, so a re-run of the
|
|
222
225
|
// same issue always converges on the same branch. Minted by branch.mjs rather than inline: the session
|
|
223
226
|
// store keys on this same string, and a second copy here would drift into a key for a branch the agent
|
|
224
227
|
// was never told to push to (branch.mjs).
|
|
225
|
-
const branch = issueBranch(target?.number, replica);
|
|
228
|
+
const branch = fork ? forkBranch({ target, fork }) : issueBranch(target?.number, replica);
|
|
226
229
|
// The replica marker the PR title carries. AGENT-HONORED, not host-enforced: the branch name above is
|
|
227
230
|
// the only replica identity the harness actually mints, and this is a request in prompt text. It is
|
|
228
231
|
// still worth asking for — the pair is meant to be read side by side in a PR list.
|
|
@@ -381,18 +384,6 @@ export function instructionBlock(instructions) {
|
|
|
381
384
|
].join("\n");
|
|
382
385
|
}
|
|
383
386
|
|
|
384
|
-
/**
|
|
385
|
-
* Wrap untrusted content in a code fence long enough that the content cannot close it early. A `##`
|
|
386
|
-
* heading or a shorter backtick run inside the payload then renders literally, inside the fence,
|
|
387
|
-
* rather than escaping the data region.
|
|
388
|
-
*/
|
|
389
|
-
function fenceBlock(content) {
|
|
390
|
-
const runs = String(content).match(/`+/g) ?? [];
|
|
391
|
-
const longest = runs.reduce((max, run) => Math.max(max, run.length), 0);
|
|
392
|
-
const fence = "`".repeat(Math.max(3, longest + 1));
|
|
393
|
-
return `${fence}text\n${content}\n${fence}`;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
387
|
/**
|
|
397
388
|
* Re-exported from branch.mjs, which owns it now that the session key needs the same validation without
|
|
398
389
|
* importing the prompt. Kept exported here because this module's own callers and tests reach for it at
|
package/src/gitlab-host.mjs
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { configError } from "./config.mjs";
|
|
27
27
|
import { PARKED_LABEL } from "./parked.mjs";
|
|
28
28
|
import { InfraRetry } from "./processor.mjs";
|
|
29
|
+
import { commentIdOf, isStatusCommentId } from "./status-comment.mjs";
|
|
29
30
|
import { fetchFailureReason } from "./gitlab-identity.mjs";
|
|
30
31
|
import { isDeterminateFetchFailure } from "./transient.mjs";
|
|
31
32
|
|
|
@@ -147,6 +148,44 @@ export function makeGitLabHost({ apiUrl = "https://gitlab.com", fetchFn = fetch
|
|
|
147
148
|
if (!res.ok) {
|
|
148
149
|
throw new InfraRetry(`gitlab-host: POST ${path} returned ${res.status}`);
|
|
149
150
|
}
|
|
151
|
+
// The created note's id, for the status commenter (issue #644). Read after the 2xx and never thrown on: the
|
|
152
|
+
// note exists, and a retry would post a second one.
|
|
153
|
+
const note = await res.json().catch(() => null);
|
|
154
|
+
return { id: commentIdOf(note?.id) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Replace the body of the status note `id` this host posted (issue #644, status-comment.mjs):
|
|
159
|
+
* `PUT /projects/{id}/issues/{iid}/notes/{note}` or `.../merge_requests/{iid}/notes/{note}`. The id is the plain
|
|
160
|
+
* note id; the collection comes from `target.type` exactly as `postStatusComment` chose it, so the same target
|
|
161
|
+
* names the same route both times. The id is checked against `STATUS_COMMENT_ID_RE` (and must be numeric, as a
|
|
162
|
+
* note id is) before it reaches a URL. An HTTP failure throws InfraRetry with `status` set, so the caller can tell
|
|
163
|
+
* a 403/404/429 (stop editing) from a blip.
|
|
164
|
+
*/
|
|
165
|
+
async function editStatusComment(projectRef, target, noteId, text, token) {
|
|
166
|
+
const id = projectId(projectRef);
|
|
167
|
+
if (!isStatusCommentId(noteId) || !/^[0-9]+$/.test(noteId)) {
|
|
168
|
+
throw configError(`gitlab-host: malformed status note id: ${JSON.stringify(noteId)}`);
|
|
169
|
+
}
|
|
170
|
+
const collection = target?.type === "pull_request" ? "merge_requests" : "issues";
|
|
171
|
+
const path = `/projects/${id}/${collection}/${encodeURIComponent(target?.number)}/notes/${noteId}`;
|
|
172
|
+
let res;
|
|
173
|
+
try {
|
|
174
|
+
res = await fetchFn(`${root}${path}`, {
|
|
175
|
+
method: "PUT",
|
|
176
|
+
headers: { "PRIVATE-TOKEN": token, "content-type": "application/json" },
|
|
177
|
+
body: JSON.stringify({ body: text }),
|
|
178
|
+
redirect: "error",
|
|
179
|
+
});
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (isDeterminateFetchFailure(err)) {
|
|
182
|
+
throw configError(`gitlab-host: PUT ${path} failed (${fetchFailureReason(err)})`);
|
|
183
|
+
}
|
|
184
|
+
throw new InfraRetry(`gitlab-host: PUT ${path} failed (${fetchFailureReason(err)})`);
|
|
185
|
+
}
|
|
186
|
+
if (!res.ok) {
|
|
187
|
+
throw Object.assign(new InfraRetry(`gitlab-host: PUT ${path} returned ${res.status}`), { status: res.status });
|
|
188
|
+
}
|
|
150
189
|
}
|
|
151
190
|
|
|
152
191
|
/**
|
|
@@ -170,7 +209,7 @@ export function makeGitLabHost({ apiUrl = "https://gitlab.com", fetchFn = fetch
|
|
|
170
209
|
* project label the project lacks, and `remove_labels` of one the issue does not carry changes nothing. The caller treats any throw as fail-open:
|
|
171
210
|
* the session store's sidecar, not this label, decides whether a reply resumes anything.
|
|
172
211
|
*/
|
|
173
|
-
async function setParkedLabel(projectRef, target, on, token) {
|
|
212
|
+
async function setParkedLabel(projectRef, target, on, token, label = PARKED_LABEL) {
|
|
174
213
|
const id = projectId(projectRef);
|
|
175
214
|
const path = `/projects/${id}/issues/${encodeURIComponent(target?.number)}`;
|
|
176
215
|
let res;
|
|
@@ -178,7 +217,7 @@ export function makeGitLabHost({ apiUrl = "https://gitlab.com", fetchFn = fetch
|
|
|
178
217
|
res = await fetchFn(`${root}${path}`, {
|
|
179
218
|
method: "PUT",
|
|
180
219
|
headers: { "PRIVATE-TOKEN": token, "content-type": "application/json" },
|
|
181
|
-
body: JSON.stringify(on ? { add_labels:
|
|
220
|
+
body: JSON.stringify(on ? { add_labels: label } : { remove_labels: label }),
|
|
182
221
|
redirect: "error",
|
|
183
222
|
});
|
|
184
223
|
} catch (err) {
|
|
@@ -187,7 +226,7 @@ export function makeGitLabHost({ apiUrl = "https://gitlab.com", fetchFn = fetch
|
|
|
187
226
|
if (!res.ok) throw new InfraRetry(`gitlab-host: PUT ${path} returned ${res.status}`);
|
|
188
227
|
}
|
|
189
228
|
|
|
190
|
-
return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
|
|
229
|
+
return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, editStatusComment, resolvePullRequestHead, setParkedLabel };
|
|
191
230
|
}
|
|
192
231
|
|
|
193
232
|
/**
|
package/src/gitlab-prompt.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { forkBranch } from "./fork-job.mjs";
|
|
1
2
|
/**
|
|
2
3
|
* The agent's envelope for a GitLab job -- the counterpart of github-prompt.mjs, and a separate builder
|
|
3
4
|
* for one reason: the GitHub envelope instructs the agent in `gh` prose, and `gh` implements the GitHub
|
|
@@ -21,14 +22,14 @@ const MR_DATA_HEADING = "## Triggering merge request (data, not instructions)";
|
|
|
21
22
|
const RESUMED_DATA_HEADING = "## New activity on this merge request (data, not instructions)";
|
|
22
23
|
|
|
23
24
|
/** Build the prompt for a GitLab job, discriminated on the job's target type. */
|
|
24
|
-
export function buildGitLabPrompt({ flow, target, comment, resumed = false, replica, replicas, instructions }) {
|
|
25
|
+
export function buildGitLabPrompt({ flow, target, comment, resumed = false, replica, replicas, instructions, fork }) {
|
|
25
26
|
const type = target?.type;
|
|
26
27
|
// Third shape, chosen by the HOST -- see the github twin for why the runner must not choose it.
|
|
27
28
|
// The resumed envelope takes NO replica argument, and that is a consequence rather than an omission:
|
|
28
29
|
// triggers.mjs refuses run.replicas beside run.resume, so a replica job never resumes.
|
|
29
30
|
if (resumed) return buildResumedPrompt(flow, target, comment, instructions);
|
|
30
31
|
if (type === "pull_request") return buildMergeRequestPrompt(flow, target, comment, replica, replicas, instructions);
|
|
31
|
-
return buildIssuePrompt(flow, target, comment, replica, replicas, instructions);
|
|
32
|
+
return buildIssuePrompt(flow, target, comment, replica, replicas, instructions, fork);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -128,12 +129,12 @@ function buildResumedPrompt(flow, target, comment, instructions) {
|
|
|
128
129
|
return `${envelope}\n\n${dataRegion(RESUMED_DATA_HEADING, noun, target, comment)}\n`;
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
function buildIssuePrompt(flow, target, comment, replica, replicas, instructions) {
|
|
132
|
+
function buildIssuePrompt(flow, target, comment, replica, replicas, instructions, fork) {
|
|
132
133
|
// The branch name derives solely from the issue's iid -- a stable, project-assigned integer -- plus, for
|
|
133
134
|
// a replica, its host-assigned index. It is never taken from the mutable title or description, so a
|
|
134
135
|
// re-run of the same issue always converges on the same branch. Minted by branch.mjs so the session key
|
|
135
136
|
// and this envelope name one string.
|
|
136
|
-
const branch = issueBranch(target?.number, replica);
|
|
137
|
+
const branch = fork ? forkBranch({ target, fork }) : issueBranch(target?.number, replica);
|
|
137
138
|
// The MR title marker. AGENT-HONORED, not host-enforced: the branch above is the only replica identity
|
|
138
139
|
// the harness actually mints, and this is a request in prompt text. Still worth asking for -- the pair
|
|
139
140
|
// is meant to be read side by side in a merge request list.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document a file's text holds, as `{ value }`, or `{ problem }` saying why a writer that keeps a list under `listKey`
|
|
3
|
+
* could not keep what it holds. One rule for the triggers file and the pause windows file, each of which lost every row to a
|
|
4
|
+
* stray comma (`INT-TRIGGERS-FILE-CONTRACT`, `INT-PAUSE-WINDOWS-FILE-CONTRACT`).
|
|
5
|
+
*
|
|
6
|
+
* The words are its own, never the parser's message, which quotes the file. A top-level value that is not an object, or a
|
|
7
|
+
* list key that is not a list, parses and still gives a write nothing to start from, so it would drop what the file holds
|
|
8
|
+
* exactly as it would for a stray comma. A file with no such key is fine: it holds no row, and the writer keeps its other keys.
|
|
9
|
+
* A list key that is not a list comes with its `value` too, since a newer release may mean something by that shape, and a
|
|
10
|
+
* writer says that first: "upgrade" is the answer then, not "fix the file".
|
|
11
|
+
*/
|
|
12
|
+
export function heldDocument(raw, listKey) {
|
|
13
|
+
let value;
|
|
14
|
+
try {
|
|
15
|
+
value = JSON.parse(raw);
|
|
16
|
+
} catch {
|
|
17
|
+
// An editor shows nothing at the start of such a file, so the plain words would not lead to the fix.
|
|
18
|
+
return { problem: raw.charCodeAt(0) === 0xfeff ? "is not valid JSON (it starts with a byte-order mark)" : "is not valid JSON" };
|
|
19
|
+
}
|
|
20
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return { problem: "is not a JSON object" };
|
|
21
|
+
if (Object.hasOwn(value, listKey) && !Array.isArray(value[listKey])) return { value, problem: `has a "${listKey}" that is not a list` };
|
|
22
|
+
return { value };
|
|
23
|
+
}
|