@rulvar/core 1.10.0 → 1.12.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/dist/index.d.ts +40 -1
- package/dist/index.js +111 -18
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -5104,6 +5104,7 @@ declare class ExternalRegistry {
|
|
|
5104
5104
|
private readonly waiters;
|
|
5105
5105
|
private readonly keysByScope;
|
|
5106
5106
|
private activity;
|
|
5107
|
+
private closedFlag;
|
|
5107
5108
|
private quiesceListener?;
|
|
5108
5109
|
private quiesceScheduled;
|
|
5109
5110
|
private readonly emitEvent?;
|
|
@@ -5126,6 +5127,22 @@ declare class ExternalRegistry {
|
|
|
5126
5127
|
pending(): PendingExternal[];
|
|
5127
5128
|
/** The synthesized resolveExternal key of an approval suspension. */
|
|
5128
5129
|
static approvalKey(entryRef: number): string;
|
|
5130
|
+
/**
|
|
5131
|
+
* The resolveExternal key a journaled suspension answers to: externals
|
|
5132
|
+
* carry the workflow-chosen key in the payload; approvals and Flavor B
|
|
5133
|
+
* decisions synthesize `approval:<seq>`. Undefined for anything that
|
|
5134
|
+
* is not a suspended entry.
|
|
5135
|
+
*/
|
|
5136
|
+
static suspensionKeyOf(entry: JournalEntry): string | undefined;
|
|
5137
|
+
/**
|
|
5138
|
+
* Settling the run closes this execution segment permanently: every
|
|
5139
|
+
* parked waiter is detached, so a resolution arriving after
|
|
5140
|
+
* handle.result settled appends durably through the fold and wakes
|
|
5141
|
+
* NOTHING; exactly one subsequent engine.resume owns the continuation.
|
|
5142
|
+
* Idempotent. (Suspension ownership rule; v1.10 deep E2E review.)
|
|
5143
|
+
*/
|
|
5144
|
+
close(): void;
|
|
5145
|
+
get closed(): boolean;
|
|
5129
5146
|
private scheduleQuiesceCheck;
|
|
5130
5147
|
/**
|
|
5131
5148
|
* ctx.awaitExternal: journal (or re-match) the suspended entry and park
|
|
@@ -5181,9 +5198,25 @@ declare class ExternalRegistry {
|
|
|
5181
5198
|
/**
|
|
5182
5199
|
* RunHandle.resolveExternal: the live path validates BEFORE append and
|
|
5183
5200
|
* throws InvalidResolutionError without journaling; a winning attempt
|
|
5184
|
-
* settles the waiting promise in place.
|
|
5201
|
+
* settles the waiting promise in place. Without an open waiter the
|
|
5202
|
+
* attempt goes through the journal fold instead: a repeated resolution
|
|
5203
|
+
* is the documented journaled no-op ('already_resolved'), and once the
|
|
5204
|
+
* segment settled the resolution appends durably WITHOUT waking the
|
|
5205
|
+
* closed body (exactly one engine.resume owns the continuation).
|
|
5185
5206
|
*/
|
|
5186
5207
|
resolveExternal(key: string, value: Json): Promise<ResolutionOutcome>;
|
|
5208
|
+
/** The shared live-path payload validation (throws, journals nothing). */
|
|
5209
|
+
private validatePayload;
|
|
5210
|
+
/**
|
|
5211
|
+
* Resolution without a live waiter, over the journal fold. Three cases:
|
|
5212
|
+
* a key no suspension ever carried throws InvalidResolutionError; a key
|
|
5213
|
+
* whose suspensions are all closed submits through the arbiter and
|
|
5214
|
+
* returns the journaled no-op ('already_resolved' or
|
|
5215
|
+
* 'target_abandoned', durability.md contract); an OPEN suspension is
|
|
5216
|
+
* resolvable this way only once the segment settled (closed registry),
|
|
5217
|
+
* with the exact live-path validation and no wake.
|
|
5218
|
+
*/
|
|
5219
|
+
private resolveDetached;
|
|
5187
5220
|
}
|
|
5188
5221
|
//#endregion
|
|
5189
5222
|
//#region src/engine/ctx.d.ts
|
|
@@ -5963,6 +5996,12 @@ declare class InMemoryTranscriptStore implements TranscriptStore {
|
|
|
5963
5996
|
//#region src/stores/jsonl.d.ts
|
|
5964
5997
|
declare class JsonlFileStore implements JournalStore {
|
|
5965
5998
|
private readonly dir;
|
|
5999
|
+
/**
|
|
6000
|
+
* The stored tail seq per run, lazily initialized from the file on the
|
|
6001
|
+
* first append this instance performs (obligation A5). Per instance by
|
|
6002
|
+
* design: cross-process writers are the lease seam's job.
|
|
6003
|
+
*/
|
|
6004
|
+
private readonly lastSeq;
|
|
5966
6005
|
constructor(options: {
|
|
5967
6006
|
dir: string;
|
|
5968
6007
|
});
|
package/dist/index.js
CHANGED
|
@@ -5626,6 +5626,7 @@ var ExternalRegistry = class ExternalRegistry {
|
|
|
5626
5626
|
waiters = /* @__PURE__ */ new Map();
|
|
5627
5627
|
keysByScope = /* @__PURE__ */ new Set();
|
|
5628
5628
|
activity = 0;
|
|
5629
|
+
closedFlag = false;
|
|
5629
5630
|
quiesceListener;
|
|
5630
5631
|
quiesceScheduled = false;
|
|
5631
5632
|
emitEvent;
|
|
@@ -5697,6 +5698,34 @@ var ExternalRegistry = class ExternalRegistry {
|
|
|
5697
5698
|
static approvalKey(entryRef) {
|
|
5698
5699
|
return `approval:${entryRef}`;
|
|
5699
5700
|
}
|
|
5701
|
+
/**
|
|
5702
|
+
* The resolveExternal key a journaled suspension answers to: externals
|
|
5703
|
+
* carry the workflow-chosen key in the payload; approvals and Flavor B
|
|
5704
|
+
* decisions synthesize `approval:<seq>`. Undefined for anything that
|
|
5705
|
+
* is not a suspended entry.
|
|
5706
|
+
*/
|
|
5707
|
+
static suspensionKeyOf(entry) {
|
|
5708
|
+
if (entry.status !== "suspended") return;
|
|
5709
|
+
if (entry.kind === "external") {
|
|
5710
|
+
const key = entry.value?.key;
|
|
5711
|
+
return typeof key === "string" ? key : void 0;
|
|
5712
|
+
}
|
|
5713
|
+
if (entry.kind === "approval") return ExternalRegistry.approvalKey(entry.seq);
|
|
5714
|
+
}
|
|
5715
|
+
/**
|
|
5716
|
+
* Settling the run closes this execution segment permanently: every
|
|
5717
|
+
* parked waiter is detached, so a resolution arriving after
|
|
5718
|
+
* handle.result settled appends durably through the fold and wakes
|
|
5719
|
+
* NOTHING; exactly one subsequent engine.resume owns the continuation.
|
|
5720
|
+
* Idempotent. (Suspension ownership rule; v1.10 deep E2E review.)
|
|
5721
|
+
*/
|
|
5722
|
+
close() {
|
|
5723
|
+
this.closedFlag = true;
|
|
5724
|
+
this.waiters.clear();
|
|
5725
|
+
}
|
|
5726
|
+
get closed() {
|
|
5727
|
+
return this.closedFlag;
|
|
5728
|
+
}
|
|
5700
5729
|
scheduleQuiesceCheck() {
|
|
5701
5730
|
if (this.quiesceScheduled) return;
|
|
5702
5731
|
this.quiesceScheduled = true;
|
|
@@ -5885,7 +5914,7 @@ var ExternalRegistry = class ExternalRegistry {
|
|
|
5885
5914
|
const waiter = this.waiters.get(entryRef);
|
|
5886
5915
|
if (waiter !== void 0) {
|
|
5887
5916
|
this.waiters.delete(entryRef);
|
|
5888
|
-
waiter.resolve(attempt.value);
|
|
5917
|
+
if (!this.closedFlag) waiter.resolve(attempt.value);
|
|
5889
5918
|
}
|
|
5890
5919
|
}
|
|
5891
5920
|
return outcome;
|
|
@@ -5893,37 +5922,76 @@ var ExternalRegistry = class ExternalRegistry {
|
|
|
5893
5922
|
/**
|
|
5894
5923
|
* RunHandle.resolveExternal: the live path validates BEFORE append and
|
|
5895
5924
|
* throws InvalidResolutionError without journaling; a winning attempt
|
|
5896
|
-
* settles the waiting promise in place.
|
|
5925
|
+
* settles the waiting promise in place. Without an open waiter the
|
|
5926
|
+
* attempt goes through the journal fold instead: a repeated resolution
|
|
5927
|
+
* is the documented journaled no-op ('already_resolved'), and once the
|
|
5928
|
+
* segment settled the resolution appends durably WITHOUT waking the
|
|
5929
|
+
* closed body (exactly one engine.resume owns the continuation).
|
|
5897
5930
|
*/
|
|
5898
5931
|
async resolveExternal(key, value) {
|
|
5899
5932
|
const waiter = [...this.waiters.values()].find((candidate) => candidate.key === key);
|
|
5900
|
-
if (waiter === void 0)
|
|
5901
|
-
|
|
5933
|
+
if (waiter === void 0) return this.resolveDetached(key, value);
|
|
5934
|
+
await this.validatePayload(waiter.kind, key, value, waiter.schemaSpec);
|
|
5935
|
+
const outcome = await this.replayer.resolveSuspended(waiter.entryRef, {
|
|
5936
|
+
by: "external",
|
|
5937
|
+
value
|
|
5938
|
+
});
|
|
5939
|
+
this.emitResolutionOutcome(waiter.entryRef, "external", outcome);
|
|
5940
|
+
if (outcome.applied) {
|
|
5941
|
+
this.waiters.delete(waiter.entryRef);
|
|
5942
|
+
if (!this.closedFlag) waiter.resolve(value);
|
|
5943
|
+
}
|
|
5944
|
+
return outcome;
|
|
5945
|
+
}
|
|
5946
|
+
/** The shared live-path payload validation (throws, journals nothing). */
|
|
5947
|
+
async validatePayload(kind, key, value, schemaSpec) {
|
|
5948
|
+
if (kind === "approval") {
|
|
5902
5949
|
const decision = value?.decision;
|
|
5903
5950
|
if (decision !== "allow" && decision !== "deny") throw new InvalidResolutionError(`approval '${key}' resolves with { decision: 'allow' | 'deny', reason? }`);
|
|
5904
5951
|
}
|
|
5905
|
-
if (
|
|
5906
|
-
const
|
|
5907
|
-
if (
|
|
5952
|
+
if (kind === "decision") {
|
|
5953
|
+
const decisionKind = value?.kind;
|
|
5954
|
+
if (decisionKind !== "retry" && decisionKind !== "decompose" && decisionKind !== "cancel" && decisionKind !== "accept") throw new InvalidResolutionError(`escalation '${key}' resolves with an EscalationDecision ({ kind: 'retry' | 'decompose' | 'cancel' | 'accept', ... })`);
|
|
5908
5955
|
}
|
|
5909
|
-
if (
|
|
5910
|
-
const validation = await validateSchemaSpec(
|
|
5956
|
+
if (schemaSpec !== void 0) {
|
|
5957
|
+
const validation = await validateSchemaSpec(schemaSpec, value);
|
|
5911
5958
|
if (!validation.valid) throw new InvalidResolutionError(`resolution for '${key}' does not validate against the pinned schema: ` + validation.issues.map((issue) => issue.message).join("; "), { data: { issues: validation.issues.map((issue) => issue.message) } });
|
|
5912
5959
|
}
|
|
5913
|
-
|
|
5960
|
+
}
|
|
5961
|
+
/**
|
|
5962
|
+
* Resolution without a live waiter, over the journal fold. Three cases:
|
|
5963
|
+
* a key no suspension ever carried throws InvalidResolutionError; a key
|
|
5964
|
+
* whose suspensions are all closed submits through the arbiter and
|
|
5965
|
+
* returns the journaled no-op ('already_resolved' or
|
|
5966
|
+
* 'target_abandoned', durability.md contract); an OPEN suspension is
|
|
5967
|
+
* resolvable this way only once the segment settled (closed registry),
|
|
5968
|
+
* with the exact live-path validation and no wake.
|
|
5969
|
+
*/
|
|
5970
|
+
async resolveDetached(key, value) {
|
|
5971
|
+
const candidates = this.replayer.snapshot().filter((entry) => ExternalRegistry.suspensionKeyOf(entry) === key);
|
|
5972
|
+
const open = candidates.find((entry) => this.replayer.suspensionState(entry.seq).state === "suspended");
|
|
5973
|
+
if (open === void 0 && candidates.length === 0) throw new InvalidResolutionError(`no open awaitExternal suspension with key '${key}' in this run`);
|
|
5974
|
+
if (open !== void 0 && !this.closedFlag) throw new InvalidResolutionError(`no open awaitExternal suspension with key '${key}' in this run`);
|
|
5975
|
+
const target = open ?? candidates[candidates.length - 1];
|
|
5976
|
+
await this.validatePayload(target.kind === "approval" ? target.deadlineAt === void 0 ? "approval" : "decision" : "external", key, value, target.value?.schema);
|
|
5977
|
+
const outcome = await this.replayer.resolveSuspended(target.seq, {
|
|
5914
5978
|
by: "external",
|
|
5915
5979
|
value
|
|
5916
5980
|
});
|
|
5917
|
-
this.emitResolutionOutcome(
|
|
5918
|
-
if (outcome.applied) {
|
|
5919
|
-
this.waiters.delete(waiter.entryRef);
|
|
5920
|
-
waiter.resolve(value);
|
|
5921
|
-
}
|
|
5981
|
+
this.emitResolutionOutcome(target.seq, "external", outcome);
|
|
5922
5982
|
return outcome;
|
|
5923
5983
|
}
|
|
5924
5984
|
};
|
|
5925
5985
|
//#endregion
|
|
5926
5986
|
//#region src/stores/inmemory.ts
|
|
5987
|
+
/**
|
|
5988
|
+
* InMemoryStore (M1-T04): the default journal store. Process-local, so
|
|
5989
|
+
* nothing survives a process exit and cross-process resume is
|
|
5990
|
+
* impossible (same-process resume of a kept instance works); the store
|
|
5991
|
+
* warns loudly exactly once per instance unless constructed with
|
|
5992
|
+
* `quiet: true` (the deliberate choice of a test tier).
|
|
5993
|
+
* An in-memory TranscriptStore ships alongside for the same default.
|
|
5994
|
+
*/
|
|
5927
5995
|
function deepCopy(value) {
|
|
5928
5996
|
return JSON.parse(JSON.stringify(value));
|
|
5929
5997
|
}
|
|
@@ -5937,6 +6005,8 @@ var InMemoryStore = class {
|
|
|
5937
6005
|
append(runId, e) {
|
|
5938
6006
|
this.warnOnce();
|
|
5939
6007
|
const entries = this.runs.get(runId) ?? [];
|
|
6008
|
+
const tail = entries[entries.length - 1];
|
|
6009
|
+
if (tail !== void 0 && Number.isFinite(e.seq) && Number.isFinite(tail.seq) && e.seq <= tail.seq) return Promise.reject(new JournalOrderViolation(`InMemoryStore: append of seq ${e.seq} to run '${runId}' is not after the stored tail seq ${tail.seq}; a concurrent writer raced this journal from a stale tail`));
|
|
5940
6010
|
entries.push(deepCopy(e));
|
|
5941
6011
|
this.runs.set(runId, entries);
|
|
5942
6012
|
return Promise.resolve();
|
|
@@ -6023,6 +6093,12 @@ function safeName(runId) {
|
|
|
6023
6093
|
}
|
|
6024
6094
|
var JsonlFileStore = class {
|
|
6025
6095
|
dir;
|
|
6096
|
+
/**
|
|
6097
|
+
* The stored tail seq per run, lazily initialized from the file on the
|
|
6098
|
+
* first append this instance performs (obligation A5). Per instance by
|
|
6099
|
+
* design: cross-process writers are the lease seam's job.
|
|
6100
|
+
*/
|
|
6101
|
+
lastSeq = /* @__PURE__ */ new Map();
|
|
6026
6102
|
constructor(options) {
|
|
6027
6103
|
this.dir = options.dir;
|
|
6028
6104
|
mkdirSync(this.dir, { recursive: true });
|
|
@@ -6034,7 +6110,16 @@ var JsonlFileStore = class {
|
|
|
6034
6110
|
return join(this.dir, `${safeName(runId)}${META_SUFFIX}`);
|
|
6035
6111
|
}
|
|
6036
6112
|
async append(runId, e) {
|
|
6113
|
+
let tail = this.lastSeq.get(runId);
|
|
6114
|
+
if (tail === void 0) {
|
|
6115
|
+
const existing = await this.load(runId);
|
|
6116
|
+
const last = existing[existing.length - 1];
|
|
6117
|
+
tail = last !== void 0 && Number.isFinite(last.seq) ? last.seq : Number.NEGATIVE_INFINITY;
|
|
6118
|
+
this.lastSeq.set(runId, tail);
|
|
6119
|
+
}
|
|
6120
|
+
if (Number.isFinite(e.seq) && e.seq <= tail) throw new JournalOrderViolation(`JsonlFileStore: append of seq ${e.seq} to run '${runId}' is not after the stored tail seq ${tail}; a concurrent writer raced this journal from a stale tail`);
|
|
6037
6121
|
appendFileSync(this.journalPath(runId), `${JSON.stringify(e)}\n`, "utf8");
|
|
6122
|
+
if (Number.isFinite(e.seq)) this.lastSeq.set(runId, e.seq);
|
|
6038
6123
|
}
|
|
6039
6124
|
async load(runId) {
|
|
6040
6125
|
let raw;
|
|
@@ -6091,6 +6176,7 @@ var JsonlFileStore = class {
|
|
|
6091
6176
|
async delete(runId) {
|
|
6092
6177
|
rmSync(this.journalPath(runId), { force: true });
|
|
6093
6178
|
rmSync(this.metaPath(runId), { force: true });
|
|
6179
|
+
this.lastSeq.delete(runId);
|
|
6094
6180
|
}
|
|
6095
6181
|
};
|
|
6096
6182
|
const TRANSCRIPT_SUFFIX = ".bin";
|
|
@@ -12557,6 +12643,7 @@ function createEngine(options) {
|
|
|
12557
12643
|
return priceUsdOf(pricing, usage);
|
|
12558
12644
|
};
|
|
12559
12645
|
const providerLimiter = new KeyedLimiter(options.concurrency?.perProvider);
|
|
12646
|
+
const activeSegments = /* @__PURE__ */ new Set();
|
|
12560
12647
|
function run(wf, args, opts, resumeCtx) {
|
|
12561
12648
|
if (wf.kind !== "workflow" && wf.kind !== "compiled-workflow") throw new ConfigError("engine.run accepts in-process Workflow values or compileScript CompiledWorkflow values");
|
|
12562
12649
|
const compiled = wf.kind === "compiled-workflow" ? wf : void 0;
|
|
@@ -12701,6 +12788,8 @@ function createEngine(options) {
|
|
|
12701
12788
|
workflowHash: compiled === void 0 ? hashWorkflowBody(wf) : hashWorkflowSource(compiled.source),
|
|
12702
12789
|
...compiled === void 0 ? {} : { workflowSourceRef: workflowSourceRef(runId) }
|
|
12703
12790
|
});
|
|
12791
|
+
if (activeSegments.has(runId)) throw new ConfigError(`run '${runId}' already has a live execution segment in this engine; await its settled result before starting another one (exactly one segment owns a run; https://docs.rulvar.com/guide/durability#resolving-a-settled-run)`);
|
|
12792
|
+
activeSegments.add(runId);
|
|
12704
12793
|
const result = (async () => {
|
|
12705
12794
|
let status = "ok";
|
|
12706
12795
|
let value;
|
|
@@ -12741,8 +12830,9 @@ function createEngine(options) {
|
|
|
12741
12830
|
}))]);
|
|
12742
12831
|
if (raced.kind === "suspended") {
|
|
12743
12832
|
bodyPromise.catch(() => void 0);
|
|
12833
|
+
external.close();
|
|
12744
12834
|
status = "suspended";
|
|
12745
|
-
pending = raced.open;
|
|
12835
|
+
pending = raced.open.filter((item) => replayer.suspensionState(item.entryRef).state === "suspended");
|
|
12746
12836
|
for (const item of pending) bus.emit({
|
|
12747
12837
|
type: "external:waiting",
|
|
12748
12838
|
key: item.key,
|
|
@@ -12764,7 +12854,7 @@ function createEngine(options) {
|
|
|
12764
12854
|
value = void 0;
|
|
12765
12855
|
if (thrown instanceof BudgetExhaustedError || budget.exhausted) {
|
|
12766
12856
|
status = "exhausted";
|
|
12767
|
-
wireError = thrown instanceof RulvarError ? thrown.toWire() : void 0;
|
|
12857
|
+
wireError = thrown instanceof AgentCallError ? agentResultWire(thrown.result, thrown.message) : thrown instanceof RulvarError ? thrown.toWire() : void 0;
|
|
12768
12858
|
} else if (controller.signal.aborted) {
|
|
12769
12859
|
status = "cancelled";
|
|
12770
12860
|
wireError = {
|
|
@@ -12782,6 +12872,7 @@ function createEngine(options) {
|
|
|
12782
12872
|
}
|
|
12783
12873
|
} finally {
|
|
12784
12874
|
if (deadlineTimer !== void 0) clearTimeout(deadlineTimer);
|
|
12875
|
+
external.close();
|
|
12785
12876
|
await replayer.flush().catch(() => void 0);
|
|
12786
12877
|
}
|
|
12787
12878
|
const ledger = replayer.ledger();
|
|
@@ -12807,7 +12898,9 @@ function createEngine(options) {
|
|
|
12807
12898
|
});
|
|
12808
12899
|
return outcome;
|
|
12809
12900
|
})();
|
|
12810
|
-
result.catch(() => void 0)
|
|
12901
|
+
result.catch(() => void 0).finally(() => {
|
|
12902
|
+
activeSegments.delete(runId);
|
|
12903
|
+
});
|
|
12811
12904
|
return {
|
|
12812
12905
|
runId,
|
|
12813
12906
|
result,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulvar/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Rulvar core: L0 contracts, journal kernel, ctx primitives, agent runtime, model router, tool system, dynamic orchestrator, InMemory and JSONL stores, event stream.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|