flow-authoring-stress.test.mjs
153 lines 5.5 KB
Raw
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 15 hours ago
1 /**
2 * Tier 4 — STRESS: large drafts + concurrent proposals against one flow_id.
3 *
4 * @see lib/flow/flow-authoring.mjs
5 * @see docs/FLOW-AUTHORING-WRITEBACK-CONTRACT-7A-L1.md §7
6 */
7 import { describe, it, beforeEach, afterEach } from 'node:test';
8 import assert from 'node:assert/strict';
9 import fs from 'node:fs';
10 import path from 'node:path';
11 import { fileURLToPath } from 'node:url';
12 import {
13 handleFlowProposeRequest,
14 precheckApprovedFlowProposal,
15 applyFlowProposalToIndex,
16 flowStateId,
17 } from '../lib/flow/flow-authoring.mjs';
18 import {
19 getFlow,
20 flowDefinitionForClient,
21 latestStoredFlow,
22 loadFlowStore,
23 MAX_STEPS_PER_FLOW,
24 buildFlowStepId,
25 } from '../lib/flow/flow-store.mjs';
26 import { createProposal, getProposal, updateProposalStatus } from '../hub/proposals-store.mjs';
27 import { emptyStarterDir } from './fixtures/flow/authoring-helpers.mjs';
28
29 const __dirname = path.dirname(fileURLToPath(import.meta.url));
30 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-flow-authoring-stress');
31 const visible = new Set(['personal', 'project', 'org']);
32
33 function bigFlow(flowId, version, stepCount) {
34 const steps = [];
35 const stepRefs = [];
36 for (let i = 1; i <= stepCount; i += 1) {
37 const stepId = buildFlowStepId(flowId, i);
38 stepRefs.push(stepId);
39 steps.push({
40 schema: 'knowtation.flow_step/v0',
41 step_id: stepId,
42 flow_id: flowId,
43 ordinal: i,
44 owned_job: `Job ${i}`,
45 instruction: `Do step ${i} faithfully.`,
46 trigger: `Run for step ${i}.`,
47 when_not_to_run: `Skip step ${i} when not applicable.`,
48 boundaries: ['Read only', 'Untrusted input'],
49 output_shape: `Output of step ${i}.`,
50 verification: { kind: 'artifact_exists', evidence_required: true, description: `Step ${i} artifact exists.` },
51 automatable: 'manual',
52 });
53 }
54 return {
55 flow: {
56 schema: 'knowtation.flow/v0',
57 flow_id: flowId,
58 title: 'Large flow',
59 version,
60 scope: 'personal',
61 summary: 'A large flow for stress testing.',
62 tags: ['stress'],
63 steps: stepRefs,
64 inputs: [],
65 updated: '2026-06-20T00:00:00Z',
66 truncated: false,
67 },
68 steps,
69 };
70 }
71
72 describe('Flow authoring — stress', () => {
73 const dataDir = path.join(tmpRoot, 'data');
74 const vaultId = 'default';
75
76 beforeEach(() => {
77 fs.rmSync(tmpRoot, { recursive: true, force: true });
78 fs.mkdirSync(dataDir, { recursive: true });
79 process.env.FLOW_AUTHORING_WRITES = '1';
80 });
81 afterEach(() => {
82 fs.rmSync(tmpRoot, { recursive: true, force: true });
83 delete process.env.FLOW_AUTHORING_WRITES;
84 });
85
86 it('accepts a MAX_STEPS_PER_FLOW-step draft and reconciles it', () => {
87 const bundle = bigFlow('flow_big', '1.0.0', MAX_STEPS_PER_FLOW);
88 const proposed = handleFlowProposeRequest({
89 dataDir, vaultId, visibleScopes: visible, kind: 'new',
90 flow: bundle.flow, steps: bundle.steps, intent: 'big', createProposal,
91 });
92 assert.equal(proposed.ok, true);
93 const pre = precheckApprovedFlowProposal(dataDir, getProposal(dataDir, proposed.payload.proposal_id));
94 assert.equal(pre.ok, true);
95 applyFlowProposalToIndex(dataDir, pre.vaultId, pre.flow, pre.steps);
96 const got = getFlow(dataDir, vaultId, 'flow_big', { filterScopes: visible, starterDir: emptyStarterDir(dataDir) });
97 assert.ok(got);
98 assert.equal(got.steps.length, MAX_STEPS_PER_FLOW);
99 });
100
101 it('many concurrent edits on one flow_id: exactly one approves, the rest conflict', () => {
102 const seed = bigFlow('flow_race', '1.0.0', 3);
103 const seedProposed = handleFlowProposeRequest({
104 dataDir, vaultId, visibleScopes: visible, kind: 'new',
105 flow: seed.flow, steps: seed.steps, intent: 'seed', createProposal,
106 });
107 const seedPre = precheckApprovedFlowProposal(dataDir, getProposal(dataDir, seedProposed.payload.proposal_id));
108 applyFlowProposalToIndex(dataDir, seedPre.vaultId, seedPre.flow, seedPre.steps);
109
110 const store = loadFlowStore(dataDir);
111 const cur = latestStoredFlow(store.vaults[vaultId], 'flow_race');
112 const canonical = flowDefinitionForClient(cur.flow, cur.steps);
113 const baseStateId = flowStateId(canonical.flow, canonical.steps);
114
115 // 10 racers all base on the same v1.0.0 state.
116 const proposalIds = [];
117 for (let i = 0; i < 10; i += 1) {
118 const edited = structuredClone(canonical);
119 edited.flow.version = '1.0.1';
120 edited.flow.summary = `racer ${i}`;
121 const r = handleFlowProposeRequest({
122 dataDir, vaultId, visibleScopes: visible, kind: 'edit',
123 flow: edited.flow, steps: edited.steps, intent: `race ${i}`, flowId: 'flow_race',
124 baseVersion: '1.0.0', baseStateId, createProposal,
125 });
126 assert.equal(r.ok, true);
127 proposalIds.push(r.payload.proposal_id);
128 }
129
130 let approved = 0;
131 let conflicts = 0;
132 for (const id of proposalIds) {
133 const pre = precheckApprovedFlowProposal(dataDir, getProposal(dataDir, id));
134 if (pre.ok) {
135 applyFlowProposalToIndex(dataDir, pre.vaultId, pre.flow, pre.steps);
136 updateProposalStatus(dataDir, id, 'approved');
137 approved += 1;
138 } else {
139 assert.equal(pre.code, 'FLOW_LINEAGE_CONFLICT');
140 conflicts += 1;
141 }
142 }
143 assert.equal(approved, 1);
144 assert.equal(conflicts, 9);
145
146 // No index corruption: exactly two version rows (1.0.0 + 1.0.1).
147 const finalStore = loadFlowStore(dataDir);
148 const rows = finalStore.vaults[vaultId].flows.filter((f) => f.flow_id === 'flow_race');
149 assert.equal(rows.length, 2);
150 const versions = rows.map((r) => r.version).sort();
151 assert.deepEqual(versions, ['1.0.0', '1.0.1']);
152 });
153 });
File History 1 commit
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 15 hours ago