@timurproko/a1 0.1.8-dev.332 → 0.1.8-dev.368
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 +40 -11
- package/dist/composition/owned-ui.d.ts +2 -0
- package/dist/composition/owned-ui.js +20 -3
- package/dist/contracts/owned-ui/index.d.ts +2 -0
- package/dist/contracts/owned-ui/index.js +2 -0
- package/dist/contracts/owned-ui/model.d.ts +38 -3
- package/dist/contracts/owned-ui/prompt-history.d.ts +18 -0
- package/dist/contracts/owned-ui/prompt-suggestions.d.ts +1 -1
- package/dist/contracts/owned-ui/prompt-suggestions.js +4 -1
- package/dist/contracts/owned-ui/suggestion-diagnostics.d.ts +22 -0
- package/dist/contracts/owned-ui/suggestion-diagnostics.js +9 -0
- package/dist/contracts/owned-ui/transcript-lifecycle.d.ts +5 -0
- package/dist/contracts/owned-ui/transcript-lifecycle.js +33 -0
- package/dist/contracts/owned-ui/validation.js +45 -2
- package/dist/contracts/presentation/index.d.ts +8 -0
- package/dist/features/owned-ui/settings-app.js +1 -1
- package/dist/features/prompt-history/image-sidecar.d.ts +32 -0
- package/dist/features/prompt-history/image-sidecar.js +130 -0
- package/dist/features/prompt-history/index.d.ts +2 -0
- package/dist/features/prompt-history/index.js +1 -0
- package/dist/features/prompt-history/paths.d.ts +1 -0
- package/dist/features/prompt-history/paths.js +2 -1
- package/dist/features/prompt-history/store.d.ts +1 -1
- package/dist/features/prompt-history/store.js +46 -2
- package/dist/features/prompt-history/worker.js +1 -1
- package/dist/features/prompt-suggestions/diagnostics.d.ts +18 -0
- package/dist/features/prompt-suggestions/diagnostics.js +82 -0
- package/dist/features/prompt-suggestions/index.d.ts +1 -0
- package/dist/features/prompt-suggestions/index.js +1 -0
- package/dist/foundation/release/bootstrap.d.ts +2 -0
- package/dist/foundation/release/bootstrap.js +54 -18
- package/dist/foundation/release/index.d.ts +1 -0
- package/dist/foundation/release/index.js +1 -0
- 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 +20 -21
- 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 +9 -1
- package/dist/integrations/pi/components/owned-editor-ux.js +11 -8
- package/dist/integrations/pi/components/prompt-input-port.d.ts +18 -0
- package/dist/integrations/pi/components/prompt-input-port.js +1 -0
- package/dist/integrations/pi/components/shell-editor-autocomplete.js +66 -11
- package/dist/integrations/pi/components/shell-footer-status.d.ts +1 -1
- package/dist/integrations/pi/components/shell-footer-status.js +9 -5
- package/dist/integrations/pi/components/shell-presenters-info.js +7 -2
- package/dist/integrations/pi/components/shell-presenters-transcript.d.ts +6 -2
- package/dist/integrations/pi/components/shell-presenters-transcript.js +81 -49
- package/dist/integrations/pi/components/shell-shared-facade.d.ts +17 -4
- package/dist/integrations/pi/components/shell-shared-facade.js +2 -1
- package/dist/integrations/pi/components/submitted-prompt-adapter.d.ts +3 -1
- package/dist/integrations/pi/components/submitted-prompt-adapter.js +20 -4
- package/dist/integrations/pi/components/tool-result-adapter.d.ts +6 -0
- package/dist/integrations/pi/components/tool-result-adapter.js +27 -0
- package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.d.ts +1 -0
- package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.js +5 -0
- package/dist/integrations/pi/components/upstream/components/owned-editor.d.ts +7 -2
- package/dist/integrations/pi/components/upstream/components/owned-editor.js +41 -33
- package/dist/integrations/pi/components/upstream/components/session-footer.d.ts +2 -1
- package/dist/integrations/pi/components/upstream/components/session-footer.js +12 -7
- package/dist/integrations/pi/components/upstream/history/editor-core.js +1 -1
- package/dist/integrations/pi/engine/adapter.d.ts +2 -1
- package/dist/integrations/pi/engine/adapter.js +181 -84
- package/dist/integrations/pi/engine/tool-rendering.d.ts +13 -0
- package/dist/integrations/pi/engine/tool-rendering.js +122 -0
- package/dist/integrations/pi/session-ui/prompt-chips.d.ts +30 -0
- package/dist/integrations/pi/session-ui/prompt-chips.js +128 -1
- package/dist/integrations/pi/session-ui/prompt-history-controller.d.ts +8 -0
- package/dist/integrations/pi/session-ui/prompt-history-controller.js +30 -2
- package/dist/integrations/pi/session-ui/prompt-suggestion-controller.d.ts +11 -3
- package/dist/integrations/pi/session-ui/prompt-suggestion-controller.js +126 -51
- package/dist/integrations/pi/session-ui/session-shell-root.d.ts +24 -2
- package/dist/integrations/pi/session-ui/session-shell-root.js +108 -38
- package/dist/integrations/pi/session-ui/session-shell.js +51 -31
- package/dist/integrations/pi/session-ui/session-viewport-controller.d.ts +7 -1
- package/dist/integrations/pi/session-ui/session-viewport-controller.js +106 -16
- package/dist/integrations/pi/tui-runtime/adapter.d.ts +2 -0
- package/dist/integrations/pi/tui-runtime/adapter.js +35 -11
- package/dist/integrations/pi/tui-runtime/contracts.d.ts +4 -1
- package/dist/integrations/pi/tui-runtime/index.d.ts +1 -0
- package/dist/integrations/pi/tui-runtime/index.js +1 -0
- package/dist/integrations/pi/tui-runtime/mouse-report-input.d.ts +13 -0
- package/dist/integrations/pi/tui-runtime/mouse-report-input.js +59 -0
- package/dist/integrations/pi/tui-runtime/overlay-geometry.d.ts +32 -0
- package/dist/integrations/pi/tui-runtime/overlay-geometry.js +159 -0
- 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.d.ts +1 -1
- package/dist/product-identity.js +1 -1
- package/dist/product-identity.json +1 -0
- package/dist/ui/components/index.d.ts +1 -0
- package/dist/ui/components/index.js +1 -0
- package/dist/ui/components/line-input.d.ts +2 -3
- package/dist/ui/components/line-input.js +4 -20
- package/dist/ui/components/mouse.d.ts +1 -1
- package/dist/ui/components/mouse.js +4 -3
- package/dist/ui/components/prompt-input.d.ts +32 -0
- package/dist/ui/components/prompt-input.js +54 -0
- package/docs/architecture/boundaries.md +1 -0
- package/docs/architecture/history-editor-provenance.md +5 -1
- package/docs/architecture/project-structure.md +1 -0
- package/docs/architecture/prompt-suggestions.md +68 -0
- package/docs/architecture/toolchain.md +1 -1
- package/docs/architecture/ui-reference-provenance.md +23 -0
- package/docs/ci-release-runbook.md +45 -7
- package/docs/features/modal-content-interaction.md +37 -0
- package/package.json +1 -1
|
@@ -8,7 +8,7 @@ export declare class PromptHistoryStore {
|
|
|
8
8
|
#private;
|
|
9
9
|
readonly path: string;
|
|
10
10
|
readonly profileId: string;
|
|
11
|
-
constructor(path: string, profileId: string, limit: number);
|
|
11
|
+
constructor(path: string, profileId: string, limit: number, imagesDir?: string);
|
|
12
12
|
record(input: PromptHistorySubmission): void;
|
|
13
13
|
snapshot(): PromptHistorySnapshot;
|
|
14
14
|
close(): void;
|
|
@@ -3,6 +3,7 @@ import { chmodSync, closeSync, lstatSync, mkdirSync, openSync, statSync } from "
|
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
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
|
+
import { collectImageChipIdentifiers, PromptImageSidecar } from "./image-sidecar.js";
|
|
6
7
|
export class HistoryStorageError extends Error {
|
|
7
8
|
code;
|
|
8
9
|
certainty;
|
|
@@ -17,11 +18,13 @@ export class PromptHistoryStore {
|
|
|
17
18
|
path;
|
|
18
19
|
profileId;
|
|
19
20
|
#database;
|
|
20
|
-
|
|
21
|
+
#sidecar;
|
|
22
|
+
constructor(path, profileId, limit, imagesDir) {
|
|
21
23
|
this.path = path;
|
|
22
24
|
this.profileId = profileId;
|
|
23
25
|
if (!Number.isInteger(limit) || limit < 10 || limit > 100 || limit % 10 !== 0)
|
|
24
26
|
throw new HistoryStorageError("schema");
|
|
27
|
+
this.#sidecar = imagesDir === undefined ? undefined : new PromptImageSidecar(imagesDir);
|
|
25
28
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
26
29
|
if (lstatSync(dirname(path)).isSymbolicLink())
|
|
27
30
|
throw new HistoryStorageError("unavailable");
|
|
@@ -67,6 +70,10 @@ export class PromptHistoryStore {
|
|
|
67
70
|
this.#database.close();
|
|
68
71
|
throw error;
|
|
69
72
|
}
|
|
73
|
+
// Rationale: opportunistically reclaim any sidecar whose row was pruned in a prior process
|
|
74
|
+
// crash or by another concurrent opener. Bounded by the number of files on disk minus the
|
|
75
|
+
// surviving reference set; failures are swallowed to preserve honest history behavior.
|
|
76
|
+
this.#sweepOrphanedSidecars();
|
|
70
77
|
}
|
|
71
78
|
record(input) {
|
|
72
79
|
assertPromptHistorySubmission(input);
|
|
@@ -133,11 +140,48 @@ export class PromptHistoryStore {
|
|
|
133
140
|
#prune(limit) {
|
|
134
141
|
const rows = this.#database.prepare("SELECT sequence,bytes FROM prompts ORDER BY sequence DESC").all();
|
|
135
142
|
let bytes = 0;
|
|
143
|
+
const dropped = [];
|
|
136
144
|
for (const [index, row] of rows.entries()) {
|
|
137
145
|
bytes += Number(row.bytes);
|
|
138
146
|
if (index >= limit || bytes > PROMPT_HISTORY_MAX_TEXT_BYTES)
|
|
139
|
-
|
|
147
|
+
dropped.push(row.sequence);
|
|
140
148
|
}
|
|
149
|
+
if (dropped.length === 0)
|
|
150
|
+
return;
|
|
151
|
+
// Invariant: sidecar reap uses text still referenced by SURVIVING rows so a row still
|
|
152
|
+
// referencing an id shared with a pruned row keeps the file. Collect surviving ids before
|
|
153
|
+
// deleting so a concurrent snapshot cannot see a live row whose sidecar is already gone.
|
|
154
|
+
const droppedTexts = dropped.map(sequence => this.#database.prepare("SELECT text FROM prompts WHERE sequence=?").get(sequence)?.text).filter((value) => typeof value === "string");
|
|
155
|
+
for (const sequence of dropped)
|
|
156
|
+
this.#database.prepare("DELETE FROM prompts WHERE sequence=?").run(sequence);
|
|
157
|
+
if (this.#sidecar === undefined)
|
|
158
|
+
return;
|
|
159
|
+
const survivingIds = this.#collectReferencedIdentifiers();
|
|
160
|
+
for (const text of droppedTexts) {
|
|
161
|
+
for (const id of collectImageChipIdentifiers(text)) {
|
|
162
|
+
if (!survivingIds.has(id))
|
|
163
|
+
this.#sidecar.unlink(id);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
#collectReferencedIdentifiers() {
|
|
168
|
+
const identifiers = new Set();
|
|
169
|
+
const rows = this.#database.prepare("SELECT text FROM prompts").all();
|
|
170
|
+
for (const row of rows) {
|
|
171
|
+
if (typeof row.text !== "string")
|
|
172
|
+
continue;
|
|
173
|
+
for (const id of collectImageChipIdentifiers(row.text))
|
|
174
|
+
identifiers.add(id);
|
|
175
|
+
}
|
|
176
|
+
return identifiers;
|
|
177
|
+
}
|
|
178
|
+
#sweepOrphanedSidecars() {
|
|
179
|
+
if (this.#sidecar === undefined)
|
|
180
|
+
return;
|
|
181
|
+
try {
|
|
182
|
+
this.#sidecar.sweep(this.#collectReferencedIdentifiers());
|
|
183
|
+
}
|
|
184
|
+
catch { /* Rationale: sidecar sweep never blocks history readiness. */ }
|
|
141
185
|
}
|
|
142
186
|
#checkSize(maintain = false) {
|
|
143
187
|
let size = 0;
|
|
@@ -6,7 +6,7 @@ if (port !== null) {
|
|
|
6
6
|
let store;
|
|
7
7
|
let chain = Promise.resolve();
|
|
8
8
|
const options = workerData;
|
|
9
|
-
const open = retry(() => { store = new PromptHistoryStore(options.path, options.profileId, options.limit); });
|
|
9
|
+
const open = retry(() => { store = new PromptHistoryStore(options.path, options.profileId, options.limit, options.imagesDir); });
|
|
10
10
|
void open.then(() => port.postMessage({ id: 0, ok: true }), error => port.postMessage({ id: 0, ok: false, code: classifyHistoryError(error) }));
|
|
11
11
|
port.on("message", (request) => {
|
|
12
12
|
chain = chain.then(async () => {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type SuggestionDiagnosticObserver, type SuggestionDiagnosticRecord } from "../../contracts/owned-ui/index.js";
|
|
2
|
+
export interface SuggestionDiagnosticCaptureOptions {
|
|
3
|
+
readonly enabled?: boolean;
|
|
4
|
+
/** Explicit local destination, or an injected sink for deterministic I/O tests. */
|
|
5
|
+
readonly destination?: string;
|
|
6
|
+
readonly writeSnapshot?: (snapshot: string) => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/** Bounded metadata snapshots; one in-flight write and one replaceable latest snapshot. */
|
|
9
|
+
export declare class SuggestionDiagnosticCapture implements SuggestionDiagnosticObserver {
|
|
10
|
+
#private;
|
|
11
|
+
readonly options: SuggestionDiagnosticCaptureOptions;
|
|
12
|
+
constructor(options?: SuggestionDiagnosticCaptureOptions);
|
|
13
|
+
record(record: SuggestionDiagnosticRecord): void;
|
|
14
|
+
snapshot(): readonly SuggestionDiagnosticRecord[];
|
|
15
|
+
/** Explicit evidence boundary, never awaited by input or rendering. */
|
|
16
|
+
flush(): Promise<void>;
|
|
17
|
+
dispose(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { SUGGESTION_DECISION_REASONS, SUGGESTION_DIAGNOSTIC_EVENTS, } from "../../contracts/owned-ui/index.js";
|
|
3
|
+
/** Bounded metadata snapshots; one in-flight write and one replaceable latest snapshot. */
|
|
4
|
+
export class SuggestionDiagnosticCapture {
|
|
5
|
+
options;
|
|
6
|
+
#records = [];
|
|
7
|
+
#pending;
|
|
8
|
+
#writing;
|
|
9
|
+
#disposed = false;
|
|
10
|
+
#write;
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.options = options;
|
|
13
|
+
this.#write = options.writeSnapshot ?? (options.destination === undefined ? undefined
|
|
14
|
+
: snapshot => writeFile(options.destination, snapshot, { encoding: "utf8", mode: 0o600 }));
|
|
15
|
+
}
|
|
16
|
+
record(record) {
|
|
17
|
+
if (!this.options.enabled || this.#disposed)
|
|
18
|
+
return;
|
|
19
|
+
const safe = sanitize(record);
|
|
20
|
+
if (safe === null)
|
|
21
|
+
return;
|
|
22
|
+
this.#records.push(safe);
|
|
23
|
+
if (this.#records.length > 128)
|
|
24
|
+
this.#records.shift();
|
|
25
|
+
let snapshot = this.#serialize();
|
|
26
|
+
while (Buffer.byteLength(snapshot, "utf8") > 65_536) {
|
|
27
|
+
this.#records.shift();
|
|
28
|
+
snapshot = this.#serialize();
|
|
29
|
+
}
|
|
30
|
+
if (this.#write) {
|
|
31
|
+
this.#pending = snapshot;
|
|
32
|
+
this.#pump();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
snapshot() { return this.#records.map(record => ({ ...record })); }
|
|
36
|
+
/** Explicit evidence boundary, never awaited by input or rendering. */
|
|
37
|
+
async flush() {
|
|
38
|
+
while (this.#writing)
|
|
39
|
+
await this.#writing;
|
|
40
|
+
}
|
|
41
|
+
dispose() {
|
|
42
|
+
this.#disposed = true;
|
|
43
|
+
this.#records = [];
|
|
44
|
+
this.#pending = undefined;
|
|
45
|
+
}
|
|
46
|
+
#serialize() { return JSON.stringify({ schema: "prompt-suggestion-diagnostics-v1", records: this.#records }); }
|
|
47
|
+
#pump() {
|
|
48
|
+
if (this.#writing || this.#pending === undefined || !this.#write || this.#disposed)
|
|
49
|
+
return;
|
|
50
|
+
const snapshot = this.#pending;
|
|
51
|
+
this.#pending = undefined;
|
|
52
|
+
this.#writing = Promise.resolve().then(() => this.#write(snapshot)).catch(() => {
|
|
53
|
+
// Security: do not retain or display raw filesystem/provider errors.
|
|
54
|
+
}).finally(() => {
|
|
55
|
+
this.#writing = undefined;
|
|
56
|
+
this.#pump();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function sanitize(record) {
|
|
61
|
+
if (!SUGGESTION_DIAGNOSTIC_EVENTS.includes(record.event))
|
|
62
|
+
return null;
|
|
63
|
+
const reasoning = ["ordinary", "off", "minimal", "low", "medium", "high", "xhigh", "max", "unavailable"].includes(record.reasoning)
|
|
64
|
+
? record.reasoning : "unavailable";
|
|
65
|
+
const reason = record.reason !== undefined && SUGGESTION_DECISION_REASONS.includes(record.reason) ? record.reason : undefined;
|
|
66
|
+
// Security: explicit projection prevents arbitrary extra fields, including raw error payloads, from escaping.
|
|
67
|
+
return {
|
|
68
|
+
event: record.event, ...(reason === undefined ? {} : { reason }),
|
|
69
|
+
session: boundedNumber(record.session), run: boundedNumber(record.run), response: boundedNumber(record.response),
|
|
70
|
+
request: boundedNumber(record.request), provider: identifier(record.provider), model: identifier(record.model),
|
|
71
|
+
reasoning, elapsedMs: boundedNumber(record.elapsedMs),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function boundedNumber(value) {
|
|
75
|
+
return Number.isFinite(value) ? Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, Math.floor(value))) : 0;
|
|
76
|
+
}
|
|
77
|
+
function identifier(value) {
|
|
78
|
+
// Security: identifiers are metadata, not display names or arbitrary JSON. Reject controls/path-shaped values.
|
|
79
|
+
if (typeof value !== "string" || /[\p{C}\\\s]/u.test(value) || value.includes(":") || value.startsWith("/"))
|
|
80
|
+
return "redacted";
|
|
81
|
+
return value.slice(0, 64);
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./diagnostics.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./diagnostics.js";
|
|
@@ -35,6 +35,8 @@ 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>;
|
|
39
41
|
/**
|
|
40
42
|
* Hand a release the key set its own build reads. A retained pre-cutover release is
|
|
@@ -10,6 +10,8 @@ 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";
|
|
@@ -29,6 +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();
|
|
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;
|
|
32
49
|
// Invariant: records left by cohorts whose processes are gone say nothing about ownership, and there
|
|
33
50
|
// can now be several of them. Clearing them first keeps the decision below about what is
|
|
34
51
|
// actually running.
|
|
@@ -80,10 +97,8 @@ export async function runBootstrap(options) {
|
|
|
80
97
|
return verified;
|
|
81
98
|
});
|
|
82
99
|
await markStartupPhase(environment, "durable-validation-complete");
|
|
83
|
-
const retainedPaths = resolveCohortEndpoint(paths, retained.releaseId, environment);
|
|
84
100
|
await markStartupPhase(environment, "replacement-supervisor-start");
|
|
85
|
-
|
|
86
|
-
await waitForVerifiedEndpoint(retainedPaths.endpointMetadataPath, retained, 8_000, startup);
|
|
101
|
+
await ensureSupervisor(retained, environment);
|
|
87
102
|
await markStartupPhase(environment, "replacement-supervisor-ready");
|
|
88
103
|
return await launchUi(retained, environment, sessionArgs);
|
|
89
104
|
}
|
|
@@ -102,6 +117,10 @@ export async function runBootstrap(options) {
|
|
|
102
117
|
throw error;
|
|
103
118
|
return fallback;
|
|
104
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;
|
|
105
124
|
await stateStore.recordCandidate(candidate);
|
|
106
125
|
state = await stateStore.read();
|
|
107
126
|
if (!state.references.active) {
|
|
@@ -192,8 +211,7 @@ export async function runBootstrap(options) {
|
|
|
192
211
|
else {
|
|
193
212
|
selected = await readMaterializedRelease(decision.releaseRoot);
|
|
194
213
|
}
|
|
195
|
-
|
|
196
|
-
await waitForVerifiedEndpoint(resolveCohortEndpoint(paths, selected.releaseId, environment).endpointMetadataPath, selected, 8_000, startup);
|
|
214
|
+
await ensureSupervisor(selected, environment);
|
|
197
215
|
return await launchUi(selected, environment, sessionArgs);
|
|
198
216
|
}
|
|
199
217
|
/**
|
|
@@ -209,14 +227,7 @@ async function launchRetainedActive(state, paths, environment, sessionArgs, outp
|
|
|
209
227
|
return null;
|
|
210
228
|
try {
|
|
211
229
|
const retained = await readCertifiedReleaseManifest(active, resolve(paths.dataDir, "releases"));
|
|
212
|
-
|
|
213
|
-
const endpoint = await readEndpointMetadata(retainedPaths.endpointMetadataPath);
|
|
214
|
-
if (!endpoint || await probeOwnership(endpoint) !== "live-verified") {
|
|
215
|
-
if (endpoint)
|
|
216
|
-
await removeEndpointArtifacts(retainedPaths.endpointMetadataPath, retainedPaths.endpoint);
|
|
217
|
-
const startup = await startSupervisor(retained, environment);
|
|
218
|
-
await waitForVerifiedEndpoint(retainedPaths.endpointMetadataPath, retained, 8_000, startup);
|
|
219
|
-
}
|
|
230
|
+
await ensureSupervisor(retained, environment);
|
|
220
231
|
output.write(`${PRODUCT_TEXT.diagnostic(`installation is being replaced; starting the retained release ${retained.packageVersion}`)}\n`);
|
|
221
232
|
return await launchUi(retained, environment, sessionArgs);
|
|
222
233
|
}
|
|
@@ -246,6 +257,25 @@ export async function recordParentCertifiedRelease(release, dataDir) {
|
|
|
246
257
|
await chmod(path, 0o400);
|
|
247
258
|
return path;
|
|
248
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
|
+
}
|
|
249
279
|
export async function startSupervisor(release, environment) {
|
|
250
280
|
const entry = await resolveReleaseEntryPoint(release, "bin/supervisor.js");
|
|
251
281
|
const paths = resolveProductPaths(environment);
|
|
@@ -296,10 +326,11 @@ export function releaseEnvironment(environment, release, profile) {
|
|
|
296
326
|
export async function waitForVerifiedEndpoint(path, release, timeoutMs, startup) {
|
|
297
327
|
const deadline = Date.now() + timeoutMs;
|
|
298
328
|
let childOutcome = null;
|
|
329
|
+
let collision = null;
|
|
299
330
|
void startup?.childOutcome.then(outcome => { childOutcome = outcome; });
|
|
300
331
|
while (Date.now() < deadline) {
|
|
301
332
|
const metadata = await readEndpointMetadata(path);
|
|
302
|
-
if (metadata && metadata
|
|
333
|
+
if (metadata && endpointMatchesRelease(metadata, release) && await probeOwnership(metadata) === "live-verified") {
|
|
303
334
|
if (startup)
|
|
304
335
|
await rm(startup.resultPath, { force: true });
|
|
305
336
|
return;
|
|
@@ -307,15 +338,21 @@ export async function waitForVerifiedEndpoint(path, release, timeoutMs, startup)
|
|
|
307
338
|
if (startup) {
|
|
308
339
|
const result = await readSupervisorStartupResult(startup.resultPath, startup.attemptId, startup.releaseId);
|
|
309
340
|
if (result?.outcome === "failure") {
|
|
310
|
-
|
|
341
|
+
const error = Object.assign(new Error(PRODUCT_TEXT.diagnostic(`supervisor startup failed at ${result.stage}: ${result.message}`)), {
|
|
311
342
|
code: result.code ?? "SUPERVISOR_STARTUP_FAILED",
|
|
312
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;
|
|
313
348
|
}
|
|
314
349
|
}
|
|
315
|
-
if (childOutcome)
|
|
350
|
+
if (childOutcome && !collision)
|
|
316
351
|
throw new Error(PRODUCT_TEXT.diagnostic(`supervisor exited before readiness: ${JSON.stringify(childOutcome)}`));
|
|
317
352
|
await new Promise(resolvePromise => setTimeout(resolvePromise, 40));
|
|
318
353
|
}
|
|
354
|
+
if (collision)
|
|
355
|
+
throw collision;
|
|
319
356
|
throw new Error(PRODUCT_TEXT.diagnostic(`supervisor did not publish verified endpoint metadata within ${timeoutMs}ms`));
|
|
320
357
|
}
|
|
321
358
|
export async function readEndpointMetadata(path) {
|
|
@@ -410,8 +447,7 @@ async function activatePendingAfterBlockerExit(candidate, stateStore, paths, env
|
|
|
410
447
|
return;
|
|
411
448
|
await removeEndpointArtifacts(paths.endpointMetadataPath, paths.endpoint);
|
|
412
449
|
await stateStore.activate(candidate.releaseId);
|
|
413
|
-
|
|
414
|
-
await waitForVerifiedEndpoint(resolveCohortEndpoint(paths, candidate.releaseId, environment).endpointMetadataPath, candidate, 8_000, startup);
|
|
450
|
+
await ensureSupervisor(candidate, environment);
|
|
415
451
|
}
|
|
416
452
|
export async function releaseVerifiedIdleOwner(metadata, dataDir, operations = {}) {
|
|
417
453
|
try {
|
|
@@ -10,6 +10,7 @@ export * from "./release-store.js";
|
|
|
10
10
|
export * from "./restart-certification.js";
|
|
11
11
|
export * from "./stable-release.js";
|
|
12
12
|
export * from "./update.js";
|
|
13
|
+
export * from "./update-launch.js";
|
|
13
14
|
export * from "./update-recovery.js";
|
|
14
15
|
export * from "./update-transaction.js";
|
|
15
16
|
export * from "./warmup.js";
|
|
@@ -10,6 +10,7 @@ export * from "./release-store.js";
|
|
|
10
10
|
export * from "./restart-certification.js";
|
|
11
11
|
export * from "./stable-release.js";
|
|
12
12
|
export * from "./update.js";
|
|
13
|
+
export * from "./update-launch.js";
|
|
13
14
|
export * from "./update-recovery.js";
|
|
14
15
|
export * from "./update-transaction.js";
|
|
15
16
|
export * from "./warmup.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CohortState, ReleaseRecord } from "./cohort-state.js";
|
|
2
|
+
import type { UpdateTransaction } from "./update-transaction.js";
|
|
3
|
+
/** Keep interactive launches on the last successful release until the update commits success. */
|
|
4
|
+
export declare function selectUpdateLaunchRelease(state: CohortState, transaction: UpdateTransaction | null): ReleaseRecord | null;
|
|
5
|
+
/** Pause cohort retirement during preparation; afterward only the successful launch release admits new sessions. */
|
|
6
|
+
export declare function selectSupervisorLaunchReleaseId(state: CohortState, transaction: UpdateTransaction | null): string | null;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Keep interactive launches on the last successful release until the update commits success. */
|
|
2
|
+
export function selectUpdateLaunchRelease(state, transaction) {
|
|
3
|
+
if (transaction === null || transaction.status === "completed")
|
|
4
|
+
return null;
|
|
5
|
+
const prior = transaction.priorActiveReleaseId === null ? undefined : state.releases[transaction.priorActiveReleaseId];
|
|
6
|
+
if (!prior || prior.approval !== "approved") {
|
|
7
|
+
throw new Error("unfinished update has no verified previous release available for launch; resume the update");
|
|
8
|
+
}
|
|
9
|
+
return prior;
|
|
10
|
+
}
|
|
11
|
+
/** Pause cohort retirement during preparation; afterward only the successful launch release admits new sessions. */
|
|
12
|
+
export function selectSupervisorLaunchReleaseId(state, transaction) {
|
|
13
|
+
// Concurrency: both the previous cohort and the warming candidate must remain available until cutover.
|
|
14
|
+
if (transaction?.status === "active")
|
|
15
|
+
return null;
|
|
16
|
+
return selectUpdateLaunchRelease(state, transaction)?.releaseId ?? state.references.active;
|
|
17
|
+
}
|
|
@@ -112,5 +112,7 @@ export type UpdateOwnershipAction = "clean-dead-record" | "leave-running" | "end
|
|
|
112
112
|
*/
|
|
113
113
|
export declare function planUpdateOwnership(ownership: "live-verified" | "dead", runsFromRetainedRelease: boolean): UpdateOwnershipAction;
|
|
114
114
|
export declare function createUpdateLifecycleCoordinator(environment?: NodeJS.ProcessEnv, fileSystem?: UpdateFileSystem, output?: UpdateOutput): UpdateLifecycleCoordinator;
|
|
115
|
+
/** Renders the terminal-only update meter; the visual preview shares this exact frame. */
|
|
116
|
+
export declare function renderUpdateProgressBar(percent: number): string;
|
|
115
117
|
export declare function runSelfUpdate(options: SelfUpdateOptions): Promise<number>;
|
|
116
118
|
export declare function assertUpdatePerformanceBudget(evidence: UpdatePerformanceEvidence, maximumPostNpmDurationMs?: number): void;
|
|
@@ -5,7 +5,7 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
|
5
5
|
import crossSpawn from "cross-spawn";
|
|
6
6
|
import { valid as validSemver } from "semver";
|
|
7
7
|
import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
|
|
8
|
-
import { certifyMaterializedRelease, probeOwnership, readEndpointMetadata, removeEndpointArtifacts,
|
|
8
|
+
import { certifyMaterializedRelease, probeOwnership, readEndpointMetadata, removeEndpointArtifacts, ensureSupervisor, waitForProcessExit, } from "./bootstrap.js";
|
|
9
9
|
import { resolveCohortEndpoint, resolveProductPaths } from "../lifecycle/index.js";
|
|
10
10
|
import { encodeFrame, LineFrameDecoder } from "../protocol/index.js";
|
|
11
11
|
import { CohortStateStore } from "./cohort-state.js";
|
|
@@ -163,17 +163,17 @@ export function createUpdateLifecycleCoordinator(environment = process.env, file
|
|
|
163
163
|
const diagnostics = await certifyMaterializedRelease(candidate, paths.dataDir);
|
|
164
164
|
await stateStore.approve(candidate.releaseId, diagnostics);
|
|
165
165
|
await phase("certified");
|
|
166
|
-
await stateStore.activate(candidate.releaseId);
|
|
167
|
-
await phase("active-reference-committed");
|
|
168
166
|
onWarmup?.("started");
|
|
169
167
|
await warmMaterializedRelease(candidate, environment);
|
|
170
168
|
onWarmup?.("completed");
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
await ensureSupervisor(candidate, environment);
|
|
170
|
+
// Invariant: warmup and authenticated readiness precede changing the active reference.
|
|
171
|
+
await stateStore.activate(candidate.releaseId);
|
|
172
|
+
await phase("active-reference-committed");
|
|
173
173
|
},
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
|
-
const PROGRESS_BAR_WIDTH =
|
|
176
|
+
const PROGRESS_BAR_WIDTH = 40;
|
|
177
177
|
const PROGRESS_TICK_MS = 200;
|
|
178
178
|
/**
|
|
179
179
|
* Copying the release owns 78–92 and is reported file by file, so the bar crosses
|
|
@@ -186,10 +186,15 @@ const ACTIVATION_PROGRESS = {
|
|
|
186
186
|
certified: { at: 94, creepTo: 96 },
|
|
187
187
|
"active-reference-committed": { at: 96, creepTo: 99 },
|
|
188
188
|
};
|
|
189
|
-
|
|
189
|
+
/** Renders the terminal-only update meter; the visual preview shares this exact frame. */
|
|
190
|
+
export function renderUpdateProgressBar(percent) {
|
|
190
191
|
const bounded = Math.min(100, Math.max(0, Math.round(percent)));
|
|
191
192
|
const filled = Math.round((bounded / 100) * PROGRESS_BAR_WIDTH);
|
|
192
|
-
|
|
193
|
+
// Rationale: a gray line, a darker gray track, and one space before the percentage.
|
|
194
|
+
// Explicit RGB keeps both grays neutral even when the terminal remaps its ANSI palette.
|
|
195
|
+
const gray = "\u001b[38;2;128;128;128m";
|
|
196
|
+
const track = "\u001b[38;2;102;102;102m";
|
|
197
|
+
return `${gray}${"━".repeat(filled)}${track}${"─".repeat(PROGRESS_BAR_WIDTH - filled)}${gray} ${bounded}%\u001b[39m`;
|
|
193
198
|
}
|
|
194
199
|
function createUpdateProgress(output, enabled) {
|
|
195
200
|
let visible = false;
|
|
@@ -202,7 +207,7 @@ function createUpdateProgress(output, enabled) {
|
|
|
202
207
|
return;
|
|
203
208
|
shown = rounded;
|
|
204
209
|
visible = true;
|
|
205
|
-
output.stdout(`\r${
|
|
210
|
+
output.stdout(`\r${renderUpdateProgressBar(rounded)}`);
|
|
206
211
|
};
|
|
207
212
|
const stopCreep = () => {
|
|
208
213
|
if (timer !== null) {
|
|
@@ -375,11 +380,7 @@ export async function runSelfUpdate(options) {
|
|
|
375
380
|
: async () => await scheduleReleaseCleanup(paths.dataDir, paths));
|
|
376
381
|
let transaction = await transactionStore.read();
|
|
377
382
|
try {
|
|
378
|
-
if (await lifecycle.targetIsActive(targetVersion)) {
|
|
379
|
-
if (transaction?.status === "active") {
|
|
380
|
-
await transactionStore.advance("supervisor-verified");
|
|
381
|
-
await transactionStore.finish("completed");
|
|
382
|
-
}
|
|
383
|
+
if ((!transaction || transaction.status === "completed") && await lifecycle.targetIsActive(targetVersion)) {
|
|
383
384
|
await maintenance();
|
|
384
385
|
await transactionStore.clearCompleted();
|
|
385
386
|
output.stdout(`${PRODUCT_TEXT.commandName} is up to date — no update needed.\n`);
|
|
@@ -510,13 +511,14 @@ export async function runSelfUpdate(options) {
|
|
|
510
511
|
options.onPhaseTiming?.({ phase: "supervisor-verified", durationMs: Math.max(0, now() - activationPhaseStartedAt) });
|
|
511
512
|
const transactionStartedAt = now();
|
|
512
513
|
await transactionStore.advance("supervisor-verified");
|
|
513
|
-
await transactionStore.finish("completed");
|
|
514
514
|
if (transaction.recovery?.capsulePath)
|
|
515
515
|
await removeUpdateRecoveryCapsule(paths.dataDir, transaction.transactionId);
|
|
516
516
|
// Invariant: successful output follows the durable cleanup disposition. Slow recursive
|
|
517
517
|
// removal belongs to the detached worker started by this maintenance coordinator.
|
|
518
518
|
await maintenance();
|
|
519
|
-
|
|
519
|
+
// Concurrency: this is the launch cutover. No failure-prone update work follows success.
|
|
520
|
+
await transactionStore.finish("completed");
|
|
521
|
+
await transactionStore.clearCompleted().catch(() => undefined);
|
|
520
522
|
options.onPhaseTiming?.({ phase: "transaction-complete", durationMs: Math.max(0, now() - transactionStartedAt) });
|
|
521
523
|
progress.finish();
|
|
522
524
|
output.stdout(`${PRODUCT_TEXT.commandName} updated successfully: ${targetVersion}\n`);
|
|
@@ -609,11 +611,8 @@ async function rollbackPriorCohort(dataDir, environment, priorReleaseId) {
|
|
|
609
611
|
throw new Error(`prior release ${priorReleaseId} is not the recorded rollback cohort`);
|
|
610
612
|
}
|
|
611
613
|
const release = await readMaterializedRelease(prior.releaseRoot);
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
// a cohort that survived the update keeps serving the work it already had.
|
|
615
|
-
const startup = await startSupervisor(release, environment);
|
|
616
|
-
await waitForVerifiedEndpoint(resolveCohortEndpoint(paths, release.releaseId, environment).endpointMetadataPath, release, 8_000, startup);
|
|
614
|
+
// Invariant: rollback reuses a surviving prior cohort rather than racing its occupied endpoint.
|
|
615
|
+
await ensureSupervisor(release, environment);
|
|
617
616
|
return "rolled back";
|
|
618
617
|
}
|
|
619
618
|
function phaseBefore(current, target) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
-
import { assertImmutableExecutionRoot, CohortStateStore, readCertifiedReleaseManifest, recordParentCertifiedRelease } from "../release/index.js";
|
|
4
|
+
import { assertImmutableExecutionRoot, CohortStateStore, readCertifiedReleaseManifest, recordParentCertifiedRelease, selectSupervisorLaunchReleaseId, UpdateTransactionStore } from "../release/index.js";
|
|
5
5
|
import { publishSupervisorStartupResult, supervisorStartupFailure, supervisorStartupReady, supervisorStartupResultPath } from "../lifecycle/index.js";
|
|
6
6
|
import { ControlStore } from "../storage/index.js";
|
|
7
7
|
import { resolveCohortEndpoint, resolveProductPaths } from "./paths.js";
|
|
@@ -38,7 +38,10 @@ export async function runSupervisor(arguments_ = []) {
|
|
|
38
38
|
const bootNonce = randomUUID();
|
|
39
39
|
const store = new ControlStore(paths.databasePath, bootNonce);
|
|
40
40
|
const cohortState = new CohortStateStore(paths.dataDir);
|
|
41
|
-
server = new SupervisorServer(store, paths, release, bootNonce, undefined, undefined, undefined, async () =>
|
|
41
|
+
server = new SupervisorServer(store, paths, release, bootNonce, undefined, undefined, undefined, async () => {
|
|
42
|
+
const transaction = await new UpdateTransactionStore(paths.dataDir).read();
|
|
43
|
+
return selectSupervisorLaunchReleaseId(await cohortState.read(), transaction);
|
|
44
|
+
});
|
|
42
45
|
stage = "endpoint-listen";
|
|
43
46
|
await server.listen();
|
|
44
47
|
if (resultPath && attemptId)
|