@sqaoss/flowy 1.13.1 → 1.14.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/README.md CHANGED
@@ -263,6 +263,19 @@ bun run typecheck # TypeScript
263
263
  cd server && bunx --bun vitest run # Server tests
264
264
  ```
265
265
 
266
+ ## Open Source vs. Hosted
267
+
268
+ This repository — the `@sqaoss/flowy` CLI **and the bundled local server** it runs
269
+ via `flowy serve` — is open source under Apache-2.0. You can self-host the full
270
+ planning workflow with no account and no subscription; your data stays in a local
271
+ SQLite file on your machine.
272
+
273
+ The hosted service at `flowy-ai.fly.dev` is a separate, commercial offering. It is
274
+ a proprietary backend whose source is **not** part of this repository, and it
275
+ exposes a superset of the open-source server's GraphQL schema plus account and
276
+ billing operations. Using the hosted service is optional — the CLI talks to either
277
+ your local server or the hosted one depending on how you configure it.
278
+
266
279
  ## License
267
280
 
268
281
  Apache-2.0. Copyright 2026 SQA & Automation SRL.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqaoss/flowy",
3
- "version": "1.13.1",
3
+ "version": "1.14.0",
4
4
  "description": "Agentic persistent planning",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,6 +47,8 @@
47
47
  ],
48
48
  "scripts": {
49
49
  "cli": "bun src/index.ts",
50
+ "audit": "bun audit",
51
+ "audit:prod": "bun audit --production --json",
50
52
  "check": "biome check --write .",
51
53
  "test": "vitest run",
52
54
  "test:watch": "vitest",
@@ -255,6 +255,41 @@ describe('CLI/local-server contract (P1-1)', () => {
255
255
  expect(updateNode.title).toBe('Renamed Task')
256
256
  expect(updateNode.metadata).toContain('note')
257
257
 
258
+ // CLAIM_NODE (P2-1/F28): atomically claim a task for work. A fresh draft
259
+ // task is claimable -> flips to in_progress and is returned. A second claim
260
+ // on the same (now in_progress) task loses the race -> claimNode is null.
261
+ // A non-claimable (done) task is also rejected with null. This is the
262
+ // primitive `task claim`/`task next` use so parallel agents never
263
+ // double-claim; the SaaS contract guard mirrors it (flowy-ai v35).
264
+ const { createNode: claimable } = await ok<{
265
+ createNode: { id: string }
266
+ }>(LOCAL_CONTRACT_OPERATIONS.CREATE_TASK, {
267
+ type: 'task',
268
+ title: 'Claimable Task',
269
+ description: 'up for grabs',
270
+ })
271
+ const firstClaim = await ok<{
272
+ claimNode: { id: string; status: string } | null
273
+ }>(LOCAL_CONTRACT_OPERATIONS.CLAIM_NODE, { id: claimable.id })
274
+ expect(firstClaim.claimNode?.id).toBe(claimable.id)
275
+ expect(firstClaim.claimNode?.status).toBe('in_progress')
276
+ // Second claim on the same task loses: already in_progress, not claimable.
277
+ const secondClaim = await ok<{ claimNode: { id: string } | null }>(
278
+ LOCAL_CONTRACT_OPERATIONS.CLAIM_NODE,
279
+ { id: claimable.id },
280
+ )
281
+ expect(secondClaim.claimNode).toBeNull()
282
+ // A done task is never claimable.
283
+ await ok(LOCAL_CONTRACT_OPERATIONS.UPDATE_STATUS, {
284
+ id: claimable.id,
285
+ status: 'done',
286
+ })
287
+ const doneClaim = await ok<{ claimNode: { id: string } | null }>(
288
+ LOCAL_CONTRACT_OPERATIONS.CLAIM_NODE,
289
+ { id: claimable.id },
290
+ )
291
+ expect(doneClaim.claimNode).toBeNull()
292
+
258
293
  // --- Queries: reads ----------------------------------------------------
259
294
  const getNode = await ok<{ node: { id: string } | null }>(
260
295
  LOCAL_CONTRACT_OPERATIONS.GET_NODE,
@@ -489,6 +524,7 @@ describe('CLI/local-server contract (P1-1)', () => {
489
524
  'UPDATE_NODE',
490
525
  'UPDATE_STATUS',
491
526
  'APPROVE_NODE',
527
+ 'CLAIM_NODE',
492
528
  'DELETE_NODE',
493
529
  'CREATE_EDGE',
494
530
  'LINK_TASK',
@@ -395,6 +395,40 @@ describe('createResolvers', () => {
395
395
  expect(e.extensions?.code).toBe('VALIDATION_ERROR')
396
396
  }
397
397
  })
398
+
399
+ it('applies the row update and its audit rows in one transaction (F28)', () => {
400
+ // The read-modify-write must be a single transaction so a node update and
401
+ // its audit trail commit together. We assert the observable contract: a
402
+ // multi-field update lands the row change AND exactly the matching audit
403
+ // entries (no partial application).
404
+ const node = create(resolvers, {
405
+ type: 'task',
406
+ title: 'Old',
407
+ description: 'old body',
408
+ })
409
+ const auditBefore = resolvers.Query.auditLog(null, {
410
+ nodeId: node.id,
411
+ }).length
412
+ const updated = resolvers.Mutation.updateNode(null, {
413
+ id: node.id,
414
+ title: 'New',
415
+ status: 'in_progress',
416
+ })
417
+ expect(updated.title).toBe('New')
418
+ expect(updated.status).toBe('in_progress')
419
+ // Row change is persisted (read-back).
420
+ const persisted = find(resolvers, node.id)
421
+ expect(persisted.title).toBe('New')
422
+ expect(persisted.status).toBe('in_progress')
423
+ // Two field diffs -> two new audit rows (a title `update` + a
424
+ // `status_change`), committed in the same unit as the row write.
425
+ const history = resolvers.Query.auditLog(null, { nodeId: node.id })
426
+ expect(history.length).toBe(auditBefore + 2)
427
+ expect(history.some((e) => e.action === 'status_change')).toBe(true)
428
+ expect(
429
+ history.some((e) => e.action === 'update' && e.field === 'title'),
430
+ ).toBe(true)
431
+ })
398
432
  })
399
433
 
400
434
  describe('Mutation.deleteNode', () => {
@@ -514,6 +548,73 @@ describe('createResolvers', () => {
514
548
  })
515
549
  })
516
550
 
551
+ describe('Mutation.claimNode (F28 atomic CAS)', () => {
552
+ const CLAIMABLE = ['draft', 'pending_review', 'approved', 'blocked']
553
+
554
+ it.each(
555
+ CLAIMABLE,
556
+ )('claims a %s task into in_progress and returns it', (status) => {
557
+ const node = create(resolvers, { type: 'task', title: 'Claim me' })
558
+ if (status !== 'draft') {
559
+ resolvers.Mutation.updateNode(null, { id: node.id, status })
560
+ }
561
+ const claimed = resolvers.Mutation.claimNode(null, { id: node.id })
562
+ expect(claimed).not.toBeNull()
563
+ expect(claimed?.id).toBe(node.id)
564
+ expect(claimed?.status).toBe('in_progress')
565
+ // The change is persisted, not just returned.
566
+ expect(find(resolvers, node.id).status).toBe('in_progress')
567
+ })
568
+
569
+ it('returns null when the node is already in_progress (lost the race)', () => {
570
+ const node = create(resolvers, { type: 'task', title: 'Hot' })
571
+ const first = resolvers.Mutation.claimNode(null, { id: node.id })
572
+ expect(first?.status).toBe('in_progress')
573
+ // A second claim on the same node loses: CAS no longer matches.
574
+ const second = resolvers.Mutation.claimNode(null, { id: node.id })
575
+ expect(second).toBeNull()
576
+ // Still in_progress, untouched by the losing claim.
577
+ expect(find(resolvers, node.id).status).toBe('in_progress')
578
+ })
579
+
580
+ it.each([
581
+ 'done',
582
+ 'cancelled',
583
+ 'in_progress',
584
+ ])('returns null for a non-claimable %s node', (status) => {
585
+ const node = create(resolvers, { type: 'task', title: 'Nope' })
586
+ resolvers.Mutation.updateNode(null, { id: node.id, status })
587
+ const claimed = resolvers.Mutation.claimNode(null, { id: node.id })
588
+ expect(claimed).toBeNull()
589
+ expect(find(resolvers, node.id).status).toBe(status)
590
+ })
591
+
592
+ it('returns null for a nonexistent node (nothing to claim)', () => {
593
+ expect(
594
+ resolvers.Mutation.claimNode(null, { id: 'task_missing' }),
595
+ ).toBeNull()
596
+ })
597
+
598
+ it('records a status_change audit entry on a successful claim', () => {
599
+ const node = create(resolvers, { type: 'task', title: 'Audited' })
600
+ resolvers.Mutation.claimNode(null, { id: node.id })
601
+ const history = resolvers.Query.auditLog(null, { nodeId: node.id })
602
+ const statusChange = history.find((e) => e.action === 'status_change')
603
+ expect(statusChange?.field).toBe('status')
604
+ expect(statusChange?.oldValue).toBe('draft')
605
+ expect(statusChange?.newValue).toBe('in_progress')
606
+ })
607
+
608
+ it('writes no audit entry when the claim loses', () => {
609
+ const node = create(resolvers, { type: 'task', title: 'Loser' })
610
+ resolvers.Mutation.claimNode(null, { id: node.id })
611
+ const before = resolvers.Query.auditLog(null, { nodeId: node.id }).length
612
+ resolvers.Mutation.claimNode(null, { id: node.id }) // loses
613
+ const after = resolvers.Query.auditLog(null, { nodeId: node.id }).length
614
+ expect(after).toBe(before)
615
+ })
616
+ })
617
+
517
618
  describe('Mutation.createEdge', () => {
518
619
  it('links two nodes', () => {
519
620
  const project = resolvers.Mutation.createNode(null, {
@@ -118,6 +118,15 @@ const VALID_STATUSES = new Set([
118
118
  'cancelled',
119
119
  ])
120
120
 
121
+ /**
122
+ * Statuses a task may be atomically claimed *from* by `claimNode` (F28). A claim
123
+ * flips any of these to `in_progress` in a single CAS statement. `in_progress`,
124
+ * `done`, and `cancelled` are intentionally excluded: an already-claimed,
125
+ * finished, or abandoned task is not up for grabs. Kept in lockstep with the
126
+ * SaaS `CLAIMABLE_STATUSES` so a claim behaves identically across backends.
127
+ */
128
+ const CLAIMABLE_STATUSES = ['draft', 'pending_review', 'approved', 'blocked']
129
+
121
130
  function assertValidStatus(status: string): void {
122
131
  if (!VALID_STATUSES.has(status)) {
123
132
  throw validationError(
@@ -588,80 +597,92 @@ export function createResolvers(db: Db, opts: ResolverOptions = {}) {
588
597
  metadata?: string
589
598
  },
590
599
  ) => {
591
- const existing = db.raw
592
- .query('SELECT * FROM nodes WHERE id = ?')
593
- .get(args.id) as NodeRow | null
594
- if (!existing) throw notFoundError(`Node ${args.id} not found`)
595
-
600
+ // Input validation that does NOT depend on the current row is done up
601
+ // front so a malformed request errors before opening a transaction.
596
602
  if (args.title != null && !args.title.trim()) {
597
603
  throw validationError('Title cannot be empty')
598
604
  }
599
605
  if (args.description != null && !args.description.trim()) {
600
606
  throw validationError('Description cannot be empty')
601
607
  }
602
- if (args.status != null) {
603
- assertValidStatus(args.status)
604
- if (enforceStatusLifecycle) {
608
+ if (args.status != null) assertValidStatus(args.status)
609
+ // Normalize metadata up front too: a non-JSON value must be rejected
610
+ // (VALIDATION_ERROR) without touching the database.
611
+ const nextMetadata =
612
+ args.metadata != null ? normalizeMetadata(args.metadata) : undefined
613
+
614
+ const now = new Date().toISOString()
615
+ // Transactional read-modify-write (F28). The SELECT, the diff, the
616
+ // UPDATE, and the audit rows all run inside ONE transaction so a
617
+ // mid-update failure rolls the whole unit back (never a half-applied
618
+ // row with no audit, or vice versa) and the read can't observe a row a
619
+ // concurrent writer is mid-mutating. SQLite is a single writer, so the
620
+ // transaction also serializes this RMW against any other write —
621
+ // matching the SaaS FOR UPDATE row lock.
622
+ let next!: NodeRow
623
+ db.raw.transaction(() => {
624
+ const existing = db.raw
625
+ .query('SELECT * FROM nodes WHERE id = ?')
626
+ .get(args.id) as NodeRow | null
627
+ if (!existing) throw notFoundError(`Node ${args.id} not found`)
628
+ // The lifecycle transition check reads the current status, so it
629
+ // belongs inside the transaction with the row it validates against.
630
+ if (args.status != null && enforceStatusLifecycle) {
605
631
  assertValidTransition(existing.status, args.status)
606
632
  }
607
- }
608
633
 
609
- const next: NodeRow = {
610
- ...existing,
611
- title: args.title ?? existing.title,
612
- description:
613
- args.description !== undefined
614
- ? args.description
615
- : existing.description,
616
- status: args.status ?? existing.status,
617
- metadata:
618
- args.metadata != null
619
- ? normalizeMetadata(args.metadata)
620
- : existing.metadata,
621
- }
622
- // Field-level diffs, mirroring SaaS: title/description/metadata changes
623
- // are 'update' entries; a status change is a 'status_change' entry.
624
- const diffs: Array<{
625
- field: string
626
- action: string
627
- oldValue: string | null
628
- newValue: string | null
629
- }> = []
630
- if (next.title !== existing.title) {
631
- diffs.push({
632
- field: 'title',
633
- action: 'update',
634
- oldValue: existing.title,
635
- newValue: next.title,
636
- })
637
- }
638
- if (next.description !== existing.description) {
639
- diffs.push({
640
- field: 'description',
641
- action: 'update',
642
- oldValue: existing.description,
643
- newValue: next.description,
644
- })
645
- }
646
- if (next.status !== existing.status) {
647
- diffs.push({
648
- field: 'status',
649
- action: 'status_change',
650
- oldValue: existing.status,
651
- newValue: next.status,
652
- })
653
- }
654
- if (next.metadata !== existing.metadata) {
655
- diffs.push({
656
- field: 'metadata',
657
- action: 'update',
658
- oldValue: existing.metadata,
659
- newValue: next.metadata,
660
- })
661
- }
634
+ next = {
635
+ ...existing,
636
+ title: args.title ?? existing.title,
637
+ description:
638
+ args.description !== undefined
639
+ ? args.description
640
+ : existing.description,
641
+ status: args.status ?? existing.status,
642
+ metadata:
643
+ nextMetadata !== undefined ? nextMetadata : existing.metadata,
644
+ }
645
+ // Field-level diffs, mirroring SaaS: title/description/metadata
646
+ // changes are 'update' entries; a status change is 'status_change'.
647
+ const diffs: Array<{
648
+ field: string
649
+ action: string
650
+ oldValue: string | null
651
+ newValue: string | null
652
+ }> = []
653
+ if (next.title !== existing.title) {
654
+ diffs.push({
655
+ field: 'title',
656
+ action: 'update',
657
+ oldValue: existing.title,
658
+ newValue: next.title,
659
+ })
660
+ }
661
+ if (next.description !== existing.description) {
662
+ diffs.push({
663
+ field: 'description',
664
+ action: 'update',
665
+ oldValue: existing.description,
666
+ newValue: next.description,
667
+ })
668
+ }
669
+ if (next.status !== existing.status) {
670
+ diffs.push({
671
+ field: 'status',
672
+ action: 'status_change',
673
+ oldValue: existing.status,
674
+ newValue: next.status,
675
+ })
676
+ }
677
+ if (next.metadata !== existing.metadata) {
678
+ diffs.push({
679
+ field: 'metadata',
680
+ action: 'update',
681
+ oldValue: existing.metadata,
682
+ newValue: next.metadata,
683
+ })
684
+ }
662
685
 
663
- const now = new Date().toISOString()
664
- db.raw.transaction(() => {
665
686
  db.raw.run(
666
687
  'UPDATE nodes SET title = ?, description = ?, status = ?, metadata = ?, updated_at = ? WHERE id = ?',
667
688
  [
@@ -752,6 +773,52 @@ export function createResolvers(db: Db, opts: ResolverOptions = {}) {
752
773
  return rowToNode({ ...existing, status: 'approved', updated_at: now })
753
774
  },
754
775
 
776
+ // Atomically claim a task for work (F28). A single compare-and-set
777
+ // `UPDATE ... WHERE id = ? AND status IN (<claimable>)` flips a claimable
778
+ // node to in_progress and returns the new row via RETURNING. SQLite is a
779
+ // single writer, so the statement is its own atomic unit: exactly one of
780
+ // two concurrent claimers can match the WHERE clause and get a row back;
781
+ // the loser's UPDATE matches nothing and returns null. This is the
782
+ // primitive that lets parallel agents share one backlog without
783
+ // double-claiming. Returns null when the node is missing, already
784
+ // in_progress/done/cancelled, or lost the race. Mirrors SaaS `claimNode`.
785
+ claimNode: (_: unknown, args: { id: string }): NodeGql | null => {
786
+ const placeholders = CLAIMABLE_STATUSES.map(() => '?').join(', ')
787
+ const now = new Date().toISOString()
788
+ let claimed: NodeRow | null = null
789
+ // Read-then-CAS inside ONE transaction. SQLite is a single writer, so
790
+ // the transaction serializes against any concurrent claimer: we capture
791
+ // the prior status (for an accurate audit oldValue), then run the gated
792
+ // CAS. Reading first is safe precisely because the write below is in the
793
+ // same atomic unit — no other writer can slip in between. The CAS
794
+ // (`WHERE status IN (<claimable>)`) is still the source of truth for who
795
+ // wins; the prior read only labels the audit entry.
796
+ db.raw.transaction(() => {
797
+ const prior = db.raw
798
+ .query('SELECT status FROM nodes WHERE id = ?')
799
+ .get(args.id) as { status: string } | null
800
+ claimed = db.raw
801
+ .query(
802
+ `UPDATE nodes SET status = 'in_progress', updated_at = ?
803
+ WHERE id = ? AND status IN (${placeholders})
804
+ RETURNING ${NODE_COLS}`,
805
+ )
806
+ .get(now, args.id, ...CLAIMABLE_STATUSES) as NodeRow | null
807
+ // Only the winner audits a status_change; a losing/no-match claim
808
+ // touches no row and writes nothing.
809
+ if (claimed) {
810
+ insertAudit(db, {
811
+ nodeId: claimed.id,
812
+ action: 'status_change',
813
+ field: 'status',
814
+ oldValue: prior?.status ?? null,
815
+ newValue: 'in_progress',
816
+ })
817
+ }
818
+ })()
819
+ return claimed ? rowToNode(claimed) : null
820
+ },
821
+
755
822
  createEdge: (
756
823
  _: unknown,
757
824
  args: { sourceId: string; targetId: string; relation: string },
@@ -70,7 +70,7 @@ describe('schema', () => {
70
70
  expect(fields.tree).toBeUndefined()
71
71
  })
72
72
 
73
- it('Mutation type has createNode, updateNode, approveNode, deleteNode, createEdge, removeEdge', () => {
73
+ it('Mutation type has createNode, updateNode, approveNode, claimNode, deleteNode, createEdge, removeEdge', () => {
74
74
  const mutationType = schema.getType(
75
75
  'Mutation',
76
76
  ) as import('graphql').GraphQLObjectType
@@ -78,11 +78,24 @@ describe('schema', () => {
78
78
  expect(fields.createNode).toBeDefined()
79
79
  expect(fields.updateNode).toBeDefined()
80
80
  expect(fields.approveNode).toBeDefined()
81
+ expect(fields.claimNode).toBeDefined()
81
82
  expect(fields.deleteNode).toBeDefined()
82
83
  expect(fields.createEdge).toBeDefined()
83
84
  expect(fields.removeEdge).toBeDefined()
84
85
  })
85
86
 
87
+ it('claimNode takes an id arg and returns a NULLABLE Node (null on lost race)', () => {
88
+ const mutationType = schema.getType(
89
+ 'Mutation',
90
+ ) as import('graphql').GraphQLObjectType
91
+ const claimNode = mutationType.getFields().claimNode
92
+ const args = claimNode.args.map((a) => a.name)
93
+ expect(args).toEqual(expect.arrayContaining(['id']))
94
+ // Nullable on purpose: a lost race / non-claimable node returns null, not
95
+ // an error. So the type must be `Node`, never `Node!`.
96
+ expect(String(claimNode.type)).toBe('Node')
97
+ })
98
+
86
99
  it('createNode accepts type, title, description, status, metadata args', () => {
87
100
  const mutationType = schema.getType(
88
101
  'Mutation',
@@ -95,6 +95,12 @@ export const typeDefs = /* GraphQL */ `
95
95
  metadata: String
