@stigmer/runner-slim 3.0.9-dev.20260615153829 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/main.js +378 -199
  2. package/package.json +6 -6
  3. package/workflow-bundle.js +196 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigmer/runner-slim",
3
- "version": "3.0.9-dev.20260615153829",
3
+ "version": "3.1.0",
4
4
  "description": "Self-contained Stigmer runner build for embedding in desktop apps — the bundle-friendly @stigmer/runner",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -16,11 +16,11 @@
16
16
  "jq-wasm": "^1.1.0-jq-1.8.1"
17
17
  },
18
18
  "optionalDependencies": {
19
- "@stigmer/runner-slim-darwin-arm64": "3.0.9-dev.20260615153829",
20
- "@stigmer/runner-slim-darwin-x64": "3.0.9-dev.20260615153829",
21
- "@stigmer/runner-slim-linux-x64": "3.0.9-dev.20260615153829",
22
- "@stigmer/runner-slim-linux-arm64": "3.0.9-dev.20260615153829",
23
- "@stigmer/runner-slim-win32-x64": "3.0.9-dev.20260615153829"
19
+ "@stigmer/runner-slim-darwin-arm64": "3.1.0",
20
+ "@stigmer/runner-slim-darwin-x64": "3.1.0",
21
+ "@stigmer/runner-slim-linux-x64": "3.1.0",
22
+ "@stigmer/runner-slim-linux-arm64": "3.1.0",
23
+ "@stigmer/runner-slim-win32-x64": "3.1.0"
24
24
  },
25
25
  "keywords": [
26
26
  "stigmer",
@@ -27739,6 +27739,40 @@ async function orchestrateAgentCall(input) {
27739
27739
  activityError = err;
27740
27740
  activityDone = true;
27741
27741
  });
