mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
8ab7c3d5f1
* feat(standalone): add shell-neutral exact lifecycle * feat(terminal): add native exact cold-start carrier * test(terminal): exercise mutable exact channel heads * feat(standalone): model synced blobs and Shell updates * feat(terminal): exercise the complete Shell lifecycle * feat(release): add the independent exact Terminal line * test(exact): run the full local Terminal release line * feat(terminal): harden exact Sidecar lifecycle * feat(standalone): prove exact update transactions * refactor(standalone): separate shell lifecycle dimensions * refactor(e2e): decouple terminal exact drivers * refactor(terminal): isolate exact sidecar fixture host * feat(terminal): gate handoff on physical retirement * refactor(release): gate exact activation on public acceptance * test(terminal): formalize guarded lifecycle algebra * fix(terminal): guard shell installer handoff * feat(terminal): add exact bootloader handoff * feat(terminal): integrate exact sidecar lifecycle * refactor(exact): rename validation channels * test(terminal): harden exact release acceptance * fix(postinstall): build standalone dependencies first Generated-By: looper 0.11.2 (runner=fixer, agent=codex) * fix(ci): build the native Terminal scene Generated-By: looper 0.11.2 (runner=fixer, agent=codex) * test(ci): cover exact convergence handoff routing Generated-By: looper 0.11.2 (runner=fixer, agent=codex) * fix(exact): isolate terminal validation from main CI * fix(terminal): complete shell update confirmation Generated-By: looper 0.11.2 (runner=fixer, agent=codex) * fix(exact): bind acceptance to installed manifest Generated-By: looper 0.11.2 (runner=fixer, agent=codex) * fix(standalone): block occupied shell installs Generated-By: looper 0.11.2 (runner=fixer, agent=codex) --------- Co-authored-by: Looper <looper@noreply.github.com>
230 lines
11 KiB
JavaScript
230 lines
11 KiB
JavaScript
import { randomUUID } from "node:crypto";
|
|
import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
|
|
const defaultHeartbeatIntervalMs = 5_000;
|
|
const defaultLeaseDurationMs = 15_000;
|
|
const defaultTransitionLeaseDurationMs = 30_000;
|
|
let sequence = 0;
|
|
|
|
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
const canonical = (value) => `${JSON.stringify(value)}\n`;
|
|
const iso = () => new Date().toISOString();
|
|
|
|
async function replaceFile(from, to) {
|
|
try { await rename(from, to); }
|
|
catch (error) {
|
|
if (process.platform !== "win32" || (error?.code !== "EPERM" && error?.code !== "EEXIST")) throw error;
|
|
await unlink(to).catch((unlinkError) => { if (unlinkError?.code !== "ENOENT") throw unlinkError; });
|
|
await rename(from, to);
|
|
}
|
|
}
|
|
|
|
export class FileFixtureLifecyclePort {
|
|
constructor(root, options = {}) {
|
|
this.root = root;
|
|
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? defaultHeartbeatIntervalMs;
|
|
this.leaseDurationMs = options.leaseDurationMs ?? defaultLeaseDurationMs;
|
|
this.transitionLeaseDurationMs = options.transitionLeaseDurationMs ?? defaultTransitionLeaseDurationMs;
|
|
this.transitionHeartbeatIntervalMs = Math.max(20, Math.min(5_000, Math.floor(this.transitionLeaseDurationMs / 3)));
|
|
this.algebra = options.algebra;
|
|
if (this.algebra == null || !["initial", "validate", "reduce", "project", "blockers", "ready"].every((name) => typeof this.algebra[name] === "function")) {
|
|
throw new Error("fixture lifecycle requires the Standalone shared lifecycle algebra");
|
|
}
|
|
if (!Number.isInteger(this.heartbeatIntervalMs) || this.heartbeatIntervalMs < 1_000) throw new Error("invalid fixture heartbeat interval");
|
|
if (!Number.isInteger(this.leaseDurationMs) || this.leaseDurationMs <= 0) throw new Error("invalid fixture lease duration");
|
|
if (!Number.isInteger(this.transitionLeaseDurationMs) || this.transitionLeaseDurationMs < 40) throw new Error("invalid fixture transition lease duration");
|
|
}
|
|
|
|
paths(scope) {
|
|
const directory = join(this.root, "channels", scope.channel, "namespaces", scope.namespace, "fixture");
|
|
return { state: join(directory, "lifecycle.json"), lock: join(directory, "lifecycle.lock") };
|
|
}
|
|
|
|
async read(scope) {
|
|
const { state: path } = this.paths(scope);
|
|
try { return this.algebra.validate(JSON.parse(await readFile(path, "utf8")), scope); }
|
|
catch (error) { if (error?.code === "ENOENT") return this.algebra.initial({ ...scope }); throw error; }
|
|
}
|
|
|
|
async write(scope, value) {
|
|
const { state: path } = this.paths(scope);
|
|
await mkdir(dirname(path), { recursive: true });
|
|
const temporary = `${path}.${process.pid}.${Date.now()}.${sequence++}.tmp`;
|
|
await writeFile(temporary, canonical(value), { encoding: "utf8", flag: "wx" });
|
|
try { await replaceFile(temporary, path); }
|
|
catch (error) { await unlink(temporary).catch(() => undefined); throw error; }
|
|
}
|
|
|
|
async transaction(scope, operation) {
|
|
const { lock } = this.paths(scope);
|
|
await mkdir(dirname(lock), { recursive: true });
|
|
let handle;
|
|
const owner = canonical({ owner: randomUUID(), pid: process.pid, acquiredAt: iso() });
|
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
try { handle = await open(lock, "wx"); break; }
|
|
catch (error) {
|
|
if (error?.code !== "EEXIST") throw error;
|
|
const metadata = await stat(lock).catch(() => null);
|
|
if (metadata != null && Date.now() - metadata.mtimeMs > 30_000) { await unlink(lock).catch(() => undefined); continue; }
|
|
await sleep(10);
|
|
}
|
|
}
|
|
if (handle == null) throw new Error("fixture lifecycle transaction timed out");
|
|
try {
|
|
await handle.writeFile(owner);
|
|
const current = this.algebra.reduce(await this.read(scope), { type: "tick", now: iso(), leaseDurationMs: this.leaseDurationMs });
|
|
const { state, result } = await operation(current);
|
|
await this.write(scope, state);
|
|
return result ?? this.algebra.project(state, this.heartbeatIntervalMs);
|
|
} finally {
|
|
await handle.close();
|
|
const currentOwner = await readFile(lock, "utf8").catch((error) => { if (error?.code === "ENOENT") return null; throw error; });
|
|
if (currentOwner === owner) await unlink(lock).catch((error) => { if (error?.code !== "ENOENT") throw error; });
|
|
}
|
|
}
|
|
|
|
startInternal(scope, generation, attachment, capability, binding) {
|
|
return this.transaction(scope, async (state) => {
|
|
const heartbeatAt = iso();
|
|
const next = this.algebra.reduce(state, {
|
|
type: "start",
|
|
generationId: generation.id,
|
|
bindingDigest: binding?.digest ?? generation.id,
|
|
instanceId: state.instanceId ?? randomUUID(),
|
|
attachment,
|
|
heartbeatAt,
|
|
leaseExpiresAt: new Date(Date.parse(heartbeatAt) + this.leaseDurationMs).toISOString(),
|
|
...(capability == null ? {} : { capability }),
|
|
});
|
|
return { state: next };
|
|
});
|
|
}
|
|
|
|
start(scope, generation, attachment, binding) {
|
|
if (binding == null) throw new Error("fixture lifecycle requires an exact generation binding");
|
|
return this.startInternal(scope, generation, attachment, undefined, binding);
|
|
}
|
|
startWithCapability(scope, generation, attachment, capability, binding) { return this.startInternal(scope, generation, attachment, capability, binding); }
|
|
|
|
awaitReady(scope, readiness) {
|
|
return this.transaction(scope, async (state) => ({ state, result: this.algebra.ready(state, readiness) }));
|
|
}
|
|
|
|
heartbeatInternal(scope, attachment, capabilityHash) {
|
|
return this.transaction(scope, async (state) => {
|
|
const heartbeatAt = iso();
|
|
return { state: this.algebra.reduce(state, {
|
|
type: "heartbeat",
|
|
attachment,
|
|
heartbeatAt,
|
|
leaseExpiresAt: new Date(Date.parse(heartbeatAt) + this.leaseDurationMs).toISOString(),
|
|
...(capabilityHash == null ? {} : { capabilityHash }),
|
|
}) };
|
|
});
|
|
}
|
|
|
|
heartbeat(scope, attachment) { return this.heartbeatInternal(scope, attachment); }
|
|
heartbeatWithCapability(scope, attachment, capabilityHash) { return this.heartbeatInternal(scope, attachment, capabilityHash); }
|
|
|
|
releaseInternal(scope, attachmentId, capabilityHash) {
|
|
return this.transaction(scope, async (state) => ({ state: this.algebra.reduce(state, {
|
|
type: "release-attachment",
|
|
attachmentId,
|
|
...(capabilityHash == null ? {} : { capabilityHash }),
|
|
}) }));
|
|
}
|
|
|
|
release(scope, attachmentId) { return this.releaseInternal(scope, attachmentId); }
|
|
releaseWithCapability(scope, attachmentId, capabilityHash) { return this.releaseInternal(scope, attachmentId, capabilityHash); }
|
|
status(scope) { return this.transaction(scope, async (state) => ({ state })); }
|
|
async occupants(scope) { return (await this.status(scope)).occupants; }
|
|
|
|
async beginTransition(scope, kind, options = {}) {
|
|
if (kind !== "shell-install" && kind !== "content-restart") throw new Error(`unsupported fixture lifecycle transition: ${kind}`);
|
|
const token = options.attemptId ?? randomUUID();
|
|
const acquired = await this.transaction(scope, async (state) => {
|
|
if (state.transition != null) {
|
|
if (state.transition.token !== token || state.transition.kind !== kind) {
|
|
return { state, result: { state: "blocked", reason: "transition-active", occupants: this.algebra.project(state, this.heartbeatIntervalMs).occupants } };
|
|
}
|
|
return {
|
|
state,
|
|
result: {
|
|
state: "acquired",
|
|
occupants: this.algebra.project(state, this.heartbeatIntervalMs).occupants,
|
|
transition: state.transition,
|
|
},
|
|
};
|
|
}
|
|
const blockers = this.algebra.blockers(state, kind, { attachmentId: options.ownerAttachmentId, shellType: options.ownerShellType });
|
|
if (blockers.length > 0 && options.force !== true) return { state, result: { state: "blocked", reason: "occupied", occupants: blockers } };
|
|
const acquiredAt = iso();
|
|
const transition = {
|
|
token,
|
|
kind,
|
|
phase: "reserved",
|
|
fence: state.fence,
|
|
acquiredAt,
|
|
expiresAt: new Date(Date.parse(acquiredAt) + this.transitionLeaseDurationMs).toISOString(),
|
|
};
|
|
const next = this.algebra.reduce(state, { type: "reserve-transition", transition });
|
|
return { state: next, result: { state: "acquired", occupants: this.algebra.project(state, this.heartbeatIntervalMs).occupants, transition } };
|
|
});
|
|
if (acquired.state === "blocked") return acquired;
|
|
let transitionFence = acquired.transition.fence;
|
|
let expiresAt = acquired.transition.expiresAt;
|
|
let phase = acquired.transition.phase;
|
|
const renew = async () => {
|
|
expiresAt = new Date(Date.now() + this.transitionLeaseDurationMs).toISOString();
|
|
await this.transaction(scope, async (state) => ({ state: this.algebra.reduce(state, { type: "renew-transition", token, fence: transitionFence, expiresAt }) }));
|
|
};
|
|
const release = async () => {
|
|
await this.transaction(scope, async (state) => ({ state: this.algebra.reduce(state, { type: "release-transition", token, fence: transitionFence }) }));
|
|
};
|
|
const forceStop = async () => {
|
|
expiresAt = new Date(Date.now() + this.transitionLeaseDurationMs).toISOString();
|
|
const status = await this.transaction(scope, async (state) => {
|
|
const next = this.algebra.reduce(state, { type: "force-stop", token, fence: transitionFence, requestedAt: iso(), expiresAt });
|
|
return { state: next };
|
|
});
|
|
transitionFence = status.fence;
|
|
phase = "stopped-sealed";
|
|
};
|
|
const complete = async (generation, attachment, binding, capabilityHash) => {
|
|
const heartbeatAt = iso();
|
|
return this.transaction(scope, async (state) => ({ state: this.algebra.reduce(state, {
|
|
type: "complete-start",
|
|
token,
|
|
fence: transitionFence,
|
|
generationId: generation.id,
|
|
bindingDigest: binding?.digest ?? generation.id,
|
|
instanceId: randomUUID(),
|
|
attachment,
|
|
heartbeatAt,
|
|
leaseExpiresAt: new Date(Date.parse(heartbeatAt) + this.leaseDurationMs).toISOString(),
|
|
...(capabilityHash == null ? {} : { capabilityHash }),
|
|
}) }));
|
|
};
|
|
return {
|
|
state: "acquired",
|
|
transition: {
|
|
attemptId: token,
|
|
get fence() { return transitionFence; },
|
|
get expiresAt() { return expiresAt; },
|
|
get phase() { return phase; },
|
|
heartbeatIntervalMs: this.transitionHeartbeatIntervalMs,
|
|
occupants: acquired.occupants,
|
|
renew,
|
|
release,
|
|
forceStop,
|
|
completeBoundStart: (generation, attachment, binding, capabilityHash) => complete(generation, attachment, binding, capabilityHash),
|
|
},
|
|
};
|
|
}
|
|
|
|
stop(scope, fence) {
|
|
return this.transaction(scope, async (state) => ({ state: this.algebra.reduce(state, { type: "stop", fence, requestedAt: iso() }) }));
|
|
}
|
|
}
|