pi-radar 0.7.0 → 0.10.0

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.
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { createRadarClient } from "./radar-client.ts";
4
+ import { createRadarChildren } from "./radar-children.ts";
4
5
  import { createRadarHandoffPort } from "./radar-communication.ts";
5
6
  import {
6
7
  createRadarExecutionAdapter,
@@ -33,6 +34,7 @@ export default function registerPiRadar(pi: PiRadarHost): void {
33
34
  const runtime = lazyRadarRuntime(async () => ({
34
35
  publication: execution.publication,
35
36
  communication: createRadarHandoffPort(client, execution.publication),
37
+ children: createRadarChildren(client, execution.publication),
36
38
  execution,
37
39
  }));
38
40
  provideRadarRuntime(pi.events, runtime);
@@ -911,7 +911,6 @@ export interface ManagedSubject {
911
911
  session: string | null;
912
912
  state: State;
913
913
  subject_id: string;
914
- successor: string | null;
915
914
  }
916
915
  /**
917
916
  * Exact spawn-edge child identity required by `spawn.close`.
@@ -1323,7 +1322,6 @@ export interface LifecycleObservation {
1323
1322
  session: string | null;
1324
1323
  state: State;
1325
1324
  subject_id: string;
1326
- successor: string | null;
1327
1325
  }
1328
1326
  /**
1329
1327
  * A runtime pane: location plus optional display text.
@@ -1452,6 +1450,7 @@ export interface SpawnListPageSerializePublicSpawnTopology {
1452
1450
  * nothing to verify.
1453
1451
  */
1454
1452
  freshness?: SpawnListPageSerializeFreshness | null;
1453
+ granted_at?: string | null;
1455
1454
  launched: SpawnListPageSerializeRequestOutcome;
1456
1455
  location?: SpawnListPageSerializeRegistryLocation | null;
1457
1456
  parent: string;
@@ -1461,6 +1460,7 @@ export interface SpawnListPageSerializePublicSpawnTopology {
1461
1460
  request_id: string;
1462
1461
  state: SpawnListPageSerializeSpawnState;
1463
1462
  version: number;
1463
+ withdrawn?: SpawnListPageSerializeWithdrawn | null;
1464
1464
  }
1465
1465
  /**
1466
1466
  * The child a token bound: the registering incarnation's own registry identity.
@@ -1476,6 +1476,11 @@ export interface SpawnListPageSerializeBoundChild {
1476
1476
  */
1477
1477
  registration_id?: string | null;
1478
1478
  source: string;
1479
+ /**
1480
+ * The durable agent the bound child attaches as. Absent for an edge without
1481
+ * a tracked launch and in records written before it was recorded.
1482
+ */
1483
+ subject_id?: string | null;
1479
1484
  }
1480
1485
  /**
1481
1486
  * Where an agent's incarnation lives on one backend, in that backend's own
@@ -1492,3 +1497,11 @@ export interface SpawnListPageSerializeRegistryLocation {
1492
1497
  tab?: string | null;
1493
1498
  workspace?: string | null;
1494
1499
  }
1500
+ /**
1501
+ * Why and when a launch ended without ever presenting, so a reader never infers
1502
+ * failure from absence.
1503
+ */
1504
+ export interface SpawnListPageSerializeWithdrawn {
1505
+ at: string;
1506
+ reason: string;
1507
+ }
@@ -0,0 +1,222 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { RadarClient, RadarChannelSnapshot, RadarResult } from "./radar-client.ts";
3
+ import type { SpawnListPageSerializePublicSpawnTopology as ListedEdge } from "./generated-contracts.ts";
4
+ import { inspectRadarChild, type RadarInspection } from "./radar-inspection.ts";
5
+ import { observeRadarChild, waitForChild, launchFailure, type RadarChildObservation, type RadarLaunchOutcome } from "./radar-launch.ts";
6
+ import type { RadarPublication } from "./radar-publication.ts";
7
+
8
+ const PAGE = 100;
9
+ const MAX_PAGES = 20;
10
+
11
+ /** One child of the caller, named by the agent that survives its restarts. */
12
+ export type RadarChild = {
13
+ subject_id: string;
14
+ /** The launch that last bound this child. */
15
+ request_id: string;
16
+ pane_id?: string;
17
+ state: "unbound" | "bound" | "unresolved";
18
+ exited: boolean;
19
+ };
20
+
21
+ /** The assignment a child is held to: published again when its process is replaced. */
22
+ export type RadarChildAssignment = {
23
+ update(snapshot: RadarChannelSnapshot): void;
24
+ retire(): void;
25
+ };
26
+
27
+ /** The program a restarted child runs as. */
28
+ export type RadarChildProgram = {
29
+ executable: string;
30
+ argv: string[];
31
+ cwd?: string;
32
+ env?: Record<string, string>;
33
+ };
34
+
35
+ export type RadarChildren = {
36
+ list(): Promise<RadarResult<RadarChild[]>>;
37
+ /**
38
+ * Replace this caller's live child with a new process in its own pane and
39
+ * session, and wait for the daemon to grant it or withdraw the launch.
40
+ */
41
+ restart(
42
+ subject: string,
43
+ program: RadarChildProgram,
44
+ options?: { pollMs?: number; signal?: AbortSignal },
45
+ ): Promise<RadarLaunchOutcome>;
46
+ assignment(subject: string, owner: string): RadarChildAssignment;
47
+ retire(subject: string): void;
48
+ observe(
49
+ subject: string,
50
+ session_id: string | undefined,
51
+ ): Promise<RadarChildObservation>;
52
+ inspect(subject: string, session_id?: string): Promise<RadarInspection>;
53
+ };
54
+
55
+ type Resolved = { child: RadarChild; registration_id: string };
56
+
57
+ /**
58
+ * The caller's children, addressed by subject. A subject gains an edge per launch,
59
+ * so the newest bound edge is the current process; its registration is read per
60
+ * call and never remembered, because a remembered registration is the stale key
61
+ * this surface exists to hide. An edge bound without a subject is not addressable.
62
+ */
63
+ export function createRadarChildren(
64
+ client: Pick<RadarClient, "spawnList" | "spawnGet" | "agentGet" | "output" | "processInfo" | "restart">,
65
+ publication: Pick<RadarPublication, "assignment" | "retireAssignment" | "subject">,
66
+ ): RadarChildren {
67
+ const held = new Map<
68
+ string,
69
+ { writer: RadarChildAssignment; published?: string; publish?: (target: Resolved) => void }
70
+ >();
71
+
72
+ async function resolveAll(): Promise<RadarResult<Map<string, Resolved & { at: number }>>> {
73
+ const newest = new Map<string, Resolved & { at: number }>();
74
+ let after: string | undefined;
75
+ for (let page = 0; page < MAX_PAGES; page++) {
76
+ const read = await client.spawnList({ ...(after !== undefined ? { after } : {}), limit: PAGE });
77
+ if (read.ok === false) return read;
78
+ for (const edge of read.value.spawns as ListedEdge[]) {
79
+ const bound = edge.bound;
80
+ if (!bound?.subject_id || !bound.registration_id || edge.granted_at == null || edge.withdrawn != null) continue;
81
+ const at = Date.parse(bound.bound_at);
82
+ if (Number.isNaN(at)) continue;
83
+ const current = newest.get(bound.subject_id);
84
+ if (current !== undefined && current.at >= at) continue;
85
+ newest.set(bound.subject_id, {
86
+ at,
87
+ registration_id: bound.registration_id,
88
+ child: {
89
+ subject_id: bound.subject_id,
90
+ request_id: edge.request_id,
91
+ ...(edge.location?.pane ? { pane_id: edge.location.pane } : {}),
92
+ state: edge.state,
93
+ exited: edge.exit_observation != null && edge.exit_observation.state !== "unknown",
94
+ },
95
+ });
96
+ }
97
+ if (!read.value.next) return { ok: true, value: newest };
98
+ after = read.value.next;
99
+ }
100
+ return { ok: false, code: "protocol", message: "the caller's launches exceed the readable pages" };
101
+ }
102
+
103
+ async function resolve(subject: string): Promise<Resolved | undefined> {
104
+ const all = await resolveAll();
105
+ return all.ok ? all.value.get(subject) : undefined;
106
+ }
107
+
108
+ async function resolveAndFollow(subject: string): Promise<Resolved | undefined> {
109
+ const all = await resolveAll();
110
+ if (!all.ok) return undefined;
111
+ follow(all.value);
112
+ return all.value.get(subject);
113
+ }
114
+
115
+ /**
116
+ * A child replaced under its subject has a new process with no assignment. Each
117
+ * read the caller already makes finds the new registration and moves the held
118
+ * assignment onto it, so no caller has to notice a replacement.
119
+ */
120
+ function follow(all: Map<string, Resolved>): void {
121
+ for (const [subject, entry] of held) {
122
+ const target = all.get(subject);
123
+ if (target !== undefined && entry.published !== target.registration_id)
124
+ entry.publish?.(target);
125
+ }
126
+ }
127
+
128
+ return {
129
+ async list() {
130
+ const all = await resolveAll();
131
+ if (!all.ok) return all;
132
+ follow(all.value);
133
+ return { ok: true, value: [...all.value.values()].map((entry) => entry.child) };
134
+ },
135
+ async restart(subject, program, options) {
136
+ const parent = await publication.subject();
137
+ if (parent?.subject_id === undefined)
138
+ return { kind: "failed", message: "this process has no Radar registration to restart a child under" };
139
+ const request_id = randomUUID();
140
+ const requested = await client.restart({
141
+ request_id,
142
+ parent: parent.registration_id,
143
+ subject_id: subject,
144
+ ...program,
145
+ });
146
+ if (requested.ok === false)
147
+ return launchFailure(requested, request_id);
148
+ const bound = await waitForChild(client, requested.value.launch_request_id, {
149
+ pollMs: options?.pollMs ?? 250,
150
+ ...(options?.signal !== undefined ? { signal: options.signal } : {}),
151
+ });
152
+ if (bound.kind !== "bound") return bound;
153
+ await resolveAndFollow(subject);
154
+ return {
155
+ kind: "ready",
156
+ value: {
157
+ request_id: requested.value.launch_request_id,
158
+ subject_id: bound.subject_id,
159
+ pane_id: bound.pane_id,
160
+ },
161
+ };
162
+ },
163
+ assignment(subject, owner) {
164
+ const existing = held.get(subject);
165
+ if (existing !== undefined) return existing.writer;
166
+ let latest: RadarChannelSnapshot | undefined;
167
+ const entry: {
168
+ writer: RadarChildAssignment;
169
+ published?: string;
170
+ publish?: (target: Resolved) => void;
171
+ } = { writer: undefined as unknown as RadarChildAssignment };
172
+ const send = (target: Resolved): void => {
173
+ if (latest === undefined) return;
174
+ if (entry.published !== undefined && entry.published !== target.registration_id)
175
+ publication.retireAssignment(entry.published);
176
+ entry.published = target.registration_id;
177
+ publication.assignment(target.registration_id, owner).update(latest);
178
+ };
179
+ entry.publish = send;
180
+ entry.writer = {
181
+ update(snapshot) {
182
+ latest = snapshot;
183
+ void resolve(subject).then((target) => {
184
+ if (target !== undefined) send(target);
185
+ });
186
+ },
187
+ retire() {
188
+ latest = undefined;
189
+ if (entry.published !== undefined) publication.retireAssignment(entry.published);
190
+ entry.published = undefined;
191
+ },
192
+ };
193
+ held.set(subject, entry);
194
+ return entry.writer;
195
+ },
196
+ retire(subject) {
197
+ held.get(subject)?.writer.retire();
198
+ held.delete(subject);
199
+ },
200
+ async observe(subject, session_id) {
201
+ const target = await resolveAndFollow(subject);
202
+ if (target === undefined)
203
+ return { kind: "unknown", diagnostic: "the daemon records no bound launch for this child" };
204
+ return observeRadarChild(client as RadarClient, {
205
+ request_id: target.child.request_id,
206
+ pane_id: target.child.pane_id,
207
+ registration_id: target.registration_id,
208
+ session_id,
209
+ });
210
+ },
211
+ async inspect(subject, session_id) {
212
+ const target = await resolveAndFollow(subject);
213
+ if (target?.child.pane_id === undefined)
214
+ throw new Error("Inspection target is not a bound child with a pane");
215
+ return inspectRadarChild(client as RadarClient, {
216
+ agentId: target.registration_id,
217
+ paneId: target.child.pane_id,
218
+ ...(session_id !== undefined ? { session: session_id } : {}),
219
+ });
220
+ },
221
+ };
222
+ }
@@ -34,15 +34,18 @@ import type {
34
34
  export const RADAR_PROTOCOL_VERSION = 2;
35
35
  /**
36
36
  * Revision 5 names attempts by claim_id and requires exact-claim controls.
37
+ * Revision 7 starts every managed launch through `radar run --launch` and lets a
38
+ * parent continue or restart its own child by subject.
37
39
  * Earlier daemons expose different identity fields; require a coordinated update.
38
40
  */
39
- export const RADAR_MINIMUM_REVISION = 6;
41
+ export const RADAR_MINIMUM_REVISION = 8;
40
42
  /** Public daemon contracts this client may preflight. */
41
43
  export const RADAR_REGISTRY_CONTRACT = "registry.v1";
42
44
  export const RADAR_REGISTRY_FACTS_CONTRACT = "registry.facts.v1";
43
45
  export const RADAR_SPAWN_CONTRACT = "spawn.v1";
44
46
  export const RADAR_SPAWN_LIST_CONTRACT = "spawn.list.v1";
45
47
  export const RADAR_SPAWN_CLOSE_CONTRACT = "spawn.close.v1";
48
+ export const RADAR_LIFECYCLE_RESTART_CONTRACT = "lifecycle.restart.v1";
46
49
  export const RADAR_SPAWN_LAUNCH_CONTEXT_CONTRACT = "spawn.launch_context.v1";
47
50
  export const RADAR_EVENTS_CONTRACT = "events.v1";
48
51
  export const RADAR_COMMUNICATION_CONTRACT = "communication.handoff.v1";
@@ -304,8 +307,11 @@ export type RadarSpawnEdge = {
304
307
  created: RadarEffectOutcome;
305
308
  launched: RadarEffectOutcome;
306
309
  location?: RadarLocation;
307
- bound?: { source: string; incarnation: string; registration_id?: string };
310
+ bound?: { source: string; incarnation: string; registration_id?: string; subject_id?: string };
308
311
  exit_observation?: RadarSpawnExitObservation;
312
+ /** Set when the daemon withdrew the launch before its process presented. */
313
+ withdrawn?: { at: string; reason: string };
314
+ granted_at?: string;
309
315
  };
310
316
 
311
317
  /**
@@ -825,7 +831,11 @@ function spawnEdge(value: unknown): RadarResult<RadarSpawnEdge> {
825
831
  const source = stringAt(bound, "source");
826
832
  const incarnation = stringAt(bound, "incarnation");
827
833
  const agentId = stringAt(bound, "registration_id");
834
+ const subjectId = stringAt(bound, "subject_id");
828
835
  const exit = exitAt(recordAt(value, "exit_observation"));
836
+ const withdrawnRecord = recordAt(value, "withdrawn");
837
+ const withdrawnAt = stringAt(withdrawnRecord, "at");
838
+ const withdrawnReason = stringAt(withdrawnRecord, "reason");
829
839
  return {
830
840
  ok: true,
831
841
  value: {
@@ -840,10 +850,15 @@ function spawnEdge(value: unknown): RadarResult<RadarSpawnEdge> {
840
850
  source,
841
851
  incarnation,
842
852
  ...(isUuid(agentId) ? { registration_id: agentId } : {}),
853
+ ...(isUuid(subjectId) ? { subject_id: subjectId } : {}),
843
854
  },
844
855
  }
845
856
  : {}),
846
857
  ...(exit !== undefined ? { exit_observation: exit } : {}),
858
+ ...(stringAt(value, "granted_at") !== undefined ? { granted_at: stringAt(value, "granted_at") } : {}),
859
+ ...(withdrawnAt !== undefined && withdrawnReason !== undefined
860
+ ? { withdrawn: { at: withdrawnAt, reason: withdrawnReason } }
861
+ : {}),
847
862
  },
848
863
  };
849
864
  }
@@ -959,9 +974,12 @@ function spawnRequestShape(request: {
959
974
  cwd?: string;
960
975
  env?: Record<string, string>;
961
976
  managed?: RadarManagedLaunchMetadata;
977
+ continue_subject_id?: string;
962
978
  }): string | undefined {
963
979
  if (!isUuid(request.request_id))
964
980
  return "spawn requires a canonical request UUID";
981
+ if (request.continue_subject_id !== undefined && !isUuid(request.continue_subject_id))
982
+ return "continue_subject_id must be a canonical UUID";
965
983
  if (!isUuid(request.parent))
966
984
  return "spawn requires the parent's canonical agent UUID";
967
985
  if (!isUuid(request.parent_session_id))
@@ -1100,7 +1118,23 @@ export type RadarClient = {
1100
1118
  cwd?: string;
1101
1119
  env?: Record<string, string>;
1102
1120
  managed?: RadarManagedLaunchMetadata;
1121
+ /** Start this released child agent again, in the session the daemon recorded for it. */
1122
+ continue_subject_id?: string;
1103
1123
  }): Promise<RadarResult<RadarSpawnEdge>>;
