@sqaoss/flowy 1.8.0 → 1.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqaoss/flowy",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Agentic persistent planning",
5
5
  "type": "module",
6
6
  "bin": {
@@ -281,11 +281,44 @@ describe('CLI/local-server contract (P1-1)', () => {
281
281
  })
282
282
  expect(exportDescendants.descendants.length).toBeGreaterThan(0)
283
283
 
284
- const subtree = await ok<{ subtree: Array<{ id: string }> }>(
285
- LOCAL_CONTRACT_OPERATIONS.SUBTREE,
286
- { nodeId: project.id, maxDepth: 10 },
287
- )
288
- expect(subtree.subtree.length).toBeGreaterThan(0)
284
+ // SUBTREE follows one relation (default part_of) and annotates each node
285
+ // with parentId/depth/relation. From the project, the part_of view is:
286
+ // project -> feature (depth 1) -> {task, blocker, imported} (depth 2),
287
+ // all reached via part_of edges. The blocker -> task `blocks` edge must NOT
288
+ // change anyone's parent/relation: every returned node carries
289
+ // relation 'part_of' and its part_of parent, never the blocks linkage.
290
+ const subtree = await ok<{
291
+ subtree: Array<{
292
+ id: string
293
+ parentId: string
294
+ depth: number
295
+ relation: string
296
+ }>
297
+ }>(LOCAL_CONTRACT_OPERATIONS.SUBTREE, {
298
+ nodeId: project.id,
299
+ relation: 'part_of',
300
+ maxDepth: 10,
301
+ })
302
+ const subtreeById = new Map(subtree.subtree.map((n) => [n.id, n]))
303
+ expect(subtreeById.get(feature.id)).toMatchObject({
304
+ parentId: project.id,
305
+ depth: 1,
306
+ relation: 'part_of',
307
+ })
308
+ expect(subtreeById.get(task.id)).toMatchObject({
309
+ parentId: feature.id,
310
+ depth: 2,
311
+ relation: 'part_of',
312
+ })
313
+ // the blocker is reached via its part_of edge to the feature (depth 2),
314
+ // NOT via the blocks edge that points at the task — proving blocks edges
315
+ // do not leak into the hierarchy view.
316
+ expect(subtreeById.get(blocker.id)).toMatchObject({
317
+ parentId: feature.id,
318
+ depth: 2,
319
+ relation: 'part_of',
320
+ })
321
+ expect(subtree.subtree.every((n) => n.relation === 'part_of')).toBe(true)
289
322
 
290
323
  // task is blocked by an unfinished blocker, so readyTasks excludes it.
291
324
  const ready = await ok<{ readyTasks: Array<{ id: string }> }>(
@@ -580,7 +580,7 @@ describe('createResolvers', () => {
580
580
  })
581
581
 
582
582
  describe('Query.subtree', () => {
583
- it('returns full tree traversal across all edge types', () => {
583
+ it('returns the part_of hierarchy by default', () => {
584
584
  const project = resolvers.Mutation.createNode(null, {
585
585
  type: 'project',
586
586
  title: 'Root',
@@ -608,6 +608,125 @@ describe('createResolvers', () => {
608
608
  expect(tree).toHaveLength(2)
609
609
  })
610
610
 
611
+ it('returns parentId, depth and relation for each node (default part_of)', () => {
612
+ const project = resolvers.Mutation.createNode(null, {
613
+ type: 'project',
614
+ title: 'Root',
615
+ })
616
+ const feature = resolvers.Mutation.createNode(null, {
617
+ type: 'feature',
618
+ title: 'F1',
619
+ })
620
+ const task = resolvers.Mutation.createNode(null, {
621
+ type: 'task',
622
+ title: 'T1',
623
+ })
624
+ resolvers.Mutation.createEdge(null, {
625
+ sourceId: feature.id,
626
+ targetId: project.id,
627
+ relation: 'part_of',
628
+ })
629
+ resolvers.Mutation.createEdge(null, {
630
+ sourceId: task.id,
631
+ targetId: feature.id,
632
+ relation: 'part_of',
633
+ })
634
+
635
+ const tree = resolvers.Query.subtree(null, { nodeId: project.id })
636
+ const byId = new Map(tree.map((n) => [n.id, n]))
637
+
638
+ const featNode = byId.get(feature.id)
639
+ expect(featNode).toBeDefined()
640
+ expect(featNode?.depth).toBe(1)
641
+ expect(featNode?.parentId).toBe(project.id)
642
+ expect(featNode?.relation).toBe('part_of')
643
+
644
+ const taskNode = byId.get(task.id)
645
+ expect(taskNode).toBeDefined()
646
+ expect(taskNode?.depth).toBe(2)
647
+ expect(taskNode?.parentId).toBe(feature.id)
648
+ expect(taskNode?.relation).toBe('part_of')
649
+ })
650
+
651
+ it('excludes blocks edges from the default part_of view', () => {
652
+ const project = resolvers.Mutation.createNode(null, {
653
+ type: 'project',
654
+ title: 'Root',
655
+ })
656
+ const feature = resolvers.Mutation.createNode(null, {
657
+ type: 'feature',
658
+ title: 'F1',
659
+ })
660
+ const task = resolvers.Mutation.createNode(null, {
661
+ type: 'task',
662
+ title: 'T1',
663
+ })
664
+ const blocker = resolvers.Mutation.createNode(null, {
665
+ type: 'task',
666
+ title: 'Blocker',
667
+ })
668
+ resolvers.Mutation.createEdge(null, {
669
+ sourceId: feature.id,
670
+ targetId: project.id,
671
+ relation: 'part_of',
672
+ })
673
+ resolvers.Mutation.createEdge(null, {
674
+ sourceId: task.id,
675
+ targetId: feature.id,
676
+ relation: 'part_of',
677
+ })
678
+ // blocker blocks task: source=blocker, target=task. A blocks edge must NOT
679
+ // pull the blocker into the project's part_of hierarchy view.
680
+ resolvers.Mutation.createEdge(null, {
681
+ sourceId: blocker.id,
682
+ targetId: task.id,
683
+ relation: 'blocks',
684
+ })
685
+
686
+ const tree = resolvers.Query.subtree(null, { nodeId: project.id })
687
+ const ids = tree.map((n) => n.id)
688
+ expect(ids).toContain(feature.id)
689
+ expect(ids).toContain(task.id)
690
+ expect(ids).not.toContain(blocker.id)
691
+ expect(tree.every((n) => n.relation === 'part_of')).toBe(true)
692
+ })
693
+
694
+ it('follows only the requested relation when overridden', () => {
695
+ const task = resolvers.Mutation.createNode(null, {
696
+ type: 'task',
697
+ title: 'Target',
698
+ })
699
+ const blocker = resolvers.Mutation.createNode(null, {
700
+ type: 'task',
701
+ title: 'Blocker',
702
+ })
703
+ const child = resolvers.Mutation.createNode(null, {
704
+ type: 'task',
705
+ title: 'Part of target',
706
+ })
707
+ // blocker blocks task (source=blocker, target=task)
708
+ resolvers.Mutation.createEdge(null, {
709
+ sourceId: blocker.id,
710
+ targetId: task.id,
711
+ relation: 'blocks',
712
+ })
713
+ // child is part_of task (source=child, target=task)
714
+ resolvers.Mutation.createEdge(null, {
715
+ sourceId: child.id,
716
+ targetId: task.id,
717
+ relation: 'part_of',
718
+ })
719
+
720
+ const blocks = resolvers.Query.subtree(null, {
721
+ nodeId: task.id,
722
+ relation: 'blocks',
723
+ })
724
+ expect(blocks.map((n) => n.id)).toEqual([blocker.id])
725
+ expect(blocks[0].relation).toBe('blocks')
726
+ expect(blocks[0].depth).toBe(1)
727
+ expect(blocks[0].parentId).toBe(task.id)
728
+ })
729
+
611
730
  it('respects maxDepth', () => {
612
731
  const project = resolvers.Mutation.createNode(null, {
613
732
  type: 'project',
@@ -638,6 +757,7 @@ describe('createResolvers', () => {
638
757
  })
639
758
  expect(shallow).toHaveLength(1)
640
759
  expect(shallow[0].id).toBe(feature.id)
760
+ expect(shallow[0].depth).toBe(1)
641
761
  })
642
762
  })
643
763
 
@@ -40,6 +40,23 @@ export interface NodeGql {
40
40
  updatedAt: string
41
41
  }
42
42
 
43
+ /**
44
+ * A node returned from a subtree traversal, annotated with the edge that pulled
45
+ * it in: `parentId` (the node it descends from), `depth` (1 for the root's
46
+ * direct children), and `relation` (the relation of the linking edge).
47
+ */
48
+ export interface SubtreeNodeGql extends NodeGql {
49
+ parentId: string
50
+ depth: number
51
+ relation: string
52
+ }
53
+
54
+ interface SubtreeRow extends NodeRow {
55
+ parent_id: string
56
+ depth: number
57
+ edge_relation: string
58
+ }
59
+
43
60
  const NODE_COLS =
44
61
  'id, type, title, description, status, metadata, created_at, updated_at'
45
62
 
@@ -177,21 +194,39 @@ export function createResolvers(db: Db) {
177
194
  return selectNodes(rows)
178
195
  },
179
196
 
180
- subtree: (_: unknown, args: { nodeId: string; maxDepth?: number }) => {
197
+ // Walk the part_of hierarchy (or another `relation`, default 'part_of')
198
+ // downward from `nodeId`, returning each reachable node annotated with the
199
+ // edge that pulled it in: parentId, depth (root's direct children = 1) and
200
+ // relation. Following a single relation keeps the hierarchy view clean —
201
+ // `blocks` dependency edges no longer leak into the part_of tree.
202
+ subtree: (
203
+ _: unknown,
204
+ args: { nodeId: string; relation?: string; maxDepth?: number },
205
+ ): SubtreeNodeGql[] => {
206
+ const relation = args.relation ?? 'part_of'
181
207
  const maxDepth = args.maxDepth ?? 100
182
208
  if (maxDepth === 0) return []
183
209
  const rows = db.raw
184
210
  .query(
185
- `WITH RECURSIVE tree(id, depth) AS (
186
- SELECT source_id, 1 FROM edges WHERE target_id = ?1
211
+ `WITH RECURSIVE tree(id, parent_id, depth) AS (
212
+ SELECT source_id, target_id, 1 FROM edges
213
+ WHERE target_id = ?1 AND relation = ?3
187
214
  UNION ALL
188
- SELECT e.source_id, t.depth + 1 FROM edges e
189
- JOIN tree t ON e.target_id = t.id WHERE t.depth < ?2
215
+ SELECT e.source_id, e.target_id, t.depth + 1 FROM edges e
216
+ JOIN tree t ON e.target_id = t.id
217
+ WHERE t.depth < ?2 AND e.relation = ?3
190
218
  )
191
- SELECT DISTINCT ${prefixedCols()} FROM nodes n JOIN tree t ON n.id = t.id`,
219
+ SELECT ${prefixedCols()}, t.parent_id AS parent_id, t.depth AS depth, ?3 AS edge_relation
220
+ FROM nodes n JOIN tree t ON n.id = t.id
221
+ ORDER BY t.depth`,
192
222
  )
193
- .all(args.nodeId, maxDepth) as NodeRow[]
194
- return selectNodes(rows)
223
+ .all(args.nodeId, maxDepth, relation) as SubtreeRow[]
224
+ return rows.map((row) => ({
225
+ ...rowToNode(row),
226
+ parentId: row.parent_id,
227
+ depth: row.depth,
228
+ relation: row.edge_relation,
229
+ }))
195
230
  },
196
231
 
197
232
  // Nodes connected to `nodeId` by `relation`, following edges in the given
@@ -19,11 +19,29 @@ export const typeDefs = /* GraphQL */ `
19
19
  createdAt: String!
20
20
  }
21
21
 
22
+ # A node returned from a subtree traversal, annotated with how it was reached:
23
+ # the parent it descends from (parentId), how many edges down the root it sits
24
+ # (depth, root's direct children are depth 1), and the relation of the edge
25
+ # that links it to its parent.
26
+ type SubtreeNode {
27
+ id: String!
28
+ type: String!
29
+ title: String!
30
+ description: String
31
+ status: String!
32
+ metadata: String
33
+ createdAt: String!
34
+ updatedAt: String!
35
+ parentId: String!
36
+ depth: Int!
37
+ relation: String!
38
+ }
39
+
22
40
  type Query {
23
41
  node(id: String!): Node
24
42
  nodes(type: String): [Node!]!
25
43
  descendants(nodeId: String!, relation: String, maxDepth: Int): [Node!]!
26
- subtree(nodeId: String!, maxDepth: Int): [Node!]!
44
+ subtree(nodeId: String!, relation: String, maxDepth: Int): [SubtreeNode!]!
27
45
  edges(nodeId: String!, relation: String!, direction: String): [Node!]!
28
46
  readyTasks(projectId: String): [Node!]!
29
47
  search(query: String!, type: String, status: String, limit: Int): [Node!]!
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'vitest'
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2
2
 
3
3
  describe('tree command', () => {
4
4
  test('exports a flat command with id argument and depth option', async () => {
@@ -6,4 +6,92 @@ describe('tree command', () => {
6
6
  expect(treeCommand.name()).toBe('tree')
7
7
  expect(treeCommand.commands).toHaveLength(0)
8
8
  })
9
+
10
+ test('exposes a --relation option defaulting to part_of', async () => {
11
+ const { treeCommand } = await import('./tree.ts')
12
+ const relationOpt = treeCommand.options.find((o) => o.long === '--relation')
13
+ expect(relationOpt).toBeDefined()
14
+ expect(relationOpt?.defaultValue).toBe('part_of')
15
+ })
16
+
17
+ describe('action', () => {
18
+ beforeEach(() => {
19
+ vi.resetModules()
20
+ })
21
+
22
+ afterEach(() => {
23
+ vi.restoreAllMocks()
24
+ vi.unstubAllEnvs()
25
+ })
26
+
27
+ test('sends the SUBTREE op with the relation filter and renders depth/relation', async () => {
28
+ const calls: Array<{ query: string; variables: unknown }> = []
29
+ vi.doMock('../util/client.ts', () => ({
30
+ graphql: vi.fn(async (query: string, variables: unknown) => {
31
+ calls.push({ query, variables })
32
+ return {
33
+ subtree: [
34
+ {
35
+ id: 'feat_1',
36
+ type: 'feature',
37
+ title: 'F1',
38
+ status: 'draft',
39
+ parentId: 'proj_1',
40
+ depth: 1,
41
+ relation: 'part_of',
42
+ },
43
+ ],
44
+ }
45
+ }),
46
+ }))
47
+ const outputs: unknown[] = []
48
+ vi.doMock('../util/format.ts', () => ({
49
+ output: vi.fn((data: unknown) => outputs.push(data)),
50
+ outputError: vi.fn(),
51
+ }))
52
+
53
+ const { SUBTREE } = await import('../util/operations.ts')
54
+ const { treeCommand } = await import('./tree.ts')
55
+ await treeCommand.parseAsync(['proj_1'], { from: 'user' })
56
+
57
+ expect(calls).toHaveLength(1)
58
+ expect(calls[0]!.query).toBe(SUBTREE)
59
+ expect(calls[0]!.variables).toMatchObject({
60
+ nodeId: 'proj_1',
61
+ relation: 'part_of',
62
+ })
63
+
64
+ const rendered = outputs[0] as Array<Record<string, unknown>>
65
+ expect(rendered[0]).toMatchObject({
66
+ id: 'feat_1',
67
+ parentId: 'proj_1',
68
+ depth: 1,
69
+ relation: 'part_of',
70
+ })
71
+ })
72
+
73
+ test('passes an overridden relation through to the op', async () => {
74
+ const calls: Array<{ variables: unknown }> = []
75
+ vi.doMock('../util/client.ts', () => ({
76
+ graphql: vi.fn(async (_query: string, variables: unknown) => {
77
+ calls.push({ variables })
78
+ return { subtree: [] }
79
+ }),
80
+ }))
81
+ vi.doMock('../util/format.ts', () => ({
82
+ output: vi.fn(),
83
+ outputError: vi.fn(),
84
+ }))
85
+
86
+ const { treeCommand } = await import('./tree.ts')
87
+ await treeCommand.parseAsync(['task_1', '--relation', 'blocks'], {
88
+ from: 'user',
89
+ })
90
+
91
+ expect(calls[0]!.variables).toMatchObject({
92
+ nodeId: 'task_1',
93
+ relation: 'blocks',
94
+ })
95
+ })
96
+ })
9
97
  })
@@ -7,10 +7,16 @@ export const treeCommand = new Command('tree')
7
7
  .description('Show subtree from any entity')
8
8
  .argument('<id>', 'Root node ID')
9
9
  .option('--depth <n>', 'Max depth', '10')
10
+ .option(
11
+ '--relation <relation>',
12
+ 'Edge relation to follow (e.g. part_of, blocks)',
13
+ 'part_of',
14
+ )
10
15
  .action(async (id: string, opts) => {
11
16
  try {
12
17
  const data = await graphql<{ subtree: unknown[] }>(SUBTREE, {
13
18
  nodeId: id,
19
+ relation: opts.relation,
14
20
  maxDepth: Number.parseInt(opts.depth, 10),
15
21
  })
16
22
  output(data.subtree)
@@ -76,10 +76,11 @@ export const LIST_TASKS = `query ListTasks($nodeId: String!, $relation: String!,
76
76
  }
77
77
  }`
78
78
 
79
- /** tree.ts — full subtree from any root. */
80
- export const SUBTREE = `query Subtree($nodeId: String!, $maxDepth: Int) {
81
- subtree(nodeId: $nodeId, maxDepth: $maxDepth) {
82
- id type title status
79
+ /** tree.ts — subtree from any root, filtered to one relation (default part_of),
80
+ * each node annotated with parentId/depth/relation. */
81
+ export const SUBTREE = `query Subtree($nodeId: String!, $relation: String, $maxDepth: Int) {
82
+ subtree(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
83
+ id type title status parentId depth relation
83
84
  }
84
85
  }`
85
86