pi-radar 0.3.0 → 0.5.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.
Files changed (36) hide show
  1. package/README.md +4 -2
  2. package/dist/extension/baseline.js +227 -402
  3. package/dist/extension/radar-client.js +53 -13
  4. package/dist/extension/radar-communication.js +5 -5
  5. package/dist/extension/radar-execution.js +168 -383
  6. package/dist/extension/radar-launch.js +13 -13
  7. package/dist/extension/radar-publication.js +160 -299
  8. package/dist/index.js +239 -414
  9. package/extension/generated-contracts.ts +1249 -13
  10. package/extension/radar-client.ts +92 -29
  11. package/extension/radar-communication.ts +5 -5
  12. package/extension/radar-execution.ts +11 -58
  13. package/extension/radar-launch.ts +21 -21
  14. package/extension/radar-publication.ts +209 -412
  15. package/package.json +6 -5
  16. package/schema/public/v1/handoff-publish-params.deserialize.schema.json +2 -2
  17. package/schema/public/v1/handoff-publish-params.serialize.schema.json +2 -2
  18. package/schema/public/v1/handoff-receipt.deserialize.schema.json +4 -4
  19. package/schema/public/v1/handoff-receipt.serialize.schema.json +4 -4
  20. package/schema/public/v1/handoff.deserialize.schema.json +4 -4
  21. package/schema/public/v1/handoff.serialize.schema.json +4 -4
  22. package/schema/public/v1/observation-get-params.deserialize.schema.json +46 -0
  23. package/schema/public/v1/observation-get-params.serialize.schema.json +52 -0
  24. package/schema/public/v1/observation-get-response.deserialize.schema.json +2731 -0
  25. package/schema/public/v1/observation-get-response.serialize.schema.json +2862 -0
  26. package/schema/public/v1/observation-snapshot.deserialize.schema.json +2616 -0
  27. package/schema/public/v1/observation-snapshot.serialize.schema.json +2741 -0
  28. package/schema/public/v1/presence-answer.deserialize.schema.json +40 -0
  29. package/schema/public/v1/presence-answer.serialize.schema.json +40 -0
  30. package/schema/public/v1/presence-release.deserialize.schema.json +51 -0
  31. package/schema/public/v1/presence-release.serialize.schema.json +52 -0
  32. package/schema/public/v1/presence-report.deserialize.schema.json +149 -0
  33. package/schema/public/v1/presence-report.serialize.schema.json +157 -0
  34. package/extension/radar-session-marker.ts +0 -146
  35. package/schema/public/v1/launch-identity-marker.deserialize.schema.json +0 -32
  36. package/schema/public/v1/launch-identity-marker.serialize.schema.json +0 -32
@@ -14,7 +14,6 @@ import {
14
14
  } from "node:fs";