1124
+ /**
1125
+ * Restart this caller's own live child under a new program. The daemon finds the
1126
+ * child's current process from the subject and replaces it in its own pane and
1127
+ * session; a program with a bare argument that may be a message is refused.
1128
+ */
1129
+ restart(request: {
1130
+ request_id: string;
1131
+ parent: string;
1132
+ subject_id: string;
1133
+ executable: string;
1134
+ argv: string[];
1135
+ cwd?: string;
1136
+ env?: Record<string, string>;
1137
+ }): Promise<RadarResult<{ request_id: string; launch_request_id: string }>>;
1104
1138
  /** List launches owned by this client's registered process. */
1105
1139
  spawnList(request: Pick<SpawnListParamsDeserialize, "after" | "limit">): Promise<RadarResult<SpawnListPageSerialize>>;
1106
1140
  spawnGet(request_id: string): Promise<RadarResult<RadarSpawnTopology>>;
@@ -1671,6 +1705,9 @@ export function createRadarClient(options: RadarClientOptions = {}): RadarClient
1671
1705
  ...(request.managed !== undefined
1672
1706
  ? { managed: request.managed }
1673
1707
  : {}),
1708
+ ...(request.continue_subject_id !== undefined
1709
+ ? { continue_subject_id: request.continue_subject_id }
1710
+ : {}),
1674
1711
  },
