@intx/hub-sessions 0.2.2 → 0.3.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 (73) hide show
  1. package/README.md +3 -5
  2. package/dist/agent-repo.d.ts +9 -5
  3. package/dist/agent-repo.js +2 -2
  4. package/dist/agent-state-kind.js +4 -0
  5. package/dist/asset-service.d.ts +1 -20
  6. package/dist/asset-service.js +9 -91
  7. package/dist/committed-source-tree.d.ts +10 -0
  8. package/dist/committed-source-tree.js +35 -0
  9. package/dist/credential-push.d.ts +7 -6
  10. package/dist/credential-push.js +42 -18
  11. package/dist/event-collector-registry.d.ts +1 -1
  12. package/dist/event-collector-registry.js +4 -4
  13. package/dist/event-collector.d.ts +1 -1
  14. package/dist/event-collector.js +10 -2
  15. package/dist/hub-session-lookups.d.ts +125 -7
  16. package/dist/hub-session-lookups.js +539 -80
  17. package/dist/hub-session-orchestrator.js +14 -49
  18. package/dist/index.d.ts +17 -8
  19. package/dist/index.js +14 -6
  20. package/dist/repo-store/index.d.ts +1 -1
  21. package/dist/repo-store/store.d.ts +1 -1
  22. package/dist/repo-store/store.js +138 -1
  23. package/dist/repo-store/subscribe-kind.d.ts +6 -3
  24. package/dist/repo-store/subscribe-kind.js +42 -77
  25. package/dist/repo-store/types.d.ts +94 -6
  26. package/dist/session-service.d.ts +277 -96
  27. package/dist/session-service.js +741 -547
  28. package/dist/sidecar-allocation/contracts.d.ts +78 -0
  29. package/dist/sidecar-allocation/contracts.js +21 -0
  30. package/dist/sidecar-allocation/index.d.ts +4 -0
  31. package/dist/sidecar-allocation/index.js +3 -0
  32. package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
  33. package/dist/sidecar-allocation/placement-policy.js +21 -0
  34. package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
  35. package/dist/sidecar-allocation/plugin-registry.js +37 -0
  36. package/dist/sidecar-allocation/reconciler.d.ts +42 -0
  37. package/dist/sidecar-allocation/reconciler.js +431 -0
  38. package/dist/skill-kind.js +4 -0
  39. package/dist/substrate.d.ts +3 -3
  40. package/dist/substrate.js +1 -1
  41. package/dist/workflow-allocation-service.d.ts +58 -0
  42. package/dist/workflow-allocation-service.js +239 -0
  43. package/dist/workflow-closure-resolution.d.ts +106 -0
  44. package/dist/workflow-closure-resolution.js +123 -0
  45. package/dist/workflow-definition-ensure.d.ts +24 -0
  46. package/dist/workflow-definition-ensure.js +75 -0
  47. package/dist/workflow-dispatch-service.d.ts +40 -0
  48. package/dist/workflow-dispatch-service.js +146 -0
  49. package/dist/workflow-dispatch-settlement.d.ts +29 -0
  50. package/dist/workflow-dispatch-settlement.js +140 -0
  51. package/dist/workflow-kind.d.ts +17 -1
  52. package/dist/workflow-kind.js +127 -80
  53. package/dist/workflow-probe-gate.d.ts +214 -0
  54. package/dist/workflow-probe-gate.js +207 -0
  55. package/dist/workflow-run-kind.d.ts +128 -14
  56. package/dist/workflow-run-kind.js +353 -83
  57. package/dist/workflow-run-reader.d.ts +1 -1
  58. package/dist/workflow-run-reader.js +3 -7
  59. package/dist/workflow-run-restore.d.ts +15 -0
  60. package/dist/workflow-run-restore.js +26 -0
  61. package/dist/workflow-source-closure.d.ts +35 -0
  62. package/dist/workflow-source-closure.js +342 -0
  63. package/dist/ws/index.d.ts +3 -3
  64. package/dist/ws/index.js +1 -1
  65. package/dist/ws/sidecar-events.d.ts +100 -12
  66. package/dist/ws/sidecar-events.js +2 -0
  67. package/dist/ws/sidecar-handler.d.ts +128 -7
  68. package/dist/ws/sidecar-handler.js +1069 -135
  69. package/dist/ws/sidecar-token-authenticator.d.ts +3 -1
  70. package/dist/ws/sidecar-token-authenticator.js +64 -7
  71. package/package.json +14 -13
  72. package/dist/available-skills-stanza.d.ts +0 -21
  73. package/dist/available-skills-stanza.js +0 -32
