@sqaoss/flowy 1.10.0 → 1.11.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
@@ -147,6 +147,8 @@ flowy serve --port 5000 --host 0.0.0.0 --db ~/flowy.sqlite # override defaults
147
147
 
148
148
  The self-hosted server supports the full planning workflow — `init`, `project`/`feature`/`task` CRUD, `status`, `approve`, `search`, `tree`, `task deps`, `task list --ready/--all`, and `import`/`export`. Account-only commands (`whoami`, `billing`, `key`) are remote-mode features and don't apply locally.
149
149
 
150
+ The canonical status flow is `draft → pending_review → approved → in_progress → done`, plus `blocked` and `cancelled`. By default any status change is allowed (and the `status` command validates the value client-side). To make the local server *enforce* legal transitions — rejecting illegal jumps like `draft → done` with a `VALIDATION_ERROR` — start it with `FLOWY_ENFORCE_STATUS_LIFECYCLE=1`. Enforcement is opt-in and off by default.
151
+
150
152
  ## Command Reference
151
153
 
152
154
  | Command | Description |
@@ -179,7 +181,7 @@ The self-hosted server supports the full planning workflow — `init`, `project`
179
181
  | `task deps <id>` | Show what blocks a task and what it blocks |
180
182
  | `status <id> <status>` | Update status (shorthand) |
181
183
  | `approve <id>` | Approve (must be pending_review) |
182
- | `search <query> [--type] [--status] [--limit]` | Full-text search |
184
+ | `search <query> [--type] [--status] [--limit]` | Full-text search; prints `{ nodes, truncated, total }` and warns on stderr when results are capped at `--limit` |
183
185
  | `tree <id> [--depth N]` | Show subtree from any entity |
184
186
  | `import <manifest>` | Ingest a JSON manifest of nodes + edges (idempotent by client-key) |
185
187
  | `export [output]` | Dump the active project as a manifest (stdout or file) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqaoss/flowy",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Agentic persistent planning",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,6 +50,7 @@
50
50
  "check": "biome check --write .",
51
51
  "test": "vitest run",
52
52
  "test:watch": "vitest",
53
+ "test:coverage": "vitest run --coverage",
53
54
  "test:e2e": "vitest run --config vitest.config.e2e.ts",
54
55
  "sdl": "bun scripts/print-sdl.ts",
55
56
  "typecheck": "tsc --noEmit",
@@ -64,11 +65,12 @@
64
65
  "@commitlint/config-conventional": "^20.4.2",
65
66
  "@semantic-release/changelog": "6.0.3",
66
67
  "@semantic-release/git": "10.0.1",
68
+ "@vitest/coverage-v8": "4.1.8",
67
69
  "husky": "^9.1.7",
68
70
  "lint-staged": "^16.3.0",
69
71
  "semantic-release": "25.0.3",
70
72
  "typescript": "^5",
71
- "vitest": "^4.1.2"
73
+ "vitest": "4.1.8"
72
74
  },
