@zgeoff/atc 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +10 -10
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +98 -10
  4. package/src/agents/build-args-without-flags.ts +25 -0
  5. package/src/agents/build-atc-bridge-files.ts +13 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/build-claude-query-options.ts +72 -0
  8. package/src/agents/build-cli-command.ts +7 -5
  9. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  10. package/src/agents/build-hook-settings.ts +8 -2
  11. package/src/agents/build-restore-mode-args.ts +23 -0
  12. package/src/agents/claude-adapter.ts +112 -18
  13. package/src/agents/claude-effort-levels.ts +5 -0
  14. package/src/agents/codex-adapter.ts +48 -2
  15. package/src/agents/find-claude-permission-mode.ts +24 -0
  16. package/src/agents/find-flag-value.ts +27 -0
  17. package/src/agents/gateway-adapter.ts +83 -13
  18. package/src/agents/grok-adapter.ts +24 -1
  19. package/src/agents/make-claude-headless-runner.ts +46 -0
  20. package/src/agents/plan-pasted-line-input.ts +22 -0
  21. package/src/agents/plan-typed-line-input.ts +8 -0
  22. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  23. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
  24. package/src/agents/write-atc-bridge.ts +2 -6
  25. package/src/cli.ts +23 -9
  26. package/src/client/daemon-client.ts +14 -2
  27. package/src/client/index.ts +50 -1
  28. package/src/client/spawn-picker.ts +27 -4
  29. package/src/client/ui.ts +8 -0
  30. package/src/daemon/build-agent-list.ts +37 -1
  31. package/src/daemon/build-config-revision.ts +27 -0
  32. package/src/daemon/build-execution-targets.ts +73 -0
  33. package/src/daemon/build-fleet-events.ts +2 -1
  34. package/src/daemon/build-imp-provider.ts +68 -0
  35. package/src/daemon/build-payload-hash.ts +31 -0
  36. package/src/daemon/build-report-trail-entry.ts +4 -2
  37. package/src/daemon/build-scoped-context.ts +235 -0
  38. package/src/daemon/build-session-lifecycle.ts +52 -0
  39. package/src/daemon/build-tar-archive.ts +85 -0
  40. package/src/daemon/build-target-access.ts +33 -0
  41. package/src/daemon/build-target-forbidden-error.ts +13 -0
  42. package/src/daemon/build-target-identity.ts +22 -0
  43. package/src/daemon/build-target-list.ts +50 -0
  44. package/src/daemon/daemon-connection.ts +425 -96
  45. package/src/daemon/daemon.ts +730 -96
  46. package/src/daemon/effect-remains-error.ts +13 -0
  47. package/src/daemon/execution-provider.ts +208 -0
  48. package/src/daemon/find-execution-refusal.ts +104 -0
  49. package/src/daemon/hooks.ts +5 -17
  50. package/src/daemon/idempotency-ledger.ts +164 -0
  51. package/src/daemon/imp-client-port.ts +343 -0
  52. package/src/daemon/imp-harness.ts +618 -0
  53. package/src/daemon/imp-port-error.ts +18 -0
  54. package/src/daemon/imp-port.ts +246 -0
  55. package/src/daemon/imp-provider.ts +601 -0
  56. package/src/daemon/is-binding-current.ts +43 -0
  57. package/src/daemon/local-pty-provider.ts +142 -0
  58. package/src/daemon/materialize-workspace.ts +571 -0
  59. package/src/daemon/mint-session-id.ts +5 -5
  60. package/src/daemon/parse-hook-line.ts +31 -0
  61. package/src/daemon/parse-spawn-overrides.ts +94 -0
  62. package/src/daemon/permission-registry.ts +14 -4
  63. package/src/daemon/pick-session-state.ts +15 -0
  64. package/src/daemon/restore-fleet.ts +103 -38
  65. package/src/daemon/screen-model.ts +7 -0
  66. package/src/daemon/session-runtime.ts +10 -0
  67. package/src/daemon/sessions.ts +835 -105
  68. package/src/daemon/start-headless-turn.ts +12 -3
  69. package/src/daemon/start-session-bridge.ts +294 -0
  70. package/src/daemon/target-access.ts +36 -0
  71. package/src/mcp/answer-mcp-request.ts +12 -4
  72. package/src/mcp/answer-rpc-request.ts +28 -1
  73. package/src/mcp/build-principal-caller.ts +15 -0
  74. package/src/mcp/build-spawn-descriptions.ts +45 -0
  75. package/src/mcp/build-tool-list.ts +32 -4
  76. package/src/mcp/mcp-tools.ts +107 -10
  77. package/src/mcp/parse-idempotency-key.ts +29 -0
  78. package/src/mcp/reconnecting-caller.ts +39 -7
  79. package/src/mcp/require-daemon-features.ts +10 -0
  80. package/src/mcp/run-tool.ts +87 -16
  81. package/src/mcp/types.ts +2 -0
  82. package/src/protocol/daemon-error.ts +5 -1
  83. package/src/protocol/daemon-features.ts +35 -0
  84. package/src/protocol/protocol.ts +67 -11
  85. package/src/protocol/request-param-schemas.ts +126 -10
  86. package/src/report.ts +37 -2
  87. package/src/run-bridge-tap.ts +241 -0
  88. package/src/shared/agent-session-id.ts +1 -1
  89. package/src/shared/collect-clean-env.ts +9 -1
  90. package/src/shared/collect-principals.ts +51 -0
  91. package/src/shared/collect-targets.ts +144 -0
  92. package/src/shared/config.ts +139 -14
  93. package/src/shared/daemon-id.ts +8 -0
  94. package/src/shared/format-json-kind.ts +21 -0
  95. package/src/shared/open-bridge-socket.ts +86 -0
  96. package/src/shared/send-bridge-request.ts +47 -0
  97. package/src/shared/sort-json-keys.ts +19 -0
  98. package/src/shared/to-daemon-id.ts +11 -0
  99. package/src/statusline.ts +34 -3
  100. package/src/store/fleet-entry.ts +61 -9
  101. package/src/store/idempotency-record.ts +48 -0
  102. package/src/store/message-owner.ts +1 -1
  103. package/src/store/run-migrations.ts +330 -5
  104. package/src/store/state-store.ts +607 -44
  105. package/src/store/trail-entry.ts +4 -0
  106. package/src/store/workspace-materialization.ts +70 -0
  107. package/src/tap.ts +11 -0
  108. package/src/workspace/check-url-credentials.ts +59 -0
  109. package/src/workspace/check-workspace-completeness.ts +90 -0
  110. package/src/workspace/create-workspace-clone.ts +251 -0
  111. package/src/workspace/normalize-git-url.ts +59 -0
  112. package/src/workspace/read-workspace-tar.ts +38 -0
  113. package/src/workspace/repository-env-vars.ts +22 -0
  114. package/src/workspace/resolve-path-source.ts +170 -0
  115. package/src/workspace/run-git.ts +87 -0
  116. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  117. package/src/workspace/workspace-provenance.ts +11 -0
  118. package/src/workspace/workspace-source.ts +8 -0
  119. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Thrown by an idempotent effect's start when it failed after the effect
