From ca8796bd974dfa6d2c6e4c06c84bbe7c5be78a0b Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 1 Jul 2026 15:52:58 +0000 Subject: [PATCH 1/5] feat(task-manager): add durable task system with SQLite repository + UUID ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce @ai-devkit/task-manager as the durable unit for development/debug work. A task has a stable id and encapsulates artifacts, evidence, progress, blockers, lifecycle phase, agent attribution, branch/worktree/PR links, and an append-only event history. Structure (mirrors @ai-devkit/memory): src/ task.types.ts # public types: Actor, Task, TaskEvent, payloads task.errors.ts # TaskError hierarchy + isTaskEventType guard task.ids.ts # raw UUIDv4 id generators (crypto.randomUUID) actor-resolver.ts # resolveCurrentActor (env + process) task.repository.ts # TaskRepository — task/event persistence (SQLite) task.service.ts # TaskService (public consume-only surface) database/ # connection.ts (getDatabase/closeDatabase singleton, # WAL, busy_timeout), schema.ts, migrations/ index.ts Persistence: - SQLite at ~/.ai-devkit/tasks.db via better-sqlite3, mirroring @ai-devkit/memory: process-wide getDatabase() singleton, versioned migrations via user_version, SQL assets copied to dist by the build; tests reset with closeDatabase(). - TaskRepository owns the task/event SQL. A tasks table holds snapshots (full Task JSON + indexed columns); a task_events table is the append-only history. - No SPI / swappable-backend abstraction: TaskRepository is the single concrete persistence class. IDs: raw UUIDv4 (crypto.randomUUID()) stored as TEXT — globally unique, no prefix, suffix, or collision checks. Generated in the service layer (like memory). CLI: ai-devkit task create/list/show/update/phase/status/progress/next/blocker/ evidence/artifact/assign/note/event/close, with --json and attribution. Tests: 890 passing repo-wide; task-manager coverage 90% stmt / 79% branch. Public API: Task/TaskEvent shapes, TaskService methods, CLI commands, and the 14 task.* event types are stable. The only format change vs the prior draft is that ids are now raw UUIDs (no task_/evt_ prefix); the persistence class is named TaskRepository (concrete, in task.repository.ts). --- .../design/2026-07-01-feature-task-system.md | 318 ++++++++ .../2026-07-01-feature-task-system.md | 107 +++ .../2026-07-01-feature-task-system.md | 98 +++ .../2026-07-01-feature-task-system.md | 111 +++ .../testing/2026-07-01-feature-task-system.md | 60 ++ package-lock.json | 33 +- packages/cli/package.json | 1 + .../cli/src/__tests__/commands/task.test.ts | 399 ++++++++++ packages/cli/src/cli.ts | 2 + packages/cli/src/commands/task.ts | 523 ++++++++++++ packages/task-manager/.eslintrc.json | 21 + packages/task-manager/.gitignore | 26 + packages/task-manager/.swcrc | 21 + packages/task-manager/README.md | 84 ++ packages/task-manager/package.json | 63 ++ packages/task-manager/project.json | 29 + packages/task-manager/src/actor-resolver.ts | 53 ++ .../task-manager/src/database/connection.ts | 127 +++ packages/task-manager/src/database/index.ts | 12 + .../src/database/migrations/001_initial.sql | 36 + packages/task-manager/src/database/schema.ts | 74 ++ packages/task-manager/src/index.ts | 72 ++ packages/task-manager/src/task.errors.ts | 96 +++ packages/task-manager/src/task.ids.ts | 34 + packages/task-manager/src/task.repository.ts | 159 ++++ packages/task-manager/src/task.service.ts | 752 ++++++++++++++++++ packages/task-manager/src/task.types.ts | 242 ++++++ .../integration/add-event-coverage.test.ts | 314 ++++++++ .../tests/integration/service.test.ts | 374 +++++++++ .../tests/integration/task.repository.test.ts | 266 +++++++ .../tests/unit/actor-resolver.test.ts | 64 ++ .../task-manager/tests/unit/errors.test.ts | 72 ++ packages/task-manager/tests/unit/ids.test.ts | 29 + packages/task-manager/tsconfig.json | 34 + packages/task-manager/vitest.config.ts | 20 + 35 files changed, 4724 insertions(+), 2 deletions(-) create mode 100644 docs/ai/design/2026-07-01-feature-task-system.md create mode 100644 docs/ai/implementation/2026-07-01-feature-task-system.md create mode 100644 docs/ai/planning/2026-07-01-feature-task-system.md create mode 100644 docs/ai/requirements/2026-07-01-feature-task-system.md create mode 100644 docs/ai/testing/2026-07-01-feature-task-system.md create mode 100644 packages/cli/src/__tests__/commands/task.test.ts create mode 100644 packages/cli/src/commands/task.ts create mode 100644 packages/task-manager/.eslintrc.json create mode 100644 packages/task-manager/.gitignore create mode 100644 packages/task-manager/.swcrc create mode 100644 packages/task-manager/README.md create mode 100644 packages/task-manager/package.json create mode 100644 packages/task-manager/project.json create mode 100644 packages/task-manager/src/actor-resolver.ts create mode 100644 packages/task-manager/src/database/connection.ts create mode 100644 packages/task-manager/src/database/index.ts create mode 100644 packages/task-manager/src/database/migrations/001_initial.sql create mode 100644 packages/task-manager/src/database/schema.ts create mode 100644 packages/task-manager/src/index.ts create mode 100644 packages/task-manager/src/task.errors.ts create mode 100644 packages/task-manager/src/task.ids.ts create mode 100644 packages/task-manager/src/task.repository.ts create mode 100644 packages/task-manager/src/task.service.ts create mode 100644 packages/task-manager/src/task.types.ts create mode 100644 packages/task-manager/tests/integration/add-event-coverage.test.ts create mode 100644 packages/task-manager/tests/integration/service.test.ts create mode 100644 packages/task-manager/tests/integration/task.repository.test.ts create mode 100644 packages/task-manager/tests/unit/actor-resolver.test.ts create mode 100644 packages/task-manager/tests/unit/errors.test.ts create mode 100644 packages/task-manager/tests/unit/ids.test.ts create mode 100644 packages/task-manager/tsconfig.json create mode 100644 packages/task-manager/vitest.config.ts diff --git a/docs/ai/design/2026-07-01-feature-task-system.md b/docs/ai/design/2026-07-01-feature-task-system.md new file mode 100644 index 00000000..e0691614 --- /dev/null +++ b/docs/ai/design/2026-07-01-feature-task-system.md @@ -0,0 +1,318 @@ +--- +phase: design +title: System Design & Architecture +description: Define the technical architecture, components, and data models +--- + +# System Design & Architecture — Task System + +## Architecture Overview + +```mermaid +graph TD + CLI["ai-devkit task CLI"] --> SVC["TaskService"] + Skills["dev-lifecycle / verify skills"] -->|CLI or lib| SVC + SVC -->|uses| REPO["TaskRepository"] + REPO --> SQL["SQLite (~/.ai-devkit/tasks.db)"] + SQL --> TASKS["tasks table: snapshots"] + SQL --> EVENTS["task_events table: append-only history"] +``` + +- New package `@ai-devkit/task-manager`, structured like `@ai-devkit/memory` and consumed by + the `ai-devkit` CLI the same way memory is. +- **Layering:** CLI and skills call `TaskService`, which uses `TaskRepository` for + persistence. Callers never touch the database directly. +- **SQLite** (via `better-sqlite3`, the same library `@ai-devkit/memory` uses) stores a + `tasks` table of snapshots and a `task_events` table for the append-only event history. + +## Data Models + +### Actor (attribution unit — reused on events and as task owner) + +```ts +interface Actor { + agentId?: string; // agent id from the agent-manager registry, if any + agentType?: string; // e.g. "claude" | "codex" | "pi" | "human" + pid?: number; + sessionId?: string; // agent session id, if known +} +``` + +### Task (snapshot — authoritative for reads) + +```ts +type TaskStatus = "open" | "active" | "blocked" | "completed" | "abandoned"; +type LifecyclePhase = string | null; // free-form; recommended: +// requirements | design | planning | implementation | testing | review + +interface TaskProgress { text: string | null; percent: number | null; } // percent 0..100 + +interface TaskLinks { + branch?: string; worktree?: string; pr?: string; commits?: string[]; +} + +interface TaskBlocker { + blockerId: string; // raw UUIDv4 + text: string; + status: "open" | "resolved"; + raisedAt: string; // ISO 8601 + resolvedAt: string | null; + raisedBy: Actor | null; +} + +interface TaskEvidence { + evidenceId: string; // raw UUIDv4 + command: string | null; + exitCode: number | null; + passed: boolean; // true = pass/success, false = fail + summary: string | null; + artifacts: string[]; // artifactId refs and/or free path strings + recordedAt: string; + actor: Actor | null; +} + +interface TaskArtifact { + artifactId: string; // raw UUIDv4 + path: string; // reference only — never copied into storage + kind: string | null; // "log" | "report" | "diff" | "screenshot" | ... + description: string | null; + addedAt: string; +} + +interface Task { + // identity + taskId: string; // raw UUIDv4; immutable, never reused + title: string; + summary: string | null; + feature: string | null; // kebab-case feature key (nullable for ad-hoc tasks) + // state + status: TaskStatus; + phase: LifecyclePhase; + phaseEnteredAt: string | null; + progress: TaskProgress; + nextStep: string | null; + blockers: TaskBlocker[]; + evidence: TaskEvidence[]; + artifacts: TaskArtifact[]; + // ownership / links + attribution: Actor | null; // current owner; per-event emitter is on each event + links: TaskLinks; + tags: string[]; + meta: Record; // free-form extras + // bookkeeping + createdAt: string; + updatedAt: string; + createdBy: Actor | null; + eventCount: number; // cached count derived from task_events + lastEventAt: string | null; // cached, derived +} +``` + +All fields are persisted in the task snapshot (the `tasks` table stores the full Task as +JSON plus indexed query columns) and authoritative for reads. The full event timeline is +read on demand from the `task_events` table via `getEvents()`; `eventCount`/`lastEventAt` +are cached on the snapshot for cheap listing. + +### TaskEvent (one row in the `task_events` table) + +```ts +interface TaskEvent { + eventId: string; // raw UUIDv4 + taskId: string; + ts: string; // ISO 8601 + type: TaskEventType; // see table below + actor: Actor | null; // who emitted this event (auto-resolved if omitted by caller) + payload: Record; // shape depends on type +} +``` + +### Event types + +| `type` | Mutates snapshot? | Payload shape | +|---|---|---| +| `task.created` | yes (init) | `{ title, feature?, summary?, status, phase? }` | +| `task.updated` | yes | `{ patch: Partial, fields: string[] }` (title/summary/tags/links/meta) | +| `task.phase.set` | yes | `{ phase, previous? }` | +| `task.status.set` | yes | `{ status, previous? }` | +| `task.progress.set` | yes | `{ text?, percent? }` | +| `task.next_step.set` | yes | `{ step: string \| null }` | +| `task.blocker.add` | yes | `{ blockerId, text }` | +| `task.blocker.resolve` | yes | `{ blockerId }` | +| `task.evidence.add` | yes | `{ evidenceId, command?, exitCode?, passed, summary?, artifacts? }` | +| `task.artifact.add` | yes | `{ artifactId, path, kind?, description? }` | +| `task.attribution.set` | yes | `{ agentId?, agentType?, pid?, sessionId? }` | +| `task.note.append` | no (event-only) | `{ text }` | +| `task.custom` | no (event-only) | `{ name: string, data?: object }` — observability escape hatch; never mutates state | +| `task.closed` | yes | `{ status: "completed" \| "abandoned" }` | + +Stateful types mutate the snapshot **and** append the event. `task.note.append` and +`task.custom` append the event only. + +## API Design + +### `TaskService` (public API consumed by CLI and skills) + +All methods async. Every mutator accepts an optional `opts?: { actor?: Actor }` to override +attribution; if omitted, the service auto-resolves the current agent (see Attribution). Every +mutator returns the updated `Task` snapshot unless noted. + +```ts +class TaskService { + constructor(repository: TaskRepository); + + // identity / lookup + create(input: { title: string; feature?: string; summary?: string; + phase?: LifecyclePhase; tags?: string[]; links?: TaskLinks; + meta?: Record; actor?: Actor; }): Promise; + get(taskId: string): Promise; // throws TaskNotFoundError + resolveTask(ref: string | { feature: string } | { taskId: string }): Promise; + list(filter?: { feature?: string; status?: TaskStatus; phase?: LifecyclePhase; + limit?: number }): Promise; + + // generic scalar patch -> task.updated + update(taskId: string, patch: { title?: string; summary?: string; tags?: string[]; + links?: Partial; meta?: Record; }, + opts?): Promise; + + // dedicated state setters (each emits its specific event) + setPhase(taskId: string, phase: LifecyclePhase, opts?): Promise; + setStatus(taskId: string, status: TaskStatus, opts?): Promise; + setProgress(taskId: string, progress: { text?: string | null; percent?: number | null }, opts?): Promise; + setNextStep(taskId: string, step: string | null, opts?): Promise; + + // blockers / evidence / artifacts / attribution + addBlocker(taskId: string, input: { text: string }, opts?): Promise<{ task: Task; blockerId: string }>; + resolveBlocker(taskId: string, blockerId: string, opts?): Promise; + addEvidence(taskId: string, input: { command?: string; exitCode?: number; passed: boolean; + summary?: string; artifacts?: string[] }, opts?): Promise<{ task: Task; evidenceId: string }>; + addArtifact(taskId: string, input: { path: string; kind?: string; description?: string }, opts?): Promise<{ task: Task; artifactId: string }>; + setAttribution(taskId: string, actor: Actor, opts?): Promise; + + // notes / lifecycle + addNote(taskId: string, text: string, opts?): Promise; // event-only + close(taskId: string, status: "completed" | "abandoned", opts?): Promise; + + // low-level event append (used internally by the setters above) + addEvent(taskId: string, type: TaskEventType, payload: Record, opts?): Promise; + getEvents(taskId: string, filter?: { type?: TaskEventType; limit?: number }): Promise; +} + +function createTaskService(dbPath?: string): TaskService; +``` + +`addEvent` applies the matching snapshot mutation for a stateful type then appends; for +`task.note.append` / `task.custom` it appends only. Callers use +`addEvent(id, "task.custom", { name, data })` to record observability without changing state. + +### `resolveTask` reference resolution + +`resolveTask(ref)` and the CLI `` argument accept, in order: +1. a full `taskId` (raw UUID); +2. a unique `taskId` **prefix** — error if ambiguous; +3. a **feature key** → the latest **non-terminal** task whose `feature === ref`. + +This powers "the current task for this feature" for dev-lifecycle/verify without requiring +callers to remember ids. + +## CLI (`ai-devkit task ...`) + +All commands accept global flags `--store ` (override tasks dir), `--json` (machine +output), and attribution overrides `--agent `, `--agent-type `, `--pid `, +`--session `. Env: `AI_DEVKIT_TASKS_DB`. `` resolves per `resolveTask` above. + +| Command | Flags | Emits | +|---|---|---| +| `task create` | `--title ` `--feature ` `--summary ` `--phase

