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
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for task CRUD, executed_by write-once, and plan_files insertion.
|
|
3
|
+
*
|
|
4
|
+
* Uses in-memory SQLite via bun:sqlite. Each test gets a fresh DB
|
|
5
|
+
* with the full schema applied by runMigrations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Database } from "bun:sqlite";
|
|
9
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { runMigrations } from "./migrations.ts";
|
|
11
|
+
import { createPlan, getPlan } from "./plans.ts";
|
|
12
|
+
import { getSession } from "./sessions.ts";
|
|
13
|
+
import {
|
|
14
|
+
createTasksBatch,
|
|
15
|
+
getTask,
|
|
16
|
+
listTasksByPlan,
|
|
17
|
+
nextTaskForAgent,
|
|
18
|
+
splitFilesByStack,
|
|
19
|
+
updateTaskStatus,
|
|
20
|
+
} from "./tasks.ts";
|
|
21
|
+
import type { Plan } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
let db: Database;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
db = new Database(":memory:");
|
|
27
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
28
|
+
runMigrations(db);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function makePlan(overrides: Partial<Parameters<typeof createPlan>[1]> = {}): Plan {
|
|
32
|
+
return createPlan(db, {
|
|
33
|
+
id: crypto.randomUUID(),
|
|
34
|
+
slug: "test-plan",
|
|
35
|
+
title: "Test",
|
|
36
|
+
status: "draft",
|
|
37
|
+
priority: 2,
|
|
38
|
+
approvedAt: null,
|
|
39
|
+
completedAt: null,
|
|
40
|
+
sessionId: null,
|
|
41
|
+
overview: "test",
|
|
42
|
+
approach: null,
|
|
43
|
+
complexity: 3,
|
|
44
|
+
createdBy: "test",
|
|
45
|
+
updatedBy: "test",
|
|
46
|
+
sourceSessionId: null,
|
|
47
|
+
sourceMessageId: null,
|
|
48
|
+
category: null,
|
|
49
|
+
metadata: {},
|
|
50
|
+
archivedAt: null,
|
|
51
|
+
...overrides,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeTask(overrides: Record<string, unknown> = {}) {
|
|
56
|
+
return {
|
|
57
|
+
orderIndex: 0,
|
|
58
|
+
description: "test task",
|
|
59
|
+
agent: "js-smith",
|
|
60
|
+
files: [] as string[],
|
|
61
|
+
complexity: 1,
|
|
62
|
+
dependencies: [] as string[],
|
|
63
|
+
createdBy: "foreman",
|
|
64
|
+
updatedBy: "foreman",
|
|
65
|
+
sourceSessionId: null as string | null,
|
|
66
|
+
sourceMessageId: null as string | null,
|
|
67
|
+
reviewedBy: null as string | null,
|
|
68
|
+
tokensUsed: null as number | null,
|
|
69
|
+
durationMs: null as number | null,
|
|
70
|
+
artifacts: [] as string[],
|
|
71
|
+
metadata: {},
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Issue 2: executed_by write-once ─────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
describe("updateTaskStatus — executed_by write-once", () => {
|
|
79
|
+
test("transition to 'running' sets plan.executed_by_agent", () => {
|
|
80
|
+
const plan = makePlan();
|
|
81
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask()]);
|
|
82
|
+
const taskId = tasks[0]?.id as string;
|
|
83
|
+
|
|
84
|
+
updateTaskStatus(db, taskId, "running", undefined, "js-smith", {
|
|
85
|
+
agent: "js-smith",
|
|
86
|
+
sessionId: "ses_123",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const updated = getPlan(db, plan.id);
|
|
90
|
+
expect(updated?.executedByAgent).toBe("js-smith");
|
|
91
|
+
expect(updated?.executedBySession).toBe("ses_123");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("two 'running' transitions — no overwrite (write-once)", () => {
|
|
95
|
+
const plan = makePlan();
|
|
96
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask(), makeTask({ orderIndex: 1 })]);
|
|
97
|
+
|
|
98
|
+
// First task starts
|
|
99
|
+
updateTaskStatus(db, tasks[0]?.id as string, "running", undefined, "agent-A", {
|
|
100
|
+
agent: "agent-A",
|
|
101
|
+
sessionId: "ses_A",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Second task starts — should NOT overwrite
|
|
105
|
+
updateTaskStatus(db, tasks[1]?.id as string, "running", undefined, "agent-B", {
|
|
106
|
+
agent: "agent-B",
|
|
107
|
+
sessionId: "ses_B",
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const updated = getPlan(db, plan.id);
|
|
111
|
+
expect(updated?.executedByAgent).toBe("agent-A");
|
|
112
|
+
expect(updated?.executedBySession).toBe("ses_A");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("transition to 'done' does NOT change plan.executed_by_agent", () => {
|
|
116
|
+
const plan = makePlan();
|
|
117
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask()]);
|
|
118
|
+
|
|
119
|
+
// First set running
|
|
120
|
+
updateTaskStatus(db, tasks[0]?.id as string, "running", undefined, "js-smith", {
|
|
121
|
+
agent: "js-smith",
|
|
122
|
+
sessionId: "ses_123",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Then set done
|
|
126
|
+
updateTaskStatus(db, tasks[0]?.id as string, "done", { result: "ok" }, "js-smith", {
|
|
127
|
+
agent: "js-smith",
|
|
128
|
+
sessionId: "ses_123",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const updated = getPlan(db, plan.id);
|
|
132
|
+
expect(updated?.executedByAgent).toBe("js-smith"); // unchanged from running
|
|
133
|
+
expect(updated?.executedBySession).toBe("ses_123");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("no ctx provided — no executed_by write", () => {
|
|
137
|
+
const plan = makePlan();
|
|
138
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask()]);
|
|
139
|
+
|
|
140
|
+
updateTaskStatus(db, tasks[0]?.id as string, "running", undefined, "js-smith");
|
|
141
|
+
|
|
142
|
+
const updated = getPlan(db, plan.id);
|
|
143
|
+
expect(updated?.executedByAgent).toBeNull();
|
|
144
|
+
expect(updated?.executedBySession).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ─── Issue 4: createTasksBatch plan_files insertion ──────────────────────────
|
|
149
|
+
|
|
150
|
+
describe("createTasksBatch — plan_files insertion", () => {
|
|
151
|
+
test("task.files inserts rows with role='modified'", () => {
|
|
152
|
+
const plan = makePlan();
|
|
153
|
+
createTasksBatch(db, plan.id, [makeTask({ files: ["src/a.ts", "src/b.ts"] })]);
|
|
154
|
+
|
|
155
|
+
const rows = db
|
|
156
|
+
.query("SELECT * FROM plan_files WHERE plan_id = ? ORDER BY file_path")
|
|
157
|
+
.all(plan.id) as Array<{ plan_id: string; file_path: string; role: string }>;
|
|
158
|
+
|
|
159
|
+
expect(rows).toHaveLength(2);
|
|
160
|
+
expect(rows[0]?.file_path).toBe("src/a.ts");
|
|
161
|
+
expect(rows[0]?.role).toBe("modified");
|
|
162
|
+
expect(rows[1]?.file_path).toBe("src/b.ts");
|
|
163
|
+
expect(rows[1]?.role).toBe("modified");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("duplicate files across tasks — no break (INSERT OR IGNORE)", () => {
|
|
167
|
+
const plan = makePlan();
|
|
168
|
+
createTasksBatch(db, plan.id, [
|
|
169
|
+
makeTask({ files: ["src/shared.ts"], description: "task A" }),
|
|
170
|
+
makeTask({ orderIndex: 1, files: ["src/shared.ts", "src/other.ts"], description: "task B" }),
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
const rows = db.query("SELECT * FROM plan_files WHERE plan_id = ?").all(plan.id) as Array<{
|
|
174
|
+
file_path: string;
|
|
175
|
+
}>;
|
|
176
|
+
|
|
177
|
+
// shared.ts appears once (PK dedup), other.ts once = 2 total
|
|
178
|
+
expect(rows).toHaveLength(2);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("no files — 0 rows in plan_files", () => {
|
|
182
|
+
const plan = makePlan();
|
|
183
|
+
createTasksBatch(db, plan.id, [makeTask()]);
|
|
184
|
+
|
|
185
|
+
const rows = db.query("SELECT * FROM plan_files WHERE plan_id = ?").all(plan.id);
|
|
186
|
+
|
|
187
|
+
expect(rows).toHaveLength(0);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ─── High 3: ensureSession before setExecutedByOnce ─────────────────────────
|
|
192
|
+
|
|
193
|
+
describe("updateTaskStatus — ensureSession FK safety", () => {
|
|
194
|
+
test("running with non-existent sessionId creates session automatically", () => {
|
|
195
|
+
const plan = makePlan();
|
|
196
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask()]);
|
|
197
|
+
const taskId = tasks[0]?.id as string;
|
|
198
|
+
const sessionId = `ses_${crypto.randomUUID()}`;
|
|
199
|
+
|
|
200
|
+
// Session should NOT exist yet
|
|
201
|
+
expect(getSession(db, sessionId)).toBeNull();
|
|
202
|
+
|
|
203
|
+
updateTaskStatus(db, taskId, "running", undefined, "js-smith", {
|
|
204
|
+
agent: "js-smith",
|
|
205
|
+
sessionId,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Session should now exist (auto-created by ensureSession)
|
|
209
|
+
const session = getSession(db, sessionId);
|
|
210
|
+
expect(session).not.toBeNull();
|
|
211
|
+
expect(session?.id).toBe(sessionId);
|
|
212
|
+
|
|
213
|
+
// Plan executed_by should also be set
|
|
214
|
+
const updated = getPlan(db, plan.id);
|
|
215
|
+
expect(updated?.executedByAgent).toBe("js-smith");
|
|
216
|
+
expect(updated?.executedBySession).toBe(sessionId);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ─── P3: nextTaskForAgent atomic claim (race condition fix) ─────────────────
|
|
221
|
+
|
|
222
|
+
describe("nextTaskForAgent — atomic claim", () => {
|
|
223
|
+
test("returns pending task and sets status=running atomically", () => {
|
|
224
|
+
const plan = makePlan();
|
|
225
|
+
createTasksBatch(db, plan.id, [
|
|
226
|
+
makeTask({ orderIndex: 0, agent: "js-smith" }),
|
|
227
|
+
makeTask({ orderIndex: 1, agent: "js-smith" }),
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
const task = nextTaskForAgent(db, "js-smith", { planId: plan.id });
|
|
231
|
+
expect(task).not.toBeNull();
|
|
232
|
+
expect(task?.status).toBe("running");
|
|
233
|
+
expect(task?.startedAt).toBeGreaterThan(0);
|
|
234
|
+
expect(task?.orderIndex).toBe(0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("second call claims next pending task (not already-running)", () => {
|
|
238
|
+
const plan = makePlan();
|
|
239
|
+
createTasksBatch(db, plan.id, [
|
|
240
|
+
makeTask({ orderIndex: 0, agent: "js-smith", description: "task A" }),
|
|
241
|
+
makeTask({ orderIndex: 1, agent: "js-smith", description: "task B" }),
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
const first = nextTaskForAgent(db, "js-smith", { planId: plan.id });
|
|
245
|
+
const second = nextTaskForAgent(db, "js-smith", { planId: plan.id });
|
|
246
|
+
|
|
247
|
+
expect(first).not.toBeNull();
|
|
248
|
+
expect(second).not.toBeNull();
|
|
249
|
+
expect(first?.id).not.toBe(second?.id);
|
|
250
|
+
expect(first?.orderIndex).toBe(0);
|
|
251
|
+
expect(second?.orderIndex).toBe(1);
|
|
252
|
+
expect(second?.status).toBe("running");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("returns null when no pending tasks for agent", () => {
|
|
256
|
+
const plan = makePlan();
|
|
257
|
+
createTasksBatch(db, plan.id, [makeTask({ agent: "other-agent" })]);
|
|
258
|
+
|
|
259
|
+
const task = nextTaskForAgent(db, "js-smith", { planId: plan.id });
|
|
260
|
+
expect(task).toBeNull();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("claims across plans when planId omitted", () => {
|
|
264
|
+
const plan1 = makePlan({ slug: "plan-1" });
|
|
265
|
+
const plan2 = makePlan({ slug: "plan-2" });
|
|
266
|
+
createTasksBatch(db, plan1.id, [makeTask({ agent: "js-smith" })]);
|
|
267
|
+
createTasksBatch(db, plan2.id, [makeTask({ agent: "js-smith" })]);
|
|
268
|
+
|
|
269
|
+
const task = nextTaskForAgent(db, "js-smith");
|
|
270
|
+
expect(task).not.toBeNull();
|
|
271
|
+
expect(task?.status).toBe("running");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("skips archived tasks", () => {
|
|
275
|
+
const plan = makePlan();
|
|
276
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask({ agent: "js-smith" })]);
|
|
277
|
+
|
|
278
|
+
// Archive the task
|
|
279
|
+
const archivedTask = tasks[0];
|
|
280
|
+
if (!archivedTask) throw new Error("test setup: expected at least one task");
|
|
281
|
+
db.query("UPDATE plan_tasks SET archived_at = ? WHERE id = ?").run(Date.now(), archivedTask.id);
|
|
282
|
+
|
|
283
|
+
const task = nextTaskForAgent(db, "js-smith", { planId: plan.id });
|
|
284
|
+
expect(task).toBeNull();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ─── F1: createTasksBatch no-overlap pre-dispatch ───────────────────────────
|
|
289
|
+
|
|
290
|
+
describe("createTasksBatch — no-overlap pre-dispatch", () => {
|
|
291
|
+
test("skips task with same (agent, description) already exists", () => {
|
|
292
|
+
const plan = makePlan();
|
|
293
|
+
const first = createTasksBatch(db, plan.id, [
|
|
294
|
+
makeTask({ agent: "js-smith", description: "build auth module" }),
|
|
295
|
+
]);
|
|
296
|
+
expect(first).toHaveLength(1);
|
|
297
|
+
|
|
298
|
+
// Attempt duplicate — should be skipped
|
|
299
|
+
const second = createTasksBatch(db, plan.id, [
|
|
300
|
+
makeTask({ agent: "js-smith", description: "build auth module" }),
|
|
301
|
+
]);
|
|
302
|
+
expect(second).toHaveLength(0);
|
|
303
|
+
|
|
304
|
+
// Verify only one task in DB
|
|
305
|
+
const rows = db
|
|
306
|
+
.query("SELECT * FROM plan_tasks WHERE plan_id = ? AND archived_at IS NULL")
|
|
307
|
+
.all(plan.id);
|
|
308
|
+
expect(rows).toHaveLength(1);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("different agent same description — allowed", () => {
|
|
312
|
+
const plan = makePlan();
|
|
313
|
+
createTasksBatch(db, plan.id, [
|
|
314
|
+
makeTask({ agent: "js-smith", description: "build auth module" }),
|
|
315
|
+
]);
|
|
316
|
+
|
|
317
|
+
const result = createTasksBatch(db, plan.id, [
|
|
318
|
+
makeTask({ agent: "reviewer", description: "build auth module", orderIndex: 1 }),
|
|
319
|
+
]);
|
|
320
|
+
expect(result).toHaveLength(1);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("same agent different description — allowed", () => {
|
|
324
|
+
const plan = makePlan();
|
|
325
|
+
createTasksBatch(db, plan.id, [
|
|
326
|
+
makeTask({ agent: "js-smith", description: "build auth module" }),
|
|
327
|
+
]);
|
|
328
|
+
|
|
329
|
+
const result = createTasksBatch(db, plan.id, [
|
|
330
|
+
makeTask({ agent: "js-smith", description: "build auth tests", orderIndex: 1 }),
|
|
331
|
+
]);
|
|
332
|
+
expect(result).toHaveLength(1);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("in-batch duplicates also skipped", () => {
|
|
336
|
+
const plan = makePlan();
|
|
337
|
+
const result = createTasksBatch(db, plan.id, [
|
|
338
|
+
makeTask({ agent: "js-smith", description: "build auth module", orderIndex: 0 }),
|
|
339
|
+
makeTask({ agent: "js-smith", description: "build auth module", orderIndex: 1 }),
|
|
340
|
+
]);
|
|
341
|
+
// First insert, second skipped
|
|
342
|
+
expect(result).toHaveLength(1);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("overlap check includes non-archived tasks only", () => {
|
|
346
|
+
const plan = makePlan();
|
|
347
|
+
const first = createTasksBatch(db, plan.id, [
|
|
348
|
+
makeTask({ agent: "js-smith", description: "build auth module" }),
|
|
349
|
+
]);
|
|
350
|
+
|
|
351
|
+
// Archive it
|
|
352
|
+
const archivedFirst = first[0];
|
|
353
|
+
if (!archivedFirst) throw new Error("test setup: expected at least one task");
|
|
354
|
+
db.query("UPDATE plan_tasks SET archived_at = ? WHERE id = ?").run(
|
|
355
|
+
Date.now(),
|
|
356
|
+
archivedFirst.id,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// Re-create same task — should succeed (archived doesn't block)
|
|
360
|
+
// Use different orderIndex since archived row still occupies (plan_id, order_index)
|
|
361
|
+
const second = createTasksBatch(db, plan.id, [
|
|
362
|
+
makeTask({ agent: "js-smith", description: "build auth module", orderIndex: 1 }),
|
|
363
|
+
]);
|
|
364
|
+
expect(second).toHaveLength(1);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ─── M5: original_plan_data snapshot completeness ───────────────────────────
|
|
369
|
+
|
|
370
|
+
describe("createTasksBatch — original_plan_data snapshot (M5)", () => {
|
|
371
|
+
test("snapshot includes files", () => {
|
|
372
|
+
const plan = makePlan();
|
|
373
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask({ files: ["src/a.ts", "src/b.ts"] })]);
|
|
374
|
+
const snapshot = JSON.parse(tasks[0]?.originalPlanData ?? "{}");
|
|
375
|
+
|
|
376
|
+
expect(snapshot.files).toEqual(["src/a.ts", "src/b.ts"]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("snapshot includes metadata", () => {
|
|
380
|
+
const plan = makePlan();
|
|
381
|
+
const tasks = createTasksBatch(db, plan.id, [
|
|
382
|
+
makeTask({ metadata: { reviewedBy: "chronicler", tokensUsed: 1500 } }),
|
|
383
|
+
]);
|
|
384
|
+
const snapshot = JSON.parse(tasks[0]?.originalPlanData ?? "{}");
|
|
385
|
+
|
|
386
|
+
expect(snapshot.metadata).toEqual({ reviewedBy: "chronicler", tokensUsed: 1500 });
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("snapshot includes dependencies", () => {
|
|
390
|
+
const plan = makePlan();
|
|
391
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask({ dependencies: ["dep-1", "dep-2"] })]);
|
|
392
|
+
const snapshot = JSON.parse(tasks[0]?.originalPlanData ?? "{}");
|
|
393
|
+
|
|
394
|
+
expect(snapshot.dependencies).toEqual(["dep-1", "dep-2"]);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("snapshot includes empty arrays/objects when fields omitted", () => {
|
|
398
|
+
const plan = makePlan();
|
|
399
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask()]);
|
|
400
|
+
const snapshot = JSON.parse(tasks[0]?.originalPlanData ?? "{}");
|
|
401
|
+
|
|
402
|
+
expect(snapshot.files).toEqual([]);
|
|
403
|
+
expect(snapshot.dependencies).toEqual([]);
|
|
404
|
+
expect(snapshot.metadata).toEqual({});
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ─── M6: truncation warning + return metadata ───────────────────────────────
|
|
409
|
+
|
|
410
|
+
describe("updateTaskStatus — truncation metadata (M6)", () => {
|
|
411
|
+
test("result > 16KB → truncated:true with correct lengths", () => {
|
|
412
|
+
const plan = makePlan();
|
|
413
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask()]);
|
|
414
|
+
const taskId = tasks[0]?.id as string;
|
|
415
|
+
const bigResult = "x".repeat(17 * 1024); // 17 KB
|
|
416
|
+
|
|
417
|
+
const originalWarn = console.warn;
|
|
418
|
+
const warnCalls: unknown[][] = [];
|
|
419
|
+
console.warn = (...args: unknown[]) => warnCalls.push(args);
|
|
420
|
+
|
|
421
|
+
const result = updateTaskStatus(db, taskId, "done", { result: bigResult }, "test");
|
|
422
|
+
|
|
423
|
+
expect(result?.truncation.truncated).toBe(true);
|
|
424
|
+
expect(result?.truncation.originalLength).toBe(17 * 1024);
|
|
425
|
+
expect(result?.truncation.truncatedLength).toBe(16 * 1024);
|
|
426
|
+
expect(result?.result).toContain("…[truncated]");
|
|
427
|
+
expect(warnCalls.length).toBeGreaterThan(0);
|
|
428
|
+
expect(String(warnCalls[0]?.[0])).toContain(`task_update_status ${taskId}`);
|
|
429
|
+
|
|
430
|
+
console.warn = originalWarn;
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("result exactly 16KB → truncated:false", () => {
|
|
434
|
+
const plan = makePlan();
|
|
435
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask()]);
|
|
436
|
+
const taskId = tasks[0]?.id as string;
|
|
437
|
+
const exactResult = "y".repeat(16 * 1024); // exactly 16 KB
|
|
438
|
+
|
|
439
|
+
const result = updateTaskStatus(db, taskId, "done", { result: exactResult }, "test");
|
|
440
|
+
|
|
441
|
+
expect(result?.truncation.truncated).toBe(false);
|
|
442
|
+
expect(result?.truncation.originalLength).toBeUndefined();
|
|
443
|
+
expect(result?.result).toBe(exactResult);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("error field also truncated with same behavior", () => {
|
|
447
|
+
const plan = makePlan();
|
|
448
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask()]);
|
|
449
|
+
const taskId = tasks[0]?.id as string;
|
|
450
|
+
const bigError = "e".repeat(20 * 1024); // 20 KB
|
|
451
|
+
|
|
452
|
+
const originalWarn = console.warn;
|
|
453
|
+
console.warn = () => {};
|
|
454
|
+
|
|
455
|
+
const result = updateTaskStatus(db, taskId, "failed", { error: bigError }, "test");
|
|
456
|
+
|
|
457
|
+
expect(result?.truncation.truncated).toBe(true);
|
|
458
|
+
expect(result?.truncation.originalLength).toBe(20 * 1024);
|
|
459
|
+
expect(result?.truncation.truncatedLength).toBe(16 * 1024);
|
|
460
|
+
expect(result?.error).toContain("…[truncated]");
|
|
461
|
+
|
|
462
|
+
console.warn = originalWarn;
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// ─── M7: cross-stack file splitting ─────────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
describe("splitFilesByStack (M7)", () => {
|
|
469
|
+
test("groups files by extension stack", () => {
|
|
470
|
+
const result = splitFilesByStack(["main.go", "app.ts", "style.vue"]);
|
|
471
|
+
|
|
472
|
+
expect(Object.keys(result)).toHaveLength(3);
|
|
473
|
+
expect(result.go).toEqual(["main.go"]);
|
|
474
|
+
expect(result.js).toEqual(["app.ts"]);
|
|
475
|
+
expect(result.vue).toEqual(["style.vue"]);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("unknown extension → 'other'", () => {
|
|
479
|
+
const result = splitFilesByStack(["file.xyz", "main.go"]);
|
|
480
|
+
|
|
481
|
+
expect(result.other).toEqual(["file.xyz"]);
|
|
482
|
+
expect(result.go).toEqual(["main.go"]);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("single file → single stack", () => {
|
|
486
|
+
const result = splitFilesByStack(["main.go"]);
|
|
487
|
+
|
|
488
|
+
expect(Object.keys(result)).toHaveLength(1);
|
|
489
|
+
expect(result.go).toEqual(["main.go"]);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("same extension → no split (one stack)", () => {
|
|
493
|
+
const result = splitFilesByStack(["a.go", "b.go"]);
|
|
494
|
+
|
|
495
|
+
expect(Object.keys(result)).toHaveLength(1);
|
|
496
|
+
expect(result.go).toEqual(["a.go", "b.go"]);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("tsx/jsx/ts/js all map to 'js'", () => {
|
|
500
|
+
const result = splitFilesByStack(["a.ts", "b.tsx", "c.js", "d.jsx"]);
|
|
501
|
+
|
|
502
|
+
expect(Object.keys(result)).toHaveLength(1);
|
|
503
|
+
expect(result.js).toEqual(["a.ts", "b.tsx", "c.js", "d.jsx"]);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
describe("createTasksBatch — cross-stack split (M7)", () => {
|
|
508
|
+
test("task with main.go + app.ts → 2 tasks (go-smith, js-smith)", () => {
|
|
509
|
+
const plan = makePlan();
|
|
510
|
+
const tasks = createTasksBatch(db, plan.id, [
|
|
511
|
+
makeTask({ files: ["main.go", "app.ts"], description: "build feature" }),
|
|
512
|
+
]);
|
|
513
|
+
|
|
514
|
+
expect(tasks).toHaveLength(2);
|
|
515
|
+
|
|
516
|
+
const goTask = tasks.find((t) => t.agent === "go-smith");
|
|
517
|
+
const jsTask = tasks.find((t) => t.agent === "js-smith");
|
|
518
|
+
|
|
519
|
+
expect(goTask).toBeDefined();
|
|
520
|
+
expect(jsTask).toBeDefined();
|
|
521
|
+
expect(goTask?.files).toEqual(["main.go"]);
|
|
522
|
+
expect(jsTask?.files).toEqual(["app.ts"]);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("task with main.go + main.go → no split (same stack, dedup files)", () => {
|
|
526
|
+
const plan = makePlan();
|
|
527
|
+
const tasks = createTasksBatch(db, plan.id, [
|
|
528
|
+
makeTask({ files: ["main.go", "main.go"], description: "build backend" }),
|
|
529
|
+
]);
|
|
530
|
+
|
|
531
|
+
// Same stack → no split, original task used as-is
|
|
532
|
+
expect(tasks).toHaveLength(1);
|
|
533
|
+
expect(tasks[0]?.agent).toBe("js-smith"); // original agent preserved
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("task with single file → 1 task, no split", () => {
|
|
537
|
+
const plan = makePlan();
|
|
538
|
+
const tasks = createTasksBatch(db, plan.id, [
|
|
539
|
+
makeTask({ files: ["main.go"], description: "build backend" }),
|
|
540
|
+
]);
|
|
541
|
+
|
|
542
|
+
expect(tasks).toHaveLength(1);
|
|
543
|
+
expect(tasks[0]?.agent).toBe("js-smith"); // original agent preserved
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("task with file.unknown + main.go → 2 tasks (other + go)", () => {
|
|
547
|
+
const plan = makePlan();
|
|
548
|
+
const tasks = createTasksBatch(db, plan.id, [
|
|
549
|
+
makeTask({ files: ["README.xyz", "main.go"], description: "build docs" }),
|
|
550
|
+
]);
|
|
551
|
+
|
|
552
|
+
expect(tasks).toHaveLength(2);
|
|
553
|
+
|
|
554
|
+
const otherTask = tasks.find((t) => t.agent === "smith");
|
|
555
|
+
const goTask = tasks.find((t) => t.agent === "go-smith");
|
|
556
|
+
|
|
557
|
+
expect(otherTask).toBeDefined();
|
|
558
|
+
expect(goTask).toBeDefined();
|
|
559
|
+
expect(otherTask?.files).toEqual(["README.xyz"]);
|
|
560
|
+
expect(goTask?.files).toEqual(["main.go"]);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("splitReason='cross-stack' in metadata of sub-tasks", () => {
|
|
564
|
+
const plan = makePlan();
|
|
565
|
+
const tasks = createTasksBatch(db, plan.id, [
|
|
566
|
+
makeTask({ files: ["main.go", "app.ts"], description: "build feature" }),
|
|
567
|
+
]);
|
|
568
|
+
|
|
569
|
+
expect(tasks).toHaveLength(2);
|
|
570
|
+
for (const task of tasks) {
|
|
571
|
+
expect((task.metadata as Record<string, unknown>).splitReason).toBe("cross-stack");
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// ─── T1: updateTaskStatus — extended fields ──────────────────────────────────
|
|
577
|
+
|
|
578
|
+
describe("updateTaskStatus — extended fields (T1)", () => {
|
|
579
|
+
test("artifacts write", () => {
|
|
580
|
+
const plan = makePlan();
|
|
581
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask()]);
|
|
582
|
+
const taskId = tasks[0]?.id as string;
|
|
583
|
+
|
|
584
|
+
updateTaskStatus(db, taskId, "done", { artifacts: ["file1.ts", "file2.ts"] });
|
|
585
|
+
|
|
586
|
+
const row = db.query("SELECT artifacts FROM plan_tasks WHERE id = ?").get(taskId) as {
|
|
587
|
+
artifacts: string;
|
|
588
|
+
};
|
|
589
|
+
expect(JSON.parse(row.artifacts)).toEqual(["file1.ts", "file2.ts"]);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("artifacts truncation", () => {
|
|
593
|
+
const plan = makePlan();
|
|
594
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask()]);
|
|
595
|
+
const taskId = tasks[0]?.id as string;
|
|
596
|
+
|
|
597
|
+
// Build artifacts array whose JSON > 16KB
|
|
598
|
+
const longStrings = Array.from({ length: 200 }, (_, i) => `file${"x".repeat(100)}_${i}.ts`);
|
|
599
|
+
const warnSpy = { calls: [] as string[] };
|
|
600
|
+
const origWarn = console.warn;
|
|
601
|
+
console.warn = (...args: unknown[]) => {
|
|
602
|
+
warnSpy.calls.push(args.join(" "));
|
|
603
|
+
origWarn(...args);
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
const result = updateTaskStatus(db, taskId, "done", { artifacts: longStrings });
|
|
608
|
+
|
|
609
|
+
expect(result?.truncation.truncated).toBe(true);
|
|
610
|
+
expect(warnSpy.calls.some((m) => m.includes("artifacts truncated"))).toBe(true);
|
|
611
|
+
|
|
612
|
+
const row = db.query("SELECT artifacts FROM plan_tasks WHERE id = ?").get(taskId) as {
|
|
613
|
+
artifacts: string;
|
|
614
|
+
};
|
|
615
|
+
const stored: string[] = JSON.parse(row.artifacts);
|
|
616
|
+
expect(stored.length).toBeLessThan(longStrings.length);
|
|
617
|
+
// Stored JSON must fit within 16KB
|
|
618
|
+
expect(JSON.stringify(stored).length).toBeLessThanOrEqual(16 * 1024);
|
|
619
|
+
} finally {
|
|
620
|
+
console.warn = origWarn;
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("metadataPatch deep merge", () => {
|
|
625
|
+
const plan = makePlan();
|
|
626
|
+
const tasks = createTasksBatch(db, plan.id, [
|
|
627
|
+
makeTask({ metadata: { a: 1, b: { x: 1 } } as unknown as Record<string, unknown> }),
|
|
628
|
+
]);
|
|
629
|
+
const taskId = tasks[0]?.id as string;
|
|
630
|
+
|
|
631
|
+
updateTaskStatus(db, taskId, "done", { metadataPatch: { b: { y: 2 }, c: 3 } });
|
|
632
|
+
|
|
633
|
+
const task = getTask(db, taskId);
|
|
634
|
+
expect(task?.metadata as unknown as Record<string, unknown>).toEqual({
|
|
635
|
+
a: 1,
|
|
636
|
+
b: { x: 1, y: 2 },
|
|
637
|
+
c: 3,
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("reviewedBy write", () => {
|
|
642
|
+
const plan = makePlan();
|
|
643
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask()]);
|
|
644
|
+
const taskId = tasks[0]?.id as string;
|
|
645
|
+
|
|
646
|
+
updateTaskStatus(db, taskId, "done", { reviewedBy: "inspector" });
|
|
647
|
+
|
|
648
|
+
const row = db.query("SELECT reviewed_by FROM plan_tasks WHERE id = ?").get(taskId) as {
|
|
649
|
+
reviewed_by: string;
|
|
650
|
+
};
|
|
651
|
+
expect(row.reviewed_by).toBe("inspector");
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("reviewedVerdict stored in metadata", () => {
|
|
655
|
+
const plan = makePlan();
|
|
656
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask()]);
|
|
657
|
+
const taskId = tasks[0]?.id as string;
|
|
658
|
+
|
|
659
|
+
updateTaskStatus(db, taskId, "done", { reviewedVerdict: "approved" });
|
|
660
|
+
|
|
661
|
+
const task = getTask(db, taskId);
|
|
662
|
+
expect((task?.metadata as Record<string, unknown>).reviewedVerdict).toBe("approved");
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("retrocompat — undefined fields = no-write", () => {
|
|
666
|
+
const plan = makePlan();
|
|
667
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask({ artifacts: ["existing.ts"] })]);
|
|
668
|
+
const taskId = tasks[0]?.id as string;
|
|
669
|
+
|
|
670
|
+
// Update with only result — artifacts/metadata/reviewedBy should stay unchanged
|
|
671
|
+
updateTaskStatus(db, taskId, "done", { result: "ok" });
|
|
672
|
+
|
|
673
|
+
const row = db
|
|
674
|
+
.query("SELECT artifacts, reviewed_by, metadata FROM plan_tasks WHERE id = ?")
|
|
675
|
+
.get(taskId) as { artifacts: string; reviewed_by: string | null; metadata: string };
|
|
676
|
+
expect(JSON.parse(row.artifacts)).toEqual(["existing.ts"]);
|
|
677
|
+
expect(row.reviewed_by).toBeNull();
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test("combined — all fields at once", () => {
|
|
681
|
+
const plan = makePlan();
|
|
682
|
+
const tasks = createTasksBatch(db, plan.id, [makeTask({ metadata: { existing: true } })]);
|
|
683
|
+
const taskId = tasks[0]?.id as string;
|
|
684
|
+
|
|
685
|
+
const result = updateTaskStatus(db, taskId, "done", {
|
|
686
|
+
result: "final output",
|
|
687
|
+
artifacts: ["a.ts", "b.ts"],
|
|
688
|
+
metadataPatch: { newKey: 42 },
|
|
689
|
+
reviewedBy: "warden",
|
|
690
|
+
reviewedVerdict: "pass",
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
expect(result?.result).toBe("final output");
|
|
694
|
+
|
|
695
|
+
const row = db
|
|
696
|
+
.query("SELECT artifacts, reviewed_by, metadata FROM plan_tasks WHERE id = ?")
|
|
697
|
+
.get(taskId) as { artifacts: string; reviewed_by: string; metadata: string };
|
|
698
|
+
expect(JSON.parse(row.artifacts)).toEqual(["a.ts", "b.ts"]);
|
|
699
|
+
expect(row.reviewed_by).toBe("warden");
|
|
700
|
+
const meta = JSON.parse(row.metadata);
|
|
701
|
+
expect(meta.existing).toBe(true);
|
|
702
|
+
expect(meta.newKey).toBe(42);
|
|
703
|
+
expect(meta.reviewedVerdict).toBe("pass");
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
// ─── order_index collision-safe allocation (fix: plan ca69222a) ──────────────
|
|
708
|
+
|
|
709
|
+
describe("createTasksBatch — order_index collision-safe allocation", () => {
|
|
710
|
+
test("(a) batch con plan pre-poblado — nueva task asigna order_index=1 sin colisión", () => {
|
|
711
|
+
const plan = makePlan();
|
|
712
|
+
// Pre-populate with 1 task at order_index=0
|
|
713
|
+
createTasksBatch(db, plan.id, [makeTask({ description: "existing task" })]);
|
|
714
|
+
|
|
715
|
+
// New batch — caller passes orderIndex=0 (collides with existing)
|
|
716
|
+
const result = createTasksBatch(db, plan.id, [
|
|
717
|
+
makeTask({ description: "new task", orderIndex: 0 }),
|
|
718
|
+
]);
|
|
719
|
+
|
|
720
|
+
expect(result).toHaveLength(1);
|
|
721
|
+
expect(result[0]?.orderIndex).toBe(1);
|
|
722
|
+
expect(result[0]?.description).toBe("new task");
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("(b) split cross-stack con plan pre-poblado — parent=1, decimal 1.1", () => {
|
|
726
|
+
const plan = makePlan();
|
|
727
|
+
// Pre-populate with 1 task at order_index=0
|
|
728
|
+
createTasksBatch(db, plan.id, [makeTask({ description: "existing task" })]);
|
|
729
|
+
|
|
730
|
+
// New batch with cross-stack files: .py+.py → python, .md → other = 2 stacks
|
|
731
|
+
const result = createTasksBatch(db, plan.id, [
|
|
732
|
+
makeTask({ files: ["a.py", "b.py", "c.md"], description: "split task" }),
|
|
733
|
+
]);
|
|
734
|
+
|
|
735
|
+
expect(result).toHaveLength(2);
|
|
736
|
+
// Parent (first sub-task) → order_index=1 (next free after 0)
|
|
737
|
+
// Second sub-task → 1 + 0.1 = 1.1
|
|
738
|
+
const orderIndices = result.map((t) => t.orderIndex).sort((a, b) => a - b);
|
|
739
|
+
expect(orderIndices).toEqual([1, 1.1]);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test("(c) split cross-stack — decimales ocupados → escala a integer libre", () => {
|
|
743
|
+
const plan = makePlan();
|
|
744
|
+
// Pre-populate with tasks at 0.1 and 0.2 (but NOT 0)
|
|
745
|
+
createTasksBatch(db, plan.id, [
|
|
746
|
+
makeTask({ description: "task at 0.1", orderIndex: 0.1, agent: "agent-X" }),
|
|
747
|
+
makeTask({ description: "task at 0.2", orderIndex: 0.2, agent: "agent-Y" }),
|
|
748
|
+
]);
|
|
749
|
+
|
|
750
|
+
// New batch with 3-stack split, caller passes orderIndex=0
|
|
751
|
+
// .go + .ts + .py → 3 stacks → 3 sub-tasks
|
|
752
|
+
const result = createTasksBatch(db, plan.id, [
|
|
753
|
+
makeTask({ files: ["a.go", "b.ts", "c.py"], description: "split task", orderIndex: 0 }),
|
|
754
|
+
]);
|
|
755
|
+
|
|
756
|
+
expect(result).toHaveLength(3);
|
|
757
|
+
// stackIdx=0 → parentOrder=0 (free)
|
|
758
|
+
// stackIdx=1 → 0.1 (occupied) → escalate to next free integer = 1
|
|
759
|
+
// stackIdx=2 → 0.2 (occupied) → escalate to next free integer = 2
|
|
760
|
+
const orderIndices = result.map((t) => t.orderIndex).sort((a, b) => a - b);
|
|
761
|
+
expect(orderIndices).toEqual([0, 1, 2]);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test("(d) caller pasa orderIndex colisionante → core reasigna a siguiente libre", () => {
|
|
765
|
+
const plan = makePlan();
|
|
766
|
+
// Pre-populate with task at order_index=0
|
|
767
|
+
createTasksBatch(db, plan.id, [makeTask({ description: "existing task" })]);
|
|
768
|
+
|
|
769
|
+
// New batch — caller passes orderIndex=0 (collides)
|
|
770
|
+
const result = createTasksBatch(db, plan.id, [
|
|
771
|
+
makeTask({ description: "new task", orderIndex: 0 }),
|
|
772
|
+
]);
|
|
773
|
+
|
|
774
|
+
expect(result).toHaveLength(1);
|
|
775
|
+
expect(result[0]?.orderIndex).toBe(1); // reassigned to next free
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test("(e) reproduce bug 18252705 — 2nd batch with colliding orderIndex succeeds", () => {
|
|
779
|
+
const plan = makePlan();
|
|
780
|
+
// 1st batch — 1 task at order_index=0 (simulates old caller passing idx=0)
|
|
781
|
+
const first = createTasksBatch(db, plan.id, [
|
|
782
|
+
makeTask({ description: "task 0", orderIndex: 0 }),
|
|
783
|
+
]);
|
|
784
|
+
expect(first).toHaveLength(1);
|
|
785
|
+
expect(first[0]?.orderIndex).toBe(0);
|
|
786
|
+
|
|
787
|
+
// 2nd batch — 4 tasks. OLD callers passed orderIndex 0,1,2,3 (idx from map).
|
|
788
|
+
// With fix, even if caller passes colliding indices, core reassigns.
|
|
789
|
+
const second = createTasksBatch(db, plan.id, [
|
|
790
|
+
makeTask({ description: "task A", orderIndex: 0 }), // collides → 1
|
|
791
|
+
makeTask({ description: "task B", orderIndex: 1 }), // collides → 2
|
|
792
|
+
makeTask({ description: "task C", orderIndex: 2 }), // collides → 3
|
|
793
|
+
makeTask({ description: "task D", orderIndex: 3 }), // collides → 4
|
|
794
|
+
]);
|
|
795
|
+
|
|
796
|
+
expect(second).toHaveLength(4);
|
|
797
|
+
const orderIndices = second.map((t) => t.orderIndex).sort((a, b) => a - b);
|
|
798
|
+
expect(orderIndices).toEqual([1, 2, 3, 4]);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("(f) caller omite orderIndex — core asigna secuencial desde MAX+1", () => {
|
|
802
|
+
const plan = makePlan();
|
|
803
|
+
// 1st batch — 2 tasks, no orderIndex specified
|
|
804
|
+
const first = createTasksBatch(db, plan.id, [
|
|
805
|
+
makeTask({ description: "task X" }),
|
|
806
|
+
makeTask({ description: "task Y" }),
|
|
807
|
+
]);
|
|
808
|
+
expect(first).toHaveLength(2);
|
|
809
|
+
expect(first[0]?.orderIndex).toBe(0);
|
|
810
|
+
expect(first[1]?.orderIndex).toBe(1);
|
|
811
|
+
|
|
812
|
+
// 2nd batch — 2 more tasks, no orderIndex
|
|
813
|
+
const second = createTasksBatch(db, plan.id, [
|
|
814
|
+
makeTask({ description: "task Z" }),
|
|
815
|
+
makeTask({ description: "task W" }),
|
|
816
|
+
]);
|
|
817
|
+
expect(second).toHaveLength(2);
|
|
818
|
+
expect(second[0]?.orderIndex).toBe(2);
|
|
819
|
+
expect(second[1]?.orderIndex).toBe(3);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
test("(g) archived task ocupa slot — nueva task evita colisión", () => {
|
|
823
|
+
const plan = makePlan();
|
|
824
|
+
// Create and archive a task at order_index=0
|
|
825
|
+
const created = createTasksBatch(db, plan.id, [makeTask({ description: "archived task" })]);
|
|
826
|
+
db.query("UPDATE plan_tasks SET archived_at = ? WHERE id = ?").run(
|
|
827
|
+
Date.now(),
|
|
828
|
+
created[0]?.id as string,
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
// New batch — caller passes orderIndex=0 (collides with archived row)
|
|
832
|
+
const result = createTasksBatch(db, plan.id, [
|
|
833
|
+
makeTask({ description: "new task", orderIndex: 0 }),
|
|
834
|
+
]);
|
|
835
|
+
|
|
836
|
+
expect(result).toHaveLength(1);
|
|
837
|
+
// Archived row still occupies (plan_id, order_index) in UNIQUE constraint.
|
|
838
|
+
// Core detects collision via usedOrderIndices (includes archived) → reassigns.
|
|
839
|
+
expect(result[0]?.orderIndex).not.toBe(0);
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// ─── listTasksByPlan — includeArchived flag (plan 76a12c8d) ──────────────────
|
|
844
|
+
|
|
845
|
+
describe("listTasksByPlan — includeArchived flag", () => {
|
|
846
|
+
test("(a) sin flag — omite tasks con archived_at IS NOT NULL", () => {
|
|
847
|
+
const plan = makePlan();
|
|
848
|
+
// Create 2 tasks: one live, one to be archived
|
|
849
|
+
const created = createTasksBatch(db, plan.id, [
|
|
850
|
+
makeTask({ description: "live task" }),
|
|
851
|
+
makeTask({ description: "doomed task" }),
|
|
852
|
+
]);
|
|
853
|
+
expect(created).toHaveLength(2);
|
|
854
|
+
|
|
855
|
+
// Archive the 2nd task
|
|
856
|
+
db.query("UPDATE plan_tasks SET archived_at = ? WHERE id = ?").run(
|
|
857
|
+
Date.now(),
|
|
858
|
+
created[1]?.id as string,
|
|
859
|
+
);
|
|
860
|
+
|
|
861
|
+
// Default call (no includeArchived) → only live task
|
|
862
|
+
const tasks = listTasksByPlan(db, plan.id);
|
|
863
|
+
expect(tasks).toHaveLength(1);
|
|
864
|
+
expect(tasks[0]?.description).toBe("live task");
|
|
865
|
+
expect(tasks[0]?.archivedAt).toBeNull();
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
test("(b) includeArchived=true — retorna tasks archivadas también", () => {
|
|
869
|
+
const plan = makePlan();
|
|
870
|
+
const created = createTasksBatch(db, plan.id, [
|
|
871
|
+
makeTask({ description: "live task" }),
|
|
872
|
+
makeTask({ description: "archived task" }),
|
|
873
|
+
]);
|
|
874
|
+
expect(created).toHaveLength(2);
|
|
875
|
+
|
|
876
|
+
db.query("UPDATE plan_tasks SET archived_at = ? WHERE id = ?").run(
|
|
877
|
+
Date.now(),
|
|
878
|
+
created[1]?.id as string,
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
// With includeArchived=true → both tasks returned
|
|
882
|
+
const tasks = listTasksByPlan(db, plan.id, { includeArchived: true });
|
|
883
|
+
expect(tasks).toHaveLength(2);
|
|
884
|
+
const descriptions = tasks.map((t) => t.description).sort();
|
|
885
|
+
expect(descriptions).toEqual(["archived task", "live task"]);
|
|
886
|
+
// Archived task carries archivedAt timestamp
|
|
887
|
+
const archived = tasks.find((t) => t.description === "archived task");
|
|
888
|
+
expect(archived?.archivedAt).not.toBeNull();
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
test("(c) includeArchived=true + status filter — combina ambos filtros", () => {
|
|
892
|
+
const plan = makePlan();
|
|
893
|
+
const created = createTasksBatch(db, plan.id, [
|
|
894
|
+
makeTask({ description: "live pending" }),
|
|
895
|
+
makeTask({ description: "archived pending" }),
|
|
896
|
+
makeTask({ description: "live done" }),
|
|
897
|
+
]);
|
|
898
|
+
// Archive 2nd task, mark 3rd as done
|
|
899
|
+
db.query("UPDATE plan_tasks SET archived_at = ? WHERE id = ?").run(
|
|
900
|
+
Date.now(),
|
|
901
|
+
created[1]?.id as string,
|
|
902
|
+
);
|
|
903
|
+
updateTaskStatus(db, created[2]?.id as string, "done", { result: "ok" }, "test");
|
|
904
|
+
|
|
905
|
+
// status=pending + includeArchived=true → both pending tasks (live + archived)
|
|
906
|
+
const pendingAll = listTasksByPlan(db, plan.id, {
|
|
907
|
+
status: "pending",
|
|
908
|
+
includeArchived: true,
|
|
909
|
+
});
|
|
910
|
+
expect(pendingAll).toHaveLength(2);
|
|
911
|
+
expect(pendingAll.map((t) => t.description).sort()).toEqual([
|
|
912
|
+
"archived pending",
|
|
913
|
+
"live pending",
|
|
914
|
+
]);
|
|
915
|
+
|
|
916
|
+
// status=pending without includeArchived → only live pending
|
|
917
|
+
const pendingLive = listTasksByPlan(db, plan.id, { status: "pending" });
|
|
918
|
+
expect(pendingLive).toHaveLength(1);
|
|
919
|
+
expect(pendingLive[0]?.description).toBe("live pending");
|
|
920
|
+
});
|
|
921
|
+
});
|