@timurproko/a1 0.1.8-dev.218 → 0.1.8-dev.224
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/bin/cli.js +6 -0
- package/bin/guardian.js +3 -0
- package/bin/release-cleanup.js +8 -0
- package/bin/ui.js +4 -0
- package/bin/warmup.js +18 -0
- package/dist/features/owned-ui/run.js +2 -0
- package/dist/foundation/launch-guardian/main.js +2 -0
- package/dist/foundation/release/bootstrap.d.ts +3 -1
- package/dist/foundation/release/bootstrap.js +9 -0
- package/dist/foundation/release/cohort-state.d.ts +61 -1
- package/dist/foundation/release/cohort-state.js +192 -11
- package/dist/foundation/release/dependency-layer.d.ts +78 -0
- package/dist/foundation/release/dependency-layer.js +487 -0
- package/dist/foundation/release/endpoints.d.ts +1 -1
- package/dist/foundation/release/endpoints.js +8 -8
- package/dist/foundation/release/index.d.ts +2 -0
- package/dist/foundation/release/index.js +2 -0
- package/dist/foundation/release/release-gc.d.ts +59 -7
- package/dist/foundation/release/release-gc.js +533 -19
- package/dist/foundation/release/release-store.d.ts +3 -1
- package/dist/foundation/release/release-store.js +60 -10
- package/dist/foundation/release/release.d.ts +4 -1
- package/dist/foundation/release/release.js +9 -2
- package/dist/foundation/release/update.d.ts +10 -2
- package/dist/foundation/release/update.js +27 -4
- package/dist/foundation/release/warmup.d.ts +3 -0
- package/dist/foundation/release/warmup.js +35 -0
- package/dist/foundation/startup/index.d.ts +1 -0
- package/dist/foundation/startup/index.js +1 -0
- package/dist/foundation/startup/startup-runtime.d.ts +36 -0
- package/dist/foundation/startup/startup-runtime.js +140 -0
- package/dist/integrations/pi/engine/runtime-integration.js +5 -0
- package/dist/native/darwin-arm64/manifest.json +3 -3
- package/dist/native/darwin-arm64/process-guardian +0 -0
- package/dist/native/linux-x64/manifest.json +1 -1
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/dist/product-identity.d.ts +2 -2
- package/dist/product-identity.js +4 -1
- package/dist/product-identity.json +8 -1
- package/dist/runtime-payload-inventory.json +122328 -0
- package/package.json +2 -2
package/bin/cli.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
const commandStartedAtMs = performance.timeOrigin + performance.now();
|
|
3
4
|
const packageRoot = new URL("..", import.meta.url);
|
|
5
|
+
const startup = await import("../dist/foundation/startup/index.js");
|
|
6
|
+
const startupProfile = process.argv[2] === "pi" ? "pi" : "a1";
|
|
7
|
+
startup.initializeStartupTrace(process.env, startupProfile, commandStartedAtMs);
|
|
8
|
+
startup.enableEnvironmentCompileCache(process.env);
|
|
9
|
+
await startup.markStartupPhase(process.env, "command-invoked");
|
|
4
10
|
const { fileURLToPath } = await import("node:url");
|
|
5
11
|
const { readFile } = await import("node:fs/promises");
|
|
6
12
|
const { cliCapabilities, dispatchCli } = await import("../dist/cli/index.js");
|
package/bin/guardian.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
const startup = await import("../dist/foundation/startup/index.js");
|
|
4
|
+
startup.enableEnvironmentCompileCache(process.env);
|
|
5
|
+
await startup.markStartupPhase(process.env, "guardian-start");
|
|
3
6
|
const { fileURLToPath } = await import("node:url");
|
|
4
7
|
const { resolve } = await import("node:path");
|
|
5
8
|
const { runLaunchGuardian } = await import("../dist/foundation/launch-guardian/index.js");
|
package/bin/ui.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
const startup = await import("../dist/foundation/startup/index.js");
|
|
4
|
+
startup.enableEnvironmentCompileCache(process.env);
|
|
5
|
+
await startup.markStartupPhase(process.env, "ui-entry");
|
|
3
6
|
const { assertSinglePiTuiModuleAtLaunch } = await import("./module-identity.js");
|
|
4
7
|
const { fileURLToPath } = await import("node:url");
|
|
5
8
|
|
|
@@ -27,6 +30,7 @@ runSelectedInteractiveRuntime(process.env.A1_LAUNCH_PROFILE ?? "a1", {
|
|
|
27
30
|
import("../dist/features/owned-ui/index.js"),
|
|
28
31
|
import("../dist/composition/index.js"),
|
|
29
32
|
]);
|
|
33
|
+
await startup.markStartupPhase(process.env, "ui-modules-loaded");
|
|
30
34
|
const { application, settings } = await composeOwnedUi({
|
|
31
35
|
cwd: process.cwd(),
|
|
32
36
|
profileId,
|
package/bin/warmup.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const startup = await import("../dist/foundation/startup/index.js");
|
|
4
|
+
startup.assertImmutableWarmupEnvironment(process.env);
|
|
5
|
+
startup.enableEnvironmentCompileCache(process.env);
|
|
6
|
+
|
|
7
|
+
// Security: this entry imports but never composes, so it creates no terminal, profile/session
|
|
8
|
+
// path, trust callback, executable resource loader, extension runner, or network client.
|
|
9
|
+
const [composition, engine, components, runtime] = await Promise.all([
|
|
10
|
+
import("../dist/composition/index.js"),
|
|
11
|
+
import("../dist/integrations/pi/engine/index.js"),
|
|
12
|
+
import("../dist/integrations/pi/components/index.js"),
|
|
13
|
+
import("../dist/integrations/pi/tui-runtime/index.js"),
|
|
14
|
+
]);
|
|
15
|
+
if (typeof composition.composeOwnedUi !== "function" || typeof engine.createPiEngineAdapter !== "function"
|
|
16
|
+
|| typeof components.applyConfiguredPiTheme !== "function" || typeof runtime.createPiTerminalBridge !== "function") {
|
|
17
|
+
throw new Error("immutable startup graph is incomplete");
|
|
18
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { markStartupPhase } from "../../foundation/startup/index.js";
|
|
1
2
|
export async function runOwnedUi(options) {
|
|
2
3
|
const { application, settings } = options;
|
|
3
4
|
try {
|
|
@@ -5,6 +6,7 @@ export async function runOwnedUi(options) {
|
|
|
5
6
|
await settings.load();
|
|
6
7
|
application.start();
|
|
7
8
|
await application.flush();
|
|
9
|
+
await markStartupPhase(process.env, "first-input-ready-render");
|
|
8
10
|
if (!application.disposed)
|
|
9
11
|
await application.waitUntilStopped();
|
|
10
12
|
return 0;
|
|
@@ -5,6 +5,7 @@ import { assertLaunchProfileId } from "../lifecycle/index.js";
|
|
|
5
5
|
import { closeVerifiedContainment, LinuxNativeProcessInspector, NativeGuardianContainment, resolveProcessGuardianPath, verifyProcessGuardianArtifact, WindowsNativeProcessInspector, } from "../process-containment/index.js";
|
|
6
6
|
import { SupervisorClient } from "../protocol/index.js";
|
|
7
7
|
import { resolveCohortEndpoint, resolveProductPaths } from "../supervision/index.js";
|
|
8
|
+
import { markStartupPhase } from "../startup/index.js";
|
|
8
9
|
export async function runLaunchGuardian(options) {
|
|
9
10
|
assertLaunchProfileId(options.profileId);
|
|
10
11
|
const environment = { ...(options.environment ?? process.env) };
|
|
@@ -24,6 +25,7 @@ export async function runLaunchGuardian(options) {
|
|
|
24
25
|
? resolveCohortEndpoint(paths, releaseId, environment).endpoint
|
|
25
26
|
: paths.endpoint;
|
|
26
27
|
await client.connect(endpoint);
|
|
28
|
+
await markStartupPhase(environment, "guardian-connected");
|
|
27
29
|
const containment = options.containment ?? new NativeGuardianContainment(instanceId, helperPath, resolve(paths.runtimeDir, "launch-instances", `${instanceId}.ready.json`));
|
|
28
30
|
const stop = stopRequests(client, instanceId);
|
|
29
31
|
let rootIdentity = null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type OwnershipProbe } from "./cohort-selection.js";
|
|
2
2
|
import { type SupervisorEndpointMetadata } from "./cohort-state.js";
|
|
3
|
-
import { type LaunchProfileId } from "../lifecycle/index.js";
|
|
3
|
+
import { resolveProductPaths, type LaunchProfileId } from "../lifecycle/index.js";
|
|
4
4
|
import { cleanupProvenIdleOwner } from "./process-cleanup.js";
|
|
5
5
|
import { type MaterializedRelease, type VerifyMaterializedReleaseOptions } from "./release-store.js";
|
|
6
6
|
export interface BootstrapOptions {
|
|
@@ -21,6 +21,8 @@ export interface BootstrapOptions {
|
|
|
21
21
|
* release is fit.
|
|
22
22
|
*/
|
|
23
23
|
readonly releaseIsLaunchable?: (releaseRoot: string) => boolean;
|
|
24
|
+
/** Test seam for bounded maintenance scheduled after stale endpoint sweeping. */
|
|
25
|
+
readonly scheduleMaintenance?: (dataDir: string, paths: ReturnType<typeof resolveProductPaths>) => Promise<void>;
|
|
24
26
|
}
|
|
25
27
|
export declare function runBootstrap(options: BootstrapOptions): Promise<number>;
|
|
26
28
|
export declare function certifyMaterializedRelease(release: MaterializedRelease, dataDir: string, verification?: VerifyMaterializedReleaseOptions): Promise<string>;
|
|
@@ -10,9 +10,12 @@ import { encodeFrame, LineFrameDecoder } from "../protocol/index.js";
|
|
|
10
10
|
import { cleanupProvenIdleOwner, processIsAlive } from "./process-cleanup.js";
|
|
11
11
|
import { sweepDeadEndpoints } from "./endpoints.js";
|
|
12
12
|
import { consumeMaterializationProof, materializeRelease, readCertifiedReleaseManifest, readMaterializedRelease, resolveReleaseEntryPoint, verifyMaterializedRelease } from "./release-store.js";
|
|
13
|
+
import { scheduleReleaseCleanup } from "./release-gc.js";
|
|
13
14
|
import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
|
|
15
|
+
import { markStartupPhase } from "../startup/index.js";
|
|
14
16
|
export async function runBootstrap(options) {
|
|
15
17
|
const environment = { ...(options.environment ?? process.env) };
|
|
18
|
+
await markStartupPhase(environment, "bootstrap-start");
|
|
16
19
|
const launchProfileId = options.launchIntent?.profileId ?? "a1";
|
|
17
20
|
assertLaunchProfileId(launchProfileId);
|
|
18
21
|
environment[PRODUCT_IDENTITY.environment.launchProfile] = launchProfileId;
|
|
@@ -25,6 +28,10 @@ export async function runBootstrap(options) {
|
|
|
25
28
|
// can now be several of them. Clearing them first keeps the decision below about what is
|
|
26
29
|
// actually running.
|
|
27
30
|
await sweepDeadEndpoints(paths).catch(() => []);
|
|
31
|
+
// Performance: reconciliation is serialized by cohort state, but launch never awaits a physical
|
|
32
|
+
// backlog. The scheduler commits a plan and starts a detached bounded worker.
|
|
33
|
+
const scheduleMaintenance = options.scheduleMaintenance ?? scheduleReleaseCleanup;
|
|
34
|
+
void scheduleMaintenance(paths.dataDir, paths).catch(() => { });
|
|
28
35
|
// Protocol: each cohort keeps its own endpoint, so a launch looks for the endpoint of the release it
|
|
29
36
|
// is about to run rather than for the one endpoint the runtime directory used to have.
|
|
30
37
|
const activeReleaseId = state.references.active;
|
|
@@ -196,6 +203,7 @@ export async function startSupervisor(release, environment) {
|
|
|
196
203
|
child.unref();
|
|
197
204
|
}
|
|
198
205
|
async function launchUi(release, environment) {
|
|
206
|
+
await markStartupPhase(environment, "bootstrap-selected");
|
|
199
207
|
const entry = await resolveReleaseEntryPoint(release, "bin/guardian.js");
|
|
200
208
|
return await new Promise((resolvePromise, rejectPromise) => {
|
|
201
209
|
const child = spawn(process.execPath, [entry], {
|
|
@@ -211,6 +219,7 @@ export function releaseEnvironment(environment, release) {
|
|
|
211
219
|
return {
|
|
212
220
|
...environment,
|
|
213
221
|
[PRODUCT_IDENTITY.environment.releaseId]: release.releaseId,
|
|
222
|
+
[PRODUCT_IDENTITY.environment.releaseLayers]: (release.dependencyLayers ?? []).map(layer => layer.layerId).join(","),
|
|
214
223
|
[PRODUCT_IDENTITY.environment.releaseRoot]: release.releaseRoot,
|
|
215
224
|
[PRODUCT_IDENTITY.environment.releaseDigest]: release.contentDigest,
|
|
216
225
|
};
|
|
@@ -18,11 +18,41 @@ export interface ReleaseReferences {
|
|
|
18
18
|
readonly rollback: string | null;
|
|
19
19
|
readonly retention: readonly string[];
|
|
20
20
|
}
|
|
21
|
+
export type ExternalReleaseHoldAuthority = "agent" | "migration";
|
|
22
|
+
/** A current hold supplied by an authority that must participate in every reconciliation. */
|
|
23
|
+
export interface ExternalReleaseHold {
|
|
24
|
+
readonly authority: ExternalReleaseHoldAuthority;
|
|
25
|
+
readonly releaseId: string;
|
|
26
|
+
}
|
|
27
|
+
export interface ActiveReleaseTransactionReference {
|
|
28
|
+
readonly status: "active" | "completed" | "rolled-back" | "failed";
|
|
29
|
+
readonly priorActiveReleaseId: string | null;
|
|
30
|
+
}
|
|
31
|
+
export type ReleaseCleanupStage = "detached" | "trash";
|
|
32
|
+
export interface ReleaseCleanupDisposition {
|
|
33
|
+
readonly release: ReleaseRecord;
|
|
34
|
+
readonly stage: ReleaseCleanupStage;
|
|
35
|
+
readonly trashPath: string | null;
|
|
36
|
+
readonly attempts: number;
|
|
37
|
+
readonly lastAttemptAt: string | null;
|
|
38
|
+
readonly lastError: string | null;
|
|
39
|
+
}
|
|
40
|
+
export interface ReleaseCleanupDiagnostic {
|
|
41
|
+
readonly releaseId: string;
|
|
42
|
+
readonly stage: string;
|
|
43
|
+
readonly attemptedAt: string;
|
|
44
|
+
readonly error: string;
|
|
45
|
+
}
|
|
46
|
+
export interface ReleaseCleanupState {
|
|
47
|
+
readonly pending: Readonly<Record<string, ReleaseCleanupDisposition>>;
|
|
48
|
+
readonly diagnostics: readonly ReleaseCleanupDiagnostic[];
|
|
49
|
+
}
|
|
21
50
|
export interface CohortState {
|
|
22
51
|
readonly schema: typeof RELEASE_COHORT_SCHEMA;
|
|
23
52
|
readonly revision: number;
|
|
24
53
|
readonly releases: Readonly<Record<string, ReleaseRecord>>;
|
|
25
54
|
readonly references: ReleaseReferences;
|
|
55
|
+
readonly cleanup: ReleaseCleanupState;
|
|
26
56
|
readonly activation: {
|
|
27
57
|
readonly state: "idle" | "pending" | "draining" | "blocked" | "failed";
|
|
28
58
|
readonly reason: string | null;
|
|
@@ -54,18 +84,48 @@ export interface SupervisorEndpointMetadata {
|
|
|
54
84
|
readonly optionalFeatures: readonly string[];
|
|
55
85
|
readonly contractDigest: string;
|
|
56
86
|
}
|
|
87
|
+
export interface ProtectedReleaseInputs {
|
|
88
|
+
readonly liveReleaseIds?: readonly string[];
|
|
89
|
+
readonly externalHolds?: readonly ExternalReleaseHold[];
|
|
90
|
+
readonly transaction?: ActiveReleaseTransactionReference | null;
|
|
91
|
+
}
|
|
92
|
+
export interface ProtectedReleasePlan {
|
|
93
|
+
readonly protectedReleaseIds: readonly string[];
|
|
94
|
+
readonly retainedReleaseIds: readonly string[];
|
|
95
|
+
readonly collectibleReleaseIds: readonly string[];
|
|
96
|
+
}
|
|
97
|
+
export interface OrphanReleaseDisposition {
|
|
98
|
+
readonly release: ReleaseRecord;
|
|
99
|
+
readonly stage: ReleaseCleanupStage;
|
|
100
|
+
readonly trashPath?: string | null;
|
|
101
|
+
}
|
|
102
|
+
export interface ReleaseRetentionReconciliation {
|
|
103
|
+
readonly state: CohortState;
|
|
104
|
+
readonly plan: ProtectedReleasePlan;
|
|
105
|
+
readonly detachedReleaseIds: readonly string[];
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Derive protection exclusively from current selectors and ownership authorities.
|
|
109
|
+
* The historical retention snapshot is deliberately not an input to this plan.
|
|
110
|
+
*/
|
|
111
|
+
export declare function planProtectedReleases(state: CohortState, inputs?: ProtectedReleaseInputs): ProtectedReleasePlan;
|
|
57
112
|
/** Serializes validated cohort-state revisions under a lock and commits each update atomically. */
|
|
58
113
|
export declare class CohortStateStore {
|
|
59
114
|
readonly path: string;
|
|
60
115
|
constructor(dataDir: string);
|
|
61
116
|
read(): Promise<CohortState>;
|
|
62
|
-
update(operation: (current: CohortState) => CohortState): Promise<CohortState>;
|
|
117
|
+
update(operation: (current: CohortState) => CohortState | Promise<CohortState>): Promise<CohortState>;
|
|
63
118
|
recordCandidate(release: MaterializedRelease): Promise<CohortState>;
|
|
64
119
|
approve(releaseId: string, diagnosticsPath: string): Promise<CohortState>;
|
|
65
120
|
activate(releaseId: string): Promise<CohortState>;
|
|
66
121
|
blockPending(reason: string, blockerGenerationIds: readonly string[]): Promise<CohortState>;
|
|
67
122
|
rollback(ownershipReleased: boolean): Promise<CohortState>;
|
|
68
123
|
setRetention(releaseIds: readonly string[]): Promise<CohortState>;
|
|
124
|
+
/** Atomically replace legacy retention history and detach every currently collectible record. */
|
|
125
|
+
reconcileRetention(resolveInputs: (current: CohortState) => ProtectedReleaseInputs | Promise<ProtectedReleaseInputs>, orphans?: readonly OrphanReleaseDisposition[]): Promise<ReleaseRetentionReconciliation>;
|
|
126
|
+
markCleanupTrash(releaseId: string, trashPath: string): Promise<CohortState>;
|
|
127
|
+
recordCleanupFailure(releaseId: string, stage: string, error: unknown): Promise<CohortState>;
|
|
128
|
+
completeCleanup(releaseId: string): Promise<CohortState>;
|
|
69
129
|
removeUnreferencedRelease(releaseId: string, externalReferences: readonly string[]): Promise<CohortState>;
|
|
70
130
|
}
|
|
71
131
|
export declare function emptyState(): CohortState;
|
|
@@ -1,8 +1,42 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
2
|
+
import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { PRODUCT_IDENTITY } from "../../product-identity.js";
|
|
5
5
|
export const RELEASE_COHORT_SCHEMA = PRODUCT_IDENTITY.protocol.releaseCohortSchema;
|
|
6
|
+
const RELEASE_ID_PATTERN = /^[0-9A-Za-z.+_-]+-[a-f0-9]{20}$/;
|
|
7
|
+
/**
|
|
8
|
+
* Derive protection exclusively from current selectors and ownership authorities.
|
|
9
|
+
* The historical retention snapshot is deliberately not an input to this plan.
|
|
10
|
+
*/
|
|
11
|
+
export function planProtectedReleases(state, inputs = {}) {
|
|
12
|
+
const holds = inputs.externalHolds ?? [];
|
|
13
|
+
for (const hold of holds) {
|
|
14
|
+
if (hold.authority !== "agent" && hold.authority !== "migration")
|
|
15
|
+
throw new Error(`unknown release hold authority: ${String(hold.authority)}`);
|
|
16
|
+
if (!state.releases[hold.releaseId] && !state.cleanup.pending[hold.releaseId]) {
|
|
17
|
+
throw new Error(`external ${hold.authority} hold names unknown release ${hold.releaseId}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const protectedIds = new Set();
|
|
21
|
+
for (const id of [state.references.active, state.references.pending, state.references.approved, state.references.rollback]) {
|
|
22
|
+
if (id !== null)
|
|
23
|
+
protectedIds.add(id);
|
|
24
|
+
}
|
|
25
|
+
for (const id of inputs.liveReleaseIds ?? [])
|
|
26
|
+
protectedIds.add(id);
|
|
27
|
+
for (const hold of holds)
|
|
28
|
+
protectedIds.add(hold.releaseId);
|
|
29
|
+
if (inputs.transaction?.status === "active" && inputs.transaction.priorActiveReleaseId !== null) {
|
|
30
|
+
protectedIds.add(inputs.transaction.priorActiveReleaseId);
|
|
31
|
+
}
|
|
32
|
+
const protectedReleaseIds = [...protectedIds].sort();
|
|
33
|
+
const known = Object.keys(state.releases).sort();
|
|
34
|
+
return {
|
|
35
|
+
protectedReleaseIds,
|
|
36
|
+
retainedReleaseIds: known.filter(id => protectedIds.has(id)),
|
|
37
|
+
collectibleReleaseIds: known.filter(id => !protectedIds.has(id)),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
6
40
|
/** Serializes validated cohort-state revisions under a lock and commits each update atomically. */
|
|
7
41
|
export class CohortStateStore {
|
|
8
42
|
path;
|
|
@@ -11,7 +45,7 @@ export class CohortStateStore {
|
|
|
11
45
|
}
|
|
12
46
|
async read() {
|
|
13
47
|
try {
|
|
14
|
-
const state = JSON.parse(await readFile(this.path, "utf8"));
|
|
48
|
+
const state = normalizeState(JSON.parse(await readFile(this.path, "utf8")));
|
|
15
49
|
validateState(state);
|
|
16
50
|
return state;
|
|
17
51
|
}
|
|
@@ -27,7 +61,7 @@ export class CohortStateStore {
|
|
|
27
61
|
const lock = await acquireLock(lockPath);
|
|
28
62
|
try {
|
|
29
63
|
const current = await this.read();
|
|
30
|
-
const next = operation(current);
|
|
64
|
+
const next = await operation(current);
|
|
31
65
|
validateTransition(current, next);
|
|
32
66
|
const committed = { ...next, revision: current.revision + 1 };
|
|
33
67
|
const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
|
|
@@ -66,7 +100,6 @@ export class CohortStateStore {
|
|
|
66
100
|
references: {
|
|
67
101
|
...current.references,
|
|
68
102
|
pending: current.references.active === release.releaseId ? current.references.pending : release.releaseId,
|
|
69
|
-
retention: unique([...current.references.retention, release.releaseId]),
|
|
70
103
|
},
|
|
71
104
|
activation: activation("pending", "candidate awaits certification and safe activation"),
|
|
72
105
|
};
|
|
@@ -91,11 +124,11 @@ export class CohortStateStore {
|
|
|
91
124
|
return {
|
|
92
125
|
...current,
|
|
93
126
|
references: {
|
|
127
|
+
...current.references,
|
|
94
128
|
active: releaseId,
|
|
95
129
|
pending: null,
|
|
96
130
|
approved: releaseId,
|
|
97
131
|
rollback: prior && prior !== releaseId ? prior : current.references.rollback,
|
|
98
|
-
retention: unique([...current.references.retention, releaseId, ...(prior ? [prior] : [])]),
|
|
99
132
|
},
|
|
100
133
|
activation: activation("idle", null),
|
|
101
134
|
};
|
|
@@ -122,7 +155,6 @@ export class CohortStateStore {
|
|
|
122
155
|
pending: null,
|
|
123
156
|
approved: rollbackId,
|
|
124
157
|
rollback: current.references.active,
|
|
125
|
-
retention: unique([...current.references.retention, rollbackId, ...(current.references.active ? [current.references.active] : [])]),
|
|
126
158
|
},
|
|
127
159
|
activation: activation("idle", null),
|
|
128
160
|
};
|
|
@@ -135,9 +167,74 @@ export class CohortStateStore {
|
|
|
135
167
|
return { ...current, references: { ...current.references, retention: unique(releaseIds) } };
|
|
136
168
|
});
|
|
137
169
|
}
|
|
170
|
+
/** Atomically replace legacy retention history and detach every currently collectible record. */
|
|
171
|
+
async reconcileRetention(resolveInputs, orphans = []) {
|
|
172
|
+
let plan = null;
|
|
173
|
+
let detachedReleaseIds = [];
|
|
174
|
+
const state = await this.update(async (current) => {
|
|
175
|
+
const inputs = await resolveInputs(current);
|
|
176
|
+
plan = planProtectedReleases(current, inputs);
|
|
177
|
+
const nextReleases = { ...current.releases };
|
|
178
|
+
const pending = { ...current.cleanup.pending };
|
|
179
|
+
for (const releaseId of plan.collectibleReleaseIds) {
|
|
180
|
+
const release = nextReleases[releaseId];
|
|
181
|
+
pending[releaseId] ??= cleanupDisposition(release, "detached", null);
|
|
182
|
+
delete nextReleases[releaseId];
|
|
183
|
+
}
|
|
184
|
+
for (const orphan of orphans) {
|
|
185
|
+
const releaseId = orphan.release.releaseId;
|
|
186
|
+
if (nextReleases[releaseId] || pending[releaseId] || plan.protectedReleaseIds.includes(releaseId))
|
|
187
|
+
continue;
|
|
188
|
+
pending[releaseId] = cleanupDisposition(orphan.release, orphan.stage, orphan.trashPath ?? null);
|
|
189
|
+
}
|
|
190
|
+
detachedReleaseIds = plan.collectibleReleaseIds;
|
|
191
|
+
return {
|
|
192
|
+
...current,
|
|
193
|
+
releases: nextReleases,
|
|
194
|
+
references: { ...current.references, retention: plan.retainedReleaseIds },
|
|
195
|
+
cleanup: { ...current.cleanup, pending },
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
if (plan === null)
|
|
199
|
+
throw new Error("release retention reconciliation did not produce a plan");
|
|
200
|
+
return { state, plan, detachedReleaseIds };
|
|
201
|
+
}
|
|
202
|
+
async markCleanupTrash(releaseId, trashPath) {
|
|
203
|
+
return await this.update(current => {
|
|
204
|
+
const disposition = current.cleanup.pending[releaseId];
|
|
205
|
+
if (!disposition)
|
|
206
|
+
throw new Error(`release ${releaseId} has no cleanup disposition`);
|
|
207
|
+
return replaceCleanup(current, releaseId, { ...disposition, stage: "trash", trashPath, lastError: null });
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
async recordCleanupFailure(releaseId, stage, error) {
|
|
211
|
+
return await this.update(current => {
|
|
212
|
+
const attemptedAt = new Date().toISOString();
|
|
213
|
+
const message = boundedError(error);
|
|
214
|
+
const disposition = current.cleanup.pending[releaseId];
|
|
215
|
+
const pending = disposition ? {
|
|
216
|
+
...current.cleanup.pending,
|
|
217
|
+
[releaseId]: { ...disposition, attempts: disposition.attempts + 1, lastAttemptAt: attemptedAt, lastError: message },
|
|
218
|
+
} : current.cleanup.pending;
|
|
219
|
+
return {
|
|
220
|
+
...current,
|
|
221
|
+
cleanup: {
|
|
222
|
+
pending,
|
|
223
|
+
diagnostics: [...current.cleanup.diagnostics, { releaseId, stage, attemptedAt, error: message }].slice(-64),
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
async completeCleanup(releaseId) {
|
|
229
|
+
return await this.update(current => {
|
|
230
|
+
const pending = { ...current.cleanup.pending };
|
|
231
|
+
delete pending[releaseId];
|
|
232
|
+
return { ...current, cleanup: { ...current.cleanup, pending } };
|
|
233
|
+
});
|
|
234
|
+
}
|
|
138
235
|
async removeUnreferencedRelease(releaseId, externalReferences) {
|
|
139
236
|
return await this.update(current => {
|
|
140
|
-
requiredRelease(current, releaseId);
|
|
237
|
+
const release = requiredRelease(current, releaseId);
|
|
141
238
|
const protectedIds = new Set([
|
|
142
239
|
current.references.active,
|
|
143
240
|
current.references.pending,
|
|
@@ -150,7 +247,14 @@ export class CohortStateStore {
|
|
|
150
247
|
throw new Error(`release ${releaseId} is still referenced and cannot be collected`);
|
|
151
248
|
const releases = { ...current.releases };
|
|
152
249
|
delete releases[releaseId];
|
|
153
|
-
return {
|
|
250
|
+
return {
|
|
251
|
+
...current,
|
|
252
|
+
releases,
|
|
253
|
+
cleanup: {
|
|
254
|
+
...current.cleanup,
|
|
255
|
+
pending: { ...current.cleanup.pending, [releaseId]: cleanupDisposition(release, "detached", null) },
|
|
256
|
+
},
|
|
257
|
+
};
|
|
154
258
|
});
|
|
155
259
|
}
|
|
156
260
|
}
|
|
@@ -160,9 +264,16 @@ export function emptyState() {
|
|
|
160
264
|
revision: 0,
|
|
161
265
|
releases: {},
|
|
162
266
|
references: { active: null, pending: null, approved: null, rollback: null, retention: [] },
|
|
267
|
+
cleanup: { pending: {}, diagnostics: [] },
|
|
163
268
|
activation: activation("idle", null),
|
|
164
269
|
};
|
|
165
270
|
}
|
|
271
|
+
function cleanupDisposition(release, stage, trashPath) {
|
|
272
|
+
return { release, stage, trashPath, attempts: 0, lastAttemptAt: null, lastError: null };
|
|
273
|
+
}
|
|
274
|
+
function replaceCleanup(current, releaseId, disposition) {
|
|
275
|
+
return { ...current, cleanup: { ...current.cleanup, pending: { ...current.cleanup.pending, [releaseId]: disposition } } };
|
|
276
|
+
}
|
|
166
277
|
function activation(state, reason) {
|
|
167
278
|
return { state, reason, blockerGenerationIds: [], updatedAt: new Date().toISOString() };
|
|
168
279
|
}
|
|
@@ -177,22 +288,89 @@ async function acquireLock(path) {
|
|
|
177
288
|
const deadline = Date.now() + 5_000;
|
|
178
289
|
while (true) {
|
|
179
290
|
try {
|
|
180
|
-
|
|
291
|
+
const lock = await open(path, "wx", 0o600);
|
|
292
|
+
await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
|
|
293
|
+
await lock.sync();
|
|
294
|
+
return lock;
|
|
181
295
|
}
|
|
182
296
|
catch (error) {
|
|
183
|
-
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")
|
|
297
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
|
|
298
|
+
throw error;
|
|
299
|
+
if (await reclaimAbandonedLock(path))
|
|
300
|
+
continue;
|
|
301
|
+
if (Date.now() >= deadline)
|
|
184
302
|
throw error;
|
|
185
303
|
await new Promise(resolvePromise => setTimeout(resolvePromise, 20));
|
|
186
304
|
}
|
|
187
305
|
}
|
|
188
306
|
}
|
|
307
|
+
async function reclaimAbandonedLock(path) {
|
|
308
|
+
let abandoned = false;
|
|
309
|
+
try {
|
|
310
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
311
|
+
if (typeof value.pid === "number") {
|
|
312
|
+
try {
|
|
313
|
+
process.kill(value.pid, 0);
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
abandoned = !(error instanceof Error && "code" in error && error.code === "EPERM");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
const metadata = await stat(path).catch(() => null);
|
|
322
|
+
abandoned = metadata !== null && Date.now() - metadata.mtimeMs > 10_000;
|
|
323
|
+
}
|
|
324
|
+
if (!abandoned)
|
|
325
|
+
return false;
|
|
326
|
+
const quarantine = `${path}.abandoned`;
|
|
327
|
+
const oldQuarantine = await stat(quarantine).catch(() => null);
|
|
328
|
+
if (oldQuarantine && Date.now() - oldQuarantine.mtimeMs > 10_000)
|
|
329
|
+
await rm(quarantine, { force: true });
|
|
330
|
+
try {
|
|
331
|
+
// Concurrency: the fixed destination lets exactly one waiter claim the stale lock;
|
|
332
|
+
// another waiter cannot accidentally rename a newly acquired replacement.
|
|
333
|
+
await rename(path, quarantine);
|
|
334
|
+
await rm(quarantine, { force: true });
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function normalizeState(value) {
|
|
342
|
+
if (!value || typeof value !== "object")
|
|
343
|
+
return value;
|
|
344
|
+
const state = value;
|
|
345
|
+
return {
|
|
346
|
+
...state,
|
|
347
|
+
cleanup: state.cleanup ?? { pending: {}, diagnostics: [] },
|
|
348
|
+
};
|
|
349
|
+
}
|
|
189
350
|
function validateState(state) {
|
|
190
|
-
if (state.schema !== RELEASE_COHORT_SCHEMA || !Number.isSafeInteger(state.revision) || state.revision < 0
|
|
351
|
+
if (!state || state.schema !== RELEASE_COHORT_SCHEMA || !Number.isSafeInteger(state.revision) || state.revision < 0
|
|
352
|
+
|| !state.releases || typeof state.releases !== "object" || !state.references || !Array.isArray(state.references.retention)
|
|
353
|
+
|| !state.cleanup || typeof state.cleanup.pending !== "object" || !Array.isArray(state.cleanup.diagnostics)) {
|
|
191
354
|
throw new Error("invalid release cohort state");
|
|
355
|
+
}
|
|
356
|
+
for (const [releaseId, release] of Object.entries(state.releases))
|
|
357
|
+
validateReleaseRecord(releaseId, release);
|
|
192
358
|
for (const reference of [state.references.active, state.references.pending, state.references.approved, state.references.rollback]) {
|
|
193
359
|
if (reference !== null && !state.releases[reference])
|
|
194
360
|
throw new Error(`release reference points to an unknown release: ${reference}`);
|
|
195
361
|
}
|
|
362
|
+
for (const [releaseId, disposition] of Object.entries(state.cleanup.pending)) {
|
|
363
|
+
validateReleaseRecord(releaseId, disposition.release);
|
|
364
|
+
if (!["detached", "trash"].includes(disposition.stage))
|
|
365
|
+
throw new Error(`invalid cleanup disposition for ${releaseId}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function validateReleaseRecord(releaseId, release) {
|
|
369
|
+
if (release.releaseId !== releaseId || !RELEASE_ID_PATTERN.test(releaseId) || typeof release.releaseRoot !== "string"
|
|
370
|
+
|| typeof release.packageVersion !== "string" || !/^[a-f0-9]{64}$/.test(release.contentDigest)
|
|
371
|
+
|| !["candidate", "approved", "rejected"].includes(release.approval)) {
|
|
372
|
+
throw new Error(`invalid release record for ${releaseId}`);
|
|
373
|
+
}
|
|
196
374
|
}
|
|
197
375
|
function validateTransition(current, next) {
|
|
198
376
|
validateState(next);
|
|
@@ -201,3 +379,6 @@ function validateTransition(current, next) {
|
|
|
201
379
|
if (next.revision !== current.revision)
|
|
202
380
|
throw new Error("release cohort revision is controlled by the state store");
|
|
203
381
|
}
|
|
382
|
+
function boundedError(error) {
|
|
383
|
+
return (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ").slice(0, 1_000);
|
|
384
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type ReleaseFileIdentity } from "./release.js";
|
|
2
|
+
import { PRODUCT_IDENTITY } from "../../product-identity.js";
|
|
3
|
+
export declare const DEPENDENCY_LAYER_SCHEMA: string;
|
|
4
|
+
export declare const DEPENDENCY_LAYER_MANIFEST = "dependency-layer-manifest.json";
|
|
5
|
+
export declare const RUNTIME_PAYLOAD_INVENTORY = "dist/runtime-payload-inventory.json";
|
|
6
|
+
export interface RuntimePayloadExclusion {
|
|
7
|
+
readonly path: string;
|
|
8
|
+
readonly reason: "type-declaration" | "source-map" | "unreachable-development";
|
|
9
|
+
}
|
|
10
|
+
export interface RuntimePayloadInventory {
|
|
11
|
+
readonly schema: typeof PRODUCT_IDENTITY.evidence.runtimePayloadSchema;
|
|
12
|
+
readonly includedFiles: number;
|
|
13
|
+
readonly includedBytes: number;
|
|
14
|
+
readonly excludedFiles: number;
|
|
15
|
+
readonly excludedBytes: number;
|
|
16
|
+
readonly uncertaintyRetainedFiles: number;
|
|
17
|
+
readonly exclusions: readonly RuntimePayloadExclusion[];
|
|
18
|
+
}
|
|
19
|
+
export interface DependencyLayerIdentity {
|
|
20
|
+
readonly schema: typeof DEPENDENCY_LAYER_SCHEMA;
|
|
21
|
+
readonly layerId: string;
|
|
22
|
+
readonly contentDigest: string;
|
|
23
|
+
readonly files: readonly ReleaseFileIdentity[];
|
|
24
|
+
readonly inventory: RuntimePayloadInventory;
|
|
25
|
+
}
|
|
26
|
+
export interface DependencyLayerReference {
|
|
27
|
+
readonly layerId: string;
|
|
28
|
+
readonly contentDigest: string;
|
|
29
|
+
readonly binding: "node_modules";
|
|
30
|
+
}
|
|
31
|
+
export interface MaterializedDependencyLayer extends DependencyLayerIdentity {
|
|
32
|
+
readonly layerRoot: string;
|
|
33
|
+
readonly reused: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface DependencyLayerOperationEvent {
|
|
36
|
+
readonly operation: "source-read" | "layer-write" | "layer-reuse" | "verification-read";
|
|
37
|
+
readonly path: string;
|
|
38
|
+
readonly bytes: number;
|
|
39
|
+
}
|
|
40
|
+
export interface SelectedRuntimePayload {
|
|
41
|
+
readonly paths: readonly string[];
|
|
42
|
+
readonly inventory: RuntimePayloadInventory;
|
|
43
|
+
}
|
|
44
|
+
export interface PublishedRuntimeFileClassification {
|
|
45
|
+
readonly packageName: string;
|
|
46
|
+
readonly packageVersion: string;
|
|
47
|
+
readonly packagePath: string;
|
|
48
|
+
readonly disposition: "include" | "exclude";
|
|
49
|
+
readonly reason?: RuntimePayloadExclusion["reason"];
|
|
50
|
+
}
|
|
51
|
+
export interface GeneratedRuntimePayload extends SelectedRuntimePayload {
|
|
52
|
+
readonly classifications: readonly PublishedRuntimeFileClassification[];
|
|
53
|
+
}
|
|
54
|
+
export interface PublishedRuntimePayload extends GeneratedRuntimePayload {
|
|
55
|
+
readonly schema: typeof PRODUCT_IDENTITY.evidence.runtimePayloadSchema;
|
|
56
|
+
readonly entryPoints: readonly string[];
|
|
57
|
+
readonly declaredAssets: readonly string[];
|
|
58
|
+
}
|
|
59
|
+
/** Consume topology-independent publication classifications, retaining unknown installed content. */
|
|
60
|
+
export declare function selectPublishedDependencyRuntimePayload(packageRoot: string, paths: readonly string[]): Promise<SelectedRuntimePayload>;
|
|
61
|
+
/** Generate publication evidence from literal module reachability and declared runtime assets. */
|
|
62
|
+
export declare function generateDependencyRuntimePayload(packageRoot: string, allPaths: readonly string[], productPaths: readonly string[]): Promise<GeneratedRuntimePayload>;
|
|
63
|
+
/** Conservatively omit only files Node never executes; uncertain assets remain selected. */
|
|
64
|
+
export declare function selectDependencyRuntimePayload(packageRoot: string, paths: readonly string[]): Promise<SelectedRuntimePayload>;
|
|
65
|
+
export interface MaterializeDependencyLayerOptions {
|
|
66
|
+
readonly inventory: RuntimePayloadInventory;
|
|
67
|
+
readonly cachedFiles?: ReadonlyMap<string, Buffer>;
|
|
68
|
+
readonly onOperation?: (event: DependencyLayerOperationEvent) => void;
|
|
69
|
+
readonly writeCandidateFile?: (path: string, bytes: Uint8Array, mode: number) => Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
/** Materialize or reuse an exact immutable dependency layer after one source-content pass. */
|
|
72
|
+
export declare function materializeDependencyLayer(packageRoot: string, dataDir: string, paths: readonly string[], options: MaterializeDependencyLayerOptions): Promise<MaterializedDependencyLayer | null>;
|
|
73
|
+
/** Read trusted layer certification and canonical metadata without rereading every payload byte. */
|
|
74
|
+
export declare function readCertifiedDependencyLayer(dataDir: string, layerId: string, expected?: Pick<DependencyLayerIdentity, "layerId" | "contentDigest">): Promise<Omit<MaterializedDependencyLayer, "reused">>;
|
|
75
|
+
/** Fully verify a layer when certification is absent or explicit tamper evidence is required. */
|
|
76
|
+
export declare function verifyDependencyLayer(dataDir: string, reference: DependencyLayerReference, onOperation?: (event: DependencyLayerOperationEvent) => void): Promise<Omit<MaterializedDependencyLayer, "reused">>;
|
|
77
|
+
export declare function dependencyReference(layer: MaterializedDependencyLayer): DependencyLayerReference;
|
|
78
|
+
export declare function dependencyLayerCertificationPath(dataDir: string, layerId: string): string;
|