diff --git a/changelog.d/2598.fixed.md b/changelog.d/2598.fixed.md new file mode 100644 index 000000000..9e7a8fd7a --- /dev/null +++ b/changelog.d/2598.fixed.md @@ -0,0 +1 @@ +A workflow save that the store refused no longer leaves a half-created workflow in memory: the refusal is returned and the graph, its creation line and the sequence number are dropped, so the next successful save cannot persist a workflow the caller was told was not created; a refused rename keeps the name it could not save ([#2598](https://github.com/ThinkFlowLab/FlyBot/issues/2598)). diff --git a/plugins/workflow/src/service.js b/plugins/workflow/src/service.js index 4c5fa3ad8..b347bf594 100644 --- a/plugins/workflow/src/service.js +++ b/plugins/workflow/src/service.js @@ -1270,12 +1270,27 @@ export function createWorkflowService({ if (!item) return { ok: false, error: 'No such workflow' } const name = cleanLine(body.name, 80) if (!name) return { ok: false, error: 'A workflow needs a name' } - item.name = name - if (body.description !== undefined) { - item.description = cleanLine(body.description, 400) + // #2598: the new name is memory until `write()` has landed it, so a + // refused write puts the old one back instead of listing a name the + // store never took. + const before = { name: item.name, description: item.description, hadDescription: Object.hasOwn(item, 'description') } + let written = false + try { + item.name = name + if (body.description !== undefined) { + item.description = cleanLine(body.description, 400) + } + write() + written = true + return present() + } catch (error) { + if (!written) { + item.name = before.name + if (before.hadDescription) item.description = before.description + else delete item.description + } + return refuse(error) } - write() - return present() }, delete(body = {}) { if (readOnly()) return { ok: false, code: 'read-only', error: 'The host is read-only' } @@ -1324,6 +1339,14 @@ export function createWorkflowService({ }, create(body = {}) { if (readOnly()) return { ok: false, code: 'read-only', error: 'The host is read-only' } + // #2598: `write()` refuses before its first byte when the temp leaf is not + // writable, but the caller is told nothing was created. The push and its + // creation line below are memory, so remember what they change and undo + // them when the write refuses: the next successful write must not persist + // a graph the caller never got. + const before = { workflows: state.workflows.length, seq, stateSeq: state.seq } + let created = null + let written = false try { if (body.template) { const template = templateById(body.template) @@ -1360,6 +1383,7 @@ export function createWorkflowService({ ...(Array.isArray(body.edges) ? { edges: body.edges } : { layers: body.layers }), }) if (!item.name) throw new Error('A workflow needs a name') + created = item // #1544: the Bot that wrote the graph caps the connectors a run may reach from it. const authoredBy = cleanLine(body.callerBotId ?? body.authoredBy, 80) || undefined if (authoredBy) item.authoredBy = authoredBy @@ -1378,8 +1402,15 @@ export function createWorkflowService({ text: `Workflow「${item.name}」已创建 · 成员 ${memberNames}`, }], now()) write() + written = true return present() } catch (error) { + if (!written && created) { + state.workflows.length = before.workflows + delete state.logs[created.id] + seq = before.seq + state.seq = before.stateSeq + } return refuse(error) } }, diff --git a/plugins/workflow/test/refused-write-rollback.test.js b/plugins/workflow/test/refused-write-rollback.test.js new file mode 100644 index 000000000..ed5be855a --- /dev/null +++ b/plugins/workflow/test/refused-write-rollback.test.js @@ -0,0 +1,75 @@ +// #2598: a store write that refuses before its first byte must leave memory as +// it was. `write()` refuses when the temp leaf is a link (temp-name sweep), and +// `create()` had already pushed the graph and its creation line by then, so the +// caller was told nothing was created while the next successful write persisted +// it. `rename()` writes before it keeps the new name for the same reason. +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createWorkflowService } from '../src/service.js' +import { DELIVERY_ID } from '../src/template.js' + +const GRAPH = { name: 'Audit graph', nodes: [{ id: 'plan', name: 'Planner' }], edges: [] } + +// The store lands through `