@gajae-code/coding-agent 0.15.5 → 0.16.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/CHANGELOG.md +85 -1
- package/dist/types/async/job-manager.d.ts +83 -0
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
- package/dist/types/cli/model-presets-cli.d.ts +9 -0
- package/dist/types/cli/root-flags.d.ts +14 -0
- package/dist/types/cli/update-cli.d.ts +15 -0
- package/dist/types/cli.d.ts +7 -0
- package/dist/types/commands/auth-gateway.d.ts +3 -0
- package/dist/types/commands/gc.d.ts +13 -0
- package/dist/types/commands/launch.d.ts +10 -0
- package/dist/types/commands/model-presets.d.ts +21 -0
- package/dist/types/config/autorouting-tier-map.d.ts +120 -0
- package/dist/types/config/file-lock.d.ts +54 -2
- package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
- package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +3 -2
- package/dist/types/config/model-preset-registry.d.ts +368 -0
- package/dist/types/config/model-profile-activation.d.ts +39 -0
- package/dist/types/config/model-profiles.d.ts +5 -3
- package/dist/types/config/model-registry.d.ts +17 -5
- package/dist/types/config/settings-schema.d.ts +19 -1
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
- package/dist/types/coordinator-mcp/projection-scan.d.ts +54 -0
- package/dist/types/crash/record-loader.d.ts +15 -0
- package/dist/types/extensibility/extensions/types.d.ts +2 -5
- package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
- package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +72 -1
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/main.d.ts +3 -1
- package/dist/types/master-mode/context.d.ts +21 -0
- package/dist/types/master-mode/first-request.d.ts +34 -0
- package/dist/types/master-mode/snapshot.d.ts +32 -0
- package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
- package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
- package/dist/types/modes/components/oauth-selector.d.ts +1 -0
- package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
- package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
- package/dist/types/modes/components/status-line/segments.d.ts +10 -0
- package/dist/types/modes/jobs-observer.d.ts +36 -0
- package/dist/types/modes/print-mode.d.ts +17 -0
- package/dist/types/modes/ui-language.d.ts +7 -1
- package/dist/types/sdk/broker/broker.d.ts +80 -1
- package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
- package/dist/types/sdk/broker/index.d.ts +3 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
- package/dist/types/sdk/broker/master-capability.d.ts +10 -0
- package/dist/types/sdk/broker/session-index.d.ts +40 -11
- package/dist/types/sdk/broker/session-scope.d.ts +72 -0
- package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
- package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
- package/dist/types/sdk/broker/state-version.d.ts +4 -1
- package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
- package/dist/types/sdk/bus/index.d.ts +6 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/cli/index.d.ts +1 -0
- package/dist/types/sdk/cli/master-cli.d.ts +63 -0
- package/dist/types/sdk/cli/rows.d.ts +4 -3
- package/dist/types/sdk/cli/session-cli.d.ts +18 -2
- package/dist/types/sdk/client/client.d.ts +3 -2
- package/dist/types/sdk/client/discovery.d.ts +1 -0
- package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
- package/dist/types/sdk/host/control/index.d.ts +1 -1
- package/dist/types/sdk/host/control/operations.d.ts +2 -0
- package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
- package/dist/types/sdk/host/host.d.ts +10 -0
- package/dist/types/sdk/host/model-pin.d.ts +7 -2
- package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +67 -1
- package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
- package/dist/types/sdk/lifecycle/service.d.ts +15 -3
- package/dist/types/sdk/lifecycle-session.d.ts +2 -0
- package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
- package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
- package/dist/types/sdk/router/session-router.d.ts +11 -0
- package/dist/types/sdk/session.d.ts +16 -0
- package/dist/types/sdk/startup-capability.d.ts +2 -1
- package/dist/types/session/account-inventory.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +24 -7
- package/dist/types/session/credential-store-errors.d.ts +1 -0
- package/dist/types/session/fold-coordinator.d.ts +201 -0
- package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
- package/dist/types/session/sdk-run-capability.d.ts +2 -0
- package/dist/types/session/session-manager.d.ts +65 -0
- package/dist/types/session/yield-queue.d.ts +5 -4
- package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
- package/dist/types/telemetry/control.d.ts +11 -0
- package/dist/types/telemetry/events.d.ts +20 -0
- package/dist/types/telemetry/index.d.ts +3 -0
- package/dist/types/telemetry/transport.d.ts +22 -0
- package/dist/types/tools/bash-interactive.d.ts +23 -0
- package/dist/types/tools/bash.d.ts +4 -0
- package/dist/types/tools/gh.d.ts +8 -0
- package/dist/types/tools/index.d.ts +10 -5
- package/dist/types/web/search/providers/base.d.ts +9 -4
- package/dist/types/web/search/providers/xai.d.ts +2 -1
- package/dist/types/web/search/types.d.ts +14 -0
- package/package.json +10 -7
- package/scripts/generate-hotkeys-docs.ts +4 -0
- package/scripts/generate-sdk-operation-inventory.ts +9 -3
- package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
- package/src/async/job-manager.ts +671 -39
- package/src/cli/accounts-cli.ts +9 -5
- package/src/cli/args.ts +17 -0
- package/src/cli/auth-broker-cli.ts +40 -12
- package/src/cli/auth-gateway-cli.ts +273 -97
- package/src/cli/config-cli.ts +2 -2
- package/src/cli/fast-help.ts +4 -0
- package/src/cli/model-presets-cli.ts +100 -0
- package/src/cli/root-flags.ts +5 -0
- package/src/cli/update-cli.ts +117 -16
- package/src/cli.ts +6 -2
- package/src/commands/auth-gateway.ts +12 -8
- package/src/commands/gc.ts +19 -0
- package/src/commands/launch.ts +86 -18
- package/src/commands/model-presets.ts +24 -0
- package/src/commands/sdk.ts +113 -21
- package/src/config/autorouting-tier-map.ts +44 -0
- package/src/config/file-lock-gc.ts +9 -4
- package/src/config/file-lock.ts +890 -99
- package/src/config/internal/model-preset-registry-test-state.ts +28 -0
- package/src/config/internal/model-preset-registry-test-support.ts +11 -0
- package/src/config/keybindings.ts +6 -2
- package/src/config/model-preset-registry.ts +2795 -0
- package/src/config/model-profile-activation.ts +115 -28
- package/src/config/model-profile-contract.ts +4 -1
- package/src/config/model-profiles.ts +56 -14
- package/src/config/model-registry.ts +1044 -122
- package/src/config/model-resolver.ts +5 -0
- package/src/config/settings-schema.ts +19 -1
- package/src/config/settings.ts +11 -1
- package/src/coordinator-mcp/model-preset.ts +32 -17
- package/src/coordinator-mcp/projection-scan.ts +592 -0
- package/src/coordinator-mcp/question-state.ts +9 -11
- package/src/coordinator-mcp/server.ts +111 -42
- package/src/coordinator-mcp/session-reaper.ts +9 -3
- package/src/crash/index-store.ts +2 -0
- package/src/crash/record-loader.ts +10 -0
- package/src/extensibility/extensions/loader.ts +0 -1
- package/src/extensibility/extensions/types.ts +2 -3
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
- package/src/gjc-runtime/empty-delete-gc.ts +301 -0
- package/src/gjc-runtime/gc-render.ts +29 -0
- package/src/gjc-runtime/gc-runtime.ts +174 -11
- package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
- package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
- package/src/gjc-runtime/session-state-lock.ts +1321 -147
- package/src/gjc-runtime/session-state-sidecar.ts +1004 -25
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
- package/src/gjc-runtime/tmux-sessions.ts +252 -13
- package/src/hooks/skill-state.ts +6 -0
- package/src/index.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +14 -13
- package/src/internal-urls/issue-pr-protocol.ts +4 -2
- package/src/lsp/config.ts +4 -3
- package/src/lsp/path-trust.ts +12 -6
- package/src/main.ts +66 -4
- package/src/master-mode/context.ts +50 -0
- package/src/master-mode/first-request.ts +65 -0
- package/src/master-mode/snapshot.ts +205 -0
- package/src/modes/acp/acp-agent.ts +616 -161
- package/src/modes/components/footer.ts +4 -2
- package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
- package/src/modes/components/jobs-overlay-model.ts +99 -16
- package/src/modes/components/model-selector.ts +158 -18
- package/src/modes/components/oauth-selector.ts +6 -1
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/model-name.ts +53 -0
- package/src/modes/components/status-line/priority-row.ts +107 -0
- package/src/modes/components/status-line/segments.ts +29 -10
- package/src/modes/components/tool-status-header.ts +151 -15
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +51 -6
- package/src/modes/controllers/selector-controller.ts +191 -96
- package/src/modes/interactive-mode.ts +14 -1
- package/src/modes/jobs-observer.ts +149 -16
- package/src/modes/print-mode.ts +64 -0
- package/src/modes/runtime-init.ts +1 -1
- package/src/modes/ui-language.ts +49 -2
- package/src/modes/utils/injected-user-submission.ts +15 -1
- package/src/prompts/system/master-mode.md +28 -0
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/fold-receipt.md +3 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk/acp/adapter.ts +17 -2
- package/src/sdk/broker/broker.ts +1671 -34
- package/src/sdk/broker/endpoint-authority.ts +86 -0
- package/src/sdk/broker/ensure.ts +5 -0
- package/src/sdk/broker/index.ts +3 -0
- package/src/sdk/broker/lifecycle.ts +217 -46
- package/src/sdk/broker/master-capability.ts +232 -0
- package/src/sdk/broker/session-index.ts +121 -17
- package/src/sdk/broker/session-scope.ts +272 -0
- package/src/sdk/broker/spawn-authority.ts +1031 -0
- package/src/sdk/broker/spawn-substrate.ts +548 -0
- package/src/sdk/broker/state-version.ts +26 -14
- package/src/sdk/broker/transport.ts +1 -0
- package/src/sdk/broker/worktree-occupancy.ts +90 -0
- package/src/sdk/bus/chat-daemon-control.ts +5 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +46 -5
- package/src/sdk/bus/index.ts +51 -4
- package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
- package/src/sdk/bus/prompt-reconciliation.ts +28 -4
- package/src/sdk/bus/slack-conversation.ts +5 -1
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/cli/index.ts +1 -0
- package/src/sdk/cli/master-cli.ts +259 -0
- package/src/sdk/cli/rows.ts +11 -8
- package/src/sdk/cli/session-cli.ts +276 -6
- package/src/sdk/client/client.ts +14 -8
- package/src/sdk/client/discovery.ts +3 -1
- package/src/sdk/host/control/dispatch.ts +101 -37
- package/src/sdk/host/control/index.ts +2 -0
- package/src/sdk/host/control/operations.ts +2 -0
- package/src/sdk/host/control/runtime-gate.ts +203 -11
- package/src/sdk/host/host.ts +27 -2
- package/src/sdk/host/model-pin.ts +185 -25
- package/src/sdk/host/sdk-run-capability.ts +1 -0
- package/src/sdk/host/session-runtime.test.ts +581 -43
- package/src/sdk/host/session-runtime.ts +655 -101
- package/src/sdk/lifecycle/broker-client.ts +28 -0
- package/src/sdk/lifecycle/service.ts +279 -20
- package/src/sdk/lifecycle-session.ts +10 -2
- package/src/sdk/prompt-deadline-manager.ts +16 -1
- package/src/sdk/protocol/adapter-validation.ts +27 -0
- package/src/sdk/protocol/operation-inventory.generated.json +53 -2
- package/src/sdk/protocol/operation-registry.ts +17 -4
- package/src/sdk/router/session-router.ts +294 -154
- package/src/sdk/session.ts +440 -167
- package/src/sdk/startup-capability.ts +1 -0
- package/src/session/account-inventory.ts +27 -11
- package/src/session/agent-session.ts +1292 -180
- package/src/session/blob-store.ts +15 -5
- package/src/session/credential-store-errors.ts +2 -0
- package/src/session/fold-coordinator.ts +433 -0
- package/src/session/internal/managed-session-storage.ts +153 -78
- package/src/session/sdk-run-capability-internal.ts +15 -0
- package/src/session/sdk-run-capability.ts +2 -0
- package/src/session/session-manager.ts +342 -28
- package/src/session/yield-queue.ts +24 -10
- package/src/setup/frictionless-onboarding.ts +2 -0
- package/src/setup/provider-presets.json +1 -1
- package/src/slash-commands/helpers/credential-switch.ts +6 -1
- package/src/task/executor.ts +19 -3
- package/src/telemetry/control.ts +26 -0
- package/src/telemetry/events.ts +1268 -0
- package/src/telemetry/index.ts +3 -0
- package/src/telemetry/transport.ts +87 -0
- package/src/tools/bash-interactive.ts +182 -85
- package/src/tools/bash.ts +1028 -109
- package/src/tools/gh.ts +104 -48
- package/src/tools/github-cache.ts +2 -2
- package/src/tools/index.ts +11 -5
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/web/search/provider.ts +120 -11
- package/src/web/search/providers/anthropic.ts +89 -17
- package/src/web/search/providers/base.ts +9 -4
- package/src/web/search/providers/gemini.ts +87 -15
- package/src/web/search/providers/openai-compatible.ts +23 -8
- package/src/web/search/providers/xai.ts +98 -12
- package/src/web/search/types.ts +16 -0
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import * as fsSync from "node:fs";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
export type SpawnClaimV2 = {
|
|
7
|
+
version: 2;
|
|
8
|
+
claimId: string;
|
|
9
|
+
lifecycleIdentity: string;
|
|
10
|
+
/** Opaque binding of the raw selector request, retained for catalog-independent replay. */
|
|
11
|
+
requestBindingMac?: string;
|
|
12
|
+
bindingMac: string;
|
|
13
|
+
state:
|
|
14
|
+
| "prepared"
|
|
15
|
+
| "substrate_starting"
|
|
16
|
+
| "authority_active"
|
|
17
|
+
| "seed_prepared"
|
|
18
|
+
| "dispatching"
|
|
19
|
+
| "accepted"
|
|
20
|
+
| "pre_send_rejected"
|
|
21
|
+
| "uncertain"
|
|
22
|
+
| "closed";
|
|
23
|
+
failure?: SpawnSubstrateFailure;
|
|
24
|
+
preSendLease?: { epoch: string; status: "owned" | "consumed" };
|
|
25
|
+
childId?: string;
|
|
26
|
+
seed?: SeedDeliveryV2;
|
|
27
|
+
authorityRef?: string;
|
|
28
|
+
createdAt: number;
|
|
29
|
+
updatedAt: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type SpawnSubstrateFailure = {
|
|
33
|
+
substrateKind: "tmux" | "psmux" | "headless";
|
|
34
|
+
code: string;
|
|
35
|
+
message: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type SeedDeliveryV2 = {
|
|
39
|
+
version: 2;
|
|
40
|
+
phase: "prepared" | "dispatching" | "accepted" | "pre_send_rejected" | "uncertain";
|
|
41
|
+
clientRef: string;
|
|
42
|
+
commandId?: string;
|
|
43
|
+
turnId?: string;
|
|
44
|
+
acceptedAt?: number;
|
|
45
|
+
lastQ26Status?: "accepted" | "in_flight" | "terminal_ok" | "failed" | "unknown";
|
|
46
|
+
observedAt?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Live-only capability check. Implementations must not retain either argument. */
|
|
50
|
+
export interface MasterCapabilityVerifier {
|
|
51
|
+
verifyMasterCapability(
|
|
52
|
+
ownerSessionId: string,
|
|
53
|
+
rawCapability: string,
|
|
54
|
+
attestationEpoch: string,
|
|
55
|
+
): Promise<{ allowed: boolean }>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Structured, shell-free child launch contract owned by the Broker. */
|
|
59
|
+
export interface SpawnSubstrateLaunchSpec {
|
|
60
|
+
childSessionId: string;
|
|
61
|
+
cwd: string;
|
|
62
|
+
argv: readonly string[];
|
|
63
|
+
/** Broker-inherited values are filtered to the portable launch subset. */
|
|
64
|
+
inheritedEnv?: Readonly<Record<string, string>>;
|
|
65
|
+
/** Child-specific values are part of this spec and must be shell-safe. */
|
|
66
|
+
env?: Readonly<Record<string, string>>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Durable facts required to re-prove one spawned substrate without task material. */
|
|
70
|
+
export type SpawnSubstrateProof = {
|
|
71
|
+
substrateKind: "tmux" | "psmux" | "headless";
|
|
72
|
+
providerIdentity: string;
|
|
73
|
+
nativeSessionId?: string;
|
|
74
|
+
pid?: number;
|
|
75
|
+
processIncarnation?: string;
|
|
76
|
+
ownerGeneration?: number;
|
|
77
|
+
stateFileProof?: Readonly<Record<string, string | number>>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export interface SpawnSubstrateProvider {
|
|
81
|
+
launch(
|
|
82
|
+
spec: SpawnSubstrateLaunchSpec,
|
|
83
|
+
): Promise<
|
|
84
|
+
| { ok: true; proof: SpawnSubstrateProof }
|
|
85
|
+
| { ok: false; code: "substrate_unavailable" | "substrate_proof_failed"; message: string }
|
|
86
|
+
>;
|
|
87
|
+
verify(proof: SpawnSubstrateProof): Promise<"verified" | "mismatch" | "gone">;
|
|
88
|
+
close(proof: SpawnSubstrateProof): Promise<{ ok: boolean; code?: string }>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type SpawnAuthorityCloseState = "active" | "close_requested" | "closed" | "uncertain";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Exact, durable ownership evidence for a child substrate. It is deliberately
|
|
95
|
+
* structural: request text, capability material, and their derivatives never
|
|
96
|
+
* belong in this record.
|
|
97
|
+
*/
|
|
98
|
+
export type SpawnAuthorityV1 = {
|
|
99
|
+
version: 1;
|
|
100
|
+
authorityId: string;
|
|
101
|
+
claimId: string;
|
|
102
|
+
childId: string;
|
|
103
|
+
ownerSessionId: string;
|
|
104
|
+
lifecycleIdentity: string;
|
|
105
|
+
substrateKind: SpawnSubstrateProof["substrateKind"];
|
|
106
|
+
providerIdentity: string;
|
|
107
|
+
nativeSessionId?: string;
|
|
108
|
+
pid?: number;
|
|
109
|
+
processIncarnation?: string;
|
|
110
|
+
ownerGeneration?: number;
|
|
111
|
+
stateFileProof?: Readonly<Record<string, string | number>>;
|
|
112
|
+
/**
|
|
113
|
+
* Identity of the child HOST endpoint proven at registration. Distinct from
|
|
114
|
+
* the substrate `pid`/`processIncarnation` above: proving the multiplexer
|
|
115
|
+
* substrate is intact says nothing about which host answers on that session
|
|
116
|
+
* id. Optional so pre-pin v1 rows still reopen; recovery treats an absent
|
|
117
|
+
* pin as missing authority and fails closed rather than matching by id.
|
|
118
|
+
*/
|
|
119
|
+
endpointGeneration?: number;
|
|
120
|
+
endpointPid?: number;
|
|
121
|
+
endpointIncarnation?: string;
|
|
122
|
+
/** Launch locator, so a colliding generation/pid in another workspace cannot pass. */
|
|
123
|
+
endpointCwd?: string;
|
|
124
|
+
endpointStateRoot?: string;
|
|
125
|
+
closeState: SpawnAuthorityCloseState;
|
|
126
|
+
orphanedAt?: number;
|
|
127
|
+
orphanRecoveredAt?: number;
|
|
128
|
+
closeRequestedAt?: number;
|
|
129
|
+
closedAt?: number;
|
|
130
|
+
createdAt: number;
|
|
131
|
+
updatedAt: number;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export type SpawnClaimTransition = {
|
|
135
|
+
claimId: string;
|
|
136
|
+
from: SpawnClaimV2["state"];
|
|
137
|
+
to: SpawnClaimV2["state"];
|
|
138
|
+
/** Required when the transition consumes the one pre-send lease. */
|
|
139
|
+
leaseEpoch?: string;
|
|
140
|
+
/** The fresh child ID is committed before substrate launch. */
|
|
141
|
+
childId?: string;
|
|
142
|
+
/** Required for seed and terminal state transitions that carry Q26 facts. */
|
|
143
|
+
seed?: SeedDeliveryV2;
|
|
144
|
+
failure?: SpawnSubstrateFailure;
|
|
145
|
+
/** Required when exact substrate authority becomes active or closes. */
|
|
146
|
+
authority?: SpawnAuthorityV1;
|
|
147
|
+
/** Required to downgrade dispatching only when no frame was handed off. */
|
|
148
|
+
provenNoHandoff?: true;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export type SpawnAuthorityTransitionResult = {
|
|
152
|
+
claim: SpawnClaimV2;
|
|
153
|
+
authority?: SpawnAuthorityV1;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export class SpawnAuthorityTransitionError extends Error {
|
|
157
|
+
constructor(readonly code: "claim_not_found" | "stale_transition" | "illegal_transition" | "invalid_transition") {
|
|
158
|
+
super("Spawn authority transition was rejected.");
|
|
159
|
+
this.name = "SpawnAuthorityTransitionError";
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type SpawnClaimDecision =
|
|
164
|
+
| { kind: "owner"; claim: SpawnClaimV2; recovery: boolean }
|
|
165
|
+
| { kind: "in_progress"; claim: SpawnClaimV2 }
|
|
166
|
+
| { kind: "replay"; claim: SpawnClaimV2 }
|
|
167
|
+
| { kind: "terminal"; claim: SpawnClaimV2 }
|
|
168
|
+
| { kind: "terminal_uncertain"; claim: SpawnClaimV2 }
|
|
169
|
+
| { kind: "idempotency_conflict"; claim: SpawnClaimV2 };
|
|
170
|
+
|
|
171
|
+
type StoredClaim = {
|
|
172
|
+
version: 1;
|
|
173
|
+
claim: SpawnClaimV2;
|
|
174
|
+
authority?: SpawnAuthorityV1;
|
|
175
|
+
integrity: string;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
type SpawnAuthorityDurableWrite = {
|
|
179
|
+
claim: SpawnClaimV2;
|
|
180
|
+
authority?: SpawnAuthorityV1;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export interface SpawnAuthorityStoreOptions {
|
|
184
|
+
/** Test-only hook that runs before the journal record becomes durable. */
|
|
185
|
+
beforeSyncForTest?: (write: SpawnAuthorityDurableWrite) => Promise<void> | void;
|
|
186
|
+
/** Test-only hook after both file and parent-directory fsync complete. */
|
|
187
|
+
afterSyncForTest?: (write: SpawnAuthorityDurableWrite) => Promise<void> | void;
|
|
188
|
+
/** Test-only hook between the file barrier and the parent-directory barrier. */
|
|
189
|
+
beforeDirectorySyncForTest?: (write: SpawnAuthorityDurableWrite) => Promise<void> | void;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const CLAIM_STATES = new Set<SpawnClaimV2["state"]>([
|
|
193
|
+
"prepared",
|
|
194
|
+
"substrate_starting",
|
|
195
|
+
"authority_active",
|
|
196
|
+
"seed_prepared",
|
|
197
|
+
"dispatching",
|
|
198
|
+
"accepted",
|
|
199
|
+
"pre_send_rejected",
|
|
200
|
+
"uncertain",
|
|
201
|
+
"closed",
|
|
202
|
+
]);
|
|
203
|
+
const SEED_PHASES = new Set<SeedDeliveryV2["phase"]>([
|
|
204
|
+
"prepared",
|
|
205
|
+
"dispatching",
|
|
206
|
+
"accepted",
|
|
207
|
+
"pre_send_rejected",
|
|
208
|
+
"uncertain",
|
|
209
|
+
]);
|
|
210
|
+
const Q26_STATUSES = new Set<NonNullable<SeedDeliveryV2["lastQ26Status"]>>([
|
|
211
|
+
"accepted",
|
|
212
|
+
"in_flight",
|
|
213
|
+
"terminal_ok",
|
|
214
|
+
"failed",
|
|
215
|
+
"unknown",
|
|
216
|
+
]);
|
|
217
|
+
const CLOSE_STATES = new Set<SpawnAuthorityCloseState>(["active", "close_requested", "closed", "uncertain"]);
|
|
218
|
+
const CLAIM_KEYS = new Set([
|
|
219
|
+
"version",
|
|
220
|
+
"claimId",
|
|
221
|
+
"lifecycleIdentity",
|
|
222
|
+
"requestBindingMac",
|
|
223
|
+
"bindingMac",
|
|
224
|
+
"state",
|
|
225
|
+
"failure",
|
|
226
|
+
"preSendLease",
|
|
227
|
+
"childId",
|
|
228
|
+
"seed",
|
|
229
|
+
"authorityRef",
|
|
230
|
+
"createdAt",
|
|
231
|
+
"updatedAt",
|
|
232
|
+
]);
|
|
233
|
+
const SEED_KEYS = new Set([
|
|
234
|
+
"version",
|
|
235
|
+
"phase",
|
|
236
|
+
"clientRef",
|
|
237
|
+
"commandId",
|
|
238
|
+
"turnId",
|
|
239
|
+
"acceptedAt",
|
|
240
|
+
"lastQ26Status",
|
|
241
|
+
"observedAt",
|
|
242
|
+
]);
|
|
243
|
+
const AUTHORITY_KEYS = new Set([
|
|
244
|
+
"version",
|
|
245
|
+
"authorityId",
|
|
246
|
+
"claimId",
|
|
247
|
+
"childId",
|
|
248
|
+
"ownerSessionId",
|
|
249
|
+
"lifecycleIdentity",
|
|
250
|
+
"substrateKind",
|
|
251
|
+
"providerIdentity",
|
|
252
|
+
"nativeSessionId",
|
|
253
|
+
"pid",
|
|
254
|
+
"processIncarnation",
|
|
255
|
+
"ownerGeneration",
|
|
256
|
+
"stateFileProof",
|
|
257
|
+
"endpointGeneration",
|
|
258
|
+
"endpointPid",
|
|
259
|
+
"endpointIncarnation",
|
|
260
|
+
"endpointCwd",
|
|
261
|
+
"endpointStateRoot",
|
|
262
|
+
"closeState",
|
|
263
|
+
"orphanedAt",
|
|
264
|
+
"orphanRecoveredAt",
|
|
265
|
+
"closeRequestedAt",
|
|
266
|
+
"closedAt",
|
|
267
|
+
"createdAt",
|
|
268
|
+
"updatedAt",
|
|
269
|
+
]);
|
|
270
|
+
const SUBSTRATE_KINDS = new Set<SpawnSubstrateProof["substrateKind"]>(["tmux", "psmux", "headless"]);
|
|
271
|
+
const FORBIDDEN_FIELD =
|
|
272
|
+
/(?:task|prompt|capability|idempotency|fingerprint|requesthash|digest|credential|token|secret|password|stderr)/i;
|
|
273
|
+
const TRANSITION_EDGES: Readonly<Record<SpawnClaimV2["state"], readonly SpawnClaimV2["state"][]>> = {
|
|
274
|
+
prepared: ["substrate_starting", "pre_send_rejected", "uncertain", "closed"],
|
|
275
|
+
substrate_starting: ["authority_active", "pre_send_rejected", "uncertain", "closed"],
|
|
276
|
+
authority_active: ["seed_prepared", "pre_send_rejected", "uncertain", "closed"],
|
|
277
|
+
seed_prepared: ["dispatching", "pre_send_rejected", "uncertain", "closed"],
|
|
278
|
+
dispatching: ["accepted", "pre_send_rejected", "uncertain", "closed"],
|
|
279
|
+
accepted: ["uncertain", "closed"],
|
|
280
|
+
pre_send_rejected: ["uncertain", "closed"],
|
|
281
|
+
uncertain: ["closed"],
|
|
282
|
+
closed: [],
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
function canonicalJson(value: unknown): string {
|
|
286
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
287
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
288
|
+
const record = value as Record<string, unknown>;
|
|
289
|
+
return `{${Object.keys(record)
|
|
290
|
+
.filter(key => record[key] !== undefined)
|
|
291
|
+
.sort()
|
|
292
|
+
.map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
|
293
|
+
.join(",")}}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function hasOnlyKeys(value: Record<string, unknown>, keys: Set<string>): boolean {
|
|
297
|
+
return Object.keys(value).every(key => keys.has(key) && !FORBIDDEN_FIELD.test(key));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function isOpaque(value: unknown): value is string {
|
|
301
|
+
return typeof value === "string" && value.length > 0 && value.length <= 512;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function isTimestamp(value: unknown): value is number {
|
|
305
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function isPositiveInteger(value: unknown): value is number {
|
|
309
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isNonNegativeInteger(value: unknown): value is number {
|
|
313
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isStateFileProof(value: unknown): value is Readonly<Record<string, string | number>> {
|
|
317
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
318
|
+
const proof = value as Record<string, unknown>;
|
|
319
|
+
return (
|
|
320
|
+
Object.keys(proof).length <= 64 &&
|
|
321
|
+
Object.entries(proof).every(
|
|
322
|
+
([key, field]) =>
|
|
323
|
+
/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(key) &&
|
|
324
|
+
!FORBIDDEN_FIELD.test(key) &&
|
|
325
|
+
((typeof field === "string" && isOpaque(field)) || isNonNegativeInteger(field)),
|
|
326
|
+
)
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function isSpawnSubstrateProof(value: unknown): value is SpawnSubstrateProof {
|
|
331
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
332
|
+
const proof = value as Record<string, unknown>;
|
|
333
|
+
return (
|
|
334
|
+
Object.keys(proof).every(
|
|
335
|
+
key =>
|
|
336
|
+
key === "substrateKind" ||
|
|
337
|
+
key === "providerIdentity" ||
|
|
338
|
+
key === "nativeSessionId" ||
|
|
339
|
+
key === "pid" ||
|
|
340
|
+
key === "processIncarnation" ||
|
|
341
|
+
key === "ownerGeneration" ||
|
|
342
|
+
key === "stateFileProof",
|
|
343
|
+
) &&
|
|
344
|
+
typeof proof.substrateKind === "string" &&
|
|
345
|
+
SUBSTRATE_KINDS.has(proof.substrateKind as SpawnSubstrateProof["substrateKind"]) &&
|
|
346
|
+
isOpaque(proof.providerIdentity) &&
|
|
347
|
+
(proof.nativeSessionId === undefined || isOpaque(proof.nativeSessionId)) &&
|
|
348
|
+
(proof.pid === undefined || isPositiveInteger(proof.pid)) &&
|
|
349
|
+
(proof.processIncarnation === undefined || isOpaque(proof.processIncarnation)) &&
|
|
350
|
+
(proof.ownerGeneration === undefined || isNonNegativeInteger(proof.ownerGeneration)) &&
|
|
351
|
+
(proof.stateFileProof === undefined || isStateFileProof(proof.stateFileProof)) &&
|
|
352
|
+
(proof.nativeSessionId !== undefined || (proof.pid !== undefined && proof.processIncarnation !== undefined))
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function isSeed(value: unknown): value is SeedDeliveryV2 {
|
|
357
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
358
|
+
const seed = value as Record<string, unknown>;
|
|
359
|
+
if (
|
|
360
|
+
!hasOnlyKeys(seed, SEED_KEYS) ||
|
|
361
|
+
seed.version !== 2 ||
|
|
362
|
+
typeof seed.phase !== "string" ||
|
|
363
|
+
!SEED_PHASES.has(seed.phase as SeedDeliveryV2["phase"]) ||
|
|
364
|
+
!isOpaque(seed.clientRef) ||
|
|
365
|
+
(seed.commandId !== undefined && !isOpaque(seed.commandId)) ||
|
|
366
|
+
(seed.turnId !== undefined && !isOpaque(seed.turnId)) ||
|
|
367
|
+
(seed.acceptedAt !== undefined && !isTimestamp(seed.acceptedAt)) ||
|
|
368
|
+
(seed.lastQ26Status !== undefined &&
|
|
369
|
+
(typeof seed.lastQ26Status !== "string" ||
|
|
370
|
+
!Q26_STATUSES.has(seed.lastQ26Status as NonNullable<SeedDeliveryV2["lastQ26Status"]>))) ||
|
|
371
|
+
(seed.observedAt !== undefined && !isTimestamp(seed.observedAt))
|
|
372
|
+
)
|
|
373
|
+
return false;
|
|
374
|
+
if (seed.phase === "accepted")
|
|
375
|
+
return isOpaque(seed.commandId) && isOpaque(seed.turnId) && isTimestamp(seed.acceptedAt);
|
|
376
|
+
if (seed.phase === "prepared" || seed.phase === "dispatching" || seed.phase === "pre_send_rejected")
|
|
377
|
+
return seed.commandId === undefined && seed.turnId === undefined && seed.acceptedAt === undefined;
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function isSpawnAuthorityV1(value: unknown): value is SpawnAuthorityV1 {
|
|
382
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
383
|
+
const authority = value as Record<string, unknown>;
|
|
384
|
+
return (
|
|
385
|
+
hasOnlyKeys(authority, AUTHORITY_KEYS) &&
|
|
386
|
+
authority.version === 1 &&
|
|
387
|
+
isOpaque(authority.authorityId) &&
|
|
388
|
+
isOpaque(authority.claimId) &&
|
|
389
|
+
isOpaque(authority.childId) &&
|
|
390
|
+
isOpaque(authority.ownerSessionId) &&
|
|
391
|
+
isOpaque(authority.lifecycleIdentity) &&
|
|
392
|
+
typeof authority.substrateKind === "string" &&
|
|
393
|
+
SUBSTRATE_KINDS.has(authority.substrateKind as SpawnSubstrateProof["substrateKind"]) &&
|
|
394
|
+
isOpaque(authority.providerIdentity) &&
|
|
395
|
+
(authority.endpointGeneration === undefined || isNonNegativeInteger(authority.endpointGeneration)) &&
|
|
396
|
+
(authority.endpointPid === undefined || isPositiveInteger(authority.endpointPid)) &&
|
|
397
|
+
(authority.endpointIncarnation === undefined || isOpaque(authority.endpointIncarnation)) &&
|
|
398
|
+
(authority.endpointCwd === undefined || isOpaque(authority.endpointCwd)) &&
|
|
399
|
+
(authority.endpointStateRoot === undefined || isOpaque(authority.endpointStateRoot)) &&
|
|
400
|
+
(authority.nativeSessionId === undefined || isOpaque(authority.nativeSessionId)) &&
|
|
401
|
+
(authority.pid === undefined || isPositiveInteger(authority.pid)) &&
|
|
402
|
+
(authority.processIncarnation === undefined || isOpaque(authority.processIncarnation)) &&
|
|
403
|
+
(authority.ownerGeneration === undefined || isNonNegativeInteger(authority.ownerGeneration)) &&
|
|
404
|
+
(authority.stateFileProof === undefined || isStateFileProof(authority.stateFileProof)) &&
|
|
405
|
+
(authority.nativeSessionId !== undefined ||
|
|
406
|
+
(authority.pid !== undefined && authority.processIncarnation !== undefined)) &&
|
|
407
|
+
typeof authority.closeState === "string" &&
|
|
408
|
+
CLOSE_STATES.has(authority.closeState as SpawnAuthorityCloseState) &&
|
|
409
|
+
(authority.orphanedAt === undefined || isTimestamp(authority.orphanedAt)) &&
|
|
410
|
+
(authority.orphanRecoveredAt === undefined || isTimestamp(authority.orphanRecoveredAt)) &&
|
|
411
|
+
(authority.closeRequestedAt === undefined || isTimestamp(authority.closeRequestedAt)) &&
|
|
412
|
+
(authority.closedAt === undefined || isTimestamp(authority.closedAt)) &&
|
|
413
|
+
isTimestamp(authority.createdAt) &&
|
|
414
|
+
isTimestamp(authority.updatedAt) &&
|
|
415
|
+
authority.updatedAt >= authority.createdAt &&
|
|
416
|
+
(authority.closeState !== "closed" || isTimestamp(authority.closedAt)) &&
|
|
417
|
+
(authority.closeState !== "close_requested" || isTimestamp(authority.closeRequestedAt))
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function hasClaimStateShape(claim: SpawnClaimV2): boolean {
|
|
422
|
+
switch (claim.state) {
|
|
423
|
+
case "prepared":
|
|
424
|
+
return claim.childId === undefined && claim.seed === undefined && claim.authorityRef === undefined;
|
|
425
|
+
case "substrate_starting":
|
|
426
|
+
return claim.childId !== undefined && claim.seed === undefined && claim.authorityRef === undefined;
|
|
427
|
+
case "authority_active":
|
|
428
|
+
return claim.childId !== undefined && claim.seed === undefined && claim.authorityRef !== undefined;
|
|
429
|
+
case "seed_prepared":
|
|
430
|
+
return claim.childId !== undefined && claim.authorityRef !== undefined && claim.seed?.phase === "prepared";
|
|
431
|
+
case "dispatching":
|
|
432
|
+
return claim.childId !== undefined && claim.authorityRef !== undefined && claim.seed?.phase === "dispatching";
|
|
433
|
+
case "accepted":
|
|
434
|
+
return claim.childId !== undefined && claim.authorityRef !== undefined && claim.seed?.phase === "accepted";
|
|
435
|
+
case "pre_send_rejected":
|
|
436
|
+
return claim.seed === undefined || claim.seed.phase === "pre_send_rejected";
|
|
437
|
+
case "uncertain":
|
|
438
|
+
return claim.seed === undefined || claim.seed.phase === "uncertain";
|
|
439
|
+
case "closed":
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function isSpawnSubstrateFailure(value: unknown): value is SpawnSubstrateFailure {
|
|
445
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
446
|
+
const failure = value as Record<string, unknown>;
|
|
447
|
+
return (
|
|
448
|
+
Object.keys(failure).every(key => key === "substrateKind" || key === "code" || key === "message") &&
|
|
449
|
+
SUBSTRATE_KINDS.has(failure.substrateKind as SpawnSubstrateProof["substrateKind"]) &&
|
|
450
|
+
isOpaque(failure.code) &&
|
|
451
|
+
isOpaque(failure.message) &&
|
|
452
|
+
failure.message.length <= 2048
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function isSpawnClaimV2(value: unknown): value is SpawnClaimV2 {
|
|
457
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
458
|
+
const claim = value as Record<string, unknown>;
|
|
459
|
+
const lease = claim.preSendLease;
|
|
460
|
+
const typed =
|
|
461
|
+
hasOnlyKeys(claim, CLAIM_KEYS) &&
|
|
462
|
+
claim.version === 2 &&
|
|
463
|
+
isOpaque(claim.claimId) &&
|
|
464
|
+
isOpaque(claim.lifecycleIdentity) &&
|
|
465
|
+
(claim.requestBindingMac === undefined ||
|
|
466
|
+
(typeof claim.requestBindingMac === "string" && /^[0-9a-f]{64}$/i.test(claim.requestBindingMac))) &&
|
|
467
|
+
/^[0-9a-f]{64}$/i.test(String(claim.bindingMac)) &&
|
|
468
|
+
typeof claim.state === "string" &&
|
|
469
|
+
CLAIM_STATES.has(claim.state as SpawnClaimV2["state"]) &&
|
|
470
|
+
(lease === undefined ||
|
|
471
|
+
(typeof lease === "object" &&
|
|
472
|
+
lease !== null &&
|
|
473
|
+
!Array.isArray(lease) &&
|
|
474
|
+
Object.keys(lease).every(key => key === "epoch" || key === "status") &&
|
|
475
|
+
isOpaque((lease as { epoch?: unknown }).epoch) &&
|
|
476
|
+
((lease as { status?: unknown }).status === "owned" ||
|
|
477
|
+
(lease as { status?: unknown }).status === "consumed"))) &&
|
|
478
|
+
(claim.childId === undefined || isOpaque(claim.childId)) &&
|
|
479
|
+
(claim.seed === undefined || isSeed(claim.seed)) &&
|
|
480
|
+
(claim.authorityRef === undefined || isOpaque(claim.authorityRef)) &&
|
|
481
|
+
(claim.failure === undefined || isSpawnSubstrateFailure(claim.failure)) &&
|
|
482
|
+
isTimestamp(claim.createdAt) &&
|
|
483
|
+
isTimestamp(claim.updatedAt) &&
|
|
484
|
+
claim.updatedAt >= claim.createdAt;
|
|
485
|
+
return typed && hasClaimStateShape(claim as SpawnClaimV2);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function isRecoverablePreSend(claim: SpawnClaimV2): boolean {
|
|
489
|
+
return claim.state === "prepared" || (claim.state === "seed_prepared" && claim.seed?.phase === "prepared");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function claimTimestampAfter(current: number): number {
|
|
493
|
+
if (current >= Number.MAX_SAFE_INTEGER) throw new Error("Spawn authority timestamp cannot advance.");
|
|
494
|
+
return Math.max(Date.now(), current + 1);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function matchingAuthority(claim: SpawnClaimV2, authority: SpawnAuthorityV1 | undefined): boolean {
|
|
498
|
+
return (
|
|
499
|
+
authority !== undefined &&
|
|
500
|
+
claim.authorityRef === authority.authorityId &&
|
|
501
|
+
claim.claimId === authority.claimId &&
|
|
502
|
+
claim.lifecycleIdentity === authority.lifecycleIdentity &&
|
|
503
|
+
claim.childId === authority.childId
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function authorityBinding(authority: SpawnAuthorityV1): string {
|
|
508
|
+
const {
|
|
509
|
+
closeState: _closeState,
|
|
510
|
+
orphanedAt: _orphanedAt,
|
|
511
|
+
orphanRecoveredAt: _orphanRecoveredAt,
|
|
512
|
+
closeRequestedAt: _closeRequestedAt,
|
|
513
|
+
closedAt: _closedAt,
|
|
514
|
+
createdAt: _createdAt,
|
|
515
|
+
updatedAt: _updatedAt,
|
|
516
|
+
...binding
|
|
517
|
+
} = authority;
|
|
518
|
+
return canonicalJson(binding);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function sameAuthorityBinding(left: SpawnAuthorityV1, right: SpawnAuthorityV1): boolean {
|
|
522
|
+
return authorityBinding(left) === authorityBinding(right);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function sameSeedIdentity(left: SeedDeliveryV2, right: SeedDeliveryV2): boolean {
|
|
526
|
+
return left.version === right.version && left.clientRef === right.clientRef;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function cloneAuthority(authority: SpawnAuthorityV1 | undefined): SpawnAuthorityV1 | undefined {
|
|
530
|
+
return authority === undefined
|
|
531
|
+
? undefined
|
|
532
|
+
: {
|
|
533
|
+
...authority,
|
|
534
|
+
...(authority.stateFileProof === undefined ? {} : { stateFileProof: { ...authority.stateFileProof } }),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function cloneClaim(claim: SpawnClaimV2): SpawnClaimV2 {
|
|
539
|
+
return {
|
|
540
|
+
...claim,
|
|
541
|
+
...(claim.preSendLease === undefined ? {} : { preSendLease: { ...claim.preSendLease } }),
|
|
542
|
+
...(claim.seed === undefined ? {} : { seed: { ...claim.seed } }),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Append-only source of spawn-effect authority. It deliberately records no request
|
|
548
|
+
* input: the opaque identity and raw/canonical structural binding MACs are its
|
|
549
|
+
* only correlators.
|
|
550
|
+
*/
|
|
551
|
+
export class SpawnAuthorityStore {
|
|
552
|
+
readonly #file: string;
|
|
553
|
+
readonly #identityKey: Buffer;
|
|
554
|
+
readonly #options: SpawnAuthorityStoreOptions;
|
|
555
|
+
readonly #latest = new Map<string, SpawnClaimV2>();
|
|
556
|
+
readonly #authorities = new Map<string, SpawnAuthorityV1>();
|
|
557
|
+
readonly #active = new Set<string>();
|
|
558
|
+
#tail: Promise<void> = Promise.resolve();
|
|
559
|
+
|
|
560
|
+
constructor(agentDir: string, brokerIdentityKey: string, options: SpawnAuthorityStoreOptions = {}) {
|
|
561
|
+
if (!/^[0-9a-f]{64}$/i.test(brokerIdentityKey)) throw new Error("Broker identity key is invalid.");
|
|
562
|
+
this.#file = path.join(agentDir, "sdk", "spawn-authority.jsonl");
|
|
563
|
+
this.#identityKey = Buffer.from(brokerIdentityKey, "hex");
|
|
564
|
+
this.#options = options;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
get file(): string {
|
|
568
|
+
return this.#file;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
claims(): readonly SpawnClaimV2[] {
|
|
572
|
+
return [...this.#latest.values()].map(cloneClaim);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
claim(lifecycleIdentity: string): SpawnClaimV2 | undefined {
|
|
576
|
+
const claim = this.#latest.get(lifecycleIdentity);
|
|
577
|
+
return claim === undefined ? undefined : cloneClaim(claim);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
authority(lifecycleIdentity: string): SpawnAuthorityV1 | undefined {
|
|
581
|
+
return cloneAuthority(this.#authorities.get(lifecycleIdentity));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async open(): Promise<void> {
|
|
585
|
+
await this.#serial(async () => {
|
|
586
|
+
await fs.mkdir(path.dirname(this.#file), { recursive: true, mode: 0o700 });
|
|
587
|
+
this.#latest.clear();
|
|
588
|
+
this.#authorities.clear();
|
|
589
|
+
let source = "";
|
|
590
|
+
try {
|
|
591
|
+
source = await Bun.file(this.#file).text();
|
|
592
|
+
} catch (error) {
|
|
593
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
594
|
+
}
|
|
595
|
+
for (const line of source.split("\n")) {
|
|
596
|
+
if (!line) continue;
|
|
597
|
+
let row: unknown;
|
|
598
|
+
try {
|
|
599
|
+
row = Bun.JSON5.parse(line);
|
|
600
|
+
} catch {
|
|
601
|
+
throw new Error("Spawn authority journal contains malformed durable evidence.");
|
|
602
|
+
}
|
|
603
|
+
if (!this.#isStoredClaim(row))
|
|
604
|
+
throw new Error("Spawn authority journal contains invalid durable evidence.");
|
|
605
|
+
const stored = row as StoredClaim;
|
|
606
|
+
const prior = this.#latest.get(stored.claim.lifecycleIdentity);
|
|
607
|
+
if (prior && (prior.claimId !== stored.claim.claimId || stored.claim.updatedAt <= prior.updatedAt))
|
|
608
|
+
throw new Error("Spawn authority journal claim history is invalid.");
|
|
609
|
+
this.#latest.set(stored.claim.lifecycleIdentity, stored.claim);
|
|
610
|
+
if (stored.authority) this.#authorities.set(stored.claim.lifecycleIdentity, stored.authority);
|
|
611
|
+
else this.#authorities.delete(stored.claim.lifecycleIdentity);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async claimOrJoin(
|
|
617
|
+
lifecycleIdentity: string,
|
|
618
|
+
bindingMac: string | undefined,
|
|
619
|
+
requestBindingMac = bindingMac,
|
|
620
|
+
): Promise<SpawnClaimDecision> {
|
|
621
|
+
if (
|
|
622
|
+
!isOpaque(lifecycleIdentity) ||
|
|
623
|
+
(bindingMac !== undefined && !/^[0-9a-f]{64}$/i.test(bindingMac)) ||
|
|
624
|
+
requestBindingMac === undefined ||
|
|
625
|
+
!/^[0-9a-f]{64}$/i.test(requestBindingMac)
|
|
626
|
+
)
|
|
627
|
+
throw new Error("Invalid opaque spawn claim key.");
|
|
628
|
+
return await this.#serial(async () => {
|
|
629
|
+
const current = this.#latest.get(lifecycleIdentity);
|
|
630
|
+
if (!current) {
|
|
631
|
+
if (bindingMac === undefined) throw new Error("A canonical spawn binding is required for a new claim.");
|
|
632
|
+
const now = Date.now();
|
|
633
|
+
const claim: SpawnClaimV2 = {
|
|
634
|
+
version: 2,
|
|
635
|
+
claimId: randomBytes(24).toString("base64url"),
|
|
636
|
+
lifecycleIdentity,
|
|
637
|
+
requestBindingMac,
|
|
638
|
+
bindingMac,
|
|
639
|
+
state: "prepared",
|
|
640
|
+
preSendLease: { epoch: randomBytes(24).toString("base64url"), status: "owned" },
|
|
641
|
+
createdAt: now,
|
|
642
|
+
updatedAt: now,
|
|
643
|
+
};
|
|
644
|
+
await this.#append(claim);
|
|
645
|
+
this.#active.add(lifecycleIdentity);
|
|
646
|
+
return { kind: "owner", claim: cloneClaim(claim), recovery: false };
|
|
647
|
+
}
|
|
648
|
+
const requestMatches =
|
|
649
|
+
current.requestBindingMac === undefined
|
|
650
|
+
? timingSafeEqual(Buffer.from(current.bindingMac, "hex"), Buffer.from(requestBindingMac, "hex"))
|
|
651
|
+
: timingSafeEqual(Buffer.from(current.requestBindingMac, "hex"), Buffer.from(requestBindingMac, "hex"));
|
|
652
|
+
if (
|
|
653
|
+
!requestMatches ||
|
|
654
|
+
(bindingMac !== undefined &&
|
|
655
|
+
!timingSafeEqual(Buffer.from(current.bindingMac, "hex"), Buffer.from(bindingMac, "hex")))
|
|
656
|
+
)
|
|
657
|
+
return { kind: "idempotency_conflict", claim: cloneClaim(current) };
|
|
658
|
+
if (this.#active.has(lifecycleIdentity)) return { kind: "in_progress", claim: cloneClaim(current) };
|
|
659
|
+
if (isRecoverablePreSend(current)) {
|
|
660
|
+
const rotated: SpawnClaimV2 = {
|
|
661
|
+
...current,
|
|
662
|
+
preSendLease: { epoch: randomBytes(24).toString("base64url"), status: "owned" },
|
|
663
|
+
updatedAt: claimTimestampAfter(current.updatedAt),
|
|
664
|
+
};
|
|
665
|
+
await this.#append(rotated, this.#authorities.get(lifecycleIdentity));
|
|
666
|
+
this.#active.add(lifecycleIdentity);
|
|
667
|
+
return { kind: "owner", claim: cloneClaim(rotated), recovery: true };
|
|
668
|
+
}
|
|
669
|
+
if (current.state === "uncertain") return { kind: "terminal_uncertain", claim: cloneClaim(current) };
|
|
670
|
+
if (current.state === "accepted" || current.state === "pre_send_rejected" || current.state === "closed")
|
|
671
|
+
return { kind: "terminal", claim: cloneClaim(current) };
|
|
672
|
+
return { kind: "replay", claim: cloneClaim(current) };
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Fsyncs one allowlisted state edge. Callers cannot patch claims in place,
|
|
678
|
+
* which prevents a retry from downgrading a handoff-capable state.
|
|
679
|
+
*/
|
|
680
|
+
async persistTransition(
|
|
681
|
+
lifecycleIdentity: string,
|
|
682
|
+
transition: SpawnClaimTransition,
|
|
683
|
+
): Promise<SpawnAuthorityTransitionResult> {
|
|
684
|
+
return await this.#serial(async () => {
|
|
685
|
+
const current = this.#latest.get(lifecycleIdentity);
|
|
686
|
+
if (!current) throw new SpawnAuthorityTransitionError("claim_not_found");
|
|
687
|
+
if (current.claimId !== transition.claimId || current.state !== transition.from)
|
|
688
|
+
throw new SpawnAuthorityTransitionError("stale_transition");
|
|
689
|
+
if (!TRANSITION_EDGES[current.state].includes(transition.to))
|
|
690
|
+
throw new SpawnAuthorityTransitionError("illegal_transition");
|
|
691
|
+
|
|
692
|
+
const currentAuthority = this.#authorities.get(lifecycleIdentity);
|
|
693
|
+
let nextAuthority = currentAuthority;
|
|
694
|
+
let next: SpawnClaimV2;
|
|
695
|
+
switch (transition.to) {
|
|
696
|
+
case "substrate_starting": {
|
|
697
|
+
if (
|
|
698
|
+
!isOpaque(transition.childId) ||
|
|
699
|
+
transition.seed !== undefined ||
|
|
700
|
+
transition.authority !== undefined ||
|
|
701
|
+
transition.failure !== undefined
|
|
702
|
+
)
|
|
703
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
704
|
+
next = { ...current, state: "substrate_starting", childId: transition.childId };
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
case "authority_active": {
|
|
708
|
+
const authority = transition.authority;
|
|
709
|
+
if (
|
|
710
|
+
!isOpaque(transition.childId) ||
|
|
711
|
+
!isSpawnAuthorityV1(authority) ||
|
|
712
|
+
authority.claimId !== current.claimId ||
|
|
713
|
+
authority.lifecycleIdentity !== current.lifecycleIdentity ||
|
|
714
|
+
authority.childId !== transition.childId ||
|
|
715
|
+
authority.closeState !== "active" ||
|
|
716
|
+
current.childId !== transition.childId ||
|
|
717
|
+
transition.seed !== undefined
|
|
718
|
+
)
|
|
719
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
720
|
+
nextAuthority = authority;
|
|
721
|
+
next = {
|
|
722
|
+
...current,
|
|
723
|
+
state: "authority_active",
|
|
724
|
+
authorityRef: authority.authorityId,
|
|
725
|
+
};
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
case "seed_prepared": {
|
|
729
|
+
if (
|
|
730
|
+
!matchingAuthority(current, currentAuthority) ||
|
|
731
|
+
!isSeed(transition.seed) ||
|
|
732
|
+
transition.seed.phase !== "prepared" ||
|
|
733
|
+
transition.childId !== undefined ||
|
|
734
|
+
transition.authority !== undefined
|
|
735
|
+
)
|
|
736
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
737
|
+
next = { ...current, state: "seed_prepared", seed: transition.seed };
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
case "dispatching": {
|
|
741
|
+
if (
|
|
742
|
+
currentAuthority === undefined ||
|
|
743
|
+
!matchingAuthority(current, currentAuthority) ||
|
|
744
|
+
currentAuthority.closeState !== "active" ||
|
|
745
|
+
current.preSendLease?.status !== "owned" ||
|
|
746
|
+
!isOpaque(transition.leaseEpoch) ||
|
|
747
|
+
transition.leaseEpoch !== current.preSendLease.epoch ||
|
|
748
|
+
!isSeed(transition.seed) ||
|
|
749
|
+
transition.seed.phase !== "dispatching" ||
|
|
750
|
+
!current.seed ||
|
|
751
|
+
!sameSeedIdentity(current.seed, transition.seed) ||
|
|
752
|
+
transition.childId !== undefined ||
|
|
753
|
+
transition.authority !== undefined
|
|
754
|
+
)
|
|
755
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
756
|
+
next = {
|
|
757
|
+
...current,
|
|
758
|
+
state: "dispatching",
|
|
759
|
+
preSendLease: { ...current.preSendLease, status: "consumed" },
|
|
760
|
+
seed: transition.seed,
|
|
761
|
+
};
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
case "accepted": {
|
|
765
|
+
if (
|
|
766
|
+
!matchingAuthority(current, currentAuthority) ||
|
|
767
|
+
current.preSendLease?.status !== "consumed" ||
|
|
768
|
+
!current.seed ||
|
|
769
|
+
!isSeed(transition.seed) ||
|
|
770
|
+
transition.seed.phase !== "accepted" ||
|
|
771
|
+
!sameSeedIdentity(current.seed, transition.seed) ||
|
|
772
|
+
transition.childId !== undefined ||
|
|
773
|
+
transition.authority !== undefined
|
|
774
|
+
)
|
|
775
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
776
|
+
next = { ...current, state: "accepted", seed: transition.seed };
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
case "pre_send_rejected": {
|
|
780
|
+
if (current.state === "dispatching" && transition.provenNoHandoff !== true)
|
|
781
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
782
|
+
if (
|
|
783
|
+
transition.childId !== undefined ||
|
|
784
|
+
transition.authority !== undefined ||
|
|
785
|
+
(transition.failure !== undefined && !isSpawnSubstrateFailure(transition.failure))
|
|
786
|
+
)
|
|
787
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
788
|
+
if (current.seed) {
|
|
789
|
+
if (
|
|
790
|
+
!isSeed(transition.seed) ||
|
|
791
|
+
transition.seed.phase !== "pre_send_rejected" ||
|
|
792
|
+
!sameSeedIdentity(current.seed, transition.seed)
|
|
793
|
+
)
|
|
794
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
795
|
+
next = { ...current, state: "pre_send_rejected", seed: transition.seed, failure: transition.failure };
|
|
796
|
+
} else {
|
|
797
|
+
if (transition.seed !== undefined) throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
798
|
+
next = {
|
|
799
|
+
...current,
|
|
800
|
+
state: "pre_send_rejected",
|
|
801
|
+
...(transition.failure === undefined ? {} : { failure: transition.failure }),
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
case "uncertain": {
|
|
807
|
+
if (transition.childId !== undefined || transition.authority !== undefined)
|
|
808
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
809
|
+
if (current.seed) {
|
|
810
|
+
if (
|
|
811
|
+
!isSeed(transition.seed) ||
|
|
812
|
+
transition.seed.phase !== "uncertain" ||
|
|
813
|
+
!sameSeedIdentity(current.seed, transition.seed)
|
|
814
|
+
)
|
|
815
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
816
|
+
next = { ...current, state: "uncertain", seed: transition.seed };
|
|
817
|
+
} else {
|
|
818
|
+
if (transition.seed !== undefined) throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
819
|
+
next = { ...current, state: "uncertain" };
|
|
820
|
+
}
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
case "closed": {
|
|
824
|
+
if (transition.childId !== undefined || transition.seed !== undefined)
|
|
825
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
826
|
+
if (currentAuthority) {
|
|
827
|
+
const authority = transition.authority;
|
|
828
|
+
if (
|
|
829
|
+
!isSpawnAuthorityV1(authority) ||
|
|
830
|
+
!sameAuthorityBinding(currentAuthority, authority) ||
|
|
831
|
+
authority.closeState !== "closed" ||
|
|
832
|
+
authority.updatedAt <= currentAuthority.updatedAt
|
|
833
|
+
)
|
|
834
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
835
|
+
nextAuthority = authority;
|
|
836
|
+
} else if (transition.authority !== undefined)
|
|
837
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
838
|
+
next = { ...current, state: "closed" };
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
case "prepared":
|
|
842
|
+
throw new SpawnAuthorityTransitionError("illegal_transition");
|
|
843
|
+
}
|
|
844
|
+
next = { ...next, updatedAt: claimTimestampAfter(current.updatedAt) };
|
|
845
|
+
if (!isSpawnClaimV2(next) || (nextAuthority !== undefined && !matchingAuthority(next, nextAuthority)))
|
|
846
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
847
|
+
await this.#append(next, nextAuthority);
|
|
848
|
+
return {
|
|
849
|
+
claim: cloneClaim(next),
|
|
850
|
+
...(nextAuthority === undefined ? {} : { authority: cloneAuthority(nextAuthority)! }),
|
|
851
|
+
};
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** Persists only an exact authority lifecycle update, never a proof replacement. */
|
|
856
|
+
async persistAuthority(
|
|
857
|
+
lifecycleIdentity: string,
|
|
858
|
+
authority: SpawnAuthorityV1,
|
|
859
|
+
): Promise<SpawnAuthorityTransitionResult> {
|
|
860
|
+
return await this.#serial(async () => {
|
|
861
|
+
const current = this.#latest.get(lifecycleIdentity);
|
|
862
|
+
const prior = this.#authorities.get(lifecycleIdentity);
|
|
863
|
+
if (!current || !prior) throw new SpawnAuthorityTransitionError("claim_not_found");
|
|
864
|
+
if (
|
|
865
|
+
!isSpawnAuthorityV1(authority) ||
|
|
866
|
+
!matchingAuthority(current, authority) ||
|
|
867
|
+
!sameAuthorityBinding(prior, authority) ||
|
|
868
|
+
authority.updatedAt <= prior.updatedAt ||
|
|
869
|
+
!this.#allowedAuthorityUpdate(prior, authority)
|
|
870
|
+
)
|
|
871
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
872
|
+
const next = { ...current, updatedAt: claimTimestampAfter(current.updatedAt) };
|
|
873
|
+
await this.#append(next, authority);
|
|
874
|
+
return { claim: cloneClaim(next), authority: cloneAuthority(authority)! };
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** Records an observed Q26 status without reopening or changing a state edge. */
|
|
879
|
+
async persistSeedObservation(
|
|
880
|
+
lifecycleIdentity: string,
|
|
881
|
+
seed: SeedDeliveryV2,
|
|
882
|
+
): Promise<SpawnAuthorityTransitionResult> {
|
|
883
|
+
return await this.#serial(async () => {
|
|
884
|
+
const current = this.#latest.get(lifecycleIdentity);
|
|
885
|
+
const authority = this.#authorities.get(lifecycleIdentity);
|
|
886
|
+
if (!current?.seed || !matchingAuthority(current, authority))
|
|
887
|
+
throw new SpawnAuthorityTransitionError("claim_not_found");
|
|
888
|
+
if (
|
|
889
|
+
!isSeed(seed) ||
|
|
890
|
+
seed.phase !== current.seed.phase ||
|
|
891
|
+
!sameSeedIdentity(current.seed, seed) ||
|
|
892
|
+
(seed.observedAt !== undefined &&
|
|
893
|
+
current.seed.observedAt !== undefined &&
|
|
894
|
+
seed.observedAt < current.seed.observedAt)
|
|
895
|
+
)
|
|
896
|
+
throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
897
|
+
const next = { ...current, seed, updatedAt: claimTimestampAfter(current.updatedAt) };
|
|
898
|
+
if (!isSpawnClaimV2(next)) throw new SpawnAuthorityTransitionError("invalid_transition");
|
|
899
|
+
await this.#append(next, authority);
|
|
900
|
+
return { claim: cloneClaim(next), authority: cloneAuthority(authority)! };
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
async releaseOwner(lifecycleIdentity: string): Promise<void> {
|
|
905
|
+
await this.#serial(async () => this.#active.delete(lifecycleIdentity));
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
#allowedAuthorityUpdate(prior: SpawnAuthorityV1, next: SpawnAuthorityV1): boolean {
|
|
909
|
+
if (next.orphanedAt !== undefined && prior.orphanedAt !== undefined && next.orphanedAt < prior.orphanedAt)
|
|
910
|
+
return false;
|
|
911
|
+
if (
|
|
912
|
+
next.orphanRecoveredAt !== undefined &&
|
|
913
|
+
next.orphanedAt !== undefined &&
|
|
914
|
+
next.orphanRecoveredAt < next.orphanedAt
|
|
915
|
+
)
|
|
916
|
+
return false;
|
|
917
|
+
switch (prior.closeState) {
|
|
918
|
+
case "active":
|
|
919
|
+
return (
|
|
920
|
+
next.closeState === "active" || next.closeState === "close_requested" || next.closeState === "uncertain"
|
|
921
|
+
);
|
|
922
|
+
case "close_requested":
|
|
923
|
+
return (
|
|
924
|
+
next.closeState === "close_requested" || next.closeState === "closed" || next.closeState === "uncertain"
|
|
925
|
+
);
|
|
926
|
+
case "uncertain":
|
|
927
|
+
return next.closeState === "uncertain" || next.closeState === "closed";
|
|
928
|
+
case "closed":
|
|
929
|
+
return false;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
#integrity(claim: SpawnClaimV2, authority?: SpawnAuthorityV1): string {
|
|
934
|
+
return createHmac("sha256", this.#identityKey)
|
|
935
|
+
.update(canonicalJson({ claim, ...(authority === undefined ? {} : { authority }) }))
|
|
936
|
+
.digest("hex");
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
#isStoredClaim(value: unknown): value is StoredClaim {
|
|
940
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
941
|
+
const row = value as Partial<StoredClaim>;
|
|
942
|
+
return (
|
|
943
|
+
Object.keys(row).every(
|
|
944
|
+
key => key === "version" || key === "claim" || key === "authority" || key === "integrity",
|
|
945
|
+
) &&
|
|
946
|
+
row.version === 1 &&
|
|
947
|
+
isSpawnClaimV2(row.claim) &&
|
|
948
|
+
(row.authority === undefined || isSpawnAuthorityV1(row.authority)) &&
|
|
949
|
+
(row.authority === undefined || matchingAuthority(row.claim, row.authority)) &&
|
|
950
|
+
typeof row.integrity === "string" &&
|
|
951
|
+
/^[0-9a-f]{64}$/i.test(row.integrity) &&
|
|
952
|
+
timingSafeEqual(
|
|
953
|
+
Buffer.from(row.integrity, "hex"),
|
|
954
|
+
Buffer.from(this.#integrity(row.claim, row.authority), "hex"),
|
|
955
|
+
)
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
async #append(claim: SpawnClaimV2, authority?: SpawnAuthorityV1): Promise<void> {
|
|
960
|
+
if (!isSpawnClaimV2(claim) || (authority !== undefined && !matchingAuthority(claim, authority)))
|
|
961
|
+
throw new Error("Refusing to persist invalid spawn authority evidence.");
|
|
962
|
+
const write: SpawnAuthorityDurableWrite = { claim, ...(authority === undefined ? {} : { authority }) };
|
|
963
|
+
const row: StoredClaim = { version: 1, ...write, integrity: this.#integrity(claim, authority) };
|
|
964
|
+
// The row is appended before the durability barrier, so a barrier failure
|
|
965
|
+
// can leave a physically written line that in-memory state never accepted.
|
|
966
|
+
// A retry would then append a second row for the same claim and reopen
|
|
967
|
+
// would reject the whole journal as invalid history. Roll back to the
|
|
968
|
+
// pre-write length instead, so a failed append leaves no trace at all.
|
|
969
|
+
let priorSize = 0;
|
|
970
|
+
try {
|
|
971
|
+
priorSize = (await fs.stat(this.#file)).size;
|
|
972
|
+
} catch (error) {
|
|
973
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
974
|
+
}
|
|
975
|
+
const handle = await fs.open(
|
|
976
|
+
this.#file,
|
|
977
|
+
fsSync.constants.O_WRONLY | fsSync.constants.O_APPEND | fsSync.constants.O_CREAT,
|
|
978
|
+
0o600,
|
|
979
|
+
);
|
|
980
|
+
let rollbackFailed = false;
|
|
981
|
+
try {
|
|
982
|
+
try {
|
|
983
|
+
await handle.writeFile(`${canonicalJson(row)}\n`);
|
|
984
|
+
await this.#options.beforeSyncForTest?.(write);
|
|
985
|
+
await handle.sync();
|
|
986
|
+
// The parent-directory barrier belongs INSIDE the rolled-back region:
|
|
987
|
+
// a throw here would otherwise leave a durable row that in-memory
|
|
988
|
+
// state never accepted, and the retry would append a duplicate that
|
|
989
|
+
// makes the journal unreopenable.
|
|
990
|
+
await this.#options.beforeDirectorySyncForTest?.(write);
|
|
991
|
+
const directory = await fs.open(path.dirname(this.#file), fsSync.constants.O_RDONLY);
|
|
992
|
+
try {
|
|
993
|
+
await directory.sync();
|
|
994
|
+
} finally {
|
|
995
|
+
await directory.close();
|
|
996
|
+
}
|
|
997
|
+
} catch (error) {
|
|
998
|
+
try {
|
|
999
|
+
await handle.truncate(priorSize);
|
|
1000
|
+
await handle.sync();
|
|
1001
|
+
} catch {
|
|
1002
|
+
// A failed rollback may leave a partial row behind. Surface that
|
|
1003
|
+
// instead of reporting only the original fault, because the journal
|
|
1004
|
+
// can no longer be assumed clean.
|
|
1005
|
+
rollbackFailed = true;
|
|
1006
|
+
}
|
|
1007
|
+
if (rollbackFailed)
|
|
1008
|
+
throw new Error("Spawn authority journal append failed and its rollback could not be completed.");
|
|
1009
|
+
throw error;
|
|
1010
|
+
}
|
|
1011
|
+
} finally {
|
|
1012
|
+
await handle.close();
|
|
1013
|
+
}
|
|
1014
|
+
this.#latest.set(claim.lifecycleIdentity, claim);
|
|
1015
|
+
if (authority) this.#authorities.set(claim.lifecycleIdentity, authority);
|
|
1016
|
+
else this.#authorities.delete(claim.lifecycleIdentity);
|
|
1017
|
+
await this.#options.afterSyncForTest?.(write);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async #serial<T>(operation: () => Promise<T>): Promise<T> {
|
|
1021
|
+
const prior = this.#tail;
|
|
1022
|
+
const completion = Promise.withResolvers<void>();
|
|
1023
|
+
this.#tail = prior.then(() => completion.promise);
|
|
1024
|
+
await prior;
|
|
1025
|
+
try {
|
|
1026
|
+
return await operation();
|
|
1027
|
+
} finally {
|
|
1028
|
+
completion.resolve();
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|