@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 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) throw new InvalidResolutionError(`no open awaitExternal suspension with key '${key}' in this run`);
5901
- if (waiter.kind === "approval") {
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 (waiter.kind === "decision") {
5906
- const kind = value?.kind;
5907
- if (kind !== "retry" && kind !== "decompose" && kind !== "cancel" && kind !== "accept") throw new InvalidResolutionError(`escalation '${key}' resolves with an EscalationDecision ({ kind: 'retry' | 'decompose' | 'cancel' | 'accept', ... })`);
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 (waiter.schemaSpec !== void 0) {
5910
- const validation = await validateSchemaSpec(waiter.schemaSpec, value);
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
- const outcome = await this.replayer.resolveSuspended(waiter.entryRef, {
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(waiter.entryRef, "external", outcome);
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.10.0",
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",