Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1559-workflow-trigger-word-boundary.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Workflow auto-assign now matches an ASCII trigger only as a whole word, so `notice` no longer routes a card that merely says `noticeable`, and `pr` no longer routes `Improve`, `Prepare` or `Sprint`. The whole word still routes (`notice`, `pr`), and non-ASCII triggers such as `周报` keep matching inside a longer title. The two-character trigger guard is unchanged. ([#1559](https://github.com/ThinkFlowLab/FlyBot/issues/1559))
2 changes: 1 addition & 1 deletion docs/subsystems/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher,
5. A node declares up to eight inputs and eight outputs. A required input is an output of the same name on an ancestor, or `task`, `title`, or `brief` on a root. A start with no provider is refused, and Bots spawned for that attempt are released. On a Bot-backed run a successor stays queued until its required inputs are present. If every forward predecessor is done, failed or skipped and an input is still missing, that node fails and the run stops. The dispatch text carries `Input <name>: <value>`. The Bot reports `OUTPUT <name>: <value>`; a single declared output may be the reply body. Nodes start in edge order.
6. Each node is a real ephemeral Bot for that run, then it is removed. A permanent sidebar Bot (`botId`) is optional and is not required for the step to run. **File and shell rights are the same as a normal sidebar Bot's:** every node Bot gets the `danger-full-access` preset, and `EPHEMERAL_DENIED_TOOLS` still refuses the team-admin tools at the guard and approval layers (#1466, 29 names). **`node.tools` selects connectors only:** `spawn` passes the node's cleaned list to `fb.bot.create`, the host records it as `stepTools`, and `connector-access.js` allows `mcp__github__*` for `github` and `mcp__composio__*` for `composio` — a connector the node did not name is refused, and a node that names none reaches none. The list is also stored in `fields.tools` for the UI. A hidden run Bot cannot show an approval card, so the approval seam answers the same way the guard does. The host stores this on the run Bot as `stepTools` and `permission`.
7. A loop edge (`loop: true`, `max` from 1 to 8, default 3, optional `until: { output, eq }`) is the only cycle for success paths. The body runs again until `until` matches or `max` runs have finished, and the forward edges wait until then. Restarting the body drops the branch choice made inside it and queues a sibling that the previous pass skipped, so the next pass can take the other side. A `when: { output, eq }` edge is taken only when that output equals `eq`. `when: { else: true }` is taken only when no other `when` matched. A node whose forward edges are all closed is `skipped` and does not block a join. A run cannot spin: every loop has a max. An `onFail` edge (`onFail: true`, `max` from 1 to 8, default 3) provides failure recovery: when a node fails, if an `onFail` edge points from that node back to an upstream target and recovery attempts remain (tracked in `failAttempts` per edge), the workflow resets both nodes to their pre-failure states (the target to `running`, the failing node to `queued`) and re-runs from the target through the failing node. After `max` attempts are exhausted, the node fails permanently and the run stops. An `onFail` edge does not interfere with `failOnce` retry (which restarts the same node once) or success-path `loop` edges. Settings → Workflow graphs can add a link, a condition, a loop, or a failure recovery edge.
8. Auto-assign considers an `open` card only. A saved Bot claim beats a workflow trigger. Otherwise first hit wins: a `workflow:` token, then `workflowId` / `workflow`, then an assignee that names a workflow, then a `bot:` token, then an assignee that names a local Bot, then the longest trigger contained in the title, note or criteria. A miss returns no route. A second ingest of the same task and target is a no-op, and a live run for that task id is not restarted.
8. Auto-assign considers an `open` card only. A saved Bot claim beats a workflow trigger. Otherwise first hit wins: a `workflow:` token, then `workflowId` / `workflow`, then an assignee that names a workflow, then a `bot:` token, then an assignee that names a local Bot, then the longest trigger in the title, note, criteria or tags — an ASCII trigger must match a whole word (`notice` does not match `noticeable`, `pr` does not match `Improve`), while a non-ASCII trigger matches inside a longer text because CJK has no word boundary. A miss returns no route. A second ingest of the same task and target is a no-op, and a live run for that task id is not restarted.
9. The browser draws text only. Names and messages are React text children. The unit renderer asserts a hostile name is not markup.
10. `enabled: false` registers no endpoint, tool or listener and writes nothing.
11. A sidebar click opens the stage or a dialog and leaves the Workflows section in place. The stage and the dialogs portal onto `document.body`. When that container is missing they stay in the section tree; the click does not throw.
Expand Down
2 changes: 1 addition & 1 deletion plugins/workflow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ This is not the workflow-definition engine (`workflows/<id>.json`, #1272). This
3. `assignee` naming a workflow
4. a `bot:<id or name>` token
5. `assignee` naming a local Bot (a remote Bot never matches)
6. a workflow trigger contained in the title, note or criteria (longest trigger wins)
6. a workflow trigger in the title, note, criteria or tags (longest trigger wins; an ASCII trigger must be a whole word, so `notice` does not match `noticeable` and `pr` does not match `Improve`, while a non-ASCII trigger matches inside a longer text)
- A miss stays unassigned for the manual picker.

## A live run
Expand Down
19 changes: 16 additions & 3 deletions plugins/workflow/src/match.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
// 3. `assignee` naming a workflow (id or exact name).
// 4. A `bot:<id or name>` token.
// 5. `assignee` naming a local Bot (id or exact name). A remote Bot never matches.
// 6. A workflow trigger keyword contained in the title, note or criteria.
// The longest trigger wins. A tie keeps the earlier workflow.
// 6. A workflow trigger keyword in the title, note, criteria or tags.
// The longest trigger wins. A tie keeps the earlier workflow. An ASCII
// trigger must be a whole word (`notice` does not match `noticeable`, `pr`
// does not match `Improve`); a non-ASCII trigger matches inside a longer
// text, because a script such as CJK has no word boundary.
//
// Only an `open` task is considered. Claimed, working and finished cards are left alone.

Expand All @@ -25,6 +28,16 @@
return [task.title, task.note, task.criteria, ...tags].map(text).filter(Boolean).join('\n')
}

// #1559: an ASCII trigger is a word, not a fragment. `notice` must not fire
// on `noticeable` and `pr` must not fire on `Improve`. A trigger that is not
// plain ASCII keeps substring matching, since CJK has no word boundary. The
// two-character minimum is a separate guard and stays where it is.
function containsTrigger(lower, word) {
if (!/^[\x00-\x7f]+$/.test(word)) return lower.includes(word)

Check warning on line 36 in plugins/workflow/src/match.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

eslint(no-control-regex)

Unexpected control character
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return new RegExp(`(?<![a-z0-9_])${escaped}(?![a-z0-9_])`).test(lower)
}

function findWorkflow(workflows, raw) {
const wanted = text(raw).toLowerCase()
if (!wanted) return undefined
Expand Down Expand Up @@ -71,7 +84,7 @@
for (const item of graphs) {
for (const trigger of item.triggers ?? []) {
const word = text(trigger).toLowerCase()
if (word.length < 2 || !lower.includes(word)) continue
if (word.length < 2 || !containsTrigger(lower, word)) continue
if (word.length > bestLength) {
best = item
bestLength = word.length
Expand Down
34 changes: 34 additions & 0 deletions plugins/workflow/test/assign.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,40 @@ test('an open task auto-starts the matching workflow, and a miss stays unassigne
assert.equal(svc.ingest({ id: '#53', title: 'notice', status: 'accepted' }).matched, false)
})

test('an ASCII trigger matches a whole word, and a non-ASCII trigger still matches inside one', () => {
const svc = service()
const workflows = svc.state().workflows
const route = title => matchTask({ id: '#1', title, status: 'open' }, { workflows, bots: [] })

// A trigger inside a longer word is not a hit: the card only mentions it.
assert.equal(route('noticeable bug in logging'), null)
assert.equal(route('notices about the release'), null)
assert.equal(route('noticed at last'), null)

// The whole word is there, so it still routes.
assert.deepEqual(route('notice'), { kind: 'workflow', id: DELIVERY_ID })
assert.deepEqual(route('notice: deploy paused'), { kind: 'workflow', id: DELIVERY_ID })
assert.deepEqual(route('缩短交付组群 notice'), { kind: 'workflow', id: DELIVERY_ID })

// Non-ASCII triggers have no word boundary and keep matching inside a longer text.
assert.deepEqual(route('写周报'), { kind: 'workflow', id: 'weekly' })
assert.deepEqual(route('周五周报'), { kind: 'workflow', id: 'weekly' })

// A two-character ASCII trigger is still legal, but only as its own word.
const prLanes = [{ id: 'wf-pr', name: 'PR lane', triggers: ['pr'] }]
const prRoute = title => matchTask({ id: '#1', title, status: 'open' }, { workflows: prLanes, bots: [] })
assert.equal(prRoute('Improve onboarding docs'), null)
assert.equal(prRoute('Prepare release notes'), null)
assert.equal(prRoute('Sprint planning'), null)
assert.deepEqual(prRoute('pr'), { kind: 'workflow', id: 'wf-pr' })
assert.deepEqual(prRoute('open a PR to fix it'), { kind: 'workflow', id: 'wf-pr' })
assert.deepEqual(prRoute('PR: bump deps'), { kind: 'workflow', id: 'wf-pr' })

// The existing under-two-characters guard is untouched.
const oneChar = [{ id: 'wf-one', name: 'One', triggers: ['x'] }]
assert.equal(matchTask({ id: '#1', title: 'x', status: 'open' }, { workflows: oneChar, bots: [] }), null)
})

test('apply registers the workflow endpoints and stays quiet when disabled', () => {
const names = []
const writes = []
Expand Down
Loading