@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 +13 -0
- package/package.json +3 -1
- package/server/src/contract.test.ts +36 -0
- package/server/src/resolvers.test.ts +101 -0
- package/server/src/resolvers.ts +131 -64
- package/server/src/schema.test.ts +14 -1
- package/server/src/schema.ts +6 -0
- package/src/commands/task.test.ts +156 -2
- package/src/commands/task.ts +81 -0
- package/src/util/operations.test.ts +1 -0
- package/src/util/operations.ts +15 -0
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.
|
|
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, {
|
package/server/src/resolvers.ts
CHANGED
|
@@ -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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
604
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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',
|
package/server/src/schema.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
})
|
package/src/commands/task.ts
CHANGED
|
@@ -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')
|
package/src/util/operations.ts
CHANGED
|
@@ -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,
|