@cleocode/core 2026.4.44 → 2026.4.46
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/dist/conduit/index.js +749 -0
- package/dist/conduit/index.js.map +7 -0
- package/dist/index.js +81 -62
- package/dist/index.js.map +3 -3
- package/dist/internal.js +110682 -0
- package/dist/internal.js.map +7 -0
- package/dist/store/db-helpers.d.ts +8 -3
- package/dist/store/db-helpers.d.ts.map +1 -1
- package/dist/store/migration-manager.d.ts.map +1 -1
- package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/store/__tests__/db-helpers.test.ts +46 -6
- package/src/store/db-helpers.ts +25 -4
- package/src/store/migration-manager.ts +41 -9
- package/src/store/sqlite-data-accessor.ts +2 -1
- package/src/tasks/__tests__/epic-auto-complete.test.ts +331 -0
- package/src/tasks/complete.ts +7 -4
|
@@ -22,10 +22,15 @@ export interface ArchiveFields {
|
|
|
22
22
|
* Upsert a single task row into the tasks table.
|
|
23
23
|
* Handles both active task upsert and archived task upsert via optional archiveFields.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* When `allowOrphanParent` is true (bulk/migration mode, T5034): silently nulls out
|
|
26
|
+
* parentId if the referenced parent does not exist, preventing FK violations.
|
|
27
|
+
* When false (normal single-task writes, default): logs a warning but still proceeds
|
|
28
|
+
* so that FK enforcement at the DB level provides the final safety net.
|
|
29
|
+
*
|
|
30
|
+
* Callers that perform bulk imports or archive restoration should pass
|
|
31
|
+
* `allowOrphanParent: true` to enable the lenient behavior.
|
|
27
32
|
*/
|
|
28
|
-
export declare function upsertTask(db: DrizzleDb, row: NewTaskRow, archiveFields?: ArchiveFields): Promise<void>;
|
|
33
|
+
export declare function upsertTask(db: DrizzleDb, row: NewTaskRow, archiveFields?: ArchiveFields, allowOrphanParent?: boolean): Promise<void>;
|
|
29
34
|
/**
|
|
30
35
|
* Upsert a single session row into the sessions table.
|
|
31
36
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"db-helpers.d.ts","sourceRoot":"","sources":["../../src/store/db-helpers.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"db-helpers.d.ts","sourceRoot":"","sources":["../../src/store/db-helpers.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAElE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,KAAK,MAAM,MAAM,mBAAmB,CAAC;AAI5C,sCAAsC;AACtC,KAAK,SAAS,GAAG,kBAAkB,CAAC,OAAO,MAAM,CAAC,CAAC;AAEnD,+CAA+C;AAC/C,MAAM,WAAW,aAAa;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,UAAU,CAC9B,EAAE,EAAE,SAAS,EACb,GAAG,EAAE,UAAU,EACf,aAAa,CAAC,EAAE,aAAa,EAC7B,iBAAiB,UAAQ,GACxB,OAAO,CAAC,IAAI,CAAC,CAoEf;AAED;;GAEG;AACH,wBAAsB,aAAa,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAkClF;AAED;;;GAGG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,SAAS,EACb,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,EAAE,EACjB,QAAQ,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GACrB,OAAO,CAAC,IAAI,CAAC,CAWf;AAED;;;;;;;GAOG;AACH,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,SAAS,EACb,KAAK,EAAE,KAAK,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,EAChD,QAAQ,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GACrB,OAAO,CAAC,IAAI,CAAC,CAyBf;AAED;;;GAGG;AACH,wBAAsB,wBAAwB,CAC5C,EAAE,EAAE,SAAS,EACb,KAAK,EAAE,IAAI,EAAE,EACb,aAAa,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAC1B,OAAO,CAAC,IAAI,CAAC,CA6Bf;AAED;;;GAGG;AACH,wBAAsB,qBAAqB,CAAC,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BvF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"migration-manager.d.ts","sourceRoot":"","sources":["../../src/store/migration-manager.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAIlE,sDAAsD;AACtD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,GAAG,EAAE,MAAM,CAAC;CACb;AAOD;;GAEG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAK9E;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAIlD;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CASvD;AAiCD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,YAAY,EACtB,gBAAgB,EAAE,MAAM,EACxB,cAAc,EAAE,MAAM,EACtB,YAAY,EAAE,MAAM,GACnB,IAAI,
|
|
1
|
+
{"version":3,"file":"migration-manager.d.ts","sourceRoot":"","sources":["../../src/store/migration-manager.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAIlE,sDAAsD;AACtD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,GAAG,EAAE,MAAM,CAAC;CACb;AAOD;;GAEG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAK9E;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAIlD;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CASvD;AAiCD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,YAAY,EACtB,gBAAgB,EAAE,MAAM,EACxB,cAAc,EAAE,MAAM,EACtB,YAAY,EAAE,MAAM,GACnB,IAAI,CAkKN;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAG5D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAE9B,EAAE,EAAE,kBAAkB,CAAC,GAAG,CAAC,EAC3B,gBAAgB,EAAE,MAAM,EACxB,QAAQ,CAAC,EAAE,YAAY,EACvB,cAAc,CAAC,EAAE,MAAM,EACvB,YAAY,CAAC,EAAE,MAAM,GACpB,IAAI,CAkCN;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,YAAY,EACtB,SAAS,EAAE,MAAM,EACjB,eAAe,EAAE,cAAc,EAAE,EACjC,YAAY,EAAE,MAAM,GACnB,IAAI,CAmBN"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sqlite-data-accessor.d.ts","sourceRoot":"","sources":["../../src/store/sqlite-data-accessor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAMH,OAAO,KAAK,EAEV,YAAY,EAKb,MAAM,oBAAoB,CAAC;AA0C5B,yDAAyD;AACzD,wBAAsB,YAAY,CAChC,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,OAAO,GACb,OAAO,CAAC,IAAI,CAAC,CAWf;AAID;;;;;;;;GAQG;AACH,wBAAsB,wBAAwB,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,
|
|
1
|
+
{"version":3,"file":"sqlite-data-accessor.d.ts","sourceRoot":"","sources":["../../src/store/sqlite-data-accessor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAMH,OAAO,KAAK,EAEV,YAAY,EAKb,MAAM,oBAAoB,CAAC;AA0C5B,yDAAyD;AACzD,wBAAsB,YAAY,CAChC,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,OAAO,GACb,OAAO,CAAC,IAAI,CAAC,CAWf;AAID;;;;;;;;GAQG;AACH,wBAAsB,wBAAwB,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAy1BlF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"complete.d.ts","sourceRoot":"","sources":["../../src/tasks/complete.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,OAAO,EAAoB,MAAM,qBAAqB,CAAC;AAM3E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAI9D,qCAAqC;AACrC,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,mCAAmC;AACnC,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,IAAI,CAAC;IACX,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,cAAc,CAAC,EAAE,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,GAAG,OAAO,CAAC,CAAC,CAAC;CACvD;AAiED;;;;GAIG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE,mBAAmB,EAC5B,GAAG,CAAC,EAAE,MAAM,EACZ,QAAQ,CAAC,EAAE,YAAY,GACtB,OAAO,CAAC,kBAAkB,CAAC,
|
|
1
|
+
{"version":3,"file":"complete.d.ts","sourceRoot":"","sources":["../../src/tasks/complete.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,OAAO,EAAoB,MAAM,qBAAqB,CAAC;AAM3E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAI9D,qCAAqC;AACrC,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,mCAAmC;AACnC,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,IAAI,CAAC;IACX,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,cAAc,CAAC,EAAE,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,GAAG,OAAO,CAAC,CAAC,CAAC;CACvD;AAiED;;;;GAIG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE,mBAAmB,EAC5B,GAAG,CAAC,EAAE,MAAM,EACZ,QAAQ,CAAC,EAAE,YAAY,GACtB,OAAO,CAAC,kBAAkB,CAAC,CAsQ7B"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cleocode/core",
|
|
3
|
-
"version": "2026.4.
|
|
3
|
+
"version": "2026.4.46",
|
|
4
4
|
"description": "CLEO core business logic kernel — tasks, sessions, memory, orchestration, lifecycle, with bundled SQLite store",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -53,13 +53,13 @@
|
|
|
53
53
|
"write-file-atomic": "^7.0.1",
|
|
54
54
|
"yaml": "^2.8.3",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
-
"@cleocode/adapters": "2026.4.
|
|
57
|
-
"@cleocode/
|
|
58
|
-
"@cleocode/
|
|
59
|
-
"@cleocode/
|
|
60
|
-
"@cleocode/
|
|
61
|
-
"@cleocode/nexus": "2026.4.
|
|
62
|
-
"@cleocode/skills": "2026.4.
|
|
56
|
+
"@cleocode/adapters": "2026.4.46",
|
|
57
|
+
"@cleocode/caamp": "2026.4.46",
|
|
58
|
+
"@cleocode/agents": "2026.4.46",
|
|
59
|
+
"@cleocode/lafs": "2026.4.46",
|
|
60
|
+
"@cleocode/contracts": "2026.4.46",
|
|
61
|
+
"@cleocode/nexus": "2026.4.46",
|
|
62
|
+
"@cleocode/skills": "2026.4.46"
|
|
63
63
|
},
|
|
64
64
|
"engines": {
|
|
65
65
|
"node": ">=24.0.0"
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for db-helpers.ts — defensive orphan parent handling (T5034).
|
|
2
|
+
* Tests for db-helpers.ts — defensive orphan parent handling (T5034, T585).
|
|
3
3
|
*
|
|
4
4
|
* @task T5034
|
|
5
|
+
* @task T585
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
@@ -30,13 +31,47 @@ describe('upsertTask — orphan parent handling', () => {
|
|
|
30
31
|
await rm(tempDir, { recursive: true, force: true });
|
|
31
32
|
});
|
|
32
33
|
|
|
33
|
-
it('nulls out parentId when parent
|
|
34
|
+
it('nulls out parentId when allowOrphanParent=true and parent does not exist', async () => {
|
|
34
35
|
const { getDb } = await import('../sqlite.js');
|
|
35
36
|
const { upsertTask } = await import('../db-helpers.js');
|
|
36
37
|
const schema = await import('../tasks-schema.js');
|
|
37
38
|
const db = await getDb();
|
|
38
39
|
|
|
39
|
-
// Insert a child task with a non-existent parent
|
|
40
|
+
// Insert a child task with a non-existent parent using allowOrphanParent=true (bulk mode)
|
|
41
|
+
await upsertTask(
|
|
42
|
+
db,
|
|
43
|
+
{
|
|
44
|
+
id: 'T100',
|
|
45
|
+
title: 'Child task',
|
|
46
|
+
description: 'Has orphan parent ref',
|
|
47
|
+
status: 'pending',
|
|
48
|
+
priority: 'medium',
|
|
49
|
+
parentId: 'T999', // does NOT exist in DB
|
|
50
|
+
createdAt: new Date().toISOString(),
|
|
51
|
+
},
|
|
52
|
+
undefined,
|
|
53
|
+
true, // allowOrphanParent: silently null out for bulk/archive operations (T5034)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Verify the task was inserted with parentId = null
|
|
57
|
+
const rows = await db.select().from(schema.tasks).where(eq(schema.tasks.id, 'T100')).all();
|
|
58
|
+
|
|
59
|
+
expect(rows).toHaveLength(1);
|
|
60
|
+
expect(rows[0]!.parentId).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('preserves parentId (with warning) when allowOrphanParent=false and parent does not exist', async () => {
|
|
64
|
+
// In normal single-task write mode (default), upsertTask logs a warning but does NOT
|
|
65
|
+
// silently null out the parentId. This prevents data corruption for normal task creation
|
|
66
|
+
// while still surfacing the integrity issue. The FK constraint (disabled in tests) would
|
|
67
|
+
// reject the write in production if the parent truly does not exist.
|
|
68
|
+
const { getDb } = await import('../sqlite.js');
|
|
69
|
+
const { upsertTask } = await import('../db-helpers.js');
|
|
70
|
+
const schema = await import('../tasks-schema.js');
|
|
71
|
+
const db = await getDb();
|
|
72
|
+
|
|
73
|
+
// Insert a child task with a non-existent parent using default allowOrphanParent=false
|
|
74
|
+
// In VITEST, FK enforcement is OFF, so this will succeed without null-out.
|
|
40
75
|
await upsertTask(db, {
|
|
41
76
|
id: 'T100',
|
|
42
77
|
title: 'Child task',
|
|
@@ -47,11 +82,13 @@ describe('upsertTask — orphan parent handling', () => {
|
|
|
47
82
|
createdAt: new Date().toISOString(),
|
|
48
83
|
});
|
|
49
84
|
|
|
50
|
-
//
|
|
85
|
+
// With allowOrphanParent=false, the parentId is NOT nulled out — warning is logged.
|
|
86
|
+
// In test env FK is off, so T100 gets stored with parentId='T999' (not null).
|
|
51
87
|
const rows = await db.select().from(schema.tasks).where(eq(schema.tasks.id, 'T100')).all();
|
|
52
88
|
|
|
53
89
|
expect(rows).toHaveLength(1);
|
|
54
|
-
|
|
90
|
+
// parentId is preserved (not silently nulled), indicating the warning-only behavior
|
|
91
|
+
expect(rows[0]!.parentId).toBe('T999');
|
|
55
92
|
});
|
|
56
93
|
|
|
57
94
|
it('preserves parentId when parent task exists', async () => {
|
|
@@ -88,7 +125,9 @@ describe('upsertTask — orphan parent handling', () => {
|
|
|
88
125
|
expect(rows[0]!.parentId).toBe('T001');
|
|
89
126
|
});
|
|
90
127
|
|
|
91
|
-
it('handles archived task with orphan parent (T5034 regression)', async () => {
|
|
128
|
+
it('handles archived task with orphan parent using allowOrphanParent=true (T5034 regression)', async () => {
|
|
129
|
+
// Bulk archive operations pass allowOrphanParent=true to tolerate missing parents.
|
|
130
|
+
// This prevents FK violations when archiving tasks whose parents were deleted.
|
|
92
131
|
const { getDb } = await import('../sqlite.js');
|
|
93
132
|
const { upsertTask } = await import('../db-helpers.js');
|
|
94
133
|
const schema = await import('../tasks-schema.js');
|
|
@@ -110,6 +149,7 @@ describe('upsertTask — orphan parent handling', () => {
|
|
|
110
149
|
archivedAt: '2025-06-01T00:00:00Z',
|
|
111
150
|
archiveReason: 'completed',
|
|
112
151
|
},
|
|
152
|
+
true, // allowOrphanParent: bulk/archive mode silently nulls (T5034)
|
|
113
153
|
);
|
|
114
154
|
|
|
115
155
|
// Should succeed (not throw) and null out the parentId
|
package/src/store/db-helpers.ts
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
import type { Session, Task } from '@cleocode/contracts';
|
|
11
11
|
import { eq, inArray } from 'drizzle-orm';
|
|
12
12
|
import type { NodeSQLiteDatabase } from 'drizzle-orm/node-sqlite';
|
|
13
|
+
import { getLogger } from '../logger.js';
|
|
13
14
|
import type { NewTaskRow } from './tasks-schema.js';
|
|
14
15
|
import * as schema from './tasks-schema.js';
|
|
15
16
|
|
|
17
|
+
const log = getLogger('db-helpers');
|
|
18
|
+
|
|
16
19
|
/** Drizzle database instance type. */
|
|
17
20
|
type DrizzleDb = NodeSQLiteDatabase<typeof schema>;
|
|
18
21
|
|
|
@@ -27,15 +30,24 @@ export interface ArchiveFields {
|
|
|
27
30
|
* Upsert a single task row into the tasks table.
|
|
28
31
|
* Handles both active task upsert and archived task upsert via optional archiveFields.
|
|
29
32
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
33
|
+
* When `allowOrphanParent` is true (bulk/migration mode, T5034): silently nulls out
|
|
34
|
+
* parentId if the referenced parent does not exist, preventing FK violations.
|
|
35
|
+
* When false (normal single-task writes, default): logs a warning but still proceeds
|
|
36
|
+
* so that FK enforcement at the DB level provides the final safety net.
|
|
37
|
+
*
|
|
38
|
+
* Callers that perform bulk imports or archive restoration should pass
|
|
39
|
+
* `allowOrphanParent: true` to enable the lenient behavior.
|
|
32
40
|
*/
|
|
33
41
|
export async function upsertTask(
|
|
34
42
|
db: DrizzleDb,
|
|
35
43
|
row: NewTaskRow,
|
|
36
44
|
archiveFields?: ArchiveFields,
|
|
45
|
+
allowOrphanParent = false,
|
|
37
46
|
): Promise<void> {
|
|
38
|
-
//
|
|
47
|
+
// Validate parentId exists before writing (T5034, T585).
|
|
48
|
+
// In bulk/archive mode (allowOrphanParent=true) we silently null it out to
|
|
49
|
+
// avoid FK violations during migrations. In normal mode we log a warning so
|
|
50
|
+
// the data integrity issue surfaces without breaking the write.
|
|
39
51
|
if (row.parentId) {
|
|
40
52
|
const parent = await db
|
|
41
53
|
.select({ id: schema.tasks.id })
|
|
@@ -44,7 +56,16 @@ export async function upsertTask(
|
|
|
44
56
|
.limit(1)
|
|
45
57
|
.all();
|
|
46
58
|
if (parent.length === 0) {
|
|
47
|
-
|
|
59
|
+
if (allowOrphanParent) {
|
|
60
|
+
row = { ...row, parentId: null };
|
|
61
|
+
} else {
|
|
62
|
+
// Log a warning — the FK constraint will reject the write if enabled,
|
|
63
|
+
// or the task will be stored without a parent if FKs are off (test mode).
|
|
64
|
+
log.warn(
|
|
65
|
+
{ taskId: row.id, parentId: row.parentId },
|
|
66
|
+
'upsertTask: parentId references a non-existent task — parent relationship may be lost',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
48
69
|
}
|
|
49
70
|
}
|
|
50
71
|
|
|
@@ -144,23 +144,55 @@ export function reconcileJournal(
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
// Scenario 2: Journal has orphaned entries from a previous CLEO version
|
|
147
|
+
//
|
|
148
|
+
// Two distinct sub-cases require different handling:
|
|
149
|
+
//
|
|
150
|
+
// A) DB is AHEAD of this install (forward-compatibility): all local hashes
|
|
151
|
+
// are present in the DB, but the DB also has additional entries for
|
|
152
|
+
// migrations this install does not know about. This happens when a user
|
|
153
|
+
// runs a globally-installed (older) cleo binary against a DB that was
|
|
154
|
+
// last written by a newer cleo version. Deleting those entries would
|
|
155
|
+
// cause an infinite reconciliation cycle: Drizzle re-runs the "missing"
|
|
156
|
+
// migrations, hits duplicate-column errors (Scenario 3 recovers), writes
|
|
157
|
+
// them back — only for this install to delete them again on the next run.
|
|
158
|
+
// ACTION: skip reconciliation, log at debug only.
|
|
159
|
+
//
|
|
160
|
+
// B) DB has stale hashes from a genuinely old CLEO version whose checksum
|
|
161
|
+
// algorithm produced different hashes for the same migration files (i.e.,
|
|
162
|
+
// at least one local hash is MISSING from the DB while other DB entries
|
|
163
|
+
// are unrecognised). ACTION: delete and re-seed as before, log at warn.
|
|
147
164
|
if (tableExists(nativeDb, '__drizzle_migrations') && tableExists(nativeDb, existenceTable)) {
|
|
148
165
|
const localMigrations = readMigrationFiles({ migrationsFolder });
|
|
149
166
|
const localHashes = new Set(localMigrations.map((m) => m.hash));
|
|
150
167
|
const dbEntries = nativeDb.prepare('SELECT hash FROM "__drizzle_migrations"').all() as Array<{
|
|
151
168
|
hash: string;
|
|
152
169
|
}>;
|
|
153
|
-
const
|
|
170
|
+
const orphanedEntries = dbEntries.filter((e) => !localHashes.has(e.hash));
|
|
171
|
+
const hasOrphanedEntries = orphanedEntries.length > 0;
|
|
154
172
|
|
|
155
173
|
if (hasOrphanedEntries) {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
174
|
+
const dbHashes = new Set(dbEntries.map((e) => e.hash));
|
|
175
|
+
const allLocalHashesPresentInDb = localMigrations.every((m) => dbHashes.has(m.hash));
|
|
176
|
+
|
|
177
|
+
if (allLocalHashesPresentInDb) {
|
|
178
|
+
// Sub-case A: DB is ahead — this install is older than the DB.
|
|
179
|
+
// Do NOT modify the journal; log at debug so we can trace if needed.
|
|
180
|
+
const log = getLogger(logSubsystem);
|
|
181
|
+
log.debug(
|
|
182
|
+
{ extra: orphanedEntries.length },
|
|
183
|
+
`Migration journal has ${orphanedEntries.length} entries for migrations not known to this install (DB is ahead). Skipping reconciliation.`,
|
|
184
|
+
);
|
|
185
|
+
} else {
|
|
186
|
+
// Sub-case B: Genuine stale hashes from an older CLEO version.
|
|
187
|
+
const log = getLogger(logSubsystem);
|
|
188
|
+
log.warn(
|
|
189
|
+
{ orphaned: orphanedEntries.length },
|
|
190
|
+
`Detected stale migration journal entries from a previous CLEO version. Reconciling.`,
|
|
191
|
+
);
|
|
192
|
+
nativeDb.exec('DELETE FROM "__drizzle_migrations"');
|
|
193
|
+
for (const m of localMigrations) {
|
|
194
|
+
insertJournalEntry(nativeDb, m.hash, m.folderMillis, m.name ?? '');
|
|
195
|
+
}
|
|
164
196
|
}
|
|
165
197
|
}
|
|
166
198
|
}
|
|
@@ -198,7 +198,8 @@ export async function createSqliteDataAccessor(cwd?: string): Promise<DataAccess
|
|
|
198
198
|
cycleTimeDays: taskAny.cycleTimeDays ?? null,
|
|
199
199
|
};
|
|
200
200
|
|
|
201
|
-
|
|
201
|
+
// allowOrphanParent=true: bulk archive writes tolerate missing parents (T5034)
|
|
202
|
+
await upsertTask(db, row, archiveFields, true);
|
|
202
203
|
depBatch.push({ taskId: task.id, deps: task.depends ?? [] });
|
|
203
204
|
}
|
|
204
205
|
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for epic auto-complete behavior (T585).
|
|
3
|
+
*
|
|
4
|
+
* Verifies that an epic only auto-completes when ALL its direct subtasks
|
|
5
|
+
* are in terminal states (done or cancelled), not when only some are done.
|
|
6
|
+
*
|
|
7
|
+
* @task T585
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { writeFile } from 'node:fs/promises';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
13
|
+
import { createTestDb, seedTasks, type TestDbEnv } from '../../store/__tests__/test-db-helper.js';
|
|
14
|
+
import type { DataAccessor } from '../../store/data-accessor.js';
|
|
15
|
+
import { resetDbState } from '../../store/sqlite.js';
|
|
16
|
+
import { completeTask } from '../complete.js';
|
|
17
|
+
|
|
18
|
+
describe('epic auto-complete', () => {
|
|
19
|
+
let env: TestDbEnv;
|
|
20
|
+
let accessor: DataAccessor;
|
|
21
|
+
|
|
22
|
+
const writeConfig = async (config: Record<string, unknown>): Promise<void> => {
|
|
23
|
+
await writeFile(join(env.cleoDir, 'config.json'), JSON.stringify(config));
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
env = await createTestDb();
|
|
28
|
+
accessor = env.accessor;
|
|
29
|
+
process.env['CLEO_DIR'] = env.cleoDir;
|
|
30
|
+
await writeConfig({
|
|
31
|
+
enforcement: {
|
|
32
|
+
session: { requiredForMutate: false },
|
|
33
|
+
acceptance: { mode: 'off' },
|
|
34
|
+
},
|
|
35
|
+
lifecycle: { mode: 'off' },
|
|
36
|
+
verification: { enabled: false },
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
delete process.env['CLEO_DIR'];
|
|
42
|
+
resetDbState();
|
|
43
|
+
await env.cleanup();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('does NOT auto-complete epic when only one of two subtasks is completed (bug T585)', async () => {
|
|
47
|
+
await seedTasks(accessor, [
|
|
48
|
+
{
|
|
49
|
+
id: 'T001',
|
|
50
|
+
title: 'Test Epic',
|
|
51
|
+
type: 'epic',
|
|
52
|
+
status: 'active',
|
|
53
|
+
priority: 'medium',
|
|
54
|
+
acceptance: ['AC1'],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'T002',
|
|
58
|
+
title: 'Sub 1',
|
|
59
|
+
type: 'task',
|
|
60
|
+
status: 'active',
|
|
61
|
+
priority: 'medium',
|
|
62
|
+
parentId: 'T001',
|
|
63
|
+
acceptance: ['AC1'],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'T003',
|
|
67
|
+
title: 'Sub 2',
|
|
68
|
+
type: 'task',
|
|
69
|
+
status: 'pending',
|
|
70
|
+
priority: 'medium',
|
|
71
|
+
parentId: 'T001',
|
|
72
|
+
acceptance: ['AC1'],
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const result = await completeTask({ taskId: 'T002' }, env.tempDir, accessor);
|
|
77
|
+
|
|
78
|
+
expect(result.task.status).toBe('done');
|
|
79
|
+
expect(result.autoCompleted).toBeUndefined();
|
|
80
|
+
|
|
81
|
+
// Verify the epic is still not done
|
|
82
|
+
const epic = await accessor.loadSingleTask('T001');
|
|
83
|
+
expect(epic?.status).not.toBe('done');
|
|
84
|
+
|
|
85
|
+
// Verify T003 is still pending
|
|
86
|
+
const sub2 = await accessor.loadSingleTask('T003');
|
|
87
|
+
expect(sub2?.status).toBe('pending');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('auto-completes epic when the LAST pending subtask is completed', async () => {
|
|
91
|
+
await seedTasks(accessor, [
|
|
92
|
+
{
|
|
93
|
+
id: 'T001',
|
|
94
|
+
title: 'Test Epic',
|
|
95
|
+
type: 'epic',
|
|
96
|
+
status: 'active',
|
|
97
|
+
priority: 'medium',
|
|
98
|
+
acceptance: ['AC1'],
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: 'T002',
|
|
102
|
+
title: 'Sub 1',
|
|
103
|
+
type: 'task',
|
|
104
|
+
status: 'done',
|
|
105
|
+
priority: 'medium',
|
|
106
|
+
parentId: 'T001',
|
|
107
|
+
acceptance: ['AC1'],
|
|
108
|
+
completedAt: new Date().toISOString(),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'T003',
|
|
112
|
+
title: 'Sub 2',
|
|
113
|
+
type: 'task',
|
|
114
|
+
status: 'active',
|
|
115
|
+
priority: 'medium',
|
|
116
|
+
parentId: 'T001',
|
|
117
|
+
acceptance: ['AC1'],
|
|
118
|
+
},
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
const result = await completeTask({ taskId: 'T003' }, env.tempDir, accessor);
|
|
122
|
+
|
|
123
|
+
expect(result.task.status).toBe('done');
|
|
124
|
+
expect(result.autoCompleted).toContain('T001');
|
|
125
|
+
|
|
126
|
+
const epic = await accessor.loadSingleTask('T001');
|
|
127
|
+
expect(epic?.status).toBe('done');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('does NOT auto-complete epic when remaining subtask is blocked (not terminal)', async () => {
|
|
131
|
+
await seedTasks(accessor, [
|
|
132
|
+
{
|
|
133
|
+
id: 'T001',
|
|
134
|
+
title: 'Test Epic',
|
|
135
|
+
type: 'epic',
|
|
136
|
+
status: 'active',
|
|
137
|
+
priority: 'medium',
|
|
138
|
+
acceptance: ['AC1'],
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: 'T002',
|
|
142
|
+
title: 'Sub 1',
|
|
143
|
+
type: 'task',
|
|
144
|
+
status: 'active',
|
|
145
|
+
priority: 'medium',
|
|
146
|
+
parentId: 'T001',
|
|
147
|
+
acceptance: ['AC1'],
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: 'T003',
|
|
151
|
+
title: 'Sub 2',
|
|
152
|
+
type: 'task',
|
|
153
|
+
status: 'blocked',
|
|
154
|
+
priority: 'medium',
|
|
155
|
+
parentId: 'T001',
|
|
156
|
+
acceptance: ['AC1'],
|
|
157
|
+
},
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
const result = await completeTask({ taskId: 'T002' }, env.tempDir, accessor);
|
|
161
|
+
|
|
162
|
+
expect(result.task.status).toBe('done');
|
|
163
|
+
expect(result.autoCompleted).toBeUndefined();
|
|
164
|
+
|
|
165
|
+
const epic = await accessor.loadSingleTask('T001');
|
|
166
|
+
expect(epic?.status).not.toBe('done');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('auto-completes epic when all remaining subtasks are cancelled', async () => {
|
|
170
|
+
await seedTasks(accessor, [
|
|
171
|
+
{
|
|
172
|
+
id: 'T001',
|
|
173
|
+
title: 'Test Epic',
|
|
174
|
+
type: 'epic',
|
|
175
|
+
status: 'active',
|
|
176
|
+
priority: 'medium',
|
|
177
|
+
acceptance: ['AC1'],
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: 'T002',
|
|
181
|
+
title: 'Sub 1',
|
|
182
|
+
type: 'task',
|
|
183
|
+
status: 'active',
|
|
184
|
+
priority: 'medium',
|
|
185
|
+
parentId: 'T001',
|
|
186
|
+
acceptance: ['AC1'],
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: 'T003',
|
|
190
|
+
title: 'Sub 2',
|
|
191
|
+
type: 'task',
|
|
192
|
+
status: 'cancelled',
|
|
193
|
+
priority: 'medium',
|
|
194
|
+
parentId: 'T001',
|
|
195
|
+
acceptance: ['AC1'],
|
|
196
|
+
cancelledAt: new Date().toISOString(),
|
|
197
|
+
},
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
const result = await completeTask({ taskId: 'T002' }, env.tempDir, accessor);
|
|
201
|
+
|
|
202
|
+
expect(result.task.status).toBe('done');
|
|
203
|
+
expect(result.autoCompleted).toContain('T001');
|
|
204
|
+
|
|
205
|
+
const epic = await accessor.loadSingleTask('T001');
|
|
206
|
+
expect(epic?.status).toBe('done');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('does NOT auto-complete epic when getChildren returns an empty list (vacuous truth guard, T585)', async () => {
|
|
210
|
+
// Defensive guard against [].every() === true (vacuous truth).
|
|
211
|
+
// If getChildren(epic) returns [] — e.g. because all children have
|
|
212
|
+
// parent_id=null due to a data-integrity issue — completing any task
|
|
213
|
+
// with task.parentId set to that epic must NOT auto-complete it.
|
|
214
|
+
//
|
|
215
|
+
// We simulate this by: seeding T001 (epic) and T002 (task with parentId=T001),
|
|
216
|
+
// then manually clearing T002's parent_id in the DB so getChildren(T001) returns [].
|
|
217
|
+
// But loadSingleTask(T002) still returns parentId:'T001' ... wait, no. If parent_id
|
|
218
|
+
// is NULL in DB then rowToTask returns parentId: undefined. So task.parentId is falsy
|
|
219
|
+
// and the auto-complete block is skipped entirely.
|
|
220
|
+
//
|
|
221
|
+
// The vacuous truth guard `siblings.length > 0` is still correct defensive code for
|
|
222
|
+
// the scenario where getChildren returns [] for any reason (e.g., future filter changes).
|
|
223
|
+
// The primary bug fix (partial completion doesn't close epic) is proven by test 1 above.
|
|
224
|
+
//
|
|
225
|
+
// This test creates a mock accessor that returns [] for getChildren to verify the guard.
|
|
226
|
+
await seedTasks(accessor, [
|
|
227
|
+
{
|
|
228
|
+
id: 'T001',
|
|
229
|
+
title: 'Test Epic',
|
|
230
|
+
type: 'epic',
|
|
231
|
+
status: 'active',
|
|
232
|
+
priority: 'medium',
|
|
233
|
+
acceptance: ['AC1'],
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: 'T002',
|
|
237
|
+
title: 'Sub 1',
|
|
238
|
+
type: 'task',
|
|
239
|
+
status: 'active',
|
|
240
|
+
priority: 'medium',
|
|
241
|
+
parentId: 'T001',
|
|
242
|
+
acceptance: ['AC1'],
|
|
243
|
+
},
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
// Create a wrapper accessor that overrides getChildren to return []
|
|
247
|
+
// to simulate the vacuous truth scenario.
|
|
248
|
+
const wrappedAccessor = {
|
|
249
|
+
...accessor,
|
|
250
|
+
getChildren: async (parentId: string) => {
|
|
251
|
+
if (parentId === 'T001') return []; // Simulate no registered children
|
|
252
|
+
return accessor.getChildren(parentId);
|
|
253
|
+
},
|
|
254
|
+
} as typeof accessor;
|
|
255
|
+
|
|
256
|
+
const result = await completeTask({ taskId: 'T002' }, env.tempDir, wrappedAccessor);
|
|
257
|
+
|
|
258
|
+
expect(result.task.status).toBe('done');
|
|
259
|
+
// With vacuous truth guard: empty siblings → allDone = false → epic does NOT auto-complete
|
|
260
|
+
expect(result.autoCompleted).toBeUndefined();
|
|
261
|
+
|
|
262
|
+
const epic = await accessor.loadSingleTask('T001');
|
|
263
|
+
expect(epic?.status).not.toBe('done');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('does NOT auto-complete epic when it has 5 subtasks and only 1 is being completed', async () => {
|
|
267
|
+
await seedTasks(accessor, [
|
|
268
|
+
{
|
|
269
|
+
id: 'T001',
|
|
270
|
+
title: 'Big Epic',
|
|
271
|
+
type: 'epic',
|
|
272
|
+
status: 'active',
|
|
273
|
+
priority: 'medium',
|
|
274
|
+
acceptance: ['AC1'],
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
id: 'T002',
|
|
278
|
+
title: 'Sub 1',
|
|
279
|
+
type: 'task',
|
|
280
|
+
status: 'active',
|
|
281
|
+
priority: 'medium',
|
|
282
|
+
parentId: 'T001',
|
|
283
|
+
acceptance: ['AC1'],
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
id: 'T003',
|
|
287
|
+
title: 'Sub 2',
|
|
288
|
+
type: 'task',
|
|
289
|
+
status: 'pending',
|
|
290
|
+
priority: 'medium',
|
|
291
|
+
parentId: 'T001',
|
|
292
|
+
acceptance: ['AC1'],
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
id: 'T004',
|
|
296
|
+
title: 'Sub 3',
|
|
297
|
+
type: 'task',
|
|
298
|
+
status: 'pending',
|
|
299
|
+
priority: 'medium',
|
|
300
|
+
parentId: 'T001',
|
|
301
|
+
acceptance: ['AC1'],
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
id: 'T005',
|
|
305
|
+
title: 'Sub 4',
|
|
306
|
+
type: 'task',
|
|
307
|
+
status: 'pending',
|
|
308
|
+
priority: 'medium',
|
|
309
|
+
parentId: 'T001',
|
|
310
|
+
acceptance: ['AC1'],
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: 'T006',
|
|
314
|
+
title: 'Sub 5',
|
|
315
|
+
type: 'task',
|
|
316
|
+
status: 'pending',
|
|
317
|
+
priority: 'medium',
|
|
318
|
+
parentId: 'T001',
|
|
319
|
+
acceptance: ['AC1'],
|
|
320
|
+
},
|
|
321
|
+
]);
|
|
322
|
+
|
|
323
|
+
const result = await completeTask({ taskId: 'T002' }, env.tempDir, accessor);
|
|
324
|
+
|
|
325
|
+
expect(result.task.status).toBe('done');
|
|
326
|
+
expect(result.autoCompleted).toBeUndefined();
|
|
327
|
+
|
|
328
|
+
const epic = await accessor.loadSingleTask('T001');
|
|
329
|
+
expect(epic?.status).not.toBe('done');
|
|
330
|
+
});
|
|
331
|
+
});
|
package/src/tasks/complete.ts
CHANGED
|
@@ -235,10 +235,13 @@ export async function completeTask(
|
|
|
235
235
|
const parent = await acc.loadSingleTask(task.parentId);
|
|
236
236
|
if (parent && parent.type === 'epic' && !parent.noAutoComplete) {
|
|
237
237
|
const siblings = await acc.getChildren(parent.id);
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
238
|
+
// Guard: only auto-complete if the epic has at least one registered child.
|
|
239
|
+
// An empty siblings list means no children are recorded in the DB, which
|
|
240
|
+
// would vacuously satisfy .every() and incorrectly auto-complete the epic.
|
|
241
|
+
// The current task is not yet 'done' in DB, so match it by ID.
|
|
242
|
+
const allDone =
|
|
243
|
+
siblings.length > 0 &&
|
|
244
|
+
siblings.every((c) => c.id === task.id || c.status === 'done' || c.status === 'cancelled');
|
|
242
245
|
if (allDone) {
|
|
243
246
|
parent.status = 'done';
|
|
244
247
|
parent.completedAt = now;
|