3
+ * began and could not confirm taking it back, so the effect may still stand.
4
+ * The ledger keeps the key as outcome_unknown instead of releasing it, and a
5
+ * retry under the key never runs the effect a second time.
6
+ */
7
+ export class EffectRemainsError extends Error {
8
+ constructor(message: string, options?: Readonly<ErrorOptions>) {
9
+ super(message, options);
10
+
11
+ this.name = 'EffectRemainsError';
12
+ }
13
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The host a session's harness runs on: it starts a process in a
3
+ * pseudo-terminal and, as its capabilities declare, unpacks files into its
4
+ * filesystem and runs commands there. The daemon starts every session
5
+ * terminal through this interface, so a new host is a provider, not a
6
+ * change to the session code. The daemon checks a capability before it
7
+ * calls the operation behind it, and a request needing one the provider
8
+ * lacks fails with `unsupported_operation`.
9
+ */
10
+ export interface ExecutionProvider {
11
+ // The provider kind a target's config selects, such as `local-pty`.
12
+ readonly kind: string;
13
+ readonly capabilities: ExecutionCapabilities;
14
+
15
+ // Whether the host is a machine other than the daemon's own. A remote
16
+ // harness's files, transcripts included, live on that machine, and its
17
+ // environment holds only what atc sets for it.
18
+ readonly remote: boolean;
19
+
20
+ // Where a remote host keeps atc's files: the folder each session's own
21
+ // files unpack under, and the atc binary inside the host, null when the
22
+ // host has none. Absent on the daemon's own machine.
23
+ readonly guest?: GuestLayout;
24
+
25
+ // Readies the host a harness is about to start on: a remote host is
26
+ // created when missing, woken when asleep, and held awake while its
27
+ // harnesses run. Rejects with the refusal before any harness starts.
28
+ readonly prepareHost: (request: HostRequest) => Promise<void>;
29
+
30
+ // Starts a process in a pseudo-terminal of the given size, on the host the
31
+ // spec holds, which a prepare readied first.
32
+ readonly spawnHarness: (spec: HarnessSpec) => HarnessHandle;
33
+
34
+ // Unpacks a tar archive into a directory on a host, creating the directory
35
+ // first. The daemon's own machine takes no host.
36
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- archive bytes have no readonly form
37
+ readonly transferArchive: (archive: Uint8Array, dir: string, host?: string) => Promise<void>;
38
+
39
+ // Runs a command to completion and returns what it printed.
40
+ readonly runCommand: (spec: CommandSpec) => Promise<CommandResult>;
41
+
42
+ // Puts a host to sleep with every harness on it kept inside, so a revive
43
+ // finds each one as it was. Rejects with `host_leased` when another owner
44
+ // keeps the host awake, and leaves the host as it was then.
45
+ readonly suspendHost: (host: string) => Promise<void>;
46
+
47
+ // Deletes a host and everything on it, harnesses included. Nothing brings
48
+ // a destroyed host back.
49
+ readonly destroyHost: (host: string) => Promise<void>;
50
+
51
+ // Stops the provider's own background work when the daemon stops, and
52
+ // leaves every remote harness running for the next daemon to find.
53
+ readonly dispose: () => void;
54
+ }
55
+
56
+ /**
57
+ * The host a harness is about to start on, and the daemon that holds it:
58
+ * a remote host is held per daemon, so two daemons never release each
59
+ * other's hold.
60
+ */
61
+ export interface HostRequest {
62
+ readonly host: string;
63
+ readonly daemonID: string;
64
+
65
+ // Whether the harness about to start needs atc inside the host, which a
66
+ // provider that ships its own binary installs when missing.
67
+ readonly installATC?: boolean;
68
+ }
69
+
70
+ export interface GuestLayout {
71
+ readonly dir: string;
72
+ readonly atc: string | null;
73
+ }
74
+
75
+ /**
76
+ * What a provider can do. The terminal capabilities split so a host that can
77
+ * show output but not take input, or not resize, says so; `suspend` and
78
+ * `destroy` cover the lifecycle of a host that outlives its process.
79
+ */
80
+ export interface ExecutionCapabilities {
81
+ // Start a harness in a pseudo-terminal.
82
+ readonly spawn: boolean;
83
+
84
+ // Stream a running harness's output to attached clients.
85
+ readonly attach: boolean;
86
+
87
+ // Write keystrokes to a running harness.
88
+ readonly input: boolean;
89
+
90
+ // Change a running harness's terminal size.
91
+ readonly resize: boolean;
92
+
93
+ // End a running harness.
94
+ readonly kill: boolean;
95
+
96
+ // Unpack an archive into the host's filesystem.
97
+ readonly transfer: boolean;
98
+
99
+ // Run a command on the host to completion.
100
+ readonly run: boolean;
101
+
102
+ // Run an agent turn without a terminal, through the agent's own runner.
103
+ readonly headless: boolean;
104
+
105
+ // Pause the host and resume it later with its state intact.
106
+ readonly suspend: boolean;
107
+
108
+ // Delete the host and everything on it.
109
+ readonly destroy: boolean;
110
+ }
111
+
112
+ export type ExecutionCapability = keyof ExecutionCapabilities;
113
+
114
+ export interface HarnessSpec {
115
+ // The atc session the harness belongs to, and the host it runs on: the
116
+ // session's own id, or its parent's when the two share a host.
117
+ readonly session: string;
118
+ readonly host: string;
119
+ readonly bin: string;
120
+ readonly args: readonly string[];
121
+ readonly cwd: string;
122
+
123
+ // The variables atc sets for the harness. A provider for the daemon's
124
+ // own machine adds the daemon's environment around them; a remote one
125
+ // passes only these and its own.
126
+ readonly env: Readonly<Record<string, string>>;
127
+
128
+ // The variable names the harness goes without even when the daemon's
129
+ // environment holds them, such as a workspace's credential; none of the
130
+ // variables atc sets is withheld.
131
+ readonly withheldEnv?: readonly string[];
132
+ readonly cols: number;
133
+ readonly rows: number;
134
+
135
+ // Takes each connection a process of the harness opens to the daemon. A
136
+ // remote provider relays them from a socket inside the host that serves
137
+ // this harness alone, and points the harness's ATC_SOCKET at it.
138
+ readonly onRelay?: (relay: HarnessRelay) => void;
139
+ }
140
+
141
+ /**
142
+ * One connection from a process inside a harness's host to the daemon, in
143
+ * lines: each line the process writes arrives whole, and each line the
144
+ * daemon writes reaches the process in order.
145
+ */
146
+ export interface HarnessRelay {
147
+ readonly onLine: (listener: (line: string) => void) => void;
148
+ readonly onClose: (listener: () => void) => void;
149
+
150
+ // Settles once the relay has room for more.
151
+ readonly writeLine: (line: string) => Promise<void>;
152
+ readonly close: () => void;
153
+ }
154
+
155
+ /**
156
+ * A running harness. Output and exit arrive through listeners, each
157
+ * subscription detachable on its own.
158
+ */
159
+ export interface HarnessHandle {
160
+ readonly onData: (listener: (data: string) => void) => HarnessSubscription;
161
+ readonly onExit: (listener: (exit: HarnessExit) => void) => HarnessSubscription;
162
+
163
+ // Follows a remote harness's connection: lost and being restored, or
164
+ // restored. A harness on the daemon's own machine has no connection.
165
+ readonly onAttachment?: (
166
+ listener: (attachment: HarnessAttachment) => void,
167
+ ) => HarnessSubscription;
168
+ readonly write: (data: string) => void;
169
+ readonly resize: (cols: number, rows: number) => void;
170
+ readonly kill: () => void;
171
+
172
+ // Stops following the harness and leaves its process running, for a host
173
+ // that keeps it after the daemon lets go; on a host that cannot keep it,
174
+ // the process ends as a kill ends it. No listener fires after a detach.
175
+ readonly detach: () => void;
176
+ }
177
+
178
+ export type HarnessAttachment = 'attached' | 'reattaching';
179
+
180
+ interface HarnessSubscription {
181
+ readonly dispose: () => void;
182
+ }
183
+
184
+ export interface HarnessExit {
185
+ readonly exitCode: number;
186
+
187
+ // Why the harness stopped: its process exited, its host went to sleep
188
+ // with the process inside, or the host lost it without an exit, such as
189
+ // a cold boot. Absent is an exit.
190
+ readonly reason?: 'exited' | 'suspended' | 'ended';
191
+
192
+ // What ended it, for a reason other than an exit.
193
+ readonly detail?: string;
194
+ }
195
+
196
+ export interface CommandSpec {
197
+ readonly argv: readonly string[];
198
+ readonly cwd: string;
199
+
200
+ // The host the command runs on; the daemon's own machine takes none.
201
+ readonly host?: string;
202
+ }
203
+
204
+ export interface CommandResult {
205
+ readonly exitCode: number;
206
+ readonly stdout: string;
207
+ readonly stderr: string;
208
+ }
@@ -0,0 +1,104 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+ import type { TargetConfigError } from '../shared/collect-targets';
3
+ import type { ExecutionTarget } from './build-execution-targets';
4
+ import type { ExecutionCapability } from './execution-provider';
5
+
6
+ // The target a session runs on and the identity it was bound to there; a
7
+ // null identity is a session not yet bound, which binds to the target as
8
+ // it stands now. A null target is a spawn that names none when the config
9
+ // gives no default.
10
+ interface TargetBinding {
11
+ readonly target: string | null;
12
+ readonly targetIdentity: string | null;
13
+ }
14
+
15
+ /**
16
+ * The refusal for running work of the given capability on a session's
17
+ * target, or null when the target serves it. Every path that starts a
18
+ * harness, writes to one, or runs a headless turn asks this first, so no
19
+ * path runs a session anywhere but the target it is bound to:
20
+ *
21
+ * - `target_config_invalid` when the config file exists but cannot be
22
+ * read or parsed, which refuses every target, `local` included; when the
23
+ * config's `targets` map, or the target's own entry, is malformed; and
24
+ * when there is no target because the config gives no default.
25
+ * - `unknown_target` when no target holds the id.
26
+ * - `target_changed` when the target's identity is not the one the session
27
+ * was bound to: the name now holds another provider or other options.
28
+ * - `target_unavailable` when this daemon has no provider of its kind.
29
+ * - `unsupported_operation` when the provider lacks the capability.
30
+ */
31
+ export function findExecutionRefusal(
32
+ targets: ReadonlyMap<string, ExecutionTarget>,
33
+ errors: readonly TargetConfigError[],
34
+ binding: TargetBinding,
35
+ capability: ExecutionCapability,
36
+ ): DaemonError | null {
37
+ const fileError = errors.find((error) => error.scope === 'config');
38
+
39
+ if (fileError !== undefined) {
40
+ return new DaemonError(
41
+ 'target_config_invalid',
42
+ `config file ${fileError.path} cannot be used (${fileError.problem}: ${fileError.detail}), so no session runs on any target, local included. Fix the file and restart the daemon`,
43
+ { problem: fileError.problem, path: fileError.path, detail: fileError.detail },
44
+ );
45
+ }
46
+
47
+ const id = binding.target;
48
+
49
+ if (id === null) {
50
+ const problem =
51
+ errors.find((error) => error.scope !== 'target')?.problem ??
52
+ 'the targets map holds no local target and no defaultTarget is set';
53
+
54
+ return new DaemonError(
55
+ 'target_config_invalid',
56
+ `no default execution target: ${problem}. Name a target on the spawn, or fix targets in config.json and restart the daemon`,
57
+ { problem },
58
+ );
59
+ }
60
+
61
+ const configError = errors.find(
62
+ (error) => error.scope === 'targets' || (error.scope === 'target' && error.target === id),
63
+ );
64
+
65
+ if (configError !== undefined) {
66
+ return new DaemonError(
67
+ 'target_config_invalid',
68
+ `execution target '${id}' cannot be used: ${configError.problem}. Fix targets in config.json and restart the daemon`,
69
+ { target: id, problem: configError.problem },
70
+ );
71
+ }
72
+
73
+ const target = targets.get(id);
74
+
75
+ if (target === undefined) {
76
+ return new DaemonError('unknown_target', `no execution target '${id}'`, { target: id });
77
+ }
78
+
79
+ if (binding.targetIdentity !== null && binding.targetIdentity !== target.identity) {
80
+ return new DaemonError(
81
+ 'target_changed',
82
+ `execution target '${id}' changed since this session started on it (was ${binding.targetIdentity}, now ${target.identity}). Restore the target's earlier config to use the session, or kill it`,
83
+ { target: id, boundIdentity: binding.targetIdentity, currentIdentity: target.identity },
84
+ );
85
+ }
86
+
87
+ if (target.provider === null) {
88
+ return new DaemonError(
89
+ 'target_unavailable',
90
+ `execution target '${id}' needs a '${target.kind}' provider, which this daemon does not have`,
91
+ { target: id, provider: target.kind },
92
+ );
93
+ }
94
+
95
+ if (!target.provider.capabilities[capability]) {
96
+ return new DaemonError(
97
+ 'unsupported_operation',
98
+ `the ${target.provider.kind} execution provider does not support ${capability}`,
99
+ { provider: target.provider.kind, capability },
100
+ );
101
+ }
102
+
103
+ return null;
104
+ }
@@ -1,8 +1,7 @@
1
1
  import { unlinkSync } from 'node:fs';
2
2
  import { socketPath } from '../shared/config';
3
- import { isRecord } from '../shared/report';
4
3
  import type { SessionID } from '../shared/session-id';
5
- import { toSessionID } from '../shared/to-session-id';
4
+ import { parseHookLine } from './parse-hook-line';
6
5
 
7
6
  export interface HookEvent {
8
7
  atcId: SessionID;
@@ -38,22 +37,11 @@ export function startHookServer(onEvent: (e: HookEvent) => void, path: string =
38
37
  continue;
39
38
  }
40
39
 
41
- try {
42
- const parsed: unknown = JSON.parse(line);
40
+ const event = parseHookLine(line);
43
41
 
44
- if (
45
- isRecord(parsed) &&
46
- typeof parsed['atcId'] === 'string' &&
47
- typeof parsed['event'] === 'string' &&
48
- isRecord(parsed['payload'])
49
- ) {
50
- onEvent({
51
- atcId: toSessionID(parsed['atcId']),
52
- event: parsed['event'],
53
- payload: parsed['payload'],
54
- });
55
- }
56
- } catch {}
42
+ if (event !== null) {
43
+ onEvent(event);
44
+ }
57
45
  }
58
46
  },
