@code-yeongyu/senpi-agent-core 2026.10.31-1 → 2026.11.2-1
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/dist/agent-loop.js +26 -5
- package/dist/agent.d.ts +7 -0
- package/dist/agent.js +6 -0
- package/dist/conformance/index.d.ts +6 -0
- package/dist/conformance/index.js +3 -0
- package/dist/conformance/loop-core-manifest.d.ts +14 -0
- package/dist/conformance/loop-core-manifest.js +38 -0
- package/dist/conformance/registry.js +2 -0
- package/dist/execution/ledger-codec.d.ts +5 -0
- package/dist/execution/ledger-codec.js +64 -0
- package/dist/execution/ledger-transition.d.ts +4 -0
- package/dist/execution/ledger-transition.js +139 -0
- package/dist/execution/ledger-types.d.ts +93 -0
- package/dist/execution/ledger-types.js +6 -0
- package/dist/execution/ledger.d.ts +17 -0
- package/dist/execution/ledger.js +101 -0
- package/dist/execution/testing/kill-ledger.d.ts +12 -0
- package/dist/execution/testing/kill-ledger.js +88 -0
- package/dist/execution/testing/ledger-cases.d.ts +6 -0
- package/dist/execution/testing/ledger-cases.js +74 -0
- package/dist/execution/testing/ledger-harness.d.ts +26 -0
- package/dist/execution/testing/ledger-harness.js +58 -0
- package/dist/execution/testing/ledger-recovery-cases.d.ts +6 -0
- package/dist/execution/testing/ledger-recovery-cases.js +108 -0
- package/dist/execution/testing/ledger-registry.d.ts +3 -0
- package/dist/execution/testing/ledger-registry.js +34 -0
- package/dist/execution/tool-host.d.ts +32 -0
- package/dist/execution/tool-host.js +2 -0
- package/dist/types.d.ts +6 -0
- package/package.json +3 -3
package/dist/agent-loop.js
CHANGED
|
@@ -99,7 +99,10 @@ export class StreamStartTimeoutError extends Error {
|
|
|
99
99
|
*/
|
|
100
100
|
async function runLoop(initialContext, newMessages, initialConfig, signal, emit, streamFunction) {
|
|
101
101
|
let currentContext = initialContext;
|
|
102
|
-
let config =
|
|
102
|
+
let config = {
|
|
103
|
+
...initialConfig,
|
|
104
|
+
executionScope: initialConfig.executionScope ?? { id: crypto.randomUUID(), root: "" },
|
|
105
|
+
};
|
|
103
106
|
let firstTurn = true;
|
|
104
107
|
let firstProviderRequest = true;
|
|
105
108
|
let drainedTerminatingQueue;
|
|
@@ -922,10 +925,28 @@ async function executeToolCalls(currentContext, assistantMessage, config, signal
|
|
|
922
925
|
// channel and its result buffered; running it again would duplicate a
|
|
923
926
|
// side-effecting tool.
|
|
924
927
|
const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall" && !isCursorExecResolved(c));
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
928
|
+
const host = config.toolHost ?? {
|
|
929
|
+
execute: async (call, options) => {
|
|
930
|
+
// Only yield when a witness sink is configured and returns a promise: an unconditional `await` adds a
|
|
931
|
+
// microtask per call and reorders lifecycle vs message events against today's fused path (EQ E03).
|
|
932
|
+
const recordWitness = options.recordWitness;
|
|
933
|
+
if (recordWitness) {
|
|
934
|
+
for (const raw of call.calls) {
|
|
935
|
+
const pending = recordWitness({ pipeline: "legacy", toolCallId: raw.id });
|
|
936
|
+
if (pending)
|
|
937
|
+
await pending;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return call.mode === "sequential"
|
|
941
|
+
? executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, options.signal, options.emit)
|
|
942
|
+
: executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, options.signal, options.emit);
|
|
943
|
+
},
|
|
944
|
+
};
|
|
945
|
+
return host.execute({
|
|
946
|
+
scope: config.executionScope ?? { id: crypto.randomUUID(), root: "" },
|
|
947
|
+
calls: toolCalls,
|
|
948
|
+
mode: config.toolExecution ?? "parallel",
|
|
949
|
+
}, { signal, emit, recordWitness: config.onExecutionPipeline });
|
|
929
950
|
}
|
|
930
951
|
async function executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit) {
|
|
931
952
|
const finalizedCalls = [];
|
package/dist/agent.d.ts
CHANGED
|
@@ -37,6 +37,9 @@ export interface AgentOptions {
|
|
|
37
37
|
streamKeepAliveTimeoutMs?: number;
|
|
38
38
|
maxRetryDelayMs?: number;
|
|
39
39
|
toolExecution?: ToolExecutionMode;
|
|
40
|
+
toolHost?: AgentLoopConfig["toolHost"];
|
|
41
|
+
executionScope?: AgentLoopConfig["executionScope"];
|
|
42
|
+
onExecutionPipeline?: AgentLoopConfig["onExecutionPipeline"];
|
|
40
43
|
removedToolHints?: Record<string, string>;
|
|
41
44
|
resolveUnknownToolCall?: AgentLoopConfig["resolveUnknownToolCall"];
|
|
42
45
|
abortServerSideFallback?: boolean;
|
|
@@ -113,6 +116,10 @@ export declare class Agent {
|
|
|
113
116
|
maxRetryDelayMs?: number;
|
|
114
117
|
/** Tool execution strategy for assistant messages that contain multiple tool calls. */
|
|
115
118
|
toolExecution: ToolExecutionMode;
|
|
119
|
+
toolHost?: AgentLoopConfig["toolHost"];
|
|
120
|
+
executionScope: NonNullable<AgentLoopConfig["executionScope"]>;
|
|
121
|
+
getExecutionScope?: () => NonNullable<AgentLoopConfig["executionScope"]>;
|
|
122
|
+
onExecutionPipeline?: AgentLoopConfig["onExecutionPipeline"];
|
|
116
123
|
/** Migration guidance returned when a removed tool name is called. */
|
|
117
124
|
removedToolHints: Record<string, string>;
|
|
118
125
|
/** Optional call-time resolver for tools absent from the request context. */
|
package/dist/agent.js
CHANGED
|
@@ -167,6 +167,9 @@ export class Agent {
|
|
|
167
167
|
this.streamKeepAliveTimeoutMs = runtimeOptions.streamKeepAliveTimeoutMs;
|
|
168
168
|
this.maxRetryDelayMs = runtimeOptions.maxRetryDelayMs;
|
|
169
169
|
this.toolExecution = runtimeOptions.toolExecution ?? "parallel";
|
|
170
|
+
this.toolHost = runtimeOptions.toolHost;
|
|
171
|
+
this.executionScope = runtimeOptions.executionScope ?? { id: crypto.randomUUID(), root: "" };
|
|
172
|
+
this.onExecutionPipeline = runtimeOptions.onExecutionPipeline;
|
|
170
173
|
this.removedToolHints = runtimeOptions.removedToolHints ?? {};
|
|
171
174
|
this.resolveUnknownToolCall = runtimeOptions.resolveUnknownToolCall;
|
|
172
175
|
this.abortServerSideFallback = runtimeOptions.abortServerSideFallback;
|
|
@@ -485,6 +488,9 @@ export class Agent {
|
|
|
485
488
|
abortServerSideFallback: this.abortServerSideFallback,
|
|
486
489
|
cursorExecHandlers: this.cursorExecHandlers,
|
|
487
490
|
toolExecution: this.toolExecution,
|
|
491
|
+
toolHost: this.toolHost,
|
|
492
|
+
executionScope: this.getExecutionScope?.() ?? this.executionScope,
|
|
493
|
+
onExecutionPipeline: this.onExecutionPipeline,
|
|
488
494
|
removedToolHints: this.removedToolHints,
|
|
489
495
|
resolveUnknownToolCall: this.resolveUnknownToolCall,
|
|
490
496
|
beforeToolCall: this.beforeToolCall,
|
|
@@ -39,4 +39,10 @@ export { TRANSPORT_CASES } from "../transport/testing/transport-cases.ts";
|
|
|
39
39
|
export type { CarrierFault, TransportCaseAdapters, TransportConformanceAdapter, WireView, } from "../transport/testing/transport-harness.ts";
|
|
40
40
|
export type { CarrierConnection, CarrierSide, FrameHandler, Transport, TransportClock, TransportFrame, TransportSide, } from "../transport/transport.ts";
|
|
41
41
|
export { TransportClosedError } from "../transport/transport.ts";
|
|
42
|
+
export { createExecutionLedger } from "../execution/ledger.ts";
|
|
43
|
+
export type { ExecutionLedger } from "../execution/ledger.ts";
|
|
44
|
+
export type { ExecutionAdmission, ExecutionCommand, ExecutionEntry, ExecutionEvent, ExecutionKey, ExecutionOutcome, ExecutionProgress, FrozenCall, LedgerReply, } from "../execution/ledger-types.ts";
|
|
45
|
+
export { LEDGER_CASES } from "../execution/testing/ledger-cases.ts";
|
|
46
|
+
export { KILL_LEDGER_CASES, KILL_LEDGER_DECLARATIONS } from "../execution/testing/kill-ledger.ts";
|
|
47
|
+
export type { KillLedgerDeclaration } from "../execution/testing/kill-ledger.ts";
|
|
42
48
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -20,4 +20,7 @@ export { FAULT_PROFILE_NAMES } from "../transport/testing/fault-transport.js";
|
|
|
20
20
|
export { MULTI_CASES } from "../transport/testing/multi-cases.js";
|
|
21
21
|
export { TRANSPORT_CASES } from "../transport/testing/transport-cases.js";
|
|
22
22
|
export { TransportClosedError } from "../transport/transport.js";
|
|
23
|
+
export { createExecutionLedger } from "../execution/ledger.js";
|
|
24
|
+
export { LEDGER_CASES } from "../execution/testing/ledger-cases.js";
|
|
25
|
+
export { KILL_LEDGER_CASES, KILL_LEDGER_DECLARATIONS } from "../execution/testing/kill-ledger.js";
|
|
23
26
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S06: the host-independent loop boundary, relative to packages/agent/src.
|
|
3
|
+
* Every local dependency (including type imports) must also be listed and checked.
|
|
4
|
+
* Host adapters, carriers, persistence implementations and the Node harness stay outside.
|
|
5
|
+
* Extend this closure when adding loop modules; never add a host implementation to it.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LOOP_CORE_MODULES: readonly ["agent.ts", "agent-loop.ts", "assistant-terminal-state.ts", "empty-assistant-recovery.ts", "internal-events.ts", "local-failure.ts", "response-timing.ts", "stream-fn.ts", "stream-keepalive.ts", "tool-arguments.ts", "tool-name-alias.ts", "types.ts", "durable/store.ts", "execution/tool-host.ts", "durable/store-admission.ts", "durable/wake-host.ts", "transport/transport.ts"];
|
|
8
|
+
/**
|
|
9
|
+
* Existing portable model/message utilities and schema types, not provider runtimes.
|
|
10
|
+
* Exact specifiers deliberately exclude compat, OAuth, SDKs and host-specific subpaths.
|
|
11
|
+
* The model implementation enters the loop through StreamFn, not a provider import.
|
|
12
|
+
*/
|
|
13
|
+
export declare const LOOP_CORE_EXTERNAL_MODULES: readonly ["@earendil-works/pi-ai", "@earendil-works/pi-ai/utils/oauth-refresh-error", "@earendil-works/pi-ai/utils/retry", "@earendil-works/pi-ai/utils/tool-name-match", "typebox"];
|
|
14
|
+
//# sourceMappingURL=loop-core-manifest.d.ts.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S06: the host-independent loop boundary, relative to packages/agent/src.
|
|
3
|
+
* Every local dependency (including type imports) must also be listed and checked.
|
|
4
|
+
* Host adapters, carriers, persistence implementations and the Node harness stay outside.
|
|
5
|
+
* Extend this closure when adding loop modules; never add a host implementation to it.
|
|
6
|
+
*/
|
|
7
|
+
export const LOOP_CORE_MODULES = [
|
|
8
|
+
"agent.ts",
|
|
9
|
+
"agent-loop.ts",
|
|
10
|
+
"assistant-terminal-state.ts",
|
|
11
|
+
"empty-assistant-recovery.ts",
|
|
12
|
+
"internal-events.ts",
|
|
13
|
+
"local-failure.ts",
|
|
14
|
+
"response-timing.ts",
|
|
15
|
+
"stream-fn.ts",
|
|
16
|
+
"stream-keepalive.ts",
|
|
17
|
+
"tool-arguments.ts",
|
|
18
|
+
"tool-name-alias.ts",
|
|
19
|
+
"types.ts",
|
|
20
|
+
"durable/store.ts",
|
|
21
|
+
"execution/tool-host.ts",
|
|
22
|
+
"durable/store-admission.ts",
|
|
23
|
+
"durable/wake-host.ts",
|
|
24
|
+
"transport/transport.ts",
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* Existing portable model/message utilities and schema types, not provider runtimes.
|
|
28
|
+
* Exact specifiers deliberately exclude compat, OAuth, SDKs and host-specific subpaths.
|
|
29
|
+
* The model implementation enters the loop through StreamFn, not a provider import.
|
|
30
|
+
*/
|
|
31
|
+
export const LOOP_CORE_EXTERNAL_MODULES = [
|
|
32
|
+
"@earendil-works/pi-ai",
|
|
33
|
+
"@earendil-works/pi-ai/utils/oauth-refresh-error",
|
|
34
|
+
"@earendil-works/pi-ai/utils/retry",
|
|
35
|
+
"@earendil-works/pi-ai/utils/tool-name-match",
|
|
36
|
+
"typebox",
|
|
37
|
+
];
|
|
38
|
+
//# sourceMappingURL=loop-core-manifest.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LEDGER_REGISTRY } from "../execution/testing/ledger-registry.js";
|
|
1
2
|
export const ADAPTER_KINDS = ["store", "wake", "transport", "executor", "host"];
|
|
2
3
|
/**
|
|
3
4
|
* Every case of the durable-core conformance suite (packages/agent/docs/durable-core-adr.md). A change that adds cases
|
|
@@ -34,6 +35,7 @@ const storeCase = (id, impl, expected) => ({
|
|
|
34
35
|
expected,
|
|
35
36
|
});
|
|
36
37
|
export const CONFORMANCE_REGISTRY = [
|
|
38
|
+
...LEDGER_REGISTRY,
|
|
37
39
|
storeCase("STORE-01", STORE_CASES_IMPL, "a commit adopts once, after its batch is durable; every instance sees each batch whole"),
|
|
38
40
|
storeCase("STORE-02", STORE_CASES_IMPL, "a cut at every write point of a commit leaves nothing of it durable, with no adopt and no seq burned"),
|
|
39
41
|
storeCase("STORE-03", STORE_CASES_IMPL, "concurrent admissions run one at a time in admission order; a refused mutation releases the line"),
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { StoreValue } from "../durable/store.ts";
|
|
2
|
+
import type { ExecutionEntry } from "./ledger-types.ts";
|
|
3
|
+
/** Stored JSON crosses its schema boundary once, on read. A corrupt row must never look like a missing execution. */
|
|
4
|
+
export declare function decodeExecution(value: StoreValue): ExecutionEntry;
|
|
5
|
+
//# sourceMappingURL=ledger-codec.d.ts.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
function object(value) {
|
|
2
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
function frozen(value) {
|
|
5
|
+
return (object(value) && typeof value.name === "string" && typeof value.digest === "string" && value.args !== undefined);
|
|
6
|
+
}
|
|
7
|
+
function outcome(value) {
|
|
8
|
+
if (!object(value) || typeof value.hooksRan !== "boolean" || typeof value.uncertain !== "boolean")
|
|
9
|
+
return false;
|
|
10
|
+
switch (value.reason) {
|
|
11
|
+
case "result":
|
|
12
|
+
return value.value !== undefined && value.uncertain === false;
|
|
13
|
+
case "cancelled":
|
|
14
|
+
case "lease_expired":
|
|
15
|
+
case "hand_restarted":
|
|
16
|
+
case "uncertain":
|
|
17
|
+
return true;
|
|
18
|
+
default:
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function progress(value) {
|
|
23
|
+
if (!object(value))
|
|
24
|
+
return false;
|
|
25
|
+
switch (value.state) {
|
|
26
|
+
case "admitted":
|
|
27
|
+
case "preparing":
|
|
28
|
+
case "acked":
|
|
29
|
+
return true;
|
|
30
|
+
case "prepared":
|
|
31
|
+
case "prepared_awaiting_consent":
|
|
32
|
+
case "released":
|
|
33
|
+
case "dispatched":
|
|
34
|
+
return frozen(value.call);
|
|
35
|
+
case "reconciling":
|
|
36
|
+
return frozen(value.call) && typeof value.until === "number" && Number.isFinite(value.until);
|
|
37
|
+
case "settled":
|
|
38
|
+
return outcome(value.outcome);
|
|
39
|
+
default:
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function entry(value) {
|
|
44
|
+
return (object(value) &&
|
|
45
|
+
["issuer", "commandId", "executor", "workspace", "scope", "requestDigest"].every((key) => typeof value[key] === "string") &&
|
|
46
|
+
["generation", "admittedAt", "leaseUntil", "initialLeaseUntil", "renewSeq"].every((key) => typeof value[key] === "number" && Number.isFinite(value[key])) &&
|
|
47
|
+
typeof value.reconcilable === "boolean" &&
|
|
48
|
+
typeof value.effectPossible === "boolean" &&
|
|
49
|
+
typeof value.bodyDispatched === "boolean" &&
|
|
50
|
+
progress(value.progress));
|
|
51
|
+
}
|
|
52
|
+
class ExecutionLedgerCorruptError extends Error {
|
|
53
|
+
constructor() {
|
|
54
|
+
super("invalid durable execution ledger row");
|
|
55
|
+
this.name = "ExecutionLedgerCorruptError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Stored JSON crosses its schema boundary once, on read. A corrupt row must never look like a missing execution. */
|
|
59
|
+
export function decodeExecution(value) {
|
|
60
|
+
if (!entry(value))
|
|
61
|
+
throw new ExecutionLedgerCorruptError();
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=ledger-codec.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type ExecutionCommand, type ExecutionEntry, type LedgerReply } from "./ledger-types.ts";
|
|
2
|
+
/** A body may run only for an applied reply in state `dispatched`, never for an expiry settlement or replay. */
|
|
3
|
+
export declare function transitionExecution(entry: ExecutionEntry, command: ExecutionCommand): LedgerReply;
|
|
4
|
+
//# sourceMappingURL=ledger-transition.d.ts.map
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { applied, MAX_EXECUTION_LIFETIME_MS, RECONCILIATION_GRACE_MS, refused, replayed, } from "./ledger-types.js";
|
|
2
|
+
const advance = (entry, progress) => applied({ ...entry, progress });
|
|
3
|
+
const settle = (entry, reason) => advance(entry, {
|
|
4
|
+
state: "settled",
|
|
5
|
+
outcome: { reason, hooksRan: entry.effectPossible, uncertain: entry.effectPossible || entry.bodyDispatched },
|
|
6
|
+
});
|
|
7
|
+
function authorized(entry, { actor, event }) {
|
|
8
|
+
switch (event.kind) {
|
|
9
|
+
case "cancel":
|
|
10
|
+
return actor.id === entry.issuer || actor.approvedWorkspace === entry.workspace;
|
|
11
|
+
case "consent":
|
|
12
|
+
case "release":
|
|
13
|
+
case "abort":
|
|
14
|
+
case "ack":
|
|
15
|
+
case "renew":
|
|
16
|
+
return actor.id === entry.issuer;
|
|
17
|
+
case "prepare":
|
|
18
|
+
case "effect":
|
|
19
|
+
case "freeze":
|
|
20
|
+
case "dispatch":
|
|
21
|
+
case "result":
|
|
22
|
+
case "expire":
|
|
23
|
+
case "restart":
|
|
24
|
+
return actor.id === entry.executor;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function recover(entry, now) {
|
|
28
|
+
switch (entry.progress.state) {
|
|
29
|
+
case "admitted":
|
|
30
|
+
case "preparing":
|
|
31
|
+
return settle(entry, "hand_restarted");
|
|
32
|
+
case "dispatched":
|
|
33
|
+
return entry.reconcilable
|
|
34
|
+
? advance(entry, {
|
|
35
|
+
state: "reconciling",
|
|
36
|
+
call: entry.progress.call,
|
|
37
|
+
until: Math.min(entry.leaseUntil, now + RECONCILIATION_GRACE_MS),
|
|
38
|
+
})
|
|
39
|
+
: settle(entry, "hand_restarted");
|
|
40
|
+
case "reconciling":
|
|
41
|
+
return now >= entry.progress.until ? settle(entry, "hand_restarted") : replayed(entry);
|
|
42
|
+
case "prepared":
|
|
43
|
+
case "prepared_awaiting_consent":
|
|
44
|
+
case "released":
|
|
45
|
+
case "settled":
|
|
46
|
+
case "acked":
|
|
47
|
+
return replayed(entry);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** A body may run only for an applied reply in state `dispatched`, never for an expiry settlement or replay. */
|
|
51
|
+
export function transitionExecution(entry, command) {
|
|
52
|
+
const { event, now } = command;
|
|
53
|
+
if (command.generation !== entry.generation)
|
|
54
|
+
return refused("stale_generation");
|
|
55
|
+
if (!authorized(entry, command))
|
|
56
|
+
return refused("unauthorized");
|
|
57
|
+
if (!Number.isFinite(now) || now < entry.admittedAt)
|
|
58
|
+
return refused("invalid_time");
|
|
59
|
+
const progress = entry.progress;
|
|
60
|
+
if (progress.state === "acked")
|
|
61
|
+
return event.kind === "ack" ? { status: "replayed", entry } : { status: "result_expired" };
|
|
62
|
+
if (progress.state === "settled") {
|
|
63
|
+
switch (event.kind) {
|
|
64
|
+
case "ack":
|
|
65
|
+
return advance(entry, { state: "acked" });
|
|
66
|
+
case "release":
|
|
67
|
+
case "abort":
|
|
68
|
+
case "consent":
|
|
69
|
+
return refused("terminal");
|
|
70
|
+
default:
|
|
71
|
+
return replayed(entry);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (now >= entry.leaseUntil)
|
|
75
|
+
return settle(entry, "lease_expired");
|
|
76
|
+
if (progress.state === "reconciling" && now >= progress.until)
|
|
77
|
+
return settle(entry, "hand_restarted");
|
|
78
|
+
switch (event.kind) {
|
|
79
|
+
case "prepare":
|
|
80
|
+
return progress.state === "admitted" ? advance(entry, { state: "preparing" }) : replayed(entry);
|
|
81
|
+
case "effect":
|
|
82
|
+
if (entry.effectPossible)
|
|
83
|
+
return replayed(entry);
|
|
84
|
+
return progress.state === "preparing"
|
|
85
|
+
? applied({ ...entry, effectPossible: true })
|
|
86
|
+
: refused("invalid_transition");
|
|
87
|
+
case "freeze":
|
|
88
|
+
if (progress.state === "preparing" && entry.effectPossible) {
|
|
89
|
+
return advance(entry, {
|
|
90
|
+
state: event.awaitingConsent ? "prepared_awaiting_consent" : "prepared",
|
|
91
|
+
call: event.call,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return "call" in progress && progress.call.digest === event.call.digest
|
|
95
|
+
? replayed(entry)
|
|
96
|
+
: refused("invalid_transition");
|
|
97
|
+
case "consent":
|
|
98
|
+
return progress.state === "prepared_awaiting_consent"
|
|
99
|
+
? advance(entry, { state: "prepared", call: progress.call })
|
|
100
|
+
: refused("invalid_transition");
|
|
101
|
+
case "release":
|
|
102
|
+
if (progress.state === "prepared")
|
|
103
|
+
return advance(entry, { state: "released", call: progress.call });
|
|
104
|
+
return progress.state === "released" || entry.bodyDispatched ? replayed(entry) : refused("invalid_transition");
|
|
105
|
+
case "dispatch":
|
|
106
|
+
if (entry.bodyDispatched)
|
|
107
|
+
return replayed(entry);
|
|
108
|
+
return progress.state === "released"
|
|
109
|
+
? applied({ ...entry, bodyDispatched: true, progress: { state: "dispatched", call: progress.call } })
|
|
110
|
+
: refused("invalid_transition");
|
|
111
|
+
case "result":
|
|
112
|
+
if (progress.state !== "dispatched" && progress.state !== "reconciling")
|
|
113
|
+
return refused("invalid_transition");
|
|
114
|
+
return advance(entry, {
|
|
115
|
+
state: "settled",
|
|
116
|
+
outcome: { reason: "result", value: event.value, hooksRan: entry.effectPossible, uncertain: false },
|
|
117
|
+
});
|
|
118
|
+
case "cancel":
|
|
119
|
+
return settle(entry, "cancelled");
|
|
120
|
+
case "abort":
|
|
121
|
+
return entry.bodyDispatched ? refused("invalid_transition") : settle(entry, "cancelled");
|
|
122
|
+
case "ack":
|
|
123
|
+
return refused("invalid_transition");
|
|
124
|
+
case "renew":
|
|
125
|
+
if (event.seq <= entry.renewSeq)
|
|
126
|
+
return replayed(entry);
|
|
127
|
+
if (!Number.isSafeInteger(event.seq) ||
|
|
128
|
+
!Number.isFinite(event.leaseUntil) ||
|
|
129
|
+
event.leaseUntil <= entry.leaseUntil ||
|
|
130
|
+
event.leaseUntil > entry.admittedAt + MAX_EXECUTION_LIFETIME_MS)
|
|
131
|
+
return refused("invalid_renewal");
|
|
132
|
+
return applied({ ...entry, renewSeq: event.seq, leaseUntil: event.leaseUntil });
|
|
133
|
+
case "expire":
|
|
134
|
+
return replayed(entry);
|
|
135
|
+
case "restart":
|
|
136
|
+
return recover(entry, now);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
//# sourceMappingURL=ledger-transition.js.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { StoreValue } from "../durable/store.ts";
|
|
2
|
+
export type ExecutionKey = {
|
|
3
|
+
readonly issuer: string;
|
|
4
|
+
readonly commandId: string;
|
|
5
|
+
};
|
|
6
|
+
export type ExecutionAdmission = ExecutionKey & {
|
|
7
|
+
readonly executor: string;
|
|
8
|
+
readonly workspace: string;
|
|
9
|
+
readonly scope: string;
|
|
10
|
+
readonly generation: number;
|
|
11
|
+
readonly requestDigest: string;
|
|
12
|
+
readonly admittedAt: number;
|
|
13
|
+
readonly leaseUntil: number;
|
|
14
|
+
readonly reconcilable: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type FrozenCall = {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly args: StoreValue;
|
|
19
|
+
readonly digest: string;
|
|
20
|
+
};
|
|
21
|
+
export type ExecutionOutcome = {
|
|
22
|
+
readonly reason: "result";
|
|
23
|
+
readonly value: StoreValue;
|
|
24
|
+
readonly hooksRan: boolean;
|
|
25
|
+
readonly uncertain: false;
|
|
26
|
+
} | {
|
|
27
|
+
readonly reason: "cancelled" | "lease_expired" | "hand_restarted" | "uncertain";
|
|
28
|
+
readonly hooksRan: boolean;
|
|
29
|
+
readonly uncertain: boolean;
|
|
30
|
+
};
|
|
31
|
+
export type ExecutionProgress = {
|
|
32
|
+
readonly state: "admitted" | "preparing";
|
|
33
|
+
} | {
|
|
34
|
+
readonly state: "prepared" | "prepared_awaiting_consent" | "released" | "dispatched";
|
|
35
|
+
readonly call: FrozenCall;
|
|
36
|
+
} | {
|
|
37
|
+
readonly state: "reconciling";
|
|
38
|
+
readonly call: FrozenCall;
|
|
39
|
+
readonly until: number;
|
|
40
|
+
} | {
|
|
41
|
+
readonly state: "settled";
|
|
42
|
+
readonly outcome: ExecutionOutcome;
|
|
43
|
+
} | {
|
|
44
|
+
readonly state: "acked";
|
|
45
|
+
};
|
|
46
|
+
export type ExecutionEntry = ExecutionAdmission & {
|
|
47
|
+
readonly initialLeaseUntil: number;
|
|
48
|
+
readonly renewSeq: number;
|
|
49
|
+
readonly effectPossible: boolean;
|
|
50
|
+
readonly bodyDispatched: boolean;
|
|
51
|
+
readonly progress: ExecutionProgress;
|
|
52
|
+
};
|
|
53
|
+
export type ExecutionEvent = {
|
|
54
|
+
readonly kind: "prepare" | "effect" | "consent" | "release" | "dispatch" | "cancel" | "abort" | "ack" | "expire" | "restart";
|
|
55
|
+
} | {
|
|
56
|
+
readonly kind: "freeze";
|
|
57
|
+
readonly call: FrozenCall;
|
|
58
|
+
readonly awaitingConsent: boolean;
|
|
59
|
+
} | {
|
|
60
|
+
readonly kind: "result";
|
|
61
|
+
readonly value: StoreValue;
|
|
62
|
+
} | {
|
|
63
|
+
readonly kind: "renew";
|
|
64
|
+
readonly seq: number;
|
|
65
|
+
readonly leaseUntil: number;
|
|
66
|
+
};
|
|
67
|
+
export type ExecutionCommand = {
|
|
68
|
+
readonly key: ExecutionKey;
|
|
69
|
+
readonly generation: number;
|
|
70
|
+
/** Verified identity and workspace approval, supplied by the authenticated endpoint, never by a wire body. */
|
|
71
|
+
readonly actor: {
|
|
72
|
+
readonly id: string;
|
|
73
|
+
readonly approvedWorkspace?: string;
|
|
74
|
+
};
|
|
75
|
+
readonly now: number;
|
|
76
|
+
readonly event: ExecutionEvent;
|
|
77
|
+
};
|
|
78
|
+
export type LedgerRefusal = "not_found" | "identity_conflict" | "invalid_admission" | "stale_generation" | "unauthorized" | "invalid_transition" | "invalid_renewal" | "invalid_time" | "terminal";
|
|
79
|
+
export type LedgerReply = {
|
|
80
|
+
readonly status: "applied" | "replayed";
|
|
81
|
+
readonly entry: ExecutionEntry;
|
|
82
|
+
} | {
|
|
83
|
+
readonly status: "refused";
|
|
84
|
+
readonly reason: LedgerRefusal;
|
|
85
|
+
} | {
|
|
86
|
+
readonly status: "result_expired";
|
|
87
|
+
};
|
|
88
|
+
export declare const MAX_EXECUTION_LIFETIME_MS = 86400000;
|
|
89
|
+
export declare const RECONCILIATION_GRACE_MS = 30000;
|
|
90
|
+
export declare const refused: (reason: LedgerRefusal) => LedgerReply;
|
|
91
|
+
export declare const replayed: (entry: ExecutionEntry) => LedgerReply;
|
|
92
|
+
export declare const applied: (entry: ExecutionEntry) => LedgerReply;
|
|
93
|
+
//# sourceMappingURL=ledger-types.d.ts.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const MAX_EXECUTION_LIFETIME_MS = 86_400_000;
|
|
2
|
+
export const RECONCILIATION_GRACE_MS = 30_000;
|
|
3
|
+
export const refused = (reason) => ({ status: "refused", reason });
|
|
4
|
+
export const replayed = (entry) => entry.progress.state === "acked" ? { status: "result_expired" } : { status: "replayed", entry };
|
|
5
|
+
export const applied = (entry) => ({ status: "applied", entry });
|
|
6
|
+
//# sourceMappingURL=ledger-types.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AdmissionLine } from "../durable/store-admission.ts";
|
|
2
|
+
import type { DurableStore } from "../durable/store.ts";
|
|
3
|
+
import { type ExecutionAdmission, type ExecutionCommand, type ExecutionEntry, type ExecutionKey, type LedgerReply } from "./ledger-types.ts";
|
|
4
|
+
export interface ExecutionLedger {
|
|
5
|
+
admit(input: ExecutionAdmission): Promise<LedgerReply>;
|
|
6
|
+
apply(command: ExecutionCommand): Promise<LedgerReply>;
|
|
7
|
+
read(key: ExecutionKey): Promise<ExecutionEntry | undefined>;
|
|
8
|
+
/** Called once on endpoint restart; also expires overdue leases and reconciliation deadlines. */
|
|
9
|
+
recover(now: number): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
/** All ledgers sharing a writable store epoch must share its single admission line. */
|
|
12
|
+
export declare function createExecutionLedger(options: {
|
|
13
|
+
readonly store: DurableStore;
|
|
14
|
+
readonly epoch: number;
|
|
15
|
+
readonly admission: AdmissionLine;
|
|
16
|
+
}): ExecutionLedger;
|
|
17
|
+
//# sourceMappingURL=ledger.d.ts.map
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { decodeExecution } from "./ledger-codec.js";
|
|
2
|
+
import { transitionExecution } from "./ledger-transition.js";
|
|
3
|
+
import { applied, MAX_EXECUTION_LIFETIME_MS, refused, replayed, } from "./ledger-types.js";
|
|
4
|
+
const PREFIX = "execution.ledger:";
|
|
5
|
+
const keyOf = ({ issuer, commandId }) => `${PREFIX}${JSON.stringify([issuer, commandId])}`;
|
|
6
|
+
/** All ledgers sharing a writable store epoch must share its single admission line. */
|
|
7
|
+
export function createExecutionLedger(options) {
|
|
8
|
+
const { store, epoch, admission } = options;
|
|
9
|
+
const read = async (key) => {
|
|
10
|
+
const value = await store.getDocument(keyOf(key));
|
|
11
|
+
return value === undefined ? undefined : decodeExecution(value);
|
|
12
|
+
};
|
|
13
|
+
const persist = async (entry, event, audit = false) => {
|
|
14
|
+
const writes = [
|
|
15
|
+
{
|
|
16
|
+
op: "append",
|
|
17
|
+
id: store.mintId(),
|
|
18
|
+
kind: audit ? "execution.audit" : "execution.transition",
|
|
19
|
+
data: {
|
|
20
|
+
issuer: entry.issuer,
|
|
21
|
+
commandId: entry.commandId,
|
|
22
|
+
generation: entry.generation,
|
|
23
|
+
event,
|
|
24
|
+
state: entry.progress.state,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
if (!audit)
|
|
29
|
+
writes.push({ op: "put", key: keyOf(entry), data: entry });
|
|
30
|
+
await store.commit(epoch, writes, () => { });
|
|
31
|
+
};
|
|
32
|
+
const apply = (command) => admission.admit(async () => {
|
|
33
|
+
const entry = await read(command.key);
|
|
34
|
+
if (entry === undefined)
|
|
35
|
+
return refused("not_found");
|
|
36
|
+
const reply = transitionExecution(entry, command);
|
|
37
|
+
if (reply.status === "applied")
|
|
38
|
+
await persist(reply.entry, command.event.kind);
|
|
39
|
+
else if (reply.status === "replayed" &&
|
|
40
|
+
entry.progress.state === "settled" &&
|
|
41
|
+
(command.event.kind === "result" || command.event.kind === "cancel"))
|
|
42
|
+
await persist(entry, command.event.kind, true);
|
|
43
|
+
return reply;
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
read,
|
|
47
|
+
apply,
|
|
48
|
+
admit: (input) => admission.admit(async () => {
|
|
49
|
+
const prior = await read(input);
|
|
50
|
+
if (prior !== undefined) {
|
|
51
|
+
const same = prior.executor === input.executor &&
|
|
52
|
+
prior.workspace === input.workspace &&
|
|
53
|
+
prior.scope === input.scope &&
|
|
54
|
+
prior.generation === input.generation &&
|
|
55
|
+
prior.requestDigest === input.requestDigest &&
|
|
56
|
+
prior.admittedAt === input.admittedAt &&
|
|
57
|
+
prior.initialLeaseUntil === input.leaseUntil &&
|
|
58
|
+
prior.reconcilable === input.reconcilable;
|
|
59
|
+
return same ? replayed(prior) : refused("identity_conflict");
|
|
60
|
+
}
|
|
61
|
+
if (![
|
|
62
|
+
input.issuer,
|
|
63
|
+
input.commandId,
|
|
64
|
+
input.executor,
|
|
65
|
+
input.workspace,
|
|
66
|
+
input.scope,
|
|
67
|
+
input.requestDigest,
|
|
68
|
+
].every(Boolean) ||
|
|
69
|
+
!Number.isSafeInteger(input.generation) ||
|
|
70
|
+
input.generation < 1 ||
|
|
71
|
+
!Number.isFinite(input.admittedAt) ||
|
|
72
|
+
!Number.isFinite(input.leaseUntil) ||
|
|
73
|
+
input.leaseUntil <= input.admittedAt ||
|
|
74
|
+
input.leaseUntil > input.admittedAt + MAX_EXECUTION_LIFETIME_MS)
|
|
75
|
+
return refused("invalid_admission");
|
|
76
|
+
const entry = {
|
|
77
|
+
...input,
|
|
78
|
+
initialLeaseUntil: input.leaseUntil,
|
|
79
|
+
renewSeq: 0,
|
|
80
|
+
effectPossible: false,
|
|
81
|
+
bodyDispatched: false,
|
|
82
|
+
progress: { state: "admitted" },
|
|
83
|
+
};
|
|
84
|
+
await persist(entry, "admit");
|
|
85
|
+
return applied(entry);
|
|
86
|
+
}),
|
|
87
|
+
recover: async (now) => {
|
|
88
|
+
for (const document of await store.scanDocuments(PREFIX)) {
|
|
89
|
+
const entry = decodeExecution(document.data);
|
|
90
|
+
await apply({
|
|
91
|
+
key: entry,
|
|
92
|
+
generation: entry.generation,
|
|
93
|
+
actor: { id: entry.executor },
|
|
94
|
+
now,
|
|
95
|
+
event: { kind: "restart" },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=ledger.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CaseImplementation } from "../../conformance/runner.ts";
|
|
2
|
+
import type { StoreWritePoint } from "../../durable/store.ts";
|
|
3
|
+
import { type StoreCaseAdapters } from "../../durable/testing/store-harness.ts";
|
|
4
|
+
export interface KillLedgerDeclaration {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly operation: string;
|
|
7
|
+
readonly point: StoreWritePoint;
|
|
8
|
+
readonly placement: "everywhere";
|
|
9
|
+
}
|
|
10
|
+
export declare const KILL_LEDGER_DECLARATIONS: readonly KillLedgerDeclaration[];
|
|
11
|
+
export declare const KILL_LEDGER_CASES: ReadonlyMap<string, CaseImplementation<StoreCaseAdapters>>;
|
|
12
|
+
//# sourceMappingURL=kill-ledger.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { claimedStore } from "../../durable/testing/store-cases.js";
|
|
2
|
+
import { cutOnceAt, expectCut, expectEqual } from "../../durable/testing/store-harness.js";
|
|
3
|
+
import { ADMISSION, command, DISPATCH, FROZEN, ledgerOn, PREPARE, runEvents } from "./ledger-harness.js";
|
|
4
|
+
const TRANSITIONS = [
|
|
5
|
+
{ name: "admit", before: [], expected: "admitted" },
|
|
6
|
+
{ name: "prepare", before: [], event: { kind: "prepare" }, expected: "preparing" },
|
|
7
|
+
{ name: "effect", before: PREPARE.slice(0, 1), event: { kind: "effect" }, expected: "preparing" },
|
|
8
|
+
{
|
|
9
|
+
name: "freeze",
|
|
10
|
+
before: PREPARE.slice(0, 2),
|
|
11
|
+
event: { kind: "freeze", call: FROZEN, awaitingConsent: false },
|
|
12
|
+
expected: "prepared",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "await-consent",
|
|
16
|
+
before: PREPARE.slice(0, 2),
|
|
17
|
+
event: { kind: "freeze", call: FROZEN, awaitingConsent: true },
|
|
18
|
+
expected: "prepared_awaiting_consent",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "consent",
|
|
22
|
+
before: [...PREPARE.slice(0, 2), { kind: "freeze", call: FROZEN, awaitingConsent: true }],
|
|
23
|
+
event: { kind: "consent" },
|
|
24
|
+
expected: "prepared",
|
|
25
|
+
},
|
|
26
|
+
{ name: "release", before: PREPARE, event: { kind: "release" }, expected: "released" },
|
|
27
|
+
{ name: "dispatch", before: DISPATCH.slice(0, 4), event: { kind: "dispatch" }, expected: "dispatched" },
|
|
28
|
+
{ name: "result", before: DISPATCH, event: { kind: "result", value: "done" }, expected: "settled" },
|
|
29
|
+
{ name: "cancel", before: DISPATCH, event: { kind: "cancel" }, expected: "settled" },
|
|
30
|
+
{ name: "abort", before: PREPARE, event: { kind: "abort" }, expected: "settled" },
|
|
31
|
+
{ name: "renew", before: [], event: { kind: "renew", seq: 1, leaseUntil: 70_100 }, expected: "admitted" },
|
|
32
|
+
{ name: "expire", before: PREPARE, event: { kind: "expire" }, now: 60_100, expected: "settled" },
|
|
33
|
+
{ name: "restart", before: PREPARE.slice(0, 2), event: { kind: "restart" }, expected: "settled" },
|
|
34
|
+
{ name: "reconcile", before: DISPATCH, event: { kind: "restart" }, reconcilable: true, expected: "reconciling" },
|
|
35
|
+
{ name: "ack", before: [...DISPATCH, { kind: "result", value: "done" }], event: { kind: "ack" }, expected: "acked" },
|
|
36
|
+
];
|
|
37
|
+
const POINTS = [
|
|
38
|
+
{ op: "commit", at: "before" },
|
|
39
|
+
{ op: "commit", at: "write", index: 0 },
|
|
40
|
+
{ op: "commit", at: "write", index: 1 },
|
|
41
|
+
{ op: "commit", at: "durable" },
|
|
42
|
+
];
|
|
43
|
+
const cases = TRANSITIONS.flatMap((transition, index) => POINTS.map((point, cutIndex) => {
|
|
44
|
+
const id = `KILL-LEDGER-${String(index + 1).padStart(2, "0")}-${cutIndex + 1}`;
|
|
45
|
+
const run = async ({ store: adapter }) => {
|
|
46
|
+
const { medium, store } = await claimedStore(adapter);
|
|
47
|
+
const ledger = ledgerOn(store);
|
|
48
|
+
const input = { ...ADMISSION, reconcilable: transition.reconcilable ?? false };
|
|
49
|
+
if (transition.event !== undefined)
|
|
50
|
+
await ledger.admit(input);
|
|
51
|
+
await runEvents(ledger, transition.before);
|
|
52
|
+
const baseline = await store.exportSnapshot();
|
|
53
|
+
const cut = ledgerOn(await adapter.open(medium, cutOnceAt(point)));
|
|
54
|
+
await expectCut(transition.event === undefined ? cut.admit(input) : cut.apply(command(transition.event, transition.now)), `${transition.name}/${point.at}`);
|
|
55
|
+
const reopenedStore = await adapter.open(medium);
|
|
56
|
+
const reopened = ledgerOn(reopenedStore);
|
|
57
|
+
const snapshot = await reopenedStore.exportSnapshot();
|
|
58
|
+
if (point.at !== "durable") {
|
|
59
|
+
expectEqual(snapshot, baseline, "a cut before durable changes neither journal nor row");
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
expectEqual(snapshot.head, baseline.head + 1, "exactly one durable transition");
|
|
63
|
+
const entry = await reopened.read(ADMISSION);
|
|
64
|
+
expectEqual(entry?.progress.state, transition.expected, "the complete endpoint transition survived");
|
|
65
|
+
if (transition.name === "effect")
|
|
66
|
+
expectEqual(entry?.effectPossible, true, "effect permission durable before callback");
|
|
67
|
+
if (transition.name === "dispatch") {
|
|
68
|
+
expectEqual(entry?.bodyDispatched, true, "dispatch permission is durable before body");
|
|
69
|
+
expectEqual((await reopened.apply(command({ kind: "dispatch" }))).status, "replayed", "no second dispatch after crash");
|
|
70
|
+
}
|
|
71
|
+
if (transition.name === "renew")
|
|
72
|
+
expectEqual(entry?.renewSeq, 1, "renewal sequence survives");
|
|
73
|
+
}
|
|
74
|
+
await reopened.recover(transition.now ?? 102);
|
|
75
|
+
const recovered = await reopened.read(ADMISSION);
|
|
76
|
+
if (recovered?.progress.state === "preparing" || recovered?.progress.state === "dispatched") {
|
|
77
|
+
throw new Error("restart left a non-resumable execution live");
|
|
78
|
+
}
|
|
79
|
+
if (recovered !== undefined) {
|
|
80
|
+
const replay = await reopened.admit(input);
|
|
81
|
+
expectEqual(replay.status, recovered.progress.state === "acked" ? "result_expired" : "replayed", "identity survives restart");
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
return { id, operation: transition.name, point, placement: "everywhere", run };
|
|
85
|
+
}));
|
|
86
|
+
export const KILL_LEDGER_DECLARATIONS = cases.map(({ run: _run, ...entry }) => entry);
|
|
87
|
+
export const KILL_LEDGER_CASES = new Map(cases.map(({ id, run }) => [id, run]));
|
|
88
|
+
//# sourceMappingURL=kill-ledger.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CaseImplementation } from "../../conformance/runner.ts";
|
|
2
|
+
import { type StoreCaseAdapters } from "../../durable/testing/store-harness.ts";
|
|
3
|
+
type LedgerCase = CaseImplementation<StoreCaseAdapters>;
|
|
4
|
+
export declare const LEDGER_CASES: ReadonlyMap<string, LedgerCase>;
|
|
5
|
+
export {};
|
|
6
|
+
//# sourceMappingURL=ledger-cases.d.ts.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { expectEqual } from "../../durable/testing/store-harness.js";
|
|
2
|
+
import { ADMISSION, command, DISPATCH, fixture, FROZEN, PREPARE, row, runEvents } from "./ledger-harness.js";
|
|
3
|
+
import { LEDGER_RECOVERY_CASES } from "./ledger-recovery-cases.js";
|
|
4
|
+
const identity = async ({ store }) => {
|
|
5
|
+
const { ledger } = await fixture(store);
|
|
6
|
+
expectEqual((await ledger.admit(ADMISSION)).status, "replayed", "same identity");
|
|
7
|
+
for (const change of [{ scope: "other" }, { generation: 2 }, { requestDigest: "changed" }, { executor: "other" }]) {
|
|
8
|
+
expectEqual(await ledger.admit({ ...ADMISSION, ...change }), { status: "refused", reason: "identity_conflict" }, "binding");
|
|
9
|
+
}
|
|
10
|
+
expectEqual((await ledger.admit({ ...ADMISSION, issuer: "another" })).status, "applied", "issuer namespaces identity");
|
|
11
|
+
expectEqual(await ledger.apply({ ...command({ kind: "prepare" }), generation: 2 }), { status: "refused", reason: "stale_generation" }, "generation fence");
|
|
12
|
+
};
|
|
13
|
+
const preparation = async ({ store }) => {
|
|
14
|
+
const { ledger, reopen } = await fixture(store);
|
|
15
|
+
expectEqual(await ledger.apply(command({ kind: "effect" })), { status: "refused", reason: "invalid_transition" }, "no effects before prepare");
|
|
16
|
+
await ledger.apply(command({ kind: "prepare" }));
|
|
17
|
+
expectEqual(await ledger.apply(command({ kind: "freeze", call: FROZEN, awaitingConsent: false })), { status: "refused", reason: "invalid_transition" }, "freeze requires effect marker");
|
|
18
|
+
await ledger.apply(command({ kind: "effect" }));
|
|
19
|
+
expectEqual((await row(await reopen())).effectPossible, true, "effect marker survives reopen before a callback may run");
|
|
20
|
+
expectEqual((await ledger.apply(command({ kind: "effect" }))).status, "replayed", "effects never granted twice");
|
|
21
|
+
await ledger.apply(command({ kind: "freeze", call: FROZEN, awaitingConsent: false }));
|
|
22
|
+
expectEqual((await row(await reopen())).progress, { state: "prepared", call: FROZEN }, "frozen bytes survive reopen");
|
|
23
|
+
};
|
|
24
|
+
const consent = async ({ store }) => {
|
|
25
|
+
const { ledger } = await fixture(store);
|
|
26
|
+
await runEvents(ledger, [...PREPARE.slice(0, 2), { kind: "freeze", call: FROZEN, awaitingConsent: true }]);
|
|
27
|
+
expectEqual(await ledger.apply(command({ kind: "release" })), { status: "refused", reason: "invalid_transition" }, "consent wait cannot release");
|
|
28
|
+
expectEqual(await ledger.apply({ ...command({ kind: "consent" }), actor: { id: "other" } }), { status: "refused", reason: "unauthorized" }, "consent issuer");
|
|
29
|
+
await runEvents(ledger, [{ kind: "consent" }, { kind: "release" }, { kind: "dispatch" }]);
|
|
30
|
+
expectEqual((await row(ledger)).progress, { state: "dispatched", call: FROZEN }, "consent releases the frozen call without preparation");
|
|
31
|
+
};
|
|
32
|
+
const dispatch = async ({ store }) => {
|
|
33
|
+
const { ledger, reopen } = await fixture(store);
|
|
34
|
+
expectEqual(await ledger.apply(command({ kind: "dispatch" })), { status: "refused", reason: "invalid_transition" }, "no dispatch without release");
|
|
35
|
+
await runEvents(ledger, DISPATCH);
|
|
36
|
+
expectEqual((await (await reopen()).apply(command({ kind: "dispatch" }))).status, "replayed", "durable dispatch is never granted twice");
|
|
37
|
+
expectEqual((await ledger.admit(ADMISSION)).status, "replayed", "invoke replay");
|
|
38
|
+
};
|
|
39
|
+
const cancelFirst = async ({ store }) => {
|
|
40
|
+
for (const before of [[], PREPARE.slice(0, 2), DISPATCH]) {
|
|
41
|
+
const { ledger, store: durable, reopen } = await fixture(store);
|
|
42
|
+
await runEvents(ledger, before);
|
|
43
|
+
await ledger.apply(command({ kind: "cancel" }));
|
|
44
|
+
const terminal = await row(ledger);
|
|
45
|
+
expectEqual(terminal.progress, {
|
|
46
|
+
state: "settled",
|
|
47
|
+
outcome: { reason: "cancelled", hooksRan: before.length > 0, uncertain: before.length > 0 },
|
|
48
|
+
}, "cancel projection follows the effect boundary");
|
|
49
|
+
await ledger.apply(command({ kind: "result", value: "late" }));
|
|
50
|
+
expectEqual(await row(await reopen()), terminal, "late result cannot replace cancellation");
|
|
51
|
+
expectEqual((await durable.scanRecords({ kind: "execution.audit" })).length, 1, "late result is audited");
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const resultFirst = async ({ store }) => {
|
|
55
|
+
const { ledger, reopen } = await fixture(store);
|
|
56
|
+
await runEvents(ledger, DISPATCH);
|
|
57
|
+
await ledger.apply(command({ kind: "result", value: { content: "done" } }));
|
|
58
|
+
const terminal = await row(ledger);
|
|
59
|
+
await ledger.apply(command({ kind: "cancel" }));
|
|
60
|
+
expectEqual(await row(await reopen()), terminal, "result-first is immutable");
|
|
61
|
+
for (const kind of ["release", "abort", "consent"]) {
|
|
62
|
+
expectEqual(await ledger.apply(command({ kind })), { status: "refused", reason: "terminal" }, `${kind} on terminal`);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
export const LEDGER_CASES = new Map([
|
|
66
|
+
["LEDGER-01", identity],
|
|
67
|
+
["LEDGER-02", preparation],
|
|
68
|
+
["LEDGER-03", consent],
|
|
69
|
+
["LEDGER-04", dispatch],
|
|
70
|
+
["LEDGER-05", cancelFirst],
|
|
71
|
+
["LEDGER-06", resultFirst],
|
|
72
|
+
...LEDGER_RECOVERY_CASES,
|
|
73
|
+
]);
|
|
74
|
+
//# sourceMappingURL=ledger-cases.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { DurableStore, DurableStoreOptions } from "../../durable/store.ts";
|
|
2
|
+
import { type StoreConformanceAdapter } from "../../durable/testing/store-harness.ts";
|
|
3
|
+
import { type ExecutionLedger } from "../ledger.ts";
|
|
4
|
+
import type { ExecutionAdmission, ExecutionCommand, ExecutionEntry, ExecutionEvent, LedgerReply } from "../ledger-types.ts";
|
|
5
|
+
export declare const ADMISSION: ExecutionAdmission;
|
|
6
|
+
export declare const FROZEN: {
|
|
7
|
+
readonly name: "read";
|
|
8
|
+
readonly args: {
|
|
9
|
+
readonly path: "a";
|
|
10
|
+
};
|
|
11
|
+
readonly digest: "frozen";
|
|
12
|
+
};
|
|
13
|
+
export declare const PREPARE: readonly ExecutionEvent[];
|
|
14
|
+
export declare const DISPATCH: readonly ExecutionEvent[];
|
|
15
|
+
export declare function command(event: ExecutionEvent, now?: number): ExecutionCommand;
|
|
16
|
+
export declare function accepted(reply: LedgerReply): ExecutionEntry;
|
|
17
|
+
export declare function ledgerOn(store: DurableStore, epoch?: number): ExecutionLedger;
|
|
18
|
+
export declare function fixture(adapter: StoreConformanceAdapter, input?: ExecutionAdmission): Promise<{
|
|
19
|
+
medium: string;
|
|
20
|
+
store: DurableStore;
|
|
21
|
+
ledger: ExecutionLedger;
|
|
22
|
+
reopen: (options?: DurableStoreOptions) => Promise<ExecutionLedger>;
|
|
23
|
+
}>;
|
|
24
|
+
export declare function runEvents(ledger: ExecutionLedger, events: readonly ExecutionEvent[]): Promise<void>;
|
|
25
|
+
export declare function row(ledger: ExecutionLedger): Promise<ExecutionEntry>;
|
|
26
|
+
//# sourceMappingURL=ledger-harness.d.ts.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createAdmissionLine } from "../../durable/store-admission.js";
|
|
2
|
+
import { claimedStore } from "../../durable/testing/store-cases.js";
|
|
3
|
+
import { expectEqual } from "../../durable/testing/store-harness.js";
|
|
4
|
+
import { createExecutionLedger } from "../ledger.js";
|
|
5
|
+
export const ADMISSION = {
|
|
6
|
+
issuer: "brain",
|
|
7
|
+
commandId: "call",
|
|
8
|
+
executor: "hand",
|
|
9
|
+
workspace: "workspace",
|
|
10
|
+
scope: "scope",
|
|
11
|
+
generation: 1,
|
|
12
|
+
requestDigest: "raw",
|
|
13
|
+
admittedAt: 100,
|
|
14
|
+
leaseUntil: 60_100,
|
|
15
|
+
reconcilable: false,
|
|
16
|
+
};
|
|
17
|
+
export const FROZEN = { name: "read", args: { path: "a" }, digest: "frozen" };
|
|
18
|
+
export const PREPARE = [
|
|
19
|
+
{ kind: "prepare" },
|
|
20
|
+
{ kind: "effect" },
|
|
21
|
+
{ kind: "freeze", call: FROZEN, awaitingConsent: false },
|
|
22
|
+
];
|
|
23
|
+
export const DISPATCH = [...PREPARE, { kind: "release" }, { kind: "dispatch" }];
|
|
24
|
+
export function command(event, now = 101) {
|
|
25
|
+
const issuer = ["consent", "release", "abort", "ack", "renew", "cancel"].includes(event.kind);
|
|
26
|
+
return { key: ADMISSION, generation: 1, actor: { id: issuer ? "brain" : "hand" }, now, event };
|
|
27
|
+
}
|
|
28
|
+
export function accepted(reply) {
|
|
29
|
+
if (reply.status !== "applied" && reply.status !== "replayed") {
|
|
30
|
+
throw new Error(`expected an execution row, got ${JSON.stringify(reply)}`);
|
|
31
|
+
}
|
|
32
|
+
return reply.entry;
|
|
33
|
+
}
|
|
34
|
+
export function ledgerOn(store, epoch = 1) {
|
|
35
|
+
return createExecutionLedger({ store, epoch, admission: createAdmissionLine() });
|
|
36
|
+
}
|
|
37
|
+
export async function fixture(adapter, input = ADMISSION) {
|
|
38
|
+
const { medium, store } = await claimedStore(adapter);
|
|
39
|
+
const ledger = ledgerOn(store);
|
|
40
|
+
expectEqual((await ledger.admit(input)).status, "applied", "admitted");
|
|
41
|
+
return {
|
|
42
|
+
medium,
|
|
43
|
+
store,
|
|
44
|
+
ledger,
|
|
45
|
+
reopen: async (options) => ledgerOn(await adapter.open(medium, options)),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export async function runEvents(ledger, events) {
|
|
49
|
+
for (const event of events)
|
|
50
|
+
expectEqual((await ledger.apply(command(event))).status, "applied", event.kind);
|
|
51
|
+
}
|
|
52
|
+
export async function row(ledger) {
|
|
53
|
+
const entry = await ledger.read(ADMISSION);
|
|
54
|
+
if (entry === undefined)
|
|
55
|
+
throw new Error("the admitted execution disappeared");
|
|
56
|
+
return entry;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=ledger-harness.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CaseImplementation } from "../../conformance/runner.ts";
|
|
2
|
+
import { type StoreCaseAdapters } from "../../durable/testing/store-harness.ts";
|
|
3
|
+
type LedgerCase = CaseImplementation<StoreCaseAdapters>;
|
|
4
|
+
export declare const LEDGER_RECOVERY_CASES: ReadonlyMap<string, LedgerCase>;
|
|
5
|
+
export {};
|
|
6
|
+
//# sourceMappingURL=ledger-recovery-cases.d.ts.map
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { expectEqual, expectStoreError } from "../../durable/testing/store-harness.js";
|
|
2
|
+
import { MAX_EXECUTION_LIFETIME_MS } from "../ledger-types.js";
|
|
3
|
+
import { accepted, ADMISSION, command, DISPATCH, fixture, FROZEN, ledgerOn, PREPARE, row, runEvents, } from "./ledger-harness.js";
|
|
4
|
+
const renewal = async ({ store }) => {
|
|
5
|
+
const { ledger } = await fixture(store);
|
|
6
|
+
const renew = { kind: "renew", seq: 1, leaseUntil: 100_000 };
|
|
7
|
+
expectEqual(await ledger.apply({ ...command(renew), actor: { id: "hand" } }), { status: "refused", reason: "unauthorized" }, "issuer-only renewal");
|
|
8
|
+
expectEqual((await ledger.apply(command(renew))).status, "applied", "renewal starts at admission");
|
|
9
|
+
expectEqual((await ledger.apply(command(renew))).status, "replayed", "duplicate renewal");
|
|
10
|
+
expectEqual(await ledger.apply(command({ ...renew, seq: 2, leaseUntil: ADMISSION.admittedAt + MAX_EXECUTION_LIFETIME_MS + 1 })), { status: "refused", reason: "invalid_renewal" }, "24-hour cap");
|
|
11
|
+
expectEqual(await ledger.apply(command({ ...renew, seq: 2, leaseUntil: 90_000 })), { status: "refused", reason: "invalid_renewal" }, "lease cannot shrink");
|
|
12
|
+
await ledger.apply(command({ kind: "cancel" }));
|
|
13
|
+
const terminal = await row(ledger);
|
|
14
|
+
expectEqual(accepted(await ledger.apply(command({ ...renew, seq: 3 }))), terminal, "terminal renewal returns stored outcome");
|
|
15
|
+
};
|
|
16
|
+
const acknowledgement = async ({ store }) => {
|
|
17
|
+
const { ledger, reopen } = await fixture(store);
|
|
18
|
+
expectEqual(await ledger.apply(command({ kind: "ack" })), { status: "refused", reason: "invalid_transition" }, "early ACK");
|
|
19
|
+
await ledger.apply(command({ kind: "cancel" }));
|
|
20
|
+
expectEqual(await ledger.apply({ ...command({ kind: "ack" }), actor: { id: "other" } }), { status: "refused", reason: "unauthorized" }, "issuer-only ACK");
|
|
21
|
+
await ledger.apply(command({ kind: "ack" }));
|
|
22
|
+
const reopened = await reopen();
|
|
23
|
+
expectEqual((await reopened.apply(command({ kind: "ack" }))).status, "replayed", "duplicate ACK");
|
|
24
|
+
expectEqual(await reopened.admit(ADMISSION), { status: "result_expired" }, "dedup after ACK");
|
|
25
|
+
expectEqual((await row(reopened)).progress, { state: "acked" }, "payload removed, identity retained");
|
|
26
|
+
};
|
|
27
|
+
const preparingRestart = async ({ store }) => {
|
|
28
|
+
for (const effects of [false, true]) {
|
|
29
|
+
const { ledger, reopen } = await fixture(store);
|
|
30
|
+
await runEvents(ledger, PREPARE.slice(0, effects ? 2 : 1));
|
|
31
|
+
const reopened = await reopen();
|
|
32
|
+
await reopened.recover(102);
|
|
33
|
+
expectEqual((await row(reopened)).progress, {
|
|
34
|
+
state: "settled",
|
|
35
|
+
outcome: { reason: "hand_restarted", hooksRan: effects, uncertain: effects },
|
|
36
|
+
}, "preparing never reruns after restart");
|
|
37
|
+
expectEqual((await reopened.apply(command({ kind: "prepare" }, 103))).status, "replayed", "no new preparation permission");
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const preparedRestart = async ({ store }) => {
|
|
41
|
+
for (const state of ["prepared", "prepared_awaiting_consent", "released"]) {
|
|
42
|
+
const { ledger, reopen } = await fixture(store);
|
|
43
|
+
await runEvents(ledger, [
|
|
44
|
+
...PREPARE.slice(0, 2),
|
|
45
|
+
{ kind: "freeze", call: FROZEN, awaitingConsent: state === "prepared_awaiting_consent" },
|
|
46
|
+
]);
|
|
47
|
+
if (state === "released")
|
|
48
|
+
await ledger.apply(command({ kind: "release" }));
|
|
49
|
+
const reopened = await reopen();
|
|
50
|
+
await reopened.recover(102);
|
|
51
|
+
expectEqual((await row(reopened)).progress, { state, call: FROZEN }, "frozen state survives restart");
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const reconciliation = async ({ store }) => {
|
|
55
|
+
for (const reconcilable of [false, true]) {
|
|
56
|
+
const { ledger, reopen } = await fixture(store, { ...ADMISSION, reconcilable });
|
|
57
|
+
await runEvents(ledger, DISPATCH);
|
|
58
|
+
const reopened = await reopen();
|
|
59
|
+
await reopened.recover(102);
|
|
60
|
+
if (!reconcilable) {
|
|
61
|
+
expectEqual((await row(reopened)).progress, { state: "settled", outcome: { reason: "hand_restarted", hooksRan: true, uncertain: true } }, "unreconcilable dispatch");
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
expectEqual((await row(reopened)).progress, { state: "reconciling", call: FROZEN, until: 30_102 }, "30-second grace");
|
|
65
|
+
await reopened.recover(103);
|
|
66
|
+
expectEqual((await row(reopened)).progress, { state: "reconciling", call: FROZEN, until: 30_102 }, "another restart cannot extend grace");
|
|
67
|
+
await reopened.apply(command({ kind: "expire" }, 30_102));
|
|
68
|
+
expectEqual((await row(reopened)).progress.state, "settled", "grace deadline settles without dispatching again");
|
|
69
|
+
}
|
|
70
|
+
const { ledger, reopen } = await fixture(store, { ...ADMISSION, reconcilable: true });
|
|
71
|
+
await runEvents(ledger, DISPATCH);
|
|
72
|
+
const restarted = await reopen();
|
|
73
|
+
await restarted.recover(102);
|
|
74
|
+
await restarted.apply(command({ kind: "result", value: "reconciled" }, 103));
|
|
75
|
+
expectEqual((await row(restarted)).progress, { state: "settled", outcome: { reason: "result", value: "reconciled", hooksRan: true, uncertain: false } }, "authoritative reconciliation resolves uncertainty");
|
|
76
|
+
};
|
|
77
|
+
const expiryAndFencing = async ({ store }) => {
|
|
78
|
+
const { ledger, store: durable, medium } = await fixture(store);
|
|
79
|
+
await runEvents(ledger, PREPARE);
|
|
80
|
+
const expired = accepted(await ledger.apply(command({ kind: "release" }, ADMISSION.leaseUntil)));
|
|
81
|
+
expectEqual(expired.progress, { state: "settled", outcome: { reason: "lease_expired", hooksRan: true, uncertain: true } }, "lease reason retained at boundary");
|
|
82
|
+
const next = await store.open(medium);
|
|
83
|
+
await next.claim(1, 2);
|
|
84
|
+
await expectStoreError(ledger.admit({ ...ADMISSION, commandId: "old" }), "stale-epoch", "old owner cannot admit");
|
|
85
|
+
const current = ledgerOn(next, 2);
|
|
86
|
+
expectEqual((await current.admit({ ...ADMISSION, commandId: "new" })).status, "applied", "new owner");
|
|
87
|
+
expectEqual(await durable.readEpoch(), 2, "fence shared by reopened instances");
|
|
88
|
+
const { ledger: concurrent } = await fixture(store);
|
|
89
|
+
const results = await Promise.all([
|
|
90
|
+
concurrent.apply(command({ kind: "prepare" })),
|
|
91
|
+
concurrent.apply(command({ kind: "prepare" })),
|
|
92
|
+
]);
|
|
93
|
+
expectEqual(results.map((reply) => reply.status), ["applied", "replayed"], "one preparation grant under concurrency");
|
|
94
|
+
expectEqual(await concurrent.apply({ ...command({ kind: "cancel" }), actor: { id: "approver", approvedWorkspace: "wrong" } }), { status: "refused", reason: "unauthorized" }, "approval cannot cross workspaces");
|
|
95
|
+
expectEqual((await concurrent.apply({
|
|
96
|
+
...command({ kind: "cancel" }),
|
|
97
|
+
actor: { id: "approver", approvedWorkspace: ADMISSION.workspace },
|
|
98
|
+
})).status, "applied", "workspace approver may cancel");
|
|
99
|
+
};
|
|
100
|
+
export const LEDGER_RECOVERY_CASES = new Map([
|
|
101
|
+
["LEDGER-07", renewal],
|
|
102
|
+
["LEDGER-08", acknowledgement],
|
|
103
|
+
["LEDGER-09", preparingRestart],
|
|
104
|
+
["LEDGER-10", preparedRestart],
|
|
105
|
+
["LEDGER-11", reconciliation],
|
|
106
|
+
["LEDGER-12", expiryAndFencing],
|
|
107
|
+
]);
|
|
108
|
+
//# sourceMappingURL=ledger-recovery-cases.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { KILL_LEDGER_DECLARATIONS } from "./kill-ledger.js";
|
|
2
|
+
const expectations = [
|
|
3
|
+
"identity and generation are bound; replay never re-admits",
|
|
4
|
+
"effect_possible is durable before preparation effects and freeze",
|
|
5
|
+
"consent waits on the frozen call; unauthorized approval is refused",
|
|
6
|
+
"dispatch requires release and is granted at most once",
|
|
7
|
+
"cancel-first stays terminal; late result is audit-only",
|
|
8
|
+
"result-first stays terminal; terminal release, abort and consent are refused",
|
|
9
|
+
"renewal is issuer-only, monotonic, capped and returns terminal outcomes",
|
|
10
|
+
"ACK is issuer-only, terminal-only and idempotent; replay after ACK is result_expired",
|
|
11
|
+
"preparing never reruns after restart; uncertainty follows effect_possible",
|
|
12
|
+
"prepared, consent-waiting and released calls survive restart",
|
|
13
|
+
"reconcilable dispatch has one bounded grace; an authoritative result resolves uncertainty",
|
|
14
|
+
"lease expiry retains its reason; concurrent admissions and owner epochs are fenced",
|
|
15
|
+
];
|
|
16
|
+
export const LEDGER_REGISTRY = [
|
|
17
|
+
...expectations.map((expected, index) => ({
|
|
18
|
+
id: `LEDGER-${String(index + 1).padStart(2, "0")}`,
|
|
19
|
+
impl: index < 6
|
|
20
|
+
? "packages/agent/src/execution/testing/ledger-cases.ts"
|
|
21
|
+
: "packages/agent/src/execution/testing/ledger-recovery-cases.ts",
|
|
22
|
+
intro: "#5004",
|
|
23
|
+
adapters: ["store"],
|
|
24
|
+
expected,
|
|
25
|
+
})),
|
|
26
|
+
...KILL_LEDGER_DECLARATIONS.map(({ id, operation, point }) => ({
|
|
27
|
+
id,
|
|
28
|
+
impl: "packages/agent/src/execution/testing/kill-ledger.ts",
|
|
29
|
+
intro: "#5004",
|
|
30
|
+
adapters: ["store"],
|
|
31
|
+
expected: `everywhere: kill ${point.at}${point.at === "write" ? ` ${point.index}` : ""} ${operation}; journal and row recover atomically, with no repeated dispatch`,
|
|
32
|
+
})),
|
|
33
|
+
];
|
|
34
|
+
//# sourceMappingURL=ledger-registry.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { AgentEvent, AgentToolCall, ToolExecutionMode } from "../types.ts";
|
|
3
|
+
/** Brain-minted identity and execution root; the executor treats the identity as opaque. */
|
|
4
|
+
export interface ExecutionScope {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly root: string;
|
|
7
|
+
}
|
|
8
|
+
/** The raw model calls, before name resolution, argument preparation, validation or hooks. */
|
|
9
|
+
export interface ToolHostCall {
|
|
10
|
+
readonly scope: ExecutionScope;
|
|
11
|
+
readonly calls: readonly AgentToolCall[];
|
|
12
|
+
readonly mode: ToolExecutionMode;
|
|
13
|
+
}
|
|
14
|
+
export interface ExecutionPipelineWitness {
|
|
15
|
+
readonly pipeline: "legacy" | "prepared";
|
|
16
|
+
readonly toolCallId: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ToolHostResult {
|
|
19
|
+
readonly messages: ToolResultMessage[];
|
|
20
|
+
readonly terminate: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface ToolHostOptions {
|
|
23
|
+
readonly signal?: AbortSignal;
|
|
24
|
+
/** Preserve tool events and ordered result-message events. The loop appends the returned messages to context. */
|
|
25
|
+
readonly emit: (event: AgentEvent) => Promise<void> | void;
|
|
26
|
+
/** Receipt metadata only: never an AgentEvent, tool result, provider payload or EQ observable. */
|
|
27
|
+
readonly recordWitness?: (witness: ExecutionPipelineWitness) => Promise<void> | void;
|
|
28
|
+
}
|
|
29
|
+
export interface ToolHost {
|
|
30
|
+
execute(call: ToolHostCall, options: ToolHostOptions): Promise<ToolHostResult>;
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=tool-host.d.ts.map
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Api, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, CursorExecHandlers, ImageContent, JsonValue, Message, Model, ProviderDiagnostic, SimpleStreamOptions, TextContent, ThinkingSelection, Tool, ToolResultMessage, TranscriptContext, Usage } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { Static, TSchema } from "typebox";
|
|
3
|
+
import type { ExecutionPipelineWitness, ExecutionScope, ToolHost } from "./execution/tool-host.ts";
|
|
4
|
+
export type { ExecutionPipelineWitness, ExecutionScope, ToolHost, ToolHostCall, ToolHostOptions, ToolHostResult, } from "./execution/tool-host.ts";
|
|
3
5
|
/**
|
|
4
6
|
* Stream function used by the agent loop. `Models.streamSimple` satisfies
|
|
5
7
|
* this shape.
|
|
@@ -322,6 +324,10 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
322
324
|
* Default: "parallel"
|
|
323
325
|
*/
|
|
324
326
|
toolExecution?: ToolExecutionMode;
|
|
327
|
+
/** Unprepared batch boundary. Omitted selects the existing in-process legacy scheduler. */
|
|
328
|
+
toolHost?: ToolHost;
|
|
329
|
+
executionScope?: ExecutionScope;
|
|
330
|
+
onExecutionPipeline?: (witness: ExecutionPipelineWitness) => Promise<void> | void;
|
|
325
331
|
/**
|
|
326
332
|
* Optional migration guidance for tool names intentionally removed by an extension.
|
|
327
333
|
* The matching hint is appended to the normal unknown-tool error before hooks run.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-agent-core",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.11.02-1",
|
|
4
4
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -70,8 +70,8 @@
|
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"@earendil-works/chord": "0.99.1",
|
|
73
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.
|
|
74
|
-
"@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.
|
|
73
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.11.02-1",
|
|
74
|
+
"@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.11.02-1",
|
|
75
75
|
"diff": "9.0.0",
|
|
76
76
|
"ignore": "7.0.9",
|
|
77
77
|
"typebox": "1.3.34",
|