pi-radar 0.2.0 → 0.4.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 (42) hide show
  1. package/README.md +10 -4
  2. package/dist/extension/baseline.js +333 -394
  3. package/dist/extension/radar-client.js +126 -11
  4. package/dist/extension/radar-communication.js +39 -0
  5. package/dist/extension/radar-execution.js +169 -382
  6. package/dist/extension/radar-launch.js +18 -13
  7. package/dist/extension/radar-publication.js +161 -298
  8. package/dist/extension/radar-runtime.js +1 -1
  9. package/dist/index.js +356 -407
  10. package/extension/baseline.ts +2 -0
  11. package/extension/generated-contracts.ts +1310 -10
  12. package/extension/radar-client.ts +191 -23
  13. package/extension/radar-communication.ts +66 -0
  14. package/extension/radar-execution.ts +11 -58
  15. package/extension/radar-launch.ts +29 -22
  16. package/extension/radar-publication.ts +211 -410
  17. package/extension/radar-runtime.ts +4 -1
  18. package/index.ts +1 -0
  19. package/package.json +9 -5
  20. package/schema/public/v1/handoff-inbox.deserialize.schema.json +18 -0
  21. package/schema/public/v1/handoff-inbox.serialize.schema.json +18 -0
  22. package/schema/public/v1/handoff-publish-params.deserialize.schema.json +33 -0
  23. package/schema/public/v1/handoff-publish-params.serialize.schema.json +33 -0
  24. package/schema/public/v1/handoff-receipt.deserialize.schema.json +66 -0
  25. package/schema/public/v1/handoff-receipt.serialize.schema.json +66 -0
  26. package/schema/public/v1/handoff.deserialize.schema.json +84 -0
  27. package/schema/public/v1/handoff.serialize.schema.json +84 -0
  28. package/schema/public/v1/observation-get-params.deserialize.schema.json +46 -0
  29. package/schema/public/v1/observation-get-params.serialize.schema.json +52 -0
  30. package/schema/public/v1/observation-get-response.deserialize.schema.json +2731 -0
  31. package/schema/public/v1/observation-get-response.serialize.schema.json +2862 -0
  32. package/schema/public/v1/observation-snapshot.deserialize.schema.json +2616 -0
  33. package/schema/public/v1/observation-snapshot.serialize.schema.json +2741 -0
  34. package/schema/public/v1/presence-answer.deserialize.schema.json +40 -0
  35. package/schema/public/v1/presence-answer.serialize.schema.json +40 -0
  36. package/schema/public/v1/presence-release.deserialize.schema.json +51 -0
  37. package/schema/public/v1/presence-release.serialize.schema.json +52 -0
  38. package/schema/public/v1/presence-report.deserialize.schema.json +149 -0
  39. package/schema/public/v1/presence-report.serialize.schema.json +157 -0
  40. package/extension/radar-session-marker.ts +0 -146
  41. package/schema/public/v1/launch-identity-marker.deserialize.schema.json +0 -32
  42. 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,7 +1126,9 @@ 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;
1129
+ registration_id: string;
1130
+ /** Stable managed logical subject: a Radar-issued launch, or an adopted Pi session. */
1131
+ subject_id?: string;
1321
1132
  location?: RadarLocation;
1322
1133
  };
1323
1134
 
@@ -1333,32 +1144,23 @@ export type RadarPublicationOptions = {
1333
1144
  scheduler?: RadarScheduler;
1334
1145
  /** `undefined` omits the process claim rather than guessing one. */
1335
1146
  birth?: ProcessBirth;
1336
- /**
1337
- * The pane this process may publish for itself, resolved when the registration
1338
- * record is first built. It is awaited rather than required up front, because
1339
- * the daemon may not have answered yet - and a gap in that evidence is not a
1340
- * fact to freeze: only a settled answer writes the immutable record, and a
1341
- * record that already exists is never rebuilt and never acquires a location
1342
- * 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.
1343
1149
  */
1344
1150
  location?: () => Promise<RadarLocationResolution>;
1345
1151
  /** Set on a managed child: the sidecar its owner correlates the subject with. */
1346
1152
  binding?: { key: string; run: string; owner: string; label: string };
1347
- /**
1348
- * A daemon-spawned child's one-use launch token. It is sent with this
1349
- * registration only, and never written to the subject record: a private
1350
- * credential is not part of the identity that record holds.
1351
- */
1153
+ /** A daemon-spawned child's one-use launch token; sent privately, never stored in the process record. */
1352
1154
  spawnToken?: string;
1353
1155
  leaseMs?: number;
1354
1156
  heartbeatMs?: number;
1355
1157
  };