96
96
  ): Node!
97
97
  approveNode(id: String!): Node!
98
+ # Atomically claim a task for work (F28). Compare-and-set: flips a claimable
99
+ # node (draft/pending_review/approved/blocked) to in_progress in a single
100
+ # statement and returns it. Returns null if the node does not exist, is not
101
+ # claimable, or was already claimed by a concurrent caller (lost the race) —
102
+ # so parallel agents never double-claim the same task.
103
+ claimNode(id: String!): Node
98
104
  deleteNode(id: String!): Boolean!
99
105
  createEdge(sourceId: String!, targetId: String!, relation: String!): Edge!
100
106
  removeEdge(sourceId: String!, targetId: String!, relation: String!): Boolean!
@@ -23,10 +23,10 @@ describe('task command', () => {
23
23
  vi.clearAllMocks()
24
24
  })
25
25
 
26
- test('exports a command group with 8 subcommands', async () => {
26
+ test('exports a command group with 10 subcommands', async () => {
27
27
  const { taskCommand } = await import('./task.ts')
28
28
  expect(taskCommand.name()).toBe('task')
29
- expect(taskCommand.commands).toHaveLength(8)
29
+ expect(taskCommand.commands).toHaveLength(10)
30
30
 
31
31
  const names = taskCommand.commands.map((c) => c.name())
32
32
  expect(names).toContain('create')
@@ -37,6 +37,8 @@ describe('task command', () => {
37
37
  expect(names).toContain('update')
38
38
  expect(names).toContain('delete')
39
39
  expect(names).toContain('deps')
40
+ expect(names).toContain('claim')
41
+ expect(names).toContain('next')
40
42
  })
41
43
 
42
44
  test('create exposes both --description and --description-file options', async () => {
@@ -437,4 +439,156 @@ describe('task command', () => {
437
439
  { id: 'task_2', type: 'task', title: 'Two', status: 'done' },
438
440
  ])
439
441
  })
442
+
443
+ test('claim sends claimNode and prints the claimed task', async () => {
444
+ const { graphql } = await import('../util/client.ts')
445
+ const { output } = await import('../util/format.ts')
446
+ const { taskCommand } = await import('./task.ts')
447
+
448
+ vi.mocked(graphql).mockResolvedValueOnce({
449
+ claimNode: { id: 'task_1', status: 'in_progress' },
450
+ })
451
+
452
+ const claimCmd = taskCommand.commands.find((c) => c.name() === 'claim')!
453
+ await claimCmd.parseAsync(['task_1'], { from: 'user' })
454
+
455
+ const [query, variables] = vi.mocked(graphql).mock.calls[0]!
456
+ expect(query).toContain('claimNode')
457
+ expect(variables).toMatchObject({ id: 'task_1' })
458
+ expect(output).toHaveBeenCalledWith({ id: 'task_1', status: 'in_progress' })
459
+ })
460
+
461
+ test('claim reports a lost race (null) via outputError with non-zero exit', async () => {
462
+ const { graphql } = await import('../util/client.ts')
463
+ const { output, outputError } = await import('../util/format.ts')
464
+ const { taskCommand } = await import('./task.ts')
465
+
466
+ // claimNode returns null: the task was already claimed / not claimable.
467
+ vi.mocked(graphql).mockResolvedValueOnce({ claimNode: null })
468
+
469
+ const claimCmd = taskCommand.commands.find((c) => c.name() === 'claim')!
470
+ await claimCmd.parseAsync(['task_1'], { from: 'user' })
471
+
472
+ expect(output).not.toHaveBeenCalled()
473
+ expect(outputError).toHaveBeenCalledWith(
474
+ expect.objectContaining({
475
+ message: expect.stringMatching(/already claimed|not claimable/i),
476
+ }),
477
+ )
478
+ })
479
+
480
+ test('next claims the first ready task and prints it', async () => {
481
+ const { graphql } = await import('../util/client.ts')
482
+ const { output } = await import('../util/format.ts')
483
+ const { taskCommand } = await import('./task.ts')
484
+
485
+ vi.mocked(graphql)
486
+ // readyTasks
487
+ .mockResolvedValueOnce({
488
+ readyTasks: [
489
+ { id: 'task_a', title: 'A', status: 'draft' },
490
+ { id: 'task_b', title: 'B', status: 'draft' },
491
+ ],
492
+ })
493
+ // claimNode(task_a) wins
494
+ .mockResolvedValueOnce({
495
+ claimNode: { id: 'task_a', status: 'in_progress' },
496
+ })
497
+
498
+ const nextCmd = taskCommand.commands.find((c) => c.name() === 'next')!
499
+ await nextCmd.parseAsync([], { from: 'user' })
500
+
501
+ expect(graphql).toHaveBeenCalledTimes(2)
502
+ const [readyQuery] = vi.mocked(graphql).mock.calls[0]!
503
+ expect(readyQuery).toContain('readyTasks')
504
+ const [claimQuery, claimVars] = vi.mocked(graphql).mock.calls[1]!
505
+ expect(claimQuery).toContain('claimNode')
506
+ expect(claimVars).toMatchObject({ id: 'task_a' })
507
+ expect(output).toHaveBeenCalledWith({ id: 'task_a', status: 'in_progress' })
508
+ })
509
+
510
+ test('next retries the next candidate when a claim loses the race', async () => {
511
+ const { graphql } = await import('../util/client.ts')
512
+ const { output } = await import('../util/format.ts')
513
+ const { taskCommand } = await import('./task.ts')
514
+
515
+ vi.mocked(graphql)
516
+ // readyTasks
517
+ .mockResolvedValueOnce({
518
+ readyTasks: [
519
+ { id: 'task_a', title: 'A', status: 'draft' },
520
+ { id: 'task_b', title: 'B', status: 'draft' },
521
+ ],
522
+ })
523
+ // claimNode(task_a) loses (another agent took it)
524
+ .mockResolvedValueOnce({ claimNode: null })
525
+ // claimNode(task_b) wins
526
+ .mockResolvedValueOnce({
527
+ claimNode: { id: 'task_b', status: 'in_progress' },
528
+ })
529
+
530
+ const nextCmd = taskCommand.commands.find((c) => c.name() === 'next')!
531
+ await nextCmd.parseAsync([], { from: 'user' })
532
+
533
+ expect(graphql).toHaveBeenCalledTimes(3)
534
+ expect(vi.mocked(graphql).mock.calls[1]![1]).toMatchObject({ id: 'task_a' })
535
+ expect(vi.mocked(graphql).mock.calls[2]![1]).toMatchObject({ id: 'task_b' })
536
+ expect(output).toHaveBeenCalledWith({ id: 'task_b', status: 'in_progress' })
537
+ })
538
+
539
+ test('next reports when no ready tasks exist via outputError', async () => {
540
+ const { graphql } = await import('../util/client.ts')
541
+ const { output, outputError } = await import('../util/format.ts')
542
+ const { taskCommand } = await import('./task.ts')
543
+
544
+ vi.mocked(graphql).mockResolvedValueOnce({ readyTasks: [] })
545
+
546
+ const nextCmd = taskCommand.commands.find((c) => c.name() === 'next')!
547
+ await nextCmd.parseAsync([], { from: 'user' })
548
+
549
+ expect(output).not.toHaveBeenCalled()
550
+ expect(outputError).toHaveBeenCalledWith(
551
+ expect.objectContaining({
552
+ message: expect.stringMatching(/no ready|no claimable/i),
553
+ }),
554
+ )
555
+ })
556
+
557
+ test('next reports when every ready task lost its race via outputError', async () => {
558
+ const { graphql } = await import('../util/client.ts')
559
+ const { output, outputError } = await import('../util/format.ts')
560
+ const { taskCommand } = await import('./task.ts')
561
+
562
+ vi.mocked(graphql)
563
+ .mockResolvedValueOnce({
564
+ readyTasks: [{ id: 'task_a', title: 'A', status: 'draft' }],
565
+ })
566
+ .mockResolvedValueOnce({ claimNode: null }) // lost
567
+
568
+ const nextCmd = taskCommand.commands.find((c) => c.name() === 'next')!
569
+ await nextCmd.parseAsync([], { from: 'user' })
570
+
571
+ expect(output).not.toHaveBeenCalled()
572
+ expect(outputError).toHaveBeenCalled()
573
+ })
574
+
575
+ test('next --project scopes readyTasks to the given project', async () => {
576
+ const { graphql } = await import('../util/client.ts')
577
+ const { taskCommand } = await import('./task.ts')
578
+
579
+ vi.mocked(graphql)
580
+ .mockResolvedValueOnce({
581
+ readyTasks: [{ id: 'task_a', title: 'A', status: 'draft' }],
582
+ })
583
+ .mockResolvedValueOnce({
584
+ claimNode: { id: 'task_a', status: 'in_progress' },
585
+ })
586
+
587
+ const nextCmd = taskCommand.commands.find((c) => c.name() === 'next')!
588
+ await nextCmd.parseAsync(['--project', 'proj_42'], { from: 'user' })
589
+
590
+ const [readyQuery, readyVars] = vi.mocked(graphql).mock.calls[0]!
591
+ expect(readyQuery).toContain('readyTasks')
592
+ expect(readyVars).toMatchObject({ projectId: 'proj_42' })
593
+ })
440
594
  })
@@ -6,6 +6,7 @@ import { output, outputError } from '../util/format.ts'
6
6
  import {
7
7
  ALL_TASKS,
8
8
  BLOCK_TASK,
9
+ CLAIM_NODE,
9
10
  CREATE_TASK,
10
11
  DELETE_NODE,
11
12
  LIST_TASKS,
@@ -16,6 +17,12 @@ import {
16
17
  UPDATE_NODE,
17
18
  } from '../util/operations.ts'
18
19
 
20
+ /** A task as returned by claimNode/readyTasks selections. */
21
+ interface ClaimedTask {
22
+ id: string
23
+ [key: string]: unknown
24
+ }
25
+
19
26
  export const taskCommand = new Command('task').description(
20
27
  'Manage tasks in the active feature',
21
28
  )
@@ -171,6 +178,80 @@ taskCommand
171
178
  }
172
179
  })