59
47
  open(socket) {
@@ -0,0 +1,164 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+ import type { EffectTarget, IdempotencyRecord } from '../store/idempotency-record';
3
+ import type { StateStore } from '../store/state-store';
4
+ import { EffectRemainsError } from './effect-remains-error';
5
+
6
+ // A request's idempotency key, the hash of the payload it came with, and
7
+ // the principal the request acts as when it is not the ledger's own.
8
+ export interface KeyedRequest {
9
+ readonly key: string;
10
+ readonly payloadHash: string;
11
+ readonly principal?: string;
12
+ }
13
+
14
+ interface IdempotentCall<T> {
15
+ readonly operation: string;
16
+ readonly keyed: KeyedRequest;
17
+
18
+ // The id the effect runs under, minted before the claim records it.
19
+ readonly effectRef: string;
20
+
21
+ // Starts the effect. A plain throw means nothing took effect, so the claim
22
+ // is dropped for a retry to run fresh; an EffectRemainsError means the
23
+ // effect may still stand, so the claim is kept as outcome_unknown.
24
+ readonly start: () => T | Promise<T>;
25
+
26
+ // Resolves once the effect is durable; the claim completes only after.
27
+ readonly settle: () => Promise<void>;
28
+
29
+ // The answer to a retry of a completed key.
30
+ readonly replay: (record: IdempotencyRecord) => T | Promise<T>;
31
+
32
+ // The target the effect's session is bound to, recorded with the
33
+ // completed key; absent or null for an effect bound to none.
34
+ readonly findEffectTarget?: (result: T) => EffectTarget | null;
35
+ }
36
+
37
+ /**
38
+ * Runs keyed effects at most once per key for each principal: the
39
+ * request's own, else the ledger's. The first
40
+ * request claims the key with its payload's hash and the pre-minted effect
41
+ * id, runs the effect, and completes the key with its answer once the
42
+ * effect is durable. A retry with the same payload replays the completed
43
+ * answer; one with a different payload is `idempotency_conflict`; one whose
44
+ * effect a stopped daemon may or may not have run is `outcome_unknown` with
45
+ * the effect id in `data.effectRef`, and never starts the effect again.
46
+ */
47
+ export class IdempotencyLedger {
48
+ private readonly store: StateStore;
49
+
50
+ private readonly principal: string;
51
+
52
+ // One promise chain per key, so two requests under the same key never
53
+ // interleave their claim and completion on one daemon.
54
+ private readonly locks = new Map<string, Promise<void>>();
55
+
56
+ constructor(store: StateStore, principal: string) {
57
+ this.store = store;
58
+ this.principal = principal;
59
+ }
60
+
61
+ async run<T extends Readonly<Record<string, unknown>>>(call: IdempotentCall<T>): Promise<T> {
62
+ const lockKey = JSON.stringify([
63
+ call.keyed.principal ?? this.principal,
64
+ call.operation,
65
+ call.keyed.key,
66
+ ]);
67
+
68
+ const previous = this.locks.get(lockKey) ?? Promise.resolve();
69
+ const released = Promise.withResolvers<void>();
70
+
71
+ const chained = (async () => {
72
+ await previous;
73
+ await released.promise;
74
+ })();
75
+
76
+ this.locks.set(lockKey, chained);
77
+
78
+ await previous;
79
+
80
+ try {
81
+ return await this.runClaimed(call);
82
+ } finally {
83
+ released.resolve();
84
+
85
+ if (this.locks.get(lockKey) === chained) {
86
+ this.locks.delete(lockKey);
87
+ }
88
+ }
89
+ }
90
+
91
+ private async runClaimed<T extends Readonly<Record<string, unknown>>>(
92
+ call: IdempotentCall<T>,
93
+ ): Promise<T> {
94
+ const id = {
95
+ principal: call.keyed.principal ?? this.principal,
96
+ operation: call.operation,
97
+ key: call.keyed.key,
98
+ };
99
+
100
+ const held = await this.store.claimIdempotencyKey({
101
+ ...id,
102
+ payloadHash: call.keyed.payloadHash,
103
+ effectRef: call.effectRef,
104
+ at: Date.now(),
105
+ });
106
+
107
+ if (held !== null) {
108
+ return answerHeldKey(call, held);
109
+ }
110
+
111
+ let result: T;
112
+
113
+ try {
114
+ result = await call.start();
115
+ } catch (error) {
116
+ if (error instanceof EffectRemainsError) {
117
+ await this.store.updateIdempotencyOutcomeUnknown(id, Date.now());
118
+
119
+ throw new DaemonError(
120
+ 'outcome_unknown',
121
+ `the ${call.operation} under idempotency key '${call.keyed.key}' failed and its effect may still stand; check ${call.effectRef} before retrying under a new key`,
122
+ { effectRef: call.effectRef },
123
+ );
124
+ }
125
+
126
+ await this.store.removeIdempotencyKey(id);
127
+
128
+ throw error;
129
+ }
130
+
131
+ // A settle that fails leaves the claim in progress: the effect started,
132
+ // so the next daemon start marks its outcome unknown rather than letting
133
+ // a retry run it again.
134
+ await call.settle();
135
+
136
+ await this.store.updateIdempotencyCompleted(
137
+ id,
138
+ JSON.stringify(result),
139
+ Date.now(),
140
+ call.findEffectTarget?.(result) ?? null,
141
+ );
142
+
143
+ return result;
144
+ }
145
+ }
146
+
147
+ function answerHeldKey<T>(call: IdempotentCall<T>, held: IdempotencyRecord): T | Promise<T> {
148
+ if (held.payloadHash !== call.keyed.payloadHash) {
149
+ throw new DaemonError(
150
+ 'idempotency_conflict',
151
+ `idempotency key '${held.key}' was first used with a different ${held.operation} payload`,
152
+ );
153
+ }
154
+
155
+ if (held.state === 'completed') {
156
+ return call.replay(held);
157
+ }
158
+
159
+ throw new DaemonError(
160
+ 'outcome_unknown',
161
+ `the ${held.operation} under idempotency key '${held.key}' was interrupted and may or may not have taken effect; check ${held.effectRef} before retrying under a new key`,
162
+ { effectRef: held.effectRef },
163
+ );
164
+ }