One user edit cascaded into 3-5 workflow executions and hourly sync jobs
generated 1000+ wasted runs/week (7d: 15.7k of 18k executions of the
ticket-status workflow ended in skipped no-op guards). Root cause: workflow
scripts write records via REST with a runner-signed token, and record-event
triggers could not distinguish those writes from human edits — every script
PATCH re-triggered its own workflow.
Attribution chain (new):
- WorkflowTokenService signs {workflowId, executionId} into the runner token
- jwt.strategy lifts it into CLS workflowContext (User-type branch)
- all 13 automation.record.* emit sites tag payloads with writeSource
- the 4 record triggers drop SELF echoes (writeSource.workflowId === own id)
unconditionally; cross-workflow writes keep firing by default (chaining is
load-bearing: order-sync建档 -> status workflow stamps 待处理, ~142 real
actions/week). Opt-in stronger dedup via trigger config
skipWorkflowWrites: true.
Verified live: human edit (处理反馈=正在核实) -> exactly 1 execution, script
flipped 流转状态 待处理->处理中, zero follow-up echoes; restore writes likewise
1 execution each with skipped no-ops. Unit: 14 new tests, 318 adjacent green.
Parse LAST_MODIFIED_TIME([Name]/{fldId}) references into trackedFieldIds so
the modification time only resets when a referenced field changes.
- core: relax expression from z.literal('LAST_MODIFIED_TIME()') to z.string(),
add parseLastModifiedTimeReferences() + spec
- v1 field-supplement.service: resolve trackedFieldIds from expression on
create/convert (incl. same-type merge), 400 on unknown field name
- v2 field-open-api-v2.service: resolve trackedFieldIds from expression on
create/createFields/convert
- docs: field-types.md tracked-field reference section
Investigation of the 'grid edits bypass the REST layer' report found the
ShareDB socket submit path is dead code (adapter db.commit() throws and no
frontend submitOp callsites exist) — grid cell edits already flow through
record.updateCell -> REST updateRecord and DO fire triggers. The real
coverage gaps were the selection-layer writes that call the open-api
SERVICES below the controller where the manual emits live:
- v1 selection paste/paste-by-id, clear/clear-by-id (grid paste, Delete-key
clear): emitted nothing. Now emit automation.record.create/update with
full post-write records + changedFieldIds/oldFields diffs (best-effort —
emission failure logs but never fails the write).
- v1 record duplicate: emitted nothing. Now emits automation.record.create.
- v2 paste endpoints (range paste + paste-stream + paste-by-id-stream):
returned only counts. PasteResult/PasteStreamDoneEvent now carry per-record
update diffs and created-record values through to the nest layer, which
emits create/update events (full record + diff metadata).
- v2 record duplicate: now emits automation.record.create.
Form submissions intentionally remain formSubmitted-only (independent
channel); deletions have no trigger category. Docs corrected: grid cell
edits fire triggers (they are REST), coverage table updated.
Verified live: duplicate / range paste / stream paste on a v2 base each
produced a workflow execution with full triggerInput; selection e2e
114/114, PasteHandler specs 30/30, selection unit 31/31.
Wrapping --config in {"config":{...}} deploys a silent empty script
(succeeds in ~1ms, result {}). Document the trap + verification recipe
(workflow get --json, nodes[].config.code non-empty).
Engine gap: the 'automation.record.update' event carries changedFieldIds
(and now oldFields), but RecordUpdatedTrigger.accumulate() dropped both —
scripts had no way to see what changed or pre-update values, despite the
documented trigger output (trigger-types.md).
- record-updated/record-condition/record-created-or-updated triggers:
pass changedFieldIds + oldFields through to accumulate()
- batch-buffer TriggerEvent += oldFields; batch flush records[] entries
now carry changedFieldIds + oldFields per record
- v2 updateRecord/updateRecords: emit oldFields (pre-update snapshot)
- v1 controller updateRecord/updateRecords: snapshot before-fields via
getRecordsById, compute changedFieldIds (deep-equal diff, same
semantics as v2) instead of hardcoded [] — v1/v2 parity
- new spec record-updated.trigger.spec.ts (4 tests, passthrough +
watch-filter + table isolation); workflow suite 16 files / 99 tests
- trigger-types.md: document semantics + honest scope (REST API writes;
ShareDB grid edits do not fire this trigger)
Live-verified: PATCH 流转角色 → trigger_input.changedFieldIds=[fld...],
oldFields=[售前客服] → record=[技术]; script reads oldRole via
triggerInput; fieldIds watch-filter blocks non-watched fields.
Scripts can now branch on WHO triggered the workflow and their role in the
base, without an extra API call (the workflow token has no role-read scope).
New triggerInput fields (user-initiated triggers only: buttonClick /
recordCreated / recordUpdated / recordCondition / formSubmitted):
- _triggerUserRoles: string[] (enabled role display names in this base)
- _triggerUserIsAdmin: boolean (space owner / AM admin / AM-disabled)
Absent for scheduledTime / webhook (no user context).
Implementation:
- trigger-user-info.ts: resolveTriggerUserInfo(prisma, baseId, userId) —
standalone, unit-tested (8 tests). Mirrors the admin logic in
AuthorityMatrixPermissionService but returns only role names + bool.
- workflow-runner.service.ts: resolve once at first action node, merge into
enrichedInput (both execute() and test() paths).
- skill action-types.md: document the fields + script branching example.
- action add/update: new --dependencies <json> option
- parseDependencies accepts JSON, name@version shorthand, or bare names
- update is incremental; --dependencies replaces the whole array
- docs: deployment/systemd.md + skill SKILL/action-types/development
document the npm-deps feature and the service-PATH requirement
(spawn npm ENOENT without nvm bin dir on PATH)
Ran 4 end-to-end evals (skill-creator flow): all 23 assertions passed (100%).
The evals exposed 3 issues, now fixed:
1. CLI bug: getRecords/createRecord/updateRecord --target-table flag was
read as opts['target-table'] but commander stores it camelCase
(opts.targetTable). Added a pick() helper accepting both forms.
2. trigger-types.md: buttonClick output said record.fields.* implying all
fields; actually record.fields only contains the button field's count.
Documented the back-channel API fetch pattern for other fields.
3. automation-guide.md: documented that 'workflow test' uses mock input
with empty fields, so upstream outputs are absent in test mode — use
real fire for data-dependent validation.
Also: corrected button field options (workflow.id object, not workflowId).
Rename the skill directory and update all references (SKILL.md frontmatter
name, AGENTS.md, design doc, install.sh). Git tracks this as a rename so
history is preserved.