@timurproko/a1 0.1.1-dev.1 → 0.1.1-dev.11

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.
Files changed (176) hide show
  1. package/README.md +8 -43
  2. package/bin/guardian.js +22 -0
  3. package/bin/{a1-ui.js → ui.js} +4 -8
  4. package/dist/native/darwin-arm64/manifest.json +20 -0
  5. package/dist/native/darwin-arm64/process-guardian +0 -0
  6. package/dist/native/linux-x64/manifest.json +20 -0
  7. package/dist/native/linux-x64/process-guardian +0 -0
  8. package/dist/native/win32-x64/manifest.json +20 -0
  9. package/dist/native/win32-x64/process-guardian.exe +0 -0
  10. package/dist/src/composition/index.d.ts +26 -0
  11. package/dist/src/composition/index.js +94 -2
  12. package/dist/src/features/launch/initialize-profile.js +23 -2
  13. package/dist/src/features/launch/profiles.d.ts +1 -1
  14. package/dist/src/features/launch/profiles.js +2 -2
  15. package/dist/src/features/launch/runtime-selection.d.ts +13 -8
  16. package/dist/src/features/launch/runtime-selection.js +6 -6
  17. package/dist/src/features/owned-ui/index.d.ts +1 -0
  18. package/dist/src/features/owned-ui/index.js +1 -0
  19. package/dist/src/features/owned-ui/run.d.ts +6 -0
  20. package/dist/src/features/owned-ui/run.js +3 -1
  21. package/dist/src/features/owned-ui/settings-app.d.ts +22 -0
  22. package/dist/src/features/owned-ui/settings-app.js +633 -0
  23. package/dist/src/foundation/agent-engine-contracts/domain.d.ts +26 -0
  24. package/dist/src/foundation/launch-guardian/index.d.ts +1 -0
  25. package/dist/src/foundation/launch-guardian/index.js +1 -0
  26. package/dist/src/foundation/launch-guardian/main.d.ts +31 -0
  27. package/dist/src/foundation/launch-guardian/main.js +165 -0
  28. package/dist/src/foundation/lifecycle/commands.d.ts +40 -0
  29. package/dist/src/foundation/lifecycle/commands.js +1 -0
  30. package/dist/src/foundation/lifecycle/index.d.ts +2 -0
  31. package/dist/src/foundation/lifecycle/index.js +2 -0
  32. package/dist/src/foundation/lifecycle/launch-instance.d.ts +46 -0
  33. package/dist/src/foundation/lifecycle/launch-instance.js +134 -0
  34. package/dist/src/foundation/lifecycle/model.d.ts +6 -42
  35. package/dist/src/foundation/owned-ui-settings/declarations.d.ts +13 -0
  36. package/dist/src/foundation/owned-ui-settings/declarations.js +39 -0
  37. package/dist/src/foundation/owned-ui-settings/index.d.ts +6 -0
  38. package/dist/src/foundation/owned-ui-settings/index.js +6 -0
  39. package/dist/src/foundation/owned-ui-settings/migrations.d.ts +13 -0
  40. package/dist/src/foundation/owned-ui-settings/migrations.js +28 -0
  41. package/dist/src/foundation/owned-ui-settings/resolution.d.ts +45 -0
  42. package/dist/src/foundation/owned-ui-settings/resolution.js +147 -0
  43. package/dist/src/foundation/owned-ui-settings/sections.d.ts +70 -0
  44. package/dist/src/foundation/owned-ui-settings/sections.js +93 -0
  45. package/dist/src/foundation/owned-ui-settings/session.d.ts +44 -0
  46. package/dist/src/foundation/owned-ui-settings/session.js +145 -0
  47. package/dist/src/foundation/owned-ui-settings/store.d.ts +28 -0
  48. package/dist/src/foundation/owned-ui-settings/store.js +90 -0
  49. package/dist/src/foundation/pi-component-adapter/index.d.ts +1 -0
  50. package/dist/src/foundation/pi-component-adapter/index.js +1 -0
  51. package/dist/src/foundation/pi-component-adapter/shell-selectors-dialogs.d.ts +9 -1
  52. package/dist/src/foundation/pi-component-adapter/shell-selectors-dialogs.js +10 -8
  53. package/dist/src/foundation/pi-component-adapter/upstream/theme/theme.d.ts +8 -0
  54. package/dist/src/foundation/pi-component-adapter/upstream/theme/theme.js +12 -0
  55. package/dist/src/foundation/pi-engine-adapter/adapter.d.ts +17 -3
  56. package/dist/src/foundation/pi-engine-adapter/adapter.js +80 -7
  57. package/dist/src/foundation/pi-engine-adapter/pi-settings-metadata.json +374 -0
  58. package/dist/src/foundation/pi-engine-adapter/public-main-entry.js +10 -1
  59. package/dist/src/foundation/pi-engine-adapter/settings-integration.d.ts +40 -2
  60. package/dist/src/foundation/pi-engine-adapter/settings-integration.js +172 -24
  61. package/dist/src/foundation/pi-engine-adapter/workflows.d.ts +9 -0
  62. package/dist/src/foundation/pi-owned-ui-integration/index.d.ts +1 -0
  63. package/dist/src/foundation/pi-owned-ui-integration/index.js +1 -0
  64. package/dist/src/foundation/pi-owned-ui-integration/route-host.d.ts +29 -0
  65. package/dist/src/foundation/pi-owned-ui-integration/route-host.js +1 -0
  66. package/dist/src/foundation/pi-owned-ui-integration/session-shell.d.ts +6 -0
  67. package/dist/src/foundation/pi-owned-ui-integration/session-shell.js +71 -1
  68. package/dist/src/foundation/pi-tui-runtime-adapter/adapter.d.ts +6 -0
  69. package/dist/src/foundation/pi-tui-runtime-adapter/adapter.js +9 -0
  70. package/dist/src/foundation/process-containment/artifact.d.ts +1 -0
  71. package/dist/src/foundation/process-containment/artifact.js +34 -0
  72. package/dist/src/foundation/process-containment/contracts.d.ts +31 -0
  73. package/dist/src/foundation/process-containment/contracts.js +35 -0
  74. package/dist/src/foundation/process-containment/index.d.ts +5 -0
  75. package/dist/src/foundation/process-containment/index.js +5 -0
  76. package/dist/src/foundation/process-containment/linux-process-inspector.d.ts +6 -0
  77. package/dist/src/foundation/process-containment/linux-process-inspector.js +26 -0
  78. package/dist/src/foundation/process-containment/native-guardian-containment.d.ts +16 -0
  79. package/dist/src/foundation/process-containment/native-guardian-containment.js +139 -0
  80. package/dist/src/foundation/process-containment/windows-process-inspector.d.ts +17 -0
  81. package/dist/src/foundation/process-containment/windows-process-inspector.js +53 -0
  82. package/dist/src/foundation/protocol/client.d.ts +2 -1
  83. package/dist/src/foundation/protocol/client.js +3 -0
  84. package/dist/src/foundation/protocol/messages.d.ts +5 -5
  85. package/dist/src/foundation/protocol/messages.js +1 -1
  86. package/dist/src/foundation/release/bootstrap.d.ts +2 -2
  87. package/dist/src/foundation/release/bootstrap.js +69 -18
  88. package/dist/src/foundation/release/cohort-selection.js +3 -3
  89. package/dist/src/foundation/release/cohort-state.d.ts +3 -2
  90. package/dist/src/foundation/release/concurrency.d.ts +1 -0
  91. package/dist/src/foundation/release/concurrency.js +17 -0
  92. package/dist/src/foundation/release/process-cleanup.d.ts +1 -1
  93. package/dist/src/foundation/release/process-cleanup.js +2 -2
  94. package/dist/src/foundation/release/release-store.d.ts +35 -2
  95. package/dist/src/foundation/release/release-store.js +90 -31
  96. package/dist/src/foundation/release/release.d.ts +15 -0
  97. package/dist/src/foundation/release/release.js +65 -36
  98. package/dist/src/foundation/release/update.d.ts +19 -2
  99. package/dist/src/foundation/release/update.js +132 -16
  100. package/dist/src/foundation/storage/control-store.d.ts +8 -7
  101. package/dist/src/foundation/storage/control-store.js +105 -55
  102. package/dist/src/foundation/supervision/main.js +6 -5
  103. package/dist/src/foundation/supervision/server.d.ts +4 -2
  104. package/dist/src/foundation/supervision/server.js +254 -87
  105. package/dist/src/foundation/ui-apps/contracts.d.ts +42 -0
  106. package/dist/src/foundation/ui-apps/contracts.js +12 -0
  107. package/dist/src/foundation/ui-apps/host.d.ts +42 -0
  108. package/dist/src/foundation/ui-apps/host.js +152 -0
  109. package/dist/src/foundation/ui-apps/index.d.ts +3 -0
  110. package/dist/src/foundation/ui-apps/index.js +3 -0
  111. package/dist/src/foundation/ui-apps/registry.d.ts +11 -0
  112. package/dist/src/foundation/ui-apps/registry.js +32 -0
  113. package/dist/src/foundation/ui-components/dialog-panel.d.ts +36 -0
  114. package/dist/src/foundation/ui-components/dialog-panel.js +47 -0
  115. package/dist/src/foundation/ui-components/frame.d.ts +21 -0
  116. package/dist/src/foundation/ui-components/frame.js +54 -0
  117. package/dist/src/foundation/ui-components/index.d.ts +18 -0
  118. package/dist/src/foundation/ui-components/index.js +18 -0
  119. package/dist/src/foundation/ui-components/label.d.ts +8 -0
  120. package/dist/src/foundation/ui-components/label.js +36 -0
  121. package/dist/src/foundation/ui-components/line-input.d.ts +74 -0
  122. package/dist/src/foundation/ui-components/line-input.js +245 -0
  123. package/dist/src/foundation/ui-components/list-block.d.ts +66 -0
  124. package/dist/src/foundation/ui-components/list-block.js +176 -0
  125. package/dist/src/foundation/ui-components/list-view.d.ts +66 -0
  126. package/dist/src/foundation/ui-components/list-view.js +76 -0
  127. package/dist/src/foundation/ui-components/mouse.d.ts +15 -0
  128. package/dist/src/foundation/ui-components/mouse.js +46 -0
  129. package/dist/src/foundation/ui-components/pane.d.ts +33 -0
  130. package/dist/src/foundation/ui-components/pane.js +7 -0
  131. package/dist/src/foundation/ui-components/revision.d.ts +35 -0
  132. package/dist/src/foundation/ui-components/revision.js +79 -0
  133. package/dist/src/foundation/ui-components/scrollbar.d.ts +62 -0
  134. package/dist/src/foundation/ui-components/scrollbar.js +102 -0
  135. package/dist/src/foundation/ui-components/shortcuts.d.ts +57 -0
  136. package/dist/src/foundation/ui-components/shortcuts.js +81 -0
  137. package/dist/src/foundation/ui-components/spans.d.ts +7 -0
  138. package/dist/src/foundation/ui-components/spans.js +59 -0
  139. package/dist/src/foundation/ui-components/status-line.d.ts +16 -0
  140. package/dist/src/foundation/ui-components/status-line.js +14 -0
  141. package/dist/src/foundation/ui-components/stepper.d.ts +24 -0
  142. package/dist/src/foundation/ui-components/stepper.js +33 -0
  143. package/dist/src/foundation/ui-components/surface.d.ts +20 -0
  144. package/dist/src/foundation/ui-components/surface.js +38 -0
  145. package/dist/src/foundation/ui-components/text.d.ts +24 -0
  146. package/dist/src/foundation/ui-components/text.js +123 -0
  147. package/dist/src/foundation/ui-components/theme.d.ts +23 -0
  148. package/dist/src/foundation/ui-components/theme.js +13 -0
  149. package/dist/src/foundation/ui-components/value-menu.d.ts +41 -0
  150. package/dist/src/foundation/ui-components/value-menu.js +33 -0
  151. package/dist/src/product-identity.d.ts +2 -2
  152. package/dist/src/product-identity.js +2 -1
  153. package/dist/src/product-identity.json +8 -5
  154. package/docs/architecture/boundaries.md +31 -13
  155. package/docs/architecture/process-guardian-provenance.md +26 -0
  156. package/docs/architecture/toolchain.md +9 -3
  157. package/docs/architecture/ui-reference-provenance.md +50 -0
  158. package/docs/ci-release-runbook.md +67 -0
  159. package/docs/features/launch-profiles.md +7 -1
  160. package/docs/manual-launch-instance-acceptance.md +66 -0
  161. package/docs/manual-terminal-colour-check.md +55 -0
  162. package/package.json +20 -14
  163. package/dist/src/composition/transparent-runtime.d.ts +0 -6
  164. package/dist/src/composition/transparent-runtime.js +0 -19
  165. package/dist/src/foundation/transparent-terminal/command-resolution.d.ts +0 -13
  166. package/dist/src/foundation/transparent-terminal/command-resolution.js +0 -69
  167. package/dist/src/foundation/transparent-terminal/foreground-broker.d.ts +0 -32
  168. package/dist/src/foundation/transparent-terminal/foreground-broker.js +0 -87
  169. package/dist/src/foundation/transparent-terminal/index.d.ts +0 -4
  170. package/dist/src/foundation/transparent-terminal/index.js +0 -4
  171. package/dist/src/foundation/transparent-terminal/main.d.ts +0 -9
  172. package/dist/src/foundation/transparent-terminal/main.js +0 -94
  173. package/dist/src/foundation/transparent-terminal/native-launcher.d.ts +0 -25
  174. package/dist/src/foundation/transparent-terminal/native-launcher.js +0 -146
  175. /package/bin/{a1.js → cli.js} +0 -0
  176. /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, assertTransparentTerminalLaunchProfile, } from "../lifecycle/index.js";
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
- #foregroundLease;
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
- constructor(store, paths = resolveProductPaths(), release, bootNonce = randomUUID(), terminateProcess = code => process.exit(code)) {
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
- this.#foregroundLease = store.loadLiveForegroundTerminalLease();
42
+ for (const instance of store.loadActiveLaunchInstances())
43
+ this.#instances.set(instance.id, instance);
32
44
  }
