@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.
- package/README.md +3 -5
- package/dist/agent-repo.d.ts +9 -5
- package/dist/agent-repo.js +2 -2
- package/dist/agent-state-kind.js +4 -0
- package/dist/asset-service.d.ts +1 -20
- package/dist/asset-service.js +9 -91
- package/dist/committed-source-tree.d.ts +10 -0
- package/dist/committed-source-tree.js +35 -0
- package/dist/credential-push.d.ts +7 -6
- package/dist/credential-push.js +42 -18
- package/dist/event-collector-registry.d.ts +1 -1
- package/dist/event-collector-registry.js +4 -4
- package/dist/event-collector.d.ts +1 -1
- package/dist/event-collector.js +10 -2
- package/dist/hub-session-lookups.d.ts +125 -7
- package/dist/hub-session-lookups.js +539 -80
- package/dist/hub-session-orchestrator.js +14 -49
- package/dist/index.d.ts +17 -8
- package/dist/index.js +14 -6
- package/dist/repo-store/index.d.ts +1 -1
- package/dist/repo-store/store.d.ts +1 -1
- package/dist/repo-store/store.js +138 -1
- package/dist/repo-store/subscribe-kind.d.ts +6 -3
- package/dist/repo-store/subscribe-kind.js +42 -77
- package/dist/repo-store/types.d.ts +94 -6
- package/dist/session-service.d.ts +277 -96
- package/dist/session-service.js +741 -547
- package/dist/sidecar-allocation/contracts.d.ts +78 -0
- package/dist/sidecar-allocation/contracts.js +21 -0
- package/dist/sidecar-allocation/index.d.ts +4 -0
- package/dist/sidecar-allocation/index.js +3 -0
- package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
- package/dist/sidecar-allocation/placement-policy.js +21 -0
- package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
- package/dist/sidecar-allocation/plugin-registry.js +37 -0
- package/dist/sidecar-allocation/reconciler.d.ts +42 -0
- package/dist/sidecar-allocation/reconciler.js +431 -0
- package/dist/skill-kind.js +4 -0
- package/dist/substrate.d.ts +3 -3
- package/dist/substrate.js +1 -1
- package/dist/workflow-allocation-service.d.ts +58 -0
- package/dist/workflow-allocation-service.js +239 -0
- package/dist/workflow-closure-resolution.d.ts +106 -0
- package/dist/workflow-closure-resolution.js +123 -0
- package/dist/workflow-definition-ensure.d.ts +24 -0
- package/dist/workflow-definition-ensure.js +75 -0
- package/dist/workflow-dispatch-service.d.ts +40 -0
- package/dist/workflow-dispatch-service.js +146 -0
- package/dist/workflow-dispatch-settlement.d.ts +29 -0
- package/dist/workflow-dispatch-settlement.js +140 -0
- package/dist/workflow-kind.d.ts +17 -1
- package/dist/workflow-kind.js +127 -80
- package/dist/workflow-probe-gate.d.ts +214 -0
- package/dist/workflow-probe-gate.js +207 -0
- package/dist/workflow-run-kind.d.ts +128 -14
- package/dist/workflow-run-kind.js +353 -83
- package/dist/workflow-run-reader.d.ts +1 -1
- package/dist/workflow-run-reader.js +3 -7
- package/dist/workflow-run-restore.d.ts +15 -0
- package/dist/workflow-run-restore.js +26 -0
- package/dist/workflow-source-closure.d.ts +35 -0
- package/dist/workflow-source-closure.js +342 -0
- package/dist/ws/index.d.ts +3 -3
- package/dist/ws/index.js +1 -1
- package/dist/ws/sidecar-events.d.ts +100 -12
- package/dist/ws/sidecar-events.js +2 -0
- package/dist/ws/sidecar-handler.d.ts +128 -7
- package/dist/ws/sidecar-handler.js +1069 -135
- package/dist/ws/sidecar-token-authenticator.d.ts +3 -1
- package/dist/ws/sidecar-token-authenticator.js +64 -7
- package/package.json +14 -13
- package/dist/available-skills-stanza.d.ts +0 -21
- 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,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
|
+
}
|