27742
+ // Last-written file-review reference for this child, as a deterministic key, so
27743
+ // we write pending_file_reviews only when the child's AWAITING_REVIEW set changes
27744
+ // (avoids status-write storms). undefined = never written yet.
27745
+ let lastFileReviewKey;
27746
+ // Poll-derive the child's file-review gate and surface it (reference-only) on the
27747
+ // parent. No new Temporal signal: the orchestrator already holds the child open
27748
+ // (async-completion) and reads its status here on the same cadence as progress.
27749
+ async function syncFileReviews(childId) {
27750
+ let ids;
27751
+ try {
27752
+ ids = await statusProxy.GetAwaitingFileReviewChangeSetIds(childId);
27753
+ }
27754
+ catch (err) {
27755
+ _temporalio_workflow__WEBPACK_IMPORTED_MODULE_0__.log.warn("Failed to derive child file-review state (non-fatal)", {
27756
+ error: String(err),
27757
+ taskName: input.taskName,
27758
+ });
27759
+ return;
27760
+ }
27761
+ // Deterministic change key (sorted): a stable comparison across replays.
27762
+ const key = [...ids].sort().join(",");
27763
+ if (key === lastFileReviewKey)
27764
+ return;
27765
+ lastFileReviewKey = key;
27766
+ try {
27767
+ await statusProxy.UpdateWorkflowFileReviewStatus(input.workflowExecutionId, childId, ids);
27768
+ }
27769
+ catch (err) {
27770
+ _temporalio_workflow__WEBPACK_IMPORTED_MODULE_0__.log.warn("Failed to update workflow file-review status (non-fatal)", {
27771
+ error: String(err),
27772
+ taskName: input.taskName,
27773
+ });
27774
+ }
27775
+ }
27742
27776
  while (!activityDone) {
27743
27777
  // Wait for a signal, activity completion, or periodic timeout for progress polling.
27744
27778
  // condition() returns false on timeout, true when the predicate became true.
@@ -27749,6 +27783,7 @@ async function orchestrateAgentCall(input) {
27749
27783
  if (childExecId && !initialProgressEmitted) {
27750
27784
  initialProgressEmitted = true;
27751
27785
  await emitProgress(input, childExecId, null);
27786
+ await syncFileReviews(childExecId);
27752
27787
  }
27753
27788
  // Periodic progress: on timeout, poll the child execution for live data
27754
27789
  if (!conditionMet && childExecId) {
@@ -27765,6 +27800,7 @@ async function orchestrateAgentCall(input) {
27765
27800
  if (progress) {
27766
27801
  await emitProgress(input, childExecId, progress);
27767
27802
  }
27803
+ await syncFileReviews(childExecId);
27768
27804
  }
27769
27805
  // Handle HITL approval notification
27770
27806
  if (pendingNotification) {
@@ -27787,13 +27823,26 @@ async function orchestrateAgentCall(input) {
27787
27823
  if (childExecId && !initialProgressEmitted) {
27788
27824
  await emitProgress(input, childExecId, null);
27789
27825
  }
27790
- try {
27791
- await statusProxy.ClearWorkflowApprovalStatus(input.workflowExecutionId);
27792
- }
27793
- catch (clearErr) {
27794
- _temporalio_workflow__WEBPACK_IMPORTED_MODULE_0__.log.warn("Failed to clear workflow approval status (non-fatal)", {
27795
- error: String(clearErr),
27796
- });
27826
+ // Scoped clears for this child only (per-child merge) — a completing child must
27827
+ // not wipe a parallel sibling's still-pending gate. Skipped if the child never
27828
+ // started (nothing was ever surfaced for it).
27829
+ if (childExecId) {
27830
+ try {
27831
+ await statusProxy.ClearWorkflowApprovalStatus(input.workflowExecutionId, childExecId);
27832
+ }
27833
+ catch (clearErr) {
27834
+ _temporalio_workflow__WEBPACK_IMPORTED_MODULE_0__.log.warn("Failed to clear workflow approval status (non-fatal)", {
27835
+ error: String(clearErr),
27836
+ });
27837
+ }
27838
+ try {
27839
+ await statusProxy.UpdateWorkflowFileReviewStatus(input.workflowExecutionId, childExecId, []);
27840
+ }
27841
+ catch (clearErr) {
27842
+ _temporalio_workflow__WEBPACK_IMPORTED_MODULE_0__.log.warn("Failed to clear workflow file-review status (non-fatal)", {
27843
+ error: String(clearErr),
27844
+ });
27845
+ }
27797
27846
  }
27798
27847
  if (activityError) {
27799
27848
  if (childExecId) {
@@ -27847,8 +27896,10 @@ async function emitProgress(input, childExecId, progress) {
27847
27896
  "use strict";
27848
27897
  __webpack_require__.r(__webpack_exports__);
27849
27898
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
27899
+ /* harmony export */ applyDestructiveHintTightener: () => (/* binding */ applyDestructiveHintTightener),
27850
27900
  /* harmony export */ connectMcpServer: () => (/* binding */ connectMcpServer),
27851
- /* harmony export */ discoverMcpServerLegacy: () => (/* binding */ discoverMcpServerLegacy)
27901
+ /* harmony export */ discoverMcpServerLegacy: () => (/* binding */ discoverMcpServerLegacy),
27902
+ /* harmony export */ planIncrementalClassification: () => (/* binding */ planIncrementalClassification)
27852
27903
  /* harmony export */ });
27853
27904
  /* harmony import */ var _temporalio_workflow__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @temporalio/workflow */ "./node_modules/@temporalio/workflow/lib/index.js");
27854
27905
  /**
@@ -27882,6 +27933,115 @@ function classifyWithTimeout(numTools) {
27882
27933
  });
27883
27934
  }
27884
27935
  // ─────────────────────────────────────────────────────────────────────────────
27936
+ // Incremental classification planner (pure, deterministic, sandbox-safe)
27937
+ // ─────────────────────────────────────────────────────────────────────────────
27938
+ /**
27939
+ * Canonical, order-stable signature of a single tool's definition.
27940
+ *
27941
+ * Mirrors the shape `toolsFingerprint` hashes (name + description + input_schema)
27942
+ * so "unchanged" here means the same thing it means for the whole-server
27943
+ * fingerprint. Uses only JSON — no `node:crypto` — so it is safe to call from
27944
+ * inside the Temporal deterministic V8 isolate.
27945
+ */
27946
+ function toolSignature(tool) {
27947
+ return JSON.stringify({
27948
+ name: tool.name,
27949
+ description: tool.description,
27950
+ input_schema: tool.inputSchema ?? null,
27951
+ });
27952
+ }
27953
+ /**
27954
+ * Partition the freshly discovered tools into those that must be classified and
27955
+ * the prior approval decisions that can be carried forward verbatim.
27956
+ *
27957
+ * Reuse is **content-addressed**: a prior decision is kept only when a tool's
27958
+ * name AND full definition are byte-identical to the previous connect. This is
27959
+ * deliberately stricter than reusing by name alone — a tool can keep its name
27960
+ * while its schema changes from benign to destructive, and such a tool MUST be
27961
+ * re-evaluated rather than left with a stale "auto-approve". Tools that are
27962
+ * unchanged are never re-classified (stable, deterministic, no LLM cost; no
27963
+ * flapping for borderline tools), and a tool present last time but gone now is
27964
+ * simply absent from both outputs (dropped).
27965
+ *
27966
+ * The previous approval list is a presence-set of *gated* tools (a tool in
27967
+ * `previousToolApprovals` requires approval; a known tool absent from it was
27968
+ * auto-approved). So a reused tool emits a carried-forward entry only when it
27969
+ * was gated; reused auto-approved tools emit nothing, which correctly keeps them
27970
+ * un-gated. `ClassifyToolApprovals` likewise returns only gated entries, so the
27971
+ * union `[...carriedForward, ...classified]` is the complete gated set.
27972
+ *
27973
+ * Pure and free of Temporal APIs so it can be exhaustively unit-tested and is
27974
+ * safe to evaluate inside the workflow sandbox.
27975
+ */
27976
+ function planIncrementalClassification(previousTools, previousToolApprovals, currentTools) {
27977
+ const prevSigByName = new Map();
27978
+ for (const tool of previousTools) {
27979
+ prevSigByName.set(tool.name, toolSignature(tool));
27980
+ }
27981
+ const prevGatedByName = new Map();
27982
+ for (const approval of previousToolApprovals) {
27983
+ prevGatedByName.set(approval.toolName, approval);
27984
+ }
27985
+ const toolsToClassify = [];
27986
+ const carriedForward = [];
27987
+ for (const tool of currentTools) {
27988
+ const prevSig = prevSigByName.get(tool.name);
27989
+ const unchanged = prevSig !== undefined && prevSig === toolSignature(tool);
27990
+ if (!unchanged) {
27991
+ toolsToClassify.push(tool);
27992
+ continue;
27993
+ }
27994
+ const gated = prevGatedByName.get(tool.name);
27995
+ if (gated) {
27996
+ carriedForward.push({
27997
+ tool_name: gated.toolName,
27998
+ requires_approval: true,
27999
+ message: gated.message,
28000
+ });
28001
+ }
28002
+ // An unchanged tool that was not gated stays auto-approved — emit nothing.
28003
+ }
28004
+ return { toolsToClassify, carriedForward };
28005
+ }
28006
+ // ─────────────────────────────────────────────────────────────────────────────
28007
+ // destructiveHint fail-closed tightener (pure, deterministic, sandbox-safe)
28008
+ // ─────────────────────────────────────────────────────────────────────────────
28009
+ /**
28010
+ * Force-gate any tool whose live MCP annotation declares `destructiveHint:true`
28011
+ * but that the classifier (or carry-forward) left un-gated.
28012
+ *
28013
+ * This is the ONLY way annotations influence policy, and it is deliberately
28014
+ * one-directional. The MCP spec warns that clients must never make tool-use
28015
+ * decisions on annotations from untrusted servers; trusting a server's
28016
+ * "I am destructive" claim only ever ADDS an approval prompt (the safe
28017
+ * direction), so it cannot be abused. The inverse — trusting `readOnlyHint` to
28018
+ * AUTO-APPROVE — is exactly the unsafe direction the spec forbids, so a spoofed
28019
+ * `readOnlyHint:true` on a destructive tool must never relax it. Read-only
28020
+ * auto-approval authority lives solely with the trusted LLM classifier.
28021
+ *
28022
+ * Recomputed from live discovery on every connect, so it has zero coupling to
28023
+ * `toolSignature`/incremental reuse and needs no persistence. Pure JS so it is
28024
+ * safe to evaluate inside the Temporal deterministic isolate.
28025
+ */
28026
+ function applyDestructiveHintTightener(gated, currentTools) {
28027
+ const gatedNames = new Set(gated.map((g) => g.tool_name));
28028
+ const tightened = [...gated];
28029
+ let addedCount = 0;
28030
+ for (const tool of currentTools) {
28031
+ if (tool.annotations?.destructiveHint === true && !gatedNames.has(tool.name)) {
28032
+ tightened.push({
28033
+ tool_name: tool.name,
28034
+ requires_approval: true,
28035
+ message: `Execute ${tool.name}`,
28036
+ from_destructive_hint: true,
28037
+ });
28038
+ gatedNames.add(tool.name);
28039
+ addedCount++;
28040
+ }
28041
+ }
28042
+ return { tightened, addedCount };
28043
+ }
28044
+ // ─────────────────────────────────────────────────────────────────────────────
27885
28045
  // ConnectMcpServerWorkflow — primary connect flow
27886
28046
  // ─────────────────────────────────────────────────────────────────────────────
27887
28047
  async function connectMcpServer(input) {
@@ -27890,23 +28050,22 @@ async function connectMcpServer(input) {
27890
28050
  executionContextId: input.execution_context_id ?? null,
27891
28051
  invokerIdentityAccountId: input.invoker_identity_account_id ?? null,
27892
28052
  });
27893
- const canReusePreviousApprovals = discovery.newToolsFingerprint !== "" &&
27894
- discovery.newToolsFingerprint === discovery.previousToolsFingerprint &&
27895
- discovery.previousToolApprovals.length > 0;
28053
+ // Content-addressed incremental classification: reuse prior decisions for
28054
+ // tools whose definition is unchanged, and classify only the new or changed
28055
+ // ones. Keeps decisions stable/deterministic across reconnects and avoids
28056
+ // redundant LLM calls, while still re-evaluating a tool whose schema changed.
28057
+ const { toolsToClassify, carriedForward } = planIncrementalClassification(discovery.previousTools, discovery.previousToolApprovals, discovery.tools);
27896
28058
  let toolApprovals;
27897
- if (canReusePreviousApprovals) {
27898
- _temporalio_workflow__WEBPACK_IMPORTED_MODULE_0__.log.info(`Tools unchanged for '${input.mcp_server_id}' ` +
27899
- `(fingerprint ${discovery.newToolsFingerprint.slice(0, 12)}) ` +
27900
- `reusing ${discovery.previousToolApprovals.length} previous approval(s)`);
27901
- toolApprovals = discovery.previousToolApprovals.map((a) => ({
27902
- tool_name: a.toolName,
27903
- requires_approval: a.requiresApproval,
27904
- message: a.message,
27905
- }));
28059
+ if (toolsToClassify.length === 0) {
28060
+ _temporalio_workflow__WEBPACK_IMPORTED_MODULE_0__.log.info(`Tools unchanged for '${input.mcp_server_id}' — reusing ` +
28061
+ `${carriedForward.length} previous approval(s), no classification needed`);
28062
+ toolApprovals = carriedForward;
27906
28063
  }
27907
28064
  else {
28065
+ _temporalio_workflow__WEBPACK_IMPORTED_MODULE_0__.log.info(`Classifying ${toolsToClassify.length} new/changed tool(s) for ` +
28066
+ `'${input.mcp_server_id}', reusing ${carriedForward.length} prior decision(s)`);
27908
28067
  const classifyInput = {
27909
- tools: discovery.tools.map((t) => ({
28068
+ tools: toolsToClassify.map((t) => ({
27910
28069
  name: t.name,
27911
28070
  description: t.description,
27912
28071
  input_schema: t.inputSchema ?? null,
@@ -27915,9 +28074,22 @@ async function connectMcpServer(input) {
27915
28074
  serverDescription: "",
27916
28075
  mcpServerId: input.mcp_server_id,
27917
28076
  };
27918
- const classify = classifyWithTimeout(discovery.tools.length);
27919
- toolApprovals = await classify.ClassifyToolApprovals(classifyInput);
27920
- }
28077
+ const classify = classifyWithTimeout(toolsToClassify.length);
28078
+ const classified = await classify.ClassifyToolApprovals(classifyInput);
28079
+ toolApprovals = [...carriedForward, ...classified];
28080
+ }
28081
+ // Fail-closed tightener over the FULL live tool set: a tool the server's own
28082
+ // annotation marks destructiveHint=true is force-gated if it slipped through
28083
+ // un-gated. Runs on live discovery (not the reused/classified subset), so it
28084
+ // also re-asserts gating for carried-forward tools whose server later flips a
28085
+ // tool to destructive. We never trust readOnlyHint to relax — see the
28086
+ // tightener's contract for the MCP untrusted-hints rationale.
28087
+ const { tightened, addedCount } = applyDestructiveHintTightener(toolApprovals, discovery.tools);
28088
+ if (addedCount > 0) {
28089
+ _temporalio_workflow__WEBPACK_IMPORTED_MODULE_0__.log.info(`Force-gated ${addedCount} tool(s) via destructiveHint annotation for ` +
28090
+ `'${input.mcp_server_id}'`);
28091
+ }
28092
+ toolApprovals = tightened;
27921
28093
  return {
27922
28094
  tools: discovery.tools.map((t) => ({
27923
28095
  name: t.name,