33
45
  snapshot() {
34
- return { revision: this.#revision };
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.#foregroundLease)
58
- this.#releaseForegroundLeaseForUpdate();
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.#foregroundLease === null;
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 ? "foreground terminal lease is still live" : "boot nonce or candidate release mismatch",
95
- liveGenerationIds: this.#liveGenerationIds(),
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
- const accepted = request.bootNonce === this.bootNonce && typeof request.targetVersion === "string";
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.#clients.delete(socket));
141
- socket.on("error", () => this.#clients.delete(socket));
155
+ socket.on("close", () => this.#onSocketClosed(socket));
156
+ socket.on("error", () => this.#onSocketClosed(socket));
142
157
  }
143
158
  async #execute(command, socket) {
144
- const recorded = this.#results.get(command.requestId);
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 === "acquire-foreground-terminal-lease")
152
- this.#acquireForegroundLease(command);
153
- if (command.type === "activate-foreground-terminal-lease")
154
- this.#activateForegroundLease(command);
155
- if (command.type === "heartbeat-foreground-terminal-lease")
156
- this.#heartbeatForegroundLease(command);
157
- if (command.type === "release-foreground-terminal-lease")
158
- this.#releaseForegroundLease(command);
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(command.requestId, result);
194
+ this.#results.set(resultKey, result);
170
195
  this.#send(socket, { type: "command-result", result });
171
196
  }
172
- #acquireForegroundLease(command) {
173
- if (!command.leaseId || !command.ownerId)
174
- throw new Error("foreground lease and owner identities are required");
175
- assertTransparentTerminalLaunchProfile(command.profile);
176
- const lease = {
177
- id: command.leaseId,
178
- ownerId: command.ownerId,
179
- profile: command.profile,
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
- generationId: null,
182
- processIdentity: null,
183
- acquiredAt: new Date().toISOString(),
184
- heartbeatAt: null,
185
- releasedAt: null,
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.acquireForegroundTerminalLease(lease);
189
- this.#foregroundLease = lease;
190
- void this.#writeEndpointMetadata();
228
+ this.store.createLaunchInstance(instance);
229
+ this.#instances.set(instance.id, instance);
230
+ this.#bindOwner(instance.id, socket);
231
+ this.#mutated();
191
232
  }
192
- #activateForegroundLease(command) {
193
- assertNativeProcessIdentity(command.processIdentity);
194
- if (!this.#foregroundLease || this.#foregroundLease.id !== command.leaseId || this.#foregroundLease.state !== "requested")
195
- throw new Error("foreground terminal lease not found or is not requested");
196
- const heartbeatAt = new Date().toISOString();
197
- if (!this.store.activateForegroundTerminalLease(command.leaseId, command.generationId, command.processIdentity, heartbeatAt))
198
- throw new Error("foreground terminal lease activation was rejected");
199
- this.#foregroundLease = { ...this.#foregroundLease, state: "active", generationId: command.generationId, processIdentity: command.processIdentity, heartbeatAt };
200
- void this.#writeEndpointMetadata();
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
- #heartbeatForegroundLease(command) {
203
- assertNativeProcessIdentity(command.processIdentity);
204
- if (!this.#foregroundLease || this.#foregroundLease.id !== command.leaseId || this.#foregroundLease.state !== "active")
205
- throw new Error("active foreground terminal lease not found");
206
- const heartbeatAt = new Date().toISOString();
207
- if (!this.store.heartbeatForegroundTerminalLease(command.leaseId, command.processIdentity, heartbeatAt))
208
- throw new Error("foreground terminal lease ownership mismatch");
209
- this.#foregroundLease = { ...this.#foregroundLease, heartbeatAt };
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
- #releaseForegroundLease(command) {
212
- if (command.processIdentity)
213
- assertNativeProcessIdentity(command.processIdentity);
214
- if (!this.#foregroundLease || this.#foregroundLease.id !== command.leaseId)
215
- throw new Error("foreground terminal lease not found");
216
- const releasedAt = new Date().toISOString();
217
- if (!this.store.releaseForegroundTerminalLease(command.leaseId, command.processIdentity, command.outcome, releasedAt))
218
- throw new Error("foreground terminal lease ownership mismatch");
219
- this.#foregroundLease = null;
220
- void this.#writeEndpointMetadata();
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
- #releaseForegroundLeaseForUpdate() {
223
- const lease = this.#foregroundLease;
224
- if (!lease)
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
- const outcome = { kind: "stopped", reason: "update" };
227
- if (!this.store.releaseForegroundTerminalLease(lease.id, lease.processIdentity, outcome, new Date().toISOString()))
228
- throw new Error("foreground terminal lease update shutdown was rejected");
229
- this.#foregroundLease = null;
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
- #liveGenerationIds() {
232
- return this.#foregroundLease?.generationId ? [this.#foregroundLease.generationId] : [];
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.#foregroundLease ? "busy" : "idle",
250
- liveGenerationIds: this.#liveGenerationIds(),
251
- nonResumableGenerationIds: this.#liveGenerationIds(),
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
+ }