@sqaoss/flowy 1.9.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 +3 -1
- package/package.json +5 -2
- package/server/src/contract.test.ts +53 -7
- package/server/src/index.errors.test.ts +6 -3
- package/server/src/index.ts +10 -1
- package/server/src/migrations.test.ts +61 -0
- package/server/src/migrations.ts +27 -0
- package/server/src/resolvers.test.ts +355 -16
- package/server/src/resolvers.ts +315 -38
- package/server/src/schema.ts +32 -1
- package/skills/using-flowy/SKILL.md +3 -0
- package/src/commands/history.test.ts +99 -0
- package/src/commands/history.ts +20 -0
- package/src/commands/search.test.ts +90 -0
- package/src/commands/search.ts +19 -3
- package/src/commands/status.test.ts +71 -0
- package/src/commands/status.ts +18 -4
- package/src/index.test.ts +16 -0
- package/src/index.ts +2 -0
- package/src/util/operations.test.ts +1 -0
- package/src/util/operations.ts +26 -3
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.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Agentic persistent planning",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
"check": "biome check --write .",
|
|
51
51
|
"test": "vitest run",
|
|
52
52
|
"test:watch": "vitest",
|
|
53
|
+
"test:coverage": "vitest run --coverage",
|
|
54
|
+
"test:e2e": "vitest run --config vitest.config.e2e.ts",
|
|
53
55
|
"sdl": "bun scripts/print-sdl.ts",
|
|
54
56
|
"typecheck": "tsc --noEmit",
|
|
55
57
|
"prepare": "husky"
|
|
@@ -63,11 +65,12 @@
|
|
|
63
65
|
"@commitlint/config-conventional": "^20.4.2",
|
|
64
66
|
"@semantic-release/changelog": "6.0.3",
|
|
65
67
|
"@semantic-release/git": "10.0.1",
|
|
68
|
+
"@vitest/coverage-v8": "4.1.8",
|
|
66
69
|
"husky": "^9.1.7",
|
|
67
70
|
"lint-staged": "^16.3.0",
|
|
68
71
|
"semantic-release": "25.0.3",
|
|
69
72
|
"typescript": "^5",
|
|
70
|
-
"vitest": "
|
|
73
|
+
"vitest": "4.1.8"
|
|
71
74
|
},
|
|
72
75
|
"lint-staged": {
|
|
73
76
|
"*.{ts,tsx,js,jsx}": [
|
|
@@ -31,9 +31,11 @@
|
|
|
31
31
|
* - `rotateApiKey` (key.ts) — API key lifecycle; local has no keys.
|
|
32
32
|
* - `createCheckout` (billing.ts)— Polar checkout; local has no billing.
|
|
33
33
|
*
|
|
34
|
-
* Conversely, the SaaS schema carries operations the local server
|
|
35
|
-
* CLI does not yet call against local: `
|
|
36
|
-
*
|
|
34
|
+
* Conversely, the SaaS schema carries some operations the local server still
|
|
35
|
+
* lacks and the CLI does not yet call against local: `ancestors` and
|
|
36
|
+
* `nodes(status/limit/offset)` pagination. (`auditLog` was such a divergence
|
|
37
|
+
* until P1-2/F27 ported it to the bundled local server; it is now part of
|
|
38
|
+
* LOCAL_CONTRACT_OPERATIONS and exercised below.)
|
|
37
39
|
* Status vocabulary and edge relations are shared (`part_of`, `blocks`); the
|
|
38
40
|
* SaaS schema additionally recognises `epic`/`depends_on`/`informs` relations
|
|
39
41
|
* the bundled server does not. The test below asserts the local server rejects
|
|
@@ -358,11 +360,54 @@ describe('CLI/local-server contract (P1-1)', () => {
|
|
|
358
360
|
})
|
|
359
361
|
expect(Array.isArray(exportEdges.edges)).toBe(true)
|
|
360
362
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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')
|
|
379
|
+
|
|
380
|
+
// auditLog (P1-2/F27): the task has accumulated a trail — a `create` on
|
|
381
|
+
// insert, a `status_change` to pending_review, an `approve`, plus the
|
|
382
|
+
// content `update`s. Entries come back newest-first and carry the
|
|
383
|
+
// SaaS-shaped fields.
|
|
384
|
+
const history = await ok<{
|
|
385
|
+
auditLog: Array<{
|
|
386
|
+
id: string
|
|
387
|
+
action: string
|
|
388
|
+
field: string | null
|
|
389
|
+
oldValue: string | null
|
|
390
|
+
newValue: string | null
|
|
391
|
+
snapshot: string | null
|
|
392
|
+
changedBy: string
|
|
393
|
+
createdAt: string
|
|
394
|
+
}>
|
|
395
|
+
}>(LOCAL_CONTRACT_OPERATIONS.AUDIT_LOG, { nodeId: task.id, limit: 50 })
|
|
396
|
+
const actions = history.auditLog.map((e) => e.action)
|
|
397
|
+
expect(actions).toContain('create')
|
|
398
|
+
expect(actions).toContain('status_change')
|
|
399
|
+
expect(actions).toContain('approve')
|
|
400
|
+
// changedBy default actor + ISO timestamps
|
|
401
|
+
expect(history.auditLog.every((e) => e.changedBy === 'local')).toBe(true)
|
|
402
|
+
expect(history.auditLog.every((e) => typeof e.createdAt === 'string')).toBe(
|
|
403
|
+
true,
|
|
404
|
+
)
|
|
405
|
+
// the status_change entry carries the field-level diff
|
|
406
|
+
const statusEntry = history.auditLog.find(
|
|
407
|
+
(e) => e.action === 'status_change',
|
|
364
408
|
)
|
|
365
|
-
expect(
|
|
409
|
+
expect(statusEntry?.field).toBe('status')
|
|
410
|
+
expect(statusEntry?.newValue).toBe('pending_review')
|
|
366
411
|
|
|
367
412
|
// --- Edge removal + node deletion --------------------------------------
|
|
368
413
|
const { removeEdge } = await ok<{ removeEdge: boolean }>(
|
|
@@ -414,6 +459,7 @@ describe('CLI/local-server contract (P1-1)', () => {
|
|
|
414
459
|
'EXPORT_PROJECT',
|
|
415
460
|
'EXPORT_DESCENDANTS',
|
|
416
461
|
'EXPORT_EDGES',
|
|
462
|
+
'AUDIT_LOG',
|
|
417
463
|
])
|
|
418
464
|
expect(new Set(Object.keys(LOCAL_CONTRACT_OPERATIONS))).toEqual(exercised)
|
|
419
465
|
})
|
|
@@ -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(
|
|
33
|
-
q: '
|
|
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]
|
package/server/src/index.ts
CHANGED
|
@@ -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 }),
|
|
@@ -42,6 +42,67 @@ describe('runMigrations', () => {
|
|
|
42
42
|
db.close()
|
|
43
43
|
})
|
|
44
44
|
|
|
45
|
+
it('creates the audit_log table with SaaS-mirrored columns on a fresh DB', () => {
|
|
46
|
+
const db = new Database(':memory:')
|
|
47
|
+
runMigrations(db)
|
|
48
|
+
expect(tableNames(db)).toContain('audit_log')
|
|
49
|
+
const cols = columnNames(db, 'audit_log')
|
|
50
|
+
for (const c of [
|
|
51
|
+
'id',
|
|
52
|
+
'node_id',
|
|
53
|
+
'action',
|
|
54
|
+
'field',
|
|
55
|
+
'old_value',
|
|
56
|
+
'new_value',
|
|
57
|
+
'snapshot',
|
|
58
|
+
'changed_by',
|
|
59
|
+
'created_at',
|
|
60
|
+
]) {
|
|
61
|
+
expect(cols).toContain(c)
|
|
62
|
+
}
|
|
63
|
+
db.close()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('adds audit_log when upgrading an existing pre-audit DB', () => {
|
|
67
|
+
const db = new Database(':memory:')
|
|
68
|
+
// Simulate a DB already at the pre-audit version (nodes + edges + metadata,
|
|
69
|
+
// user_version = 2) that has never seen the audit migration.
|
|
70
|
+
db.run(`
|
|
71
|
+
CREATE TABLE nodes (
|
|
72
|
+
id TEXT PRIMARY KEY,
|
|
73
|
+
type TEXT NOT NULL CHECK(type IN ('project', 'feature', 'task')),
|
|
74
|
+
title TEXT NOT NULL,
|
|
75
|
+
description TEXT,
|
|
76
|
+
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'pending_review', 'approved', 'in_progress', 'done', 'blocked', 'cancelled')),
|
|
77
|
+
metadata TEXT,
|
|
78
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
79
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
80
|
+
)
|
|
81
|
+
`)
|
|
82
|
+
db.run(`
|
|
83
|
+
CREATE TABLE edges (
|
|
84
|
+
source_id TEXT NOT NULL REFERENCES nodes(id),
|
|
85
|
+
target_id TEXT NOT NULL REFERENCES nodes(id),
|
|
86
|
+
relation TEXT NOT NULL CHECK(relation IN ('part_of', 'blocks')),
|
|
87
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
88
|
+
PRIMARY KEY (source_id, target_id, relation)
|
|
89
|
+
)
|
|
90
|
+
`)
|
|
91
|
+
db.run("INSERT INTO nodes (id, type, title) VALUES ('task_1', 'task', 'T')")
|
|
92
|
+
db.run('PRAGMA user_version = 2')
|
|
93
|
+
|
|
94
|
+
expect(tableNames(db)).not.toContain('audit_log')
|
|
95
|
+
runMigrations(db)
|
|
96
|
+
expect(userVersion(db)).toBe(LATEST_VERSION)
|
|
97
|
+
expect(tableNames(db)).toContain('audit_log')
|
|
98
|
+
// existing data preserved across the upgrade
|
|
99
|
+
const row = db
|
|
100
|
+
.query<{ id: string }, []>('SELECT id FROM nodes WHERE id = ?')
|
|
101
|
+
.get('task_1') as { id: string }
|
|
102
|
+
expect(row.id).toBe('task_1')
|
|
103
|
+
db.close()
|
|
104
|
+
})
|
|
105
|
+
|
|
45
106
|
it('is a no-op when run twice (idempotent)', () => {
|
|
46
107
|
const db = new Database(':memory:')
|
|
47
108
|
runMigrations(db)
|
package/server/src/migrations.ts
CHANGED
|
@@ -116,6 +116,33 @@ const MIGRATIONS: Migration[] = [
|
|
|
116
116
|
db.run('CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type)')
|
|
117
117
|
db.run('CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status)')
|
|
118
118
|
},
|
|
119
|
+
|
|
120
|
+
// 2 -> 3: add the `audit_log` table (F27). Mirrors the SaaS audit_log schema
|
|
121
|
+
// (id, node_id, action, field, old_value, new_value, snapshot, changed_by,
|
|
122
|
+
// created_at) so `flowy history` output is consistent across backends. The
|
|
123
|
+
// local server is single-tenant and has no users table, so `changed_by`
|
|
124
|
+
// carries a constant actor ('local') rather than a FK to a user. `node_id`
|
|
125
|
+
// is nullable and ON DELETE SET NULL so delete-audit rows survive the node.
|
|
126
|
+
// Uses IF NOT EXISTS so the step is idempotent on a DB that somehow already
|
|
127
|
+
// has the table.
|
|
128
|
+
(db) => {
|
|
129
|
+
db.run(`
|
|
130
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
131
|
+
id TEXT PRIMARY KEY,
|
|
132
|
+
node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL,
|
|
133
|
+
action TEXT NOT NULL,
|
|
134
|
+
field TEXT,
|
|
135
|
+
old_value TEXT,
|
|
136
|
+
new_value TEXT,
|
|
137
|
+
snapshot TEXT,
|
|
138
|
+
changed_by TEXT NOT NULL DEFAULT 'local',
|
|
139
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
140
|
+
)
|
|
141
|
+
`)
|
|
142
|
+
db.run(
|
|
143
|
+
'CREATE INDEX IF NOT EXISTS idx_audit_log_node ON audit_log(node_id)',
|
|
144
|
+
)
|
|
145
|
+
},
|
|
119
146
|
]
|
|
120
147
|
|
|
121
148
|
export const LATEST_VERSION = MIGRATIONS.length
|