ndomo 0.1.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/.bun-version +1 -0
- package/.dockerignore +79 -0
- package/.editorconfig +18 -0
- package/.env.example +19 -0
- package/.github/CODEOWNERS +8 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +62 -0
- package/.github/ISSUE_TEMPLATE/config.yml +2 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +34 -0
- package/.github/dependabot.yml +36 -0
- package/.github/pull_request_template.md +24 -0
- package/.github/release.yml +30 -0
- package/.github/workflows/gitleaks.yml +28 -0
- package/.github/workflows/release-please.yml +27 -0
- package/.github/workflows/smoke.yml +29 -0
- package/.husky/commit-msg +1 -0
- package/CHANGELOG.md +114 -0
- package/Dockerfile +32 -0
- package/README.es.md +174 -0
- package/README.md +187 -0
- package/agents/chronicler.md +98 -0
- package/agents/ci-smith.md +136 -0
- package/agents/craftsman.md +341 -0
- package/agents/deploy-smith.md +138 -0
- package/agents/foreman.md +377 -0
- package/agents/go-smith.md +164 -0
- package/agents/guild.md +188 -0
- package/agents/inspector.md +83 -0
- package/agents/js-smith.md +127 -0
- package/agents/ops-scout.md +173 -0
- package/agents/painter.md +200 -0
- package/agents/python-smith.md +120 -0
- package/agents/ranger.md +307 -0
- package/agents/release-smith.md +165 -0
- package/agents/rust-smith.md +159 -0
- package/agents/sage.md +178 -0
- package/agents/scout.md +144 -0
- package/agents/scribe.md +156 -0
- package/agents/smith.md +201 -0
- package/agents/vue-smith.md +155 -0
- package/agents/warden.md +216 -0
- package/agents/zig-smith.md +156 -0
- package/bin/ndomo-analyses.ts +4 -0
- package/bin/ndomo-status.ts +4 -0
- package/biome.json +57 -0
- package/bun.lock +514 -0
- package/commitlint.config.js +3 -0
- package/config/ndomo.config.json +258 -0
- package/config/ndomo.schema.json +166 -0
- package/docs/agents.md +375 -0
- package/docs/bugs/plan-create-orphan-fk.md +131 -0
- package/docs/bugs/task_create_batch-order-index-collision.md +158 -0
- package/docs/configuration.md +276 -0
- package/docs/database.md +364 -0
- package/docs/features/feature-flexible-builder-v1.md +724 -0
- package/docs/features/feature-flexible-builder-v2.md +882 -0
- package/docs/features/feature-flexible-builder.md +974 -0
- package/docs/http-server.md +244 -0
- package/docs/installation.md +259 -0
- package/docs/integrations.md +129 -0
- package/docs/operations/anti-pattern-sub-agent-verify-2026-06-21.md +32 -0
- package/docs/operations/audit-v1.md +417 -0
- package/docs/operations/audit-v2.md +197 -0
- package/docs/operations/audit-v3.md +306 -0
- package/docs/operations/db-optimize-foundations.md +123 -0
- package/docs/operations/verify-gate-architecture.md +82 -0
- package/docs/workflows.md +448 -0
- package/opencode.json +5 -0
- package/package.json +65 -0
- package/release-please-config.json +11 -0
- package/scripts/dev-bust-cache.sh +164 -0
- package/scripts/install.sh +688 -0
- package/scripts/smoke-e2e.ts +704 -0
- package/scripts/smoke-hot.ts +417 -0
- package/scripts/smoke-http.sh +228 -0
- package/scripts/smoke-v4.ts +256 -0
- package/scripts/smoke-v5.ts +397 -0
- package/scripts/smoke.sh +9 -0
- package/scripts/uninstall.sh +224 -0
- package/skills/api-security-best-practices/SKILL.md +915 -0
- package/skills/bash-scripting/SKILL.md +201 -0
- package/skills/bun/SKILL.md +313 -0
- package/skills/cavecrew/SKILL.md +82 -0
- package/skills/caveman/SKILL.md +74 -0
- package/skills/caveman-review/README.md +33 -0
- package/skills/caveman-review/SKILL.md +55 -0
- package/skills/find-skills/SKILL.md +142 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +55 -0
- package/skills/golang-patterns/SKILL.md +674 -0
- package/skills/golang-security/SKILL.md +185 -0
- package/skills/golang-security/evals/evals.json +595 -0
- package/skills/golang-security/references/architecture.md +268 -0
- package/skills/golang-security/references/checklist.md +80 -0
- package/skills/golang-security/references/cookies.md +200 -0
- package/skills/golang-security/references/cryptography.md +424 -0
- package/skills/golang-security/references/filesystem.md +285 -0
- package/skills/golang-security/references/injection.md +315 -0
- package/skills/golang-security/references/logging.md +163 -0
- package/skills/golang-security/references/memory-safety.md +241 -0
- package/skills/golang-security/references/network.md +253 -0
- package/skills/golang-security/references/secrets.md +189 -0
- package/skills/golang-security/references/third-party.md +159 -0
- package/skills/golang-security/references/threat-modeling.md +189 -0
- package/skills/golang-testing/SKILL.md +720 -0
- package/skills/grill-me/SKILL.md +7 -0
- package/skills/javascript-testing-patterns/SKILL.md +537 -0
- package/skills/javascript-testing-patterns/references/advanced-testing-patterns.md +513 -0
- package/skills/modern-javascript-patterns/SKILL.md +43 -0
- package/skills/modern-javascript-patterns/references/advanced-patterns.md +487 -0
- package/skills/modern-javascript-patterns/references/details.md +457 -0
- package/skills/python-anti-patterns/SKILL.md +349 -0
- package/skills/python-design-patterns/SKILL.md +85 -0
- package/skills/python-design-patterns/references/details.md +353 -0
- package/skills/python-error-handling/SKILL.md +193 -0
- package/skills/python-error-handling/references/details.md +171 -0
- package/skills/python-testing-patterns/SKILL.md +278 -0
- package/skills/python-testing-patterns/references/advanced-patterns.md +411 -0
- package/skills/python-testing-patterns/references/details.md +349 -0
- package/skills/rust-patterns/SKILL.md +500 -0
- package/skills/rust-testing/SKILL.md +501 -0
- package/skills/security-review/SKILL.md +504 -0
- package/skills/security-review/cloud-infrastructure-security.md +361 -0
- package/skills/vue-best-practices/SKILL.md +154 -0
- package/skills/vue-best-practices/references/animation-class-based-technique.md +254 -0
- package/skills/vue-best-practices/references/animation-state-driven-technique.md +291 -0
- package/skills/vue-best-practices/references/component-async.md +97 -0
- package/skills/vue-best-practices/references/component-data-flow.md +307 -0
- package/skills/vue-best-practices/references/component-fallthrough-attrs.md +174 -0
- package/skills/vue-best-practices/references/component-keep-alive.md +137 -0
- package/skills/vue-best-practices/references/component-slots.md +216 -0
- package/skills/vue-best-practices/references/component-suspense.md +228 -0
- package/skills/vue-best-practices/references/component-teleport.md +108 -0
- package/skills/vue-best-practices/references/component-transition-group.md +128 -0
- package/skills/vue-best-practices/references/component-transition.md +125 -0
- package/skills/vue-best-practices/references/composables.md +290 -0
- package/skills/vue-best-practices/references/directives.md +162 -0
- package/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md +159 -0
- package/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md +182 -0
- package/skills/vue-best-practices/references/perf-virtualize-large-lists.md +187 -0
- package/skills/vue-best-practices/references/plugins.md +166 -0
- package/skills/vue-best-practices/references/reactivity.md +344 -0
- package/skills/vue-best-practices/references/render-functions.md +201 -0
- package/skills/vue-best-practices/references/sfc.md +310 -0
- package/skills/vue-best-practices/references/state-management.md +135 -0
- package/skills/vue-best-practices/references/updated-hook-performance.md +187 -0
- package/skills/vue-pinia-best-practices/SKILL.md +21 -0
- package/skills/vue-pinia-best-practices/reference/pinia-no-active-pinia-error.md +248 -0
- package/skills/vue-pinia-best-practices/reference/pinia-setup-store-return-all-state.md +227 -0
- package/skills/vue-pinia-best-practices/reference/pinia-store-destructuring-breaks-reactivity.md +193 -0
- package/skills/vue-pinia-best-practices/reference/state-url-for-ephemeral-filters.md +238 -0
- package/skills/vue-pinia-best-practices/reference/state-use-pinia-for-large-apps.md +262 -0
- package/skills/vue-pinia-best-practices/reference/store-method-binding-parentheses.md +191 -0
- package/skills/zig-0.16/SKILL.md +840 -0
- package/skills/zig-0.16/scripts/check-zig-version.sh +21 -0
- package/src/cli/analyses.ts +280 -0
- package/src/cli/index.ts +108 -0
- package/src/cli/serve.ts +192 -0
- package/src/cli/smoke.ts +131 -0
- package/src/cli/status.test.ts +204 -0
- package/src/cli/status.ts +263 -0
- package/src/cli/vacuum.test.ts +82 -0
- package/src/cli/vacuum.ts +96 -0
- package/src/config/schema.test.ts +88 -0
- package/src/config/schema.ts +64 -0
- package/src/db/analyses-migration.test.ts +210 -0
- package/src/db/analyses.test.ts +466 -0
- package/src/db/analyses.ts +375 -0
- package/src/db/auto-checkpoint.ts +131 -0
- package/src/db/client.test.ts +129 -0
- package/src/db/client.ts +55 -0
- package/src/db/fts-escape.ts +20 -0
- package/src/db/incidents.test.ts +201 -0
- package/src/db/incidents.ts +93 -0
- package/src/db/index.ts +86 -0
- package/src/db/migrations-v13.test.ts +141 -0
- package/src/db/migrations-v8.test.ts +301 -0
- package/src/db/migrations.ts +147 -0
- package/src/db/plan-archive.test.ts +180 -0
- package/src/db/plan-archive.ts +274 -0
- package/src/db/plan-create.test.ts +276 -0
- package/src/db/plan-create.ts +78 -0
- package/src/db/plan-files.test.ts +289 -0
- package/src/db/plan-update-status.ts +287 -0
- package/src/db/plans.test.ts +490 -0
- package/src/db/plans.ts +534 -0
- package/src/db/resolve-project-dir.test.ts +143 -0
- package/src/db/resolve-project-dir.ts +75 -0
- package/src/db/rollbacks.test.ts +150 -0
- package/src/db/rollbacks.ts +67 -0
- package/src/db/schema.ts +907 -0
- package/src/db/sessions.test.ts +80 -0
- package/src/db/sessions.ts +135 -0
- package/src/db/shutdown.test.ts +147 -0
- package/src/db/shutdown.ts +45 -0
- package/src/db/tasks.test.ts +921 -0
- package/src/db/tasks.ts +747 -0
- package/src/db/types.ts +619 -0
- package/src/http/__tests__/auth.test.ts +196 -0
- package/src/http/__tests__/routes.test.ts +465 -0
- package/src/http/__tests__/sse.test.ts +317 -0
- package/src/http/auth.ts +72 -0
- package/src/http/middleware/cors.ts +53 -0
- package/src/http/middleware/security-headers.ts +21 -0
- package/src/http/routes/events.ts +112 -0
- package/src/http/routes/health.ts +51 -0
- package/src/http/routes/plans.ts +66 -0
- package/src/http/routes/sessions.ts +50 -0
- package/src/http/routes/tasks.ts +60 -0
- package/src/http/server.ts +95 -0
- package/src/http/sse.ts +116 -0
- package/src/index.ts +37 -0
- package/src/lib.ts +65 -0
- package/src/mem/scoped.ts +65 -0
- package/src/orchestrator/background.test.ts +268 -0
- package/src/orchestrator/background.ts +293 -0
- package/src/orchestrator/memory-hook.ts +182 -0
- package/src/orchestrator/reconciler.ts +123 -0
- package/src/orchestrator/scheduler.test.ts +300 -0
- package/src/orchestrator/scheduler.ts +243 -0
- package/src/plugin.test.ts +2574 -0
- package/src/plugin.ts +1690 -0
- package/src/sdk/client.ts +66 -0
- package/src/worktrees/manager.ts +236 -0
- package/src/worktrees/state.ts +87 -0
- package/tests/integration/ranger-flow.test.ts +257 -0
- package/tools/analysis_archive.ts +28 -0
- package/tools/analysis_create.ts +55 -0
- package/tools/analysis_get.ts +33 -0
- package/tools/analysis_link_plan.ts +44 -0
- package/tools/analysis_list.ts +48 -0
- package/tools/analysis_search.ts +36 -0
- package/tools/analysis_update.ts +44 -0
- package/tools/plan_approve.ts +31 -0
- package/tools/plan_create.ts +58 -0
- package/tools/plan_get.ts +40 -0
- package/tools/plan_list.ts +37 -0
- package/tools/plan_search.ts +34 -0
- package/tools/plan_update_status.ts +71 -0
- package/tools/session_checkpoint.ts +31 -0
- package/tools/session_end.ts +26 -0
- package/tools/session_start.ts +43 -0
- package/tools/task_create_batch.ts +70 -0
- package/tools/task_list.ts +35 -0
- package/tools/task_next_for_agent.ts +30 -0
- package/tools/task_search.ts +34 -0
- package/tools/task_update_status.ts +37 -0
- package/tsconfig.json +31 -0
package/src/db/tasks.ts
ADDED
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ndomo DB — Task CRUD + FTS5 search.
|
|
3
|
+
*
|
|
4
|
+
* All functions take a Database instance and return camelCase TS types.
|
|
5
|
+
* createTasksBatch is transactional.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Database, SQLQueryBindings } from "bun:sqlite";
|
|
9
|
+
import { escapeFtsQuery } from "./fts-escape.ts";
|
|
10
|
+
import { setExecutedByOnce } from "./plans.ts";
|
|
11
|
+
import { ensureSession } from "./sessions.ts";
|
|
12
|
+
import type { PlanTask, TaskMetadata, TaskStatus } from "./types.ts";
|
|
13
|
+
import { taskFromRow } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
// ─── M7: Cross-stack file splitting ─────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** Map file extension → stack key. Unrecognized extensions → 'other'. */
|
|
18
|
+
const STACK_MAP: Record<string, string> = {
|
|
19
|
+
".go": "go",
|
|
20
|
+
".vue": "vue",
|
|
21
|
+
".ts": "js",
|
|
22
|
+
".tsx": "js",
|
|
23
|
+
".js": "js",
|
|
24
|
+
".jsx": "js",
|
|
25
|
+
".py": "python",
|
|
26
|
+
".rs": "rust",
|
|
27
|
+
".zig": "zig",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Map stack key → default agent for that stack. */
|
|
31
|
+
const STACK_AGENT_MAP: Record<string, string> = {
|
|
32
|
+
go: "go-smith",
|
|
33
|
+
vue: "js-smith",
|
|
34
|
+
js: "js-smith",
|
|
35
|
+
python: "python-smith",
|
|
36
|
+
rust: "rust-smith",
|
|
37
|
+
zig: "zig-smith",
|
|
38
|
+
other: "smith",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Group files by their stack (determined by extension).
|
|
43
|
+
* Returns a record of stackKey → file paths.
|
|
44
|
+
*
|
|
45
|
+
* @example splitFilesByStack(["main.go", "app.ts"]) → { go: ["main.go"], js: ["app.ts"] }
|
|
46
|
+
*/
|
|
47
|
+
export function splitFilesByStack(files: string[]): Record<string, string[]> {
|
|
48
|
+
const result: Record<string, string[]> = {};
|
|
49
|
+
for (const f of files) {
|
|
50
|
+
const dotIdx = f.lastIndexOf(".");
|
|
51
|
+
const ext = dotIdx >= 0 ? f.slice(dotIdx).toLowerCase() : "";
|
|
52
|
+
const stack = STACK_MAP[ext] ?? "other";
|
|
53
|
+
if (!result[stack]) result[stack] = [];
|
|
54
|
+
result[stack].push(f);
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── M6: Truncation types ───────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/** Metadata returned when result/error is truncated to 16 KB. */
|
|
62
|
+
export interface TaskTruncationInfo {
|
|
63
|
+
truncated: boolean;
|
|
64
|
+
originalLength?: number;
|
|
65
|
+
truncatedLength?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Return type for updateTaskStatus — includes truncation metadata. */
|
|
69
|
+
export type TaskUpdateResult = (PlanTask & { truncation: TaskTruncationInfo }) | null;
|
|
70
|
+
|
|
71
|
+
export function createTasksBatch(
|
|
72
|
+
db: Database,
|
|
73
|
+
planId: string,
|
|
74
|
+
tasks: Array<
|
|
75
|
+
Omit<
|
|
76
|
+
PlanTask,
|
|
77
|
+
| "id"
|
|
78
|
+
| "planId"
|
|
79
|
+
| "status"
|
|
80
|
+
| "startedAt"
|
|
81
|
+
| "completedAt"
|
|
82
|
+
| "result"
|
|
83
|
+
| "error"
|
|
84
|
+
| "archivedAt"
|
|
85
|
+
| "originalPlanData"
|
|
86
|
+
| "orderIndex"
|
|
87
|
+
> & {
|
|
88
|
+
/** Preferred order_index slot. If omitted or occupied, core allocates dynamically. */
|
|
89
|
+
orderIndex?: number;
|
|
90
|
+
}
|
|
91
|
+
>,
|
|
92
|
+
): PlanTask[] {
|
|
93
|
+
// v6: soft warning for large task batches
|
|
94
|
+
if (tasks.length > 5) {
|
|
95
|
+
console.warn(
|
|
96
|
+
`ndomo: creating ${tasks.length} tasks in batch for plan ${planId} — consider splitting large plans`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// F1: pre-dispatch overlap check — skip tasks with same (planId, agent, description)
|
|
101
|
+
// that already exist (any status, not archived). Prevents duplicate task creation.
|
|
102
|
+
const existingSignatures = new Set<string>();
|
|
103
|
+
const existingRows = db
|
|
104
|
+
.query("SELECT agent, description FROM plan_tasks WHERE plan_id = ? AND archived_at IS NULL")
|
|
105
|
+
.all(planId) as Array<{ agent: string; description: string }>;
|
|
106
|
+
for (const row of existingRows) {
|
|
107
|
+
existingSignatures.add(`${row.agent}::${row.description}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── order_index collision-safe allocation (fix: UNIQUE constraint on retries) ──
|
|
111
|
+
// The UNIQUE(plan_id, order_index) constraint covers ALL rows (archived or not).
|
|
112
|
+
// Callers may pass t.orderIndex as a preferred slot, but it's only a hint —
|
|
113
|
+
// the core reassigns if the slot is occupied. This makes task_create_batch safe
|
|
114
|
+
// to call 2+ times on the same plan (retry, cross-step dispatch, etc.).
|
|
115
|
+
const MAX_RETRIES = 10;
|
|
116
|
+
|
|
117
|
+
// Collect ALL existing order_indices (including archived) for collision detection.
|
|
118
|
+
const usedOrderIndices = new Set<number>();
|
|
119
|
+
const allOrderRows = db
|
|
120
|
+
.query("SELECT order_index FROM plan_tasks WHERE plan_id = ?")
|
|
121
|
+
.all(planId) as Array<{ order_index: number }>;
|
|
122
|
+
for (const row of allOrderRows) {
|
|
123
|
+
usedOrderIndices.add(row.order_index);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// nextFreeInteger starts after the MAX of non-archived tasks (so new tasks
|
|
127
|
+
// fill in after the active set, not after archived outliers).
|
|
128
|
+
const maxRow = db
|
|
129
|
+
.query("SELECT MAX(order_index) as m FROM plan_tasks WHERE plan_id = ? AND archived_at IS NULL")
|
|
130
|
+
.get(planId) as { m: number | null } | undefined;
|
|
131
|
+
let nextFreeInteger = Math.floor(maxRow?.m ?? -1) + 1;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Allocate a unique order_index.
|
|
135
|
+
* Tries the preferred slot first; if occupied (or undefined), falls back to
|
|
136
|
+
* nextFreeInteger, incrementing until a free slot is found.
|
|
137
|
+
* Marks the slot as used in usedOrderIndices before returning.
|
|
138
|
+
*/
|
|
139
|
+
function allocateOrderIndex(preferred: number | undefined): number {
|
|
140
|
+
if (preferred !== undefined && !usedOrderIndices.has(preferred)) {
|
|
141
|
+
usedOrderIndices.add(preferred);
|
|
142
|
+
if (Math.floor(preferred) >= nextFreeInteger) {
|
|
143
|
+
nextFreeInteger = Math.floor(preferred) + 1;
|
|
144
|
+
}
|
|
145
|
+
return preferred;
|
|
146
|
+
}
|
|
147
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
148
|
+
const candidate = nextFreeInteger;
|
|
149
|
+
if (!usedOrderIndices.has(candidate)) {
|
|
150
|
+
usedOrderIndices.add(candidate);
|
|
151
|
+
nextFreeInteger = candidate + 1;
|
|
152
|
+
return candidate;
|
|
153
|
+
}
|
|
154
|
+
nextFreeInteger++;
|
|
155
|
+
}
|
|
156
|
+
throw new Error(
|
|
157
|
+
`ndomo: could not allocate unique order_index after ${MAX_RETRIES} retries for plan ${planId}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Allocate order_index for a split sub-task.
|
|
163
|
+
* stackIdx=0 → parent slot (integer).
|
|
164
|
+
* stackIdx>0 → parentOrder + stackIdx*0.1 (decimal); if occupied, escalate
|
|
165
|
+
* to next free integer (no further decimal attempts).
|
|
166
|
+
*/
|
|
167
|
+
function allocateSplitOrderIndex(parentOrder: number, stackIdx: number): number {
|
|
168
|
+
if (stackIdx === 0) {
|
|
169
|
+
// parentOrder was already allocated and marked as used in the pre-loop.
|
|
170
|
+
// Return directly — do NOT re-allocate (would see slot as occupied).
|
|
171
|
+
return parentOrder;
|
|
172
|
+
}
|
|
173
|
+
const decimalCandidate = parentOrder + stackIdx * 0.1;
|
|
174
|
+
if (!usedOrderIndices.has(decimalCandidate)) {
|
|
175
|
+
usedOrderIndices.add(decimalCandidate);
|
|
176
|
+
return decimalCandidate;
|
|
177
|
+
}
|
|
178
|
+
// Decimal occupied → escalate to next free integer
|
|
179
|
+
return allocateOrderIndex(undefined);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const results: PlanTask[] = [];
|
|
183
|
+
const txn = db.transaction(() => {
|
|
184
|
+
for (const t of tasks) {
|
|
185
|
+
// M7: split cross-stack files into sub-tasks
|
|
186
|
+
const filesByStack = t.files && t.files.length > 1 ? splitFilesByStack(t.files) : null;
|
|
187
|
+
const stackKeys = filesByStack ? Object.keys(filesByStack) : [];
|
|
188
|
+
const needsSplit = filesByStack !== null && stackKeys.length > 1;
|
|
189
|
+
|
|
190
|
+
// For split tasks: allocate parent order_index first (used as base for decimals).
|
|
191
|
+
// For non-split tasks: orderIndex is allocated per sub-task below.
|
|
192
|
+
const parentOrder = needsSplit ? allocateOrderIndex(t.orderIndex) : undefined;
|
|
193
|
+
|
|
194
|
+
// Generate sub-tasks: either the original or split children
|
|
195
|
+
// (orderIndex is allocated below via helpers — not set here)
|
|
196
|
+
const subTasks = needsSplit
|
|
197
|
+
? stackKeys.map((stack) => ({
|
|
198
|
+
...t,
|
|
199
|
+
description: t.description,
|
|
200
|
+
agent: STACK_AGENT_MAP[stack] ?? "smith",
|
|
201
|
+
files: filesByStack[stack] ?? [],
|
|
202
|
+
metadata: {
|
|
203
|
+
...t.metadata,
|
|
204
|
+
splitFrom: null as string | null, // filled after first insert
|
|
205
|
+
splitReason: "cross-stack" as const,
|
|
206
|
+
},
|
|
207
|
+
}))
|
|
208
|
+
: [t];
|
|
209
|
+
|
|
210
|
+
let firstSubTaskId: string | null = null;
|
|
211
|
+
|
|
212
|
+
for (let stackIdx = 0; stackIdx < subTasks.length; stackIdx++) {
|
|
213
|
+
const effectiveTask = subTasks[stackIdx];
|
|
214
|
+
if (effectiveTask === undefined) continue;
|
|
215
|
+
|
|
216
|
+
// Skip if task with same (agent, description) already exists for this plan
|
|
217
|
+
const sig = `${effectiveTask.agent}::${effectiveTask.description}`;
|
|
218
|
+
if (existingSignatures.has(sig)) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Allocate order_index via collision-safe helpers
|
|
223
|
+
const orderIndex = needsSplit
|
|
224
|
+
? allocateSplitOrderIndex(parentOrder as number, stackIdx)
|
|
225
|
+
: allocateOrderIndex(effectiveTask.orderIndex);
|
|
226
|
+
|
|
227
|
+
const id = crypto.randomUUID();
|
|
228
|
+
|
|
229
|
+
// Wire splitFrom to first sub-task id
|
|
230
|
+
if (needsSplit && firstSubTaskId === null) {
|
|
231
|
+
firstSubTaskId = id;
|
|
232
|
+
}
|
|
233
|
+
const taskMetadata = needsSplit
|
|
234
|
+
? { ...effectiveTask.metadata, splitFrom: firstSubTaskId }
|
|
235
|
+
: (effectiveTask.metadata ?? {});
|
|
236
|
+
|
|
237
|
+
// Defense-in-depth: try/catch UNIQUE constraint with retry on order_index.
|
|
238
|
+
// The pre-loop allocation should prevent collisions, but if a race or
|
|
239
|
+
// edge case triggers SQLITE_CONSTRAINT, reassign order_index and retry.
|
|
240
|
+
let currentOrderIndex = orderIndex;
|
|
241
|
+
let originalPlanData = JSON.stringify({
|
|
242
|
+
description: effectiveTask.description,
|
|
243
|
+
agent: effectiveTask.agent,
|
|
244
|
+
files: effectiveTask.files ?? [],
|
|
245
|
+
complexity: effectiveTask.complexity,
|
|
246
|
+
dependencies: effectiveTask.dependencies ?? [],
|
|
247
|
+
metadata: taskMetadata,
|
|
248
|
+
orderIndex: currentOrderIndex,
|
|
249
|
+
createdBy: effectiveTask.createdBy,
|
|
250
|
+
});
|
|
251
|
+
let inserted = false;
|
|
252
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
253
|
+
try {
|
|
254
|
+
db.query(
|
|
255
|
+
`INSERT INTO plan_tasks (id, plan_id, order_index, description, agent, files, complexity, status, dependencies, metadata, created_by, updated_by, source_session_id, source_message_id, reviewed_by, tokens_used, duration_ms, artifacts, original_plan_data)
|
|
256
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
257
|
+
).run(
|
|
258
|
+
id,
|
|
259
|
+
planId,
|
|
260
|
+
currentOrderIndex,
|
|
261
|
+
effectiveTask.description,
|
|
262
|
+
effectiveTask.agent,
|
|
263
|
+
JSON.stringify(effectiveTask.files ?? []),
|
|
264
|
+
effectiveTask.complexity,
|
|
265
|
+
JSON.stringify(effectiveTask.dependencies ?? []),
|
|
266
|
+
JSON.stringify(taskMetadata),
|
|
267
|
+
effectiveTask.createdBy,
|
|
268
|
+
effectiveTask.updatedBy ?? effectiveTask.createdBy,
|
|
269
|
+
effectiveTask.sourceSessionId ?? null,
|
|
270
|
+
effectiveTask.sourceMessageId ?? null,
|
|
271
|
+
effectiveTask.reviewedBy ?? null,
|
|
272
|
+
effectiveTask.tokensUsed ?? null,
|
|
273
|
+
effectiveTask.durationMs ?? null,
|
|
274
|
+
JSON.stringify(effectiveTask.artifacts ?? []),
|
|
275
|
+
originalPlanData,
|
|
276
|
+
);
|
|
277
|
+
inserted = true;
|
|
278
|
+
break;
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (
|
|
281
|
+
attempt < MAX_RETRIES - 1 &&
|
|
282
|
+
err instanceof Error &&
|
|
283
|
+
err.message.includes("UNIQUE")
|
|
284
|
+
) {
|
|
285
|
+
// order_index collision — reassign and rebuild snapshot
|
|
286
|
+
currentOrderIndex = allocateOrderIndex(undefined);
|
|
287
|
+
originalPlanData = JSON.stringify({
|
|
288
|
+
description: effectiveTask.description,
|
|
289
|
+
agent: effectiveTask.agent,
|
|
290
|
+
files: effectiveTask.files ?? [],
|
|
291
|
+
complexity: effectiveTask.complexity,
|
|
292
|
+
dependencies: effectiveTask.dependencies ?? [],
|
|
293
|
+
metadata: taskMetadata,
|
|
294
|
+
orderIndex: currentOrderIndex,
|
|
295
|
+
createdBy: effectiveTask.createdBy,
|
|
296
|
+
});
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (!inserted) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`ndomo: failed to insert task after ${MAX_RETRIES} retries for plan ${planId}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
results.push({
|
|
309
|
+
id,
|
|
310
|
+
planId,
|
|
311
|
+
orderIndex: currentOrderIndex,
|
|
312
|
+
description: effectiveTask.description,
|
|
313
|
+
agent: effectiveTask.agent,
|
|
314
|
+
files: effectiveTask.files ?? [],
|
|
315
|
+
complexity: effectiveTask.complexity,
|
|
316
|
+
status: "pending",
|
|
317
|
+
startedAt: null,
|
|
318
|
+
completedAt: null,
|
|
319
|
+
result: null,
|
|
320
|
+
error: null,
|
|
321
|
+
dependencies: effectiveTask.dependencies ?? [],
|
|
322
|
+
createdBy: effectiveTask.createdBy,
|
|
323
|
+
updatedBy: effectiveTask.updatedBy ?? effectiveTask.createdBy ?? "unknown",
|
|
324
|
+
sourceSessionId: effectiveTask.sourceSessionId ?? null,
|
|
325
|
+
sourceMessageId: effectiveTask.sourceMessageId ?? null,
|
|
326
|
+
reviewedBy: effectiveTask.reviewedBy ?? null,
|
|
327
|
+
tokensUsed: effectiveTask.tokensUsed ?? null,
|
|
328
|
+
durationMs: effectiveTask.durationMs ?? null,
|
|
329
|
+
artifacts: effectiveTask.artifacts ?? [],
|
|
330
|
+
metadata: taskMetadata as TaskMetadata,
|
|
331
|
+
archivedAt: null,
|
|
332
|
+
originalPlanData,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Track signature to prevent in-batch duplicates
|
|
336
|
+
existingSignatures.add(sig);
|
|
337
|
+
|
|
338
|
+
// Issue 4: insert task files into plan_files with role='modified' (spec v2 §7.2)
|
|
339
|
+
if (effectiveTask.files && effectiveTask.files.length > 0) {
|
|
340
|
+
for (const filePath of effectiveTask.files) {
|
|
341
|
+
db.query(
|
|
342
|
+
"INSERT OR IGNORE INTO plan_files (plan_id, file_path, role) VALUES (?, ?, 'modified')",
|
|
343
|
+
).run(planId, filePath);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
txn();
|
|
350
|
+
return results;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function getTask(db: Database, id: string): PlanTask | null {
|
|
354
|
+
const row = db.query("SELECT * FROM plan_tasks WHERE id = ?").get(id);
|
|
355
|
+
return row != null ? taskFromRow(row) : null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function listTasksByPlan(
|
|
359
|
+
db: Database,
|
|
360
|
+
planId: string,
|
|
361
|
+
opts: { status?: TaskStatus; includeArchived?: boolean } = {},
|
|
362
|
+
): PlanTask[] {
|
|
363
|
+
const conditions: string[] = ["plan_id = ?"];
|
|
364
|
+
const params: SQLQueryBindings[] = [planId];
|
|
365
|
+
|
|
366
|
+
if (opts.status !== undefined) {
|
|
367
|
+
conditions.push("status = ?");
|
|
368
|
+
params.push(opts.status);
|
|
369
|
+
}
|
|
370
|
+
if (!opts.includeArchived) {
|
|
371
|
+
conditions.push("archived_at IS NULL");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const rows = db
|
|
375
|
+
.query(`SELECT * FROM plan_tasks WHERE ${conditions.join(" AND ")} ORDER BY order_index`)
|
|
376
|
+
.all(...params);
|
|
377
|
+
return (rows as unknown[]).map(taskFromRow);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const MAX_RESULT_BYTES = 16 * 1024;
|
|
381
|
+
const TRUNC_SUFFIX = "…[truncated]";
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Truncate strings exceeding 16 KB to prevent unbounded storage growth.
|
|
385
|
+
* Returns [truncated, wasTruncated] tuple for metadata tracking.
|
|
386
|
+
*/
|
|
387
|
+
function truncWithInfo(s: string | undefined): [string | undefined, boolean, number | undefined] {
|
|
388
|
+
if (s === undefined) return [undefined, false, undefined];
|
|
389
|
+
if (s.length > MAX_RESULT_BYTES) {
|
|
390
|
+
const truncated = `${s.slice(0, MAX_RESULT_BYTES - TRUNC_SUFFIX.length)}${TRUNC_SUFFIX}`;
|
|
391
|
+
return [truncated, true, s.length];
|
|
392
|
+
}
|
|
393
|
+
return [s, false, undefined];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Deep merge source into target. Nested objects merge recursively,
|
|
398
|
+
* arrays are replaced (not concatenated), primitives overwrite.
|
|
399
|
+
*/
|
|
400
|
+
function deepMerge(
|
|
401
|
+
target: Record<string, unknown>,
|
|
402
|
+
source: Record<string, unknown>,
|
|
403
|
+
): Record<string, unknown> {
|
|
404
|
+
const result: Record<string, unknown> = { ...target };
|
|
405
|
+
for (const key of Object.keys(source)) {
|
|
406
|
+
const srcVal = source[key];
|
|
407
|
+
const tgtVal = result[key];
|
|
408
|
+
if (
|
|
409
|
+
srcVal !== null &&
|
|
410
|
+
typeof srcVal === "object" &&
|
|
411
|
+
!Array.isArray(srcVal) &&
|
|
412
|
+
tgtVal !== null &&
|
|
413
|
+
typeof tgtVal === "object" &&
|
|
414
|
+
!Array.isArray(tgtVal)
|
|
415
|
+
) {
|
|
416
|
+
result[key] = deepMerge(tgtVal as Record<string, unknown>, srcVal as Record<string, unknown>);
|
|
417
|
+
} else {
|
|
418
|
+
result[key] = srcVal;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return result;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Truncate artifacts array so JSON.stringify fits within MAX_RESULT_BYTES.
|
|
426
|
+
* Keeps first N elements that fit. Returns [truncatedArray, wasTruncated].
|
|
427
|
+
*/
|
|
428
|
+
function truncateArtifacts(artifacts: string[]): [string[], boolean] {
|
|
429
|
+
const fullJson = JSON.stringify(artifacts);
|
|
430
|
+
if (fullJson.length <= MAX_RESULT_BYTES) return [artifacts, false];
|
|
431
|
+
const truncated: string[] = [];
|
|
432
|
+
for (const item of artifacts) {
|
|
433
|
+
const candidate = [...truncated, item];
|
|
434
|
+
if (JSON.stringify(candidate).length > MAX_RESULT_BYTES) break;
|
|
435
|
+
truncated.push(item);
|
|
436
|
+
}
|
|
437
|
+
return [truncated, true];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function updateTaskStatus(
|
|
441
|
+
db: Database,
|
|
442
|
+
id: string,
|
|
443
|
+
status: TaskStatus,
|
|
444
|
+
fields?: {
|
|
445
|
+
result?: string;
|
|
446
|
+
error?: string;
|
|
447
|
+
artifacts?: string[];
|
|
448
|
+
metadataPatch?: Record<string, unknown>;
|
|
449
|
+
reviewedBy?: string;
|
|
450
|
+
reviewedVerdict?: string;
|
|
451
|
+
},
|
|
452
|
+
updatedBy?: string,
|
|
453
|
+
ctx?: { agent?: string; sessionId?: string },
|
|
454
|
+
): TaskUpdateResult {
|
|
455
|
+
const now = Date.now();
|
|
456
|
+
const setClauses: string[] = ["status = ?"];
|
|
457
|
+
const params: SQLQueryBindings[] = [status];
|
|
458
|
+
|
|
459
|
+
if (status === "running") {
|
|
460
|
+
setClauses.push("started_at = ?");
|
|
461
|
+
params.push(now);
|
|
462
|
+
|
|
463
|
+
// Issue 2: write-once executed_by_agent/session on plan when task starts
|
|
464
|
+
if (ctx?.agent) {
|
|
465
|
+
const task = db.query("SELECT plan_id FROM plan_tasks WHERE id = ?").get(id) as
|
|
466
|
+
| { plan_id: string }
|
|
467
|
+
| undefined;
|
|
468
|
+
if (task?.plan_id) {
|
|
469
|
+
// HIGH 3: ensure session exists before FK write
|
|
470
|
+
if (ctx.sessionId) {
|
|
471
|
+
ensureSession(db, ctx.sessionId, "auto-created for task execution", ctx.agent);
|
|
472
|
+
}
|
|
473
|
+
setExecutedByOnce(db, task.plan_id, ctx.agent, ctx.sessionId ?? null);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (status === "done" || status === "failed") {
|
|
478
|
+
setClauses.push("completed_at = ?");
|
|
479
|
+
params.push(now);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// M6: truncate result/error to 16 KB max with warning + metadata
|
|
483
|
+
const truncationInfo: TaskTruncationInfo = { truncated: false };
|
|
484
|
+
const fieldDefs: Array<[string, string | undefined]> = [
|
|
485
|
+
["result", fields?.result],
|
|
486
|
+
["error", fields?.error],
|
|
487
|
+
];
|
|
488
|
+
for (const [col, val] of fieldDefs) {
|
|
489
|
+
if (val !== undefined) {
|
|
490
|
+
const [truncated, wasTruncated, originalLength] = truncWithInfo(val);
|
|
491
|
+
if (wasTruncated) {
|
|
492
|
+
truncationInfo.truncated = true;
|
|
493
|
+
if (originalLength !== undefined) truncationInfo.originalLength = originalLength;
|
|
494
|
+
truncationInfo.truncatedLength = MAX_RESULT_BYTES;
|
|
495
|
+
console.warn(
|
|
496
|
+
`ndomo: task_update_status ${id} — ${col} truncated from ${originalLength} to ${MAX_RESULT_BYTES} bytes`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
setClauses.push(`${col} = ?`);
|
|
500
|
+
params.push(truncated as SQLQueryBindings);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// T1: artifacts — JSON.stringify, truncate array if >16KB
|
|
505
|
+
if (fields?.artifacts !== undefined) {
|
|
506
|
+
const [truncatedArr, wasTruncated] = truncateArtifacts(fields.artifacts);
|
|
507
|
+
if (wasTruncated) {
|
|
508
|
+
truncationInfo.truncated = true;
|
|
509
|
+
truncationInfo.originalLength = JSON.stringify(fields.artifacts).length;
|
|
510
|
+
truncationInfo.truncatedLength = MAX_RESULT_BYTES;
|
|
511
|
+
console.warn(
|
|
512
|
+
`ndomo: task_update_status ${id} — artifacts truncated from ${truncationInfo.originalLength} to ${MAX_RESULT_BYTES} bytes`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
setClauses.push("artifacts = ?");
|
|
516
|
+
params.push(JSON.stringify(truncatedArr));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// T1: reviewed_by column
|
|
520
|
+
if (fields?.reviewedBy !== undefined) {
|
|
521
|
+
setClauses.push("reviewed_by = ?");
|
|
522
|
+
params.push(fields.reviewedBy);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// T1: metadataPatch (deep merge) + reviewedVerdict (stored in metadata)
|
|
526
|
+
if (fields?.metadataPatch !== undefined || fields?.reviewedVerdict !== undefined) {
|
|
527
|
+
const currentRow = db.query("SELECT metadata FROM plan_tasks WHERE id = ?").get(id) as
|
|
528
|
+
| { metadata: string | null }
|
|
529
|
+
| undefined;
|
|
530
|
+
const currentMetadata: Record<string, unknown> = currentRow?.metadata
|
|
531
|
+
? JSON.parse(currentRow.metadata)
|
|
532
|
+
: {};
|
|
533
|
+
const patch: Record<string, unknown> = { ...(fields.metadataPatch ?? {}) };
|
|
534
|
+
if (fields?.reviewedVerdict !== undefined) {
|
|
535
|
+
patch.reviewedVerdict = fields.reviewedVerdict;
|
|
536
|
+
}
|
|
537
|
+
const merged = deepMerge(currentMetadata, patch);
|
|
538
|
+
setClauses.push("metadata = ?");
|
|
539
|
+
params.push(JSON.stringify(merged));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (updatedBy !== undefined) {
|
|
543
|
+
setClauses.push("updated_by = ?");
|
|
544
|
+
params.push(updatedBy);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
params.push(id);
|
|
548
|
+
db.query(`UPDATE plan_tasks SET ${setClauses.join(", ")} WHERE id = ?`).run(...params);
|
|
549
|
+
const task = getTask(db, id);
|
|
550
|
+
if (!task) return null;
|
|
551
|
+
return { ...task, truncation: truncationInfo };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function searchTasks(
|
|
555
|
+
db: Database,
|
|
556
|
+
query: string,
|
|
557
|
+
limit = 20,
|
|
558
|
+
opts: { includeArchived?: boolean } = {},
|
|
559
|
+
): PlanTask[] {
|
|
560
|
+
const archiveFilter = opts.includeArchived ? "" : "AND t.archived_at IS NULL";
|
|
561
|
+
const rows = db
|
|
562
|
+
.query(
|
|
563
|
+
`SELECT t.* FROM plan_tasks t
|
|
564
|
+
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
|
565
|
+
WHERE tasks_fts MATCH ? ${archiveFilter}
|
|
566
|
+
ORDER BY rank
|
|
567
|
+
LIMIT ?`,
|
|
568
|
+
)
|
|
569
|
+
.all(escapeFtsQuery(query), limit);
|
|
570
|
+
return (rows as unknown[]).map(taskFromRow);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Resolve a task's dependencies: classify each dep ID by its current status.
|
|
575
|
+
* Throws if taskId is not found in the database.
|
|
576
|
+
*
|
|
577
|
+
* @returns Object with `canStart` (true iff all deps are 'done') and
|
|
578
|
+
* arrays categorizing each dep by status.
|
|
579
|
+
*/
|
|
580
|
+
export function resolveTaskDependencies(
|
|
581
|
+
db: Database,
|
|
582
|
+
taskId: string,
|
|
583
|
+
): {
|
|
584
|
+
canStart: boolean;
|
|
585
|
+
pendingDeps: string[];
|
|
586
|
+
runningDeps: string[];
|
|
587
|
+
failedDeps: string[];
|
|
588
|
+
blockedDeps: string[];
|
|
589
|
+
doneDeps: string[];
|
|
590
|
+
missingDeps: string[];
|
|
591
|
+
dependencies: string[];
|
|
592
|
+
} {
|
|
593
|
+
const row = db
|
|
594
|
+
.query("SELECT dependencies FROM plan_tasks WHERE id = ?")
|
|
595
|
+
.get(taskId) as { dependencies: string } | undefined;
|
|
596
|
+
if (!row) throw new Error(`ndomo: task ${taskId} not found`);
|
|
597
|
+
|
|
598
|
+
const dependencies: string[] = (JSON.parse(row.dependencies) as string[]) ?? [];
|
|
599
|
+
if (dependencies.length === 0) {
|
|
600
|
+
return {
|
|
601
|
+
canStart: true,
|
|
602
|
+
pendingDeps: [],
|
|
603
|
+
runningDeps: [],
|
|
604
|
+
failedDeps: [],
|
|
605
|
+
blockedDeps: [],
|
|
606
|
+
doneDeps: [],
|
|
607
|
+
missingDeps: [],
|
|
608
|
+
dependencies,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const pendingDeps: string[] = [];
|
|
613
|
+
const runningDeps: string[] = [];
|
|
614
|
+
const failedDeps: string[] = [];
|
|
615
|
+
const blockedDeps: string[] = [];
|
|
616
|
+
const doneDeps: string[] = [];
|
|
617
|
+
const missingDeps: string[] = [];
|
|
618
|
+
|
|
619
|
+
// Batch-fetch all dep statuses in one query
|
|
620
|
+
const placeholders = dependencies.map(() => "?").join(",");
|
|
621
|
+
const depRows = db
|
|
622
|
+
.query(`SELECT id, status FROM plan_tasks WHERE id IN (${placeholders})`)
|
|
623
|
+
.all(...dependencies) as Array<{ id: string; status: string }>;
|
|
624
|
+
|
|
625
|
+
const statusMap = new Map<string, string>();
|
|
626
|
+
for (const dr of depRows) {
|
|
627
|
+
statusMap.set(dr.id, dr.status);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
for (const depId of dependencies) {
|
|
631
|
+
const st = statusMap.get(depId);
|
|
632
|
+
if (st === undefined) {
|
|
633
|
+
missingDeps.push(depId);
|
|
634
|
+
} else if (st === "done") {
|
|
635
|
+
doneDeps.push(depId);
|
|
636
|
+
} else if (st === "pending") {
|
|
637
|
+
pendingDeps.push(depId);
|
|
638
|
+
} else if (st === "running") {
|
|
639
|
+
runningDeps.push(depId);
|
|
640
|
+
} else if (st === "failed") {
|
|
641
|
+
failedDeps.push(depId);
|
|
642
|
+
} else if (st === "blocked") {
|
|
643
|
+
blockedDeps.push(depId);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const canStart =
|
|
648
|
+
doneDeps.length === dependencies.length;
|
|
649
|
+
|
|
650
|
+
return {
|
|
651
|
+
canStart,
|
|
652
|
+
pendingDeps,
|
|
653
|
+
runningDeps,
|
|
654
|
+
failedDeps,
|
|
655
|
+
blockedDeps,
|
|
656
|
+
doneDeps,
|
|
657
|
+
missingDeps,
|
|
658
|
+
dependencies,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Atomically claim next pending task for agent.
|
|
664
|
+
* Uses transaction (SELECT + UPDATE) to prevent race condition.
|
|
665
|
+
* SQLite transactions are SERIALIZABLE — no concurrent claim possible.
|
|
666
|
+
*
|
|
667
|
+
* Respects task dependencies: a pending task is only claimed if all
|
|
668
|
+
* entries in its `dependencies` JSON array have status='done'.
|
|
669
|
+
*/
|
|
670
|
+
export function nextTaskForAgent(
|
|
671
|
+
db: Database,
|
|
672
|
+
agent: string,
|
|
673
|
+
opts: { planId?: string; includeArchived?: boolean } = {},
|
|
674
|
+
): PlanTask | null {
|
|
675
|
+
const now = Date.now();
|
|
676
|
+
const archiveFilter = opts.includeArchived ? "" : "AND archived_at IS NULL";
|
|
677
|
+
|
|
678
|
+
return db.transaction(() => {
|
|
679
|
+
// Fetch candidate pending tasks (cap at 100 for efficiency)
|
|
680
|
+
const rows =
|
|
681
|
+
opts.planId !== undefined
|
|
682
|
+
? (db
|
|
683
|
+
.query(
|
|
684
|
+
`SELECT * FROM plan_tasks
|
|
685
|
+
WHERE agent = ? AND plan_id = ? AND status = 'pending' ${archiveFilter}
|
|
686
|
+
ORDER BY order_index LIMIT 100`,
|
|
687
|
+
)
|
|
688
|
+
.all(agent, opts.planId) as unknown[])
|
|
689
|
+
: (db
|
|
690
|
+
.query(
|
|
691
|
+
`SELECT * FROM plan_tasks
|
|
692
|
+
WHERE agent = ? AND status = 'pending' ${archiveFilter}
|
|
693
|
+
ORDER BY order_index LIMIT 100`,
|
|
694
|
+
)
|
|
695
|
+
.all(agent) as unknown[]);
|
|
696
|
+
|
|
697
|
+
for (const row of rows) {
|
|
698
|
+
const task = taskFromRow(row);
|
|
699
|
+
|
|
700
|
+
// Check dependencies: all must be 'done'
|
|
701
|
+
if (task.dependencies.length > 0) {
|
|
702
|
+
const placeholders = task.dependencies.map(() => "?").join(",");
|
|
703
|
+
const depRows = db
|
|
704
|
+
.query(`SELECT id, status FROM plan_tasks WHERE id IN (${placeholders})`)
|
|
705
|
+
.all(...task.dependencies) as Array<{ id: string; status: string }>;
|
|
706
|
+
|
|
707
|
+
const statusMap = new Map<string, string>();
|
|
708
|
+
for (const dr of depRows) {
|
|
709
|
+
statusMap.set(dr.id, dr.status);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const allDone = task.dependencies.every((depId) => statusMap.get(depId) === "done");
|
|
713
|
+
if (!allDone) continue; // skip — deps not met
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Claim this task
|
|
717
|
+
db.query(
|
|
718
|
+
`UPDATE plan_tasks SET status = 'running', started_at = ?, updated_by = ? WHERE id = ?`,
|
|
719
|
+
).run(now, agent, task.id);
|
|
720
|
+
return { ...task, status: "running" as const, startedAt: now, updatedBy: agent };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return null;
|
|
724
|
+
})();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ─── Tag helpers ─────────────────────────────────────────────────────────────
|
|
728
|
+
|
|
729
|
+
export function addTaskTag(db: Database, taskId: string, tag: string, addedBy: string): void {
|
|
730
|
+
db.query(
|
|
731
|
+
"INSERT OR IGNORE INTO task_tags (task_id, tag, added_by, added_at) VALUES (?, ?, ?, ?)",
|
|
732
|
+
).run(taskId, tag, addedBy, Date.now());
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export function removeTaskTag(db: Database, taskId: string, tag: string): void {
|
|
736
|
+
db.query("DELETE FROM task_tags WHERE task_id = ? AND tag = ?").run(taskId, tag);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export function getTaskTags(
|
|
740
|
+
db: Database,
|
|
741
|
+
taskId: string,
|
|
742
|
+
): Array<{ tag: string; addedBy: string; addedAt: number }> {
|
|
743
|
+
const rows = db
|
|
744
|
+
.query("SELECT tag, added_by, added_at FROM task_tags WHERE task_id = ? ORDER BY tag")
|
|
745
|
+
.all(taskId) as Array<{ tag: string; added_by: string; added_at: number }>;
|
|
746
|
+
return rows.map((r) => ({ tag: r.tag, addedBy: r.added_by, addedAt: r.added_at }));
|
|
747
|
+
}
|