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..98ec6897 --- /dev/null +++ b/docs/ai/design/2026-07-01-feature-task-system.md @@ -0,0 +1,320 @@ +--- +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 + 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` 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. + +## 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, when provided by the 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 }`; if omitted, +the event actor is stored as `null` (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. + +## Plugin CLI (`ai-devkit task ...`) + +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 ` (explicit DB path override), `--json` +(machine output), and attribution overrides `--agent `, `--agent-type `, `--pid `, +`--session `. By default, the plugin resolves project `.ai-devkit.json` `tasks.path`, then +falls back to `~/.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`). +- 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 + +- **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. 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. + +## 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..05b2782b --- /dev/null +++ b/docs/ai/implementation/2026-07-01-feature-task-system.md @@ -0,0 +1,114 @@ +--- +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 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?** + +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) + 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, 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/`. + +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:** + +### 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 stores `null` when caller omits actor metadata. +- List ordering is `createdAt` desc then `taskId` desc (deterministic for same-second tasks). + +## Integration Points +**How do pieces connect?** + +- 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. + +## 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..7ffdc4a7 --- /dev/null +++ b/docs/ai/planning/2026-07-01-feature-task-system.md @@ -0,0 +1,100 @@ +--- +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 — 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. + +## 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 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 + 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; 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 + via `@ai-devkit/task-manager`. + +### M2: CLI surface +- [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; `` 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, + 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 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. +- [ ] **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..2ddc8ca6 --- /dev/null +++ b/docs/ai/requirements/2026-07-01-feature-task-system.md @@ -0,0 +1,112 @@ +--- +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/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. + +## 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..c1af6a5d --- /dev/null +++ b/docs/ai/testing/2026-07-01-feature-task-system.md @@ -0,0 +1,69 @@ +--- +phase: testing +title: Testing Strategy +description: Define test coverage, scenarios, and quality gates +--- + +# Testing Strategy — Task System + +## Test Plan + +Unit, integration, and plugin command coverage live in `packages/task-manager/tests/`. + +### 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] 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` +- [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 `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`. + +## 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/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/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 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. +- `nx lint cli`: 0 errors (only pre-existing warnings). diff --git a/package-lock.json b/package-lock.json index 1e438369..becd3367 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", @@ -13801,6 +13805,31 @@ "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", + "commander": "^11.1.0", + "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/src/__tests__/lib/Config.test.ts b/packages/cli/src/__tests__/lib/Config.test.ts index 9abb6d46..facf1d26 100644 --- a/packages/cli/src/__tests__/lib/Config.test.ts +++ b/packages/cli/src/__tests__/lib/Config.test.ts @@ -774,4 +774,5 @@ describe('ConfigManager', () => { expect(result).toBe('/test/dir/.ai-devkit/project-memory.db'); }); }); + }); diff --git a/packages/cli/src/lib/Config.ts b/packages/cli/src/lib/Config.ts index 9e41b093..b38c724a 100644 --- a/packages/cli/src/lib/Config.ts +++ b/packages/cli/src/lib/Config.ts @@ -97,8 +97,10 @@ export class ConfigManager { async getMemoryDbPath(): Promise { const config = await this.read(); - const configuredPath = config?.memory?.path; + return this.resolveConfiguredPath(config?.memory?.path); + } + private resolveConfiguredPath(configuredPath: unknown): string | undefined { if (typeof configuredPath !== 'string') { return undefined; } 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..4467511e --- /dev/null +++ b/packages/task-manager/README.md @@ -0,0 +1,91 @@ +# @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). 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 + +```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 }`; 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`, +`setAttribution`, `addNote`, `close`, low-level `addEvent`, `getEvents`. + +`resolveTask` accepts a full id, a unique id prefix, or a feature key (latest non-terminal +task). + +## 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 ` add |resolve `, evidence, artifact, assign, note, event, +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. + +```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 when supplied by the caller; `task.attribution` +records the **current owner**. When `actor` is omitted the event stores `null`. + +## 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..d760812f --- /dev/null +++ b/packages/task-manager/package.json @@ -0,0 +1,77 @@ +{ + "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" + }, + "./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", + "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", + "commander": "^11.1.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/command.ts b/packages/task-manager/src/command.ts new file mode 100644 index 00000000..5b463156 --- /dev/null +++ b/packages/task-manager/src/command.ts @@ -0,0 +1,582 @@ +import type { Command } from 'commander'; +import { readFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import * as path from 'node:path'; +import { + createTaskService, + AmbiguousTaskRefError, + isTaskEventType, +} 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; + pid?: string; + session?: string; +} + +function actorFromOptions(opts: AttributionOptions): Actor | 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 { + if (dbPathFlag && dbPathFlag.trim()) { + return createTaskService(dbPathFlag); + } + return createTaskService(await resolveConfiguredTasksDbPath(runtime)); +} + +async function resolveConfiguredTasksDbPath(runtime: AiDevkitRuntime): Promise { + 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 { + if (json) { + console.log(JSON.stringify(value, null, 2)); + return; + } + if (typeof value === 'string') { + 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 '-'; + 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( + runtime: AiDevkitRuntime, + service: TaskService, + id: string +): Promise<{ taskId: string } | null> { + try { + const task = await service.resolveTask(id); + if (!task) { + runtime.logger.error(`No task found for "${id}".`); + return null; + } + return { taskId: task.taskId }; + } catch (error) { + if (error instanceof AmbiguousTaskRefError) { + 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 ?? '-'}`); + 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 ? '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(` events: ${task.eventCount} created: ${task.createdAt}`); + return lines.join('\n'); +} + +export function register(command: Command, runtime: AiDevkitRuntime): void { + command.description('Manage durable development/debug tasks'); + + const addAttributionFlags = (cmd: Command): Command => + cmd + .option('--db-path ', 'Override the configured tasks database path') + .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( + command + .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 = await createService(runtime, opts.dbPath); + 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 { + runtime.logger.info(`Created task ${created.taskId}`); + console.log(renderTask(created)); + } + }) + ); + + addAttributionFlags( + command + .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 = await createService(runtime, opts.dbPath); + 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) { + runtime.logger.warn('No tasks found.'); + return; + } + 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 ?? '-', + ].join('\t')), + ].join('\n')); + }) + ); + + addAttributionFlags( + 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(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, 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; + } + console.log(renderTask(taskObj)); + if (opts.events) { + const events = await service.getEvents(resolved.taskId); + console.log('\nevents:'); + for (const e of events) { + console.log(` ${e.ts} ${e.type} (${e.eventId})`); + } + } + }) + ); + + addAttributionFlags( + command + .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 = 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; + 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(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(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); + }) + ); + + addAttributionFlags( + 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(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), + }); + output(updated, opts.json); + }) + ); + + addAttributionFlags( + command + .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 = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, 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( + 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(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, { + actor: actorFromOptions(opts), + }); + output(updated, opts.json); + }) + ); + + addAttributionFlags( + 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(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) { + runtime.logger.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) { + runtime.logger.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 { + runtime.logger.error(`Unknown blocker action "${action}". Use: add | resolve.`); + process.exitCode = 1; + } + }) + ); + + addAttributionFlags( + command + .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 = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); + if (!resolved) return; + if (!opts.passed && !opts.failed) { + runtime.logger.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( + 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(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, 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( + command + .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 = await createService(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); + if (!resolved) return; + const actor = actorFromOptions(opts); + if (!actor) { + runtime.logger.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(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(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); + if (!resolved) return; + const text = (textParts ?? []).join(' ').trim(); + if (!text) { + runtime.logger.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( + 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(runtime, opts.dbPath); + const resolved = await resolveOrError(runtime, service, id); + if (!resolved) return; + const type = opts.type ?? 'task.custom'; + if (!isTaskEventType(type)) { + runtime.logger.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( + 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(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') { + runtime.logger.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); + }) + ); +} + +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/task-manager/src/database/connection.ts b/packages/task-manager/src/database/connection.ts new file mode 100644 index 00000000..c09920a1 --- /dev/null +++ b/packages/task-manager/src/database/connection.ts @@ -0,0 +1,122 @@ +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 > 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(); + } + 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..cc11f22d --- /dev/null +++ b/packages/task-manager/src/index.ts @@ -0,0 +1,70 @@ +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'; + +/** + * Convenience factory: a TaskService backed by a TaskRepository at the resolved + * DB path (dbPath arg > ~/.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..0687ac20 --- /dev/null +++ b/packages/task-manager/src/task.errors.ts @@ -0,0 +1,89 @@ +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, + }; + } +} + +export class TaskNotFoundError extends TaskError { + constructor(taskId: string) { + super(`Task not found: ${taskId}`, 'TASK_NOT_FOUND', { taskId }); + this.name = 'TaskNotFoundError'; + } +} + +export class TaskValidationError extends TaskError { + constructor(message: string, details?: Record<string, unknown>) { + super(message, 'TASK_VALIDATION_ERROR', details); + this.name = 'TaskValidationError'; + } +} + +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'; + } +} + +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'; + } +} + +export class TaskRepositoryError extends TaskError { + constructor(message: string, details?: Record<string, unknown>) { + super(message, 'TASK_REPOSITORY_ERROR', details); + this.name = 'TaskRepositoryError'; + } +} + +export class UnknownEventTypeError extends TaskError { + constructor(type: string) { + super(`Unknown task event type: ${type}`, 'UNKNOWN_EVENT_TYPE', { type }); + this.name = 'UnknownEventTypeError'; + } +} + +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..1ecb631a --- /dev/null +++ b/packages/task-manager/src/task.repository.ts @@ -0,0 +1,158 @@ +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; 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; + } + + 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..03f87ef7 --- /dev/null +++ b/packages/task-manager/src/task.service.ts @@ -0,0 +1,716 @@ +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'; + +export interface TaskMutationOptions { + 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) {} + + async create(input: TaskCreateInput): Promise<Task> { + validateTitle(input.title); + const feature = validateFeature(input.feature ?? null); + + const now = nowIso(); + const actor = input.actor ?? null; + 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); + + 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); + } + + const direct = await this.repository.readTask(ref); + if (direct) { + return direct; + } + + 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); + } + + 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; + } + + 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); + } + + 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); + } + + 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 ?? null, + }; + 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 ?? null, + }; + 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); + } + + 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 ?? null; + 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 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 ?? null; + + const mutated = this.applyEventToSnapshot(task, type, payload, actor); + if (mutated) { + await this.repository.writeTask(task); + } + const event = await this.appendEventInternal(taskId, type, payload, actor); + 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; + } + + 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 ?? null; + 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>, + actor: Actor | null + ): 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: actor, + }, + ]; + 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, + }, + ]; + 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': + 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..88fa7dde --- /dev/null +++ b/packages/task-manager/src/task.types.ts @@ -0,0 +1,222 @@ +/** + * 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 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; +} + +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 { + /** 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; + status: TaskStatus; + phase: LifecyclePhase; + phaseEnteredAt: string | null; // ISO 8601 + progress: TaskProgress; + nextStep: string | null; + blockers: TaskBlocker[]; + evidence: TaskEvidence[]; + artifacts: TaskArtifact[]; + /** 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>; + 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; +} + +/** + * 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, 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>; +} + +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/command.test.ts b/packages/task-manager/tests/command.test.ts new file mode 100644 index 00000000..68db13e0 --- /dev/null +++ b/packages/task-manager/tests/command.test.ts @@ -0,0 +1,458 @@ +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 { register } from '../src/command.js'; +import { createTaskService } from '../src/index.js'; + +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'; + +const mockRuntime = { + cwd: '/repo', + homeDir: '/home/test', + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +}; + +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), + isTaskEventType: vi.fn((t: string) => t.startsWith('task.')), + AmbiguousTaskRefError: class AmbiguousTaskRefError extends Error {}, + TaskNotFoundError: class TaskNotFoundError extends Error {}, +})); + +function createProgram(): Command { + const program = new Command(); + register(program.command('task'), mockRuntime); + return program; +} + +function sampleTask(overrides: Record<string, unknown> = {}) { + 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 mockedCreateTaskService = createTaskService as MockedFunction<typeof createTaskService>; + let consoleLogSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + vi.clearAllMocks(); + 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 () => { + const task = sampleTask(); + mockTaskService.create.mockResolvedValue(task); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'test', 'task', 'create', + '--title', 'Sample task', + '--json', + ]); + + 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 () => { + const task = sampleTask(); + mockTaskService.create.mockResolvedValue(task); + + const program = createProgram(); + 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); + + const program = createProgram(); + 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 = createProgram(); + 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 = createProgram(); + await program.parseAsync(['node', 'test', 'task', 'list']); + + 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 = 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 = createProgram(); + await program.parseAsync(['node', 'test', 'task', 'list']); + expect(mockRuntime.logger.warn).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 = createProgram(); + 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 = createProgram(); + await program.parseAsync(['node', 'test', 'task', 'show', 'missing']); + expect(mockRuntime.logger.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 = createProgram(); + 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 = createProgram(); + 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 = createProgram(); + 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 = createProgram(); + 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 = createProgram(); + 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 = createProgram(); + 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 = createProgram(); + 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 = createProgram(); + await program.parseAsync(['node', 'test', 'task', 'evidence', 'demo', '--command', 'x']); + expect(mockRuntime.logger.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 = createProgram(); + 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 = createProgram(); + 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 = createProgram(); + 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 = createProgram(); + 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 = 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/integration/add-event-coverage.test.ts b/packages/task-manager/tests/integration/add-event-coverage.test.ts new file mode 100644 index 00000000..702c50b0 --- /dev/null +++ b/packages/task-manager/tests/integration/add-event-coverage.test.ts @@ -0,0 +1,309 @@ +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] + ); +} + +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] + ); +} + +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..d164abe7 --- /dev/null +++ b/packages/task-manager/tests/integration/service.test.ts @@ -0,0 +1,369 @@ +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); + 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'); + 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); + 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); + 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'); + 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); + 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..cfd55a31 --- /dev/null +++ b/packages/task-manager/tests/integration/task.repository.test.ts @@ -0,0 +1,254 @@ +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] + ); +} + +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(); + 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', () => { + it('uses explicit argument first', () => { + expect(resolveDbPath('/explicit/tasks.db')).toBe('/explicit/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', () => { + 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', () => { + let dir: string; + + beforeEach(() => { + closeDatabase(); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-service-factory-')); + }); + + afterEach(() => { + closeDatabase(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + 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); + }); +}); + +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/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', + }, + ], + }); + }); +}); 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, + }, + }, + }, +});