` `--tags ` `--branch` `--worktree` `--pr` | `task.created` | +| `task list` | `--feature` `--status` `--phase` `--limit ` `--json` (table default) | — | +| `task show ` | `--events` `--json` | — | +| `task update ` | `--title` `--summary` `--tags` `--branch` `--worktree` `--pr` | `task.updated` | +| `task phase ` | — | `task.phase.set` | +| `task status ` | — | `task.status.set` | +| `task progress ` | `--text ` `--percent ` `--clear` | `task.progress.set` | +| `task next ` | `--clear` | `task.next_step.set` | +| `task blocker add ` | — | `task.blocker.add` | +| `task blocker resolve ` | — | `task.blocker.resolve` | +| `task evidence ` | `--command ` `--exit-code ` `--passed` `--failed` `--summary ` `--artifact ...` | `task.evidence.add` | +| `task artifact ` | `--kind ` `--description ` | `task.artifact.add` | +| `task assign ` | `--agent ` `--agent-type ` `--pid ` `--session ` | `task.attribution.set` | +| `task note ` | — | `task.note.append` | +| `task event ` | `--type ` `--payload ` | `task.custom` / low-level addEvent | +| `task close [completed\|abandoned]` | — | `task.closed` | + +## Evidence / Artifact Model + +- **Evidence** records a verification result inline: `command`, `exitCode`, `passed` (required, + boolean), `summary` (inline text — the durable path for output), and `artifacts` (refs). The + `verify` skill calls e.g. `task evidence --command "nx test" --exit-code 0 --passed + --summary "all green"`. +- **Artifacts** are **references only** (`path` + optional `kind`/`description`). The repository + never copies files, keeping tasks lightweight. To capture text durably, put it in evidence + `summary`; to point at a file, add an artifact ref. + +## Attribution Model + +- Per-event `actor` records who **emitted** the event; task `attribution` records the **current + owner** (set via `task.attribution.set` / `task assign`). +- When `actor` is omitted, the service auto-resolves in order: + 1. explicit flags/opts (`--agent*`); + 2. env (`AI_DEVKIT_AGENT_ID`, `AI_DEVKIT_AGENT_TYPE`, `AI_DEVKIT_SESSION_ID`, + `AI_DEVKIT_AGENT_PID`); + 3. `process.pid`. + +## Feature ↔ Task ↔ Phase + +- **One task per feature** is the default model; `phase` is a single field on the task that + advances through the lifecycle. Callers update the feature task's phase field + (`task.phase.set`), not one task per phase. +- `feature` is optional (ad-hoc debug tasks may omit it). Multiple tasks may share a feature + key; `resolveTask(feature)` returns the latest **non-terminal** one. No parent/child + hierarchy in MVP. +- `phase` is a free-form string (recommended: `requirements|design|planning|implementation| + testing|review`) so structured-debug and custom workflows are unconstrained. + +## Storage Layout + +Tasks are stored in a single SQLite database at `~/.ai-devkit/tasks.db`: + +- **`tasks`** — one row per task: `task_id` (PK), `snapshot` (the full Task JSON), plus + indexed `feature`/`status`/`phase`/`created_at`/`updated_at` columns for queryability and + DB inspection. +- **`task_events`** — append-only event history: one row per event (`id` for stable insertion + order, `event_id` unique natural key; `actor`/`payload` stored as JSON text). + +Task and event ids are raw UUIDv4 strings from Node `crypto.randomUUID()`, stored as TEXT +(36 chars: `8-4-4-4-12` lowercase hex) — globally unique with no prefix, coordination, or collision +checks. Override the DB path with `--store`, `AI_DEVKIT_TASKS_DB`, or the `TaskRepository` / +`DatabaseConnection` constructor. Only `TaskRepository` reads/writes the database; callers use +`TaskService` or the CLI. + +## Design Decisions & Trade-offs + +- **Snapshot + append-only events (not pure event sourcing):** reads are a single indexed + query; events give a full audit trail. Event-sourced rebuild is a documented future capability + (events already carry enough to reconstruct), deferred to keep MVP simple. +- **SQLite persistence:** `better-sqlite3` (same as `@ai-devkit/memory`) with WAL + + `busy_timeout` for safe concurrent local access. `TaskRepository` owns the task/event SQL, + mirroring memory's connection lifecycle (`getDatabase()`/`closeDatabase()`). +- **Raw UUID ids:** task/event (and blocker/evidence/artifact) ids are `crypto.randomUUID()` + values stored as TEXT — globally unique with no central counter, prefix, or collision checks. + Creation order is tracked by the indexed `created_at` column, not the id. +- **Dedicated event types over one generic type:** each task state change has a precise event; + `task.custom` remains for anything not anticipated. +- **`resolveTask` accepts feature key:** dev-lifecycle/verify find "the current task" without + threading ids through every skill. + +## Non-Functional Requirements + +- **Atomicity:** each mutation is a single SQLite statement (autocommit); snapshot writes and + event appends are independent atomic operations. WAL + `synchronous=NORMAL` keep reads + non-blocking while protecting against crashes. +- **Concurrency:** WAL mode + `busy_timeout=5000ms` allow safe concurrent readers and serialized + writers across local processes (an upgrade over naive file locking). +- **Portability:** a single portable `tasks.db` SQLite file under `~/.ai-devkit/`, inspectable + with any SQLite tool. +- **Performance:** indexed `task_id`/`feature`/`status`/`phase` lookups; MVP targets hundreds of + tasks. (`list` still reads all snapshots in-memory for sort/filter; repository-level filtering is a + future optimization that needs no API change.) +- **Validation:** strict runtime validation (title non-empty, percent 0..100, known status) + with typed errors (`TaskNotFoundError`, `TaskValidationError`, `AmbiguousTaskRefError`). diff --git a/docs/ai/implementation/2026-07-01-feature-task-system.md b/docs/ai/implementation/2026-07-01-feature-task-system.md new file mode 100644 index 00000000..9d1565ed --- /dev/null +++ b/docs/ai/implementation/2026-07-01-feature-task-system.md @@ -0,0 +1,107 @@ +--- +phase: implementation +title: Implementation Guide +description: Technical implementation notes, patterns, and code guidelines +--- + +# Implementation Guide — Task System + +## Development Setup +**How do we get started?** + +- Node `>=20.20.0`, monorepo with nx + swc + vitest (no new tooling introduced). +- Work in the `feature-task-system` worktree at `.worktrees/feature-task-system`. +- `npm ci` bootstraps the workspace; the new package is symlinked under + `node_modules/@ai-devkit/task-manager` automatically. + +## Code Structure +**How is the code organized?** + +New package `packages/task-manager` mirrors `@ai-devkit/memory`: + +``` +packages/task-manager/ + src/ + task.types.ts # public types: Actor, Task, TaskEvent, TaskEventType union, payloads + task.errors.ts # TaskError hierarchy + isTaskEventType guard + task.ids.ts # raw UUID id generators (crypto.randomUUID) + actor-resolver.ts # resolveCurrentActor (env + process), ATTRIB_ENV + task.repository.ts # TaskRepository — task/event persistence (SQLite) + task.service.ts # TaskService (public consume-only surface) + database/ # connection.ts (WAL/migrations), schema.ts, migrations/001_initial.sql + index.ts # public exports (import path @ai-devkit/task-manager) + tests/ + unit/ # task.ids, actor-resolver, task.errors + integration/ # task.repository, service, add-event coverage, repository errors +``` + +Structure follows `@ai-devkit/memory`: a `database/` module (connection/schema/migrations) +with the business module split into `task.repository.ts` (persistence) and `task.service.ts` +(logic), mirroring memory's split between `database/` and `handlers/`. + +CLI: `packages/cli/src/commands/task.ts` registers `ai-devkit task ...`; wired in `cli.ts`. +Test: `packages/cli/src/__tests__/commands/task.test.ts`. + +## Implementation Notes +**Key technical details to remember:** + +### Core Features +- **Public API surface:** the exported type names, field names, and `TaskEventType` union + strings in `src/index.ts` are the package's public API; keep them stable for consumers. +- **Snapshot + events:** the `tasks` table holds the authoritative snapshot (full Task JSON + + indexed columns); the `task_events` table is the append-only audit trail. Stateful event + types mutate the snapshot **and** append; `task.note.append` / `task.custom` are event-only. +- **resolveTask resolution order:** full id → unique id prefix (error if ambiguous) → feature + key (latest non-terminal task). Powers dev-lifecycle/verify "current task for feature". +- **addEvent escape hatch:** applies the matching snapshot mutation for stateful types, else + appends only. Used by typed setters internally and by callers for `task.custom` observability. + +### Patterns & Best Practices +- Persistence: `TaskService` uses `TaskRepository` for all task/event storage. +- SQLite mirrors `@ai-devkit/memory`: `better-sqlite3`, WAL + `busy_timeout` + + `synchronous=NORMAL`, versioned migrations tracked via the `user_version` pragma. + `TaskRepository` uses the process-wide `getDatabase()` singleton (same pattern as + memory) and only holds a DB path; tests reset the singleton with `closeDatabase()` between + cases to switch database files. +- **Ids are raw UUIDs** (`` (raw UUIDv4), …) from Node `crypto.randomUUID()`, generated in + the service layer (like memory generates ids in its handlers, not in the DB layer). No + collision checks or central counters are needed. +- Every mutator accepts `opts?{actor}` and auto-resolves attribution when omitted. +- List ordering is `createdAt` desc then `taskId` desc (deterministic for same-second tasks). + +## Integration Points +**How do pieces connect?** + +- CLI → `TaskService` → `TaskRepository` → SQLite (`~/.ai-devkit/tasks.db`). +- Skills (`dev-lifecycle`, `verify`, `structured-debug`) emit via `ai-devkit task ...`. +- Storage default `~/.ai-devkit/tasks.db`; override via `--store`, `AI_DEVKIT_TASKS_DB`, or + the `TaskRepository` / `DatabaseConnection` constructor arg. + +## Error Handling +**How do we handle failures?** + +- Typed errors: `TaskNotFoundError`, `TaskValidationError`, `AmbiguousTaskRefError`, + `TaskResourceNotFoundError`, `TaskRepositoryError`, `UnknownEventTypeError` (all extend `TaskError` + with `.code`/`.toJSON()`). +- Atomicity: each snapshot write / event append is a single SQLite statement (autocommit), + so a crash never leaves a half-written row. WAL + `synchronous=NORMAL` protect durability. +- All repository I/O errors (including connection-open and JSON parse failures) are wrapped as + `TaskRepositoryError`. + +## Performance Considerations +**How do we keep it fast?** + +- MVP targets hundreds of tasks. `task_id`/`feature`/`status`/`phase` lookups are indexed. + (`list` still loads all snapshots in-memory for sort/filter; repository-level filtering is a + future optimization needing no API change.) +- `eventCount`/`lastEventAt` are cached on the snapshot to avoid re-reading events for listing. +- WAL keeps reads non-blocking while writers serialize. + +## Security Notes +**What security measures are in place?** + +- Strict input validation (non-empty title, kebab-case feature, percent 0..100, status enum). +- Artifacts are references only — the repository never copies user files, so no path + injection of file contents into the database. +- Attribution is best-effort local metadata (agent id/type/pid/session); no auth/permissions in + MVP (single-user local tool, documented limitation). diff --git a/docs/ai/planning/2026-07-01-feature-task-system.md b/docs/ai/planning/2026-07-01-feature-task-system.md new file mode 100644 index 00000000..fb7b443c --- /dev/null +++ b/docs/ai/planning/2026-07-01-feature-task-system.md @@ -0,0 +1,98 @@ +--- +phase: planning +title: Project Planning & Task Breakdown +description: Break down work into actionable tasks and estimate timeline +--- + +# Project Planning & Task Breakdown — Task System + +## Milestones +**What are the major checkpoints?** + +- [x] **M1 — Core package + SQLite repository.** `@ai-devkit/task-manager` with types, + `TaskService`, `TaskRepository`, WAL/migrations, append-only events. +- [x] **M2 — CLI surface.** `ai-devkit task ...` wired into the CLI; `--json` + attribution. +- [x] **M3 — Tests, docs, simplify, validate.** Unit + integration coverage, README, simplify + pass, green build/lint/test, commit, PR. + +## Task Breakdown +**What specific work needs to be done?** + +### M1: Core package + SQLite repository +- [ ] **1.1 Scaffold package.** `packages/task-manager/{package.json, tsconfig.json, + project.json, vitest.config.ts, .eslintrc.json, .swcrc, .gitignore}` mirroring + `@ai-devkit/memory`. Name `@ai-devkit/task-manager`, `type: module`, strict TS. + Validation: `nx lint task-manager` runs (even if no src yet). +- [ ] **1.2 Types module** (`src/task.types.ts`). `Actor`, `TaskStatus`, `LifecyclePhase`, + `TaskProgress`, `TaskLinks`, `TaskBlocker`, `TaskEvidence`, `TaskArtifact`, `Task`, + `TaskEventType` (closed union), `TaskEvent`. Type names per the design doc. +- [ ] **1.3 Errors** (`src/task.errors.ts`). `TaskError` base, `TaskNotFoundError`, + `TaskValidationError`, `AmbiguousTaskRefError`, `TaskRepositoryError`. +- [ ] **1.4 IDs + time helpers** (`src/task.ids.ts`). `taskId`/`eventId`/`blockerId`/`evidenceId`/ + `artifactId` generators (raw UUIDv4 via Node `crypto.randomUUID()`). +- [ ] **1.5 Actor resolver** (`src/actor-resolver.ts`). `resolveCurrentActor()`: + flags/env/agent-manager best-effort/null. Pure env+process for MVP (no hard dep on + agent-manager to keep package standalone); agent-manager lookup deferred. +- [ ] **1.6 TaskRepository + database layer** (`src/task.repository.ts`, + `src/database/`). `TaskRepository`: `exists`, `readTask`, `writeTask`, + `listTaskIds`, `readEvents`, `appendEvent`. (Id generation lives in the service, not the + repository — mirroring memory.) + `database/{connection,schema,migrations}` mirrors `@ai-devkit/memory` (WAL, busy_timeout, + versioned migrations via `user_version`). +- [ ] **1.7 TaskService** (`src/task.service.ts`). All service methods; delegates to the repository; + applies snapshot mutation per event type; auto-resolves actor; `resolveTask` + (full id → unique prefix → feature→latest non-terminal). +- [ ] **1.8 Package index** (`src/index.ts`). Export types + `TaskService` + `TaskRepository` + + CLI option interfaces (`TaskCreateOptions`, etc.) for the CLI layer, so consumers import + via `@ai-devkit/task-manager`. + +### M2: CLI surface +- [ ] **2.1 Dependency wire.** Add `@ai-devkit/task-manager` to `packages/cli/package.json` + deps; register `registerTaskCommand(program)` in `cli.ts`. +- [ ] **2.2 task command** (`packages/cli/src/commands/task.ts`). All verbs/flags from the + design doc; `--json`, `--store`, `--agent*` globals; `` via `resolveTask`. +- [ ] **2.3 Output formatting.** `list` table (id/title/status/phase/feature), `show` + pretty + `--events`, `--json` machine output everywhere. + +### M3: Tests, docs, simplify, validate +- [x] **3.1 Unit tests** (`packages/task-manager/tests/unit/`). ids, actor-resolver, + service mutation-per-event, resolveTask resolution order, validation errors. +- [x] **3.2 Integration tests** (`tests/integration/`). TaskRepository round-trip, + migrations, append-only events, addEvent escape hatch coverage, repository error branches. +- [x] **3.3 CLI command tests** (`packages/cli/src/__tests__/commands/task.test.ts`) mirroring + the memory command test pattern (mocked TaskService). +- [x] **3.4 README** (`packages/task-manager/README.md`) + a section in root README. +- [x] **3.5 simplify-implementation pass** on the new code. +- [x] **3.6 Validate**: `nx test`, `nx build`, `nx lint` green for task-manager + cli + repo. +- [ ] **3.7 Commit** (dev-commit) + **PR** (dev-pr). Report URL/SHA/limitations. **Do not merge.** + +## Dependencies +**What needs to happen in what order?** + +- 1.1 → 1.2 → 1.3/1.4/1.5 (parallel) → 1.6 → 1.7 → 1.8 (M1 gate). +- M1 → 2.1 → 2.2 → 2.3 (M2 gate). +- M2 → 3.1/3.2/3.3 (parallel) → 3.4 → 3.5 → 3.6 → 3.7. + +## Timeline & Estimates +**When will things be done?** + +- M1 ~ core; M2 ~ CLI; M3 ~ test/doc/ship. Sequential within milestone; TDD where it adds value + (service/repository logic), validation-after for boilerplate. + +## Risks & Mitigation +**What could go wrong?** + +- **Monorepo build wiring** (`@ai-devkit/` workspace dep) → Mitigation: mirror memory's exact + package.json/project.json shape; verify `nx build cli` resolves the new dep. +- **Overbuilding** (project-management creep) → Mitigation: no hierarchy/boards/permissions; + MVP scope only. + +## Resources Needed +**What do we need to succeed?** + +- Existing `@ai-devkit/memory` package as the structural template. +- `ui`/`withErrorHandler`/`ConfigManager` CLI utilities (reuse, don't rebuild). + +## Notes +- Keep `Task`/`TaskEvent` fields and event-type strings stable for consumers. +- Use the existing `.worktrees/feature-task-system` worktree; branch `feature-task-system`. diff --git a/docs/ai/requirements/2026-07-01-feature-task-system.md b/docs/ai/requirements/2026-07-01-feature-task-system.md new file mode 100644 index 00000000..16b8623b --- /dev/null +++ b/docs/ai/requirements/2026-07-01-feature-task-system.md @@ -0,0 +1,111 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: Clarify the problem space, gather requirements, and define success criteria +--- + +# Requirements & Problem Understanding — Task System + +## Problem Statement +**What problem are we solving?** + +AI coding agents (and the humans steering them) work on features and bugs that span many +steps: requirements, design, code, tests, review, verification. Today each step's context +lives in scattered places — agent transcripts, shell history, memory entries, chat threads — +with no single durable record that ties a unit of work to its artifacts, evidence, progress, +blockers, current lifecycle phase, ownership, and branch/PR links. + +- **Who is affected:** AI agents running the dev-lifecycle (requirements → review), the + `verify` skill needing a place to record evidence, multi-agent setups attributing work, and + humans reviewing what happened on a task. +- **Current situation/workaround:** Work state is implicit. `memory` stores reusable + knowledge but not per-task progress. `agent-manager` tracks running processes, not work + units. There is no durable, queryable task record. + +## Goals & Objectives +**What do we want to achieve?** + +- **Primary goals** + - Introduce a **task** as the durable unit for development/debug work with a **stable task + identifier**. + - Encapsulate per task: **artifacts, evidence, progress, blockers, workflow phase, + ownership/agent attribution, branch/worktree/PR links**, and an **append-only event + history**. + - SQLite storage at `~/.ai-devkit/tasks.db` (snapshot table + append-only events table). + - Provide a service API over the concrete SQLite task repository so callers do not touch + database details. + - CLI UX for create/list/show/update and for adding evidence/progress/blockers/artifacts. +- **Secondary goals** + - Practical integration points for `dev-lifecycle` (phase transitions) and `verify` + (evidence recording). + - A stable **event model** for tracking progress and evidence. +- **Non-goals (explicitly out of scope)** + - Project management: no sprints, milestones, boards, Gantt, multi-project hierarchies, + permissions, or user accounts. + - Full event-sourced rebuild of snapshots from history (events are an append-only audit + trail; snapshot is authoritative for MVP — replay is a documented future capability). + - Concurrent multi-writer locking / networked multi-user sync (single-user local tool). + - A TUI/dashboard for tasks in this MVP. + - Auto-replacing the memory or agent-manager subsystems. + +## User Stories & Use Cases +**How will users interact with the solution?** + +- As an **agent running dev-lifecycle**, I want to create a task when starting a feature so + every subsequent phase, artifact, and decision is anchored to one stable id. +- As an **agent**, I want to advance a task's phase (`requirements` → … → `review`) so the + workflow state is queryable. +- As the **verify** skill, I want to append **evidence** (claim + command/output + pass/fail) + to a task so completion claims are auditable. +- As an **agent**, I want to record **progress** notes and raise/resolve **blockers** so + handoffs between sessions/agents are lossless. +- As a **multi-agent setup**, I want to attribute work (assignees/owner/actor) and link a + branch/worktree/PR so ownership is clear. +- As a **human reviewer**, I want `task show ` (and `--events`) to reconstruct what + happened on a task. + +Edge cases: unknown/short task id lookups, missing task, corrupt snapshot JSON, +duplicate ids (negligible with UUIDs), appending to a task whose snapshot was hand-edited. + +## Success Criteria +**How will we know when we're done?** + +- A task can be created, listed, shown, updated, advanced through phases, and have + artifacts/evidence/progress/blockers added — all via CLI with `--json` machine output. +- Every mutation appends an event to the event history **and** updates the task snapshot. +- `TaskService` keeps callers and the CLI separated from SQLite details; only the task-manager + repository layer reads and writes the database. +- Stable task ids survive renames/updates and are globally unique (raw UUIDs); creation + order is tracked by the indexed `created_at` column, not the id. +- Unit + integration tests pass; `nx test`, `nx build`, `nx lint` are green for the new + package and the CLI. + +## Constraints & Assumptions +**What limitations do we need to work within?** + +- **Technical constraints** + - Node `>=20.20.0`, ESM, TypeScript strict (match existing packages). + - Monorepo + nx + swc build; mirror the `@ai-devkit/memory` package shape. + - SQLite (`better-sqlite3`) must be portable across agents sharing `~/.ai-devkit/tasks.db`. +- **Assumptions** + - Single-writer at a time per task (no fs-level locking for MVP). + - `~/.ai-devkit/` is the shared home data dir (already used by `agent-manager`, + `channel-connector`). + - Task ids are generated once at creation and **never** change. +- **MVP decisions:** + 1. **Task id format:** raw UUIDv4 (`crypto.randomUUID()`), stored as TEXT — + stable, human-readable, globally unique with no collision checks or coordination. + 2. **Storage model:** SQLite at `~/.ai-devkit/tasks.db`; snapshot (in the `tasks` table) + authoritative for reads; `task_events` table append-only audit trail; atomic per-statement + writes; WAL + `busy_timeout` for concurrency. + 3. **Event model:** stateful event types mutate the snapshot; a generic `task.custom` event + type is reserved for tracing/observability and does **not** mutate the snapshot. + 4. **New package** `@ai-devkit/task-manager` mirrors `@ai-devkit/memory`. + 5. **Default DB path** `~/.ai-devkit/tasks.db`; overridable via the `--store` flag or + the `AI_DEVKIT_TASKS_DB` env var. + +## Questions & Open Items +**What do we still need to clarify?** + +None blocking. The MVP boundary above was set from the directive. Deferred to later phases: +event-sourced snapshot rebuild, concurrency locking, task TUI. diff --git a/docs/ai/testing/2026-07-01-feature-task-system.md b/docs/ai/testing/2026-07-01-feature-task-system.md new file mode 100644 index 00000000..1402630f --- /dev/null +++ b/docs/ai/testing/2026-07-01-feature-task-system.md @@ -0,0 +1,60 @@ +--- +phase: testing +title: Testing Strategy +description: Define test coverage, scenarios, and quality gates +--- + +# Testing Strategy — Task System + +## Test Plan + +Unit + integration coverage in `packages/task-manager/tests/` and a command-layer test in +`packages/cli/src/__tests__/commands/`. + +### Scenarios (mapped to requirements success criteria) + +- [x] Task created with stable id + `task.created` event — `service.test.ts` +- [x] create validation (empty title, bad feature key, null feature, initial phase) — `service.test.ts` +- [x] setPhase/setStatus/setProgress/setNextStep mutate snapshot + emit typed events — `service.test.ts` +- [x] status enum validation; percent range validation — `service.test.ts` +- [x] addBlocker → resolveBlocker; unknown blocker error — `service.test.ts` +- [x] addEvidence requires boolean `passed`; records command/exitCode/summary — `service.test.ts` +- [x] addArtifact stores path reference only (never copies file contents) — `service.test.ts` +- [x] setAttribution sets current owner — `service.test.ts` +- [x] addNote is event-only (no state mutation) — `service.test.ts` +- [x] close emits task.closed; idempotent on terminal task — `service.test.ts` +- [x] resolveTask: full id / unique prefix / feature→latest non-terminal / ambiguous error / null — `service.test.ts` +- [x] list filters + deterministic newest-first ordering — `service.test.ts` +- [x] addEvent escape hatch applies every stateful type; task.custom is event-only; unknown type rejected — `add-event-coverage.test.ts` +- [x] getEvents filter by type + limit — `service.test.ts` +- [x] TaskRepository round-trip, upsert, append-only events (insertion order), schema init — `task.repository.test.ts` +- [x] repository error branches wrapped as TaskRepositoryError (corrupt snapshot/payload, invalid path) — `task.repository.test.ts`, `add-event-coverage.test.ts` +- [x] raw UUID id format + uniqueness — `task.ids.test.ts` +- [x] actor auto-resolution env/override/pid — `actor-resolver.test.ts` +- [x] error hierarchy + isTaskEventType guard — `errors.test.ts` +- [x] CLI verbs parse flags, resolve refs, print JSON/table, error on bad input — `task.test.ts` (20 tests) + +### Mocks / Fixtures +- Command tests mock `@ai-devkit/task-manager` (mirrors memory command test pattern). +- Integration tests use a real `TaskRepository` against a per-test temp SQLite DB + (`mkdtempSync` + `tasks.db`); the shared `getDatabase()` singleton is reset with + `closeDatabase()` in `beforeEach`/`afterEach`, and the temp dir is removed in `afterEach`. + +## Coverage Target + +vitest v8; package thresholds statements/branches/functions/lines ≥ 75%. +`packages/task-manager` meets: statements ~94%, branches ~79%, functions ~99%, lines ~94%. + +## Test Files +- `packages/task-manager/tests/unit/ids.test.ts` +- `packages/task-manager/tests/unit/actor-resolver.test.ts` +- `packages/task-manager/tests/unit/errors.test.ts` +- `packages/task-manager/tests/integration/task.repository.test.ts` +- `packages/task-manager/tests/integration/service.test.ts` +- `packages/task-manager/tests/integration/add-event-coverage.test.ts` +- `packages/cli/src/__tests__/commands/task.test.ts` + +## Results +- `nx test` (whole repo): 890 passing, 0 failing (73 test files). +- `nx build`: 6 projects succeed. +- `nx lint`: 0 errors (only pre-existing warnings). diff --git a/package-lock.json b/package-lock.json index 1e438369..2a5ddc18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-devkit", - "version": "0.45.0", + "version": "0.46.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-devkit", - "version": "0.45.0", + "version": "0.46.0", "license": "MIT", "workspaces": [ "apps/*", @@ -42,6 +42,10 @@ "resolved": "packages/pi-session-tracker", "link": true }, + "node_modules/@ai-devkit/task-manager": { + "resolved": "packages/task-manager", + "link": true + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", @@ -13456,6 +13460,7 @@ "@ai-devkit/agent-manager": "0.24.0", "@ai-devkit/channel-connector": "0.12.0", "@ai-devkit/memory": "0.15.0", + "@ai-devkit/task-manager": "0.1.0", "@inquirer/prompts": "^8.5.2", "chalk": "^5.6.0", "commander": "^11.1.0", @@ -13801,6 +13806,30 @@ "peerDependencies": { "@earendil-works/pi-coding-agent": "*" } + }, + "packages/task-manager": { + "name": "@ai-devkit/task-manager", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "better-sqlite3": "^12.6.2" + }, + "devDependencies": { + "@swc/cli": "^0.8.1", + "@swc/core": "^1.10.0", + "@types/better-sqlite3": "^7.6.11", + "@types/node": "^20.11.5", + "@typescript-eslint/eslint-plugin": "^8.60.1", + "@typescript-eslint/parser": "^8.60.1", + "@vitest/coverage-v8": "^4.1.8", + "eslint": "^8.56.0", + "ts-node": "^10.9.2", + "typescript": "^5.5.0", + "vitest": "^4.1.8" + }, + "engines": { + "node": ">=20.20.0" + } } } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 4a06b8de..a06adef8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,6 +37,7 @@ "@ai-devkit/agent-manager": "0.24.0", "@ai-devkit/channel-connector": "0.12.0", "@ai-devkit/memory": "0.15.0", + "@ai-devkit/task-manager": "0.1.0", "@inquirer/prompts": "^8.5.2", "chalk": "^5.6.0", "commander": "^11.1.0", diff --git a/packages/cli/src/__tests__/commands/task.test.ts b/packages/cli/src/__tests__/commands/task.test.ts new file mode 100644 index 00000000..2c9f6661 --- /dev/null +++ b/packages/cli/src/__tests__/commands/task.test.ts @@ -0,0 +1,399 @@ +import type { MockedFunction } from 'vitest'; +import { Command } from 'commander'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { registerTaskCommand } from '../../commands/task.js'; +import { ui } from '../../util/terminal-ui.js'; + +// Mock the task-manager package so the command layer is tested in isolation, +// mirroring the memory command test pattern. +const mockTaskService = { + create: vi.fn(), + get: vi.fn(), + resolveTask: vi.fn(), + list: vi.fn(), + update: vi.fn(), + setPhase: vi.fn(), + setStatus: vi.fn(), + setProgress: vi.fn(), + setNextStep: vi.fn(), + addBlocker: vi.fn(), + resolveBlocker: vi.fn(), + addEvidence: vi.fn(), + addArtifact: vi.fn(), + setAttribution: vi.fn(), + addNote: vi.fn(), + close: vi.fn(), + addEvent: vi.fn(), + getEvents: vi.fn(), +}; + +const TASK_ID = '11111111-1111-4111-8111-111111111111'; +const SECOND_TASK_ID = '22222222-2222-4222-8222-222222222222'; +const EVENT_ID = '33333333-3333-4333-8333-333333333333'; +const BLOCKER_ID = '44444444-4444-4444-8444-444444444444'; +const EVIDENCE_ID = '55555555-5555-4555-8555-555555555555'; + +vi.mock('@ai-devkit/task-manager', () => ({ + createTaskService: vi.fn(() => mockTaskService), + resolveCurrentActor: vi.fn((override?: Record) => + override ? { agentId: 'resolved-agent', ...override } : null + ), + isTaskEventType: vi.fn((t: string) => t.startsWith('task.')), + AmbiguousTaskRefError: class AmbiguousTaskRefError extends Error {}, + TaskNotFoundError: class TaskNotFoundError extends Error {}, +})); + +vi.mock('../../util/terminal-ui.js', () => ({ + ui: { + error: vi.fn(), + warning: vi.fn(), + success: vi.fn(), + text: vi.fn(), + table: vi.fn(), + }, +})); + +function sampleTask(overrides: Record = {}) { + return { + taskId: TASK_ID, + title: 'Sample task', + summary: null, + feature: 'demo', + status: 'open', + phase: null, + phaseEnteredAt: null, + progress: { text: null, percent: null }, + nextStep: null, + blockers: [], + evidence: [], + artifacts: [], + attribution: null, + links: {}, + tags: [], + meta: {}, + createdAt: '2026-07-01T12:00:00.000Z', + updatedAt: '2026-07-01T12:00:00.000Z', + createdBy: null, + eventCount: 1, + lastEventAt: '2026-07-01T12:00:00.000Z', + ...overrides, + }; +} + +describe('task command', () => { + const mockedUi = vi.mocked(ui); + let consoleLogSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + Object.values(mockTaskService).forEach((fn) => (fn as ReturnType).mockReset()); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + describe('create', () => { + it('creates a task and prints JSON', async () => { + const task = sampleTask(); + mockTaskService.create.mockResolvedValue(task); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync([ + 'node', 'test', 'task', 'create', + '--title', 'Sample task', + '--feature', 'demo', + '--json', + ]); + + expect(mockTaskService.create).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Sample task', feature: 'demo' }) + ); + expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(task, null, 2)); + }); + + it('parses tags and links', async () => { + mockTaskService.create.mockResolvedValue(sampleTask()); + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync([ + 'node', 'test', 'task', 'create', + '--title', 'T', + '--tags', 'a,b', + '--branch', 'feature-x', + '--pr', 'https://github.com/x/pull/1', + '--json', + ]); + + expect(mockTaskService.create).toHaveBeenCalledWith( + expect.objectContaining({ + tags: ['a', 'b'], + links: expect.objectContaining({ + branch: 'feature-x', + pr: 'https://github.com/x/pull/1', + }), + }) + ); + }); + }); + + describe('list', () => { + it('renders a table by default', async () => { + mockTaskService.list.mockResolvedValue([sampleTask(), sampleTask({ taskId: SECOND_TASK_ID, title: 'Other' })]); + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync(['node', 'test', 'task', 'list']); + + expect(mockedUi.table).toHaveBeenCalled(); + const args = (mockedUi.table as MockedFunction).mock.calls[0]![0]; + expect(args.headers).toEqual(['id', 'title', 'status', 'phase', 'feature']); + expect(args.rows).toHaveLength(2); + }); + + it('prints JSON with --json', async () => { + const tasks = [sampleTask()]; + mockTaskService.list.mockResolvedValue(tasks); + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync(['node', 'test', 'task', 'list', '--json']); + expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(tasks, null, 2)); + }); + + it('warns when empty', async () => { + mockTaskService.list.mockResolvedValue([]); + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync(['node', 'test', 'task', 'list']); + expect(mockedUi.warning).toHaveBeenCalledWith('No tasks found.'); + }); + }); + + describe('show', () => { + it('resolves the id and prints JSON with --events', async () => { + const task = sampleTask(); + mockTaskService.resolveTask.mockResolvedValue(task); + mockTaskService.get.mockResolvedValue(task); + const events = [{ eventId: EVENT_ID, type: 'task.created', ts: 't', actor: null, taskId: task.taskId, payload: {} }]; + mockTaskService.getEvents.mockResolvedValue(events); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync(['node', 'test', 'task', 'show', 'demo', '--events', '--json']); + + expect(mockTaskService.resolveTask).toHaveBeenCalledWith('demo'); + expect(consoleLogSpy).toHaveBeenCalledWith( + JSON.stringify({ task, events }, null, 2) + ); + }); + + it('prints an error when no task resolves', async () => { + mockTaskService.resolveTask.mockResolvedValue(null); + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync(['node', 'test', 'task', 'show', 'missing']); + expect(mockedUi.error).toHaveBeenCalledWith('No task found for "missing".'); + }); + }); + + describe('phase / status / progress / next', () => { + it('sets phase by feature key', async () => { + const task = sampleTask({ phase: 'design' }); + mockTaskService.resolveTask.mockResolvedValue(task); + mockTaskService.setPhase.mockResolvedValue(task); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync(['node', 'test', 'task', 'phase', 'demo', 'design', '--json']); + + expect(mockTaskService.setPhase).toHaveBeenCalledWith(TASK_ID, 'design', expect.any(Object)); + }); + + it('sets progress with percent and text', async () => { + const task = sampleTask(); + mockTaskService.resolveTask.mockResolvedValue(task); + mockTaskService.setProgress.mockResolvedValue(task); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync(['node', 'test', 'task', 'progress', 'demo', '--text', 'half', '--percent', '50', '--json']); + + expect(mockTaskService.setProgress).toHaveBeenCalledWith( + task.taskId, + { text: 'half', percent: 50 }, + expect.any(Object) + ); + }); + + it('clears progress with --clear', async () => { + const task = sampleTask(); + mockTaskService.resolveTask.mockResolvedValue(task); + mockTaskService.setProgress.mockResolvedValue(task); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync(['node', 'test', 'task', 'progress', 'demo', '--clear']); + expect(mockTaskService.setProgress).toHaveBeenCalledWith( + task.taskId, + { text: null, percent: null }, + expect.any(Object) + ); + }); + + it('sets next step from variadic args', async () => { + const task = sampleTask(); + mockTaskService.resolveTask.mockResolvedValue(task); + mockTaskService.setNextStep.mockResolvedValue(task); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync(['node', 'test', 'task', 'next', 'demo', 'write', 'tests', '--json']); + expect(mockTaskService.setNextStep).toHaveBeenCalledWith(task.taskId, 'write tests', expect.any(Object)); + }); + }); + + describe('blocker', () => { + it('adds a blocker with variadic text', async () => { + const task = sampleTask(); + mockTaskService.resolveTask.mockResolvedValue(task); + mockTaskService.addBlocker.mockResolvedValue({ task, blockerId: BLOCKER_ID }); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync(['node', 'test', 'task', 'blocker', 'demo', 'add', 'waiting', 'on', 'x', '--json']); + expect(mockTaskService.addBlocker).toHaveBeenCalledWith( + task.taskId, + { text: 'waiting on x' }, + expect.any(Object) + ); + }); + + it('resolves a blocker by id', async () => { + const task = sampleTask(); + mockTaskService.resolveTask.mockResolvedValue(task); + mockTaskService.resolveBlocker.mockResolvedValue(task); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync(['node', 'test', 'task', 'blocker', 'demo', 'resolve', BLOCKER_ID, '--json']); + expect(mockTaskService.resolveBlocker).toHaveBeenCalledWith(task.taskId, BLOCKER_ID, expect.any(Object)); + }); + }); + + describe('evidence', () => { + it('records passing evidence with command and exit code', async () => { + const task = sampleTask(); + mockTaskService.resolveTask.mockResolvedValue(task); + mockTaskService.addEvidence.mockResolvedValue({ task, evidenceId: EVIDENCE_ID }); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync([ + 'node', 'test', 'task', 'evidence', 'demo', + '--command', 'nx test', '--exit-code', '0', '--passed', '--summary', 'green', + '--json', + ]); + expect(mockTaskService.addEvidence).toHaveBeenCalledWith( + task.taskId, + expect.objectContaining({ command: 'nx test', exitCode: 0, passed: true, summary: 'green' }), + expect.any(Object) + ); + }); + + it('errors when neither --passed nor --failed is given', async () => { + const task = sampleTask(); + mockTaskService.resolveTask.mockResolvedValue(task); + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync(['node', 'test', 'task', 'evidence', 'demo', '--command', 'x']); + expect(mockedUi.error).toHaveBeenCalledWith(expect.stringContaining('--passed')); + }); + + it('collects repeatable --artifact refs', async () => { + const task = sampleTask(); + mockTaskService.resolveTask.mockResolvedValue(task); + mockTaskService.addEvidence.mockResolvedValue({ task, evidenceId: EVIDENCE_ID }); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync([ + 'node', 'test', 'task', 'evidence', 'demo', '--passed', + '--artifact', '/a.log', '--artifact', '/b.log', '--json', + ]); + expect(mockTaskService.addEvidence).toHaveBeenCalledWith( + task.taskId, + expect.objectContaining({ artifacts: ['/a.log', '/b.log'] }), + expect.any(Object) + ); + }); + }); + + describe('note', () => { + it('appends a note from variadic text', async () => { + const task = sampleTask(); + mockTaskService.resolveTask.mockResolvedValue(task); + mockTaskService.addNote.mockResolvedValue(task); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync(['node', 'test', 'task', 'note', 'demo', 'quick', 'note', '--json']); + expect(mockTaskService.addNote).toHaveBeenCalledWith(task.taskId, 'quick note', expect.any(Object)); + }); + }); + + describe('event', () => { + it('appends a custom event defaulting to task.custom with parsed payload', async () => { + const task = sampleTask(); + mockTaskService.resolveTask.mockResolvedValue(task); + const event = { eventId: EVENT_ID, taskId: task.taskId, ts: 't', type: 'task.custom', actor: null, payload: { name: 'tick' } }; + mockTaskService.addEvent.mockResolvedValue(event); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync([ + 'node', 'test', 'task', 'event', 'demo', + '--payload', '{"name":"tick"}', '--json', + ]); + expect(mockTaskService.addEvent).toHaveBeenCalledWith( + task.taskId, + 'task.custom', + { name: 'tick' }, + expect.any(Object) + ); + expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(event, null, 2)); + }); + + it('reads payload from @file', async () => { + const task = sampleTask(); + mockTaskService.resolveTask.mockResolvedValue(task); + mockTaskService.addEvent.mockResolvedValue({ + eventId: EVENT_ID, taskId: task.taskId, ts: 't', type: 'task.custom', actor: null, payload: {}, + }); + const tmp = path.join(os.tmpdir(), `evt-${Date.now()}.json`); + fs.writeFileSync(tmp, JSON.stringify({ name: 'from-file' }), 'utf8'); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync(['node', 'test', 'task', 'event', 'demo', '--payload', `@${tmp}`, '--json']); + expect(mockTaskService.addEvent).toHaveBeenCalledWith( + task.taskId, + 'task.custom', + { name: 'from-file' }, + expect.any(Object) + ); + }); + }); + + describe('close', () => { + it('defaults to completed', async () => { + const task = sampleTask({ status: 'completed' }); + mockTaskService.resolveTask.mockResolvedValue(task); + mockTaskService.close.mockResolvedValue(task); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync(['node', 'test', 'task', 'close', 'demo', '--json']); + expect(mockTaskService.close).toHaveBeenCalledWith(task.taskId, 'completed', expect.any(Object)); + }); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f0c9e86e..00fbb828 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -6,6 +6,7 @@ import { phaseCommand } from './commands/phase.js'; import { lintCommand } from './commands/lint.js'; import { installCommand } from './commands/install.js'; import { registerMemoryCommand } from './commands/memory.js'; +import { registerTaskCommand } from './commands/task.js'; import { registerSkillCommand } from './commands/skill.js'; import { registerAgentCommand } from './commands/agent.js'; import { registerChannelCommand } from './commands/channel.js'; @@ -58,6 +59,7 @@ program .action(installCommand); registerMemoryCommand(program); +registerTaskCommand(program); registerSkillCommand(program); registerAgentCommand(program); registerChannelCommand(program); diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts new file mode 100644 index 00000000..72018b86 --- /dev/null +++ b/packages/cli/src/commands/task.ts @@ -0,0 +1,523 @@ +import type { Command } from 'commander'; +import { readFileSync } from 'fs'; +import { + createTaskService, + resolveCurrentActor, + AmbiguousTaskRefError, + isTaskEventType, +} from '@ai-devkit/task-manager'; +import type { Actor, Task, TaskService, TaskStatus } from '@ai-devkit/task-manager'; +import { ui } from '../util/terminal-ui.js'; +import { withErrorHandler } from '../util/errors.js'; +import { truncate } from '../util/text.js'; + +const TITLE_MAX_LENGTH = 50; +const VALID_STATUSES: TaskStatus[] = ['open', 'active', 'blocked', 'completed', 'abandoned']; + +interface AttributionOptions { + agent?: string; + agentType?: string; + pid?: string; + session?: string; +} + +function buildActorOverride(opts: AttributionOptions): Partial | undefined { + const override: Partial = {}; + if (opts.agent) override.agentId = opts.agent; + if (opts.agentType) override.agentType = opts.agentType; + if (opts.pid) override.pid = Number.parseInt(opts.pid, 10); + if (opts.session) override.sessionId = opts.session; + return Object.keys(override).length > 0 ? override : undefined; +} + +function actorFromOptions(opts: AttributionOptions): Actor | undefined { + const override = buildActorOverride(opts); + if (!override) return undefined; + return resolveCurrentActor(override) ?? undefined; +} + +function createService(storeFlag?: string): TaskService { + return createTaskService(storeFlag); +} + +function output(value: unknown, json: boolean): void { + if (json) { + console.log(JSON.stringify(value, null, 2)); + return; + } + if (typeof value === 'string') { + ui.text(value); + } else { + console.log(JSON.stringify(value, null, 2)); + } +} + +function formatActor(actor: { agentId?: string; agentType?: string; pid?: number; sessionId?: string } | null): string { + if (!actor) return '—'; + const parts: string[] = []; + if (actor.agentType) parts.push(actor.agentType); + if (actor.agentId) parts.push(actor.agentId); + if (actor.pid) parts.push(`pid:${actor.pid}`); + return parts.length ? parts.join('/') : '—'; +} + +async function resolveOrError( + service: TaskService, + id: string +): Promise<{ taskId: string } | null> { + try { + const task = await service.resolveTask(id); + if (!task) { + ui.error(`No task found for "${id}".`); + return null; + } + return { taskId: task.taskId }; + } catch (error) { + if (error instanceof AmbiguousTaskRefError) { + ui.error(`${error.message}`); + return null; + } + throw error; + } +} + +function renderTask(task: Task): string { + const lines: string[] = []; + lines.push(`${task.taskId}`); + lines.push(` title: ${task.title}`); + lines.push(` status: ${task.status} phase: ${task.phase ?? '—'}`); + if (task.feature) lines.push(` feature: ${task.feature}`); + if (task.summary) lines.push(` summary: ${task.summary}`); + if (task.progress.text || task.progress.percent !== null) { + lines.push(` progress: ${task.progress.text ?? ''}${task.progress.percent !== null ? ` (${task.progress.percent}%)` : ''}`); + } + if (task.nextStep) lines.push(` next: ${task.nextStep}`); + lines.push(` attribution: ${formatActor(task.attribution)}`); + const links = [task.links.branch, task.links.worktree, task.links.pr].filter(Boolean).join(' · '); + if (links) lines.push(` links: ${links}`); + if (task.tags.length) lines.push(` tags: ${task.tags.join(', ')}`); + if (task.blockers.length) { + lines.push(` blockers:`); + for (const b of task.blockers) { + lines.push(` [${b.status}] ${b.blockerId} — ${truncate(b.text, 80)}`); + } + } + if (task.evidence.length) { + lines.push(` evidence:`); + for (const e of task.evidence) { + lines.push(` ${e.passed ? '✓' : '✗'} ${e.evidenceId}${e.command ? ` — ${truncate(e.command, 60)}` : ''}`); + } + } + if (task.artifacts.length) { + lines.push(` artifacts:`); + for (const a of task.artifacts) { + lines.push(` ${a.artifactId} — ${a.path}${a.kind ? ` [${a.kind}]` : ''}`); + } + } + lines.push(` events: ${task.eventCount} created: ${task.createdAt}`); + return lines.join('\n'); +} + +export function registerTaskCommand(program: Command): void { + const task = program.command('task').description('Manage durable development/debug tasks'); + + const addAttributionFlags = (cmd: Command): Command => + cmd + .option('--store ', 'Override the tasks database path (env: AI_DEVKIT_TASKS_DB)') + .option('--agent ', 'Agent id for attribution') + .option('--agent-type ', 'Agent type for attribution (e.g. claude, pi)') + .option('--pid ', 'Process id for attribution') + .option('--session ', 'Agent session id for attribution') + .option('--json', 'Output machine-readable JSON'); + + addAttributionFlags( + task + .command('create') + .description('Create a new task') + .requiredOption('--title ', 'Task title') + .option('--feature <feature>', 'Kebab-case feature key') + .option('--summary <summary>', 'Short summary') + .option('--phase <phase>', 'Initial lifecycle phase') + .option('--tags <tags>', 'Comma-separated tags') + .option('--branch <branch>', 'Git branch link') + .option('--worktree <path>', 'Git worktree link') + .option('--pr <url>', 'Pull request link') + ).action( + withErrorHandler('create task', async (opts) => { + const service = createService(opts.store); + const created = await service.create({ + title: opts.title, + feature: opts.feature, + summary: opts.summary, + phase: opts.phase, + tags: opts.tags ? opts.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined, + links: { branch: opts.branch, worktree: opts.worktree, pr: opts.pr }, + actor: actorFromOptions(opts), + }); + if (opts.json) { + output(created, true); + } else { + ui.success(`Created task ${created.taskId}`); + ui.text(renderTask(created)); + } + }) + ); + + addAttributionFlags( + task + .command('list') + .description('List tasks (newest first)') + .option('--feature <feature>', 'Filter by feature key') + .option('--status <status>', `Filter by status (${VALID_STATUSES.join('|')})`) + .option('--phase <phase>', 'Filter by phase') + .option('--limit <n>', 'Maximum results', '20') + ).action( + withErrorHandler('list tasks', async (opts) => { + const service = createService(opts.store); + const tasks = await service.list({ + feature: opts.feature, + status: opts.status as TaskStatus | undefined, + phase: opts.phase, + limit: Number.parseInt(opts.limit, 10) || 20, + }); + if (opts.json) { + output(tasks, true); + return; + } + if (tasks.length === 0) { + ui.warning('No tasks found.'); + return; + } + ui.table({ + headers: ['id', 'title', 'status', 'phase', 'feature'], + rows: tasks.map((t) => [ + t.taskId, + truncate(t.title, TITLE_MAX_LENGTH), + t.status, + t.phase ?? '—', + t.feature ?? '—', + ]), + }); + }) + ); + + addAttributionFlags( + task + .command('show <id>') + .description('Show a task (resolves id, prefix, or feature)') + .option('--events', 'Include the event history') + ).action( + withErrorHandler('show task', async (id: string, opts) => { + const service = createService(opts.store); + const resolved = await resolveOrError(service, id); + if (!resolved) return; + const taskObj = await service.get(resolved.taskId); + if (opts.json) { + const payload: Record<string, unknown> = { task: taskObj }; + if (opts.events) { + payload.events = await service.getEvents(resolved.taskId); + } + output(payload, true); + return; + } + ui.text(renderTask(taskObj)); + if (opts.events) { + const events = await service.getEvents(resolved.taskId); + ui.text('\nevents:'); + for (const e of events) { + ui.text(` ${e.ts} ${e.type} (${e.eventId})`); + } + } + }) + ); + + addAttributionFlags( + task + .command('update <id>') + .description('Update task scalar fields (title/summary/tags/links)') + .option('--title <title>', 'New title') + .option('--summary <summary>', 'New summary') + .option('--tags <tags>', 'Comma-separated tags (replaces)') + .option('--branch <branch>', 'Git branch link') + .option('--worktree <path>', 'Git worktree link') + .option('--pr <url>', 'Pull request link') + ).action( + withErrorHandler('update task', async (id: string, opts) => { + const service = createService(opts.store); + const resolved = await resolveOrError(service, id); + if (!resolved) return; + const patch: Record<string, unknown> = {}; + if (opts.title !== undefined) patch.title = opts.title; + if (opts.summary !== undefined) patch.summary = opts.summary; + if (opts.tags !== undefined) { + patch.tags = opts.tags.split(',').map((t: string) => t.trim()).filter(Boolean); + } + if (opts.branch !== undefined || opts.worktree !== undefined || opts.pr !== undefined) { + patch.links = { branch: opts.branch, worktree: opts.worktree, pr: opts.pr }; + } + const updated = await service.update(resolved.taskId, patch, { actor: actorFromOptions(opts) }); + output(updated, opts.json); + }) + ); + + addAttributionFlags(task.command('phase <id> <phase>').description('Set the lifecycle phase')).action( + withErrorHandler('set task phase', async (id: string, phase: string, opts) => { + const service = createService(opts.store); + const resolved = await resolveOrError(service, id); + if (!resolved) return; + const updated = await service.setPhase(resolved.taskId, phase, { actor: actorFromOptions(opts) }); + output(updated, opts.json); + }) + ); + + addAttributionFlags( + task + .command('status <id> <status>') + .description(`Set status (${VALID_STATUSES.join('|')})`) + ).action( + withErrorHandler('set task status', async (id: string, status: string, opts) => { + const service = createService(opts.store); + const resolved = await resolveOrError(service, id); + if (!resolved) return; + const updated = await service.setStatus(resolved.taskId, status as TaskStatus, { + actor: actorFromOptions(opts), + }); + output(updated, opts.json); + }) + ); + + addAttributionFlags( + task + .command('progress <id>') + .description('Set progress text/percent') + .option('--text <text>', 'Progress text') + .option('--percent <n>', 'Completion percent (0..100)') + .option('--clear', 'Clear progress') + ).action( + withErrorHandler('set task progress', async (id: string, opts) => { + const service = createService(opts.store); + const resolved = await resolveOrError(service, id); + if (!resolved) return; + const progress = + opts.clear === true + ? { text: null, percent: null } + : { + text: opts.text, + percent: opts.percent !== undefined ? Number.parseInt(opts.percent, 10) : undefined, + }; + const updated = await service.setProgress(resolved.taskId, progress, { + actor: actorFromOptions(opts), + }); + output(updated, opts.json); + }) + ); + + addAttributionFlags( + task + .command('next <id> [step...]') + .description('Set the next step (pass --clear to remove)') + .option('--clear', 'Clear the next step') + ).action( + withErrorHandler('set task next step', async (id: string, stepParts: string[] | undefined, opts) => { + const service = createService(opts.store); + const resolved = await resolveOrError(service, id); + if (!resolved) return; + const step = opts.clear === true ? null : (stepParts ?? []).join(' ').trim() || null; + const updated = await service.setNextStep(resolved.taskId, step, { + actor: actorFromOptions(opts), + }); + output(updated, opts.json); + }) + ); + + addAttributionFlags( + task + .command('blocker <id> <action> [rest...]') + .description('Manage blockers: add <text> | resolve <blockerId>') + ).action( + withErrorHandler('manage blocker', async (id: string, action: string, rest: string[] | undefined, opts) => { + const service = createService(opts.store); + const resolved = await resolveOrError(service, id); + if (!resolved) return; + const args = rest ?? []; + if (action === 'add') { + const text = args.join(' ').trim(); + if (!text) { + ui.error('blocker add requires blocker text.'); + process.exitCode = 1; + return; + } + const result = await service.addBlocker( + resolved.taskId, + { text }, + { actor: actorFromOptions(opts) } + ); + output(result.task, opts.json); + } else if (action === 'resolve') { + const blockerId = args[0]; + if (!blockerId) { + ui.error('blocker resolve requires a blockerId.'); + process.exitCode = 1; + return; + } + const updated = await service.resolveBlocker(resolved.taskId, blockerId, { + actor: actorFromOptions(opts), + }); + output(updated, opts.json); + } else { + ui.error(`Unknown blocker action "${action}". Use: add | resolve.`); + process.exitCode = 1; + } + }) + ); + + addAttributionFlags( + task + .command('evidence <id>') + .description('Record validation evidence (use --passed or --failed)') + .option('--command <command>', 'Command that was run') + .option('--exit-code <code>', 'Exit code of the command') + .option('--passed', 'Mark evidence as passing') + .option('--failed', 'Mark evidence as failing') + .option('--summary <summary>', 'Inline summary of the result') + .option('--artifact <path>', 'Artifact reference (repeatable)', (val: string, acc: string[]) => [...acc, val], [] as string[]) + ).action( + withErrorHandler('record evidence', async (id: string, opts) => { + const service = createService(opts.store); + const resolved = await resolveOrError(service, id); + if (!resolved) return; + if (!opts.passed && !opts.failed) { + ui.error('Evidence requires either --passed or --failed.'); + process.exitCode = 1; + return; + } + const result = await service.addEvidence( + resolved.taskId, + { + command: opts.command, + exitCode: opts.exitCode !== undefined ? Number.parseInt(opts.exitCode, 10) : undefined, + passed: opts.passed === true, + summary: opts.summary, + artifacts: opts.artifact, + }, + { actor: actorFromOptions(opts) } + ); + output(result.task, opts.json); + }) + ); + + addAttributionFlags( + task + .command('artifact <id> <path>') + .description('Add an artifact reference (never copies the file)') + .option('--kind <kind>', 'Artifact kind (e.g. log, report, diff)') + .option('--description <description>', 'Artifact description') + ).action( + withErrorHandler('add artifact', async (id: string, artifactPath: string, opts) => { + const service = createService(opts.store); + const resolved = await resolveOrError(service, id); + if (!resolved) return; + const result = await service.addArtifact( + resolved.taskId, + { path: artifactPath, kind: opts.kind, description: opts.description }, + { actor: actorFromOptions(opts) } + ); + output(result.task, opts.json); + }) + ); + + addAttributionFlags( + task + .command('assign <id>') + .description('Set current task ownership/attribution') + .requiredOption('--agent <id>', 'Agent id') + .option('--agent-type <type>', 'Agent type') + .option('--pid <pid>', 'Process id') + .option('--session <id>', 'Session id') + ).action( + withErrorHandler('assign task', async (id: string, opts) => { + const service = createService(opts.store); + const resolved = await resolveOrError(service, id); + if (!resolved) return; + const actor = actorFromOptions(opts); + if (!actor) { + ui.error('At least one attribution flag is required.'); + process.exitCode = 1; + return; + } + const updated = await service.setAttribution(resolved.taskId, actor); + output(updated, opts.json); + }) + ); + + addAttributionFlags(task.command('note <id> [text...]').description('Append a note (event-only)')).action( + withErrorHandler('append note', async (id: string, textParts: string[] | undefined, opts) => { + const service = createService(opts.store); + const resolved = await resolveOrError(service, id); + if (!resolved) return; + const text = (textParts ?? []).join(' ').trim(); + if (!text) { + ui.error('Note text must be a non-empty string.'); + process.exitCode = 1; + return; + } + const updated = await service.addNote(resolved.taskId, text, { + actor: actorFromOptions(opts), + }); + output(updated, opts.json); + }) + ); + + addAttributionFlags( + task + .command('event <id>') + .description('Append a low-level event (defaults to task.custom)') + .option('--type <type>', 'Event type from the closed set (default: task.custom)') + .option('--payload <json|@file>', 'JSON payload or @path to a JSON file') + ).action( + withErrorHandler('append event', async (id: string, opts) => { + const service = createService(opts.store); + const resolved = await resolveOrError(service, id); + if (!resolved) return; + const type = opts.type ?? 'task.custom'; + if (!isTaskEventType(type)) { + ui.error(`Unknown event type: ${type}`); + process.exitCode = 1; + return; + } + let payload: Record<string, unknown> = {}; + if (opts.payload) { + const raw = opts.payload.startsWith('@') + ? readFileSync(opts.payload.slice(1), 'utf8') + : opts.payload; + payload = JSON.parse(raw) as Record<string, unknown>; + } + const event = await service.addEvent(resolved.taskId, type, payload, { + actor: actorFromOptions(opts), + }); + output(event, opts.json); + }) + ); + + addAttributionFlags( + task + .command('close <id> [status]') + .description('Close a task (completed|abandoned). Default: completed') + ).action( + withErrorHandler('close task', async (id: string, statusArg: string | undefined, opts) => { + const service = createService(opts.store); + const resolved = await resolveOrError(service, id); + if (!resolved) return; + const status = (statusArg ?? 'completed') as 'completed' | 'abandoned'; + if (status !== 'completed' && status !== 'abandoned') { + ui.error('Close status must be "completed" or "abandoned".'); + process.exitCode = 1; + return; + } + const updated = await service.close(resolved.taskId, status, { + actor: actorFromOptions(opts), + }); + output(updated, opts.json); + }) + ); +} diff --git a/packages/task-manager/.eslintrc.json b/packages/task-manager/.eslintrc.json new file mode 100644 index 00000000..64ac3ade --- /dev/null +++ b/packages/task-manager/.eslintrc.json @@ -0,0 +1,21 @@ +{ + "parser": "@typescript-eslint/parser", + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], + "plugins": ["@typescript-eslint"], + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "env": { + "node": true, + "es6": true + }, + "rules": { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { "caughtErrorsIgnorePattern": "^ignore" } + ] + } +} diff --git a/packages/task-manager/.gitignore b/packages/task-manager/.gitignore new file mode 100644 index 00000000..e81b6118 --- /dev/null +++ b/packages/task-manager/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Test coverage +coverage/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Environment +.env +.env.local diff --git a/packages/task-manager/.swcrc b/packages/task-manager/.swcrc new file mode 100644 index 00000000..f6bea8b6 --- /dev/null +++ b/packages/task-manager/.swcrc @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "jsc": { + "parser": { + "syntax": "typescript", + "decorators": false, + "dynamicImport": true + }, + "target": "es2022", + "loose": false, + "externalHelpers": false, + "keepClassNames": true + }, + "module": { + "type": "es6", + "strict": true, + "noInterop": false + }, + "sourceMaps": true, + "minify": false +} diff --git a/packages/task-manager/README.md b/packages/task-manager/README.md new file mode 100644 index 00000000..c362b651 --- /dev/null +++ b/packages/task-manager/README.md @@ -0,0 +1,84 @@ +# @ai-devkit/task-manager + +Durable task system for AI DevKit. A **task** is the durable unit for +development/debug work, with a stable task id and encapsulated artifacts, evidence, +progress, blockers, workflow phase, ownership/agent attribution, branch/worktree/PR +links, and an append-only event history. + +## Storage (SQLite) + +Tasks are stored in a single SQLite database at `~/.ai-devkit/tasks.db`: + +- **`tasks`** — one row per task snapshot (full Task JSON + indexed query columns). +- **`task_events`** — append-only event history (one row per event). + +Uses `better-sqlite3` with WAL + `busy_timeout` (same library and approach as +`@ai-devkit/memory`). Task/event ids are raw UUIDv4 (`crypto.randomUUID()`), stored as TEXT +(globally unique, no collision checks). Override the DB path with `--store`, +`AI_DEVKIT_TASKS_DB`, or the `TaskRepository` / `DatabaseConnection` constructor. `TaskRepository` +owns task/event persistence over the shared SQLite connection. + +## Quick start + +```ts +import { createTaskService } from '@ai-devkit/task-manager'; + +const service = createTaskService(); // ~/.ai-devkit/tasks.db + +const task = await service.create({ title: 'Ship feature X', feature: 'feature-x', phase: 'requirements' }); + +await service.setPhase(task.taskId, 'design'); +await service.setProgress(task.taskId, { text: 'halfway', percent: 50 }); +await service.addEvidence(task.taskId, { command: 'nx test', exitCode: 0, passed: true, summary: 'all green' }); +await service.addBlocker(task.taskId, { text: 'waiting on review' }); +await service.addNote(task.taskId, 'designing now'); +await service.close(task.taskId, 'completed'); + +const events = await service.getEvents(task.taskId); +``` + +## Service API + +All methods async. Every mutator accepts `opts?: { actor?: Actor }` (auto-resolved if +omitted) and returns the updated `Task` (or `{ task, blockerId/evidenceId/artifactId }`). + +`create`, `get`, `resolveTask(ref)`, `list(filter?)`, `update`, `setPhase`, `setStatus`, +`setProgress`, `setNextStep`, `addBlocker`, `resolveBlocker`, `addEvidence`, `addArtifact`, +`setAttribution`, `addNote`, `close`, low-level `addEvent`, `getEvents`. + +`resolveTask` accepts a full id, a unique id prefix, or a feature key (latest non-terminal +task). + +## CLI + +`ai-devkit task ...` — create, list, show, update, phase, status, progress, next, +blocker `<id> add <text>|resolve <blockerId>`, evidence, artifact, assign, note, event, +close. Global flags: `--store`, `--json`, `--agent`, `--agent-type`, `--pid`, `--session`. +See the design doc for the full verb/flag table. + +```bash +ai-devkit task create --title "Ship feature X" --feature feature-x --phase requirements --json +ai-devkit task phase feature-x design # resolves by feature key +ai-devkit task evidence feature-x --command "nx test" --exit-code 0 --passed --summary "all green" +ai-devkit task show feature-x --events +``` + +## Event types (closed set) + +`task.created`, `task.updated`, `task.phase.set`, `task.status.set`, `task.progress.set`, +`task.next_step.set`, `task.blocker.add`, `task.blocker.resolve`, `task.evidence.add`, +`task.artifact.add`, `task.attribution.set`, `task.note.append` (event-only), +`task.custom` (event-only escape hatch), `task.closed`. + +## Attribution + +Per-event `actor` records who **emitted** an event; `task.attribution` records the **current +owner**. When `actor` is omitted the service auto-resolves from explicit opts, env +(`AI_DEVKIT_AGENT_ID/TYPE/SESSION_ID/PID`), and `process.pid`. + +## Limitations (MVP) + +- `createTaskService` / `TaskRepository` use the process-wide `getDatabase()` singleton + connection; long-running callers that need to release it should call `closeDatabase()`. +- Snapshot is authoritative; event-sourced replay is a documented future capability. +- No project management (boards/milestones/hierarchies/permissions). diff --git a/packages/task-manager/package.json b/packages/task-manager/package.json new file mode 100644 index 00000000..71e13990 --- /dev/null +++ b/packages/task-manager/package.json @@ -0,0 +1,63 @@ +{ + "name": "@ai-devkit/task-manager", + "version": "0.1.0", + "type": "module", + "description": "Durable task system for AI DevKit: stable task ids, artifacts, evidence, progress, blockers, lifecycle phase, agent attribution, and append-only event history", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly && cp -r src/database/migrations dist/database/", + "dev": "swc src -d dist --strip-leading-paths --watch", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint src/**/*.ts", + "clean": "rm -rf dist" + }, + "keywords": [ + "ai", + "task", + "dev-lifecycle", + "evidence", + "agent", + "trace", + "ai-devkit" + ], + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/codeaholicguy/ai-devkit.git", + "directory": "packages/task-manager" + }, + "dependencies": { + "better-sqlite3": "^12.6.2" + }, + "devDependencies": { + "@swc/cli": "^0.8.1", + "@swc/core": "^1.10.0", + "@types/better-sqlite3": "^7.6.11", + "@types/node": "^20.11.5", + "@typescript-eslint/eslint-plugin": "^8.60.1", + "@typescript-eslint/parser": "^8.60.1", + "@vitest/coverage-v8": "^4.1.8", + "eslint": "^8.56.0", + "ts-node": "^10.9.2", + "typescript": "^5.5.0", + "vitest": "^4.1.8" + }, + "engines": { + "node": ">=20.20.0" + }, + "files": [ + "dist", + "README.md" + ] +} diff --git a/packages/task-manager/project.json b/packages/task-manager/project.json new file mode 100644 index 00000000..caaf8feb --- /dev/null +++ b/packages/task-manager/project.json @@ -0,0 +1,29 @@ +{ + "name": "task-manager", + "root": "packages/task-manager", + "sourceRoot": "packages/task-manager/src", + "projectType": "library", + "targets": { + "build": { + "executor": "nx:run-commands", + "options": { + "command": "npm run build", + "cwd": "packages/task-manager" + } + }, + "test": { + "executor": "nx:run-commands", + "options": { + "command": "npm run test", + "cwd": "packages/task-manager" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "npm run lint", + "cwd": "packages/task-manager" + } + } + } +} diff --git a/packages/task-manager/src/actor-resolver.ts b/packages/task-manager/src/actor-resolver.ts new file mode 100644 index 00000000..49328932 --- /dev/null +++ b/packages/task-manager/src/actor-resolver.ts @@ -0,0 +1,53 @@ +import type { Actor } from './task.types.js'; + +/** + * Environment variable names used for agent attribution auto-resolution. + * Tracing workers / agents may set these so the task service can attribute + * events without explicit `--agent` flags on every call. + */ +export const ATTRIB_ENV = { + agentId: 'AI_DEVKIT_AGENT_ID', + agentType: 'AI_DEVKIT_AGENT_TYPE', + sessionId: 'AI_DEVKIT_SESSION_ID', + agentPid: 'AI_DEVKIT_AGENT_PID', +} as const; + +/** + * Resolves the current actor. Explicit overrides take precedence over + * environment variables; pid falls back to the current process id. + */ +export function resolveCurrentActor(override?: Partial<Actor>): Actor | null { + const env = process.env; + const resolved: Actor = {}; + + const agentType = env[ATTRIB_ENV.agentType]; + if (agentType) { + resolved.agentType = agentType; + } + const agentId = env[ATTRIB_ENV.agentId]; + if (agentId) { + resolved.agentId = agentId; + } + const sessionId = env[ATTRIB_ENV.sessionId]; + if (sessionId) { + resolved.sessionId = sessionId; + } + + const pidEnv = env[ATTRIB_ENV.agentPid]; + if (pidEnv && /^\d+$/.test(pidEnv)) { + resolved.pid = Number.parseInt(pidEnv, 10); + } else { + resolved.pid = process.pid; + } + + if (override) { + for (const [key, value] of Object.entries(override)) { + if (value !== undefined) { + (resolved as Record<string, unknown>)[key] = value; + } + } + } + + const hasAny = resolved.agentId || resolved.agentType || resolved.sessionId || resolved.pid; + return hasAny ? resolved : null; +} diff --git a/packages/task-manager/src/database/connection.ts b/packages/task-manager/src/database/connection.ts new file mode 100644 index 00000000..991ea2aa --- /dev/null +++ b/packages/task-manager/src/database/connection.ts @@ -0,0 +1,127 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { homedir } from 'os'; +import { initializeSchema } from './schema.js'; + +/** + * Default database path: ~/.ai-devkit/tasks.db + */ +export const DEFAULT_DB_PATH = join(homedir(), '.ai-devkit', 'tasks.db'); + +export interface DatabaseOptions { + dbPath?: string; + verbose?: boolean; + readonly?: boolean; +} + +/** + * Resolve the database file path for the CLI/service. + * + * Precedence: explicit `dbPath` arg > `AI_DEVKIT_TASKS_DB` env > default. (The + * `DatabaseConnection` constructor itself does not read the env var — mirroring + * @ai-devkit/memory — so this helper is the single place that does.) + */ +export function resolveDbPath(dbPath?: string): string { + if (dbPath && dbPath.trim()) { + return dbPath.trim(); + } + const env = process.env.AI_DEVKIT_TASKS_DB; + if (env && env.trim()) { + return env.trim(); + } + return DEFAULT_DB_PATH; +} + +/** + * SQLite connection. Configures WAL + busy_timeout + synchronous=NORMAL for safe + * concurrent local access (mirrors @ai-devkit/memory). Schema migrations run via + * the `getDatabase` singleton, not here. + */ +export class DatabaseConnection { + private db: Database.Database; + private readonly dbPath: string; + + constructor(options: DatabaseOptions = {}) { + this.dbPath = options.dbPath ?? DEFAULT_DB_PATH; + + const dir = dirname(this.dbPath); + mkdirSync(dir, { recursive: true }); + + this.db = new Database(this.dbPath, { + readonly: options.readonly ?? false, + verbose: options.verbose ? console.log : undefined, + }); + + this.configure(); + } + + private configure(): void { + this.db.pragma('journal_mode = WAL'); + this.db.pragma('foreign_keys = ON'); + this.db.pragma('synchronous = NORMAL'); + this.db.pragma('busy_timeout = 5000'); + this.db.pragma('mmap_size = 268435456'); + } + + get instance(): Database.Database { + return this.db; + } + + get path(): string { + return this.dbPath; + } + + get isOpen(): boolean { + return this.db.open; + } + + query<T>(sql: string, params: unknown[] = []): T[] { + return this.db.prepare(sql).all(...params) as T[]; + } + + queryOne<T>(sql: string, params: unknown[] = []): T | undefined { + return this.db.prepare(sql).get(...params) as T | undefined; + } + + execute(sql: string, params: unknown[] = []): Database.RunResult { + return this.db.prepare(sql).run(...params); + } + + transaction<T>(fn: () => T): T { + return this.db.transaction(fn)(); + } + + close(): void { + if (this.db.open) { + this.db.close(); + } + } +} + +// Process-wide singleton connection (mirrors @ai-devkit/memory). The repository +// obtains the shared connection via getDatabase(); tests reset it with +// closeDatabase() to switch database files between cases. +let instance: DatabaseConnection | null = null; +let schemaInitialized = false; + +export function getDatabase(options?: DatabaseOptions): DatabaseConnection { + if (!instance) { + instance = new DatabaseConnection(options); + } + + if (!schemaInitialized) { + initializeSchema(instance); + schemaInitialized = true; + } + + return instance; +} + +export function closeDatabase(): void { + if (instance) { + instance.close(); + instance = null; + schemaInitialized = false; + } +} diff --git a/packages/task-manager/src/database/index.ts b/packages/task-manager/src/database/index.ts new file mode 100644 index 00000000..cdb29f6f --- /dev/null +++ b/packages/task-manager/src/database/index.ts @@ -0,0 +1,12 @@ +export { + DatabaseConnection, + getDatabase, + closeDatabase, + resolveDbPath, + DEFAULT_DB_PATH, +} from './connection.js'; +export type { DatabaseOptions } from './connection.js'; +export { + initializeSchema, + getSchemaVersion, +} from './schema.js'; diff --git a/packages/task-manager/src/database/migrations/001_initial.sql b/packages/task-manager/src/database/migrations/001_initial.sql new file mode 100644 index 00000000..64723a2c --- /dev/null +++ b/packages/task-manager/src/database/migrations/001_initial.sql @@ -0,0 +1,36 @@ +-- Migration 001: Initial schema +-- Stores task snapshots and an append-only event history in SQLite. + +-- One row per task. `snapshot` holds the full Task JSON (preserves the logical +-- Task shape exactly); the indexed columns mirror queryable fields so the DB is +-- inspectable and ready for future repository-level filtering. +CREATE TABLE IF NOT EXISTS tasks ( + task_id TEXT PRIMARY KEY, + snapshot TEXT NOT NULL, + feature TEXT, + status TEXT NOT NULL, + phase TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_tasks_feature ON tasks(feature); +CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); +CREATE INDEX IF NOT EXISTS idx_tasks_phase ON tasks(phase); + +-- Append-only event history: one row per event. `id` gives stable insertion +-- order (chronological append order); `event_id` is the unique natural key. +-- (No FK to tasks, mirroring @ai-devkit/memory's schema convention; task rows are +-- never deleted in MVP, so orphan events are not a concern.) +CREATE TABLE IF NOT EXISTS task_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + task_id TEXT NOT NULL, + ts TEXT NOT NULL, + type TEXT NOT NULL, + actor TEXT, + payload TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_task_events_task_id ON task_events(task_id); +CREATE INDEX IF NOT EXISTS idx_task_events_type ON task_events(type); diff --git a/packages/task-manager/src/database/schema.ts b/packages/task-manager/src/database/schema.ts new file mode 100644 index 00000000..72f623de --- /dev/null +++ b/packages/task-manager/src/database/schema.ts @@ -0,0 +1,74 @@ +import { readFileSync, readdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import type { DatabaseConnection } from './connection.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export function getSchemaVersion(db: DatabaseConnection): number { + const result = db.instance.pragma('user_version') as { user_version: number }[]; + return result[0]?.user_version ?? 0; +} + +function setSchemaVersion(db: DatabaseConnection, version: number): void { + db.instance.pragma(`user_version = ${version}`); +} + +function getMigrationsDir(): string { + // In production, migrations are in dist/database/migrations + // In development/testing, they are in src/database/migrations + return join(__dirname, 'migrations'); +} + +interface Migration { + version: number; + path: string; + name: string; +} + +function getMigrationFiles(): Migration[] { + const migrationsDir = getMigrationsDir(); + + let files: string[]; + try { + files = readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')).sort(); + } catch { + return []; + } + + return files.map((file) => { + const match = file.match(/^(\d+)_(.+)\.sql$/); + if (!match || !match[1] || !match[2]) { + throw new Error(`Invalid migration filename: ${file}. Expected format: 001_name.sql`); + } + return { + version: parseInt(match[1], 10), + name: match[2], + path: join(migrationsDir, file), + }; + }); +} + +/** + * Run any pending versioned migrations. Idempotent: tracks progress via the + * `user_version` pragma (mirrors @ai-devkit/memory). + */ +export function initializeSchema(db: DatabaseConnection): void { + const currentVersion = getSchemaVersion(db); + const migrations = getMigrationFiles(); + + const pendingMigrations = migrations.filter((m) => m.version > currentVersion); + + if (pendingMigrations.length === 0) { + return; + } + + for (const migration of pendingMigrations) { + const sql = readFileSync(migration.path, 'utf-8'); + + db.transaction(() => { + db.instance.exec(sql); + setSchemaVersion(db, migration.version); + }); + } +} diff --git a/packages/task-manager/src/index.ts b/packages/task-manager/src/index.ts new file mode 100644 index 00000000..7d8ed36c --- /dev/null +++ b/packages/task-manager/src/index.ts @@ -0,0 +1,72 @@ +export type { + Actor, + LifecyclePhase, + TaskStatus, + TaskProgress, + TaskLinks, + TaskBlocker, + TaskEvidence, + TaskArtifact, + Task, + TaskEvent, + TaskEventType, + TaskCreatedPayload, + TaskUpdatedPayload, + TaskPhaseSetPayload, + TaskStatusSetPayload, + TaskProgressSetPayload, + TaskNextStepSetPayload, + TaskBlockerAddPayload, + TaskBlockerResolvePayload, + TaskEvidenceAddPayload, + TaskArtifactAddPayload, + TaskAttributionSetPayload, + TaskNoteAppendPayload, + TaskCustomPayload, + TaskClosedPayload, +} from './task.types.js'; + +export { TaskRepository } from './task.repository.js'; +export { + DatabaseConnection, + getDatabase, + closeDatabase, + resolveDbPath, + DEFAULT_DB_PATH, +} from './database/connection.js'; +export type { DatabaseOptions } from './database/connection.js'; + +export { TaskService } from './task.service.js'; +export type { + TaskMutationOptions, + TaskCreateInput, + TaskUpdatePatch, + TaskListFilter, + TaskEventsFilter, + TaskRef, +} from './task.service.js'; + +export { + TaskError, + TaskNotFoundError, + TaskValidationError, + AmbiguousTaskRefError, + TaskResourceNotFoundError, + TaskRepositoryError, + UnknownEventTypeError, + isTaskEventType, +} from './task.errors.js'; + +export { resolveCurrentActor, ATTRIB_ENV } from './actor-resolver.js'; + +/** + * Convenience factory: a TaskService backed by a TaskRepository at the resolved + * DB path (dbPath arg > AI_DEVKIT_TASKS_DB > ~/.ai-devkit/tasks.db). + */ +import { TaskRepository } from './task.repository.js'; +import { TaskService } from './task.service.js'; +import { resolveDbPath } from './database/connection.js'; + +export function createTaskService(dbPath?: string): TaskService { + return new TaskService(new TaskRepository(resolveDbPath(dbPath))); +} diff --git a/packages/task-manager/src/task.errors.ts b/packages/task-manager/src/task.errors.ts new file mode 100644 index 00000000..a25942a3 --- /dev/null +++ b/packages/task-manager/src/task.errors.ts @@ -0,0 +1,96 @@ +import type { TaskEventType } from './task.types.js'; + +/** + * Base error for the task system. Typed error codes enable programmatic handling. + */ +export class TaskError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly details?: Record<string, unknown> + ) { + super(message); + this.name = 'TaskError'; + Object.setPrototypeOf(this, new.target.prototype); + } + + toJSON(): Record<string, unknown> { + return { + error: this.code, + message: this.message, + details: this.details, + }; + } +} + +/** Thrown when a referenced task id does not exist. */ +export class TaskNotFoundError extends TaskError { + constructor(taskId: string) { + super(`Task not found: ${taskId}`, 'TASK_NOT_FOUND', { taskId }); + this.name = 'TaskNotFoundError'; + } +} + +/** Thrown when user input or a task field fails validation. */ +export class TaskValidationError extends TaskError { + constructor(message: string, details?: Record<string, unknown>) { + super(message, 'TASK_VALIDATION_ERROR', details); + this.name = 'TaskValidationError'; + } +} + +/** Thrown when a task reference resolves to multiple candidates (e.g. ambiguous prefix). */ +export class AmbiguousTaskRefError extends TaskError { + constructor(ref: string, matches: string[]) { + super( + `Ambiguous task reference "${ref}" matches ${matches.length} tasks: ${matches.join(', ')}`, + 'AMBIGUOUS_TASK_REF', + { ref, matches } + ); + this.name = 'AmbiguousTaskRefError'; + } +} + +/** Thrown when a referenced sub-resource (blocker, artifact) does not exist on a task. */ +export class TaskResourceNotFoundError extends TaskError { + constructor(taskId: string, kind: string, id: string) { + super(`${kind} "${id}" not found on task ${taskId}`, 'TASK_RESOURCE_NOT_FOUND', { taskId, kind, id }); + this.name = 'TaskResourceNotFoundError'; + } +} + +/** Thrown when the underlying persistence (repository) layer fails. */ +export class TaskRepositoryError extends TaskError { + constructor(message: string, details?: Record<string, unknown>) { + super(message, 'TASK_REPOSITORY_ERROR', details); + this.name = 'TaskRepositoryError'; + } +} + +/** Thrown when an event type is not part of the closed set or cannot mutate a snapshot. */ +export class UnknownEventTypeError extends TaskError { + constructor(type: string) { + super(`Unknown task event type: ${type}`, 'UNKNOWN_EVENT_TYPE', { type }); + this.name = 'UnknownEventTypeError'; + } +} + +/** Type guard for the closed TaskEventType union. */ +export function isTaskEventType(type: string): type is TaskEventType { + return [ + 'task.created', + 'task.updated', + 'task.phase.set', + 'task.status.set', + 'task.progress.set', + 'task.next_step.set', + 'task.blocker.add', + 'task.blocker.resolve', + 'task.evidence.add', + 'task.artifact.add', + 'task.attribution.set', + 'task.note.append', + 'task.custom', + 'task.closed', + ].includes(type); +} diff --git a/packages/task-manager/src/task.ids.ts b/packages/task-manager/src/task.ids.ts new file mode 100644 index 00000000..c287511e --- /dev/null +++ b/packages/task-manager/src/task.ids.ts @@ -0,0 +1,34 @@ +import { randomUUID } from 'crypto'; + +/** + * Id generation + ISO timestamps. + * + * Ids are raw UUIDv4 strings from Node's cryptographic `crypto.randomUUID()` + * (stored in SQLite as TEXT), generated in the service layer. UUIDs are globally + * unique, so there are no prefixes, suffixes, or collision checks. Callers should + * treat ids as opaque strings and match by exact value or a unique prefix. + */ + +export function nowIso(now: Date = new Date()): string { + return now.toISOString(); +} + +export function makeTaskId(): string { + return randomUUID(); +} + +export function makeEventId(): string { + return randomUUID(); +} + +export function makeBlockerId(): string { + return randomUUID(); +} + +export function makeEvidenceId(): string { + return randomUUID(); +} + +export function makeArtifactId(): string { + return randomUUID(); +} diff --git a/packages/task-manager/src/task.repository.ts b/packages/task-manager/src/task.repository.ts new file mode 100644 index 00000000..ff45ec8f --- /dev/null +++ b/packages/task-manager/src/task.repository.ts @@ -0,0 +1,159 @@ +import type { Task, TaskEvent } from './task.types.js'; +import { TaskRepositoryError } from './task.errors.js'; +import { + getDatabase, + DEFAULT_DB_PATH, + type DatabaseConnection, +} from './database/connection.js'; + +interface EventRow { + event_id: string; + task_id: string; + ts: string; + type: string; + actor: string | null; + payload: string; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Task persistence. Stores each task snapshot as one row (full Task JSON plus + * indexed query columns) and each event as one row in an append-only `task_events` + * table. Holds only a DB path and obtains the shared connection from `getDatabase` + * on each operation (mirrors @ai-devkit/memory's connection lifecycle). + * + * Storage layout (see src/database/migrations/001_initial.sql): + * - `tasks` — one row per task snapshot (full Task JSON + indexed cols) + * - `task_events` — append-only event history (one row per event) + * + * Default DB path: ~/.ai-devkit/tasks.db (override via the `dbPath` arg or + * AI_DEVKIT_TASKS_DB; see resolveDbPath). Long-running callers release the shared + * connection with `closeDatabase()`. + */ +export class TaskRepository { + private readonly dbPath: string; + + constructor(dbPath: string = DEFAULT_DB_PATH) { + this.dbPath = dbPath; + } + + /** Shared connection (opens/initializes the schema on first use). */ + private db(): DatabaseConnection { + try { + return getDatabase({ dbPath: this.dbPath }); + } catch (error) { + throw new TaskRepositoryError('Failed to open task database', { + originalError: describeError(error), + }); + } + } + + async exists(taskId: string): Promise<boolean> { + const row = this.db().queryOne<{ task_id: string }>( + 'SELECT task_id FROM tasks WHERE task_id = ?', + [taskId] + ); + return row !== undefined; + } + + async readTask(taskId: string): Promise<Task | null> { + const row = this.db().queryOne<{ snapshot: string }>( + 'SELECT snapshot FROM tasks WHERE task_id = ?', + [taskId] + ); + if (!row) { + return null; + } + try { + return JSON.parse(row.snapshot) as Task; + } catch (error) { + throw new TaskRepositoryError(`Failed to read task ${taskId}`, { + taskId, + originalError: describeError(error), + }); + } + } + + async writeTask(task: Task): Promise<void> { + const snapshot = JSON.stringify(task); + try { + this.db().execute( + `INSERT OR REPLACE INTO tasks (task_id, snapshot, feature, status, phase, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + task.taskId, + snapshot, + task.feature, + task.status, + task.phase, + task.createdAt, + task.updatedAt, + ] + ); + } catch (error) { + throw new TaskRepositoryError(`Failed to write task ${task.taskId}`, { + taskId: task.taskId, + originalError: describeError(error), + }); + } + } + + async listTaskIds(): Promise<string[]> { + const rows = this.db().query<{ task_id: string }>('SELECT task_id FROM tasks'); + return rows.map((r) => r.task_id); + } + + async readEvents(taskId: string): Promise<TaskEvent[]> { + const rows = this.db().query<EventRow>( + `SELECT event_id, task_id, ts, type, actor, payload + FROM task_events WHERE task_id = ? ORDER BY id ASC`, + [taskId] + ); + const events: TaskEvent[] = []; + for (const row of rows) { + try { + events.push({ + eventId: row.event_id, + taskId: row.task_id, + ts: row.ts, + type: row.type as TaskEvent['type'], + actor: row.actor ? (JSON.parse(row.actor) as TaskEvent['actor']) : null, + payload: JSON.parse(row.payload) as Record<string, unknown>, + }); + } catch (error) { + throw new TaskRepositoryError(`Failed to read events for task ${taskId}`, { + taskId, + eventId: row.event_id, + originalError: describeError(error), + }); + } + } + return events; + } + + async appendEvent(event: TaskEvent): Promise<void> { + try { + this.db().execute( + `INSERT INTO task_events (event_id, task_id, ts, type, actor, payload) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + event.eventId, + event.taskId, + event.ts, + event.type, + event.actor ? JSON.stringify(event.actor) : null, + JSON.stringify(event.payload), + ] + ); + } catch (error) { + throw new TaskRepositoryError(`Failed to append event for task ${event.taskId}`, { + taskId: event.taskId, + eventId: event.eventId, + originalError: describeError(error), + }); + } + } +} diff --git a/packages/task-manager/src/task.service.ts b/packages/task-manager/src/task.service.ts new file mode 100644 index 00000000..9107552c --- /dev/null +++ b/packages/task-manager/src/task.service.ts @@ -0,0 +1,752 @@ +import type { + Actor, + LifecyclePhase, + Task, + TaskArtifact, + TaskBlocker, + TaskEvent, + TaskEventType, + TaskEvidence, + TaskLinks, + TaskProgress, + TaskStatus, +} from './task.types.js'; +import type { TaskRepository } from './task.repository.js'; +import { + AmbiguousTaskRefError, + TaskNotFoundError, + TaskResourceNotFoundError, + TaskValidationError, + UnknownEventTypeError, + isTaskEventType, +} from './task.errors.js'; +import { makeArtifactId, makeBlockerId, makeEvidenceId, makeEventId, makeTaskId, nowIso } from './task.ids.js'; +import { resolveCurrentActor } from './actor-resolver.js'; + +/** Options accepted by every mutating service method. */ +export interface TaskMutationOptions { + /** Override attribution for this mutation; auto-resolved if omitted. */ + actor?: Actor; +} + +export interface TaskCreateInput { + title: string; + feature?: string; + summary?: string | null; + phase?: LifecyclePhase; + tags?: string[]; + links?: Partial<TaskLinks>; + meta?: Record<string, string | number | boolean | null>; + actor?: Actor; +} + +export interface TaskUpdatePatch { + title?: string; + summary?: string; + tags?: string[]; + links?: Partial<TaskLinks>; + meta?: Record<string, string | number | boolean | null>; +} + +export interface TaskListFilter { + feature?: string; + status?: TaskStatus; + phase?: LifecyclePhase; + limit?: number; +} + +export interface TaskEventsFilter { + type?: TaskEventType; + limit?: number; +} + +export type TaskRef = string | { feature: string } | { taskId: string }; + +const TERMINAL_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>(['completed', 'abandoned']); + +function isTerminal(task: Task): boolean { + return TERMINAL_STATUSES.has(task.status); +} + +function validateTitle(title: string): void { + const trimmed = title.trim(); + if (!trimmed) { + throw new TaskValidationError('Task title must be a non-empty string.'); + } + if (trimmed.length > 300) { + throw new TaskValidationError('Task title must be at most 300 characters.'); + } +} + +function validatePercent(percent: number | null | undefined): number | null { + if (percent === null || percent === undefined) { + return null; + } + if (typeof percent !== 'number' || Number.isNaN(percent)) { + throw new TaskValidationError('progress.percent must be a number.'); + } + if (percent < 0 || percent > 100) { + throw new TaskValidationError('progress.percent must be between 0 and 100 (inclusive).'); + } + return percent; +} + +function validateStatus(status: string): asserts status is TaskStatus { + const valid: TaskStatus[] = ['open', 'active', 'blocked', 'completed', 'abandoned']; + if (!valid.includes(status as TaskStatus)) { + throw new TaskValidationError( + `Invalid status "${status}". Expected one of: ${valid.join(', ')}` + ); + } +} + +function validateFeature(feature: string | null | undefined): string | null { + if (feature === null || feature === undefined || feature === '') { + return null; + } + const trimmed = feature.trim(); + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(trimmed)) { + throw new TaskValidationError( + `Invalid feature key "${trimmed}". Use kebab-case (lowercase letters, digits, hyphens).` + ); + } + return trimmed; +} + +/** + * TaskService — the public task API. Consumed by the CLI, skills, and other + * packages. All methods are async; they delegate persistence to `TaskRepository` + * and never touch the database directly. + * + * Public methods: + * create, get, resolveTask, list, update, setPhase, setStatus, setProgress, + * setNextStep, addBlocker, resolveBlocker, addEvidence, addArtifact, + * setAttribution, addNote, close, addEvent, getEvents. + */ +export class TaskService { + constructor(private readonly repository: TaskRepository) {} + + // ------------------------------------------------------------------------- + // identity / lookup + // ------------------------------------------------------------------------- + + async create(input: TaskCreateInput): Promise<Task> { + validateTitle(input.title); + const feature = validateFeature(input.feature ?? null); + + const now = nowIso(); + const actor = input.actor ?? resolveCurrentActor(); + const taskId = makeTaskId(); + + const task: Task = { + taskId, + title: input.title.trim(), + summary: input.summary?.trim() || null, + feature, + status: 'open', + phase: input.phase ?? null, + phaseEnteredAt: input.phase ? now : null, + progress: { text: null, percent: null }, + nextStep: null, + blockers: [], + evidence: [], + artifacts: [], + attribution: actor, + links: { + branch: input.links?.branch, + worktree: input.links?.worktree, + pr: input.links?.pr, + commits: input.links?.commits, + }, + tags: input.tags ? [...input.tags] : [], + meta: input.meta ? { ...input.meta } : {}, + createdAt: now, + updatedAt: now, + createdBy: actor, + eventCount: 0, + lastEventAt: null, + }; + + await this.repository.writeTask(task); + + await this.appendEventInternal(task.taskId, 'task.created', { + title: task.title, + feature: task.feature, + summary: task.summary, + status: task.status, + phase: task.phase, + }, actor); + + // Refresh cached counters (eventCount/lastEventAt) after appending the event. + return this.refreshCachedCounters(task); + } + + async get(taskId: string): Promise<Task> { + return this.requireTask(taskId); + } + + /** + * Resolve a task reference. Order: + * 1. full taskId + * 2. unique taskId prefix (error if ambiguous) + * 3. feature key -> latest non-terminal task with that feature + * Returns null if nothing matches. + */ + async resolveTask(ref: TaskRef): Promise<Task | null> { + if (typeof ref === 'object') { + if ('taskId' in ref) { + return this.repository.readTask(ref.taskId); + } + return this.latestNonTerminalByFeature(ref.feature); + } + + // Direct id match. + const direct = await this.repository.readTask(ref); + if (direct) { + return direct; + } + + // Unique prefix match. + const ids = await this.repository.listTaskIds(); + const prefixed = ids.filter((id) => id.startsWith(ref)); + if (prefixed.length === 1) { + const match = await this.repository.readTask(prefixed[0]!); + return match; + } + if (prefixed.length > 1) { + throw new AmbiguousTaskRefError(ref, prefixed); + } + + // Feature key fallback. + return this.latestNonTerminalByFeature(ref); + } + + async list(filter: TaskListFilter = {}): Promise<Task[]> { + const ids = await this.repository.listTaskIds(); + const tasks: Task[] = []; + for (const id of ids) { + const task = await this.repository.readTask(id); + if (!task) { + continue; + } + if (filter.feature && task.feature !== filter.feature) { + continue; + } + if (filter.status && task.status !== filter.status) { + continue; + } + if (filter.phase && task.phase !== filter.phase) { + continue; + } + tasks.push(task); + } + // Newest first: primary sort by createdAt (descending), tie-break by taskId so + // same-second creations remain deterministic. + tasks.sort((a, b) => { + const byCreated = b.createdAt.localeCompare(a.createdAt); + if (byCreated !== 0) { + return byCreated; + } + return b.taskId.localeCompare(a.taskId); + }); + if (filter.limit !== undefined && filter.limit >= 0) { + return tasks.slice(0, filter.limit); + } + return tasks; + } + + // ------------------------------------------------------------------------- + // generic scalar patch + // ------------------------------------------------------------------------- + + async update(taskId: string, patch: TaskUpdatePatch, opts?: TaskMutationOptions): Promise<Task> { + const task = await this.requireTask(taskId); + const fields: string[] = []; + if (patch.title !== undefined) { + validateTitle(patch.title); + task.title = patch.title.trim(); + fields.push('title'); + } + if (patch.summary !== undefined) { + task.summary = patch.summary.trim() || null; + fields.push('summary'); + } + if (patch.tags !== undefined) { + task.tags = [...patch.tags]; + fields.push('tags'); + } + if (patch.links !== undefined) { + task.links = mergeLinks(task.links, patch.links); + fields.push('links'); + } + if (patch.meta !== undefined) { + task.meta = { ...patch.meta }; + fields.push('meta'); + } + if (fields.length === 0) { + return task; + } + return this.persistAndRecord(task, 'task.updated', { patch, fields }, opts); + } + + // ------------------------------------------------------------------------- + // dedicated state setters + // ------------------------------------------------------------------------- + + async setPhase(taskId: string, phase: LifecyclePhase, opts?: TaskMutationOptions): Promise<Task> { + const task = await this.requireTask(taskId); + const previous = task.phase; + const normalized = phase === '' ? null : phase; + if (previous === normalized && task.phaseEnteredAt !== null) { + return task; + } + task.phase = normalized; + task.phaseEnteredAt = normalized === null ? null : nowIso(); + return this.persistAndRecord(task, 'task.phase.set', { phase: normalized, previous }, opts); + } + + async setStatus(taskId: string, status: TaskStatus, opts?: TaskMutationOptions): Promise<Task> { + validateStatus(status); + const task = await this.requireTask(taskId); + const previous = task.status; + if (previous === status) { + return task; + } + task.status = status; + return this.persistAndRecord(task, 'task.status.set', { status, previous }, opts); + } + + async setProgress( + taskId: string, + progress: { text?: string | null; percent?: number | null }, + opts?: TaskMutationOptions + ): Promise<Task> { + const task = await this.requireTask(taskId); + const next: TaskProgress = { + text: progress.text === undefined ? task.progress.text : progress.text, + percent: + progress.percent === undefined + ? task.progress.percent + : validatePercent(progress.percent), + }; + task.progress = next; + return this.persistAndRecord( + task, + 'task.progress.set', + { text: next.text, percent: next.percent }, + opts + ); + } + + async setNextStep(taskId: string, step: string | null, opts?: TaskMutationOptions): Promise<Task> { + const task = await this.requireTask(taskId); + const normalized = step === null || step.trim() === '' ? null : step.trim(); + if (task.nextStep === normalized) { + return task; + } + task.nextStep = normalized; + return this.persistAndRecord(task, 'task.next_step.set', { step: normalized }, opts); + } + + // ------------------------------------------------------------------------- + // blockers / evidence / artifacts / attribution + // ------------------------------------------------------------------------- + + async addBlocker( + taskId: string, + input: { text: string }, + opts?: TaskMutationOptions + ): Promise<{ task: Task; blockerId: string }> { + const text = input.text?.trim(); + if (!text) { + throw new TaskValidationError('Blocker text must be a non-empty string.'); + } + const task = await this.requireTask(taskId); + const blockerId = makeBlockerId(); + const blocker: TaskBlocker = { + blockerId, + text, + status: 'open', + raisedAt: nowIso(), + resolvedAt: null, + raisedBy: opts?.actor ?? resolveCurrentActor(), + }; + task.blockers = [...task.blockers, blocker]; + const updated = await this.persistAndRecord( + task, + 'task.blocker.add', + { blockerId, text }, + opts + ); + return { task: updated, blockerId }; + } + + async resolveBlocker(taskId: string, blockerId: string, opts?: TaskMutationOptions): Promise<Task> { + const task = await this.requireTask(taskId); + const blocker = task.blockers.find((b) => b.blockerId === blockerId); + if (!blocker) { + throw new TaskResourceNotFoundError(taskId, 'Blocker', blockerId); + } + if (blocker.status === 'resolved') { + return task; + } + blocker.status = 'resolved'; + blocker.resolvedAt = nowIso(); + task.blockers = [...task.blockers]; + return this.persistAndRecord(task, 'task.blocker.resolve', { blockerId }, opts); + } + + async addEvidence( + taskId: string, + input: { + command?: string | null; + exitCode?: number | null; + passed: boolean; + summary?: string | null; + artifacts?: string[]; + }, + opts?: TaskMutationOptions + ): Promise<{ task: Task; evidenceId: string }> { + if (typeof input.passed !== 'boolean') { + throw new TaskValidationError('Evidence `passed` must be a boolean.'); + } + const task = await this.requireTask(taskId); + const evidenceId = makeEvidenceId(); + const evidence: TaskEvidence = { + evidenceId, + command: input.command ?? null, + exitCode: input.exitCode ?? null, + passed: input.passed, + summary: input.summary ?? null, + artifacts: input.artifacts ? [...input.artifacts] : [], + recordedAt: nowIso(), + actor: opts?.actor ?? resolveCurrentActor(), + }; + task.evidence = [...task.evidence, evidence]; + const updated = await this.persistAndRecord(task, 'task.evidence.add', { + evidenceId, + command: evidence.command, + exitCode: evidence.exitCode, + passed: evidence.passed, + summary: evidence.summary, + artifacts: evidence.artifacts, + }, opts); + return { task: updated, evidenceId }; + } + + async addArtifact( + taskId: string, + input: { path: string; kind?: string | null; description?: string | null }, + opts?: TaskMutationOptions + ): Promise<{ task: Task; artifactId: string }> { + const artifactPath = input.path?.trim(); + if (!artifactPath) { + throw new TaskValidationError('Artifact path must be a non-empty string.'); + } + const task = await this.requireTask(taskId); + const artifactId = makeArtifactId(); + const artifact: TaskArtifact = { + artifactId, + path: artifactPath, + kind: input.kind ?? null, + description: input.description ?? null, + addedAt: nowIso(), + }; + task.artifacts = [...task.artifacts, artifact]; + const updated = await this.persistAndRecord( + task, + 'task.artifact.add', + { + artifactId, + path: artifact.path, + kind: artifact.kind, + description: artifact.description, + }, + opts + ); + return { task: updated, artifactId }; + } + + async setAttribution(taskId: string, actor: Actor, opts?: TaskMutationOptions): Promise<Task> { + const task = await this.requireTask(taskId); + task.attribution = actor; + const payload = { + agentId: actor.agentId, + agentType: actor.agentType, + pid: actor.pid, + sessionId: actor.sessionId, + }; + return this.persistAndRecord(task, 'task.attribution.set', payload, opts); + } + + // ------------------------------------------------------------------------- + // notes / lifecycle + // ------------------------------------------------------------------------- + + async addNote(taskId: string, text: string, opts?: TaskMutationOptions): Promise<Task> { + const trimmed = text?.trim(); + if (!trimmed) { + throw new TaskValidationError('Note text must be a non-empty string.'); + } + const task = await this.requireTask(taskId); + // Event-only: no snapshot mutation beyond cached counters. + const actor = opts?.actor ?? resolveCurrentActor(); + await this.appendEventInternal(task.taskId, 'task.note.append', { text: trimmed }, actor); + return this.refreshCachedCounters(task); + } + + async close( + taskId: string, + status: 'completed' | 'abandoned', + opts?: TaskMutationOptions + ): Promise<Task> { + const task = await this.requireTask(taskId); + if (isTerminal(task)) { + return task; + } + task.status = status; + return this.persistAndRecord(task, 'task.closed', { status }, opts); + } + + // ------------------------------------------------------------------------- + // low-level event escape hatch + event read + // ------------------------------------------------------------------------- + + /** + * Low-level event append. For a known stateful `type` it applies the matching + * snapshot mutation then appends; for non-mutating types it appends only. + * Used internally by the typed setters; exposed for the tracing worker's + * `task.custom` observability needs and for callers that know the contract. + */ + async addEvent( + taskId: string, + type: string, + payload: Record<string, unknown>, + opts?: TaskMutationOptions + ): Promise<TaskEvent> { + if (!isTaskEventType(type)) { + throw new UnknownEventTypeError(type); + } + const task = await this.requireTask(taskId); + const actor = opts?.actor ?? resolveCurrentActor(); + + const mutated = this.applyEventToSnapshot(task, type, payload); + if (mutated) { + await this.repository.writeTask(task); + } + const event = await this.appendEventInternal(taskId, type, payload, actor); + // Refresh cached counters (eventCount/lastEventAt) after appending the event. + await this.refreshCachedCounters(task); + return event; + } + + async getEvents(taskId: string, filter: TaskEventsFilter = {}): Promise<TaskEvent[]> { + await this.requireTask(taskId); + let events = await this.repository.readEvents(taskId); + if (filter.type) { + events = events.filter((e) => e.type === filter.type); + } + if (filter.limit !== undefined && filter.limit >= 0) { + events = events.slice(0, filter.limit); + } + return events; + } + + // ------------------------------------------------------------------------- + // internals + // ------------------------------------------------------------------------- + + private async requireTask(taskId: string): Promise<Task> { + const task = await this.repository.readTask(taskId); + if (!task) { + throw new TaskNotFoundError(taskId); + } + return task; + } + + private async latestNonTerminalByFeature(feature: string): Promise<Task | null> { + const tasks = await this.list({ feature }); + return tasks.find((t) => !isTerminal(t)) ?? null; + } + + /** + * Persist the mutated task snapshot and append a stateful event. + * Refreshes cached counters (eventCount/lastEventAt) from the event stream. + */ + private async persistAndRecord( + task: Task, + type: TaskEventType, + payload: Record<string, unknown>, + opts?: TaskMutationOptions + ): Promise<Task> { + const actor = opts?.actor ?? resolveCurrentActor(); + task.updatedAt = nowIso(); + await this.repository.writeTask(task); + await this.appendEventInternal(task.taskId, type, payload, actor); + return this.refreshCachedCounters(task); + } + + private async appendEventInternal( + taskId: string, + type: TaskEventType, + payload: Record<string, unknown>, + actor: Actor | null + ): Promise<TaskEvent> { + const event: TaskEvent = { + eventId: makeEventId(), + taskId, + ts: nowIso(), + type, + actor, + payload, + }; + await this.repository.appendEvent(event); + return event; + } + + /** + * Re-read events to refresh cached counters. Cheaper than a full replay for MVP + * (counts/last-timestamp only); a future event-sourced rebuild is documented. + */ + private async refreshCachedCounters(task: Task): Promise<Task> { + const events = await this.repository.readEvents(task.taskId); + task.eventCount = events.length; + task.lastEventAt = events.length > 0 ? events[events.length - 1]!.ts : null; + await this.repository.writeTask(task); + return task; + } + + /** + * Apply a typed event's payload to the in-memory snapshot. Returns whether the + * snapshot was mutated. Used by the low-level `addEvent` escape hatch. + */ + private applyEventToSnapshot( + task: Task, + type: TaskEventType, + payload: Record<string, unknown> + ): boolean { + switch (type) { + case 'task.created': + // Already handled at create time; no-op if received via addEvent. + return false; + case 'task.updated': { + const patch = (payload.patch as TaskUpdatePatch) ?? {}; + if (patch.title !== undefined) { + task.title = patch.title; + } + if (patch.summary !== undefined) { + task.summary = patch.summary; + } + if (patch.tags !== undefined) { + task.tags = [...patch.tags]; + } + if (patch.links !== undefined) { + task.links = mergeLinks(task.links, patch.links); + } + if (patch.meta !== undefined) { + task.meta = { ...patch.meta }; + } + return true; + } + case 'task.phase.set': { + const phase = (payload.phase as LifecyclePhase) ?? null; + task.phase = phase; + task.phaseEnteredAt = phase === null ? null : nowIso(); + return true; + } + case 'task.status.set': { + task.status = payload.status as TaskStatus; + return true; + } + case 'task.progress.set': { + task.progress = { + text: (payload.text as string | null | undefined) ?? null, + percent: (payload.percent as number | null | undefined) ?? null, + }; + return true; + } + case 'task.next_step.set': { + task.nextStep = (payload.step as string | null) ?? null; + return true; + } + case 'task.blocker.add': { + task.blockers = [ + ...task.blockers, + { + blockerId: payload.blockerId as string, + text: payload.text as string, + status: 'open', + raisedAt: nowIso(), + resolvedAt: null, + raisedBy: resolveCurrentActor(), + }, + ]; + return true; + } + case 'task.blocker.resolve': { + const id = payload.blockerId as string; + task.blockers = task.blockers.map((b) => + b.blockerId === id ? { ...b, status: 'resolved', resolvedAt: nowIso() } : b + ); + return true; + } + case 'task.evidence.add': { + task.evidence = [ + ...task.evidence, + { + evidenceId: payload.evidenceId as string, + command: (payload.command as string | null | undefined) ?? null, + exitCode: (payload.exitCode as number | null | undefined) ?? null, + passed: Boolean(payload.passed), + summary: (payload.summary as string | null | undefined) ?? null, + artifacts: (payload.artifacts as string[] | undefined) ?? [], + recordedAt: nowIso(), + actor: resolveCurrentActor(), + }, + ]; + return true; + } + case 'task.artifact.add': { + task.artifacts = [ + ...task.artifacts, + { + artifactId: payload.artifactId as string, + path: payload.path as string, + kind: (payload.kind as string | null | undefined) ?? null, + description: (payload.description as string | null | undefined) ?? null, + addedAt: nowIso(), + }, + ]; + return true; + } + case 'task.attribution.set': { + task.attribution = { + agentId: payload.agentId as string | undefined, + agentType: payload.agentType as string | undefined, + pid: payload.pid as number | undefined, + sessionId: payload.sessionId as string | undefined, + }; + return true; + } + case 'task.closed': { + task.status = payload.status as TaskStatus; + return true; + } + case 'task.note.append': + case 'task.custom': + // Non-mutating types: event-only. + return false; + default: + return false; + } + } +} + +function mergeLinks(base: TaskLinks, patch: Partial<TaskLinks>): TaskLinks { + const merged: TaskLinks = { ...base }; + if (patch.branch !== undefined) merged.branch = patch.branch; + if (patch.worktree !== undefined) merged.worktree = patch.worktree; + if (patch.pr !== undefined) merged.pr = patch.pr; + if (patch.commits !== undefined) merged.commits = [...patch.commits]; + return merged; +} diff --git a/packages/task-manager/src/task.types.ts b/packages/task-manager/src/task.types.ts new file mode 100644 index 00000000..24b0b49c --- /dev/null +++ b/packages/task-manager/src/task.types.ts @@ -0,0 +1,242 @@ +/** + * Task system types. + * + * Type names, field names, and the TaskEventType union strings are the package's + * public surface; consumers (CLI, skills) wire up against them directly. + */ + +// --------------------------------------------------------------------------- +// Attribution +// --------------------------------------------------------------------------- + +/** + * Attribution unit: who emitted an event or who currently owns a task. + * All fields optional; a completely-unattributed actor is represented as `null`. + */ +export interface Actor { + /** Stable agent id from the agent-manager registry, if any. */ + agentId?: string; + /** Agent type, e.g. "claude" | "codex" | "pi" | "human". */ + agentType?: string; + /** OS process id of the emitting agent, if known. */ + pid?: number; + /** Agent session id, if known. */ + sessionId?: string; +} + +// --------------------------------------------------------------------------- +// Task snapshot (JSON, authoritative for reads) +// --------------------------------------------------------------------------- + +export type TaskStatus = 'open' | 'active' | 'blocked' | 'completed' | 'abandoned'; + +/** + * Workflow phase. Free-form string so structured-debug and custom workflows + * are not constrained. Recommended lifecycle values: + * "requirements" | "design" | "planning" | "implementation" | "testing" | "review" + */ +export type LifecyclePhase = string | null; + +export interface TaskProgress { + /** Free-form progress description. */ + text: string | null; + /** Completion percentage in the inclusive range 0..100. */ + percent: number | null; +} + +export interface TaskLinks { + branch?: string; + worktree?: string; + pr?: string; + commits?: string[]; +} + +export interface TaskBlocker { + /** Raw UUIDv4. */ + blockerId: string; + text: string; + status: 'open' | 'resolved'; + raisedAt: string; // ISO 8601 + resolvedAt: string | null; // ISO 8601 + raisedBy: Actor | null; +} + +export interface TaskEvidence { + /** Raw UUIDv4. */ + evidenceId: string; + command: string | null; + exitCode: number | null; + /** true = pass/success, false = fail. Required. */ + passed: boolean; + /** Inline durable summary text (the "copied" path for verification output). */ + summary: string | null; + /** Artifact references (artifactId and/or free path strings). */ + artifacts: string[]; + recordedAt: string; // ISO 8601 + actor: Actor | null; +} + +export interface TaskArtifact { + /** Raw UUIDv4. */ + artifactId: string; + /** Reference only — the file contents are never copied into storage. */ + path: string; + /** e.g. "log" | "report" | "diff" | "screenshot". */ + kind: string | null; + description: string | null; + addedAt: string; // ISO 8601 +} + +/** + * Task snapshot. Persisted as JSON in the `tasks` table; authoritative for reads. + */ +export interface Task { + // identity + /** Raw UUIDv4; immutable, never reused. */ + taskId: string; + title: string; + summary: string | null; + /** Kebab-case feature key, e.g. "task-system". Nullable for ad-hoc tasks. */ + feature: string | null; + // state + status: TaskStatus; + phase: LifecyclePhase; + phaseEnteredAt: string | null; // ISO 8601 + progress: TaskProgress; + nextStep: string | null; + blockers: TaskBlocker[]; + evidence: TaskEvidence[]; + artifacts: TaskArtifact[]; + // ownership / links + /** Current owner (who owns the task now); per-event emitter is on each event. */ + attribution: Actor | null; + links: TaskLinks; + tags: string[]; + /** Free-form tracing extras (kept as primitive values for JSON portability). */ + meta: Record<string, string | number | boolean | null>; + // bookkeeping + createdAt: string; // ISO 8601 + updatedAt: string; // ISO 8601 + createdBy: Actor | null; + /** Cached count of events (derived from the `task_events` table). */ + eventCount: number; + /** Cached timestamp of the most recent event (derived). */ + lastEventAt: string | null; +} + +// --------------------------------------------------------------------------- +// TaskEvent (one row in the `task_events` table — append-only audit trail) +// --------------------------------------------------------------------------- + +/** + * Closed set of event type strings. Do not change the literal values. + */ +export type TaskEventType = + | 'task.created' + | 'task.updated' + | 'task.phase.set' + | 'task.status.set' + | 'task.progress.set' + | 'task.next_step.set' + | 'task.blocker.add' + | 'task.blocker.resolve' + | 'task.evidence.add' + | 'task.artifact.add' + | 'task.attribution.set' + | 'task.note.append' + | 'task.custom' + | 'task.closed'; + +export interface TaskEvent { + /** Raw UUIDv4. */ + eventId: string; + taskId: string; + /** ISO 8601 timestamp. */ + ts: string; + type: TaskEventType; + /** Who emitted this event (auto-resolved by the service if the caller omits it). */ + actor: Actor | null; + /** Shape depends on `type`; see the contract for per-type payload schemas. */ + payload: Record<string, unknown>; +} + +// --------------------------------------------------------------------------- +// Mutating stateful event payloads (strongly typed helpers) +// --------------------------------------------------------------------------- + +export interface TaskCreatedPayload { + title: string; + feature?: string | null; + summary?: string | null; + status: TaskStatus; + phase?: LifecyclePhase; +} + +export interface TaskUpdatedPayload { + patch: Partial<Pick<Task, 'title' | 'summary' | 'tags' | 'links' | 'meta'>>; + fields: string[]; +} + +export interface TaskPhaseSetPayload { + phase: LifecyclePhase; + previous?: LifecyclePhase; +} + +export interface TaskStatusSetPayload { + status: TaskStatus; + previous?: TaskStatus; +} + +export interface TaskProgressSetPayload { + text?: string | null; + percent?: number | null; +} + +export interface TaskNextStepSetPayload { + step: string | null; +} + +export interface TaskBlockerAddPayload { + blockerId: string; + text: string; +} + +export interface TaskBlockerResolvePayload { + blockerId: string; +} + +export interface TaskEvidenceAddPayload { + evidenceId: string; + command?: string | null; + exitCode?: number | null; + passed: boolean; + summary?: string | null; + artifacts?: string[]; +} + +export interface TaskArtifactAddPayload { + artifactId: string; + path: string; + kind?: string | null; + description?: string | null; +} + +export interface TaskAttributionSetPayload { + agentId?: string; + agentType?: string; + pid?: number; + sessionId?: string; +} + +export interface TaskNoteAppendPayload { + text: string; +} + +export interface TaskCustomPayload { + name: string; + data?: Record<string, unknown>; +} + +export interface TaskClosedPayload { + status: 'completed' | 'abandoned'; +} diff --git a/packages/task-manager/tests/integration/add-event-coverage.test.ts b/packages/task-manager/tests/integration/add-event-coverage.test.ts new file mode 100644 index 00000000..695a1be7 --- /dev/null +++ b/packages/task-manager/tests/integration/add-event-coverage.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { TaskRepository } from '../../src/task.repository.js'; +import { getDatabase, closeDatabase } from '../../src/database/connection.js'; +import { TaskService } from '../../src/task.service.js'; +import { TaskRepositoryError, TaskNotFoundError } from '../../src/task.errors.js'; +import { nowIso } from '../../src/task.ids.js'; + +const TASK_ID = '00000000-0000-4000-8000-000000000001'; +const MISSING_TASK_ID = '00000000-0000-4000-8000-000000000099'; +const EVENT_ID = '00000000-0000-4000-8000-000000000002'; +const BLOCKER_ID = '00000000-0000-4000-8000-000000000003'; +const EVIDENCE_ID = '00000000-0000-4000-8000-000000000004'; +const ARTIFACT_ID = '00000000-0000-4000-8000-000000000005'; + +/** Write a raw snapshot string directly (bypassing JSON encoding), mirroring how + * @ai-devkit/memory's repository tests inject raw rows for error-path coverage. */ +function writeRawSnapshot(dbPath: string, taskId: string, rawSnapshot: string): void { + const now = nowIso(); + getDatabase({ dbPath }).execute( + `INSERT OR REPLACE INTO tasks (task_id, snapshot, feature, status, phase, created_at, updated_at) + VALUES (?, ?, NULL, 'open', NULL, ?, ?)`, + [taskId, rawSnapshot, now, now] + ); +} + +/** Append a raw event payload string directly (bypassing JSON encoding). */ +function appendRawEvent( + dbPath: string, + taskId: string, + eventId: string, + type: string, + rawPayload: string +): void { + getDatabase({ dbPath }).execute( + `INSERT INTO task_events (event_id, task_id, ts, type, actor, payload) + VALUES (?, ?, ?, ?, NULL, ?)`, + [eventId, taskId, nowIso(), type, rawPayload] + ); +} + +/** + * Coverage-focused tests for the low-level addEvent escape hatch (each stateful + * type applies a snapshot mutation) and for repository error branches. + */ +function createRepository(): { repository: TaskRepository; dir: string } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-evt-')); + const repository = new TaskRepository(path.join(dir, 'tasks.db')); + return { repository, dir }; +} + +describe('addEvent escape hatch — every stateful type', () => { + let dir: string; + let repository: TaskRepository; + let service: TaskService; + + beforeEach(() => { + closeDatabase(); + ({ repository, dir } = createRepository()); + service = new TaskService(repository); + }); + + afterEach(() => { + closeDatabase(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('task.updated applies a generic patch via addEvent', async () => { + const task = await service.create({ title: 'T' }); + await service.addEvent(task.taskId, 'task.updated', { + patch: { title: 'Patched', tags: ['x'] }, + fields: ['title', 'tags'], + }); + const updated = await service.get(task.taskId); + expect(updated.title).toBe('Patched'); + expect(updated.tags).toEqual(['x']); + }); + + it('task.phase.set sets phase via addEvent', async () => { + const task = await service.create({ title: 'T' }); + await service.addEvent(task.taskId, 'task.phase.set', { phase: 'planning' }); + expect((await service.get(task.taskId)).phase).toBe('planning'); + }); + + it('task.status.set sets status via addEvent', async () => { + const task = await service.create({ title: 'T' }); + await service.addEvent(task.taskId, 'task.status.set', { status: 'blocked' }); + expect((await service.get(task.taskId)).status).toBe('blocked'); + }); + + it('task.progress.set sets progress via addEvent', async () => { + const task = await service.create({ title: 'T' }); + await service.addEvent(task.taskId, 'task.progress.set', { text: 'go', percent: 25 }); + expect((await service.get(task.taskId)).progress).toEqual({ text: 'go', percent: 25 }); + }); + + it('task.next_step.set sets nextStep via addEvent', async () => { + const task = await service.create({ title: 'T' }); + await service.addEvent(task.taskId, 'task.next_step.set', { step: 'do thing' }); + expect((await service.get(task.taskId)).nextStep).toBe('do thing'); + }); + + it('task.blocker.add adds a blocker via addEvent', async () => { + const task = await service.create({ title: 'T' }); + await service.addEvent(task.taskId, 'task.blocker.add', { + blockerId: BLOCKER_ID, + text: 'stuck', + }); + const updated = await service.get(task.taskId); + expect(updated.blockers).toHaveLength(1); + expect(updated.blockers[0]).toMatchObject({ blockerId: BLOCKER_ID, text: 'stuck' }); + }); + + it('task.blocker.resolve resolves via addEvent', async () => { + const task = await service.create({ title: 'T' }); + await service.addEvent(task.taskId, 'task.blocker.add', { + blockerId: BLOCKER_ID, + text: 'stuck', + }); + await service.addEvent(task.taskId, 'task.blocker.resolve', { blockerId: BLOCKER_ID }); + const updated = await service.get(task.taskId); + expect(updated.blockers[0]!.status).toBe('resolved'); + }); + + it('task.evidence.add records evidence via addEvent', async () => { + const task = await service.create({ title: 'T' }); + await service.addEvent(task.taskId, 'task.evidence.add', { + evidenceId: EVIDENCE_ID, + command: 'nx build', + exitCode: 0, + passed: true, + }); + const updated = await service.get(task.taskId); + expect(updated.evidence).toHaveLength(1); + expect(updated.evidence[0]).toMatchObject({ evidenceId: EVIDENCE_ID, passed: true }); + }); + + it('task.artifact.add adds an artifact via addEvent', async () => { + const task = await service.create({ title: 'T' }); + await service.addEvent(task.taskId, 'task.artifact.add', { + artifactId: ARTIFACT_ID, + path: '/tmp/x', + }); + const updated = await service.get(task.taskId); + expect(updated.artifacts[0]).toMatchObject({ artifactId: ARTIFACT_ID, path: '/tmp/x' }); + }); + + it('task.attribution.set sets attribution via addEvent', async () => { + const task = await service.create({ title: 'T' }); + await service.addEvent(task.taskId, 'task.attribution.set', { agentId: 'a1' }); + const updated = await service.get(task.taskId); + expect(updated.attribution).toMatchObject({ agentId: 'a1' }); + }); + + it('task.closed closes the task via addEvent', async () => { + const task = await service.create({ title: 'T' }); + await service.addEvent(task.taskId, 'task.closed', { status: 'abandoned' }); + expect((await service.get(task.taskId)).status).toBe('abandoned'); + }); + + it('task.created is a safe no-op via addEvent (already handled at create)', async () => { + const task = await service.create({ title: 'T' }); + const evt = await service.addEvent(task.taskId, 'task.created', { + title: 'X', + status: 'open', + }); + expect(evt.type).toBe('task.created'); + // Title unchanged. + expect((await service.get(task.taskId)).title).toBe('T'); + }); +}); + +describe('repository error branches', () => { + let dir: string; + let dbPath: string; + let repository: TaskRepository; + + beforeEach(() => { + closeDatabase(); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-repository-err-')); + dbPath = path.join(dir, 'tasks.db'); + repository = new TaskRepository(dbPath); + }); + + afterEach(() => { + closeDatabase(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('readTask wraps a corrupt snapshot as TaskRepositoryError', async () => { + writeRawSnapshot(dbPath, TASK_ID, '{ not valid json'); + await expect(repository.readTask(TASK_ID)).rejects.toBeInstanceOf(TaskRepositoryError); + }); + + it('readEvents wraps a corrupt event payload as TaskRepositoryError', async () => { + await repository.writeTask({ + taskId: TASK_ID, + title: 'T', + summary: null, + feature: null, + status: 'open', + phase: null, + phaseEnteredAt: null, + progress: { text: null, percent: null }, + nextStep: null, + blockers: [], + evidence: [], + artifacts: [], + attribution: null, + links: {}, + tags: [], + meta: {}, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: null, + eventCount: 0, + lastEventAt: null, + }); + appendRawEvent(dbPath, TASK_ID, EVENT_ID, 'task.custom', '{ bad json'); + await expect(repository.readEvents(TASK_ID)).rejects.toBeInstanceOf(TaskRepositoryError); + }); + + it('using a repository at an invalid path surfaces TaskRepositoryError on use', async () => { + const blockFile = path.join(dir, 'blocker'); + fs.writeFileSync(blockFile, 'x', 'utf8'); + const bad = new TaskRepository(path.join(blockFile, 'tasks.db')); + await expect(bad.exists('x')).rejects.toBeInstanceOf(TaskRepositoryError); + }); +}); + +describe('service edge cases', () => { + let dir: string; + let repository: TaskRepository; + let service: TaskService; + + beforeEach(() => { + closeDatabase(); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-edge-')); + repository = new TaskRepository(path.join(dir, 'tasks.db')); + service = new TaskService(repository); + }); + + afterEach(() => { + closeDatabase(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('update with empty patch is a no-op', async () => { + const task = await service.create({ title: 'T' }); + const before = await service.get(task.taskId); + const result = await service.update(task.taskId, {}); + expect(result.eventCount).toBe(before.eventCount); + }); + + it('addBlocker rejects empty text', async () => { + const task = await service.create({ title: 'T' }); + await expect(service.addBlocker(task.taskId, { text: ' ' })).rejects.toBeInstanceOf( + Error + ); + }); + + it('addEvidence rejects missing passed', async () => { + const task = await service.create({ title: 'T' }); + await expect(service.addEvidence(task.taskId, { passed: 1 as never })).rejects.toThrow(); + }); + + it('addArtifact rejects empty path', async () => { + const task = await service.create({ title: 'T' }); + await expect(service.addArtifact(task.taskId, { path: ' ' })).rejects.toThrow(); + }); + + it('addNote rejects empty text', async () => { + const task = await service.create({ title: 'T' }); + await expect(service.addNote(task.taskId, ' ')).rejects.toThrow(); + }); + + it('setProgress clear via null text', async () => { + const task = await service.create({ title: 'T' }); + await service.setProgress(task.taskId, { text: 'hi', percent: 10 }); + const cleared = await service.setProgress(task.taskId, { text: null }); + expect(cleared.progress.text).toBeNull(); + }); + + it('resolveBlocker is idempotent on already-resolved blocker', async () => { + const task = await service.create({ title: 'T' }); + const { blockerId } = await service.addBlocker(task.taskId, { text: 'x' }); + await service.resolveBlocker(task.taskId, blockerId); + const before = await service.get(task.taskId); + await service.resolveBlocker(task.taskId, blockerId); + const after = await service.get(task.taskId); + expect(after.eventCount).toBe(before.eventCount); + }); + + it('addEvent throws TaskNotFoundError for a missing task', async () => { + await expect( + service.addEvent(MISSING_TASK_ID, 'task.custom', { name: 'x' }) + ).rejects.toBeInstanceOf(TaskNotFoundError); + }); + + it('list with limit slices results', async () => { + await service.create({ title: 'A' }); + await service.create({ title: 'B' }); + await service.create({ title: 'C' }); + const limited = await service.list({ limit: 2 }); + expect(limited).toHaveLength(2); + }); + + it('list with limit 0 returns empty', async () => { + await service.create({ title: 'A' }); + expect(await service.list({ limit: 0 })).toHaveLength(0); + }); +}); diff --git a/packages/task-manager/tests/integration/service.test.ts b/packages/task-manager/tests/integration/service.test.ts new file mode 100644 index 00000000..ccdd6f1e --- /dev/null +++ b/packages/task-manager/tests/integration/service.test.ts @@ -0,0 +1,374 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { TaskRepository } from '../../src/task.repository.js'; +import { closeDatabase } from '../../src/database/connection.js'; +import { TaskService } from '../../src/task.service.js'; +import { + TaskNotFoundError, + TaskValidationError, + AmbiguousTaskRefError, + TaskResourceNotFoundError, + UnknownEventTypeError, +} from '../../src/task.errors.js'; +import type { Task, TaskEvent } from '../../src/task.types.js'; +import { nowIso } from '../../src/task.ids.js'; + +const MISSING_TASK_ID = '00000000-0000-4000-8000-000000000099'; +const MISSING_BLOCKER_ID = '00000000-0000-4000-8000-000000000098'; + +/** Write a minimal task directly to the repository (bypasses service.create's + * random id generation), for tests that need controlled ids. */ +async function writeDirectTask(repository: TaskRepository, taskId: string): Promise<void> { + const now = nowIso(); + const task: Task = { + taskId, + title: 'T', + summary: null, + feature: null, + status: 'open', + phase: null, + phaseEnteredAt: null, + progress: { text: null, percent: null }, + nextStep: null, + blockers: [], + evidence: [], + artifacts: [], + attribution: null, + links: {}, + tags: [], + meta: {}, + createdAt: now, + updatedAt: now, + createdBy: null, + eventCount: 0, + lastEventAt: null, + }; + await repository.writeTask(task); +} + +describe('TaskService (integration with TaskRepository)', () => { + let dir: string; + let repository: TaskRepository; + let service: TaskService; + + beforeEach(() => { + closeDatabase(); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-service-')); + repository = new TaskRepository(path.join(dir, 'tasks.db')); + service = new TaskService(repository); + }); + + afterEach(() => { + closeDatabase(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + async function readEventsFromDisk(taskId: string): Promise<TaskEvent[]> { + return service.getEvents(taskId); + } + + describe('create', () => { + it('creates a task with stable id and task.created event', async () => { + const task = await service.create({ title: 'Ship feature X', feature: 'feature-x' }); + expect(task.taskId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(task.status).toBe('open'); + expect(task.feature).toBe('feature-x'); + expect(task.eventCount).toBe(1); + expect(task.lastEventAt).not.toBeNull(); + + const events = await readEventsFromDisk(task.taskId); + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe('task.created'); + expect(events[0]!.payload).toMatchObject({ title: 'Ship feature X', status: 'open' }); + }); + + it('rejects empty title', async () => { + await expect(service.create({ title: ' ' })).rejects.toBeInstanceOf(TaskValidationError); + }); + + it('rejects invalid feature key', async () => { + await expect(service.create({ title: 'T', feature: 'Bad Feature!' })).rejects.toBeInstanceOf( + TaskValidationError + ); + }); + + it('allows null feature for ad-hoc tasks', async () => { + const task = await service.create({ title: 'Ad-hoc debug' }); + expect(task.feature).toBeNull(); + }); + + it('sets phase and phaseEnteredAt when provided', async () => { + const task = await service.create({ title: 'T', phase: 'requirements' }); + expect(task.phase).toBe('requirements'); + expect(task.phaseEnteredAt).not.toBeNull(); + }); + }); + + describe('get', () => { + it('throws TaskNotFoundError for missing task', async () => { + await expect(service.get(MISSING_TASK_ID)).rejects.toBeInstanceOf(TaskNotFoundError); + }); + }); + + describe('state setters', () => { + it('setPhase mutates snapshot and emits task.phase.set with previous', async () => { + const task = await service.create({ title: 'T', phase: 'requirements' }); + const updated = await service.setPhase(task.taskId, 'design'); + expect(updated.phase).toBe('design'); + expect(updated.phaseEnteredAt).not.toBeNull(); + + const events = await readEventsFromDisk(task.taskId); + const phaseEvent = events.find((e) => e.type === 'task.phase.set'); + expect(phaseEvent).toBeDefined(); + expect(phaseEvent!.payload).toMatchObject({ phase: 'design', previous: 'requirements' }); + }); + + it('setStatus validates the status enum', async () => { + const task = await service.create({ title: 'T' }); + await expect(service.setStatus(task.taskId, 'bogus' as never)).rejects.toBeInstanceOf( + TaskValidationError + ); + const updated = await service.setStatus(task.taskId, 'active'); + expect(updated.status).toBe('active'); + }); + + it('setProgress validates percent range', async () => { + const task = await service.create({ title: 'T' }); + await expect( + service.setProgress(task.taskId, { percent: 150 }) + ).rejects.toBeInstanceOf(TaskValidationError); + const updated = await service.setProgress(task.taskId, { text: 'halfway', percent: 50 }); + expect(updated.progress).toEqual({ text: 'halfway', percent: 50 }); + }); + + it('setNextStep trims and stores; --clear sets null', async () => { + const task = await service.create({ title: 'T' }); + const updated = await service.setNextStep(task.taskId, ' write tests '); + expect(updated.nextStep).toBe('write tests'); + const cleared = await service.setNextStep(task.taskId, null); + expect(cleared.nextStep).toBeNull(); + }); + }); + + describe('blockers', () => { + it('addBlocker then resolveBlocker updates status', async () => { + const task = await service.create({ title: 'T' }); + const { blockerId } = await service.addBlocker(task.taskId, { text: 'blocked by X' }); + expect(blockerId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + + let updated = await service.get(task.taskId); + expect(updated.blockers).toHaveLength(1); + expect(updated.blockers[0]).toMatchObject({ status: 'open', text: 'blocked by X' }); + + updated = await service.resolveBlocker(task.taskId, blockerId); + expect(updated.blockers[0]!.status).toBe('resolved'); + expect(updated.blockers[0]!.resolvedAt).not.toBeNull(); + + const events = await readEventsFromDisk(task.taskId); + expect(events.some((e) => e.type === 'task.blocker.add')).toBe(true); + expect(events.some((e) => e.type === 'task.blocker.resolve')).toBe(true); + }); + + it('resolveBlocker throws for unknown blocker', async () => { + const task = await service.create({ title: 'T' }); + await expect( + service.resolveBlocker(task.taskId, MISSING_BLOCKER_ID) + ).rejects.toBeInstanceOf(TaskResourceNotFoundError); + }); + }); + + describe('evidence + artifacts', () => { + it('addEvidence records passed/fail and command', async () => { + const task = await service.create({ title: 'T' }); + const { evidenceId } = await service.addEvidence(task.taskId, { + command: 'nx test', + exitCode: 0, + passed: true, + summary: 'all green', + }); + expect(evidenceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + const updated = await service.get(task.taskId); + expect(updated.evidence).toHaveLength(1); + expect(updated.evidence[0]).toMatchObject({ + passed: true, + exitCode: 0, + command: 'nx test', + summary: 'all green', + }); + }); + + it('addEvidence requires passed boolean', async () => { + const task = await service.create({ title: 'T' }); + await expect( + service.addEvidence(task.taskId, { passed: 'yes' as never }) + ).rejects.toBeInstanceOf(TaskValidationError); + }); + + it('addArtifact stores a path reference (never copies)', async () => { + const task = await service.create({ title: 'T' }); + const { artifactId } = await service.addArtifact(task.taskId, { + path: '/tmp/build.log', + kind: 'log', + description: 'build output', + }); + expect(artifactId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + const updated = await service.get(task.taskId); + // Artifacts are references only: no file is copied; the snapshot + // just stores the path, which round-trips through the repository. + expect(updated.artifacts[0]).toMatchObject({ path: '/tmp/build.log', kind: 'log' }); + }); + }); + + describe('attribution + notes', () => { + it('setAttribution sets current owner', async () => { + const task = await service.create({ title: 'T' }); + const updated = await service.setAttribution(task.taskId, { + agentId: 'agent-7', + agentType: 'pi', + }); + expect(updated.attribution).toMatchObject({ agentId: 'agent-7', agentType: 'pi' }); + }); + + it('addNote appends an event without mutating snapshot state', async () => { + const task = await service.create({ title: 'T' }); + const before = await service.get(task.taskId); + const updated = await service.addNote(task.taskId, 'a quick note'); + // title/phase unchanged; eventCount bumped. + expect(updated.title).toBe(before.title); + expect(updated.eventCount).toBe(before.eventCount + 1); + const events = await readEventsFromDisk(task.taskId); + const note = events.find((e) => e.type === 'task.note.append'); + expect(note).toBeDefined(); + expect(note!.payload).toEqual({ text: 'a quick note' }); + }); + }); + + describe('close', () => { + it('marks task terminal and emits task.closed', async () => { + const task = await service.create({ title: 'T' }); + const closed = await service.close(task.taskId, 'completed'); + expect(closed.status).toBe('completed'); + const events = await readEventsFromDisk(task.taskId); + expect(events.some((e) => e.type === 'task.closed')).toBe(true); + }); + + it('is idempotent on an already-terminal task', async () => { + const task = await service.create({ title: 'T' }); + await service.close(task.taskId, 'completed'); + const before = await service.get(task.taskId); + await service.close(task.taskId, 'abandoned'); + const after = await service.get(task.taskId); + expect(after.status).toBe('completed'); + expect(after.eventCount).toBe(before.eventCount); + }); + }); + + describe('update (generic patch)', () => { + it('patches scalar fields and emits task.updated', async () => { + const task = await service.create({ title: 'T' }); + const updated = await service.update(task.taskId, { + title: 'New title', + tags: ['a', 'b'], + links: { branch: 'feature-x' }, + }); + expect(updated.title).toBe('New title'); + expect(updated.tags).toEqual(['a', 'b']); + expect(updated.links.branch).toBe('feature-x'); + const events = await readEventsFromDisk(task.taskId); + const upd = events.find((e) => e.type === 'task.updated'); + expect(upd).toBeDefined(); + expect(upd!.payload).toHaveProperty('fields'); + }); + }); + + describe('resolveTask', () => { + it('resolves by full id', async () => { + const task = await service.create({ title: 'T', feature: 'feat' }); + expect(await service.resolveTask(task.taskId)).not.toBeNull(); + }); + + it('resolves by unique id prefix', async () => { + const task = await service.create({ title: 'T', feature: 'feat' }); + const prefix = task.taskId.slice(0, 8); // first hex segment + const resolved = await service.resolveTask(prefix); + expect(resolved?.taskId).toBe(task.taskId); + }); + + it('throws AmbiguousTaskRefError on ambiguous prefix', async () => { + // Write two tasks directly with ids sharing a common prefix (random + // UUIDs never collide in practice, so we craft the ids here). + const t1 = '00000000-0000-4000-8000-0000000000aa'; + const t2 = '00000000-0000-4000-8000-0000000000ab'; + await writeDirectTask(repository, t1); + await writeDirectTask(repository, t2); + // Both match the shared prefix. + await expect(service.resolveTask('00000000')).rejects.toBeInstanceOf( + AmbiguousTaskRefError + ); + }); + + it('resolves by feature key to the latest non-terminal task', async () => { + const a = await service.create({ title: 'A', feature: 'feat' }); + const b = await service.create({ title: 'B', feature: 'feat' }); + await service.close(a.taskId, 'completed'); // a is terminal + const resolved = await service.resolveTask('feat'); + expect(resolved?.taskId).toBe(b.taskId); + }); + + it('returns null when nothing matches', async () => { + expect(await service.resolveTask('nonexistent-feature')).toBeNull(); + }); + }); + + describe('list', () => { + it('filters by feature/status/phase and returns newest first', async () => { + await service.create({ title: 'A', feature: 'feat', phase: 'design' }); + await service.create({ title: 'B', feature: 'feat', phase: 'testing' }); + await service.create({ title: 'C', feature: 'other' }); + + const featTasks = await service.list({ feature: 'feat' }); + expect(featTasks).toHaveLength(2); + // Newest first. + expect(featTasks[0]!.title).toBe('B'); + }); + }); + + describe('addEvent escape hatch', () => { + it('applies a stateful mutation then appends', async () => { + const task = await service.create({ title: 'T' }); + await service.addEvent(task.taskId, 'task.status.set', { status: 'active' }); + const updated = await service.get(task.taskId); + expect(updated.status).toBe('active'); + }); + + it('appends task.custom without mutating snapshot', async () => { + const task = await service.create({ title: 'T' }); + const before = await service.get(task.taskId); + await service.addEvent(task.taskId, 'task.custom', { name: 'trace.tick', data: { k: 1 } }); + const after = await service.get(task.taskId); + expect(after.status).toBe(before.status); + expect(after.eventCount).toBe(before.eventCount + 1); + }); + + it('rejects unknown event types', async () => { + const task = await service.create({ title: 'T' }); + await expect( + service.addEvent(task.taskId, 'task.bogus', {}) + ).rejects.toBeInstanceOf(UnknownEventTypeError); + }); + }); + + describe('getEvents filtering', () => { + it('filters by type and applies limit', async () => { + const task = await service.create({ title: 'T' }); + await service.addNote(task.taskId, 'one'); + await service.addNote(task.taskId, 'two'); + const notes = await service.getEvents(task.taskId, { type: 'task.note.append' }); + expect(notes).toHaveLength(2); + const limited = await service.getEvents(task.taskId, { limit: 1 }); + expect(limited).toHaveLength(1); + }); + }); +}); diff --git a/packages/task-manager/tests/integration/task.repository.test.ts b/packages/task-manager/tests/integration/task.repository.test.ts new file mode 100644 index 00000000..4b08ee1c --- /dev/null +++ b/packages/task-manager/tests/integration/task.repository.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { TaskRepository } from '../../src/task.repository.js'; +import { + resolveDbPath, + DatabaseConnection, + getDatabase, + closeDatabase, + DEFAULT_DB_PATH, +} from '../../src/database/connection.js'; +import { getSchemaVersion, initializeSchema } from '../../src/database/schema.js'; +import { TaskRepositoryError } from '../../src/task.errors.js'; +import type { Task, TaskEvent } from '../../src/task.types.js'; +import { makeTaskId, makeEventId, nowIso } from '../../src/task.ids.js'; +import { createTaskService } from '../../src/index.js'; + +const TASK_ID = '00000000-0000-4000-8000-000000000001'; +const SECOND_TASK_ID = '00000000-0000-4000-8000-000000000002'; +const MISSING_TASK_ID = '00000000-0000-4000-8000-000000000099'; +const EVENT_ID = '00000000-0000-4000-8000-000000000003'; + +function makeTask(overrides: Partial<Task> = {}): Task { + const taskId = makeTaskId(); + return { + taskId, + title: 'Sample task', + summary: null, + feature: 'demo', + status: 'open', + phase: null, + phaseEnteredAt: null, + progress: { text: null, percent: null }, + nextStep: null, + blockers: [], + evidence: [], + artifacts: [], + attribution: null, + links: {}, + tags: [], + meta: {}, + createdAt: nowIso(), + updatedAt: nowIso(), + createdBy: null, + eventCount: 0, + lastEventAt: null, + ...overrides, + }; +} + +function makeEvent(taskId: string, overrides: Partial<TaskEvent> = {}): TaskEvent { + return { + eventId: makeEventId(), + taskId, + ts: nowIso(), + type: 'task.created', + actor: null, + payload: { title: 'A' }, + ...overrides, + }; +} + +/** Write a raw snapshot string directly (bypassing JSON encoding), mirroring how + * @ai-devkit/memory's repository tests inject raw rows for error-path coverage. */ +function writeRawSnapshot(dbPath: string, taskId: string, rawSnapshot: string): void { + const now = nowIso(); + getDatabase({ dbPath }).execute( + `INSERT OR REPLACE INTO tasks (task_id, snapshot, feature, status, phase, created_at, updated_at) + VALUES (?, ?, NULL, 'open', NULL, ?, ?)`, + [taskId, rawSnapshot, now, now] + ); +} + +/** Append a raw event payload string directly (bypassing JSON encoding). */ +function appendRawEvent( + dbPath: string, + taskId: string, + eventId: string, + type: string, + rawPayload: string +): void { + getDatabase({ dbPath }).execute( + `INSERT INTO task_events (event_id, task_id, ts, type, actor, payload) + VALUES (?, ?, ?, ?, NULL, ?)`, + [eventId, taskId, nowIso(), type, rawPayload] + ); +} + +describe('TaskRepository', () => { + let dir: string; + let dbPath: string; + let repository: TaskRepository; + + beforeEach(() => { + closeDatabase(); // reset the process-wide singleton for a clean DB file + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-sqlite-')); + dbPath = path.join(dir, 'tasks.db'); + repository = new TaskRepository(dbPath); + }); + + afterEach(() => { + closeDatabase(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('writes and reads a task snapshot round-trip', async () => { + const task = makeTask({ title: 'Hello', feature: 'feat', status: 'active', phase: 'design' }); + await repository.writeTask(task); + const read = await repository.readTask(task.taskId); + expect(read).toEqual(task); + }); + + it('returns null for a missing task', async () => { + expect(await repository.readTask(MISSING_TASK_ID)).toBeNull(); + }); + + it('exists() reflects presence of a task row', async () => { + const task = makeTask(); + expect(await repository.exists(task.taskId)).toBe(false); + await repository.writeTask(task); + expect(await repository.exists(task.taskId)).toBe(true); + }); + + it('writeTask upserts (INSERT OR REPLACE) on repeat writes', async () => { + const task = makeTask({ title: 'v1' }); + await repository.writeTask(task); + await repository.writeTask({ ...task, title: 'v2', updatedAt: nowIso() }); + const read = await repository.readTask(task.taskId); + expect(read?.title).toBe('v2'); + }); + + it('listTaskIds returns all task ids', async () => { + await repository.writeTask(makeTask({ taskId: TASK_ID })); + await repository.writeTask(makeTask({ taskId: SECOND_TASK_ID })); + const ids = await repository.listTaskIds(); + expect(ids.sort()).toEqual([TASK_ID, SECOND_TASK_ID]); + }); + + it('returns [] for listTaskIds when empty', async () => { + expect(await repository.listTaskIds()).toEqual([]); + }); + + it('appends events and reads them back in insertion order', async () => { + await repository.writeTask(makeTask({ taskId: TASK_ID })); + const e1 = makeEvent(TASK_ID, { type: 'task.phase.set', payload: { phase: 'design' } }); + const e2 = makeEvent(TASK_ID, { type: 'task.note.append', payload: { text: 'hi' } }); + await repository.appendEvent(e1); + await repository.appendEvent(e2); + + const events = await repository.readEvents(TASK_ID); + expect(events).toHaveLength(2); + expect(events[0]).toEqual(e1); + expect(events[1]).toEqual(e2); + }); + + it('preserves actor (nullable JSON) and payload through round-trip', async () => { + await repository.writeTask(makeTask({ taskId: TASK_ID })); + const evt = makeEvent(TASK_ID, { + type: 'task.attribution.set', + actor: { agentId: 'a1', agentType: 'pi', pid: 123 }, + payload: { agentId: 'a1' }, + }); + await repository.appendEvent(evt); + const read = await repository.readEvents(TASK_ID); + expect(read[0]).toEqual(evt); + }); + + it('returns [] for events when none exist', async () => { + expect(await repository.readEvents(MISSING_TASK_ID)).toEqual([]); + }); + + it('wraps a corrupt snapshot read as TaskRepositoryError', async () => { + writeRawSnapshot(dbPath, TASK_ID, '{ not valid json'); + await expect(repository.readTask(TASK_ID)).rejects.toBeInstanceOf(TaskRepositoryError); + }); + + it('wraps a corrupt event payload read as TaskRepositoryError', async () => { + await repository.writeTask(makeTask({ taskId: TASK_ID })); + appendRawEvent(dbPath, TASK_ID, EVENT_ID, 'task.custom', '{ bad json'); + await expect(repository.readEvents(TASK_ID)).rejects.toBeInstanceOf(TaskRepositoryError); + }); + + it('using a repository at an invalid path surfaces TaskRepositoryError on use', async () => { + const blockFile = path.join(dir, 'blocker'); + fs.writeFileSync(blockFile, 'x', 'utf8'); + const bad = new TaskRepository(path.join(blockFile, 'tasks.db')); + await expect(bad.exists('x')).rejects.toBeInstanceOf(TaskRepositoryError); + }); +}); + +describe('resolveDbPath', () => { + const origEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...origEnv }; + }); + + it('uses explicit argument first', () => { + expect(resolveDbPath('/explicit/tasks.db')).toBe('/explicit/tasks.db'); + }); + + it('falls back to AI_DEVKIT_TASKS_DB when no argument', () => { + process.env.AI_DEVKIT_TASKS_DB = '/from/env/tasks.db'; + expect(resolveDbPath()).toBe('/from/env/tasks.db'); + }); + + it('falls back to ~/.ai-devkit/tasks.db when nothing is set', () => { + delete process.env.AI_DEVKIT_TASKS_DB; + expect(resolveDbPath()).toBe(path.join(os.homedir(), '.ai-devkit', 'tasks.db')); + expect(DEFAULT_DB_PATH).toBe(path.join(os.homedir(), '.ai-devkit', 'tasks.db')); + }); +}); + +describe('createTaskService', () => { + const origEnv = { ...process.env }; + let dir: string; + + beforeEach(() => { + closeDatabase(); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-service-factory-')); + }); + + afterEach(() => { + closeDatabase(); + process.env = { ...origEnv }; + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('uses AI_DEVKIT_TASKS_DB when no explicit path is provided', async () => { + const dbPath = path.join(dir, 'env-tasks.db'); + process.env.AI_DEVKIT_TASKS_DB = dbPath; + const service = createTaskService(); + await service.create({ title: 'From env' }); + expect(fs.existsSync(dbPath)).toBe(true); + }); +}); + +describe('schema + connection', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-schema-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('initializeSchema brings a fresh connection to version 1', () => { + const conn = new DatabaseConnection({ dbPath: path.join(dir, 'tasks.db') }); + expect(getSchemaVersion(conn)).toBe(0); + initializeSchema(conn); + expect(getSchemaVersion(conn)).toBe(1); + conn.close(); + }); + + it('initializeSchema is idempotent (running again does not bump version)', () => { + const dbPath = path.join(dir, 'tasks.db'); + const conn = new DatabaseConnection({ dbPath }); + initializeSchema(conn); + initializeSchema(conn); + expect(getSchemaVersion(conn)).toBe(1); + conn.close(); + }); +}); diff --git a/packages/task-manager/tests/unit/actor-resolver.test.ts b/packages/task-manager/tests/unit/actor-resolver.test.ts new file mode 100644 index 00000000..e4877e0a --- /dev/null +++ b/packages/task-manager/tests/unit/actor-resolver.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolveCurrentActor, ATTRIB_ENV } from '../../src/actor-resolver.js'; + +describe('resolveCurrentActor', () => { + const origEnv = { ...process.env }; + + beforeEach(() => { + for (const key of Object.values(ATTRIB_ENV)) { + delete process.env[key]; + } + }); + + afterEach(() => { + for (const [key, value] of Object.entries(origEnv)) { + if (Object.values(ATTRIB_ENV).includes(key as never)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } + }); + + it('resolves pid from process.pid when no env is set', () => { + const actor = resolveCurrentActor(); + expect(actor).not.toBeNull(); + expect(actor?.pid).toBe(process.pid); + }); + + it('reads agent attribution from environment variables', () => { + process.env[ATTRIB_ENV.agentId] = 'agent-42'; + process.env[ATTRIB_ENV.agentType] = 'claude'; + process.env[ATTRIB_ENV.sessionId] = 'sess-abc'; + process.env[ATTRIB_ENV.agentPid] = '999'; + + const actor = resolveCurrentActor(); + expect(actor).toEqual({ + agentId: 'agent-42', + agentType: 'claude', + sessionId: 'sess-abc', + pid: 999, + }); + }); + + it('ignores a non-numeric AI_DEVKIT_AGENT_PID and falls back to process.pid', () => { + process.env[ATTRIB_ENV.agentPid] = 'not-a-number'; + const actor = resolveCurrentActor(); + expect(actor?.pid).toBe(process.pid); + }); + + it('explicit override takes precedence over environment', () => { + process.env[ATTRIB_ENV.agentId] = 'env-agent'; + const actor = resolveCurrentActor({ agentId: 'override-agent', agentType: 'pi' }); + expect(actor?.agentId).toBe('override-agent'); + expect(actor?.agentType).toBe('pi'); + }); + + it('returns a pid-only actor when no agent context resolves', () => { + const actor = resolveCurrentActor(); + expect(actor).not.toBeNull(); + expect(Object.keys(actor!)).toEqual(['pid']); + }); +}); diff --git a/packages/task-manager/tests/unit/errors.test.ts b/packages/task-manager/tests/unit/errors.test.ts new file mode 100644 index 00000000..79333c8b --- /dev/null +++ b/packages/task-manager/tests/unit/errors.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { + TaskError, + TaskNotFoundError, + TaskValidationError, + AmbiguousTaskRefError, + TaskResourceNotFoundError, + TaskRepositoryError, + UnknownEventTypeError, + isTaskEventType, +} from '../../src/task.errors.js'; + +const TASK_ID = '11111111-1111-4111-8111-111111111111'; +const OTHER_TASK_ID = '22222222-2222-4222-8222-222222222222'; +const BLOCKER_ID = '33333333-3333-4333-8333-333333333333'; + +describe('errors', () => { + it('TaskNotFoundError carries taskId and code', () => { + const err = new TaskNotFoundError(TASK_ID); + expect(err.code).toBe('TASK_NOT_FOUND'); + expect(err.details).toEqual({ taskId: TASK_ID }); + expect(err.toJSON()).toHaveProperty('error', 'TASK_NOT_FOUND'); + }); + + it('AmbiguousTaskRefError lists matches', () => { + const err = new AmbiguousTaskRefError('11111111', [TASK_ID, OTHER_TASK_ID]); + expect(err.code).toBe('AMBIGUOUS_TASK_REF'); + expect(err.details).toEqual({ ref: '11111111', matches: [TASK_ID, OTHER_TASK_ID] }); + }); + + it('TaskResourceNotFoundError names kind and id', () => { + const err = new TaskResourceNotFoundError(TASK_ID, 'Blocker', BLOCKER_ID); + expect(err.code).toBe('TASK_RESOURCE_NOT_FOUND'); + expect(err.message).toContain('Blocker'); + }); + + it('TaskValidationError has VALIDATION_ERROR code', () => { + const err = new TaskValidationError('bad input'); + expect(err.code).toBe('TASK_VALIDATION_ERROR'); + }); + + it('TaskRepositoryError has TASK_REPOSITORY_ERROR code', () => { + const err = new TaskRepositoryError('disk full'); + expect(err.code).toBe('TASK_REPOSITORY_ERROR'); + }); + + it('UnknownEventTypeError rejects unknown types', () => { + const err = new UnknownEventTypeError('task.bogus'); + expect(err.code).toBe('UNKNOWN_EVENT_TYPE'); + }); + + it('isTaskEventType guards the closed union', () => { + expect(isTaskEventType('task.created')).toBe(true); + expect(isTaskEventType('task.custom')).toBe(true); + expect(isTaskEventType('task.bogus')).toBe(false); + expect(isTaskEventType('')).toBe(false); + }); + + it('all errors extend TaskError and support toJSON', () => { + for (const err of [ + new TaskNotFoundError('x'), + new TaskValidationError('x'), + new AmbiguousTaskRefError('x', ['a']), + new TaskResourceNotFoundError('x', 'Blocker', 'y'), + new TaskRepositoryError('x'), + new UnknownEventTypeError('x'), + ]) { + expect(err).toBeInstanceOf(TaskError); + expect(err.toJSON()).toHaveProperty('message'); + } + }); +}); diff --git a/packages/task-manager/tests/unit/ids.test.ts b/packages/task-manager/tests/unit/ids.test.ts new file mode 100644 index 00000000..f28ba0a8 --- /dev/null +++ b/packages/task-manager/tests/unit/ids.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { + makeTaskId, + makeEventId, + makeBlockerId, + makeEvidenceId, + makeArtifactId, + nowIso, +} from '../../src/task.ids.js'; + +// A UUID v4 body (crypto.randomUUID() output): 8-4-4-4-12 lowercase hex digits. +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +describe('id generation', () => { + it('generates a task id as a raw UUIDv4', () => { + expect(makeTaskId()).toMatch(UUID); + }); + + it('generates all id kinds as raw UUIDv4', () => { + expect(makeEventId()).toMatch(UUID); + expect(makeBlockerId()).toMatch(UUID); + expect(makeEvidenceId()).toMatch(UUID); + expect(makeArtifactId()).toMatch(UUID); + }); + + it('nowIso returns a valid ISO 8601 string', () => { + expect(nowIso()).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); +}); diff --git a/packages/task-manager/tsconfig.json b/packages/task-manager/tsconfig.json new file mode 100644 index 00000000..0a01ad25 --- /dev/null +++ b/packages/task-manager/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "ES2022" + ], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "sourceMap": true, + "resolveJsonModule": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "isolatedModules": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "tests" + ] +} diff --git a/packages/task-manager/vitest.config.ts b/packages/task-manager/vitest.config.ts new file mode 100644 index 00000000..fbab2185 --- /dev/null +++ b/packages/task-manager/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/index.ts', 'src/**/*.d.ts', 'src/types/**'], + thresholds: { + branches: 75, + functions: 75, + lines: 75, + statements: 75, + }, + }, + }, +}); From 0c9559b16e347927b20e7775cb59d72b3bd20fd4 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen <hoangnn93@gmail.com> Date: Thu, 2 Jul 2026 13:07:15 +0200 Subject: [PATCH 2/5] feat(task-manager): use configured task db path --- .../design/2026-07-01-feature-task-system.md | 15 ++-- .../2026-07-01-feature-task-system.md | 8 +- .../2026-07-01-feature-task-system.md | 4 +- .../testing/2026-07-01-feature-task-system.md | 4 + .../cli/src/__tests__/commands/task.test.ts | 45 +++++++++++ packages/cli/src/__tests__/lib/Config.test.ts | 80 +++++++++++++++++++ packages/cli/src/commands/task.ts | 41 +++++----- packages/cli/src/lib/Config.ts | 9 ++- packages/cli/src/types.ts | 6 ++ packages/task-manager/README.md | 10 ++- .../task-manager/src/database/connection.ts | 9 +-- packages/task-manager/src/index.ts | 2 +- packages/task-manager/src/task.errors.ts | 7 -- packages/task-manager/src/task.repository.ts | 5 +- packages/task-manager/src/task.service.ts | 36 --------- packages/task-manager/src/task.types.ts | 20 ----- .../integration/add-event-coverage.test.ts | 5 -- .../tests/integration/service.test.ts | 9 +-- .../tests/integration/task.repository.test.ts | 26 ++---- 19 files changed, 202 insertions(+), 139 deletions(-) diff --git a/docs/ai/design/2026-07-01-feature-task-system.md b/docs/ai/design/2026-07-01-feature-task-system.md index e0691614..193e13c5 100644 --- a/docs/ai/design/2026-07-01-feature-task-system.md +++ b/docs/ai/design/2026-07-01-feature-task-system.md @@ -216,9 +216,11 @@ callers to remember ids. ## CLI (`ai-devkit task ...`) -All commands accept global flags `--store <path>` (override tasks dir), `--json` (machine -output), and attribution overrides `--agent <id>`, `--agent-type <t>`, `--pid <n>`, -`--session <s>`. Env: `AI_DEVKIT_TASKS_DB`. `<id>` resolves per `resolveTask` above. +All commands accept global flags `--db-path <path>` (explicit DB path override), `--json` +(machine output), and attribution overrides `--agent <id>`, `--agent-type <t>`, `--pid <n>`, +`--session <s>`. By default, the CLI resolves `.ai-devkit.json` `tasks.path` using +`ConfigManager`, then falls back to `~/.ai-devkit/tasks.db`. `<id>` resolves per `resolveTask` +above. | Command | Flags | Emits | |---|---|---| @@ -282,9 +284,10 @@ Tasks are stored in a single SQLite database at `~/.ai-devkit/tasks.db`: Task and event ids are raw UUIDv4 strings from Node `crypto.randomUUID()`, stored as TEXT (36 chars: `8-4-4-4-12` lowercase hex) — globally unique with no prefix, coordination, or collision -checks. Override the DB path with `--store`, `AI_DEVKIT_TASKS_DB`, or the `TaskRepository` / -`DatabaseConnection` constructor. Only `TaskRepository` reads/writes the database; callers use -`TaskService` or the CLI. +checks. Project CLI usage can configure `.ai-devkit.json` `tasks.path`; explicit callers can +override the DB path with `--db-path` or the `TaskRepository` / `DatabaseConnection` +constructor. Only `TaskRepository` reads/writes the database; callers use `TaskService` or +the CLI. ## Design Decisions & Trade-offs diff --git a/docs/ai/implementation/2026-07-01-feature-task-system.md b/docs/ai/implementation/2026-07-01-feature-task-system.md index 9d1565ed..4efa6b6a 100644 --- a/docs/ai/implementation/2026-07-01-feature-task-system.md +++ b/docs/ai/implementation/2026-07-01-feature-task-system.md @@ -72,10 +72,12 @@ Test: `packages/cli/src/__tests__/commands/task.test.ts`. ## Integration Points **How do pieces connect?** -- CLI → `TaskService` → `TaskRepository` → SQLite (`~/.ai-devkit/tasks.db`). +- CLI → `ConfigManager.getTasksDbPath()` → `TaskService` → `TaskRepository` → SQLite + (`.ai-devkit.json` `tasks.path`, else `~/.ai-devkit/tasks.db`). - Skills (`dev-lifecycle`, `verify`, `structured-debug`) emit via `ai-devkit task ...`. -- Storage default `~/.ai-devkit/tasks.db`; override via `--store`, `AI_DEVKIT_TASKS_DB`, or - the `TaskRepository` / `DatabaseConnection` constructor arg. +- Storage default `~/.ai-devkit/tasks.db`; CLI resolves `.ai-devkit.json` `tasks.path`, and + explicit overrides use `--db-path` or the `TaskRepository` / `DatabaseConnection` + constructor arg. ## Error Handling **How do we handle failures?** diff --git a/docs/ai/requirements/2026-07-01-feature-task-system.md b/docs/ai/requirements/2026-07-01-feature-task-system.md index 16b8623b..107a4acb 100644 --- a/docs/ai/requirements/2026-07-01-feature-task-system.md +++ b/docs/ai/requirements/2026-07-01-feature-task-system.md @@ -101,8 +101,8 @@ duplicate ids (negligible with UUIDs), appending to a task whose snapshot was ha 3. **Event model:** stateful event types mutate the snapshot; a generic `task.custom` event type is reserved for tracing/observability and does **not** mutate the snapshot. 4. **New package** `@ai-devkit/task-manager` mirrors `@ai-devkit/memory`. - 5. **Default DB path** `~/.ai-devkit/tasks.db`; overridable via the `--store` flag or - the `AI_DEVKIT_TASKS_DB` env var. + 5. **Default DB path** `~/.ai-devkit/tasks.db`; project CLI usage can set + `.ai-devkit.json` `tasks.path`, with `--db-path` as an explicit command override. ## Questions & Open Items **What do we still need to clarify?** diff --git a/docs/ai/testing/2026-07-01-feature-task-system.md b/docs/ai/testing/2026-07-01-feature-task-system.md index 1402630f..005ae54e 100644 --- a/docs/ai/testing/2026-07-01-feature-task-system.md +++ b/docs/ai/testing/2026-07-01-feature-task-system.md @@ -33,6 +33,9 @@ Unit + integration coverage in `packages/task-manager/tests/` and a command-laye - [x] actor auto-resolution env/override/pid — `actor-resolver.test.ts` - [x] error hierarchy + isTaskEventType guard — `errors.test.ts` - [x] CLI verbs parse flags, resolve refs, print JSON/table, error on bad input — `task.test.ts` (20 tests) +- [x] CLI resolves task DB path from `.ai-devkit.json` `tasks.path`; `--db-path` overrides config — `task.test.ts` +- [x] `ConfigManager.getTasksDbPath()` handles missing, blank, absolute, and relative paths — `Config.test.ts` +- [x] Task package DB resolution uses only explicit `dbPath` or `~/.ai-devkit/tasks.db` fallback — `task.repository.test.ts` ### Mocks / Fixtures - Command tests mock `@ai-devkit/task-manager` (mirrors memory command test pattern). @@ -53,6 +56,7 @@ vitest v8; package thresholds statements/branches/functions/lines ≥ 75%. - `packages/task-manager/tests/integration/service.test.ts` - `packages/task-manager/tests/integration/add-event-coverage.test.ts` - `packages/cli/src/__tests__/commands/task.test.ts` +- `packages/cli/src/__tests__/lib/Config.test.ts` ## Results - `nx test` (whole repo): 890 passing, 0 failing (73 test files). diff --git a/packages/cli/src/__tests__/commands/task.test.ts b/packages/cli/src/__tests__/commands/task.test.ts index 2c9f6661..c2b88322 100644 --- a/packages/cli/src/__tests__/commands/task.test.ts +++ b/packages/cli/src/__tests__/commands/task.test.ts @@ -5,6 +5,7 @@ import * as os from 'os'; import * as path from 'path'; import { registerTaskCommand } from '../../commands/task.js'; +import { createTaskService } from '@ai-devkit/task-manager'; import { ui } from '../../util/terminal-ui.js'; // Mock the task-manager package so the command layer is tested in isolation, @@ -36,6 +37,11 @@ const EVENT_ID = '33333333-3333-4333-8333-333333333333'; const BLOCKER_ID = '44444444-4444-4444-8444-444444444444'; const EVIDENCE_ID = '55555555-5555-4555-8555-555555555555'; +const mockGetTasksDbPath = vi.fn<() => Promise<string | undefined>>(); +const mockConfigManager = { + getTasksDbPath: mockGetTasksDbPath, +}; + vi.mock('@ai-devkit/task-manager', () => ({ createTaskService: vi.fn(() => mockTaskService), resolveCurrentActor: vi.fn((override?: Record<string, unknown>) => @@ -46,6 +52,10 @@ vi.mock('@ai-devkit/task-manager', () => ({ TaskNotFoundError: class TaskNotFoundError extends Error {}, })); +vi.mock('../../lib/Config.js', () => ({ + ConfigManager: vi.fn(function () { return mockConfigManager; }), +})); + vi.mock('../../util/terminal-ui.js', () => ({ ui: { error: vi.fn(), @@ -85,15 +95,50 @@ function sampleTask(overrides: Record<string, unknown> = {}) { describe('task command', () => { const mockedUi = vi.mocked(ui); + const mockedCreateTaskService = createTaskService as MockedFunction<typeof createTaskService>; let consoleLogSpy: ReturnType<typeof vi.spyOn>; beforeEach(() => { vi.clearAllMocks(); + mockGetTasksDbPath.mockResolvedValue(undefined); Object.values(mockTaskService).forEach((fn) => (fn as ReturnType<typeof vi.fn>).mockReset()); consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); }); describe('create', () => { + it('uses the configured task database path', async () => { + mockGetTasksDbPath.mockResolvedValue('/repo/.ai-devkit/tasks.db'); + const task = sampleTask(); + mockTaskService.create.mockResolvedValue(task); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync([ + 'node', 'test', 'task', 'create', + '--title', 'Sample task', + '--json', + ]); + + expect(mockedCreateTaskService).toHaveBeenCalledWith('/repo/.ai-devkit/tasks.db'); + }); + + it('lets --db-path override the configured task database path', async () => { + mockGetTasksDbPath.mockResolvedValue('/repo/.ai-devkit/tasks.db'); + const task = sampleTask(); + mockTaskService.create.mockResolvedValue(task); + + const program = new Command(); + registerTaskCommand(program); + await program.parseAsync([ + 'node', 'test', 'task', 'create', + '--title', 'Sample task', + '--db-path', '/tmp/tasks.db', + '--json', + ]); + + expect(mockedCreateTaskService).toHaveBeenCalledWith('/tmp/tasks.db'); + }); + it('creates a task and prints JSON', async () => { const task = sampleTask(); mockTaskService.create.mockResolvedValue(task); diff --git a/packages/cli/src/__tests__/lib/Config.test.ts b/packages/cli/src/__tests__/lib/Config.test.ts index 9abb6d46..7db8e4b2 100644 --- a/packages/cli/src/__tests__/lib/Config.test.ts +++ b/packages/cli/src/__tests__/lib/Config.test.ts @@ -774,4 +774,84 @@ describe('ConfigManager', () => { expect(result).toBe('/test/dir/.ai-devkit/project-memory.db'); }); }); + + describe('getTasksDbPath', () => { + it('returns undefined when config does not exist', async () => { + (mockFs.pathExists as any).mockResolvedValue(false); + + const result = await configManager.getTasksDbPath(); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when tasks.path is missing', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue({ + version: '1.0.0', + environments: [], + phases: [], + createdAt: '2024-01-01T00:00:00.000Z', + }); + + const result = await configManager.getTasksDbPath(); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when tasks.path is blank or invalid', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue({ + version: '1.0.0', + environments: [], + phases: [], + tasks: { path: ' ' }, + createdAt: '2024-01-01T00:00:00.000Z', + }); + + await expect(configManager.getTasksDbPath()).resolves.toBeUndefined(); + + (mockFs.readJson as any).mockResolvedValue({ + version: '1.0.0', + environments: [], + phases: [], + tasks: { path: 42 }, + createdAt: '2024-01-01T00:00:00.000Z', + }); + + await expect(configManager.getTasksDbPath()).resolves.toBeUndefined(); + }); + + it('returns absolute tasks.path as-is', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue({ + version: '1.0.0', + environments: [], + phases: [], + tasks: { path: '/custom/tasks.db' }, + createdAt: '2024-01-01T00:00:00.000Z', + }); + + const result = await configManager.getTasksDbPath(); + + expect(result).toBe('/custom/tasks.db'); + expect(mockPath.isAbsolute).toHaveBeenCalledWith('/custom/tasks.db'); + }); + + it('resolves relative tasks.path from the config directory', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue({ + version: '1.0.0', + environments: [], + phases: [], + tasks: { path: '.ai-devkit/project-tasks.db' }, + createdAt: '2024-01-01T00:00:00.000Z', + }); + + const result = await configManager.getTasksDbPath(); + + expect(mockPath.dirname).toHaveBeenCalledWith('/test/dir/.ai-devkit.json'); + expect(mockPath.resolve).toHaveBeenCalledWith('/test/dir', '.ai-devkit/project-tasks.db'); + expect(result).toBe('/test/dir/.ai-devkit/project-tasks.db'); + }); + }); }); diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 72018b86..fa501fe8 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -7,6 +7,7 @@ import { isTaskEventType, } from '@ai-devkit/task-manager'; import type { Actor, Task, TaskService, TaskStatus } from '@ai-devkit/task-manager'; +import { ConfigManager } from '../lib/Config.js'; import { ui } from '../util/terminal-ui.js'; import { withErrorHandler } from '../util/errors.js'; import { truncate } from '../util/text.js'; @@ -36,8 +37,12 @@ function actorFromOptions(opts: AttributionOptions): Actor | undefined { return resolveCurrentActor(override) ?? undefined; } -function createService(storeFlag?: string): TaskService { - return createTaskService(storeFlag); +async function createService(dbPathFlag?: string): Promise<TaskService> { + if (dbPathFlag && dbPathFlag.trim()) { + return createTaskService(dbPathFlag); + } + const configManager = new ConfigManager(); + return createTaskService(await configManager.getTasksDbPath()); } function output(value: unknown, json: boolean): void { @@ -123,7 +128,7 @@ export function registerTaskCommand(program: Command): void { const addAttributionFlags = (cmd: Command): Command => cmd - .option('--store <db-path>', 'Override the tasks database path (env: AI_DEVKIT_TASKS_DB)') + .option('--db-path <path>', 'Override the configured tasks database path') .option('--agent <id>', 'Agent id for attribution') .option('--agent-type <type>', 'Agent type for attribution (e.g. claude, pi)') .option('--pid <pid>', 'Process id for attribution') @@ -144,7 +149,7 @@ export function registerTaskCommand(program: Command): void { .option('--pr <url>', 'Pull request link') ).action( withErrorHandler('create task', async (opts) => { - const service = createService(opts.store); + const service = await createService(opts.dbPath); const created = await service.create({ title: opts.title, feature: opts.feature, @@ -173,7 +178,7 @@ export function registerTaskCommand(program: Command): void { .option('--limit <n>', 'Maximum results', '20') ).action( withErrorHandler('list tasks', async (opts) => { - const service = createService(opts.store); + const service = await createService(opts.dbPath); const tasks = await service.list({ feature: opts.feature, status: opts.status as TaskStatus | undefined, @@ -208,7 +213,7 @@ export function registerTaskCommand(program: Command): void { .option('--events', 'Include the event history') ).action( withErrorHandler('show task', async (id: string, opts) => { - const service = createService(opts.store); + const service = await createService(opts.dbPath); const resolved = await resolveOrError(service, id); if (!resolved) return; const taskObj = await service.get(resolved.taskId); @@ -243,7 +248,7 @@ export function registerTaskCommand(program: Command): void { .option('--pr <url>', 'Pull request link') ).action( withErrorHandler('update task', async (id: string, opts) => { - const service = createService(opts.store); + const service = await createService(opts.dbPath); const resolved = await resolveOrError(service, id); if (!resolved) return; const patch: Record<string, unknown> = {}; @@ -262,7 +267,7 @@ export function registerTaskCommand(program: Command): void { addAttributionFlags(task.command('phase <id> <phase>').description('Set the lifecycle phase')).action( withErrorHandler('set task phase', async (id: string, phase: string, opts) => { - const service = createService(opts.store); + const service = await createService(opts.dbPath); const resolved = await resolveOrError(service, id); if (!resolved) return; const updated = await service.setPhase(resolved.taskId, phase, { actor: actorFromOptions(opts) }); @@ -276,7 +281,7 @@ export function registerTaskCommand(program: Command): void { .description(`Set status (${VALID_STATUSES.join('|')})`) ).action( withErrorHandler('set task status', async (id: string, status: string, opts) => { - const service = createService(opts.store); + const service = await createService(opts.dbPath); const resolved = await resolveOrError(service, id); if (!resolved) return; const updated = await service.setStatus(resolved.taskId, status as TaskStatus, { @@ -295,7 +300,7 @@ export function registerTaskCommand(program: Command): void { .option('--clear', 'Clear progress') ).action( withErrorHandler('set task progress', async (id: string, opts) => { - const service = createService(opts.store); + const service = await createService(opts.dbPath); const resolved = await resolveOrError(service, id); if (!resolved) return; const progress = @@ -319,7 +324,7 @@ export function registerTaskCommand(program: Command): void { .option('--clear', 'Clear the next step') ).action( withErrorHandler('set task next step', async (id: string, stepParts: string[] | undefined, opts) => { - const service = createService(opts.store); + const service = await createService(opts.dbPath); const resolved = await resolveOrError(service, id); if (!resolved) return; const step = opts.clear === true ? null : (stepParts ?? []).join(' ').trim() || null; @@ -336,7 +341,7 @@ export function registerTaskCommand(program: Command): void { .description('Manage blockers: add <text> | resolve <blockerId>') ).action( withErrorHandler('manage blocker', async (id: string, action: string, rest: string[] | undefined, opts) => { - const service = createService(opts.store); + const service = await createService(opts.dbPath); const resolved = await resolveOrError(service, id); if (!resolved) return; const args = rest ?? []; @@ -383,7 +388,7 @@ export function registerTaskCommand(program: Command): void { .option('--artifact <path>', 'Artifact reference (repeatable)', (val: string, acc: string[]) => [...acc, val], [] as string[]) ).action( withErrorHandler('record evidence', async (id: string, opts) => { - const service = createService(opts.store); + const service = await createService(opts.dbPath); const resolved = await resolveOrError(service, id); if (!resolved) return; if (!opts.passed && !opts.failed) { @@ -414,7 +419,7 @@ export function registerTaskCommand(program: Command): void { .option('--description <description>', 'Artifact description') ).action( withErrorHandler('add artifact', async (id: string, artifactPath: string, opts) => { - const service = createService(opts.store); + const service = await createService(opts.dbPath); const resolved = await resolveOrError(service, id); if (!resolved) return; const result = await service.addArtifact( @@ -436,7 +441,7 @@ export function registerTaskCommand(program: Command): void { .option('--session <id>', 'Session id') ).action( withErrorHandler('assign task', async (id: string, opts) => { - const service = createService(opts.store); + const service = await createService(opts.dbPath); const resolved = await resolveOrError(service, id); if (!resolved) return; const actor = actorFromOptions(opts); @@ -452,7 +457,7 @@ export function registerTaskCommand(program: Command): void { addAttributionFlags(task.command('note <id> [text...]').description('Append a note (event-only)')).action( withErrorHandler('append note', async (id: string, textParts: string[] | undefined, opts) => { - const service = createService(opts.store); + const service = await createService(opts.dbPath); const resolved = await resolveOrError(service, id); if (!resolved) return; const text = (textParts ?? []).join(' ').trim(); @@ -476,7 +481,7 @@ export function registerTaskCommand(program: Command): void { .option('--payload <json|@file>', 'JSON payload or @path to a JSON file') ).action( withErrorHandler('append event', async (id: string, opts) => { - const service = createService(opts.store); + const service = await createService(opts.dbPath); const resolved = await resolveOrError(service, id); if (!resolved) return; const type = opts.type ?? 'task.custom'; @@ -505,7 +510,7 @@ export function registerTaskCommand(program: Command): void { .description('Close a task (completed|abandoned). Default: completed') ).action( withErrorHandler('close task', async (id: string, statusArg: string | undefined, opts) => { - const service = createService(opts.store); + const service = await createService(opts.dbPath); const resolved = await resolveOrError(service, id); if (!resolved) return; const status = (statusArg ?? 'completed') as 'completed' | 'abandoned'; diff --git a/packages/cli/src/lib/Config.ts b/packages/cli/src/lib/Config.ts index 9e41b093..58ef5857 100644 --- a/packages/cli/src/lib/Config.ts +++ b/packages/cli/src/lib/Config.ts @@ -97,8 +97,15 @@ export class ConfigManager { async getMemoryDbPath(): Promise<string | undefined> { const config = await this.read(); - const configuredPath = config?.memory?.path; + return this.resolveConfiguredPath(config?.memory?.path); + } + + async getTasksDbPath(): Promise<string | undefined> { + const config = await this.read(); + return this.resolveConfiguredPath(config?.tasks?.path); + } + private resolveConfiguredPath(configuredPath: unknown): string | undefined { if (typeof configuredPath !== 'string') { return undefined; } diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index f0261013..7740ade0 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -28,6 +28,9 @@ export interface DevKitConfig { memory?: { path?: string; }; + tasks?: { + path?: string; + }; environments: EnvironmentCode[]; phases: Phase[]; registries?: Record<string, string>; @@ -60,6 +63,9 @@ export interface GlobalDevKitConfig { memory?: { path?: string; }; + tasks?: { + path?: string; + }; } export interface PhaseMetadata { diff --git a/packages/task-manager/README.md b/packages/task-manager/README.md index c362b651..75683b46 100644 --- a/packages/task-manager/README.md +++ b/packages/task-manager/README.md @@ -14,9 +14,10 @@ Tasks are stored in a single SQLite database at `~/.ai-devkit/tasks.db`: Uses `better-sqlite3` with WAL + `busy_timeout` (same library and approach as `@ai-devkit/memory`). Task/event ids are raw UUIDv4 (`crypto.randomUUID()`), stored as TEXT -(globally unique, no collision checks). Override the DB path with `--store`, -`AI_DEVKIT_TASKS_DB`, or the `TaskRepository` / `DatabaseConnection` constructor. `TaskRepository` -owns task/event persistence over the shared SQLite connection. +(globally unique, no collision checks). CLI callers can configure `.ai-devkit.json` +`tasks.path` or override it with `--db-path`; library callers pass an explicit path to +`createTaskService`, `TaskRepository`, or `DatabaseConnection`. `TaskRepository` owns +task/event persistence over the shared SQLite connection. ## Quick start @@ -53,7 +54,8 @@ task). `ai-devkit task ...` — create, list, show, update, phase, status, progress, next, blocker `<id> add <text>|resolve <blockerId>`, evidence, artifact, assign, note, event, -close. Global flags: `--store`, `--json`, `--agent`, `--agent-type`, `--pid`, `--session`. +close. The CLI resolves `.ai-devkit.json` `tasks.path` when present. Global flags: +`--db-path`, `--json`, `--agent`, `--agent-type`, `--pid`, `--session`. See the design doc for the full verb/flag table. ```bash diff --git a/packages/task-manager/src/database/connection.ts b/packages/task-manager/src/database/connection.ts index 991ea2aa..c09920a1 100644 --- a/packages/task-manager/src/database/connection.ts +++ b/packages/task-manager/src/database/connection.ts @@ -18,18 +18,13 @@ export interface DatabaseOptions { /** * Resolve the database file path for the CLI/service. * - * Precedence: explicit `dbPath` arg > `AI_DEVKIT_TASKS_DB` env > default. (The - * `DatabaseConnection` constructor itself does not read the env var — mirroring - * @ai-devkit/memory — so this helper is the single place that does.) + * Precedence: explicit `dbPath` arg > default. Project-aware callers should + * resolve configured paths before constructing the service/repository. */ export function resolveDbPath(dbPath?: string): string { if (dbPath && dbPath.trim()) { return dbPath.trim(); } - const env = process.env.AI_DEVKIT_TASKS_DB; - if (env && env.trim()) { - return env.trim(); - } return DEFAULT_DB_PATH; } diff --git a/packages/task-manager/src/index.ts b/packages/task-manager/src/index.ts index 7d8ed36c..99773cb8 100644 --- a/packages/task-manager/src/index.ts +++ b/packages/task-manager/src/index.ts @@ -61,7 +61,7 @@ export { resolveCurrentActor, ATTRIB_ENV } from './actor-resolver.js'; /** * Convenience factory: a TaskService backed by a TaskRepository at the resolved - * DB path (dbPath arg > AI_DEVKIT_TASKS_DB > ~/.ai-devkit/tasks.db). + * DB path (dbPath arg > ~/.ai-devkit/tasks.db). */ import { TaskRepository } from './task.repository.js'; import { TaskService } from './task.service.js'; diff --git a/packages/task-manager/src/task.errors.ts b/packages/task-manager/src/task.errors.ts index a25942a3..0687ac20 100644 --- a/packages/task-manager/src/task.errors.ts +++ b/packages/task-manager/src/task.errors.ts @@ -23,7 +23,6 @@ export class TaskError extends Error { } } -/** Thrown when a referenced task id does not exist. */ export class TaskNotFoundError extends TaskError { constructor(taskId: string) { super(`Task not found: ${taskId}`, 'TASK_NOT_FOUND', { taskId }); @@ -31,7 +30,6 @@ export class TaskNotFoundError extends TaskError { } } -/** Thrown when user input or a task field fails validation. */ export class TaskValidationError extends TaskError { constructor(message: string, details?: Record<string, unknown>) { super(message, 'TASK_VALIDATION_ERROR', details); @@ -39,7 +37,6 @@ export class TaskValidationError extends TaskError { } } -/** Thrown when a task reference resolves to multiple candidates (e.g. ambiguous prefix). */ export class AmbiguousTaskRefError extends TaskError { constructor(ref: string, matches: string[]) { super( @@ -51,7 +48,6 @@ export class AmbiguousTaskRefError extends TaskError { } } -/** Thrown when a referenced sub-resource (blocker, artifact) does not exist on a task. */ export class TaskResourceNotFoundError extends TaskError { constructor(taskId: string, kind: string, id: string) { super(`${kind} "${id}" not found on task ${taskId}`, 'TASK_RESOURCE_NOT_FOUND', { taskId, kind, id }); @@ -59,7 +55,6 @@ export class TaskResourceNotFoundError extends TaskError { } } -/** Thrown when the underlying persistence (repository) layer fails. */ export class TaskRepositoryError extends TaskError { constructor(message: string, details?: Record<string, unknown>) { super(message, 'TASK_REPOSITORY_ERROR', details); @@ -67,7 +62,6 @@ export class TaskRepositoryError extends TaskError { } } -/** Thrown when an event type is not part of the closed set or cannot mutate a snapshot. */ export class UnknownEventTypeError extends TaskError { constructor(type: string) { super(`Unknown task event type: ${type}`, 'UNKNOWN_EVENT_TYPE', { type }); @@ -75,7 +69,6 @@ export class UnknownEventTypeError extends TaskError { } } -/** Type guard for the closed TaskEventType union. */ export function isTaskEventType(type: string): type is TaskEventType { return [ 'task.created', diff --git a/packages/task-manager/src/task.repository.ts b/packages/task-manager/src/task.repository.ts index ff45ec8f..1ecb631a 100644 --- a/packages/task-manager/src/task.repository.ts +++ b/packages/task-manager/src/task.repository.ts @@ -29,8 +29,8 @@ function describeError(error: unknown): string { * - `tasks` — one row per task snapshot (full Task JSON + indexed cols) * - `task_events` — append-only event history (one row per event) * - * Default DB path: ~/.ai-devkit/tasks.db (override via the `dbPath` arg or - * AI_DEVKIT_TASKS_DB; see resolveDbPath). Long-running callers release the shared + * Default DB path: ~/.ai-devkit/tasks.db (override via the `dbPath` arg; see + * resolveDbPath). Long-running callers release the shared * connection with `closeDatabase()`. */ export class TaskRepository { @@ -40,7 +40,6 @@ export class TaskRepository { this.dbPath = dbPath; } - /** Shared connection (opens/initializes the schema on first use). */ private db(): DatabaseConnection { try { return getDatabase({ dbPath: this.dbPath }); diff --git a/packages/task-manager/src/task.service.ts b/packages/task-manager/src/task.service.ts index 9107552c..d55ff354 100644 --- a/packages/task-manager/src/task.service.ts +++ b/packages/task-manager/src/task.service.ts @@ -23,9 +23,7 @@ import { import { makeArtifactId, makeBlockerId, makeEvidenceId, makeEventId, makeTaskId, nowIso } from './task.ids.js'; import { resolveCurrentActor } from './actor-resolver.js'; -/** Options accepted by every mutating service method. */ export interface TaskMutationOptions { - /** Override attribution for this mutation; auto-resolved if omitted. */ actor?: Actor; } @@ -126,10 +124,6 @@ function validateFeature(feature: string | null | undefined): string | null { export class TaskService { constructor(private readonly repository: TaskRepository) {} - // ------------------------------------------------------------------------- - // identity / lookup - // ------------------------------------------------------------------------- - async create(input: TaskCreateInput): Promise<Task> { validateTitle(input.title); const feature = validateFeature(input.feature ?? null); @@ -177,7 +171,6 @@ export class TaskService { phase: task.phase, }, actor); - // Refresh cached counters (eventCount/lastEventAt) after appending the event. return this.refreshCachedCounters(task); } @@ -200,13 +193,11 @@ export class TaskService { return this.latestNonTerminalByFeature(ref.feature); } - // Direct id match. const direct = await this.repository.readTask(ref); if (direct) { return direct; } - // Unique prefix match. const ids = await this.repository.listTaskIds(); const prefixed = ids.filter((id) => id.startsWith(ref)); if (prefixed.length === 1) { @@ -217,7 +208,6 @@ export class TaskService { throw new AmbiguousTaskRefError(ref, prefixed); } - // Feature key fallback. return this.latestNonTerminalByFeature(ref); } @@ -255,10 +245,6 @@ export class TaskService { return tasks; } - // ------------------------------------------------------------------------- - // generic scalar patch - // ------------------------------------------------------------------------- - async update(taskId: string, patch: TaskUpdatePatch, opts?: TaskMutationOptions): Promise<Task> { const task = await this.requireTask(taskId); const fields: string[] = []; @@ -289,10 +275,6 @@ export class TaskService { return this.persistAndRecord(task, 'task.updated', { patch, fields }, opts); } - // ------------------------------------------------------------------------- - // dedicated state setters - // ------------------------------------------------------------------------- - async setPhase(taskId: string, phase: LifecyclePhase, opts?: TaskMutationOptions): Promise<Task> { const task = await this.requireTask(taskId); const previous = task.phase; @@ -348,10 +330,6 @@ export class TaskService { return this.persistAndRecord(task, 'task.next_step.set', { step: normalized }, opts); } - // ------------------------------------------------------------------------- - // blockers / evidence / artifacts / attribution - // ------------------------------------------------------------------------- - async addBlocker( taskId: string, input: { text: string }, @@ -479,10 +457,6 @@ export class TaskService { return this.persistAndRecord(task, 'task.attribution.set', payload, opts); } - // ------------------------------------------------------------------------- - // notes / lifecycle - // ------------------------------------------------------------------------- - async addNote(taskId: string, text: string, opts?: TaskMutationOptions): Promise<Task> { const trimmed = text?.trim(); if (!trimmed) { @@ -508,10 +482,6 @@ export class TaskService { return this.persistAndRecord(task, 'task.closed', { status }, opts); } - // ------------------------------------------------------------------------- - // low-level event escape hatch + event read - // ------------------------------------------------------------------------- - /** * Low-level event append. For a known stateful `type` it applies the matching * snapshot mutation then appends; for non-mutating types it appends only. @@ -535,7 +505,6 @@ export class TaskService { await this.repository.writeTask(task); } const event = await this.appendEventInternal(taskId, type, payload, actor); - // Refresh cached counters (eventCount/lastEventAt) after appending the event. await this.refreshCachedCounters(task); return event; } @@ -552,10 +521,6 @@ export class TaskService { return events; } - // ------------------------------------------------------------------------- - // internals - // ------------------------------------------------------------------------- - private async requireTask(taskId: string): Promise<Task> { const task = await this.repository.readTask(taskId); if (!task) { @@ -734,7 +699,6 @@ export class TaskService { } case 'task.note.append': case 'task.custom': - // Non-mutating types: event-only. return false; default: return false; diff --git a/packages/task-manager/src/task.types.ts b/packages/task-manager/src/task.types.ts index 24b0b49c..43c57a99 100644 --- a/packages/task-manager/src/task.types.ts +++ b/packages/task-manager/src/task.types.ts @@ -5,10 +5,6 @@ * public surface; consumers (CLI, skills) wire up against them directly. */ -// --------------------------------------------------------------------------- -// Attribution -// --------------------------------------------------------------------------- - /** * Attribution unit: who emitted an event or who currently owns a task. * All fields optional; a completely-unattributed actor is represented as `null`. @@ -24,10 +20,6 @@ export interface Actor { sessionId?: string; } -// --------------------------------------------------------------------------- -// Task snapshot (JSON, authoritative for reads) -// --------------------------------------------------------------------------- - export type TaskStatus = 'open' | 'active' | 'blocked' | 'completed' | 'abandoned'; /** @@ -91,14 +83,12 @@ export interface TaskArtifact { * Task snapshot. Persisted as JSON in the `tasks` table; authoritative for reads. */ export interface Task { - // identity /** Raw UUIDv4; immutable, never reused. */ taskId: string; title: string; summary: string | null; /** Kebab-case feature key, e.g. "task-system". Nullable for ad-hoc tasks. */ feature: string | null; - // state status: TaskStatus; phase: LifecyclePhase; phaseEnteredAt: string | null; // ISO 8601 @@ -107,14 +97,12 @@ export interface Task { blockers: TaskBlocker[]; evidence: TaskEvidence[]; artifacts: TaskArtifact[]; - // ownership / links /** Current owner (who owns the task now); per-event emitter is on each event. */ attribution: Actor | null; links: TaskLinks; tags: string[]; /** Free-form tracing extras (kept as primitive values for JSON portability). */ meta: Record<string, string | number | boolean | null>; - // bookkeeping createdAt: string; // ISO 8601 updatedAt: string; // ISO 8601 createdBy: Actor | null; @@ -124,10 +112,6 @@ export interface Task { lastEventAt: string | null; } -// --------------------------------------------------------------------------- -// TaskEvent (one row in the `task_events` table — append-only audit trail) -// --------------------------------------------------------------------------- - /** * Closed set of event type strings. Do not change the literal values. */ @@ -160,10 +144,6 @@ export interface TaskEvent { payload: Record<string, unknown>; } -// --------------------------------------------------------------------------- -// Mutating stateful event payloads (strongly typed helpers) -// --------------------------------------------------------------------------- - export interface TaskCreatedPayload { title: string; feature?: string | null; diff --git a/packages/task-manager/tests/integration/add-event-coverage.test.ts b/packages/task-manager/tests/integration/add-event-coverage.test.ts index 695a1be7..702c50b0 100644 --- a/packages/task-manager/tests/integration/add-event-coverage.test.ts +++ b/packages/task-manager/tests/integration/add-event-coverage.test.ts @@ -26,7 +26,6 @@ function writeRawSnapshot(dbPath: string, taskId: string, rawSnapshot: string): ); } -/** Append a raw event payload string directly (bypassing JSON encoding). */ function appendRawEvent( dbPath: string, taskId: string, @@ -41,10 +40,6 @@ function appendRawEvent( ); } -/** - * Coverage-focused tests for the low-level addEvent escape hatch (each stateful - * type applies a snapshot mutation) and for repository error branches. - */ function createRepository(): { repository: TaskRepository; dir: string } { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-evt-')); const repository = new TaskRepository(path.join(dir, 'tasks.db')); diff --git a/packages/task-manager/tests/integration/service.test.ts b/packages/task-manager/tests/integration/service.test.ts index ccdd6f1e..d164abe7 100644 --- a/packages/task-manager/tests/integration/service.test.ts +++ b/packages/task-manager/tests/integration/service.test.ts @@ -215,8 +215,6 @@ describe('TaskService (integration with TaskRepository)', () => { }); expect(artifactId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); const updated = await service.get(task.taskId); - // Artifacts are references only: no file is copied; the snapshot - // just stores the path, which round-trips through the repository. expect(updated.artifacts[0]).toMatchObject({ path: '/tmp/build.log', kind: 'log' }); }); }); @@ -235,7 +233,6 @@ describe('TaskService (integration with TaskRepository)', () => { const task = await service.create({ title: 'T' }); const before = await service.get(task.taskId); const updated = await service.addNote(task.taskId, 'a quick note'); - // title/phase unchanged; eventCount bumped. expect(updated.title).toBe(before.title); expect(updated.eventCount).toBe(before.eventCount + 1); const events = await readEventsFromDisk(task.taskId); @@ -291,7 +288,7 @@ describe('TaskService (integration with TaskRepository)', () => { it('resolves by unique id prefix', async () => { const task = await service.create({ title: 'T', feature: 'feat' }); - const prefix = task.taskId.slice(0, 8); // first hex segment + const prefix = task.taskId.slice(0, 8); const resolved = await service.resolveTask(prefix); expect(resolved?.taskId).toBe(task.taskId); }); @@ -303,7 +300,6 @@ describe('TaskService (integration with TaskRepository)', () => { const t2 = '00000000-0000-4000-8000-0000000000ab'; await writeDirectTask(repository, t1); await writeDirectTask(repository, t2); - // Both match the shared prefix. await expect(service.resolveTask('00000000')).rejects.toBeInstanceOf( AmbiguousTaskRefError ); @@ -312,7 +308,7 @@ describe('TaskService (integration with TaskRepository)', () => { it('resolves by feature key to the latest non-terminal task', async () => { const a = await service.create({ title: 'A', feature: 'feat' }); const b = await service.create({ title: 'B', feature: 'feat' }); - await service.close(a.taskId, 'completed'); // a is terminal + await service.close(a.taskId, 'completed'); const resolved = await service.resolveTask('feat'); expect(resolved?.taskId).toBe(b.taskId); }); @@ -330,7 +326,6 @@ describe('TaskService (integration with TaskRepository)', () => { const featTasks = await service.list({ feature: 'feat' }); expect(featTasks).toHaveLength(2); - // Newest first. expect(featTasks[0]!.title).toBe('B'); }); }); diff --git a/packages/task-manager/tests/integration/task.repository.test.ts b/packages/task-manager/tests/integration/task.repository.test.ts index 4b08ee1c..cfd55a31 100644 --- a/packages/task-manager/tests/integration/task.repository.test.ts +++ b/packages/task-manager/tests/integration/task.repository.test.ts @@ -72,7 +72,6 @@ function writeRawSnapshot(dbPath: string, taskId: string, rawSnapshot: string): ); } -/** Append a raw event payload string directly (bypassing JSON encoding). */ function appendRawEvent( dbPath: string, taskId: string, @@ -93,7 +92,7 @@ describe('TaskRepository', () => { let repository: TaskRepository; beforeEach(() => { - closeDatabase(); // reset the process-wide singleton for a clean DB file + closeDatabase(); dir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-sqlite-')); dbPath = path.join(dir, 'tasks.db'); repository = new TaskRepository(dbPath); @@ -190,30 +189,21 @@ describe('TaskRepository', () => { }); describe('resolveDbPath', () => { - const origEnv = { ...process.env }; - - afterEach(() => { - process.env = { ...origEnv }; - }); - it('uses explicit argument first', () => { expect(resolveDbPath('/explicit/tasks.db')).toBe('/explicit/tasks.db'); }); - it('falls back to AI_DEVKIT_TASKS_DB when no argument', () => { - process.env.AI_DEVKIT_TASKS_DB = '/from/env/tasks.db'; - expect(resolveDbPath()).toBe('/from/env/tasks.db'); + it('trims explicit arguments', () => { + expect(resolveDbPath(' /explicit/tasks.db ')).toBe('/explicit/tasks.db'); }); it('falls back to ~/.ai-devkit/tasks.db when nothing is set', () => { - delete process.env.AI_DEVKIT_TASKS_DB; expect(resolveDbPath()).toBe(path.join(os.homedir(), '.ai-devkit', 'tasks.db')); expect(DEFAULT_DB_PATH).toBe(path.join(os.homedir(), '.ai-devkit', 'tasks.db')); }); }); describe('createTaskService', () => { - const origEnv = { ...process.env }; let dir: string; beforeEach(() => { @@ -223,15 +213,13 @@ describe('createTaskService', () => { afterEach(() => { closeDatabase(); - process.env = { ...origEnv }; fs.rmSync(dir, { recursive: true, force: true }); }); - it('uses AI_DEVKIT_TASKS_DB when no explicit path is provided', async () => { - const dbPath = path.join(dir, 'env-tasks.db'); - process.env.AI_DEVKIT_TASKS_DB = dbPath; - const service = createTaskService(); - await service.create({ title: 'From env' }); + it('uses an explicit path when provided', async () => { + const dbPath = path.join(dir, 'configured-tasks.db'); + const service = createTaskService(dbPath); + await service.create({ title: 'From explicit path' }); expect(fs.existsSync(dbPath)).toBe(true); }); }); From 8d1d63f9c7ea3dd8d4fda50c2d2a6058fac17b0a Mon Sep 17 00:00:00 2001 From: Hoang Nguyen <hoangnn93@gmail.com> Date: Thu, 2 Jul 2026 13:33:01 +0200 Subject: [PATCH 3/5] feat(task-manager): ship task command as plugin --- .../design/2026-07-01-feature-task-system.md | 23 +- .../2026-07-01-feature-task-system.md | 20 +- .../2026-07-01-feature-task-system.md | 16 +- .../2026-07-01-feature-task-system.md | 3 +- .../testing/2026-07-01-feature-task-system.md | 26 +- package-lock.json | 2 +- packages/cli/package.json | 1 - packages/cli/src/__tests__/lib/Config.test.ts | 79 ------ .../plugin/task-manager-package.test.ts | 22 ++ packages/cli/src/cli.ts | 2 - packages/cli/src/lib/Config.ts | 5 - packages/cli/src/types.ts | 6 - packages/task-manager/README.md | 10 +- packages/task-manager/package.json | 14 ++ .../task.ts => task-manager/src/command.ts} | 233 +++++++++++------- .../tests/command.test.ts} | 171 +++++++------ .../task-manager/tests/plugin-package.test.ts | 15 ++ 17 files changed, 353 insertions(+), 295 deletions(-) create mode 100644 packages/cli/src/__tests__/services/plugin/task-manager-package.test.ts rename packages/{cli/src/commands/task.ts => task-manager/src/command.ts} (71%) rename packages/{cli/src/__tests__/commands/task.test.ts => task-manager/tests/command.test.ts} (77%) create mode 100644 packages/task-manager/tests/plugin-package.test.ts diff --git a/docs/ai/design/2026-07-01-feature-task-system.md b/docs/ai/design/2026-07-01-feature-task-system.md index 193e13c5..00dc56d1 100644 --- a/docs/ai/design/2026-07-01-feature-task-system.md +++ b/docs/ai/design/2026-07-01-feature-task-system.md @@ -10,17 +10,17 @@ description: Define the technical architecture, components, and data models ```mermaid graph TD - CLI["ai-devkit task CLI"] --> SVC["TaskService"] - Skills["dev-lifecycle / verify skills"] -->|CLI or lib| SVC + PluginCLI["task plugin command"] --> SVC["TaskService"] + Skills["dev-lifecycle / verify skills"] -->|when plugin enabled| SVC SVC -->|uses| REPO["TaskRepository"] REPO --> SQL["SQLite (~/.ai-devkit/tasks.db)"] SQL --> TASKS["tasks table: snapshots"] SQL --> EVENTS["task_events table: append-only history"] ``` -- New package `@ai-devkit/task-manager`, structured like `@ai-devkit/memory` and consumed by - the `ai-devkit` CLI the same way memory is. -- **Layering:** CLI and skills call `TaskService`, which uses `TaskRepository` for +- New package `@ai-devkit/task-manager`, structured like `@ai-devkit/memory` for the service + layer and packaged as an optional AI DevKit plugin for the `task` CLI command. +- **Layering:** plugin CLI and skills call `TaskService`, which uses `TaskRepository` for persistence. Callers never touch the database directly. - **SQLite** (via `better-sqlite3`, the same library `@ai-devkit/memory` uses) stores a `tasks` table of snapshots and a `task_events` table for the append-only event history. @@ -214,13 +214,13 @@ function createTaskService(dbPath?: string): TaskService; This powers "the current task for this feature" for dev-lifecycle/verify without requiring callers to remember ids. -## CLI (`ai-devkit task ...`) +## Plugin CLI (`ai-devkit task ...`) -All commands accept global flags `--db-path <path>` (explicit DB path override), `--json` +The `task` command is available after installing and enabling `@ai-devkit/task-manager` as an +AI DevKit plugin. All commands accept global flags `--db-path <path>` (explicit DB path override), `--json` (machine output), and attribution overrides `--agent <id>`, `--agent-type <t>`, `--pid <n>`, -`--session <s>`. By default, the CLI resolves `.ai-devkit.json` `tasks.path` using -`ConfigManager`, then falls back to `~/.ai-devkit/tasks.db`. `<id>` resolves per `resolveTask` -above. +`--session <s>`. By default, the plugin resolves project `.ai-devkit.json` `tasks.path`, then +falls back to `~/.ai-devkit/tasks.db`. `<id>` resolves per `resolveTask` above. | Command | Flags | Emits | |---|---|---| @@ -284,7 +284,8 @@ Tasks are stored in a single SQLite database at `~/.ai-devkit/tasks.db`: Task and event ids are raw UUIDv4 strings from Node `crypto.randomUUID()`, stored as TEXT (36 chars: `8-4-4-4-12` lowercase hex) — globally unique with no prefix, coordination, or collision -checks. Project CLI usage can configure `.ai-devkit.json` `tasks.path`; explicit callers can +checks. Project CLI usage can configure `.ai-devkit.json` `tasks.path` once the plugin is +installed; explicit callers can override the DB path with `--db-path` or the `TaskRepository` / `DatabaseConnection` constructor. Only `TaskRepository` reads/writes the database; callers use `TaskService` or the CLI. diff --git a/docs/ai/implementation/2026-07-01-feature-task-system.md b/docs/ai/implementation/2026-07-01-feature-task-system.md index 4efa6b6a..a8601c68 100644 --- a/docs/ai/implementation/2026-07-01-feature-task-system.md +++ b/docs/ai/implementation/2026-07-01-feature-task-system.md @@ -12,7 +12,9 @@ description: Technical implementation notes, patterns, and code guidelines - Node `>=20.20.0`, monorepo with nx + swc + vitest (no new tooling introduced). - Work in the `feature-task-system` worktree at `.worktrees/feature-task-system`. - `npm ci` bootstraps the workspace; the new package is symlinked under - `node_modules/@ai-devkit/task-manager` automatically. + `node_modules/@ai-devkit/task-manager` automatically for local development. +- End users enable the `task` command explicitly with + `ai-devkit plugin add @ai-devkit/task-manager`; the core CLI does not import or register it. ## Code Structure **How is the code organized?** @@ -28,6 +30,7 @@ packages/task-manager/ actor-resolver.ts # resolveCurrentActor (env + process), ATTRIB_ENV task.repository.ts # TaskRepository — task/event persistence (SQLite) task.service.ts # TaskService (public consume-only surface) + command.ts # optional AI DevKit plugin command entrypoint database/ # connection.ts (WAL/migrations), schema.ts, migrations/001_initial.sql index.ts # public exports (import path @ai-devkit/task-manager) tests/ @@ -39,8 +42,10 @@ Structure follows `@ai-devkit/memory`: a `database/` module (connection/schema/m with the business module split into `task.repository.ts` (persistence) and `task.service.ts` (logic), mirroring memory's split between `database/` and `handlers/`. -CLI: `packages/cli/src/commands/task.ts` registers `ai-devkit task ...`; wired in `cli.ts`. -Test: `packages/cli/src/__tests__/commands/task.test.ts`. +Plugin CLI: `packages/task-manager/src/command.ts` registers subcommands on the host-provided +`task` command. The command is discovered from `package.json` `aiDevkit.commands`, not wired +into `packages/cli/src/cli.ts`. +Test: `packages/task-manager/tests/command.test.ts`. ## Implementation Notes **Key technical details to remember:** @@ -72,10 +77,11 @@ Test: `packages/cli/src/__tests__/commands/task.test.ts`. ## Integration Points **How do pieces connect?** -- CLI → `ConfigManager.getTasksDbPath()` → `TaskService` → `TaskRepository` → SQLite - (`.ai-devkit.json` `tasks.path`, else `~/.ai-devkit/tasks.db`). -- Skills (`dev-lifecycle`, `verify`, `structured-debug`) emit via `ai-devkit task ...`. -- Storage default `~/.ai-devkit/tasks.db`; CLI resolves `.ai-devkit.json` `tasks.path`, and +- Plugin CLI → project `.ai-devkit.json` `tasks.path` → `TaskService` → `TaskRepository` → + SQLite (else `~/.ai-devkit/tasks.db`). +- Skills (`dev-lifecycle`, `verify`, `structured-debug`) can emit via `ai-devkit task ...` + when the optional plugin is installed/enabled. +- Storage default `~/.ai-devkit/tasks.db`; the plugin CLI resolves `.ai-devkit.json` `tasks.path`, and explicit overrides use `--db-path` or the `TaskRepository` / `DatabaseConnection` constructor arg. diff --git a/docs/ai/planning/2026-07-01-feature-task-system.md b/docs/ai/planning/2026-07-01-feature-task-system.md index fb7b443c..c1520231 100644 --- a/docs/ai/planning/2026-07-01-feature-task-system.md +++ b/docs/ai/planning/2026-07-01-feature-task-system.md @@ -11,7 +11,8 @@ description: Break down work into actionable tasks and estimate timeline - [x] **M1 — Core package + SQLite repository.** `@ai-devkit/task-manager` with types, `TaskService`, `TaskRepository`, WAL/migrations, append-only events. -- [x] **M2 — CLI surface.** `ai-devkit task ...` wired into the CLI; `--json` + attribution. +- [x] **M2 — Plugin CLI surface.** `ai-devkit task ...` provided by the optional + `@ai-devkit/task-manager` plugin; `--json` + attribution. - [x] **M3 — Tests, docs, simplify, validate.** Unit + integration coverage, README, simplify pass, green build/lint/test, commit, PR. @@ -47,10 +48,11 @@ description: Break down work into actionable tasks and estimate timeline via `@ai-devkit/task-manager`. ### M2: CLI surface -- [ ] **2.1 Dependency wire.** Add `@ai-devkit/task-manager` to `packages/cli/package.json` - deps; register `registerTaskCommand(program)` in `cli.ts`. -- [ ] **2.2 task command** (`packages/cli/src/commands/task.ts`). All verbs/flags from the - design doc; `--json`, `--store`, `--agent*` globals; `<id>` via `resolveTask`. +- [x] **2.1 Plugin manifest wire.** Add `aiDevkit.commands` to + `packages/task-manager/package.json` so the host plugin loader registers `task` only when + the package is installed/enabled. +- [x] **2.2 task command** (`packages/task-manager/src/command.ts`). All verbs/flags from the + design doc; `--json`, `--db-path`, `--agent*` globals; `<id>` via `resolveTask`. - [ ] **2.3 Output formatting.** `list` table (id/title/status/phase/feature), `show` pretty + `--events`, `--json` machine output everywhere. @@ -59,8 +61,8 @@ description: Break down work into actionable tasks and estimate timeline service mutation-per-event, resolveTask resolution order, validation errors. - [x] **3.2 Integration tests** (`tests/integration/`). TaskRepository round-trip, migrations, append-only events, addEvent escape hatch coverage, repository error branches. -- [x] **3.3 CLI command tests** (`packages/cli/src/__tests__/commands/task.test.ts`) mirroring - the memory command test pattern (mocked TaskService). +- [x] **3.3 Plugin command tests** (`packages/task-manager/tests/command.test.ts`) covering + command registration, parsing, output, and DB path resolution with a mocked TaskService. - [x] **3.4 README** (`packages/task-manager/README.md`) + a section in root README. - [x] **3.5 simplify-implementation pass** on the new code. - [x] **3.6 Validate**: `nx test`, `nx build`, `nx lint` green for task-manager + cli + repo. diff --git a/docs/ai/requirements/2026-07-01-feature-task-system.md b/docs/ai/requirements/2026-07-01-feature-task-system.md index 107a4acb..2ddc8ca6 100644 --- a/docs/ai/requirements/2026-07-01-feature-task-system.md +++ b/docs/ai/requirements/2026-07-01-feature-task-system.md @@ -100,7 +100,8 @@ duplicate ids (negligible with UUIDs), appending to a task whose snapshot was ha writes; WAL + `busy_timeout` for concurrency. 3. **Event model:** stateful event types mutate the snapshot; a generic `task.custom` event type is reserved for tracing/observability and does **not** mutate the snapshot. - 4. **New package** `@ai-devkit/task-manager` mirrors `@ai-devkit/memory`. + 4. **New package/plugin** `@ai-devkit/task-manager` mirrors `@ai-devkit/memory` for the + library layer and exposes `ai-devkit task ...` through the AI DevKit plugin manifest. 5. **Default DB path** `~/.ai-devkit/tasks.db`; project CLI usage can set `.ai-devkit.json` `tasks.path`, with `--db-path` as an explicit command override. diff --git a/docs/ai/testing/2026-07-01-feature-task-system.md b/docs/ai/testing/2026-07-01-feature-task-system.md index 005ae54e..6778f10e 100644 --- a/docs/ai/testing/2026-07-01-feature-task-system.md +++ b/docs/ai/testing/2026-07-01-feature-task-system.md @@ -8,8 +8,7 @@ description: Define test coverage, scenarios, and quality gates ## Test Plan -Unit + integration coverage in `packages/task-manager/tests/` and a command-layer test in -`packages/cli/src/__tests__/commands/`. +Unit, integration, and plugin command coverage live in `packages/task-manager/tests/`. ### Scenarios (mapped to requirements success criteria) @@ -32,13 +31,16 @@ Unit + integration coverage in `packages/task-manager/tests/` and a command-laye - [x] raw UUID id format + uniqueness — `task.ids.test.ts` - [x] actor auto-resolution env/override/pid — `actor-resolver.test.ts` - [x] error hierarchy + isTaskEventType guard — `errors.test.ts` -- [x] CLI verbs parse flags, resolve refs, print JSON/table, error on bad input — `task.test.ts` (20 tests) -- [x] CLI resolves task DB path from `.ai-devkit.json` `tasks.path`; `--db-path` overrides config — `task.test.ts` -- [x] `ConfigManager.getTasksDbPath()` handles missing, blank, absolute, and relative paths — `Config.test.ts` +- [x] Plugin CLI verbs parse flags, resolve refs, print JSON/table, error on bad input — `command.test.ts` +- [x] Plugin CLI resolves task DB path from `.ai-devkit.json` `tasks.path`; `--db-path` overrides config — `command.test.ts` +- [x] Package declares the `task` plugin command manifest — `plugin-package.test.ts` +- [x] Plugin command DB path resolution handles missing, blank, absolute, and relative + `.ai-devkit.json` `tasks.path` values — `command.test.ts` - [x] Task package DB resolution uses only explicit `dbPath` or `~/.ai-devkit/tasks.db` fallback — `task.repository.test.ts` ### Mocks / Fixtures -- Command tests mock `@ai-devkit/task-manager` (mirrors memory command test pattern). +- Command tests mock `packages/task-manager/src/index.ts` so command parsing is isolated from + SQLite and service behavior. - Integration tests use a real `TaskRepository` against a per-test temp SQLite DB (`mkdtempSync` + `tasks.db`); the shared `getDatabase()` singleton is reset with `closeDatabase()` in `beforeEach`/`afterEach`, and the temp dir is removed in `afterEach`. @@ -55,10 +57,14 @@ vitest v8; package thresholds statements/branches/functions/lines ≥ 75%. - `packages/task-manager/tests/integration/task.repository.test.ts` - `packages/task-manager/tests/integration/service.test.ts` - `packages/task-manager/tests/integration/add-event-coverage.test.ts` -- `packages/cli/src/__tests__/commands/task.test.ts` +- `packages/task-manager/tests/command.test.ts` +- `packages/task-manager/tests/plugin-package.test.ts` - `packages/cli/src/__tests__/lib/Config.test.ts` ## Results -- `nx test` (whole repo): 890 passing, 0 failing (73 test files). -- `nx build`: 6 projects succeed. -- `nx lint`: 0 errors (only pre-existing warnings). +- `nx test task-manager`: 115 passing, 0 failing (8 test files). +- `nx test cli`: 871 passing, 0 failing (73 test files). +- `nx build task-manager`: succeeds. +- `nx build cli`: succeeds, including dependent package builds. +- `nx lint task-manager`: 0 errors. +- `nx lint cli`: 0 errors (only pre-existing warnings). diff --git a/package-lock.json b/package-lock.json index 2a5ddc18..becd3367 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13460,7 +13460,6 @@ "@ai-devkit/agent-manager": "0.24.0", "@ai-devkit/channel-connector": "0.12.0", "@ai-devkit/memory": "0.15.0", - "@ai-devkit/task-manager": "0.1.0", "@inquirer/prompts": "^8.5.2", "chalk": "^5.6.0", "commander": "^11.1.0", @@ -13822,6 +13821,7 @@ "@typescript-eslint/eslint-plugin": "^8.60.1", "@typescript-eslint/parser": "^8.60.1", "@vitest/coverage-v8": "^4.1.8", + "commander": "^11.1.0", "eslint": "^8.56.0", "ts-node": "^10.9.2", "typescript": "^5.5.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index a06adef8..4a06b8de 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,7 +37,6 @@ "@ai-devkit/agent-manager": "0.24.0", "@ai-devkit/channel-connector": "0.12.0", "@ai-devkit/memory": "0.15.0", - "@ai-devkit/task-manager": "0.1.0", "@inquirer/prompts": "^8.5.2", "chalk": "^5.6.0", "commander": "^11.1.0", diff --git a/packages/cli/src/__tests__/lib/Config.test.ts b/packages/cli/src/__tests__/lib/Config.test.ts index 7db8e4b2..facf1d26 100644 --- a/packages/cli/src/__tests__/lib/Config.test.ts +++ b/packages/cli/src/__tests__/lib/Config.test.ts @@ -775,83 +775,4 @@ describe('ConfigManager', () => { }); }); - describe('getTasksDbPath', () => { - it('returns undefined when config does not exist', async () => { - (mockFs.pathExists as any).mockResolvedValue(false); - - const result = await configManager.getTasksDbPath(); - - expect(result).toBeUndefined(); - }); - - it('returns undefined when tasks.path is missing', async () => { - (mockFs.pathExists as any).mockResolvedValue(true); - (mockFs.readJson as any).mockResolvedValue({ - version: '1.0.0', - environments: [], - phases: [], - createdAt: '2024-01-01T00:00:00.000Z', - }); - - const result = await configManager.getTasksDbPath(); - - expect(result).toBeUndefined(); - }); - - it('returns undefined when tasks.path is blank or invalid', async () => { - (mockFs.pathExists as any).mockResolvedValue(true); - (mockFs.readJson as any).mockResolvedValue({ - version: '1.0.0', - environments: [], - phases: [], - tasks: { path: ' ' }, - createdAt: '2024-01-01T00:00:00.000Z', - }); - - await expect(configManager.getTasksDbPath()).resolves.toBeUndefined(); - - (mockFs.readJson as any).mockResolvedValue({ - version: '1.0.0', - environments: [], - phases: [], - tasks: { path: 42 }, - createdAt: '2024-01-01T00:00:00.000Z', - }); - - await expect(configManager.getTasksDbPath()).resolves.toBeUndefined(); - }); - - it('returns absolute tasks.path as-is', async () => { - (mockFs.pathExists as any).mockResolvedValue(true); - (mockFs.readJson as any).mockResolvedValue({ - version: '1.0.0', - environments: [], - phases: [], - tasks: { path: '/custom/tasks.db' }, - createdAt: '2024-01-01T00:00:00.000Z', - }); - - const result = await configManager.getTasksDbPath(); - - expect(result).toBe('/custom/tasks.db'); - expect(mockPath.isAbsolute).toHaveBeenCalledWith('/custom/tasks.db'); - }); - - it('resolves relative tasks.path from the config directory', async () => { - (mockFs.pathExists as any).mockResolvedValue(true); - (mockFs.readJson as any).mockResolvedValue({ - version: '1.0.0', - environments: [], - phases: [], - tasks: { path: '.ai-devkit/project-tasks.db' }, - createdAt: '2024-01-01T00:00:00.000Z', - }); - - const result = await configManager.getTasksDbPath(); - - expect(mockPath.dirname).toHaveBeenCalledWith('/test/dir/.ai-devkit.json'); - expect(mockPath.resolve).toHaveBeenCalledWith('/test/dir', '.ai-devkit/project-tasks.db'); - expect(result).toBe('/test/dir/.ai-devkit/project-tasks.db'); - }); - }); }); diff --git a/packages/cli/src/__tests__/services/plugin/task-manager-package.test.ts b/packages/cli/src/__tests__/services/plugin/task-manager-package.test.ts new file mode 100644 index 00000000..c1e2cbce --- /dev/null +++ b/packages/cli/src/__tests__/services/plugin/task-manager-package.test.ts @@ -0,0 +1,22 @@ +import fs from 'fs-extra'; +import path from 'path'; + +describe('task manager plugin package', () => { + it('declares the task plugin command manifest', async () => { + const packageJsonPath = path.resolve( + process.cwd(), + '../../packages/task-manager/package.json' + ); + const packageJson = await fs.readJson(packageJsonPath) as unknown; + const aiDevkit = (packageJson as { aiDevkit?: { commands?: unknown } }).aiDevkit; + + expect((packageJson as { name?: string }).name).toBe('@ai-devkit/task-manager'); + expect(aiDevkit?.commands).toEqual([ + { + name: 'task', + description: 'Manage durable development/debug tasks', + entry: './dist/command.js', + }, + ]); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 00fbb828..f0c9e86e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -6,7 +6,6 @@ import { phaseCommand } from './commands/phase.js'; import { lintCommand } from './commands/lint.js'; import { installCommand } from './commands/install.js'; import { registerMemoryCommand } from './commands/memory.js'; -import { registerTaskCommand } from './commands/task.js'; import { registerSkillCommand } from './commands/skill.js'; import { registerAgentCommand } from './commands/agent.js'; import { registerChannelCommand } from './commands/channel.js'; @@ -59,7 +58,6 @@ program .action(installCommand); registerMemoryCommand(program); -registerTaskCommand(program); registerSkillCommand(program); registerAgentCommand(program); registerChannelCommand(program); diff --git a/packages/cli/src/lib/Config.ts b/packages/cli/src/lib/Config.ts index 58ef5857..b38c724a 100644 --- a/packages/cli/src/lib/Config.ts +++ b/packages/cli/src/lib/Config.ts @@ -100,11 +100,6 @@ export class ConfigManager { return this.resolveConfiguredPath(config?.memory?.path); } - async getTasksDbPath(): Promise<string | undefined> { - const config = await this.read(); - return this.resolveConfiguredPath(config?.tasks?.path); - } - private resolveConfiguredPath(configuredPath: unknown): string | undefined { if (typeof configuredPath !== 'string') { return undefined; diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 7740ade0..f0261013 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -28,9 +28,6 @@ export interface DevKitConfig { memory?: { path?: string; }; - tasks?: { - path?: string; - }; environments: EnvironmentCode[]; phases: Phase[]; registries?: Record<string, string>; @@ -63,9 +60,6 @@ export interface GlobalDevKitConfig { memory?: { path?: string; }; - tasks?: { - path?: string; - }; } export interface PhaseMetadata { diff --git a/packages/task-manager/README.md b/packages/task-manager/README.md index 75683b46..1fb55754 100644 --- a/packages/task-manager/README.md +++ b/packages/task-manager/README.md @@ -50,11 +50,17 @@ omitted) and returns the updated `Task` (or `{ task, blockerId/evidenceId/artifa `resolveTask` accepts a full id, a unique id prefix, or a feature key (latest non-terminal task). -## CLI +## Plugin CLI + +Install and enable the package as an AI DevKit plugin before using the command: + +```bash +ai-devkit plugin add @ai-devkit/task-manager +``` `ai-devkit task ...` — create, list, show, update, phase, status, progress, next, blocker `<id> add <text>|resolve <blockerId>`, evidence, artifact, assign, note, event, -close. The CLI resolves `.ai-devkit.json` `tasks.path` when present. Global flags: +close. The plugin resolves `.ai-devkit.json` `tasks.path` when present. Global flags: `--db-path`, `--json`, `--agent`, `--agent-type`, `--pid`, `--session`. See the design doc for the full verb/flag table. diff --git a/packages/task-manager/package.json b/packages/task-manager/package.json index 71e13990..d760812f 100644 --- a/packages/task-manager/package.json +++ b/packages/task-manager/package.json @@ -9,8 +9,21 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./command": { + "types": "./dist/command.d.ts", + "import": "./dist/command.js" } }, + "aiDevkit": { + "commands": [ + { + "name": "task", + "description": "Manage durable development/debug tasks", + "entry": "./dist/command.js" + } + ] + }, "scripts": { "build": "swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly && cp -r src/database/migrations dist/database/", "dev": "swc src -d dist --strip-leading-paths --watch", @@ -49,6 +62,7 @@ "@typescript-eslint/parser": "^8.60.1", "@vitest/coverage-v8": "^4.1.8", "eslint": "^8.56.0", + "commander": "^11.1.0", "ts-node": "^10.9.2", "typescript": "^5.5.0", "vitest": "^4.1.8" diff --git a/packages/cli/src/commands/task.ts b/packages/task-manager/src/command.ts similarity index 71% rename from packages/cli/src/commands/task.ts rename to packages/task-manager/src/command.ts index fa501fe8..191dfac5 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/task-manager/src/command.ts @@ -1,20 +1,28 @@ import type { Command } from 'commander'; -import { readFileSync } from 'fs'; +import { readFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import * as path from 'node:path'; import { createTaskService, resolveCurrentActor, AmbiguousTaskRefError, isTaskEventType, -} from '@ai-devkit/task-manager'; -import type { Actor, Task, TaskService, TaskStatus } from '@ai-devkit/task-manager'; -import { ConfigManager } from '../lib/Config.js'; -import { ui } from '../util/terminal-ui.js'; -import { withErrorHandler } from '../util/errors.js'; -import { truncate } from '../util/text.js'; +} from './index.js'; +import type { Actor, Task, TaskService, TaskStatus } from './index.js'; const TITLE_MAX_LENGTH = 50; const VALID_STATUSES: TaskStatus[] = ['open', 'active', 'blocked', 'completed', 'abandoned']; +interface AiDevkitRuntime { + cwd: string; + homeDir: string; + logger: { + info(message: string): void; + warn(message: string): void; + error(message: string): void; + }; +} + interface AttributionOptions { agent?: string; agentType?: string; @@ -37,12 +45,45 @@ function actorFromOptions(opts: AttributionOptions): Actor | undefined { return resolveCurrentActor(override) ?? undefined; } -async function createService(dbPathFlag?: string): Promise<TaskService> { +async function createService(runtime: AiDevkitRuntime, dbPathFlag?: string): Promise<TaskService> { if (dbPathFlag && dbPathFlag.trim()) { return createTaskService(dbPathFlag); } - const configManager = new ConfigManager(); - return createTaskService(await configManager.getTasksDbPath()); + return createTaskService(await resolveConfiguredTasksDbPath(runtime)); +} + +async function resolveConfiguredTasksDbPath(runtime: AiDevkitRuntime): Promise<string | undefined> { + const configPath = path.join(runtime.cwd, '.ai-devkit.json'); + let config: unknown; + + try { + config = JSON.parse(await readFile(configPath, 'utf8')) as unknown; + } catch (error) { + const code = typeof error === 'object' && error !== null && 'code' in error ? (error as { code?: unknown }).code : undefined; + if (code === 'ENOENT') { + return path.join(runtime.homeDir, '.ai-devkit', 'tasks.db'); + } + throw error; + } + + const configuredPath = typeof config === 'object' && config !== null && 'tasks' in config + ? (config as { tasks?: { path?: unknown } }).tasks?.path + : undefined; + + if (typeof configuredPath !== 'string') { + return path.join(runtime.homeDir, '.ai-devkit', 'tasks.db'); + } + + const trimmedPath = configuredPath.trim(); + if (!trimmedPath) { + return path.join(runtime.homeDir, '.ai-devkit', 'tasks.db'); + } + + if (path.isAbsolute(trimmedPath)) { + return trimmedPath; + } + + return path.resolve(path.dirname(configPath), trimmedPath); } function output(value: unknown, json: boolean): void { @@ -51,46 +92,51 @@ function output(value: unknown, json: boolean): void { return; } if (typeof value === 'string') { - ui.text(value); + console.log(value); } else { console.log(JSON.stringify(value, null, 2)); } } function formatActor(actor: { agentId?: string; agentType?: string; pid?: number; sessionId?: string } | null): string { - if (!actor) return '—'; + if (!actor) return '-'; const parts: string[] = []; if (actor.agentType) parts.push(actor.agentType); if (actor.agentId) parts.push(actor.agentId); if (actor.pid) parts.push(`pid:${actor.pid}`); - return parts.length ? parts.join('/') : '—'; + return parts.length ? parts.join('/') : '-'; } async function resolveOrError( + runtime: AiDevkitRuntime, service: TaskService, id: string ): Promise<{ taskId: string } | null> { try { const task = await service.resolveTask(id); if (!task) { - ui.error(`No task found for "${id}".`); + runtime.logger.error(`No task found for "${id}".`); return null; } return { taskId: task.taskId }; } catch (error) { if (error instanceof AmbiguousTaskRefError) { - ui.error(`${error.message}`); + runtime.logger.error(`${error.message}`); return null; } throw error; } } +function truncate(value: string, maxLength: number): string { + return value.length <= maxLength ? value : `${value.slice(0, Math.max(0, maxLength - 3))}...`; +} + function renderTask(task: Task): string { const lines: string[] = []; lines.push(`${task.taskId}`); lines.push(` title: ${task.title}`); - lines.push(` status: ${task.status} phase: ${task.phase ?? '—'}`); + lines.push(` status: ${task.status} phase: ${task.phase ?? '-'}`); if (task.feature) lines.push(` feature: ${task.feature}`); if (task.summary) lines.push(` summary: ${task.summary}`); if (task.progress.text || task.progress.percent !== null) { @@ -98,33 +144,33 @@ function renderTask(task: Task): string { } if (task.nextStep) lines.push(` next: ${task.nextStep}`); lines.push(` attribution: ${formatActor(task.attribution)}`); - const links = [task.links.branch, task.links.worktree, task.links.pr].filter(Boolean).join(' · '); + const links = [task.links.branch, task.links.worktree, task.links.pr].filter(Boolean).join(' | '); if (links) lines.push(` links: ${links}`); if (task.tags.length) lines.push(` tags: ${task.tags.join(', ')}`); if (task.blockers.length) { lines.push(` blockers:`); for (const b of task.blockers) { - lines.push(` [${b.status}] ${b.blockerId} — ${truncate(b.text, 80)}`); + lines.push(` [${b.status}] ${b.blockerId} - ${truncate(b.text, 80)}`); } } if (task.evidence.length) { lines.push(` evidence:`); for (const e of task.evidence) { - lines.push(` ${e.passed ? '✓' : '✗'} ${e.evidenceId}${e.command ? ` — ${truncate(e.command, 60)}` : ''}`); + lines.push(` ${e.passed ? 'PASS' : 'FAIL'} ${e.evidenceId}${e.command ? ` - ${truncate(e.command, 60)}` : ''}`); } } if (task.artifacts.length) { lines.push(` artifacts:`); for (const a of task.artifacts) { - lines.push(` ${a.artifactId} — ${a.path}${a.kind ? ` [${a.kind}]` : ''}`); + lines.push(` ${a.artifactId} - ${a.path}${a.kind ? ` [${a.kind}]` : ''}`); } } lines.push(` events: ${task.eventCount} created: ${task.createdAt}`); return lines.join('\n'); } -export function registerTaskCommand(program: Command): void { - const task = program.command('task').description('Manage durable development/debug tasks'); +export function register(command: Command, runtime: AiDevkitRuntime): void { + command.description('Manage durable development/debug tasks'); const addAttributionFlags = (cmd: Command): Command => cmd @@ -136,7 +182,7 @@ export function registerTaskCommand(program: Command): void { .option('--json', 'Output machine-readable JSON'); addAttributionFlags( - task + command .command('create') .description('Create a new task') .requiredOption('--title <title>', 'Task title') @@ -149,7 +195,7 @@ export function registerTaskCommand(program: Command): void { .option('--pr <url>', 'Pull request link') ).action( withErrorHandler('create task', async (opts) => { - const service = await createService(opts.dbPath); + const service = await createService(runtime, opts.dbPath); const created = await service.create({ title: opts.title, feature: opts.feature, @@ -162,14 +208,14 @@ export function registerTaskCommand(program: Command): void { if (opts.json) { output(created, true); } else { - ui.success(`Created task ${created.taskId}`); - ui.text(renderTask(created)); + runtime.logger.info(`Created task ${created.taskId}`); + console.log(renderTask(created)); } }) ); addAttributionFlags( - task + command .command('list') .description('List tasks (newest first)') .option('--feature <feature>', 'Filter by feature key') @@ -178,7 +224,7 @@ export function registerTaskCommand(program: Command): void { .option('--limit <n>', 'Maximum results', '20') ).action( withErrorHandler('list tasks', async (opts) => { - const service = await createService(opts.dbPath); + const service = await createService(runtime, opts.dbPath); const tasks = await service.list({ feature: opts.feature, status: opts.status as TaskStatus | undefined, @@ -190,31 +236,31 @@ export function registerTaskCommand(program: Command): void { return; } if (tasks.length === 0) { - ui.warning('No tasks found.'); + runtime.logger.warn('No tasks found.'); return; } - ui.table({ - headers: ['id', 'title', 'status', 'phase', 'feature'], - rows: tasks.map((t) => [ + console.log([ + ['id', 'title', 'status', 'phase', 'feature'].join('\t'), + ...tasks.map((t) => [ t.taskId, truncate(t.title, TITLE_MAX_LENGTH), t.status, - t.phase ?? '—', - t.feature ?? '—', - ]), - }); + t.phase ?? '-', + t.feature ?? '-', + ].join('\t')), + ].join('\n')); }) ); addAttributionFlags( - task + command .command('show <id>') .description('Show a task (resolves id, prefix, or feature)') .option('--events', 'Include the event history') ).action( withErrorHandler('show task', async (id: string, opts) => { - const service = await createService(opts.dbPath); - const resolved = await resolveOrError(service, id); + const service = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); if (!resolved) return; const taskObj = await service.get(resolved.taskId); if (opts.json) { @@ -225,19 +271,19 @@ export function registerTaskCommand(program: Command): void { output(payload, true); return; } - ui.text(renderTask(taskObj)); + console.log(renderTask(taskObj)); if (opts.events) { const events = await service.getEvents(resolved.taskId); - ui.text('\nevents:'); + console.log('\nevents:'); for (const e of events) { - ui.text(` ${e.ts} ${e.type} (${e.eventId})`); + console.log(` ${e.ts} ${e.type} (${e.eventId})`); } } }) ); addAttributionFlags( - task + command .command('update <id>') .description('Update task scalar fields (title/summary/tags/links)') .option('--title <title>', 'New title') @@ -248,8 +294,8 @@ export function registerTaskCommand(program: Command): void { .option('--pr <url>', 'Pull request link') ).action( withErrorHandler('update task', async (id: string, opts) => { - const service = await createService(opts.dbPath); - const resolved = await resolveOrError(service, id); + const service = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); if (!resolved) return; const patch: Record<string, unknown> = {}; if (opts.title !== undefined) patch.title = opts.title; @@ -265,10 +311,10 @@ export function registerTaskCommand(program: Command): void { }) ); - addAttributionFlags(task.command('phase <id> <phase>').description('Set the lifecycle phase')).action( + addAttributionFlags(command.command('phase <id> <phase>').description('Set the lifecycle phase')).action( withErrorHandler('set task phase', async (id: string, phase: string, opts) => { - const service = await createService(opts.dbPath); - const resolved = await resolveOrError(service, id); + const service = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); if (!resolved) return; const updated = await service.setPhase(resolved.taskId, phase, { actor: actorFromOptions(opts) }); output(updated, opts.json); @@ -276,13 +322,13 @@ export function registerTaskCommand(program: Command): void { ); addAttributionFlags( - task + command .command('status <id> <status>') .description(`Set status (${VALID_STATUSES.join('|')})`) ).action( withErrorHandler('set task status', async (id: string, status: string, opts) => { - const service = await createService(opts.dbPath); - const resolved = await resolveOrError(service, id); + const service = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); if (!resolved) return; const updated = await service.setStatus(resolved.taskId, status as TaskStatus, { actor: actorFromOptions(opts), @@ -292,7 +338,7 @@ export function registerTaskCommand(program: Command): void { ); addAttributionFlags( - task + command .command('progress <id>') .description('Set progress text/percent') .option('--text <text>', 'Progress text') @@ -300,8 +346,8 @@ export function registerTaskCommand(program: Command): void { .option('--clear', 'Clear progress') ).action( withErrorHandler('set task progress', async (id: string, opts) => { - const service = await createService(opts.dbPath); - const resolved = await resolveOrError(service, id); + const service = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); if (!resolved) return; const progress = opts.clear === true @@ -318,14 +364,14 @@ export function registerTaskCommand(program: Command): void { ); addAttributionFlags( - task + command .command('next <id> [step...]') .description('Set the next step (pass --clear to remove)') .option('--clear', 'Clear the next step') ).action( withErrorHandler('set task next step', async (id: string, stepParts: string[] | undefined, opts) => { - const service = await createService(opts.dbPath); - const resolved = await resolveOrError(service, id); + const service = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); if (!resolved) return; const step = opts.clear === true ? null : (stepParts ?? []).join(' ').trim() || null; const updated = await service.setNextStep(resolved.taskId, step, { @@ -336,19 +382,19 @@ export function registerTaskCommand(program: Command): void { ); addAttributionFlags( - task + command .command('blocker <id> <action> [rest...]') .description('Manage blockers: add <text> | resolve <blockerId>') ).action( withErrorHandler('manage blocker', async (id: string, action: string, rest: string[] | undefined, opts) => { - const service = await createService(opts.dbPath); - const resolved = await resolveOrError(service, id); + const service = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); if (!resolved) return; const args = rest ?? []; if (action === 'add') { const text = args.join(' ').trim(); if (!text) { - ui.error('blocker add requires blocker text.'); + runtime.logger.error('blocker add requires blocker text.'); process.exitCode = 1; return; } @@ -361,7 +407,7 @@ export function registerTaskCommand(program: Command): void { } else if (action === 'resolve') { const blockerId = args[0]; if (!blockerId) { - ui.error('blocker resolve requires a blockerId.'); + runtime.logger.error('blocker resolve requires a blockerId.'); process.exitCode = 1; return; } @@ -370,14 +416,14 @@ export function registerTaskCommand(program: Command): void { }); output(updated, opts.json); } else { - ui.error(`Unknown blocker action "${action}". Use: add | resolve.`); + runtime.logger.error(`Unknown blocker action "${action}". Use: add | resolve.`); process.exitCode = 1; } }) ); addAttributionFlags( - task + command .command('evidence <id>') .description('Record validation evidence (use --passed or --failed)') .option('--command <command>', 'Command that was run') @@ -388,11 +434,11 @@ export function registerTaskCommand(program: Command): void { .option('--artifact <path>', 'Artifact reference (repeatable)', (val: string, acc: string[]) => [...acc, val], [] as string[]) ).action( withErrorHandler('record evidence', async (id: string, opts) => { - const service = await createService(opts.dbPath); - const resolved = await resolveOrError(service, id); + const service = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); if (!resolved) return; if (!opts.passed && !opts.failed) { - ui.error('Evidence requires either --passed or --failed.'); + runtime.logger.error('Evidence requires either --passed or --failed.'); process.exitCode = 1; return; } @@ -412,15 +458,15 @@ export function registerTaskCommand(program: Command): void { ); addAttributionFlags( - task + command .command('artifact <id> <path>') .description('Add an artifact reference (never copies the file)') .option('--kind <kind>', 'Artifact kind (e.g. log, report, diff)') .option('--description <description>', 'Artifact description') ).action( withErrorHandler('add artifact', async (id: string, artifactPath: string, opts) => { - const service = await createService(opts.dbPath); - const resolved = await resolveOrError(service, id); + const service = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); if (!resolved) return; const result = await service.addArtifact( resolved.taskId, @@ -432,7 +478,7 @@ export function registerTaskCommand(program: Command): void { ); addAttributionFlags( - task + command .command('assign <id>') .description('Set current task ownership/attribution') .requiredOption('--agent <id>', 'Agent id') @@ -441,12 +487,12 @@ export function registerTaskCommand(program: Command): void { .option('--session <id>', 'Session id') ).action( withErrorHandler('assign task', async (id: string, opts) => { - const service = await createService(opts.dbPath); - const resolved = await resolveOrError(service, id); + const service = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); if (!resolved) return; const actor = actorFromOptions(opts); if (!actor) { - ui.error('At least one attribution flag is required.'); + runtime.logger.error('At least one attribution flag is required.'); process.exitCode = 1; return; } @@ -455,14 +501,14 @@ export function registerTaskCommand(program: Command): void { }) ); - addAttributionFlags(task.command('note <id> [text...]').description('Append a note (event-only)')).action( + addAttributionFlags(command.command('note <id> [text...]').description('Append a note (event-only)')).action( withErrorHandler('append note', async (id: string, textParts: string[] | undefined, opts) => { - const service = await createService(opts.dbPath); - const resolved = await resolveOrError(service, id); + const service = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); if (!resolved) return; const text = (textParts ?? []).join(' ').trim(); if (!text) { - ui.error('Note text must be a non-empty string.'); + runtime.logger.error('Note text must be a non-empty string.'); process.exitCode = 1; return; } @@ -474,19 +520,19 @@ export function registerTaskCommand(program: Command): void { ); addAttributionFlags( - task + command .command('event <id>') .description('Append a low-level event (defaults to task.custom)') .option('--type <type>', 'Event type from the closed set (default: task.custom)') .option('--payload <json|@file>', 'JSON payload or @path to a JSON file') ).action( withErrorHandler('append event', async (id: string, opts) => { - const service = await createService(opts.dbPath); - const resolved = await resolveOrError(service, id); + const service = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); if (!resolved) return; const type = opts.type ?? 'task.custom'; if (!isTaskEventType(type)) { - ui.error(`Unknown event type: ${type}`); + runtime.logger.error(`Unknown event type: ${type}`); process.exitCode = 1; return; } @@ -505,17 +551,17 @@ export function registerTaskCommand(program: Command): void { ); addAttributionFlags( - task + command .command('close <id> [status]') .description('Close a task (completed|abandoned). Default: completed') ).action( withErrorHandler('close task', async (id: string, statusArg: string | undefined, opts) => { - const service = await createService(opts.dbPath); - const resolved = await resolveOrError(service, id); + const service = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); if (!resolved) return; const status = (statusArg ?? 'completed') as 'completed' | 'abandoned'; if (status !== 'completed' && status !== 'abandoned') { - ui.error('Close status must be "completed" or "abandoned".'); + runtime.logger.error('Close status must be "completed" or "abandoned".'); process.exitCode = 1; return; } @@ -526,3 +572,18 @@ export function registerTaskCommand(program: Command): void { }) ); } + +function withErrorHandler<TArgs extends unknown[]>( + operation: string, + handler: (...args: TArgs) => Promise<void> +): (...args: TArgs) => Promise<void> { + return async (...args: TArgs): Promise<void> => { + try { + await handler(...args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Failed to ${operation}: ${message}`); + process.exitCode = 1; + } + }; +} diff --git a/packages/cli/src/__tests__/commands/task.test.ts b/packages/task-manager/tests/command.test.ts similarity index 77% rename from packages/cli/src/__tests__/commands/task.test.ts rename to packages/task-manager/tests/command.test.ts index c2b88322..ba1f8460 100644 --- a/packages/cli/src/__tests__/commands/task.test.ts +++ b/packages/task-manager/tests/command.test.ts @@ -1,15 +1,13 @@ import type { MockedFunction } from 'vitest'; import { Command } from 'commander'; import * as fs from 'fs'; +import { readFile } from 'node:fs/promises'; import * as os from 'os'; import * as path from 'path'; -import { registerTaskCommand } from '../../commands/task.js'; -import { createTaskService } from '@ai-devkit/task-manager'; -import { ui } from '../../util/terminal-ui.js'; +import { register } from '../src/command.js'; +import { createTaskService } from '../src/index.js'; -// Mock the task-manager package so the command layer is tested in isolation, -// mirroring the memory command test pattern. const mockTaskService = { create: vi.fn(), get: vi.fn(), @@ -37,12 +35,21 @@ const EVENT_ID = '33333333-3333-4333-8333-333333333333'; const BLOCKER_ID = '44444444-4444-4444-8444-444444444444'; const EVIDENCE_ID = '55555555-5555-4555-8555-555555555555'; -const mockGetTasksDbPath = vi.fn<() => Promise<string | undefined>>(); -const mockConfigManager = { - getTasksDbPath: mockGetTasksDbPath, +const mockRuntime = { + cwd: '/repo', + homeDir: '/home/test', + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, }; -vi.mock('@ai-devkit/task-manager', () => ({ +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(async () => JSON.stringify({ tasks: { path: '.ai-devkit/tasks.db' } })), +})); + +vi.mock('../src/index.js', () => ({ createTaskService: vi.fn(() => mockTaskService), resolveCurrentActor: vi.fn((override?: Record<string, unknown>) => override ? { agentId: 'resolved-agent', ...override } : null @@ -52,19 +59,11 @@ vi.mock('@ai-devkit/task-manager', () => ({ TaskNotFoundError: class TaskNotFoundError extends Error {}, })); -vi.mock('../../lib/Config.js', () => ({ - ConfigManager: vi.fn(function () { return mockConfigManager; }), -})); - -vi.mock('../../util/terminal-ui.js', () => ({ - ui: { - error: vi.fn(), - warning: vi.fn(), - success: vi.fn(), - text: vi.fn(), - table: vi.fn(), - }, -})); +function createProgram(): Command { + const program = new Command(); + register(program.command('task'), mockRuntime); + return program; +} function sampleTask(overrides: Record<string, unknown> = {}) { return { @@ -94,25 +93,22 @@ function sampleTask(overrides: Record<string, unknown> = {}) { } describe('task command', () => { - const mockedUi = vi.mocked(ui); const mockedCreateTaskService = createTaskService as MockedFunction<typeof createTaskService>; let consoleLogSpy: ReturnType<typeof vi.spyOn>; beforeEach(() => { vi.clearAllMocks(); - mockGetTasksDbPath.mockResolvedValue(undefined); + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ tasks: { path: '.ai-devkit/tasks.db' } })); Object.values(mockTaskService).forEach((fn) => (fn as ReturnType<typeof vi.fn>).mockReset()); consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); }); describe('create', () => { it('uses the configured task database path', async () => { - mockGetTasksDbPath.mockResolvedValue('/repo/.ai-devkit/tasks.db'); const task = sampleTask(); mockTaskService.create.mockResolvedValue(task); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync([ 'node', 'test', 'task', 'create', '--title', 'Sample task', @@ -122,13 +118,55 @@ describe('task command', () => { expect(mockedCreateTaskService).toHaveBeenCalledWith('/repo/.ai-devkit/tasks.db'); }); + it('uses the default task database path when project config is missing', async () => { + const error = new Error('missing config') as Error & { code: string }; + error.code = 'ENOENT'; + vi.mocked(readFile).mockRejectedValue(error); + mockTaskService.create.mockResolvedValue(sampleTask()); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'test', 'task', 'create', + '--title', 'Sample task', + '--json', + ]); + + expect(mockedCreateTaskService).toHaveBeenCalledWith('/home/test/.ai-devkit/tasks.db'); + }); + + it('uses the default task database path when tasks.path is blank', async () => { + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ tasks: { path: ' ' } })); + mockTaskService.create.mockResolvedValue(sampleTask()); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'test', 'task', 'create', + '--title', 'Sample task', + '--json', + ]); + + expect(mockedCreateTaskService).toHaveBeenCalledWith('/home/test/.ai-devkit/tasks.db'); + }); + + it('uses absolute tasks.path from project config as-is', async () => { + vi.mocked(readFile).mockResolvedValue(JSON.stringify({ tasks: { path: '/custom/tasks.db' } })); + mockTaskService.create.mockResolvedValue(sampleTask()); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'test', 'task', 'create', + '--title', 'Sample task', + '--json', + ]); + + expect(mockedCreateTaskService).toHaveBeenCalledWith('/custom/tasks.db'); + }); + it('lets --db-path override the configured task database path', async () => { - mockGetTasksDbPath.mockResolvedValue('/repo/.ai-devkit/tasks.db'); const task = sampleTask(); mockTaskService.create.mockResolvedValue(task); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync([ 'node', 'test', 'task', 'create', '--title', 'Sample task', @@ -143,8 +181,7 @@ describe('task command', () => { const task = sampleTask(); mockTaskService.create.mockResolvedValue(task); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync([ 'node', 'test', 'task', 'create', '--title', 'Sample task', @@ -160,8 +197,7 @@ describe('task command', () => { it('parses tags and links', async () => { mockTaskService.create.mockResolvedValue(sampleTask()); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync([ 'node', 'test', 'task', 'create', '--title', 'T', @@ -186,31 +222,27 @@ describe('task command', () => { describe('list', () => { it('renders a table by default', async () => { mockTaskService.list.mockResolvedValue([sampleTask(), sampleTask({ taskId: SECOND_TASK_ID, title: 'Other' })]); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync(['node', 'test', 'task', 'list']); - expect(mockedUi.table).toHaveBeenCalled(); - const args = (mockedUi.table as MockedFunction<typeof ui.table>).mock.calls[0]![0]; - expect(args.headers).toEqual(['id', 'title', 'status', 'phase', 'feature']); - expect(args.rows).toHaveLength(2); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('id\ttitle\tstatus\tphase\tfeature')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining(`${TASK_ID}\tSample task\topen`)); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining(`${SECOND_TASK_ID}\tOther\topen`)); }); it('prints JSON with --json', async () => { const tasks = [sampleTask()]; mockTaskService.list.mockResolvedValue(tasks); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync(['node', 'test', 'task', 'list', '--json']); expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(tasks, null, 2)); }); it('warns when empty', async () => { mockTaskService.list.mockResolvedValue([]); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync(['node', 'test', 'task', 'list']); - expect(mockedUi.warning).toHaveBeenCalledWith('No tasks found.'); + expect(mockRuntime.logger.warn).toHaveBeenCalledWith('No tasks found.'); }); }); @@ -222,8 +254,7 @@ describe('task command', () => { const events = [{ eventId: EVENT_ID, type: 'task.created', ts: 't', actor: null, taskId: task.taskId, payload: {} }]; mockTaskService.getEvents.mockResolvedValue(events); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync(['node', 'test', 'task', 'show', 'demo', '--events', '--json']); expect(mockTaskService.resolveTask).toHaveBeenCalledWith('demo'); @@ -234,10 +265,9 @@ describe('task command', () => { it('prints an error when no task resolves', async () => { mockTaskService.resolveTask.mockResolvedValue(null); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync(['node', 'test', 'task', 'show', 'missing']); - expect(mockedUi.error).toHaveBeenCalledWith('No task found for "missing".'); + expect(mockRuntime.logger.error).toHaveBeenCalledWith('No task found for "missing".'); }); }); @@ -247,8 +277,7 @@ describe('task command', () => { mockTaskService.resolveTask.mockResolvedValue(task); mockTaskService.setPhase.mockResolvedValue(task); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync(['node', 'test', 'task', 'phase', 'demo', 'design', '--json']); expect(mockTaskService.setPhase).toHaveBeenCalledWith(TASK_ID, 'design', expect.any(Object)); @@ -259,8 +288,7 @@ describe('task command', () => { mockTaskService.resolveTask.mockResolvedValue(task); mockTaskService.setProgress.mockResolvedValue(task); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync(['node', 'test', 'task', 'progress', 'demo', '--text', 'half', '--percent', '50', '--json']); expect(mockTaskService.setProgress).toHaveBeenCalledWith( @@ -275,8 +303,7 @@ describe('task command', () => { mockTaskService.resolveTask.mockResolvedValue(task); mockTaskService.setProgress.mockResolvedValue(task); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync(['node', 'test', 'task', 'progress', 'demo', '--clear']); expect(mockTaskService.setProgress).toHaveBeenCalledWith( task.taskId, @@ -290,8 +317,7 @@ describe('task command', () => { mockTaskService.resolveTask.mockResolvedValue(task); mockTaskService.setNextStep.mockResolvedValue(task); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync(['node', 'test', 'task', 'next', 'demo', 'write', 'tests', '--json']); expect(mockTaskService.setNextStep).toHaveBeenCalledWith(task.taskId, 'write tests', expect.any(Object)); }); @@ -303,8 +329,7 @@ describe('task command', () => { mockTaskService.resolveTask.mockResolvedValue(task); mockTaskService.addBlocker.mockResolvedValue({ task, blockerId: BLOCKER_ID }); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync(['node', 'test', 'task', 'blocker', 'demo', 'add', 'waiting', 'on', 'x', '--json']); expect(mockTaskService.addBlocker).toHaveBeenCalledWith( task.taskId, @@ -318,8 +343,7 @@ describe('task command', () => { mockTaskService.resolveTask.mockResolvedValue(task); mockTaskService.resolveBlocker.mockResolvedValue(task); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync(['node', 'test', 'task', 'blocker', 'demo', 'resolve', BLOCKER_ID, '--json']); expect(mockTaskService.resolveBlocker).toHaveBeenCalledWith(task.taskId, BLOCKER_ID, expect.any(Object)); }); @@ -331,8 +355,7 @@ describe('task command', () => { mockTaskService.resolveTask.mockResolvedValue(task); mockTaskService.addEvidence.mockResolvedValue({ task, evidenceId: EVIDENCE_ID }); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync([ 'node', 'test', 'task', 'evidence', 'demo', '--command', 'nx test', '--exit-code', '0', '--passed', '--summary', 'green', @@ -348,10 +371,9 @@ describe('task command', () => { it('errors when neither --passed nor --failed is given', async () => { const task = sampleTask(); mockTaskService.resolveTask.mockResolvedValue(task); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync(['node', 'test', 'task', 'evidence', 'demo', '--command', 'x']); - expect(mockedUi.error).toHaveBeenCalledWith(expect.stringContaining('--passed')); + expect(mockRuntime.logger.error).toHaveBeenCalledWith(expect.stringContaining('--passed')); }); it('collects repeatable --artifact refs', async () => { @@ -359,8 +381,7 @@ describe('task command', () => { mockTaskService.resolveTask.mockResolvedValue(task); mockTaskService.addEvidence.mockResolvedValue({ task, evidenceId: EVIDENCE_ID }); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync([ 'node', 'test', 'task', 'evidence', 'demo', '--passed', '--artifact', '/a.log', '--artifact', '/b.log', '--json', @@ -379,8 +400,7 @@ describe('task command', () => { mockTaskService.resolveTask.mockResolvedValue(task); mockTaskService.addNote.mockResolvedValue(task); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync(['node', 'test', 'task', 'note', 'demo', 'quick', 'note', '--json']); expect(mockTaskService.addNote).toHaveBeenCalledWith(task.taskId, 'quick note', expect.any(Object)); }); @@ -393,8 +413,7 @@ describe('task command', () => { const event = { eventId: EVENT_ID, taskId: task.taskId, ts: 't', type: 'task.custom', actor: null, payload: { name: 'tick' } }; mockTaskService.addEvent.mockResolvedValue(event); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync([ 'node', 'test', 'task', 'event', 'demo', '--payload', '{"name":"tick"}', '--json', @@ -417,8 +436,7 @@ describe('task command', () => { const tmp = path.join(os.tmpdir(), `evt-${Date.now()}.json`); fs.writeFileSync(tmp, JSON.stringify({ name: 'from-file' }), 'utf8'); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync(['node', 'test', 'task', 'event', 'demo', '--payload', `@${tmp}`, '--json']); expect(mockTaskService.addEvent).toHaveBeenCalledWith( task.taskId, @@ -435,8 +453,7 @@ describe('task command', () => { mockTaskService.resolveTask.mockResolvedValue(task); mockTaskService.close.mockResolvedValue(task); - const program = new Command(); - registerTaskCommand(program); + const program = createProgram(); await program.parseAsync(['node', 'test', 'task', 'close', 'demo', '--json']); expect(mockTaskService.close).toHaveBeenCalledWith(task.taskId, 'completed', expect.any(Object)); }); diff --git a/packages/task-manager/tests/plugin-package.test.ts b/packages/task-manager/tests/plugin-package.test.ts new file mode 100644 index 00000000..c9539546 --- /dev/null +++ b/packages/task-manager/tests/plugin-package.test.ts @@ -0,0 +1,15 @@ +import packageJson from '../package.json' with { type: 'json' }; + +describe('task manager plugin package', () => { + it('declares the task command in the ai-devkit plugin manifest', () => { + expect(packageJson.aiDevkit).toEqual({ + commands: [ + { + name: 'task', + description: 'Manage durable development/debug tasks', + entry: './dist/command.js', + }, + ], + }); + }); +}); From 63693bc0fbc11f4b17096938932ae80967ece969 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen <hoangnn93@gmail.com> Date: Thu, 2 Jul 2026 13:40:13 +0200 Subject: [PATCH 4/5] test(task-manager): keep plugin manifest test in package --- .../testing/2026-07-01-feature-task-system.md | 2 +- .../plugin/task-manager-package.test.ts | 22 ------------------- 2 files changed, 1 insertion(+), 23 deletions(-) delete mode 100644 packages/cli/src/__tests__/services/plugin/task-manager-package.test.ts diff --git a/docs/ai/testing/2026-07-01-feature-task-system.md b/docs/ai/testing/2026-07-01-feature-task-system.md index 6778f10e..f471bc64 100644 --- a/docs/ai/testing/2026-07-01-feature-task-system.md +++ b/docs/ai/testing/2026-07-01-feature-task-system.md @@ -63,7 +63,7 @@ vitest v8; package thresholds statements/branches/functions/lines ≥ 75%. ## Results - `nx test task-manager`: 115 passing, 0 failing (8 test files). -- `nx test cli`: 871 passing, 0 failing (73 test files). +- `nx test cli`: 870 passing, 0 failing (72 test files). - `nx build task-manager`: succeeds. - `nx build cli`: succeeds, including dependent package builds. - `nx lint task-manager`: 0 errors. diff --git a/packages/cli/src/__tests__/services/plugin/task-manager-package.test.ts b/packages/cli/src/__tests__/services/plugin/task-manager-package.test.ts deleted file mode 100644 index c1e2cbce..00000000 --- a/packages/cli/src/__tests__/services/plugin/task-manager-package.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import fs from 'fs-extra'; -import path from 'path'; - -describe('task manager plugin package', () => { - it('declares the task plugin command manifest', async () => { - const packageJsonPath = path.resolve( - process.cwd(), - '../../packages/task-manager/package.json' - ); - const packageJson = await fs.readJson(packageJsonPath) as unknown; - const aiDevkit = (packageJson as { aiDevkit?: { commands?: unknown } }).aiDevkit; - - expect((packageJson as { name?: string }).name).toBe('@ai-devkit/task-manager'); - expect(aiDevkit?.commands).toEqual([ - { - name: 'task', - description: 'Manage durable development/debug tasks', - entry: './dist/command.js', - }, - ]); - }); -}); From 31e91a3879e5945daf0d19b7d1fe8f5c64866225 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen <hoangnn93@gmail.com> Date: Thu, 2 Jul 2026 13:56:37 +0200 Subject: [PATCH 5/5] refactor(task-manager): require explicit actor attribution --- .../design/2026-07-01-feature-task-system.md | 16 ++--- .../2026-07-01-feature-task-system.md | 5 +- .../2026-07-01-feature-task-system.md | 10 +-- .../testing/2026-07-01-feature-task-system.md | 7 +- packages/task-manager/README.md | 9 ++- packages/task-manager/src/actor-resolver.ts | 53 --------------- packages/task-manager/src/command.ts | 19 ++---- packages/task-manager/src/index.ts | 2 - packages/task-manager/src/task.service.ts | 22 +++---- packages/task-manager/src/task.types.ts | 2 +- packages/task-manager/tests/command.test.ts | 3 - .../tests/unit/actor-resolver.test.ts | 64 ------------------- 12 files changed, 39 insertions(+), 173 deletions(-) delete mode 100644 packages/task-manager/src/actor-resolver.ts delete mode 100644 packages/task-manager/tests/unit/actor-resolver.test.ts diff --git a/docs/ai/design/2026-07-01-feature-task-system.md b/docs/ai/design/2026-07-01-feature-task-system.md index 00dc56d1..98ec6897 100644 --- a/docs/ai/design/2026-07-01-feature-task-system.md +++ b/docs/ai/design/2026-07-01-feature-task-system.md @@ -121,7 +121,7 @@ interface TaskEvent { taskId: string; ts: string; // ISO 8601 type: TaskEventType; // see table below - actor: Actor | null; // who emitted this event (auto-resolved if omitted by caller) + actor: Actor | null; // who emitted this event, when provided by the caller payload: Record<string, unknown>; // shape depends on type } ``` @@ -152,9 +152,9 @@ Stateful types mutate the snapshot **and** append the event. `task.note.append` ### `TaskService` (public API consumed by CLI and skills) -All methods async. Every mutator accepts an optional `opts?: { actor?: Actor }` to override -attribution; if omitted, the service auto-resolves the current agent (see Attribution). Every -mutator returns the updated `Task` snapshot unless noted. +All methods async. Every mutator accepts an optional `opts?: { actor?: Actor }`; if omitted, +the event actor is stored as `null` (see Attribution). Every mutator returns the updated `Task` +snapshot unless noted. ```ts class TaskService { @@ -255,11 +255,9 @@ falls back to `~/.ai-devkit/tasks.db`. `<id>` resolves per `resolveTask` above. - Per-event `actor` records who **emitted** the event; task `attribution` records the **current owner** (set via `task.attribution.set` / `task assign`). -- When `actor` is omitted, the service auto-resolves in order: - 1. explicit flags/opts (`--agent*`); - 2. env (`AI_DEVKIT_AGENT_ID`, `AI_DEVKIT_AGENT_TYPE`, `AI_DEVKIT_SESSION_ID`, - `AI_DEVKIT_AGENT_PID`); - 3. `process.pid`. +- Actor metadata is explicit. Skills and CLI callers may pass `opts.actor` or attribution flags + (`--agent`, `--agent-type`, `--pid`, `--session`); when no actor is provided, events store + `null`. ## Feature ↔ Task ↔ Phase diff --git a/docs/ai/implementation/2026-07-01-feature-task-system.md b/docs/ai/implementation/2026-07-01-feature-task-system.md index a8601c68..05b2782b 100644 --- a/docs/ai/implementation/2026-07-01-feature-task-system.md +++ b/docs/ai/implementation/2026-07-01-feature-task-system.md @@ -27,14 +27,13 @@ packages/task-manager/ task.types.ts # public types: Actor, Task, TaskEvent, TaskEventType union, payloads task.errors.ts # TaskError hierarchy + isTaskEventType guard task.ids.ts # raw UUID id generators (crypto.randomUUID) - actor-resolver.ts # resolveCurrentActor (env + process), ATTRIB_ENV task.repository.ts # TaskRepository — task/event persistence (SQLite) task.service.ts # TaskService (public consume-only surface) command.ts # optional AI DevKit plugin command entrypoint database/ # connection.ts (WAL/migrations), schema.ts, migrations/001_initial.sql index.ts # public exports (import path @ai-devkit/task-manager) tests/ - unit/ # task.ids, actor-resolver, task.errors + unit/ # task.ids, task.errors integration/ # task.repository, service, add-event coverage, repository errors ``` @@ -71,7 +70,7 @@ Test: `packages/task-manager/tests/command.test.ts`. - **Ids are raw UUIDs** (`<uuid>` (raw UUIDv4), …) from Node `crypto.randomUUID()`, generated in the service layer (like memory generates ids in its handlers, not in the DB layer). No collision checks or central counters are needed. -- Every mutator accepts `opts?{actor}` and auto-resolves attribution when omitted. +- Every mutator accepts `opts?{actor}` and stores `null` when caller omits actor metadata. - List ordering is `createdAt` desc then `taskId` desc (deterministic for same-second tasks). ## Integration Points diff --git a/docs/ai/planning/2026-07-01-feature-task-system.md b/docs/ai/planning/2026-07-01-feature-task-system.md index c1520231..7ffdc4a7 100644 --- a/docs/ai/planning/2026-07-01-feature-task-system.md +++ b/docs/ai/planning/2026-07-01-feature-task-system.md @@ -31,9 +31,9 @@ description: Break down work into actionable tasks and estimate timeline `TaskValidationError`, `AmbiguousTaskRefError`, `TaskRepositoryError`. - [ ] **1.4 IDs + time helpers** (`src/task.ids.ts`). `taskId`/`eventId`/`blockerId`/`evidenceId`/ `artifactId` generators (raw UUIDv4 via Node `crypto.randomUUID()`). -- [ ] **1.5 Actor resolver** (`src/actor-resolver.ts`). `resolveCurrentActor()`: - flags/env/agent-manager best-effort/null. Pure env+process for MVP (no hard dep on - agent-manager to keep package standalone); agent-manager lookup deferred. +- [ ] **1.5 Explicit actor handling**. Mutators accept caller-supplied actor metadata and store + `null` when omitted. Skills provide actor details when they have useful running-agent + context. - [ ] **1.6 TaskRepository + database layer** (`src/task.repository.ts`, `src/database/`). `TaskRepository`: `exists`, `readTask`, `writeTask`, `listTaskIds`, `readEvents`, `appendEvent`. (Id generation lives in the service, not the @@ -41,7 +41,7 @@ description: Break down work into actionable tasks and estimate timeline `database/{connection,schema,migrations}` mirrors `@ai-devkit/memory` (WAL, busy_timeout, versioned migrations via `user_version`). - [ ] **1.7 TaskService** (`src/task.service.ts`). All service methods; delegates to the repository; - applies snapshot mutation per event type; auto-resolves actor; `resolveTask` + applies snapshot mutation per event type; records explicit actor metadata; `resolveTask` (full id → unique prefix → feature→latest non-terminal). - [ ] **1.8 Package index** (`src/index.ts`). Export types + `TaskService` + `TaskRepository` + CLI option interfaces (`TaskCreateOptions`, etc.) for the CLI layer, so consumers import @@ -57,7 +57,7 @@ description: Break down work into actionable tasks and estimate timeline pretty + `--events`, `--json` machine output everywhere. ### M3: Tests, docs, simplify, validate -- [x] **3.1 Unit tests** (`packages/task-manager/tests/unit/`). ids, actor-resolver, +- [x] **3.1 Unit tests** (`packages/task-manager/tests/unit/`). ids, service mutation-per-event, resolveTask resolution order, validation errors. - [x] **3.2 Integration tests** (`tests/integration/`). TaskRepository round-trip, migrations, append-only events, addEvent escape hatch coverage, repository error branches. diff --git a/docs/ai/testing/2026-07-01-feature-task-system.md b/docs/ai/testing/2026-07-01-feature-task-system.md index f471bc64..c1af6a5d 100644 --- a/docs/ai/testing/2026-07-01-feature-task-system.md +++ b/docs/ai/testing/2026-07-01-feature-task-system.md @@ -29,7 +29,7 @@ Unit, integration, and plugin command coverage live in `packages/task-manager/te - [x] TaskRepository round-trip, upsert, append-only events (insertion order), schema init — `task.repository.test.ts` - [x] repository error branches wrapped as TaskRepositoryError (corrupt snapshot/payload, invalid path) — `task.repository.test.ts`, `add-event-coverage.test.ts` - [x] raw UUID id format + uniqueness — `task.ids.test.ts` -- [x] actor auto-resolution env/override/pid — `actor-resolver.test.ts` +- [x] explicit actor metadata is recorded and omitted actor stays `null` — `service.test.ts`, `command.test.ts` - [x] error hierarchy + isTaskEventType guard — `errors.test.ts` - [x] Plugin CLI verbs parse flags, resolve refs, print JSON/table, error on bad input — `command.test.ts` - [x] Plugin CLI resolves task DB path from `.ai-devkit.json` `tasks.path`; `--db-path` overrides config — `command.test.ts` @@ -52,7 +52,6 @@ vitest v8; package thresholds statements/branches/functions/lines ≥ 75%. ## Test Files - `packages/task-manager/tests/unit/ids.test.ts` -- `packages/task-manager/tests/unit/actor-resolver.test.ts` - `packages/task-manager/tests/unit/errors.test.ts` - `packages/task-manager/tests/integration/task.repository.test.ts` - `packages/task-manager/tests/integration/service.test.ts` @@ -62,8 +61,8 @@ vitest v8; package thresholds statements/branches/functions/lines ≥ 75%. - `packages/cli/src/__tests__/lib/Config.test.ts` ## Results -- `nx test task-manager`: 115 passing, 0 failing (8 test files). -- `nx test cli`: 870 passing, 0 failing (72 test files). +- `nx test task-manager`: 110 passing, 0 failing (7 test files). +- `nx test cli`: 871 passing, 0 failing (72 test files). - `nx build task-manager`: succeeds. - `nx build cli`: succeeds, including dependent package builds. - `nx lint task-manager`: 0 errors. diff --git a/packages/task-manager/README.md b/packages/task-manager/README.md index 1fb55754..4467511e 100644 --- a/packages/task-manager/README.md +++ b/packages/task-manager/README.md @@ -40,8 +40,8 @@ const events = await service.getEvents(task.taskId); ## Service API -All methods async. Every mutator accepts `opts?: { actor?: Actor }` (auto-resolved if -omitted) and returns the updated `Task` (or `{ task, blockerId/evidenceId/artifactId }`). +All methods async. Every mutator accepts `opts?: { actor?: Actor }`; omitted actor metadata is +stored as `null`. Mutators return the updated `Task` (or `{ task, blockerId/evidenceId/artifactId }`). `create`, `get`, `resolveTask(ref)`, `list(filter?)`, `update`, `setPhase`, `setStatus`, `setProgress`, `setNextStep`, `addBlocker`, `resolveBlocker`, `addEvidence`, `addArtifact`, @@ -80,9 +80,8 @@ ai-devkit task show feature-x --events ## Attribution -Per-event `actor` records who **emitted** an event; `task.attribution` records the **current -owner**. When `actor` is omitted the service auto-resolves from explicit opts, env -(`AI_DEVKIT_AGENT_ID/TYPE/SESSION_ID/PID`), and `process.pid`. +Per-event `actor` records who **emitted** an event when supplied by the caller; `task.attribution` +records the **current owner**. When `actor` is omitted the event stores `null`. ## Limitations (MVP) diff --git a/packages/task-manager/src/actor-resolver.ts b/packages/task-manager/src/actor-resolver.ts deleted file mode 100644 index 49328932..00000000 --- a/packages/task-manager/src/actor-resolver.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { Actor } from './task.types.js'; - -/** - * Environment variable names used for agent attribution auto-resolution. - * Tracing workers / agents may set these so the task service can attribute - * events without explicit `--agent` flags on every call. - */ -export const ATTRIB_ENV = { - agentId: 'AI_DEVKIT_AGENT_ID', - agentType: 'AI_DEVKIT_AGENT_TYPE', - sessionId: 'AI_DEVKIT_SESSION_ID', - agentPid: 'AI_DEVKIT_AGENT_PID', -} as const; - -/** - * Resolves the current actor. Explicit overrides take precedence over - * environment variables; pid falls back to the current process id. - */ -export function resolveCurrentActor(override?: Partial<Actor>): Actor | null { - const env = process.env; - const resolved: Actor = {}; - - const agentType = env[ATTRIB_ENV.agentType]; - if (agentType) { - resolved.agentType = agentType; - } - const agentId = env[ATTRIB_ENV.agentId]; - if (agentId) { - resolved.agentId = agentId; - } - const sessionId = env[ATTRIB_ENV.sessionId]; - if (sessionId) { - resolved.sessionId = sessionId; - } - - const pidEnv = env[ATTRIB_ENV.agentPid]; - if (pidEnv && /^\d+$/.test(pidEnv)) { - resolved.pid = Number.parseInt(pidEnv, 10); - } else { - resolved.pid = process.pid; - } - - if (override) { - for (const [key, value] of Object.entries(override)) { - if (value !== undefined) { - (resolved as Record<string, unknown>)[key] = value; - } - } - } - - const hasAny = resolved.agentId || resolved.agentType || resolved.sessionId || resolved.pid; - return hasAny ? resolved : null; -} diff --git a/packages/task-manager/src/command.ts b/packages/task-manager/src/command.ts index 191dfac5..5b463156 100644 --- a/packages/task-manager/src/command.ts +++ b/packages/task-manager/src/command.ts @@ -4,7 +4,6 @@ import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; import { createTaskService, - resolveCurrentActor, AmbiguousTaskRefError, isTaskEventType, } from './index.js'; @@ -30,19 +29,13 @@ interface AttributionOptions { session?: string; } -function buildActorOverride(opts: AttributionOptions): Partial<Actor> | undefined { - const override: Partial<Actor> = {}; - if (opts.agent) override.agentId = opts.agent; - if (opts.agentType) override.agentType = opts.agentType; - if (opts.pid) override.pid = Number.parseInt(opts.pid, 10); - if (opts.session) override.sessionId = opts.session; - return Object.keys(override).length > 0 ? override : undefined; -} - function actorFromOptions(opts: AttributionOptions): Actor | undefined { - const override = buildActorOverride(opts); - if (!override) return undefined; - return resolveCurrentActor(override) ?? undefined; + const actor: Actor = {}; + if (opts.agent) actor.agentId = opts.agent; + if (opts.agentType) actor.agentType = opts.agentType; + if (opts.pid) actor.pid = Number.parseInt(opts.pid, 10); + if (opts.session) actor.sessionId = opts.session; + return Object.keys(actor).length > 0 ? actor : undefined; } async function createService(runtime: AiDevkitRuntime, dbPathFlag?: string): Promise<TaskService> { diff --git a/packages/task-manager/src/index.ts b/packages/task-manager/src/index.ts index 99773cb8..cc11f22d 100644 --- a/packages/task-manager/src/index.ts +++ b/packages/task-manager/src/index.ts @@ -57,8 +57,6 @@ export { isTaskEventType, } from './task.errors.js'; -export { resolveCurrentActor, ATTRIB_ENV } from './actor-resolver.js'; - /** * Convenience factory: a TaskService backed by a TaskRepository at the resolved * DB path (dbPath arg > ~/.ai-devkit/tasks.db). diff --git a/packages/task-manager/src/task.service.ts b/packages/task-manager/src/task.service.ts index d55ff354..03f87ef7 100644 --- a/packages/task-manager/src/task.service.ts +++ b/packages/task-manager/src/task.service.ts @@ -21,7 +21,6 @@ import { isTaskEventType, } from './task.errors.js'; import { makeArtifactId, makeBlockerId, makeEvidenceId, makeEventId, makeTaskId, nowIso } from './task.ids.js'; -import { resolveCurrentActor } from './actor-resolver.js'; export interface TaskMutationOptions { actor?: Actor; @@ -129,7 +128,7 @@ export class TaskService { const feature = validateFeature(input.feature ?? null); const now = nowIso(); - const actor = input.actor ?? resolveCurrentActor(); + const actor = input.actor ?? null; const taskId = makeTaskId(); const task: Task = { @@ -347,7 +346,7 @@ export class TaskService { status: 'open', raisedAt: nowIso(), resolvedAt: null, - raisedBy: opts?.actor ?? resolveCurrentActor(), + raisedBy: opts?.actor ?? null, }; task.blockers = [...task.blockers, blocker]; const updated = await this.persistAndRecord( @@ -398,7 +397,7 @@ export class TaskService { summary: input.summary ?? null, artifacts: input.artifacts ? [...input.artifacts] : [], recordedAt: nowIso(), - actor: opts?.actor ?? resolveCurrentActor(), + actor: opts?.actor ?? null, }; task.evidence = [...task.evidence, evidence]; const updated = await this.persistAndRecord(task, 'task.evidence.add', { @@ -464,7 +463,7 @@ export class TaskService { } const task = await this.requireTask(taskId); // Event-only: no snapshot mutation beyond cached counters. - const actor = opts?.actor ?? resolveCurrentActor(); + const actor = opts?.actor ?? null; await this.appendEventInternal(task.taskId, 'task.note.append', { text: trimmed }, actor); return this.refreshCachedCounters(task); } @@ -498,9 +497,9 @@ export class TaskService { throw new UnknownEventTypeError(type); } const task = await this.requireTask(taskId); - const actor = opts?.actor ?? resolveCurrentActor(); + const actor = opts?.actor ?? null; - const mutated = this.applyEventToSnapshot(task, type, payload); + const mutated = this.applyEventToSnapshot(task, type, payload, actor); if (mutated) { await this.repository.writeTask(task); } @@ -544,7 +543,7 @@ export class TaskService { payload: Record<string, unknown>, opts?: TaskMutationOptions ): Promise<Task> { - const actor = opts?.actor ?? resolveCurrentActor(); + const actor = opts?.actor ?? null; task.updatedAt = nowIso(); await this.repository.writeTask(task); await this.appendEventInternal(task.taskId, type, payload, actor); @@ -588,7 +587,8 @@ export class TaskService { private applyEventToSnapshot( task: Task, type: TaskEventType, - payload: Record<string, unknown> + payload: Record<string, unknown>, + actor: Actor | null ): boolean { switch (type) { case 'task.created': @@ -643,7 +643,7 @@ export class TaskService { status: 'open', raisedAt: nowIso(), resolvedAt: null, - raisedBy: resolveCurrentActor(), + raisedBy: actor, }, ]; return true; @@ -666,7 +666,7 @@ export class TaskService { summary: (payload.summary as string | null | undefined) ?? null, artifacts: (payload.artifacts as string[] | undefined) ?? [], recordedAt: nowIso(), - actor: resolveCurrentActor(), + actor, }, ]; return true; diff --git a/packages/task-manager/src/task.types.ts b/packages/task-manager/src/task.types.ts index 43c57a99..88fa7dde 100644 --- a/packages/task-manager/src/task.types.ts +++ b/packages/task-manager/src/task.types.ts @@ -138,7 +138,7 @@ export interface TaskEvent { /** ISO 8601 timestamp. */ ts: string; type: TaskEventType; - /** Who emitted this event (auto-resolved by the service if the caller omits it). */ + /** Who emitted this event, when the caller provides actor metadata. */ actor: Actor | null; /** Shape depends on `type`; see the contract for per-type payload schemas. */ payload: Record<string, unknown>; diff --git a/packages/task-manager/tests/command.test.ts b/packages/task-manager/tests/command.test.ts index ba1f8460..68db13e0 100644 --- a/packages/task-manager/tests/command.test.ts +++ b/packages/task-manager/tests/command.test.ts @@ -51,9 +51,6 @@ vi.mock('node:fs/promises', () => ({ vi.mock('../src/index.js', () => ({ createTaskService: vi.fn(() => mockTaskService), - resolveCurrentActor: vi.fn((override?: Record<string, unknown>) => - override ? { agentId: 'resolved-agent', ...override } : null - ), isTaskEventType: vi.fn((t: string) => t.startsWith('task.')), AmbiguousTaskRefError: class AmbiguousTaskRefError extends Error {}, TaskNotFoundError: class TaskNotFoundError extends Error {}, diff --git a/packages/task-manager/tests/unit/actor-resolver.test.ts b/packages/task-manager/tests/unit/actor-resolver.test.ts deleted file mode 100644 index e4877e0a..00000000 --- a/packages/task-manager/tests/unit/actor-resolver.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { resolveCurrentActor, ATTRIB_ENV } from '../../src/actor-resolver.js'; - -describe('resolveCurrentActor', () => { - const origEnv = { ...process.env }; - - beforeEach(() => { - for (const key of Object.values(ATTRIB_ENV)) { - delete process.env[key]; - } - }); - - afterEach(() => { - for (const [key, value] of Object.entries(origEnv)) { - if (Object.values(ATTRIB_ENV).includes(key as never)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - } - }); - - it('resolves pid from process.pid when no env is set', () => { - const actor = resolveCurrentActor(); - expect(actor).not.toBeNull(); - expect(actor?.pid).toBe(process.pid); - }); - - it('reads agent attribution from environment variables', () => { - process.env[ATTRIB_ENV.agentId] = 'agent-42'; - process.env[ATTRIB_ENV.agentType] = 'claude'; - process.env[ATTRIB_ENV.sessionId] = 'sess-abc'; - process.env[ATTRIB_ENV.agentPid] = '999'; - - const actor = resolveCurrentActor(); - expect(actor).toEqual({ - agentId: 'agent-42', - agentType: 'claude', - sessionId: 'sess-abc', - pid: 999, - }); - }); - - it('ignores a non-numeric AI_DEVKIT_AGENT_PID and falls back to process.pid', () => { - process.env[ATTRIB_ENV.agentPid] = 'not-a-number'; - const actor = resolveCurrentActor(); - expect(actor?.pid).toBe(process.pid); - }); - - it('explicit override takes precedence over environment', () => { - process.env[ATTRIB_ENV.agentId] = 'env-agent'; - const actor = resolveCurrentActor({ agentId: 'override-agent', agentType: 'pi' }); - expect(actor?.agentId).toBe('override-agent'); - expect(actor?.agentType).toBe('pi'); - }); - - it('returns a pid-only actor when no agent context resolves', () => { - const actor = resolveCurrentActor(); - expect(actor).not.toBeNull(); - expect(Object.keys(actor!)).toEqual(['pid']); - }); -});