@workflow-cannon/workspace-kit 0.14.0 → 0.16.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 +55 -106
- package/dist/modules/approvals/review-runtime.js +3 -11
- package/dist/modules/improvement/generate-recommendations-runtime.js +3 -11
- package/dist/modules/index.d.ts +2 -0
- package/dist/modules/index.js +1 -0
- package/dist/modules/task-engine/index.d.ts +5 -0
- package/dist/modules/task-engine/index.js +327 -17
- package/dist/modules/task-engine/migrate-task-persistence-runtime.d.ts +2 -0
- package/dist/modules/task-engine/migrate-task-persistence-runtime.js +192 -0
- package/dist/modules/task-engine/planning-config.d.ts +10 -0
- package/dist/modules/task-engine/planning-config.js +37 -0
- package/dist/modules/task-engine/planning-open.d.ts +16 -0
- package/dist/modules/task-engine/planning-open.js +34 -0
- package/dist/modules/task-engine/sqlite-dual-planning.d.ts +21 -0
- package/dist/modules/task-engine/sqlite-dual-planning.js +137 -0
- package/dist/modules/task-engine/store.d.ts +12 -3
- package/dist/modules/task-engine/store.js +62 -38
- package/dist/modules/task-engine/wishlist-store.d.ts +23 -0
- package/dist/modules/task-engine/wishlist-store.js +108 -0
- package/dist/modules/task-engine/wishlist-types.d.ts +33 -0
- package/dist/modules/task-engine/wishlist-types.js +5 -0
- package/dist/modules/task-engine/wishlist-validation.d.ts +16 -0
- package/dist/modules/task-engine/wishlist-validation.js +96 -0
- package/package.json +11 -2
- package/src/modules/documentation/README.md +1 -0
- package/src/modules/documentation/instructions/document-project.md +1 -0
- package/src/modules/documentation/instructions/generate-document.md +1 -0
- package/src/modules/documentation/templates/README.md +89 -0
|
@@ -1,23 +1,21 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { maybeSpawnTranscriptHookAfterCompletion } from "../../core/transcript-completion-hook.js";
|
|
3
|
-
import { TaskStore } from "./store.js";
|
|
4
3
|
import { TransitionService } from "./service.js";
|
|
5
4
|
import { TaskEngineError, getAllowedTransitionsFrom } from "./transitions.js";
|
|
6
5
|
import { getNextActions } from "./suggestions.js";
|
|
7
6
|
import { readWorkspaceStatusSnapshot } from "./dashboard-status.js";
|
|
7
|
+
import { openPlanningStores } from "./planning-open.js";
|
|
8
|
+
import { runMigrateTaskPersistence } from "./migrate-task-persistence-runtime.js";
|
|
9
|
+
import { buildWishlistItemFromIntake, validateWishlistIntakePayload, validateWishlistUpdatePayload, WISHLIST_ID_RE } from "./wishlist-validation.js";
|
|
8
10
|
export { TaskStore } from "./store.js";
|
|
9
11
|
export { TransitionService } from "./service.js";
|
|
10
12
|
export { TaskEngineError, TransitionValidator, isTransitionAllowed, getTransitionAction, resolveTargetState, getAllowedTransitionsFrom, stateValidityGuard, dependencyCheckGuard } from "./transitions.js";
|
|
11
13
|
export { getNextActions } from "./suggestions.js";
|
|
12
14
|
export { readWorkspaceStatusSnapshot } from "./dashboard-status.js";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
const p = tasks.storeRelativePath;
|
|
19
|
-
return typeof p === "string" && p.trim().length > 0 ? p.trim() : undefined;
|
|
20
|
-
}
|
|
15
|
+
export { WishlistStore } from "./wishlist-store.js";
|
|
16
|
+
export { validateWishlistIntakePayload, validateWishlistUpdatePayload, buildWishlistItemFromIntake, WISHLIST_ID_RE } from "./wishlist-validation.js";
|
|
17
|
+
export { openPlanningStores } from "./planning-open.js";
|
|
18
|
+
export { getTaskPersistenceBackend, planningSqliteDatabaseRelativePath, planningTaskStoreRelativePath, planningWishlistStoreRelativePath } from "./planning-config.js";
|
|
21
19
|
const TASK_ID_RE = /^T\d+$/;
|
|
22
20
|
const MUTABLE_TASK_FIELDS = new Set([
|
|
23
21
|
"title",
|
|
@@ -35,6 +33,72 @@ const MUTABLE_TASK_FIELDS = new Set([
|
|
|
35
33
|
function nowIso() {
|
|
36
34
|
return new Date().toISOString();
|
|
37
35
|
}
|
|
36
|
+
function parseConversionDecomposition(raw) {
|
|
37
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
38
|
+
return { ok: false, message: "convert-wishlist requires 'decomposition' object" };
|
|
39
|
+
}
|
|
40
|
+
const o = raw;
|
|
41
|
+
const rationale = typeof o.rationale === "string" ? o.rationale.trim() : "";
|
|
42
|
+
const boundaries = typeof o.boundaries === "string" ? o.boundaries.trim() : "";
|
|
43
|
+
const dependencyIntent = typeof o.dependencyIntent === "string" ? o.dependencyIntent.trim() : "";
|
|
44
|
+
if (!rationale || !boundaries || !dependencyIntent) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
message: "decomposition requires non-empty rationale, boundaries, and dependencyIntent"
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { ok: true, value: { rationale, boundaries, dependencyIntent } };
|
|
51
|
+
}
|
|
52
|
+
function buildTaskFromConversionPayload(row, timestamp) {
|
|
53
|
+
const id = typeof row.id === "string" ? row.id.trim() : "";
|
|
54
|
+
if (!TASK_ID_RE.test(id)) {
|
|
55
|
+
return { ok: false, message: "Each converted task requires 'id' matching T<number>" };
|
|
56
|
+
}
|
|
57
|
+
const title = typeof row.title === "string" ? row.title.trim() : "";
|
|
58
|
+
if (!title) {
|
|
59
|
+
return { ok: false, message: `Task '${id}' requires non-empty title` };
|
|
60
|
+
}
|
|
61
|
+
const phase = typeof row.phase === "string" ? row.phase.trim() : "";
|
|
62
|
+
if (!phase) {
|
|
63
|
+
return { ok: false, message: `Task '${id}' requires 'phase' for workable tasks` };
|
|
64
|
+
}
|
|
65
|
+
const type = typeof row.type === "string" && row.type.trim() ? row.type.trim() : "workspace-kit";
|
|
66
|
+
const priority = typeof row.priority === "string" && ["P1", "P2", "P3"].includes(row.priority)
|
|
67
|
+
? row.priority
|
|
68
|
+
: undefined;
|
|
69
|
+
const approach = typeof row.approach === "string" ? row.approach.trim() : "";
|
|
70
|
+
if (!approach) {
|
|
71
|
+
return { ok: false, message: `Task '${id}' requires 'approach'` };
|
|
72
|
+
}
|
|
73
|
+
const technicalScope = Array.isArray(row.technicalScope)
|
|
74
|
+
? row.technicalScope.filter((x) => typeof x === "string")
|
|
75
|
+
: [];
|
|
76
|
+
const acceptanceCriteria = Array.isArray(row.acceptanceCriteria)
|
|
77
|
+
? row.acceptanceCriteria.filter((x) => typeof x === "string")
|
|
78
|
+
: [];
|
|
79
|
+
if (technicalScope.length === 0) {
|
|
80
|
+
return { ok: false, message: `Task '${id}' requires non-empty technicalScope array` };
|
|
81
|
+
}
|
|
82
|
+
if (acceptanceCriteria.length === 0) {
|
|
83
|
+
return { ok: false, message: `Task '${id}' requires non-empty acceptanceCriteria array` };
|
|
84
|
+
}
|
|
85
|
+
const task = {
|
|
86
|
+
id,
|
|
87
|
+
title,
|
|
88
|
+
type,
|
|
89
|
+
status: "proposed",
|
|
90
|
+
createdAt: timestamp,
|
|
91
|
+
updatedAt: timestamp,
|
|
92
|
+
priority,
|
|
93
|
+
dependsOn: Array.isArray(row.dependsOn) ? row.dependsOn.filter((x) => typeof x === "string") : undefined,
|
|
94
|
+
unblocks: Array.isArray(row.unblocks) ? row.unblocks.filter((x) => typeof x === "string") : undefined,
|
|
95
|
+
phase,
|
|
96
|
+
approach,
|
|
97
|
+
technicalScope,
|
|
98
|
+
acceptanceCriteria
|
|
99
|
+
};
|
|
100
|
+
return { ok: true, task };
|
|
101
|
+
}
|
|
38
102
|
function mutationEvidence(mutationType, taskId, actor, details) {
|
|
39
103
|
return {
|
|
40
104
|
mutationId: `${mutationType}-${taskId}-${nowIso()}-${crypto.randomUUID().slice(0, 8)}`,
|
|
@@ -48,7 +112,7 @@ function mutationEvidence(mutationType, taskId, actor, details) {
|
|
|
48
112
|
export const taskEngineModule = {
|
|
49
113
|
registration: {
|
|
50
114
|
id: "task-engine",
|
|
51
|
-
version: "0.
|
|
115
|
+
version: "0.6.0",
|
|
52
116
|
contractVersion: "1",
|
|
53
117
|
capabilities: ["task-engine"],
|
|
54
118
|
dependsOn: [],
|
|
@@ -81,6 +145,11 @@ export const taskEngineModule = {
|
|
|
81
145
|
file: "update-task.md",
|
|
82
146
|
description: "Update mutable task fields without lifecycle bypass."
|
|
83
147
|
},
|
|
148
|
+
{
|
|
149
|
+
name: "update-wishlist",
|
|
150
|
+
file: "update-wishlist.md",
|
|
151
|
+
description: "Update mutable fields on an open Wishlist item."
|
|
152
|
+
},
|
|
84
153
|
{
|
|
85
154
|
name: "archive-task",
|
|
86
155
|
file: "archive-task.md",
|
|
@@ -126,6 +195,21 @@ export const taskEngineModule = {
|
|
|
126
195
|
file: "create-task-from-plan.md",
|
|
127
196
|
description: "Promote planning output into a canonical task."
|
|
128
197
|
},
|
|
198
|
+
{
|
|
199
|
+
name: "convert-wishlist",
|
|
200
|
+
file: "convert-wishlist.md",
|
|
201
|
+
description: "Convert a Wishlist item into one or more phased tasks and close the wishlist item."
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: "create-wishlist",
|
|
205
|
+
file: "create-wishlist.md",
|
|
206
|
+
description: "Create a Wishlist ideation item with strict required fields (separate namespace from tasks)."
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: "get-wishlist",
|
|
210
|
+
file: "get-wishlist.md",
|
|
211
|
+
description: "Retrieve a single Wishlist item by ID."
|
|
212
|
+
},
|
|
129
213
|
{
|
|
130
214
|
name: "get-task",
|
|
131
215
|
file: "get-task.md",
|
|
@@ -136,6 +220,11 @@ export const taskEngineModule = {
|
|
|
136
220
|
file: "list-tasks.md",
|
|
137
221
|
description: "List tasks with optional status/phase filters."
|
|
138
222
|
},
|
|
223
|
+
{
|
|
224
|
+
name: "list-wishlist",
|
|
225
|
+
file: "list-wishlist.md",
|
|
226
|
+
description: "List Wishlist items (ideation-only; not part of task execution queues)."
|
|
227
|
+
},
|
|
139
228
|
{
|
|
140
229
|
name: "get-ready-queue",
|
|
141
230
|
file: "get-ready-queue.md",
|
|
@@ -146,6 +235,11 @@ export const taskEngineModule = {
|
|
|
146
235
|
file: "get-next-actions.md",
|
|
147
236
|
description: "Get prioritized next-action suggestions with blocking analysis."
|
|
148
237
|
},
|
|
238
|
+
{
|
|
239
|
+
name: "migrate-task-persistence",
|
|
240
|
+
file: "migrate-task-persistence.md",
|
|
241
|
+
description: "Copy task + wishlist state between JSON files and a single SQLite database (offline migration)."
|
|
242
|
+
},
|
|
149
243
|
{
|
|
150
244
|
name: "dashboard-summary",
|
|
151
245
|
file: "dashboard-summary.md",
|
|
@@ -156,9 +250,12 @@ export const taskEngineModule = {
|
|
|
156
250
|
},
|
|
157
251
|
async onCommand(command, ctx) {
|
|
158
252
|
const args = command.args ?? {};
|
|
159
|
-
|
|
253
|
+
if (command.name === "migrate-task-persistence") {
|
|
254
|
+
return runMigrateTaskPersistence(ctx, args);
|
|
255
|
+
}
|
|
256
|
+
let planning;
|
|
160
257
|
try {
|
|
161
|
-
await
|
|
258
|
+
planning = await openPlanningStores(ctx);
|
|
162
259
|
}
|
|
163
260
|
catch (err) {
|
|
164
261
|
if (err instanceof TaskEngineError) {
|
|
@@ -167,9 +264,10 @@ export const taskEngineModule = {
|
|
|
167
264
|
return {
|
|
168
265
|
ok: false,
|
|
169
266
|
code: "storage-read-error",
|
|
170
|
-
message: `Failed to
|
|
267
|
+
message: `Failed to open task planning stores: ${err.message}`
|
|
171
268
|
};
|
|
172
269
|
}
|
|
270
|
+
const store = planning.taskStore;
|
|
173
271
|
if (command.name === "run-transition") {
|
|
174
272
|
const taskId = typeof args.taskId === "string" ? args.taskId : undefined;
|
|
175
273
|
const action = typeof args.action === "string" ? args.action : undefined;
|
|
@@ -463,6 +561,15 @@ export const taskEngineModule = {
|
|
|
463
561
|
phase: t.phase ?? null
|
|
464
562
|
}));
|
|
465
563
|
const blockedTop = suggestion.blockingAnalysis.slice(0, 15);
|
|
564
|
+
let wishlistItems = [];
|
|
565
|
+
try {
|
|
566
|
+
const wishlistStore = await planning.openWishlist();
|
|
567
|
+
wishlistItems = wishlistStore.getAllItems();
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
/* wishlist store optional */
|
|
571
|
+
}
|
|
572
|
+
const wishlistOpenCount = wishlistItems.filter((i) => i.status === "open").length;
|
|
466
573
|
const data = {
|
|
467
574
|
schemaVersion: 1,
|
|
468
575
|
taskStoreLastUpdated: store.getLastUpdated(),
|
|
@@ -470,6 +577,12 @@ export const taskEngineModule = {
|
|
|
470
577
|
stateSummary: suggestion.stateSummary,
|
|
471
578
|
readyQueueTop: readyTop,
|
|
472
579
|
readyQueueCount: suggestion.readyQueue.length,
|
|
580
|
+
executionPlanningScope: "tasks-only",
|
|
581
|
+
wishlist: {
|
|
582
|
+
schemaVersion: 1,
|
|
583
|
+
openCount: wishlistOpenCount,
|
|
584
|
+
totalCount: wishlistItems.length
|
|
585
|
+
},
|
|
473
586
|
blockedSummary: {
|
|
474
587
|
count: suggestion.blockingAnalysis.length,
|
|
475
588
|
top: blockedTop
|
|
@@ -507,7 +620,7 @@ export const taskEngineModule = {
|
|
|
507
620
|
ok: true,
|
|
508
621
|
code: "tasks-listed",
|
|
509
622
|
message: `Found ${tasks.length} tasks`,
|
|
510
|
-
data: { tasks, count: tasks.length }
|
|
623
|
+
data: { tasks, count: tasks.length, scope: "tasks-only" }
|
|
511
624
|
};
|
|
512
625
|
}
|
|
513
626
|
if (command.name === "get-ready-queue") {
|
|
@@ -523,7 +636,7 @@ export const taskEngineModule = {
|
|
|
523
636
|
ok: true,
|
|
524
637
|
code: "ready-queue-retrieved",
|
|
525
638
|
message: `${ready.length} tasks in ready queue`,
|
|
526
|
-
data: { tasks: ready, count: ready.length }
|
|
639
|
+
data: { tasks: ready, count: ready.length, scope: "tasks-only" }
|
|
527
640
|
};
|
|
528
641
|
}
|
|
529
642
|
if (command.name === "get-next-actions") {
|
|
@@ -535,7 +648,7 @@ export const taskEngineModule = {
|
|
|
535
648
|
message: suggestion.suggestedNext
|
|
536
649
|
? `Suggested next: ${suggestion.suggestedNext.id} — ${suggestion.suggestedNext.title}`
|
|
537
650
|
: "No tasks in ready queue",
|
|
538
|
-
data: suggestion
|
|
651
|
+
data: { ...suggestion, scope: "tasks-only" }
|
|
539
652
|
};
|
|
540
653
|
}
|
|
541
654
|
if (command.name === "get-task-summary") {
|
|
@@ -545,6 +658,7 @@ export const taskEngineModule = {
|
|
|
545
658
|
ok: true,
|
|
546
659
|
code: "task-summary",
|
|
547
660
|
data: {
|
|
661
|
+
scope: "tasks-only",
|
|
548
662
|
stateSummary: suggestion.stateSummary,
|
|
549
663
|
readyQueueCount: suggestion.readyQueue.length,
|
|
550
664
|
suggestedNext: suggestion.suggestedNext
|
|
@@ -565,8 +679,204 @@ export const taskEngineModule = {
|
|
|
565
679
|
code: "blocked-summary",
|
|
566
680
|
data: {
|
|
567
681
|
blockedCount: suggestion.blockingAnalysis.length,
|
|
568
|
-
blockedItems: suggestion.blockingAnalysis
|
|
682
|
+
blockedItems: suggestion.blockingAnalysis,
|
|
683
|
+
scope: "tasks-only"
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
if (command.name === "create-wishlist") {
|
|
688
|
+
const wishlistStore = await planning.openWishlist();
|
|
689
|
+
const raw = args;
|
|
690
|
+
const v = validateWishlistIntakePayload(raw);
|
|
691
|
+
if (!v.ok) {
|
|
692
|
+
return { ok: false, code: "invalid-task-schema", message: v.errors.join(" ") };
|
|
693
|
+
}
|
|
694
|
+
const ts = nowIso();
|
|
695
|
+
const item = buildWishlistItemFromIntake(raw, ts);
|
|
696
|
+
try {
|
|
697
|
+
wishlistStore.addItem(item);
|
|
698
|
+
}
|
|
699
|
+
catch (err) {
|
|
700
|
+
if (err instanceof TaskEngineError) {
|
|
701
|
+
return { ok: false, code: err.code, message: err.message };
|
|
569
702
|
}
|
|
703
|
+
throw err;
|
|
704
|
+
}
|
|
705
|
+
await wishlistStore.save();
|
|
706
|
+
return {
|
|
707
|
+
ok: true,
|
|
708
|
+
code: "wishlist-created",
|
|
709
|
+
message: `Created wishlist '${item.id}'`,
|
|
710
|
+
data: { item }
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
if (command.name === "list-wishlist") {
|
|
714
|
+
const wishlistStore = await planning.openWishlist();
|
|
715
|
+
const statusFilter = typeof args.status === "string" ? args.status : undefined;
|
|
716
|
+
let items = wishlistStore.getAllItems();
|
|
717
|
+
if (statusFilter && ["open", "converted", "cancelled"].includes(statusFilter)) {
|
|
718
|
+
items = items.filter((i) => i.status === statusFilter);
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
ok: true,
|
|
722
|
+
code: "wishlist-listed",
|
|
723
|
+
message: `Found ${items.length} wishlist items`,
|
|
724
|
+
data: { items, count: items.length, scope: "wishlist-only" }
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
if (command.name === "get-wishlist") {
|
|
728
|
+
const wishlistId = typeof args.wishlistId === "string" && args.wishlistId.trim().length > 0
|
|
729
|
+
? args.wishlistId.trim()
|
|
730
|
+
: typeof args.id === "string" && args.id.trim().length > 0
|
|
731
|
+
? args.id.trim()
|
|
732
|
+
: "";
|
|
733
|
+
if (!wishlistId) {
|
|
734
|
+
return { ok: false, code: "invalid-task-schema", message: "get-wishlist requires 'wishlistId' or 'id'" };
|
|
735
|
+
}
|
|
736
|
+
const wishlistStore = await planning.openWishlist();
|
|
737
|
+
const item = wishlistStore.getItem(wishlistId);
|
|
738
|
+
if (!item) {
|
|
739
|
+
return { ok: false, code: "task-not-found", message: `Wishlist item '${wishlistId}' not found` };
|
|
740
|
+
}
|
|
741
|
+
return {
|
|
742
|
+
ok: true,
|
|
743
|
+
code: "wishlist-retrieved",
|
|
744
|
+
data: { item }
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
if (command.name === "update-wishlist") {
|
|
748
|
+
const wishlistId = typeof args.wishlistId === "string" ? args.wishlistId.trim() : "";
|
|
749
|
+
const updates = typeof args.updates === "object" && args.updates !== null ? args.updates : undefined;
|
|
750
|
+
if (!wishlistId || !updates) {
|
|
751
|
+
return { ok: false, code: "invalid-task-schema", message: "update-wishlist requires wishlistId and updates" };
|
|
752
|
+
}
|
|
753
|
+
const wishlistStore = await planning.openWishlist();
|
|
754
|
+
const existing = wishlistStore.getItem(wishlistId);
|
|
755
|
+
if (!existing) {
|
|
756
|
+
return { ok: false, code: "task-not-found", message: `Wishlist item '${wishlistId}' not found` };
|
|
757
|
+
}
|
|
758
|
+
if (existing.status !== "open") {
|
|
759
|
+
return { ok: false, code: "invalid-transition", message: "Only open wishlist items can be updated" };
|
|
760
|
+
}
|
|
761
|
+
const uv = validateWishlistUpdatePayload(updates);
|
|
762
|
+
if (!uv.ok) {
|
|
763
|
+
return { ok: false, code: "invalid-task-schema", message: uv.errors.join(" ") };
|
|
764
|
+
}
|
|
765
|
+
const merged = { ...existing, updatedAt: nowIso() };
|
|
766
|
+
const mutable = [
|
|
767
|
+
"title",
|
|
768
|
+
"problemStatement",
|
|
769
|
+
"expectedOutcome",
|
|
770
|
+
"impact",
|
|
771
|
+
"constraints",
|
|
772
|
+
"successSignals",
|
|
773
|
+
"requestor",
|
|
774
|
+
"evidenceRef"
|
|
775
|
+
];
|
|
776
|
+
for (const key of mutable) {
|
|
777
|
+
if (key in updates && typeof updates[key] === "string") {
|
|
778
|
+
merged[key] = updates[key].trim();
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
wishlistStore.updateItem(merged);
|
|
782
|
+
await wishlistStore.save();
|
|
783
|
+
return {
|
|
784
|
+
ok: true,
|
|
785
|
+
code: "wishlist-updated",
|
|
786
|
+
message: `Updated wishlist '${wishlistId}'`,
|
|
787
|
+
data: { item: merged }
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
if (command.name === "convert-wishlist") {
|
|
791
|
+
const wishlistId = typeof args.wishlistId === "string" ? args.wishlistId.trim() : "";
|
|
792
|
+
if (!wishlistId || !WISHLIST_ID_RE.test(wishlistId)) {
|
|
793
|
+
return {
|
|
794
|
+
ok: false,
|
|
795
|
+
code: "invalid-task-schema",
|
|
796
|
+
message: "convert-wishlist requires wishlistId matching W<number>"
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
const dec = parseConversionDecomposition(args.decomposition);
|
|
800
|
+
if (!dec.ok) {
|
|
801
|
+
return { ok: false, code: "invalid-task-schema", message: dec.message };
|
|
802
|
+
}
|
|
803
|
+
const tasksRaw = args.tasks;
|
|
804
|
+
if (!Array.isArray(tasksRaw) || tasksRaw.length === 0) {
|
|
805
|
+
return {
|
|
806
|
+
ok: false,
|
|
807
|
+
code: "invalid-task-schema",
|
|
808
|
+
message: "convert-wishlist requires non-empty tasks array"
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
const wishlistStore = await planning.openWishlist();
|
|
812
|
+
const wlItem = wishlistStore.getItem(wishlistId);
|
|
813
|
+
if (!wlItem) {
|
|
814
|
+
return { ok: false, code: "task-not-found", message: `Wishlist item '${wishlistId}' not found` };
|
|
815
|
+
}
|
|
816
|
+
if (wlItem.status !== "open") {
|
|
817
|
+
return {
|
|
818
|
+
ok: false,
|
|
819
|
+
code: "invalid-transition",
|
|
820
|
+
message: "Only open wishlist items can be converted"
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
const actor = typeof args.actor === "string"
|
|
824
|
+
? args.actor
|
|
825
|
+
: ctx.resolvedActor !== undefined
|
|
826
|
+
? ctx.resolvedActor
|
|
827
|
+
: undefined;
|
|
828
|
+
const timestamp = nowIso();
|
|
829
|
+
const built = [];
|
|
830
|
+
for (const row of tasksRaw) {
|
|
831
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
832
|
+
return { ok: false, code: "invalid-task-schema", message: "Each task must be an object" };
|
|
833
|
+
}
|
|
834
|
+
const bt = buildTaskFromConversionPayload(row, timestamp);
|
|
835
|
+
if (!bt.ok) {
|
|
836
|
+
return { ok: false, code: "invalid-task-schema", message: bt.message };
|
|
837
|
+
}
|
|
838
|
+
if (store.getTask(bt.task.id)) {
|
|
839
|
+
return {
|
|
840
|
+
ok: false,
|
|
841
|
+
code: "duplicate-task-id",
|
|
842
|
+
message: `Task '${bt.task.id}' already exists`
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
built.push(bt.task);
|
|
846
|
+
}
|
|
847
|
+
const convertedIds = built.map((t) => t.id);
|
|
848
|
+
const updatedWishlist = {
|
|
849
|
+
...wlItem,
|
|
850
|
+
status: "converted",
|
|
851
|
+
updatedAt: timestamp,
|
|
852
|
+
convertedAt: timestamp,
|
|
853
|
+
convertedToTaskIds: convertedIds,
|
|
854
|
+
conversionDecomposition: dec.value
|
|
855
|
+
};
|
|
856
|
+
const applyConvertMutations = () => {
|
|
857
|
+
for (const t of built) {
|
|
858
|
+
store.addTask(t);
|
|
859
|
+
store.addMutationEvidence(mutationEvidence("create-task", t.id, actor, {
|
|
860
|
+
initialStatus: t.status,
|
|
861
|
+
source: "convert-wishlist",
|
|
862
|
+
wishlistId
|
|
863
|
+
}));
|
|
864
|
+
}
|
|
865
|
+
wishlistStore.updateItem(updatedWishlist);
|
|
866
|
+
};
|
|
867
|
+
if (planning.kind === "sqlite") {
|
|
868
|
+
planning.sqliteDual.withTransaction(applyConvertMutations);
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
applyConvertMutations();
|
|
872
|
+
await store.save();
|
|
873
|
+
await wishlistStore.save();
|
|
874
|
+
}
|
|
875
|
+
return {
|
|
876
|
+
ok: true,
|
|
877
|
+
code: "wishlist-converted",
|
|
878
|
+
message: `Converted wishlist '${wishlistId}' to tasks: ${convertedIds.join(", ")}`,
|
|
879
|
+
data: { wishlist: updatedWishlist, createdTasks: built }
|
|
570
880
|
};
|
|
571
881
|
}
|
|
572
882
|
return {
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import fsSync from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import { TaskEngineError } from "./transitions.js";
|
|
6
|
+
import { SqliteDualPlanningStore } from "./sqlite-dual-planning.js";
|
|
7
|
+
import { planningSqliteDatabaseRelativePath, planningTaskStoreRelativePath, planningWishlistStoreRelativePath } from "./planning-config.js";
|
|
8
|
+
import { DEFAULT_TASK_STORE_PATH } from "./store.js";
|
|
9
|
+
import { DEFAULT_WISHLIST_PATH } from "./wishlist-store.js";
|
|
10
|
+
function emptyTaskDoc() {
|
|
11
|
+
return {
|
|
12
|
+
schemaVersion: 1,
|
|
13
|
+
tasks: [],
|
|
14
|
+
transitionLog: [],
|
|
15
|
+
mutationLog: [],
|
|
16
|
+
lastUpdated: new Date().toISOString()
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function emptyWishDoc() {
|
|
20
|
+
return {
|
|
21
|
+
schemaVersion: 1,
|
|
22
|
+
items: [],
|
|
23
|
+
lastUpdated: new Date().toISOString()
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
async function atomicWriteJson(targetPath, body) {
|
|
27
|
+
const dir = path.dirname(targetPath);
|
|
28
|
+
const tmpPath = `${targetPath}.${crypto.randomUUID().slice(0, 8)}.tmp`;
|
|
29
|
+
await fs.mkdir(dir, { recursive: true });
|
|
30
|
+
await fs.writeFile(tmpPath, body, "utf8");
|
|
31
|
+
await fs.rename(tmpPath, targetPath);
|
|
32
|
+
}
|
|
33
|
+
export async function runMigrateTaskPersistence(ctx, args) {
|
|
34
|
+
const direction = typeof args.direction === "string" ? args.direction.trim() : "";
|
|
35
|
+
if (direction !== "json-to-sqlite" && direction !== "sqlite-to-json") {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
code: "invalid-task-schema",
|
|
39
|
+
message: "migrate-task-persistence requires direction: 'json-to-sqlite' | 'sqlite-to-json'"
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const dryRun = args.dryRun === true;
|
|
43
|
+
const force = args.force === true;
|
|
44
|
+
const taskRel = planningTaskStoreRelativePath(ctx) ?? DEFAULT_TASK_STORE_PATH;
|
|
45
|
+
const wishRel = planningWishlistStoreRelativePath(ctx) ?? DEFAULT_WISHLIST_PATH;
|
|
46
|
+
const taskPath = path.resolve(ctx.workspacePath, taskRel);
|
|
47
|
+
const wishPath = path.resolve(ctx.workspacePath, wishRel);
|
|
48
|
+
const dbRel = planningSqliteDatabaseRelativePath(ctx);
|
|
49
|
+
const dual = new SqliteDualPlanningStore(ctx.workspacePath, dbRel);
|
|
50
|
+
if (direction === "json-to-sqlite") {
|
|
51
|
+
if (fsSync.existsSync(dual.dbPath) && !force) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
code: "storage-write-error",
|
|
55
|
+
message: `SQLite database already exists at ${dual.dbPath} (pass force:true to overwrite)`
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
let taskDoc = emptyTaskDoc();
|
|
59
|
+
try {
|
|
60
|
+
const raw = await fs.readFile(taskPath, "utf8");
|
|
61
|
+
const parsed = JSON.parse(raw);
|
|
62
|
+
if (parsed.schemaVersion !== 1) {
|
|
63
|
+
throw new TaskEngineError("import-parse-error", `Unsupported task schema ${parsed.schemaVersion}`);
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(parsed.mutationLog)) {
|
|
66
|
+
parsed.mutationLog = [];
|
|
67
|
+
}
|
|
68
|
+
taskDoc = parsed;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
if (err.code === "ENOENT") {
|
|
72
|
+
taskDoc = emptyTaskDoc();
|
|
73
|
+
}
|
|
74
|
+
else if (err instanceof TaskEngineError) {
|
|
75
|
+
return { ok: false, code: err.code, message: err.message };
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
code: "import-parse-error",
|
|
81
|
+
message: `Failed to read task JSON: ${err.message}`
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let wishDoc = emptyWishDoc();
|
|
86
|
+
try {
|
|
87
|
+
const raw = await fs.readFile(wishPath, "utf8");
|
|
88
|
+
const parsed = JSON.parse(raw);
|
|
89
|
+
if (parsed.schemaVersion !== 1) {
|
|
90
|
+
throw new TaskEngineError("import-parse-error", `Unsupported wishlist schema ${parsed.schemaVersion}`);
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(parsed.items)) {
|
|
93
|
+
throw new TaskEngineError("import-parse-error", "Wishlist items must be an array");
|
|
94
|
+
}
|
|
95
|
+
wishDoc = parsed;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (err.code === "ENOENT") {
|
|
99
|
+
wishDoc = emptyWishDoc();
|
|
100
|
+
}
|
|
101
|
+
else if (err instanceof TaskEngineError) {
|
|
102
|
+
return { ok: false, code: err.code, message: err.message };
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
code: "import-parse-error",
|
|
108
|
+
message: `Failed to read wishlist JSON: ${err.message}`
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (dryRun) {
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
code: "migrate-dry-run",
|
|
116
|
+
message: "Dry run: would import JSON task/wishlist documents into SQLite",
|
|
117
|
+
data: {
|
|
118
|
+
dbPath: dual.dbPath,
|
|
119
|
+
taskPath,
|
|
120
|
+
wishPath,
|
|
121
|
+
taskCount: taskDoc.tasks.length,
|
|
122
|
+
wishlistCount: wishDoc.items.length
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
dual.seedFromDocuments(taskDoc, wishDoc);
|
|
127
|
+
try {
|
|
128
|
+
dual.persistSync();
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
code: "storage-write-error",
|
|
134
|
+
message: `Failed to write SQLite database: ${err.message}`
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
code: "migrated-json-to-sqlite",
|
|
140
|
+
message: `Imported task and wishlist JSON into ${dual.dbPath}`,
|
|
141
|
+
data: { dbPath: dual.dbPath, taskPath, wishPath }
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// sqlite-to-json
|
|
145
|
+
if (!fsSync.existsSync(dual.dbPath)) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
code: "storage-read-error",
|
|
149
|
+
message: `SQLite database not found at ${dual.dbPath}`
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
dual.loadFromDisk();
|
|
153
|
+
if (!force && (fsSync.existsSync(taskPath) || fsSync.existsSync(wishPath))) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
code: "storage-write-error",
|
|
157
|
+
message: `Target JSON path already exists (task or wishlist); pass force:true to overwrite`,
|
|
158
|
+
data: { taskPath, wishPath }
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (dryRun) {
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
code: "migrate-dry-run",
|
|
165
|
+
message: "Dry run: would export SQLite documents to JSON files",
|
|
166
|
+
data: {
|
|
167
|
+
dbPath: dual.dbPath,
|
|
168
|
+
taskPath,
|
|
169
|
+
wishPath,
|
|
170
|
+
taskCount: dual.taskDocument.tasks.length,
|
|
171
|
+
wishlistCount: dual.wishlistDocument.items.length
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
await atomicWriteJson(taskPath, JSON.stringify(dual.taskDocument, null, 2) + "\n");
|
|
177
|
+
await atomicWriteJson(wishPath, JSON.stringify(dual.wishlistDocument, null, 2) + "\n");
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
code: "storage-write-error",
|
|
183
|
+
message: `Failed to write JSON export: ${err.message}`
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
ok: true,
|
|
188
|
+
code: "migrated-sqlite-to-json",
|
|
189
|
+
message: `Exported SQLite planning state to ${taskPath} and ${wishPath}`,
|
|
190
|
+
data: { dbPath: dual.dbPath, taskPath, wishPath }
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ModuleLifecycleContext } from "../../contracts/module-contract.js";
|
|
2
|
+
export type TaskPersistenceBackend = "json" | "sqlite";
|
|
3
|
+
export declare function getTaskPersistenceBackend(config: Record<string, unknown> | undefined): TaskPersistenceBackend;
|
|
4
|
+
export declare function planningTaskStoreRelativePath(ctx: {
|
|
5
|
+
effectiveConfig?: Record<string, unknown>;
|
|
6
|
+
}): string | undefined;
|
|
7
|
+
export declare function planningWishlistStoreRelativePath(ctx: {
|
|
8
|
+
effectiveConfig?: Record<string, unknown>;
|
|
9
|
+
}): string | undefined;
|
|
10
|
+
export declare function planningSqliteDatabaseRelativePath(ctx: ModuleLifecycleContext): string;
|