@edgehero/pi-dispatch 4.3.1 → 5.0.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 +20 -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 +117 -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 +128 -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 +159 -0
- package/src/console-client.mjs +848 -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 +1639 -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/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 +1 -1
- 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 +4 -1
- 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 +33 -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 +398 -51
- 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/forgejo-host.mjs
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import { configError } from "./config.mjs";
|
|
32
32
|
import { PARKED_LABEL } from "./parked.mjs";
|
|
33
33
|
import { InfraRetry } from "./processor.mjs";
|
|
34
|
+
import { commentIdOf, isStatusCommentId } from "./status-comment.mjs";
|
|
34
35
|
import { fetchFailureReason } from "./gitlab-identity.mjs";
|
|
35
36
|
import { isDeterminateFetchFailure } from "./transient.mjs";
|
|
36
37
|
|
|
@@ -155,6 +156,42 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
|
|
|
155
156
|
if (!res.ok) {
|
|
156
157
|
throw new InfraRetry(`forgejo-host: POST ${path} returned ${res.status}`);
|
|
157
158
|
}
|
|
159
|
+
// The created comment's id, for the status commenter (issue #644). Read after the 2xx and never thrown on: the
|
|
160
|
+
// comment exists, and a retry would post a second one.
|
|
161
|
+
const comment = await res.json().catch(() => null);
|
|
162
|
+
return { id: commentIdOf(comment?.id) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Replace the body of the status comment `id` this host posted (issue #644, status-comment.mjs):
|
|
167
|
+
* `PATCH /repos/{o}/{r}/issues/comments/{id}`. The id is the plain comment id, and `target` is unread for the
|
|
168
|
+
* reason `postStatusComment` gives: a pull request's comments are issue comments. The id is checked against
|
|
169
|
+
* `STATUS_COMMENT_ID_RE` (and must be numeric, as a comment id is) before it reaches a URL. An HTTP failure throws
|
|
170
|
+
* InfraRetry with `status` set, so the caller can tell a 403/404/429 (stop editing) from a blip.
|
|
171
|
+
*/
|
|
172
|
+
async function editStatusComment(ref, target, id, text, token) {
|
|
173
|
+
const [owner, name] = splitRepo(ref);
|
|
174
|
+
if (!isStatusCommentId(id) || !/^[0-9]+$/.test(id)) {
|
|
175
|
+
throw configError(`forgejo-host: malformed status comment id: ${JSON.stringify(id)}`);
|
|
176
|
+
}
|
|
177
|
+
const path = `/repos/${owner}/${name}/issues/comments/${id}`;
|
|
178
|
+
let res;
|
|
179
|
+
try {
|
|
180
|
+
res = await fetchFn(`${root}${path}`, {
|
|
181
|
+
method: "PATCH",
|
|
182
|
+
headers: { Authorization: `token ${token}`, "content-type": "application/json" },
|
|
183
|
+
body: JSON.stringify({ body: text }),
|
|
184
|
+
redirect: "error",
|
|
185
|
+
});
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (isDeterminateFetchFailure(err)) {
|
|
188
|
+
throw configError(`forgejo-host: PATCH ${path} failed (${fetchFailureReason(err)})`);
|
|
189
|
+
}
|
|
190
|
+
throw new InfraRetry(`forgejo-host: PATCH ${path} failed (${fetchFailureReason(err)})`);
|
|
191
|
+
}
|
|
192
|
+
if (!res.ok) {
|
|
193
|
+
throw Object.assign(new InfraRetry(`forgejo-host: PATCH ${path} returned ${res.status}`), { status: res.status });
|
|
194
|
+
}
|
|
158
195
|
}
|
|
159
196
|
|
|
160
197
|
/**
|
|
@@ -188,7 +225,7 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
|
|
|
188
225
|
* this fix is cleared too. The duplicate repository labels themselves are left alone: deleting a repository's labels
|
|
189
226
|
* is the operator's call, and a spare one carried by no issue routes nothing (docs/forgejo.md says how to tidy them).
|
|
190
227
|
*/
|
|
191
|
-
async function setParkedLabel(ref, target, on, token) {
|
|
228
|
+
async function setParkedLabel(ref, target, on, token, label = PARKED_LABEL) {
|
|
192
229
|
const [owner, name] = splitRepo(ref);
|
|
193
230
|
const send = async (method, path, body, accept = []) => {
|
|
194
231
|
let res;
|
|
@@ -206,17 +243,17 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
|
|
|
206
243
|
return res;
|
|
207
244
|
};
|
|
208
245
|
const issue = `/repos/${owner}/${name}/issues/${encodeURIComponent(target?.number)}/labels`;
|
|
209
|
-
const markers = (labels) => (Array.isArray(labels) ? labels : []).filter((l) => l?.name ===
|
|
246
|
+
const markers = (labels) => (Array.isArray(labels) ? labels : []).filter((l) => l?.name === label && Number.isSafeInteger(l?.id)).map((l) => l.id);
|
|
210
247
|
if (on) {
|
|
211
248
|
let ids = await repoLabelIds(owner, name, token, markers);
|
|
212
249
|
if (ids.length === 0) {
|
|
213
|
-
const res = await send("POST", `/repos/${owner}/${name}/labels`, { name:
|
|
250
|
+
const res = await send("POST", `/repos/${owner}/${name}/labels`, { name: label, color: "#fbca04", description: "pi-dispatch: the last run is waiting for an answer" }, [409, 422]);
|
|
214
251
|
// A forge that DOES refuse the duplicate (409/422, a Gitea or a later Forgejo) raced another park to it: list
|
|
215
252
|
// again. A 201 hands back the label, so no second read is needed.
|
|
216
253
|
const created = res.ok ? await res.json().catch(() => null) : null;
|
|
217
254
|
ids = Number.isSafeInteger(created?.id) ? [created.id] : await repoLabelIds(owner, name, token, markers);
|
|
218
255
|
}
|
|
219
|
-
if (ids.length === 0) throw new InfraRetry(`forgejo-host: ${owner}/${name} has no ${
|
|
256
|
+
if (ids.length === 0) throw new InfraRetry(`forgejo-host: ${owner}/${name} has no ${label} label after creating it`);
|
|
220
257
|
await send("POST", issue, { labels: [Math.min(...ids)] });
|
|
221
258
|
return;
|
|
222
259
|
}
|
|
@@ -244,7 +281,7 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
|
|
|
244
281
|
throw new InfraRetry(`forgejo-host: ${owner}/${name} lists more than ${MAX_LABEL_PAGES} pages of labels`);
|
|
245
282
|
}
|
|
246
283
|
|
|
247
|
-
return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
|
|
284
|
+
return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, editStatusComment, resolvePullRequestHead, setParkedLabel };
|
|
248
285
|
}
|
|
249
286
|
|
|
250
287
|
/**
|
package/src/forgejo-prompt.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { forkBranch } from "./fork-job.mjs";
|
|
1
2
|
/**
|
|
2
3
|
* The agent's envelope for a Forgejo/Gitea job -- the third sibling of github-prompt.mjs and
|
|
3
4
|
* gitlab-prompt.mjs, and a separate builder for the same single reason: the GitHub envelope instructs the
|
|
@@ -23,13 +24,13 @@ const PR_DATA_HEADING = "## Triggering pull request (data, not instructions)";
|
|
|
23
24
|
const RESUMED_DATA_HEADING = "## New activity on this pull request (data, not instructions)";
|
|
24
25
|
|
|
25
26
|
/** Build the prompt for a Forgejo job, discriminated on the job's target type. */
|
|
26
|
-
export function buildForgejoPrompt({ flow, target, comment, resumed = false, replica, replicas, instructions }) {
|
|
27
|
+
export function buildForgejoPrompt({ flow, target, comment, resumed = false, replica, replicas, instructions, fork }) {
|
|
27
28
|
const type = target?.type;
|
|
28
29
|
// Third shape, chosen by the HOST -- see the github twin for why the runner must not choose it.
|
|
29
30
|
// No replica argument on the resumed shape: triggers.mjs refuses run.replicas beside run.resume.
|
|
30
31
|
if (resumed) return buildResumedPrompt(flow, target, comment, instructions);
|
|
31
32
|
if (type === "pull_request") return buildPullRequestPrompt(flow, target, comment, replica, replicas, instructions);
|
|
32
|
-
return buildIssuePrompt(flow, target, comment, replica, replicas, instructions);
|
|
33
|
+
return buildIssuePrompt(flow, target, comment, replica, replicas, instructions, fork);
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
/**
|
|
@@ -125,12 +126,12 @@ function buildResumedPrompt(flow, target, comment, instructions) {
|
|
|
125
126
|
return `${envelope}\n\n${dataRegion(RESUMED_DATA_HEADING, noun, target, comment)}\n`;
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
function buildIssuePrompt(flow, target, comment, replica, replicas, instructions) {
|
|
129
|
+
function buildIssuePrompt(flow, target, comment, replica, replicas, instructions, fork) {
|
|
129
130
|
// The branch name derives solely from the issue's index -- a stable, repository-assigned integer --
|
|
130
131
|
// plus, for a replica, its host-assigned index. It is never taken from the mutable title or body, so a
|
|
131
132
|
// re-run of the same issue always converges on the same branch. Minted by branch.mjs so the session key
|
|
132
133
|
// and this envelope name one string.
|
|
133
|
-
const branch = issueBranch(target?.number, replica);
|
|
134
|
+
const branch = fork ? forkBranch({ target, fork }) : issueBranch(target?.number, replica);
|
|
134
135
|
// AGENT-HONORED, like the github twin's: the branch is the only replica identity the harness mints.
|
|
135
136
|
const marker = replica === undefined ? "" : `[r${replica}/${replicas}] `;
|
|
136
137
|
|
package/src/forges.mjs
CHANGED
|
@@ -49,6 +49,11 @@
|
|
|
49
49
|
* selector, and the reason is independent of who is gating: a rule keyed on labels that names no labels
|
|
50
50
|
* is a rule that fires on ALL of them. GitLab's is null because a label added to a merge request arrives
|
|
51
51
|
* as a plain `update`, indistinguishable from any other edit -- there is no action to attach the rule to.
|
|
52
|
+
* - `apiRoot` is where the forge's REST API lives: `url` for a forge with one public API, or `field`, the field of
|
|
53
|
+
* the forge's parsed config (`config.<kind>`, what `loadGitLabAuth`, `loadForgejoAuth` and `loadAzureAuth` return)
|
|
54
|
+
* that holds the instance the operator configured. `forgeApiRoot` reads it. The worker hands it to an answer draft
|
|
55
|
+
* job as its `apiUrl`, and the console binds a completed draft to it, so the two read the same field, though each
|
|
56
|
+
* process reads its own env.
|
|
52
57
|
*/
|
|
53
58
|
export const FORGES = {
|
|
54
59
|
github: {
|
|
@@ -59,6 +64,7 @@ export const FORGES = {
|
|
|
59
64
|
hostVars: [],
|
|
60
65
|
receiverOnlyVars: [],
|
|
61
66
|
prLabelAction: "labeled",
|
|
67
|
+
apiRoot: { url: "https://api.github.com" },
|
|
62
68
|
},
|
|
63
69
|
gitlab: {
|
|
64
70
|
jobIdPrefix: "gl-",
|
|
@@ -72,12 +78,15 @@ export const FORGES = {
|
|
|
72
78
|
// is not an approval at all -- a Guest can set one at issue creation -- which is why every gitlab
|
|
73
79
|
// trigger is gated on the actor's resolved access level.)
|
|
74
80
|
prLabelAction: null,
|
|
81
|
+
apiRoot: { field: "apiUrl" },
|
|
75
82
|
},
|
|
76
83
|
forgejo: {
|
|
77
84
|
// Forgejo's webhook transport is byte-compatible with GitHub's -- it signs the raw body HMAC-SHA256
|
|
78
|
-
// and sends X-Hub-Signature-256, X-GitHub-Delivery and X-GitHub-Event. So the delivery id is a GUID
|
|
79
|
-
//
|
|
80
|
-
//
|
|
85
|
+
// and sends X-Hub-Signature-256, X-GitHub-Delivery and X-GitHub-Event. So the delivery id is a GUID,
|
|
86
|
+
// one per delivery, and REQ-DEDUP-BY-DELIVERY-GUID transfers unchanged. Forgejo never sends a delivery
|
|
87
|
+
// again on its own, and its replay, unlike a GitHub redelivery, is a NEW GUID, so only the semantic
|
|
88
|
+
// window coalesces one (receiver/src/receiver.mjs, WHAT A 503 MEANS). Only the PREFIX differs, and only
|
|
89
|
+
// so the id spaces stay disjoint.
|
|
81
90
|
jobIdPrefix: "fj-",
|
|
82
91
|
deliveryIdName: "X-GitHub-Delivery GUID",
|
|
83
92
|
// Forgejo numbers issues and pull requests from ONE per-repository sequence, exactly as GitHub does
|
|
@@ -105,6 +114,7 @@ export const FORGES = {
|
|
|
105
114
|
// the actor's resolved repository permission -- see filter-forgejo.mjs for why that is not
|
|
106
115
|
// redundant belt-and-braces but the only claim about Forgejo this project is willing to make.
|
|
107
116
|
prLabelAction: "label_updated",
|
|
117
|
+
apiRoot: { field: "apiUrl" },
|
|
108
118
|
},
|
|
109
119
|
azure: {
|
|
110
120
|
// Azure Service Hooks send NO delivery-id header at all, so this id comes from the body's top-level
|
|
@@ -125,6 +135,9 @@ export const FORGES = {
|
|
|
125
135
|
// Azure attaches no labels to a pull request at all, so there is no action to attach the rule to and
|
|
126
136
|
// a predicated PR rule could never match. The loader refuses one rather than letting it load dead.
|
|
127
137
|
prLabelAction: null,
|
|
138
|
+
// The organization URL is the API root: an answer draft reads work item comments under it, and the graph's
|
|
139
|
+
// origin is derived from it.
|
|
140
|
+
apiRoot: { field: "orgUrl" },
|
|
128
141
|
},
|
|
129
142
|
};
|
|
130
143
|
|
|
@@ -150,6 +163,23 @@ export function forgeSpec(kind) {
|
|
|
150
163
|
return isForgeKind(kind) ? FORGES[kind] : undefined;
|
|
151
164
|
}
|
|
152
165
|
|
|
166
|
+
/**
|
|
167
|
+
* The raw API root of `kind`'s forge, from `configs`, the parsed forge configs keyed by kind (the worker's `config`, or
|
|
168
|
+
* the console's `loadGitLabAuth`, `loadForgejoAuth` and `loadAzureAuth` results under the same keys), or null for a kind
|
|
169
|
+
* that is no forge, a forge that is not configured or a row without an `apiRoot`. Only `configs[kind]` is read, so a
|
|
170
|
+
* caller whose loaders can throw may hand in getters and run only the asked forge's.
|
|
171
|
+
*
|
|
172
|
+
* Raw, as configured, and not normalized here: the worker hands it to the job as an answer draft's `apiUrl`, which the
|
|
173
|
+
* runner fetches under and compares Forgejo's `issue_url` with, so a normalized copy would move what the runner
|
|
174
|
+
* compares. A caller that compares two roots normalizes both (`answerApiRoot`).
|
|
175
|
+
*/
|
|
176
|
+
export function forgeApiRoot(kind, configs) {
|
|
177
|
+
const root = forgeSpec(kind)?.apiRoot;
|
|
178
|
+
if (!root) return null;
|
|
179
|
+
const value = Object.hasOwn(root, "url") ? root.url : configs?.[kind]?.[root.field];
|
|
180
|
+
return typeof value === "string" ? value : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
153
183
|
/**
|
|
154
184
|
* Every environment variable name any forge's mint can write. This is the set `PI_FORWARD_ENV` must
|
|
155
185
|
* refuse: a forwarded host value under one of these names would shadow the per-job scoped token with a
|
package/src/fork-job.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { issueBranch } from "./branch.mjs";
|
|
2
|
+
|
|
3
|
+
export const FORK_THINKING_LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
4
|
+
const ID = /^[A-Za-z0-9._:-]{1,200}$/;
|
|
5
|
+
const ENTRY = /^[A-Za-z0-9_-]{1,128}$/;
|
|
6
|
+
const KEY = /^[0-9a-f]{32}$/;
|
|
7
|
+
|
|
8
|
+
/** A closed queue contract: no paths, credentials, or policy overrides can ride inside lineage. */
|
|
9
|
+
export function validateFork(value) {
|
|
10
|
+
const bad = () => { throw new TypeError("fork must name a bounded run, entry, digest and independent lineage"); };
|
|
11
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return bad();
|
|
12
|
+
const { id, fromJob, fromEntry, position, sourceDigest, rootKey, message } = value;
|
|
13
|
+
if (![id, fromJob, fromEntry, position, sourceDigest, rootKey].every((field) => typeof field === "string")) return bad();
|
|
14
|
+
if (Object.keys(value).some((k) => !["id", "fromJob", "fromEntry", "position", "sourceDigest", "rootKey", "message"].includes(k)) || !KEY.test(id ?? "") || !ID.test(fromJob ?? "") || !ENTRY.test(fromEntry ?? "") || !["before", "at"].includes(position) || !/^[0-9a-f]{64}$/.test(sourceDigest ?? "") || !KEY.test(rootKey ?? "") || (message !== undefined && (typeof message !== "string" || Buffer.byteLength(message) > 16_384 || message.includes("\0")))) return bad();
|
|
15
|
+
return { id, fromJob, fromEntry, position, sourceDigest, rootKey, ...(message !== undefined ? { message } : {}) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function forkBranch(job) {
|
|
19
|
+
const fork = validateFork(job.fork);
|
|
20
|
+
return `${issueBranch(job.target.number)}-fork-${fork.id}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Fixed harness instructions precede operator text, which remains fenced user data. */
|
|
24
|
+
export function managedForkPrompt(job, original) {
|
|
25
|
+
if (!job.fork) return original;
|
|
26
|
+
const fork = validateFork(job.fork);
|
|
27
|
+
const branch = forkBranch(job);
|
|
28
|
+
return [
|
|
29
|
+
"## Managed session fork",
|
|
30
|
+
`Continue the selected historical conversation on your own branch \`${branch}\`.`,
|
|
31
|
+
"Earlier messages describe a different attempt. Its branch and pull request belong to that attempt.",
|
|
32
|
+
"Do not push to, close, or update that attempt's branch or pull request. Open a separate pull request for this fork.",
|
|
33
|
+
"The workspace is restored to the selected point. Check its state before repeating external side effects.",
|
|
34
|
+
"", original,
|
|
35
|
+
...(fork.message ? ["", "## Operator follow-up (data)", JSON.stringify(fork.message)] : []),
|
|
36
|
+
].join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Run records retain validated identifiers and the selected model, never the operator's message. */
|
|
40
|
+
export function recordedFork(fork, model = null) {
|
|
41
|
+
try {
|
|
42
|
+
const value = validateFork(fork);
|
|
43
|
+
// pi generates eight hex digits; a crafted transcript id is never durable record text.
|
|
44
|
+
if (!/^[0-9a-f]{8}$/.test(value.fromEntry)) return null;
|
|
45
|
+
return { id: value.id, fromJob: value.fromJob, fromEntry: value.fromEntry, position: value.position, model };
|
|
46
|
+
} catch { return null; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
const ARCHIVE_REASONS = new Set(["capacity", "busy", "unsafe-extensions", "base-too-large", "store-full", "store-unreadable", "timeout", "invalid", "immutable", "not-completed", "transcript-unavailable", "checkpoint-unavailable", "unavailable"]);
|
|
51
|
+
/** Archive telemetry is a closed vocabulary; filesystem messages and transcript text never enter records. */
|
|
52
|
+
export function forkArchiveFact({ saved = false, reason = null, pointSkipped = null } = {}) {
|
|
53
|
+
const clean = (value) => {
|
|
54
|
+
const token = typeof value === "string" ? value.replace(/^fork-source-/, "").replace(/^fork-/, "") : "unavailable";
|
|
55
|
+
return ARCHIVE_REASONS.has(token) ? token : "unavailable";
|
|
56
|
+
};
|
|
57
|
+
return { saved: saved === true, reason: saved === true ? null : clean(reason), ...(pointSkipped !== null ? { pointSkipped: clean(pointSkipped) } : {}) };
|
|
58
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
3
|
+
import { parseTriggersDocument } from "./triggers.mjs";
|
|
4
|
+
|
|
5
|
+
/** Re-read rather than cache: a queued fork cannot retain authority withdrawn after confirmation. */
|
|
6
|
+
export function forkPolicyDigest({ triggersPath, job, fs = nodeFs } = {}) {
|
|
7
|
+
try {
|
|
8
|
+
const index = job?.trigger?.matched?.index;
|
|
9
|
+
if (!Number.isInteger(index) || index < 0 || !triggersPath) return { refused: "fork-trigger-unavailable" };
|
|
10
|
+
const { triggers } = parseTriggersDocument(fs.readFileSync(triggersPath, "utf8"), triggersPath);
|
|
11
|
+
const entry = triggers[index];
|
|
12
|
+
if (!entry || entry.run?.kind !== job.kind || entry.on?.type !== job.trigger.matched.type || entry.on?.disarmed || entry.on?.type === "disarmed" || entry.run?.command !== undefined || (entry.on.type !== "comment" && entry.run.flow !== job.flow)) return { refused: "fork-trigger-changed" };
|
|
13
|
+
return { ok: true, digest: createHash("sha256").update(JSON.stringify(entry)).digest("hex") };
|
|
14
|
+
} catch { return { refused: "fork-trigger-unavailable" }; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function makeCheckForkPolicy({ triggersPath, fs = nodeFs } = {}) {
|
|
18
|
+
return (job) => {
|
|
19
|
+
const read = forkPolicyDigest({ triggersPath, job, fs });
|
|
20
|
+
if (!read.ok) return read;
|
|
21
|
+
return typeof job.forkPolicy === "string" && job.forkPolicy === read.digest ? { ok: true } : { refused: "fork-trigger-changed" };
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Immutable, run-addressed fork sources (issue #648). The canonical session moves on every promotion,
|
|
3
|
+
* so looking up an old run through its session key would silently fork a newer conversation instead.
|
|
4
|
+
* These private artifacts have their own bounded retention; no index maps jobs to arbitrary paths.
|
|
5
|
+
* Workspace points are captured ONLY while the caller has quiesced the container. A transcript alone
|
|
6
|
+
* never establishes that the files at an earlier entry are available.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import { hostname } from "node:os";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import { DEFAULT_CHECKPOINT_LIMITS, makeWorkspaceCheckpointer, retryableStoreError } from "./workspace-checkpoint.mjs";
|
|
13
|
+
|
|
14
|
+
export const FORK_SOURCE_LIMITS = Object.freeze({ maxBytes: 8 * 1024 * 1024, maxStoreBytes: 256 * 1024 * 1024, maxSources: 1024, maxPoints: 64, ttlMs: 7 * 86400_000 });
|
|
15
|
+
const ID = /^[A-Za-z0-9._:-]{1,200}$/;
|
|
16
|
+
const ENTRY = /^[A-Za-z0-9_-]{1,128}$/;
|
|
17
|
+
const HEX = /^[0-9a-f]{64}$/;
|
|
18
|
+
const KEY = /^[0-9a-f]{32}$/;
|
|
19
|
+
const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
20
|
+
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:@/+\-]{0,127}$/;
|
|
21
|
+
const HOST = /^[A-Za-z0-9._-]{1,64}$/;
|
|
22
|
+
const POSITIONS = new Set(["before", "at"]);
|
|
23
|
+
const ENTRY_TYPES = new Set(["message", "thinking_level_change", "model_change", "usage", "compaction", "branch_summary", "custom", "label", "session_info", "custom_message", "context_edit"]);
|
|
24
|
+
const ROLES = new Set(["user", "assistant", "toolResult"]);
|
|
25
|
+
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
26
|
+
export const forkSourceKey = (runId) => digest(`fork-source\0${runId}`).slice(0, 32);
|
|
27
|
+
const pointKey = (entryId, position) => digest(`${entryId}\0${position}`);
|
|
28
|
+
const refused = (suffix) => ({ refused: `fork-source-${suffix}` });
|
|
29
|
+
|
|
30
|
+
function regular(path, max) {
|
|
31
|
+
const fd = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
32
|
+
try {
|
|
33
|
+
const st = fs.fstatSync(fd);
|
|
34
|
+
if (!st.isFile() || st.size > max) throw new Error("not-bounded-file");
|
|
35
|
+
const out = Buffer.alloc(st.size);
|
|
36
|
+
let offset = 0;
|
|
37
|
+
while (offset < out.length) {
|
|
38
|
+
const n = fs.readSync(fd, out, offset, out.length - offset, offset);
|
|
39
|
+
if (!n) throw new Error("short-file");
|
|
40
|
+
offset += n;
|
|
41
|
+
}
|
|
42
|
+
const after = fs.fstatSync(fd);
|
|
43
|
+
if (st.size !== after.size || st.mtimeMs !== after.mtimeMs) throw new Error("changed-file");
|
|
44
|
+
return out;
|
|
45
|
+
} finally { fs.closeSync(fd); }
|
|
46
|
+
}
|
|
47
|
+
function directory(path, create = false) {
|
|
48
|
+
if (create) {
|
|
49
|
+
try { if (!fs.lstatSync(path).isDirectory()) throw new Error("not-directory"); }
|
|
50
|
+
catch (e) { if (e?.code !== "ENOENT") throw e; }
|
|
51
|
+
fs.mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
52
|
+
}
|
|
53
|
+
if (!fs.lstatSync(path).isDirectory()) throw new Error("not-directory");
|
|
54
|
+
}
|
|
55
|
+
function durable(path, bytes) {
|
|
56
|
+
const fd = fs.openSync(path, "wx", 0o600);
|
|
57
|
+
try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
58
|
+
}
|
|
59
|
+
function syncDir(path) {
|
|
60
|
+
const fd = fs.openSync(path, "r");
|
|
61
|
+
try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
62
|
+
}
|
|
63
|
+
function parseEntries(bytes) {
|
|
64
|
+
const lines = bytes.toString("utf8").split("\n").filter(Boolean);
|
|
65
|
+
const header = JSON.parse(lines.shift() ?? "null");
|
|
66
|
+
if (header?.type !== "session") throw new Error("header");
|
|
67
|
+
const entries = [];
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const e = JSON.parse(line);
|
|
71
|
+
if (!ENTRY.test(e.id ?? "") || seen.has(e.id) || (e.parentId !== null && !seen.has(e.parentId))) throw new Error("tree");
|
|
72
|
+
seen.add(e.id);
|
|
73
|
+
entries.push({ id: e.id, parentId: e.parentId, type: ENTRY_TYPES.has(e.type) ? e.type : "unknown", ...(e.type === "message" ? { role: ROLES.has(e.message?.role) ? e.message.role : "unknown" } : {}) });
|
|
74
|
+
}
|
|
75
|
+
return entries;
|
|
76
|
+
}
|
|
77
|
+
/** Last complete tree path, with every tool call answered, is a usable workspace boundary. */
|
|
78
|
+
export function forkPointEntry(bytes) {
|
|
79
|
+
try {
|
|
80
|
+
const raw = bytes.toString("utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
81
|
+
const entries = raw.slice(1);
|
|
82
|
+
parseEntries(bytes);
|
|
83
|
+
const byId = new Map(entries.map((e) => [e.id, e]));
|
|
84
|
+
const path = [];
|
|
85
|
+
for (let e = entries.at(-1); e; e = byId.get(e.parentId)) path.unshift(e);
|
|
86
|
+
const pending = new Set();
|
|
87
|
+
for (const e of path) {
|
|
88
|
+
if (e.type !== "message") continue;
|
|
89
|
+
if (e.message?.role === "assistant") for (const c of e.message.content ?? []) if (c.type === "toolCall") pending.add(c.id);
|
|
90
|
+
if (e.message?.role === "toolResult") pending.delete(e.message.toolCallId);
|
|
91
|
+
}
|
|
92
|
+
return pending.size === 0 ? path.at(-1)?.id ?? null : null;
|
|
93
|
+
} catch { return null; }
|
|
94
|
+
}
|
|
95
|
+
function validManifest(m) {
|
|
96
|
+
const fields = ["v", "runId", "sessionKey", "baseSha", "venue", "piVersion", "attempt", "host", "hostQueue", "at", "bytes", "transcriptSha256", "sourceDigest"];
|
|
97
|
+
return m && Object.keys(m).length === fields.length && fields.every((k) => Object.hasOwn(m, k)) && m.v === 1 && ID.test(m.runId) && KEY.test(m.sessionKey) && SHA.test(m.baseSha) && TOKEN.test(m.venue) && /^\d+\.\d+\.\d+$/.test(m.piVersion) && Number.isSafeInteger(m.attempt) && m.attempt >= 0 && HOST.test(m.host) && (m.hostQueue === null || /^pi-jobs@[A-Za-z0-9._-]{1,64}$/.test(m.hostQueue)) && Number.isSafeInteger(m.at) && m.at >= 0 && Number.isSafeInteger(m.bytes) && m.bytes >= 0 && HEX.test(m.transcriptSha256) && HEX.test(m.sourceDigest);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The sweep's age of a source from its manifest, of ANY version (issue #761): `{ runId, at }` when the manifest carries
|
|
102
|
+
* an integer `v` of 1 or more, a run id that names this very directory (`forkSourceKey(runId) === key`) and a publish
|
|
103
|
+
* time that is a safe integer of 0 or more; null otherwise. For the sweep alone, and lenient on purpose: judged by
|
|
104
|
+
* `validManifest`, a source a newer release published reads as no manifest, so it would be aged by its earlier
|
|
105
|
+
* `origin.json` (removed while young) or by the folder's mtime, and with no run id its workspace archive would never be
|
|
106
|
+
* removed. `read` stays strict, so a newer source is still never served here.
|
|
107
|
+
*/
|
|
108
|
+
function sweepAge(m, key) {
|
|
109
|
+
if (!m || typeof m !== "object" || Array.isArray(m)) return null;
|
|
110
|
+
if (!Number.isSafeInteger(m.v) || m.v < 1) return null;
|
|
111
|
+
if (typeof m.runId !== "string" || !ID.test(m.runId) || forkSourceKey(m.runId) !== key) return null;
|
|
112
|
+
if (!Number.isSafeInteger(m.at) || m.at < 0) return null;
|
|
113
|
+
return { runId: m.runId, at: m.at };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createForkStore({ dir, now = Date.now, limits = {}, hostName = hostname(), hostQueue = null, checkpointer = null, checkpointLimits = {}, readRegular = regular } = {}) {
|
|
117
|
+
const L = { ...FORK_SOURCE_LIMITS, ...limits };
|
|
118
|
+
const root = typeof dir === "string" && dir ? resolve(dir) : null;
|
|
119
|
+
const host = HOST.test(hostName) ? hostName : "unknown";
|
|
120
|
+
const archive = root ? checkpointer ?? makeWorkspaceCheckpointer({ storeDir: join(root, ".workspaces"), now, hostName: host, keepHistory: true, limits: { ttlMs: L.ttlMs, snapshotTimeoutMs: 5_000, ...checkpointLimits } }) : null;
|
|
121
|
+
const pathOf = (runId) => join(root, forkSourceKey(runId));
|
|
122
|
+
const pointsOf = (runId) => join(pathOf(runId), "points");
|
|
123
|
+
let archiveBusy = false;
|
|
124
|
+
function dirs(runId, create = false) {
|
|
125
|
+
if (!root || !ID.test(runId ?? "")) throw new Error("bad-args");
|
|
126
|
+
directory(root, create);
|
|
127
|
+
directory(pathOf(runId), create);
|
|
128
|
+
}
|
|
129
|
+
function usage() {
|
|
130
|
+
let bytes = 0, sources = 0;
|
|
131
|
+
for (const name of fs.readdirSync(root)) {
|
|
132
|
+
if (!KEY.test(name) || !fs.lstatSync(join(root, name)).isDirectory()) continue;
|
|
133
|
+
sources++;
|
|
134
|
+
try { bytes += fs.lstatSync(join(root, name, "transcript.jsonl")).size; } catch {}
|
|
135
|
+
}
|
|
136
|
+
return { bytes, sources };
|
|
137
|
+
}
|
|
138
|
+
function publish({ runId, sessionKey, transcriptPath, baseSha, venue, piVersion, attempt = 0 } = {}) {
|
|
139
|
+
if (!root) return refused("unconfigured");
|
|
140
|
+
let lock;
|
|
141
|
+
try {
|
|
142
|
+
directory(root, true);
|
|
143
|
+
const before = usage();
|
|
144
|
+
if (!ID.test(runId ?? "") || !KEY.test(sessionKey ?? "") || !SHA.test(baseSha ?? "") || !TOKEN.test(venue ?? "") || !/^\d+\.\d+\.\d+$/.test(piVersion ?? "") || !Number.isSafeInteger(attempt) || attempt < 0) return refused("invalid");
|
|
145
|
+
const existed = fs.existsSync(pathOf(runId));
|
|
146
|
+
if (!existed && before.sources >= L.maxSources) return refused("capacity");
|
|
147
|
+
dirs(runId, true);
|
|
148
|
+
if (fs.existsSync(join(pathOf(runId), "unsafe-base"))) return { refused: "fork-unsafe-extensions" };
|
|
149
|
+
lock = fs.openSync(join(pathOf(runId), "publish.lock"), "wx", 0o600);
|
|
150
|
+
const bytes = readRegular(transcriptPath, L.maxBytes);
|
|
151
|
+
parseEntries(bytes);
|
|
152
|
+
const old = read({ runId });
|
|
153
|
+
if (old.ok) return old.manifest.transcriptSha256 === digest(bytes) ? { ok: true, sourceDigest: old.manifest.sourceDigest, manifest: old.manifest } : refused("immutable");
|
|
154
|
+
if (fs.existsSync(join(pathOf(runId), "manifest.json"))) return refused("corrupt");
|
|
155
|
+
if (before.bytes + bytes.length > L.maxStoreBytes) return refused("capacity");
|
|
156
|
+
const body = { v: 1, runId, sessionKey, baseSha, venue, piVersion, attempt, host, hostQueue, at: now(), bytes: bytes.length, transcriptSha256: digest(bytes) };
|
|
157
|
+
const manifest = { ...body, sourceDigest: digest(JSON.stringify(body)) };
|
|
158
|
+
if (!validManifest(manifest)) return refused("invalid");
|
|
159
|
+
// The manifest rename is the commit point. A crash before it leaves no readable source.
|
|
160
|
+
fs.rmSync(join(pathOf(runId), "transcript.jsonl"), { force: true });
|
|
161
|
+
durable(join(pathOf(runId), "transcript.jsonl"), bytes);
|
|
162
|
+
const incoming = join(pathOf(runId), `manifest.${randomBytes(8).toString("hex")}.incoming`);
|
|
163
|
+
durable(incoming, JSON.stringify(manifest));
|
|
164
|
+
fs.renameSync(incoming, join(pathOf(runId), "manifest.json"));
|
|
165
|
+
syncDir(pathOf(runId));
|
|
166
|
+
return { ok: true, sourceDigest: manifest.sourceDigest, manifest };
|
|
167
|
+
} catch (e) { return refused(e?.code === "EEXIST" ? "busy" : "unreadable"); }
|
|
168
|
+
finally { if (lock !== undefined) { fs.closeSync(lock); fs.rmSync(join(pathOf(runId), "publish.lock"), { force: true }); } }
|
|
169
|
+
}
|
|
170
|
+
function read({ runId, sourceDigest, entryId, position = "before" } = {}) {
|
|
171
|
+
if (!root) return refused("unconfigured");
|
|
172
|
+
if (!ID.test(runId ?? "") || (sourceDigest !== undefined && !HEX.test(sourceDigest))) return refused("invalid");
|
|
173
|
+
try {
|
|
174
|
+
dirs(runId);
|
|
175
|
+
if (fs.existsSync(join(pathOf(runId), "unsafe-base"))) return { refused: "fork-unsafe-extensions" };
|
|
176
|
+
const manifest = JSON.parse(readRegular(join(pathOf(runId), "manifest.json"), 8192));
|
|
177
|
+
if (!validManifest(manifest) || manifest.runId !== runId) return refused("corrupt");
|
|
178
|
+
const { sourceDigest: expected, ...body } = manifest;
|
|
179
|
+
if (digest(JSON.stringify(body)) !== expected) return refused("corrupt");
|
|
180
|
+
if (sourceDigest !== undefined && sourceDigest !== expected) return refused("changed");
|
|
181
|
+
if (now() - manifest.at > L.ttlMs || manifest.at > now()) return refused("expired");
|
|
182
|
+
const transcriptPath = join(pathOf(runId), "transcript.jsonl");
|
|
183
|
+
const bytes = readRegular(transcriptPath, L.maxBytes);
|
|
184
|
+
if (bytes.length !== manifest.bytes || digest(bytes) !== manifest.transcriptSha256) return refused("corrupt");
|
|
185
|
+
const entries = parseEntries(bytes);
|
|
186
|
+
const firstUser = entries.find((e) => e.type === "message" && e.role === "user");
|
|
187
|
+
// Load at most the point cap once. A long transcript must not turn preview into two
|
|
188
|
+
// filesystem opens per entry (an 8 MiB transcript can hold thousands of metadata entries).
|
|
189
|
+
const points = new Map();
|
|
190
|
+
try {
|
|
191
|
+
directory(pointsOf(runId));
|
|
192
|
+
const names = fs.readdirSync(pointsOf(runId)).filter((n) => /^[0-9a-f]{64}\.json$/.test(n));
|
|
193
|
+
if (names.length > L.maxPoints) return refused("corrupt");
|
|
194
|
+
for (const name of names) {
|
|
195
|
+
const p = JSON.parse(readRegular(join(pointsOf(runId), name), 8192));
|
|
196
|
+
if (ENTRY.test(p.entryId ?? "") && p.position === "at" && name === `${pointKey(p.entryId, p.position)}.json`) {
|
|
197
|
+
const valid = pointRecord(runId, p.entryId, p.position);
|
|
198
|
+
if (valid) points.set(p.entryId, valid);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch (e) {
|
|
202
|
+
if (retryableStoreError(e)) return { unavailable: e.code };
|
|
203
|
+
if (e?.code !== "ENOENT") return refused("checkpoint-unreadable");
|
|
204
|
+
}
|
|
205
|
+
const pointFor = (e, p) => e.id === firstUser?.id && p === "before" ? { base: true } : p === "before" && e.parentId ? points.get(e.parentId) : p === "at" ? points.get(e.id) : null;
|
|
206
|
+
for (const e of entries) e.positions = [...POSITIONS].filter((p) => pointFor(e, p));
|
|
207
|
+
const out = { ok: true, manifest, sourceDigest: expected, transcriptPath, entries };
|
|
208
|
+
if (entryId === undefined) return out;
|
|
209
|
+
if (!ENTRY.test(entryId) || !POSITIONS.has(position)) return refused("invalid-entry");
|
|
210
|
+
if (!entries.some((e) => e.id === entryId)) return refused("entry-missing");
|
|
211
|
+
const point = pointFor(entries.find((e) => e.id === entryId), position);
|
|
212
|
+
return point ? { ...out, point } : refused("checkpoint-missing");
|
|
213
|
+
} catch (e) { return retryableStoreError(e) ? { unavailable: e.code } : refused(e?.code === "ENOENT" ? "missing" : "unreadable"); }
|
|
214
|
+
}
|
|
215
|
+
function pointRecord(runId, entryId, position) {
|
|
216
|
+
try {
|
|
217
|
+
directory(pointsOf(runId));
|
|
218
|
+
const p = JSON.parse(readRegular(join(pointsOf(runId), `${pointKey(entryId, position)}.json`), 8192));
|
|
219
|
+
if (Object.keys(p).length !== 6 || p.entryId !== entryId || p.position !== position || !Number.isSafeInteger(p.seq) || p.seq < 1 || !HEX.test(p.transcriptSha256) || !Number.isSafeInteger(p.bytes) || p.bytes < 0 || !SHA.test(p.baseSha)) return null;
|
|
220
|
+
return p;
|
|
221
|
+
} catch (error) { if (retryableStoreError(error)) throw error; return null; }
|
|
222
|
+
}
|
|
223
|
+
async function primeBase(args = {}) {
|
|
224
|
+
if (!root || !ID.test(args.runId ?? "")) return refused("invalid");
|
|
225
|
+
try {
|
|
226
|
+
directory(root, true);
|
|
227
|
+
if (!fs.existsSync(pathOf(args.runId)) && usage().sources >= L.maxSources) return refused("capacity");
|
|
228
|
+
dirs(args.runId, true);
|
|
229
|
+
const originPath = join(pathOf(args.runId), "origin.json");
|
|
230
|
+
try { durable(originPath, JSON.stringify({ runId: args.runId, at: now() })); }
|
|
231
|
+
catch (e) { if (e?.code !== "EEXIST") throw e; }
|
|
232
|
+
} catch { return refused("unreadable"); }
|
|
233
|
+
if (archiveBusy) return refused("busy");
|
|
234
|
+
archiveBusy = true;
|
|
235
|
+
try {
|
|
236
|
+
const maxStore = checkpointLimits.maxStoreBytes ?? DEFAULT_CHECKPOINT_LIMITS.maxStoreBytes;
|
|
237
|
+
const maxBase = checkpointLimits.maxBaseBytes ?? DEFAULT_CHECKPOINT_LIMITS.maxBaseBytes;
|
|
238
|
+
const before = await archive.usage?.();
|
|
239
|
+
if (before?.bytes !== undefined && before.bytes + maxBase > maxStore) return refused("capacity");
|
|
240
|
+
const primed = await archive.primeBase({ ...args, objectFormat: args.objectFormat ?? (args.baseSha?.length === 64 ? "sha256" : "sha1") });
|
|
241
|
+
const after = primed?.ok ? await archive.usage?.() : null;
|
|
242
|
+
if (after?.bytes > maxStore) {
|
|
243
|
+
await archive.remove?.(args.runId);
|
|
244
|
+
return refused("capacity");
|
|
245
|
+
}
|
|
246
|
+
if (primed?.baseHasExtensions) {
|
|
247
|
+
try { durable(join(pathOf(args.runId), "unsafe-base"), "1\n"); } catch {}
|
|
248
|
+
return { refused: "fork-unsafe-extensions" };
|
|
249
|
+
}
|
|
250
|
+
return primed;
|
|
251
|
+
} catch { return refused("unreadable"); }
|
|
252
|
+
finally { archiveBusy = false; }
|
|
253
|
+
}
|
|
254
|
+
async function capturePoint({ runId, entryId, position = "at", transcriptPath, offset, ...args } = {}) {
|
|
255
|
+
if (!root || !ID.test(runId ?? "") || (entryId !== undefined && !ENTRY.test(entryId)) || !POSITIONS.has(position) || !Number.isSafeInteger(offset) || offset <= 0 || offset > L.maxBytes || !["frozen", "dead"].includes(args.quiesced)) return refused("invalid");
|
|
256
|
+
if (archiveBusy) return refused("busy");
|
|
257
|
+
archiveBusy = true;
|
|
258
|
+
try {
|
|
259
|
+
dirs(runId);
|
|
260
|
+
directory(pointsOf(runId), true);
|
|
261
|
+
const bytes = readRegular(transcriptPath, L.maxBytes).subarray(0, offset);
|
|
262
|
+
if (bytes.length !== offset) return refused("checkpoint-mismatch");
|
|
263
|
+
entryId ??= forkPointEntry(bytes);
|
|
264
|
+
if (!ENTRY.test(entryId ?? "")) return refused("checkpoint-unsafe");
|
|
265
|
+
if (pointRecord(runId, entryId, position)) return { ok: true, unchanged: true };
|
|
266
|
+
const names = fs.readdirSync(pointsOf(runId)).filter((n) => /^[0-9a-f]{64}\.json$/.test(n));
|
|
267
|
+
if (names.length >= L.maxPoints) return refused("point-capacity");
|
|
268
|
+
const entries = parseEntries(bytes);
|
|
269
|
+
if (entries.at(-1)?.id !== entryId || forkPointEntry(bytes) !== entryId || position !== "at") return refused("checkpoint-mismatch");
|
|
270
|
+
const current = await archive.readManifest(runId);
|
|
271
|
+
const seq = current.ok ? current.manifest.seq + 1 : 1;
|
|
272
|
+
if (seq > L.maxPoints) return refused("point-capacity");
|
|
273
|
+
const saved = await archive.snapshot({ ...args, runId, seq, transcript: { srcFile: transcriptPath, offset } });
|
|
274
|
+
if (!saved.ok) return saved;
|
|
275
|
+
if (saved.manifest.workspace.partial || saved.manifest.workspace.ignoredDropped) return refused("checkpoint-partial");
|
|
276
|
+
const point = { entryId, position, seq, transcriptSha256: saved.manifest.transcript.sha256, bytes: offset, baseSha: args.baseSha };
|
|
277
|
+
if (point.transcriptSha256 !== digest(bytes)) return refused("checkpoint-mismatch");
|
|
278
|
+
durable(join(pointsOf(runId), `${pointKey(entryId, position)}.json`), JSON.stringify(point));
|
|
279
|
+
syncDir(pointsOf(runId));
|
|
280
|
+
return { ok: true, point };
|
|
281
|
+
} catch { return refused("checkpoint-unreadable"); }
|
|
282
|
+
finally { archiveBusy = false; }
|
|
283
|
+
}
|
|
284
|
+
async function readPoint(options) {
|
|
285
|
+
try {
|
|
286
|
+
const source = read(options);
|
|
287
|
+
if (!source.ok) return source;
|
|
288
|
+
if (archive.readBase) {
|
|
289
|
+
const base = await archive.readBase(options.runId);
|
|
290
|
+
if (base?.unavailable) return base;
|
|
291
|
+
if (!base?.ok || base.baseSha !== source.manifest.baseSha) return refused("base-missing");
|
|
292
|
+
}
|
|
293
|
+
if (source.point?.base) return source;
|
|
294
|
+
if (!source.point) return refused("checkpoint-missing");
|
|
295
|
+
const saved = await archive.readManifest(options.runId, { seq: source.point.seq });
|
|
296
|
+
if (saved?.unavailable) return saved;
|
|
297
|
+
if (saved?.reason === "internal-error") return { unavailable: "checkpoint-unreadable" };
|
|
298
|
+
if (!saved.ok || saved.manifest.workspace.partial || saved.manifest.workspace.ignoredDropped || saved.manifest.transcript.sha256 !== source.point.transcriptSha256 || saved.manifest.transcript.bytes !== source.point.bytes || saved.manifest.workspace.baseSha !== source.manifest.baseSha) return refused("checkpoint-mismatch");
|
|
299
|
+
// Bind the archived prefix to THIS immutable source, not merely to an entry id repeated elsewhere.
|
|
300
|
+
const prefix = readRegular(source.transcriptPath, L.maxBytes).subarray(0, source.point.bytes);
|
|
301
|
+
if (digest(prefix) !== source.point.transcriptSha256 || forkPointEntry(prefix) !== source.point.entryId) return refused("checkpoint-mismatch");
|
|
302
|
+
return { ...source, checkpoint: saved.manifest };
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return retryableStoreError(error) ? { unavailable: error.code } : { unavailable: "checkpoint-unreadable" };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async function restorePoint({ intoWorkspace, ...options }) {
|
|
308
|
+
const source = await readPoint(options);
|
|
309
|
+
if (!source.ok || source.point.base) return source;
|
|
310
|
+
const restored = await archive.restore({ runId: options.runId, manifest: source.checkpoint, intoWorkspace });
|
|
311
|
+
return restored.refused || restored.skipped || restored.unavailable ? restored : { ok: true, ...restored };
|
|
312
|
+
}
|
|
313
|
+
async function sweep() {
|
|
314
|
+
if (!root) return { removed: 0 };
|
|
315
|
+
let removed = 0;
|
|
316
|
+
try {
|
|
317
|
+
directory(root);
|
|
318
|
+
for (const key of fs.readdirSync(root)) {
|
|
319
|
+
if (!KEY.test(key) || !fs.lstatSync(join(root, key)).isDirectory()) continue;
|
|
320
|
+
let runId, at;
|
|
321
|
+
try { const age = sweepAge(JSON.parse(readRegular(join(root, key, "manifest.json"), 8192)), key); if (age) ({ runId, at } = age); } catch {}
|
|
322
|
+
if (at === undefined) {
|
|
323
|
+
try {
|
|
324
|
+
const origin = JSON.parse(readRegular(join(root, key, "origin.json"), 4096));
|
|
325
|
+
if (Object.keys(origin).length === 2 && ID.test(origin.runId ?? "") && forkSourceKey(origin.runId) === key && Number.isSafeInteger(origin.at) && origin.at >= 0) { runId = origin.runId; at = origin.at; }
|
|
326
|
+
} catch {}
|
|
327
|
+
}
|
|
328
|
+
if (at === undefined) at = fs.lstatSync(join(root, key)).mtimeMs;
|
|
329
|
+
if (now() - at <= L.ttlMs) continue;
|
|
330
|
+
if (runId) await archive.remove(runId);
|
|
331
|
+
fs.rmSync(join(root, key), { recursive: true, force: true });
|
|
332
|
+
removed++;
|
|
333
|
+
}
|
|
334
|
+
return { removed };
|
|
335
|
+
} catch { return refused("sweep-failed"); }
|
|
336
|
+
}
|
|
337
|
+
return { publish, read, primeBase, capturePoint, readPoint, restorePoint, sweep, baseSourceUrl: (runId) => archive?.baseSourceUrl(runId) };
|
|
338
|
+
}
|
package/src/get-token.mjs
CHANGED
|
@@ -120,7 +120,7 @@ export async function makeGitHubAuth(cfg, deps = {}) {
|
|
|
120
120
|
// for `{ metadata: "read" }`, so the token it holds for that one question cannot write anything
|
|
121
121
|
// even if leaked. Job mints never pass this and keep the installation's full grant -- narrowing
|
|
122
122
|
// is the caller's statement of intent, not a default this mint could guess.
|
|
123
|
-
const permissions = job?.permissions;
|
|
123
|
+
const permissions = job?.purpose === "answer-draft" ? { contents: "read", issues: "read", pull_requests: "read", metadata: "read" } : job?.permissions;
|
|
124
124
|
let minted;
|
|
125
125
|
try {
|
|
126
126
|
const appAuth = createAppAuth({ ...auth, request: mintRequest });
|