@sanity/workflow-engine 0.8.0 → 0.9.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.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { parse, evaluate } from "groq-js";
2
- import { isTerminalTaskStatus, WORKFLOW_DEFINITION_TYPE, DOCUMENT_VALUE_PERMISSIONS } from "./_chunks-es/schema.js";
2
+ import { actorFulfillsRole, isTerminalTaskStatus, WORKFLOW_DEFINITION_TYPE, andConditions, DOCUMENT_VALUE_PERMISSIONS } from "./_chunks-es/schema.js";
3
3
  import { isStartableDefinition } from "./_chunks-es/schema.js";
4
4
  import * as v from "valibot";
5
5
  function findOpenStageEntry(host) {
@@ -140,11 +140,11 @@ function scopedStateOverlay(instance, snapshot, taskName) {
140
140
  const stageState = renderedState(stageEntry.state ?? [], snapshot), task = taskName ? stageEntry.tasks.find((t) => t.name === taskName) : void 0;
141
141
  return { ...stageState, ...renderedState(task?.state ?? [], snapshot) };
142
142
  }
143
- function assignedFor(instance, taskName, actor) {
143
+ function assignedFor(instance, taskName, actor, roleAliases) {
144
144
  if (actor === void 0) return !1;
145
145
  const entry = findOpenStageEntry(instance)?.tasks.find((t) => t.name === taskName)?.state?.find((s) => s._type === "assignees");
146
146
  return entry === void 0 ? !1 : entry.value.some(
147
- (a) => a.type === "user" ? a.id === actor.id : (actor.roles ?? []).includes(a.role)
147
+ (a) => a.type === "user" ? a.id === actor.id : actorFulfillsRole(actor.roles, a.role, roleAliases)
148
148
  );
149
149
  }
150
150
  function effectsContextMap(instance) {
@@ -663,6 +663,17 @@ function isRevisionConflict(error) {
663
663
  const { statusCode, message } = error;
664
664
  return statusCode === 409 ? !0 : typeof message == "string" && message.includes("ifRevisionId check failed");
665
665
  }
666
+ class ConcurrentEditStateError extends Error {
667
+ instanceId;
668
+ target;
669
+ attempts;
670
+ constructor(args) {
671
+ const where = args.target.task !== void 0 ? `${args.target.task}.${args.target.state}` : args.target.state;
672
+ super(
673
+ `Edit of "${args.target.scope}:${where}" (instance ${args.instanceId}) lost the optimistic-locking race ${args.attempts} attempts running \u2014 a concurrent writer kept committing first. Re-read and retry, or investigate a write storm on this instance.`
674
+ ), this.name = "ConcurrentEditStateError", this.instanceId = args.instanceId, this.target = args.target, this.attempts = args.attempts;
675
+ }
676
+ }
666
677
  class CascadeLimitError extends Error {
667
678
  instanceId;
668
679
  limit;
@@ -1282,6 +1293,12 @@ function coerceToGdr(raw, workflowResource) {
1282
1293
  function gdrToBareId(uri) {
1283
1294
  return uri.includes(":") ? extractDocumentId(uri) : uri;
1284
1295
  }
1296
+ function loadCallContext(client, instanceId, options) {
1297
+ return loadContext(client, instanceId, {
1298
+ ...options?.clock ? { clock: options.clock } : {},
1299
+ ...options?.clientForGdr ? { clientForGdr: options.clientForGdr } : {}
1300
+ });
1301
+ }
1285
1302
  async function loadContext(client, instanceId, options) {
1286
1303
  const instance = await client.getDocument(instanceId);
1287
1304
  if (!instance)
@@ -1302,7 +1319,7 @@ async function ctxConditionParams(ctx, opts) {
1302
1319
  ...base,
1303
1320
  state,
1304
1321
  actor: opts?.actor,
1305
- assigned: opts?.taskName !== void 0 ? assignedFor(ctx.instance, opts.taskName, opts?.actor) : !1,
1322
+ assigned: opts?.taskName !== void 0 ? assignedFor(ctx.instance, opts.taskName, opts?.actor, ctx.definition.roleAliases) : !1,
1306
1323
  ...opts?.vars
1307
1324
  };
1308
1325
  return { ...await evaluatePredicates({
@@ -2033,22 +2050,27 @@ function runOps(args) {
2033
2050
  const summaries = [];
2034
2051
  for (const op of ops) {
2035
2052
  const summary = applyOp(op, { mutation, stage, taskName: origin.task, params, actor, self, now });
2036
- summaries.push(summary), mutation.history.push({
2037
- _key: randomKey(),
2038
- _type: "opApplied",
2039
- at: now,
2040
- stage,
2041
- ...origin.task !== void 0 ? { task: origin.task } : {},
2042
- ...origin.action !== void 0 ? { action: origin.action } : {},
2043
- ...origin.transition !== void 0 ? { transition: origin.transition } : {},
2044
- opType: summary.opType,
2045
- ...summary.target !== void 0 ? { target: summary.target } : {},
2046
- ...summary.resolved !== void 0 ? { resolved: summary.resolved } : {},
2047
- ...actor !== void 0 ? { actor } : {}
2048
- });
2053
+ summaries.push(summary), mutation.history.push(opAppliedEntry({ origin, summary, stage, actor, now }));
2049
2054
  }
2050
2055
  return summaries;
2051
2056
  }
2057
+ function opAppliedEntry(args) {
2058
+ const { origin, summary, stage, actor, now } = args;
2059
+ return {
2060
+ _key: randomKey(),
2061
+ _type: "opApplied",
2062
+ at: now,
2063
+ stage,
2064
+ ...origin.task !== void 0 ? { task: origin.task } : {},
2065
+ ...origin.action !== void 0 ? { action: origin.action } : {},
2066
+ ...origin.transition !== void 0 ? { transition: origin.transition } : {},
2067
+ ...origin.edit === !0 ? { edit: !0 } : {},
2068
+ opType: summary.opType,
2069
+ ...summary.target !== void 0 ? { target: summary.target } : {},
2070
+ ...summary.resolved !== void 0 ? { resolved: summary.resolved } : {},
2071
+ ...actor !== void 0 ? { actor } : {}
2072
+ };
2073
+ }
2052
2074
  function applyOp(op, ctx) {
2053
2075
  switch (op.type) {
2054
2076
  case "state.set":
@@ -2682,6 +2704,85 @@ async function fetchGrantsCached(requestFn, resourcePath) {
2682
2704
  return;
2683
2705
  }
2684
2706
  }
2707
+ function effectiveEditable(baseline, override) {
2708
+ if (baseline === void 0) return;
2709
+ if (override === void 0) return baseline;
2710
+ if (baseline === !0 && override === !0) return !0;
2711
+ const parts = [baseline, override].map((e) => e === !0 ? void 0 : e);
2712
+ return andConditions(parts) ?? !0;
2713
+ }
2714
+ function slotSites(definition, stage) {
2715
+ const sites = [];
2716
+ for (const entry of definition.state ?? []) sites.push({ scope: "workflow", entry });
2717
+ for (const entry of stage.state ?? []) sites.push({ scope: "stage", entry });
2718
+ for (const task of stage.tasks ?? [])
2719
+ for (const entry of task.state ?? []) sites.push({ scope: "task", task: task.name, entry });
2720
+ return sites;
2721
+ }
2722
+ function toResolvedSlot(site, stage) {
2723
+ return {
2724
+ scope: site.scope,
2725
+ ...site.task !== void 0 ? { task: site.task } : {},
2726
+ name: site.entry.name,
2727
+ type: site.entry.type,
2728
+ ...site.entry.title !== void 0 ? { title: site.entry.title } : {},
2729
+ ref: { scope: site.scope, state: site.entry.name },
2730
+ effective: effectiveEditable(site.entry.editable, stage.editable?.[site.entry.name])
2731
+ };
2732
+ }
2733
+ function editableSlotsInStage(definition, stage) {
2734
+ return slotSites(definition, stage).filter((site) => site.entry.editable !== void 0).map((site) => toResolvedSlot(site, stage));
2735
+ }
2736
+ function editTargetMatches(slot, target) {
2737
+ return slot.name !== target.state ? !1 : target.task !== void 0 ? slot.scope === "task" && slot.task === target.task : target.scope !== void 0 ? slot.scope === target.scope : !0;
2738
+ }
2739
+ const SCOPE_PRECEDENCE = { task: 0, stage: 1, workflow: 2 };
2740
+ function resolveEditTarget(args) {
2741
+ const { definition, stage, target } = args, found = slotSites(definition, stage).filter(
2742
+ (site) => editTargetMatches(
2743
+ {
2744
+ scope: site.scope,
2745
+ name: site.entry.name,
2746
+ ...site.task !== void 0 ? { task: site.task } : {}
2747
+ },
2748
+ target
2749
+ )
2750
+ ).sort((a, b) => SCOPE_PRECEDENCE[a.scope] - SCOPE_PRECEDENCE[b.scope])[0];
2751
+ return found ? toResolvedSlot(found, stage) : void 0;
2752
+ }
2753
+ function slotWindowOpen(instance, slot) {
2754
+ if (instance.completedAt !== void 0) return { open: !1, detail: "instance completed" };
2755
+ if (instance.abortedAt !== void 0) return { open: !1, detail: "instance aborted" };
2756
+ if (slot.scope !== "task") return { open: !0 };
2757
+ const status = findOpenStageEntry(instance)?.tasks.find((t) => t.name === slot.task)?.status;
2758
+ return status === "active" ? { open: !0 } : { open: !1, detail: `task "${slot.task}" is ${status ?? "not active"}` };
2759
+ }
2760
+ function readSlotValue(instance, slot) {
2761
+ return slotStateHost(instance, slot)?.find((e) => e.name === slot.name)?.value;
2762
+ }
2763
+ function slotStateHost(instance, slot) {
2764
+ if (slot.scope === "workflow") return instance.state;
2765
+ const stageEntry = findOpenStageEntry(instance);
2766
+ return slot.scope === "stage" ? stageEntry?.state : stageEntry?.tasks.find((t) => t.name === slot.task)?.state;
2767
+ }
2768
+ function slotProvenance(instance, ref) {
2769
+ let latest;
2770
+ for (const entry of instance.history)
2771
+ entry._type === "opApplied" && (entry.target?.scope !== ref.scope || entry.target.state !== ref.state || (latest = { at: entry.at, ...entry.actor !== void 0 ? { actor: entry.actor } : {} }));
2772
+ return latest === void 0 ? {} : {
2773
+ ...latest.actor !== void 0 ? { setBy: latest.actor } : {},
2774
+ setAt: latest.at
2775
+ };
2776
+ }
2777
+ function editDisabledReason(args) {
2778
+ const { effective, instance, window, guardDenial, predicateSatisfied } = args;
2779
+ if (effective === void 0) return { kind: "not-editable" };
2780
+ if (!window.open)
2781
+ return instance.completedAt !== void 0 ? { kind: "instance-completed", completedAt: instance.completedAt } : { kind: "edit-window-closed", detail: window.detail ?? "closed" };
2782
+ if (guardDenial !== void 0) return guardDenial;
2783
+ if (!predicateSatisfied)
2784
+ return { kind: "editor-not-permitted", predicate: effective === !0 ? "true" : effective };
2785
+ }
2685
2786
  async function evaluateInstance(args) {
2686
2787
  const { client, tag, instanceId, resourceClients } = args, now = (args.clock ?? wallClock)();
2687
2788
  validateTag(tag);
@@ -2715,6 +2816,7 @@ async function evaluateFromSnapshot(args) {
2715
2816
  actor,
2716
2817
  snapshot,
2717
2818
  scope,
2819
+ roleAliases: definition.roleAliases,
2718
2820
  stageHasExits: !isTerminalStage(stage),
2719
2821
  guardDenial
2720
2822
  })
@@ -2736,16 +2838,65 @@ async function evaluateFromSnapshot(args) {
2736
2838
  stage,
2737
2839
  tasks: taskEvaluations,
2738
2840
  transitions: transitionEvaluations
2739
- }, pendingOnYou = taskEvaluations.filter((t) => t.pendingOnActor), canInteract = taskEvaluations.some((t) => t.actions.some((a) => a.allowed));
2841
+ }, pendingOnYou = taskEvaluations.filter((t) => t.pendingOnActor), canInteract = taskEvaluations.some((t) => t.actions.some((a) => a.allowed)), editGuardDenial = guardDenial?.kind === "mutation-guard-denied" ? guardDenial : void 0, editableSlots = await evaluateEditableSlots({
2842
+ instance,
2843
+ definition,
2844
+ stage,
2845
+ actor,
2846
+ snapshot,
2847
+ scope,
2848
+ guardDenial: editGuardDenial
2849
+ });
2740
2850
  return {
2741
2851
  instance,
2742
2852
  definition,
2743
2853
  actor,
2744
2854
  currentStage,
2745
2855
  pendingOnYou,
2746
- canInteract
2856
+ canInteract,
2857
+ editableSlots
2747
2858
  };
2748
2859
  }
2860
+ async function evaluateEditableSlots(args) {
2861
+ const { instance, definition, stage, actor, snapshot, scope, guardDenial } = args, slots = [];
2862
+ for (const slot of editableSlotsInStage(definition, stage)) {
2863
+ const window = slotWindowOpen(instance, slot), predicateSatisfied = await editPredicateSatisfied({
2864
+ slot,
2865
+ window,
2866
+ guardDenial,
2867
+ instance,
2868
+ snapshot,
2869
+ scope,
2870
+ actor,
2871
+ roleAliases: definition.roleAliases
2872
+ }), reason = editDisabledReason({
2873
+ effective: slot.effective,
2874
+ instance,
2875
+ window,
2876
+ guardDenial,
2877
+ predicateSatisfied
2878
+ }), value = readSlotValue(instance, slot);
2879
+ slots.push({
2880
+ scope: slot.scope,
2881
+ ...slot.task !== void 0 ? { task: slot.task } : {},
2882
+ name: slot.name,
2883
+ type: slot.type,
2884
+ ...slot.title !== void 0 ? { title: slot.title } : {},
2885
+ value,
2886
+ editable: reason === void 0,
2887
+ ...reason !== void 0 ? { disabledReason: reason } : {},
2888
+ ...slotProvenance(instance, slot.ref)
2889
+ });
2890
+ }
2891
+ return slots;
2892
+ }
2893
+ async function editPredicateSatisfied(args) {
2894
+ const { slot, window, guardDenial, instance, snapshot, scope, actor, roleAliases } = args;
2895
+ if (!window.open || guardDenial !== void 0) return !1;
2896
+ if (slot.effective === !0) return !0;
2897
+ const params = slot.scope === "task" && slot.task !== void 0 ? taskScopeFor({ scope, instance, snapshot, actor, taskName: slot.task, roleAliases }) : scope;
2898
+ return evaluateCondition({ condition: slot.effective, snapshot, params });
2899
+ }
2749
2900
  async function renderScope(args) {
2750
2901
  const { instance, definition, actor, grants, snapshot, now } = args, params = {
2751
2902
  ...buildParams({ instance, now, snapshot }),
@@ -2771,15 +2922,36 @@ async function advisoryCan(instance, actor, grants) {
2771
2922
  });
2772
2923
  return can;
2773
2924
  }
2774
- async function evaluateTask(args) {
2775
- const { task, statusEntry, instance, actor, snapshot, scope, stageHasExits, guardDenial } = args, status = statusEntry?.status ?? "pending", assigned = assignedFor(instance, task.name, actor), taskScope = {
2925
+ function taskScopeFor(args) {
2926
+ const { scope, instance, snapshot, actor, taskName, roleAliases } = args;
2927
+ return {
2776
2928
  ...scope,
2777
2929
  state: {
2778
2930
  ...scope.state,
2779
- ...scopedStateOverlay(instance, snapshot, task.name)
2931
+ ...scopedStateOverlay(instance, snapshot, taskName)
2780
2932
  },
2781
- assigned
2782
- }, unmetRequirements = await evaluateRequirements({
2933
+ assigned: assignedFor(instance, taskName, actor, roleAliases)
2934
+ };
2935
+ }
2936
+ async function evaluateTask(args) {
2937
+ const {
2938
+ task,
2939
+ statusEntry,
2940
+ instance,
2941
+ actor,
2942
+ snapshot,
2943
+ scope,
2944
+ roleAliases,
2945
+ stageHasExits,
2946
+ guardDenial
2947
+ } = args, status = statusEntry?.status ?? "pending", taskScope = taskScopeFor({
2948
+ scope,
2949
+ instance,
2950
+ snapshot,
2951
+ actor,
2952
+ taskName: task.name,
2953
+ roleAliases
2954
+ }), assigned = taskScope.assigned === !0, unmetRequirements = await evaluateRequirements({
2783
2955
  requirements: task.requirements,
2784
2956
  snapshot,
2785
2957
  params: taskScope
@@ -3006,10 +3178,7 @@ async function commitAbort(ctx, reason, actor) {
3006
3178
  async function fireAction(args) {
3007
3179
  const { client, instanceId, task, action, params, options } = args;
3008
3180
  for (let attempt = 1; attempt <= CONCURRENT_FIRE_ACTION_MAX_ATTEMPTS; attempt++) {
3009
- const ctx = await loadContext(client, instanceId, {
3010
- ...options?.clock ? { clock: options.clock } : {},
3011
- ...options?.clientForGdr ? { clientForGdr: options.clientForGdr } : {}
3012
- });
3181
+ const ctx = await loadCallContext(client, instanceId, options);
3013
3182
  try {
3014
3183
  return await commitAction(ctx, task, action, params, options);
3015
3184
  } catch (error) {
@@ -3104,6 +3273,114 @@ function formatDisabledReason(task, action, reason) {
3104
3273
  const detail = disabledReasonDetail[reason.kind](reason);
3105
3274
  return `Action "${task}:${action}" is not allowed: ${detail}`;
3106
3275
  }
3276
+ class EditStateDeniedError extends Error {
3277
+ reason;
3278
+ target;
3279
+ constructor(args) {
3280
+ super(formatEditDisabledReason(args.target, args.reason)), this.name = "EditStateDeniedError", this.reason = args.reason, this.target = args.target;
3281
+ }
3282
+ }
3283
+ const editDisabledReasonDetail = {
3284
+ "not-editable": () => "slot is not declared editable",
3285
+ "instance-completed": (r) => `instance completed at ${r.completedAt}`,
3286
+ "edit-window-closed": (r) => `edit window closed (${r.detail})`,
3287
+ "editor-not-permitted": (r) => `editor not permitted (${r.predicate})`,
3288
+ "mutation-guard-denied": (r) => `blocked by mutation guard(s) [${r.guardIds.join(", ")}]${r.detail ? ` (${r.detail})` : ""}`
3289
+ };
3290
+ function formatEditDisabledReason(target, reason) {
3291
+ const detail = editDisabledReasonDetail[reason.kind](
3292
+ reason
3293
+ ), where = target.task !== void 0 ? `${target.task}.${target.state}` : target.state;
3294
+ return `State "${target.scope}:${where}" is not editable: ${detail}`;
3295
+ }
3296
+ async function editState(args) {
3297
+ const { client, instanceId, target, mode = "set", value, options } = args;
3298
+ for (let attempt = 1; attempt <= CONCURRENT_FIRE_ACTION_MAX_ATTEMPTS; attempt++) {
3299
+ const ctx = await loadCallContext(client, instanceId, options);
3300
+ try {
3301
+ return await commitEdit(ctx, target, mode, value, options);
3302
+ } catch (error) {
3303
+ if (!isRevisionConflict(error)) throw error;
3304
+ }
3305
+ }
3306
+ throw new ConcurrentEditStateError({
3307
+ instanceId,
3308
+ target: labelTarget(target),
3309
+ attempts: CONCURRENT_FIRE_ACTION_MAX_ATTEMPTS
3310
+ });
3311
+ }
3312
+ async function commitEdit(ctx, target, mode, value, options) {
3313
+ const actor = options?.actor, stage = findStage(ctx.definition, ctx.instance.currentStage), slot = resolveEditTarget({ definition: ctx.definition, stage, target });
3314
+ if (slot === void 0)
3315
+ throw new Error(
3316
+ `No state slot "${describeTarget(target)}" resolves in current stage "${stage.name}" of ${ctx.definition.name}`
3317
+ );
3318
+ await assertSlotEditable(ctx, slot, options);
3319
+ const op = buildEditOp(slot, mode, value), mutation = startMutation(ctx.instance), ranOps = runOps({
3320
+ ops: [op],
3321
+ mutation,
3322
+ stage: stage.name,
3323
+ origin: {
3324
+ edit: !0,
3325
+ ...slot.scope === "task" && slot.task !== void 0 ? { task: slot.task } : {}
3326
+ },
3327
+ params: {},
3328
+ actor,
3329
+ self: selfGdr(ctx.instance),
3330
+ now: ctx.now
3331
+ });
3332
+ return await persistThenDeploy(
3333
+ ctx,
3334
+ mutation,
3335
+ () => ranOps.some(isStateOp) ? refreshStageGuards(ctx, mutation, stage.name) : Promise.resolve()
3336
+ ), {
3337
+ edited: !0,
3338
+ target: slotTarget(slot),
3339
+ ...ranOps.length > 0 ? { ranOps } : {}
3340
+ };
3341
+ }
3342
+ async function assertSlotEditable(ctx, slot, options) {
3343
+ const actor = options?.actor, window = slotWindowOpen(ctx.instance, slot), can = options?.grants !== void 0 && actor !== void 0 ? await advisoryCan(ctx.instance, actor, options.grants) : void 0, predicateSatisfied = window.open && slot.effective !== !0 && slot.effective !== void 0 ? await ctxEvaluateCondition(ctx, slot.effective, {
3344
+ ...slot.task !== void 0 ? { taskName: slot.task } : {},
3345
+ ...actor !== void 0 ? { actor } : {},
3346
+ ...can !== void 0 ? { vars: { can } } : {}
3347
+ }) : slot.effective === !0, reason = editDisabledReason({
3348
+ effective: slot.effective,
3349
+ instance: ctx.instance,
3350
+ window,
3351
+ guardDenial: void 0,
3352
+ predicateSatisfied
3353
+ });
3354
+ if (reason !== void 0) throw new EditStateDeniedError({ target: slotTarget(slot), reason });
3355
+ }
3356
+ function buildEditOp(slot, mode, value) {
3357
+ if (mode === "unset") return { type: "state.unset", target: slot.ref };
3358
+ if (value === void 0)
3359
+ throw new Error(`editState mode "${mode}" requires a value for slot "${slot.name}"`);
3360
+ return {
3361
+ type: mode === "append" ? "state.append" : "state.set",
3362
+ target: slot.ref,
3363
+ value: { type: "literal", value }
3364
+ };
3365
+ }
3366
+ function slotTarget(slot) {
3367
+ return {
3368
+ scope: slot.scope,
3369
+ state: slot.name,
3370
+ ...slot.task !== void 0 ? { task: slot.task } : {}
3371
+ };
3372
+ }
3373
+ function labelTarget(target) {
3374
+ return {
3375
+ scope: target.scope ?? (target.task !== void 0 ? "task" : "workflow"),
3376
+ state: target.state,
3377
+ ...target.task !== void 0 ? { task: target.task } : {}
3378
+ };
3379
+ }
3380
+ function describeTarget(target) {
3381
+ const where = target.task !== void 0 ? `${target.task}.${target.state}` : target.state;
3382
+ return target.scope !== void 0 ? `${target.scope}:${where}` : where;
3383
+ }
3107
3384
  function bareIdFromSpawnRef(uri) {
3108
3385
  if (!uri.includes(":")) return [uri];
3109
3386
  try {
@@ -3150,18 +3427,60 @@ function buildClientForGdr(defaultClient, resolver) {
3150
3427
  function toEffectsContextEntries(ctx) {
3151
3428
  return Object.entries(ctx).map(([id, value]) => effectsContextEntry(id, value));
3152
3429
  }
3430
+ async function dispatchGatedWrite(args) {
3431
+ const { client, tag, instanceId, resourceClients, access, apply } = args, evaluation = await evaluateInstance({
3432
+ client,
3433
+ tag,
3434
+ instanceId,
3435
+ access,
3436
+ clock: args.clock,
3437
+ ...resourceClients !== void 0 ? { resourceClients } : {}
3438
+ }), ranOps = await apply(args.before, evaluation), cascaded = await cascade(client, instanceId, args.actor, args.clientForGdr, args.clock);
3439
+ return {
3440
+ instance: await reload(client, instanceId, tag),
3441
+ cascaded,
3442
+ fired: !0,
3443
+ ...ranOps !== void 0 ? { ranOps } : {}
3444
+ };
3445
+ }
3153
3446
  function assertActionAllowed(evaluation, task, action) {
3154
3447
  const actionEval = evaluation.currentStage.tasks.find((t) => t.task.name === task)?.actions.find((a) => a.action.name === action);
3155
3448
  if (actionEval?.allowed === !1 && actionEval.disabledReason)
3156
3449
  throw new ActionDisabledError({ task, action, reason: actionEval.disabledReason });
3157
3450
  }
3158
- async function applyAction(args) {
3159
- const { client, instance, task, action, params, actor, grants, clock, clientForGdr } = args, options = {
3160
- actor,
3161
- ...grants !== void 0 ? { grants } : {},
3162
- ...clock !== void 0 ? { clock } : {},
3163
- ...clientForGdr !== void 0 ? { clientForGdr } : {}
3451
+ function assertEditAllowed(evaluation, target) {
3452
+ const slot = evaluation.editableSlots.filter((s) => editTargetMatches(s, target)).sort((a, b) => SCOPE_PRECEDENCE[a.scope] - SCOPE_PRECEDENCE[b.scope])[0];
3453
+ if (slot !== void 0 && !slot.editable && slot.disabledReason !== void 0)
3454
+ throw new EditStateDeniedError({
3455
+ target: {
3456
+ scope: slot.scope,
3457
+ state: slot.name,
3458
+ ...slot.task !== void 0 ? { task: slot.task } : {}
3459
+ },
3460
+ reason: slot.disabledReason
3461
+ });
3462
+ }
3463
+ async function applyEdit(args) {
3464
+ const { client, instance, target, mode, value, actor, grants, clock, clientForGdr } = args;
3465
+ return (await editState({
3466
+ client,
3467
+ instanceId: instance._id,
3468
+ target,
3469
+ ...mode !== void 0 ? { mode } : {},
3470
+ ...value !== void 0 ? { value } : {},
3471
+ options: buildEngineCallOptions({ actor, grants, clock, clientForGdr })
3472
+ })).ranOps;
3473
+ }
3474
+ function buildEngineCallOptions(args) {
3475
+ return {
3476
+ actor: args.actor,
3477
+ ...args.grants !== void 0 ? { grants: args.grants } : {},
3478
+ ...args.clock !== void 0 ? { clock: args.clock } : {},
3479
+ ...args.clientForGdr !== void 0 ? { clientForGdr: args.clientForGdr } : {}
3164
3480
  };
3481
+ }
3482
+ async function applyAction(args) {
3483
+ const { client, instance, task, action, params, actor, grants, clock, clientForGdr } = args, options = buildEngineCallOptions({ actor, grants, clock, clientForGdr });
3165
3484
  return findOpenStageEntry(instance)?.tasks.find((t) => t.name === task)?.status === "pending" && await invokeTask({ client, instanceId: instance._id, task, options }), (await fireAction({
3166
3485
  client,
3167
3486
  instanceId: instance._id,
@@ -3377,34 +3696,68 @@ const workflow = {
3377
3696
  idempotent,
3378
3697
  resourceClients
3379
3698
  } = args, { access, actor, clientForGdr } = await resolveOperationContext(args), clock = args.clock ?? wallClock, before = await reload(client, instanceId, tag);
3380
- if (findOpenStageEntry(before)?.tasks.find((t) => t.name === task) === void 0 && idempotent === !0)
3381
- return { instance: before, cascaded: 0, fired: !1 };
3382
- const evaluation = await evaluateInstance({
3699
+ return findOpenStageEntry(before)?.tasks.find((t) => t.name === task) === void 0 && idempotent === !0 ? { instance: before, cascaded: 0, fired: !1 } : dispatchGatedWrite({
3383
3700
  client,
3384
3701
  tag,
3385
3702
  instanceId,
3386
3703
  access,
3704
+ actor,
3705
+ clientForGdr,
3387
3706
  clock,
3388
- ...resourceClients !== void 0 ? { resourceClients } : {}
3707
+ before,
3708
+ ...resourceClients !== void 0 ? { resourceClients } : {},
3709
+ apply: (instance, evaluation) => (assertActionAllowed(evaluation, task, action), applyAction({
3710
+ client,
3711
+ instance,
3712
+ task,
3713
+ action,
3714
+ actor,
3715
+ ...access.grants !== void 0 ? { grants: access.grants } : {},
3716
+ clock,
3717
+ clientForGdr,
3718
+ ...params !== void 0 ? { params } : {}
3719
+ }))
3389
3720
  });
3390
- assertActionAllowed(evaluation, task, action);
3391
- const ranOps = await applyAction({
3721
+ },
3722
+ /**
3723
+ * Edit a declared-editable state slot directly — reassign, reschedule,
3724
+ * claim-by-hand, append to a running log — through the generic edit seam,
3725
+ * instead of a bespoke action per field. Soft-gates on the slot's declared
3726
+ * editability (the same projection a UI renders), applies the edit as a
3727
+ * `state.*` op (so provenance + history are stamped by the op path),
3728
+ * refreshes the stage's guards, then cascades — an edit to a value a
3729
+ * transition reads can and should move the instance. Advisory like every
3730
+ * engine gate; the lake ACL + guards are the only hard locks.
3731
+ *
3732
+ * Each call is a discrete COMMIT (a history entry, a guard refresh, a
3733
+ * cascade, an `ifRevisionId` write), not a draft patch — so an inline-field
3734
+ * UI must bind it to a deliberate boundary (blur / Enter / Save / debounce),
3735
+ * never an `onChange` per keystroke.
3736
+ */
3737
+ editState: async (args) => {
3738
+ const { client, tag, instanceId, target, mode, value, resourceClients } = args, { access, actor, clientForGdr } = await resolveOperationContext(args), clock = args.clock ?? wallClock, before = await reload(client, instanceId, tag);
3739
+ return dispatchGatedWrite({
3392
3740
  client,
3393
- instance: before,
3394
- task,
3395
- action,
3741
+ tag,
3742
+ instanceId,
3743
+ access,
3396
3744
  actor,
3397
- ...access.grants !== void 0 ? { grants: access.grants } : {},
3398
- clock,
3399
3745
  clientForGdr,
3400
- ...params !== void 0 ? { params } : {}
3401
- }), cascaded = await cascade(client, instanceId, actor, clientForGdr, clock);
3402
- return {
3403
- instance: await reload(client, instanceId, tag),
3404
- cascaded,
3405
- fired: !0,
3406
- ...ranOps !== void 0 ? { ranOps } : {}
3407
- };
3746
+ clock,
3747
+ before,
3748
+ ...resourceClients !== void 0 ? { resourceClients } : {},
3749
+ apply: (instance, evaluation) => (assertEditAllowed(evaluation, target), applyEdit({
3750
+ client,
3751
+ instance,
3752
+ target,
3753
+ ...mode !== void 0 ? { mode } : {},
3754
+ ...value !== void 0 ? { value } : {},
3755
+ actor,
3756
+ ...access.grants !== void 0 ? { grants: access.grants } : {},
3757
+ clock,
3758
+ clientForGdr
3759
+ }))
3760
+ });
3408
3761
  },
3409
3762
  /**
3410
3763
  * Report a queued effect's outcome. Drains it from `pendingEffects`,
@@ -3822,6 +4175,9 @@ function createInstanceSession(args) {
3822
4175
  ...clock !== void 0 ? { now: clock() } : {},
3823
4176
  ...grants !== void 0 ? { grants } : {}
3824
4177
  });
4178
+ }, settleAfterApply = async (actor, held, ranOps) => {
4179
+ const cascaded = await cascade(client, instance._id, actor, clientForGdr, clock, held);
4180
+ return instance = await reload(client, instance._id, tag), { instance, cascaded, fired: !0, ...ranOps !== void 0 ? { ranOps } : {} };
3825
4181
  }, commit = async (run) => {
3826
4182
  committing = !0;
3827
4183
  const held = new Map(overlay);
@@ -3872,8 +4228,25 @@ function createInstanceSession(args) {
3872
4228
  ...grants !== void 0 ? { grants } : {},
3873
4229
  ...clock !== void 0 ? { clock } : {},
3874
4230
  ...params !== void 0 ? { params } : {}
3875
- }), cascaded = await cascade(client, instance._id, actor, clientForGdr, clock, held);
3876
- return instance = await reload(client, instance._id, tag), { instance, cascaded, fired: !0, ...ranOps !== void 0 ? { ranOps } : {} };
4231
+ });
4232
+ return settleAfterApply(actor, held, ranOps);
4233
+ });
4234
+ },
4235
+ editState({ target, mode, value }) {
4236
+ return commit(async (actor, held) => {
4237
+ assertEditAllowed(await evaluateWith(held, heldGuards), target);
4238
+ const { grants } = await access(), ranOps = await applyEdit({
4239
+ client,
4240
+ instance,
4241
+ target,
4242
+ ...mode !== void 0 ? { mode } : {},
4243
+ ...value !== void 0 ? { value } : {},
4244
+ actor,
4245
+ clientForGdr,
4246
+ ...grants !== void 0 ? { grants } : {},
4247
+ ...clock !== void 0 ? { clock } : {}
4248
+ });
4249
+ return settleAfterApply(actor, held, ranOps);
3877
4250
  });
3878
4251
  }
3879
4252
  };
@@ -3921,6 +4294,7 @@ function createEngine(args) {
3921
4294
  deployDefinitions: (rest) => workflow.deployDefinitions(bind(rest)),
3922
4295
  startInstance: (rest) => workflow.startInstance(bind(rest)),
3923
4296
  fireAction: (rest) => workflow.fireAction(bind(rest)),
4297
+ editState: (rest) => workflow.editState(bind(rest)),
3924
4298
  completeEffect: (rest) => workflow.completeEffect(bind(rest)),
3925
4299
  tick: (rest) => workflow.tick(bind(rest)),
3926
4300
  evaluateInstance: (rest) => evaluateInstance(bind(rest)),
@@ -4216,10 +4590,12 @@ export {
4216
4590
  ActionDisabledError,
4217
4591
  ActionParamsInvalidError,
4218
4592
  CascadeLimitError,
4593
+ ConcurrentEditStateError,
4219
4594
  ConcurrentFireActionError,
4220
4595
  DEFAULT_CONTENT_PERSPECTIVE,
4221
4596
  DISPLAY,
4222
4597
  EFFECTS_CONTEXT_DISPLAY,
4598
+ EditStateDeniedError,
4223
4599
  GUARD_DOC_TYPE,
4224
4600
  GUARD_LIFTED_PREDICATE,
4225
4601
  GUARD_OWNER,