@junghanacs/entwurf 0.30.1 → 0.32.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/AGENTS.md +4 -2
- package/CHANGELOG.md +194 -0
- package/DELIVERY.md +1 -0
- package/README.md +64 -14
- package/VERIFY.md +36 -1
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/setup-clean-host.md +121 -4
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +7 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +34 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +28 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +28 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +92 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +236 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +746 -0
- package/mcp/entwurf-bridge/src/index.ts +7 -7
- package/mcp/entwurf-bridge/tsconfig.build.json +8 -1
- package/package.json +19 -19
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/pi-durable/bootstrap.mjs +404 -0
- package/pi/pi-durable/carrier/LICENSE +21 -0
- package/pi/pi-durable/carrier/harness-setup.js +123 -0
- package/pi/pi-durable/carrier/prompt.js +74 -0
- package/pi/pi-durable/carrier/runtime.js +402 -0
- package/pi/pi-durable/carrier/sessions.js +56 -0
- package/pi/pi-durable/carrier/subagent.js +55 -0
- package/pi/pi-durable/carrier/tui.js +655 -0
- package/pi/pi-durable/carrier-relocation.mjs +160 -0
- package/pi/pi-durable/carrier-resolver.mjs +38 -0
- package/pi/pi-durable/overlay/runtime-contacts.patch +91 -0
- package/pi/pi-durable/overlay/upstream-pin.json +108 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +7 -1
- package/pi-extensions/lib/fresh-call-composition.ts +42 -7
- package/pi-extensions/lib/meta-sender-identity.ts +7 -1
- package/pi-extensions/lib/meta-session.ts +28 -2
- package/pi-extensions/lib/mux-fresh-call.ts +32 -2
- package/pi-extensions/lib/pi-durable-fresh-preflight.ts +113 -0
- package/pi-extensions/lib/pi-durable-runtime.ts +318 -0
- package/pi-extensions/meta-bridge-pi-durable.ts +1024 -0
- package/run.sh +624 -184
- package/scripts/check-entwurf-bridge-pi-free.ts +7 -5
- package/scripts/check-fresh-call-dispatch.ts +18 -0
- package/scripts/check-gate-qualification.ts +87 -34
- package/scripts/check-herdr-activation.ts +5 -4
- package/scripts/check-pi-durable-contact.ts +767 -0
- package/scripts/check-pi-durable-receive.ts +1079 -0
- package/scripts/check-pi-durable-send.ts +1821 -0
- package/scripts/emit-pi-durable-carrier.ts +355 -0
- package/scripts/lib/mutation-qualify.ts +21 -45
- package/scripts/lib/pi-durable-fresh-live-receipts.ts +139 -0
- package/scripts/lib/pi-durable-operator-guard.ts +89 -0
- package/scripts/lib/pi-durable-r1.ts +100 -0
- package/scripts/lib/pi-durable-reap.ts +27 -0
- package/scripts/lib/pi-durable-receive.ts +137 -0
- package/scripts/lib/qualification-receipt.ts +7 -11
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/copilot-birth.json +2 -2
- package/scripts/mutants/gate-qualification.json +13 -0
- package/scripts/mutants/meta-hook-session-switch.json +4 -2
- package/scripts/mutants/mux-fresh-call.json +69 -3
- package/scripts/mutants/omp-birth.json +2 -2
- package/scripts/mutants/omp-fresh.json +6 -4
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/mutants/pi-durable.json +723 -0
- package/scripts/mutants/pi-floor.json +24 -2
- package/scripts/pi-durable-contact-host.mjs +105 -0
- package/scripts/pi-durable-receive-host.mjs +127 -0
- package/scripts/pi-durable-send-host.mjs +284 -0
- package/scripts/smoke-herdr-raw-install-live.sh +1 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +624 -0
- package/scripts/smoke-setup-verdict.sh +22 -3
- package/scripts/smoke-user-scope-citizen.sh +1 -1
- package/scripts/tsconfig.json +1 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-durable-fresh-preflight — the pre-mutation check a pi-durable fresh call needs (#129 W, L6;
|
|
3
|
+
* `docs/adding-a-harness.md` step 9 clauses 2, 3 and 5).
|
|
4
|
+
*
|
|
5
|
+
* Same question as `omp-fresh-preflight.ts`, at the same moment: before `mux-fresh-call` mutates the
|
|
6
|
+
* operator's tmux session, is everything the fresh contract needs actually in place? A refusal HERE,
|
|
7
|
+
* before placement, leaves no window behind. A pass here does not promise the window's own pass: the
|
|
8
|
+
* managed verb re-verifies inside the window, whose env is the tmux SERVER's and whose `entwurf` is
|
|
9
|
+
* whatever PATH names, so under env or package-closure skew it can still refuse after the window
|
|
10
|
+
* exists — and that refusal is left showing in it.
|
|
11
|
+
*
|
|
12
|
+
* ── The inputs, first ──
|
|
13
|
+
*
|
|
14
|
+
* model the fresh-call model grammar already admits `/`, and a durable launch needs the
|
|
15
|
+
* provider spelled out (the bootstrap refuses a model resolved by inference), so
|
|
16
|
+
* the model here is `<provider>/<model id>` split at the FIRST `/` — a catalog id
|
|
17
|
+
* that itself contains `/` keeps it. A model with no provider part is refused.
|
|
18
|
+
* first input the whole framing (callback first, then the task) rides the bootstrap's closed
|
|
19
|
+
* `{v:2,task}` payload, whose decoder caps `task` at 16000 characters. A framed
|
|
20
|
+
* task over that cap would open a window whose bootstrap refuses its own argv.
|
|
21
|
+
*
|
|
22
|
+
* ── Then the carrier, then the four units ──
|
|
23
|
+
*
|
|
24
|
+
* carrier the package's own pi-durable carrier and its declared SDK set, verified
|
|
25
|
+
* (`pi-durable-runtime.ts`); passed in as that check's verdict.
|
|
26
|
+
* birth the compiled contact in this package's closure — it mints the record.
|
|
27
|
+
* MCP hand the compiled entwurf-bridge entry in the same closure — the contact spawns it.
|
|
28
|
+
* receive the compiled owner join in the same closure admits `pi-durable-host`; without
|
|
29
|
+
* it every dispatch to the citizen is the honest `mailbox-undeliverable`.
|
|
30
|
+
* visible identity the packaged bootstrap — the one that hands the TUI the view whose conversation
|
|
31
|
+
* labels carry the garden id.
|
|
32
|
+
*
|
|
33
|
+
* ── What this deliberately does NOT claim ──
|
|
34
|
+
*
|
|
35
|
+
* Filesystem truth about ONE package closure and the SDK it resolves. It does not prove the durable
|
|
36
|
+
* app opens, the bridge connects, the doorbell arms or a garden id renders — that is runtime truth for
|
|
37
|
+
* the clause 7 LIVE receipt. No spawn, no network, no mutation; every path arrives as an argument, so
|
|
38
|
+
* this leaf imports nothing but types.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
42
|
+
import type { PiDurablePackageLayout, PiDurableRuntimeRejectReason } from "./pi-durable-runtime.ts";
|
|
43
|
+
|
|
44
|
+
/** The bootstrap decoder's task cap (`meta-bridge-omp.ts` OMP_BOOTSTRAP_TASK_MAX_CHARS); the beside
|
|
45
|
+
* test holds the two equal. */
|
|
46
|
+
export const PI_DURABLE_FIRST_INPUT_MAX_CHARS = 16000;
|
|
47
|
+
|
|
48
|
+
export type PiDurablePreflightRejectReason =
|
|
49
|
+
| "pi-durable-model-not-provider-qualified"
|
|
50
|
+
| "pi-durable-first-input-too-long"
|
|
51
|
+
| PiDurableRuntimeRejectReason
|
|
52
|
+
| "pi-durable-birth-unit-missing"
|
|
53
|
+
| "pi-durable-mcp-hand-missing"
|
|
54
|
+
| "pi-durable-receive-unit-missing"
|
|
55
|
+
| "pi-durable-visible-identity-missing";
|
|
56
|
+
|
|
57
|
+
/** `<provider>/<model id>` at the FIRST `/`, or `null` when either side is empty. */
|
|
58
|
+
export function splitPiDurableModel(model: string): { provider: string; model: string } | null {
|
|
59
|
+
const at = model.indexOf("/");
|
|
60
|
+
if (at <= 0 || at === model.length - 1) return null;
|
|
61
|
+
return { provider: model.slice(0, at), model: model.slice(at + 1) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The compiled owner join, read from the emitted bytes the bridge actually loads. */
|
|
65
|
+
function ownerJoinAdmitsDurableHost(file: string): boolean {
|
|
66
|
+
try {
|
|
67
|
+
const join = /SENDER_JOINED_RECEIVER_OWNER_KINDS\s*=\s*(\[[^\]]*\])/.exec(readFileSync(file, "utf8"))?.[1];
|
|
68
|
+
return join?.includes('"pi-durable-host"') ?? false;
|
|
69
|
+
} catch {
|
|
70
|
+
// Bounded environment probe: an unreadable file is a missing unit.
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The FIRST missing thing, in repair order, or `null` when a fresh launch may proceed. */
|
|
76
|
+
export function piDurableFreshPreflight(params: {
|
|
77
|
+
readonly model: string;
|
|
78
|
+
readonly firstInput: string;
|
|
79
|
+
readonly layout: PiDurablePackageLayout | null;
|
|
80
|
+
/** The verdict of `checkPiDurableRuntime` for the caller's env: `null` = verified. */
|
|
81
|
+
readonly runtime: PiDurableRuntimeRejectReason | null;
|
|
82
|
+
}): PiDurablePreflightRejectReason | null {
|
|
83
|
+
if (splitPiDurableModel(params.model) === null) return "pi-durable-model-not-provider-qualified";
|
|
84
|
+
if (params.firstInput.length > PI_DURABLE_FIRST_INPUT_MAX_CHARS) return "pi-durable-first-input-too-long";
|
|
85
|
+
if (params.layout === null) return "pi-durable-package-incomplete";
|
|
86
|
+
if (params.runtime !== null) return params.runtime;
|
|
87
|
+
const layout = params.layout;
|
|
88
|
+
if (!existsSync(layout.adapter)) return "pi-durable-birth-unit-missing";
|
|
89
|
+
if (!existsSync(layout.bridgeEntry)) return "pi-durable-mcp-hand-missing";
|
|
90
|
+
if (!ownerJoinAdmitsDurableHost(layout.deliverability)) return "pi-durable-receive-unit-missing";
|
|
91
|
+
if (!existsSync(layout.bootstrap)) return "pi-durable-visible-identity-missing";
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Repair text beside the predicate that decides it. The runtime reasons keep theirs on the runtime
|
|
96
|
+
* leaf; `mux-fresh-call` spreads both tables. */
|
|
97
|
+
export const PI_DURABLE_PREFLIGHT_HINT: Record<
|
|
98
|
+
Exclude<PiDurablePreflightRejectReason, PiDurableRuntimeRejectReason>,
|
|
99
|
+
string
|
|
100
|
+
> = {
|
|
101
|
+
"pi-durable-model-not-provider-qualified":
|
|
102
|
+
"a pi-durable model is `<provider>/<model id>` — the durable bootstrap resolves no provider by inference",
|
|
103
|
+
"pi-durable-first-input-too-long":
|
|
104
|
+
"the framed first input exceeds the 16000-character bootstrap payload cap — shorten the task",
|
|
105
|
+
"pi-durable-birth-unit-missing":
|
|
106
|
+
"this Entwurf package has no compiled pi-durable contact (mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js), so the sibling would mint no record — reinstall or rebuild Entwurf",
|
|
107
|
+
"pi-durable-mcp-hand-missing":
|
|
108
|
+
"this Entwurf package has no compiled entwurf-bridge entry, so the sibling's callback tool would not exist — reinstall or rebuild Entwurf",
|
|
109
|
+
"pi-durable-receive-unit-missing":
|
|
110
|
+
"this Entwurf package's compiled owner join does not admit pi-durable-host, so nothing could ever be delivered to the sibling — reinstall or rebuild Entwurf",
|
|
111
|
+
"pi-durable-visible-identity-missing":
|
|
112
|
+
"this Entwurf package has no packaged pi-durable bootstrap (pi/pi-durable/bootstrap.mjs), so no TUI would carry the garden id — reinstall Entwurf",
|
|
113
|
+
};
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-durable-runtime — whether THIS package's pi-durable carrier can run, and the resolver it runs
|
|
3
|
+
* under (#129 W, #130).
|
|
4
|
+
*
|
|
5
|
+
* The experimental durable app is source-only upstream: published `@earendil-works/pi-coding-agent`
|
|
6
|
+
* ships no `dist/experimental`. Entwurf therefore ships the app itself as a CARRIER — the pinned
|
|
7
|
+
* upstream files, type-stripped and relocated (pi/pi-durable/carrier/, emitted only by the
|
|
8
|
+
* maintainer's scripts/emit-pi-durable-carrier.ts) — and declares the published SDK set it runs
|
|
9
|
+
* against as exact production dependencies. Nothing is provisioned beside the package: there is no
|
|
10
|
+
* runtime directory, no source checkout and no model data to copy (it rides the published pi-ai).
|
|
11
|
+
*
|
|
12
|
+
* The carrier counts as runnable only when, read from where this package is installed:
|
|
13
|
+
* - every pinned carrier file, the license, the resolver and its relocation grammar are present
|
|
14
|
+
* (else `pi-durable-package-incomplete`);
|
|
15
|
+
* - each carrier file and the license carry the pinned sha256 (else `pi-durable-carrier-drift`);
|
|
16
|
+
* - every declared SDK member and direct dependency resolves from the carrier, and every member
|
|
17
|
+
* copy the carrier can reach — at any depth, nested copies included — resolves each of its own
|
|
18
|
+
* member dependencies (else `pi-durable-sdk-absent`);
|
|
19
|
+
* - each of those PHYSICAL edges lands on a package that is the one it names, at exactly the
|
|
20
|
+
* declared version (else `pi-durable-sdk-mismatch`). The members declare each other with caret ranges, so semver
|
|
21
|
+
* satisfaction is not the contract — the bytes a carrier import actually binds are. Sharing one
|
|
22
|
+
* copy with a host install of the same version is an ordinary layout and passes; how many copies
|
|
23
|
+
* of a version exist is not asked.
|
|
24
|
+
*
|
|
25
|
+
* Every refusal names the first failing item, in repair order. This leaf is the native-set check
|
|
26
|
+
* only: the ordinary Pi HOST on PATH is a different subject (`pi_supported_range` in run.sh).
|
|
27
|
+
*
|
|
28
|
+
* Read-only: file reads and Node's own package resolution (`module.findPackageJSON`) from the
|
|
29
|
+
* carrier's location. No git, no network, no environment, no credential, settings or session read.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
33
|
+
import * as fs from "node:fs";
|
|
34
|
+
import { findPackageJSON } from "node:module";
|
|
35
|
+
import * as path from "node:path";
|
|
36
|
+
import { pathToFileURL } from "node:url";
|
|
37
|
+
|
|
38
|
+
/** Every way the carrier can be refused. Stable contract: these cross the launcher, setup and the
|
|
39
|
+
* fresh-call surfaces as named refusals. */
|
|
40
|
+
export type PiDurableRuntimeRejectReason =
|
|
41
|
+
| "pi-durable-package-incomplete"
|
|
42
|
+
| "pi-durable-carrier-drift"
|
|
43
|
+
| "pi-durable-sdk-absent"
|
|
44
|
+
| "pi-durable-sdk-mismatch";
|
|
45
|
+
|
|
46
|
+
/** The package closure this module belongs to: the pin, the carrier and the compiled durable units. */
|
|
47
|
+
export interface PiDurablePackageLayout {
|
|
48
|
+
readonly root: string;
|
|
49
|
+
readonly overlayDir: string;
|
|
50
|
+
/** The emitted upstream app files and their license. */
|
|
51
|
+
readonly carrierDir: string;
|
|
52
|
+
/** The `--import` resolver the launcher runs the bootstrap under. */
|
|
53
|
+
readonly resolver: string;
|
|
54
|
+
/** The relocation grammar the resolver imports. */
|
|
55
|
+
readonly relocation: string;
|
|
56
|
+
/** The packaged bootstrap the launcher executes under the resolver. */
|
|
57
|
+
readonly bootstrap: string;
|
|
58
|
+
/** The compiled durable contact (birth, doorbell, visible identity decoration). */
|
|
59
|
+
readonly adapter: string;
|
|
60
|
+
/** The compiled entwurf-bridge entry the contact spawns as its MCP hand. */
|
|
61
|
+
readonly bridgeEntry: string;
|
|
62
|
+
/** The compiled sender/receiver owner join the bridge decides deliverability with. */
|
|
63
|
+
readonly deliverability: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The layout rooted at `root`, as plain paths. */
|
|
67
|
+
export function piDurableLayoutAt(root: string): PiDurablePackageLayout {
|
|
68
|
+
const durable = path.join(root, "pi", "pi-durable");
|
|
69
|
+
const dist = path.join(root, "mcp", "entwurf-bridge", "dist");
|
|
70
|
+
return {
|
|
71
|
+
root,
|
|
72
|
+
overlayDir: path.join(durable, "overlay"),
|
|
73
|
+
carrierDir: path.join(durable, "carrier"),
|
|
74
|
+
resolver: path.join(durable, "carrier-resolver.mjs"),
|
|
75
|
+
relocation: path.join(durable, "carrier-relocation.mjs"),
|
|
76
|
+
bootstrap: path.join(durable, "bootstrap.mjs"),
|
|
77
|
+
adapter: path.join(dist, "pi-extensions", "meta-bridge-pi-durable.js"),
|
|
78
|
+
bridgeEntry: path.join(dist, "mcp", "entwurf-bridge", "src", "index.js"),
|
|
79
|
+
deliverability: path.join(dist, "pi-extensions", "lib", "entwurf-deliverability.js"),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The package closure, found from where THIS module lives: the source depth first, then the compiled
|
|
85
|
+
* depth (`<root>/mcp/entwurf-bridge/dist/pi-extensions/lib/`). `null` when neither carries the pin —
|
|
86
|
+
* a packaging defect, refused by name.
|
|
87
|
+
*/
|
|
88
|
+
export function piDurablePackageLayout(from: string = import.meta.dirname): PiDurablePackageLayout | null {
|
|
89
|
+
for (const up of [
|
|
90
|
+
["..", ".."],
|
|
91
|
+
["..", "..", "..", "..", ".."],
|
|
92
|
+
]) {
|
|
93
|
+
const root = path.resolve(from, ...up);
|
|
94
|
+
const layout = piDurableLayoutAt(root);
|
|
95
|
+
if (fs.existsSync(path.join(layout.overlayDir, "upstream-pin.json"))) return layout;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** What the verifier reads from the packaged pin. */
|
|
101
|
+
export interface PiDurableCarrierPin {
|
|
102
|
+
readonly files: readonly { readonly name: string; readonly sha256: string }[];
|
|
103
|
+
readonly license: { readonly sha256: string };
|
|
104
|
+
/** `@earendil-works/*` members: name → exact version. */
|
|
105
|
+
readonly members: Readonly<Record<string, string>>;
|
|
106
|
+
/** Third-party packages a carrier file imports itself: name → exact version. */
|
|
107
|
+
readonly direct: Readonly<Record<string, string>>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
111
|
+
const EXACT = /^\d+\.\d+\.\d+$/;
|
|
112
|
+
|
|
113
|
+
/** The packaged pin's carrier and SDK declarations. A malformed pin throws (crash, don't warn). */
|
|
114
|
+
export function readPiDurableCarrierPin(overlayDir: string): PiDurableCarrierPin {
|
|
115
|
+
const pin = JSON.parse(fs.readFileSync(path.join(overlayDir, "upstream-pin.json"), "utf8"));
|
|
116
|
+
const files = pin?.carrier?.files;
|
|
117
|
+
const license = pin?.carrier?.license;
|
|
118
|
+
const members = pin?.sdk?.members;
|
|
119
|
+
const direct = pin?.sdk?.direct;
|
|
120
|
+
const versions = (set: unknown) =>
|
|
121
|
+
typeof set === "object" && set !== null && Object.values(set).every((v) => typeof v === "string" && EXACT.test(v));
|
|
122
|
+
if (
|
|
123
|
+
!Array.isArray(files) ||
|
|
124
|
+
files.length === 0 ||
|
|
125
|
+
!files.every((f) => typeof f?.name === "string" && /^[a-z0-9-]+\.js$/.test(f.name) && SHA256.test(f?.sha256)) ||
|
|
126
|
+
!SHA256.test(license?.sha256) ||
|
|
127
|
+
!versions(members) ||
|
|
128
|
+
Object.keys(members).length === 0 ||
|
|
129
|
+
!versions(direct)
|
|
130
|
+
) {
|
|
131
|
+
throw new Error("pi-durable pin: carrier files/license digests and exact sdk members/direct versions are required");
|
|
132
|
+
}
|
|
133
|
+
return { files, license, members, direct };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One physical dependency edge: `to` as resolved from `from`'s location. */
|
|
137
|
+
export interface PiDurableSdkEdge {
|
|
138
|
+
/** `carrier` or the member the edge leaves. */
|
|
139
|
+
readonly from: string;
|
|
140
|
+
readonly to: string;
|
|
141
|
+
readonly want: string;
|
|
142
|
+
/** The resolved package's version, or `null` when it does not resolve. */
|
|
143
|
+
readonly got: string | null;
|
|
144
|
+
/** The name the resolved package.json declares, or `null` — must be `to` itself. */
|
|
145
|
+
readonly name: string | null;
|
|
146
|
+
/** The resolved package directory, or `null`. */
|
|
147
|
+
readonly at: string | null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What the installed package holds, read without changing it. */
|
|
151
|
+
export interface PiDurableRuntimeFacts {
|
|
152
|
+
/** A carrier/resolver/relocation file that is not there. */
|
|
153
|
+
readonly missing: readonly string[];
|
|
154
|
+
/** A carrier file (or LICENSE) whose bytes are not the pinned ones. */
|
|
155
|
+
readonly drifted: readonly string[];
|
|
156
|
+
readonly edges: readonly PiDurableSdkEdge[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const sha256 = (data: Buffer): string => createHash("sha256").update(data).digest("hex");
|
|
160
|
+
|
|
161
|
+
/** `name` resolved from `fromFile` by Node's own package resolution: its version and directory. */
|
|
162
|
+
function resolveEdge(from: string, to: string, want: string, fromFile: string): PiDurableSdkEdge {
|
|
163
|
+
let manifest: string | undefined;
|
|
164
|
+
try {
|
|
165
|
+
manifest = findPackageJSON(to, pathToFileURL(fromFile));
|
|
166
|
+
} catch {
|
|
167
|
+
// Bounded environment probe: an unresolvable package is the `got: null` answer.
|
|
168
|
+
manifest = undefined;
|
|
169
|
+
}
|
|
170
|
+
if (manifest === undefined) return { from, to, want, got: null, name: null, at: null };
|
|
171
|
+
const json = JSON.parse(fs.readFileSync(manifest, "utf8"));
|
|
172
|
+
return {
|
|
173
|
+
from,
|
|
174
|
+
to,
|
|
175
|
+
want,
|
|
176
|
+
got: typeof json.version === "string" ? json.version : null,
|
|
177
|
+
name: typeof json.name === "string" ? json.name : null,
|
|
178
|
+
at: fs.realpathSync(path.dirname(manifest)),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function inspectPiDurableRuntime(layout: PiDurablePackageLayout): PiDurableRuntimeFacts {
|
|
183
|
+
const pin = readPiDurableCarrierPin(layout.overlayDir);
|
|
184
|
+
const missing: string[] = [];
|
|
185
|
+
const drifted: string[] = [];
|
|
186
|
+
const pinned = [...pin.files, { name: "LICENSE", sha256: pin.license.sha256 }];
|
|
187
|
+
for (const file of pinned) {
|
|
188
|
+
const at = path.join(layout.carrierDir, file.name);
|
|
189
|
+
if (!fs.existsSync(at)) missing.push(path.relative(layout.root, at));
|
|
190
|
+
else if (sha256(fs.readFileSync(at)) !== file.sha256) drifted.push(path.relative(layout.root, at));
|
|
191
|
+
}
|
|
192
|
+
for (const at of [layout.resolver, layout.relocation]) {
|
|
193
|
+
if (!fs.existsSync(at)) missing.push(path.relative(layout.root, at));
|
|
194
|
+
}
|
|
195
|
+
const edges: PiDurableSdkEdge[] = [];
|
|
196
|
+
// From the carrier's own location: the bare imports its files make resolve from there.
|
|
197
|
+
const base = path.join(layout.carrierDir, pin.files[0].name);
|
|
198
|
+
// Every member COPY the carrier can reach is walked, at any depth: a nested copy's own member
|
|
199
|
+
// dependencies are edges too. One physical directory is walked once (shared and duplicate copies
|
|
200
|
+
// are ordinary; a member cycle terminates), keyed by its realpath.
|
|
201
|
+
const pending: { name: string; at: string }[] = [];
|
|
202
|
+
for (const [name, want] of Object.entries({ ...pin.members, ...pin.direct })) {
|
|
203
|
+
const edge = resolveEdge("carrier", name, want, base);
|
|
204
|
+
edges.push(edge);
|
|
205
|
+
if (name in pin.members && edge.at !== null) pending.push({ name, at: edge.at });
|
|
206
|
+
}
|
|
207
|
+
const walked = new Set<string>();
|
|
208
|
+
for (let next = pending.shift(); next !== undefined; next = pending.shift()) {
|
|
209
|
+
if (walked.has(next.at)) continue;
|
|
210
|
+
walked.add(next.at);
|
|
211
|
+
const manifest = path.join(next.at, "package.json");
|
|
212
|
+
const deps = JSON.parse(fs.readFileSync(manifest, "utf8")).dependencies ?? {};
|
|
213
|
+
for (const dep of Object.keys(deps)
|
|
214
|
+
.filter((d) => d in pin.members)
|
|
215
|
+
.sort()) {
|
|
216
|
+
const edge = resolveEdge(next.name, dep, pin.members[dep], manifest);
|
|
217
|
+
edges.push(edge);
|
|
218
|
+
if (edge.at !== null) pending.push({ name: dep, at: edge.at });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { missing, drifted, edges };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const edgeText = (e: PiDurableSdkEdge): string =>
|
|
225
|
+
`${e.from} → ${e.to} wants ${e.want}, ${
|
|
226
|
+
e.got === null
|
|
227
|
+
? "resolves nothing"
|
|
228
|
+
: `binds ${e.name === e.to ? "" : `${e.name ?? "(unnamed)"}@`}${e.got} at ${e.at}`
|
|
229
|
+
}`;
|
|
230
|
+
|
|
231
|
+
/** The verdict over those facts, in repair order: `null` is "the carrier can run". */
|
|
232
|
+
export function piDurableRuntimeVerdict(
|
|
233
|
+
facts: PiDurableRuntimeFacts,
|
|
234
|
+
): { reason: PiDurableRuntimeRejectReason; detail: string } | null {
|
|
235
|
+
if (facts.missing.length > 0)
|
|
236
|
+
return { reason: "pi-durable-package-incomplete", detail: `missing ${facts.missing.join(", ")}` };
|
|
237
|
+
if (facts.drifted.length > 0)
|
|
238
|
+
return { reason: "pi-durable-carrier-drift", detail: `changed ${facts.drifted.join(", ")}` };
|
|
239
|
+
const absent = facts.edges.find((e) => e.got === null);
|
|
240
|
+
if (absent !== undefined) return { reason: "pi-durable-sdk-absent", detail: edgeText(absent) };
|
|
241
|
+
const mismatch = facts.edges.find((e) => e.got !== e.want || e.name !== e.to);
|
|
242
|
+
if (mismatch !== undefined) return { reason: "pi-durable-sdk-mismatch", detail: edgeText(mismatch) };
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export type PiDurableRuntimeCheck =
|
|
247
|
+
| { ok: true; resolver: string; edges: readonly PiDurableSdkEdge[] }
|
|
248
|
+
| { ok: false; reason: PiDurableRuntimeRejectReason; detail: string };
|
|
249
|
+
|
|
250
|
+
/** Locate and verify. The launcher, setup, the fresh preflight and the durable gates all ask this. */
|
|
251
|
+
export function checkPiDurableRuntime(layout: PiDurablePackageLayout | null): PiDurableRuntimeCheck {
|
|
252
|
+
if (layout === null) {
|
|
253
|
+
return { ok: false, reason: "pi-durable-package-incomplete", detail: "no pi-durable pin next to this code" };
|
|
254
|
+
}
|
|
255
|
+
const facts = inspectPiDurableRuntime(layout);
|
|
256
|
+
const verdict = piDurableRuntimeVerdict(facts);
|
|
257
|
+
if (verdict !== null) return { ok: false, ...verdict };
|
|
258
|
+
return { ok: true, resolver: layout.resolver, edges: facts.edges };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export const PI_DURABLE_RUNTIME_HINT: Record<PiDurableRuntimeRejectReason, string> = {
|
|
262
|
+
"pi-durable-package-incomplete":
|
|
263
|
+
"this Entwurf package is missing part of its pi-durable carrier — reinstall Entwurf; nothing outside the package supplies it",
|
|
264
|
+
"pi-durable-carrier-drift":
|
|
265
|
+
"a pi-durable carrier file is not the bytes pi/pi-durable/overlay/upstream-pin.json records — reinstall Entwurf; the carrier is never edited in place",
|
|
266
|
+
"pi-durable-sdk-absent":
|
|
267
|
+
"a declared pi SDK package does not resolve from the carrier — reinstall Entwurf so its dependencies are installed",
|
|
268
|
+
"pi-durable-sdk-mismatch":
|
|
269
|
+
"a pi SDK package the carrier binds is not the declared version (another install's copy, an override or a partial upgrade) — reinstall Entwurf into a tree whose @earendil-works packages are the pinned version",
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/** Exit codes of the CLI below, read by `run.sh`: 0 verified, 3 refused. There is no "absent"
|
|
273
|
+
* answer any more: the carrier ships in the package, so a missing piece is an incomplete install. */
|
|
274
|
+
export const PI_DURABLE_RUNTIME_EXIT = { verified: 0, refused: 3 } as const;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* `resolve`: print the verified carrier's resolver path (stdout) and exit 0, or print
|
|
278
|
+
* `<reason>: <detail> — <hint>` (stderr) and exit 3. The launcher execs node under that resolver;
|
|
279
|
+
* setup maps the two codes to PASS / FAIL.
|
|
280
|
+
*/
|
|
281
|
+
export function runPiDurableRuntimeCli(
|
|
282
|
+
argv: readonly string[],
|
|
283
|
+
layout: PiDurablePackageLayout | null,
|
|
284
|
+
out: { stdout: (line: string) => void; stderr: (line: string) => void },
|
|
285
|
+
): number {
|
|
286
|
+
if (argv.length !== 1 || argv[0] !== "resolve") {
|
|
287
|
+
out.stderr("usage: pi-durable-runtime resolve");
|
|
288
|
+
return 2;
|
|
289
|
+
}
|
|
290
|
+
const checked = checkPiDurableRuntime(layout);
|
|
291
|
+
if (checked.ok) {
|
|
292
|
+
out.stdout(checked.resolver);
|
|
293
|
+
return PI_DURABLE_RUNTIME_EXIT.verified;
|
|
294
|
+
}
|
|
295
|
+
out.stderr(`${checked.reason}: ${checked.detail} — ${PI_DURABLE_RUNTIME_HINT[checked.reason]}`);
|
|
296
|
+
return PI_DURABLE_RUNTIME_EXIT.refused;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// RUN AS THE CLI ONLY WHEN THIS FILE IS THE PROCESS ENTRY. Decided by an argv comparison, as in
|
|
300
|
+
// meta-bridge-hook-codex.ts, and not by the `main` flag on import.meta: that flag landed in Node 24.2.0
|
|
301
|
+
// and the package floor is major 24, so on 24.0/24.1 it is undefined — `resolve` would exit 0 having
|
|
302
|
+
// printed nothing, which setup reads as PASS and the launcher as a resolver.
|
|
303
|
+
const invokedDirectly = (() => {
|
|
304
|
+
const entry = process.argv[1];
|
|
305
|
+
if (typeof entry !== "string" || entry.length === 0) return false;
|
|
306
|
+
try {
|
|
307
|
+
return fs.realpathSync(entry) === fs.realpathSync(import.meta.filename);
|
|
308
|
+
} catch {
|
|
309
|
+
// Bounded environment probe: an entry that cannot be resolved is not this file.
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
})();
|
|
313
|
+
if (invokedDirectly) {
|
|
314
|
+
process.exitCode = runPiDurableRuntimeCli(process.argv.slice(2), piDurablePackageLayout(), {
|
|
315
|
+
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
316
|
+
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
317
|
+
});
|
|
318
|
+
}
|