173
180
 
181
+ taskCommand
182
+ .command('claim')
183
+ .description(
184
+ 'Atomically claim a task for work (draft/pending_review/approved/blocked → in_progress)',
185
+ )
186
+ .argument('<id>', 'Task ID')
187
+ .action(async (id: string) => {
188
+ try {
189
+ // One atomic compare-and-set on the server: the task flips to in_progress
190
+ // only if it is still claimable, so two agents can never both claim it.
191
+ const data = await graphql<{ claimNode: ClaimedTask | null }>(
192
+ CLAIM_NODE,
193
+ {
194
+ id,
195
+ },
196
+ )
197
+ if (!data.claimNode) {
198
+ // Lost the race or never claimable. Surface a clear message + non-zero
199
+ // exit so a caller (or a wrapping script) can branch on the failure.
200
+ throw new Error(
201
+ `Could not claim ${id}: already claimed by another agent or not claimable (must be draft/pending_review/approved/blocked).`,
202
+ )
203
+ }
204
+ output(data.claimNode)
205
+ } catch (error) {
206
+ outputError(error)
207
+ }
208
+ })
209
+
210
+ taskCommand
211
+ .command('next')
212
+ .description(
213
+ 'Atomically claim the next ready task — picks a ready task and claims it, ' +
214
+ 'retrying past any a concurrent agent grabs first',
215
+ )
216
+ .option(
217
+ '--project <id>',
218
+ 'Scope to a project (defaults to the active project; omit with --all)',
219
+ )
220
+ .option('--all', 'Consider ready tasks across the whole backlog')
221
+ .action(async (opts) => {
222
+ try {
223
+ const projectId =
224
+ opts.project ?? (opts.all ? undefined : resolveProject()?.id)
225
+ const ready = await graphql<{ readyTasks: ClaimedTask[] }>(READY_TASKS, {
226
+ projectId: projectId ?? null,
227
+ })
228
+ if (ready.readyTasks.length === 0) {
229
+ throw new Error(
230
+ 'No ready tasks to claim (none are actionable, or all are blocked/done).',
231
+ )
232
+ }
233
+ // Walk the ready list and claim the first task we win. A null claimNode
234
+ // means a concurrent agent took that one between our read and our claim —
235
+ // skip to the next candidate so parallel agents each get a distinct task.
236
+ for (const candidate of ready.readyTasks) {
237
+ const data = await graphql<{ claimNode: ClaimedTask | null }>(
238
+ CLAIM_NODE,
239
+ { id: candidate.id },
240
+ )
241
+ if (data.claimNode) {
242
+ output(data.claimNode)
243
+ return
244
+ }
245
+ }
246
+ // Every ready candidate was claimed out from under us.
247
+ throw new Error(
248
+ 'No claimable task left: every ready task was claimed by another agent. Try again.',
249
+ )
250
+ } catch (error) {
251
+ outputError(error)
252
+ }
253
+ })
254
+
174
255
  taskCommand
