trekoon 0.1.1 → 0.1.4

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.
@@ -2,10 +2,142 @@ import { Database } from "bun:sqlite";
2
2
 
3
3
  import { BASE_SCHEMA_STATEMENTS, SCHEMA_VERSION } from "./schema";
4
4
 
5
+ const BASE_MIGRATION_VERSION = 1;
5
6
  const BASE_MIGRATION_NAME = `0001_base_schema_v${SCHEMA_VERSION}`;
7
+ const LEGACY_BASE_MIGRATION_NAME_PATTERNS: readonly string[] = [
8
+ "0001_base_schema_v*",
9
+ ];
6
10
 
7
- function hasMigration(db: Database, name: string): boolean {
8
- const migrationTableExists: { count: number } | null = db
11
+ const BASE_ROLLBACK_STATEMENTS: readonly string[] = [
12
+ "DROP TABLE IF EXISTS sync_conflicts;",
13
+ "DROP TABLE IF EXISTS sync_cursors;",
14
+ "DROP TABLE IF EXISTS git_context;",
15
+ "DROP TABLE IF EXISTS events;",
16
+ "DROP TABLE IF EXISTS dependencies;",
17
+ "DROP TABLE IF EXISTS subtasks;",
18
+ "DROP TABLE IF EXISTS tasks;",
19
+ "DROP TABLE IF EXISTS epics;",
20
+ ];
21
+
22
+ const INDEX_MIGRATION_UP_STATEMENTS: readonly string[] = [
23
+ "CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);",
24
+ "CREATE INDEX IF NOT EXISTS idx_events_git_branch ON events(git_branch);",
25
+ "CREATE INDEX IF NOT EXISTS idx_events_created_at_id ON events(created_at, id);",
26
+ "CREATE INDEX IF NOT EXISTS idx_dependencies_source ON dependencies(source_id);",
27
+ "CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on ON dependencies(depends_on_id);",
28
+ ];
29
+
30
+ const INDEX_MIGRATION_DOWN_STATEMENTS: readonly string[] = [
31
+ "DROP INDEX IF EXISTS idx_events_created_at;",
32
+ "DROP INDEX IF EXISTS idx_events_git_branch;",
33
+ "DROP INDEX IF EXISTS idx_events_created_at_id;",
34
+ "DROP INDEX IF EXISTS idx_dependencies_source;",
35
+ "DROP INDEX IF EXISTS idx_dependencies_depends_on;",
36
+ ];
37
+
38
+ const EVENT_ARCHIVE_MIGRATION_UP_STATEMENTS: readonly string[] = [
39
+ `
40
+ CREATE TABLE IF NOT EXISTS event_archive (
41
+ id TEXT PRIMARY KEY,
42
+ entity_kind TEXT NOT NULL,
43
+ entity_id TEXT NOT NULL,
44
+ operation TEXT NOT NULL,
45
+ payload TEXT NOT NULL,
46
+ git_branch TEXT,
47
+ git_head TEXT,
48
+ created_at INTEGER NOT NULL,
49
+ updated_at INTEGER NOT NULL,
50
+ version INTEGER NOT NULL DEFAULT 1
51
+ );
52
+ `,
53
+ "CREATE INDEX IF NOT EXISTS idx_event_archive_created_at ON event_archive(created_at);",
54
+ ];
55
+
56
+ const EVENT_ARCHIVE_MIGRATION_DOWN_STATEMENTS: readonly string[] = [
57
+ "DROP INDEX IF EXISTS idx_event_archive_created_at;",
58
+ "DROP TABLE IF EXISTS event_archive;",
59
+ ];
60
+
61
+ interface Migration {
62
+ readonly version: number;
63
+ readonly name: string;
64
+ up(db: Database): void;
65
+ down(db: Database): void;
66
+ }
67
+
68
+ interface AppliedMigrationRow {
69
+ readonly version: number;
70
+ readonly name: string;
71
+ readonly applied_at: number;
72
+ }
73
+
74
+ export interface AppliedMigration {
75
+ readonly version: number;
76
+ readonly name: string;
77
+ readonly appliedAt: number;
78
+ }
79
+
80
+ export interface MigrationStatus {
81
+ readonly currentVersion: number;
82
+ readonly latestVersion: number;
83
+ readonly applied: readonly AppliedMigration[];
84
+ readonly pending: ReadonlyArray<{ version: number; name: string }>;
85
+ }
86
+
87
+ export interface RollbackSummary {
88
+ readonly fromVersion: number;
89
+ readonly toVersion: number;
90
+ readonly rolledBack: number;
91
+ readonly rolledBackMigrations: readonly string[];
92
+ }
93
+
94
+ const MIGRATIONS: readonly Migration[] = [
95
+ {
96
+ version: BASE_MIGRATION_VERSION,
97
+ name: BASE_MIGRATION_NAME,
98
+ up(db: Database): void {
99
+ for (const statement of BASE_SCHEMA_STATEMENTS) {
100
+ db.exec(statement);
101
+ }
102
+ },
103
+ down(db: Database): void {
104
+ for (const statement of BASE_ROLLBACK_STATEMENTS) {
105
+ db.exec(statement);
106
+ }
107
+ },
108
+ },
109
+ {
110
+ version: 2,
111
+ name: "0002_sync_dependency_indexes",
112
+ up(db: Database): void {
113
+ for (const statement of INDEX_MIGRATION_UP_STATEMENTS) {
114
+ db.exec(statement);
115
+ }
116
+ },
117
+ down(db: Database): void {
118
+ for (const statement of INDEX_MIGRATION_DOWN_STATEMENTS) {
119
+ db.exec(statement);
120
+ }
121
+ },
122
+ },
123
+ {
124
+ version: 3,
125
+ name: "0003_event_archive_retention",
126
+ up(db: Database): void {
127
+ for (const statement of EVENT_ARCHIVE_MIGRATION_UP_STATEMENTS) {
128
+ db.exec(statement);
129
+ }
130
+ },
131
+ down(db: Database): void {
132
+ for (const statement of EVENT_ARCHIVE_MIGRATION_DOWN_STATEMENTS) {
133
+ db.exec(statement);
134
+ }
135
+ },
136
+ },
137
+ ];
138
+
139
+ function migrationTableExists(db: Database): boolean {
140
+ const row = db
9
141
  .query(
10
142
  `
11
143
  SELECT COUNT(*) AS count
@@ -15,32 +147,221 @@ function hasMigration(db: Database, name: string): boolean {
15
147
  )
16
148
  .get() as { count: number } | null;
17
149
 
18
- if (!migrationTableExists || migrationTableExists.count === 0) {
19
- return false;
150
+ return (row?.count ?? 0) > 0;
151
+ }
152
+
153
+ function hasMigrationVersionColumn(db: Database): boolean {
154
+ const columns = db.query("PRAGMA table_info(schema_migrations);").all() as Array<{ name: string }>;
155
+ return columns.some((column) => column.name === "version");
156
+ }
157
+
158
+ function ensureMigrationTable(db: Database): void {
159
+ db.exec(`
160
+ CREATE TABLE IF NOT EXISTS schema_migrations (
161
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
162
+ version INTEGER NOT NULL UNIQUE,
163
+ name TEXT NOT NULL UNIQUE,
164
+ applied_at INTEGER NOT NULL
165
+ );
166
+ `);
167
+ }
168
+
169
+ function ensureMigrationVersionColumn(db: Database): void {
170
+ if (!migrationTableExists(db) || hasMigrationVersionColumn(db)) {
171
+ return;
172
+ }
173
+
174
+ db.exec("ALTER TABLE schema_migrations ADD COLUMN version INTEGER;");
175
+ db.query("UPDATE schema_migrations SET version = ? WHERE version IS NULL AND name = ?;").run(BASE_MIGRATION_VERSION, BASE_MIGRATION_NAME);
176
+
177
+ for (const legacyPattern of LEGACY_BASE_MIGRATION_NAME_PATTERNS) {
178
+ db.query("UPDATE schema_migrations SET version = ? WHERE version IS NULL AND name GLOB ?;").run(
179
+ BASE_MIGRATION_VERSION,
180
+ legacyPattern,
181
+ );
182
+ }
183
+
184
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_schema_migrations_version ON schema_migrations(version);");
185
+
186
+ const unresolvedRow = db
187
+ .query(
188
+ `
189
+ SELECT COUNT(*) AS count
190
+ FROM schema_migrations
191
+ WHERE version IS NULL;
192
+ `,
193
+ )
194
+ .get() as { count: number } | null;
195
+
196
+ if ((unresolvedRow?.count ?? 0) > 0) {
197
+ throw new Error(
198
+ "Unable to infer one or more schema_migrations.version values during legacy upgrade. Repair schema_migrations entries manually so every row has a valid version, then rerun migrations.",
199
+ );
200
+ }
201
+ }
202
+
203
+ function validateMigrationPlan(): void {
204
+ const seen = new Set<number>();
205
+
206
+ for (let index = 0; index < MIGRATIONS.length; index += 1) {
207
+ const migration: Migration = MIGRATIONS[index]!;
208
+
209
+ if (migration.version !== index + 1) {
210
+ throw new Error(`Migration versions must be contiguous from 1 (found ${migration.version} at index ${index}).`);
211
+ }
212
+
213
+ if (seen.has(migration.version)) {
214
+ throw new Error(`Duplicate migration version ${migration.version}.`);
215
+ }
216
+
217
+ seen.add(migration.version);
218
+ }
219
+ }
220
+
221
+ function runExclusive<T>(db: Database, operation: () => T): T {
222
+ db.exec("BEGIN EXCLUSIVE TRANSACTION;");
223
+
224
+ try {
225
+ const result: T = operation();
226
+ db.exec("COMMIT;");
227
+ return result;
228
+ } catch (error: unknown) {
229
+ db.exec("ROLLBACK;");
230
+ throw error;
231
+ }
232
+ }
233
+
234
+ function currentVersion(db: Database): number {
235
+ if (!migrationTableExists(db)) {
236
+ return 0;
20
237
  }
21
238
 
22
- const row: { count: number } | null = db
23
- .query("SELECT COUNT(*) AS count FROM schema_migrations WHERE name = ?;")
24
- .get(name) as { count: number } | null;
239
+ const row = db
240
+ .query("SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations;")
241
+ .get() as { version: number } | null;
25
242
 
26
- return Boolean(row && row.count > 0);
243
+ return row?.version ?? 0;
244
+ }
245
+
246
+ function listAppliedMigrations(db: Database): AppliedMigrationRow[] {
247
+ if (!migrationTableExists(db)) {
248
+ return [];
249
+ }
250
+
251
+ return db
252
+ .query(
253
+ `
254
+ SELECT version, name, applied_at
255
+ FROM schema_migrations
256
+ WHERE version IS NOT NULL
257
+ ORDER BY version ASC;
258
+ `,
259
+ )
260
+ .all() as AppliedMigrationRow[];
261
+ }
262
+
263
+ function migrationForVersion(version: number): Migration {
264
+ const found = MIGRATIONS.find((migration) => migration.version === version);
265
+
266
+ if (!found) {
267
+ throw new Error(`No migration definition found for version ${version}.`);
268
+ }
269
+
270
+ return found;
271
+ }
272
+
273
+ function recordMigration(db: Database, migration: Migration): void {
274
+ db.query("INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?);").run(
275
+ migration.version,
276
+ migration.name,
277
+ Date.now(),
278
+ );
27
279
  }
28
280
 
29
281
  export function migrateDatabase(db: Database): void {
30
- if (hasMigration(db, BASE_MIGRATION_NAME)) {
31
- return;
282
+ runExclusive(db, (): void => {
283
+ ensureMigrationTable(db);
284
+ ensureMigrationVersionColumn(db);
285
+ validateMigrationPlan();
286
+
287
+ const version: number = currentVersion(db);
288
+
289
+ for (const migration of MIGRATIONS) {
290
+ if (migration.version <= version) {
291
+ continue;
292
+ }
293
+
294
+ migration.up(db);
295
+ recordMigration(db, migration);
296
+ }
297
+ });
298
+ }
299
+
300
+ export function describeMigrations(db: Database): MigrationStatus {
301
+ ensureMigrationTable(db);
302
+ ensureMigrationVersionColumn(db);
303
+ validateMigrationPlan();
304
+
305
+ const appliedRows: AppliedMigrationRow[] = listAppliedMigrations(db);
306
+ const latestVersion: number = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
307
+ const activeVersion: number = appliedRows[appliedRows.length - 1]?.version ?? 0;
308
+ const appliedVersions = new Set(appliedRows.map((row) => row.version));
309
+
310
+ return {
311
+ currentVersion: activeVersion,
312
+ latestVersion,
313
+ applied: appliedRows.map((row) => ({
314
+ version: row.version,
315
+ name: row.name,
316
+ appliedAt: row.applied_at,
317
+ })),
318
+ pending: MIGRATIONS.filter((migration) => !appliedVersions.has(migration.version)).map((migration) => ({
319
+ version: migration.version,
320
+ name: migration.name,
321
+ })),
322
+ };
323
+ }
324
+
325
+ export function rollbackDatabase(db: Database, targetVersion: number): RollbackSummary {
326
+ if (!Number.isInteger(targetVersion) || targetVersion < 0) {
327
+ throw new Error("Rollback target version must be a non-negative integer.");
32
328
  }
33
329
 
34
- const now: number = Date.now();
330
+ return runExclusive(db, (): RollbackSummary => {
331
+ ensureMigrationTable(db);
332
+ ensureMigrationVersionColumn(db);
333
+ validateMigrationPlan();
35
334
 
36
- db.transaction((): void => {
37
- for (const statement of BASE_SCHEMA_STATEMENTS) {
38
- db.exec(statement);
335
+ const fromVersion: number = currentVersion(db);
336
+ if (targetVersion > fromVersion) {
337
+ throw new Error(`Cannot roll back to version ${targetVersion}; current version is ${fromVersion}.`);
39
338
  }
40
339
 
41
- db.query("INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?);").run(
42
- BASE_MIGRATION_NAME,
43
- now,
44
- );
45
- })();
340
+ const appliedDescending = db
341
+ .query(
342
+ `
343
+ SELECT version, name, applied_at
344
+ FROM schema_migrations
345
+ WHERE version IS NOT NULL AND version > ?
346
+ ORDER BY version DESC;
347
+ `,
348
+ )
349
+ .all(targetVersion) as AppliedMigrationRow[];
350
+
351
+ const rolledBackMigrations: string[] = [];
352
+
353
+ for (const row of appliedDescending) {
354
+ const migration: Migration = migrationForVersion(row.version);
355
+ migration.down(db);
356
+ db.query("DELETE FROM schema_migrations WHERE version = ?;").run(row.version);
357
+ rolledBackMigrations.push(migration.name);
358
+ }
359
+
360
+ return {
361
+ fromVersion,
362
+ toVersion: targetVersion,
363
+ rolledBack: appliedDescending.length,
364
+ rolledBackMigrations,
365
+ };
366
+ });
46
367
  }
@@ -5,6 +5,7 @@ export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
5
5
  `
6
6
  CREATE TABLE IF NOT EXISTS schema_migrations (
7
7
  id INTEGER PRIMARY KEY AUTOINCREMENT,
8
+ version INTEGER NOT NULL UNIQUE,
8
9
  name TEXT NOT NULL UNIQUE,
9
10
  applied_at INTEGER NOT NULL
10
11
  );
@@ -6,6 +6,7 @@ export interface MutableRow {
6
6
 
7
7
  export interface MigrationRecord {
8
8
  readonly id: number;
9
+ readonly version: number;
9
10
  readonly name: string;
10
11
  readonly applied_at: number;
11
12
  }
@@ -41,11 +41,19 @@ interface EventPayload {
41
41
  readonly fields?: Record<string, unknown>;
42
42
  }
43
43
 
44
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value);
46
+ }
47
+
44
48
  function parsePayload(rawPayload: string): EventPayload {
45
49
  try {
46
50
  const parsed: unknown = JSON.parse(rawPayload);
47
51
 
48
- if (typeof parsed === "object" && parsed !== null) {
52
+ if (isObjectRecord(parsed)) {
53
+ if ("fields" in parsed && !isObjectRecord(parsed.fields)) {
54
+ return {};
55
+ }
56
+
49
57
  return parsed as EventPayload;
50
58
  }
51
59
  } catch {
package/AGENTS.md DELETED
@@ -1,54 +0,0 @@
1
- # AGENTS.md
2
-
3
- This file contains guidelines for agents.
4
-
5
- ## Mandatory: Atomic Commit Policy
6
-
7
- Every code change MUST be followed by an immediate commit.
8
-
9
- **Commit format**:
10
- ```
11
- <imperative verb> <what changed> ← Line 1: max 50 chars
12
- <blank line> ← Line 2: blank
13
- <why/context, one point per line> ← Body: max 72 chars per line
14
- ```
15
-
16
- **Rules**:
17
- 1. One commit per logical change
18
- 2. Small, atomic commits - one file per commit preferred
19
- 3. Never batch unrelated changes
20
-
21
- **Enforcement**:
22
- - After any file modification, stop and commit before modifying another file
23
- - Run `git status --short` after each commit to verify clean tree
24
- - At milestones: run `bun run build && bun run lint && bun run test`
25
-
26
- **Anti-patterns**:
27
- - ❌ Multiple unrelated files in one commit
28
- - ❌ Generic messages like "Update file", "WIP", "Fix stuff"
29
- - ❌ Commit message over 50 chars on first line
30
-
31
- ## Coding Conventions
32
-
33
- For Bun/TypeScript code:
34
- - **Imports**: Group order (stdlib → third-party → local), explicit named imports, remove unused
35
- - **Formatting**: Consistent quotes, avoid mixed tabs/spaces
36
- - **Types**: Prefer explicit types at API boundaries, avoid `any` unless justified
37
- - **Naming**: `camelCase` (vars/functions), `PascalCase` (types), `UPPER_SNAKE_CASE` (constants)
38
-
39
- **Error handling**:
40
- - Never silently swallow errors
41
- - Include actionable context (operation + endpoint + status code)
42
- - Redact secrets from errors and logs
43
-
44
- ## Agent Behavior
45
-
46
- - Prefer minimal, targeted edits; avoid broad rewrites
47
- - Preserve existing examples unless fixing factual issues
48
- - For CLI changes, prioritize startup speed and low-latency UX
49
- - Keep commands compatible with macOS and Linux shells
50
-
51
- ## Security
52
-
53
- - Never commit secrets (tokens, credentials)
54
- - Redact secrets from errors and logs
package/CONTRIBUTING.md DELETED
@@ -1,18 +0,0 @@
1
- # Contributing to Trekoon
2
-
3
- ## No-copy implementation policy
4
-
5
- Trekoon is implemented in this repository root. The `trekker/` directory is
6
- reference-only.
7
-
8
- - Do not copy code or files from `trekker/` into root `src/`.
9
- - Do not mirror file layout one-to-one from `trekker/`.
10
- - Write root implementation code directly, with Trekoon-native structure.
11
-
12
- ## PR checklist
13
-
14
- - [ ] Any new logic was written directly in root project files.
15
- - [ ] Changes were reviewed for suspiciously identical blocks/comments versus
16
- `trekker/` reference code.
17
- - [ ] Sync-related writes preserve git context (`branch`, `head`, `worktree`).
18
- - [ ] README command/flag examples match actual implemented CLI behavior.
package/bun.lock DELETED
@@ -1,29 +0,0 @@
1
- {
2
- "lockfileVersion": 1,
3
- "configVersion": 0,
4
- "workspaces": {
5
- "": {
6
- "name": "trekoon",
7
- "dependencies": {
8
- "@toon-format/toon": "^2.1.0",
9
- },
10
- "devDependencies": {
11
- "@types/bun": "^1.3.9",
12
- "typescript": "^5.9.3",
13
- },
14
- },
15
- },
16
- "packages": {
17
- "@toon-format/toon": ["@toon-format/toon@2.1.0", "", {}, "sha512-JwWptdF5eOA0HaQxbKAzkpQtR4wSWTEfDlEy/y3/4okmOAX1qwnpLZMmtEWr+ncAhTTY1raCKH0kteHhSXnQqg=="],
18
-
19
- "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
20
-
21
- "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
22
-
23
- "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
24
-
25
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
26
-
27
- "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
28
- }
29
- }
@@ -1,101 +0,0 @@
1
- import { mkdtempSync, rmSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
-
5
- import { afterEach, describe, expect, test } from "bun:test";
6
-
7
- import { runDep } from "../../src/commands/dep";
8
- import { runEpic } from "../../src/commands/epic";
9
- import { runSubtask } from "../../src/commands/subtask";
10
- import { runTask } from "../../src/commands/task";
11
-
12
- const tempDirs: string[] = [];
13
-
14
- function createWorkspace(): string {
15
- const workspace = mkdtempSync(join(tmpdir(), "trekoon-dep-"));
16
- tempDirs.push(workspace);
17
- return workspace;
18
- }
19
-
20
- afterEach((): void => {
21
- while (tempDirs.length > 0) {
22
- const next = tempDirs.pop();
23
- if (next) {
24
- rmSync(next, { recursive: true, force: true });
25
- }
26
- }
27
- });
28
-
29
- async function createTaskGraph(cwd: string): Promise<{ taskA: string; taskB: string; subtask: string }> {
30
- const epic = await runEpic({
31
- cwd,
32
- mode: "human",
33
- args: ["create", "--title", "Roadmap", "--description", "desc"],
34
- });
35
- const epicId = (epic.data as { epic: { id: string } }).epic.id;
36
-
37
- const taskA = await runTask({
38
- cwd,
39
- mode: "human",
40
- args: ["create", "--epic", epicId, "--title", "Task A", "--description", "desc a"],
41
- });
42
- const taskAId = (taskA.data as { task: { id: string } }).task.id;
43
-
44
- const taskB = await runTask({
45
- cwd,
46
- mode: "human",
47
- args: ["create", "--epic", epicId, "--title", "Task B", "--description", "desc b"],
48
- });
49
- const taskBId = (taskB.data as { task: { id: string } }).task.id;
50
-
51
- const subtask = await runSubtask({
52
- cwd,
53
- mode: "human",
54
- args: ["create", "--task", taskBId, "--title", "Subtask"],
55
- });
56
-
57
- return {
58
- taskA: taskAId,
59
- taskB: taskBId,
60
- subtask: (subtask.data as { subtask: { id: string } }).subtask.id,
61
- };
62
- }
63
-
64
- describe("dep command", (): void => {
65
- test("supports add/list/remove", async (): Promise<void> => {
66
- const cwd = createWorkspace();
67
- const nodes = await createTaskGraph(cwd);
68
-
69
- const added = await runDep({ cwd, mode: "human", args: ["add", nodes.taskA, nodes.subtask] });
70
- expect(added.ok).toBeTrue();
71
-
72
- const listed = await runDep({ cwd, mode: "human", args: ["list", nodes.taskA] });
73
- expect(listed.ok).toBeTrue();
74
- expect((listed.data as { dependencies: unknown[] }).dependencies.length).toBe(1);
75
-
76
- const removed = await runDep({ cwd, mode: "human", args: ["remove", nodes.taskA, nodes.subtask] });
77
- expect(removed.ok).toBeTrue();
78
- expect((removed.data as { removed: number }).removed).toBe(1);
79
- });
80
-
81
- test("enforces referential checks for task/subtask nodes", async (): Promise<void> => {
82
- const cwd = createWorkspace();
83
- const nodes = await createTaskGraph(cwd);
84
-
85
- const bad = await runDep({ cwd, mode: "human", args: ["add", nodes.taskA, "missing-node-id"] });
86
- expect(bad.ok).toBeFalse();
87
- expect(bad.error?.code).toBe("not_found");
88
- });
89
-
90
- test("detects dependency cycles", async (): Promise<void> => {
91
- const cwd = createWorkspace();
92
- const nodes = await createTaskGraph(cwd);
93
-
94
- const first = await runDep({ cwd, mode: "human", args: ["add", nodes.taskA, nodes.taskB] });
95
- expect(first.ok).toBeTrue();
96
-
97
- const cycle = await runDep({ cwd, mode: "human", args: ["add", nodes.taskB, nodes.taskA] });
98
- expect(cycle.ok).toBeFalse();
99
- expect(cycle.error?.code).toBe("invalid_dependency");
100
- });
101
- });