1356
1158
 
1357
- /** The persisted subject plus the daemon-issued identity it resolved to. */
1358
- type SubjectRecord = {
1159
+ /** The process registration and its daemon-issued registration ID. */
1160
+ type RegistrationRecord = {
1359
1161
  version: typeof RADAR_PUBLICATION_VERSION;
1360
1162
  registration: RadarRegistration;
1361
- agent_id?: string;
1163
+ registration_id?: string;
1362
1164
  };
1363
1165
 
1364
1166
  /**
@@ -1397,10 +1199,13 @@ function unavailablePublication(error?: unknown): RadarPublication {
1397
1199
  };
1398
1200
  return {
1399
1201
  execution: writer,
1400
- context: {
1401
- update: () => undefined,
1202
+ presence: {
1203
+ report: () => undefined,
1204
+ ensure: async () => undefined,
1205
+ release: async () => undefined,
1402
1206
  stop: () => undefined,
1403
1207
  reopen: () => undefined,
1208
+ management: () => undefined,
1404
1209
  diagnostic: () => diagnostic,
1405
1210
  },
1406
1211
  facts: { ...writer, update: () => undefined },
@@ -1436,7 +1241,7 @@ function createRadarPublicationInner(
1436
1241
  return cached;
1437
1242
  }
1438
1243
 
1439
- const subjectName = `subjects/${createHash("sha256")
1244
+ const registrationName = `registrations/${createHash("sha256")
1440
1245
  .update(`${options.registration.source}\0${incarnation}`)
1441
1246
  .digest("hex")
1442
1247
  .slice(0, 32)}.json`;
@@ -1454,8 +1259,8 @@ function createRadarPublicationInner(
1454
1259
  .slice(0, 32)}.json`;
1455
1260
 
1456
1261
  /** One current-session record per publisher, for the same reason as channels. */
1457
- const contextRecord = `context/${createHash("sha256")
1458
- .update(`context\0${incarnation}`)
1262
+ const presenceRecord = `presence/${createHash("sha256")
1263
+ .update(`presence\0${incarnation}`)
1459
1264
  .digest("hex")
1460
1265
  .slice(0, 32)}.json`;
1461
1266
 
@@ -1464,8 +1269,8 @@ function createRadarPublicationInner(
1464
1269
  * stored content wins over a later attempt to build it again: a claim read
1465
1270
  * successfully once may not change what this incarnation is registered as.
1466
1271
  */
1467
- function readSubject(): SubjectRecord | undefined {
1468
- const value = store.read(subjectName);
1272
+ function readRegistration(): RegistrationRecord | undefined {
1273
+ const value = store.read(registrationName);
1469
1274
  const registration = recordAt(value, "registration");
1470
1275
  if (
1471
1276
  numberAt(value, "version") !== RADAR_PUBLICATION_VERSION ||
@@ -1473,20 +1278,20 @@ function createRadarPublicationInner(
1473
1278
  textAt(registration, "incarnation") !== incarnation
1474
1279
  )
1475
1280
  return undefined;
1476
- const storedAgentId = textAt(value, "agent_id");
1281
+ const storedAgentId = textAt(value, "registration_id");
1477
1282
  return {
1478
1283
  version: RADAR_PUBLICATION_VERSION,
1479
1284
  registration: registration as RadarRegistration,
1480
1285
  ...(storedAgentId !== undefined && isUuid(storedAgentId)
1481
- ? { agent_id: storedAgentId }
1286
+ ? { registration_id: storedAgentId }
1482
1287
  : {}),
1483
1288
  };
1484
1289
  }
1485
1290
 
1486
- const stored = readSubject();
1487
- let subject: SubjectRecord | undefined = stored;
1291
+ const stored = readRegistration();
1292
+ let processRecord: RegistrationRecord | undefined = stored;
1488
1293
  let registrationDiagnostic: string | undefined;
1489
- let registrationRecord: Promise<SubjectRecord | undefined> | undefined;
1294
+ let registrationRecord: Promise<RegistrationRecord | undefined> | undefined;
1490
1295
  /**
1491
1296
  * The immutable registration is written before it is ever sent, and the stored
1492
1297
  * content wins over a later attempt to build it again: a claim read
@@ -1501,18 +1306,13 @@ function createRadarPublicationInner(
1501
1306
  * never be repaired without replacing the identity it belongs to; the next
1502
1307
  * attempt asks the daemon again instead.
1503
1308
  */
1504
- async function ensureRegistration(): Promise<SubjectRecord | undefined> {
1505
- if (subject !== undefined) return subject;
1309
+ async function ensureRegistration(): Promise<RegistrationRecord | undefined> {
1310
+ if (processRecord !== undefined) return processRecord;
1506
1311
  registrationRecord ??= (async () => {
1507
1312
  const resolution = options.location
1508
1313
  ? await options.location()
1509
1314
  : ({ state: "absent", reason: "this process publishes no location" } as const);
1510
- if (resolution.state === "pending") {
1511
- registrationDiagnostic =
1512
- `location bootstrap pending: ${resolution.reason}`.slice(0, 256);
1513
- return undefined;
1514
- }
1515
- const built: SubjectRecord = {
1315
+ const built: RegistrationRecord = {
1516
1316
  version: RADAR_PUBLICATION_VERSION,
1517
1317
  registration: {
1518
1318
  ...options.registration,
@@ -1524,14 +1324,14 @@ function createRadarPublicationInner(
1524
1324
  },
1525
1325
  };
1526
1326
  try {
1527
- store.write(subjectName, built);
1327
+ store.write(registrationName, built);
1528
1328
  } catch (error) {
1529
1329
  registrationDiagnostic ??=
1530
1330
  `private storage unavailable: ${String(error)}`.slice(0, 256);
1531
1331
  return undefined;
1532
1332
  }
1533
1333
  registrationDiagnostic = undefined;
1534
- subject = built;
1334
+ processRecord = built;
1535
1335
  return built;
1536
1336
  })();
1537
1337
  try {
@@ -1540,11 +1340,10 @@ function createRadarPublicationInner(
1540
1340
  registrationRecord = undefined;
1541
1341
  }
1542
1342
  }
1543
- let agentId = stored?.agent_id;
1343
+ let agentId = stored?.registration_id;
1544
1344
  let subjectReady: Promise<string | undefined> | undefined;
1545
1345
  async function ensureSubject(recheck: boolean): Promise<string | undefined> {
1546
- if (recheck) agentId = undefined;
1547
- if (agentId !== undefined) return agentId;
1346
+ if (!recheck && agentId !== undefined) return agentId;
1548
1347
  subjectReady ??= (async () => {
1549
1348
  const ready = await client.ping();
1550
1349
  if (ready.ok === false) {
@@ -1554,18 +1353,11 @@ function createRadarPublicationInner(
1554
1353
  }
1555
1354
  const record = await ensureRegistration();
1556
1355
  if (record === undefined) return undefined;
1557
- const answer = await client.register({
1558
- ...record.registration,
1559
- ...(spawnToken !== undefined ? { spawn_token: spawnToken } : {}),
1560
- });
1561
- if (answer.ok === false) {
1562
- if (answer.code !== "absent" && answer.code !== "timeout" && answer.code !== "transport")
1563
- registrationDiagnostic ??= `subject registration refused: ${answer.message}`.slice(0, 256);
1564
- return undefined;
1565
- }
1566
- agentId = answer.value.agent_id;
1567
- subject = { ...record, agent_id: agentId };
1568
- 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);
1569
1361
  if (options.binding !== undefined) writeBinding(options.binding);
1570
1362
  return agentId;
1571
1363
  })();
@@ -1585,7 +1377,7 @@ function createRadarPublicationInner(
1585
1377
  if (agentId === undefined) return;
1586
1378
  store.write(`bindings/${binding.key}.json`, {
1587
1379
  version: RADAR_PUBLICATION_VERSION,
1588
- agent_id: agentId,
1380
+ registration_id: agentId,
1589
1381
  incarnation,
1590
1382
  run: binding.run,
1591
1383
  owner: binding.owner,
@@ -1615,14 +1407,18 @@ function createRadarPublicationInner(
1615
1407
  heartbeatMs: options.heartbeatMs ?? RADAR_HEARTBEAT_MS,
1616
1408
  });
1617
1409
 
1618
- const context = createContextWriter({
1619
- store,
1620
- client,
1621
- scheduler,
1622
- recordName: contextRecord,
1623
- publisher: publisher("herdsman-pi"),
1624
- subject: ensureSubject,
1625
- 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
+ },
1626
1422
  heartbeatMs: options.heartbeatMs ?? RADAR_HEARTBEAT_MS,
1627
1423
  });
1628
1424
 
@@ -1643,7 +1439,7 @@ function createRadarPublicationInner(
1643
1439
 
1644
1440
  const publication: RadarPublication = {
1645
1441
  execution,
1646
- context,
1442
+ presence,
1647
1443
  facts,
1648
1444
  assignment(target, reportingOwner: string) {
1649
1445
  const existing = assignmentWriters.get(target);
@@ -1658,7 +1454,7 @@ function createRadarPublicationInner(
1658
1454
  publisher: publisher("herdsman-owner", reportingOwner),
1659
1455
  codec: assignmentCodec,
1660
1456
  // The owner publishes about a subject it did not register: the exact
1661
- // agent_id from the child's sidecar is the whole correlation.
1457
+ // registration_id from the child's sidecar is the whole correlation.
1662
1458
  subject: async () => target,
1663
1459
  leaseMs: options.leaseMs ?? RADAR_LEASE_MS,
1664
1460
  heartbeatMs: options.heartbeatMs ?? RADAR_HEARTBEAT_MS,
@@ -1678,7 +1474,7 @@ function createRadarPublicationInner(
1678
1474
  registrationDiagnostic ??= `Radar binding unavailable: ${String(error).slice(0, 180)}`;
1679
1475
  return undefined;
1680
1476
  }
1681
- const bound = textAt(value, "agent_id");
1477
+ const bound = textAt(value, "registration_id");
1682
1478
  const boundIncarnation = textAt(value, "incarnation");
1683
1479
  const run = textAt(value, "run");
1684
1480
  const owner = textAt(value, "owner");
@@ -1697,7 +1493,7 @@ function createRadarPublicationInner(
1697
1493
  return undefined;
1698
1494
  return {
1699
1495
  version: RADAR_PUBLICATION_VERSION,
1700
- agent_id: bound,
1496
+ registration_id: bound,
1701
1497
  incarnation: boundIncarnation,
1702
1498
  run,
1703
1499
  owner,
@@ -1715,27 +1511,32 @@ function createRadarPublicationInner(
1715
1511
  if (agentId === undefined) return undefined;
1716
1512
  const record = await ensureRegistration();
1717
1513
  if (record === undefined) return undefined;
1718
- const location = record.registration.location;
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;
1719
1519
  return {
1720
- agent_id: agentId,
1520
+ registration_id: agentId,
1521
+ ...(subjectId !== undefined ? { subject_id: subjectId } : {}),
1721
1522
  ...(location !== undefined ? { location } : {}),
1722
1523
  };
1723
1524
  },
1724
1525
  diagnostic: () =>
1725
1526
  registrationDiagnostic ??
1726
1527
  execution.diagnostic() ??
1727
- context.diagnostic() ??
1528
+ presence.diagnostic() ??
1728
1529
  facts.diagnostic(),
1729
1530
  stop() {
1730
1531
  for (const writer of assignmentWriters.values()) writer.stop();
1731
1532
  execution.stop();
1732
- context.stop();
1533
+ presence.stop();
1733
1534
  facts.stop();
1734
1535
  },
1735
1536
  reopen() {
1736
1537
  for (const writer of assignmentWriters.values()) writer.reopen();
1737
1538
  execution.reopen();
1738
- context.reopen();
1539
+ presence.reopen();
1739
1540
  facts.reopen();
1740
1541
  },
1741
1542
  };