@@ -0,0 +1,78 @@
1
+ export type EnsureSidecarRequest = {
2
+ readonly allocationId: string;
3
+ readonly generation: number;
4
+ readonly tenantId: string;
5
+ readonly anchorRunId: string;
6
+ readonly sidecarId: string;
7
+ readonly token: string;
8
+ readonly hubWebSocketUrl: string;
9
+ };
10
+ export type DestroySidecarRequest = {
11
+ readonly allocationId: string;
12
+ readonly generation: number;
13
+ readonly sidecarId: string;
14
+ readonly externalRef?: string;
15
+ };
16
+ export declare const SidecarOperationFailure: import("arktype/internal/variants/object.ts").ObjectType<{
17
+ kind: "rejected";
18
+ code: string;
19
+ message: string;
20
+ retryable: boolean;
21
+ }, {}>;
22
+ export type SidecarOperationFailure = typeof SidecarOperationFailure.infer;
23
+ /**
24
+ * Acceptance means the requested infrastructure exists, not that it is ready.
25
+ * Rejection means no infrastructure exists for this generation; a provisioner
26
+ * must throw when it cannot determine whether the request took effect.
27
+ */
28
+ export declare const EnsureSidecarResult: import("arktype/internal/variants/object.ts").ObjectType<{
29
+ kind: "rejected";
30
+ code: string;
31
+ message: string;
32
+ retryable: boolean;
33
+ } | {
34
+ kind: "accepted";
35
+ externalRef?: string;
36
+ }, {}>;
37
+ export type EnsureSidecarResult = typeof EnsureSidecarResult.infer;
38
+ export declare const DestroySidecarResult: import("arktype/internal/variants/object.ts").ObjectType<{
39
+ kind: "rejected";
40
+ code: string;
41
+ message: string;
42
+ retryable: boolean;
43
+ } | {
44
+ kind: "destroyed";
45
+ }, {}>;
46
+ export type DestroySidecarResult = typeof DestroySidecarResult.infer;
47
+ export interface SidecarProvisioner {
48
+ readonly id: string;
49
+ readonly apiVersion: 1;
50
+ /** Stable, non-secret identity for the backend configuration. */
51
+ readonly bindingFingerprint: string;
52
+ /**
53
+ * Converges infrastructure for this generation. Implementations must be
54
+ * idempotent and reject generations older than one they have observed.
55
+ */
56
+ ensure(request: EnsureSidecarRequest): Promise<EnsureSidecarResult>;
57
+ /**
58
+ * Idempotently destroys the allocation and fences older ensure calls so a
59
+ * delayed request cannot recreate infrastructure after destruction.
60
+ */
61
+ destroy(request: DestroySidecarRequest): Promise<DestroySidecarResult>;
62
+ }
63
+ export type SidecarCredentialIdentity = {
64
+ readonly kind: "shared";
65
+ readonly sidecarId: string;
66
+ } | {
67
+ readonly kind: "allocated";
68
+ readonly sidecarId: string;
69
+ readonly allocationId: string;
70
+ readonly tenantId: string;
71
+ readonly anchorRunId: string;
72
+ readonly workflowRunAddress: string;
73
+ readonly generation: number;
74
+ };
75
+ export interface SidecarCredentialResolver {
76
+ resolve(token: string): Promise<SidecarCredentialIdentity | null>;
77
+ isCurrent(identity: SidecarCredentialIdentity, use: "registration" | "readiness" | "routing"): Promise<boolean>;
78
+ }
@@ -0,0 +1,21 @@
1
+ import { type } from "arktype";
2
+ // Provisioner results cross a plugin boundary, so they are validated at
3
+ // runtime rather than trusted from the declared return type.
4
+ export const SidecarOperationFailure = type({
5
+ kind: "'rejected'",
6
+ code: "string",
7
+ message: "string",
8
+ retryable: "boolean",
9
+ });
10
+ /**
11
+ * Acceptance means the requested infrastructure exists, not that it is ready.
12
+ * Rejection means no infrastructure exists for this generation; a provisioner
13
+ * must throw when it cannot determine whether the request took effect.
14
+ */
15
+ export const EnsureSidecarResult = type({
16
+ kind: "'accepted'",
17
+ "externalRef?": "string",
18
+ }).or(SidecarOperationFailure);
19
+ export const DestroySidecarResult = type({
20
+ kind: "'destroyed'",
21
+ }).or(SidecarOperationFailure);
@@ -0,0 +1,4 @@
1
+ export { resolveEffectiveSidecarPlacement, type ResolveEffectiveSidecarPlacementOpts, } from "./placement-policy.js";
2
+ export { createSidecarPluginRegistry, type CreateSidecarPluginRegistryOpts, type SidecarPluginRegistry, } from "./plugin-registry.js";
3
+ export type { DestroySidecarRequest, DestroySidecarResult, EnsureSidecarRequest, EnsureSidecarResult, SidecarCredentialIdentity, SidecarCredentialResolver, SidecarOperationFailure, SidecarProvisioner, } from "./contracts.js";
4
+ export { createSidecarAllocationReconciler, type SidecarAllocationReconciler, type SidecarAllocationReconcilerDeps, } from "./reconciler.js";
@@ -0,0 +1,3 @@
1
+ export { resolveEffectiveSidecarPlacement, } from "./placement-policy.js";
2
+ export { createSidecarPluginRegistry, } from "./plugin-registry.js";
3
+ export { createSidecarAllocationReconciler, } from "./reconciler.js";
@@ -0,0 +1,11 @@
1
+ import type { SidecarPlacementRequirement, TenantConfig } from "@intx/types";
2
+ export type ResolveEffectiveSidecarPlacementOpts = {
3
+ /** Tenant configs ordered from the workflow tenant through its ancestors. */
4
+ readonly tenantConfigs: readonly TenantConfig[];
5
+ };
6
+ /**
7
+ * Resolves the placement fixed onto a new workflow run. An exclusive
8
+ * requirement at any tenant ancestor forces exclusive placement; no tenant
9
+ * configuration can weaken it.
10
+ */
11
+ export declare function resolveEffectiveSidecarPlacement({ tenantConfigs, }: ResolveEffectiveSidecarPlacementOpts): SidecarPlacementRequirement | null;
@@ -0,0 +1,21 @@
1
+ const EXCLUSIVE_PLACEMENT = Object.freeze({
2
+ sharing: "exclusive",
3
+ reuse: "never",
4
+ });
5
+ /**
6
+ * Resolves the placement fixed onto a new workflow run. An exclusive
7
+ * requirement at any tenant ancestor forces exclusive placement; no tenant
8
+ * configuration can weaken it.
9
+ */
10
+ export function resolveEffectiveSidecarPlacement({ tenantConfigs, }) {
11
+ const tenantPlacements = tenantConfigs.flatMap((config) => config.sidecarPlacement?.sharing === "exclusive"
12
+ ? [config.sidecarPlacement]
13
+ : []);
14
+ if (tenantPlacements.length === 0) {
15
+ return null;
16
+ }
17
+ const tenantRequiresFreshCapacity = tenantPlacements.some((placement) => placement.reuse !== "same-deployment");
18
+ return tenantRequiresFreshCapacity
19
+ ? EXCLUSIVE_PLACEMENT
20
+ : { sharing: "exclusive", reuse: "same-deployment" };
21
+ }
@@ -0,0 +1,11 @@
1
+ import type { SidecarProvisioner } from "./contracts.js";
2
+ export type SidecarPluginRegistry = {
3
+ getDefaultProvisioner(): SidecarProvisioner | null;
4
+ /** Missing plugins return null so reconciliation can stop fail-closed. */
5
+ getProvisioner(id: string): SidecarProvisioner | null;
6
+ };
7
+ export type CreateSidecarPluginRegistryOpts = {
8
+ readonly provisioners: readonly SidecarProvisioner[];
9
+ readonly defaultProvisionerId?: string;
10
+ };
11
+ export declare function createSidecarPluginRegistry({ provisioners, defaultProvisionerId, }: CreateSidecarPluginRegistryOpts): SidecarPluginRegistry;
@@ -0,0 +1,37 @@
1
+ export function createSidecarPluginRegistry({ provisioners, defaultProvisionerId, }) {
2
+ const provisionersById = new Map();
3
+ for (const provisioner of provisioners) {
4
+ validateId(provisioner.id);
5
+ if (provisioner.apiVersion !== 1) {
6
+ throw new Error(`Unsupported sidecar provisioner API version for ${provisioner.id}: ${String(provisioner.apiVersion)}`);
7
+ }
8
+ if (provisioner.bindingFingerprint.trim() === "") {
9
+ throw new Error(`Sidecar provisioner ${provisioner.id} requires a binding fingerprint`);
10
+ }
11
+ if (provisionersById.has(provisioner.id)) {
12
+ throw new Error(`Duplicate sidecar provisioner id: ${provisioner.id}`);
13
+ }
14
+ provisionersById.set(provisioner.id, provisioner);
15
+ }
16
+ if (defaultProvisionerId !== undefined) {
17
+ validateId(defaultProvisionerId);
18
+ if (!provisionersById.has(defaultProvisionerId)) {
19
+ throw new Error(`Default sidecar provisioner ${defaultProvisionerId} is not registered`);
20
+ }
21
+ }
22
+ return {
23
+ getDefaultProvisioner() {
24
+ return defaultProvisionerId === undefined
25
+ ? null
26
+ : (provisionersById.get(defaultProvisionerId) ?? null);
27
+ },
28
+ getProvisioner(id) {
29
+ return provisionersById.get(id) ?? null;
30
+ },
31
+ };
32
+ }
33
+ function validateId(id) {
34
+ if (id.trim() === "") {
35
+ throw new Error("Sidecar provisioner id must be non-empty");
36
+ }
37
+ }
@@ -0,0 +1,42 @@
1
+ import type { SidecarAllocation, SidecarAllocationStore } from "@intx/db";
2
+ import type { AllocatedSidecarTarget, SidecarAllocationRouter } from "../ws/sidecar-handler.js";
3
+ import type { SidecarPluginRegistry } from "./plugin-registry.js";
4
+ type AllocationStore = Pick<SidecarAllocationStore, "beginReplacement" | "beginUnrecoverableRelease" | "bindInitialSidecar" | "bindReplacementSidecar" | "claimNextReconcilable" | "extendReconciliationLease" | "failWithoutInfrastructure" | "listActive" | "markAllocated" | "markConnectionLost" | "markConnectionReady" | "markReleased" | "parkReconciliation" | "scheduleReconnectIfUnscheduled" | "scheduleRetry" | "wakeReconciliation">;
5
+ export type SidecarAllocationReconcilerDeps = {
6
+ readonly allocationStore: AllocationStore;
7
+ readonly plugins: SidecarPluginRegistry;
8
+ readonly router: Pick<SidecarAllocationRouter, "fenceAllocation" | "isAllocatedSidecarReady" | "waitForAllocatedSidecar">;
9
+ readonly hubWebSocketUrl: string;
10
+ /** Idempotently restores and deploys one connected allocation generation. */
11
+ readonly onReady?: (allocation: SidecarAllocation) => Promise<void>;
12
+ /**
13
+ * Replace an allocated worker after its reconnect grace expires. Disabled by
14
+ * default because Hub recovery does not restore arbitrary sidecar or
15
+ * isolation-container filesystem state, so automatic continuation could run
16
+ * without state the previous worker produced.
17
+ */
18
+ readonly enableAutomaticReplacementRecovery?: boolean;
19
+ readonly leaseDurationMs?: number;
20
+ readonly connectTimeoutMs?: number;
21
+ readonly retryDelayMs?: (attempt: number) => number;
22
+ readonly now?: () => Date;
23
+ readonly createSidecarId?: () => string;
24
+ readonly createToken?: () => string;
25
+ readonly createLeaseId?: () => string;
26
+ };
27
+ export type SidecarAllocationReconciler = {
28
+ /** Rebuild all trust fences before accepting allocated connections. */
29
+ initialize(): Promise<void>;
30
+ /** Starts a durable reconnect grace period for the exact lost generation. */
31
+ handleDisconnect(target: AllocatedSidecarTarget): Promise<void>;
32
+ /** Wakes recovery as soon as the exact generation reconnects. */
33
+ handleConnected(target: AllocatedSidecarTarget): Promise<void>;
34
+ /** Repairs allocated generations left unscheduled after a lost event write. */
35
+ repairUnscheduledConnections(): Promise<void>;
36
+ /** Reconcile at most one due allocation. Returns false when none are due. */
37
+ reconcileNext(): Promise<boolean>;
38
+ /** Drain the currently due queue, bounded to catch accidental hot loops. */
39
+ reconcileUntilIdle(maxIterations?: number): Promise<number>;
40
+ };
41
+ export declare function createSidecarAllocationReconciler({ allocationStore, plugins, router, hubWebSocketUrl, onReady, enableAutomaticReplacementRecovery, leaseDurationMs, connectTimeoutMs, retryDelayMs, now, createSidecarId, createToken, createLeaseId, }: SidecarAllocationReconcilerDeps): SidecarAllocationReconciler;
42
+ export {};
@@ -0,0 +1,431 @@
1
+ import { type } from "arktype";
2
+ import { sha256 } from "@intx/crypto";
3
+ import { getLogger } from "@intx/log";
4
+ import { hexEncode } from "@intx/types";
5
+ import { SessionLaunchError } from "../session-service.js";
6
+ import { DestroySidecarResult, EnsureSidecarResult, } from "./contracts.js";
7
+ const logger = getLogger(["hub", "sidecar-allocation"]);
8
+ const DEFAULT_LEASE_DURATION_MS = 60_000;
9
+ const DEFAULT_CONNECT_TIMEOUT_MS = 120_000;
10
+ const MAX_RETRY_BACKOFF_ATTEMPT = 5;
11
+ function randomHex(bytes) {
12
+ return hexEncode(crypto.getRandomValues(new Uint8Array(bytes)));
13
+ }
14
+ function defaultRetryDelay(attempt) {
15
+ return Math.min(1_000 * 2 ** Math.min(attempt, MAX_RETRY_BACKOFF_ATTEMPT), 30_000);
16
+ }
17
+ function parseEnsureResult(value) {
18
+ const result = EnsureSidecarResult(value);
19
+ if (result instanceof type.errors) {
20
+ throw new Error(`sidecar provisioner returned an invalid ensure result: ${result.summary}`);
21
+ }
22
+ return result;
23
+ }
24
+ function parseDestroyResult(value) {
25
+ const result = DestroySidecarResult(value);
26
+ if (result instanceof type.errors) {
27
+ throw new Error(`sidecar provisioner returned an invalid destroy result: ${result.summary}`);
28
+ }
29
+ return result;
30
+ }
31
+ export function createSidecarAllocationReconciler({ allocationStore, plugins, router, hubWebSocketUrl, onReady, enableAutomaticReplacementRecovery = false, leaseDurationMs = DEFAULT_LEASE_DURATION_MS, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, retryDelayMs = defaultRetryDelay, now = () => new Date(), createSidecarId = () => `sc_${randomHex(16)}`, createToken = () => `intx_sc_${randomHex(32)}`, createLeaseId = () => `lease_${randomHex(16)}`, }) {
32
+ if (leaseDurationMs <= 0)
33
+ throw new Error("leaseDurationMs must be positive");
34
+ if (connectTimeoutMs <= 0) {
35
+ throw new Error("connectTimeoutMs must be positive");
36
+ }
37
+ function provisionerFor(allocation) {
38
+ const provisioner = plugins.getProvisioner(allocation.provisionerId);
39
+ if (provisioner === null ||
40
+ provisioner.apiVersion !== allocation.provisionerApiVersion ||
41
+ provisioner.bindingFingerprint !==
42
+ allocation.provisionerBindingFingerprint) {
43
+ return null;
44
+ }
45
+ return provisioner;
46
+ }
47
+ function retryAt(attempt) {
48
+ return new Date(now().getTime() + retryDelayMs(attempt));
49
+ }
50
+ async function withLeaseHeartbeat(allocation, leaseId, operation) {
51
+ const interval = setInterval(() => {
52
+ void allocationStore
53
+ .extendReconciliationLease(allocation.id, leaseId, leaseDurationMs)
54
+ .catch((error) => {
55
+ logger.warn `Failed to extend allocation ${allocation.id} lease: ${error instanceof Error ? error.message : String(error)}`;
56
+ });
57
+ }, Math.max(1, Math.floor(leaseDurationMs / 3)));
58
+ try {
59
+ return await operation();
60
+ }
61
+ finally {
62
+ clearInterval(interval);
63
+ }
64
+ }
65
+ async function replaceAfterFailure(allocation, leaseId, code, message) {
66
+ if (allocation.status === "allocated" &&
67
+ !enableAutomaticReplacementRecovery) {
68
+ const releasing = await allocationStore.beginUnrecoverableRelease({
69
+ allocationId: allocation.id,
70
+ expectedGeneration: allocation.generation,
71
+ expectedLeaseId: leaseId,
72
+ failureCode: code,
73
+ failureMessage: `Automatic recovery is disabled: ${message}`,
74
+ now: now(),
75
+ });
76
+ if (releasing !== null) {
77
+ router.fenceAllocation(releasing.id, releasing.generation);
78
+ }
79
+ return;
80
+ }
81
+ const replaced = await allocationStore.beginReplacement({
82
+ allocationId: allocation.id,
83
+ expectedStatus: allocation.status === "allocated" ? "allocated" : "provisioning",
84
+ expectedGeneration: allocation.generation,
85
+ expectedLeaseId: leaseId,
86
+ nextAttemptAt: retryAt(allocation.ensureAttempts + allocation.destroyAttempts),
87
+ failureCode: code,
88
+ failureMessage: message,
89
+ now: now(),
90
+ });
91
+ if (replaced !== null) {
92
+ router.fenceAllocation(replaced.id, replaced.generation);
93
+ }
94
+ }
95
+ async function waitUntilReady(allocation, leaseId, connectionAlreadyReady = false) {
96
+ const target = {
97
+ allocationId: allocation.id,
98
+ generation: allocation.generation,
99
+ };
100
+ const deadline = allocation.connectDeadline;
101
+ const remaining = deadline === undefined ? 0 : deadline.getTime() - now().getTime();
102
+ try {
103
+ if (!connectionAlreadyReady &&
104
+ !(await router.isAllocatedSidecarReady(target))) {
105
+ await withLeaseHeartbeat(allocation, leaseId, () => router.waitForAllocatedSidecar(target, Math.max(0, remaining)));
106
+ }
107
+ }
108
+ catch (error) {
109
+ await replaceAfterFailure(allocation, leaseId, "sidecar_connect_failed", error instanceof Error ? error.message : String(error));
110
+ return;
111
+ }
112
+ if (onReady !== undefined) {
113
+ try {
114
+ await withLeaseHeartbeat(allocation, leaseId, () => onReady(allocation));
115
+ }
116
+ catch (error) {
117
+ if (error instanceof SessionLaunchError && error.leakedAgent) {
118
+ await replaceAfterFailure(allocation, leaseId, "sidecar_initialization_uncertain", error.message);
119
+ return;
120
+ }
121
+ await allocationStore.scheduleRetry({
122
+ allocationId: allocation.id,
123
+ expectedStatus: "allocated",
124
+ expectedGeneration: allocation.generation,
125
+ // Initialization has no separate durable attempt counter. Use the
126
+ // capped delay so a persistent launch error cannot create a hot loop.
127
+ nextAttemptAt: retryAt(MAX_RETRY_BACKOFF_ATTEMPT),
128
+ expectedLeaseId: leaseId,
129
+ failure: {
130
+ code: "sidecar_initialization_failed",
131
+ message: error instanceof Error ? error.message : String(error),
132
+ },
133
+ now: now(),
134
+ });
135
+ return;
136
+ }
137
+ }
138
+ await allocationStore.markConnectionReady({
139
+ allocationId: allocation.id,
140
+ generation: allocation.generation,
141
+ expectedLeaseId: leaseId,
142
+ now: now(),
143
+ });
144
+ }
145
+ async function acceptEnsure(allocation, leaseId, provisioner, token) {
146
+ if (allocation.sidecarId === undefined) {
147
+ throw new Error(`Allocation ${allocation.id} has no sidecar identity`);
148
+ }
149
+ const sidecarId = allocation.sidecarId;
150
+ let result;
151
+ try {
152
+ result = parseEnsureResult(await withLeaseHeartbeat(allocation, leaseId, () => provisioner.ensure({
153
+ allocationId: allocation.id,
154
+ generation: allocation.generation,
155
+ tenantId: allocation.tenantId,
156
+ anchorRunId: allocation.anchorRunId,
157
+ sidecarId,
158
+ token,
159
+ hubWebSocketUrl,
160
+ })));
161
+ }
162
+ catch (error) {
163
+ await replaceAfterFailure(allocation, leaseId, "ensure_failed", error instanceof Error ? error.message : String(error));
164
+ return;
165
+ }
166
+ if (result.kind === "rejected") {
167
+ if (!result.retryable) {
168
+ await allocationStore.failWithoutInfrastructure({
169
+ allocationId: allocation.id,
170
+ expectedStatus: "provisioning",
171
+ expectedGeneration: allocation.generation,
172
+ code: result.code,
173
+ message: result.message,
174
+ expectedLeaseId: leaseId,
175
+ now: now(),
176
+ });
177
+ return;
178
+ }
179
+ await replaceAfterFailure(allocation, leaseId, result.code, result.message);
180
+ return;
181
+ }
182
+ const allocated = await allocationStore.markAllocated({
183
+ allocationId: allocation.id,
184
+ generation: allocation.generation,
185
+ ...(result.externalRef !== undefined
186
+ ? { externalRef: result.externalRef }
187
+ : {}),
188
+ expectedLeaseId: leaseId,
189
+ now: now(),
190
+ });
191
+ if (allocated !== null) {
192
+ const target = {
193
+ allocationId: allocated.id,
194
+ generation: allocated.generation,
195
+ };
196
+ if (await router.isAllocatedSidecarReady(target)) {
197
+ await waitUntilReady(allocated, leaseId, true);
198
+ return;
199
+ }
200
+ // Provisioning acceptance and websocket readiness are separate durable
201
+ // transitions. Do not hold the single reconciliation loop for the full
202
+ // connection timeout: park this lease at its persisted deadline and let
203
+ // sidecar.allocated.connected wake it immediately when the worker arrives.
204
+ await allocationStore.parkReconciliation(allocated.id, leaseId, {
205
+ kind: "await-connection",
206
+ fallbackNextAttemptAt: retryAt(MAX_RETRY_BACKOFF_ATTEMPT),
207
+ });
208
+ }
209
+ }
210
+ async function bindAndEnsure(allocation, leaseId, provisioner, replacement) {
211
+ const token = createToken();
212
+ const sidecarId = createSidecarId();
213
+ const connectDeadline = new Date(now().getTime() + connectTimeoutMs);
214
+ const bound = replacement
215
+ ? await allocationStore.bindReplacementSidecar({
216
+ allocationId: allocation.id,
217
+ generation: allocation.generation,
218
+ sidecarId,
219
+ tokenHashSha256: await sha256(token),
220
+ connectDeadline,
221
+ expectedLeaseId: leaseId,
222
+ now: now(),
223
+ })
224
+ : await allocationStore.bindInitialSidecar({
225
+ allocationId: allocation.id,
226
+ expectedGeneration: allocation.generation,
227
+ sidecarId,
228
+ tokenHashSha256: await sha256(token),
229
+ connectDeadline,
230
+ expectedLeaseId: leaseId,
231
+ now: now(),
232
+ });
233
+ if (bound === null)
234
+ return;
235
+ router.fenceAllocation(bound.id, bound.generation);
236
+ await acceptEnsure(bound, leaseId, provisioner, token);
237
+ }
238
+ async function retryDestroy(allocation, leaseId) {
239
+ if (allocation.status !== "replacing" &&
240
+ allocation.status !== "releasing") {
241
+ throw new Error(`Cannot retry destroy while allocation ${allocation.id} is ${allocation.status}`);
242
+ }
243
+ await allocationStore.scheduleRetry({
244
+ allocationId: allocation.id,
245
+ expectedStatus: allocation.status,
246
+ expectedGeneration: allocation.generation,
247
+ nextAttemptAt: retryAt(allocation.destroyAttempts),
248
+ expectedLeaseId: leaseId,
249
+ attempt: "destroy",
250
+ now: now(),
251
+ });
252
+ }
253
+ async function destroyCurrent(allocation, leaseId, provisioner) {
254
+ if (allocation.sidecarId === undefined)
255
+ return true;
256
+ const sidecarId = allocation.sidecarId;
257
+ try {
258
+ const result = parseDestroyResult(await withLeaseHeartbeat(allocation, leaseId, () => provisioner.destroy({
259
+ allocationId: allocation.id,
260
+ generation: allocation.generation,
261
+ sidecarId,
262
+ ...(allocation.externalRef !== undefined
263
+ ? { externalRef: allocation.externalRef }
264
+ : {}),
265
+ })));
266
+ if (result.kind === "destroyed")
267
+ return true;
268
+ }
269
+ catch (error) {
270
+ logger.warn `Destroy failed for allocation ${allocation.id}: ${error instanceof Error ? error.message : String(error)}`;
271
+ }
272
+ await retryDestroy(allocation, leaseId);
273
+ return false;
274
+ }
275
+ async function reconcile(allocation, leaseId) {
276
+ router.fenceAllocation(allocation.id, allocation.generation);
277
+ if (allocation.status === "released" || allocation.status === "failed") {
278
+ return;
279
+ }
280
+ const provisioner = provisionerFor(allocation);
281
+ if (provisioner === null) {
282
+ if (allocation.status === "pending") {
283
+ await allocationStore.failWithoutInfrastructure({
284
+ allocationId: allocation.id,
285
+ expectedStatus: "pending",
286
+ expectedGeneration: allocation.generation,
287
+ code: "provisioner_unavailable",
288
+ message: `Provisioner ${allocation.provisionerId} is unavailable or its binding changed`,
289
+ expectedLeaseId: leaseId,
290
+ now: now(),
291
+ });
292
+ }
293
+ else {
294
+ await allocationStore.scheduleRetry({
295
+ allocationId: allocation.id,
296
+ expectedStatus: allocation.status,
297
+ expectedGeneration: allocation.generation,
298
+ nextAttemptAt: retryAt(allocation.ensureAttempts + allocation.destroyAttempts),
299
+ expectedLeaseId: leaseId,
300
+ now: now(),
301
+ });
302
+ }
303
+ return;
304
+ }
305
+ switch (allocation.status) {
306
+ case "pending":
307
+ await bindAndEnsure(allocation, leaseId, provisioner, false);
308
+ return;
309
+ case "provisioning":
310
+ // The raw bearer token is deliberately not durable. Re-entering this
311
+ // state means the process died before ensure acceptance was recorded.
312
+ await replaceAfterFailure(allocation, leaseId, "ensure_outcome_unknown", "Hub restarted before sidecar provisioning acceptance was recorded");
313
+ return;
314
+ case "allocated":
315
+ await waitUntilReady(allocation, leaseId);
316
+ return;
317
+ case "replacing":
318
+ if (!(await destroyCurrent(allocation, leaseId, provisioner)))
319
+ return;
320
+ await bindAndEnsure(allocation, leaseId, provisioner, true);
321
+ return;
322
+ case "releasing":
323
+ if (!(await destroyCurrent(allocation, leaseId, provisioner)))
324
+ return;
325
+ await allocationStore.markReleased({
326
+ allocationId: allocation.id,
327
+ generation: allocation.generation,
328
+ expectedLeaseId: leaseId,
329
+ now: now(),
330
+ });
331
+ return;
332
+ default: {
333
+ const exhaustive = allocation.status;
334
+ throw new Error(`Allocation ${allocation.id} has unhandled status ${String(exhaustive)}`);
335
+ }
336
+ }
337
+ }
338
+ async function initialize() {
339
+ for (const allocation of await allocationStore.listActive()) {
340
+ router.fenceAllocation(allocation.id, allocation.generation);
341
+ if (allocation.status === "allocated") {
342
+ await allocationStore.markConnectionLost({
343
+ allocationId: allocation.id,
344
+ generation: allocation.generation,
345
+ connectDeadline: allocation.connectDeadline ??
346
+ new Date(now().getTime() + connectTimeoutMs),
347
+ now: now(),
348
+ });
349
+ }
350
+ else if (allocation.nextAttemptAt === undefined) {
351
+ // Scheduled retries are durable state. Only repair an unscheduled
352
+ // active row; moving an existing deadline earlier would erase provider
353
+ // backoff whenever the Hub restarts.
354
+ await allocationStore.wakeReconciliation(allocation.id, allocation.generation);
355
+ }
356
+ }
357
+ }
358
+ async function handleDisconnect(target) {
359
+ await allocationStore.markConnectionLost({
360
+ allocationId: target.allocationId,
361
+ generation: target.generation,
362
+ connectDeadline: new Date(now().getTime() + connectTimeoutMs),
363
+ now: now(),
364
+ });
365
+ }
366
+ async function handleConnected(target) {
367
+ await allocationStore.wakeReconciliation(target.allocationId, target.generation);
368
+ }
369
+ async function repairUnscheduledConnections() {
370
+ for (const allocation of await allocationStore.listActive()) {
371
+ if (allocation.status !== "allocated" ||
372
+ allocation.nextAttemptAt !== undefined ||
373
+ allocation.reconciliationLeaseId !== undefined ||
374
+ allocation.reconciliationLeaseExpiresAt !== undefined) {
375
+ continue;
376
+ }
377
+ const target = {
378
+ allocationId: allocation.id,
379
+ generation: allocation.generation,
380
+ };
381
+ if (await router.isAllocatedSidecarReady(target))
382
+ continue;
383
+ try {
384
+ await allocationStore.scheduleReconnectIfUnscheduled({
385
+ ...target,
386
+ connectDeadline: allocation.connectDeadline ??
387
+ new Date(now().getTime() + connectTimeoutMs),
388
+ now: now(),
389
+ });
390
+ }
391
+ catch (error) {
392
+ logger.warn `Failed to repair allocation ${allocation.id} reconnect schedule: ${error instanceof Error ? error.message : String(error)}`;
393
+ }
394
+ }
395
+ }
396
+ async function reconcileNext() {
397
+ const leaseId = createLeaseId();
398
+ const allocation = await allocationStore.claimNextReconcilable({
399
+ leaseId,
400
+ leaseDurationMs,
401
+ });
402
+ if (allocation === null)
403
+ return false;
404
+ try {
405
+ await reconcile(allocation, leaseId);
406
+ }
407
+ catch (error) {
408
+ logger.error `Allocation ${allocation.id} reconciliation failed: ${error instanceof Error ? error.message : String(error)}`;
409
+ await allocationStore.parkReconciliation(allocation.id, leaseId, {
410
+ kind: "retry-after-error",
411
+ notBefore: retryAt(MAX_RETRY_BACKOFF_ATTEMPT),
412
+ });
413
+ }
414
+ return true;
415
+ }
416
+ async function reconcileUntilIdle(maxIterations = 100) {
417
+ let reconciled = 0;
418
+ while (reconciled < maxIterations && (await reconcileNext())) {
419
+ reconciled += 1;
420
+ }
421
+ return reconciled;
422
+ }
423
+ return {
424
+ initialize,
425
+ handleDisconnect,
426
+ handleConnected,
427
+ repairUnscheduledConnections,
428
+ reconcileNext,
429
+ reconcileUntilIdle,
430
+ };
431
+ }