@timurproko/a1 0.1.8-dev.322 → 0.1.8-dev.335
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/README.md +22 -11
- package/dist/cli/dispatch.js +1 -1
- package/dist/features/owned-ui/project-trust-prompt.js +1 -1
- package/dist/features/owned-ui/settings-app.js +1 -1
- package/dist/features/prompt-history/service.d.ts +31 -1
- package/dist/features/prompt-history/service.js +294 -129
- package/dist/features/prompt-history/store.d.ts +2 -1
- package/dist/features/prompt-history/store.js +17 -4
- package/dist/features/prompt-history/worker.js +6 -3
- package/dist/foundation/launch-context/index.d.ts +36 -6
- package/dist/foundation/launch-context/index.js +64 -11
- package/dist/foundation/launch-guardian/main.js +2 -1
- package/dist/foundation/release/bootstrap.d.ts +7 -0
- package/dist/foundation/release/bootstrap.js +95 -19
- package/dist/foundation/release/cohort-state.js +0 -3
- package/dist/foundation/release/dependency-certification-retention.d.ts +7 -0
- package/dist/foundation/release/dependency-certification-retention.js +88 -0
- package/dist/foundation/release/dependency-certification.d.ts +24 -0
- package/dist/foundation/release/dependency-certification.js +208 -0
- package/dist/foundation/release/dependency-layer.d.ts +3 -4
- package/dist/foundation/release/dependency-layer.js +6 -37
- package/dist/foundation/release/index.d.ts +1 -0
- package/dist/foundation/release/index.js +1 -0
- package/dist/foundation/release/release-gc.js +72 -44
- package/dist/foundation/release/release-store.d.ts +2 -0
- package/dist/foundation/release/release-store.js +4 -3
- package/dist/foundation/release/restart-certification.js +16 -5
- package/dist/foundation/release/update-launch.d.ts +6 -0
- package/dist/foundation/release/update-launch.js +17 -0
- package/dist/foundation/release/update.d.ts +2 -0
- package/dist/foundation/release/update.js +21 -27
- package/dist/foundation/release/warmup.js +16 -5
- package/dist/foundation/supervision/main.js +5 -2
- package/dist/foundation/supervision/server.js +40 -13
- package/dist/integrations/pi/components/owned-editor-ux.d.ts +1 -1
- package/dist/integrations/pi/components/owned-editor-ux.js +51 -27
- package/dist/integrations/pi/engine/adapter.d.ts +20 -0
- package/dist/integrations/pi/engine/adapter.js +223 -87
- package/dist/integrations/pi/engine/pending-delivery.d.ts +26 -0
- package/dist/integrations/pi/engine/pending-delivery.js +149 -0
- package/dist/integrations/pi/session-ui/prompt-chips.js +38 -25
- package/dist/integrations/pi/session-ui/prompt-history-controller.d.ts +0 -2
- package/dist/integrations/pi/session-ui/prompt-history-controller.js +3 -8
- package/dist/integrations/pi/session-ui/session-shell-root.d.ts +1 -1
- package/dist/integrations/pi/session-ui/session-shell-root.js +5 -1
- package/dist/integrations/pi/session-ui/session-shell.js +9 -6
- package/dist/integrations/pi/session-ui/session-viewport-controller.d.ts +2 -2
- package/dist/integrations/pi/session-ui/session-viewport-controller.js +4 -5
- package/dist/integrations/pi/session-ui/text-paste.d.ts +5 -0
- package/dist/integrations/pi/session-ui/text-paste.js +16 -0
- package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +2 -2
- package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +82 -39
- package/dist/native/darwin-arm64/manifest.json +1 -1
- 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.json +1 -1
- package/docs/architecture/internal-naming.md +5 -3
- package/docs/architecture/toolchain.md +1 -1
- package/docs/ci-release-runbook.md +45 -7
- package/docs/manual-code-streaming-cleanup.md +88 -0
- package/package.json +1 -1
|
@@ -5,9 +5,11 @@ import { PRODUCT_IDENTITY } from "../../product-identity.js";
|
|
|
5
5
|
import { assertPromptHistorySubmission, PROMPT_HISTORY_MAX_ENTRY_BYTES, PROMPT_HISTORY_MAX_TEXT_BYTES } from "../../contracts/owned-ui/index.js";
|
|
6
6
|
export class HistoryStorageError extends Error {
|
|
7
7
|
code;
|
|
8
|
-
|
|
8
|
+
certainty;
|
|
9
|
+
constructor(code, certainty = "uncommitted") {
|
|
9
10
|
super(`Prompt history ${code}`);
|
|
10
11
|
this.code = code;
|
|
12
|
+
this.certainty = certainty;
|
|
11
13
|
}
|
|
12
14
|
}
|
|
13
15
|
const MAX_STORAGE_BYTES = 64 * 1024 * 1024;
|
|
@@ -107,14 +109,25 @@ export class PromptHistoryStore {
|
|
|
107
109
|
}
|
|
108
110
|
close() { this.#database.close(); }
|
|
109
111
|
#transaction(work) {
|
|
110
|
-
|
|
112
|
+
try {
|
|
113
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
throw new HistoryStorageError(classifyHistoryError(error), "uncommitted");
|
|
117
|
+
}
|
|
111
118
|
try {
|
|
112
119
|
work();
|
|
113
120
|
this.#database.exec("COMMIT");
|
|
114
121
|
}
|
|
115
122
|
catch (error) {
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
// Invariant: only a successful rollback proves that replay cannot advance recency twice.
|
|
124
|
+
try {
|
|
125
|
+
this.#database.exec("ROLLBACK");
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
throw new HistoryStorageError(classifyHistoryError(error), "unknown");
|
|
129
|
+
}
|
|
130
|
+
throw new HistoryStorageError(classifyHistoryError(error), "uncommitted");
|
|
118
131
|
}
|
|
119
132
|
}
|
|
120
133
|
#prune(limit) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parentPort, workerData } from "node:worker_threads";
|
|
2
2
|
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
-
import { PromptHistoryStore, classifyHistoryError } from "./store.js";
|
|
3
|
+
import { HistoryStorageError, PromptHistoryStore, classifyHistoryError } from "./store.js";
|
|
4
4
|
const port = parentPort;
|
|
5
5
|
if (port !== null) {
|
|
6
6
|
let store;
|
|
@@ -22,7 +22,8 @@ if (port !== null) {
|
|
|
22
22
|
port.postMessage({ id: request.id, ok: true, value });
|
|
23
23
|
}
|
|
24
24
|
catch (error) {
|
|
25
|
-
port.postMessage({ id: request.id, ok: false, code: classifyHistoryError(error)
|
|
25
|
+
port.postMessage({ id: request.id, ok: false, code: classifyHistoryError(error),
|
|
26
|
+
certainty: error instanceof HistoryStorageError ? error.certainty : "unknown" });
|
|
26
27
|
}
|
|
27
28
|
});
|
|
28
29
|
});
|
|
@@ -34,7 +35,9 @@ async function retry(operation) {
|
|
|
34
35
|
return operation();
|
|
35
36
|
}
|
|
36
37
|
catch (error) {
|
|
37
|
-
if (classifyHistoryError(error) !== "busy"
|
|
38
|
+
if (classifyHistoryError(error) !== "busy"
|
|
39
|
+
|| error instanceof HistoryStorageError && error.certainty === "unknown"
|
|
40
|
+
|| performance.now() >= deadline)
|
|
38
41
|
throw error;
|
|
39
42
|
await delay(25);
|
|
40
43
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** The
|
|
1
|
+
/** The contract this build emits; supersedes the pre-cutover key set it still accepts. */
|
|
2
2
|
export declare const PRIVATE_LAUNCH_CONTRACT = "neutral-launch-v1";
|
|
3
3
|
export declare const PRIVATE_ENVIRONMENT: Readonly<{
|
|
4
4
|
releaseRoot: "LAUNCH_CONTEXT_RELEASE_ROOT";
|
|
@@ -8,6 +8,22 @@ export declare const PRIVATE_ENVIRONMENT: Readonly<{
|
|
|
8
8
|
launchProfile: "LAUNCH_CONTEXT_PROFILE";
|
|
9
9
|
immutableWarmup: "LAUNCH_CONTEXT_WARMUP";
|
|
10
10
|
}>;
|
|
11
|
+
/**
|
|
12
|
+
* Keys published by releases that predate the neutral cutover. They are read when
|
|
13
|
+
* an older launcher hands this build a process, and written when this build
|
|
14
|
+
* starts a release that declares no contract of its own, so an upgrade never
|
|
15
|
+
* depends on both sides having been rebuilt at the same moment.
|
|
16
|
+
*/
|
|
17
|
+
export declare const SUPERSEDED_ENVIRONMENT: Readonly<{
|
|
18
|
+
releaseRoot: "A1_RELEASE_ROOT";
|
|
19
|
+
releaseId: "A1_RELEASE_ID";
|
|
20
|
+
releaseDigest: "A1_RELEASE_DIGEST";
|
|
21
|
+
releaseLayers: "A1_RELEASE_LAYERS";
|
|
22
|
+
launchProfile: "A1_LAUNCH_PROFILE";
|
|
23
|
+
immutableWarmup: "A1_IMMUTABLE_WARMUP";
|
|
24
|
+
}>;
|
|
25
|
+
/** A release either declares the current contract or predates contract declaration. */
|
|
26
|
+
export type LaunchContractTarget = typeof PRIVATE_LAUNCH_CONTRACT | "superseded";
|
|
11
27
|
export interface LaunchContext {
|
|
12
28
|
readonly releaseRoot?: string;
|
|
13
29
|
readonly releaseId?: string;
|
|
@@ -17,13 +33,27 @@ export interface LaunchContext {
|
|
|
17
33
|
readonly immutableWarmup?: "1";
|
|
18
34
|
}
|
|
19
35
|
export type LaunchContextRequirement = "optional" | "profile" | "release" | "warmup";
|
|
20
|
-
/** Read current
|
|
36
|
+
/** Read current keys, fall back to superseded keys per field, with bounded diagnostics. */
|
|
21
37
|
export declare function readLaunchContext(environment: NodeJS.ProcessEnv, requirement?: LaunchContextRequirement, platform?: NodeJS.Platform): LaunchContext;
|
|
22
|
-
/**
|
|
23
|
-
export declare function
|
|
24
|
-
/**
|
|
38
|
+
/** The key set a process started for this release must receive. */
|
|
39
|
+
export declare function launchEnvironmentKeys(contract: LaunchContractTarget): Readonly<Record<string, string>>;
|
|
40
|
+
/** Classify metadata by the contract it declares rather than rejecting what it omits. */
|
|
41
|
+
export declare function launchContractTarget(value: {
|
|
42
|
+
readonly launchContract?: unknown;
|
|
43
|
+
}): LaunchContractTarget;
|
|
44
|
+
/** Whether metadata was produced by a build that declares this contract. */
|
|
45
|
+
export declare function isCurrentLaunchContract(value: {
|
|
46
|
+
readonly launchContract?: unknown;
|
|
47
|
+
}): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Rebuild owned context without inheriting a prior session's private selection. The
|
|
50
|
+
* contract selects which key set is written, so this build can start either its
|
|
51
|
+
* own releases or a retained pre-cutover release it must keep serving.
|
|
52
|
+
*/
|
|
53
|
+
export declare function withLaunchContext(environment: NodeJS.ProcessEnv, context: LaunchContext, platform?: NodeJS.Platform, contract?: LaunchContractTarget): NodeJS.ProcessEnv;
|
|
54
|
+
/** Remove both key sets, leaving user and integration settings intact. */
|
|
25
55
|
export declare function withoutLaunchContext(environment: NodeJS.ProcessEnv, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
|
|
26
|
-
/**
|
|
56
|
+
/** Require this build's own contract where nothing older can be accepted, such as recovery capsules. */
|
|
27
57
|
export declare function assertCurrentLaunchContract(value: {
|
|
28
58
|
readonly launchContract?: unknown;
|
|
29
59
|
}): void;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** The
|
|
1
|
+
/** The contract this build emits; supersedes the pre-cutover key set it still accepts. */
|
|
2
2
|
export const PRIVATE_LAUNCH_CONTRACT = "neutral-launch-v1";
|
|
3
3
|
export const PRIVATE_ENVIRONMENT = Object.freeze({
|
|
4
4
|
releaseRoot: "LAUNCH_CONTEXT_RELEASE_ROOT",
|
|
@@ -8,18 +8,39 @@ export const PRIVATE_ENVIRONMENT = Object.freeze({
|
|
|
8
8
|
launchProfile: "LAUNCH_CONTEXT_PROFILE",
|
|
9
9
|
immutableWarmup: "LAUNCH_CONTEXT_WARMUP",
|
|
10
10
|
});
|
|
11
|
+
/**
|
|
12
|
+
* Keys published by releases that predate the neutral cutover. They are read when
|
|
13
|
+
* an older launcher hands this build a process, and written when this build
|
|
14
|
+
* starts a release that declares no contract of its own, so an upgrade never
|
|
15
|
+
* depends on both sides having been rebuilt at the same moment.
|
|
16
|
+
*/
|
|
17
|
+
export const SUPERSEDED_ENVIRONMENT = Object.freeze({
|
|
18
|
+
releaseRoot: "A1_RELEASE_ROOT",
|
|
19
|
+
releaseId: "A1_RELEASE_ID",
|
|
20
|
+
releaseDigest: "A1_RELEASE_DIGEST",
|
|
21
|
+
releaseLayers: "A1_RELEASE_LAYERS",
|
|
22
|
+
launchProfile: "A1_LAUNCH_PROFILE",
|
|
23
|
+
immutableWarmup: "A1_IMMUTABLE_WARMUP",
|
|
24
|
+
});
|
|
11
25
|
const PRIVATE_ENTRIES = Object.entries(PRIVATE_ENVIRONMENT);
|
|
12
|
-
const
|
|
26
|
+
const SUPERSEDED_ENTRIES = Object.entries(SUPERSEDED_ENVIRONMENT);
|
|
27
|
+
const PRIVATE_KEYS = new Set([...Object.values(PRIVATE_ENVIRONMENT), ...Object.values(SUPERSEDED_ENVIRONMENT)]);
|
|
13
28
|
const LOGICAL_KEYS = new Map(PRIVATE_ENTRIES.map(([logical, key]) => [key, logical]));
|
|
14
|
-
|
|
29
|
+
const SUPERSEDED_LOGICAL_KEYS = new Map(SUPERSEDED_ENTRIES.map(([logical, key]) => [key, logical]));
|
|
30
|
+
/** Read current keys, fall back to superseded keys per field, with bounded diagnostics. */
|
|
15
31
|
export function readLaunchContext(environment, requirement = "optional", platform = process.platform) {
|
|
16
32
|
const values = {};
|
|
33
|
+
const superseded = {};
|
|
17
34
|
// Performance: enumerate names only for Windows casing; never fetch unrelated environment values.
|
|
18
35
|
if (platform === "win32") {
|
|
19
36
|
for (const name of Object.keys(environment)) {
|
|
20
|
-
const
|
|
37
|
+
const upper = name.toUpperCase();
|
|
38
|
+
const logical = LOGICAL_KEYS.get(upper);
|
|
21
39
|
if (logical !== undefined)
|
|
22
40
|
acceptValue(values, logical, environment[name]);
|
|
41
|
+
const supersededLogical = SUPERSEDED_LOGICAL_KEYS.get(upper);
|
|
42
|
+
if (supersededLogical !== undefined)
|
|
43
|
+
acceptValue(superseded, supersededLogical, environment[name]);
|
|
23
44
|
}
|
|
24
45
|
}
|
|
25
46
|
else {
|
|
@@ -27,7 +48,16 @@ export function readLaunchContext(environment, requirement = "optional", platfor
|
|
|
27
48
|
if (Object.prototype.propertyIsEnumerable.call(environment, key))
|
|
28
49
|
acceptValue(values, logical, environment[key]);
|
|
29
50
|
}
|
|
51
|
+
for (const [logical, key] of SUPERSEDED_ENTRIES) {
|
|
52
|
+
if (Object.prototype.propertyIsEnumerable.call(environment, key))
|
|
53
|
+
acceptValue(superseded, logical, environment[key]);
|
|
54
|
+
}
|
|
30
55
|
}
|
|
56
|
+
// Compatibility: the current key wins wherever both are present, so a launcher that
|
|
57
|
+
// emits this contract is never overruled by an inherited pre-cutover value.
|
|
58
|
+
for (const [logical, value] of Object.entries(superseded))
|
|
59
|
+
if (values[logical] === undefined)
|
|
60
|
+
values[logical] = value;
|
|
31
61
|
if (values.launchProfile !== undefined && values.launchProfile !== "a1" && values.launchProfile !== "pi") {
|
|
32
62
|
throw invalidContext("launchProfile", "unsupported profile");
|
|
33
63
|
}
|
|
@@ -37,16 +67,39 @@ export function readLaunchContext(environment, requirement = "optional", platfor
|
|
|
37
67
|
throw invalidContext("releaseDigest", "invalid digest");
|
|
38
68
|
if (requirement === "profile")
|
|
39
69
|
requireFields(values, ["launchProfile"]);
|
|
40
|
-
if (requirement === "release" || requirement === "warmup")
|
|
41
|
-
requireFields(values, ["releaseRoot", "releaseId", "releaseDigest"
|
|
70
|
+
if (requirement === "release" || requirement === "warmup") {
|
|
71
|
+
requireFields(values, ["releaseRoot", "releaseId", "releaseDigest"]);
|
|
72
|
+
// Compatibility: Windows drops an empty value from a child environment block, so a
|
|
73
|
+
// release with no dependency layer arrives without the key it was given. The
|
|
74
|
+
// layer list is metadata of the release being started, never authority, and an
|
|
75
|
+
// absent list means the same thing the empty string does.
|
|
76
|
+
if (values.releaseLayers === undefined)
|
|
77
|
+
values.releaseLayers = "";
|
|
78
|
+
}
|
|
42
79
|
if (requirement === "warmup")
|
|
43
80
|
requireFields(values, ["immutableWarmup"]);
|
|
44
81
|
return Object.freeze(values);
|
|
45
82
|
}
|
|
46
|
-
/**
|
|
47
|
-
export function
|
|
83
|
+
/** The key set a process started for this release must receive. */
|
|
84
|
+
export function launchEnvironmentKeys(contract) {
|
|
85
|
+
return contract === PRIVATE_LAUNCH_CONTRACT ? PRIVATE_ENVIRONMENT : SUPERSEDED_ENVIRONMENT;
|
|
86
|
+
}
|
|
87
|
+
/** Classify metadata by the contract it declares rather than rejecting what it omits. */
|
|
88
|
+
export function launchContractTarget(value) {
|
|
89
|
+
return value.launchContract === PRIVATE_LAUNCH_CONTRACT ? PRIVATE_LAUNCH_CONTRACT : "superseded";
|
|
90
|
+
}
|
|
91
|
+
/** Whether metadata was produced by a build that declares this contract. */
|
|
92
|
+
export function isCurrentLaunchContract(value) {
|
|
93
|
+
return value.launchContract === PRIVATE_LAUNCH_CONTRACT;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Rebuild owned context without inheriting a prior session's private selection. The
|
|
97
|
+
* contract selects which key set is written, so this build can start either its
|
|
98
|
+
* own releases or a retained pre-cutover release it must keep serving.
|
|
99
|
+
*/
|
|
100
|
+
export function withLaunchContext(environment, context, platform = process.platform, contract = PRIVATE_LAUNCH_CONTRACT) {
|
|
48
101
|
const result = withoutLaunchContext(environment, platform);
|
|
49
|
-
for (const [logical, key] of
|
|
102
|
+
for (const [logical, key] of Object.entries(launchEnvironmentKeys(contract))) {
|
|
50
103
|
const value = context[logical];
|
|
51
104
|
if (value !== undefined)
|
|
52
105
|
result[key] = value;
|
|
@@ -54,11 +107,11 @@ export function withLaunchContext(environment, context, platform = process.platf
|
|
|
54
107
|
readLaunchContext(result, "optional", platform);
|
|
55
108
|
return result;
|
|
56
109
|
}
|
|
57
|
-
/** Remove
|
|
110
|
+
/** Remove both key sets, leaving user and integration settings intact. */
|
|
58
111
|
export function withoutLaunchContext(environment, platform = process.platform) {
|
|
59
112
|
return Object.fromEntries(Object.entries(environment).filter(([key]) => !PRIVATE_KEYS.has(platform === "win32" ? key.toUpperCase() : key)));
|
|
60
113
|
}
|
|
61
|
-
/**
|
|
114
|
+
/** Require this build's own contract where nothing older can be accepted, such as recovery capsules. */
|
|
62
115
|
export function assertCurrentLaunchContract(value) {
|
|
63
116
|
if (value.launchContract !== PRIVATE_LAUNCH_CONTRACT) {
|
|
64
117
|
throw new Error("Unsupported private launch contract. Stop existing processes and install the current package directly with npm; review disposable runtime/release state before any manual reset. User settings, sessions, and history must be preserved.");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { PRODUCT_TEXT } from "../../product-identity.js";
|
|
2
3
|
import { readLaunchContext } from "../launch-context/index.js";
|
|
3
4
|
import { resolve } from "node:path";
|
|
4
5
|
import { assertLaunchProfileId, sessionSelectionArguments } from "../lifecycle/index.js";
|
|
@@ -175,5 +176,5 @@ function exitCode(outcome) {
|
|
|
175
176
|
return 1;
|
|
176
177
|
}
|
|
177
178
|
function diagnosticError(message, code) {
|
|
178
|
-
return Object.assign(new Error(
|
|
179
|
+
return Object.assign(new Error(`${PRODUCT_TEXT.displayName}: ${message}`), { code });
|
|
179
180
|
}
|
|
@@ -35,7 +35,14 @@ export interface SupervisorStartupAttempt extends SupervisorStartupAttemptIdenti
|
|
|
35
35
|
readonly signal: NodeJS.Signals | null;
|
|
36
36
|
}>;
|
|
37
37
|
}
|
|
38
|
+
/** Reuse a verified owner, or join the winner if another launch starts the same cohort first. */
|
|
39
|
+
export declare function ensureSupervisor(release: MaterializedRelease, environment: NodeJS.ProcessEnv): Promise<void>;
|
|
38
40
|
export declare function startSupervisor(release: MaterializedRelease, environment: NodeJS.ProcessEnv): Promise<SupervisorStartupAttempt>;
|
|
41
|
+
/**
|
|
42
|
+
* Hand a release the key set its own build reads. A retained pre-cutover release is
|
|
43
|
+
* still startable this way, which is what lets a launch fall back to the previous
|
|
44
|
+
* version instead of failing while an installation is being replaced.
|
|
45
|
+
*/
|
|
39
46
|
export declare function releaseEnvironment(environment: NodeJS.ProcessEnv, release: MaterializedRelease, profile?: LaunchProfileId): NodeJS.ProcessEnv;
|
|
40
47
|
export declare function waitForVerifiedEndpoint(path: string, release: MaterializedRelease, timeoutMs: number, startup?: SupervisorStartupAttempt): Promise<void>;
|
|
41
48
|
export declare function readEndpointMetadata(path: string): Promise<SupervisorEndpointMetadata | null>;
|
|
@@ -10,12 +10,14 @@ import { assertLaunchProfileId, createSupervisorStartupAttempt, readSupervisorSt
|
|
|
10
10
|
import { encodeFrame, LineFrameDecoder } from "../protocol/index.js";
|
|
11
11
|
import { cleanupProvenIdleOwner, processIsAlive } from "./process-cleanup.js";
|
|
12
12
|
import { sweepDeadEndpoints } from "./endpoints.js";
|
|
13
|
+
import { UpdateTransactionStore } from "./update-transaction.js";
|
|
14
|
+
import { selectUpdateLaunchRelease } from "./update-launch.js";
|
|
13
15
|
import { consumeMaterializationProof, materializeRelease, readCertifiedReleaseManifest, readMaterializedRelease, resolveReleaseEntryPoint, verifyMaterializedRelease } from "./release-store.js";
|
|
14
16
|
import { scheduleReleaseCleanup } from "./release-gc.js";
|
|
15
17
|
import { createRestartSeal, readRestartCertifiedRelease, releaseCertificationDocument } from "./restart-certification.js";
|
|
16
18
|
import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
|
|
17
19
|
import { markStartupPhase } from "../startup/index.js";
|
|
18
|
-
import {
|
|
20
|
+
import { launchContractTarget, readLaunchContext, withLaunchContext } from "../launch-context/index.js";
|
|
19
21
|
export async function runBootstrap(options) {
|
|
20
22
|
const environment = withLaunchContext(options.environment ?? process.env, { launchProfile: options.launchIntent?.profileId ?? "a1" });
|
|
21
23
|
await markStartupPhase(environment, "bootstrap-start");
|
|
@@ -29,9 +31,21 @@ export async function runBootstrap(options) {
|
|
|
29
31
|
await mkdir(paths.runtimeDir, { recursive: true, mode: 0o700 });
|
|
30
32
|
const stateStore = new CohortStateStore(paths.dataDir);
|
|
31
33
|
let state = await stateStore.read();
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
const launchDuringUpdate = async () => {
|
|
35
|
+
// Concurrency: read the journal before touching npm's mutable tree, including package.json.
|
|
36
|
+
const transaction = await new UpdateTransactionStore(paths.dataDir).read();
|
|
37
|
+
if (!transaction || transaction.status === "completed")
|
|
38
|
+
return null;
|
|
39
|
+
const prior = selectUpdateLaunchRelease(await stateStore.read(), transaction);
|
|
40
|
+
if (!prior)
|
|
41
|
+
return null;
|
|
42
|
+
const retained = await readCertifiedReleaseManifest(prior, resolve(paths.dataDir, "releases"));
|
|
43
|
+
await ensureSupervisor(retained, environment);
|
|
44
|
+
return await launchUi(retained, environment, sessionArgs);
|
|
45
|
+
};
|
|
46
|
+
const duringUpdate = await launchDuringUpdate();
|
|
47
|
+
if (duringUpdate !== null)
|
|
48
|
+
return duringUpdate;
|
|
35
49
|
// Invariant: records left by cohorts whose processes are gone say nothing about ownership, and there
|
|
36
50
|
// can now be several of them. Clearing them first keeps the decision below about what is
|
|
37
51
|
// actually running.
|
|
@@ -83,15 +97,30 @@ export async function runBootstrap(options) {
|
|
|
83
97
|
return verified;
|
|
84
98
|
});
|
|
85
99
|
await markStartupPhase(environment, "durable-validation-complete");
|
|
86
|
-
const retainedPaths = resolveCohortEndpoint(paths, retained.releaseId, environment);
|
|
87
100
|
await markStartupPhase(environment, "replacement-supervisor-start");
|
|
88
|
-
|
|
89
|
-
await waitForVerifiedEndpoint(retainedPaths.endpointMetadataPath, retained, 8_000, startup);
|
|
101
|
+
await ensureSupervisor(retained, environment);
|
|
90
102
|
await markStartupPhase(environment, "replacement-supervisor-ready");
|
|
91
103
|
return await launchUi(retained, environment, sessionArgs);
|
|
92
104
|
}
|
|
93
105
|
}
|
|
94
|
-
|
|
106
|
+
// Concurrency: an installation being replaced is not a reason to refuse a launch. While
|
|
107
|
+
// npm is rewriting the package tree the candidate cannot be read consistently, so a
|
|
108
|
+
// launch that cannot take the installed payload starts the retained active release
|
|
109
|
+
// instead and leaves activation to the launch that follows the replacement.
|
|
110
|
+
let candidate;
|
|
111
|
+
try {
|
|
112
|
+
candidate = await materializeRelease(options.packageRoot, paths.dataDir);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const fallback = await launchRetainedActive(state, paths, environment, sessionArgs, output);
|
|
116
|
+
if (fallback === null)
|
|
117
|
+
throw error;
|
|
118
|
+
return fallback;
|
|
119
|
+
}
|
|
120
|
+
// Concurrency: an update may have begun while this launch was reading the installed payload.
|
|
121
|
+
const afterMaterialization = await launchDuringUpdate();
|
|
122
|
+
if (afterMaterialization !== null)
|
|
123
|
+
return afterMaterialization;
|
|
95
124
|
await stateStore.recordCandidate(candidate);
|
|
96
125
|
state = await stateStore.read();
|
|
97
126
|
if (!state.references.active) {
|
|
@@ -182,10 +211,30 @@ export async function runBootstrap(options) {
|
|
|
182
211
|
else {
|
|
183
212
|
selected = await readMaterializedRelease(decision.releaseRoot);
|
|
184
213
|
}
|
|
185
|
-
|
|
186
|
-
await waitForVerifiedEndpoint(resolveCohortEndpoint(paths, selected.releaseId, environment).endpointMetadataPath, selected, 8_000, startup);
|
|
214
|
+
await ensureSupervisor(selected, environment);
|
|
187
215
|
return await launchUi(selected, environment, sessionArgs);
|
|
188
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Start the retained active cohort when the installed payload cannot be taken. The
|
|
219
|
+
* release already carries certified evidence, so this reuses it rather than
|
|
220
|
+
* certifying anything new, and reports no selection when there is nothing safe
|
|
221
|
+
* to fall back to.
|
|
222
|
+
*/
|
|
223
|
+
async function launchRetainedActive(state, paths, environment, sessionArgs, output) {
|
|
224
|
+
const activeId = state.references.active;
|
|
225
|
+
const active = activeId === null ? undefined : state.releases[activeId];
|
|
226
|
+
if (!active || active.approval !== "approved")
|
|
227
|
+
return null;
|
|
228
|
+
try {
|
|
229
|
+
const retained = await readCertifiedReleaseManifest(active, resolve(paths.dataDir, "releases"));
|
|
230
|
+
await ensureSupervisor(retained, environment);
|
|
231
|
+
output.write(`${PRODUCT_TEXT.diagnostic(`installation is being replaced; starting the retained release ${retained.packageVersion}`)}\n`);
|
|
232
|
+
return await launchUi(retained, environment, sessionArgs);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
189
238
|
async function readInstalledVersion(packageRoot) {
|
|
190
239
|
const manifest = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8"));
|
|
191
240
|
if (typeof manifest.version !== "string" || manifest.version.length === 0) {
|
|
@@ -194,7 +243,6 @@ async function readInstalledVersion(packageRoot) {
|
|
|
194
243
|
return manifest.version;
|
|
195
244
|
}
|
|
196
245
|
export async function certifyMaterializedRelease(release, dataDir, verification = {}) {
|
|
197
|
-
assertCurrentLaunchContract(release);
|
|
198
246
|
if (!consumeMaterializationProof(release)) {
|
|
199
247
|
await verifyMaterializedRelease(release.releaseRoot, release, resolve(dataDir, "releases"), verification);
|
|
200
248
|
}
|
|
@@ -202,7 +250,6 @@ export async function certifyMaterializedRelease(release, dataDir, verification
|
|
|
202
250
|
}
|
|
203
251
|
/** Persist current-format evidence after an authenticated parent has certified the exact release. */
|
|
204
252
|
export async function recordParentCertifiedRelease(release, dataDir) {
|
|
205
|
-
assertCurrentLaunchContract(release);
|
|
206
253
|
const path = resolve(dataDir, `certification-${release.releaseId}.json`);
|
|
207
254
|
const restartSeal = await createRestartSeal(release, dataDir);
|
|
208
255
|
await chmod(path, 0o600).catch(() => { });
|
|
@@ -210,6 +257,25 @@ export async function recordParentCertifiedRelease(release, dataDir) {
|
|
|
210
257
|
await chmod(path, 0o400);
|
|
211
258
|
return path;
|
|
212
259
|
}
|
|
260
|
+
/** Reuse a verified owner, or join the winner if another launch starts the same cohort first. */
|
|
261
|
+
export async function ensureSupervisor(release, environment) {
|
|
262
|
+
const paths = resolveCohortEndpoint(resolveProductPaths(environment), release.releaseId, environment);
|
|
263
|
+
const metadata = await readEndpointMetadata(paths.endpointMetadataPath);
|
|
264
|
+
if (metadata) {
|
|
265
|
+
const probe = await probeOwnership(metadata);
|
|
266
|
+
if (probe !== "dead") {
|
|
267
|
+
if (probe === "live-verified" && endpointMatchesRelease(metadata, release))
|
|
268
|
+
return;
|
|
269
|
+
throw new Error(PRODUCT_TEXT.diagnostic(`refused duplicate supervisor startup: existing ownership is ${probe}`));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const startup = await startSupervisor(release, environment);
|
|
273
|
+
await waitForVerifiedEndpoint(paths.endpointMetadataPath, release, 8_000, startup);
|
|
274
|
+
}
|
|
275
|
+
function endpointMatchesRelease(metadata, release) {
|
|
276
|
+
return metadata.releaseId === release.releaseId && metadata.releaseRoot === release.releaseRoot
|
|
277
|
+
&& metadata.contentDigest === release.contentDigest;
|
|
278
|
+
}
|
|
213
279
|
export async function startSupervisor(release, environment) {
|
|
214
280
|
const entry = await resolveReleaseEntryPoint(release, "bin/supervisor.js");
|
|
215
281
|
const paths = resolveProductPaths(environment);
|
|
@@ -243,23 +309,28 @@ async function launchUi(release, environment, sessionArgs) {
|
|
|
243
309
|
child.once("close", (code, signal) => resolvePromise(restoreAfterOwnedExit(readLaunchContext(environment).launchProfile === "a1", code, signal)));
|
|
244
310
|
});
|
|
245
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* Hand a release the key set its own build reads. A retained pre-cutover release is
|
|
314
|
+
* still startable this way, which is what lets a launch fall back to the previous
|
|
315
|
+
* version instead of failing while an installation is being replaced.
|
|
316
|
+
*/
|
|
246
317
|
export function releaseEnvironment(environment, release, profile) {
|
|
247
|
-
assertCurrentLaunchContract(release);
|
|
248
318
|
return withLaunchContext(environment, {
|
|
249
319
|
releaseId: release.releaseId,
|
|
250
320
|
releaseLayers: (release.dependencyLayers ?? []).map(layer => layer.layerId).join(","),
|
|
251
321
|
releaseRoot: release.releaseRoot,
|
|
252
322
|
releaseDigest: release.contentDigest,
|
|
253
323
|
...(profile === undefined ? {} : { launchProfile: profile }),
|
|
254
|
-
});
|
|
324
|
+
}, process.platform, launchContractTarget(release));
|
|
255
325
|
}
|
|
256
326
|
export async function waitForVerifiedEndpoint(path, release, timeoutMs, startup) {
|
|
257
327
|
const deadline = Date.now() + timeoutMs;
|
|
258
328
|
let childOutcome = null;
|
|
329
|
+
let collision = null;
|
|
259
330
|
void startup?.childOutcome.then(outcome => { childOutcome = outcome; });
|
|
260
331
|
while (Date.now() < deadline) {
|
|
261
332
|
const metadata = await readEndpointMetadata(path);
|
|
262
|
-
if (metadata && metadata
|
|
333
|
+
if (metadata && endpointMatchesRelease(metadata, release) && await probeOwnership(metadata) === "live-verified") {
|
|
263
334
|
if (startup)
|
|
264
335
|
await rm(startup.resultPath, { force: true });
|
|
265
336
|
return;
|
|
@@ -267,15 +338,21 @@ export async function waitForVerifiedEndpoint(path, release, timeoutMs, startup)
|
|
|
267
338
|
if (startup) {
|
|
268
339
|
const result = await readSupervisorStartupResult(startup.resultPath, startup.attemptId, startup.releaseId);
|
|
269
340
|
if (result?.outcome === "failure") {
|
|
270
|
-
|
|
341
|
+
const error = Object.assign(new Error(PRODUCT_TEXT.diagnostic(`supervisor startup failed at ${result.stage}: ${result.message}`)), {
|
|
271
342
|
code: result.code ?? "SUPERVISOR_STARTUP_FAILED",
|
|
272
343
|
});
|
|
344
|
+
if (result.code !== "EADDRINUSE")
|
|
345
|
+
throw error;
|
|
346
|
+
// Concurrency: the winning process may not have published its authenticated metadata yet.
|
|
347
|
+
collision = error;
|
|
273
348
|
}
|
|
274
349
|
}
|
|
275
|
-
if (childOutcome)
|
|
350
|
+
if (childOutcome && !collision)
|
|
276
351
|
throw new Error(PRODUCT_TEXT.diagnostic(`supervisor exited before readiness: ${JSON.stringify(childOutcome)}`));
|
|
277
352
|
await new Promise(resolvePromise => setTimeout(resolvePromise, 40));
|
|
278
353
|
}
|
|
354
|
+
if (collision)
|
|
355
|
+
throw collision;
|
|
279
356
|
throw new Error(PRODUCT_TEXT.diagnostic(`supervisor did not publish verified endpoint metadata within ${timeoutMs}ms`));
|
|
280
357
|
}
|
|
281
358
|
export async function readEndpointMetadata(path) {
|
|
@@ -370,8 +447,7 @@ async function activatePendingAfterBlockerExit(candidate, stateStore, paths, env
|
|
|
370
447
|
return;
|
|
371
448
|
await removeEndpointArtifacts(paths.endpointMetadataPath, paths.endpoint);
|
|
372
449
|
await stateStore.activate(candidate.releaseId);
|
|
373
|
-
|
|
374
|
-
await waitForVerifiedEndpoint(resolveCohortEndpoint(paths, candidate.releaseId, environment).endpointMetadataPath, candidate, 8_000, startup);
|
|
450
|
+
await ensureSupervisor(candidate, environment);
|
|
375
451
|
}
|
|
376
452
|
export async function releaseVerifiedIdleOwner(metadata, dataDir, operations = {}) {
|
|
377
453
|
try {
|
|
@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
|
|
|
2
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
|
-
import { assertCurrentLaunchContract } from "../launch-context/index.js";
|
|
6
5
|
export const RELEASE_COHORT_SCHEMA = PRODUCT_IDENTITY.protocol.releaseCohortSchema;
|
|
7
6
|
const RELEASE_ID_PATTERN = /^[0-9A-Za-z.+_-]+-[a-f0-9]{20}$/;
|
|
8
7
|
/**
|
|
@@ -122,7 +121,6 @@ export class CohortStateStore {
|
|
|
122
121
|
const release = requiredRelease(current, releaseId);
|
|
123
122
|
if (release.approval !== "approved")
|
|
124
123
|
throw new Error(`cannot activate unverified release ${releaseId}`);
|
|
125
|
-
assertCurrentLaunchContract(release);
|
|
126
124
|
const prior = current.references.active;
|
|
127
125
|
return {
|
|
128
126
|
...current,
|
|
@@ -150,7 +148,6 @@ export class CohortStateStore {
|
|
|
150
148
|
const rollback = requiredRelease(current, rollbackId);
|
|
151
149
|
if (rollback.approval !== "approved")
|
|
152
150
|
throw new Error(`cannot roll back to unverified release ${rollbackId}`);
|
|
153
|
-
assertCurrentLaunchContract(rollback);
|
|
154
151
|
return {
|
|
155
152
|
...current,
|
|
156
153
|
references: {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface DependencyCertificationProtection {
|
|
2
|
+
readonly referenced: Set<string>;
|
|
3
|
+
readonly legacyRequired: Set<string>;
|
|
4
|
+
readonly uncertain: boolean;
|
|
5
|
+
}
|
|
6
|
+
/** Inspect retained and not-yet-collected release metadata, never dependency payload bytes. */
|
|
7
|
+
export declare function dependencyCertificationProtection(dataDir: string, retainedReleaseIds?: readonly string[]): Promise<DependencyCertificationProtection>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { lstat, readFile, readdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
|
+
import { PRODUCT_IDENTITY } from "../../product-identity.js";
|
|
4
|
+
import { dependencyLayerCertificationPath } from "./dependency-certification.js";
|
|
5
|
+
import { RELEASE_MANIFEST_FILENAME } from "./release-store.js";
|
|
6
|
+
import { readRestartCertifiedRelease } from "./restart-certification.js";
|
|
7
|
+
/** Inspect retained and not-yet-collected release metadata, never dependency payload bytes. */
|
|
8
|
+
export async function dependencyCertificationProtection(dataDir, retainedReleaseIds = []) {
|
|
9
|
+
const referenced = new Set();
|
|
10
|
+
const legacyRequired = new Set();
|
|
11
|
+
const canonicalData = await realpath(dataDir);
|
|
12
|
+
const releasesRoot = resolve(canonicalData, "releases");
|
|
13
|
+
const roots = [];
|
|
14
|
+
let uncertain = false;
|
|
15
|
+
const rootMetadata = await lstat(releasesRoot);
|
|
16
|
+
if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink())
|
|
17
|
+
return { referenced, legacyRequired, uncertain: true };
|
|
18
|
+
const entries = await readdir(releasesRoot, { withFileTypes: true });
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
if (entry.isSymbolicLink()) {
|
|
21
|
+
uncertain = true;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (!entry.isDirectory() || entry.name.startsWith(".candidate-"))
|
|
25
|
+
continue;
|
|
26
|
+
if (entry.name === ".trash") {
|
|
27
|
+
for (const trash of await readdir(resolve(releasesRoot, entry.name), { withFileTypes: true })) {
|
|
28
|
+
if (trash.isSymbolicLink())
|
|
29
|
+
uncertain = true;
|
|
30
|
+
else if (trash.isDirectory() && !trash.name.startsWith(".candidate"))
|
|
31
|
+
roots.push(resolve(releasesRoot, entry.name, trash.name));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else
|
|
35
|
+
roots.push(resolve(releasesRoot, entry.name));
|
|
36
|
+
}
|
|
37
|
+
const discoveredIds = new Set(roots.map(root => basename(root).split("--", 1)[0]));
|
|
38
|
+
if (retainedReleaseIds.some(id => !discoveredIds.has(id)))
|
|
39
|
+
uncertain = true;
|
|
40
|
+
for (const root of roots) {
|
|
41
|
+
try {
|
|
42
|
+
const manifestPath = resolve(root, RELEASE_MANIFEST_FILENAME);
|
|
43
|
+
const metadata = await lstat(manifestPath);
|
|
44
|
+
if (!metadata.isFile() || metadata.isSymbolicLink())
|
|
45
|
+
throw new Error("release manifest is not a regular file");
|
|
46
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
47
|
+
if (!manifest || !/^[0-9A-Za-z.+_-]+-[a-f0-9]{20}$/.test(manifest.releaseId)
|
|
48
|
+
|| (manifest.dependencyLayers !== undefined && !Array.isArray(manifest.dependencyLayers)))
|
|
49
|
+
throw new Error("invalid retained release manifest");
|
|
50
|
+
const seal = await canonicalConsumerSeal(canonicalData, manifest.releaseId, manifest.contentDigest);
|
|
51
|
+
for (const layer of manifest.dependencyLayers ?? []) {
|
|
52
|
+
if (!layer || !/^dependencies-[a-f0-9]{32}$/.test(layer.layerId))
|
|
53
|
+
throw new Error("invalid retained dependency reference");
|
|
54
|
+
referenced.add(layer.layerId);
|
|
55
|
+
const evidence = seal?.dependencyLayers.filter(item => item.layerId === layer.layerId);
|
|
56
|
+
if (evidence?.length !== 1 || evidence[0].contentDigest !== layer.contentDigest
|
|
57
|
+
|| evidence[0].certification?.path !== dependencyLayerCertificationPath(canonicalData, layer.layerId)) {
|
|
58
|
+
// Compatibility: an absent, legacy, or uncertain seal cannot prove canonical-only use.
|
|
59
|
+
legacyRequired.add(layer.layerId);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Security: an unreadable retained manifest might reference any layer; do not guess ownership.
|
|
65
|
+
uncertain = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { referenced, legacyRequired, uncertain };
|
|
69
|
+
}
|
|
70
|
+
async function canonicalConsumerSeal(dataDir, releaseId, contentDigest) {
|
|
71
|
+
try {
|
|
72
|
+
const path = resolve(dataDir, `certification-${releaseId}.json`);
|
|
73
|
+
const metadata = await lstat(path);
|
|
74
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o222) !== 0)
|
|
75
|
+
return null;
|
|
76
|
+
const document = JSON.parse(await readFile(path, "utf8"));
|
|
77
|
+
if (document.schema !== PRODUCT_IDENTITY.evidence.releaseCertificationSchema || document.releaseId !== releaseId
|
|
78
|
+
|| document.contentDigest !== contentDigest || !document.restartSeal)
|
|
79
|
+
return null;
|
|
80
|
+
// Security: path claims alone do not prove a canonical-only consumer. Reuse the bounded
|
|
81
|
+
// restart validator so stale, unsupported, or tampered seals conservatively retain legacy files.
|
|
82
|
+
await readRestartCertifiedRelease({ releaseId, contentDigest, releaseRoot: resolve(dataDir, "releases", releaseId) }, dataDir);
|
|
83
|
+
return document.restartSeal;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|