1675
1712
  requester,
1676
1713
  },
@@ -1706,6 +1743,37 @@ export function createRadarClient(options: RadarClientOptions = {}): RadarClient
1706
1743
  },
1707
1744
  );
1708
1745
  },
1746
+ async restart(request) {
1747
+ if (!isUuid(request.request_id) || !isUuid(request.parent) || !isUuid(request.subject_id))
1748
+ return {
1749
+ ok: false,
1750
+ code: "bad_params",
1751
+ message: "restart requires canonical request, parent and subject UUIDs",
1752
+ };
1753
+ return withContract(RADAR_LIFECYCLE_RESTART_CONTRACT, async () => {
1754
+ const answer = await call(
1755
+ "lifecycle.restart",
1756
+ {
1757
+ request: {
1758
+ subject_id: request.subject_id,
1759
+ parent: request.parent,
1760
+ executable: request.executable,
1761
+ argv: request.argv,
1762
+ ...(request.cwd !== undefined ? { cwd: request.cwd } : {}),
1763
+ ...(request.env !== undefined ? { env: request.env } : {}),
1764
+ },
1765
+ requester,
1766
+ },
1767
+ request.request_id,
1768
+ );
1769
+ if (answer.ok === false) return answer;
1770
+ const settled = focusOutcome(recordAt(answer.value, "request"), request.request_id);
1771
+ if (settled.ok === false) return settled;
1772
+ const launch = recordAt(recordAt(answer.value, "request"), "launch_request_id");
1773
+ if (!isUuid(launch)) return protocol("lifecycle.restart reply names no launch");
1774
+ return { ok: true, value: { ...settled.value, launch_request_id: launch } };
1775
+ });
1776
+ },
1709
1777
  async spawnList(request) {
1710
1778
  const registrationId = processRegistrationId;
1711
1779
  if (registrationId === undefined)