175
256
  .command('block')
176
257
  .description('Mark a task as blocking another')
@@ -74,6 +74,7 @@ describe('commands send canonical operations (no re-inlined queries)', () => {
74
74
  'BLOCK_TASK',
75
75
  'UNBLOCK_TASK',
76
76
  'TASK_DEPS',
77
+ 'CLAIM_NODE',
77
78
  ],
78
79
  'status.ts': ['UPDATE_STATUS'],
79
80
  'approve.ts': ['APPROVE_NODE'],
@@ -209,6 +209,20 @@ export const APPROVE_NODE = `mutation ApproveNode($id: String!) {
209
209
  approveNode(id: $id) { id type title status updatedAt }
210
210
  }`
211
211
 
212
+ /**
213
+ * task.ts `claim`/`next` — atomically claim a task for work (F28). The server
214
+ * compare-and-sets a claimable node (draft/pending_review/approved/blocked) to
215
+ * in_progress and returns it; it returns `null` when the node is missing, not
216
+ * claimable, or was already claimed by a concurrent caller (lost the race), so
217
+ * parallel agents never double-claim the same task. Served by BOTH backends:
218
+ * the bundled local server (since P2-1) and SaaS (flowy-ai v35).
219
+ */
220
+ export const CLAIM_NODE = `mutation ClaimNode($id: String!) {
221
+ claimNode(id: $id) {
222
+ id type title description status metadata createdAt updatedAt
223
+ }
224
+ }`
225
+
212
226
  /** project/feature/task `delete` — remove a node (and its edges). */
213
227
  export const DELETE_NODE = `mutation DeleteNode($id: String!) {
214
228
  deleteNode(id: $id)
@@ -352,6 +366,7 @@ export const LOCAL_CONTRACT_OPERATIONS = {
352
366
  UPDATE_NODE,
353
367
  UPDATE_STATUS,
354
368
  APPROVE_NODE,
369
+ CLAIM_NODE,
355
370
  DELETE_NODE,
356
371
  CREATE_EDGE,
357
372
  LINK_TASK,