15
15
  import { dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
16
16
  import type { RadarLocationResolution } from "./radar-location.ts";
17
- import { decodeRadarLaunchMarker } from "./radar-session-marker.ts";
18
17
  import {
19
18
  boundedRadarAssignment,
20
19
  boundedRadarFacts,
@@ -28,7 +27,7 @@ import {
28
27
  type RadarFactsSnapshot,
29
28
  type RadarClient,
30
29
  type RadarLocation,
31
- type RadarLaunchIdentityMarker,
30
+ type RadarManagement,
32
31
  type RadarPublisherIdentity,
33
32
  type RadarRegistration,
34
33
  type RadarScheduler,
@@ -37,16 +36,20 @@ import {
37
36
  systemScheduler,
38
37
  } from "./radar-client.ts";
39
38
 
39
+ /** A rejected session claim requires a fresh Pi session load before reconsideration. */
40
+ const DUPLICATE_SESSION_DIAGNOSTIC =
41
+ "Radar rejected this session claim as a duplicate; reload or resume the session before retrying";
42
+
40
43
  /** The client members publication calls; consumers narrow to this rather than the whole client. */
41
44
  export type RadarPublicationClient = Pick<
42
45
  RadarClient,
43
- "ping" | "register" | "acquire" | "publish" | "context" | "retire"
46
+ "ping" | "presence" | "releasePresence" | "acquire" | "publish" | "retire"
44
47
  >;
45
48
 
46
49
  /**
47
50
  * Durable publication state for Radar's registry (`agent-radar`
48
51
  * `docs/agent-registration.md`, contract version 1): this process's immutable
49
- * subject registration, the acquired writer binding per channel, the accepted
52
+ * registration record, the acquired writer binding per channel, the accepted
50
53
  * sequence and the exact unresolved request.
51
54
  *
52
55
  * Everything here is best-effort and asynchronous. An absent daemon, a lost
@@ -100,7 +103,7 @@ export function readProcessBirth(): ProcessBirth | undefined {
100
103
  }
101
104
  }
102
105
 
103
- const RECORD_NAME = /^(?:subjects|channels|context|bindings)\/[a-z0-9-]{1,64}\.json$/;
106
+ const RECORD_NAME = /^(?:registrations|presence|subjects|channels|context|bindings)\/[a-z0-9-]{1,64}\.json$/;
104
107
  const MAX_RECORD_BYTES = 64 * 1024;
105
108
  const PRIVATE_MODE = 0o700;
106
109
  const RECORD_MODE = 0o600;
@@ -308,7 +311,7 @@ type PendingPublish = {
308
311
 
309
312
  type ChannelRecord = {
310
313
  version: typeof RADAR_PUBLICATION_VERSION;
311
- agent_id: string;
314
+ registration_id: string;
312
315
  channel: RadarChannel;
313
316
  publisher: RadarPublisherIdentity;
314
317
  writer?: { handle: string; generation: number };
@@ -325,7 +328,6 @@ type ContextReport = {
325
328
  session: string | null;
326
329
  lease_ms: number;
327
330
  observed_at: string;
328
- launch_identity?: RadarLaunchIdentityMarker;
329
331
  };
330
332
 
331
333
  /**
@@ -335,7 +337,7 @@ type ContextReport = {
335
337
  */
336
338
  type ContextRecord = {
337
339
  version: typeof RADAR_PUBLICATION_VERSION;
338
- agent_id: string;
340
+ registration_id: string;
339
341
  publisher: RadarPublisherIdentity;
340
342
  writer?: { handle: string; generation: number };
341
343
  sequence: number;
@@ -347,7 +349,7 @@ type ContextRecord = {
347
349
  /** What a managed child tells its owner: which exact subject to publish about. */
348
350
  export type RadarChildBinding = {
349
351
  version: typeof RADAR_PUBLICATION_VERSION;
350
- agent_id: string;
352
+ registration_id: string;
351
353
  incarnation: string;
352
354
  run: string;
353
355
  owner: string;
@@ -563,9 +565,11 @@ function pendingAt(
563
565
  }
564
566
 
565
567
  const CONTEXT_PENDING_KEYS = "lease_ms,observed_at,sequence,session";
566
- const CONTEXT_PENDING_MARKED_KEYS = "launch_identity,lease_ms,observed_at,sequence,session";
567
568
  const CONTEXT_ACCEPTED_KEYS = "lease_ms,observed_at,sent_at,sequence,session";
568
- const CONTEXT_ACCEPTED_MARKED_KEYS = "launch_identity,lease_ms,observed_at,sent_at,sequence,session";
569
+ // Records written before session-bound management also carry a launch-identity
570
+ // marker. It is read past and never used: the attached subject is the identity.
571
+ const CONTEXT_PENDING_LEGACY_KEYS = "launch_identity,lease_ms,observed_at,sequence,session";
572
+ const CONTEXT_ACCEPTED_LEGACY_KEYS = "launch_identity,lease_ms,observed_at,sent_at,sequence,session";
569
573
 
570
574
  /** The reported session, or `undefined` when the field is not one of the two. */
571
575
  function sessionAt(value: unknown): string | null | undefined {
@@ -575,28 +579,12 @@ function sessionAt(value: unknown): string | null | undefined {
575
579
  return typeof found === "string" ? found : undefined;
576
580
  }
577
581
 
578
- function sameLaunchIdentity(
579
- a: RadarLaunchIdentityMarker | undefined,
580
- b: RadarLaunchIdentityMarker | undefined,
581
- ): boolean {
582
- return (
583
- a === b ||
584
- (a !== undefined &&
585
- b !== undefined &&
586
- a.version === b.version &&
587
- a.subject_id === b.subject_id &&
588
- a.launch_id === b.launch_id &&
589
- a.execution_generation === b.execution_generation)
590
- );
591
- }
592
-
593
582
  function contextReportAt(
594
583
  value: unknown,
595
584
  keys: string,
596
- markedKeys: string,
585
+ legacyKeys: string,
597
586
  ): ContextReport | undefined {
598
- const marked = exactKeys(value, markedKeys);
599
- if (!exactKeys(value, keys) && !marked) return undefined;
587
+ if (!exactKeys(value, keys) && !exactKeys(value, legacyKeys)) return undefined;
600
588
  const sequence = numberAt(value, "sequence");
601
589
  const lease = numberAt(value, "lease_ms");
602
590
  const observed = textAt(value, "observed_at");
@@ -604,17 +592,7 @@ function contextReportAt(
604
592
  if (sequence === undefined || sequence < 1) return undefined;
605
593
  if (lease === undefined || observed === undefined || session === undefined)
606
594
  return undefined;
607
- const marker = marked
608
- ? decodeRadarLaunchMarker((value as Record<string, unknown>).launch_identity)
609
- : undefined;
610
- if (marked && marker === undefined) return undefined;
611
- return {
612
- sequence,
613
- session,
614
- lease_ms: lease,
615
- observed_at: observed,
616
- ...(marker !== undefined ? { launch_identity: marker } : {}),
617
- };
595
+ return { sequence, session, lease_ms: lease, observed_at: observed };
618
596
  }
619
597
 
620
598
  export type RadarFactsWriter = Omit<RadarChannelWriter, "update"> & {
@@ -667,7 +645,7 @@ function createChannelWriter(input: ChannelWriterInput): RadarChannelWriter {
667
645
  function blank(agentId: string): ChannelRecord {
668
646
  return {
669
647
  version: RADAR_PUBLICATION_VERSION,
670
- agent_id: agentId,
648
+ registration_id: agentId,
671
649
  channel,
672
650
  publisher,
673
651
  sequence: 0,
@@ -676,7 +654,7 @@ function createChannelWriter(input: ChannelWriterInput): RadarChannelWriter {
676
654
 
677
655
  function loadRecord(): ChannelRecord {
678
656
  const value = store.read(recordName);
679
- const agentId = textAt(value, "agent_id");
657
+ const agentId = textAt(value, "registration_id");
680
658
  const storedPublisher = recordAt(value, "publisher");
681
659
  // A record written for another channel or publisher is not this writer's
682
660
  // binding: adopting it would publish under someone else's identity.
@@ -704,7 +682,7 @@ function createChannelWriter(input: ChannelWriterInput): RadarChannelWriter {
704
682
  const sentAt = numberAt(acceptedValue, "sent_at");
705
683
  return {
706
684
  version: RADAR_PUBLICATION_VERSION,
707
- agent_id: agentId,
685
+ registration_id: agentId,
708
686
  channel,
709
687
  publisher,
710
688
  ...(handle !== undefined && generation !== undefined
@@ -772,7 +750,7 @@ function createChannelWriter(input: ChannelWriterInput): RadarChannelWriter {
772
750
  // identical immutable content that was persisted with it.
773
751
  const agentId = await input.subject(recovering);
774
752
  if (agentId === undefined) return false;
775
- if (record.agent_id !== agentId) {
753
+ if (record.registration_id !== agentId) {
776
754
  // A different subject incarnation is a different channel: the previous
777
755
  // binding, sequence and unresolved request do not apply to it, so the
778
756
  // next drain publishes a complete snapshot instead of replaying one.
@@ -780,7 +758,7 @@ function createChannelWriter(input: ChannelWriterInput): RadarChannelWriter {
780
758
  save();
781
759
  }
782
760
  const answer = await client.acquire({
783
- agent_id: agentId,
761
+ registration_id: agentId,
784
762
  channel,
785
763
  publisher,
786
764
  });
@@ -821,7 +799,7 @@ function createChannelWriter(input: ChannelWriterInput): RadarChannelWriter {
821
799
  const writer = record.writer;
822
800
  if (writer === undefined) return;
823
801
  const answer = await client.publish({
824
- agent_id: record.agent_id,
802
+ registration_id: record.registration_id,
825
803
  channel,
826
804
  writer_handle: writer.handle,
827
805
  sequence: pending.sequence,
@@ -934,10 +912,10 @@ function createChannelWriter(input: ChannelWriterInput): RadarChannelWriter {
934
912
  stopPump();
935
913
  const writer = record.writer;
936
914
  if (writer === undefined) return;
937
- const agentId = record.agent_id;
915
+ const agentId = record.registration_id;
938
916
  try {
939
917
  void client
940
- .retire({ agent_id: agentId, channel, writer_handle: writer.handle })
918
+ .retire({ registration_id: agentId, channel, writer_handle: writer.handle })
941
919
  .then((answer) => {
942
920
  if (answer.ok === true) {
943
921
  try {
@@ -965,335 +943,166 @@ function createChannelWriter(input: ChannelWriterInput): RadarChannelWriter {
965
943
  };
966
944
  }
967
945
 
968
- export type RadarContextWriter = {
969
- /**
970
- * Report this process's current session. `null` is the explicit "no current
971
- * session" the daemon serves; `undefined` never is, because a process that
972
- * does not know its session has nothing to assert.
973
- */
974
- update(session: string | null, launchIdentity?: RadarLaunchIdentityMarker): void;
975
- /** Release this writer's timers. The accepted report stays until it goes stale. */
946
+ export type RadarPresenceReporter = {
947
+ /** Start a fresh session-load claim. Retries and heartbeats keep its claim ID. */
948
+ report(session: string): void;
949
+ /** Release this exact session claim on graceful shutdown. */
976
950
  stop(): void;
977
951
  /** Re-arm a stopped writer after an extension reload. */
978
952
  reopen(): void;
953
+ /** Radar's standing on the session last published, once the daemon answered. */
954
+ management(): RadarManagement | undefined;
955
+ ensure(): Promise<string | undefined>;
956
+ release(): Promise<void>;
979
957
  diagnostic(): string | undefined;
980
958
  };
981
959
 
982
- type ContextWriterInput = {
960
+ type PresenceReporterInput = {
983
961
  store: PublicationStore;
984
962
  client: RadarPublicationClient;
985
963
  scheduler: RadarScheduler;
986
964
  recordName: string;
987
- publisher: RadarPublisherIdentity;
988
- /**
989
- * The exact subject this process registered. `recheck` forgets a cached
990
- * subject so the identical immutable content is registered again, which is
991
- * what a daemon that lost its state root needs.
992
- */
993
- subject(recheck: boolean): Promise<string | undefined>;
994
- leaseMs: number;
965
+ registration(): Promise<RadarRegistration | undefined>;
995
966
  heartbeatMs: number;
996
967
  };
997
968
 
998
969
  /**
999
- * This process's own current session, under the channel discipline: one writer,
1000
- * a strictly forward sequence and a lease. The first publish binds the writer
1001
- * the daemon issues; a later session is a newer sequence under that same
1002
- * binding, and the lease is renewed from the same cadence as the process's
1003
- * facts rather than from a second heartbeat.
1004
- *
1005
- * A refusal is a real disagreement about ownership, so it stops this writer
1006
- * with one diagnostic instead of taking over a binding it did not observe.
970
+ * Owns this process's durable claim retry state; Radar owns the subject grant.
971
+ * Session-load boundaries create claim IDs; heartbeats/reconnects reuse them.
1007
972
  */
1008
- function createContextWriter(input: ContextWriterInput): RadarContextWriter {
1009
- const { store, client, scheduler, recordName, publisher } = input;
1010
- let record = loadRecord();
1011
- let desired: string | null | undefined;
1012
- let desiredMarker: RadarLaunchIdentityMarker | undefined;
1013
- let running = false;
1014
- let again = false;
1015
- let recovering = false;
1016
- let stopped = false;
1017
- let fenced = false;
1018
- let pump: RadarTimer | undefined;
1019
-
1020
- function blank(agentId: string): ContextRecord {
1021
- return {
1022
- version: RADAR_PUBLICATION_VERSION,
1023
- agent_id: agentId,
1024
- publisher,
1025
- sequence: 0,
1026
- };
1027
- }
1028
-
1029
- function loadRecord(): ContextRecord {
1030
- const value = store.read(recordName);
1031
- const agentId = textAt(value, "agent_id");
1032
- const storedPublisher = recordAt(value, "publisher");
1033
- // A record written for another publisher is not this writer's binding:
1034
- // adopting it would publish under someone else's identity.
1035
- if (
1036
- numberAt(value, "version") !== RADAR_PUBLICATION_VERSION ||
1037
- agentId === undefined ||
1038
- (!isUuid(agentId) && agentId !== "") ||
1039
- textAt(storedPublisher, "source") !== publisher.source ||
1040
- textAt(storedPublisher, "incarnation") !== publisher.incarnation ||
1041
- textAt(storedPublisher, "reporting_owner") !== publisher.reporting_owner
1042
- )
1043
- return blank("");
1044
- const writer = recordAt(value, "writer");
1045
- const handle = textAt(writer, "handle");
1046
- const generation = numberAt(writer, "generation");
973
+ function createPresenceReporter(input: PresenceReporterInput): RadarPresenceReporter {
974
+ const { store, client, scheduler, recordName } = input;
975
+ type State = {
976
+ version: 2;
977
+ claim_id: string;
978
+ session: string;
979
+ registration_id?: string;
980
+ subject_id?: string;
981
+ releases: Array<{ registration_id: string; claim_id: string }>;
982
+ };
983
+ let state: State | undefined;
984
+ let standing: RadarManagement | undefined;
985
+ let diagnostic: string | undefined;
986
+ try {
987
+ const saved = store.read(recordName) as State | undefined;
1047
988
  if (
1048
- writer !== undefined &&
1049
- (!exactKeys(writer, WRITER_KEYS) || !isUuid(handle) || generation === undefined)
989
+ saved?.version === 2 &&
990
+ isUuid(saved.claim_id) &&
991
+ isUuid(saved.session) &&
992
+ Array.isArray(saved.releases)
1050
993
  )
1051
- return blank(agentId);
1052
- const pending = contextReportAt(
1053
- recordAt(value, "pending"),
1054
- CONTEXT_PENDING_KEYS,
1055
- CONTEXT_PENDING_MARKED_KEYS,
1056
- );
1057
- const acceptedValue = recordAt(value, "accepted");
1058
- const accepted = contextReportAt(
1059
- acceptedValue,
1060
- CONTEXT_ACCEPTED_KEYS,
1061
- CONTEXT_ACCEPTED_MARKED_KEYS,
1062
- );
1063
- const sentAt = numberAt(acceptedValue, "sent_at");
1064
- return {
1065
- version: RADAR_PUBLICATION_VERSION,
1066
- agent_id: agentId,
1067
- publisher,
1068
- ...(handle !== undefined && generation !== undefined
1069
- ? { writer: { handle, generation } }
1070
- : {}),
1071
- sequence: numberAt(value, "sequence") ?? 0,
1072
- ...(pending !== undefined ? { pending } : {}),
1073
- ...(accepted !== undefined && sentAt !== undefined
1074
- ? { accepted: { ...accepted, sent_at: sentAt } }
1075
- : {}),
1076
- ...(textAt(value, "diagnostic") !== undefined
1077
- ? { diagnostic: textAt(value, "diagnostic") }
1078
- : {}),
1079
- };
1080
- }
1081
-
1082
- /** One bounded diagnostic: the first cause is the one a reader reconciles. */
1083
- function note(message: string): void {
1084
- if (record.diagnostic !== undefined) return;
1085
- record.diagnostic = message.slice(0, 256);
1086
- save();
1087
- }
1088
-
1089
- function save(): void {
1090
- store.write(recordName, record);
1091
- }
1092
-
1093
- function fence(message: string): void {
1094
- fenced = true;
1095
- stopPump();
1096
- note(`context stopped: ${message}`);
1097
- }
1098
-
1099
- function startPump(): void {
1100
- if (pump !== undefined || stopped || fenced) return;
1101
- pump = scheduler.setInterval(() => kick(), input.heartbeatMs);
1102
- pump.unref?.();
1103
- }
1104
-
1105
- function stopPump(): void {
1106
- if (pump === undefined) return;
1107
- scheduler.clearInterval(pump);
1108
- pump = undefined;
994
+ state = saved;
995
+ } catch (error) {
996
+ diagnostic = `presence storage unavailable: ${String(error)}`;
1109
997
  }
1110
-
1111
- async function ensureSubject(): Promise<boolean> {
1112
- if (record.writer !== undefined) return true;
1113
- // A subject the daemon no longer holds is registered again from the
1114
- // identical immutable content that was persisted with it.
1115
- const agentId = await input.subject(recovering);
1116
- if (agentId === undefined) return false;
1117
- if (record.agent_id !== agentId) {
1118
- // A different subject incarnation is a different context record: the
1119
- // previous binding, sequence and unresolved report do not apply to it.
1120
- record = blank(agentId);
1121
- save();
998
+ let timer: RadarTimer | undefined;
999
+ let stopped = false;
1000
+ let running: Promise<string | undefined> | undefined;
1001
+ function save(): boolean {
1002
+ try {
1003
+ if (state) store.write(recordName, state);
1004
+ return true;
1005
+ } catch (error) {
1006
+ diagnostic = `presence storage unavailable: ${String(error)}`;
1007
+ return false;
1122
1008
  }
1123
- return true;
1124
1009
  }
1125
-
1126
- function heartbeatDue(): boolean {
1127
- return (
1128
- record.accepted !== undefined &&
1129
- scheduler.now() - record.accepted.sent_at >= input.heartbeatMs
1130
- );
1131
- }
1132
-
1133
- async function send(pending: ContextReport): Promise<void> {
1134
- const writer = record.writer;
1135
- const answer = await client.context({
1136
- agent_id: record.agent_id,
1137
- publisher,
1138
- // A first publish presents no handle: the daemon binds the writer the
1139
- // publisher already is, which is the binding this writer then keeps.
1140
- ...(writer !== undefined ? { writer_handle: writer.handle } : {}),
1141
- sequence: pending.sequence,
1142
- lease_ms: pending.lease_ms,
1143
- observed_at: pending.observed_at,
1144
- context: {
1145
- session: pending.session,
1146
- ...(pending.launch_identity !== undefined
1147
- ? { launch_identity: pending.launch_identity }
1148
- : {}),
1149
- },
1150
- });
1151
- if (answer.ok === true) {
1152
- recovering = false;
1153
- record.writer = {
1154
- handle: answer.value.writer.handle,
1155
- generation: answer.value.writer.generation,
1156
- };
1157
- record.sequence = Math.max(record.sequence, answer.value.writer.sequence);
1158
- record.accepted = {
1159
- ...pending,
1160
- sent_at: answer.value.warning === undefined
1161
- ? scheduler.now()
1162
- : record.accepted?.sent_at ?? scheduler.now(),
1163
- };
1164
- record.pending = undefined;
1165
- save();
1166
- return;
1167
- }
1168
- if (
1169
- answer.code === "refused" ||
1170
- answer.code === "bad_params" ||
1171
- // A daemon that speaks the registry but does not serve this record would
1172
- // refuse every retry the same way; one diagnostic beats a silent retry
1173
- // loop once per heartbeat.
1174
- answer.code === "unknown_method"
1175
- ) {
1176
- fence(answer.message);
1177
- return;
1178
- }
1179
- if (answer.code === "not_found") {
1180
- // The subject is gone. Nothing of the old subject can be republished
1181
- // against, so the whole record is dropped: the next heartbeat registers
1182
- // the identical content again and binds a fresh writer to it.
1183
- record = blank("");
1184
- recovering = true;
1010
+ async function releasePending() {
1011
+ if (!state) return;
1012
+ const registration = await input.registration();
1013
+ if (!registration?.process) return;
1014
+ for (const release of [...state.releases]) {
1015
+ const answer = await client.releasePresence({ ...release, process: registration.process });
1016
+ if (!answer.ok) return;
1017
+ state.releases = state.releases.filter(r => r.claim_id !== release.claim_id);
1185
1018
  save();
1186
- return;
1187
1019
  }
1188
- // A timeout, a closed connection or a lost reply leaves the exact request
1189
- // pending, so the retry replays it byte for byte and the lease does not
1190
- // silently advance.
1191
1020
  }
1192
-
1193
- async function stage(
1194
- session: string | null,
1195
- launchIdentity?: RadarLaunchIdentityMarker,
1196
- ): Promise<void> {
1197
- const pending: ContextReport = {
1198
- sequence: record.sequence + 1,
1199
- session,
1200
- lease_ms: input.leaseMs,
1201
- observed_at: new Date(scheduler.now()).toISOString(),
1202
- ...(launchIdentity !== undefined ? { launch_identity: launchIdentity } : {}),
1203
- };
1204
- // Persisted before it is sent, so a lost reply replays this exact request.
1205
- record.pending = pending;
1206
- save();
1207
- await send(pending);
1208
- }
1209
-
1210
- async function drainOnce(): Promise<void> {
1211
- if (fenced) return;
1212
- if (record.pending !== undefined) {
1213
- // An unresolved request is never overwritten by newer content.
1214
- await send(record.pending);
1215
- return;
1216
- }
1217
- if (desired === undefined) return;
1218
- if (!(await ensureSubject())) return;
1219
- if (fenced) return;
1220
- if (
1221
- record.writer === undefined ||
1222
- record.accepted === undefined ||
1223
- record.accepted.session !== desired ||
1224
- !sameLaunchIdentity(record.accepted.launch_identity, desiredMarker)
1225
- ) {
1226
- await stage(desired, desiredMarker);
1227
- return;
1228
- }
1229
- if (heartbeatDue()) await stage(record.accepted.session, desiredMarker);
1021
+ function schedule() {
1022
+ if (stopped || timer) return;
1023
+ timer = scheduler.setTimeout(() => { timer = undefined; void ensure(); }, input.heartbeatMs);
1024
+ timer.unref?.();
1230
1025
  }
1231
-
1232
- async function drain(): Promise<void> {
1233
- if (running || stopped || fenced) return;
1234
- running = true;
1235
- try {
1236
- await drainOnce();
1237
- } catch (error) {
1238
- fenced = true;
1239
- stopPump();
1240
- try {
1241
- note(`context storage failure: ${String(error).slice(0, 180)}`);
1242
- } catch {
1243
- // Persistence failure is diagnostic-only; never escape into the host.
1244
- }
1245
- } finally {
1246
- running = false;
1247
- if (again && !stopped && !fenced) {
1248
- again = false;
1249
- kick();
1026
+ async function ensure(): Promise<string | undefined> {
1027
+ if (running) return running;
1028
+ if (stopped || !state) return undefined;
1029
+ const claim = state;
1030
+ if (!save()) return undefined;
1031
+ running = (async () => {
1032
+ await releasePending();
1033
+ const registration = await input.registration();
1034
+ if (!registration?.process || state !== claim || stopped) return undefined;
1035
+ const answer = await client.presence({
1036
+ ...registration, process: registration.process,
1037
+ session_id: claim.session, claim_id: claim.claim_id,
1038
+ ...(claim.registration_id ? { registration_id: claim.registration_id } : {}),
1039
+ ...(claim.subject_id ? { subject_id: claim.subject_id } : {}),
1040
+ });
1041
+ if (state !== claim) return undefined;
1042
+ if (!answer.ok) {
1043
+ if (!["absent", "timeout", "transport"].includes(answer.code)) diagnostic = answer.message;
1044
+ return undefined;
1250
1045
  }
1046
+ claim.registration_id = answer.value.registration_id;
1047
+ claim.subject_id = answer.value.standing === "granted" ? answer.value.subject_id ?? undefined : undefined;
1048
+ standing = answer.value.standing === "granted"
1049
+ ? { state: "managed", subject_id: claim.subject_id! }
1050
+ : answer.value.standing === "duplicate"
1051
+ ? { state: "duplicate" }
1052
+ : answer.value.standing === "pending"
1053
+ ? { state: "pending" }
1054
+ : { state: "unmanaged" };
1055
+ diagnostic = answer.value.standing === "duplicate" ? DUPLICATE_SESSION_DIAGNOSTIC : undefined;
1056
+ save();
1057
+ return claim.registration_id;
1058
+ })();
1059
+ try { return await running; }
1060
+ catch (error) { diagnostic = `presence unavailable: ${String(error)}`; return undefined; }
1061
+ finally {
1062
+ running = undefined;
1063
+ if (!stopped && state !== claim) void ensure();
1064
+ else schedule();
1251
1065
  }
1252
1066
  }
1253
-
1254
- function kick(): void {
1255
- if (stopped || fenced) return;
1256
- startPump();
1257
- if (running) {
1258
- again = true;
1259
- return;
1260
- }
1261
- void drain().catch(() => undefined);
1262
- }
1263
-
1264
1067
  return {
1265
- update(session, launchIdentity) {
1266
- if (
1267
- fenced ||
1268
- (session === desired &&
1269
- sameLaunchIdentity(desiredMarker, launchIdentity))
1270
- )
1271
- return;
1272
- // A session reported while this writer is stopped is remembered, not
1273
- // published: only `reopen` restarts the timers, and it must see the
1274
- // session the process is in now rather than the one it left.
1275
- desired = session;
1276
- desiredMarker = launchIdentity;
1277
- if (stopped) return;
1278
- kick();
1068
+ report(session) {
1069
+ const releases = [...(state?.releases ?? [])];
1070
+ if (state?.registration_id && !releases.some(release => release.claim_id === state!.claim_id)) {
1071
+ releases.push({ registration_id: state.registration_id, claim_id: state.claim_id });
1072
+ }
1073
+ standing = undefined;
1074
+ state = { version: 2, session, claim_id: randomUUID(), releases };
1075
+ save(); stopped = false;
1076
+ void ensure();
1279
1077
  },
1280
- stop() {
1078
+ ensure,
1079
+ async release() {
1080
+ const releasing = state;
1081
+ standing = undefined;
1281
1082
  stopped = true;
1282
- stopPump();
1283
- },
1284
- reopen() {
1285
- stopped = false;
1286
- if (desired !== undefined || record.pending !== undefined) kick();
1083
+ if (timer) scheduler.clearTimeout(timer);
1084
+ timer = undefined;
1085
+ if (running) await running;
1086
+ if (state === releasing) standing = undefined;
1087
+ if (releasing?.registration_id && !releasing.releases.some(r => r.claim_id === releasing.claim_id)) {
1088
+ releasing.releases.push({ registration_id: releasing.registration_id, claim_id: releasing.claim_id });
1089
+ if (!save()) return;
1090
+ }
1091
+ await releasePending();
1287
1092
  },
1288
- diagnostic: () => record.diagnostic,
1093
+ stop() { stopped = true; if (timer) scheduler.clearTimeout(timer); timer = undefined; },
1094
+ reopen() { stopped = false; void ensure(); },
1095
+ management: () => standing,
1096
+ diagnostic: () => diagnostic,
1289
1097
  };
1290
1098
  }
1291
1099
 
1100
+
1292
1101
  export type RadarPublication = {
1293
1102
  /** This process's own execution channel. */
1294
1103
  execution: RadarChannelWriter;
1295
1104
  /** This process's own current session. */
1296
- context: RadarContextWriter;
1105
+ presence: RadarPresenceReporter;
1297
1106
  /** This process's own facts, a complete replacement on every update. */
1298
1107
  facts: RadarFactsWriter;
1299
1108
  /** The assignment channel of one owned child subject. */
@@ -1317,8 +1126,8 @@ export type RadarPublication = {
1317
1126
 
1318
1127
  /** What a process's own registration resolved to, and where it says it is. */
1319
1128
  export type RadarSubject = {
1320
- agent_id: string;
1321
- /** Stable managed logical subject, present only for a Radar-issued launch. */
1129
+ registration_id: string;
1130
+ /** Stable managed logical subject: a Radar-issued launch, or an adopted Pi session. */
1322
1131
  subject_id?: string;
1323
1132
  location?: RadarLocation;
1324
1133
  };
@@ -1335,32 +1144,23 @@ export type RadarPublicationOptions = {
1335
1144
  scheduler?: RadarScheduler;
1336
1145
  /** `undefined` omits the process claim rather than guessing one. */
1337
1146
  birth?: ProcessBirth;
1338
- /**
1339
- * The pane this process may publish for itself, resolved when the registration
1340
- * record is first built. It is awaited rather than required up front, because
1341
- * the daemon may not have answered yet - and a gap in that evidence is not a
1342
- * fact to freeze: only a settled answer writes the immutable record, and a
1343
- * record that already exists is never rebuilt and never acquires a location
1344
- * later.
1147
+ /** Current pane observation, refreshed for every presence report. Missing location
1148
+ * leaves process identity reportable but pending subject grant; no pane is guessed.
1345
1149
  */
1346
1150
  location?: () => Promise<RadarLocationResolution>;
1347
1151
  /** Set on a managed child: the sidecar its owner correlates the subject with. */
1348
1152
  binding?: { key: string; run: string; owner: string; label: string };
1349
- /**
1350
- * A daemon-spawned child's one-use launch token. It is sent with this
1351
- * registration only, and never written to the subject record: a private
1352
- * credential is not part of the identity that record holds.
1353
- */
1153
+ /** A daemon-spawned child's one-use launch token; sent privately, never stored in the process record. */
1354
1154
  spawnToken?: string;
1355
1155
  leaseMs?: number;
1356
1156
  heartbeatMs?: number;
1357
1157
  };
1358
1158
 
1359
- /** The persisted subject plus the daemon-issued identity it resolved to. */
1360
- type SubjectRecord = {
1159
+ /** The process registration and its daemon-issued registration ID. */
1160
+ type RegistrationRecord = {
1361
1161
  version: typeof RADAR_PUBLICATION_VERSION;
1362
1162
  registration: RadarRegistration;
1363
- agent_id?: string;
1163
+ registration_id?: string;
1364
1164
  };
1365
1165
 
1366
1166
  /**
@@ -1399,10 +1199,13 @@ function unavailablePublication(error?: unknown): RadarPublication {
1399
1199
  };
1400
1200
  return {
1401
1201
  execution: writer,
1402
- context: {
1403
- update: () => undefined,
1202
+ presence: {
1203
+ report: () => undefined,
1204
+ ensure: async () => undefined,
1205
+ release: async () => undefined,
1404
1206
  stop: () => undefined,
1405
1207
  reopen: () => undefined,
1208
+ management: () => undefined,
1406
1209
  diagnostic: () => diagnostic,
1407
1210
  },
1408
1211
  facts: { ...writer, update: () => undefined },
@@ -1438,7 +1241,7 @@ function createRadarPublicationInner(
1438
1241
  return cached;
1439
1242
  }
1440
1243
 
1441
- const subjectName = `subjects/${createHash("sha256")
1244
+ const registrationName = `registrations/${createHash("sha256")
1442
1245
  .update(`${options.registration.source}\0${incarnation}`)
1443
1246
  .digest("hex")
1444
1247
  .slice(0, 32)}.json`;
@@ -1456,8 +1259,8 @@ function createRadarPublicationInner(
1456
1259
  .slice(0, 32)}.json`;
1457
1260
 
1458
1261
  /** One current-session record per publisher, for the same reason as channels. */
1459
- const contextRecord = `context/${createHash("sha256")
1460
- .update(`context\0${incarnation}`)
1262
+ const presenceRecord = `presence/${createHash("sha256")
1263
+ .update(`presence\0${incarnation}`)
1461
1264
  .digest("hex")
1462
1265
  .slice(0, 32)}.json`;
1463
1266
 
@@ -1466,8 +1269,8 @@ function createRadarPublicationInner(
1466
1269
  * stored content wins over a later attempt to build it again: a claim read
1467
1270
  * successfully once may not change what this incarnation is registered as.
1468
1271
  */
1469
- function readSubject(): SubjectRecord | undefined {
1470
- const value = store.read(subjectName);
1272
+ function readRegistration(): RegistrationRecord | undefined {
1273
+ const value = store.read(registrationName);
1471
1274
  const registration = recordAt(value, "registration");
1472
1275
  if (
1473
1276
  numberAt(value, "version") !== RADAR_PUBLICATION_VERSION ||
@@ -1475,20 +1278,20 @@ function createRadarPublicationInner(
1475
1278
  textAt(registration, "incarnation") !== incarnation
1476
1279
  )
1477
1280
  return undefined;
1478
- const storedAgentId = textAt(value, "agent_id");
1281
+ const storedAgentId = textAt(value, "registration_id");
1479
1282
  return {
1480
1283
  version: RADAR_PUBLICATION_VERSION,
1481
1284
  registration: registration as RadarRegistration,
1482
1285
  ...(storedAgentId !== undefined && isUuid(storedAgentId)
1483
- ? { agent_id: storedAgentId }
1286
+ ? { registration_id: storedAgentId }
1484
1287
  : {}),
1485
1288
  };
1486
1289
  }
1487
1290
 
1488
- const stored = readSubject();
1489
- let subject: SubjectRecord | undefined = stored;
1291
+ const stored = readRegistration();
1292
+ let processRecord: RegistrationRecord | undefined = stored;
1490
1293
  let registrationDiagnostic: string | undefined;
1491
- let registrationRecord: Promise<SubjectRecord | undefined> | undefined;
1294
+ let registrationRecord: Promise<RegistrationRecord | undefined> | undefined;
1492
1295
  /**
1493
1296
  * The immutable registration is written before it is ever sent, and the stored
1494
1297
  * content wins over a later attempt to build it again: a claim read
@@ -1503,18 +1306,13 @@ function createRadarPublicationInner(
1503
1306
  * never be repaired without replacing the identity it belongs to; the next
1504
1307
  * attempt asks the daemon again instead.
1505
1308
  */
1506
- async function ensureRegistration(): Promise<SubjectRecord | undefined> {
1507
- if (subject !== undefined) return subject;
1309
+ async function ensureRegistration(): Promise<RegistrationRecord | undefined> {
1310
+ if (processRecord !== undefined) return processRecord;
1508
1311
  registrationRecord ??= (async () => {
1509
1312
  const resolution = options.location
1510
1313
  ? await options.location()
1511
1314
  : ({ state: "absent", reason: "this process publishes no location" } as const);
1512
- if (resolution.state === "pending") {
1513
- registrationDiagnostic =
1514
- `location bootstrap pending: ${resolution.reason}`.slice(0, 256);
1515
- return undefined;
1516
- }
1517
- const built: SubjectRecord = {
1315
+ const built: RegistrationRecord = {
1518
1316
  version: RADAR_PUBLICATION_VERSION,
1519
1317
  registration: {
1520
1318
  ...options.registration,
@@ -1526,14 +1324,14 @@ function createRadarPublicationInner(
1526
1324
  },
1527
1325
  };
1528
1326
  try {
1529
- store.write(subjectName, built);
1327
+ store.write(registrationName, built);
1530
1328
  } catch (error) {
1531
1329
  registrationDiagnostic ??=
1532
1330
  `private storage unavailable: ${String(error)}`.slice(0, 256);
1533
1331
  return undefined;
1534
1332
  }
1535
1333
  registrationDiagnostic = undefined;
1536
- subject = built;
1334
+ processRecord = built;
1537
1335
  return built;
1538
1336
  })();
1539
1337
  try {
@@ -1542,11 +1340,10 @@ function createRadarPublicationInner(
1542
1340
  registrationRecord = undefined;
1543
1341
  }
1544
1342
  }
1545
- let agentId = stored?.agent_id;
1343
+ let agentId = stored?.registration_id;
1546
1344
  let subjectReady: Promise<string | undefined> | undefined;
1547
1345
  async function ensureSubject(recheck: boolean): Promise<string | undefined> {
1548
- if (recheck) agentId = undefined;
1549
- if (agentId !== undefined) return agentId;
1346
+ if (!recheck && agentId !== undefined) return agentId;
1550
1347
  subjectReady ??= (async () => {
1551
1348
  const ready = await client.ping();
1552
1349
  if (ready.ok === false) {
@@ -1556,18 +1353,11 @@ function createRadarPublicationInner(
1556
1353
  }
1557
1354
  const record = await ensureRegistration();
1558
1355
  if (record === undefined) return undefined;
1559
- const answer = await client.register({
1560
- ...record.registration,
1561
- ...(spawnToken !== undefined ? { spawn_token: spawnToken } : {}),
1562
- });
1563
- if (answer.ok === false) {
1564
- if (answer.code !== "absent" && answer.code !== "timeout" && answer.code !== "transport")
1565
- registrationDiagnostic ??= `subject registration refused: ${answer.message}`.slice(0, 256);
1566
- return undefined;
1567
- }
1568
- agentId = answer.value.agent_id;
1569
- subject = { ...record, agent_id: agentId };
1570
- store.write(subjectName, subject);
1356
+ const registered = await presence.ensure();
1357
+ if (registered === undefined) return undefined;
1358
+ agentId = registered;
1359
+ processRecord = { ...record, registration_id: agentId };
1360
+ store.write(registrationName, processRecord);
1571
1361
  if (options.binding !== undefined) writeBinding(options.binding);
1572
1362
  return agentId;
1573
1363
  })();
@@ -1587,7 +1377,7 @@ function createRadarPublicationInner(
1587
1377
  if (agentId === undefined) return;
1588
1378
  store.write(`bindings/${binding.key}.json`, {
1589
1379
  version: RADAR_PUBLICATION_VERSION,
1590
- agent_id: agentId,
1380
+ registration_id: agentId,
1591
1381
  incarnation,
1592
1382
  run: binding.run,
1593
1383
  owner: binding.owner,
@@ -1617,14 +1407,18 @@ function createRadarPublicationInner(
1617
1407
  heartbeatMs: options.heartbeatMs ?? RADAR_HEARTBEAT_MS,
1618
1408
  });
1619
1409
 
1620
- const context = createContextWriter({
1621
- store,
1622
- client,
1623
- scheduler,
1624
- recordName: contextRecord,
1625
- publisher: publisher("herdsman-pi"),
1626
- subject: ensureSubject,
1627
- leaseMs: options.leaseMs ?? RADAR_LEASE_MS,
1410
+ const presence = createPresenceReporter({
1411
+ store, client, scheduler, recordName: presenceRecord,
1412
+ registration: async () => {
1413
+ const record = await ensureRegistration();
1414
+ if (!record) return undefined;
1415
+ const location = await options.location?.();
1416
+ const { location: _initialLocation, ...identity } = record.registration;
1417
+ return { ...identity,
1418
+ ...(location?.state === "resolved" ? { location: location.location } : {}),
1419
+ ...(spawnToken ? { spawn_token: spawnToken } : {}),
1420
+ };
1421
+ },
1628
1422
  heartbeatMs: options.heartbeatMs ?? RADAR_HEARTBEAT_MS,
1629
1423
  });
1630
1424
 
@@ -1645,7 +1439,7 @@ function createRadarPublicationInner(
1645
1439
 
1646
1440
  const publication: RadarPublication = {
1647
1441
  execution,
1648
- context,
1442
+ presence,
1649
1443
  facts,
1650
1444
  assignment(target, reportingOwner: string) {
1651
1445
  const existing = assignmentWriters.get(target);
@@ -1660,7 +1454,7 @@ function createRadarPublicationInner(
1660
1454
  publisher: publisher("herdsman-owner", reportingOwner),
1661
1455
  codec: assignmentCodec,
1662
1456
  // The owner publishes about a subject it did not register: the exact
1663
- // agent_id from the child's sidecar is the whole correlation.
1457
+ // registration_id from the child's sidecar is the whole correlation.
1664
1458
  subject: async () => target,
1665
1459
  leaseMs: options.leaseMs ?? RADAR_LEASE_MS,
1666
1460
  heartbeatMs: options.heartbeatMs ?? RADAR_HEARTBEAT_MS,
@@ -1680,7 +1474,7 @@ function createRadarPublicationInner(
1680
1474
  registrationDiagnostic ??= `Radar binding unavailable: ${String(error).slice(0, 180)}`;
1681
1475
  return undefined;
1682
1476
  }
1683
- const bound = textAt(value, "agent_id");
1477
+ const bound = textAt(value, "registration_id");
1684
1478
  const boundIncarnation = textAt(value, "incarnation");
1685
1479
  const run = textAt(value, "run");
1686
1480
  const owner = textAt(value, "owner");
@@ -1699,7 +1493,7 @@ function createRadarPublicationInner(
1699
1493
  return undefined;
1700
1494
  return {
1701
1495
  version: RADAR_PUBLICATION_VERSION,
1702
- agent_id: bound,
1496
+ registration_id: bound,
1703
1497
  incarnation: boundIncarnation,
1704
1498
  run,
1705
1499
  owner,
@@ -1717,10 +1511,13 @@ function createRadarPublicationInner(
1717
1511
  if (agentId === undefined) return undefined;
1718
1512
  const record = await ensureRegistration();
1719
1513
  if (record === undefined) return undefined;
1720
- const location = record.registration.location;
1721
- const subjectId = process.env.PI_RADAR_MANAGED_SUBJECT_ID;
1514
+ const currentLocation = await options.location?.();
1515
+ const location = currentLocation?.state === "resolved" ? currentLocation.location : undefined;
1516
+ // Radar's answer to this process's current session names its subject.
1517
+ const standing = presence.management();
1518
+ const subjectId = standing?.state === "managed" ? standing.subject_id : undefined;
1722
1519
  return {
1723
- agent_id: agentId,
1520
+ registration_id: agentId,
1724
1521
  ...(subjectId !== undefined ? { subject_id: subjectId } : {}),
1725
1522
  ...(location !== undefined ? { location } : {}),
1726
1523
  };
@@ -1728,18 +1525,18 @@ function createRadarPublicationInner(
1728
1525
  diagnostic: () =>
1729
1526
  registrationDiagnostic ??
1730
1527
  execution.diagnostic() ??
1731
- context.diagnostic() ??
1528
+ presence.diagnostic() ??
1732
1529
  facts.diagnostic(),
1733
1530
  stop() {
1734
1531
  for (const writer of assignmentWriters.values()) writer.stop();
1735
1532
  execution.stop();
1736
- context.stop();
1533
+ presence.stop();
1737
1534
  facts.stop();
1738
1535
  },
1739
1536
  reopen() {
1740
1537
  for (const writer of assignmentWriters.values()) writer.reopen();
1741
1538
  execution.reopen();
1742
- context.reopen();
1539
+ presence.reopen();
1743
1540
  facts.reopen();
1744
1541
  },
1745
1542
  };