@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
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker's fixed forge-comment sentences, one leaf module (issue #710, `INT-REASON-CATALOG`).
|
|
3
|
+
*
|
|
4
|
+
* WHY THEY LIVE HERE AND NOT BESIDE THEIR CALL SITES. Each table used to sit in the module that posts it (the processor,
|
|
5
|
+
* the wiring in start.mjs, the pickup gate in index.mjs, the OpenShell adapter), and #710's reason catalog
|
|
6
|
+
* (reason-catalog.mjs) is built FROM them, never from a copy, so it must import them. Importing those modules would pull
|
|
7
|
+
* the whole job path into everything that explains a reason: the operator console and the `/dispatch` panel bundle the
|
|
8
|
+
* catalog, and the processor alone drags in pi-ai's provider tables, index.mjs a BullMQ worker, start.mjs every forge
|
|
9
|
+
* client. So the tables moved here, to a module that imports only reason constants from modules those surfaces already
|
|
10
|
+
* load, and every module that posts one imports it from here and re-exports it under its old name, so no import changed.
|
|
11
|
+
*
|
|
12
|
+
* THE RULE FOR EVERY ENTRY, unchanged by the move: a FIXED sentence, path-free, naming no model, size, project, secret
|
|
13
|
+
* name or anything a payload or a container wrote, because a forge comment's reader may be an issue author and a local
|
|
14
|
+
* job's comment lands in a service log. A comment that must carry a runtime value (a wait profile, an image ref, a cap's
|
|
15
|
+
* numbers) stays a template at its call site, and the catalog holds a general sentence for its reason instead.
|
|
16
|
+
*
|
|
17
|
+
* Moving a sentence here never changes its bytes: the comment tests compare what was posted against these exports, and
|
|
18
|
+
* the catalog's own test holds each derived sentence identical (===) to the entry it came from.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { OPENSHELL_BIND_LINGERING } from "./backends.mjs";
|
|
22
|
+
import { EXIT_OOM_KILLED } from "./run-history.mjs";
|
|
23
|
+
|
|
24
|
+
// ── The processor's (worker/src/processor.mjs re-exports every name) ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
// The post-spend terminal comments (issue #288). Every FREE refusal above the container already comments;
|
|
27
|
+
// these are the paths where money was spent and the run still ended without the agent's own status step,
|
|
28
|
+
// which used to tell the issue nothing (REQ-JOB-STATUS-COMMENTS' acceptance -- "exactly one completion or
|
|
29
|
+
// failure comment" -- held only below the spend line). Keyed by the reason token so a new abort
|
|
30
|
+
// classification adds a ROW here, never a re-plumb; sentences are FIXED and path-free, because for a
|
|
31
|
+
// local job the adapter logs the full text into a persistent service log (the "this folder" discipline
|
|
32
|
+
// at the scope-cap refusal). The completed path stays silent on purpose: exit 0 is where the AGENT'S own
|
|
33
|
+
// status comment lives (the prompt contract instructs it, including for "I cannot fix this"), and exit 2
|
|
34
|
+
// by construction means the agent was cut off before that step.
|
|
35
|
+
// EXPORTED only so a test can hold every RUNNER_POLICY_REASONS member to a row here (issue #437 review).
|
|
36
|
+
export const MODEL_AGENTS_INVALID_COMMENT = "Refused: the base commit's agent definitions are invalid. Fix their frontmatter and retry. Not run.";
|
|
37
|
+
|
|
38
|
+
export const TERMINAL_COMMENTS = {
|
|
39
|
+
"question-group-refused": "Stopped: the reviewed grouping model or private input could not be used. Check Zero touch status. Not retried.",
|
|
40
|
+
"question-group-unanswered": "Stopped: the classifier returned no confident grouping. Check Zero touch status. Not retried.",
|
|
41
|
+
"model-roles-invalid": "Stopped: the job image could not read its reviewed model roles and agent definitions. Rebuild the image and check its job inputs. Not retried.",
|
|
42
|
+
"model-roles-shadowed": "Stopped: another extension registered the reserved subagent tool, so reviewed model role resolution cannot be guaranteed. Remove the conflicting extension. Not retried.",
|
|
43
|
+
"worker-abort": "Stopped: the worker ended this run before it finished (the 30-minute job limit, or a worker shutdown). Partial work may exist. Not retried.",
|
|
44
|
+
"operator-cancel": "Stopped: the operator cancelled this run. Partial work may exist. Not retried.",
|
|
45
|
+
"agent-cancel": "Stopped by a delegated agent. Partial work may exist. Not retried.",
|
|
46
|
+
"answer-draft-refused": "Stopped: the restricted draft job refused its configuration or inputs. No answer was sent. Not retried.",
|
|
47
|
+
"runner-policy": "Stopped: the run ended inside the container before finishing (a turn or token budget, or an in-container configuration refusal). Partial work may exist. Not retried.",
|
|
48
|
+
// Issue #437. Names the cause but never the provider's own message, which may echo a key fragment. "Or
|
|
49
|
+
// access" because a 403 is as often a key that works but may not use this model or route as a bad key.
|
|
50
|
+
"provider-auth-refused": "Stopped: the AI provider refused this worker's credentials or access (an authentication or permission error). The operator needs to check the provider key and what it is allowed to use. Not retried.",
|
|
51
|
+
// Issues #501, #502. The two stops name the policy, never the amount or the model: both are operator
|
|
52
|
+
// configuration, and the comment's reader may be an issue author who can act on neither.
|
|
53
|
+
"cost-cap": "Stopped: the next AI call could have taken this run past its cost limit, so it was not made. Partial work may exist. Not retried.",
|
|
54
|
+
"model-not-allowed": "Stopped: the run tried to call an AI model this trigger does not allow, or to change an AI request in a way it does not allow, so the call was not made. Partial work may exist. Not retried.",
|
|
55
|
+
"cost-cap-unenforceable": "Stopped: this run has a cost limit, and the job image could not enforce it before each AI call, so nothing was sent to the AI provider. The operator needs to update the job image. Not retried.",
|
|
56
|
+
"model-policy-unenforceable": "Stopped: this run is limited to certain AI models, and the job image could not enforce that before each AI call, so nothing was sent to the AI provider. The operator needs to update the job image. Not retried.",
|
|
57
|
+
// The runner's refusals of the job's MCP servers, each before the prompt. Never the server's name, its host or a
|
|
58
|
+
// variable: the exit line carries the name and the cause in its message, which the worker never parses (it reaches
|
|
59
|
+
// the worker's log as the container's output). Each says what the operator can check; the issue's author can act on
|
|
60
|
+
// none of it.
|
|
61
|
+
"mcp-unavailable": "Stopped: an MCP server this trigger names could not be used (it did not start, failed its handshake or refused the connection, an authentication error among them), so the run ended before the agent started. The operator needs to check that server and the job image. Not retried.",
|
|
62
|
+
"mcp-config-invalid": "Stopped: the list of MCP servers the worker wrote for this job is not one the job image accepts, so the run ended before the agent started. The operator needs to run a worker and a job image of the same release. Not retried.",
|
|
63
|
+
"mcp-config-mismatch": "Stopped: the MCP servers the worker wrote for this job are not the ones it named for it, so the run ended before the agent started. The operator needs to run a worker and a job image of the same release. Not retried.",
|
|
64
|
+
"mcp-secret-missing": "Stopped: a credential an MCP server this trigger names needs was not set inside the job, so the run ended before the agent started. The operator needs to check the secrets this trigger passes. Not retried.",
|
|
65
|
+
"mcp-command-missing": "Stopped: the job image does not have the program an MCP server this trigger names runs, so the run ended before the agent started. The operator needs to install that server in the job image or the overlay. Not retried.",
|
|
66
|
+
"mcp-shadowed": "Stopped: another extension in this job registers a tool or command that only the job's MCP servers may, so the run ended before the agent started. The operator needs to remove that extension or this trigger's MCP servers. Not retried.",
|
|
67
|
+
// Issue #625. The runner's two refusals of a job armed with run.awaitInput, each before the prompt.
|
|
68
|
+
"await-input-shadowed": "Stopped: another extension in this job registers the tool a run uses to stop and wait for an answer, so the run ended before the agent started. The operator needs to remove that extension or this trigger's run.awaitInput. Not retried.",
|
|
69
|
+
"await-input-without-session": "Stopped: this run may stop to wait for an answer but had nowhere to keep its work, so it ended before the agent started. The operator needs to run a worker and a job image of the same release. Not retried.",
|
|
70
|
+
// Issue #644. The runner's refusal of a job armed with run.progress, before the prompt.
|
|
71
|
+
"report-progress-shadowed": "Stopped: another extension in this job registers the tool a run uses to report its progress, so the run ended before the agent started. The operator needs to remove that extension or this trigger's run.progress. Not retried.",
|
|
72
|
+
// Issue #800. The runner's refusal of a name in run.excludeExtensionTools that no staged package or overlay extension brought, before the prompt. An extension may have made calls
|
|
73
|
+
// while it loaded, so nothing here says nothing was spent, and the sentence names each cause a person can act on: no run of it can tell them apart.
|
|
74
|
+
"exclude-tool-not-loaded": "Stopped: run.excludeExtensionTools names a tool no staged package or overlay extension in this job registered when it loaded, so the run ended before the agent started. That key names what those bring: a repository's own tool of that name does not count, though the key still removes it from the session. The name may be misspelled, the package that brings the tool may be missing on this host, or the extension may register it later. The operator needs to fix the name, stage the package, exclude that extension, or take off a tool only a repository registers. Not retried.",
|
|
75
|
+
// Issue #646. The runner's two refusals of a job armed with run.steer, each before the prompt. Both are a worker and
|
|
76
|
+
// image mismatch (the worker arms steering only with the inbox mount and an exit key), so both name that fix.
|
|
77
|
+
"steer-inbox-missing": "Stopped: this run may receive messages while it works but could not read where they arrive, so it ended before the agent started. The operator needs to run a worker and a job image of the same release. Not retried.",
|
|
78
|
+
"steer-no-exit-key": "Stopped: this run may receive messages while it works but had no key to confirm their delivery with, so it ended before the agent started. The operator needs to run a worker and a job image of the same release. Not retried.",
|
|
79
|
+
// Issue #596. Never the size or the project: both are operator configuration, and the reader may be an issue author
|
|
80
|
+
// who can act on neither. The worker log and the run record carry them.
|
|
81
|
+
[EXIT_OOM_KILLED]: "Stopped: the job's container ran out of memory and was stopped. Partial work may exist. Not retried, because the same size would stop the same way. The operator can raise this job's memory size.",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Issue #625: the one comment the COMPLETED path posts, on a run that ended awaiting-input. The agent posted its own
|
|
85
|
+
// question (the tool tells it to, before it ends); this says how the run continues. Fixed and path-free, the
|
|
86
|
+
// TERMINAL_COMMENTS rule, and it carries no model text: the question never crosses the exit line.
|
|
87
|
+
// On an issue any reply continues it (the receiver routes it on the marker); a pull request's reply keeps the phrase,
|
|
88
|
+
// since its session key needs a head branch that costs a token to read (parked.mjs).
|
|
89
|
+
export const PARKED_COMMENT = "This run stopped to wait for an answer to the question above. Reply here, and the next run continues from here.";
|
|
90
|
+
export const PARKED_COMMENT_PR = "This run stopped to wait for an answer to the question above. Reply in a comment that starts with this repository's pi-dispatch comment phrase, and the next run continues from here.";
|
|
91
|
+
export function parkedComment(target) {
|
|
92
|
+
return target?.type === "pull_request" ? PARKED_COMMENT_PR : PARKED_COMMENT;
|
|
93
|
+
}
|
|
94
|
+
// Issue #638: the park's comment when a reply that arrived while the run was parking was taken as its answer (the
|
|
95
|
+
// held reply, held-settle.mjs), so the resumed run is already queued and "Reply here" would invite a second answer to
|
|
96
|
+
// a question that has one. One sentence for an issue and a pull request alike: it says how the run continues, never
|
|
97
|
+
// how to reply. Fixed words, no quote of the reply, no mention.
|
|
98
|
+
export const HELD_TAKEN_COMMENT = "The run had asked a question and took the reply above as the answer, so it continues from here.";
|
|
99
|
+
|
|
100
|
+
// Issue #502: the `model-unknown` refusal's comment. Names no model: the reader may be an issue author.
|
|
101
|
+
export const MODEL_UNKNOWN_COMMENT = "Refused: this job names an AI model that this deployment does not know (it is in neither pi's model catalog nor the overlay models.json), so no container was started and nothing was spent. Ask the operator to check the trigger's model settings. Not run.";
|
|
102
|
+
|
|
103
|
+
// The `model-unknown` refusal whose `why` is `overlay-*` (PR #558, the end-of-round check of #501 and #502): the deployment's overlay
|
|
104
|
+
// models.json is the problem, not the job's model, so the generic text above would send the author to the wrong
|
|
105
|
+
// place. Fixed text naming no path and no model; the operator finds which file and why in the worker log.
|
|
106
|
+
export const OVERLAY_REFUSED_COMMENT = "Refused before starting: the deployment's model settings file (models.json in the overlay) cannot be used for this job, so no container was started and nothing was spent. The operator needs to fix that file. Not run.";
|
|
107
|
+
/**
|
|
108
|
+
* Which of the two a `model-unknown` refusal posts: the overlay's when its `why` names the overlay (`overlay-*`). One rule
|
|
109
|
+
* for the processor and the reason catalog.
|
|
110
|
+
*/
|
|
111
|
+
export function modelUnknownComment(why) {
|
|
112
|
+
return typeof why === "string" && why.startsWith("overlay-") ? OVERLAY_REFUSED_COMMENT : MODEL_UNKNOWN_COMMENT;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Issue #341: the forge comments for a `job-user-unmappable` refusal, keyed by cause. Shorter than the operator
|
|
116
|
+
// texts in job-user.mjs on purpose: a comment's reader may be an issue author, who can act on none of it.
|
|
117
|
+
export const JOB_USER_COMMENTS = Object.freeze({
|
|
118
|
+
default: "Refused: the worker host's container runtime cannot give this job a non-root user that can read and write its own files. Not run.",
|
|
119
|
+
"runtime-unreadable": "Refused: the worker host's container runtime answered in a form the worker cannot read, so which user this job may run as is unknown. Not run.",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The comments of the durable-run refusals. Fixed and path-free, the TERMINAL_COMMENTS rule: the reader may be an issue
|
|
124
|
+
* author, and which file or which pi version is the operator's to read in the worker log.
|
|
125
|
+
*/
|
|
126
|
+
export const RECOVERY_COMMENTS = Object.freeze({
|
|
127
|
+
"recover-store-unset": "Refused: this trigger set `run.recover`, but this worker has no checkpoint store (neither PI_CHECKPOINTS_DIR nor PI_SESSIONS_DIR is set), so a lost run could not be continued while the job would still report success. Set one of them, or drop `run.recover` from this trigger. Not run.",
|
|
128
|
+
"recovery-unsafe-extensions": "Failed: this run was lost, and it is not continued from its checkpoint because the repository's `.pi/extensions/` could load code the agent wrote. Partial work may exist. Ask the operator to check the worker log.",
|
|
129
|
+
"recovery-failed": "Failed: this run was lost, continued from its checkpoint, and the continuation was lost too, so it is not continued again. Partial work may exist. Ask the operator to check the worker log.",
|
|
130
|
+
"recovery-cap": "Failed: this run was lost and continued from its checkpoint as many times as allowed, so it is not continued again. Partial work may exist. Ask the operator to check the worker log.",
|
|
131
|
+
"recovery-checkpoint-absent": "Failed: this run was lost, and its checkpoint could not be used to continue it (absent, damaged, or taken on another host, pi version or venue). Partial work may exist. Ask the operator to check the worker log.",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/** The public comments of the bundle refusals (issue #704). Fixed text: no bundle id, profile, variable or path. */
|
|
135
|
+
export const BUNDLE_COMMENTS = Object.freeze({
|
|
136
|
+
"secret-bundle-trigger-changed": "Refused: this job's project has a secret bundle, and the trigger that queued this job is no longer in the triggers file as it was, so whether it opted out of the bundle is unknown. Re-run it from the current trigger. Not run.",
|
|
137
|
+
"secret-bundle-resume": "Refused: this trigger sets `run.resume`, and the job's project has a secret bundle. A resumed job replays a transcript kept on host disk, and anything the agent echoed from the bundle would be prefilled into every later job on that key. Set `\"secretBundle\": false` on this trigger, or drop `run.resume`. Not run.",
|
|
138
|
+
"secret-bundle-profile-unknown": "Refused: this job's project has a secret bundle, and the bundle resolver profile it names is not usable on this worker host. No profile of that name is declared, or its resolver is absent or not executable. Run `pi-dispatch doctor` on the worker to see which bundle profiles it has. Not run.",
|
|
139
|
+
"secret-bundle-profile-ambiguous": "Refused: this job's project has a secret bundle, and the bundle resolver profile it names is declared twice on this worker host, once in the environment and once in the settings overlay. Neither wins, on purpose. Remove one of the two. Not run.",
|
|
140
|
+
"secret-name-reserved": "Refused: this job's project has a secret bundle, and one of its variables uses a name this deployment already uses for the job's own credentials or configuration. Whichever value reached the container, one would be silently ignored. The worker's log names the variable (`refused_secret_bundle_name_reserved`); rename it in the secrets manager. Not run.",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Issue #710: the processor's single-sentence pre-start refusals, keyed by the reason the record carries. These were
|
|
145
|
+
* inline literals at their call sites; they moved into named tables so the reason catalog can read them, and each call
|
|
146
|
+
* site posts the table's entry, so the comment is byte-identical to what it posted before.
|
|
147
|
+
*/
|
|
148
|
+
export const REFUSAL_COMMENTS = Object.freeze({
|
|
149
|
+
"sessions-dir-unset": "Refused: this trigger set `run.resume` but PI_SESSIONS_DIR is unset, so there is nowhere to persist the transcript -- the job would run with no session and still report success. Set PI_SESSIONS_DIR to a private directory outside every repo, or drop `run.resume` from this trigger. Not run.",
|
|
150
|
+
"skills-dir-missing": "Refused: this trigger set `run.skillsDir`, and that path is absent or is not a directory on the worker host. The job would have run without the skills the flow was written against. Not run.",
|
|
151
|
+
"secret-profile-unknown": "Refused: this trigger set `run.secrets`, and the resolver profile it names is not usable on this worker host. No profile of that name is declared, or its resolver is absent or not executable. The job would have started with those variables unset, and an agent that gets a 401 writes a plausible report and exits 0. Run `pi-dispatch doctor` on the worker to see which profiles it has. Not run.",
|
|
152
|
+
"secret-profile-ambiguous": "Refused: this trigger set `run.secrets`, and the resolver profile it names is declared twice on this worker host, once in the environment and once in the settings overlay. Neither wins, on purpose: the job would otherwise run against whichever one happened to be picked. Remove one of the two. Not run.",
|
|
153
|
+
"unprotected-branch": "Refused: the default branch is not protected. See SECURITY.md.",
|
|
154
|
+
"local-folder-project-changed": "Refused: this job's folder now leads into another project's folder, so no container was started and nothing was spent. Not run.",
|
|
155
|
+
"config-refused": "Refused: this deployment is misconfigured, so the job could not be started. Ask the operator to run `pi-dispatch doctor`. Not run.",
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The two `model-not-allowed` refusals before starting: the main model off the job's list, or a listed model's fallback
|
|
160
|
+
* off it (the record's `why` is then `fallback-unlisted`).
|
|
161
|
+
*/
|
|
162
|
+
export const MODEL_NOT_ALLOWED_COMMENTS = Object.freeze({
|
|
163
|
+
main: "Refused: the AI model this job would run on is not on the list of models it is allowed to use, so no container was started and nothing was spent. Ask the operator to check the trigger's model settings. Not run.",
|
|
164
|
+
"fallback-unlisted": "Refused: a model this job is allowed to use declares fallback models that are not on the job's list, so no container was started and nothing was spent. Ask the operator to list those models too, or remove that model. Not run.",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The `envelope-mismatch` refusal's two texts, since the fix differs: this host has no envelope while the fleet has a
|
|
169
|
+
* split, or another envelope.
|
|
170
|
+
*/
|
|
171
|
+
export const ENVELOPE_MISMATCH_COMMENTS = Object.freeze({
|
|
172
|
+
"no-envelope": "Refused: this worker has no budget envelope while the other workers share an applied budget split, so no container was started and nothing was spent. Ask the operator to install the envelope on this worker, or to turn delegated allocation off for every worker (`pi-dispatch doctor` says how). Not run.",
|
|
173
|
+
mismatch: "Refused: this worker's budget envelope differs from the one the current budget split was made for, so no container was started and nothing was spent. Ask the operator to run `pi-dispatch doctor`. Not run.",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The `podman-conf-widens-job` refusal's six texts: rootful Podman's service behind the local venue (`rootful-*`), the podman
|
|
178
|
+
* venue's RUNNING rootless network (`live-*`) or its configuration files, each when a widening key was found (`*widens`)
|
|
179
|
+
* or when the source could not be read whole (`*unread`), which is a different sentence.
|
|
180
|
+
*/
|
|
181
|
+
export const PODMAN_CONF_COMMENTS = Object.freeze({
|
|
182
|
+
"rootful-widens": "Refused: the worker host's Podman configuration adds to every job's container what this venue does not allow (variables, the Podman service's groups, looser limits and filters, or the programs that run it), so the operator must change it before local jobs run. Not run.",
|
|
183
|
+
"rootful-unread": "Refused: the worker host's Podman configuration could not be read in full, or is written in a form the worker does not decode, so whether it adds to a job's container what this venue does not allow is not known, and the operator must fix that before local jobs run. Not run.",
|
|
184
|
+
"live-widens": "Refused: the worker host's running Podman network still lets a job's container reach the host's own services, with an option from a configuration since changed, so the operator must restart that network before podman jobs run. Not run.",
|
|
185
|
+
"live-unread": "Refused: the worker host's running Podman network could not be read, so whether it lets a job's container reach more than this venue allows is not known, and the operator must fix that before podman jobs run. Not run.",
|
|
186
|
+
widens: "Refused: the worker host's Podman configuration lets a job's container reach more than this venue allows (the host's own services, the worker account's groups, or looser limits and filters than the worker sets), so the operator must change it before podman jobs run. Not run.",
|
|
187
|
+
unread: "Refused: the worker host's Podman configuration could not be read in full, or is written in a form the worker does not decode, so whether it lets a job's container reach more than this venue allows is not known, and the operator must fix that before podman jobs run. Not run.",
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
/** A venue's own refusal that came with no sentence of its own (`venueRefused` without a comment). */
|
|
191
|
+
export const VENUE_REFUSED_COMMENT = "Refused: this job's venue cannot run it on this host right now. Not run.";
|
|
192
|
+
/** The same, for a venue's refusal of the project's secret bundle. */
|
|
193
|
+
export const VENUE_BUNDLE_REFUSED_COMMENT = "Refused: this job's venue cannot hold its project's secret bundle as declared. Not run.";
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The reason a job retried for the podman venue's rootless network keeper carries (issue #458, PR #463 round 2): a
|
|
197
|
+
* fixed token, as every run-record reason is, and the key the terminal comment is chosen by.
|
|
198
|
+
*/
|
|
199
|
+
export const NETNS_KEEPER_NOT_HOLDING = "netns-keeper-not-holding";
|
|
200
|
+
/** Issue #476: a job held on a young keeper that kept restarting, or stayed young past the hold's bound. */
|
|
201
|
+
export const NETNS_KEEPER_CRASH_LOOP = "netns-keeper-crash-loop";
|
|
202
|
+
|
|
203
|
+
// ── The OpenShell adapter's (worker/src/backend-openshell.mjs re-exports it) ────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The fixed comments of this venue's own refusals, by reason. Fixed text, so nothing the gateway said reaches a forge
|
|
207
|
+
* comment; the evidence goes to the operator's log.
|
|
208
|
+
*/
|
|
209
|
+
export const OPENSHELL_REFUSALS = Object.freeze({
|
|
210
|
+
"openshell-gateway-not-local": "Refused: this job's venue is an OpenShell gateway that is not observed on this host, and the venue holds the job's credentials in that gateway, so it runs only on a gateway this deployment runs itself. Not run.",
|
|
211
|
+
"openshell-global-policy": "Refused: the OpenShell gateway has a gateway-wide policy in force, which would replace the policy the worker generates for this job. Not run.",
|
|
212
|
+
"openshell-landlock-missing": "Refused: this host's kernel does not offer Landlock, which the OpenShell venue needs to confine the job's sandbox and to keep its inputs read-only. Not run.",
|
|
213
|
+
"openshell-recover-unsupported": "Refused: this trigger asks for runs that continue after a lost worker, which the OpenShell venue cannot checkpoint yet. Run it on another venue, or without run.recover. Not run.",
|
|
214
|
+
"openshell-credential-reads-value": "Refused: this job's model provider reads its credential's value inside the job (to sign requests, pick an auth scheme or decode it), and on the OpenShell venue the job sees only a placeholder for it. Run it on another venue, or with a provider whose key is sent as it is. Not run.",
|
|
215
|
+
"openshell-gateway-untested": "Refused: the OpenShell gateway runs another release than the one this venue was measured on. Not run.",
|
|
216
|
+
// Issue #730: the key holders run readable here, so what keeps a job's tools from the key in their memory is three
|
|
217
|
+
// things: OpenShell's workload seccomp (process_vm_readv, ptrace, pidfd_getfd), Yama for /proc/<pid>/mem, and the
|
|
218
|
+
// entrypoint's ulimit -c 0 for a core. The setting goes to the operator's log.
|
|
219
|
+
"openshell-ptrace-scope-unprotected": "Refused: this host's kernel does not stop a process from reading the memory of the process that started it (Yama ptrace_scope is 0, or cannot be read here), and on the OpenShell venue that is what keeps the job's own tools away from the key that signs its result. Set kernel.yama.ptrace_scope to 1 on this host. Not run.",
|
|
220
|
+
// Issue #730: scope 2 or 3 also denies the broker its read of the workload (ATTACH is admin-only), so a job would reserve and then fail. The setting goes to the operator's log.
|
|
221
|
+
"openshell-ptrace-scope-too-strict": "Refused: this host's kernel allows a process to read another's memory only for an administrator (Yama ptrace_scope is 2 or 3), which also stops the OpenShell sandbox from observing the job well enough to let it reach the network, so the job would start and then fail. Set kernel.yama.ptrace_scope to exactly 1 on this host. Not run.",
|
|
222
|
+
"openshell-egress-untranslatable": "Refused: this deployment's egress settings hold an entry the OpenShell venue cannot turn into its network policy without widening it, so the operator must change it before jobs run there. Not run.",
|
|
223
|
+
// Issue #686 part 3, review round 2 of #714: the bind venue's named result for a sandbox not confirmed gone (backends.mjs).
|
|
224
|
+
[OPENSHELL_BIND_LINGERING.reason]: OPENSHELL_BIND_LINGERING.comment,
|
|
225
|
+
// Issue #704 part 3: a project secret bundle this venue cannot hold as declared (`judgeBundle` says why for each), each
|
|
226
|
+
// refused before the sandbox exists and before spend. The variable's name and the host go to the operator's log
|
|
227
|
+
// (`refused_openshell_bundle`), never to a forge comment: a bundle's names are its manager's contents.
|
|
228
|
+
"openshell-bundle-endpoint-not-allowed": "Refused: this job's project has a secret bundle whose endpoints name a host the job's network policy does not admit by that exact name, and a bundle may not widen what the job reaches. Add the host to egress-allowlist.conf (as itself, not under a wildcard entry), or remove it from the bundle's endpoints. Not run.",
|
|
229
|
+
"openshell-bundle-endpoint-shared": "Refused: this job's project has a secret bundle whose endpoints name a host the job's own forge, model or MCP credentials are sent to, and on the OpenShell venue a host is bound to one credential provider, so the job's own would stop working there. Remove that host from the bundle's endpoints. Not run.",
|
|
230
|
+
"openshell-bundle-endpoint-unknown": "Refused: this job's project has a secret bundle whose endpoints name a variable the bundle's resolver did not deliver, so a variable meant to be held as a placeholder could be missing or reach the agent as a value. Fix the name in the bundle's endpoints, or add the variable in the manager. Not run.",
|
|
231
|
+
"openshell-bundle-name-taken": "Refused: this job's project has a secret bundle with a variable the OpenShell sandbox already sets itself, so one of the two would be silently lost. Rename the variable in the manager. Not run.",
|
|
232
|
+
"openshell-bundle-unpassable": "Refused: this job's project has a secret bundle with a variable the OpenShell venue cannot hold as a placeholder (a value over 8 KiB, or more endpoint variables or hosts than a sandbox's providers hold). Run the project on another venue, or change the bundle's endpoints. Not run.",
|
|
233
|
+
"openshell-bundle-image-too-old": "Refused: this job's project has a secret bundle whose variables reach the job as values, and on the OpenShell venue they reach it by a file only a newer job image reads, so this image would run the job without them. Rebuild the job image from a version that declares bundleEnvFile. Not run.",
|
|
234
|
+
// Issue #686 part 3, bind mode only. The setting goes to the operator's log (`openshell_bind_mounts_disabled`).
|
|
235
|
+
"openshell-bind-mounts-disabled": "Refused: this job's venue mounts its files into an OpenShell sandbox, and the OpenShell gateway is not configured to allow that (its gateway.toml must allow driver config and bind mounts, and turn resource admission off), so the operator must change it before jobs run there. Not run.",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ── The wiring's terminal failures (worker/src/start.mjs; once locals of its wiring function) ───────────────────────────
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The reason a local job held for rootful Podman's service restart fails with once the hold runs out (gate round 2 of
|
|
242
|
+
* PR #473). Defined here, beside the comment it keys, and re-exported by runtime-observations.mjs, which owns the hold;
|
|
243
|
+
* an import the other way would pull that module (and the docker CLI wrapper under it) into the reason catalog.
|
|
244
|
+
*/
|
|
245
|
+
export const PODMAN_RESTART_HOLD_EXPIRED = "podman-service-restart-hold-expired";
|
|
246
|
+
|
|
247
|
+
// The infra-terminal sentence (issue #288). FIXED, never err.message: the message classes that reach
|
|
248
|
+
// a failedReason carry host paths and library words (the #310 record), and for a local job this text
|
|
249
|
+
// lands verbatim in the service log through the adapter's stdout fallthrough. The worker log already
|
|
250
|
+
// holds the truncated reason on the job_failed line beside it.
|
|
251
|
+
export const FAILED_COMMENT = "Failed: an error stopped this job and it will not be retried further. Ask the operator to check the worker log.";
|
|
252
|
+
// Issue #458 (PR #463 round 2): a job that never started because the podman venue's rootless network keeper did not
|
|
253
|
+
// hold says so, since the generic line sends an operator to a log that only says the job failed. Fixed text keyed by
|
|
254
|
+
// the fixed reason token, never the error's own words: a forge comment carries nothing a host read produced.
|
|
255
|
+
export const FAILED_COMMENT_BY_REASON = Object.freeze({
|
|
256
|
+
// Neutral on the cause (PR #463 round 3): a keeper that is not running and a proxy that must restart after it both land here.
|
|
257
|
+
[NETNS_KEEPER_NOT_HOLDING]: "Failed before it started: this worker's rootless Podman egress proxy did not pass its pre-start check (its rootless network keeper, pi-dispatch-netns-keeper), so the job was retried and never run. Nothing was spent. Ask the operator to run `pi-dispatch doctor` on the worker for the exact fix.",
|
|
258
|
+
// Issue #476: a job held on a young keeper that kept restarting while it waited.
|
|
259
|
+
[NETNS_KEEPER_CRASH_LOOP]: "Failed before it started: this worker's rootless network keeper (pi-dispatch-netns-keeper), which its Podman egress proxy needs, kept restarting while the job waited for it, so the job was retried and never run. Nothing was spent. Ask the operator to run `pi-dispatch doctor` on the worker and read the keeper's log (`journalctl --user -u pi-dispatch-netns-keeper.service`).",
|
|
260
|
+
// Issue #448 (gate round 2 of PR #473): the hold's own ending, so the comment names the cause and the fix.
|
|
261
|
+
[PODMAN_RESTART_HOLD_EXPIRED]: "Failed before it started: rootful Podman's service on this worker kept running with a containers.conf older than its files (or a file's change time stayed ahead of the host's clock) for an hour, so the job was held and never run. Nothing was spent. Ask the operator to restart it while no local job runs (`sudo systemctl restart podman.service`), then run the job again.",
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Issue #645: a recovery that waited past its deadline for the host holding its run's checkpoint, taken back off the
|
|
265
|
+
// queue by the host-lost sweep. Fixed text, like every failure sentence here. It says what happened and what is lost
|
|
266
|
+
// (the work since the run's start is on that host, not gone), and sends the operator to the host, the one fix.
|
|
267
|
+
export const RECOVERY_HOST_LOST_COMMENT = "Failed: this run stopped on a worker that has not come back, and its checkpoint lives on that worker, so it could not be continued and will not be retried further. Ask the operator to check that worker, then run the job again.";
|
|
268
|
+
|
|
269
|
+
// ── The pickup gate's (worker/src/index.mjs re-exports both names) ──────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The forge comments for the two never-fits refusals (issue #596, phase 2). GENERIC on purpose: never the size, the
|
|
273
|
+
* budget or the project, which are operator configuration an issue author can act on none of. The worker log, the run
|
|
274
|
+
* record and doctor name both sizes. There is no fleet refusal (gate round 1 of phase 2): a job on the shared
|
|
275
|
+
* queue waits for a host it fits on.
|
|
276
|
+
*/
|
|
277
|
+
export const SIZE_REFUSAL_COMMENTS = Object.freeze({
|
|
278
|
+
"job-size-exceeds-host": "Refused: this job's size is larger than the worker host's job budget, so it could never start there. No container was started and nothing was spent. Ask the operator to lower this project's job size or raise the host budget. Not run.",
|
|
279
|
+
"job-size-exceeds-share": "Refused: this job's size is larger than the share of the worker host's job budget its project may use, so it could never start there. No container was started and nothing was spent. Ask the operator to lower this project's job size or raise its host share. Not run.",
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/** The comment for a job the operator cancelled before it started, where it would have been held or retried (gate of PR #479). */
|
|
283
|
+
export const CANCELLED_BEFORE_START_COMMENT = "Stopped: the operator cancelled this run before it started. Nothing was spent. Not retried.";
|
|
284
|
+
|
|
285
|
+
// Issue #649: all approval sentences are fixed; human summaries and reasons stay in the private session/store.
|
|
286
|
+
export const APPROVAL_COMMENTS = {
|
|
287
|
+
"awaiting-approval": "Approval requested. Reply /approve, /decline followed by an optional reason, or ask a question. Only an authorized reply can continue the saved run.",
|
|
288
|
+
"approval-config-invalid": "Stopped: the approval gate configuration is invalid. Check the trigger declarations and rebuild a matching worker and job image. Not retried.",
|
|
289
|
+
"approval-without-session": "Stopped: approval gates need a persisted session. Check the session store and job image. Not retried.",
|
|
290
|
+
"approval-shadowed": "Stopped: another extension registered the reserved approval tool. Remove the conflicting registration and run again. Not retried.",
|
|
291
|
+
"approval-decision-invalid": "Stopped: the decision does not match the parked tool result. Check the retained session and request before starting another run. Not retried.",
|
|
292
|
+
"approval-author-missing": "Refused: this approval reply has no authorized actor identifier. Reply through an authenticated operator surface or the forge. Not run.",
|
|
293
|
+
"approval-expired": "Refused: this approval expired. Its durable decline is continued by the expiry job. Not run.",
|
|
294
|
+
"approval-expiry-invalid": "Refused: the expiry job does not match a durable system decline. Check the approval store. Not run.",
|
|
295
|
+
"approval-host-mismatch": "Refused: the saved approval session belongs to another worker host. Route the reply to that host. Not run.",
|
|
296
|
+
"approval-job-id-missing": "Refused: the approval continuation has no queue identifier. Check the worker wiring. Not run.",
|
|
297
|
+
"approval-park-invalid": "The approval request did not match a declared gate and saved session. No approval is armed. Check the runner and trigger declaration.",
|
|
298
|
+
"approval-pending": "Refused: this run is waiting for an approval decision. Approve, decline, or ask a question before starting more work. Not run.",
|
|
299
|
+
"approval-policy-changed": "Refused: the approval declaration changed. Start a new authorized run under the current trigger policy. Not run.",
|
|
300
|
+
"approval-policy-unavailable": "Refused: the current approval declaration could not be read. Check the triggers file before trying again. Not run.",
|
|
301
|
+
"approval-request-conflict": "The saved approval request conflicts with this conversation. No new approval is armed. Check the session and durable request.",
|
|
302
|
+
"approval-request-stale": "Refused: this reply names an approval request that is no longer pending. Reload the current request before answering. Not run.",
|
|
303
|
+
"approval-request-unbound": "Refused: this reply is not bound to the pending approval request. Reply again after the approval is published. Not run.",
|
|
304
|
+
"approval-state-conflict": "Refused: the approval was already decided, consumed, or superseded. Check its recorded decision before starting another run. Not run.",
|
|
305
|
+
"approval-state-unavailable": "The approval could not be saved durably. No approval is armed. Check the session store and Valkey before starting another run.",
|
|
306
|
+
"approval-store-unavailable": "Refused: this worker has no durable approval store. Check the worker configuration. Not run.",
|
|
307
|
+
"approval-finish-unavailable": "The approval continuation ended but its durable completion could not be saved. Check Valkey and the request before starting another run.",
|
|
308
|
+
"approval-session-not-saved": "The approval conversation could not be saved. No approval is armed. Check the retained session before starting another run.",
|
|
309
|
+
"approval-session-unavailable": "Refused: the approved session could not be resumed. No approval can be transferred to a new conversation. Check the retained session and its limits.",
|
|
310
|
+
"job-image-approval-gates-unsupported": "Refused: the job image does not declare approval gates. Rebuild the job image with approval support. Not run.",
|
|
311
|
+
"job-image-approval-auth-unsupported": "Refused: approval gates need an image with authenticated runner exits (exitAuth). Rebuild the job image. Not run."
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
for (const reason of ["approval-config-invalid", "approval-without-session", "approval-shadowed", "approval-decision-invalid"]) TERMINAL_COMMENTS[reason] = APPROVAL_COMMENTS[reason];
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* A park the processor could not arm because the live triggers file was written by a newer pi-dispatch (issue #761): the
|
|
318
|
+
* run is complete and paid for, and its record says `approval-policy-unavailable`. A string apart from
|
|
319
|
+
* `APPROVAL_COMMENTS`, which is keyed by reason tokens: this is one more comment for an existing reason, never a reason
|
|
320
|
+
* of its own, and the store comment that path posted before (`approval-state-unavailable`) sent the reader to Valkey.
|
|
321
|
+
*/
|
|
322
|
+
export const APPROVAL_PARK_NEWER_COMMENT = "No approval is armed: the triggers file was written by a newer pi-dispatch; upgrade this worker.";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { authError } from "./auth-store.mjs";
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { isDeterminateFsCode } from "./transient.mjs";
|
|
5
|
+
import { legacyAuthInteraction, loadCommunityAuthHelper, loadNativeAnthropicAuth, verifyNativeAnthropicAuth, readAuthJson, validCommunityCredential } from "./community-auth-helper.mjs";
|
|
6
|
+
import { createOpenAIAuthAdapter, verifyPinnedOpenAIOAuth } from "./oauth-openai.mjs";
|
|
7
|
+
import { COMMUNITY_AUTH_ADAPTERS, verifyCommunityAuthAdapter } from "./community-auth-patches.mjs";
|
|
8
|
+
export { COMMUNITY_AUTH_ADAPTERS, prepareCommunityAuthAdapter, verifyCommunityAuthAdapter, communityPackageDigest } from "./community-auth-patches.mjs";
|
|
9
|
+
|
|
10
|
+
/** The audited verifier asks existence questions; an unanswered stat must not become an absent dependency. */
|
|
11
|
+
export function strictAdapterFilesystem(fs = nodeFs) {
|
|
12
|
+
return {
|
|
13
|
+
...fs,
|
|
14
|
+
existsSync(path) {
|
|
15
|
+
try { fs.statSync(path); return true; }
|
|
16
|
+
catch (error) { if (isDeterminateFsCode(error?.code)) return false; throw error; }
|
|
17
|
+
},
|
|
18
|
+
readFileSync(path, ...args) {
|
|
19
|
+
const contents = fs.readFileSync(path, ...args);
|
|
20
|
+
if (typeof contents === "string" && ["package.json", ".dispatch-auth-adapter.json"].includes(basename(path))) {
|
|
21
|
+
const metadata = JSON.parse(contents);
|
|
22
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) throw authError("auth-adapter-unavailable");
|
|
23
|
+
}
|
|
24
|
+
return contents;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hostValidationError(error) {
|
|
30
|
+
if (error?.code?.startsWith("auth-")) return error;
|
|
31
|
+
const transient = !(error instanceof SyntaxError) && !isDeterminateFsCode(error?.code);
|
|
32
|
+
return authError(["ENOENT", "ENOTDIR"].includes(error?.code) ? "auth-adapter-unstaged" : "auth-adapter-unavailable", { transient });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createDefaultAuthAdapters({ now = () => Date.now(), fetch = globalThis.fetch, loadHelper = loadCommunityAuthHelper, nativeOAuth = null, fs = nodeFs } = {}) {
|
|
36
|
+
const validationFs = strictAdapterFilesystem(fs);
|
|
37
|
+
let native;
|
|
38
|
+
async function nativeAuth() {
|
|
39
|
+
if (nativeOAuth) return nativeOAuth;
|
|
40
|
+
if (!native) native = loadNativeAnthropicAuth({ fetch, now });
|
|
41
|
+
return native;
|
|
42
|
+
}
|
|
43
|
+
async function usage({ access, signal }) {
|
|
44
|
+
let response;
|
|
45
|
+
try { response = await fetch("https://api.anthropic.com/api/oauth/usage", { headers: { Authorization: `Bearer ${access}`, "anthropic-beta": "oauth-2025-04-20", Accept: "application/json" }, signal: AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(10_000)]) }); }
|
|
46
|
+
catch { throw authError("auth-usage-unavailable", { transient: true }); }
|
|
47
|
+
if (!response.ok) throw authError("auth-usage-unavailable", { transient: response.status === 429 || response.status >= 500 });
|
|
48
|
+
return readAuthJson(response);
|
|
49
|
+
}
|
|
50
|
+
const entries = { "openai-chatgpt": { ...createOpenAIAuthAdapter({ now, fetch }), async validate() {
|
|
51
|
+
try { verifyPinnedOpenAIOAuth({ readFile: fs.readFileSync }); }
|
|
52
|
+
catch (error) { throw hostValidationError(error); }
|
|
53
|
+
} } };
|
|
54
|
+
for (const [id, pin] of Object.entries(COMMUNITY_AUTH_ADAPTERS)) {
|
|
55
|
+
if (!pin.community) continue;
|
|
56
|
+
const validate = (connection) => {
|
|
57
|
+
try {
|
|
58
|
+
if (id === "@gotgenes/pi-anthropic-auth@3.4.3" && !nativeOAuth) verifyNativeAnthropicAuth({ readFile: fs.readFileSync });
|
|
59
|
+
return verifyCommunityAuthAdapter({ adapter: id, root: connection.root, digest: connection.digest, fs: validationFs });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw hostValidationError(error);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
entries[id] = {
|
|
65
|
+
validate,
|
|
66
|
+
async login({ connection, interaction, signal }) {
|
|
67
|
+
validate(connection);
|
|
68
|
+
try {
|
|
69
|
+
if (id === "pi-anthropic-oauth@0.3.6") { const helper = await loadHelper({ root: connection.root, fetch }); return validCommunityCredential(await helper.loginAnthropic(legacyAuthInteraction(interaction, signal), now)); }
|
|
70
|
+
return validCommunityCredential(await (await nativeAuth()).login({ ...interaction, signal }));
|
|
71
|
+
} catch (error) { if (error.code?.startsWith("auth-")) throw error; throw authError("auth-login-failed"); }
|
|
72
|
+
},
|
|
73
|
+
async refresh({ connection, credential, signal }) {
|
|
74
|
+
// This preflight reads local source only. Its failure proves renewal has not reached a provider.
|
|
75
|
+
try { validate(connection); }
|
|
76
|
+
catch (error) { error.beforeRefresh = true; throw error; }
|
|
77
|
+
try {
|
|
78
|
+
if (id === "pi-anthropic-oauth@0.3.6") { const helper = await loadHelper({ root: connection.root, fetch }); return validCommunityCredential(await helper.refreshAnthropicToken(credential, signal, now)); }
|
|
79
|
+
return validCommunityCredential(await (await nativeAuth()).refresh(credential, signal));
|
|
80
|
+
} catch (error) { if (error.code?.startsWith("auth-")) throw error; throw authError("auth-refresh-uncertain", { uncertain: true }); }
|
|
81
|
+
},
|
|
82
|
+
usage,
|
|
83
|
+
async revoke() { return { confirmed: false }; },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return entries;
|
|
87
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Only audited auth entry points load on the credential owner, never an extension's default/UI entry. */
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createJiti } from "jiti";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { authError, validateAuthCredential } from "./auth-store.mjs";
|
|
8
|
+
|
|
9
|
+
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
10
|
+
export const NATIVE_ANTHROPIC_AUTH_SHA256 = "835268caed5dab6a0c7f157c551ed59a00635c76305e6e0c41b579c41aa07bc5";
|
|
11
|
+
const TERMINAL = new Set(["invalid_grant", "invalid_refresh_token", "refresh_token_expired", "refresh_token_invalidated", "refresh_token_reused"]);
|
|
12
|
+
export async function readAuthJson(response, { maxBytes = 128 * 1024 } = {}) {
|
|
13
|
+
const reader = response.body?.getReader?.();
|
|
14
|
+
if (!reader) { const text = await response.text(); if (Buffer.byteLength(text) > maxBytes) throw authError("auth-response-invalid", { uncertain: true }); try { return JSON.parse(text); } catch { throw authError("auth-response-invalid", { uncertain: true }); } }
|
|
15
|
+
const chunks = []; let size = 0;
|
|
16
|
+
try { for (;;) { const item = await reader.read(); if (item.done) break; size += item.value.byteLength; if (size > maxBytes) throw authError("auth-response-invalid", { uncertain: true }); chunks.push(Buffer.from(item.value)); } }
|
|
17
|
+
finally { await reader.cancel().catch(() => {}); }
|
|
18
|
+
try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { throw authError("auth-response-invalid", { uncertain: true }); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function makeAuthFetch({ fetch = globalThis.fetch } = {}) {
|
|
22
|
+
return async (input, options = {}) => {
|
|
23
|
+
if (String(input) !== TOKEN_URL) throw authError("auth-endpoint-unsupported");
|
|
24
|
+
let response;
|
|
25
|
+
try { response = await fetch(input, { ...options, signal: AbortSignal.any([...(options.signal ? [options.signal] : []), AbortSignal.timeout(15_000)]) }); }
|
|
26
|
+
catch { throw authError("auth-network-unavailable", { transient: true, uncertain: true }); }
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
let data; try { data = await readAuthJson(response); } catch {}
|
|
29
|
+
const code = typeof data?.error === "string" ? data.error : data?.error?.code;
|
|
30
|
+
if (TERMINAL.has(code) || response.status === 401) throw authError("auth-reauth-required");
|
|
31
|
+
if (response.status === 429 || response.status >= 500) throw authError("auth-network-unavailable", { transient: true });
|
|
32
|
+
throw authError("auth-provider-refused");
|
|
33
|
+
}
|
|
34
|
+
const data = await readAuthJson(response);
|
|
35
|
+
if (typeof data.access_token !== "string" || !data.access_token || typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0 || data.expires_in > 31 * 24 * 3600 || (data.refresh_token !== undefined && (typeof data.refresh_token !== "string" || !data.refresh_token))) throw authError("auth-response-invalid", { uncertain: true });
|
|
36
|
+
return new Response(JSON.stringify(data), { status: response.status, headers: response.headers });
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function loadCommunityAuthHelper({ root, fetch = globalThis.fetch, load = null }) {
|
|
41
|
+
const loader = load ?? createJiti(import.meta.url, { fsCache: false, moduleCache: false }).import;
|
|
42
|
+
const module = await loader(join(root, "src/auth.ts"));
|
|
43
|
+
if (typeof module.configureDispatchAuth !== "function" || typeof module.loginAnthropic !== "function" || typeof module.refreshAnthropicToken !== "function") throw authError("auth-adapter-unavailable");
|
|
44
|
+
module.configureDispatchAuth(makeAuthFetch({ fetch }));
|
|
45
|
+
return module;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function verifyNativeAnthropicAuth({ resolve = import.meta.resolve, readFile = readFileSync } = {}) {
|
|
49
|
+
const path = fileURLToPath(new URL("../auth/oauth/anthropic.js", resolve("@earendil-works/pi-ai/providers/anthropic")));
|
|
50
|
+
const source = readFile(path, "utf8");
|
|
51
|
+
if (createHash("sha256").update(source).digest("hex") !== NATIVE_ANTHROPIC_AUTH_SHA256) throw authError("auth-adapter-source-mismatch");
|
|
52
|
+
return { path, source };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadNativeAnthropicAuth({ fetch = globalThis.fetch, now = () => Date.now(), verify = verifyNativeAnthropicAuth, evaluate = null } = {}) {
|
|
56
|
+
const { path, source: original } = verify();
|
|
57
|
+
let source = original.replace("await fetch(url, {", "await dispatchFetch(url, {").replaceAll("Date.now()", "dispatchNow()");
|
|
58
|
+
source = source.replace('const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";', 'const CALLBACK_HOST = "127.0.0.1";');
|
|
59
|
+
source = source.replaceAll("catch (error) {", 'catch (error) { if (error?.code?.startsWith("auth-")) throw error;');
|
|
60
|
+
source = 'let dispatchFetch = globalThis.fetch; let dispatchNow = Date.now;\nexport function configureDispatchAuth(transport, clock) { dispatchFetch = transport; dispatchNow = clock; }\n' + source;
|
|
61
|
+
const run = evaluate ?? ((text, options) => createJiti(import.meta.url, { fsCache: false, moduleCache: false }).evalModule(text, options));
|
|
62
|
+
const module = run(source, { filename: path });
|
|
63
|
+
module.configureDispatchAuth(makeAuthFetch({ fetch }), now);
|
|
64
|
+
return module.anthropicOAuth;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function legacyAuthInteraction(interaction, signal) {
|
|
68
|
+
if (!interaction || typeof interaction.notify !== "function" || typeof interaction.prompt !== "function") throw authError("auth-interaction-required");
|
|
69
|
+
return { signal, onAuth: (info) => interaction.notify({ type: "auth_url", ...info }), onDeviceCode: (info) => interaction.notify({ type: "device_code", ...info }), onPrompt: (prompt) => interaction.prompt({ type: "text", ...prompt, signal }), onManualCodeInput: () => interaction.prompt({ type: "manual_code", message: "Paste the final callback URL or code#state", signal }), onSelect: (prompt) => interaction.prompt({ type: "select", ...prompt, signal }), onProgress: () => {} };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function validCommunityCredential(value) {
|
|
73
|
+
const credential = validateAuthCredential(value, { provider: "anthropic" });
|
|
74
|
+
if (!credential.access.startsWith("sk-ant-oat")) throw authError("auth-credential-invalid");
|
|
75
|
+
return credential;
|
|
76
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Data-only reviewed adapter identities; no credential reader or provider SDK. */
|
|
2
|
+
export const COMMUNITY_AUTH_ADAPTERS = Object.freeze({
|
|
3
|
+
"openai-chatgpt": { provider: "openai", community: false },
|
|
4
|
+
"pi-anthropic-oauth@0.3.6": { provider: "anthropic", community: true, package: "pi-anthropic-oauth", version: "0.3.6" },
|
|
5
|
+
"@gotgenes/pi-anthropic-auth@3.4.3": { provider: "anthropic", community: true, package: "@gotgenes/pi-anthropic-auth", version: "3.4.3" },
|
|
6
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Derived from pinned npm artifacts by adaptCommunitySource; tests rebuild every entry.
|
|
2
|
+
export const COMMUNITY_ADAPTED_SOURCE_PINS = {
|
|
3
|
+
"pi-anthropic-oauth@0.3.6": {
|
|
4
|
+
"LICENSE": "e46cbd8e0af9d9642434b1f2c80e5de17253a6766f0e334e676b1486f470f6f3",
|
|
5
|
+
"README.md": "2cb469bb8c3e2f5561de322e6758ed3f81b4fe0fd11bf27864b81f5df511fca7",
|
|
6
|
+
"package.json": "53eb5f1772b1bdf571a8d5395c48be2b5c92fb2aec69743e8b85ae9248167068",
|
|
7
|
+
"src/auth.ts": "b84ebea78bfddef5e27dd7400b3d3ec40c3ef68847c650df89cb9639f81f4175",
|
|
8
|
+
"src/convert.ts": "86897e780f92625cef443eeade4a47814fd29d8a74ec89e2a0e1c7f526b18df2",
|
|
9
|
+
"src/index.ts": "83640fcbd7c2bf569f9c63370e5af5e5ee2ec11163d2ab4928dc3d6dd8a891d2",
|
|
10
|
+
"src/prompt.ts": "2a3440fec9ae11208fb11b2af6ace51a8764c314aaa4e2ccb243696def097a36",
|
|
11
|
+
"src/stream.ts": "f5e435881d4edfb210b93b131228144431373e293c87625ba2cedfa5d96337cb",
|
|
12
|
+
"src/usage.ts": "a8c9054514d93fae5e689ddc647fb41d4c91f6a64ccab1c79e59efb2d027d0cb",
|
|
13
|
+
"src/version.ts": "0de73b8019b0cba6fc315908deac2a406a2b447f991b28c5de3bf2bdf3c56950"
|
|
14
|
+
},
|
|
15
|
+
"@gotgenes/pi-anthropic-auth@3.4.3": {
|
|
16
|
+
"CHANGELOG.md": "89c464b1114b2e317492e8ef0ad0d87c49f6f1378af1d1e81592873ba551edd0",
|
|
17
|
+
"LICENSE": "2770a907d8f37ee6fcf820b7f9fc2f51ec4de2aeacf2eeaca0d71b5f18938c61",
|
|
18
|
+
"README.md": "5e111b6319c5824e1df60f886eede5256784b2f41e3545b889c6d23cb0c551aa",
|
|
19
|
+
"package.json": "a2ccd4a410071897465739bf63fbe91f851e1bf66ae2025926f5c4c29cb6a4bb",
|
|
20
|
+
"src/account-profile.ts": "1596f0c4d6e416c72057f91380f7cff6ed032738c9419e5a5881958f8a191cc1",
|
|
21
|
+
"src/anthropic-message.ts": "9e9cd18f9a5126a3cce20baa042751b635cc16280e4e69050026c975950b5699",
|
|
22
|
+
"src/billing-header.ts": "69c88b5f1eda91a1e070bdc91f07c10c9a607d0ddfabbea4f007f38bb011de55",
|
|
23
|
+
"src/billing-version-sync.ts": "f7c20f42eb244bac1300fec8fb56133fd10c621c09c5db93fc28ad77f5083f47",
|
|
24
|
+
"src/claude-code-version.ts": "ab44ae9493f9cc404586961ab2f0a7c1b51123d01cafc8f94e72ff1b8db6d961",
|
|
25
|
+
"src/constants.ts": "03ac3d5c0d54a3e78ba37fe517da58e3e4632950e3d1d331a80830dd6d50df65",
|
|
26
|
+
"src/debug.ts": "a06644b3d52c8040b61338820881ab83acda3a0238b177261b9f5d6c4464991b",
|
|
27
|
+
"src/diagnostics.ts": "6ef582e93c3c8eba564202c2f4c079304561783a4a1dcb08007b206f5fc19880",
|
|
28
|
+
"src/extension-config.ts": "886446388fb75c0094c78caa4196a58025fe2a72ddab762416792d292723751d",
|
|
29
|
+
"src/extra-provider-shaping.ts": "ec95df3f3dd34a2a50cc8d790e35f8d774b506634fdb34893db7f654051add87",
|
|
30
|
+
"src/host-transport.ts": "c5a130e95da77636437695f4bce6987a34790464d66608352def7a6de967adef",
|
|
31
|
+
"src/index.ts": "a3ca19c3ba3dcbce018181670c82705be79de5651fbed497b4373d2a703037a9",
|
|
32
|
+
"src/oauth-transport.ts": "a0dcf816fc9e489684602c431eb03e99b70243b4edc720d96348c84bdcb63649",
|
|
33
|
+
"src/request-shaping.ts": "0583250b5f18cd01ac9f030635ecfb0f2b0284fa2c85c51747153bd08efc7722",
|
|
34
|
+
"src/system-prompt-sections.ts": "9d922f8d9feb7a4b6ef45e12b9b47db04052cdf8a096d4f7dac87d2f30aa9036",
|
|
35
|
+
"src/system-prompt-shaping.ts": "352c5b65605dda8cb0f6638a27e0844c9881bf2895f1c8bcb57917a803d5bbd4",
|
|
36
|
+
"src/version-rejection.ts": "aac221a3877251fe90d0afba6d9cba65eedd52d360d44e15a2bbe449b544eef1"
|
|
37
|
+
}
|
|
38
|
+
};
|