73
75
  "lint-staged": {
74
76
  "*.{ts,tsx,js,jsx}": [
@@ -360,11 +360,22 @@ describe('CLI/local-server contract (P1-1)', () => {
360
360
  })
361
361
  expect(Array.isArray(exportEdges.edges)).toBe(true)
362
362
 
363
- const search = await ok<{ search: Array<{ id: string }> }>(
364
- LOCAL_CONTRACT_OPERATIONS.SEARCH,
365
- { query: 'Contract', type: 'project', status: null, limit: 50 },
366
- )
367
- expect(search.search.some((n) => n.id === project.id)).toBe(true)
363
+ // SEARCH returns a SearchResult envelope (F32): nodes + truncation meta.
364
+ const search = await ok<{
365
+ search: {
366
+ nodes: Array<{ id: string }>
367
+ truncated: boolean
368
+ total: number
369
+ }
370
+ }>(LOCAL_CONTRACT_OPERATIONS.SEARCH, {
371
+ query: 'Contract',
372
+ type: 'project',
373
+ status: null,
374
+ limit: 50,
375
+ })
376
+ expect(search.search.nodes.some((n) => n.id === project.id)).toBe(true)
377
+ expect(search.search.truncated).toBe(false)
378
+ expect(typeof search.search.total).toBe('number')
368
379
 
369
380
  // auditLog (P1-2/F27): the task has accumulated a trail — a `create` on
370
381
  // insert, a `status_change` to pending_review, an `approve`, plus the
@@ -29,9 +29,12 @@ describe('server error masking', () => {
29
29
  it('surfaces a too-short search error with real message and VALIDATION_ERROR code', async () => {
30
30
  instance = createServer({ dbPath: ':memory:', port: 0 })
31
31
 
32
- const json = await gql('query ($q: String!) { search(query: $q) { id } }', {
33
- q: 'ab',
34
- })
32
+ const json = await gql(
33
+ 'query ($q: String!) { search(query: $q) { nodes { id } } }',
34
+ {
35
+ q: 'ab',
36
+ },
37
+ )
35
38
 
36
39
  expect(json.errors).toBeDefined()
37
40
  const error = json.errors![0]
@@ -7,15 +7,24 @@ export function createServer(opts?: {
7
7
  dbPath?: string
8
8
  port?: number
9
9
  hostname?: string
10
+ enforceStatusLifecycle?: boolean
10
11
  }) {
11
12
  const dbPath = opts?.dbPath ?? process.env.FLOWY_DB_PATH ?? './flowy.sqlite'
12
13
  const port = opts?.port ?? Number(process.env.PORT ?? 4000)
13
14
  // Bind loopback by default so the unauthenticated dev server is not exposed
14
15
  // on the LAN. Override with the `hostname` opt or the HOST env var.
15
16
  const hostname = opts?.hostname ?? process.env.HOST ?? '127.0.0.1'
17
+ // Status-lifecycle enforcement is OPT-IN (F32). Off unless explicitly enabled
18
+ // via the `enforceStatusLifecycle` opt or FLOWY_ENFORCE_STATUS_LIFECYCLE=1
19
+ // (also accepts "true"). When off, any vocabulary-valid status is accepted.
20
+ const enforceStatusLifecycle =
21
+ opts?.enforceStatusLifecycle ??
22
+ ['1', 'true'].includes(
23
+ (process.env.FLOWY_ENFORCE_STATUS_LIFECYCLE ?? '').toLowerCase(),
24
+ )
16
25
 
17
26
  const db = createDb(dbPath)
18
- const resolvers = createResolvers(db)
27
+ const resolvers = createResolvers(db, { enforceStatusLifecycle })
19
28
 
20
29
  const yoga = createYoga({
21
30
  schema: createSchema({ typeDefs, resolvers }),
@@ -773,8 +773,10 @@ describe('createResolvers', () => {
773
773
  })
774
774
 
775
775
  const results = resolvers.Query.search(null, { query: 'Auth' })
776
- expect(results).toHaveLength(1)
777
- expect(results[0].title).toBe('Authentication')
776
+ expect(results.nodes).toHaveLength(1)
777
+ expect(results.nodes[0].title).toBe('Authentication')
778
+ expect(results.truncated).toBe(false)
779
+ expect(results.total).toBe(1)
778
780
  })
779
781
 
780
782
  it('finds nodes by description', () => {
@@ -785,8 +787,8 @@ describe('createResolvers', () => {
785
787
  })
786
788
 
787
789
  const results = resolvers.Query.search(null, { query: 'OAuth' })
788
- expect(results).toHaveLength(1)
789
- expect(results[0].title).toBe('Login')
790
+ expect(results.nodes).toHaveLength(1)
791
+ expect(results.nodes[0].title).toBe('Login')
790
792
  })
791
793
 
792
794
  it('filters by type', () => {
@@ -803,8 +805,8 @@ describe('createResolvers', () => {
803
805
  query: 'Auth',
804
806
  type: 'project',
805
807
  })
806
- expect(results).toHaveLength(1)
807
- expect(results[0].type).toBe('project')
808
+ expect(results.nodes).toHaveLength(1)
809
+ expect(results.nodes[0].type).toBe('project')
808
810
  })
809
811
 
810
812
  it('filters by status', () => {
@@ -825,8 +827,8 @@ describe('createResolvers', () => {
825
827
  query: 'Auth',
826
828
  status: 'in_progress',
827
829
  })
828
- expect(results).toHaveLength(1)
829
- expect(results[0].title).toBe('Auth')
830
+ expect(results.nodes).toHaveLength(1)
831
+ expect(results.nodes[0].title).toBe('Auth')
830
832
  })
831
833
 
832
834
  it('respects limit', () => {
@@ -841,7 +843,58 @@ describe('createResolvers', () => {
841
843
  query: 'Task',
842
844
  limit: 2,
843
845
  })
844
- expect(results).toHaveLength(2)
846
+ expect(results.nodes).toHaveLength(2)
847
+ })
848
+
849
+ it('signals truncation when results are capped at the limit', () => {
850
+ for (let i = 0; i < 5; i++) {
851
+ resolvers.Mutation.createNode(null, {
852
+ type: 'task',
853
+ title: `Task ${i}`,
854
+ })
855
+ }
856
+
857
+ const results = resolvers.Query.search(null, {
858
+ query: 'Task',
859
+ limit: 2,
860
+ })
861
+ expect(results.nodes).toHaveLength(2)
862
+ expect(results.truncated).toBe(true)
863
+ expect(results.total).toBe(5)
864
+ })
865
+
866
+ it('does not signal truncation when all results fit under the limit', () => {
867
+ for (let i = 0; i < 3; i++) {
868
+ resolvers.Mutation.createNode(null, {
869
+ type: 'task',
870
+ title: `Task ${i}`,
871
+ })
872
+ }
873
+
874
+ const results = resolvers.Query.search(null, {
875
+ query: 'Task',
876
+ limit: 50,
877
+ })
878
+ expect(results.nodes).toHaveLength(3)
879
+ expect(results.truncated).toBe(false)
880
+ expect(results.total).toBe(3)
881
+ })
882
+
883
+ it('does not signal truncation when results exactly equal the limit', () => {
884
+ for (let i = 0; i < 2; i++) {
885
+ resolvers.Mutation.createNode(null, {
886
+ type: 'task',
887
+ title: `Task ${i}`,
888
+ })
889
+ }
890
+
891
+ const results = resolvers.Query.search(null, {
892
+ query: 'Task',
893
+ limit: 2,
894
+ })
895
+ expect(results.nodes).toHaveLength(2)
896
+ expect(results.truncated).toBe(false)
897
+ expect(results.total).toBe(2)
845
898
  })
846
899
  })
847
900
 
@@ -958,7 +1011,9 @@ describe('createResolvers', () => {
958
1011
  const results = resolvers.Query.search(null, {
959
1012
  query: 'zzz_no_match',
960
1013
  })
961
- expect(results).toEqual([])
1014
+ expect(results.nodes).toEqual([])
1015
+ expect(results.truncated).toBe(false)
1016
+ expect(results.total).toBe(0)
962
1017
  })
963
1018
 
964
1019
  it('throws when query is shorter than 3 characters', () => {
@@ -976,23 +1031,23 @@ describe('createResolvers', () => {
976
1031
  it('succeeds with 3-character query', () => {
977
1032
  create(resolvers, { type: 'project', title: 'abc match' })
978
1033
  const results = resolvers.Query.search(null, { query: 'abc' })
979
- expect(results).toHaveLength(1)
1034
+ expect(results.nodes).toHaveLength(1)
980
1035
  })
981
1036
 
982
1037
  it('does not treat % as LIKE wildcard', () => {
983
1038
  create(resolvers, { type: 'project', title: '100% done' })
984
1039
  create(resolvers, { type: 'project', title: '100 things' })
985
1040
  const results = resolvers.Query.search(null, { query: '100%' })
986
- expect(results).toHaveLength(1)
987
- expect(results[0].title).toBe('100% done')
1041
+ expect(results.nodes).toHaveLength(1)
1042
+ expect(results.nodes[0].title).toBe('100% done')
988
1043
  })
989
1044
 
990
1045
  it('does not treat _ as LIKE wildcard', () => {
991
1046
  create(resolvers, { type: 'project', title: '_est something' })
992
1047
  create(resolvers, { type: 'project', title: 'Test something' })
993
1048
  const results = resolvers.Query.search(null, { query: '_est' })
994
- expect(results).toHaveLength(1)
995
- expect(results[0].title).toBe('_est something')
1049
+ expect(results.nodes).toHaveLength(1)
1050
+ expect(results.nodes[0].title).toBe('_est something')
996
1051
  })
997
1052
  })
998
1053
 
@@ -1071,6 +1126,150 @@ describe('createResolvers', () => {
1071
1126
  })
1072
1127
  })
1073
1128
 
1129
+ describe('status lifecycle enforcement (opt-in)', () => {
1130
+ let strict: ReturnType<typeof createResolvers>
1131
+
1132
+ beforeEach(() => {
1133
+ strict = createResolvers(db, { enforceStatusLifecycle: true })
1134
+ })
1135
+
1136
+ function strictCreate(title: string): NodeGql {
1137
+ return strict.Mutation.createNode(null, { type: 'task', title })
1138
+ }
1139
+
1140
+ it('allows the canonical forward flow draft -> ... -> done', () => {
1141
+ const node = strictCreate('Flow')
1142
+ expect(node.status).toBe('draft')
1143
+ let cur = strict.Mutation.updateNode(null, {
1144
+ id: node.id,
1145
+ status: 'pending_review',
1146
+ })
1147
+ expect(cur.status).toBe('pending_review')
1148
+ cur = strict.Mutation.updateNode(null, {
1149
+ id: node.id,
1150
+ status: 'approved',
1151
+ })
1152
+ expect(cur.status).toBe('approved')
1153
+ cur = strict.Mutation.updateNode(null, {
1154
+ id: node.id,
1155
+ status: 'in_progress',
1156
+ })
1157
+ expect(cur.status).toBe('in_progress')
1158
+ cur = strict.Mutation.updateNode(null, { id: node.id, status: 'done' })
1159
+ expect(cur.status).toBe('done')
1160
+ })
1161
+
1162
+ it('rejects an illegal skip (draft -> done) with VALIDATION_ERROR', () => {
1163
+ const node = strictCreate('Skip')
1164
+ expect(() =>
1165
+ strict.Mutation.updateNode(null, { id: node.id, status: 'done' }),
1166
+ ).toThrow(/transition/i)
1167
+ try {
1168
+ strict.Mutation.updateNode(null, { id: node.id, status: 'done' })
1169
+ } catch (e) {
1170
+ expect((e as { extensions?: { code?: string } }).extensions?.code).toBe(
1171
+ 'VALIDATION_ERROR',
1172
+ )
1173
+ }
1174
+ })
1175
+
1176
+ it('rejects skipping pending_review -> in_progress', () => {
1177
+ const node = strictCreate('Skip2')
1178
+ strict.Mutation.updateNode(null, {
1179
+ id: node.id,
1180
+ status: 'pending_review',
1181
+ })
1182
+ expect(() =>
1183
+ strict.Mutation.updateNode(null, {
1184
+ id: node.id,
1185
+ status: 'in_progress',
1186
+ }),
1187
+ ).toThrow(/transition/i)
1188
+ })
1189
+
1190
+ it('allows cancelling from an active state', () => {
1191
+ const node = strictCreate('Cancel')
1192
+ strict.Mutation.updateNode(null, {
1193
+ id: node.id,
1194
+ status: 'pending_review',
1195
+ })
1196
+ const cur = strict.Mutation.updateNode(null, {
1197
+ id: node.id,
1198
+ status: 'cancelled',
1199
+ })
1200
+ expect(cur.status).toBe('cancelled')
1201
+ })
1202
+
1203
+ it('allows blocking from in_progress and resuming', () => {
1204
+ const node = strictCreate('Block')
1205
+ strict.Mutation.updateNode(null, {
1206
+ id: node.id,
1207
+ status: 'pending_review',
1208
+ })
1209
+ strict.Mutation.updateNode(null, { id: node.id, status: 'approved' })
1210
+ strict.Mutation.updateNode(null, { id: node.id, status: 'in_progress' })
1211
+ let cur = strict.Mutation.updateNode(null, {
1212
+ id: node.id,
1213
+ status: 'blocked',
1214
+ })
1215
+ expect(cur.status).toBe('blocked')
1216
+ cur = strict.Mutation.updateNode(null, {
1217
+ id: node.id,
1218
+ status: 'in_progress',
1219
+ })
1220
+ expect(cur.status).toBe('in_progress')
1221
+ })
1222
+
1223
+ it('rejects blocking directly from draft', () => {
1224
+ const node = strictCreate('BlockEarly')
1225
+ expect(() =>
1226
+ strict.Mutation.updateNode(null, { id: node.id, status: 'blocked' }),
1227
+ ).toThrow(/transition/i)
1228
+ })
1229
+
1230
+ it('allows reopening done -> in_progress', () => {
1231
+ const node = strictCreate('Reopen')
1232
+ strict.Mutation.updateNode(null, {
1233
+ id: node.id,
1234
+ status: 'pending_review',
1235
+ })
1236
+ strict.Mutation.updateNode(null, { id: node.id, status: 'approved' })
1237
+ strict.Mutation.updateNode(null, { id: node.id, status: 'in_progress' })
1238
+ strict.Mutation.updateNode(null, { id: node.id, status: 'done' })
1239
+ const cur = strict.Mutation.updateNode(null, {
1240
+ id: node.id,
1241
+ status: 'in_progress',
1242
+ })
1243
+ expect(cur.status).toBe('in_progress')
1244
+ })
1245
+
1246
+ it('allows a same-status no-op even under enforcement', () => {
1247
+ const node = strictCreate('NoOp')
1248
+ const cur = strict.Mutation.updateNode(null, {
1249
+ id: node.id,
1250
+ status: 'draft',
1251
+ })
1252
+ expect(cur.status).toBe('draft')
1253
+ })
1254
+
1255
+ it('still validates the status vocabulary', () => {
1256
+ const node = strictCreate('Vocab')
1257
+ expect(() =>
1258
+ strict.Mutation.updateNode(null, { id: node.id, status: 'bogus' }),
1259
+ ).toThrow(/Invalid status/)
1260
+ })
1261
+
1262
+ it('does NOT enforce transitions when the flag is off (default)', () => {
1263
+ // default `resolvers` (no enforcement) permits the illegal skip
1264
+ const node = create(resolvers, { type: 'task', title: 'Default' })
1265
+ const updated = resolvers.Mutation.updateNode(null, {
1266
+ id: node.id,
1267
+ status: 'done',
1268
+ })
1269
+ expect(updated.status).toBe('done')
1270
+ })
1271
+ })
1272
+
1074
1273
  describe('Query.descendants — edge cases', () => {
1075
1274
  it('returns empty array for leaf node', () => {
1076
1275
  const leaf = create(resolvers, { type: 'task', title: 'Leaf' })
@@ -1216,7 +1415,7 @@ describe('createResolvers', () => {
1216
1415
  query: 'Test',
1217
1416
  limit: 0,
1218
1417
  })
1219
- expect(results).toEqual([])
1418
+ expect(results.nodes).toEqual([])
1220
1419
  })
1221
1420
  })
1222
1421
 
@@ -81,6 +81,18 @@ export interface SubtreeNodeGql extends NodeGql {
81
81
  relation: string
82
82
  }
83
83
 
84
+ /**
85
+ * Search results plus truncation metadata (F32). `nodes` is capped at `limit`;
86
+ * `total` is the unbounded match count and `truncated` is true when `total`
87
+ * exceeds the returned page — letting the CLI show a clear "results truncated"
88
+ * marker instead of silently dropping matches at the default cap.
89
+ */
90
+ export interface SearchResultGql {
91
+ nodes: NodeGql[]
92
+ truncated: boolean
93
+ total: number
94
+ }
95
+
84
96
  interface SubtreeRow extends NodeRow {
85
97
  parent_id: string
86
98
  depth: number
@@ -114,6 +126,36 @@ function assertValidStatus(status: string): void {
114
126
  }
115
127
  }
116
128
 
129
+ /**
130
+ * Allowed status transitions for the OPT-IN lifecycle enforcement
131
+ * (`FLOWY_ENFORCE_STATUS_LIFECYCLE`). The canonical forward flow is
132
+ * `draft → pending_review → approved → in_progress → done`; `blocked` and
133
+ * `cancelled` are reachable from active states and recoverable back into the
134
+ * flow. A same-status update is always a no-op and never checked here. When
135
+ * enforcement is OFF (the default) this map is unused and any vocabulary-valid
136
+ * status is accepted, preserving the prior behaviour.
137
+ */
138
+ const ALLOWED_TRANSITIONS: Record<string, Set<string>> = {
139
+ draft: new Set(['pending_review', 'cancelled']),
140
+ pending_review: new Set(['approved', 'draft', 'cancelled']),
141
+ approved: new Set(['in_progress', 'pending_review', 'cancelled']),
142
+ in_progress: new Set(['done', 'blocked', 'cancelled']),
143
+ done: new Set(['in_progress']),
144
+ blocked: new Set(['in_progress', 'cancelled']),
145
+ cancelled: new Set(['draft']),
146
+ }
147
+
148
+ function assertValidTransition(from: string, to: string): void {
149
+ if (from === to) return
150
+ if (!ALLOWED_TRANSITIONS[from]?.has(to)) {
151
+ throw validationError(
152
+ `Illegal status transition: ${from} → ${to}. Allowed from "${from}": ${
153
+ [...(ALLOWED_TRANSITIONS[from] ?? [])].join(', ') || '(none)'
154
+ }`,
155
+ )
156
+ }
157
+ }
158
+
117
159
  /**
118
160
  * Validate that `metadata` is a JSON string and return its canonical form.
119
161
  * Metadata is stored as a JSON string (the column and the GraphQL field are
@@ -216,7 +258,18 @@ function prefixedCols() {
216
258
  .join(', ')
217
259
  }
218
260
 
219
- export function createResolvers(db: Db) {
261
+ export interface ResolverOptions {
262
+ /**
263
+ * Enforce the canonical status lifecycle on `updateNode` status changes.
264
+ * OFF by default — when false (the default) any vocabulary-valid status is
265
+ * accepted, matching pre-F32 behaviour. Wired from
266
+ * `FLOWY_ENFORCE_STATUS_LIFECYCLE` in `index.ts`.
267
+ */
268
+ enforceStatusLifecycle?: boolean
269
+ }
270
+
271
+ export function createResolvers(db: Db, opts: ResolverOptions = {}) {
272
+ const enforceStatusLifecycle = opts.enforceStatusLifecycle ?? false
220
273
  return {
221
274
  Query: {
222
275
  node: (_: unknown, args: { id: string }) => {
@@ -386,7 +439,7 @@ export function createResolvers(db: Db) {
386
439
  status?: string
387
440
  limit?: number
388
441
  },
389
- ) => {
442
+ ): SearchResultGql => {
390
443
  if (args.query.trim().length < 3) {
391
444
  throw validationError('Search query must be at least 3 characters')
392
445
  }
@@ -403,13 +456,23 @@ export function createResolvers(db: Db) {
403
456
  conditions.push('status = ?')
404
457
  params.push(args.status)
405
458
  }
459
+ const where = conditions.join(' AND ')
406
460
  const limit = args.limit ?? 50
461
+ // Fetch one extra row so we can tell "exactly at the cap" from
462
+ // "more matches exist" without a second COUNT for the common case.
407
463
  const rows = db.raw
408
- .query(
409
- `SELECT ${NODE_COLS} FROM nodes WHERE ${conditions.join(' AND ')} LIMIT ?`,
410
- )
411
- .all(...params, limit) as NodeRow[]
412
- return selectNodes(rows)
464
+ .query(`SELECT ${NODE_COLS} FROM nodes WHERE ${where} LIMIT ?`)
465
+ .all(...params, limit + 1) as NodeRow[]
466
+ const truncated = rows.length > limit
467
+ const page = truncated ? rows.slice(0, limit) : rows
468
+ const total = truncated
469
+ ? (
470
+ db.raw
471
+ .query(`SELECT COUNT(*) AS c FROM nodes WHERE ${where}`)
472
+ .get(...params) as { c: number }
473
+ ).c
474
+ : page.length
475
+ return { nodes: selectNodes(page), truncated, total }
413
476
  },
414
477
 
415
478
  // Audit history for a node, newest first. Shaped to match the SaaS
@@ -507,7 +570,12 @@ export function createResolvers(db: Db) {
507
570
  if (args.description != null && !args.description.trim()) {
508
571
  throw validationError('Description cannot be empty')
509
572
  }
510
- if (args.status != null) assertValidStatus(args.status)
573
+ if (args.status != null) {
574
+ assertValidStatus(args.status)
575
+ if (enforceStatusLifecycle) {
576
+ assertValidTransition(existing.status, args.status)
577
+ }
578
+ }
511
579
 
512
580
  const next: NodeRow = {
513
581
  ...existing,
@@ -52,6 +52,16 @@ export const typeDefs = /* GraphQL */ `
52
52
  relation: String!
53
53
  }
54
54
 
55
+ # Search results plus truncation metadata (F32). \`nodes\` is the page capped
56
+ # at the requested \`limit\`; \`total\` is the unbounded match count and
57
+ # \`truncated\` is true when more matches exist than were returned, so the CLI
58
+ # can surface a "results truncated" marker instead of silently dropping rows.
59
+ type SearchResult {
60
+ nodes: [Node!]!
61
+ truncated: Boolean!
62
+ total: Int!
63
+ }
64
+
55
65
  type Query {
56
66
  node(id: String!): Node
57
67
  nodes(type: String): [Node!]!
@@ -59,7 +69,12 @@ export const typeDefs = /* GraphQL */ `
59
69
  subtree(nodeId: String!, relation: String, maxDepth: Int): [SubtreeNode!]!
60
70
  edges(nodeId: String!, relation: String!, direction: String): [Node!]!
61
71
  readyTasks(projectId: String): [Node!]!
62
- search(query: String!, type: String, status: String, limit: Int): [Node!]!
72
+ search(
73
+ query: String!
74
+ type: String
75
+ status: String
76
+ limit: Int
77
+ ): SearchResult!
63
78
  auditLog(nodeId: String!, limit: Int): [AuditEntry!]!
64
79
  }
65
80
 
@@ -154,6 +154,9 @@ flowy status <id> done
154
154
  flowy search "query" --type task --status draft --limit 10
155
155
  flowy tree <id> --depth 3 # show subtree from any entity
156
156
  ```
157
+ `search` prints `{ nodes, truncated, total }`. When `truncated` is `true` the
158
+ results were capped at `--limit` (default 50) and `total` more matches exist —
159
+ raise `--limit` to see them.
157
160
 
158
161
  ### Import and Export
159
162
  ```bash
@@ -0,0 +1,90 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2
+
3
+ let mockGraphql: ReturnType<typeof vi.fn>
4
+ let mockOutput: ReturnType<typeof vi.fn>
5
+ let mockOutputError: ReturnType<typeof vi.fn>
6
+ let stderr: ReturnType<typeof vi.spyOn>
7
+
8
+ beforeEach(() => {
9
+ mockOutput = vi.fn()
10
+ mockOutputError = vi.fn()
11
+ mockGraphql = vi.fn()
12
+ stderr = vi.spyOn(console, 'error').mockImplementation(() => {})
13
+
14
+ vi.doMock('../util/format.ts', () => ({
15
+ output: mockOutput,
16
+ outputError: mockOutputError,
17
+ }))
18
+ vi.doMock('../util/client.ts', () => ({
19
+ graphql: mockGraphql,
20
+ }))
21
+ })
22
+
23
+ afterEach(() => {
24
+ vi.resetModules()
25
+ vi.restoreAllMocks()
26
+ })
27
+
28
+ describe('search command', () => {
29
+ test('sends the SearchResult envelope query and outputs nodes + meta', async () => {
30
+ mockGraphql.mockResolvedValue({
31
+ search: {
32
+ nodes: [{ id: 'proj_1', title: 'Auth' }],
33
+ truncated: false,
34
+ total: 1,
35
+ },
36
+ })
37
+
38
+ const { searchCommand } = await import('./search.ts')
39
+ await searchCommand.parseAsync(['Auth'], { from: 'user' })
40
+
41
+ const query = mockGraphql.mock.calls[0]?.[0] as string
42
+ expect(query).toContain('nodes')
43
+ expect(query).toContain('truncated')
44
+ expect(query).toContain('total')
45
+
46
+ expect(mockOutput).toHaveBeenCalledWith({
47
+ nodes: [{ id: 'proj_1', title: 'Auth' }],
48
+ truncated: false,
49
+ total: 1,
50
+ })
51
+ // no truncation warning when not truncated
52
+ expect(stderr).not.toHaveBeenCalled()
53
+ })
54
+
55
+ test('renders a clear truncation marker when results are capped', async () => {
56
+ mockGraphql.mockResolvedValue({
57
+ search: {
58
+ nodes: new Array(50).fill({ id: 'x' }),
59
+ truncated: true,
60
+ total: 137,
61
+ },
62
+ })
63
+
64
+ const { searchCommand } = await import('./search.ts')
65
+ await searchCommand.parseAsync(['Task'], { from: 'user' })
66
+
67
+ const outputArg = mockOutput.mock.calls[0]?.[0] as {
68
+ truncated: boolean
69
+ total: number
70
+ }
71
+ expect(outputArg.truncated).toBe(true)
72
+ expect(outputArg.total).toBe(137)
73
+
74
+ expect(stderr).toHaveBeenCalledOnce()
75
+ const warning = stderr.mock.calls[0]?.[0] as string
76
+ expect(warning).toMatch(/truncated/i)
77
+ expect(warning).toContain('50')
78
+ expect(warning).toContain('137')
79
+ })
80
+
81
+ test('outputs error when the query fails', async () => {
82
+ mockGraphql.mockRejectedValue(new Error('boom'))
83
+ const { searchCommand } = await import('./search.ts')
84
+ await searchCommand.parseAsync(['Auth'], { from: 'user' })
85
+ expect(mockOutputError).toHaveBeenCalledWith(
86
+ expect.objectContaining({ message: 'boom' }),
87
+ )
88
+ expect(mockOutput).not.toHaveBeenCalled()
89
+ })
90
+ })
@@ -3,6 +3,12 @@ import { graphql } from '../util/client.ts'
3
3
  import { output, outputError } from '../util/format.ts'
4
4
  import { SEARCH } from '../util/operations.ts'
5
5
 
6
+ interface SearchResult {
7
+ nodes: unknown[]
8
+ truncated: boolean
9
+ total: number
10
+ }
11
+
6
12
  export const searchCommand = new Command('search')
7
13
  .description('Search nodes by text')
8
14
  .argument('<query>', 'Search query')
@@ -11,13 +17,23 @@ export const searchCommand = new Command('search')
11
17
  .option('--limit <n>', 'Limit results', '50')
12
18
  .action(async (query: string, opts) => {
13
19
  try {
14
- const data = await graphql<{ search: unknown[] }>(SEARCH, {
20
+ const limit = opts.limit ? Number.parseInt(opts.limit, 10) : undefined
21
+ const data = await graphql<{ search: SearchResult }>(SEARCH, {
15
22
  query,
16
23
  type: opts.type,
17
24
  status: opts.status,
18
- limit: opts.limit ? Number.parseInt(opts.limit, 10) : undefined,
25
+ limit,
19
26
  })
20
- output(data.search)
27
+ const { nodes, truncated, total } = data.search
28
+ // The truncation marker rides in the JSON envelope so callers can detect
29
+ // it programmatically; when capped we also warn on stderr so it is not
30
+ // silently lost in a human-read terminal.
31
+ output({ nodes, truncated, total })
32
+ if (truncated) {
33
+ console.error(
34
+ `Results truncated: showing ${nodes.length} of ${total} matches. Raise --limit to see more.`,
35
+ )
36
+ }
21
37
  } catch (error) {
22
38
  outputError(error)
23
39
  }
@@ -0,0 +1,71 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2
+
3
+ let mockGraphql: ReturnType<typeof vi.fn>
4
+ let mockOutput: ReturnType<typeof vi.fn>
5
+ let mockOutputError: ReturnType<typeof vi.fn>
6
+
7
+ beforeEach(() => {
8
+ mockOutput = vi.fn()
9
+ mockOutputError = vi.fn()
10
+ mockGraphql = vi.fn().mockResolvedValue({
11
+ updateNode: { id: 'task_1', type: 'task', title: 'T', status: 'done' },
12
+ })
13
+
14
+ vi.doMock('../util/format.ts', () => ({
15
+ output: mockOutput,
16
+ outputError: mockOutputError,
17
+ }))
18
+ vi.doMock('../util/client.ts', () => ({
19
+ graphql: mockGraphql,
20
+ }))
21
+ })
22
+
23
+ afterEach(() => {
24
+ vi.resetModules()
25
+ vi.restoreAllMocks()
26
+ })
27
+
28
+ describe('status command', () => {
29
+ test('accepts a valid status and sends the update', async () => {
30
+ const { statusCommand } = await import('./status.ts')
31
+ await statusCommand.parseAsync(['task_1', 'in_progress'], { from: 'user' })
32
+
33
+ expect(mockGraphql).toHaveBeenCalledOnce()
34
+ expect(mockGraphql.mock.calls[0]?.[1]).toEqual({
35
+ id: 'task_1',
36
+ status: 'in_progress',
37
+ })
38
+ expect(mockOutput).toHaveBeenCalledOnce()
39
+ })
40
+
41
+ test('rejects an invalid status client-side (.choices) without a request', async () => {
42
+ const { statusCommand } = await import('./status.ts')
43
+ // Commander throws on an invalid choice; configure it not to exit the
44
+ // process so the test can assert.
45
+ statusCommand.exitOverride()
46
+ statusCommand.configureOutput({ writeErr: () => {}, writeOut: () => {} })
47
+
48
+ await expect(
49
+ statusCommand.parseAsync(['task_1', 'bogus'], { from: 'user' }),
50
+ ).rejects.toThrow(/Allowed choices|bogus/i)
51
+
52
+ expect(mockGraphql).not.toHaveBeenCalled()
53
+ })
54
+
55
+ test('exposes the full canonical status vocabulary as choices', async () => {
56
+ const { statusCommand, STATUS_CHOICES } = await import('./status.ts')
57
+ expect(STATUS_CHOICES).toEqual([
58
+ 'draft',
59
+ 'pending_review',
60
+ 'approved',
61
+ 'in_progress',
62
+ 'done',
63
+ 'blocked',
64
+ 'cancelled',
65
+ ])
66
+ const statusArg = statusCommand.registeredArguments.find(
67
+ (a) => a.name() === 'status',
68
+ )
69
+ expect(statusArg?.argChoices).toEqual([...STATUS_CHOICES])
70
+ })
71
+ })
@@ -1,14 +1,28 @@
1
- import { Command } from 'commander'
1
+ import { Argument, Command } from 'commander'
2
2
  import { graphql } from '../util/client.ts'
3
3
  import { output, outputError } from '../util/format.ts'
4
4
  import { UPDATE_STATUS } from '../util/operations.ts'
5
5
 
6
+ /**
7
+ * The canonical Flowy status vocabulary, mirrored from the server's
8
+ * VALID_STATUSES. Used for client-side `.choices()` validation so an invalid
9
+ * status is rejected before a request is ever sent.
10
+ */
11
+ export const STATUS_CHOICES = [
12
+ 'draft',
13
+ 'pending_review',
14
+ 'approved',
15
+ 'in_progress',
16
+ 'done',
17
+ 'blocked',
18
+ 'cancelled',
19
+ ] as const
20
+
6
21
  export const statusCommand = new Command('status')
7
22
  .description('Update a node status (shorthand)')
8
23
  .argument('<id>', 'Node ID')
9
- .argument(
10
- '<status>',
11
- 'New status (draft, pending_review, approved, in_progress, done, blocked, cancelled)',
24
+ .addArgument(
25
+ new Argument('<status>', 'New status').choices([...STATUS_CHOICES]),
12
26
  )
13
27
  .action(async (id: string, status: string) => {
14
28
  try {
@@ -116,10 +116,18 @@ export const TASK_DEPS = `query TaskDeps($id: String!) {
116
116
  }
117
117
  }`
118
118
 
119
- /** search.ts — full-text search with optional type/status/limit filters. */
119
+ /**
120
+ * search.ts — full-text search with optional type/status/limit filters.
121
+ * Returns a SearchResult envelope (F32): `nodes` is the page capped at `limit`,
122
+ * `truncated` flags that more matches exist than were returned, and `total` is
123
+ * the unbounded match count — so the CLI can show a truncation marker instead
124
+ * of silently dropping rows at the default cap.
125
+ */
120
126
  export const SEARCH = `query Search($query: String!, $type: String, $status: String, $limit: Int) {
121
127
  search(query: $query, type: $type, status: $status, limit: $limit) {
122
- id type title description status
128
+ nodes { id type title description status }
129
+ truncated
130
+ total
123
131
  }
124
132
  }`
125
133