@timurproko/a1 0.1.1-dev.1 → 0.1.1-dev.10
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 +8 -43
- package/bin/guardian.js +22 -0
- package/bin/{a1-ui.js → ui.js} +4 -8
- package/dist/native/darwin-arm64/manifest.json +20 -0
- package/dist/native/darwin-arm64/process-guardian +0 -0
- package/dist/native/linux-x64/manifest.json +20 -0
- package/dist/native/linux-x64/process-guardian +0 -0
- package/dist/native/win32-x64/manifest.json +20 -0
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/dist/src/composition/index.d.ts +26 -0
- package/dist/src/composition/index.js +94 -2
- package/dist/src/features/launch/initialize-profile.js +23 -2
- package/dist/src/features/launch/profiles.d.ts +1 -1
- package/dist/src/features/launch/profiles.js +2 -2
- package/dist/src/features/launch/runtime-selection.d.ts +13 -8
- package/dist/src/features/launch/runtime-selection.js +6 -6
- package/dist/src/features/owned-ui/index.d.ts +1 -0
- package/dist/src/features/owned-ui/index.js +1 -0
- package/dist/src/features/owned-ui/run.d.ts +6 -0
- package/dist/src/features/owned-ui/run.js +3 -1
- package/dist/src/features/owned-ui/settings-app.d.ts +22 -0
- package/dist/src/features/owned-ui/settings-app.js +633 -0
- package/dist/src/foundation/agent-engine-contracts/domain.d.ts +26 -0
- package/dist/src/foundation/launch-guardian/index.d.ts +1 -0
- package/dist/src/foundation/launch-guardian/index.js +1 -0
- package/dist/src/foundation/launch-guardian/main.d.ts +31 -0
- package/dist/src/foundation/launch-guardian/main.js +165 -0
- package/dist/src/foundation/lifecycle/commands.d.ts +40 -0
- package/dist/src/foundation/lifecycle/commands.js +1 -0
- package/dist/src/foundation/lifecycle/index.d.ts +2 -0
- package/dist/src/foundation/lifecycle/index.js +2 -0
- package/dist/src/foundation/lifecycle/launch-instance.d.ts +46 -0
- package/dist/src/foundation/lifecycle/launch-instance.js +134 -0
- package/dist/src/foundation/lifecycle/model.d.ts +6 -42
- package/dist/src/foundation/owned-ui-settings/declarations.d.ts +13 -0
- package/dist/src/foundation/owned-ui-settings/declarations.js +39 -0
- package/dist/src/foundation/owned-ui-settings/index.d.ts +6 -0
- package/dist/src/foundation/owned-ui-settings/index.js +6 -0
- package/dist/src/foundation/owned-ui-settings/migrations.d.ts +13 -0
- package/dist/src/foundation/owned-ui-settings/migrations.js +28 -0
- package/dist/src/foundation/owned-ui-settings/resolution.d.ts +45 -0
- package/dist/src/foundation/owned-ui-settings/resolution.js +147 -0
- package/dist/src/foundation/owned-ui-settings/sections.d.ts +70 -0
- package/dist/src/foundation/owned-ui-settings/sections.js +93 -0
- package/dist/src/foundation/owned-ui-settings/session.d.ts +44 -0
- package/dist/src/foundation/owned-ui-settings/session.js +145 -0
- package/dist/src/foundation/owned-ui-settings/store.d.ts +28 -0
- package/dist/src/foundation/owned-ui-settings/store.js +90 -0
- package/dist/src/foundation/pi-component-adapter/index.d.ts +1 -0
- package/dist/src/foundation/pi-component-adapter/index.js +1 -0
- package/dist/src/foundation/pi-component-adapter/shell-selectors-dialogs.d.ts +9 -1
- package/dist/src/foundation/pi-component-adapter/shell-selectors-dialogs.js +10 -8
- package/dist/src/foundation/pi-component-adapter/upstream/theme/theme.d.ts +8 -0
- package/dist/src/foundation/pi-component-adapter/upstream/theme/theme.js +12 -0
- package/dist/src/foundation/pi-engine-adapter/adapter.d.ts +17 -3
- package/dist/src/foundation/pi-engine-adapter/adapter.js +80 -7
- package/dist/src/foundation/pi-engine-adapter/pi-settings-metadata.json +374 -0
- package/dist/src/foundation/pi-engine-adapter/public-main-entry.js +10 -1
- package/dist/src/foundation/pi-engine-adapter/settings-integration.d.ts +40 -2
- package/dist/src/foundation/pi-engine-adapter/settings-integration.js +172 -24
- package/dist/src/foundation/pi-engine-adapter/workflows.d.ts +9 -0
- package/dist/src/foundation/pi-owned-ui-integration/index.d.ts +1 -0
- package/dist/src/foundation/pi-owned-ui-integration/index.js +1 -0
- package/dist/src/foundation/pi-owned-ui-integration/route-host.d.ts +29 -0
- package/dist/src/foundation/pi-owned-ui-integration/route-host.js +1 -0
- package/dist/src/foundation/pi-owned-ui-integration/session-shell.d.ts +6 -0
- package/dist/src/foundation/pi-owned-ui-integration/session-shell.js +71 -1
- package/dist/src/foundation/pi-tui-runtime-adapter/adapter.d.ts +6 -0
- package/dist/src/foundation/pi-tui-runtime-adapter/adapter.js +9 -0
- package/dist/src/foundation/process-containment/artifact.d.ts +1 -0
- package/dist/src/foundation/process-containment/artifact.js +34 -0
- package/dist/src/foundation/process-containment/contracts.d.ts +31 -0
- package/dist/src/foundation/process-containment/contracts.js +35 -0
- package/dist/src/foundation/process-containment/index.d.ts +5 -0
- package/dist/src/foundation/process-containment/index.js +5 -0
- package/dist/src/foundation/process-containment/linux-process-inspector.d.ts +6 -0
- package/dist/src/foundation/process-containment/linux-process-inspector.js +26 -0
- package/dist/src/foundation/process-containment/native-guardian-containment.d.ts +16 -0
- package/dist/src/foundation/process-containment/native-guardian-containment.js +139 -0
- package/dist/src/foundation/process-containment/windows-process-inspector.d.ts +17 -0
- package/dist/src/foundation/process-containment/windows-process-inspector.js +53 -0
- package/dist/src/foundation/protocol/client.d.ts +2 -1
- package/dist/src/foundation/protocol/client.js +3 -0
- package/dist/src/foundation/protocol/messages.d.ts +5 -5
- package/dist/src/foundation/protocol/messages.js +1 -1
- package/dist/src/foundation/release/bootstrap.d.ts +2 -2
- package/dist/src/foundation/release/bootstrap.js +69 -18
- package/dist/src/foundation/release/cohort-selection.js +3 -3
- package/dist/src/foundation/release/cohort-state.d.ts +3 -2
- package/dist/src/foundation/release/concurrency.d.ts +1 -0
- package/dist/src/foundation/release/concurrency.js +17 -0
- package/dist/src/foundation/release/process-cleanup.d.ts +1 -1
- package/dist/src/foundation/release/process-cleanup.js +2 -2
- package/dist/src/foundation/release/release-store.d.ts +35 -2
- package/dist/src/foundation/release/release-store.js +90 -31
- package/dist/src/foundation/release/release.d.ts +15 -0
- package/dist/src/foundation/release/release.js +65 -36
- package/dist/src/foundation/release/update.d.ts +19 -2
- package/dist/src/foundation/release/update.js +132 -16
- package/dist/src/foundation/storage/control-store.d.ts +8 -7
- package/dist/src/foundation/storage/control-store.js +105 -55
- package/dist/src/foundation/supervision/main.js +6 -5
- package/dist/src/foundation/supervision/server.d.ts +4 -2
- package/dist/src/foundation/supervision/server.js +254 -87
- package/dist/src/foundation/ui-apps/contracts.d.ts +42 -0
- package/dist/src/foundation/ui-apps/contracts.js +12 -0
- package/dist/src/foundation/ui-apps/host.d.ts +42 -0
- package/dist/src/foundation/ui-apps/host.js +152 -0
- package/dist/src/foundation/ui-apps/index.d.ts +3 -0
- package/dist/src/foundation/ui-apps/index.js +3 -0
- package/dist/src/foundation/ui-apps/registry.d.ts +11 -0
- package/dist/src/foundation/ui-apps/registry.js +32 -0
- package/dist/src/foundation/ui-components/dialog-panel.d.ts +36 -0
- package/dist/src/foundation/ui-components/dialog-panel.js +47 -0
- package/dist/src/foundation/ui-components/frame.d.ts +21 -0
- package/dist/src/foundation/ui-components/frame.js +54 -0
- package/dist/src/foundation/ui-components/index.d.ts +18 -0
- package/dist/src/foundation/ui-components/index.js +18 -0
- package/dist/src/foundation/ui-components/label.d.ts +8 -0
- package/dist/src/foundation/ui-components/label.js +36 -0
- package/dist/src/foundation/ui-components/line-input.d.ts +74 -0
- package/dist/src/foundation/ui-components/line-input.js +245 -0
- package/dist/src/foundation/ui-components/list-block.d.ts +66 -0
- package/dist/src/foundation/ui-components/list-block.js +176 -0
- package/dist/src/foundation/ui-components/list-view.d.ts +66 -0
- package/dist/src/foundation/ui-components/list-view.js +76 -0
- package/dist/src/foundation/ui-components/mouse.d.ts +15 -0
- package/dist/src/foundation/ui-components/mouse.js +46 -0
- package/dist/src/foundation/ui-components/pane.d.ts +33 -0
- package/dist/src/foundation/ui-components/pane.js +7 -0
- package/dist/src/foundation/ui-components/revision.d.ts +35 -0
- package/dist/src/foundation/ui-components/revision.js +79 -0
- package/dist/src/foundation/ui-components/scrollbar.d.ts +62 -0
- package/dist/src/foundation/ui-components/scrollbar.js +102 -0
- package/dist/src/foundation/ui-components/shortcuts.d.ts +57 -0
- package/dist/src/foundation/ui-components/shortcuts.js +81 -0
- package/dist/src/foundation/ui-components/spans.d.ts +7 -0
- package/dist/src/foundation/ui-components/spans.js +59 -0
- package/dist/src/foundation/ui-components/status-line.d.ts +16 -0
- package/dist/src/foundation/ui-components/status-line.js +14 -0
- package/dist/src/foundation/ui-components/stepper.d.ts +24 -0
- package/dist/src/foundation/ui-components/stepper.js +33 -0
- package/dist/src/foundation/ui-components/surface.d.ts +20 -0
- package/dist/src/foundation/ui-components/surface.js +38 -0
- package/dist/src/foundation/ui-components/text.d.ts +24 -0
- package/dist/src/foundation/ui-components/text.js +123 -0
- package/dist/src/foundation/ui-components/theme.d.ts +23 -0
- package/dist/src/foundation/ui-components/theme.js +13 -0
- package/dist/src/foundation/ui-components/value-menu.d.ts +41 -0
- package/dist/src/foundation/ui-components/value-menu.js +33 -0
- package/dist/src/product-identity.d.ts +2 -2
- package/dist/src/product-identity.js +2 -1
- package/dist/src/product-identity.json +8 -5
- package/docs/architecture/boundaries.md +31 -13
- package/docs/architecture/process-guardian-provenance.md +26 -0
- package/docs/architecture/toolchain.md +9 -3
- package/docs/architecture/ui-reference-provenance.md +50 -0
- package/docs/ci-release-runbook.md +67 -0
- package/docs/features/launch-profiles.md +7 -1
- package/docs/manual-launch-instance-acceptance.md +66 -0
- package/docs/manual-terminal-colour-check.md +55 -0
- package/package.json +20 -14
- package/dist/src/composition/transparent-runtime.d.ts +0 -6
- package/dist/src/composition/transparent-runtime.js +0 -19
- package/dist/src/foundation/transparent-terminal/command-resolution.d.ts +0 -13
- package/dist/src/foundation/transparent-terminal/command-resolution.js +0 -69
- package/dist/src/foundation/transparent-terminal/foreground-broker.d.ts +0 -32
- package/dist/src/foundation/transparent-terminal/foreground-broker.js +0 -87
- package/dist/src/foundation/transparent-terminal/index.d.ts +0 -4
- package/dist/src/foundation/transparent-terminal/index.js +0 -4
- package/dist/src/foundation/transparent-terminal/main.d.ts +0 -9
- package/dist/src/foundation/transparent-terminal/main.js +0 -94
- package/dist/src/foundation/transparent-terminal/native-launcher.d.ts +0 -25
- package/dist/src/foundation/transparent-terminal/native-launcher.js +0 -146
- /package/bin/{a1.js → cli.js} +0 -0
- /package/bin/{a1-supervisor.js → supervisor.js} +0 -0
|
@@ -2,8 +2,9 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { createConnection, createServer } from "node:net";
|
|
4
4
|
import { platform } from "node:os";
|
|
5
|
-
import { assertNativeProcessIdentity,
|
|
5
|
+
import { assertLaunchInstanceOutcome, assertNativeProcessIdentity, assertProcessContainmentIdentity, } from "../lifecycle/index.js";
|
|
6
6
|
import { encodeFrame, isCommandMessage, isControlHello, LineFrameDecoder, localControlHello, MAX_CONTROL_FRAME_BYTES, negotiateControlFeatures } from "../protocol/index.js";
|
|
7
|
+
import { processIsAlive } from "../release/index.js";
|
|
7
8
|
import { ControlStore } from "../storage/index.js";
|
|
8
9
|
import { resolveProductPaths } from "./paths.js";
|
|
9
10
|
import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
|
|
@@ -11,27 +12,45 @@ export class SupervisorServer {
|
|
|
11
12
|
store;
|
|
12
13
|
release;
|
|
13
14
|
terminateProcess;
|
|
15
|
+
reconciliationDeadlineMs;
|
|
16
|
+
shutdownDeadlineMs;
|
|
14
17
|
id = randomUUID();
|
|
15
18
|
bootNonce;
|
|
16
19
|
pidStartIdentity = `${process.pid}:${Math.floor(Date.now() - process.uptime() * 1_000)}`;
|
|
17
20
|
startedAt = new Date().toISOString();
|
|
18
21
|
paths;
|
|
19
22
|
#server = null;
|
|
20
|
-
#
|
|
23
|
+
#instances = new Map();
|
|
24
|
+
#instanceOwners = new Map();
|
|
25
|
+
#clientIds = new Map();
|
|
26
|
+
#clientInstances = new Map();
|
|
27
|
+
#uncertainInstances = new Set();
|
|
28
|
+
#reconciliations = new Map();
|
|
21
29
|
#revision = 0;
|
|
22
30
|
#clients = new Set();
|
|
23
31
|
#results = new Map();
|
|
24
32
|
#metadataWrites = Promise.resolve();
|
|
25
|
-
|
|
33
|
+
#closing = false;
|
|
34
|
+
constructor(store, paths = resolveProductPaths(), release, bootNonce = randomUUID(), terminateProcess = code => process.exit(code), reconciliationDeadlineMs = 3_000, shutdownDeadlineMs = 1_500) {
|
|
26
35
|
this.store = store;
|
|
27
36
|
this.release = release;
|
|
28
37
|
this.terminateProcess = terminateProcess;
|
|
38
|
+
this.reconciliationDeadlineMs = reconciliationDeadlineMs;
|
|
39
|
+
this.shutdownDeadlineMs = shutdownDeadlineMs;
|
|
29
40
|
this.bootNonce = bootNonce;
|
|
30
41
|
this.paths = paths;
|
|
31
|
-
|
|
42
|
+
for (const instance of store.loadActiveLaunchInstances())
|
|
43
|
+
this.#instances.set(instance.id, instance);
|
|
32
44
|
}
|
|
33
45
|
snapshot() {
|
|
34
|
-
return {
|
|
46
|
+
return {
|
|
47
|
+
revision: this.#revision,
|
|
48
|
+
activeInstances: [...this.#instances.values()].flatMap(instance => {
|
|
49
|
+
if (instance.state !== "requested" && instance.state !== "active" && instance.state !== "stopping")
|
|
50
|
+
return [];
|
|
51
|
+
return [{ id: instance.id, profileId: instance.profileId, state: instance.state }];
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
35
54
|
}
|
|
36
55
|
async listen() {
|
|
37
56
|
if (this.#server)
|
|
@@ -54,8 +73,10 @@ export class SupervisorServer {
|
|
|
54
73
|
await this.#writeEndpointMetadata();
|
|
55
74
|
}
|
|
56
75
|
async close(stopAgents = false) {
|
|
57
|
-
if (stopAgents && this.#
|
|
58
|
-
|
|
76
|
+
if (stopAgents && !await this.#drainInstances("update", this.shutdownDeadlineMs)) {
|
|
77
|
+
throw new Error("active launch instances did not release ownership within the shutdown deadline");
|
|
78
|
+
}
|
|
79
|
+
this.#closing = true;
|
|
59
80
|
for (const client of this.#clients)
|
|
60
81
|
client.destroy();
|
|
61
82
|
if (this.#server)
|
|
@@ -86,13 +107,13 @@ export class SupervisorServer {
|
|
|
86
107
|
if (isMessageType(value, "release-idle-ownership")) {
|
|
87
108
|
const request = value;
|
|
88
109
|
const identityMatches = request.bootNonce === this.bootNonce && typeof request.candidateReleaseId === "string";
|
|
89
|
-
const released = identityMatches && this.#
|
|
110
|
+
const released = identityMatches && this.#instances.size === 0;
|
|
90
111
|
this.#send(socket, {
|
|
91
112
|
type: "release-ownership-result",
|
|
92
113
|
released,
|
|
93
114
|
reason: released ? "idle cohort released ownership"
|
|
94
|
-
: identityMatches ? "
|
|
95
|
-
|
|
115
|
+
: identityMatches ? "launch instances are still active" : "boot nonce or candidate release mismatch",
|
|
116
|
+
liveInstanceIds: this.#liveInstanceIds(),
|
|
96
117
|
});
|
|
97
118
|
if (released)
|
|
98
119
|
setTimeout(() => void this.closeForReleaseReplacement(false), 25);
|
|
@@ -100,15 +121,7 @@ export class SupervisorServer {
|
|
|
100
121
|
}
|
|
101
122
|
if (isMessageType(value, "release-update-ownership")) {
|
|
102
123
|
const request = value;
|
|
103
|
-
|
|
104
|
-
this.#send(socket, {
|
|
105
|
-
type: "release-update-result",
|
|
106
|
-
accepted,
|
|
107
|
-
reason: accepted ? `verified ${PRODUCT_TEXT.displayName} owner accepted immediate update shutdown` : "boot nonce or target version mismatch",
|
|
108
|
-
liveGenerationIds: this.#liveGenerationIds(),
|
|
109
|
-
});
|
|
110
|
-
if (accepted)
|
|
111
|
-
setTimeout(() => void this.closeForReleaseReplacement(true), 25);
|
|
124
|
+
void this.#handleUpdateRelease(socket, request);
|
|
112
125
|
continue;
|
|
113
126
|
}
|
|
114
127
|
if (!isControlHello(value)) {
|
|
@@ -125,6 +138,8 @@ export class SupervisorServer {
|
|
|
125
138
|
}
|
|
126
139
|
welcomed = true;
|
|
127
140
|
this.#clients.add(socket);
|
|
141
|
+
this.#clientIds.set(socket, value.clientId);
|
|
142
|
+
this.#clientInstances.set(socket, new Set());
|
|
128
143
|
this.#send(socket, { type: "server-hello", ...serverHello, releaseId: this.release.releaseId, supervisorId: this.id, bootNonce: this.bootNonce, pidStartIdentity: this.pidStartIdentity, negotiatedFeatures: negotiation.negotiatedFeatures, snapshot: this.snapshot() });
|
|
129
144
|
}
|
|
130
145
|
else if (isCommandMessage(value)) {
|
|
@@ -137,25 +152,35 @@ export class SupervisorServer {
|
|
|
137
152
|
socket.destroy();
|
|
138
153
|
}
|
|
139
154
|
});
|
|
140
|
-
socket.on("close", () => this.#
|
|
141
|
-
socket.on("error", () => this.#
|
|
155
|
+
socket.on("close", () => this.#onSocketClosed(socket));
|
|
156
|
+
socket.on("error", () => this.#onSocketClosed(socket));
|
|
142
157
|
}
|
|
143
158
|
async #execute(command, socket) {
|
|
144
|
-
const
|
|
159
|
+
const clientId = this.#clientIds.get(socket);
|
|
160
|
+
if (!clientId) {
|
|
161
|
+
this.#send(socket, { type: "protocol-error", code: "unauthenticated-command", message: "launch-instance commands require an authenticated client" });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const resultKey = `${clientId}:${command.requestId}`;
|
|
165
|
+
const recorded = this.#results.get(resultKey);
|
|
145
166
|
if (recorded) {
|
|
146
167
|
this.#send(socket, { type: "command-result", result: recorded });
|
|
147
168
|
return;
|
|
148
169
|
}
|
|
149
170
|
let result;
|
|
150
171
|
try {
|
|
151
|
-
if (command.type === "
|
|
152
|
-
this.#
|
|
153
|
-
if (command.type === "activate-
|
|
154
|
-
this.#
|
|
155
|
-
if (command.type === "
|
|
156
|
-
this.#
|
|
157
|
-
if (command.type === "
|
|
158
|
-
this.#
|
|
172
|
+
if (command.type === "create-launch-instance")
|
|
173
|
+
this.#createLaunchInstance(command, socket, clientId);
|
|
174
|
+
if (command.type === "activate-launch-instance")
|
|
175
|
+
this.#activateLaunchInstance(command, socket, clientId);
|
|
176
|
+
if (command.type === "begin-launch-instance-stop")
|
|
177
|
+
this.#beginLaunchInstanceStop(command, socket, clientId);
|
|
178
|
+
if (command.type === "complete-launch-instance")
|
|
179
|
+
this.#completeLaunchInstance(command, socket, clientId);
|
|
180
|
+
if (command.type === "reconcile-launch-instance") {
|
|
181
|
+
this.#ownedInstance(command.instanceId, socket, clientId);
|
|
182
|
+
await this.#reconcileLaunchInstance(command.instanceId);
|
|
183
|
+
}
|
|
159
184
|
if (command.type === "resynchronize")
|
|
160
185
|
this.#send(socket, { type: "snapshot", snapshot: this.snapshot() });
|
|
161
186
|
await this.#metadataWrites;
|
|
@@ -163,75 +188,210 @@ export class SupervisorServer {
|
|
|
163
188
|
}
|
|
164
189
|
catch (error) {
|
|
165
190
|
const message = error instanceof Error ? error.message : String(error);
|
|
166
|
-
const code = /not found/i.test(message) ? "not-found" : "driver-error";
|
|
191
|
+
const code = /not found/i.test(message) ? "not-found" : /owner|ownership|authenticated/i.test(message) ? "ownership-error" : "driver-error";
|
|
167
192
|
result = { requestId: command.requestId, ok: false, revision: this.#revision, error: { code, message } };
|
|
168
193
|
}
|
|
169
|
-
this.#results.set(
|
|
194
|
+
this.#results.set(resultKey, result);
|
|
170
195
|
this.#send(socket, { type: "command-result", result });
|
|
171
196
|
}
|
|
172
|
-
#
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
197
|
+
#createLaunchInstance(command, socket, clientId) {
|
|
198
|
+
assertNativeProcessIdentity(command.guardianIdentity);
|
|
199
|
+
const existing = this.store.loadLaunchInstance(command.instanceId);
|
|
200
|
+
if (existing) {
|
|
201
|
+
if (existing.ownerClientId !== clientId
|
|
202
|
+
|| existing.profileId !== command.profileId
|
|
203
|
+
|| existing.shutdownPolicy !== command.shutdownPolicy
|
|
204
|
+
|| !sameProcessIdentity(existing.guardianIdentity, command.guardianIdentity)) {
|
|
205
|
+
throw new Error("launch instance already exists with different authenticated ownership");
|
|
206
|
+
}
|
|
207
|
+
if (existing.state === "completed" || existing.state === "interrupted")
|
|
208
|
+
throw new Error("launch instance already has a terminal outcome");
|
|
209
|
+
this.#instances.set(existing.id, existing);
|
|
210
|
+
this.#bindOwner(existing.id, socket);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const instance = {
|
|
214
|
+
id: command.instanceId,
|
|
215
|
+
ownerClientId: clientId,
|
|
216
|
+
profileId: command.profileId,
|
|
180
217
|
state: "requested",
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
218
|
+
shutdownPolicy: command.shutdownPolicy,
|
|
219
|
+
guardianIdentity: command.guardianIdentity,
|
|
220
|
+
rootIdentity: null,
|
|
221
|
+
containmentIdentity: null,
|
|
222
|
+
createdAt: new Date().toISOString(),
|
|
223
|
+
activatedAt: null,
|
|
224
|
+
stoppingAt: null,
|
|
225
|
+
completedAt: null,
|
|
186
226
|
outcome: null,
|
|
187
227
|
};
|
|
188
|
-
this.store.
|
|
189
|
-
this.#
|
|
190
|
-
|
|
228
|
+
this.store.createLaunchInstance(instance);
|
|
229
|
+
this.#instances.set(instance.id, instance);
|
|
230
|
+
this.#bindOwner(instance.id, socket);
|
|
231
|
+
this.#mutated();
|
|
191
232
|
}
|
|
192
|
-
#
|
|
193
|
-
assertNativeProcessIdentity(command.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
233
|
+
#activateLaunchInstance(command, socket, clientId) {
|
|
234
|
+
assertNativeProcessIdentity(command.rootIdentity);
|
|
235
|
+
assertProcessContainmentIdentity(command.containmentIdentity);
|
|
236
|
+
const instance = this.#ownedInstance(command.instanceId, socket, clientId);
|
|
237
|
+
if (instance.state === "active"
|
|
238
|
+
&& sameProcessIdentity(instance.rootIdentity, command.rootIdentity)
|
|
239
|
+
&& sameContainmentIdentity(instance.containmentIdentity, command.containmentIdentity))
|
|
240
|
+
return;
|
|
241
|
+
if (instance.state !== "requested")
|
|
242
|
+
throw new Error("launch instance is not requested");
|
|
243
|
+
const activatedAt = new Date().toISOString();
|
|
244
|
+
if (!this.store.activateLaunchInstance(instance.id, clientId, command.rootIdentity, command.containmentIdentity, activatedAt)) {
|
|
245
|
+
throw new Error("launch instance activation ownership mismatch");
|
|
246
|
+
}
|
|
247
|
+
this.#instances.set(instance.id, { ...instance, state: "active", rootIdentity: command.rootIdentity, containmentIdentity: command.containmentIdentity, activatedAt });
|
|
248
|
+
this.#mutated();
|
|
201
249
|
}
|
|
202
|
-
#
|
|
203
|
-
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
250
|
+
#beginLaunchInstanceStop(command, socket, clientId) {
|
|
251
|
+
const instance = this.#ownedInstance(command.instanceId, socket, clientId);
|
|
252
|
+
if (instance.state === "stopping")
|
|
253
|
+
return;
|
|
254
|
+
if (instance.state !== "active")
|
|
255
|
+
throw new Error("active launch instance not found");
|
|
256
|
+
const stoppingAt = new Date().toISOString();
|
|
257
|
+
if (!this.store.beginLaunchInstanceStop(instance.id, clientId, stoppingAt))
|
|
258
|
+
throw new Error("launch instance stop ownership mismatch");
|
|
259
|
+
this.#instances.set(instance.id, { ...instance, state: "stopping", stoppingAt });
|
|
260
|
+
this.#mutated();
|
|
210
261
|
}
|
|
211
|
-
#
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (!
|
|
215
|
-
throw new Error("
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
262
|
+
#completeLaunchInstance(command, socket, clientId) {
|
|
263
|
+
assertLaunchInstanceOutcome(command.outcome);
|
|
264
|
+
const stored = this.store.loadLaunchInstance(command.instanceId);
|
|
265
|
+
if (!stored)
|
|
266
|
+
throw new Error("launch instance not found");
|
|
267
|
+
if (stored.ownerClientId !== clientId || this.#instanceOwners.get(stored.id) !== socket)
|
|
268
|
+
throw new Error("launch instance ownership mismatch");
|
|
269
|
+
if (stored.state === "completed" || stored.state === "interrupted") {
|
|
270
|
+
if (stored.state === command.terminalState && JSON.stringify(stored.outcome) === JSON.stringify(command.outcome))
|
|
271
|
+
return;
|
|
272
|
+
throw new Error("launch instance already has a different terminal outcome");
|
|
273
|
+
}
|
|
274
|
+
const completedAt = new Date().toISOString();
|
|
275
|
+
if (!this.store.completeLaunchInstance(stored.id, clientId, command.terminalState, command.outcome, completedAt)) {
|
|
276
|
+
throw new Error("launch instance completion ownership mismatch");
|
|
277
|
+
}
|
|
278
|
+
this.#instances.delete(stored.id);
|
|
279
|
+
this.#uncertainInstances.delete(stored.id);
|
|
280
|
+
this.#unbindOwner(stored.id, socket);
|
|
281
|
+
this.#mutated();
|
|
282
|
+
}
|
|
283
|
+
#ownedInstance(instanceId, socket, clientId) {
|
|
284
|
+
const instance = this.#instances.get(instanceId) ?? this.store.loadLaunchInstance(instanceId);
|
|
285
|
+
if (!instance || instance.state === "completed" || instance.state === "interrupted")
|
|
286
|
+
throw new Error("active launch instance not found");
|
|
287
|
+
if (instance.ownerClientId !== clientId || this.#instanceOwners.get(instanceId) !== socket)
|
|
288
|
+
throw new Error("launch instance ownership mismatch");
|
|
289
|
+
return instance;
|
|
290
|
+
}
|
|
291
|
+
#bindOwner(instanceId, socket) {
|
|
292
|
+
const current = this.#instanceOwners.get(instanceId);
|
|
293
|
+
if (current && current !== socket)
|
|
294
|
+
throw new Error("launch instance already belongs to another authenticated connection");
|
|
295
|
+
this.#instanceOwners.set(instanceId, socket);
|
|
296
|
+
this.#clientInstances.get(socket)?.add(instanceId);
|
|
297
|
+
}
|
|
298
|
+
#unbindOwner(instanceId, socket) {
|
|
299
|
+
this.#instanceOwners.delete(instanceId);
|
|
300
|
+
this.#clientInstances.get(socket)?.delete(instanceId);
|
|
301
|
+
}
|
|
302
|
+
#onSocketClosed(socket) {
|
|
303
|
+
if (!this.#clients.delete(socket) && !this.#clientIds.has(socket))
|
|
304
|
+
return;
|
|
305
|
+
const instanceIds = [...(this.#clientInstances.get(socket) ?? [])];
|
|
306
|
+
this.#clientInstances.delete(socket);
|
|
307
|
+
this.#clientIds.delete(socket);
|
|
308
|
+
for (const instanceId of instanceIds) {
|
|
309
|
+
if (this.#instanceOwners.get(instanceId) === socket)
|
|
310
|
+
this.#instanceOwners.delete(instanceId);
|
|
311
|
+
if (!this.#closing)
|
|
312
|
+
void this.#reconcileLaunchInstance(instanceId);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
#reconcileLaunchInstance(instanceId) {
|
|
316
|
+
const existing = this.#reconciliations.get(instanceId);
|
|
317
|
+
if (existing)
|
|
318
|
+
return existing;
|
|
319
|
+
const reconciliation = this.#performReconciliation(instanceId).finally(() => this.#reconciliations.delete(instanceId));
|
|
320
|
+
this.#reconciliations.set(instanceId, reconciliation);
|
|
321
|
+
return reconciliation;
|
|
221
322
|
}
|
|
222
|
-
#
|
|
223
|
-
const
|
|
224
|
-
if (!
|
|
323
|
+
async #performReconciliation(instanceId) {
|
|
324
|
+
const instance = this.#instances.get(instanceId);
|
|
325
|
+
if (!instance)
|
|
326
|
+
return;
|
|
327
|
+
const deadline = Date.now() + this.reconciliationDeadlineMs;
|
|
328
|
+
while (instance.rootIdentity && processIsAlive(instance.rootIdentity.pid) && Date.now() < deadline) {
|
|
329
|
+
await new Promise(resolvePromise => setTimeout(resolvePromise, 25));
|
|
330
|
+
}
|
|
331
|
+
if (instance.state === "requested" || (instance.rootIdentity && !processIsAlive(instance.rootIdentity.pid))) {
|
|
332
|
+
const outcome = { kind: "interrupted", reason: "owner-disconnect", message: "launch instance owner disconnected before reporting a terminal outcome" };
|
|
333
|
+
if (this.store.completeLaunchInstance(instance.id, instance.ownerClientId, "interrupted", outcome, new Date().toISOString())) {
|
|
334
|
+
this.#instances.delete(instance.id);
|
|
335
|
+
this.#uncertainInstances.delete(instance.id);
|
|
336
|
+
this.#mutated();
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
this.#uncertainInstances.add(instance.id);
|
|
341
|
+
await this.#writeEndpointMetadata();
|
|
342
|
+
}
|
|
343
|
+
async #handleUpdateRelease(socket, request) {
|
|
344
|
+
const identityMatches = request.bootNonce === this.bootNonce && typeof request.targetVersion === "string";
|
|
345
|
+
if (!identityMatches) {
|
|
346
|
+
this.#send(socket, {
|
|
347
|
+
type: "release-update-result",
|
|
348
|
+
accepted: false,
|
|
349
|
+
reason: "boot nonce or target version mismatch",
|
|
350
|
+
liveInstanceIds: this.#liveInstanceIds(),
|
|
351
|
+
});
|
|
225
352
|
return;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
353
|
+
}
|
|
354
|
+
const released = await this.#drainInstances("update", this.shutdownDeadlineMs);
|
|
355
|
+
this.#send(socket, {
|
|
356
|
+
type: "release-update-result",
|
|
357
|
+
accepted: released,
|
|
358
|
+
reason: released
|
|
359
|
+
? `verified ${PRODUCT_TEXT.displayName} owner drained all launch instances for immediate update shutdown`
|
|
360
|
+
: "one or more launch instances did not release verified ownership within the update deadline",
|
|
361
|
+
liveInstanceIds: this.#liveInstanceIds(),
|
|
362
|
+
});
|
|
363
|
+
if (released)
|
|
364
|
+
setTimeout(() => void this.closeForReleaseReplacement(false), 25);
|
|
365
|
+
}
|
|
366
|
+
async #drainInstances(reason, timeoutMs) {
|
|
367
|
+
if (this.#instances.size === 0)
|
|
368
|
+
return true;
|
|
369
|
+
this.#requestStopAll(reason);
|
|
370
|
+
const deadline = Date.now() + timeoutMs;
|
|
371
|
+
while (Date.now() < deadline) {
|
|
372
|
+
if (this.#instances.size === 0) {
|
|
373
|
+
// Let the terminal command result flush before closing owner sockets.
|
|
374
|
+
await new Promise(resolvePromise => setTimeout(resolvePromise, 25));
|
|
375
|
+
return this.#instances.size === 0;
|
|
376
|
+
}
|
|
377
|
+
await new Promise(resolvePromise => setTimeout(resolvePromise, 25));
|
|
378
|
+
}
|
|
379
|
+
return this.#instances.size === 0;
|
|
230
380
|
}
|
|
231
|
-
#
|
|
232
|
-
|
|
381
|
+
#requestStopAll(reason) {
|
|
382
|
+
for (const [instanceId, socket] of this.#instanceOwners) {
|
|
383
|
+
this.#send(socket, { type: "stop-launch-instance", requestId: randomUUID(), instanceId, reason });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
#mutated() {
|
|
387
|
+
this.#revision += 1;
|
|
388
|
+
void this.#writeEndpointMetadata();
|
|
389
|
+
}
|
|
390
|
+
#liveInstanceIds() {
|
|
391
|
+
return [...this.#instances.keys()];
|
|
233
392
|
}
|
|
234
393
|
#writeEndpointMetadata() {
|
|
394
|
+
const liveInstanceIds = this.#liveInstanceIds();
|
|
235
395
|
const metadata = {
|
|
236
396
|
schema: PRODUCT_IDENTITY.protocol.supervisorSchema,
|
|
237
397
|
...localControlHello(this.release.releaseId),
|
|
@@ -246,9 +406,10 @@ export class SupervisorServer {
|
|
|
246
406
|
releaseRoot: this.release.releaseRoot,
|
|
247
407
|
contentDigest: this.release.contentDigest,
|
|
248
408
|
ownership: {
|
|
249
|
-
state: this.#
|
|
250
|
-
|
|
251
|
-
|
|
409
|
+
state: this.#uncertainInstances.size > 0 ? "blocked" : liveInstanceIds.length > 0 ? "busy" : "idle",
|
|
410
|
+
liveInstanceIds,
|
|
411
|
+
nonResumableInstanceIds: liveInstanceIds,
|
|
412
|
+
uncertainInstanceIds: [...this.#uncertainInstances],
|
|
252
413
|
},
|
|
253
414
|
};
|
|
254
415
|
const temporary = `${this.paths.endpointMetadataPath}.${process.pid}.tmp`;
|
|
@@ -270,6 +431,12 @@ export class SupervisorServer {
|
|
|
270
431
|
socket.write(frame);
|
|
271
432
|
}
|
|
272
433
|
}
|
|
434
|
+
function sameProcessIdentity(left, right) {
|
|
435
|
+
return left !== null && left.pid === right.pid && left.startIdentity === right.startIdentity;
|
|
436
|
+
}
|
|
437
|
+
function sameContainmentIdentity(left, right) {
|
|
438
|
+
return left !== null && left.provider === right.provider && left.token === right.token;
|
|
439
|
+
}
|
|
273
440
|
function isMessageType(value, type) {
|
|
274
441
|
return typeof value === "object" && value !== null && "type" in value && value.type === type;
|
|
275
442
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { PaneInputResult, PaneMouseEvent, PaneRect, UiTheme } from "../ui-components/index.js";
|
|
2
|
+
export interface AppSize {
|
|
3
|
+
readonly width: number;
|
|
4
|
+
readonly height: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* What a host offers a presented app. An app reaches the terminal, the pinned
|
|
8
|
+
* shell, or another app only through these — never around them.
|
|
9
|
+
*/
|
|
10
|
+
export interface AppHostServices {
|
|
11
|
+
getSize(): AppSize;
|
|
12
|
+
requestRender(): void;
|
|
13
|
+
close(): void;
|
|
14
|
+
/** Restores the surface that opened this app without closing the host. */
|
|
15
|
+
returnToPrevious(): void;
|
|
16
|
+
/** Leaves A1 entirely, as the interrupt chord does from any screen. */
|
|
17
|
+
exit(): void;
|
|
18
|
+
/** True once one interrupt has been seen and the chord is waiting for the second. */
|
|
19
|
+
readonly interruptArmed: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Whether an idle interrupt closes the presented app. Every host chooses
|
|
22
|
+
* explicitly: a standalone screen closes, a persistent host stays open.
|
|
23
|
+
*/
|
|
24
|
+
readonly closeOnInterrupt: boolean;
|
|
25
|
+
/** Colours for the presented app. Absent renders plain text. */
|
|
26
|
+
readonly theme?: UiTheme;
|
|
27
|
+
}
|
|
28
|
+
export interface UiApp {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
render(rect: PaneRect, host: AppHostServices): readonly string[];
|
|
31
|
+
onInput?(data: string, host: AppHostServices): PaneInputResult;
|
|
32
|
+
onMouse?(event: PaneMouseEvent, host: AppHostServices): PaneInputResult;
|
|
33
|
+
onActivate?(host: AppHostServices): void;
|
|
34
|
+
onClose?(host: AppHostServices): void;
|
|
35
|
+
}
|
|
36
|
+
export interface AppRegistration {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
/** Route that opens the app, without its leading slash. */
|
|
39
|
+
readonly route: string;
|
|
40
|
+
create(): UiApp;
|
|
41
|
+
}
|
|
42
|
+
export declare function assertAppRegistration(registration: AppRegistration): void;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
2
|
+
export function assertAppRegistration(registration) {
|
|
3
|
+
if (!ID_PATTERN.test(registration.id)) {
|
|
4
|
+
throw new Error(`owned UI app id is not a bounded slug: ${registration.id}`);
|
|
5
|
+
}
|
|
6
|
+
if (!ID_PATTERN.test(registration.route)) {
|
|
7
|
+
throw new Error(`owned UI app ${registration.id} has an invalid route: ${registration.route}`);
|
|
8
|
+
}
|
|
9
|
+
if (typeof registration.create !== "function") {
|
|
10
|
+
throw new TypeError(`owned UI app ${registration.id} has no create()`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type PaneInputResult, type PaneMouseEvent, type UiTheme } from "../ui-components/index.js";
|
|
2
|
+
import type { AppSize, UiApp } from "./contracts.js";
|
|
3
|
+
import type { UiAppRegistry } from "./registry.js";
|
|
4
|
+
export interface AppHostSurface {
|
|
5
|
+
/** Current viewport, re-read on every frame so a resize is picked up. */
|
|
6
|
+
size(): AppSize;
|
|
7
|
+
/** Asks the surrounding runtime to repaint. */
|
|
8
|
+
requestRender(): void;
|
|
9
|
+
/** Presents these rows, or clears the surface when null. */
|
|
10
|
+
present(lines: readonly string[] | null): void;
|
|
11
|
+
/** Reports a failure that closed an app. */
|
|
12
|
+
reportFailure?(appId: string, error: unknown): void;
|
|
13
|
+
/** Leaves A1 entirely. Invoked by the interrupt chord from a presented app. */
|
|
14
|
+
exit?(): void;
|
|
15
|
+
}
|
|
16
|
+
export interface AppHostOptions {
|
|
17
|
+
readonly registry: UiAppRegistry;
|
|
18
|
+
readonly surface: AppHostSurface;
|
|
19
|
+
/** Whether an idle interrupt closes the presented app. Chosen explicitly. */
|
|
20
|
+
readonly closeOnInterrupt: boolean;
|
|
21
|
+
/** Colours handed to every presented app. Absent renders plain text. */
|
|
22
|
+
readonly theme?: UiTheme;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Presents at most one app. Input reaches the presented app first; anything it
|
|
26
|
+
* does not consume continues to the caller. A failure while rendering or
|
|
27
|
+
* handling input closes the app rather than leaving a broken surface on screen.
|
|
28
|
+
*/
|
|
29
|
+
export declare class UiAppHost {
|
|
30
|
+
#private;
|
|
31
|
+
constructor(options: AppHostOptions);
|
|
32
|
+
get presented(): UiApp | null;
|
|
33
|
+
get isPresenting(): boolean;
|
|
34
|
+
/** Opens a registered app, replacing whatever was presented. */
|
|
35
|
+
open(id: string): void;
|
|
36
|
+
openRoute(route: string): boolean;
|
|
37
|
+
render(): void;
|
|
38
|
+
handleInput(data: string): PaneInputResult;
|
|
39
|
+
handleMouse(event: PaneMouseEvent): PaneInputResult;
|
|
40
|
+
get interruptArmed(): boolean;
|
|
41
|
+
close(): void;
|
|
42
|
+
}
|