From a64430482bfbba08e63f038b4688e73fbee0ad51 Mon Sep 17 00:00:00 2001 From: michaelwitz Date: Sat, 7 Mar 2026 18:21:06 -0500 Subject: [PATCH 01/15] feat(api): add worker file-locking API with lease-based DB locks --- .agents/skills/planner-agent/SKILL.md | 126 ++-- .agents/skills/worker-agent/SKILL.md | 290 ++++---- .../ZAZZ-7-worker-file-locking-PLAN.md | 135 ++++ .../ZAZZ-7-worker-file-locking-SPEC.md | 155 +++++ .zazz/deliverables/index.yaml | 4 + api/__tests__/helpers/testDatabase.js | 3 +- api/__tests__/routes/file-locks.test.mjs | 197 ++++++ api/__tests__/routes/openapi.test.mjs | 25 + api/lib/db/schema.js | 46 ++ api/scripts/reset-and-seed.js | 1 + api/src/routes/fileLocks.js | 179 +++++ api/src/routes/index.js | 4 +- api/src/schemas/fileLocks.js | 181 +++++ api/src/schemas/index.js | 1 + api/src/schemas/validation.js | 3 +- api/src/services/databaseService.js | 260 ++++++- docs/ZAZZ-FRAMEWORK.md | 652 ++++-------------- 17 files changed, 1573 insertions(+), 689 deletions(-) create mode 100644 .zazz/deliverables/ZAZZ-7-worker-file-locking-PLAN.md create mode 100644 .zazz/deliverables/ZAZZ-7-worker-file-locking-SPEC.md create mode 100644 api/__tests__/routes/file-locks.test.mjs create mode 100644 api/src/routes/fileLocks.js create mode 100644 api/src/schemas/fileLocks.js diff --git a/.agents/skills/planner-agent/SKILL.md b/.agents/skills/planner-agent/SKILL.md index 60b8fae6..21932517 100644 --- a/.agents/skills/planner-agent/SKILL.md +++ b/.agents/skills/planner-agent/SKILL.md @@ -1,14 +1,15 @@ --- name: planner-agent -description: Creates a generic, execution-ready implementation PLAN from an approved SPEC for any deliverable. Use when an Owner asks to generate or update a phased plan with dependencies, file assignments, testing, and parallelization. +description: Creates or updates an execution-ready implementation PLAN from an approved SPEC for any deliverable. Use when an Owner asks for a phased plan with dependency-safe decomposition, repository-verified scope, AC/test traceability, parallelization strategy, and explicit verification commands. --- # Planner Agent Skill +## First Rule: Use Built-In Planning Optimizations +If the active agent/model provides built-in planning optimizations (plan mode, TODO/dependency tooling, structured decomposition), you MUST use them first. Then produce the PLAN in this skill’s required structure. ## Role -Perform a one-shot decomposition of an approved SPEC into an execution-ready PLAN document. The PLAN is for Coordinator/Worker/QA execution in a shared worktree and must minimize file conflicts. - -You are a planner only. Do not implement code in this step. +Produce an execution-ready PLAN from an approved SPEC for Coordinator/Worker/QA execution in a shared worktree. +You are planner-only in this step: DO NOT implement code. ## Framework Context - Zazz is spec-driven and test-driven. @@ -17,72 +18,94 @@ You are a planner only. Do not implement code in this step. - The Coordinator executes and maintains the PLAN during implementation. ## Companion Skill Requirement -- Load and follow `.agents/skills/zazz-board-api/SKILL.md` when planning API work. -- Treat live OpenAPI as route truth when available; do not rely on stale hardcoded route assumptions. +- For API work, you MUST load and follow `.agents/skills/zazz-board-api/SKILL.md`. +- Live OpenAPI is route truth when available. DO NOT rely on stale hardcoded route assumptions. ## Required Inputs -Before generating a PLAN, confirm these values are known: +Before writing a PLAN, you MUST have: - Project code (e.g. `ZAZZ`) - Deliverable code (e.g. `ZAZZ-5`) - Deliverable numeric ID (integer, e.g. `8`) - SPEC file path - -If any are missing, ask the Owner. +If any input is missing, stop and ask the Owner. ## PLAN Naming + Location (Generic Rule) -- Store all plans in `.zazz/deliverables/`. -- Derive PLAN name from SPEC name by replacing `-SPEC.md` with `-PLAN.md`. -- Enforce hyphen-delimited filenames. -- Update `.zazz/deliverables/index.yaml` when generating/updating the canonical PLAN: +- Store plans in `.zazz/deliverables/`. +- Derive PLAN name by replacing `-SPEC.md` with `-PLAN.md`. +- Use hyphen-delimited filenames. +- Update `.zazz/deliverables/index.yaml` only when generating/updating the canonical PLAN: - if deliverable entry exists, add or update its `plan` field - if entry does not exist, add a new deliverable record with `id`, `name`, `spec`, and `plan` -- If the Owner explicitly asks for an alternate planning draft (for example `-CODEX-PLAN.md`), create it without replacing canonical `-PLAN.md` unless asked. +- If the Owner asks for an alternate draft (for example `-CODEX-PLAN.md`), create it without replacing canonical `-PLAN.md` unless explicitly asked. Example: - SPEC: `.zazz/deliverables/ZAZZ-5-fix-routes-no-project-SPEC.md` - PLAN: `.zazz/deliverables/ZAZZ-5-fix-routes-no-project-PLAN.md` -## Output Requirements -Write one markdown PLAN file that includes: +## Output Requirements (CODEX-Style Structure) +Write one markdown PLAN file. Use this section order unless the Owner explicitly requests a different order: 1. Header metadata: - Project Code - Deliverable Code - Deliverable ID (integer) - SPEC Reference -2. Scope guardrails (in-scope, out-of-scope, explicit non-goals from SPEC) -3. Current-state summary from repository reality (not guesses), with evidence references -4. Impacted files by subsystem (API, DB, client, tests, docs/skills) -5. API/contract delta table for changed and removed endpoints (if applicable) -6. AC traceability matrix (`AC -> implementation steps -> tests`) -7. Phased decomposition with numbered phases (`1`, `2`, `3`, ...) -8. Numbered tasks/steps within each phase (`1.1`, `1.2`, `1.3`, ...) -9. Explicit dependencies (`DEPENDS_ON`, optional `COORDINATES_WITH`) -10. Parallelization notes driven by file overlap -11. Testing and validation tasks (unit/API/E2E/manual as applicable) -12. Final verification command set and owner-signoff checkpoints where applicable + - Status (`DRAFT` for new plans; preserve/update status intentionally for plan updates) + - Planning basis (standards/docs reviewed) +2. Scope guardrails: + - In scope + - Out of scope + - Explicit non-goals from SPEC +3. Verified current state (repository reality only): + - Concrete findings from existing files/routes/tests + - Explicitly call out missing coverage or missing files +4. Contract delta (when interfaces change): + - `Current -> Target` table for API/data contracts + - Required behavior semantics (401/403/404/etc.) when relevant +5. Parallelization strategy: + - Named streams + - Serialization hotspots (high-conflict files) + - Merge points between streams +6. AC traceability matrix: + - `AC -> implementation step IDs -> tests/evidence` +7. Phased execution plan: + - Numbered phases (`1`, `2`, `3`, ...) + - Numbered steps (`1.1`, `1.2`, ...) + - Each step follows the required step format below +8. Test command matrix: + - Ordered command list from targeted suites to full verification +9. Risks and mitigations: + - At least one mitigation per non-trivial risk +10. Approval checklist: + - Explicit owner approvals/assumptions to unblock execution + +Optional sections (for updating an existing active plan, not mandatory on first draft): +- Implementation status snapshot (step status table) +- Execution updates (post-plan follow-up steps) ## Planning Workflow -1. Read SPEC completely and extract AC + test requirements. -2. Read relevant standards (`testing.md`, `coding-styles.md`, architecture/data docs). -3. Inspect repository structure and identify actual files likely to change. -4. For API work, resolve target routes/capabilities from OpenAPI (or document if unavailable). -5. Group work into dependency-safe phases and explicit parallel streams. -6. Split phases into concrete steps with file ownership. -7. Add validation steps (tests, lint/type checks, OpenAPI/doc checks, manual sign-off where needed). -8. Write PLAN file to `.zazz/deliverables/`. -9. Update `.zazz/deliverables/index.yaml` only when canonical plan target changes. +1. Read SPEC completely and extract acceptance criteria, constraints, and test obligations. +2. Read relevant standards (`testing.md`, `coding-styles.md`, architecture/data docs); keep only actionable constraints. +3. Audit repository reality (routes, services, schemas, tests, docs) and record evidence-backed findings. +4. For API work, resolve target capabilities from OpenAPI. If unavailable, state this explicitly in the plan. +5. Define contract deltas and behavior requirements before decomposition. +6. Partition work into dependency-safe phases and named parallel streams. +7. Decompose phases into concrete steps with file ownership and explicit dependency edges. +8. Add validation plan (targeted tests, full tests, lint/type checks, manual sign-off where required). +9. Write PLAN file to `.zazz/deliverables/`. +10. Update `.zazz/deliverables/index.yaml` only when canonical plan target changes. ## Decomposition Rules -1. **File-first decomposition**: every step lists affected files. -2. **No same-file parallelism**: if steps touch same file(s), they must be sequential via `DEPENDS_ON`. -3. **Test-first planning**: every AC must map to one or more test activities. -4. **TDD gates per step**: include both tests-to-write-first and tests-to-run-to-complete. -5. **Small, finishable steps**: avoid oversized tasks; each step has a clear completion signal. -6. **No circular dependencies**. -7. **Reality over assumptions**: do not cite files/tests that do not exist without marking them as new files. +1. **File-first**: every step lists affected files. +2. **No same-file parallelism**: shared-file steps MUST be sequenced with `DEPENDS_ON`. +3. **AC coverage required**: every AC maps to one or more test activities. +4. **TDD gates required**: each step includes tests-to-write-first and tests-to-run-for-completion. +5. **Small and finishable**: each step has a clear completion signal. +6. **No dependency cycles**. +7. **Reality over assumptions**: mark non-existent files/tests as new. +8. **No fake completion**: do not mark steps completed in a new draft unless explicitly asked. ## Step Format (Use for every step) -For each numbered step (`1.1`, `1.2`, ...), include: +Every step (`1.1`, `1.2`, ...) MUST include: - Objective - Files affected - Deliverables/output @@ -94,32 +117,41 @@ For each numbered step (`1.1`, `1.2`, ...), include: - Acceptance criteria mapped - Completion signal +## Dependency Edge Sync Requirement (Zazz Task Graph) +When the plan is instantiated as Zazz tasks: +- Each non-`none` `DEPENDS_ON` must map to explicit `TASK_RELATIONS` edges (`relation_type = DEPENDS_ON`). +- Do not rely on task-create payload `dependencies` alone for graph correctness. +- Include an edge-validation gate command when requested by Owner/Coordinator (typically a `psql` query against `TASK_RELATIONS`). + ## Parallelization Guidance - Maximize concurrency across disjoint files/subsystems. - Call out merge points where parallel streams converge. - Serialize around high-conflict files (route registries, schema barrels, shared configs). -- Prefer planning streams such as: +- Prefer stream decomposition: - API route stream - data/schema stream - client/UI stream - tests/docs stream ## Warp-Specific Planning Capabilities (When Available) -Use Warp capabilities to improve decomposition quality: +Use available Warp capabilities to improve decomposition quality: - Semantic code search for likely impact areas - Exact symbol search for routes/functions - TODO decomposition for task sequencing - Native planning/doc tools for structured phase generation ## Quality Bar -A PLAN is complete only if it: +A PLAN is complete only if all conditions below are true: - Uses correct `-PLAN.md` naming derived from SPEC - Includes project/deliverable identifiers (including numeric deliverable ID) - Uses phased numbering (`1`, `2`, `3`) and step numbering (`1.1`, `1.2`) +- Includes scope guardrails and repository-verified current state +- Includes contract delta table when interfaces/routes/data contracts change - Includes development + testing + validation work - Includes AC traceability and test traceability - Explicitly documents dependencies and parallelizable groups - Includes concrete commands for required verification runs +- Includes risks/mitigations and owner approval checkpoints for non-trivial work - Avoids speculative routes/files and aligns to repository reality ## Environment Variables diff --git a/.agents/skills/worker-agent/SKILL.md b/.agents/skills/worker-agent/SKILL.md index 66715fee..444fdd7e 100644 --- a/.agents/skills/worker-agent/SKILL.md +++ b/.agents/skills/worker-agent/SKILL.md @@ -1,173 +1,225 @@ # Worker Agent Skill -**Role**: Executes tasks with test-driven development (TDD), creates and runs tests, commits changes +## Mission +Execute an approved deliverable PLAN from start to finish, including: +- just-in-time task realization from plan steps +- dependency relation realization +- implementation with TDD +- status lifecycle management -**Agents Using This Skill**: Workers (2-3 per deliverable) +This role is implementation-first and orchestration-capable. -**TDD emphasis**: Zazz requires well-defined test requirements and acceptance criteria for every task. Create tests before or alongside code; no task is complete until all specified tests pass. +## First Rule: Use Built-In Execution Optimizations +If the active agent/model supports built-in execution optimizations (multi-agent teams, subagents, structured planning/task tools), you MUST use them. --- -## System Prompt +## Mandatory Companion Skill -You are a Worker Agent for the Zazz multi-agent deliverable framework. Your role is to: +For any API interaction, you MUST load and follow: +- `.agents/skills/zazz-board-api/SKILL.md` -1. **Execute Tasks**: Poll for available tasks and execute them precisely -2. **Test-Driven Development (TDD)**: Every task has test requirements. Create tests before or alongside code; all specified tests must pass before task completion. If it can't be tested, it isn't well-specified—ask Coordinator. -3. **Respect Constraints**: Acquire file locks before editing; respect task dependencies -4. **Ask Questions**: If a task prompt is ambiguous, ask the Coordinator immediately (terminal-first in MVP), then sync to task notes/comments. When Slack is supported, communicate with the Deliverable Owner through the Coordinator—do not use Slack directly. -5. **Commit Atomically**: Commit all changes for a task together with clear commit message -6. **Report Status**: Update task status and heartbeat regularly -7. **Understand Context**: Reference the Deliverable SPEC to understand what's being built +Live OpenAPI is the route contract source of truth. --- -## MVP Interaction Mode (Terminal-First) +## Required Inputs -During MVP: -1. Receive clarifications and task direction primarily through terminal interaction. -2. When decisions are made in terminal interaction, post concise summaries to task notes/comments. -3. Use Zazz Board API updates where available, but do not block execution on API availability if terminal instructions are clear. +Before execution, you MUST have: +1. Project code +2. Deliverable code +3. Deliverable ID (integer) +4. Approved SPEC path +5. Approved PLAN path + +If any required input is missing, stop and ask the Owner. --- -## Phase 2: Task Execution +## Core Operating Policy + +You MUST execute in this order: + +1. Read SPEC and PLAN fully. +2. Build a step map from PLAN (`phase.step` IDs, dependencies, parallel groups). +3. Validate that the PLAN explicitly identifies parallelizable tasks/steps. + - If not explicit, pause and ask Owner/Planner for clarification before parallel execution. +4. Compute the dependency-ready set. +5. For each ready task you are about to execute, ensure its board task exists (create/reconcile just in time). +6. Ensure all required `DEPENDS_ON` edges for that task exist before starting. +7. Before moving `READY -> IN_PROGRESS`, acquire required file locks via the lock API. +8. If lock acquire conflicts, set `isBlocked=true` with `blockedReason='FILE_LOCK'`, poll every 3 seconds, and retry acquire until success. +9. When locks are acquired, clear block flags and execute with TDD. +10. Update workflow statuses continuously (`READY`, `IN_PROGRESS`, `COMPLETED`) and keep `isBlocked`/`blockedReason` truthful. +11. If course correction/rework appears after completion, add new follow-up tasks + relations to the graph; do not reopen completed tasks. +12. Recompute which tasks are now dependency-ready and repeat. +13. Stop only when every task in the current deliverable graph is either: + - `COMPLETED`, or + - blocked via task flags (`isBlocked=true`) with explicit reason `OWNER_DECISION` or `FILE_LOCK`. + +Do not implement tasks that violate dependency ordering. -**Task Polling Loop**: -``` -Every 15 seconds: - 1. Check terminal instructions for assigned work (MVP) and/or tasks with status "TO_DO" and satisfied dependencies - 2. Check file locks - if any file you need is locked, wait - 3. If task found: - - Acquire locks for all files you'll modify - - Update task status to "IN_PROGRESS" - - Read task prompt (Goal, Instructions, AC, Test Requirements, project standards) -``` +--- + +## Just-In-Time Board Sync (Required) + +Do not preload all PLAN tasks onto the board at once. + +Execution model: +1. Add tasks as the plan is executed. +2. Before starting a task, ensure it exists on the board and is dependency-ready. +3. Maintain relations and statuses in real time while implementation proceeds. +4. When scope changes, append new tasks to the graph instead of rewriting completed history. +5. If PLAN wording changes mid-execution, do not bulk rewrite the board; only add/update what is needed for the next executable work. +6. Blocking is a task property (`isBlocked` + `blockedReason`), not a workflow status column. -**Task Execution Workflow**: - -### Code Task -1. Read task instructions and acceptance criteria -2. Create unit test cases based on AC -3. Write code to implement requirements -4. Run tests until all pass -5. Ensure no console errors or warnings -6. Check code against .zazz/standards/ conventions - -### Test Creation Task -1. Read test requirements from task prompt -2. Create API test suite OR E2E test suite as specified -3. Define test cases covering all scenarios -4. Write test code (no execution yet) -5. Commit test code - -### Test Execution Task -1. Read task prompt identifying which tests to run -2. Run test suite (unit/API/E2E) -3. Capture all test output and results -4. If any tests fail, analyze failure and create issue description -5. Document test evidence (pass/fail counts, timing) -6. Commit test results +This keeps board state aligned with actual execution and avoids plan/backlog drift. --- -## File Locking & Commits +## Board API Responsibilities -**Task-level locks:** Locks are tied to the task, not the worker. When you signal ready for QA, locks transfer to the task and are held until QA signs off. This prevents other tasks from editing the same files while your task is in QA or rework. +When executing a deliverable, you are responsible for board state integrity: -**Before Editing Any File**: -1. Acquire lock for your task via `.zazz/agent-locks.json` (within the shared worktree) -2. If lock already held by another task (in QA or rework), mark your task as blocked and wait—blocked status shows with yellow outline on both task card and task node -3. Wait up to timeout (default 10 min); if timeout expires, notify Coordinator via task comment +1. **Task creation/sync** + - Create/reconcile tasks just in time for dependency-ready work. + - Reuse existing matching tasks; do not duplicate. +2. **Relation creation/sync** + - Create explicit task relations for each required `DEPENDS_ON` edge before work starts. +3. **Status management** + - Keep workflow status current as execution advances (`READY`, `IN_PROGRESS`, `COMPLETED`). + - Keep block state current via `isBlocked` + `blockedReason`. +4. **Execution notes** + - Record blockers, clarifications, and major decisions in task notes/comments. +5. **Course-correction graph updates** + - Add new tasks + relations for rework discovered during execution. + - Keep completed tasks completed; do not move backward by reopening done work. +6. **Completion signal** + - Signal deliverable completion only when all tasks in the current deliverable graph are complete. -**When Ready for QA** (order of operations: implement → run tests → commit → turn over to QA): -1. Ensure all tests pass -2. **Commit** to work tree with format: `TASK-{id}: {description} [{agent_id}]` (commit stamp before turning over) - - Example: `TASK-42: Add JWT validation to auth handler [worker_1]` -3. Transfer locks to the task (locks stay held until QA signs off; do not release) -4. Update task status to "QA" (or equivalent: ready for QA verification) -5. Update heartbeat via Zazz Board API (pub/sub) -6. **You are released**—you may immediately pick up the next available task. The task is not complete until QA signs off; if rework is needed, any worker (including you) may pick it up via the rework task card. +If API write operations are unavailable, pause and request Owner direction instead of silently diverging from board truth. --- -## Asking Questions +## File Lock API (Required) + +Before changing any task from `READY` to `IN_PROGRESS`, the worker MUST lock intended files via API. + +Routes: +1. `POST /projects/:code/deliverables/:delivId/locks/acquire` +2. `POST /projects/:code/deliverables/:delivId/locks/heartbeat` +3. `POST /projects/:code/deliverables/:delivId/locks/release` +4. `GET /projects/:code/deliverables/:delivId/locks` + +Lock workflow: +1. Determine the file list before work starts. +2. Attempt `acquire` for the full file list (atomic batch). +3. If `409 FILE_LOCK_CONFLICT`: + - keep workflow status unchanged + - set `isBlocked=true`, `blockedReason='FILE_LOCK'` + - poll every 3 seconds and retry `acquire` +4. On successful acquire: + - set `isBlocked=false` and clear `blockedReason` + - move task to `IN_PROGRESS` +5. While working, send periodic `heartbeat`. +6. On completion or handoff, `release` locks. -**If Task Prompt is Ambiguous**: -1. Ask Coordinator via terminal interaction (MVP) -2. Example: "Task 42: Should JWT validation happen in middleware or in the route handler? AC doesn't specify." -3. Sync the question and answer to task notes/comments -4. Wait for Coordinator response -5. If waiting >5 minutes, escalate to `BLOCKED` status -6. Once answered, resume work +--- + +## Parallel Execution Policy + +If subagents/teams are supported, parallelization is required. + +### Parallelization algorithm +1. Compute the ready set (dependencies satisfied). +2. From ready tasks, select tasks with no overlapping file ownership. +3. Spawn subagents for as many safe tasks as possible. +4. Assign one task per subagent. +5. Track and merge outputs; update board statuses for each task. +6. Recompute ready set and repeat. -**If You Encounter Blocker During Work**: -1. Don't try to work around it - ask Coordinator -2. Post detailed blocker context in terminal interaction -3. Sync blocker summary to task notes/comments -4. If waiting >5 minutes, escalate to `BLOCKED` status -5. Update task status to "BLOCKED" -6. Wait for response +If subagents are not supported, execute the same dependency order in single-agent mode. + +Do not run tasks in parallel when they overlap on locked/conflicting files. --- -## Test Requirements Understanding +## TDD and Completion Policy -**Task prompt will specify test type(s)**: -- **Unit**: Tests for individual functions/methods -- **API**: HTTP integration tests -- **E2E**: End-to-end workflow tests -- **Performance**: Load/stress tests (if specified in SPEC) -- **Security**: Security scanning (if specified in SPEC) +For every task: +1. Derive tests directly from acceptance criteria. +2. Use a TDD loop (default): `RED -> GREEN -> REFACTOR`. +3. Start by writing/updating a failing test that proves the requirement gap. +4. Implement the minimal code change to make the test pass. +5. Refactor safely while keeping tests green. +6. Run required test suite(s) for the task scope. +7. Do not mark task complete until required tests pass. -**Your responsibility**: Create and run all specified test types before marking task complete. TDD is non-negotiable—no task is done without passing tests. +If a task cannot be tested, escalate to the Owner as under-specified. --- -## Key Responsibilities +## Clarification and Decision Gates (Owner Escalation) + +You MUST ask the Owner when: +- instructions are unclear +- requirements are underdefined +- constraints conflict +- multiple materially different implementations are possible and SPEC/PLAN does not decide + +When escalating, include: +1. exact ambiguity +2. options considered +3. tradeoffs +4. your recommended option -- [ ] Poll for available tasks every 15 seconds -- [ ] Acquire file locks before editing -- [ ] Read task prompt completely before starting -- [ ] Create tests for all AC (test-first or test-alongside) -- [ ] Run tests until all pass -- [ ] Ask questions if prompt unclear -- [ ] Sync key terminal clarifications/blockers to task notes/comments -- [ ] Commit atomically with proper message format -- [ ] Transfer locks to task when ready for QA (locks held until QA signs off) -- [ ] Update task status and heartbeat -- [ ] Maintain fresh context (each task starts fresh) +Until clarified, keep workflow status unchanged, set `isBlocked=true` and `blockedReason='OWNER_DECISION'`, and do not guess. --- -## Best Practices +## Status Transition Rules -1. **Test First**: Consider writing tests before code -2. **Read AC Carefully**: Acceptance criteria defines what success looks like -3. **Project standards**: Follow patterns and conventions from .zazz/standards/ -4. **Ask Early**: Don't guess; ask Coordinator if unsure -5. **Atomic Commits**: One task = one commit (unless task specifies multiple commits) -6. **Clear Commit Messages**: Include task ID and clear description -7. **Test Evidence**: Ensure test output is captured and available for QA +Use these state rules: +- `TO_DO` -> `READY` when the task is selected for an executable wave +- `READY` -> `IN_PROGRESS` only after required file locks are acquired +- On lock conflict: keep workflow status unchanged, set `isBlocked=true`, `blockedReason='FILE_LOCK'`, poll every 3 seconds +- On owner decision wait: keep workflow status unchanged, set `isBlocked=true`, `blockedReason='OWNER_DECISION'` +- When blocker resolves: set `isBlocked=false`, clear `blockedReason`, then continue normal status flow +- `IN_PROGRESS` -> `COMPLETED` only after required tests pass + +Status changes must match real execution state. --- -## Environment Variables Required +## Communication Rules + +1. Prefer concise, factual updates. +2. Log blockers and decisions in task notes/comments. +3. Ask early; do not accumulate ambiguous assumptions. +4. Keep owner-facing questions actionable and decision-oriented. + +--- + +## Quality Bar + +Execution is complete only when all are true: +1. Every executed dependency-ready step has a board task before implementation starts. +2. Every required `DEPENDS_ON` edge exists as a task relation before dependent work begins. +3. Course-correction work is represented as additional graph tasks (not reopened completed tasks). +4. All tasks are either `COMPLETED` or explicitly blocked via `isBlocked=true` with Owner-visible rationale. +5. Required tests for completed tasks pass. +6. Deliverable behavior matches SPEC acceptance criteria. + +--- + +## Environment Variables ```bash export ZAZZ_API_BASE_URL="http://localhost:3000" export ZAZZ_API_TOKEN="your-api-token" -export AGENT_ID="worker_1|worker_2|worker_3" +export AGENT_ID="worker" export ZAZZ_WORKSPACE="/path/to/project" export ZAZZ_STATE_DIR="${ZAZZ_WORKSPACE}/.zazz" export AGENT_POLL_INTERVAL_SEC=15 export AGENT_HEARTBEAT_INTERVAL_SEC=10 ``` - ---- - -## Example Workflow - -See `.agents/skills/worker-agent/examples/` for: -- example-task-execution.md - Sample task execution walkthrough -- example-commit.txt - Sample commit message format diff --git a/.zazz/deliverables/ZAZZ-7-worker-file-locking-PLAN.md b/.zazz/deliverables/ZAZZ-7-worker-file-locking-PLAN.md new file mode 100644 index 00000000..5c4fbffc --- /dev/null +++ b/.zazz/deliverables/ZAZZ-7-worker-file-locking-PLAN.md @@ -0,0 +1,135 @@ +# ZAZZ-7 Worker File Locking PLAN + +SPEC Reference: `.zazz/deliverables/ZAZZ-7-worker-file-locking-SPEC.md` +Status: Execution Plan +Date: 2026-03-07 + +## 1) Implementation Strategy + +Deliver Phase 1 end-to-end in this order: + +1. DB schema + service layer +2. API route plugin + validation/OpenAPI schemas +3. Tests +4. Worker skill updates + +## 2) Phase Breakdown + +### Phase 1.1 — Data Model + +Objective: Add `FILE_LOCKS` table to the Drizzle schema. + +Changes: + +- `api/lib/db/schema.js` + - add `FILE_LOCKS` with: + - `id` serial PK + - `project_id` FK -> `PROJECTS.id` + - `deliverable_id` FK -> `DELIVERABLES.id` + - `task_id` FK -> `TASKS.id` + - `phase_step` varchar(20) nullable + - `agent_name` varchar(100) not null + - `file_path` varchar(1000) not null + - `acquired_at`, `heartbeat_at`, `lease_expires_at` + - `created_by`, `updated_by`, `updated_at` + - unique constraint `(deliverable_id, file_path)` + - indexes for `(deliverable_id, lease_expires_at)` and `(task_id)` + +Acceptance: + +- Table is generated via `drizzle-kit push --force`. + +### Phase 1.2 — Database Service Lock Operations + +Objective: Implement lock lease operations in `DatabaseService`. + +Changes: + +- `api/src/services/databaseService.js` + - add methods: + - `listActiveFileLocks({ projectId, deliverableId })` + - `acquireFileLocks({ projectId, deliverableId, taskId, phaseStep, agentName, filePaths, ttlSeconds, userId })` + - `heartbeatFileLocks({ projectId, deliverableId, taskId, agentName, filePaths, ttlSeconds, userId })` + - `releaseFileLocks({ projectId, deliverableId, taskId, agentName, filePaths })` + - internal expiry reclaim helper used by all methods + +Behavior rules: + +- reclaim expired rows (`lease_expires_at <= now`) at start of operations +- acquire is atomic per request +- conflict detection ignores locks owned by same `(taskId, agentName)` + +Acceptance: + +- Methods return deterministic payloads for success/conflict. + +### Phase 1.3 — Routes + OpenAPI + +Objective: Add project+deliverable lock endpoints. + +Changes: + +- `api/src/routes/fileLocks.js` (new) + - `GET /projects/:code/deliverables/:delivId/locks` + - `POST /projects/:code/deliverables/:delivId/locks/acquire` + - `POST /projects/:code/deliverables/:delivId/locks/heartbeat` + - `POST /projects/:code/deliverables/:delivId/locks/release` +- `api/src/routes/index.js` + - register `fileLocksRoutes` +- `api/src/schemas/fileLocks.js` (new) + - request/response schemas with summaries and descriptions +- `api/src/schemas/index.js` + `api/src/schemas/validation.js` + - export `fileLockSchemas` + +Acceptance: + +- OpenAPI contains all 4 routes with request/response shapes. + +### Phase 1.4 — Tests + +Objective: Validate lock lifecycle and conflict behavior. + +Changes: + +- `api/__tests__/routes/file-locks.test.mjs` (new) + - auth required + - acquire success + - conflict (`409 FILE_LOCK_CONFLICT`) + - release then acquire succeeds for other task + - expiry reclaim (short TTL) +- `api/__tests__/helpers/testDatabase.js` + - clear `FILE_LOCKS` in `clearTaskData` +- `api/__tests__/routes/openapi.test.mjs` + - assert lock routes are documented + +Acceptance: + +- Targeted route tests pass. + +### Phase 1.5 — Worker Skill Update + +Objective: Align worker behavior with lock API. + +Changes: + +- `.agents/skills/worker-agent/SKILL.md` + - before `READY -> IN_PROGRESS`, worker must acquire file locks via API + - on conflict, set `isBlocked=true` and `blockedReason='FILE_LOCK'` (not workflow status) + - poll every 3 seconds and retry acquire + - on acquire success, clear block flags and continue execution + +Acceptance: + +- Skill text explicitly defines lock API workflow and polling interval. + +## 3) Verification Commands + +```bash +cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/file-locks.test.mjs __tests__/routes/openapi.test.mjs +``` + +## 4) Post-Phase-1 Follow-up (Not in this execution) + +1. Add Redis cache for lock read throughput. +2. Add pub/sub + SSE push notifications for lock release events. +3. Keep PostgreSQL as canonical lock state. diff --git a/.zazz/deliverables/ZAZZ-7-worker-file-locking-SPEC.md b/.zazz/deliverables/ZAZZ-7-worker-file-locking-SPEC.md new file mode 100644 index 00000000..bd114bbc --- /dev/null +++ b/.zazz/deliverables/ZAZZ-7-worker-file-locking-SPEC.md @@ -0,0 +1,155 @@ +# ZAZZ-7 Worker File Locking SPEC (Draft) + +Status: Draft +Date: 2026-03-07 +Owner: Zazz Framework + +## 1) Objective + +Provide a language-agnostic file-lock mechanism for worker/sub-agent coordination. + +Source of truth for active locks must be the Zazz Board API + PostgreSQL (not local filesystem). + +This enables workers in any stack (Node, Python, Go, Java, etc.) to use the same lock contract. + +## 2) Scope + +### In scope (Phase 1) + +1. Add DB table for file-lock leases. +2. Add project+deliverable-scoped API routes for lock lifecycle: + - acquire (batch, atomic) + - heartbeat + - release + - list active locks +3. Add OpenAPI/Swagger schemas for all routes. +4. Add API tests for core lock behavior. +5. Update worker-agent skill to require lock API usage before moving a task from `READY` to `IN_PROGRESS`. +6. Worker behavior when lock conflict occurs: + - set task `isBlocked=true` with `blockedReason='FILE_LOCK'` (workflow status stays in its column) + - poll lock API every 3 seconds until files are available + +### Out of scope (Phase 2 / future) + +1. Redis caching for lock reads. +2. Redis/pub-sub or SSE lock-release push notifications. +3. Distributed queue/webhook orchestration. + +## 3) Problem Statement + +Local lock files are not portable across agent vendors and runtimes. + +A framework-level coordination primitive must be API-first and runtime-neutral. + +## 4) Functional Requirements + +### FR-1: Lock data model + +The system must persist lock records with at least: + +- projectId +- deliverableId +- taskId +- phaseStep (optional) +- agentName +- filePath +- acquiredAt +- heartbeatAt +- leaseExpiresAt + +### FR-2: Batch atomic acquire + +`acquire` accepts multiple file paths. + +Behavior: + +1. Reclaim expired locks first. +2. If any requested file is actively locked by another owner, acquire fails entirely. +3. If no conflicts, all requested locks are acquired/refreshed in one operation. + +### FR-3: Conflict response + +On conflict, API returns lock-owner detail for each blocked file. + +### FR-4: Heartbeat + +Heartbeat extends lease expiry for an owner/task lock set. + +### FR-5: Release + +Release removes locks for the owner/task (optionally scoped to provided file paths). + +### FR-6: Active lock listing + +List endpoint returns only active locks after expiry cleanup. + +### FR-7: Polling strategy + +If acquire conflicts, worker polls list/acquire every 3 seconds until available. + +## 5) Non-Functional Requirements + +1. Contract must be language-agnostic over HTTP. +2. Lock logic must be deterministic and race-safe enough for MVP transaction semantics. +3. Expiry reclamation must prevent dead locks when agents crash. + +## 6) API Contract (Phase 1) + +All routes are under deliverable scope: + +- `GET /projects/:code/deliverables/:delivId/locks` +- `POST /projects/:code/deliverables/:delivId/locks/acquire` +- `POST /projects/:code/deliverables/:delivId/locks/heartbeat` +- `POST /projects/:code/deliverables/:delivId/locks/release` + +### Acquire request body + +- `taskId` (number, required) +- `phaseStep` (string, optional) +- `agentName` (string, required) +- `filePaths` (string[], required, min 1) +- `ttlSeconds` (number, optional; default 30) + +### Conflict semantics + +Acquire returns `409` with: + +- `error: "FILE_LOCK_CONFLICT"` +- `conflicts: [{ filePath, taskId, agentName, phaseStep, leaseExpiresAt }]` + +### Polling guidance + +On `409`, worker should: + +1. set task `isBlocked=true` and `blockedReason='FILE_LOCK'` (do not use `BLOCKED` as workflow status) +2. poll every 3 seconds +3. retry acquire until success +4. clear `isBlocked`, clear `blockedReason`, then move to `IN_PROGRESS` + +## 7) Data Model (Phase 1) + +New table `FILE_LOCKS`. + +Constraints: + +1. One active lock row per deliverable + file path. +2. Foreign keys to project, deliverable, task. +3. Lease timestamps are required for stale-lock cleanup. + +## 8) Acceptance Criteria + +1. Lock routes are documented in OpenAPI and available in Swagger. +2. Acquire succeeds for unlocked file set. +3. Acquire returns `409 FILE_LOCK_CONFLICT` when another task/agent holds any requested file. +4. Heartbeat extends lease expiry. +5. Release removes locks and unblocks subsequent acquire. +6. Expired locks are reclaimed automatically on lock operations. +7. Worker skill explicitly mandates lock acquire before `READY -> IN_PROGRESS` and 3-second polling on lock conflict. + +## 9) Future Evolution (Phase 2) + +After Phase 1 stabilizes: + +1. Add Redis cache for hot lock reads. +2. Add pub/sub (and optionally SSE fan-out) so workers can react to lock release events instead of polling. +3. Keep PostgreSQL as source of truth. diff --git a/.zazz/deliverables/index.yaml b/.zazz/deliverables/index.yaml index 99f7047f..b4f1c506 100644 --- a/.zazz/deliverables/index.yaml +++ b/.zazz/deliverables/index.yaml @@ -13,3 +13,7 @@ deliverables: - id: ZAZZ-6 name: multiple-agent-tokens-feature spec: ZAZZ-6-multiple-agent-tokens-feature-SPEC.md + - id: ZAZZ-7 + name: worker-file-locking + spec: ZAZZ-7-worker-file-locking-SPEC.md + plan: ZAZZ-7-worker-file-locking-PLAN.md diff --git a/api/__tests__/helpers/testDatabase.js b/api/__tests__/helpers/testDatabase.js index b43b8d22..5437a119 100644 --- a/api/__tests__/helpers/testDatabase.js +++ b/api/__tests__/helpers/testDatabase.js @@ -1,5 +1,5 @@ import { db } from '../../lib/db/index.js'; -import { USERS, PROJECTS, DELIVERABLES, TASKS, TAGS, TASK_TAGS, TASK_RELATIONS, IMAGE_METADATA, IMAGE_DATA } from '../../lib/db/schema.js'; +import { USERS, PROJECTS, DELIVERABLES, TASKS, TAGS, TASK_TAGS, TASK_RELATIONS, FILE_LOCKS, IMAGE_METADATA, IMAGE_DATA } from '../../lib/db/schema.js'; import { eq, and, sql } from 'drizzle-orm'; /** @@ -62,6 +62,7 @@ export async function clearTaskData() { // Explicitly clear image tables so image route tests remain isolated. await db.delete(IMAGE_DATA); await db.delete(IMAGE_METADATA); + await db.delete(FILE_LOCKS); await db.delete(TASK_RELATIONS); await db.delete(TASK_TAGS); await db.delete(TASKS); diff --git a/api/__tests__/routes/file-locks.test.mjs b/api/__tests__/routes/file-locks.test.mjs new file mode 100644 index 00000000..095a4549 --- /dev/null +++ b/api/__tests__/routes/file-locks.test.mjs @@ -0,0 +1,197 @@ +import * as pactum from 'pactum'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { clearTaskData, createTestDeliverable, createTestTask, resetProjectDefaults } from '../helpers/testDatabase.js'; + +const { spec } = pactum; +const VALID_TOKEN = '550e8400-e29b-41d4-a716-446655440000'; + +describe('File lock routes', () => { + beforeEach(async () => { + await clearTaskData(); + await resetProjectDefaults(); + }); + + it('requires authentication', async () => { + const deliverable = await createTestDeliverable(1, { name: 'Locks auth deliverable' }); + await spec() + .get(`/projects/ZAZZ/deliverables/${deliverable.id}/locks`) + .expectStatus(401); + }); + + it('acquires and lists file locks for a task', async () => { + const deliverable = await createTestDeliverable(1, { name: 'Acquire lock deliverable' }); + const task = await createTestTask(1, { + deliverableId: deliverable.id, + title: 'Lock owner task', + phaseStep: '1.1', + }); + + const acquireResult = await spec() + .post(`/projects/ZAZZ/deliverables/${deliverable.id}/locks/acquire`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .withJson({ + taskId: task.id, + phaseStep: '1.1', + agentName: 'worker-1', + filePaths: ['api/src/routes/projects.js', 'api/src/services/databaseService.js'], + ttlSeconds: 30, + }) + .expectStatus(200) + .returns('res.body'); + + expect(acquireResult.acquired).toBe(true); + expect(acquireResult.locks).toHaveLength(2); + + const listed = await spec() + .get(`/projects/ZAZZ/deliverables/${deliverable.id}/locks`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(200) + .returns('res.body'); + + expect(listed.lockCount).toBe(2); + expect(listed.locks[0].deliverableId).toBe(deliverable.id); + }); + + it('returns FILE_LOCK_CONFLICT when another owner holds a requested file', async () => { + const deliverable = await createTestDeliverable(1, { name: 'Conflict deliverable' }); + const ownerTask = await createTestTask(1, { + deliverableId: deliverable.id, + title: 'Owner task', + phaseStep: '1.1', + }); + const blockedTask = await createTestTask(1, { + deliverableId: deliverable.id, + title: 'Blocked task', + phaseStep: '1.2', + }); + + await spec() + .post(`/projects/ZAZZ/deliverables/${deliverable.id}/locks/acquire`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .withJson({ + taskId: ownerTask.id, + phaseStep: '1.1', + agentName: 'worker-1', + filePaths: ['api/src/routes/projects.js'], + ttlSeconds: 30, + }) + .expectStatus(200); + + const conflict = await spec() + .post(`/projects/ZAZZ/deliverables/${deliverable.id}/locks/acquire`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .withJson({ + taskId: blockedTask.id, + phaseStep: '1.2', + agentName: 'worker-2', + filePaths: ['api/src/routes/projects.js', 'api/src/routes/taskGraph.js'], + ttlSeconds: 30, + }) + .expectStatus(409) + .returns('res.body'); + + expect(conflict.error).toBe('FILE_LOCK_CONFLICT'); + expect(Array.isArray(conflict.conflicts)).toBe(true); + expect(conflict.conflicts[0].filePath).toBe('api/src/routes/projects.js'); + expect(conflict.pollIntervalSeconds).toBe(3); + }); + + it('releases locks and allows another task to acquire them', async () => { + const deliverable = await createTestDeliverable(1, { name: 'Release deliverable' }); + const ownerTask = await createTestTask(1, { + deliverableId: deliverable.id, + title: 'Owner task', + phaseStep: '2.1', + }); + const nextTask = await createTestTask(1, { + deliverableId: deliverable.id, + title: 'Next task', + phaseStep: '2.2', + }); + + await spec() + .post(`/projects/ZAZZ/deliverables/${deliverable.id}/locks/acquire`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .withJson({ + taskId: ownerTask.id, + phaseStep: '2.1', + agentName: 'worker-1', + filePaths: ['client/src/App.jsx'], + ttlSeconds: 30, + }) + .expectStatus(200); + + const release = await spec() + .post(`/projects/ZAZZ/deliverables/${deliverable.id}/locks/release`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .withJson({ + taskId: ownerTask.id, + agentName: 'worker-1', + }) + .expectStatus(200) + .returns('res.body'); + + expect(release.releasedCount).toBe(1); + + const reacquire = await spec() + .post(`/projects/ZAZZ/deliverables/${deliverable.id}/locks/acquire`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .withJson({ + taskId: nextTask.id, + phaseStep: '2.2', + agentName: 'worker-2', + filePaths: ['client/src/App.jsx'], + ttlSeconds: 30, + }) + .expectStatus(200) + .returns('res.body'); + + expect(reacquire.acquired).toBe(true); + expect(reacquire.locks).toHaveLength(1); + expect(reacquire.locks[0].taskId).toBe(nextTask.id); + }); + + it('reclaims expired locks before acquire', async () => { + const deliverable = await createTestDeliverable(1, { name: 'Expiry deliverable' }); + const firstTask = await createTestTask(1, { + deliverableId: deliverable.id, + title: 'First lock owner', + phaseStep: '3.1', + }); + const secondTask = await createTestTask(1, { + deliverableId: deliverable.id, + title: 'Second lock owner', + phaseStep: '3.2', + }); + + await spec() + .post(`/projects/ZAZZ/deliverables/${deliverable.id}/locks/acquire`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .withJson({ + taskId: firstTask.id, + phaseStep: '3.1', + agentName: 'worker-1', + filePaths: ['api/src/routes/images.js'], + ttlSeconds: 5, + }) + .expectStatus(200); + + await sleep(5500); + + const result = await spec() + .post(`/projects/ZAZZ/deliverables/${deliverable.id}/locks/acquire`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .withJson({ + taskId: secondTask.id, + phaseStep: '3.2', + agentName: 'worker-2', + filePaths: ['api/src/routes/images.js'], + ttlSeconds: 30, + }) + .expectStatus(200) + .returns('res.body'); + + expect(result.acquired).toBe(true); + expect(result.locks[0].taskId).toBe(secondTask.id); + }); +}); diff --git a/api/__tests__/routes/openapi.test.mjs b/api/__tests__/routes/openapi.test.mjs index c10e0ca8..f1594299 100644 --- a/api/__tests__/routes/openapi.test.mjs +++ b/api/__tests__/routes/openapi.test.mjs @@ -88,6 +88,27 @@ describe('OpenAPI / Swagger documentation', () => { expect(path.patch.requestBody?.content?.['application/json']?.schema?.properties?.status).toBeDefined(); }); + it('should document core agent operations: file locks', async () => { + const spec = await app.swagger(); + + const listPath = spec.paths['/projects/{code}/deliverables/{delivId}/locks']; + expect(listPath).toBeDefined(); + expect(listPath.get).toBeDefined(); + + const acquirePath = spec.paths['/projects/{code}/deliverables/{delivId}/locks/acquire']; + expect(acquirePath).toBeDefined(); + expect(acquirePath.post).toBeDefined(); + expect(acquirePath.post.requestBody?.content?.['application/json']?.schema?.properties?.filePaths).toBeDefined(); + + const heartbeatPath = spec.paths['/projects/{code}/deliverables/{delivId}/locks/heartbeat']; + expect(heartbeatPath).toBeDefined(); + expect(heartbeatPath.post).toBeDefined(); + + const releasePath = spec.paths['/projects/{code}/deliverables/{delivId}/locks/release']; + expect(releasePath).toBeDefined(); + expect(releasePath.post).toBeDefined(); + }); + it('should document key paths with tags and summaries', async () => { const spec = await app.swagger(); const keyPaths = [ @@ -107,6 +128,10 @@ describe('OpenAPI / Swagger documentation', () => { '/projects/{code}/images/{id}/metadata', '/projects/{code}/tasks/{taskId}/relations', '/projects/{code}/tasks/{taskId}/readiness', + '/projects/{code}/deliverables/{delivId}/locks', + '/projects/{code}/deliverables/{delivId}/locks/acquire', + '/projects/{code}/deliverables/{delivId}/locks/heartbeat', + '/projects/{code}/deliverables/{delivId}/locks/release', '/health' ]; for (const p of keyPaths) { diff --git a/api/lib/db/schema.js b/api/lib/db/schema.js index 5e4093f5..fe3c8d7e 100644 --- a/api/lib/db/schema.js +++ b/api/lib/db/schema.js @@ -179,6 +179,27 @@ export const TASK_RELATIONS = pgTable('TASK_RELATIONS', { index('idx_task_relations_related_task_id').on(table.related_task_id), ]); +// File locks table - lease-based file ownership for worker/sub-agent coordination +export const FILE_LOCKS = pgTable('FILE_LOCKS', { + id: serial('id').primaryKey(), + project_id: integer('project_id').notNull().references(() => PROJECTS.id, { onDelete: 'cascade' }), + deliverable_id: integer('deliverable_id').notNull().references(() => DELIVERABLES.id, { onDelete: 'cascade' }), + task_id: integer('task_id').notNull().references(() => TASKS.id, { onDelete: 'cascade' }), + phase_step: varchar('phase_step', { length: 20 }), + agent_name: varchar('agent_name', { length: 100 }).notNull(), + file_path: varchar('file_path', { length: 1000 }).notNull(), + acquired_at: timestamp('acquired_at', { withTimezone: true }).defaultNow().notNull(), + heartbeat_at: timestamp('heartbeat_at', { withTimezone: true }).defaultNow().notNull(), + lease_expires_at: timestamp('lease_expires_at', { withTimezone: true }).notNull(), + created_by: integer('created_by').references(() => USERS.id, { onDelete: 'set null' }), + updated_by: integer('updated_by').references(() => USERS.id, { onDelete: 'set null' }), + updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => [ + unique('uq_file_locks_deliverable_file').on(table.deliverable_id, table.file_path), + index('idx_file_locks_deliv_expiry').on(table.deliverable_id, table.lease_expires_at), + index('idx_file_locks_task_id').on(table.task_id), +]); + // Image metadata table export const IMAGE_METADATA = pgTable('IMAGE_METADATA', { id: serial('id').primaryKey(), @@ -231,6 +252,7 @@ export const deliverablesRelations = relations(DELIVERABLES, ({ one, many }) => references: [USERS.id], }), tasks: many(TASKS), + fileLocks: many(FILE_LOCKS), images: many(IMAGE_METADATA), })); @@ -244,11 +266,35 @@ export const tasksRelations = relations(TASKS, ({ one, many }) => ({ references: [DELIVERABLES.id], }), taskTags: many(TASK_TAGS), + fileLocks: many(FILE_LOCKS), images: many(IMAGE_METADATA), relations: many(TASK_RELATIONS, { relationName: 'taskRelations' }), relatedRelations: many(TASK_RELATIONS, { relationName: 'relatedTaskRelations' }), })); +export const fileLocksRelations = relations(FILE_LOCKS, ({ one }) => ({ + project: one(PROJECTS, { + fields: [FILE_LOCKS.project_id], + references: [PROJECTS.id], + }), + deliverable: one(DELIVERABLES, { + fields: [FILE_LOCKS.deliverable_id], + references: [DELIVERABLES.id], + }), + task: one(TASKS, { + fields: [FILE_LOCKS.task_id], + references: [TASKS.id], + }), + createdByUser: one(USERS, { + fields: [FILE_LOCKS.created_by], + references: [USERS.id], + }), + updatedByUser: one(USERS, { + fields: [FILE_LOCKS.updated_by], + references: [USERS.id], + }), +})); + export const taskRelationsRelations = relations(TASK_RELATIONS, ({ one }) => ({ task: one(TASKS, { fields: [TASK_RELATIONS.task_id], diff --git a/api/scripts/reset-and-seed.js b/api/scripts/reset-and-seed.js index 211ccf30..01c413b8 100644 --- a/api/scripts/reset-and-seed.js +++ b/api/scripts/reset-and-seed.js @@ -23,6 +23,7 @@ async function resetAndSeed() { await db.execute(sql`DROP TABLE IF EXISTS "IMAGE_DATA" CASCADE`); await db.execute(sql`DROP TABLE IF EXISTS "IMAGE_METADATA" CASCADE`); await db.execute(sql`DROP TABLE IF EXISTS "TASK_RELATIONS" CASCADE`); + await db.execute(sql`DROP TABLE IF EXISTS "FILE_LOCKS" CASCADE`); await db.execute(sql`DROP TABLE IF EXISTS "TASK_TAGS" CASCADE`); await db.execute(sql`DROP TABLE IF EXISTS "TASKS" CASCADE`); await db.execute(sql`DROP TABLE IF EXISTS "DELIVERABLES" CASCADE`); diff --git a/api/src/routes/fileLocks.js b/api/src/routes/fileLocks.js new file mode 100644 index 00000000..b80150e7 --- /dev/null +++ b/api/src/routes/fileLocks.js @@ -0,0 +1,179 @@ +import { authMiddleware } from '../middleware/authMiddleware.js'; +import { fileLockSchemas } from '../schemas/validation.js'; + +export default async function fileLockRoutes(fastify, options) { + const { dbService, realtimeService } = options; + + const publishEvent = (projectCode, payload) => { + if (!realtimeService) return; + realtimeService.publish(projectCode, payload); + }; + + const resolveScope = async (code, delivId) => { + const project = await dbService.getProjectByCode(code); + if (!project) { + const error = new Error('Project not found'); + error.statusCode = 404; + throw error; + } + + const deliverableId = parseInt(delivId, 10); + if (!Number.isFinite(deliverableId)) { + const error = new Error('Invalid deliverable id'); + error.statusCode = 400; + throw error; + } + + const deliverable = await dbService.getDeliverableById(deliverableId); + if (!deliverable || deliverable.projectId !== project.id) { + const error = new Error('Deliverable not found'); + error.statusCode = 404; + throw error; + } + + return { project, deliverable, deliverableId }; + }; + + const resolveErrorStatus = (error, fallback = 500) => { + if (error?.statusCode) return error.statusCode; + const message = String(error?.message || '').toLowerCase(); + if (message.includes('not found')) return 404; + if (message.includes('required') || message.includes('invalid')) return 400; + return fallback; + }; + + fastify.addHook('preHandler', authMiddleware); + + fastify.get('/projects/:code/deliverables/:delivId/locks', { + schema: fileLockSchemas.listLocks + }, async (request, reply) => { + try { + const { code, delivId } = request.params; + const { project, deliverableId } = await resolveScope(code, delivId); + const locks = await dbService.listActiveFileLocks({ + projectId: project.id, + deliverableId, + }); + reply.send({ + deliverableId, + projectCode: project.code, + lockCount: locks.length, + locks, + }); + } catch (error) { + request.log.error(error, 'Failed to list file locks'); + reply.code(resolveErrorStatus(error)).send({ error: error.message || 'Failed to list file locks' }); + } + }); + + fastify.post('/projects/:code/deliverables/:delivId/locks/acquire', { + schema: fileLockSchemas.acquireLocks + }, async (request, reply) => { + try { + const { code, delivId } = request.params; + const { project, deliverableId } = await resolveScope(code, delivId); + const { taskId, phaseStep, agentName, filePaths, ttlSeconds } = request.body; + + const result = await dbService.acquireFileLocks({ + projectId: project.id, + deliverableId, + taskId, + phaseStep: phaseStep || null, + agentName, + filePaths, + ttlSeconds, + userId: request.user?.id || null, + }); + + if (!result.acquired) { + return reply.code(409).send({ + error: 'FILE_LOCK_CONFLICT', + message: 'One or more files are currently locked by another task/agent', + pollIntervalSeconds: 3, + conflicts: result.conflicts, + }); + } + + publishEvent(project.code, { + type: 'file-lock', + eventType: 'file-lock.acquired', + deliverableId, + taskId, + phaseStep: phaseStep || null, + agentName, + filePaths, + }); + + reply.send(result); + } catch (error) { + request.log.error(error, 'Failed to acquire file locks'); + reply.code(resolveErrorStatus(error)).send({ error: error.message || 'Failed to acquire file locks' }); + } + }); + + fastify.post('/projects/:code/deliverables/:delivId/locks/heartbeat', { + schema: fileLockSchemas.heartbeatLocks + }, async (request, reply) => { + try { + const { code, delivId } = request.params; + const { project, deliverableId } = await resolveScope(code, delivId); + const { taskId, agentName, filePaths, ttlSeconds } = request.body; + + const result = await dbService.heartbeatFileLocks({ + projectId: project.id, + deliverableId, + taskId, + agentName, + filePaths: filePaths || [], + ttlSeconds, + userId: request.user?.id || null, + }); + + publishEvent(project.code, { + type: 'file-lock', + eventType: 'file-lock.heartbeat', + deliverableId, + taskId, + agentName, + filePaths: filePaths || [], + }); + + reply.send(result); + } catch (error) { + request.log.error(error, 'Failed to heartbeat file locks'); + reply.code(resolveErrorStatus(error)).send({ error: error.message || 'Failed to heartbeat file locks' }); + } + }); + + fastify.post('/projects/:code/deliverables/:delivId/locks/release', { + schema: fileLockSchemas.releaseLocks + }, async (request, reply) => { + try { + const { code, delivId } = request.params; + const { project, deliverableId } = await resolveScope(code, delivId); + const { taskId, agentName, filePaths } = request.body; + + const result = await dbService.releaseFileLocks({ + projectId: project.id, + deliverableId, + taskId, + agentName, + filePaths: filePaths || [], + }); + + publishEvent(project.code, { + type: 'file-lock', + eventType: 'file-lock.released', + deliverableId, + taskId, + agentName, + filePaths: filePaths || [], + }); + + reply.send(result); + } catch (error) { + request.log.error(error, 'Failed to release file locks'); + reply.code(resolveErrorStatus(error)).send({ error: error.message || 'Failed to release file locks' }); + } + }); +} diff --git a/api/src/routes/index.js b/api/src/routes/index.js index dd75551a..c7c9c505 100644 --- a/api/src/routes/index.js +++ b/api/src/routes/index.js @@ -13,6 +13,7 @@ import translationsRoutes from './translations.js'; import statusDefinitionsRoutes from './statusDefinitions.js'; import taskGraphRoutes from './taskGraph.js'; import deliverableRoutes from './deliverables.js'; +import fileLockRoutes from './fileLocks.js'; const dbService = new DatabaseService(); const realtimeService = new RealtimeService(); @@ -36,7 +37,7 @@ export default async function routes(fastify, options) { reply.send({ message: 'Zazz Board API', version: '1.0.0', - endpoints: ['/health', '/users', '/projects', '/deliverables', '/tasks', '/tags', '/projects/:code/images/:id', '/translations', '/status-definitions', '/coordination-types'] + endpoints: ['/health', '/users', '/projects', '/deliverables', '/tasks', '/tags', '/projects/:code/images/:id', '/projects/:code/deliverables/:delivId/locks', '/translations', '/status-definitions', '/coordination-types'] }); }); @@ -74,4 +75,5 @@ export default async function routes(fastify, options) { await fastify.register(statusDefinitionsRoutes, pluginOptions); await fastify.register(taskGraphRoutes, pluginOptions); await fastify.register(deliverableRoutes, pluginOptions); + await fastify.register(fileLockRoutes, pluginOptions); } diff --git a/api/src/schemas/fileLocks.js b/api/src/schemas/fileLocks.js new file mode 100644 index 00000000..5dd92d08 --- /dev/null +++ b/api/src/schemas/fileLocks.js @@ -0,0 +1,181 @@ +/** + * File lock route schemas. + */ + +const lockItemSchema = { + type: 'object', + properties: { + id: { type: 'integer', description: 'Lock row id.' }, + projectId: { type: 'integer', description: 'Project id.' }, + deliverableId: { type: 'integer', description: 'Deliverable id.' }, + taskId: { type: 'integer', description: 'Task id that owns the lock.' }, + phaseStep: { type: 'string', nullable: true, description: 'Plan step label (e.g. "2.3").' }, + agentName: { type: 'string', description: 'Worker/sub-agent name that owns the lock.' }, + filePath: { type: 'string', description: 'Repo-relative file path.' }, + acquiredAt: { type: 'string', format: 'date-time' }, + heartbeatAt: { type: 'string', format: 'date-time' }, + leaseExpiresAt: { type: 'string', format: 'date-time' }, + createdBy: { type: 'integer', nullable: true }, + updatedBy: { type: 'integer', nullable: true }, + updatedAt: { type: 'string', format: 'date-time' }, + } +}; + +const lockConflictSchema = { + type: 'object', + properties: { + filePath: { type: 'string' }, + taskId: { type: 'integer' }, + phaseStep: { type: 'string', nullable: true }, + agentName: { type: 'string' }, + leaseExpiresAt: { type: 'string', format: 'date-time' }, + } +}; + +const deliverableScopeParams = { + type: 'object', + required: ['code', 'delivId'], + properties: { + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, + delivId: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id.' }, + } +}; + +const acquireLikeBodyBase = { + type: 'object', + required: ['taskId', 'agentName', 'filePaths'], + properties: { + taskId: { type: 'integer', minimum: 1, description: 'Numeric task id that will own locks.' }, + phaseStep: { type: 'string', minLength: 1, maxLength: 20, description: 'Plan step ID (e.g. "3.2").' }, + agentName: { type: 'string', minLength: 1, maxLength: 100, description: 'Worker/sub-agent identifier.' }, + filePaths: { + type: 'array', + minItems: 1, + items: { type: 'string', minLength: 1, maxLength: 1000 }, + description: 'Repo-relative file paths to lock.' + }, + ttlSeconds: { + type: 'integer', + minimum: 5, + maximum: 300, + description: 'Lease TTL in seconds. Default 30.' + } + }, + additionalProperties: false +}; + +const heartbeatBody = { + type: 'object', + required: ['taskId', 'agentName'], + properties: { + taskId: { type: 'integer', minimum: 1, description: 'Numeric task id that owns locks.' }, + agentName: { type: 'string', minLength: 1, maxLength: 100, description: 'Worker/sub-agent identifier.' }, + filePaths: { + type: 'array', + items: { type: 'string', minLength: 1, maxLength: 1000 }, + description: 'Optional subset of file paths. If omitted, refreshes all lock rows for taskId+agentName in this deliverable.' + }, + ttlSeconds: { + type: 'integer', + minimum: 5, + maximum: 300, + description: 'Lease TTL in seconds. Default 30.' + } + }, + additionalProperties: false +}; + +export const fileLockSchemas = { + listLocks: { + tags: ['file-locks'], + summary: 'List active file locks', + description: 'Returns active file lock leases for a deliverable. Expired locks are reclaimed before response.', + params: deliverableScopeParams, + response: { + 200: { + type: 'object', + properties: { + deliverableId: { type: 'integer' }, + projectCode: { type: 'string' }, + lockCount: { type: 'integer' }, + locks: { type: 'array', items: lockItemSchema }, + } + } + } + }, + + acquireLocks: { + tags: ['file-locks'], + summary: 'Acquire file locks (atomic batch)', + description: 'Attempts to acquire all requested file locks as one batch. Returns 409 FILE_LOCK_CONFLICT when any file is owned by another task/agent. Workers should poll every 3 seconds and retry on conflict.', + params: deliverableScopeParams, + body: acquireLikeBodyBase, + response: { + 200: { + type: 'object', + properties: { + acquired: { type: 'boolean' }, + ttlSeconds: { type: 'integer' }, + locks: { type: 'array', items: lockItemSchema }, + } + }, + 409: { + type: 'object', + properties: { + error: { type: 'string', enum: ['FILE_LOCK_CONFLICT'] }, + message: { type: 'string' }, + pollIntervalSeconds: { type: 'integer' }, + conflicts: { type: 'array', items: lockConflictSchema }, + } + } + } + }, + + heartbeatLocks: { + tags: ['file-locks'], + summary: 'Refresh lock lease heartbeat', + description: 'Extends lease expiry for lock rows owned by taskId+agentName. If filePaths omitted, refreshes all locks for that owner in the deliverable.', + params: deliverableScopeParams, + body: heartbeatBody, + response: { + 200: { + type: 'object', + properties: { + refreshedCount: { type: 'integer' }, + ttlSeconds: { type: 'integer' }, + locks: { type: 'array', items: lockItemSchema }, + } + } + } + }, + + releaseLocks: { + tags: ['file-locks'], + summary: 'Release file locks', + description: 'Releases lock rows for taskId+agentName. If filePaths omitted, releases all locks owned by that owner in the deliverable.', + params: deliverableScopeParams, + body: { + type: 'object', + required: ['taskId', 'agentName'], + properties: { + taskId: { type: 'integer', minimum: 1, description: 'Numeric task id that owns locks.' }, + agentName: { type: 'string', minLength: 1, maxLength: 100, description: 'Worker/sub-agent identifier.' }, + filePaths: { + type: 'array', + items: { type: 'string', minLength: 1, maxLength: 1000 }, + description: 'Optional subset of file paths to release.' + } + }, + additionalProperties: false + }, + response: { + 200: { + type: 'object', + properties: { + releasedCount: { type: 'integer' }, + locks: { type: 'array', items: lockItemSchema }, + } + } + } + } +}; diff --git a/api/src/schemas/index.js b/api/src/schemas/index.js index a59df8f0..1d0b6eab 100644 --- a/api/src/schemas/index.js +++ b/api/src/schemas/index.js @@ -22,3 +22,4 @@ export { deliverableSchemas } from './deliverables.js'; export { userSchemas } from './users.js'; export { coreSchemas } from './core.js'; export { imageSchemas } from './images.js'; +export { fileLockSchemas } from './fileLocks.js'; diff --git a/api/src/schemas/validation.js b/api/src/schemas/validation.js index adc8e745..6d612f91 100644 --- a/api/src/schemas/validation.js +++ b/api/src/schemas/validation.js @@ -19,5 +19,6 @@ export { deliverableSchemas, userSchemas, coreSchemas, - imageSchemas + imageSchemas, + fileLockSchemas } from './index.js'; diff --git a/api/src/services/databaseService.js b/api/src/services/databaseService.js index 66c0458b..305a2413 100644 --- a/api/src/services/databaseService.js +++ b/api/src/services/databaseService.js @@ -1,6 +1,6 @@ import { eq, and, sql, desc, asc, like, or, inArray, ne } from 'drizzle-orm'; import { db } from '../../lib/db/index.js'; -import { USERS, PROJECTS, DELIVERABLES, TASKS, TAGS, TASK_TAGS, IMAGE_METADATA, IMAGE_DATA, STATUS_DEFINITIONS, TRANSLATIONS, TASK_RELATIONS, COORDINATION_TYPES } from '../../lib/db/schema.js'; +import { USERS, PROJECTS, DELIVERABLES, TASKS, TAGS, TASK_TAGS, IMAGE_METADATA, IMAGE_DATA, STATUS_DEFINITIONS, TRANSLATIONS, TASK_RELATIONS, COORDINATION_TYPES, FILE_LOCKS } from '../../lib/db/schema.js'; import { getRandomTagColor } from '../utils/tagColors.js'; import { keysToCamelCase } from '../utils/propertyMapper.js'; import { randomUUID } from 'crypto'; @@ -1438,6 +1438,264 @@ class DatabaseService { } : null; } + // ==================== FILE LOCK OPERATIONS ==================== + + normalizeLockFilePaths(filePaths = []) { + if (!Array.isArray(filePaths)) return []; + const normalized = filePaths + .map((value) => String(value || '').trim()) + .filter(Boolean); + return [...new Set(normalized)]; + } + + normalizeLockTtlSeconds(ttlSeconds) { + const parsed = Number.parseInt(ttlSeconds, 10); + if (!Number.isFinite(parsed)) return 30; + if (parsed < 5) return 5; + if (parsed > 300) return 300; + return parsed; + } + + mapFileLock(lock) { + return { + id: lock.id, + projectId: lock.project_id, + deliverableId: lock.deliverable_id, + taskId: lock.task_id, + phaseStep: lock.phase_step, + agentName: lock.agent_name, + filePath: lock.file_path, + acquiredAt: lock.acquired_at, + heartbeatAt: lock.heartbeat_at, + leaseExpiresAt: lock.lease_expires_at, + createdBy: lock.created_by, + updatedBy: lock.updated_by, + updatedAt: lock.updated_at, + }; + } + + async reclaimExpiredFileLocks(tx, deliverableId) { + return tx + .delete(FILE_LOCKS) + .where( + and( + eq(FILE_LOCKS.deliverable_id, deliverableId), + sql`${FILE_LOCKS.lease_expires_at} <= NOW()` + ) + ); + } + + async listActiveFileLocks({ projectId, deliverableId }) { + return db.transaction(async (tx) => { + await this.reclaimExpiredFileLocks(tx, deliverableId); + const rows = await tx + .select() + .from(FILE_LOCKS) + .where( + and( + eq(FILE_LOCKS.project_id, projectId), + eq(FILE_LOCKS.deliverable_id, deliverableId), + sql`${FILE_LOCKS.lease_expires_at} > NOW()` + ) + ) + .orderBy(asc(FILE_LOCKS.file_path)); + return rows.map((row) => this.mapFileLock(row)); + }); + } + + async acquireFileLocks({ projectId, deliverableId, taskId, phaseStep = null, agentName, filePaths, ttlSeconds = 30, userId = null }) { + const normalizedFilePaths = this.normalizeLockFilePaths(filePaths); + if (normalizedFilePaths.length === 0) { + throw new Error('filePaths is required and must contain at least one path'); + } + const normalizedAgentName = String(agentName || '').trim(); + if (!normalizedAgentName) { + throw new Error('agentName is required'); + } + const ttl = this.normalizeLockTtlSeconds(ttlSeconds); + + return db.transaction(async (tx) => { + await this.reclaimExpiredFileLocks(tx, deliverableId); + + const [task] = await tx.select({ + id: TASKS.id, + projectId: TASKS.project_id, + deliverableId: TASKS.deliverable_id, + }) + .from(TASKS) + .where(eq(TASKS.id, taskId)) + .limit(1); + + if (!task || task.projectId !== projectId || task.deliverableId !== deliverableId) { + throw new Error('Task not found in this project/deliverable'); + } + + const existingLocks = await tx + .select() + .from(FILE_LOCKS) + .where( + and( + eq(FILE_LOCKS.deliverable_id, deliverableId), + inArray(FILE_LOCKS.file_path, normalizedFilePaths), + sql`${FILE_LOCKS.lease_expires_at} > NOW()` + ) + ); + + const conflicts = existingLocks + .filter((lock) => !(lock.task_id === taskId && lock.agent_name === normalizedAgentName)) + .map((lock) => ({ + filePath: lock.file_path, + taskId: lock.task_id, + phaseStep: lock.phase_step, + agentName: lock.agent_name, + leaseExpiresAt: lock.lease_expires_at, + })); + + if (conflicts.length > 0) { + return { + acquired: false, + error: 'FILE_LOCK_CONFLICT', + conflicts, + pollIntervalSeconds: 3, + }; + } + + const now = new Date(); + const leaseExpiresAt = new Date(now.getTime() + ttl * 1000); + + for (const filePath of normalizedFilePaths) { + const existing = existingLocks.find((lock) => lock.file_path === filePath); + if (existing) { + await tx + .update(FILE_LOCKS) + .set({ + phase_step: phaseStep ?? existing.phase_step, + heartbeat_at: now, + lease_expires_at: leaseExpiresAt, + updated_by: userId, + updated_at: now, + }) + .where(eq(FILE_LOCKS.id, existing.id)); + } else { + await tx + .insert(FILE_LOCKS) + .values({ + project_id: projectId, + deliverable_id: deliverableId, + task_id: taskId, + phase_step: phaseStep, + agent_name: normalizedAgentName, + file_path: filePath, + acquired_at: now, + heartbeat_at: now, + lease_expires_at: leaseExpiresAt, + created_by: userId, + updated_by: userId, + updated_at: now, + }); + } + } + + const acquiredRows = await tx + .select() + .from(FILE_LOCKS) + .where( + and( + eq(FILE_LOCKS.project_id, projectId), + eq(FILE_LOCKS.deliverable_id, deliverableId), + eq(FILE_LOCKS.task_id, taskId), + eq(FILE_LOCKS.agent_name, normalizedAgentName), + inArray(FILE_LOCKS.file_path, normalizedFilePaths), + sql`${FILE_LOCKS.lease_expires_at} > NOW()` + ) + ) + .orderBy(asc(FILE_LOCKS.file_path)); + + return { + acquired: true, + locks: acquiredRows.map((row) => this.mapFileLock(row)), + ttlSeconds: ttl, + }; + }); + } + + async heartbeatFileLocks({ projectId, deliverableId, taskId, agentName, filePaths = [], ttlSeconds = 30, userId = null }) { + const normalizedAgentName = String(agentName || '').trim(); + if (!normalizedAgentName) { + throw new Error('agentName is required'); + } + const normalizedFilePaths = this.normalizeLockFilePaths(filePaths); + const ttl = this.normalizeLockTtlSeconds(ttlSeconds); + + return db.transaction(async (tx) => { + await this.reclaimExpiredFileLocks(tx, deliverableId); + + const now = new Date(); + const leaseExpiresAt = new Date(now.getTime() + ttl * 1000); + + const conditions = [ + eq(FILE_LOCKS.project_id, projectId), + eq(FILE_LOCKS.deliverable_id, deliverableId), + eq(FILE_LOCKS.task_id, taskId), + eq(FILE_LOCKS.agent_name, normalizedAgentName), + sql`${FILE_LOCKS.lease_expires_at} > NOW()`, + ]; + + if (normalizedFilePaths.length > 0) { + conditions.push(inArray(FILE_LOCKS.file_path, normalizedFilePaths)); + } + + const refreshedRows = await tx + .update(FILE_LOCKS) + .set({ + heartbeat_at: now, + lease_expires_at: leaseExpiresAt, + updated_by: userId, + updated_at: now, + }) + .where(and(...conditions)) + .returning(); + + return { + refreshedCount: refreshedRows.length, + ttlSeconds: ttl, + locks: refreshedRows.map((row) => this.mapFileLock(row)), + }; + }); + } + + async releaseFileLocks({ projectId, deliverableId, taskId, agentName, filePaths = [] }) { + const normalizedAgentName = String(agentName || '').trim(); + if (!normalizedAgentName) { + throw new Error('agentName is required'); + } + const normalizedFilePaths = this.normalizeLockFilePaths(filePaths); + + return db.transaction(async (tx) => { + await this.reclaimExpiredFileLocks(tx, deliverableId); + + const conditions = [ + eq(FILE_LOCKS.project_id, projectId), + eq(FILE_LOCKS.deliverable_id, deliverableId), + eq(FILE_LOCKS.task_id, taskId), + eq(FILE_LOCKS.agent_name, normalizedAgentName), + ]; + if (normalizedFilePaths.length > 0) { + conditions.push(inArray(FILE_LOCKS.file_path, normalizedFilePaths)); + } + + const releasedRows = await tx + .delete(FILE_LOCKS) + .where(and(...conditions)) + .returning(); + + return { + releasedCount: releasedRows.length, + locks: releasedRows.map((row) => this.mapFileLock(row)), + }; + }); + } + // ==================== UTILITY METHODS ==================== /** diff --git a/docs/ZAZZ-FRAMEWORK.md b/docs/ZAZZ-FRAMEWORK.md index d5754110..37905f15 100644 --- a/docs/ZAZZ-FRAMEWORK.md +++ b/docs/ZAZZ-FRAMEWORK.md @@ -1,598 +1,212 @@ # The Zazz Framework -An opinionated, spec-driven framework for delivering software from spec to ship with AI agents. It provides terminology, workflow, tools, and document management for coordinating deliverables from specification through implementation and review. +Zazz is an opinionated, spec-driven framework for building software deliverables with agents. ---- - -## What Is Zazz? - -**Zazz** is both a framework for multi-agent software development and a task/deliverable management application (Zazz Board). The framework provides shared **terminology**, **workflow**, **tools**, and **document management** that enable AI agents to work autonomously and collaboratively on software deliverables. - -In Zazz, a *project* is a software product or application built as a succession of deliverables. Greenfield projects typically start with a handful of deliverables in order to generate an MVP; as the product grows in size and complexity, subsequent deliverables become new features, enhancements to existing features, bug-fixes, or refactors. This progression shapes the mix of deliverable types over the product lifecycle. - -The core underpinning is a **kanban-like process** driven from specification documents and implementation plans, with **test-driven development (TDD)** embedded throughout. Work flows through defined stages—from specification through planning, implementation, verification, and review—with clear handoffs, explicit acceptance criteria, and test requirements at each step. +This document defines the **current implementation focus** and the **future capability roadmap**. --- -## Core Philosophy - -### Spec-Driven Development - -Zazz is built around **intent engineering**: specifications define the desired outcome or intent, not prescriptive rules for how to build. Agents determine the *how* within the guardrails defined in project standards; the spec defines the *what*. TDD verifies that the intent is satisfied—if the tests pass, the outcome is achieved. - -The framework prioritizes **clear specification** and **detailed planning** as the foundation for autonomous development. Requirements are captured before implementation begins; acceptance criteria are explicit and testable. This creates a source of truth that agents and humans can reference throughout the lifecycle. The overarching expectation is that the SPEC and PLAN, once approved, should not require changes during development. The framework nevertheless provides a **change mechanism** for flexibility when needed—discovery, Owner feedback, or iterative refinement (e.g., UI) may warrant updates. When changes occur: the Coordinator updates the PLAN and SPEC (on Owner's behalf for SPEC; Owner approves before commit) and commits each change with a descriptive message. Git history is the change log. When deviations accumulate beyond a reasonable threshold, the Deliverable Owner may choose to abandon the current worktree and create a new deliverable with a refined spec, treating the first iteration as a prototype or learning experience rather than continuing to iterate in place. - -**Testing is not an afterthought.** Test requirements (unit, API, E2E, performance, security) are woven throughout the workflow from specification to task completion. - -### Test-Driven Development (TDD) - -**TDD is a core differentiator of Zazz.** Unlike many spec-driven frameworks that treat testing as a separate phase or optional add-on, Zazz embeds test-driven development at every level. Every deliverable and every task must have well-defined test requirements and acceptance criteria before work begins. - -**Why TDD matters for agent-driven development:** - -- **Verifiable completion** — Agents and humans agree on "done" via tests. No ambiguity about whether a task or deliverable is complete. -- **Regression safety** — Tests run continuously; failures surface immediately. Rework and new work don't silently break existing behavior. -- **Spec alignment** — Acceptance criteria are testable by definition. If it can't be tested, it isn't well-specified. -- **Evidence for review** — QA and Deliverable Owner see test results, not just code. PRs include test evidence as proof of AC satisfaction. - -**TDD flows through the lifecycle:** - - -| Stage | TDD Requirement | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **SPEC** | Each acceptance criterion is testable. Test requirements (unit, API, E2E, performance, security) are identified. | -| **PLAN** | Each task has explicit test requirements: what tests to create, what tests to run. Test creation tasks may precede or accompany feature tasks. | -| **Task execution** | Workers create and run tests per task. No task is complete until its specified tests pass. | -| **QA** | Verification is test-driven. QA runs all specified tests, documents evidence. Fresh context per evaluation. Creates rework task content and messages Coordinator when AC is not met. | -| **Rework** | Rework tasks include the failing test that demonstrates the issue. Fix is verified when that test passes. | - +## Current Focus (Active Development Phase) -**Test types:** Unit (function/method), API (integration), E2E (user flows), performance (thresholds), security (scanning). The SPEC and PLAN specify which apply; project standards define tooling and patterns. +The active framework scope is intentionally strict: -**Where TDD lives: SPEC vs PLAN** +1. `spec-builder-agent` +2. `planner-agent` +3. `worker-agent` +4. `zazz-board-api` (required companion skill for all active agents) - -| Document | TDD content | Purpose | -| -------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **SPEC** | Acceptance criteria (testable statements of "done"); test requirements at deliverable level (unit, API, E2E, performance, security) | Defines *what* must be true for the deliverable to be complete. Every requirement should have at least one AC; every AC should be testable. Test requirements answer: "What tests will prove this deliverable is done?" | -| **PLAN** | Per-task AC (derived from SPEC); per-task test requirements (what tests to create, what tests to run) | Cascades SPEC down to executable tasks. Each task gets concrete test requirements—the Planner maps SPEC AC and test requirements to specific tasks. | - - -**Cascade:** SPEC defines deliverable-level AC and test requirements → Planner decomposes into PLAN with per-task AC and test requirements → Coordinator creates tasks and hands them out → Workers create/run tests per task → QA verifies via test evidence. The SPEC is the source; the PLAN operationalizes it. - -**TDD in both is required.** The framework expects TDD in both the SPEC and the PLAN. The SPEC establishes the testability contract (no requirement without AC, no AC without a way to test it). The PLAN makes it executable (each task knows exactly what tests to create and run). Without SPEC-level TDD, the PLAN has nothing to cascade. Without PLAN-level TDD, workers don't know what to build. - -**Owner sign-off for UI and subjective AC:** Some acceptance criteria, especially for user interface components (layout, visual design, interaction feel, accessibility), cannot be fully verified by automated tests. These AC require **Deliverable Owner interaction and sign-off**. Mark such AC in the SPEC (e.g., "Owner sign-off required") so the PLAN and task cards reflect that verification is human-led. QA coordinates with the Owner to obtain sign-off before marking the task or deliverable complete. When Owner feedback during UI iteration warrants changes to scope or tasks, the Coordinator adjusts the PLAN (and SPEC if needed, with Owner approval) and commits the change. - -### Opinionated by Design - -Zazz makes deliberate choices about how work is structured: - -- **Document ownership**—Each document has a designated owner. Changes are tracked via git commits; the Coordinator is the single editor for both PLAN and SPEC during execution. - - **Project standards** (.zazz/standards/) — Project Owner - - **Deliverable Specification** — Deliverable Owner owns; Coordinator edits on Owner's behalf; Owner approves each change before commit - - **Implementation Plan** — Planner creates the initial draft; Coordinator is the only actor that updates it during execution -- **Single writer per file**—enforced via task-level file locks to prevent concurrent edits -- **Explicit dependencies**—tasks declare DEPENDS_ON and COORDINATES_WITH; no circular dependencies. -- **Independent agent contexts**—each agent has its own system prompt and memory; no shared context window. -- **Worker context is per-task**—a worker agent's context is scoped to a single task. For each new task, the worker's context is cleared. Tasks can be sized as large or small as is reasonable for the LLM in use. -- **QA context is fresh per evaluation**—each time the QA agent evaluates a task (or the final deliverable), it starts with fresh context. Inputs are SPEC, PLAN, task card, and code. No accumulation across evaluations; standard context window is sufficient. -- **Escalate ambiguity**—agents never auto-retry unclear decisions; they ask or escalate to the Deliverable Owner. -- **No Blocked column**—Blocked is a *state*, not a column. A deliverable or task can be in a blocked state while remaining in its current column (e.g., In Progress). Never create a Blocked column on the board. - -These constraints reduce coordination overhead and make agent behavior predictable. - -### Human vs Agent Separation - -Zazz clearly separates human and agent responsibilities. The **Deliverable Owner** is the human actor (product owner, stakeholder, or user) who focuses on **deliverables**. All work is grouped under deliverables; the **Deliverable Board** displays them as the primary unit of work for humans. - -The Deliverable Owner owns the *what*: they define requirements, approve the SPEC and PLAN, perform final acceptance that the deliverable meets expectations, and conduct PR review and merge. For AC that require human judgment (e.g., UI components, visual design, interaction feel), the Owner provides **in-loop sign-off**—QA coordinates with the Owner to verify these AC before marking tasks or the deliverable complete. The *how* is defined in the project standards (.zazz/standards/), SPEC, and PLAN; agents execute it—planning (Planner), implementing (Workers), verifying (QA), and coordinating (Coordinator). This separation ensures humans make product and scope decisions while agents execute within approved boundaries. - -**Two separate kanban boards:** The Deliverable Board is for the Deliverable Owner and human users; the Task Board is for agents. Do not confuse the two—each has its own workflow, columns, and audience. +All implementation guidance in this document is optimized for these four capabilities only. --- -## Core Terminology - - -| Term | Definition | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Project standards** | Atomic, project-level standards in `.zazz/standards/`. Indexed by `index.yaml`. Covers architecture, testing, tooling, languages, database, coding styles, patterns, security, observability, and other project-wide conventions. Read-only reference for agents; SPEC and PLAN reference standards. | -| **project.md** | Project overview in `.zazz/project.md`. High-level description of the application or product being built. Provides context for agents and humans. | -| **Deliverable Specification (SPEC)** | Source-of-truth document defining requirements, acceptance criteria, and test requirements. Stored as `.zazz/deliverables/{deliverable-name}-SPEC.md`. Intended to be stable at approval; the Coordinator may edit on behalf of the Owner when scope changes; Owner approves each change before commit. Changes tracked via git. | -| **Implementation Plan (PLAN)** | Execution decomposition derived from the SPEC. Phases, steps, tasks, dependencies, file assignments, and test requirements. Stored as `.zazz/deliverables/{deliverable-name}-PLAN.md`. Intended to be complete at approval; the Coordinator is the only actor that updates it during execution. Each change is committed with a descriptive message; git history is the change log. | -| **Project Owner** | The human actor who owns the project and its technical standards. May be the same person as the Deliverable Owner in smaller projects. | -| **Deliverable Owner** | The human actor (product owner, stakeholder, or user) who owns deliverables. Defines requirements, approves SPEC and PLAN, performs final acceptance, and conducts PR review and merge. All work flows through the Deliverable Owner's lens. | -| **Project** | Top-level container for a software product. A project is a succession of deliverables. Holds `deliverable_status_workflow` (Deliverable Board columns) and `status_workflow` (Task Board columns). | -| **Deliverable** | A unit of work on the Deliverable Board: a feature, bug fix, enhancement, refactor, chore, or documentation. ID format: `{PROJECT_CODE}-{int}`. Each has an approved SPEC and PLAN. Greenfield projects typically start with a handful of deliverables in order to generate an MVP; as the product grows, deliverables become new features, enhancements, or refactors. | -| **Task** | A piece of work performed by an agent. Belongs to exactly one deliverable. Each task has two representations: a **task node** (on the Task Graph) and a **task card** (on the Task Board). They are the same task—one node corresponds to one card. | -| **Task Node** | A node on the Task Graph representing a task. Each task node corresponds to exactly one task card on the Task Board. | -| **Task Card** | A card on the Task Board representing a task. Contains the prompt (description, objectives, acceptance criteria, guidance) and references to project standards, SPEC, and PLAN. Each task card corresponds to exactly one task node on the Task Graph. | -| **Task Graph** | A visualization of tasks being worked on by agents. Nodes represent tasks; edges represent dependencies. The Coordinator creates tasks via the Zazz Board API per the PLAN (created by the Planner) and hands them out to workers. The graph can branch, merge, and include coordination-style dependencies (e.g., two tasks must complete before a dependent can start). | - - -### Document Ownership - - -| Document | Owner | Who may revise | -| ------------------------------------ | ----------------- | ---------------------------------------------------------------------------------------- | -| Project standards (.zazz/standards/) | Project Owner | Project Owner only | -| project.md | Project Owner | Project Owner only | -| SPEC | Deliverable Owner | Coordinator on Owner's behalf; Owner approves each change | -| PLAN | Planner creates | Coordinator only (single editor per deliverable) | +## What Zazz Is +Zazz combines: +- A delivery framework (SPEC → PLAN → implementation execution and agent skills) +- An opinionated set of required documents and processes for building software applications +- An observability and coordination platform (Zazz Board API + UI) -Task prompts reference project standards, SPEC, and PLAN. All documents reside in `.zazz/` and are accessible to agents. +Work is organized as: +- `Project -> Deliverable -> Task` -### Change Management (Git) - -PLAN and SPEC changes are managed via **git commits**. Each edit is committed with a descriptive message; git history serves as the change log. - -- **PLAN**: The **Coordinator is the only actor** that edits the PLAN. With one Coordinator per deliverable, there is a single source of edits—no merge conflicts. When the change mechanism is invoked (discovery, rework, scope adjustment), the Coordinator updates the PLAN and commits, e.g. `PLAN: Insert task 2.3, renumber phase 2` or `PLAN: Add rework task 2.3.1`. -- **SPEC**: The Coordinator may edit the SPEC **on behalf of the Deliverable Owner** when scope or requirements change during execution. The Owner cannot keep up with the pace of changes; the Coordinator proposes edits and the Owner **approves** each change. The Coordinator commits after approval, e.g. `SPEC: Clarify AC3 per Owner approval`. The Owner retains authority—no SPEC change is committed without approval. - -### Repository Structure (.zazz) - -The `.zazz` folder is installed in the root of repositories using the Zazz framework (similar to `.agents`). It organizes project standards, specs, plans, and runtime state: - -``` -.zazz/ -├── project.md # Project overview -├── standards/ # Atomic project standards -│ ├── index.yaml # Index of standard files (order, purpose) -│ ├── system-architecture.md # Example: stack, layering, cloud deployment -│ ├── testing.md # Example: test frameworks, tooling -│ ├── coding-styles.md # Example: conventions, patterns -│ └── ... # Other atomic standard files -├── deliverables/ # SPEC and PLAN files -│ ├── index.yaml # Index of deliverables (id, spec, plan, status) -│ ├── {name}-SPEC.md -│ └── {name}-PLAN.md -├── agent-locks.json # Task-level file locks (runtime) -├── audit.log # Timestamped event log (runtime) -└── api-spec.json # Cached OpenAPI spec (runtime) -``` - -### Project Standards (.zazz/standards/) - -Project standards are **atomic**—split into multiple files instead of one monolithic document. Each file covers a specific domain. The `index.yaml` file lists all standard files and their purpose: - -```yaml -# .zazz/standards/index.yaml -standards: - - file: system-architecture.md - purpose: System architecture, stack, layering, cloud deployment - - file: testing.md - purpose: Test frameworks, patterns, tooling - - file: coding-styles.md - purpose: Coding conventions, style guides, patterns - - file: data-architecture.md - purpose: Database design philosophy, schema-first, ORM, conventions - # ... other project-wide standards -``` - -Standard files may include: architecture, testing, tooling, languages, database, cloud/deployment, coding styles, patterns, coding conventions, security, observability, API design. Only the Project Owner may revise them. Agents consult the standards directory (via the index) to ensure deliverables align with project constraints. The SPEC and PLAN reference standards; task cards inherit this context. - -### Deliverables (.zazz/deliverables/) - -The `index.yaml` file lists all deliverables and maps each to its SPEC and PLAN files: - -```yaml -# .zazz/deliverables/index.yaml -deliverables: - - id: ZAZZ-1 - name: user-auth - spec: user-auth-SPEC.md - plan: user-auth-PLAN.md - # status optional; Zazz Board is source of truth for workflow state - - id: ZAZZ-2 - name: api-rate-limiting - spec: api-rate-limiting-SPEC.md - plan: api-rate-limiting-PLAN.md - # ... -``` - -Agents and tools use the index to discover deliverables and resolve file paths. The Zazz Board remains the source of truth for deliverable status (Planning, Ready, In Progress, etc.). - -### project.md - -`.zazz/project.md` provides a high-level overview of the application or product being built. It answers: What is this project? What problem does it solve? Who are the users? This context helps agents and humans understand the scope and purpose of deliverables. Only the Project Owner may revise it. - -### Task Prompt Template - -Each task card must include: - -- **Description** — What the task accomplishes -- **Objectives** — Clear goals for the worker -- **Guidance** — Any additional instructions beyond what exists in project standards and the PLAN -- **Test plan** — Test requirements with references to project standards (what tests to create, what tests to run) -- **Acceptance criteria** — Testable conditions for completion. Some AC (e.g., UI layout, visual design) may require Deliverable Owner sign-off; mark these explicitly so QA coordinates with the Owner. +Primary artifacts: +- SPEC: `.zazz/deliverables/{name}-SPEC.md` +- PLAN: `.zazz/deliverables/{name}-PLAN.md` --- -## Two Workflow Levels +## Core Process (Current Phase) -The framework operates at two distinct levels: +## Stage 0: SPEC Creation +Actors: +- Deliverable Owner +- `spec-builder-agent` -| Level | Led By | Focus | -| --------------------- | ----------------- | -------------------------------------------------------------------------- | -| **Deliverable-level** | Deliverable Owner | Define/approve SPEC and PLAN; final acceptance; PR review and merge. | -| **Task-level** | Agents | Execute tasks, coordinate dependencies, surface questions and escalations. | +Output: +- Approved SPEC with testable acceptance criteria and test requirements. +Rules: +- Requirements must be explicit and testable. +- If a requirement is ambiguous, refine before planning. -The Deliverable Owner owns the *what* and *when*; agents execute the *how* (defined in project standards, SPEC, and PLAN) within approved boundaries. - ---- +## Stage 1: Planning -## Two Kanban Boards +Actor: +- `planner-agent` -Zazz uses **two separate kanban boards**. Do not confuse them: +Output: +- Execution-ready PLAN with: + - phases and steps + - file assignments + - dependency edges + - parallelizable groups + - test command matrix +Rules: +- PLAN must be repository-grounded (no speculative files/routes). +- PLAN must call out which tasks can run in parallel. -| Board | Audience | Content | Data Model | -| --------------------- | --------------------------------- | ------------------------------------------------------ | ---------------------------------------- | -| **Deliverable Board** | Deliverable Owner and human users | Deliverables (features, bug fixes, enhancements, etc.) | `deliverable_status_workflow` on project | -| **Task Board** | Agents | Tasks associated with deliverables | `status_workflow` on project | +## Stage 2: Execution +Actor: +- `worker-agent` (single or multi-agent team mode depending on platform capability) -**Hierarchy:** `Project → Deliverable → Task`. A project is a succession of deliverables; every task belongs to exactly one deliverable. The two boards have **different flows** driven by different concerns: the Deliverable Board tracks human-led lifecycle (planning through merge); the Task Board tracks agent-led execution driven by the PLAN. Each board has its own configurable workflow (columns) stored at the project level. +Required companion: +- `zazz-board-api` skill -The Zazz Board also provides a **Task Graph**—a third visualization of the same tasks. Each **task node** on the graph corresponds to a **task card** on the Task Board. They are two views of the same underlying task. +Output: +- Completed deliverable implementation with task lifecycle tracked in Zazz Board. -### Shared Worktree +Rules: +- Worker reads SPEC + PLAN before implementation. +- Worker executes in TDD mode (tests required per task). +- Worker uses the board API during execution (not as a one-time preload) to keep task lifecycle and dependencies accurate. -Each deliverable has **one git worktree and branch**. All agents work against the same worktree for that deliverable—Workers, QA, and Coordinator all operate in the same branch. This shared worktree is a requirement and benefit: it keeps the deliverable's changes in one place and simplifies merging. The Planner's decomposition includes file assignments and parallel sequences to **minimize file conflicts** when tasks are executed and combined. - -**Pivot option:** When deviations to the spec accumulate significantly, the Deliverable Owner may choose to abandon or archive the current worktree (rather than delete it) and create a new deliverable with a refined spec and a fresh worktree. The first iteration is then treated as a prototype or learning experience. This approach leverages git's isolation to avoid compounding drift and provides a clean slate for a better-informed specification. - -**Order of operations (task and locks):** - -1. Worker acquires file locks, implements, runs tests, **commits** to the work tree (commit stamp), then signals "ready for QA." Files remain locked; worker is released. -2. QA evaluates results against acceptance criteria and tests. -3. If pass: QA marks task complete and **releases all locks** for that task. -4. If fail: QA creates rework content and messages Coordinator; locks stay (rework inherits them). +Implementation-phase board policy: +- Add tasks just in time: create/reconcile only the dependency-ready tasks that are about to be worked. +- Before a task starts, ensure it exists on the board, has required relations, and is `READY`. +- As work proceeds, transition workflow status truthfully (`READY`, `IN_PROGRESS`, `COMPLETED`) and keep `isBlocked`/`blockedReason` accurate. +- If course correction is needed after a completed task, add new follow-up tasks and relations to the task graph; do not reopen or rewrite completed tasks. +- If PLAN wording changes during execution, avoid full board resync; update only the next executable tasks and relations. +- Signal deliverable completion only when all tasks currently on the deliverable task graph are complete. --- -## Deliverable Board Workflow - -The **Deliverable Board** workflow is **intentionally flexible**. Each project configures its own columns by selecting and ordering statuses from instance-level definitions. The framework provides default statuses for teams that want a simple starting point. - -### Default Deliverable Statuses (Deliverable Board) - - -| Status | Description | -| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Planning** | Deliverable is in the planning phase; SPEC and PLAN are being created or refined. When the plan is approved, the deliverable automatically moves to Ready. | -| **Ready** | Plan has been approved. The Coordinator picks up deliverables from Ready, creates tasks from the PLAN via API, and hands them out to workers. | -| **In Progress** | Deliverable is being worked by agents based on the plan. A change mechanism exists for updates to the PLAN or SPEC when warranted. | -| **In Review** | Deliverable Owner performs final sign-off; PR created, awaiting review and merge. | -| **Stage** | Deliverable is merged to the stage branch. | -| **Done** | Deliverable is merged to the main branch. | - +## Worker Agent Responsibilities (Expanded) -**Deliverable types:** FEATURE, BUG_FIX, REFACTOR, ENHANCEMENT, CHORE, DOCUMENTATION. Each deliverable has a human-readable ID: `{PROJECT_CODE}-{int}` (e.g., `ZAZZ-1`). The mix evolves over the product lifecycle: greenfield work begins with a handful of deliverables to build an MVP; mature products see more enhancements and refactors. +The worker agent is not a narrow code-only role in the current phase. It is a delivery executor with orchestration responsibilities. -**Blocked is a state, not a column.** A deliverable or task can be marked blocked (e.g., via an `is_blocked` flag) while remaining in its current column. The item stays in place—it does not move to a Blocked column. **Never create a Blocked column** on either board. +Mandatory responsibilities: -**Workflow flexibility:** Projects can define custom workflows. For example, a release-pipeline workflow might use: Planning → In Progress → In Review → UAT → Staged → Prod (where Prod is the terminal state). +1. Read the deliverable PLAN and SPEC first. +2. Add and update board tasks incrementally while executing (no bulk upfront task creation). +3. Ensure `DEPENDS_ON` relationships exist for each task before that task starts. +4. Keep workflow statuses and block flags accurate while executing. +5. Implement each task with TDD and evidence. +6. Escalate ambiguity to the Owner before making unclear design decisions. +7. If multiple valid approaches exist, present options and request Owner direction. +8. When rework is discovered, extend the task graph with new tasks instead of rolling back completed tasks. -### Branching Model - -As part of the opinionated framework, the expectation is **two main Git branches** in the development process: **stage** and **main**. Deliverables flow from implementation through stage (integration/staging) and finally to main. The framework is flexible—organizations may introduce additional branches (e.g., UAT, release branches) as part of their overall development process. +The worker must not silently guess when requirements are underdefined. --- -## Task Board Workflow - -The **Task Board** is driven by the PLAN. Its status columns differ from the Deliverable Board because they reflect agent execution flow, not human lifecycle. +## Multi-Agent / Agent-Team Execution Policy -### Task Board Status Columns +If the runtime supports subagents or teams (for example Codex multi-agent or Claude teams), the worker agent must use that capability to maximize safe parallelism. +Policy: -| Status | Description | -| --------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| **TO_DO** | (Optional) Task defined but not yet ready for pickup. Used when the plan structure requires an explicit backlog before READY. | -| **READY** | Dependencies met; task is available for a worker agent to pick up. | -| **IN_PROGRESS** | Worker agent is actively performing the task. | -| **QA** | Work submitted; QA agent is validating against acceptance criteria. | -| **COMPLETED** | Task finished and verified. | +1. Identify all tasks that are dependency-ready. +2. From those, select tasks with non-overlapping file ownership. +3. Spawn subagents for as many safe parallel tasks as possible. +4. Keep status transitions synchronized in board state. +5. Reconcile outputs and continue to next ready task wave. - -The `TO_DO` column is optional—it depends on how the plan is structured. Some projects use `READY` as the first column; others use `TO_DO` → `READY` when tasks are promoted automatically once dependencies are satisfied. +If the runtime does not support subagents, execute the same dependency order in single-agent mode. --- -## Task Graph - -The **Task Graph** is a visualization of the workflow of tasks as they are started, worked on, QA'd, and completed. Each **task node** on the graph corresponds to exactly one **task card** on the Task Board—they represent the same task in different views. - -### Task Node and Task Card Styling - -Status is indicated by outline color on both the **task node** (Task Graph) and **task card** (Task Board): - -- **Green outline** — Task in progress -- **Yellow outline** — Task blocked (e.g., file locked by another task in QA or rework) -- **Grey or shaded** — Task completed - -### Coordinator Creates and Hands Out Tasks +## Zazz Board API Requirement -Once the plan is approved and execution starts, the Coordinator creates tasks via the Zazz Board API per the PLAN (produced by the Planner). Tasks appear on the Task Graph and Task Board. The typical starting status for a new task is `READY` (or `TO_DO` if the project uses that column). The Coordinator hands out individual tasks to workers and adds follow-on tasks progressively as prerequisites complete. When the change mechanism is invoked (Owner feedback, discovery), the Coordinator adjusts tasks, updates the PLAN (and SPEC if needed, with Owner approval), and commits each change. +The `zazz-board-api` skill is mandatory for the active agent set. -### Graph Structure +All active agents must use API capabilities to: +- discover deliverables/tasks +- create/update tasks when required by plan execution +- create/update task relationships +- update task status as execution progresses +- capture task notes relevant to decisions, blockers, and clarifications -The Task Graph is not necessarily sequential. It can: - -- **Branch** — Multiple tasks may proceed in parallel from a single completed task -- **Merge** — Coordination-style dependencies where two or more tasks must complete before a dependent task can start (e.g., task C depends on both task A and task B) -- **Support multiple branches** — The graph can have several parallel branches, each associated with the plan for the deliverable. There is not necessarily a single linear path through the graph - -The graph structure reflects the PLAN: phases, steps, dependencies (DEPENDS_ON, COORDINATES_WITH), and rework tasks (e.g., task-1.2.1) appear as separate nodes. +OpenAPI is the source of truth for route resolution. --- -## The Kanban-Like Process (Agent Workflow) - -Work flows through five stages (0–4). Each stage has clear inputs, outputs, and handoffs. On the **Task Board**, tasks move through the status columns above. Task identification uses the format `task-{phase}.{step}` (e.g., `task-1.1`, `task-1.2`), where the first number is the plan phase or major component and the decimal is the step. Rework adds a third segment: `task-1.2.1` is the first rework of task 1.2. A task can be in a blocked *state* (e.g., `is_blocked` flag) while remaining in its current column—blocked is never a column. - -``` -Stage 0: SPEC Creation → Stage 1: Planning → Stage 2: Implementation - ↓ -Stage 4: PR & Review ← Stage 3: QA & Verification ←────┘ -``` - -### Stage 0: SPEC Creation - -**Deliverable Owner + spec-builder-agent** - -- Interactive questioning to clarify requirements -- Capture functional requirements, edge cases, constraints -- Define acceptance criteria (clear, testable) -- Identify test requirements (unit, API, E2E, performance, security) -- Output: `.zazz/deliverables/{deliverable-name}-SPEC.md` - -### Stage 1: Planning - -**Planner agent** (one-shot decomposition) - -- Decompose approved SPEC into manageable chunks, phased and sequenced -- Define per-task acceptance criteria and test requirements -- Assign files to tasks using file names and conventions; identify sequences where tasks can run in parallel without impacting the same files -- Plan to **minimize file conflicts**—when work is combined in the shared worktree, conflicts are rare -- Output: `.zazz/deliverables/{deliverable-name}-PLAN.md` -- **Trigger:** Invoked when the Owner requests a plan (e.g., after SPEC approval). Owner reviews and approves the PLAN; deliverable moves to Ready. - -**Coordinator agent** (takes over once execution starts) - -- Subscribes to plan approval events; when a deliverable moves to Ready, creates initial tasks via Zazz Board API per the PLAN -- Hands out individual tasks to workers; adds follow-on tasks progressively as prerequisites complete -- Adjusts the PLAN as required when the change mechanism is invoked; commits each change with a descriptive message - -### Stage 2: Implementation - -**Worker agents** - -- Watch the Task Board for tasks in `READY` status (or promoted from `TO_DO` to `READY` when dependencies are met) -- Read the task card: prompt, reference documentation (project standards, SPEC, PLAN) -- Perform the task: acquire file locks → implement → run tests → **commit** (commit stamp in work tree) → signal "ready for QA" -- Locks transfer to the task (files stay locked); worker is released immediately and may pick up the next task (context is cleared) -- May ask the Deliverable Owner clarifying questions via terminal; may work with the Owner to adjust requirements or the plan -- Any adjustments to requirements or plan must be noted in the task card notes; the Coordinator updates the PLAN to reflect reality. Requirements changes that affect the SPEC are made by the Coordinator on the Owner's behalf; the Owner approves each change before the Coordinator commits. -- Coordinator monitors for blockers, responds to questions, and keeps the PLAN document current - -**Note:** A task is not complete until QA signs off. Worker release is independent of task completion—workers are released when ready for QA to maximize throughput. - -### Stage 3: QA & Verification - -**QA agent** - -The QA agent is specifically designed to **find issues** and **validate acceptance criteria**. Its role is to rigorously test against the SPEC and PLAN, identify gaps, and ensure TDD and AC are satisfied. - -- **Fresh context per evaluation**—each task evaluation and the final deliverable review start with cleared context. Inputs are SPEC, PLAN, task card, and code. No context accumulation; standard context window suffices. -- Operates in a **separate agent context** from workers—validates work independently -- Actively seeks to find issues: run all tests, verify each AC, analyze code quality -- Verifies each task against acceptance criteria and tests (worker has already committed) -- **When all pass:** Marks task complete and **releases all file locks** for that task -- **When AC or TDD criteria are not met:** Create the rework task content (full context for a fresh worker) and message the Coordinator. Locks stay (rework inherits them). Include in the rework card: failing test, AC violated, reproduction steps, relevant files, expected vs actual behavior. Any available worker may pick up rework. -- May interact with the Deliverable Owner via terminal to validate rework options; updates task card notes and shares with the worker -- Iterates until all acceptance criteria and test requirements are satisfied -- QA or worker may notify the Deliverable Owner if the process becomes stalled - -**Once all tasks are complete for a deliverable, QA performs a final full review** (fresh context for this evaluation): - -- QA does a final full review and quality assurance of the deliverable as a whole before presenting to the Owner -- This phase checks functionality and other items across the entire deliverable -- If rework is required, QA creates the rework task content and messages the Coordinator. The rework task card must be self-contained so any worker can execute it without prior context. -- Once QA is satisfied, QA proceeds to Stage 4 - -**Coordinator** - -- Creates rework tasks when QA provides the task content; updates the PLAN and task graph. Rework tasks are unassigned—any available worker may pick them up. -- Works with QA during the final deliverable review to add rework tasks as needed -- Keeps the PLAN current when updates are warranted; commits each change with a descriptive message -- May update the SPEC on Owner's behalf when scope or requirements change; Owner approves each SPEC change before commit - -### Stage 4: PR & Review - -**QA agent** - -- Create PR with verification evidence (AC status, test results, rework history) -- Add the PR link to the deliverable card -- Move the deliverable into in-review status -- Ideally notify the Deliverable Owner that the deliverable is ready for review - -**Deliverable Owner** - -- Performs final acceptance, reviews PR, and merges - ---- - -## Tools and Infrastructure - -### Zazz Board +## Task Lifecycle Policy -The Zazz Board application provides **two kanban boards** and a **Task Graph**: +At minimum, worker execution must keep these states accurate: +- `TO_DO` (optional, project-dependent) +- `READY` +- `IN_PROGRESS` +- `COMPLETED` -**Deliverable Board** (for Deliverable Owner and human users): - -- Deliverable management (features, bug fixes, enhancements, refactors, chores) -- Configurable deliverable workflow (see [Deliverable Board Workflow](#deliverable-board-workflow) above) -- Deliverable statuses: Planning, Ready, In Progress, In Review, Stage, Done - -**Task Board** (for agents): - -- Tasks scoped to deliverables (`Project → Deliverable → Tasks`) -- **Task cards** — each card corresponds to a task node on the Task Graph -- Task status columns driven by the plan: `READY`, `IN_PROGRESS`, `QA`, `COMPLETED`; `TO_DO` optional before READY -- Task cards include prompt (description, objectives, AC, guidance) and references to project standards, SPEC, and PLAN -- Blocked is an `is_blocked` flag—never a column -- Task card notes for adjustments, clarifications, and audit trail -- API for agent orchestration (Swagger/OpenAPI); tasks under `/projects/:code/deliverables/:id/tasks` - -**Task Graph** (for Coordinator and visibility): - -- **Task nodes** — each node corresponds to a task card on the Task Board -- Visualizes workflow as tasks are started, worked on, QA'd, and completed -- MVP node styling: green outline (in progress), yellow outline (blocked), grey/shaded (completed) -- Coordinator creates tasks via API; new tasks typically start as `READY` -- Graph can branch, merge, and include coordination dependencies (multiple predecessors) -- Coordinator adds follow-on tasks progressively as tasks complete - -### Zazz Board Implementation - -The Zazz Board is the task coordination platform. Key implementation details: - -- **Deliverable Board** route: `/projects/:code/deliverable-kanban` -- **Task Board** route: `/projects/:code/kanban` -- **Task Graph** route: `/projects/:code/taskGraph` -- **Task scope:** Tasks are created and managed under deliverables: `POST/GET/PATCH /projects/:code/deliverables/:id/tasks` -- **Plan approval:** `PATCH /deliverables/:id/approve` sets `approved_by` and `approved_at`. Automatically moves the deliverable from Planning to Ready. A plan-approved event is published to Redis pub/sub. The Coordinator subscribes via the API and picks up plan approval messages to create tasks and begin execution. -- **Workflow config:** Projects have `deliverable_status_workflow` and `status_workflow` arrays. Status definitions are instance-level; projects select and order which statuses to use. -- **Agent pub/sub (MVP):** Redis pub/sub backend, exposed via Zazz Board API endpoints. Agents publish/subscribe for heartbeat, agent status, and agent-to-agent messaging. - -### Required API Skill - -All agents use the **zazz-board-api** rule skill to communicate and manage deliverables/tasks. This is non-optional—without it, agents cannot coordinate. - -### Local State (`.zazz/`) - -See [Repository Structure (.zazz)](#repository-structure-zazz) for the full directory layout. Runtime files: - - -| File | Purpose | -| ------------------ | ---------------------------------------------------------------------------------- | -| `agent-locks.json` | Task-level file locks (locks tied to task, not worker; released when QA signs off) | -| `audit.log` | Timestamped event log for debugging and compliance | -| `api-spec.json` | Cached OpenAPI spec from Zazz Board | - - -**Agent communication and shared state**: Redis pub/sub, exposed via Zazz Board API endpoints, provides direct and shared agent communication. Agents use this instead of local `agent-state.json` or `agent-messages.json` files. - -### Concurrency Control - -- **Shared worktree**: All agents work in the same git worktree/branch for the deliverable. -- **Task-level file locks**: Locks are tied to the **task** (and its rework chain), not the worker. Worker acquires locks, implements, commits, then signals "ready for QA"—files stay locked. Locks transfer to the task; QA releases them when marking the task complete. Rework tasks (e.g., 2.3.1) inherit the same locks as the original task (2.3). This prevents thrashing—no other task may edit those files while the original task is in QA or rework. -- **Planner decomposition**: The Planner designs the PLAN to minimize file overlap across parallel tasks—tasks that touch different files can run concurrently. The Planner uses DEPENDS_ON when (1) tasks must share files—the dependent task cannot start until the prerequisite is QA-approved and has released its locks—or (2) a task requires another's output before it can begin (e.g., the UI task depends on the backend API task). The Coordinator executes the PLAN. -- **Blocked display**: When a worker is blocked by a file locked by another task (in QA or rework), show blocked status clearly on both the **task card** (Task Board) and the **task node** (Task Graph)—the task node uses a yellow outline when blocked. -- **Lock timeout**: Expired locks can be reclaimed (e.g., after agent crash or task abandonment). - -### Worker Release and Rework - -**Order of operations:** - -1. **Worker:** Acquire locks → implement → run tests → **commit** → signal "ready for QA" → transfer locks to task → released. Files stay locked. -2. **QA:** Evaluate against AC and tests. -3. **If pass:** QA marks task complete and **releases all locks** for that task. -4. **If fail:** QA creates rework content, messages Coordinator; locks stay (rework inherits them). - -**Worker release:** A worker is released from a task as soon as they signal "ready for QA." The worker commits before turning over to QA, updates task status, and may immediately pick up the next available task. Files remain locked until QA signs off. - -**Task completion:** A task is not complete until QA signs off. QA releases the locks when marking the task complete. - -**Rework flow:** When QA finds that AC or TDD criteria are not met, QA creates the rework task content and messages the Coordinator. Locks stay with the task chain; rework inherits them. The rework card must be self-contained (failing test, AC violated, reproduction steps, relevant files, expected vs actual) because workers have cleared context. Any available worker may pick up rework. +Rules: +- Do not mark a task complete before required tests pass. +- Use task-level blocking flags (`isBlocked`, `blockedReason`) when waiting on Owner clarification/decision or file-lock contention. +- Preserve dependency integrity when advancing tasks. --- -## Agent Roles - +## Clarification and Decision Policy -| Role | Skill | Responsibility | -| ---------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Planner** | planner-agent | One-shot decomposition of SPEC into PLAN. Phases work, assigns files to tasks, identifies parallel sequences that avoid file conflicts. Output: `.zazz/deliverables/{deliverable-name}-PLAN.md`. Invoked when Owner requests a plan; does not participate in execution. | -| **Coordinator** | coordinator-agent | Takes over once execution starts. Creates tasks from PLAN via API, hands out tasks to workers, manages task graph, responds to blockers, creates rework tasks from QA content. **Only the Coordinator edits the PLAN** during execution; commits each change with a descriptive message (git history is the change log). May edit the SPEC on behalf of the Deliverable Owner when scope or requirements change; Owner approves each SPEC change before the Coordinator commits. When Slack is supported, the Coordinator is the only agent with a Slack account; Worker and QA communications to the Owner flow through the Coordinator. | -| **Worker** | worker-agent | Implement tasks with TDD (code, tests, commits), respect locks and dependencies. Context is cleared between tasks. | -| **QA** | qa-agent | Find issues and validate acceptance criteria per task. Fresh context for each evaluation (task or final review). When AC or TDD criteria not met, creates rework content and messages Coordinator. Once all tasks complete: final full review of deliverable as a whole, create PR, add PR link to deliverable card, move deliverable to in-review, notify Owner. | -| **Spec Builder** | spec-builder-agent | Guide Deliverable Owner through interactive questioning to create comprehensive SPECs. Assists the Owner in revising the SPEC. | +Worker agent must pause and ask the Owner when: +- instructions are unclear +- requirements are incomplete +- constraints conflict +- more than one materially different solution is valid and plan/spec does not decide +When asking, include: +1. the exact ambiguity +2. concrete options +3. tradeoffs and recommended option -Each agent loads one role skill plus the required zazz-board-api rule. Agents have **independent context** and communicate via the Zazz Board API (including pub/sub for agent-to-agent messaging and shared state), task comments, and local lock/audit files. - -### MVP Execution Model - -For the MVP, the Planner runs when invoked (e.g., when the Owner requests a plan after SPEC approval)—a one-shot decomposition. The Coordinator, worker agents, and QA agents run in **separate terminals** during execution. Each agent is designated with an identifier such as `coordinator`, `worker-1`, `worker-2`, `worker-3`, `qa-1`, etc. The Coordinator subscribes to plan approval events, creates tasks from the PLAN, and monitors progress. Workers watch the Task Board for tasks that transition to `READY`, then read the task card (prompt, reference docs), perform the work, and update status. Workers and QA can interact with the Deliverable Owner via terminal for clarifications and adjustments. - -### Slack Communication (When Supported) - -When the framework supports Slack, the **Coordinator agent is the only agent with a Slack account**. Communication from the QA agent or Worker agent to the Deliverable Owner (via Slack) goes through the Coordinator. Workers and QA relay questions, escalations, and status updates to the Coordinator; the Coordinator posts to Slack and relays Owner responses back. This design minimizes the number of Slack accounts that need to be maintained. +Do not continue past decision gates without explicit clarification. --- -## Framework-Agnostic Design +## Future Capabilities (Not Current Active Scope) -The Zazz framework is **framework-agnostic**. Skills are markdown files with system prompts and instructions. They can be used with: +The following personas remain part of the framework roadmap but are not the current implementation focus: -- **Warp** (oz CLI) -- **Claude API** (Anthropic) -- **LangGraph** (LangChain) -- **CrewAI** -- **AutoGen** (Microsoft) -- **OpenAI Swarm** -- **Kimi** (Moonshot AI) -- Custom implementations +- `coordinator-agent` +- `qa-agent` +- additional specialized personas -See **FRAMEWORK-SETUP.md** for integration examples. +Current status: +- These personas are in **research/planning phase** for future framework expansion. +- Existing references to them should be treated as forward-looking architecture, not present-day required workflow. ---- - -## Key Principles (Summary) - -1. **Sequential workflow stages** — Each stage depends on the previous: SPEC Creation → Planning → Implementation → QA & Rework → PR & Review. The Planner runs once; the Coordinator orchestrates from plan approval through execution; Workers and QA run within their stages. -2. **Parallel within phase** — Workers execute tasks in parallel, respecting locks and dependencies -3. **Explicit dependencies** — No circular dependencies; all relations declared upfront -4. **Single writer per file** — Task-level file locks prevent concurrent edits; locks held until QA signs off -5. **Independent contexts** — Each agent has separate context; worker context is cleared between tasks; QA context is fresh per evaluation -6. **Document ownership** — Project standards (.zazz/standards/): Project Owner. project.md: Project Owner. SPEC: Deliverable Owner owns; Coordinator edits on Owner's behalf with approval. PLAN: Planner creates initial; Coordinator is the only editor during execution. Changes tracked via git commits. When deviations accumulate significantly, Owner may archive worktree and create new deliverable (pivot option). -7. **Explicit communication** — Questions and decisions logged -8. **No auto-retry** — Ambiguous situations escalated to Deliverable Owner unless there are explicit standards in place. -9. **No Blocked column** — Blocked is a state; items stay in their column when blocked -10. **Two separate boards** — Deliverable Board (humans) and Task Board (agents); different flows, different columns -11. **Task node = Task card** — Each task node on the Task Graph corresponds to exactly one task card on the Task Board; they are two views of the same task -12. **Centralized Slack account** — When Slack is supported, only the Coordinator agents have acess to a Slack account; Worker and QA communicate to the Owner through the Coordinator -13. **Shared worktree** — All agents work in the same git worktree/branch per deliverable; Planner decomposes with file assignments to minimize conflicts; Coordinator executes the PLAN -14. **Blocked = yellow** — When a worker is blocked by a file locked by another task (in QA or rework), show blocked status on both the task card and task node (yellow outline) -15. **Project as succession** — A project or software product is a succession of deliverables. Greenfield projects start with a handful of deliverables in order to generate an MVP; as the product grows, deliverables shift to new features, enhancements, and refactors. +When these personas are activated in a future phase, this document will be expanded with their production operating policies. --- -## Related Documentation - - -| Document | Purpose | -| ------------------------- | -------------------------------------------------------------------------------------- | -| **README.md** | Skill collection overview, quick start | -| **WORKFLOW-OVERVIEW.md** | Detailed stage-by-stage workflow | -| **AGENT-ARCHITECTURE.md** | Technical architecture, communication, concurrency, error handling | -| **FRAMEWORK-SETUP.md** | Integration with Warp, Claude, LangGraph, CrewAI, etc. | -| **.agents/skills/** | Individual skill definitions for each agent | -| **.zazz/** | Project structure in repos using the framework (project.md, standards/, deliverables/) | -| **TEMPLATES/** | Task prompt template, PR template, agent state file schemas | +## Key Principles (Current Phase Summary) +1. SPEC defines intent. +2. PLAN defines executable decomposition. +3. Worker executes PLAN and keeps board state truthful. +4. TDD is required for task completion. +5. Dependencies are explicit and enforced. +6. Parallelism is maximized safely (dependency + file-overlap aware). +7. Ambiguity is escalated to the Owner, not guessed. +8. API contract truth comes from live OpenAPI. +9. Task graph evolves during implementation; course corrections are added as new tasks. --- ## Version -**Version**: 1.0.0 -**Last Updated**: 2026-02-27 -**Status**: Draft (awaiting Zazz Board API completion) \ No newline at end of file +Version: `1.3.0` +Last Updated: `2026-03-07` +Status: `Active - Core Agent Phase (Spec Builder + Planner + Worker + Board API)` From e4c462c70ca4a5c31db5ed0e243f4611e37ad225 Mon Sep 17 00:00:00 2001 From: michaelwitz Date: Sat, 7 Mar 2026 18:33:54 -0500 Subject: [PATCH 02/15] feat(worker): refine API lock workflow and align filepath schema --- .agents/skills/worker-agent/SKILL.md | 4 +++- .agents/skills/zazz-board-api/SKILL.md | 32 +++++++++++++++++++------- api/lib/db/schema.js | 4 ++-- api/src/services/databaseService.js | 20 ++++++++-------- 4 files changed, 39 insertions(+), 21 deletions(-) diff --git a/.agents/skills/worker-agent/SKILL.md b/.agents/skills/worker-agent/SKILL.md index 444fdd7e..595b6931 100644 --- a/.agents/skills/worker-agent/SKILL.md +++ b/.agents/skills/worker-agent/SKILL.md @@ -41,7 +41,7 @@ If any required input is missing, stop and ask the Owner. You MUST execute in this order: 1. Read SPEC and PLAN fully. -2. Build a step map from PLAN (`phase.step` IDs, dependencies, parallel groups). +2. Build a step map from PLAN (`phaseStep` IDs, dependencies, parallel groups). 3. Validate that the PLAN explicitly identifies parallelizable tasks/steps. - If not explicit, pause and ask Owner/Planner for clarification before parallel execution. 4. Compute the dependency-ready set. @@ -104,6 +104,7 @@ If API write operations are unavailable, pause and request Owner direction inste ## File Lock API (Required) Before changing any task from `READY` to `IN_PROGRESS`, the worker MUST lock intended files via API. +Do not create or rely on local lock files in `.zazz`; lock ownership source of truth is the Board API. Routes: 1. `POST /projects/:code/deliverables/:delivId/locks/acquire` @@ -123,6 +124,7 @@ Lock workflow: - move task to `IN_PROGRESS` 5. While working, send periodic `heartbeat`. 6. On completion or handoff, `release` locks. +7. If worker process crashes/restarts, re-resolve task state from API and reacquire before resuming edits. --- diff --git a/.agents/skills/zazz-board-api/SKILL.md b/.agents/skills/zazz-board-api/SKILL.md index ea8245db..92b14441 100644 --- a/.agents/skills/zazz-board-api/SKILL.md +++ b/.agents/skills/zazz-board-api/SKILL.md @@ -46,6 +46,7 @@ Core capabilities: - Get deliverable graph - Create task relations (`DEPENDS_ON`, `COORDINATES_WITH`) - Check task readiness +- Acquire/heartbeat/release/list deliverable file locks - Get deliverable status workflow - Image operations (list/upload/delete/fetch/metadata) using project-scoped routes @@ -53,7 +54,7 @@ Core capabilities: ## Deterministic route resolution rules For each capability: -1. Filter operations by tags relevant to agent workflows: `deliverables`, `projects`, `task-graph`, `images`. +1. Filter operations by tags relevant to agent workflows: `deliverables`, `projects`, `task-graph`, `file-locks`, `images`. 2. Match method + intent keywords in `summary`/`description`. 3. Prefer project/deliverable-scoped routes over global/legacy routes. 4. If multiple matches remain, choose the most specific path (more scoped params). @@ -75,6 +76,9 @@ These capabilities must resolve for normal agent workflows: - Approve deliverable - Create task in deliverable - Change task status in deliverable +- Acquire file locks +- Heartbeat file locks +- Release file locks - Get deliverable graph - Check task readiness @@ -100,11 +104,11 @@ Task lifecycle (required): 1. Create task in deliverable (`POST /projects/{code}/deliverables/{delivId}/tasks`) with: - `title` - `phase` - - `phaseTaskId` + - `phaseStep` - `prompt` -2. If task begins execution, set status to `IN_PROGRESS` immediately (`PATCH .../tasks/{taskId}/status`). -3. On implementation completion, set status to `QA`. -4. After QA passes, set status to `COMPLETED`. +2. If task begins execution, set status to `IN_PROGRESS` (`PATCH .../tasks/{taskId}/status`) once execution preconditions are met. +3. On implementation completion, move status according to live workflow (some projects include `QA`, others transition directly to `COMPLETED`). +4. Use task update route (not status route) for task-level blockers: `isBlocked` and `blockedReason`. Deliverable lifecycle (required): - Resolve project deliverable workflow from API/OpenAPI-capable endpoints. @@ -115,10 +119,17 @@ Dependency lifecycle (required): - Treat `DEPENDS_ON` in PLAN as required `TASK_RELATIONS` rows. - Do not assume task create `dependencies` field is sufficient for graph lines. - After task creation, create each dependency edge explicitly via relation endpoint. +- Since dependency edges are created as predecessors complete, unresolved dependencies should not be represented as blocked status. - Solo tasks are valid and visible without dependencies. +File lock lifecycle (required for worker execution): +- Acquire required file locks before task claim: `POST /projects/{code}/deliverables/{delivId}/locks/acquire`. +- On `409 FILE_LOCK_CONFLICT`, set task `isBlocked=true` and `blockedReason='FILE_LOCK'`, poll every 3 seconds, and retry. +- While work is active, refresh lease with `POST /projects/{code}/deliverables/{delivId}/locks/heartbeat`. +- On completion/handoff, release with `POST /projects/{code}/deliverables/{delivId}/locks/release`. + Verification lifecycle (required): -- After creating/updating tasks, re-fetch deliverable task list and confirm task `id`, `phaseTaskId`, and `status`. +- After creating/updating tasks, re-fetch deliverable task list and confirm task `id`, `phaseStep`, `status`, and blocker fields when used. - Re-fetch deliverable graph and confirm task presence and relation edges. - If mismatch appears, report exact endpoint + payload + response. @@ -140,12 +151,17 @@ Verification lifecycle (required): - Return both numeric `id` and display `deliverableId` - Create task: - Required inputs: `code`, `delivId`, `title` - - Required operational fields for planning execution: `phase`, `phaseTaskId`, `prompt` + - Required operational fields for planning execution: `phase`, `phaseStep`, `prompt` - Respect deliverable approval prerequisites - For each planned dependency, create explicit relation (`DEPENDS_ON`) after task creation - Update task status: - - Use explicit transitions: `READY` -> `IN_PROGRESS` -> `QA` -> `COMPLETED` + - Resolve valid transitions from live workflow; common path is `READY` -> `IN_PROGRESS` -> (`QA` optional) -> `COMPLETED` - Include `agentName` when moving to `IN_PROGRESS` to claim work +- File locks: + - Resolve lock routes from OpenAPI (`acquire`, `heartbeat`, `release`, `list`) + - Treat heartbeat as required during active work to avoid stale lock reclamation +- Blockers: + - Blocking is task metadata (`isBlocked`, `blockedReason`), not a workflow status column - Update deliverable status: - Use deliverable status endpoint, validate allowed values from workflow - Append note: diff --git a/api/lib/db/schema.js b/api/lib/db/schema.js index fe3c8d7e..c5173dc6 100644 --- a/api/lib/db/schema.js +++ b/api/lib/db/schema.js @@ -187,7 +187,7 @@ export const FILE_LOCKS = pgTable('FILE_LOCKS', { task_id: integer('task_id').notNull().references(() => TASKS.id, { onDelete: 'cascade' }), phase_step: varchar('phase_step', { length: 20 }), agent_name: varchar('agent_name', { length: 100 }).notNull(), - file_path: varchar('file_path', { length: 1000 }).notNull(), + filepath: varchar('filepath', { length: 1000 }).notNull(), acquired_at: timestamp('acquired_at', { withTimezone: true }).defaultNow().notNull(), heartbeat_at: timestamp('heartbeat_at', { withTimezone: true }).defaultNow().notNull(), lease_expires_at: timestamp('lease_expires_at', { withTimezone: true }).notNull(), @@ -195,7 +195,7 @@ export const FILE_LOCKS = pgTable('FILE_LOCKS', { updated_by: integer('updated_by').references(() => USERS.id, { onDelete: 'set null' }), updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }, (table) => [ - unique('uq_file_locks_deliverable_file').on(table.deliverable_id, table.file_path), + unique('uq_file_locks_deliverable_file').on(table.deliverable_id, table.filepath), index('idx_file_locks_deliv_expiry').on(table.deliverable_id, table.lease_expires_at), index('idx_file_locks_task_id').on(table.task_id), ]); diff --git a/api/src/services/databaseService.js b/api/src/services/databaseService.js index 305a2413..e93864ea 100644 --- a/api/src/services/databaseService.js +++ b/api/src/services/databaseService.js @@ -1464,7 +1464,7 @@ class DatabaseService { taskId: lock.task_id, phaseStep: lock.phase_step, agentName: lock.agent_name, - filePath: lock.file_path, + filePath: lock.filepath, acquiredAt: lock.acquired_at, heartbeatAt: lock.heartbeat_at, leaseExpiresAt: lock.lease_expires_at, @@ -1498,7 +1498,7 @@ class DatabaseService { sql`${FILE_LOCKS.lease_expires_at} > NOW()` ) ) - .orderBy(asc(FILE_LOCKS.file_path)); + .orderBy(asc(FILE_LOCKS.filepath)); return rows.map((row) => this.mapFileLock(row)); }); } @@ -1536,7 +1536,7 @@ class DatabaseService { .where( and( eq(FILE_LOCKS.deliverable_id, deliverableId), - inArray(FILE_LOCKS.file_path, normalizedFilePaths), + inArray(FILE_LOCKS.filepath, normalizedFilePaths), sql`${FILE_LOCKS.lease_expires_at} > NOW()` ) ); @@ -1544,7 +1544,7 @@ class DatabaseService { const conflicts = existingLocks .filter((lock) => !(lock.task_id === taskId && lock.agent_name === normalizedAgentName)) .map((lock) => ({ - filePath: lock.file_path, + filePath: lock.filepath, taskId: lock.task_id, phaseStep: lock.phase_step, agentName: lock.agent_name, @@ -1564,7 +1564,7 @@ class DatabaseService { const leaseExpiresAt = new Date(now.getTime() + ttl * 1000); for (const filePath of normalizedFilePaths) { - const existing = existingLocks.find((lock) => lock.file_path === filePath); + const existing = existingLocks.find((lock) => lock.filepath === filePath); if (existing) { await tx .update(FILE_LOCKS) @@ -1585,7 +1585,7 @@ class DatabaseService { task_id: taskId, phase_step: phaseStep, agent_name: normalizedAgentName, - file_path: filePath, + filepath: filePath, acquired_at: now, heartbeat_at: now, lease_expires_at: leaseExpiresAt, @@ -1605,11 +1605,11 @@ class DatabaseService { eq(FILE_LOCKS.deliverable_id, deliverableId), eq(FILE_LOCKS.task_id, taskId), eq(FILE_LOCKS.agent_name, normalizedAgentName), - inArray(FILE_LOCKS.file_path, normalizedFilePaths), + inArray(FILE_LOCKS.filepath, normalizedFilePaths), sql`${FILE_LOCKS.lease_expires_at} > NOW()` ) ) - .orderBy(asc(FILE_LOCKS.file_path)); + .orderBy(asc(FILE_LOCKS.filepath)); return { acquired: true, @@ -1642,7 +1642,7 @@ class DatabaseService { ]; if (normalizedFilePaths.length > 0) { - conditions.push(inArray(FILE_LOCKS.file_path, normalizedFilePaths)); + conditions.push(inArray(FILE_LOCKS.filepath, normalizedFilePaths)); } const refreshedRows = await tx @@ -1681,7 +1681,7 @@ class DatabaseService { eq(FILE_LOCKS.agent_name, normalizedAgentName), ]; if (normalizedFilePaths.length > 0) { - conditions.push(inArray(FILE_LOCKS.file_path, normalizedFilePaths)); + conditions.push(inArray(FILE_LOCKS.filepath, normalizedFilePaths)); } const releasedRows = await tx From c059d96c7a608d5ee6822862254ac990e4a62d3b Mon Sep 17 00:00:00 2001 From: michaelwitz Date: Sat, 7 Mar 2026 18:50:45 -0500 Subject: [PATCH 03/15] feat(worker-skill): add portable zazzctl adapter and setup docs --- .agents/skills/worker-agent/SKILL.md | 25 +- .agents/skills/worker-agent/scripts/README.md | 87 ++ .agents/skills/worker-agent/scripts/zazzctl | 952 ++++++++++++++++++ .agents/skills/zazz-board-api/SKILL.md | 1 + docs/zazzctl-command-spec.md | 109 ++ scripts/zazzctl | 4 + 6 files changed, 1173 insertions(+), 5 deletions(-) create mode 100644 .agents/skills/worker-agent/scripts/README.md create mode 100755 .agents/skills/worker-agent/scripts/zazzctl create mode 100644 docs/zazzctl-command-spec.md create mode 100755 scripts/zazzctl diff --git a/.agents/skills/worker-agent/SKILL.md b/.agents/skills/worker-agent/SKILL.md index 595b6931..57a06d88 100644 --- a/.agents/skills/worker-agent/SKILL.md +++ b/.agents/skills/worker-agent/SKILL.md @@ -23,6 +23,21 @@ Live OpenAPI is the route contract source of truth. --- +## Required Worker CLI Adapter + +Canonical command adapter path: +- `.agents/skills/worker-agent/scripts/zazzctl` +- setup guide: `.agents/skills/worker-agent/scripts/README.md` + +Convenience wrapper: +- `scripts/zazzctl` (delegates to the canonical skill script) + +Rule: +- Use `zazzctl` for worker board API writes/reads (tasks, relations, status, blockers, notes, graph checks, locks). +- Do not handcraft ad-hoc curl calls for normal worker execution when `zazzctl` is available. + +--- + ## Required Inputs Before execution, you MUST have: @@ -47,9 +62,9 @@ You MUST execute in this order: 4. Compute the dependency-ready set. 5. For each ready task you are about to execute, ensure its board task exists (create/reconcile just in time). 6. Ensure all required `DEPENDS_ON` edges for that task exist before starting. -7. Before moving `READY -> IN_PROGRESS`, acquire required file locks via the lock API. +7. Before moving `READY -> IN_PROGRESS`, run `zazzctl exec begin ...` (acquire locks + block/unblock synchronization + claim status). 8. If lock acquire conflicts, set `isBlocked=true` with `blockedReason='FILE_LOCK'`, poll every 3 seconds, and retry acquire until success. -9. When locks are acquired, clear block flags and execute with TDD. +9. While task is active, run periodic `zazzctl exec tick ...` heartbeats and keep notes current. 10. Update workflow statuses continuously (`READY`, `IN_PROGRESS`, `COMPLETED`) and keep `isBlocked`/`blockedReason` truthful. 11. If course correction/rework appears after completion, add new follow-up tasks + relations to the graph; do not reopen completed tasks. 12. Recompute which tasks are now dependency-ready and repeat. @@ -114,7 +129,7 @@ Routes: Lock workflow: 1. Determine the file list before work starts. -2. Attempt `acquire` for the full file list (atomic batch). +2. Attempt `acquire` for the full file list (atomic batch), preferably via `zazzctl exec begin`. 3. If `409 FILE_LOCK_CONFLICT`: - keep workflow status unchanged - set `isBlocked=true`, `blockedReason='FILE_LOCK'` @@ -122,8 +137,8 @@ Lock workflow: 4. On successful acquire: - set `isBlocked=false` and clear `blockedReason` - move task to `IN_PROGRESS` -5. While working, send periodic `heartbeat`. -6. On completion or handoff, `release` locks. +5. While working, send periodic `heartbeat` via `zazzctl exec tick`. +6. On completion or handoff, run `zazzctl exec complete` (status + release). 7. If worker process crashes/restarts, re-resolve task state from API and reacquire before resuming edits. --- diff --git a/.agents/skills/worker-agent/scripts/README.md b/.agents/skills/worker-agent/scripts/README.md new file mode 100644 index 00000000..128f4097 --- /dev/null +++ b/.agents/skills/worker-agent/scripts/README.md @@ -0,0 +1,87 @@ +# zazzctl Setup (Worker Skill) + +This directory contains the canonical worker adapter: +- `zazzctl` + +Use this script as the standard board API adapter for worker agents across projects and worktrees. + +## Requirements +- POSIX shell (`/bin/sh`) +- `curl` +- `jq` +- network access to Zazz Board API + +## Environment +Set these variables before use: + +```bash +export ZAZZ_API_BASE_URL="http://localhost:3030" +export ZAZZ_API_TOKEN="550e8400-e29b-41d4-a716-446655440000" +export ZAZZ_PROJECT_CODE="ZAZZ" +# Optional: pretty JSON output (1 default, 0 compact) +export ZAZZCTL_PRETTY=1 +``` + +## Install Patterns + +### Same repo (recommended) +Use the checked-in script directly: + +```bash +./.agents/skills/worker-agent/scripts/zazzctl help now +``` + +### Convenience wrapper in repo root +Optional wrapper file at `scripts/zazzctl` can delegate to this script. + +### Other project/worktree +Copy only this script into your target repo, then make it executable: + +```bash +cp /path/to/source/.agents/skills/worker-agent/scripts/zazzctl ./scripts/zazzctl +chmod +x ./scripts/zazzctl +``` + +## Worker Protocol Commands + +Claim task + lock files: + +```bash +./scripts/zazzctl exec begin \ + --deliverable-id 8 \ + --task-id 25 \ + --agent-name worker-1 \ + --file api/src/routes/fileLocks.js +``` + +Send heartbeat and note while working: + +```bash +./scripts/zazzctl exec tick \ + --deliverable-id 8 \ + --task-id 25 \ + --agent-name worker-1 \ + --note "RED test added for lock conflict path" +``` + +Complete task and release locks: + +```bash +./scripts/zazzctl exec complete \ + --deliverable-id 8 \ + --task-id 25 \ + --agent-name worker-1 \ + --status COMPLETED +``` + +## Exit Codes +- `0`: success (`2xx`) +- `2`: usage/dependency error +- `10`: `409 FILE_LOCK_CONFLICT` +- `20`: other `4xx` +- `30`: `5xx`/network/unexpected + +## Notes +- API is the source of truth for locks and task state. +- Do not use local lock files for worker coordination. +- Prefer `zazzctl` over ad-hoc `curl` for worker board interactions. diff --git a/.agents/skills/worker-agent/scripts/zazzctl b/.agents/skills/worker-agent/scripts/zazzctl new file mode 100755 index 00000000..b0caf5df --- /dev/null +++ b/.agents/skills/worker-agent/scripts/zazzctl @@ -0,0 +1,952 @@ +#!/usr/bin/env sh +set -eu + +ZAZZ_API_BASE_URL="${ZAZZ_API_BASE_URL:-http://localhost:3030}" +ZAZZ_API_TOKEN="${ZAZZ_API_TOKEN:-550e8400-e29b-41d4-a716-446655440000}" +ZAZZ_PROJECT_CODE="${ZAZZ_PROJECT_CODE:-ZAZZ}" +ZAZZCTL_PRETTY="${ZAZZCTL_PRETTY:-1}" + +LAST_HTTP="" +LAST_BODY="" + +usage() { + cat <<'USAGE' +Usage: zazzctl [options] + +Resources: + deliverable list|get|create|status|approve|tasks + task list|create|get|update|status|block|unblock|note|delete|readiness + relation list|add|delete + graph get + lock list|acquire|heartbeat|release + exec begin|tick|complete + +Global env defaults: + ZAZZ_API_BASE_URL (default: http://localhost:3030) + ZAZZ_API_TOKEN (default: demo token) + ZAZZ_PROJECT_CODE (default: ZAZZ) + ZAZZCTL_PRETTY (1 pretty JSON, 0 raw) + +Examples: + zazzctl task create --deliverable-id 8 --title "Implement API" --phase 2 --phase-step 2.1 --prompt "..." + zazzctl task status --deliverable-id 8 --task-id 25 --status IN_PROGRESS --agent-name worker-1 + zazzctl lock acquire --deliverable-id 8 --task-id 25 --agent-name worker-1 --file api/src/routes/fileLocks.js + zazzctl exec begin --deliverable-id 8 --task-id 25 --agent-name worker-1 --file api/src/routes/fileLocks.js + +Exit codes: + 0 success + 2 usage error + 10 lock conflict (409 FILE_LOCK_CONFLICT) + 20 API/client error (4xx) + 30 server/network error +USAGE +} + +err() { + echo "zazzctl: $*" >&2 +} + +die_usage() { + err "$*" + usage >&2 + exit 2 +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + die_usage "Missing required dependency: $1" + fi +} + +require_value() { + name="$1" + value="$2" + if [ -z "$value" ]; then + die_usage "Missing required option: $name" + fi +} + +is_success_http() { + code="$1" + [ "$code" -ge 200 ] && [ "$code" -lt 300 ] +} + +print_json() { + body="$1" + if [ "$ZAZZCTL_PRETTY" = "1" ] && printf '%s' "$body" | jq -e . >/dev/null 2>&1; then + printf '%s\n' "$body" | jq . + else + printf '%s\n' "$body" + fi +} + +http_to_exit() { + code="$1" + if is_success_http "$code"; then + echo 0 + return + fi + + if [ "$code" -eq 409 ] && printf '%s' "$LAST_BODY" | jq -e '.error == "FILE_LOCK_CONFLICT"' >/dev/null 2>&1; then + echo 10 + return + fi + + if [ "$code" -ge 400 ] && [ "$code" -lt 500 ]; then + echo 20 + return + fi + + echo 30 +} + +exit_with_last_response() { + print_json "$LAST_BODY" + exit "$(http_to_exit "$LAST_HTTP")" +} + +api_request() { + method="$1" + path="$2" + body="${3:-}" + url="${ZAZZ_API_BASE_URL%/}${path}" + + if [ -n "$body" ]; then + if ! response=$(curl -sS -X "$method" \ + -H "TB_TOKEN: ${ZAZZ_API_TOKEN}" \ + -H "Authorization: Bearer ${ZAZZ_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$body" \ + "$url" \ + -w "\n__HTTP_STATUS__:%{http_code}"); then + LAST_HTTP="000" + LAST_BODY='{"error":"NETWORK_ERROR","message":"Failed to reach API"}' + return 1 + fi + else + if ! response=$(curl -sS -X "$method" \ + -H "TB_TOKEN: ${ZAZZ_API_TOKEN}" \ + -H "Authorization: Bearer ${ZAZZ_API_TOKEN}" \ + "$url" \ + -w "\n__HTTP_STATUS__:%{http_code}"); then + LAST_HTTP="000" + LAST_BODY='{"error":"NETWORK_ERROR","message":"Failed to reach API"}' + return 1 + fi + fi + + LAST_HTTP=$(printf '%s\n' "$response" | awk -F: '/__HTTP_STATUS__/{print $2}' | tail -n1) + LAST_BODY=$(printf '%s\n' "$response" | sed '/__HTTP_STATUS__:/d') + [ -n "$LAST_HTTP" ] || LAST_HTTP="000" + return 0 +} + +call_api() { + if ! api_request "$@"; then + print_json "$LAST_BODY" + exit 30 + fi +} + +csv_files_to_json() { + input="$1" + printf '%s' "$input" | jq -Rc 'split(",") | map(gsub("^\\s+|\\s+$";"")) | map(select(length>0))' +} + +csv_ints_to_json() { + input="$1" + printf '%s' "$input" | jq -Rc 'split(",") | map(gsub("^\\s+|\\s+$";"")) | map(select(length>0) | tonumber)' +} + +append_csv() { + current="$1" + value="$2" + if [ -z "$current" ]; then + printf '%s' "$value" + else + printf '%s,%s' "$current" "$value" + fi +} + +require_cmd curl +require_cmd jq + +if [ "$#" -lt 2 ]; then + usage + exit 2 +fi + +resource="$1" +action="$2" +shift 2 + +case "$resource:$action" in + deliverable:list) + project="$ZAZZ_PROJECT_CODE" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + call_api GET "/projects/${project}/deliverables" + exit_with_last_response + ;; + + deliverable:get) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--deliverable-id" "$deliverable_id" + call_api GET "/projects/${project}/deliverables/${deliverable_id}" + exit_with_last_response + ;; + + deliverable:create) + project="$ZAZZ_PROJECT_CODE" + name="" + type="" + description="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --name) name="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --description) description="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--name" "$name" + require_value "--type" "$type" + body=$(jq -n \ + --arg name "$name" \ + --arg type "$type" \ + --arg description "$description" \ + '{name:$name,type:$type} + (if $description != "" then {description:$description} else {} end)') + call_api POST "/projects/${project}/deliverables" "$body" + exit_with_last_response + ;; + + deliverable:status) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + status="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + --status) status="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--deliverable-id" "$deliverable_id" + require_value "--status" "$status" + body=$(jq -n --arg status "$status" '{status:$status}') + call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/status" "$body" + exit_with_last_response + ;; + + deliverable:approve) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--deliverable-id" "$deliverable_id" + call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/approve" '{}' + exit_with_last_response + ;; + + deliverable:tasks|task:list) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--deliverable-id" "$deliverable_id" + call_api GET "/projects/${project}/deliverables/${deliverable_id}/tasks" + exit_with_last_response + ;; + + task:create) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + title="" + prompt="" + description="" + status="" + priority="" + agent_name="" + phase="" + phase_step="" + dependencies_csv="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + --title) title="$2"; shift 2 ;; + --prompt) prompt="$2"; shift 2 ;; + --description) description="$2"; shift 2 ;; + --status) status="$2"; shift 2 ;; + --priority) priority="$2"; shift 2 ;; + --agent-name) agent_name="$2"; shift 2 ;; + --phase) phase="$2"; shift 2 ;; + --phase-step) phase_step="$2"; shift 2 ;; + --dependencies) dependencies_csv="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--deliverable-id" "$deliverable_id" + require_value "--title" "$title" + + dependencies_json='[]' + if [ -n "$dependencies_csv" ]; then + dependencies_json=$(csv_ints_to_json "$dependencies_csv") + fi + + body=$(jq -n \ + --arg title "$title" \ + --arg prompt "$prompt" \ + --arg description "$description" \ + --arg status "$status" \ + --arg priority "$priority" \ + --arg agentName "$agent_name" \ + --arg phase "$phase" \ + --arg phaseStep "$phase_step" \ + --argjson dependencies "$dependencies_json" \ + '{title:$title} + + (if $prompt != "" then {prompt:$prompt} else {} end) + + (if $description != "" then {description:$description} else {} end) + + (if $status != "" then {status:$status} else {} end) + + (if $priority != "" then {priority:$priority} else {} end) + + (if $agentName != "" then {agentName:$agentName} else {} end) + + (if $phase != "" then {phase:($phase|tonumber)} else {} end) + + (if $phaseStep != "" then {phaseStep:$phaseStep} else {} end) + + (if (dependencies|length) > 0 then {dependencies:dependencies} else {} end)') + + call_api POST "/projects/${project}/deliverables/${deliverable_id}/tasks" "$body" + exit_with_last_response + ;; + + task:get) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + task_id="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + --task-id) task_id="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--deliverable-id" "$deliverable_id" + require_value "--task-id" "$task_id" + call_api GET "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}" + exit_with_last_response + ;; + + task:update) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + task_id="" + json_payload="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + --task-id) task_id="$2"; shift 2 ;; + --json) json_payload="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--deliverable-id" "$deliverable_id" + require_value "--task-id" "$task_id" + require_value "--json" "$json_payload" + if ! printf '%s' "$json_payload" | jq -e . >/dev/null 2>&1; then + die_usage "--json must be valid JSON" + fi + call_api PUT "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}" "$json_payload" + exit_with_last_response + ;; + + task:status) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + task_id="" + status="" + agent_name="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + --task-id) task_id="$2"; shift 2 ;; + --status) status="$2"; shift 2 ;; + --agent-name) agent_name="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--deliverable-id" "$deliverable_id" + require_value "--task-id" "$task_id" + require_value "--status" "$status" + body=$(jq -n --arg status "$status" --arg agentName "$agent_name" '{status:$status} + (if $agentName != "" then {agentName:$agentName} else {} end)') + call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}/status" "$body" + exit_with_last_response + ;; + + task:block) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + task_id="" + reason="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + --task-id) task_id="$2"; shift 2 ;; + --reason) reason="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--deliverable-id" "$deliverable_id" + require_value "--task-id" "$task_id" + require_value "--reason" "$reason" + body=$(jq -n --arg reason "$reason" '{isBlocked:true,blockedReason:$reason}') + call_api PUT "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}" "$body" + exit_with_last_response + ;; + + task:unblock) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + task_id="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + --task-id) task_id="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--deliverable-id" "$deliverable_id" + require_value "--task-id" "$task_id" + body='{"isBlocked":false,"blockedReason":null}' + call_api PUT "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}" "$body" + exit_with_last_response + ;; + + task:note) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + task_id="" + note="" + agent_name="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + --task-id) task_id="$2"; shift 2 ;; + --note) note="$2"; shift 2 ;; + --agent-name) agent_name="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--deliverable-id" "$deliverable_id" + require_value "--task-id" "$task_id" + require_value "--note" "$note" + body=$(jq -n --arg note "$note" --arg agentName "$agent_name" '{note:$note} + (if $agentName != "" then {agentName:$agentName} else {} end)') + call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}/notes" "$body" + exit_with_last_response + ;; + + task:delete) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + task_id="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + --task-id) task_id="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--deliverable-id" "$deliverable_id" + require_value "--task-id" "$task_id" + call_api DELETE "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}" + exit_with_last_response + ;; + + task:readiness) + project="$ZAZZ_PROJECT_CODE" + task_id="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --task-id) task_id="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--task-id" "$task_id" + call_api GET "/projects/${project}/tasks/${task_id}/readiness" + exit_with_last_response + ;; + + relation:list) + project="$ZAZZ_PROJECT_CODE" + task_id="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --task-id) task_id="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--task-id" "$task_id" + call_api GET "/projects/${project}/tasks/${task_id}/relations" + exit_with_last_response + ;; + + relation:add) + project="$ZAZZ_PROJECT_CODE" + task_id="" + related_task_id="" + relation_type="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --task-id) task_id="$2"; shift 2 ;; + --related-task-id) related_task_id="$2"; shift 2 ;; + --type) relation_type="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--task-id" "$task_id" + require_value "--related-task-id" "$related_task_id" + require_value "--type" "$relation_type" + body=$(jq -n --argjson relatedTaskId "$related_task_id" --arg relationType "$relation_type" '{relatedTaskId:$relatedTaskId, relationType:$relationType}') + call_api POST "/projects/${project}/tasks/${task_id}/relations" "$body" + exit_with_last_response + ;; + + relation:delete) + project="$ZAZZ_PROJECT_CODE" + task_id="" + related_task_id="" + relation_type="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --task-id) task_id="$2"; shift 2 ;; + --related-task-id) related_task_id="$2"; shift 2 ;; + --type) relation_type="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--task-id" "$task_id" + require_value "--related-task-id" "$related_task_id" + require_value "--type" "$relation_type" + call_api DELETE "/projects/${project}/tasks/${task_id}/relations/${related_task_id}/${relation_type}" + exit_with_last_response + ;; + + graph:get) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--deliverable-id" "$deliverable_id" + call_api GET "/projects/${project}/deliverables/${deliverable_id}/graph" + exit_with_last_response + ;; + + lock:list) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + *) die_usage "Unknown option: $1" ;; + esac + done + require_value "--deliverable-id" "$deliverable_id" + call_api GET "/projects/${project}/deliverables/${deliverable_id}/locks" + exit_with_last_response + ;; + + lock:acquire|lock:heartbeat|lock:release) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + task_id="" + agent_name="" + phase_step="" + ttl_seconds="" + files_csv="" + + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + --task-id) task_id="$2"; shift 2 ;; + --agent-name) agent_name="$2"; shift 2 ;; + --phase-step) phase_step="$2"; shift 2 ;; + --ttl-seconds) ttl_seconds="$2"; shift 2 ;; + --file) + files_csv=$(append_csv "$files_csv" "$2") + shift 2 + ;; + --files) + files_csv=$(append_csv "$files_csv" "$2") + shift 2 + ;; + *) die_usage "Unknown option: $1" ;; + esac + done + + require_value "--deliverable-id" "$deliverable_id" + require_value "--task-id" "$task_id" + require_value "--agent-name" "$agent_name" + + files_json='[]' + if [ -n "$files_csv" ]; then + files_json=$(csv_files_to_json "$files_csv") + fi + + case "$resource:$action" in + lock:acquire) + if [ "$files_json" = "[]" ]; then + die_usage "lock acquire requires at least one --file or --files" + fi + body=$(jq -n \ + --argjson taskId "$task_id" \ + --arg agentName "$agent_name" \ + --arg phaseStep "$phase_step" \ + --arg ttl "$ttl_seconds" \ + --argjson filePaths "$files_json" \ + '{taskId:$taskId,agentName:$agentName,filePaths:$filePaths} + + (if $phaseStep != "" then {phaseStep:$phaseStep} else {} end) + + (if $ttl != "" then {ttlSeconds:($ttl|tonumber)} else {} end)') + call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/acquire" "$body" + exit_with_last_response + ;; + lock:heartbeat) + body=$(jq -n \ + --argjson taskId "$task_id" \ + --arg agentName "$agent_name" \ + --arg ttl "$ttl_seconds" \ + --argjson filePaths "$files_json" \ + '{taskId:$taskId,agentName:$agentName} + + (if (filePaths|length) > 0 then {filePaths:filePaths} else {} end) + + (if $ttl != "" then {ttlSeconds:($ttl|tonumber)} else {} end)') + call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/heartbeat" "$body" + exit_with_last_response + ;; + lock:release) + body=$(jq -n \ + --argjson taskId "$task_id" \ + --arg agentName "$agent_name" \ + --argjson filePaths "$files_json" \ + '{taskId:$taskId,agentName:$agentName} + + (if (filePaths|length) > 0 then {filePaths:filePaths} else {} end)') + call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/release" "$body" + exit_with_last_response + ;; + esac + ;; + + exec:begin) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + task_id="" + agent_name="" + phase_step="" + ttl_seconds="" + target_status="IN_PROGRESS" + files_csv="" + + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + --task-id) task_id="$2"; shift 2 ;; + --agent-name) agent_name="$2"; shift 2 ;; + --phase-step) phase_step="$2"; shift 2 ;; + --ttl-seconds) ttl_seconds="$2"; shift 2 ;; + --status) target_status="$2"; shift 2 ;; + --file) + files_csv=$(append_csv "$files_csv" "$2") + shift 2 + ;; + --files) + files_csv=$(append_csv "$files_csv" "$2") + shift 2 + ;; + *) die_usage "Unknown option: $1" ;; + esac + done + + require_value "--deliverable-id" "$deliverable_id" + require_value "--task-id" "$task_id" + require_value "--agent-name" "$agent_name" + + files_json=$(csv_files_to_json "$files_csv") + if [ "$files_json" = "[]" ]; then + die_usage "exec begin requires at least one --file or --files" + fi + + acquire_body=$(jq -n \ + --argjson taskId "$task_id" \ + --arg agentName "$agent_name" \ + --arg phaseStep "$phase_step" \ + --arg ttl "$ttl_seconds" \ + --argjson filePaths "$files_json" \ + '{taskId:$taskId,agentName:$agentName,filePaths:$filePaths} + + (if $phaseStep != "" then {phaseStep:$phaseStep} else {} end) + + (if $ttl != "" then {ttlSeconds:($ttl|tonumber)} else {} end)') + + call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/acquire" "$acquire_body" + acquire_http="$LAST_HTTP" + acquire_resp="$LAST_BODY" + + if [ "$acquire_http" -eq 409 ] && printf '%s' "$acquire_resp" | jq -e '.error == "FILE_LOCK_CONFLICT"' >/dev/null 2>&1; then + block_body='{"isBlocked":true,"blockedReason":"FILE_LOCK"}' + call_api PUT "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}" "$block_body" + block_http="$LAST_HTTP" + block_resp="$LAST_BODY" + + LAST_BODY=$(jq -n \ + --argjson acquire "$acquire_resp" \ + --arg acquireHttp "$acquire_http" \ + --argjson block "$block_resp" \ + --arg blockHttp "$block_http" \ + '{acquire:$acquire,acquireHttp:($acquireHttp|tonumber),blockUpdate:$block,blockHttp:($blockHttp|tonumber)}') + LAST_HTTP=409 + print_json "$LAST_BODY" + exit 10 + fi + + if ! is_success_http "$acquire_http"; then + LAST_HTTP="$acquire_http" + LAST_BODY="$acquire_resp" + exit_with_last_response + fi + + unblock_body='{"isBlocked":false,"blockedReason":null}' + call_api PUT "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}" "$unblock_body" + unblock_http="$LAST_HTTP" + unblock_resp="$LAST_BODY" + + status_body=$(jq -n --arg status "$target_status" --arg agentName "$agent_name" '{status:$status,agentName:$agentName}') + call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}/status" "$status_body" + status_http="$LAST_HTTP" + status_resp="$LAST_BODY" + + LAST_BODY=$(jq -n \ + --argjson acquire "$acquire_resp" \ + --arg acquireHttp "$acquire_http" \ + --argjson unblock "$unblock_resp" \ + --arg unblockHttp "$unblock_http" \ + --argjson status "$status_resp" \ + --arg statusHttp "$status_http" \ + '{acquire:$acquire,acquireHttp:($acquireHttp|tonumber),unblock:$unblock,unblockHttp:($unblockHttp|tonumber),status:$status,statusHttp:($statusHttp|tonumber)}') + + if is_success_http "$unblock_http" && is_success_http "$status_http"; then + LAST_HTTP=200 + elif [ "$unblock_http" -ge 400 ] && [ "$unblock_http" -lt 500 ] || [ "$status_http" -ge 400 ] && [ "$status_http" -lt 500 ]; then + LAST_HTTP=400 + else + LAST_HTTP=500 + fi + + exit_with_last_response + ;; + + exec:tick) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + task_id="" + agent_name="" + ttl_seconds="" + note="" + files_csv="" + + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + --task-id) task_id="$2"; shift 2 ;; + --agent-name) agent_name="$2"; shift 2 ;; + --ttl-seconds) ttl_seconds="$2"; shift 2 ;; + --note) note="$2"; shift 2 ;; + --file) + files_csv=$(append_csv "$files_csv" "$2") + shift 2 + ;; + --files) + files_csv=$(append_csv "$files_csv" "$2") + shift 2 + ;; + *) die_usage "Unknown option: $1" ;; + esac + done + + require_value "--deliverable-id" "$deliverable_id" + require_value "--task-id" "$task_id" + require_value "--agent-name" "$agent_name" + + files_json='[]' + if [ -n "$files_csv" ]; then + files_json=$(csv_files_to_json "$files_csv") + fi + + heartbeat_body=$(jq -n \ + --argjson taskId "$task_id" \ + --arg agentName "$agent_name" \ + --arg ttl "$ttl_seconds" \ + --argjson filePaths "$files_json" \ + '{taskId:$taskId,agentName:$agentName} + + (if (filePaths|length) > 0 then {filePaths:filePaths} else {} end) + + (if $ttl != "" then {ttlSeconds:($ttl|tonumber)} else {} end)') + + call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/heartbeat" "$heartbeat_body" + heartbeat_http="$LAST_HTTP" + heartbeat_resp="$LAST_BODY" + + note_http=0 + note_resp='{}' + if [ -n "$note" ]; then + note_body=$(jq -n --arg note "$note" --arg agentName "$agent_name" '{note:$note,agentName:$agentName}') + call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}/notes" "$note_body" + note_http="$LAST_HTTP" + note_resp="$LAST_BODY" + fi + + LAST_BODY=$(jq -n \ + --argjson heartbeat "$heartbeat_resp" \ + --arg heartbeatHttp "$heartbeat_http" \ + --argjson note "$note_resp" \ + --arg noteHttp "$note_http" \ + '{heartbeat:$heartbeat,heartbeatHttp:($heartbeatHttp|tonumber),note:$note,noteHttp:($noteHttp|tonumber)}') + + if is_success_http "$heartbeat_http" && { [ "$note_http" -eq 0 ] || is_success_http "$note_http"; }; then + LAST_HTTP=200 + elif [ "$heartbeat_http" -ge 400 ] && [ "$heartbeat_http" -lt 500 ] || { [ "$note_http" -ne 0 ] && [ "$note_http" -ge 400 ] && [ "$note_http" -lt 500 ]; }; then + LAST_HTTP=400 + else + LAST_HTTP=500 + fi + + exit_with_last_response + ;; + + exec:complete) + project="$ZAZZ_PROJECT_CODE" + deliverable_id="" + task_id="" + agent_name="" + status="COMPLETED" + note="" + files_csv="" + + while [ "$#" -gt 0 ]; do + case "$1" in + --project) project="$2"; shift 2 ;; + --deliverable-id) deliverable_id="$2"; shift 2 ;; + --task-id) task_id="$2"; shift 2 ;; + --agent-name) agent_name="$2"; shift 2 ;; + --status) status="$2"; shift 2 ;; + --note) note="$2"; shift 2 ;; + --file) + files_csv=$(append_csv "$files_csv" "$2") + shift 2 + ;; + --files) + files_csv=$(append_csv "$files_csv" "$2") + shift 2 + ;; + *) die_usage "Unknown option: $1" ;; + esac + done + + require_value "--deliverable-id" "$deliverable_id" + require_value "--task-id" "$task_id" + require_value "--agent-name" "$agent_name" + + note_http=0 + note_resp='{}' + if [ -n "$note" ]; then + note_body=$(jq -n --arg note "$note" --arg agentName "$agent_name" '{note:$note,agentName:$agentName}') + call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}/notes" "$note_body" + note_http="$LAST_HTTP" + note_resp="$LAST_BODY" + fi + + status_body=$(jq -n --arg status "$status" --arg agentName "$agent_name" '{status:$status,agentName:$agentName}') + call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}/status" "$status_body" + status_http="$LAST_HTTP" + status_resp="$LAST_BODY" + + files_json='[]' + if [ -n "$files_csv" ]; then + files_json=$(csv_files_to_json "$files_csv") + fi + release_body=$(jq -n \ + --argjson taskId "$task_id" \ + --arg agentName "$agent_name" \ + --argjson filePaths "$files_json" \ + '{taskId:$taskId,agentName:$agentName} + + (if (filePaths|length) > 0 then {filePaths:filePaths} else {} end)') + + call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/release" "$release_body" + release_http="$LAST_HTTP" + release_resp="$LAST_BODY" + + LAST_BODY=$(jq -n \ + --argjson note "$note_resp" \ + --arg noteHttp "$note_http" \ + --argjson status "$status_resp" \ + --arg statusHttp "$status_http" \ + --argjson release "$release_resp" \ + --arg releaseHttp "$release_http" \ + '{note:$note,noteHttp:($noteHttp|tonumber),status:$status,statusHttp:($statusHttp|tonumber),release:$release,releaseHttp:($releaseHttp|tonumber)}') + + if { [ "$note_http" -eq 0 ] || is_success_http "$note_http"; } && is_success_http "$status_http" && is_success_http "$release_http"; then + LAST_HTTP=200 + elif { [ "$note_http" -ne 0 ] && [ "$note_http" -ge 400 ] && [ "$note_http" -lt 500 ]; } || [ "$status_http" -ge 400 ] && [ "$status_http" -lt 500 ] || [ "$release_http" -ge 400 ] && [ "$release_http" -lt 500 ]; then + LAST_HTTP=400 + else + LAST_HTTP=500 + fi + + exit_with_last_response + ;; + + help:*|*:help) + usage + exit 0 + ;; + + *) + die_usage "Unknown command: ${resource} ${action}" + ;; +esac diff --git a/.agents/skills/zazz-board-api/SKILL.md b/.agents/skills/zazz-board-api/SKILL.md index 92b14441..a5e0ee31 100644 --- a/.agents/skills/zazz-board-api/SKILL.md +++ b/.agents/skills/zazz-board-api/SKILL.md @@ -33,6 +33,7 @@ Rules: - Parse `paths` + operation metadata (`tags`, `summary`, `description`, params, requestBody, responses). - Do not trust stale hardcoded route lists when OpenAPI differs. - Do not invent routes; derive from live spec. +- If using a local command adapter (e.g. worker `zazzctl`), keep behavior aligned with OpenAPI-derived routes and schemas. --- diff --git a/docs/zazzctl-command-spec.md b/docs/zazzctl-command-spec.md new file mode 100644 index 00000000..88a314d1 --- /dev/null +++ b/docs/zazzctl-command-spec.md @@ -0,0 +1,109 @@ +# zazzctl Command Spec (Draft v0.1) + +## Purpose +`zazzctl` is a thin, vendor-neutral CLI adapter for Zazz Board API operations required by worker agents. +It standardizes command shapes, payload construction, output, and exit codes so Claude, Codex, and other agents can run the same execution protocol. + +## Scope +This first version targets worker-agent board operations: +- deliverables: list/get/create/status/approve/tasks +- tasks: create/get/update/status/block/unblock/note/delete/readiness/list +- relations: add/list/delete +- graph: get (deliverable graph) +- file locks: list/acquire/heartbeat/release +- worker execution helpers: `exec begin|tick|complete` + +## Non-goals (v0.1) +- No local lock-file mechanism (`.zazz` lock JSON). API locks are the source of truth. +- No local DB writes. +- No planner/spec-builder-specific orchestration commands. + +## Runtime Requirements +- POSIX shell +- `curl` +- `jq` + +## Script Location +- Canonical implementation: `.agents/skills/worker-agent/scripts/zazzctl` +- Root convenience wrapper: `scripts/zazzctl` + +## Environment Contract +- `ZAZZ_API_BASE_URL` (default: `http://localhost:3030`) +- `ZAZZ_API_TOKEN` (default: `550e8400-e29b-41d4-a716-446655440000`) +- `ZAZZ_PROJECT_CODE` (default: `ZAZZ`) +- `ZAZZCTL_PRETTY` (`1` default, `0` for raw compact output) + +## Output Contract +- Output body is always printed (JSON when API returns JSON). +- `ZAZZCTL_PRETTY=1` pretty-prints JSON. +- Composite `exec` commands return combined JSON objects with per-step results. + +## Exit Code Contract +- `0`: success (`2xx`) +- `2`: CLI usage / missing args / dependency not installed +- `10`: lock conflict (`409 FILE_LOCK_CONFLICT`) +- `20`: client/API request error (`4xx` except lock conflict) +- `30`: server/network/unexpected error (`5xx`, network, malformed response) + +## Command Surface + +### Deliverables +- `zazzctl deliverable list [--project CODE]` +- `zazzctl deliverable get --deliverable-id ID [--project CODE]` +- `zazzctl deliverable create --name NAME --type TYPE [--description TEXT] [--project CODE]` +- `zazzctl deliverable status --deliverable-id ID --status STATUS [--project CODE]` +- `zazzctl deliverable approve --deliverable-id ID [--project CODE]` +- `zazzctl deliverable tasks --deliverable-id ID [--project CODE]` + +### Tasks +- `zazzctl task list --deliverable-id ID [--project CODE]` +- `zazzctl task create --deliverable-id ID --title TITLE [--prompt TEXT] [--phase N] [--phase-step X.Y] [--status S] [--priority P] [--agent-name A] [--description D] [--dependencies CSV] [--project CODE]` +- `zazzctl task get --deliverable-id ID --task-id ID [--project CODE]` +- `zazzctl task update --deliverable-id ID --task-id ID --json '{...}' [--project CODE]` +- `zazzctl task status --deliverable-id ID --task-id ID --status STATUS [--agent-name A] [--project CODE]` +- `zazzctl task block --deliverable-id ID --task-id ID --reason REASON [--project CODE]` +- `zazzctl task unblock --deliverable-id ID --task-id ID [--project CODE]` +- `zazzctl task note --deliverable-id ID --task-id ID --note TEXT [--agent-name A] [--project CODE]` +- `zazzctl task delete --deliverable-id ID --task-id ID [--project CODE]` +- `zazzctl task readiness --task-id ID [--project CODE]` + +### Relations +- `zazzctl relation list --task-id ID [--project CODE]` +- `zazzctl relation add --task-id ID --related-task-id ID --type DEPENDS_ON|COORDINATES_WITH [--project CODE]` +- `zazzctl relation delete --task-id ID --related-task-id ID --type DEPENDS_ON|COORDINATES_WITH [--project CODE]` + +### Graph +- `zazzctl graph get --deliverable-id ID [--project CODE]` + +### Locks +- `zazzctl lock list --deliverable-id ID [--project CODE]` +- `zazzctl lock acquire --deliverable-id ID --task-id ID --agent-name A (--file PATH | --files CSV)+ [--phase-step X.Y] [--ttl-seconds N] [--project CODE]` +- `zazzctl lock heartbeat --deliverable-id ID --task-id ID --agent-name A [--file PATH | --files CSV] [--ttl-seconds N] [--project CODE]` +- `zazzctl lock release --deliverable-id ID --task-id ID --agent-name A [--file PATH | --files CSV] [--project CODE]` + +### Worker Execution Helpers +- `zazzctl exec begin --deliverable-id ID --task-id ID --agent-name A (--file PATH | --files CSV)+ [--phase-step X.Y] [--ttl-seconds N] [--status IN_PROGRESS] [--project CODE]` + - acquire lock batch + - if lock conflict: set `isBlocked=true` + `blockedReason='FILE_LOCK'` + - if acquired: clear block flags and set task status (default `IN_PROGRESS`) +- `zazzctl exec tick --deliverable-id ID --task-id ID --agent-name A [--file PATH | --files CSV] [--ttl-seconds N] [--note TEXT] [--project CODE]` + - heartbeat lock lease + - optional task note append +- `zazzctl exec complete --deliverable-id ID --task-id ID --agent-name A [--status COMPLETED] [--file PATH | --files CSV] [--note TEXT] [--project CODE]` + - optional note append + - update status (default `COMPLETED`) + - release locks + +## Worker Protocol (Required) +1. Create/reconcile board task and dependencies. +2. `zazzctl exec begin ...` +3. Implement using TDD (`RED -> GREEN -> REFACTOR`). +4. `zazzctl exec tick ...` periodically while task is active. +5. On owner decision wait: `task block` with `OWNER_DECISION`. +6. On completion: `zazzctl exec complete ...`. +7. Recompute ready set and continue. + +## Generic Agent Guidance +- Skills should prefer `zazzctl` for worker board writes instead of handwritten API calls. +- API remains the enforcement authority for transitions/validation. +- If `zazzctl` is unavailable, fallback direct API calls must preserve the same protocol and fields. diff --git a/scripts/zazzctl b/scripts/zazzctl new file mode 100755 index 00000000..4cec1069 --- /dev/null +++ b/scripts/zazzctl @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -eu +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec "$SCRIPT_DIR/../.agents/skills/worker-agent/scripts/zazzctl" "$@" From 6aa004311cfa9f1e3ec3a5fc1f5dae6d2d093658 Mon Sep 17 00:00:00 2001 From: michaelwitz Date: Sat, 7 Mar 2026 18:55:24 -0500 Subject: [PATCH 04/15] fix(worker-skill): tighten scope and repair zazzctl lock payload vars --- .agents/skills/worker-agent/SKILL.md | 5 +++++ .agents/skills/worker-agent/scripts/zazzctl | 10 +++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.agents/skills/worker-agent/SKILL.md b/.agents/skills/worker-agent/SKILL.md index 57a06d88..b99c0350 100644 --- a/.agents/skills/worker-agent/SKILL.md +++ b/.agents/skills/worker-agent/SKILL.md @@ -9,6 +9,11 @@ Execute an approved deliverable PLAN from start to finish, including: This role is implementation-first and orchestration-capable. +## Role Scope +- Worker agent is the primary role with full board API interaction. +- Spec-builder/planner activities may be orchestrated directly by the human Owner outside this skill. +- When this skill is active, prioritize implementation execution and board truth synchronization. + ## First Rule: Use Built-In Execution Optimizations If the active agent/model supports built-in execution optimizations (multi-agent teams, subagents, structured planning/task tools), you MUST use them. diff --git a/.agents/skills/worker-agent/scripts/zazzctl b/.agents/skills/worker-agent/scripts/zazzctl index b0caf5df..0c9ecfc1 100755 --- a/.agents/skills/worker-agent/scripts/zazzctl +++ b/.agents/skills/worker-agent/scripts/zazzctl @@ -336,7 +336,7 @@ case "$resource:$action" in + (if $agentName != "" then {agentName:$agentName} else {} end) + (if $phase != "" then {phase:($phase|tonumber)} else {} end) + (if $phaseStep != "" then {phaseStep:$phaseStep} else {} end) - + (if (dependencies|length) > 0 then {dependencies:dependencies} else {} end)') + + (if ($dependencies|length) > 0 then {dependencies:$dependencies} else {} end)') call_api POST "/projects/${project}/deliverables/${deliverable_id}/tasks" "$body" exit_with_last_response @@ -656,7 +656,7 @@ case "$resource:$action" in --arg ttl "$ttl_seconds" \ --argjson filePaths "$files_json" \ '{taskId:$taskId,agentName:$agentName} - + (if (filePaths|length) > 0 then {filePaths:filePaths} else {} end) + + (if ($filePaths|length) > 0 then {filePaths:$filePaths} else {} end) + (if $ttl != "" then {ttlSeconds:($ttl|tonumber)} else {} end)') call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/heartbeat" "$body" exit_with_last_response @@ -667,7 +667,7 @@ case "$resource:$action" in --arg agentName "$agent_name" \ --argjson filePaths "$files_json" \ '{taskId:$taskId,agentName:$agentName} - + (if (filePaths|length) > 0 then {filePaths:filePaths} else {} end)') + + (if ($filePaths|length) > 0 then {filePaths:$filePaths} else {} end)') call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/release" "$body" exit_with_last_response ;; @@ -825,7 +825,7 @@ case "$resource:$action" in --arg ttl "$ttl_seconds" \ --argjson filePaths "$files_json" \ '{taskId:$taskId,agentName:$agentName} - + (if (filePaths|length) > 0 then {filePaths:filePaths} else {} end) + + (if ($filePaths|length) > 0 then {filePaths:$filePaths} else {} end) + (if $ttl != "" then {ttlSeconds:($ttl|tonumber)} else {} end)') call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/heartbeat" "$heartbeat_body" @@ -915,7 +915,7 @@ case "$resource:$action" in --arg agentName "$agent_name" \ --argjson filePaths "$files_json" \ '{taskId:$taskId,agentName:$agentName} - + (if (filePaths|length) > 0 then {filePaths:filePaths} else {} end)') + + (if ($filePaths|length) > 0 then {filePaths:$filePaths} else {} end)') call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/release" "$release_body" release_http="$LAST_HTTP" From 381aaa3bc295b89ba588c5d83f673889b9064bcc Mon Sep 17 00:00:00 2001 From: michaelwitz Date: Sat, 7 Mar 2026 19:11:40 -0500 Subject: [PATCH 05/15] refactor(file-locks): rename to file_relative_path and fileRelativePath API contract --- .agents/skills/worker-agent/scripts/zazzctl | 24 +++++------ api/__tests__/routes/file-locks.test.mjs | 16 +++---- api/__tests__/routes/openapi.test.mjs | 2 +- api/lib/db/schema.js | 4 +- api/src/routes/fileLocks.js | 18 ++++---- api/src/schemas/fileLocks.js | 22 +++++----- api/src/services/databaseService.js | 48 ++++++++++----------- 7 files changed, 67 insertions(+), 67 deletions(-) diff --git a/.agents/skills/worker-agent/scripts/zazzctl b/.agents/skills/worker-agent/scripts/zazzctl index 0c9ecfc1..61a1a3d3 100755 --- a/.agents/skills/worker-agent/scripts/zazzctl +++ b/.agents/skills/worker-agent/scripts/zazzctl @@ -642,8 +642,8 @@ case "$resource:$action" in --arg agentName "$agent_name" \ --arg phaseStep "$phase_step" \ --arg ttl "$ttl_seconds" \ - --argjson filePaths "$files_json" \ - '{taskId:$taskId,agentName:$agentName,filePaths:$filePaths} + --argjson fileRelativePaths "$files_json" \ + '{taskId:$taskId,agentName:$agentName,fileRelativePaths:$fileRelativePaths} + (if $phaseStep != "" then {phaseStep:$phaseStep} else {} end) + (if $ttl != "" then {ttlSeconds:($ttl|tonumber)} else {} end)') call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/acquire" "$body" @@ -654,9 +654,9 @@ case "$resource:$action" in --argjson taskId "$task_id" \ --arg agentName "$agent_name" \ --arg ttl "$ttl_seconds" \ - --argjson filePaths "$files_json" \ + --argjson fileRelativePaths "$files_json" \ '{taskId:$taskId,agentName:$agentName} - + (if ($filePaths|length) > 0 then {filePaths:$filePaths} else {} end) + + (if ($fileRelativePaths|length) > 0 then {fileRelativePaths:$fileRelativePaths} else {} end) + (if $ttl != "" then {ttlSeconds:($ttl|tonumber)} else {} end)') call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/heartbeat" "$body" exit_with_last_response @@ -665,9 +665,9 @@ case "$resource:$action" in body=$(jq -n \ --argjson taskId "$task_id" \ --arg agentName "$agent_name" \ - --argjson filePaths "$files_json" \ + --argjson fileRelativePaths "$files_json" \ '{taskId:$taskId,agentName:$agentName} - + (if ($filePaths|length) > 0 then {filePaths:$filePaths} else {} end)') + + (if ($fileRelativePaths|length) > 0 then {fileRelativePaths:$fileRelativePaths} else {} end)') call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/release" "$body" exit_with_last_response ;; @@ -719,8 +719,8 @@ case "$resource:$action" in --arg agentName "$agent_name" \ --arg phaseStep "$phase_step" \ --arg ttl "$ttl_seconds" \ - --argjson filePaths "$files_json" \ - '{taskId:$taskId,agentName:$agentName,filePaths:$filePaths} + --argjson fileRelativePaths "$files_json" \ + '{taskId:$taskId,agentName:$agentName,fileRelativePaths:$fileRelativePaths} + (if $phaseStep != "" then {phaseStep:$phaseStep} else {} end) + (if $ttl != "" then {ttlSeconds:($ttl|tonumber)} else {} end)') @@ -823,9 +823,9 @@ case "$resource:$action" in --argjson taskId "$task_id" \ --arg agentName "$agent_name" \ --arg ttl "$ttl_seconds" \ - --argjson filePaths "$files_json" \ + --argjson fileRelativePaths "$files_json" \ '{taskId:$taskId,agentName:$agentName} - + (if ($filePaths|length) > 0 then {filePaths:$filePaths} else {} end) + + (if ($fileRelativePaths|length) > 0 then {fileRelativePaths:$fileRelativePaths} else {} end) + (if $ttl != "" then {ttlSeconds:($ttl|tonumber)} else {} end)') call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/heartbeat" "$heartbeat_body" @@ -913,9 +913,9 @@ case "$resource:$action" in release_body=$(jq -n \ --argjson taskId "$task_id" \ --arg agentName "$agent_name" \ - --argjson filePaths "$files_json" \ + --argjson fileRelativePaths "$files_json" \ '{taskId:$taskId,agentName:$agentName} - + (if ($filePaths|length) > 0 then {filePaths:$filePaths} else {} end)') + + (if ($fileRelativePaths|length) > 0 then {fileRelativePaths:$fileRelativePaths} else {} end)') call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/release" "$release_body" release_http="$LAST_HTTP" diff --git a/api/__tests__/routes/file-locks.test.mjs b/api/__tests__/routes/file-locks.test.mjs index 095a4549..0b412993 100644 --- a/api/__tests__/routes/file-locks.test.mjs +++ b/api/__tests__/routes/file-locks.test.mjs @@ -33,7 +33,7 @@ describe('File lock routes', () => { taskId: task.id, phaseStep: '1.1', agentName: 'worker-1', - filePaths: ['api/src/routes/projects.js', 'api/src/services/databaseService.js'], + fileRelativePaths: ['api/src/routes/projects.js', 'api/src/services/databaseService.js'], ttlSeconds: 30, }) .expectStatus(200) @@ -72,7 +72,7 @@ describe('File lock routes', () => { taskId: ownerTask.id, phaseStep: '1.1', agentName: 'worker-1', - filePaths: ['api/src/routes/projects.js'], + fileRelativePaths: ['api/src/routes/projects.js'], ttlSeconds: 30, }) .expectStatus(200); @@ -84,7 +84,7 @@ describe('File lock routes', () => { taskId: blockedTask.id, phaseStep: '1.2', agentName: 'worker-2', - filePaths: ['api/src/routes/projects.js', 'api/src/routes/taskGraph.js'], + fileRelativePaths: ['api/src/routes/projects.js', 'api/src/routes/taskGraph.js'], ttlSeconds: 30, }) .expectStatus(409) @@ -92,7 +92,7 @@ describe('File lock routes', () => { expect(conflict.error).toBe('FILE_LOCK_CONFLICT'); expect(Array.isArray(conflict.conflicts)).toBe(true); - expect(conflict.conflicts[0].filePath).toBe('api/src/routes/projects.js'); + expect(conflict.conflicts[0].fileRelativePath).toBe('api/src/routes/projects.js'); expect(conflict.pollIntervalSeconds).toBe(3); }); @@ -116,7 +116,7 @@ describe('File lock routes', () => { taskId: ownerTask.id, phaseStep: '2.1', agentName: 'worker-1', - filePaths: ['client/src/App.jsx'], + fileRelativePaths: ['client/src/App.jsx'], ttlSeconds: 30, }) .expectStatus(200); @@ -140,7 +140,7 @@ describe('File lock routes', () => { taskId: nextTask.id, phaseStep: '2.2', agentName: 'worker-2', - filePaths: ['client/src/App.jsx'], + fileRelativePaths: ['client/src/App.jsx'], ttlSeconds: 30, }) .expectStatus(200) @@ -171,7 +171,7 @@ describe('File lock routes', () => { taskId: firstTask.id, phaseStep: '3.1', agentName: 'worker-1', - filePaths: ['api/src/routes/images.js'], + fileRelativePaths: ['api/src/routes/images.js'], ttlSeconds: 5, }) .expectStatus(200); @@ -185,7 +185,7 @@ describe('File lock routes', () => { taskId: secondTask.id, phaseStep: '3.2', agentName: 'worker-2', - filePaths: ['api/src/routes/images.js'], + fileRelativePaths: ['api/src/routes/images.js'], ttlSeconds: 30, }) .expectStatus(200) diff --git a/api/__tests__/routes/openapi.test.mjs b/api/__tests__/routes/openapi.test.mjs index f1594299..2b75b2e2 100644 --- a/api/__tests__/routes/openapi.test.mjs +++ b/api/__tests__/routes/openapi.test.mjs @@ -98,7 +98,7 @@ describe('OpenAPI / Swagger documentation', () => { const acquirePath = spec.paths['/projects/{code}/deliverables/{delivId}/locks/acquire']; expect(acquirePath).toBeDefined(); expect(acquirePath.post).toBeDefined(); - expect(acquirePath.post.requestBody?.content?.['application/json']?.schema?.properties?.filePaths).toBeDefined(); + expect(acquirePath.post.requestBody?.content?.['application/json']?.schema?.properties?.fileRelativePaths).toBeDefined(); const heartbeatPath = spec.paths['/projects/{code}/deliverables/{delivId}/locks/heartbeat']; expect(heartbeatPath).toBeDefined(); diff --git a/api/lib/db/schema.js b/api/lib/db/schema.js index c5173dc6..061be93e 100644 --- a/api/lib/db/schema.js +++ b/api/lib/db/schema.js @@ -187,7 +187,7 @@ export const FILE_LOCKS = pgTable('FILE_LOCKS', { task_id: integer('task_id').notNull().references(() => TASKS.id, { onDelete: 'cascade' }), phase_step: varchar('phase_step', { length: 20 }), agent_name: varchar('agent_name', { length: 100 }).notNull(), - filepath: varchar('filepath', { length: 1000 }).notNull(), + file_relative_path: varchar('file_relative_path', { length: 1000 }).notNull(), acquired_at: timestamp('acquired_at', { withTimezone: true }).defaultNow().notNull(), heartbeat_at: timestamp('heartbeat_at', { withTimezone: true }).defaultNow().notNull(), lease_expires_at: timestamp('lease_expires_at', { withTimezone: true }).notNull(), @@ -195,7 +195,7 @@ export const FILE_LOCKS = pgTable('FILE_LOCKS', { updated_by: integer('updated_by').references(() => USERS.id, { onDelete: 'set null' }), updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }, (table) => [ - unique('uq_file_locks_deliverable_file').on(table.deliverable_id, table.filepath), + unique('uq_file_locks_deliverable_file').on(table.deliverable_id, table.file_relative_path), index('idx_file_locks_deliv_expiry').on(table.deliverable_id, table.lease_expires_at), index('idx_file_locks_task_id').on(table.task_id), ]); diff --git a/api/src/routes/fileLocks.js b/api/src/routes/fileLocks.js index b80150e7..ba991893 100644 --- a/api/src/routes/fileLocks.js +++ b/api/src/routes/fileLocks.js @@ -72,7 +72,7 @@ export default async function fileLockRoutes(fastify, options) { try { const { code, delivId } = request.params; const { project, deliverableId } = await resolveScope(code, delivId); - const { taskId, phaseStep, agentName, filePaths, ttlSeconds } = request.body; + const { taskId, phaseStep, agentName, fileRelativePaths, ttlSeconds } = request.body; const result = await dbService.acquireFileLocks({ projectId: project.id, @@ -80,7 +80,7 @@ export default async function fileLockRoutes(fastify, options) { taskId, phaseStep: phaseStep || null, agentName, - filePaths, + fileRelativePaths, ttlSeconds, userId: request.user?.id || null, }); @@ -101,7 +101,7 @@ export default async function fileLockRoutes(fastify, options) { taskId, phaseStep: phaseStep || null, agentName, - filePaths, + fileRelativePaths, }); reply.send(result); @@ -117,14 +117,14 @@ export default async function fileLockRoutes(fastify, options) { try { const { code, delivId } = request.params; const { project, deliverableId } = await resolveScope(code, delivId); - const { taskId, agentName, filePaths, ttlSeconds } = request.body; + const { taskId, agentName, fileRelativePaths, ttlSeconds } = request.body; const result = await dbService.heartbeatFileLocks({ projectId: project.id, deliverableId, taskId, agentName, - filePaths: filePaths || [], + fileRelativePaths: fileRelativePaths || [], ttlSeconds, userId: request.user?.id || null, }); @@ -135,7 +135,7 @@ export default async function fileLockRoutes(fastify, options) { deliverableId, taskId, agentName, - filePaths: filePaths || [], + fileRelativePaths: fileRelativePaths || [], }); reply.send(result); @@ -151,14 +151,14 @@ export default async function fileLockRoutes(fastify, options) { try { const { code, delivId } = request.params; const { project, deliverableId } = await resolveScope(code, delivId); - const { taskId, agentName, filePaths } = request.body; + const { taskId, agentName, fileRelativePaths } = request.body; const result = await dbService.releaseFileLocks({ projectId: project.id, deliverableId, taskId, agentName, - filePaths: filePaths || [], + fileRelativePaths: fileRelativePaths || [], }); publishEvent(project.code, { @@ -167,7 +167,7 @@ export default async function fileLockRoutes(fastify, options) { deliverableId, taskId, agentName, - filePaths: filePaths || [], + fileRelativePaths: fileRelativePaths || [], }); reply.send(result); diff --git a/api/src/schemas/fileLocks.js b/api/src/schemas/fileLocks.js index 5dd92d08..fcb84b51 100644 --- a/api/src/schemas/fileLocks.js +++ b/api/src/schemas/fileLocks.js @@ -11,7 +11,7 @@ const lockItemSchema = { taskId: { type: 'integer', description: 'Task id that owns the lock.' }, phaseStep: { type: 'string', nullable: true, description: 'Plan step label (e.g. "2.3").' }, agentName: { type: 'string', description: 'Worker/sub-agent name that owns the lock.' }, - filePath: { type: 'string', description: 'Repo-relative file path.' }, + fileRelativePath: { type: 'string', description: 'Path relative to the worktree root.' }, acquiredAt: { type: 'string', format: 'date-time' }, heartbeatAt: { type: 'string', format: 'date-time' }, leaseExpiresAt: { type: 'string', format: 'date-time' }, @@ -24,7 +24,7 @@ const lockItemSchema = { const lockConflictSchema = { type: 'object', properties: { - filePath: { type: 'string' }, + fileRelativePath: { type: 'string' }, taskId: { type: 'integer' }, phaseStep: { type: 'string', nullable: true }, agentName: { type: 'string' }, @@ -43,16 +43,16 @@ const deliverableScopeParams = { const acquireLikeBodyBase = { type: 'object', - required: ['taskId', 'agentName', 'filePaths'], + required: ['taskId', 'agentName', 'fileRelativePaths'], properties: { taskId: { type: 'integer', minimum: 1, description: 'Numeric task id that will own locks.' }, phaseStep: { type: 'string', minLength: 1, maxLength: 20, description: 'Plan step ID (e.g. "3.2").' }, agentName: { type: 'string', minLength: 1, maxLength: 100, description: 'Worker/sub-agent identifier.' }, - filePaths: { + fileRelativePaths: { type: 'array', minItems: 1, items: { type: 'string', minLength: 1, maxLength: 1000 }, - description: 'Repo-relative file paths to lock.' + description: 'Paths relative to the worktree root to lock.' }, ttlSeconds: { type: 'integer', @@ -70,10 +70,10 @@ const heartbeatBody = { properties: { taskId: { type: 'integer', minimum: 1, description: 'Numeric task id that owns locks.' }, agentName: { type: 'string', minLength: 1, maxLength: 100, description: 'Worker/sub-agent identifier.' }, - filePaths: { + fileRelativePaths: { type: 'array', items: { type: 'string', minLength: 1, maxLength: 1000 }, - description: 'Optional subset of file paths. If omitted, refreshes all lock rows for taskId+agentName in this deliverable.' + description: 'Optional subset of worktree-relative paths. If omitted, refreshes all lock rows for taskId+agentName in this deliverable.' }, ttlSeconds: { type: 'integer', @@ -134,7 +134,7 @@ export const fileLockSchemas = { heartbeatLocks: { tags: ['file-locks'], summary: 'Refresh lock lease heartbeat', - description: 'Extends lease expiry for lock rows owned by taskId+agentName. If filePaths omitted, refreshes all locks for that owner in the deliverable.', + description: 'Extends lease expiry for lock rows owned by taskId+agentName. If fileRelativePaths omitted, refreshes all locks for that owner in the deliverable.', params: deliverableScopeParams, body: heartbeatBody, response: { @@ -152,7 +152,7 @@ export const fileLockSchemas = { releaseLocks: { tags: ['file-locks'], summary: 'Release file locks', - description: 'Releases lock rows for taskId+agentName. If filePaths omitted, releases all locks owned by that owner in the deliverable.', + description: 'Releases lock rows for taskId+agentName. If fileRelativePaths omitted, releases all locks owned by that owner in the deliverable.', params: deliverableScopeParams, body: { type: 'object', @@ -160,10 +160,10 @@ export const fileLockSchemas = { properties: { taskId: { type: 'integer', minimum: 1, description: 'Numeric task id that owns locks.' }, agentName: { type: 'string', minLength: 1, maxLength: 100, description: 'Worker/sub-agent identifier.' }, - filePaths: { + fileRelativePaths: { type: 'array', items: { type: 'string', minLength: 1, maxLength: 1000 }, - description: 'Optional subset of file paths to release.' + description: 'Optional subset of worktree-relative paths to release.' } }, additionalProperties: false diff --git a/api/src/services/databaseService.js b/api/src/services/databaseService.js index e93864ea..c0f76c34 100644 --- a/api/src/services/databaseService.js +++ b/api/src/services/databaseService.js @@ -1440,9 +1440,9 @@ class DatabaseService { // ==================== FILE LOCK OPERATIONS ==================== - normalizeLockFilePaths(filePaths = []) { - if (!Array.isArray(filePaths)) return []; - const normalized = filePaths + normalizeLockFileRelativePaths(fileRelativePaths = []) { + if (!Array.isArray(fileRelativePaths)) return []; + const normalized = fileRelativePaths .map((value) => String(value || '').trim()) .filter(Boolean); return [...new Set(normalized)]; @@ -1464,7 +1464,7 @@ class DatabaseService { taskId: lock.task_id, phaseStep: lock.phase_step, agentName: lock.agent_name, - filePath: lock.filepath, + fileRelativePath: lock.file_relative_path, acquiredAt: lock.acquired_at, heartbeatAt: lock.heartbeat_at, leaseExpiresAt: lock.lease_expires_at, @@ -1498,15 +1498,15 @@ class DatabaseService { sql`${FILE_LOCKS.lease_expires_at} > NOW()` ) ) - .orderBy(asc(FILE_LOCKS.filepath)); + .orderBy(asc(FILE_LOCKS.file_relative_path)); return rows.map((row) => this.mapFileLock(row)); }); } - async acquireFileLocks({ projectId, deliverableId, taskId, phaseStep = null, agentName, filePaths, ttlSeconds = 30, userId = null }) { - const normalizedFilePaths = this.normalizeLockFilePaths(filePaths); - if (normalizedFilePaths.length === 0) { - throw new Error('filePaths is required and must contain at least one path'); + async acquireFileLocks({ projectId, deliverableId, taskId, phaseStep = null, agentName, fileRelativePaths, ttlSeconds = 30, userId = null }) { + const normalizedFileRelativePaths = this.normalizeLockFileRelativePaths(fileRelativePaths); + if (normalizedFileRelativePaths.length === 0) { + throw new Error('fileRelativePaths is required and must contain at least one path'); } const normalizedAgentName = String(agentName || '').trim(); if (!normalizedAgentName) { @@ -1536,7 +1536,7 @@ class DatabaseService { .where( and( eq(FILE_LOCKS.deliverable_id, deliverableId), - inArray(FILE_LOCKS.filepath, normalizedFilePaths), + inArray(FILE_LOCKS.file_relative_path, normalizedFileRelativePaths), sql`${FILE_LOCKS.lease_expires_at} > NOW()` ) ); @@ -1544,7 +1544,7 @@ class DatabaseService { const conflicts = existingLocks .filter((lock) => !(lock.task_id === taskId && lock.agent_name === normalizedAgentName)) .map((lock) => ({ - filePath: lock.filepath, + fileRelativePath: lock.file_relative_path, taskId: lock.task_id, phaseStep: lock.phase_step, agentName: lock.agent_name, @@ -1563,8 +1563,8 @@ class DatabaseService { const now = new Date(); const leaseExpiresAt = new Date(now.getTime() + ttl * 1000); - for (const filePath of normalizedFilePaths) { - const existing = existingLocks.find((lock) => lock.filepath === filePath); + for (const fileRelativePath of normalizedFileRelativePaths) { + const existing = existingLocks.find((lock) => lock.file_relative_path === fileRelativePath); if (existing) { await tx .update(FILE_LOCKS) @@ -1585,7 +1585,7 @@ class DatabaseService { task_id: taskId, phase_step: phaseStep, agent_name: normalizedAgentName, - filepath: filePath, + file_relative_path: fileRelativePath, acquired_at: now, heartbeat_at: now, lease_expires_at: leaseExpiresAt, @@ -1605,11 +1605,11 @@ class DatabaseService { eq(FILE_LOCKS.deliverable_id, deliverableId), eq(FILE_LOCKS.task_id, taskId), eq(FILE_LOCKS.agent_name, normalizedAgentName), - inArray(FILE_LOCKS.filepath, normalizedFilePaths), + inArray(FILE_LOCKS.file_relative_path, normalizedFileRelativePaths), sql`${FILE_LOCKS.lease_expires_at} > NOW()` ) ) - .orderBy(asc(FILE_LOCKS.filepath)); + .orderBy(asc(FILE_LOCKS.file_relative_path)); return { acquired: true, @@ -1619,12 +1619,12 @@ class DatabaseService { }); } - async heartbeatFileLocks({ projectId, deliverableId, taskId, agentName, filePaths = [], ttlSeconds = 30, userId = null }) { + async heartbeatFileLocks({ projectId, deliverableId, taskId, agentName, fileRelativePaths = [], ttlSeconds = 30, userId = null }) { const normalizedAgentName = String(agentName || '').trim(); if (!normalizedAgentName) { throw new Error('agentName is required'); } - const normalizedFilePaths = this.normalizeLockFilePaths(filePaths); + const normalizedFileRelativePaths = this.normalizeLockFileRelativePaths(fileRelativePaths); const ttl = this.normalizeLockTtlSeconds(ttlSeconds); return db.transaction(async (tx) => { @@ -1641,8 +1641,8 @@ class DatabaseService { sql`${FILE_LOCKS.lease_expires_at} > NOW()`, ]; - if (normalizedFilePaths.length > 0) { - conditions.push(inArray(FILE_LOCKS.filepath, normalizedFilePaths)); + if (normalizedFileRelativePaths.length > 0) { + conditions.push(inArray(FILE_LOCKS.file_relative_path, normalizedFileRelativePaths)); } const refreshedRows = await tx @@ -1664,12 +1664,12 @@ class DatabaseService { }); } - async releaseFileLocks({ projectId, deliverableId, taskId, agentName, filePaths = [] }) { + async releaseFileLocks({ projectId, deliverableId, taskId, agentName, fileRelativePaths = [] }) { const normalizedAgentName = String(agentName || '').trim(); if (!normalizedAgentName) { throw new Error('agentName is required'); } - const normalizedFilePaths = this.normalizeLockFilePaths(filePaths); + const normalizedFileRelativePaths = this.normalizeLockFileRelativePaths(fileRelativePaths); return db.transaction(async (tx) => { await this.reclaimExpiredFileLocks(tx, deliverableId); @@ -1680,8 +1680,8 @@ class DatabaseService { eq(FILE_LOCKS.task_id, taskId), eq(FILE_LOCKS.agent_name, normalizedAgentName), ]; - if (normalizedFilePaths.length > 0) { - conditions.push(inArray(FILE_LOCKS.filepath, normalizedFilePaths)); + if (normalizedFileRelativePaths.length > 0) { + conditions.push(inArray(FILE_LOCKS.file_relative_path, normalizedFileRelativePaths)); } const releasedRows = await tx From 39e42bec6d9a8495aed80aace539a0d51c63b86f Mon Sep 17 00:00:00 2001 From: michaelwitz Date: Sat, 7 Mar 2026 19:14:29 -0500 Subject: [PATCH 06/15] docs(worker-skill): align lock field names and status guidance --- .agents/skills/worker-agent/SKILL.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.agents/skills/worker-agent/SKILL.md b/.agents/skills/worker-agent/SKILL.md index b99c0350..648a70fa 100644 --- a/.agents/skills/worker-agent/SKILL.md +++ b/.agents/skills/worker-agent/SKILL.md @@ -70,7 +70,7 @@ You MUST execute in this order: 7. Before moving `READY -> IN_PROGRESS`, run `zazzctl exec begin ...` (acquire locks + block/unblock synchronization + claim status). 8. If lock acquire conflicts, set `isBlocked=true` with `blockedReason='FILE_LOCK'`, poll every 3 seconds, and retry acquire until success. 9. While task is active, run periodic `zazzctl exec tick ...` heartbeats and keep notes current. -10. Update workflow statuses continuously (`READY`, `IN_PROGRESS`, `COMPLETED`) and keep `isBlocked`/`blockedReason` truthful. +10. Update workflow statuses continuously (`READY`, `IN_PROGRESS`, then `QA` if configured, then `COMPLETED`) and keep `isBlocked`/`blockedReason` truthful. 11. If course correction/rework appears after completion, add new follow-up tasks + relations to the graph; do not reopen completed tasks. 12. Recompute which tasks are now dependency-ready and repeat. 13. Stop only when every task in the current deliverable graph is either: @@ -134,6 +134,8 @@ Routes: Lock workflow: 1. Determine the file list before work starts. + - Use `fileRelativePaths` (API payload) / `fileRelativePath` (API response). + - Paths must be relative to the active worktree root (not absolute paths). 2. Attempt `acquire` for the full file list (atomic batch), preferably via `zazzctl exec begin`. 3. If `409 FILE_LOCK_CONFLICT`: - keep workflow status unchanged @@ -202,12 +204,13 @@ Until clarified, keep workflow status unchanged, set `isBlocked=true` and `block ## Status Transition Rules Use these state rules: -- `TO_DO` -> `READY` when the task is selected for an executable wave +- `TO_DO` -> `READY` when the project workflow includes `TO_DO` and the task is selected for an executable wave - `READY` -> `IN_PROGRESS` only after required file locks are acquired - On lock conflict: keep workflow status unchanged, set `isBlocked=true`, `blockedReason='FILE_LOCK'`, poll every 3 seconds - On owner decision wait: keep workflow status unchanged, set `isBlocked=true`, `blockedReason='OWNER_DECISION'` - When blocker resolves: set `isBlocked=false`, clear `blockedReason`, then continue normal status flow -- `IN_PROGRESS` -> `COMPLETED` only after required tests pass +- `IN_PROGRESS` -> `QA` when workflow requires QA, otherwise `IN_PROGRESS` -> `COMPLETED` +- final transition to `COMPLETED` only after required tests pass (and QA pass when applicable) Status changes must match real execution state. @@ -237,7 +240,7 @@ Execution is complete only when all are true: ## Environment Variables ```bash -export ZAZZ_API_BASE_URL="http://localhost:3000" +export ZAZZ_API_BASE_URL="http://localhost:3030" export ZAZZ_API_TOKEN="your-api-token" export AGENT_ID="worker" export ZAZZ_WORKSPACE="/path/to/project" From 7c6ad35d8190da443bb9ff401899ee73221426ea Mon Sep 17 00:00:00 2001 From: michaelwitz Date: Sat, 7 Mar 2026 19:28:01 -0500 Subject: [PATCH 07/15] docs(readme): focus current release on spec/planner/worker --- README.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d5151d6f..dc3ccb5b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Zazz Board -**Zazz Board** is a Kanban-style orchestration app for coordinating **AI agents** and **owners** — the people who define what to build, approve PLANs, and review results. Work is organized by **project**; each project contains **deliverables** (features, bug fixes, refactors) that group **tasks**. Owners manage SPECs and deliverable flow; agents handle task execution and QA. Only deliverables are PR’d — never individual tasks. +**Zazz Board** is a Kanban-style orchestration app for coordinating **AI agents** and **owners** — the people who define what to build, approve PLANs, and review results. Work is organized by **project**; each project contains **deliverables** (features, bug fixes, refactors) that group **tasks**. Owners manage SPECs and deliverable flow; agents execute implementation work and board updates. Only deliverables are PR’d — never individual tasks. + +**Current initiative focus:** `spec-builder`, `planner`, and `worker` agent flows. Coordinator/QA agent flows are not the current release focus. **Stack**: Fastify API (JavaScript, ESM) · React client (Vite) · PostgreSQL 15 (Docker) · Drizzle ORM · Docker Compose @@ -38,7 +40,7 @@ 1. **Deliverable creation**: Owner works with the **spec builder agent** to create the deliverable specification (SPEC). During that dialogue, the agent drafts the SPEC document and creates the deliverable card on the Kanban board via the API — both with sufficient clarity and correct metadata (SPEC path, worktree, branch). 2. **Planning**: The **Planner agent** decomposes the SPEC into the PLAN — phased sequence of tasks with per-task acceptance criteria, test requirements, and file assignments. Owner approves PLAN (sets `approved_by` / `approved_at`), sets PLAN path. Owner or system moves deliverable to **In Progress** (guard: PLAN approved + PLAN path set). -3. **Execution**: **Coordinator** creates tasks from the PLAN via the API; **Workers** implement tasks; **QA** validates against acceptance criteria. When all tasks are complete, QA creates PR and sets `pull_request_url` on the deliverable, then moves deliverable to **In Review**. +3. **Execution**: The **Worker agent** realizes plan tasks just-in-time on the board, creates required relations, and implements with TDD while keeping statuses and block flags current via API. Owner-managed orchestration can run this flow directly without coordinator/QA agent personas in the current release. 4. **Review & release**: Owner reviews PR, merges to staging (**Staged**) then to main (**Done** or **Prod** for projects with a release-pipeline workflow). Status history is stored for lead-time and reporting. ### Tech notes @@ -349,15 +351,15 @@ For **Swagger UI**, see [How to access the docs with your access token](#how-to- ## Reference -|| Action | Command (project root unless noted) | -||--------|-------------------------------------| -|| Run API + client | `npm run dev` | -|| Run API only | `npm run dev:api` | -|| Run client only | `npm run dev:client` | -|| Reset dev DB (from `api/`) | `npm run db:reset` | -|| Seed only (from `api/`) | `npm run db:seed` | -|| Reset + reseed Docker DB (containers running) | `npm run docker:reset:seed` | -|| Run tests (from `api/`) | `set -a && source .env && set +a && NODE_ENV=test npm run test` | +| Action | Command (project root unless noted) | +|--------|-------------------------------------| +| Run API + client | `npm run dev` | +| Run API only | `npm run dev:api` | +| Run client only | `npm run dev:client` | +| Reset dev DB (from `api/`) | `npm run db:reset` | +| Seed only (from `api/`) | `npm run db:seed` | +| Reset + reseed Docker DB (containers running) | `npm run docker:reset:seed` | +| Run tests (from `api/`) | `set -a && source .env && set +a && NODE_ENV=test npm run test` | Env: `api/.env` — `DATABASE_URL` (dev), `DATABASE_URL_TEST` (tests). Port 5433. Test DB setup: [AGENTS.md](./AGENTS.md). Test guide: [api/__tests__/README.md](./api/__tests__/README.md). @@ -376,5 +378,5 @@ This repository is developed using the Zazz framework (dogfooding). Zazz Board i - **API docs (Swagger UI)**: **http://localhost:3030/docs** — OpenAPI 3.1, token-protected. See [API docs (Swagger)](#api-docs-swagger) and [How to access the docs with your access token](#how-to-access-the-docs-with-your-access-token). - **[api/__tests__/README.md](./api/__tests__/README.md)** — Writing and running API tests (PactumJS, helpers, safety guards). - **`.zazz/`** — Zazz Framework structure: `project.md`, `standards/` (atomic project standards), `deliverables/` (SPECs and PLANs). See [ZAZZ-FRAMEWORK.md](docs/ZAZZ-FRAMEWORK.md) Repository Structure. -- **`.agents/skills/`** — Agent skills (spec-builder, planner, coordinator, worker, qa, zazz-board-api). Developed here; synced to zazz-skills repo when stable. +- **`.agents/skills/`** — Agent skills. Current release focus: `spec-builder`, `planner`, `worker`, and `zazz-board-api` (coordinator/qa skills are not current release focus). Developed here; synced to zazz-skills repo when stable. - **`.zazz/deliverables/deliverables-feature-SPEC.md`** — Full Deliverable Specification for the deliverables feature. Also in [docs/deliverables_feature_SPEC.md](docs/deliverables_feature_SPEC.md) (legacy path). From 29c3713be19b3a899aecdde01b3c11088e4e0a98 Mon Sep 17 00:00:00 2001 From: michaelwitz Date: Sat, 7 Mar 2026 19:35:09 -0500 Subject: [PATCH 08/15] docs(skills): standardize ZAZZ_API_TOKEN usage --- .agents/skills/coordinator-agent/SKILL.md | 2 +- .agents/skills/planner-agent/SKILL.md | 2 +- .agents/skills/qa-agent/SKILL.md | 2 +- .agents/skills/spec-builder-agent/SKILL.md | 3 ++- .agents/skills/worker-agent/SKILL.md | 2 +- .agents/skills/worker-agent/scripts/README.md | 2 +- .agents/skills/worker-agent/scripts/zazzctl | 2 +- .agents/skills/zazz-board-api/SKILL.md | 6 +++--- .env.example | 7 +++++++ README.md | 6 ++++++ 10 files changed, 24 insertions(+), 10 deletions(-) diff --git a/.agents/skills/coordinator-agent/SKILL.md b/.agents/skills/coordinator-agent/SKILL.md index 6269c0e5..60867f69 100644 --- a/.agents/skills/coordinator-agent/SKILL.md +++ b/.agents/skills/coordinator-agent/SKILL.md @@ -102,7 +102,7 @@ Rework tasks are numbered hierarchically to track rework iterations: ```bash export ZAZZ_API_BASE_URL="http://localhost:3000" -export ZAZZ_API_TOKEN="your-api-token" +export ZAZZ_API_TOKEN="${ZAZZ_API_TOKEN:-550e8400-e29b-41d4-a716-446655440000}" export AGENT_ID="coordinator" export ZAZZ_WORKSPACE="/path/to/project" export ZAZZ_STATE_DIR="${ZAZZ_WORKSPACE}/.zazz" diff --git a/.agents/skills/planner-agent/SKILL.md b/.agents/skills/planner-agent/SKILL.md index 21932517..f4b5e730 100644 --- a/.agents/skills/planner-agent/SKILL.md +++ b/.agents/skills/planner-agent/SKILL.md @@ -157,7 +157,7 @@ A PLAN is complete only if all conditions below are true: ## Environment Variables ```bash export ZAZZ_API_BASE_URL="http://localhost:3000" -export ZAZZ_API_TOKEN="your-api-token" +export ZAZZ_API_TOKEN="${ZAZZ_API_TOKEN:-550e8400-e29b-41d4-a716-446655440000}" export AGENT_ID="planner" export ZAZZ_WORKSPACE="/path/to/project" export ZAZZ_STATE_DIR="${ZAZZ_WORKSPACE}/.zazz" diff --git a/.agents/skills/qa-agent/SKILL.md b/.agents/skills/qa-agent/SKILL.md index 10a7db2c..73bd4eee 100644 --- a/.agents/skills/qa-agent/SKILL.md +++ b/.agents/skills/qa-agent/SKILL.md @@ -197,7 +197,7 @@ Repeat until all AC met and all tests passing: ```bash export ZAZZ_API_BASE_URL="http://localhost:3000" -export ZAZZ_API_TOKEN="your-api-token" +export ZAZZ_API_TOKEN="${ZAZZ_API_TOKEN:-550e8400-e29b-41d4-a716-446655440000}" export AGENT_ID="qa" export ZAZZ_WORKSPACE="/path/to/project" export ZAZZ_STATE_DIR="${ZAZZ_WORKSPACE}/.zazz" diff --git a/.agents/skills/spec-builder-agent/SKILL.md b/.agents/skills/spec-builder-agent/SKILL.md index 310a811c..7377f816 100644 --- a/.agents/skills/spec-builder-agent/SKILL.md +++ b/.agents/skills/spec-builder-agent/SKILL.md @@ -479,7 +479,7 @@ During MVP: When not in development mode: When the SPEC is created or updated, sync the deliverable's **spec path** (`dedFilePath`) to Zazz Board so it appears on the deliverable card and is stored in the database. -**API calls** (requires zazz-board-api skill, `ZAZZ_API_BASE_URL`, `ZAZZ_API_TOKEN`): +**API calls** (requires zazz-board-api skill, `ZAZZ_API_BASE_URL`, `ZAZZ_API_TOKEN` with fallback to `550e8400-e29b-41d4-a716-446655440000`): 1. **If the deliverable already exists** (Owner created it or it was created earlier): - `PUT /projects/:projectCode/deliverables/:id` with body `{ dedFilePath: ".zazz/deliverables/{deliverableCode}-{slug}-SPEC.md" }` @@ -549,6 +549,7 @@ When not in development mode: When the SPEC is created or updated, sync the deli export AGENT_ID="spec-builder" export ZAZZ_WORKSPACE="/path/to/project" # Plus zazz-board-api: ZAZZ_API_BASE_URL, ZAZZ_API_TOKEN +export ZAZZ_API_TOKEN="${ZAZZ_API_TOKEN:-550e8400-e29b-41d4-a716-446655440000}" # Development mode: for improving the skill itself. Skip API calls; agent may edit SKILL.md and README.md. When off, those files are read-only. # Can also enable by saying "development mode" during the dialogue diff --git a/.agents/skills/worker-agent/SKILL.md b/.agents/skills/worker-agent/SKILL.md index 648a70fa..09fa7fc3 100644 --- a/.agents/skills/worker-agent/SKILL.md +++ b/.agents/skills/worker-agent/SKILL.md @@ -241,7 +241,7 @@ Execution is complete only when all are true: ```bash export ZAZZ_API_BASE_URL="http://localhost:3030" -export ZAZZ_API_TOKEN="your-api-token" +export ZAZZ_API_TOKEN="${ZAZZ_API_TOKEN:-550e8400-e29b-41d4-a716-446655440000}" export AGENT_ID="worker" export ZAZZ_WORKSPACE="/path/to/project" export ZAZZ_STATE_DIR="${ZAZZ_WORKSPACE}/.zazz" diff --git a/.agents/skills/worker-agent/scripts/README.md b/.agents/skills/worker-agent/scripts/README.md index 128f4097..a3ddd29c 100644 --- a/.agents/skills/worker-agent/scripts/README.md +++ b/.agents/skills/worker-agent/scripts/README.md @@ -16,7 +16,7 @@ Set these variables before use: ```bash export ZAZZ_API_BASE_URL="http://localhost:3030" -export ZAZZ_API_TOKEN="550e8400-e29b-41d4-a716-446655440000" +export ZAZZ_API_TOKEN="${ZAZZ_API_TOKEN:-550e8400-e29b-41d4-a716-446655440000}" export ZAZZ_PROJECT_CODE="ZAZZ" # Optional: pretty JSON output (1 default, 0 compact) export ZAZZCTL_PRETTY=1 diff --git a/.agents/skills/worker-agent/scripts/zazzctl b/.agents/skills/worker-agent/scripts/zazzctl index 61a1a3d3..99492758 100755 --- a/.agents/skills/worker-agent/scripts/zazzctl +++ b/.agents/skills/worker-agent/scripts/zazzctl @@ -23,7 +23,7 @@ Resources: Global env defaults: ZAZZ_API_BASE_URL (default: http://localhost:3030) - ZAZZ_API_TOKEN (default: demo token) + ZAZZ_API_TOKEN (default fallback: test token) ZAZZ_PROJECT_CODE (default: ZAZZ) ZAZZCTL_PRETTY (1 pretty JSON, 0 raw) diff --git a/.agents/skills/zazz-board-api/SKILL.md b/.agents/skills/zazz-board-api/SKILL.md index a5e0ee31..844d209a 100644 --- a/.agents/skills/zazz-board-api/SKILL.md +++ b/.agents/skills/zazz-board-api/SKILL.md @@ -14,13 +14,13 @@ Agents use this API to create/manage deliverables and tasks, update statuses, ap ## Authentication All API requests (except `/openapi.json`, `/health`, `/`, `/db-test`, `/token-info`) require: - Header: `TB_TOKEN: ` or `Authorization: Bearer ` -- Token: `ZAZZ_API_TOKEN` or fallback `550e8400-e29b-41d4-a716-446655440000` +- Token resolution: `ZAZZ_API_TOKEN` when set, otherwise fallback `550e8400-e29b-41d4-a716-446655440000` --- ## Environment variables - `ZAZZ_API_BASE_URL` (fallback: `http://localhost:3030`) -- `ZAZZ_API_TOKEN` (fallback: `550e8400-e29b-41d4-a716-446655440000`) +- `ZAZZ_API_TOKEN` (required token source; fallback if unset: `550e8400-e29b-41d4-a716-446655440000`) - `ZAZZ_PROJECT_CODE` (fallback: `ZAZZ`) --- @@ -140,7 +140,7 @@ Verification lifecycle (required): 1. Fetch OpenAPI spec. 2. Resolve routes for required capabilities using deterministic rules. 3. Validate required path/query/body schema for each operation. -4. Execute request with `TB_TOKEN`. +4. Execute request with `TB_TOKEN` or `Authorization: Bearer`, using the resolved token (`ZAZZ_API_TOKEN` first, fallback test token). 5. Validate post-conditions (task list + graph + statuses). 6. On errors, report capability + path + status + API error payload. diff --git a/.env.example b/.env.example index c5effa73..abd4c335 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,13 @@ PORT=3030 # Node environment NODE_ENV=development +# =========================================== +# API AUTH (Agent Skills) +# =========================================== + +# Seed data token example for local agent skill usage. +ZAZZ_API_TOKEN=550e8400-e29b-41d4-a716-446655440000 + # =========================================== # LOGGING # =========================================== diff --git a/README.md b/README.md index dc3ccb5b..488acece 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,12 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=password ``` +Agent skills and `zazzctl` read `ZAZZ_API_TOKEN` from root `.env`: + +```bash +ZAZZ_API_TOKEN=550e8400-e29b-41d4-a716-446655440000 +``` + ### Docker Compose reference (from `docker-compose.yml`) | Service | Container name | Host port | Container port | Notes | From 244a2ba06ece66990ea6dc5703e6bf459041743c Mon Sep 17 00:00:00 2001 From: michaelwitz Date: Sun, 8 Mar 2026 09:24:08 -0400 Subject: [PATCH 09/15] refactor(deliverables): hard-cut rename to code and alias ambiguous fields --- .../deliverable-code-rename-refactor-PLAN.md | 103 +++++++++ .zazz/standards/data-architecture.md | 6 +- api/__tests__/helpers/testDatabase.js | 2 +- api/__tests__/routes/deliverables.test.mjs | 2 + api/__tests__/routes/openapi.test.mjs | 4 + api/lib/db/schema.js | 2 +- .../seeders/data/zazz-project-snapshot.json | 216 +++++++++--------- api/scripts/seeders/seedDeliverables.js | 4 +- api/scripts/seeders/seedTaskRelations.js | 12 +- api/scripts/seeders/seedTaskTags.js | 6 +- api/scripts/seeders/seedTasks.js | 6 +- api/src/schemas/common.js | 4 +- api/src/schemas/deliverables.js | 2 +- api/src/schemas/projects.js | 4 +- api/src/services/databaseService.js | 10 +- 15 files changed, 248 insertions(+), 135 deletions(-) create mode 100644 .zazz/deliverables/deliverable-code-rename-refactor-PLAN.md diff --git a/.zazz/deliverables/deliverable-code-rename-refactor-PLAN.md b/.zazz/deliverables/deliverable-code-rename-refactor-PLAN.md new file mode 100644 index 00000000..12a2cfd9 --- /dev/null +++ b/.zazz/deliverables/deliverable-code-rename-refactor-PLAN.md @@ -0,0 +1,103 @@ +# Deliverable `deliverable_code` -> `code` Refactor Plan + +## Objective +Hard-cut rename of the deliverable identifier field across the full application stack: +- Database column: `DELIVERABLES.deliverable_code` -> `DELIVERABLES.code` +- API contract field: `deliverableCode` -> `code` +- Seed snapshot keys aligned to `code` +- OpenAPI/Swagger docs updated to only expose `code` +- Client updated to consume only `code` + +No migration files. Apply via schema change + database reset/reseed. + +## Scope +- DB schema (Drizzle) +- API service layer and route response payloads +- Seed scripts and seed snapshot JSON +- OpenAPI schema definitions and route descriptions +- API tests + OpenAPI tests +- Client deliverable list/card rendering and sorting + +## Out of Scope +- Backward compatibility for `deliverableCode` (explicitly not allowed) +- Data migration for existing DB rows + +## Implementation Steps + +1. Schema-first rename +- File: `api/lib/db/schema.js` +- Rename `deliverable_code` column definition to `code` on `DELIVERABLES`. +- Keep all existing constraints (not null + unique). + +2. Service layer hard-cut +- File: `api/src/services/databaseService.js` +- Replace all `DELIVERABLES.deliverable_code` references with `DELIVERABLES.code`. +- Rename returned API response field from `deliverableCode` to `code`. +- In create flow, persist `code` instead of `deliverable_code`. + +3. OpenAPI contract updates +- Files: + - `api/src/schemas/common.js` + - `api/src/schemas/deliverables.js` + - `api/src/schemas/projects.js` +- Replace response property `deliverableCode` with `code`. +- Update route descriptions to mention `code` only. + +4. Seed pipeline updates +- Files: + - `api/scripts/seeders/seedDeliverables.js` + - `api/scripts/seeders/seedTasks.js` + - `api/scripts/seeders/seedTaskTags.js` + - `api/scripts/seeders/seedTaskRelations.js` + - `api/scripts/seeders/data/zazz-project-snapshot.json` +- Rename seed JSON keys: + - `deliverable_code` -> `code` + - `from_deliverable_code` -> `from_code` + - `to_deliverable_code` -> `to_code` +- Update lookup/join logic in seeders to use renamed keys. + +5. Test suite updates +- Files: + - `api/__tests__/helpers/testDatabase.js` + - `api/__tests__/routes/deliverables.test.mjs` + - `api/__tests__/routes/openapi.test.mjs` +- Replace all deliverable field assertions from `deliverableCode` to `code`. +- Ensure helper inserts use `code` column. + +6. Client updates +- Files: + - `client/src/components/DeliverableCard.jsx` + - `client/src/pages/DeliverableListPage.jsx` +- Replace `deliverable.deliverableCode` with `deliverable.code`. +- Replace sort key from `deliverableCode` to `code`. + +7. Optional standards doc alignment +- File: `.zazz/standards/data-architecture.md` +- Align key-table description to current `DELIVERABLES.code` naming. + +## Verification Plan + +1. Static grep gates +- `rg -n "deliverable_code|deliverableCode" api client` +- Expect no runtime references after refactor. + +2. Recreate dev DB (schema push + full seed) +- `npm run db:reset` + +3. Recreate test DB and reseed +- Ensure `zazz_board_test` exists +- `cd api && DATABASE_URL=postgres://postgres:password@localhost:5433/zazz_board_test npm run db:reset` + +4. Thorough API test run (includes OpenAPI validation tests) +- `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test` + +5. Focused OpenAPI test confirmation +- `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/openapi.test.mjs` + +## Acceptance Criteria +- `DELIVERABLES` schema column is `code` (no `deliverable_code`) +- Deliverable API payloads expose `code` and do not expose `deliverableCode` +- Seed scripts and snapshot complete successfully using new key names +- Client deliverable UI renders/sorts by `code` +- API and OpenAPI tests pass after DB reset/reseed +- No backward compatibility layer remains for `deliverableCode` diff --git a/.zazz/standards/data-architecture.md b/.zazz/standards/data-architecture.md index 07918d95..4079d8f7 100644 --- a/.zazz/standards/data-architecture.md +++ b/.zazz/standards/data-architecture.md @@ -29,6 +29,10 @@ - **Table names**: UPPER_CASE (e.g. `PROJECTS`, `TASKS`) - **Columns**: snake_case in DB, camelCase in JS - **Automatic conversion**: `databaseService` converts returned rows via `keysToCamelCase` (`api/src/utils/propertyMapper.js`); the API and client always receive camelCase +- **Ambiguous field naming**: `keysToCamelCase` only converts shape (snake_case -> camelCase). It does not disambiguate duplicate semantic fields from joins. When multiple tables expose the same column name (for example `PROJECTS.code` and `DELIVERABLES.code`), queries/routes must alias explicitly in SQL/Drizzle select maps. + - Use table-scoped camelCase aliases for ambiguity: `projectCode` for `PROJECTS.code`, `deliverableCode` for `DELIVERABLES.code`. + - Apply the same rule to duplicate `id` columns from joins: use `projectId`, `deliverableId`, `taskId`, etc. Keep plain `id` only for the primary resource id in that contract. + - Apply the same rule for route params/bodies/responses when both values are present in one API contract. - **Task positions**: sparse numbering (e.g. 10, 20) for reordering - **System enums**: PostgreSQL `pgEnum` for fixed values (e.g. `task_relation_type`, `deliverable_type`); user-definable values use `varchar` - **UPPER_SNAKE_CASE codes**: Status codes, priorities, and enum-like values use UPPER_SNAKE_CASE. Used in: `STATUS_DEFINITIONS.code`, `TASKS.status`, `PROJECTS.status_workflow` / `deliverable_status_workflow`, `DELIVERABLES.status` / `deliverable_type`, `COORDINATION_TYPES.code`. These codes also serve as i18n keys (see [coding-styles.md](./coding-styles.md)). @@ -36,7 +40,7 @@ ## Key tables - `PROJECTS` — `id` (serial PK), `code`, `deliverable_status_workflow`, `status_workflow`, `next_deliverable_sequence` -- `DELIVERABLES` — `id` (serial PK), `deliverable_id` (varchar, e.g. ZAZZ-1), `ded_file_path`, `plan_file_path`, `prd_file_path`, `status_history` +- `DELIVERABLES` — `id` (serial PK), `code` (varchar, e.g. ZAZZ-1), `spec_filepath`, `plan_filepath`, `status_history` - `TASKS` — `id` (serial PK), `deliverable_id` FK; no separate `task_id` varchar - `TASK_RELATIONS` — `DEPENDS_ON`, `COORDINATES_WITH` - `USERS`, `TAGS`, `STATUS_DEFINITIONS`, `COORDINATION_TYPES`, `TRANSLATIONS`, `IMAGE_METADATA`, `IMAGE_DATA` diff --git a/api/__tests__/helpers/testDatabase.js b/api/__tests__/helpers/testDatabase.js index 5437a119..715c4fc5 100644 --- a/api/__tests__/helpers/testDatabase.js +++ b/api/__tests__/helpers/testDatabase.js @@ -99,7 +99,7 @@ export async function createTestDeliverable(projectId, overrides = {}) { const [deliverable] = await db.insert(DELIVERABLES).values({ project_id: projectId, project_code: overrides.projectCode || project.code, - deliverable_code: overrides.deliverableCode || `${project.code}-T${sequence}`, + code: overrides.code || `${project.code}-T${sequence}`, name: overrides.name || `Test Deliverable ${sequence}`, description: overrides.description || null, type: overrides.type || 'FEATURE', diff --git a/api/__tests__/routes/deliverables.test.mjs b/api/__tests__/routes/deliverables.test.mjs index c087dbf6..6b8b0ec2 100644 --- a/api/__tests__/routes/deliverables.test.mjs +++ b/api/__tests__/routes/deliverables.test.mjs @@ -48,7 +48,9 @@ describe('Deliverables API', () => { expect(response.name).toBe('New Deliverable'); expect(response.type).toBe('FEATURE'); expect(response.status).toBe('PLANNING'); + expect(response.projectCode).toBe('ZAZZ'); expect(response.deliverableCode).toMatch(/^ZAZZ-\d+$/); + expect(response.code).toBeUndefined(); }); it('should update and fetch a deliverable by id', async () => { diff --git a/api/__tests__/routes/openapi.test.mjs b/api/__tests__/routes/openapi.test.mjs index 2b75b2e2..76e9c7d3 100644 --- a/api/__tests__/routes/openapi.test.mjs +++ b/api/__tests__/routes/openapi.test.mjs @@ -48,6 +48,10 @@ describe('OpenAPI / Swagger documentation', () => { expect(path.post.requestBody?.content?.['application/json']?.schema?.properties?.name).toBeDefined(); expect(path.post.requestBody?.content?.['application/json']?.schema?.properties?.specFilepath).toBeDefined(); expect(path.post.requestBody?.content?.['application/json']?.schema?.properties?.planFilepath).toBeDefined(); + const responseSchema = path.post.responses?.['201']?.content?.['application/json']?.schema; + expect(responseSchema?.properties?.projectCode).toBeDefined(); + expect(responseSchema?.properties?.deliverableCode).toBeDefined(); + expect(responseSchema?.properties?.code).toBeUndefined(); }); it('should document core agent operations: create task', async () => { diff --git a/api/lib/db/schema.js b/api/lib/db/schema.js index 061be93e..46f3d253 100644 --- a/api/lib/db/schema.js +++ b/api/lib/db/schema.js @@ -87,7 +87,7 @@ export const DELIVERABLES = pgTable('DELIVERABLES', { id: serial('id').primaryKey(), project_id: integer('project_id').notNull().references(() => PROJECTS.id, { onDelete: 'cascade' }), project_code: varchar('project_code', { length: 10 }).notNull(), - deliverable_code: varchar('deliverable_code', { length: 25 }).notNull().unique(), + code: varchar('code', { length: 25 }).notNull().unique(), name: varchar('name', { length: 30 }).notNull(), description: text('description'), type: deliverableTypeEnum('type').notNull(), diff --git a/api/scripts/seeders/data/zazz-project-snapshot.json b/api/scripts/seeders/data/zazz-project-snapshot.json index 8e26a4d3..9dda1041 100644 --- a/api/scripts/seeders/data/zazz-project-snapshot.json +++ b/api/scripts/seeders/data/zazz-project-snapshot.json @@ -23,7 +23,7 @@ "phase_step": "1.1", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-1" + "code": "ZAZZ-1" }, { "id": 2, @@ -48,7 +48,7 @@ "phase_step": "2.1", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-1" + "code": "ZAZZ-1" }, { "id": 3, @@ -73,7 +73,7 @@ "phase_step": "1.1", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-3" + "code": "ZAZZ-3" }, { "id": 4, @@ -98,7 +98,7 @@ "phase_step": "1.2", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-3" + "code": "ZAZZ-3" }, { "id": 5, @@ -123,7 +123,7 @@ "phase_step": "1.3", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-3" + "code": "ZAZZ-3" }, { "id": 6, @@ -148,7 +148,7 @@ "phase_step": "2.1", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-3" + "code": "ZAZZ-3" }, { "id": 7, @@ -173,7 +173,7 @@ "phase_step": "2.2", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-3" + "code": "ZAZZ-3" }, { "id": 8, @@ -198,7 +198,7 @@ "phase_step": "2.3", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-3" + "code": "ZAZZ-3" }, { "id": 9, @@ -223,7 +223,7 @@ "phase_step": "3.1", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-3" + "code": "ZAZZ-3" }, { "id": 10, @@ -248,7 +248,7 @@ "phase_step": "3.2", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-3" + "code": "ZAZZ-3" }, { "id": 11, @@ -273,7 +273,7 @@ "phase_step": "3.3", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-3" + "code": "ZAZZ-3" }, { "id": 13, @@ -298,7 +298,7 @@ "phase_step": "1.1", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 14, @@ -323,7 +323,7 @@ "phase_step": "1.2", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 15, @@ -348,7 +348,7 @@ "phase_step": "1.3", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 16, @@ -373,7 +373,7 @@ "phase_step": "2.1", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 17, @@ -398,7 +398,7 @@ "phase_step": "2.2", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 18, @@ -423,7 +423,7 @@ "phase_step": "2.3", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 20, @@ -448,7 +448,7 @@ "phase_step": "2.4", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 21, @@ -473,7 +473,7 @@ "phase_step": "3.1", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 19, @@ -498,7 +498,7 @@ "phase_step": "3.2", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 22, @@ -523,7 +523,7 @@ "phase_step": "3.3", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 23, @@ -548,7 +548,7 @@ "phase_step": "3.4", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 24, @@ -573,7 +573,7 @@ "phase_step": "3.5", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 25, @@ -598,7 +598,7 @@ "phase_step": "4.1", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 26, @@ -623,7 +623,7 @@ "phase_step": "4.2", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 27, @@ -648,7 +648,7 @@ "phase_step": "4.3", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 28, @@ -673,7 +673,7 @@ "phase_step": "4.4", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 29, @@ -698,7 +698,7 @@ "phase_step": "4.5", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 30, @@ -723,7 +723,7 @@ "phase_step": "5.1", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 31, @@ -748,7 +748,7 @@ "phase_step": "5.2", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 32, @@ -773,7 +773,7 @@ "phase_step": "5.3", "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" }, { "id": 12, @@ -798,7 +798,7 @@ "phase_step": null, "blocked_reason": null, "coordination_code": null, - "deliverable_code": "ZAZZ-5" + "code": "ZAZZ-5" } ], "project": { @@ -829,31 +829,31 @@ "tag": "backend", "title": "ZAZZ-1: Foundation completed (schema + API read paths)", "phase_step": "1.1", - "deliverable_code": "ZAZZ-1" + "code": "ZAZZ-1" }, { "tag": "frontend", "title": "ZAZZ-1: Remaining work (UI polish + edge cases)", "phase_step": "2.1", - "deliverable_code": "ZAZZ-1" + "code": "ZAZZ-1" }, { "tag": "bug-fix", "title": "ZAZZ-3: Reproduce bug and capture failing cases", "phase_step": "1.1", - "deliverable_code": "ZAZZ-3" + "code": "ZAZZ-3" }, { "tag": "testing", "title": "ZAZZ-3: Add regression tests for invalid tag formats", "phase_step": "1.2", - "deliverable_code": "ZAZZ-3" + "code": "ZAZZ-3" }, { "tag": "bug-fix", "title": "ZAZZ-3: Fix validation for trailing hyphen and edge cases", "phase_step": "2.1", - "deliverable_code": "ZAZZ-3" + "code": "ZAZZ-3" } ], "deliverables": [ @@ -885,7 +885,7 @@ } ], "pull_request_url": null, - "deliverable_code": "ZAZZ-1", + "code": "ZAZZ-1", "spec_filepath": ".zazz/deliverables/deliverables-feature-SPEC.md", "plan_filepath": ".zazz/deliverables/deliverables-feature-PLAN.md", "project_code": "ZAZZ" @@ -913,7 +913,7 @@ } ], "pull_request_url": null, - "deliverable_code": "ZAZZ-2", + "code": "ZAZZ-2", "spec_filepath": "docs/agent-skills-SPEC.md", "plan_filepath": null, "project_code": "ZAZZ" @@ -951,7 +951,7 @@ } ], "pull_request_url": "https://github.com/zazzcode/zazz-board/pull/12", - "deliverable_code": "ZAZZ-3", + "code": "ZAZZ-3", "spec_filepath": "docs/fix-tag-validation-SPEC.md", "plan_filepath": "docs/fix-tag-validation-plan.md", "project_code": "ZAZZ" @@ -989,7 +989,7 @@ } ], "pull_request_url": null, - "deliverable_code": "ZAZZ-5", + "code": "ZAZZ-5", "spec_filepath": ".zazz/deliverables/ZAZZ-5-fix-routes-no-project-SPEC.md", "plan_filepath": ".zazz/deliverables/ZAZZ-5-fix-routes-no-project-PLAN.md", "project_code": "ZAZZ" @@ -1017,7 +1017,7 @@ } ], "pull_request_url": null, - "deliverable_code": "ZAZZ-6", + "code": "ZAZZ-6", "spec_filepath": ".zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-SPEC.md", "plan_filepath": null, "project_code": "ZAZZ" @@ -1032,8 +1032,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "1.1", "from_phase_step": "2.1", - "from_deliverable_code": "ZAZZ-1", - "to_deliverable_code": "ZAZZ-1" + "from_code": "ZAZZ-1", + "to_code": "ZAZZ-1" }, { "to_title": "ZAZZ-3: Reproduce bug and capture failing cases", @@ -1043,8 +1043,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "1.1", "from_phase_step": "1.2", - "from_deliverable_code": "ZAZZ-3", - "to_deliverable_code": "ZAZZ-3" + "from_code": "ZAZZ-3", + "to_code": "ZAZZ-3" }, { "to_title": "ZAZZ-3: Add regression tests for invalid tag formats", @@ -1054,8 +1054,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "1.2", "from_phase_step": "1.3", - "from_deliverable_code": "ZAZZ-3", - "to_deliverable_code": "ZAZZ-3" + "from_code": "ZAZZ-3", + "to_code": "ZAZZ-3" }, { "to_title": "ZAZZ-3: Confirm API validation contract + error messaging", @@ -1065,8 +1065,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "1.3", "from_phase_step": "2.1", - "from_deliverable_code": "ZAZZ-3", - "to_deliverable_code": "ZAZZ-3" + "from_code": "ZAZZ-3", + "to_code": "ZAZZ-3" }, { "to_title": "ZAZZ-3: Fix validation for trailing hyphen and edge cases", @@ -1076,8 +1076,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "2.1", "from_phase_step": "2.2", - "from_deliverable_code": "ZAZZ-3", - "to_deliverable_code": "ZAZZ-3" + "from_code": "ZAZZ-3", + "to_code": "ZAZZ-3" }, { "to_title": "ZAZZ-3: Add server-side canonicalization (lowercase + hyphens)", @@ -1087,8 +1087,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "2.2", "from_phase_step": "2.3", - "from_deliverable_code": "ZAZZ-3", - "to_deliverable_code": "ZAZZ-3" + "from_code": "ZAZZ-3", + "to_code": "ZAZZ-3" }, { "to_title": "ZAZZ-3: Ensure tag creation/upsert handles collisions", @@ -1098,8 +1098,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "2.3", "from_phase_step": "3.1", - "from_deliverable_code": "ZAZZ-3", - "to_deliverable_code": "ZAZZ-3" + "from_code": "ZAZZ-3", + "to_code": "ZAZZ-3" }, { "to_title": "ZAZZ-3: QA run (API + UI) for tag flows", @@ -1109,8 +1109,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "3.1", "from_phase_step": "3.2", - "from_deliverable_code": "ZAZZ-3", - "to_deliverable_code": "ZAZZ-3" + "from_code": "ZAZZ-3", + "to_code": "ZAZZ-3" }, { "to_title": "ZAZZ-3: Address review feedback / small refactor", @@ -1120,8 +1120,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "3.2", "from_phase_step": "3.3", - "from_deliverable_code": "ZAZZ-3", - "to_deliverable_code": "ZAZZ-3" + "from_code": "ZAZZ-3", + "to_code": "ZAZZ-3" }, { "to_title": "CODEX 1.2: Remove Project-Wide Graph Endpoint + Add Scoping Tests", @@ -1131,8 +1131,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "1.2", "from_phase_step": "1.3", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 1.1: Test Harness Cleanup for Image Routes", @@ -1142,8 +1142,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "1.1", "from_phase_step": "2.1", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 2.1: IMAGE_METADATA Single-Owner Schema Constraint", @@ -1153,8 +1153,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "2.1", "from_phase_step": "2.2", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 2.2: Refactor Image Service for Project/Owner Validation", @@ -1164,8 +1164,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "2.2", "from_phase_step": "2.3", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 2.3: Replace Legacy Image Routes with Scoped Contracts", @@ -1175,8 +1175,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "2.3", "from_phase_step": "2.4", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 2.1: IMAGE_METADATA Single-Owner Schema Constraint", @@ -1186,8 +1186,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "2.1", "from_phase_step": "3.1", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 2.3: Replace Legacy Image Routes with Scoped Contracts", @@ -1197,8 +1197,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "2.3", "from_phase_step": "3.1", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 1.2: Remove Project-Wide Graph Endpoint + Add Scoping Tests", @@ -1208,8 +1208,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "1.2", "from_phase_step": "3.3", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 1.2: Remove Project-Wide Graph Endpoint + Add Scoping Tests", @@ -1219,8 +1219,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "1.2", "from_phase_step": "3.4", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 2.3: Replace Legacy Image Routes with Scoped Contracts", @@ -1230,8 +1230,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "2.3", "from_phase_step": "3.4", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 1.3: Deliverable-Only Graph UI (No Null Fetch)", @@ -1241,8 +1241,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "1.3", "from_phase_step": "3.5", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 2.4: Align API Metadata Text for Scoped Image Contract", @@ -1252,8 +1252,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "2.4", "from_phase_step": "3.5", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 3.1: Add Image Scoping Integration Tests", @@ -1263,8 +1263,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "3.1", "from_phase_step": "3.5", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 3.2: Regression Tests for Unchanged Project-ID Routes", @@ -1274,8 +1274,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "3.2", "from_phase_step": "3.5", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 3.3: Finalize Graph Removal Regression Tests", @@ -1285,8 +1285,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "3.3", "from_phase_step": "3.5", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 3.4: Harden OpenAPI Assertions for Graph/Image Contract", @@ -1296,8 +1296,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "3.4", "from_phase_step": "3.5", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 3.5: Update API Skill Docs + Final Verification", @@ -1307,8 +1307,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "3.5", "from_phase_step": "4.1", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 1.3: Deliverable-Only Graph UI (No Null Fetch)", @@ -1318,8 +1318,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "1.3", "from_phase_step": "4.2", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 4.2: Persist Task Graph deliverable selection", @@ -1329,8 +1329,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "4.2", "from_phase_step": "4.3", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 4.3: Harden Task Graph selection restore timing", @@ -1340,8 +1340,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "4.3", "from_phase_step": "4.4", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 4.4: Keep completed tasks visible with green outline", @@ -1351,8 +1351,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "4.4", "from_phase_step": "4.5", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 5.1: Add SSE stream + status/relation event emits", @@ -1362,8 +1362,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "5.1", "from_phase_step": "5.2", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 5.1: Add SSE stream + status/relation event emits", @@ -1373,8 +1373,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "5.1", "from_phase_step": "5.3", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" }, { "to_title": "CODEX 5.2: Wire UI realtime subscriptions for Kanban + Graph", @@ -1384,8 +1384,8 @@ "relation_type": "DEPENDS_ON", "to_phase_step": "5.2", "from_phase_step": "5.3", - "from_deliverable_code": "ZAZZ-5", - "to_deliverable_code": "ZAZZ-5" + "from_code": "ZAZZ-5", + "to_code": "ZAZZ-5" } ] } diff --git a/api/scripts/seeders/seedDeliverables.js b/api/scripts/seeders/seedDeliverables.js index 8b9c2682..6499ec0d 100644 --- a/api/scripts/seeders/seedDeliverables.js +++ b/api/scripts/seeders/seedDeliverables.js @@ -26,11 +26,11 @@ export async function seedDeliverables() { const excludedDeliverableIds = new Set(['ZAZZ-2']); const zazzDeliverables = snapshot.deliverables - .filter((deliverable) => !excludedDeliverableIds.has(deliverable.deliverable_code)) + .filter((deliverable) => !excludedDeliverableIds.has(deliverable.code)) .map((deliverable, index) => ({ project_id: zazzProjectId, project_code: deliverable.project_code, - deliverable_code: deliverable.deliverable_code, + code: deliverable.code, name: deliverable.name, description: deliverable.description, type: deliverable.type, diff --git a/api/scripts/seeders/seedTaskRelations.js b/api/scripts/seeders/seedTaskRelations.js index 6292ab15..367b306f 100644 --- a/api/scripts/seeders/seedTaskRelations.js +++ b/api/scripts/seeders/seedTaskRelations.js @@ -20,7 +20,7 @@ export async function seedTaskRelations() { const snapshot = await loadZazzProjectSnapshot(); const deliverables = await db - .select({ id: DELIVERABLES.id, key: DELIVERABLES.deliverable_code }) + .select({ id: DELIVERABLES.id, key: DELIVERABLES.code }) .from(DELIVERABLES); const deliverableKeyByDbId = new Map(deliverables.map((deliverable) => [deliverable.id, deliverable.key])); @@ -51,12 +51,12 @@ export async function seedTaskRelations() { byDeliverableAndTitle.set(`${deliverableKey}::${task.title}`, task.id); } - const resolveTaskId = (deliverableCode, phaseStep, title) => { + const resolveTaskId = (deliverableKey, phaseStep, title) => { if (phaseStep) { - const byPhaseTask = byDeliverableAndPhaseTask.get(`${deliverableCode}::${phaseStep}`); + const byPhaseTask = byDeliverableAndPhaseTask.get(`${deliverableKey}::${phaseStep}`); if (byPhaseTask) return byPhaseTask; } - return byDeliverableAndTitle.get(`${deliverableCode}::${title}`) || null; + return byDeliverableAndTitle.get(`${deliverableKey}::${title}`) || null; }; const now = new Date(); @@ -65,12 +65,12 @@ export async function seedTaskRelations() { for (const relation of snapshot.task_relations) { const taskId = resolveTaskId( - relation.from_deliverable_code, + relation.from_code, relation.from_phase_step, relation.from_title ); const relatedTaskId = resolveTaskId( - relation.to_deliverable_code, + relation.to_code, relation.to_phase_step, relation.to_title ); diff --git a/api/scripts/seeders/seedTaskTags.js b/api/scripts/seeders/seedTaskTags.js index 3a91866b..44634e2a 100644 --- a/api/scripts/seeders/seedTaskTags.js +++ b/api/scripts/seeders/seedTaskTags.js @@ -18,7 +18,7 @@ export async function seedTaskTags() { const tagSet = new Set(tags.map(t => t.tag)); const deliverables = await db - .select({ id: DELIVERABLES.id, key: DELIVERABLES.deliverable_code }) + .select({ id: DELIVERABLES.id, key: DELIVERABLES.code }) .from(DELIVERABLES); const deliverableKeyByDbId = new Map(deliverables.map((deliverable) => [deliverable.id, deliverable.key])); @@ -57,10 +57,10 @@ export async function seedTaskTags() { if (!tagSet.has(tagLink.tag)) continue; const taskId = tagLink.phase_step - ? byDeliverableAndPhaseTask.get(`${tagLink.deliverable_code}::${tagLink.phase_step}`) + ? byDeliverableAndPhaseTask.get(`${tagLink.code}::${tagLink.phase_step}`) : null; - const resolvedTaskId = taskId || byDeliverableAndTitle.get(`${tagLink.deliverable_code}::${tagLink.title}`); + const resolvedTaskId = taskId || byDeliverableAndTitle.get(`${tagLink.code}::${tagLink.title}`); if (!resolvedTaskId) continue; const dedupeKey = `${resolvedTaskId}::${tagLink.tag}`; diff --git a/api/scripts/seeders/seedTasks.js b/api/scripts/seeders/seedTasks.js index a016cced..85feeb56 100644 --- a/api/scripts/seeders/seedTasks.js +++ b/api/scripts/seeders/seedTasks.js @@ -11,7 +11,7 @@ async function getProjectId(code) { async function getDeliverableIdMap(projectId) { const deliverables = await db - .select({ id: DELIVERABLES.id, key: DELIVERABLES.deliverable_code }) + .select({ id: DELIVERABLES.id, key: DELIVERABLES.code }) .from(DELIVERABLES) .where(eq(DELIVERABLES.project_id, projectId)); @@ -46,9 +46,9 @@ export async function seedTasks() { const now = new Date(); const tasks = snapshot.tasks.map((task, index) => { - const deliverableDbId = deliverableIdMap.get(task.deliverable_code); + const deliverableDbId = deliverableIdMap.get(task.code); if (!deliverableDbId) { - throw new Error(`Deliverable not found for task seed: ${task.deliverable_code}`); + throw new Error(`Deliverable not found for task seed: ${task.code}`); } return { diff --git a/api/src/schemas/common.js b/api/src/schemas/common.js index 7af0e237..18fb8c9d 100644 --- a/api/src/schemas/common.js +++ b/api/src/schemas/common.js @@ -62,8 +62,8 @@ export const deliverableResponseSchema = { properties: { id: { type: 'number', description: 'Numeric primary key. Use this for API paths (e.g. create task, update deliverable).' }, projectId: { type: 'number' }, - projectCode: { type: 'string' }, - deliverableCode: { type: 'string', description: 'Human-readable code (e.g. ZAZZ-4). Use for display; use id for API calls.' }, + projectCode: { type: 'string', description: 'Project code from PROJECTS.code (e.g. ZAZZ).' }, + deliverableCode: { type: 'string', description: 'Deliverable code from DELIVERABLES.code (e.g. ZAZZ-4). Use for display; use id for API calls.' }, name: { type: 'string' }, description: { type: 'string', nullable: true }, type: { type: 'string', enum: ['FEATURE', 'BUG_FIX', 'REFACTOR', 'ENHANCEMENT', 'CHORE', 'DOCUMENTATION'] }, diff --git a/api/src/schemas/deliverables.js b/api/src/schemas/deliverables.js index f610a9ea..4b989c26 100644 --- a/api/src/schemas/deliverables.js +++ b/api/src/schemas/deliverables.js @@ -50,7 +50,7 @@ export const deliverableSchemas = { createDeliverable: { tags: ['deliverables'], summary: 'Create deliverable', - description: 'Creates a new deliverable card in the project. Use this when starting work on a new feature, bug fix, or other work item. The response includes id (numeric—use for create task and other API paths) and deliverableCode (string, e.g. ZAZZ-4—use for display). You can include specFilepath and planFilepath on create if known, or add them later via update deliverable.', + description: 'Creates a new deliverable card in the project. Use this when starting work on a new feature, bug fix, or other work item. The response includes id (numeric—use for create task and other API paths), projectCode (project code), and deliverableCode (string, e.g. ZAZZ-4—use for display). You can include specFilepath and planFilepath on create if known, or add them later via update deliverable.', params: { type: 'object', required: ['projectCode'], diff --git a/api/src/schemas/projects.js b/api/src/schemas/projects.js index 4f666276..dd387a6f 100644 --- a/api/src/schemas/projects.js +++ b/api/src/schemas/projects.js @@ -161,13 +161,13 @@ export const projectSchemas = { createDeliverableTask: { tags: ['projects'], summary: 'Create task in deliverable', - description: 'Creates a task within a deliverable. delivId is the numeric id from the create deliverable response (not deliverableCode). The deliverable must be approved before creating tasks. Include prompt with goal, instructions, and acceptance criteria. Use phase and phaseStep to align with PLAN structure (e.g. phase 1, phaseStep \"1.2\").', + description: 'Creates a task within a deliverable. delivId is the numeric id from the create deliverable response (not the deliverable code string). The deliverable must be approved before creating tasks. Include prompt with goal, instructions, and acceptance criteria. Use phase and phaseStep to align with PLAN structure (e.g. phase 1, phaseStep \"1.2\").', params: { type: 'object', required: ['code', 'delivId'], properties: { code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, - delivId: { type: 'string', pattern: '^[0-9]+$', description: 'Numeric deliverable id from create deliverable response. Use the id field, not deliverableCode.' } + delivId: { type: 'string', pattern: '^[0-9]+$', description: 'Numeric deliverable id from create deliverable response. Use the id field, not the deliverable code string.' } } }, body: { diff --git a/api/src/services/databaseService.js b/api/src/services/databaseService.js index c0f76c34..7dca64fc 100644 --- a/api/src/services/databaseService.js +++ b/api/src/services/databaseService.js @@ -368,7 +368,7 @@ class DatabaseService { id: DELIVERABLES.id, projectId: DELIVERABLES.project_id, projectCode: DELIVERABLES.project_code, - deliverableCode: DELIVERABLES.deliverable_code, + deliverableCode: DELIVERABLES.code, name: DELIVERABLES.name, description: DELIVERABLES.description, type: DELIVERABLES.type, @@ -398,7 +398,7 @@ class DatabaseService { DELIVERABLES.id, DELIVERABLES.project_id, DELIVERABLES.project_code, - DELIVERABLES.deliverable_code, + DELIVERABLES.code, DELIVERABLES.name, DELIVERABLES.description, DELIVERABLES.type, @@ -428,7 +428,7 @@ class DatabaseService { id: DELIVERABLES.id, projectId: DELIVERABLES.project_id, projectCode: DELIVERABLES.project_code, - deliverableCode: DELIVERABLES.deliverable_code, + deliverableCode: DELIVERABLES.code, name: DELIVERABLES.name, description: DELIVERABLES.description, type: DELIVERABLES.type, @@ -467,7 +467,7 @@ class DatabaseService { const [project] = await tx.select().from(PROJECTS).where(eq(PROJECTS.id, projectId)).limit(1); if (!project) throw new Error('Project not found'); - const deliverableCode = `${project.code}-${project.next_deliverable_sequence}`; + const generatedCode = `${project.code}-${project.next_deliverable_sequence}`; await tx.update(PROJECTS) .set({ next_deliverable_sequence: project.next_deliverable_sequence + 1, updated_by: userId, updated_at: new Date() }) .where(eq(PROJECTS.id, projectId)); @@ -479,7 +479,7 @@ class DatabaseService { const [row] = await tx.insert(DELIVERABLES).values({ project_id: projectId, project_code: project.code, - deliverable_code: deliverableCode, + code: generatedCode, name: data.name, description: data.description, type: data.type, From cc11c3bb0376183889597033c09f328855aeec6f Mon Sep 17 00:00:00 2001 From: michaelwitz Date: Sun, 8 Mar 2026 14:22:19 -0400 Subject: [PATCH 10/15] docs(skills): finalize ZAZZ-6 spec/plan updates and add ZAZZ-8 playwright spec draft --- .agents/skills/worker-agent/SKILL.md | 11 +- .agents/skills/worker-agent/scripts/README.md | 19 +- .agents/skills/worker-agent/scripts/zazzctl | 963 +----------------- .agents/skills/zazz-board-api/SKILL.md | 22 + .../skills/zazz-board-api/scripts/README.md | 46 + .../skills/zazz-board-api/scripts/zazzctl.mjs | 836 +++++++++++++++ ...ZZ-6-multiple-agent-tokens-feature-PLAN.md | 360 +++++++ ...ZZ-6-multiple-agent-tokens-feature-SPEC.md | 154 ++- .../ZAZZ-8-playwright-ui-testing-SPEC.md | 174 ++++ .zazz/deliverables/index.yaml | 1 + 10 files changed, 1573 insertions(+), 1013 deletions(-) create mode 100644 .agents/skills/zazz-board-api/scripts/README.md create mode 100644 .agents/skills/zazz-board-api/scripts/zazzctl.mjs create mode 100644 .zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-PLAN.md create mode 100644 .zazz/deliverables/ZAZZ-8-playwright-ui-testing-SPEC.md diff --git a/.agents/skills/worker-agent/SKILL.md b/.agents/skills/worker-agent/SKILL.md index 09fa7fc3..b74c177f 100644 --- a/.agents/skills/worker-agent/SKILL.md +++ b/.agents/skills/worker-agent/SKILL.md @@ -31,14 +31,15 @@ Live OpenAPI is the route contract source of truth. ## Required Worker CLI Adapter Canonical command adapter path: -- `.agents/skills/worker-agent/scripts/zazzctl` -- setup guide: `.agents/skills/worker-agent/scripts/README.md` +- `.agents/skills/zazz-board-api/scripts/zazzctl.mjs` +- setup guide: `.agents/skills/zazz-board-api/scripts/README.md` -Convenience wrapper: -- `scripts/zazzctl` (delegates to the canonical skill script) +Worker wrapper: +- `.agents/skills/worker-agent/scripts/zazzctl` (calls canonical CLI with `--profile worker`) +- optional repo wrapper: `scripts/zazzctl` Rule: -- Use `zazzctl` for worker board API writes/reads (tasks, relations, status, blockers, notes, graph checks, locks). +- Use `zazzctl --profile worker` for worker board API writes/reads (tasks, relations, status, blockers, notes, graph checks, locks). - Do not handcraft ad-hoc curl calls for normal worker execution when `zazzctl` is available. --- diff --git a/.agents/skills/worker-agent/scripts/README.md b/.agents/skills/worker-agent/scripts/README.md index a3ddd29c..669961d3 100644 --- a/.agents/skills/worker-agent/scripts/README.md +++ b/.agents/skills/worker-agent/scripts/README.md @@ -1,14 +1,15 @@ # zazzctl Setup (Worker Skill) -This directory contains the canonical worker adapter: +This directory contains a worker profile wrapper: - `zazzctl` -Use this script as the standard board API adapter for worker agents across projects and worktrees. +Canonical implementation lives in: +- `.agents/skills/zazz-board-api/scripts/zazzctl.mjs` + +Use this wrapper for worker agents across projects and worktrees. ## Requirements -- POSIX shell (`/bin/sh`) -- `curl` -- `jq` +- Node.js 22+ - network access to Zazz Board API ## Environment @@ -28,18 +29,18 @@ export ZAZZCTL_PRETTY=1 Use the checked-in script directly: ```bash -./.agents/skills/worker-agent/scripts/zazzctl help now +./.agents/skills/worker-agent/scripts/zazzctl help ``` ### Convenience wrapper in repo root Optional wrapper file at `scripts/zazzctl` can delegate to this script. ### Other project/worktree -Copy only this script into your target repo, then make it executable: +Copy the canonical Node CLI and (optionally) this wrapper into your target repo: ```bash -cp /path/to/source/.agents/skills/worker-agent/scripts/zazzctl ./scripts/zazzctl -chmod +x ./scripts/zazzctl +cp /path/to/source/.agents/skills/zazz-board-api/scripts/zazzctl.mjs ./scripts/zazzctl.mjs +chmod +x ./scripts/zazzctl.mjs ``` ## Worker Protocol Commands diff --git a/.agents/skills/worker-agent/scripts/zazzctl b/.agents/skills/worker-agent/scripts/zazzctl index 99492758..52d325fd 100755 --- a/.agents/skills/worker-agent/scripts/zazzctl +++ b/.agents/skills/worker-agent/scripts/zazzctl @@ -1,952 +1,19 @@ -#!/usr/bin/env sh -set -eu +#!/usr/bin/env node +const { spawn } = require('node:child_process'); +const path = require('node:path'); -ZAZZ_API_BASE_URL="${ZAZZ_API_BASE_URL:-http://localhost:3030}" -ZAZZ_API_TOKEN="${ZAZZ_API_TOKEN:-550e8400-e29b-41d4-a716-446655440000}" -ZAZZ_PROJECT_CODE="${ZAZZ_PROJECT_CODE:-ZAZZ}" -ZAZZCTL_PRETTY="${ZAZZCTL_PRETTY:-1}" +const cliPath = path.resolve(__dirname, '../../zazz-board-api/scripts/zazzctl.mjs'); +const args = ['--profile', 'worker', ...process.argv.slice(2)]; -LAST_HTTP="" -LAST_BODY="" +const child = spawn(process.execPath, [cliPath, ...args], { + stdio: 'inherit', +}); -usage() { - cat <<'USAGE' -Usage: zazzctl [options] +child.on('exit', (code) => { + process.exit(code ?? 1); +}); -Resources: - deliverable list|get|create|status|approve|tasks - task list|create|get|update|status|block|unblock|note|delete|readiness - relation list|add|delete - graph get - lock list|acquire|heartbeat|release - exec begin|tick|complete - -Global env defaults: - ZAZZ_API_BASE_URL (default: http://localhost:3030) - ZAZZ_API_TOKEN (default fallback: test token) - ZAZZ_PROJECT_CODE (default: ZAZZ) - ZAZZCTL_PRETTY (1 pretty JSON, 0 raw) - -Examples: - zazzctl task create --deliverable-id 8 --title "Implement API" --phase 2 --phase-step 2.1 --prompt "..." - zazzctl task status --deliverable-id 8 --task-id 25 --status IN_PROGRESS --agent-name worker-1 - zazzctl lock acquire --deliverable-id 8 --task-id 25 --agent-name worker-1 --file api/src/routes/fileLocks.js - zazzctl exec begin --deliverable-id 8 --task-id 25 --agent-name worker-1 --file api/src/routes/fileLocks.js - -Exit codes: - 0 success - 2 usage error - 10 lock conflict (409 FILE_LOCK_CONFLICT) - 20 API/client error (4xx) - 30 server/network error -USAGE -} - -err() { - echo "zazzctl: $*" >&2 -} - -die_usage() { - err "$*" - usage >&2 - exit 2 -} - -require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - die_usage "Missing required dependency: $1" - fi -} - -require_value() { - name="$1" - value="$2" - if [ -z "$value" ]; then - die_usage "Missing required option: $name" - fi -} - -is_success_http() { - code="$1" - [ "$code" -ge 200 ] && [ "$code" -lt 300 ] -} - -print_json() { - body="$1" - if [ "$ZAZZCTL_PRETTY" = "1" ] && printf '%s' "$body" | jq -e . >/dev/null 2>&1; then - printf '%s\n' "$body" | jq . - else - printf '%s\n' "$body" - fi -} - -http_to_exit() { - code="$1" - if is_success_http "$code"; then - echo 0 - return - fi - - if [ "$code" -eq 409 ] && printf '%s' "$LAST_BODY" | jq -e '.error == "FILE_LOCK_CONFLICT"' >/dev/null 2>&1; then - echo 10 - return - fi - - if [ "$code" -ge 400 ] && [ "$code" -lt 500 ]; then - echo 20 - return - fi - - echo 30 -} - -exit_with_last_response() { - print_json "$LAST_BODY" - exit "$(http_to_exit "$LAST_HTTP")" -} - -api_request() { - method="$1" - path="$2" - body="${3:-}" - url="${ZAZZ_API_BASE_URL%/}${path}" - - if [ -n "$body" ]; then - if ! response=$(curl -sS -X "$method" \ - -H "TB_TOKEN: ${ZAZZ_API_TOKEN}" \ - -H "Authorization: Bearer ${ZAZZ_API_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "$body" \ - "$url" \ - -w "\n__HTTP_STATUS__:%{http_code}"); then - LAST_HTTP="000" - LAST_BODY='{"error":"NETWORK_ERROR","message":"Failed to reach API"}' - return 1 - fi - else - if ! response=$(curl -sS -X "$method" \ - -H "TB_TOKEN: ${ZAZZ_API_TOKEN}" \ - -H "Authorization: Bearer ${ZAZZ_API_TOKEN}" \ - "$url" \ - -w "\n__HTTP_STATUS__:%{http_code}"); then - LAST_HTTP="000" - LAST_BODY='{"error":"NETWORK_ERROR","message":"Failed to reach API"}' - return 1 - fi - fi - - LAST_HTTP=$(printf '%s\n' "$response" | awk -F: '/__HTTP_STATUS__/{print $2}' | tail -n1) - LAST_BODY=$(printf '%s\n' "$response" | sed '/__HTTP_STATUS__:/d') - [ -n "$LAST_HTTP" ] || LAST_HTTP="000" - return 0 -} - -call_api() { - if ! api_request "$@"; then - print_json "$LAST_BODY" - exit 30 - fi -} - -csv_files_to_json() { - input="$1" - printf '%s' "$input" | jq -Rc 'split(",") | map(gsub("^\\s+|\\s+$";"")) | map(select(length>0))' -} - -csv_ints_to_json() { - input="$1" - printf '%s' "$input" | jq -Rc 'split(",") | map(gsub("^\\s+|\\s+$";"")) | map(select(length>0) | tonumber)' -} - -append_csv() { - current="$1" - value="$2" - if [ -z "$current" ]; then - printf '%s' "$value" - else - printf '%s,%s' "$current" "$value" - fi -} - -require_cmd curl -require_cmd jq - -if [ "$#" -lt 2 ]; then - usage - exit 2 -fi - -resource="$1" -action="$2" -shift 2 - -case "$resource:$action" in - deliverable:list) - project="$ZAZZ_PROJECT_CODE" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - call_api GET "/projects/${project}/deliverables" - exit_with_last_response - ;; - - deliverable:get) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--deliverable-id" "$deliverable_id" - call_api GET "/projects/${project}/deliverables/${deliverable_id}" - exit_with_last_response - ;; - - deliverable:create) - project="$ZAZZ_PROJECT_CODE" - name="" - type="" - description="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --name) name="$2"; shift 2 ;; - --type) type="$2"; shift 2 ;; - --description) description="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--name" "$name" - require_value "--type" "$type" - body=$(jq -n \ - --arg name "$name" \ - --arg type "$type" \ - --arg description "$description" \ - '{name:$name,type:$type} + (if $description != "" then {description:$description} else {} end)') - call_api POST "/projects/${project}/deliverables" "$body" - exit_with_last_response - ;; - - deliverable:status) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - status="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - --status) status="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--deliverable-id" "$deliverable_id" - require_value "--status" "$status" - body=$(jq -n --arg status "$status" '{status:$status}') - call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/status" "$body" - exit_with_last_response - ;; - - deliverable:approve) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--deliverable-id" "$deliverable_id" - call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/approve" '{}' - exit_with_last_response - ;; - - deliverable:tasks|task:list) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--deliverable-id" "$deliverable_id" - call_api GET "/projects/${project}/deliverables/${deliverable_id}/tasks" - exit_with_last_response - ;; - - task:create) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - title="" - prompt="" - description="" - status="" - priority="" - agent_name="" - phase="" - phase_step="" - dependencies_csv="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - --title) title="$2"; shift 2 ;; - --prompt) prompt="$2"; shift 2 ;; - --description) description="$2"; shift 2 ;; - --status) status="$2"; shift 2 ;; - --priority) priority="$2"; shift 2 ;; - --agent-name) agent_name="$2"; shift 2 ;; - --phase) phase="$2"; shift 2 ;; - --phase-step) phase_step="$2"; shift 2 ;; - --dependencies) dependencies_csv="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--deliverable-id" "$deliverable_id" - require_value "--title" "$title" - - dependencies_json='[]' - if [ -n "$dependencies_csv" ]; then - dependencies_json=$(csv_ints_to_json "$dependencies_csv") - fi - - body=$(jq -n \ - --arg title "$title" \ - --arg prompt "$prompt" \ - --arg description "$description" \ - --arg status "$status" \ - --arg priority "$priority" \ - --arg agentName "$agent_name" \ - --arg phase "$phase" \ - --arg phaseStep "$phase_step" \ - --argjson dependencies "$dependencies_json" \ - '{title:$title} - + (if $prompt != "" then {prompt:$prompt} else {} end) - + (if $description != "" then {description:$description} else {} end) - + (if $status != "" then {status:$status} else {} end) - + (if $priority != "" then {priority:$priority} else {} end) - + (if $agentName != "" then {agentName:$agentName} else {} end) - + (if $phase != "" then {phase:($phase|tonumber)} else {} end) - + (if $phaseStep != "" then {phaseStep:$phaseStep} else {} end) - + (if ($dependencies|length) > 0 then {dependencies:$dependencies} else {} end)') - - call_api POST "/projects/${project}/deliverables/${deliverable_id}/tasks" "$body" - exit_with_last_response - ;; - - task:get) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - task_id="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - --task-id) task_id="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--deliverable-id" "$deliverable_id" - require_value "--task-id" "$task_id" - call_api GET "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}" - exit_with_last_response - ;; - - task:update) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - task_id="" - json_payload="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - --task-id) task_id="$2"; shift 2 ;; - --json) json_payload="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--deliverable-id" "$deliverable_id" - require_value "--task-id" "$task_id" - require_value "--json" "$json_payload" - if ! printf '%s' "$json_payload" | jq -e . >/dev/null 2>&1; then - die_usage "--json must be valid JSON" - fi - call_api PUT "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}" "$json_payload" - exit_with_last_response - ;; - - task:status) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - task_id="" - status="" - agent_name="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - --task-id) task_id="$2"; shift 2 ;; - --status) status="$2"; shift 2 ;; - --agent-name) agent_name="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--deliverable-id" "$deliverable_id" - require_value "--task-id" "$task_id" - require_value "--status" "$status" - body=$(jq -n --arg status "$status" --arg agentName "$agent_name" '{status:$status} + (if $agentName != "" then {agentName:$agentName} else {} end)') - call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}/status" "$body" - exit_with_last_response - ;; - - task:block) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - task_id="" - reason="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - --task-id) task_id="$2"; shift 2 ;; - --reason) reason="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--deliverable-id" "$deliverable_id" - require_value "--task-id" "$task_id" - require_value "--reason" "$reason" - body=$(jq -n --arg reason "$reason" '{isBlocked:true,blockedReason:$reason}') - call_api PUT "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}" "$body" - exit_with_last_response - ;; - - task:unblock) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - task_id="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - --task-id) task_id="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--deliverable-id" "$deliverable_id" - require_value "--task-id" "$task_id" - body='{"isBlocked":false,"blockedReason":null}' - call_api PUT "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}" "$body" - exit_with_last_response - ;; - - task:note) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - task_id="" - note="" - agent_name="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - --task-id) task_id="$2"; shift 2 ;; - --note) note="$2"; shift 2 ;; - --agent-name) agent_name="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--deliverable-id" "$deliverable_id" - require_value "--task-id" "$task_id" - require_value "--note" "$note" - body=$(jq -n --arg note "$note" --arg agentName "$agent_name" '{note:$note} + (if $agentName != "" then {agentName:$agentName} else {} end)') - call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}/notes" "$body" - exit_with_last_response - ;; - - task:delete) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - task_id="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - --task-id) task_id="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--deliverable-id" "$deliverable_id" - require_value "--task-id" "$task_id" - call_api DELETE "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}" - exit_with_last_response - ;; - - task:readiness) - project="$ZAZZ_PROJECT_CODE" - task_id="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --task-id) task_id="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--task-id" "$task_id" - call_api GET "/projects/${project}/tasks/${task_id}/readiness" - exit_with_last_response - ;; - - relation:list) - project="$ZAZZ_PROJECT_CODE" - task_id="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --task-id) task_id="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--task-id" "$task_id" - call_api GET "/projects/${project}/tasks/${task_id}/relations" - exit_with_last_response - ;; - - relation:add) - project="$ZAZZ_PROJECT_CODE" - task_id="" - related_task_id="" - relation_type="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --task-id) task_id="$2"; shift 2 ;; - --related-task-id) related_task_id="$2"; shift 2 ;; - --type) relation_type="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--task-id" "$task_id" - require_value "--related-task-id" "$related_task_id" - require_value "--type" "$relation_type" - body=$(jq -n --argjson relatedTaskId "$related_task_id" --arg relationType "$relation_type" '{relatedTaskId:$relatedTaskId, relationType:$relationType}') - call_api POST "/projects/${project}/tasks/${task_id}/relations" "$body" - exit_with_last_response - ;; - - relation:delete) - project="$ZAZZ_PROJECT_CODE" - task_id="" - related_task_id="" - relation_type="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --task-id) task_id="$2"; shift 2 ;; - --related-task-id) related_task_id="$2"; shift 2 ;; - --type) relation_type="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--task-id" "$task_id" - require_value "--related-task-id" "$related_task_id" - require_value "--type" "$relation_type" - call_api DELETE "/projects/${project}/tasks/${task_id}/relations/${related_task_id}/${relation_type}" - exit_with_last_response - ;; - - graph:get) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--deliverable-id" "$deliverable_id" - call_api GET "/projects/${project}/deliverables/${deliverable_id}/graph" - exit_with_last_response - ;; - - lock:list) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - *) die_usage "Unknown option: $1" ;; - esac - done - require_value "--deliverable-id" "$deliverable_id" - call_api GET "/projects/${project}/deliverables/${deliverable_id}/locks" - exit_with_last_response - ;; - - lock:acquire|lock:heartbeat|lock:release) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - task_id="" - agent_name="" - phase_step="" - ttl_seconds="" - files_csv="" - - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - --task-id) task_id="$2"; shift 2 ;; - --agent-name) agent_name="$2"; shift 2 ;; - --phase-step) phase_step="$2"; shift 2 ;; - --ttl-seconds) ttl_seconds="$2"; shift 2 ;; - --file) - files_csv=$(append_csv "$files_csv" "$2") - shift 2 - ;; - --files) - files_csv=$(append_csv "$files_csv" "$2") - shift 2 - ;; - *) die_usage "Unknown option: $1" ;; - esac - done - - require_value "--deliverable-id" "$deliverable_id" - require_value "--task-id" "$task_id" - require_value "--agent-name" "$agent_name" - - files_json='[]' - if [ -n "$files_csv" ]; then - files_json=$(csv_files_to_json "$files_csv") - fi - - case "$resource:$action" in - lock:acquire) - if [ "$files_json" = "[]" ]; then - die_usage "lock acquire requires at least one --file or --files" - fi - body=$(jq -n \ - --argjson taskId "$task_id" \ - --arg agentName "$agent_name" \ - --arg phaseStep "$phase_step" \ - --arg ttl "$ttl_seconds" \ - --argjson fileRelativePaths "$files_json" \ - '{taskId:$taskId,agentName:$agentName,fileRelativePaths:$fileRelativePaths} - + (if $phaseStep != "" then {phaseStep:$phaseStep} else {} end) - + (if $ttl != "" then {ttlSeconds:($ttl|tonumber)} else {} end)') - call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/acquire" "$body" - exit_with_last_response - ;; - lock:heartbeat) - body=$(jq -n \ - --argjson taskId "$task_id" \ - --arg agentName "$agent_name" \ - --arg ttl "$ttl_seconds" \ - --argjson fileRelativePaths "$files_json" \ - '{taskId:$taskId,agentName:$agentName} - + (if ($fileRelativePaths|length) > 0 then {fileRelativePaths:$fileRelativePaths} else {} end) - + (if $ttl != "" then {ttlSeconds:($ttl|tonumber)} else {} end)') - call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/heartbeat" "$body" - exit_with_last_response - ;; - lock:release) - body=$(jq -n \ - --argjson taskId "$task_id" \ - --arg agentName "$agent_name" \ - --argjson fileRelativePaths "$files_json" \ - '{taskId:$taskId,agentName:$agentName} - + (if ($fileRelativePaths|length) > 0 then {fileRelativePaths:$fileRelativePaths} else {} end)') - call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/release" "$body" - exit_with_last_response - ;; - esac - ;; - - exec:begin) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - task_id="" - agent_name="" - phase_step="" - ttl_seconds="" - target_status="IN_PROGRESS" - files_csv="" - - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - --task-id) task_id="$2"; shift 2 ;; - --agent-name) agent_name="$2"; shift 2 ;; - --phase-step) phase_step="$2"; shift 2 ;; - --ttl-seconds) ttl_seconds="$2"; shift 2 ;; - --status) target_status="$2"; shift 2 ;; - --file) - files_csv=$(append_csv "$files_csv" "$2") - shift 2 - ;; - --files) - files_csv=$(append_csv "$files_csv" "$2") - shift 2 - ;; - *) die_usage "Unknown option: $1" ;; - esac - done - - require_value "--deliverable-id" "$deliverable_id" - require_value "--task-id" "$task_id" - require_value "--agent-name" "$agent_name" - - files_json=$(csv_files_to_json "$files_csv") - if [ "$files_json" = "[]" ]; then - die_usage "exec begin requires at least one --file or --files" - fi - - acquire_body=$(jq -n \ - --argjson taskId "$task_id" \ - --arg agentName "$agent_name" \ - --arg phaseStep "$phase_step" \ - --arg ttl "$ttl_seconds" \ - --argjson fileRelativePaths "$files_json" \ - '{taskId:$taskId,agentName:$agentName,fileRelativePaths:$fileRelativePaths} - + (if $phaseStep != "" then {phaseStep:$phaseStep} else {} end) - + (if $ttl != "" then {ttlSeconds:($ttl|tonumber)} else {} end)') - - call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/acquire" "$acquire_body" - acquire_http="$LAST_HTTP" - acquire_resp="$LAST_BODY" - - if [ "$acquire_http" -eq 409 ] && printf '%s' "$acquire_resp" | jq -e '.error == "FILE_LOCK_CONFLICT"' >/dev/null 2>&1; then - block_body='{"isBlocked":true,"blockedReason":"FILE_LOCK"}' - call_api PUT "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}" "$block_body" - block_http="$LAST_HTTP" - block_resp="$LAST_BODY" - - LAST_BODY=$(jq -n \ - --argjson acquire "$acquire_resp" \ - --arg acquireHttp "$acquire_http" \ - --argjson block "$block_resp" \ - --arg blockHttp "$block_http" \ - '{acquire:$acquire,acquireHttp:($acquireHttp|tonumber),blockUpdate:$block,blockHttp:($blockHttp|tonumber)}') - LAST_HTTP=409 - print_json "$LAST_BODY" - exit 10 - fi - - if ! is_success_http "$acquire_http"; then - LAST_HTTP="$acquire_http" - LAST_BODY="$acquire_resp" - exit_with_last_response - fi - - unblock_body='{"isBlocked":false,"blockedReason":null}' - call_api PUT "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}" "$unblock_body" - unblock_http="$LAST_HTTP" - unblock_resp="$LAST_BODY" - - status_body=$(jq -n --arg status "$target_status" --arg agentName "$agent_name" '{status:$status,agentName:$agentName}') - call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}/status" "$status_body" - status_http="$LAST_HTTP" - status_resp="$LAST_BODY" - - LAST_BODY=$(jq -n \ - --argjson acquire "$acquire_resp" \ - --arg acquireHttp "$acquire_http" \ - --argjson unblock "$unblock_resp" \ - --arg unblockHttp "$unblock_http" \ - --argjson status "$status_resp" \ - --arg statusHttp "$status_http" \ - '{acquire:$acquire,acquireHttp:($acquireHttp|tonumber),unblock:$unblock,unblockHttp:($unblockHttp|tonumber),status:$status,statusHttp:($statusHttp|tonumber)}') - - if is_success_http "$unblock_http" && is_success_http "$status_http"; then - LAST_HTTP=200 - elif [ "$unblock_http" -ge 400 ] && [ "$unblock_http" -lt 500 ] || [ "$status_http" -ge 400 ] && [ "$status_http" -lt 500 ]; then - LAST_HTTP=400 - else - LAST_HTTP=500 - fi - - exit_with_last_response - ;; - - exec:tick) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - task_id="" - agent_name="" - ttl_seconds="" - note="" - files_csv="" - - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - --task-id) task_id="$2"; shift 2 ;; - --agent-name) agent_name="$2"; shift 2 ;; - --ttl-seconds) ttl_seconds="$2"; shift 2 ;; - --note) note="$2"; shift 2 ;; - --file) - files_csv=$(append_csv "$files_csv" "$2") - shift 2 - ;; - --files) - files_csv=$(append_csv "$files_csv" "$2") - shift 2 - ;; - *) die_usage "Unknown option: $1" ;; - esac - done - - require_value "--deliverable-id" "$deliverable_id" - require_value "--task-id" "$task_id" - require_value "--agent-name" "$agent_name" - - files_json='[]' - if [ -n "$files_csv" ]; then - files_json=$(csv_files_to_json "$files_csv") - fi - - heartbeat_body=$(jq -n \ - --argjson taskId "$task_id" \ - --arg agentName "$agent_name" \ - --arg ttl "$ttl_seconds" \ - --argjson fileRelativePaths "$files_json" \ - '{taskId:$taskId,agentName:$agentName} - + (if ($fileRelativePaths|length) > 0 then {fileRelativePaths:$fileRelativePaths} else {} end) - + (if $ttl != "" then {ttlSeconds:($ttl|tonumber)} else {} end)') - - call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/heartbeat" "$heartbeat_body" - heartbeat_http="$LAST_HTTP" - heartbeat_resp="$LAST_BODY" - - note_http=0 - note_resp='{}' - if [ -n "$note" ]; then - note_body=$(jq -n --arg note "$note" --arg agentName "$agent_name" '{note:$note,agentName:$agentName}') - call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}/notes" "$note_body" - note_http="$LAST_HTTP" - note_resp="$LAST_BODY" - fi - - LAST_BODY=$(jq -n \ - --argjson heartbeat "$heartbeat_resp" \ - --arg heartbeatHttp "$heartbeat_http" \ - --argjson note "$note_resp" \ - --arg noteHttp "$note_http" \ - '{heartbeat:$heartbeat,heartbeatHttp:($heartbeatHttp|tonumber),note:$note,noteHttp:($noteHttp|tonumber)}') - - if is_success_http "$heartbeat_http" && { [ "$note_http" -eq 0 ] || is_success_http "$note_http"; }; then - LAST_HTTP=200 - elif [ "$heartbeat_http" -ge 400 ] && [ "$heartbeat_http" -lt 500 ] || { [ "$note_http" -ne 0 ] && [ "$note_http" -ge 400 ] && [ "$note_http" -lt 500 ]; }; then - LAST_HTTP=400 - else - LAST_HTTP=500 - fi - - exit_with_last_response - ;; - - exec:complete) - project="$ZAZZ_PROJECT_CODE" - deliverable_id="" - task_id="" - agent_name="" - status="COMPLETED" - note="" - files_csv="" - - while [ "$#" -gt 0 ]; do - case "$1" in - --project) project="$2"; shift 2 ;; - --deliverable-id) deliverable_id="$2"; shift 2 ;; - --task-id) task_id="$2"; shift 2 ;; - --agent-name) agent_name="$2"; shift 2 ;; - --status) status="$2"; shift 2 ;; - --note) note="$2"; shift 2 ;; - --file) - files_csv=$(append_csv "$files_csv" "$2") - shift 2 - ;; - --files) - files_csv=$(append_csv "$files_csv" "$2") - shift 2 - ;; - *) die_usage "Unknown option: $1" ;; - esac - done - - require_value "--deliverable-id" "$deliverable_id" - require_value "--task-id" "$task_id" - require_value "--agent-name" "$agent_name" - - note_http=0 - note_resp='{}' - if [ -n "$note" ]; then - note_body=$(jq -n --arg note "$note" --arg agentName "$agent_name" '{note:$note,agentName:$agentName}') - call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}/notes" "$note_body" - note_http="$LAST_HTTP" - note_resp="$LAST_BODY" - fi - - status_body=$(jq -n --arg status "$status" --arg agentName "$agent_name" '{status:$status,agentName:$agentName}') - call_api PATCH "/projects/${project}/deliverables/${deliverable_id}/tasks/${task_id}/status" "$status_body" - status_http="$LAST_HTTP" - status_resp="$LAST_BODY" - - files_json='[]' - if [ -n "$files_csv" ]; then - files_json=$(csv_files_to_json "$files_csv") - fi - release_body=$(jq -n \ - --argjson taskId "$task_id" \ - --arg agentName "$agent_name" \ - --argjson fileRelativePaths "$files_json" \ - '{taskId:$taskId,agentName:$agentName} - + (if ($fileRelativePaths|length) > 0 then {fileRelativePaths:$fileRelativePaths} else {} end)') - - call_api POST "/projects/${project}/deliverables/${deliverable_id}/locks/release" "$release_body" - release_http="$LAST_HTTP" - release_resp="$LAST_BODY" - - LAST_BODY=$(jq -n \ - --argjson note "$note_resp" \ - --arg noteHttp "$note_http" \ - --argjson status "$status_resp" \ - --arg statusHttp "$status_http" \ - --argjson release "$release_resp" \ - --arg releaseHttp "$release_http" \ - '{note:$note,noteHttp:($noteHttp|tonumber),status:$status,statusHttp:($statusHttp|tonumber),release:$release,releaseHttp:($releaseHttp|tonumber)}') - - if { [ "$note_http" -eq 0 ] || is_success_http "$note_http"; } && is_success_http "$status_http" && is_success_http "$release_http"; then - LAST_HTTP=200 - elif { [ "$note_http" -ne 0 ] && [ "$note_http" -ge 400 ] && [ "$note_http" -lt 500 ]; } || [ "$status_http" -ge 400 ] && [ "$status_http" -lt 500 ] || [ "$release_http" -ge 400 ] && [ "$release_http" -lt 500 ]; then - LAST_HTTP=400 - else - LAST_HTTP=500 - fi - - exit_with_last_response - ;; - - help:*|*:help) - usage - exit 0 - ;; - - *) - die_usage "Unknown command: ${resource} ${action}" - ;; -esac +child.on('error', (error) => { + process.stderr.write(`zazzctl worker wrapper failed: ${error.message}\n`); + process.exit(1); +}); diff --git a/.agents/skills/zazz-board-api/SKILL.md b/.agents/skills/zazz-board-api/SKILL.md index 844d209a..1b41d8ce 100644 --- a/.agents/skills/zazz-board-api/SKILL.md +++ b/.agents/skills/zazz-board-api/SKILL.md @@ -22,6 +22,25 @@ All API requests (except `/openapi.json`, `/health`, `/`, `/db-test`, `/token-in - `ZAZZ_API_BASE_URL` (fallback: `http://localhost:3030`) - `ZAZZ_API_TOKEN` (required token source; fallback if unset: `550e8400-e29b-41d4-a716-446655440000`) - `ZAZZ_PROJECT_CODE` (fallback: `ZAZZ`) +- `ZAZZCTL_PROFILE` (optional default profile: `generic`, `worker`, `planner`, `spec_builder`) + +--- + +## Canonical CLI Adapter (Required) +Use the canonical Node CLI for board communication: +- Script: `.agents/skills/zazz-board-api/scripts/zazzctl.mjs` +- Runtime prereq: Node.js 22+ (project baseline) + +CLI-first policy: +- Use `zazzctl` as the default communication path. +- Do not handcraft ad-hoc `curl` for normal execution. +- `curl` is allowed only for OpenAPI fetch/debugging when the CLI is missing a capability. + +Role profile usage: +- Worker: `zazzctl --profile worker ...` +- Planner: `zazzctl --profile planner ...` +- Spec Builder: `zazzctl --profile spec_builder ...` +- Generic (fallback): `zazzctl ...` or `zazzctl --profile generic ...` --- @@ -50,6 +69,7 @@ Core capabilities: - Acquire/heartbeat/release/list deliverable file locks - Get deliverable status workflow - Image operations (list/upload/delete/fetch/metadata) using project-scoped routes +- Spec-builder board sync: create deliverable, set deliverable status, set `specFilepath` --- @@ -115,6 +135,8 @@ Deliverable lifecycle (required): - Resolve project deliverable workflow from API/OpenAPI-capable endpoints. - Update deliverable status explicitly with status endpoints; do not assume implicit transitions. - Approve deliverable explicitly with approve endpoint when workflow requires it. +- Planner start gate: when planning starts, set deliverable status to `PLANNING`. +- Spec-builder gate: after deliverable creation, set default status to `BACKLOG` and persist `specFilepath`. Dependency lifecycle (required): - Treat `DEPENDS_ON` in PLAN as required `TASK_RELATIONS` rows. diff --git a/.agents/skills/zazz-board-api/scripts/README.md b/.agents/skills/zazz-board-api/scripts/README.md new file mode 100644 index 00000000..02ba6f45 --- /dev/null +++ b/.agents/skills/zazz-board-api/scripts/README.md @@ -0,0 +1,46 @@ +# zazzctl (Canonical Board CLI) + +Canonical location: +- `.agents/skills/zazz-board-api/scripts/zazzctl.mjs` + +Runtime: +- Node.js 22+ + +Quick start: +```bash +node .agents/skills/zazz-board-api/scripts/zazzctl.mjs help +``` + +Environment: +```bash +export ZAZZ_API_BASE_URL="http://localhost:3030" +export ZAZZ_API_TOKEN="${ZAZZ_API_TOKEN:-550e8400-e29b-41d4-a716-446655440000}" +export ZAZZ_PROJECT_CODE="ZAZZ" +``` + +Profiles: +- `worker`: task/relation/lock/exec workflow; read-only deliverable ops +- `planner`: deliverable planning updates and read checks +- `spec_builder`: deliverable create/status/update for SPEC sync +- `generic`: unrestricted adapter (use sparingly) + +Examples: +```bash +# Worker claim + lock protocol +node .agents/skills/zazz-board-api/scripts/zazzctl.mjs --profile worker exec begin \ + --deliverable-id 8 --task-id 25 --agent-name worker-1 --file api/src/routes/fileLocks.js + +# Planner sets planning status and plan path +node .agents/skills/zazz-board-api/scripts/zazzctl.mjs --profile planner deliverable status \ + --deliverable-id 4 --status PLANNING +node .agents/skills/zazz-board-api/scripts/zazzctl.mjs --profile planner deliverable update \ + --deliverable-id 4 --json '{"planFilepath":".zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-PLAN.md"}' + +# Spec builder creates deliverable, sets BACKLOG, then saves SPEC filepath +node .agents/skills/zazz-board-api/scripts/zazzctl.mjs --profile spec_builder deliverable create \ + --name "multiple-agent-tokens-feature" --type FEATURE +node .agents/skills/zazz-board-api/scripts/zazzctl.mjs --profile spec_builder deliverable status \ + --deliverable-id 4 --status BACKLOG +node .agents/skills/zazz-board-api/scripts/zazzctl.mjs --profile spec_builder deliverable update \ + --deliverable-id 4 --json '{"specFilepath":".zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-SPEC.md"}' +``` diff --git a/.agents/skills/zazz-board-api/scripts/zazzctl.mjs b/.agents/skills/zazz-board-api/scripts/zazzctl.mjs new file mode 100644 index 00000000..1ce60e2d --- /dev/null +++ b/.agents/skills/zazz-board-api/scripts/zazzctl.mjs @@ -0,0 +1,836 @@ +#!/usr/bin/env node +/** + * zazzctl (Node canonical CLI) + * Cross-platform board API adapter for all agent skills. + */ + +const DEFAULT_BASE_URL = 'http://localhost:3030'; +const DEFAULT_TOKEN = '550e8400-e29b-41d4-a716-446655440000'; +const DEFAULT_PROJECT = 'ZAZZ'; + +const env = process.env; +const config = { + baseUrl: env.ZAZZ_API_BASE_URL || DEFAULT_BASE_URL, + token: env.ZAZZ_API_TOKEN || DEFAULT_TOKEN, + projectCode: env.ZAZZ_PROJECT_CODE || DEFAULT_PROJECT, + pretty: env.ZAZZCTL_PRETTY !== '0', + profile: (env.ZAZZCTL_PROFILE || 'generic').toLowerCase(), +}; + +let lastHttp = 0; +let lastBody = {}; + +const PROFILE_ALLOW = { + generic: null, + worker: new Set([ + 'deliverable:list', + 'deliverable:get', + 'deliverable:tasks', + 'task:list', + 'task:create', + 'task:get', + 'task:update', + 'task:status', + 'task:block', + 'task:unblock', + 'task:note', + 'task:delete', + 'task:readiness', + 'relation:list', + 'relation:add', + 'relation:delete', + 'graph:get', + 'lock:list', + 'lock:acquire', + 'lock:heartbeat', + 'lock:release', + 'exec:begin', + 'exec:tick', + 'exec:complete', + ]), + planner: new Set([ + 'deliverable:list', + 'deliverable:get', + 'deliverable:update', + 'deliverable:status', + 'deliverable:approve', + 'deliverable:tasks', + 'task:list', + 'task:get', + 'task:readiness', + 'relation:list', + 'graph:get', + ]), + spec_builder: new Set([ + 'deliverable:create', + 'deliverable:update', + 'deliverable:status', + 'deliverable:get', + 'deliverable:list', + ]), +}; + +function usage() { + const text = `Usage: zazzctl [--profile generic|worker|planner|spec_builder] [options] + +Resources: + deliverable list|get|create|update|status|approve|tasks + task list|create|get|update|status|block|unblock|note|delete|readiness + relation list|add|delete + graph get + lock list|acquire|heartbeat|release + exec begin|tick|complete + +Environment: + ZAZZ_API_BASE_URL (default: ${DEFAULT_BASE_URL}) + ZAZZ_API_TOKEN (default fallback: seed token) + ZAZZ_PROJECT_CODE (default: ${DEFAULT_PROJECT}) + ZAZZCTL_PRETTY (1 pretty JSON, 0 compact) + ZAZZCTL_PROFILE (generic|worker|planner|spec_builder) + +Examples: + zazzctl --profile worker exec begin --deliverable-id 8 --task-id 25 --agent-name worker-1 --file api/src/routes/fileLocks.js + zazzctl --profile planner deliverable update --deliverable-id 4 --json '{"planFilepath":".zazz/deliverables/ZAZZ-6-PLAN.md"}' + zazzctl --profile spec_builder deliverable create --name "Agent Tokens" --type FEATURE --spec-filepath ".zazz/deliverables/ZAZZ-6-agent-tokens-SPEC.md" +`; + process.stderr.write(text); +} + +function dieUsage(message) { + process.stderr.write(`zazzctl: ${message}\n`); + usage(); + process.exit(2); +} + +function isSuccess(status) { + return status >= 200 && status < 300; +} + +function isClientError(status) { + return status >= 400 && status < 500; +} + +function httpToExit(status, body) { + if (isSuccess(status)) return 0; + if (status === 409 && body?.error === 'FILE_LOCK_CONFLICT') return 10; + if (isClientError(status)) return 20; + return 30; +} + +function toPrintedJson(value) { + if (typeof value === 'string') return value; + try { + return JSON.stringify(value ?? {}, null, config.pretty ? 2 : 0); + } catch { + return JSON.stringify({ value: String(value) }, null, config.pretty ? 2 : 0); + } +} + +function printBody(value) { + process.stdout.write(`${toPrintedJson(value)}\n`); +} + +function exitWithLastResponse(forcedCode = null) { + printBody(lastBody); + process.exit(forcedCode ?? httpToExit(lastHttp, lastBody)); +} + +function requireValue(flag, value) { + if (value === undefined || value === null || value === '') { + dieUsage(`Missing required option: ${flag}`); + } +} + +function parseJson(text, flagName = '--json') { + try { + return JSON.parse(text); + } catch { + dieUsage(`${flagName} must be valid JSON`); + } +} + +function parseIntStrict(value, flagName) { + const n = Number(value); + if (!Number.isFinite(n) || !Number.isInteger(n)) { + dieUsage(`${flagName} must be an integer`); + } + return n; +} + +function parseCsv(values) { + if (!values || values.length === 0) return []; + return values + .flatMap((entry) => String(entry).split(',')) + .map((s) => s.trim()) + .filter(Boolean); +} + +function parseCsvInts(csv) { + if (!csv) return []; + return csv + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .map((part) => parseIntStrict(part, '--dependencies')); +} + +function parseFlags(args, schema) { + const out = {}; + for (let i = 0; i < args.length; ) { + const flag = args[i]; + const def = schema[flag]; + if (!def) { + dieUsage(`Unknown option: ${flag}`); + } + if (def.boolean) { + out[def.key] = true; + i += 1; + continue; + } + const value = args[i + 1]; + if (value === undefined) { + dieUsage(`Missing value for ${flag}`); + } + if (def.multi) { + out[def.key] = out[def.key] || []; + out[def.key].push(value); + } else { + out[def.key] = value; + } + i += 2; + } + return out; +} + +function setLast(status, body) { + lastHttp = status; + lastBody = body; +} + +function parseResponseBody(text) { + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + return { raw: text }; + } +} + +async function apiRequest(method, path, body) { + const url = `${config.baseUrl.replace(/\/+$/, '')}${path}`; + const headers = { + TB_TOKEN: config.token, + Authorization: `Bearer ${config.token}`, + }; + const init = { method, headers }; + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + + try { + const response = await fetch(url, init); + const text = await response.text(); + return { status: response.status, body: parseResponseBody(text) }; + } catch (error) { + return { + status: 0, + body: { + error: 'NETWORK_ERROR', + message: 'Failed to reach API', + detail: error?.message || String(error), + }, + }; + } +} + +async function callApi(method, path, body) { + const result = await apiRequest(method, path, body); + setLast(result.status, result.body); + return result; +} + +function canonicalCommandKey(resource, action) { + return `${resource}:${action}`; +} + +function assertProfileAllowed(commandKey) { + const allow = PROFILE_ALLOW[config.profile]; + if (allow === undefined) { + dieUsage(`Unknown profile: ${config.profile}`); + } + if (allow === null) return; + if (allow.has(commandKey)) return; + setLast(403, { + error: 'PROFILE_FORBIDDEN', + message: `Command ${commandKey} is not allowed for profile ${config.profile}`, + }); + exitWithLastResponse(); +} + +async function handleDeliverable(action, args) { + const defs = { + '--project': { key: 'project' }, + '--deliverable-id': { key: 'deliverableId' }, + '--name': { key: 'name' }, + '--type': { key: 'type' }, + '--description': { key: 'description' }, + '--status': { key: 'status' }, + '--json': { key: 'json' }, + '--spec-filepath': { key: 'specFilepath' }, + '--plan-filepath': { key: 'planFilepath' }, + }; + const opt = parseFlags(args, defs); + const project = opt.project || config.projectCode; + + if (action === 'list') { + await callApi('GET', `/projects/${project}/deliverables`); + exitWithLastResponse(); + } + + if (action === 'get') { + requireValue('--deliverable-id', opt.deliverableId); + await callApi('GET', `/projects/${project}/deliverables/${opt.deliverableId}`); + exitWithLastResponse(); + } + + if (action === 'create') { + requireValue('--name', opt.name); + requireValue('--type', opt.type); + const body = { + name: opt.name, + type: opt.type, + }; + if (opt.description) body.description = opt.description; + if (opt.specFilepath) body.specFilepath = opt.specFilepath; + if (opt.planFilepath) body.planFilepath = opt.planFilepath; + await callApi('POST', `/projects/${project}/deliverables`, body); + exitWithLastResponse(); + } + + if (action === 'update') { + requireValue('--deliverable-id', opt.deliverableId); + requireValue('--json', opt.json); + const body = parseJson(opt.json); + await callApi('PUT', `/projects/${project}/deliverables/${opt.deliverableId}`, body); + exitWithLastResponse(); + } + + if (action === 'status') { + requireValue('--deliverable-id', opt.deliverableId); + requireValue('--status', opt.status); + await callApi('PATCH', `/projects/${project}/deliverables/${opt.deliverableId}/status`, { + status: opt.status, + }); + exitWithLastResponse(); + } + + if (action === 'approve') { + requireValue('--deliverable-id', opt.deliverableId); + await callApi('PATCH', `/projects/${project}/deliverables/${opt.deliverableId}/approve`, {}); + exitWithLastResponse(); + } + + if (action === 'tasks') { + requireValue('--deliverable-id', opt.deliverableId); + await callApi('GET', `/projects/${project}/deliverables/${opt.deliverableId}/tasks`); + exitWithLastResponse(); + } + + dieUsage(`Unknown command: deliverable ${action}`); +} + +async function handleTask(action, args) { + const defs = { + '--project': { key: 'project' }, + '--deliverable-id': { key: 'deliverableId' }, + '--task-id': { key: 'taskId' }, + '--title': { key: 'title' }, + '--prompt': { key: 'prompt' }, + '--description': { key: 'description' }, + '--status': { key: 'status' }, + '--priority': { key: 'priority' }, + '--agent-name': { key: 'agentName' }, + '--phase': { key: 'phase' }, + '--phase-step': { key: 'phaseStep' }, + '--dependencies': { key: 'dependencies' }, + '--json': { key: 'json' }, + '--reason': { key: 'reason' }, + '--note': { key: 'note' }, + }; + const opt = parseFlags(args, defs); + const project = opt.project || config.projectCode; + + if (action === 'list') { + requireValue('--deliverable-id', opt.deliverableId); + await callApi('GET', `/projects/${project}/deliverables/${opt.deliverableId}/tasks`); + exitWithLastResponse(); + } + + if (action === 'create') { + requireValue('--deliverable-id', opt.deliverableId); + requireValue('--title', opt.title); + const body = { title: opt.title }; + if (opt.prompt) body.prompt = opt.prompt; + if (opt.description) body.description = opt.description; + if (opt.status) body.status = opt.status; + if (opt.priority) body.priority = opt.priority; + if (opt.agentName) body.agentName = opt.agentName; + if (opt.phase !== undefined) body.phase = parseIntStrict(opt.phase, '--phase'); + if (opt.phaseStep) body.phaseStep = opt.phaseStep; + const deps = parseCsvInts(opt.dependencies || ''); + if (deps.length > 0) body.dependencies = deps; + await callApi('POST', `/projects/${project}/deliverables/${opt.deliverableId}/tasks`, body); + exitWithLastResponse(); + } + + if (action === 'get') { + requireValue('--deliverable-id', opt.deliverableId); + requireValue('--task-id', opt.taskId); + await callApi( + 'GET', + `/projects/${project}/deliverables/${opt.deliverableId}/tasks/${opt.taskId}`, + ); + exitWithLastResponse(); + } + + if (action === 'update') { + requireValue('--deliverable-id', opt.deliverableId); + requireValue('--task-id', opt.taskId); + requireValue('--json', opt.json); + await callApi( + 'PUT', + `/projects/${project}/deliverables/${opt.deliverableId}/tasks/${opt.taskId}`, + parseJson(opt.json), + ); + exitWithLastResponse(); + } + + if (action === 'status') { + requireValue('--deliverable-id', opt.deliverableId); + requireValue('--task-id', opt.taskId); + requireValue('--status', opt.status); + const body = { status: opt.status }; + if (opt.agentName) body.agentName = opt.agentName; + await callApi( + 'PATCH', + `/projects/${project}/deliverables/${opt.deliverableId}/tasks/${opt.taskId}/status`, + body, + ); + exitWithLastResponse(); + } + + if (action === 'block') { + requireValue('--deliverable-id', opt.deliverableId); + requireValue('--task-id', opt.taskId); + requireValue('--reason', opt.reason); + await callApi( + 'PUT', + `/projects/${project}/deliverables/${opt.deliverableId}/tasks/${opt.taskId}`, + { isBlocked: true, blockedReason: opt.reason }, + ); + exitWithLastResponse(); + } + + if (action === 'unblock') { + requireValue('--deliverable-id', opt.deliverableId); + requireValue('--task-id', opt.taskId); + await callApi( + 'PUT', + `/projects/${project}/deliverables/${opt.deliverableId}/tasks/${opt.taskId}`, + { isBlocked: false, blockedReason: null }, + ); + exitWithLastResponse(); + } + + if (action === 'note') { + requireValue('--deliverable-id', opt.deliverableId); + requireValue('--task-id', opt.taskId); + requireValue('--note', opt.note); + const body = { note: opt.note }; + if (opt.agentName) body.agentName = opt.agentName; + await callApi( + 'PATCH', + `/projects/${project}/deliverables/${opt.deliverableId}/tasks/${opt.taskId}/notes`, + body, + ); + exitWithLastResponse(); + } + + if (action === 'delete') { + requireValue('--deliverable-id', opt.deliverableId); + requireValue('--task-id', opt.taskId); + await callApi( + 'DELETE', + `/projects/${project}/deliverables/${opt.deliverableId}/tasks/${opt.taskId}`, + ); + exitWithLastResponse(); + } + + if (action === 'readiness') { + requireValue('--task-id', opt.taskId); + await callApi('GET', `/projects/${project}/tasks/${opt.taskId}/readiness`); + exitWithLastResponse(); + } + + dieUsage(`Unknown command: task ${action}`); +} + +async function handleRelation(action, args) { + const defs = { + '--project': { key: 'project' }, + '--task-id': { key: 'taskId' }, + '--related-task-id': { key: 'relatedTaskId' }, + '--type': { key: 'relationType' }, + }; + const opt = parseFlags(args, defs); + const project = opt.project || config.projectCode; + + if (action === 'list') { + requireValue('--task-id', opt.taskId); + await callApi('GET', `/projects/${project}/tasks/${opt.taskId}/relations`); + exitWithLastResponse(); + } + + if (action === 'add') { + requireValue('--task-id', opt.taskId); + requireValue('--related-task-id', opt.relatedTaskId); + requireValue('--type', opt.relationType); + await callApi('POST', `/projects/${project}/tasks/${opt.taskId}/relations`, { + relatedTaskId: parseIntStrict(opt.relatedTaskId, '--related-task-id'), + relationType: opt.relationType, + }); + exitWithLastResponse(); + } + + if (action === 'delete') { + requireValue('--task-id', opt.taskId); + requireValue('--related-task-id', opt.relatedTaskId); + requireValue('--type', opt.relationType); + await callApi( + 'DELETE', + `/projects/${project}/tasks/${opt.taskId}/relations/${opt.relatedTaskId}/${opt.relationType}`, + ); + exitWithLastResponse(); + } + + dieUsage(`Unknown command: relation ${action}`); +} + +async function handleGraph(action, args) { + if (action !== 'get') { + dieUsage(`Unknown command: graph ${action}`); + } + const defs = { + '--project': { key: 'project' }, + '--deliverable-id': { key: 'deliverableId' }, + }; + const opt = parseFlags(args, defs); + const project = opt.project || config.projectCode; + requireValue('--deliverable-id', opt.deliverableId); + await callApi('GET', `/projects/${project}/deliverables/${opt.deliverableId}/graph`); + exitWithLastResponse(); +} + +async function handleLock(action, args) { + const defs = { + '--project': { key: 'project' }, + '--deliverable-id': { key: 'deliverableId' }, + '--task-id': { key: 'taskId' }, + '--agent-name': { key: 'agentName' }, + '--phase-step': { key: 'phaseStep' }, + '--ttl-seconds': { key: 'ttlSeconds' }, + '--file': { key: 'fileInputs', multi: true }, + '--files': { key: 'fileInputs', multi: true }, + }; + const opt = parseFlags(args, defs); + const project = opt.project || config.projectCode; + + if (action === 'list') { + requireValue('--deliverable-id', opt.deliverableId); + await callApi('GET', `/projects/${project}/deliverables/${opt.deliverableId}/locks`); + exitWithLastResponse(); + } + + requireValue('--deliverable-id', opt.deliverableId); + requireValue('--task-id', opt.taskId); + requireValue('--agent-name', opt.agentName); + + const files = parseCsv(opt.fileInputs || []); + const body = { + taskId: parseIntStrict(opt.taskId, '--task-id'), + agentName: opt.agentName, + }; + if (opt.phaseStep) body.phaseStep = opt.phaseStep; + if (opt.ttlSeconds !== undefined) { + body.ttlSeconds = parseIntStrict(opt.ttlSeconds, '--ttl-seconds'); + } + if (files.length > 0) { + body.fileRelativePaths = files; + } + + if (action === 'acquire') { + if (!body.fileRelativePaths || body.fileRelativePaths.length === 0) { + dieUsage('lock acquire requires at least one --file or --files'); + } + await callApi('POST', `/projects/${project}/deliverables/${opt.deliverableId}/locks/acquire`, body); + exitWithLastResponse(); + } + if (action === 'heartbeat') { + await callApi( + 'POST', + `/projects/${project}/deliverables/${opt.deliverableId}/locks/heartbeat`, + body, + ); + exitWithLastResponse(); + } + if (action === 'release') { + await callApi('POST', `/projects/${project}/deliverables/${opt.deliverableId}/locks/release`, body); + exitWithLastResponse(); + } + + dieUsage(`Unknown command: lock ${action}`); +} + +function aggregateStatus(statuses) { + if (statuses.every((status) => isSuccess(status))) return 200; + if (statuses.some((status) => isClientError(status))) return 400; + return 500; +} + +async function handleExec(action, args) { + const defs = { + '--project': { key: 'project' }, + '--deliverable-id': { key: 'deliverableId' }, + '--task-id': { key: 'taskId' }, + '--agent-name': { key: 'agentName' }, + '--phase-step': { key: 'phaseStep' }, + '--ttl-seconds': { key: 'ttlSeconds' }, + '--status': { key: 'status' }, + '--note': { key: 'note' }, + '--file': { key: 'fileInputs', multi: true }, + '--files': { key: 'fileInputs', multi: true }, + }; + const opt = parseFlags(args, defs); + const project = opt.project || config.projectCode; + requireValue('--deliverable-id', opt.deliverableId); + requireValue('--task-id', opt.taskId); + requireValue('--agent-name', opt.agentName); + + const deliverableId = opt.deliverableId; + const taskId = parseIntStrict(opt.taskId, '--task-id'); + const files = parseCsv(opt.fileInputs || []); + const ttl = opt.ttlSeconds !== undefined ? parseIntStrict(opt.ttlSeconds, '--ttl-seconds') : undefined; + + if (action === 'begin') { + if (files.length === 0) { + dieUsage('exec begin requires at least one --file or --files'); + } + const acquireBody = { + taskId, + agentName: opt.agentName, + fileRelativePaths: files, + }; + if (opt.phaseStep) acquireBody.phaseStep = opt.phaseStep; + if (ttl !== undefined) acquireBody.ttlSeconds = ttl; + + const acquire = await callApi( + 'POST', + `/projects/${project}/deliverables/${deliverableId}/locks/acquire`, + acquireBody, + ); + + if (acquire.status === 409 && acquire.body?.error === 'FILE_LOCK_CONFLICT') { + const block = await callApi( + 'PUT', + `/projects/${project}/deliverables/${deliverableId}/tasks/${taskId}`, + { isBlocked: true, blockedReason: 'FILE_LOCK' }, + ); + setLast(409, { + acquire: acquire.body, + acquireHttp: acquire.status, + blockUpdate: block.body, + blockHttp: block.status, + }); + exitWithLastResponse(10); + } + + if (!isSuccess(acquire.status)) { + setLast(acquire.status, acquire.body); + exitWithLastResponse(); + } + + const unblock = await callApi( + 'PUT', + `/projects/${project}/deliverables/${deliverableId}/tasks/${taskId}`, + { isBlocked: false, blockedReason: null }, + ); + + const statusTarget = opt.status || 'IN_PROGRESS'; + const status = await callApi( + 'PATCH', + `/projects/${project}/deliverables/${deliverableId}/tasks/${taskId}/status`, + { status: statusTarget, agentName: opt.agentName }, + ); + + setLast(aggregateStatus([unblock.status, status.status]), { + acquire: acquire.body, + acquireHttp: acquire.status, + unblock: unblock.body, + unblockHttp: unblock.status, + status: status.body, + statusHttp: status.status, + }); + exitWithLastResponse(); + } + + if (action === 'tick') { + const heartbeatBody = { taskId, agentName: opt.agentName }; + if (ttl !== undefined) heartbeatBody.ttlSeconds = ttl; + if (files.length > 0) heartbeatBody.fileRelativePaths = files; + + const heartbeat = await callApi( + 'POST', + `/projects/${project}/deliverables/${deliverableId}/locks/heartbeat`, + heartbeatBody, + ); + + let noteStatus = 0; + let noteBody = {}; + if (opt.note) { + const note = await callApi( + 'PATCH', + `/projects/${project}/deliverables/${deliverableId}/tasks/${taskId}/notes`, + { note: opt.note, agentName: opt.agentName }, + ); + noteStatus = note.status; + noteBody = note.body; + } + + const aggregate = aggregateStatus([ + heartbeat.status, + ...(noteStatus ? [noteStatus] : []), + ]); + setLast(aggregate, { + heartbeat: heartbeat.body, + heartbeatHttp: heartbeat.status, + note: noteBody, + noteHttp: noteStatus, + }); + exitWithLastResponse(); + } + + if (action === 'complete') { + let noteStatus = 0; + let noteBody = {}; + if (opt.note) { + const note = await callApi( + 'PATCH', + `/projects/${project}/deliverables/${deliverableId}/tasks/${taskId}/notes`, + { note: opt.note, agentName: opt.agentName }, + ); + noteStatus = note.status; + noteBody = note.body; + } + + const status = await callApi( + 'PATCH', + `/projects/${project}/deliverables/${deliverableId}/tasks/${taskId}/status`, + { status: opt.status || 'COMPLETED', agentName: opt.agentName }, + ); + + const releaseBody = { taskId, agentName: opt.agentName }; + if (files.length > 0) releaseBody.fileRelativePaths = files; + const release = await callApi( + 'POST', + `/projects/${project}/deliverables/${deliverableId}/locks/release`, + releaseBody, + ); + + const aggregate = aggregateStatus([ + ...(noteStatus ? [noteStatus] : []), + status.status, + release.status, + ]); + setLast(aggregate, { + note: noteBody, + noteHttp: noteStatus, + status: status.body, + statusHttp: status.status, + release: release.body, + releaseHttp: release.status, + }); + exitWithLastResponse(); + } + + dieUsage(`Unknown command: exec ${action}`); +} + +async function main() { + const args = process.argv.slice(2); + + while (args[0] === '--profile') { + const profileValue = args[1]; + requireValue('--profile', profileValue); + config.profile = String(profileValue).toLowerCase(); + args.splice(0, 2); + } + + if (args.length === 0) { + usage(); + process.exit(2); + } + + if (args.length === 1 && args[0] === 'help') { + usage(); + process.exit(0); + } + + const [resource, action, ...rest] = args; + if (!resource || !action) { + usage(); + process.exit(2); + } + if (resource === 'help' || action === 'help') { + usage(); + process.exit(0); + } + + const commandKey = canonicalCommandKey(resource, action); + assertProfileAllowed(commandKey); + + if (resource === 'deliverable') { + await handleDeliverable(action, rest); + return; + } + if (resource === 'task') { + await handleTask(action, rest); + return; + } + if (resource === 'relation') { + await handleRelation(action, rest); + return; + } + if (resource === 'graph') { + await handleGraph(action, rest); + return; + } + if (resource === 'lock') { + await handleLock(action, rest); + return; + } + if (resource === 'exec') { + await handleExec(action, rest); + return; + } + + dieUsage(`Unknown resource: ${resource}`); +} + +main().catch((error) => { + setLast(0, { + error: 'CLI_RUNTIME_ERROR', + message: error?.message || String(error), + }); + exitWithLastResponse(30); +}); diff --git a/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-PLAN.md b/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-PLAN.md new file mode 100644 index 00000000..e5b4f8af --- /dev/null +++ b/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-PLAN.md @@ -0,0 +1,360 @@ +# ZAZZ-6 Multiple Agent Tokens Feature PLAN + +## 1. Header Metadata +- Project Code: `ZAZZ` +- Deliverable Code: `ZAZZ-6` +- Deliverable ID: `4` +- SPEC Reference: `.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-SPEC.md` +- Status: `DRAFT` +- Planning basis (standards/docs reviewed): + - `README.md` + - `AGENTS.md` + - `.zazz/standards/index.yaml` + - `.zazz/standards/system-architecture.md` + - `.zazz/standards/data-architecture.md` + - `.zazz/standards/coding-styles.md` + - `.zazz/standards/testing.md` + - `.agents/skills/planner-agent/SKILL.md` + - `.agents/skills/zazz-board-api/SKILL.md` + - Live OpenAPI: `GET http://localhost:3030/openapi.json` (verified 2026-03-08) + +## 2. Scope Guardrails +### In scope +- Add `AGENT_TOKENS` persistence, seed data, and reset integration. +- Expand token/auth stack to support both token types with in-memory cache and project `code↔id` maps. +- Enforce project-scoped agent-token authorization for project routes (`:id` and `:code`). +- Enforce authorization model: user tokens can access all projects (current model), agent tokens only their bound project. +- Keep agent-usable routes project-code scoped (`:code` / `:projectCode`) for consistent authorization checks. +- Add agent-token CRUD/list API routes and OpenAPI schemas. +- Enforce **user-token-only** access for agent-token management endpoints (agent tokens must be `403`). +- Add client UI for project-level agent token management. +- Add/adjust PactumJS tests and OpenAPI tests for new behavior. +- Manual UI verification is required for AC-7/8/9 in this deliverable. + +### Out of scope +- Token rotation/expiration and fine-grained permissions. +- Multi-instance distributed token cache. +- Changes to project membership/access model (`USER_PROJECTS` etc.). +- Legacy route cleanup unrelated to this deliverable. +- UI automation framework adoption (e.g., Playwright) is deferred to a future deliverable. + +### Explicit non-goals from SPEC +- Do not migrate existing external agents automatically. +- Do not add project-level restrictions beyond existing leader/non-leader behavior. +- Do not change non-project-route behavior for agent tokens. + +## 3. Verified Current State (Repository Reality) +- `api/lib/db/schema.js` has `USERS.access_token` but no `AGENT_TOKENS` table. +- `api/src/services/tokenService.js` caches only user tokens (`token -> { userId, email, fullName }`) and returns no token type/project scope or cached project `code↔id` map. +- `api/src/middleware/authMiddleware.js` authenticates token but does not attach `tokenType`/agent scope or enforce project match. +- `api/scripts/reset-and-seed.js` has no `AGENT_TOKENS` drop/seed integration. +- No agent-token routes/schemas exist in `api/src/routes/*` or `api/src/schemas/*`. +- Live OpenAPI has no `/projects/{code}/agent-tokens` or `/projects/{code}/users/{userId}/agent-tokens` paths. +- Existing route surface includes both project param styles (`/projects/{id}` and `/projects/{code}`), so middleware must resolve both. +- Existing tests use user token `550e8400-e29b-41d4-a716-446655440000` in agent persona suites: + - `api/__tests__/routes/agent-workflow.test.mjs` + - `api/__tests__/routes/deliverables-approval.test.mjs` + - `api/__tests__/routes/deliverables-status.test.mjs` +- UI currently has project row edit icon only (`client/src/components/ProjectList.jsx`); no agent-token management modal. + +## 4. Contract Delta (Current -> Target) +| Surface | Current | Target | +|---|---|---| +| DB schema | No `AGENT_TOKENS` | `AGENT_TOKENS(id, user_id, project_id, token, label, created_at)` + indexes | +| Token cache | User-token only map | Unified map for user + agent tokens with token type/scope plus in-memory project `code↔id` maps | +| Auth request context | `request.user` only | `request.user`, `request.tokenType`, `request.agentTokenProjectId`, `request.agentTokenProjectCode`, `request.agentTokenUserId` | +| Project-route auth | Any valid token accepted | Agent token must match normalized project id (from `:id` or cached `:code -> id` lookup) else `403` | +| Agent route shape | Mixed assumptions | Agent-usable routes must carry project code context (`:code`/`:projectCode`) for auth consistency | +| Token-management endpoint auth | No special split | `/projects/:code/agent-tokens` and `/projects/:code/users/:userId/agent-tokens*` require user token; agent token always `403` | +| Agent-token APIs | None | `GET /projects/:code/users/:userId/agent-tokens`, `GET /projects/:code/agent-tokens`, `POST /projects/:code/users/:userId/agent-tokens`, `DELETE /projects/:code/users/:userId/agent-tokens/:id` (no PATCH/PUT update endpoint) | +| Token value visibility | Unspecified/ambiguous | Authorized GET list responses return full token values (same model as UI requirement; not one-time display) | +| Token record mutability | Unspecified | Immutable after create; only list/create/delete operations are supported | +| OpenAPI | No agent-token ops | Full schemas/tags/response docs for new routes | +| Client UX | No project token management | Project-row manage icon + modal (leader tree view, non-leader self view, create/copy/delete-confirm flow) | +| Test coverage | No agent-token route/auth coverage | Pactum + OpenAPI + agent-persona regression tests for new semantics | + +## 5. Parallelization Strategy +- Stream A: Data/Auth foundation + - `api/lib/db/schema.js`, `api/scripts/reset-and-seed.js`, new seeder, `api/src/services/tokenService.js`, `api/src/middleware/authMiddleware.js`, `api/src/services/databaseService.js` +- Stream B: API surface + - `api/src/schemas/*` and route plugin(s), `api/src/routes/index.js` +- Stream C: Backend tests + - `api/__tests__/routes/agent-tokens.test.mjs` (new), updates to existing route tests, `openapi.test.mjs`, helper updates +- Stream D: Client UX + - `client/src/components/ProjectList.jsx`, new modal component, hooks, i18n locales + +Serialization hotspots: +- `api/src/services/databaseService.js` +- `api/src/middleware/authMiddleware.js` +- `api/src/routes/index.js` +- `client/src/components/ProjectList.jsx` +- `client/src/i18n/locales/*.json` + +Merge points: +- Merge Point 1: Stream A complete before Stream B route handler wiring. +- Merge Point 2: Streams A+B complete before Stream C assertions stabilize. +- Merge Point 3: Stream B contract stable before Stream D hook integration. + +## 6. AC Traceability Matrix +| AC | Implementation step IDs | Tests/evidence | +|---|---|---| +| AC-1 Schema | `1.1` | Schema push/reset output + seeded data query + route behavior relying on table | +| AC-2 Token Service & Cache | `1.2` | New tokenService tests and route integration tests validating add/remove cache behavior | +| AC-3 Auth Middleware | `1.3` | Pactum wrong-project `403` tests across `:code` and `:id` routes | +| AC-4 GET user tokens | `2.2`, `3.1` | `agent-tokens.test.mjs` happy/403/404/401 | +| AC-4b GET project tree | `2.2`, `3.1` | Leader/non-leader coverage in `agent-tokens.test.mjs` | +| AC-5 POST token | `2.2`, `1.2`, `3.1` | Create returns token + immediate auth usability check | +| AC-6 DELETE token | `2.2`, `1.2`, `3.1` | Delete + immediate `401` on revoked token | +| AC-7 UI icon | `4.1` | Manual owner verification checklist | +| AC-8 UI modal behaviors | `4.2` | Manual owner verification checklist (leader/non-leader flows) | +| AC-9 UI delete confirmation flow | `4.2` | Manual owner verification checklist (exact phrase gating) | +| AC-10 Tests | `3.1`, `3.2`, `3.3` | Full route/openapi/persona/wrong-project test suite | + +## 7. Phased Execution Plan +### Phase 1 - Data/Auth foundation + +#### 1.1 Add AGENT_TOKENS schema + seed integration +- Objective: Introduce persistent agent-token storage and deterministic seed data. +- Files affected: + - `api/lib/db/schema.js` + - `api/scripts/seeders/seedAgentTokens.js` (new) + - `api/scripts/reset-and-seed.js` + - `api/scripts/seed-all.js` (if required by current seed entrypoint) +- Deliverables/output: + - New `AGENT_TOKENS` table definition and indexes. + - Seeder with fixed UUID values from SPEC. + - Reset flow drops/seeds `AGENT_TOKENS` in correct order. +- DEPENDS_ON: `none` +- COORDINATES_WITH: `2.2` +- Parallelizable with: `4.1` +- TDD: tests to write first: + - Add/extend API test that expects seeded agent token to authenticate successfully on allowed project route. +- TDD: tests to run for completion: + - `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/agent-tokens.test.mjs` +- Acceptance criteria mapped: `AC-1`, partial `AC-10` +- Completion signal: `AGENT_TOKENS` exists after reset; seed rows present with expected UUID values. + +#### 1.2 Expand tokenService cache to user+agent token model +- Objective: Support single-map in-memory validation for both token types and cache mutation on create/delete. +- Files affected: + - `api/src/services/tokenService.js` + - `api/src/services/databaseService.js` + - `api/__tests__/helpers/testServer.js` + - `api/__tests__/helpers/testServerWithSwagger.js` +- Deliverables/output: + - Cache entries include `{ type, userId, projectId?, projectCode?, email?, fullName? }`. + - Project maps loaded at startup: `projectIdByCode` and `projectCodeById`. + - `addAgentTokenToCache()` and `removeAgentTokenFromCache()` implemented. + - Startup initialization includes users + agent tokens + project `code↔id` maps. +- DEPENDS_ON: `1.1` +- COORDINATES_WITH: `2.2`, `3.1` +- Parallelizable with: `4.2` +- TDD: tests to write first: + - New tests asserting `validateToken()` returns `type='agent'` and project scope for seeded agent token. + - Tests asserting cached `:code -> id` normalization path for project-scoped auth checks. + - Create/delete route tests asserting immediate cache effect. +- TDD: tests to run for completion: + - `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/agent-tokens.test.mjs __tests__/routes/agent-workflow.test.mjs` +- Acceptance criteria mapped: `AC-2`, partial `AC-5`, partial `AC-6`, partial `AC-10` +- Completion signal: New token works immediately post-create; revoked token fails next request with `401`. + +#### 1.3 Extend authMiddleware with project-scope enforcement for agent tokens +- Objective: Enforce agent-token project scoping while preserving user-token behavior and blocking agent tokens from token-management endpoints. +- Files affected: + - `api/src/middleware/authMiddleware.js` + - `api/src/routes/projects.js` (only if minimal hook adjustments required) + - Shared helper file if created for project resolution. +- Deliverables/output: + - Middleware uses cached `request.tokenType` (`user`/`agent`) as primary gate for user-only endpoints. + - Middleware resolves project context from `:code`, `:projectCode`, or `:id` on project routes using cached project maps. + - Agent token mismatch returns `403`; user token behavior unchanged. + - Agent token on token-management endpoints returns `403` even when project matches. + - Request context fields populated for downstream handlers (`agentTokenProjectCode` included). + - Agent-usable route audit confirms project code context is present where agents are expected to operate. +- DEPENDS_ON: `1.2` +- COORDINATES_WITH: `2.2`, `3.1`, `3.2` +- Parallelizable with: `4.1` +- TDD: tests to write first: + - Wrong-project tests for both route param styles. + - User-only endpoint test proving agent token is rejected by tokenType-first check (`403`) before ownership/path checks. + - Tests asserting agent-token access is only exercised on project-scoped routes carrying `:code`/`:projectCode`. +- TDD: tests to run for completion: + - `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/agent-tokens.test.mjs __tests__/routes/project-id-routes-regression.test.mjs` +- Acceptance criteria mapped: `AC-3`, partial `AC-10` +- Completion signal: Agent token succeeds only on matching project routes; mismatch reliably `403`. + +### Phase 2 - API surface and OpenAPI contracts + +#### 2.1 Add agent-token validation schemas and exports +- Objective: Define request/response schema contracts for new agent-token APIs. +- Files affected: + - `api/src/schemas/agentTokens.js` (new) + - `api/src/schemas/index.js` + - `api/src/schemas/validation.js` +- Deliverables/output: + - Schema sets for leader/non-leader behavior, `userId=me|number`, create/delete payloads. + - OpenAPI-ready summaries/descriptions/responses. +- DEPENDS_ON: `1.1` +- COORDINATES_WITH: `2.2`, `3.3` +- Parallelizable with: `3.2`, `4.1` +- TDD: tests to write first: + - OpenAPI assertions for required new paths and schemas. +- TDD: tests to run for completion: + - `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/openapi.test.mjs` +- Acceptance criteria mapped: partial `AC-4`, partial `AC-4b`, partial `AC-5`, partial `AC-6`, partial `AC-10` +- Completion signal: OpenAPI includes all new agent-token operations with documented request/response shapes. + +#### 2.2 Implement agent-token routes + DB service methods +- Objective: Deliver list/create/delete endpoints with role-based visibility, immutability, and cache updates. +- Files affected: + - `api/src/routes/agentTokens.js` (new) or `api/src/routes/projects.js` (if colocated) + - `api/src/routes/index.js` + - `api/src/services/databaseService.js` +- Deliverables/output: + - `GET /projects/:code/users/:userId/agent-tokens` + - `GET /projects/:code/agent-tokens` + - `POST /projects/:code/users/:userId/agent-tokens` + - `DELETE /projects/:code/users/:userId/agent-tokens/:id` + - Both GET list routes return full token values for authorized user-token callers. + - Leader/non-leader and ownership enforcement. + - User-token-only enforcement on all token-management endpoints. + - No PATCH/PUT token route implementation. + - Create/delete call tokenService cache mutation methods. +- DEPENDS_ON: `1.2`, `1.3`, `2.1` +- COORDINATES_WITH: `3.1`, `4.2` +- Parallelizable with: `4.1` +- TDD: tests to write first: + - Route tests for happy path + `401/403/404` for each endpoint. + - Route tests proving PATCH/PUT token-update paths are unavailable (`404`). +- TDD: tests to run for completion: + - `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/agent-tokens.test.mjs` +- Acceptance criteria mapped: `AC-4`, `AC-4b`, `AC-5`, `AC-6`, partial `AC-10` +- Completion signal: All new endpoints pass Pactum coverage and return spec-compliant payloads. + +### Phase 3 - Test updates and regression protection + +#### 3.1 Add dedicated Pactum suite for agent-token endpoints and auth scope +- Objective: Cover new API behavior thoroughly (happy, edge, negative). +- Files affected: + - `api/__tests__/routes/agent-tokens.test.mjs` (new) + - `api/__tests__/helpers/testDatabase.js` (if helper additions needed) +- Deliverables/output: + - End-to-end tests for list/create/delete and leader tree route. + - Explicit assertions that list responses include full token values for authorized users. + - Wrong-project `403`, revoked-token `401`, non-leader restrictions. + - Agent token forbidden (`403`) on all token-management endpoints. + - Immutability guard tests for non-existent PATCH/PUT token routes. +- DEPENDS_ON: `2.2` +- COORDINATES_WITH: `1.2`, `1.3` +- Parallelizable with: `3.2` +- TDD: tests to write first: + - Failing route tests before implementing handlers. +- TDD: tests to run for completion: + - `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/agent-tokens.test.mjs` +- Acceptance criteria mapped: `AC-3`, `AC-4`, `AC-4b`, `AC-5`, `AC-6`, `AC-10` +- Completion signal: Dedicated agent-token suite passes with explicit status-code coverage. + +#### 3.2 Update existing agent persona and planner-sequence tests to use agent tokens +- Objective: Prove real agent workflows run with scoped agent tokens. +- Files affected: + - `api/__tests__/routes/agent-workflow.test.mjs` + - `api/__tests__/routes/deliverables-approval.test.mjs` + - `api/__tests__/routes/deliverables-status.test.mjs` +- Deliverables/output: + - Replace user token usage with seeded ZAZZ agent token where persona simulation applies. + - Add wrong-project checks for ZAZZ vs ZED_MER token misuse. +- DEPENDS_ON: `1.3`, `2.2` +- COORDINATES_WITH: `3.1` +- Parallelizable with: `2.1` +- TDD: tests to write first: + - Wrong-project assertions expected to fail prior to middleware enforcement. +- TDD: tests to run for completion: + - `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/agent-workflow.test.mjs __tests__/routes/deliverables-approval.test.mjs __tests__/routes/deliverables-status.test.mjs` +- Acceptance criteria mapped: partial `AC-3`, `AC-10` +- Completion signal: Persona/sequence suites pass using agent token and fail correctly cross-project. + +#### 3.3 Extend OpenAPI tests for new agent-token capabilities +- Objective: Keep OpenAPI as enforceable contract for agent-token routes. +- Files affected: + - `api/__tests__/routes/openapi.test.mjs` +- Deliverables/output: + - Assertions for new agent-token paths, schema fields, and tags. + - Assertions that agent-token paths do not expose PATCH/PUT operations. +- DEPENDS_ON: `2.1`, `2.2` +- COORDINATES_WITH: `3.1` +- Parallelizable with: `4.1` +- TDD: tests to write first: + - Path-existence and requestBody schema assertions for each new operation. +- TDD: tests to run for completion: + - `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/openapi.test.mjs` +- Acceptance criteria mapped: partial `AC-10` +- Completion signal: OpenAPI suite passes and documents all new capabilities. + +### Phase 4 - Client UX and i18n + +#### 4.1 Add project-row manage-agent-tokens trigger +- Objective: Expose entry point to token management from project list. +- Files affected: + - `client/src/components/ProjectList.jsx` +- Deliverables/output: + - New action icon per project row with tooltip and modal trigger wiring. +- DEPENDS_ON: `none` +- COORDINATES_WITH: `4.2` +- Parallelizable with: `1.1`, `1.3`, `2.1` +- TDD: tests to write first: + - Define manual verification steps for icon presence and click callback/modal opening. +- TDD: tests to run for completion: + - Manual smoke against running client/API. +- Acceptance criteria mapped: `AC-7` +- Completion signal: Clicking icon opens agent token modal for selected project. + +#### 4.2 Implement Agent Tokens modal, API calls, copy/delete UX, and translations +- Objective: Deliver leader/non-leader token management experience with two-step delete confirmation. +- Files affected: + - `client/src/components/AgentTokensModal.jsx` (new) + - `client/src/App.jsx` or relevant state owner for modal wiring + - `client/src/hooks/*` (new hook or existing hook extension for agent-token API calls) + - `client/src/i18n/locales/en.json` + - `client/src/i18n/locales/es.json` + - `client/src/i18n/locales/fr.json` + - `client/src/i18n/locales/de.json` +- Deliverables/output: + - Leader tree view (`GET /projects/:code/agent-tokens`) + - Non-leader self view (`GET /projects/:code/users/me/agent-tokens`) + - Create token with optional label and copy feedback. + - Two-step in-modal delete flow with exact phrase `delete this token`. +- DEPENDS_ON: `2.2`, `4.1` +- COORDINATES_WITH: `3.1` +- Parallelizable with: `3.3` +- TDD: tests to write first: + - Define manual verification checklist for leader/non-leader list behavior and confirmation phrase gating. +- TDD: tests to run for completion: + - Manual owner sign-off for modal flows. +- Acceptance criteria mapped: `AC-8`, `AC-9` +- Completion signal: End-to-end modal flows work for leader and non-leader views with correct API calls. + +## 8. Test Command Matrix +1. `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/agent-tokens.test.mjs` +2. `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/agent-workflow.test.mjs __tests__/routes/deliverables-approval.test.mjs __tests__/routes/deliverables-status.test.mjs` +3. `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/project-id-routes-regression.test.mjs` +4. `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/openapi.test.mjs` +5. `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test` +6. Manual UI verification on `http://localhost:3001` with leader and non-leader tokens for modal behavior and i18n text presence. + +## 9. Risks and Mitigations +- Risk: Auth middleware project resolution can break existing routes due to mixed `:id`/`:code` param conventions. + - Mitigation: Add explicit resolution helper + regression tests covering both param types before rollout. +- Risk: Token cache drift after create/delete in long-running process. + - Mitigation: Route handlers must call cache mutation methods synchronously after DB success; add immediate-use and immediate-revoke tests. +- Risk: Leader/non-leader authorization bugs may leak token visibility. + - Mitigation: Centralize leader check via `PROJECTS.leader_id` and enforce in service/route layer with dedicated `403` tests. +- Risk: UI delete confirmation may allow accidental revocation if phrase check is weak. + - Mitigation: Exact normalized match (`trim()` + lowercase equality) and disabled confirm button until match. +- Risk: A future refactor accidentally adds token-update endpoint (PATCH/PUT), violating immutable token policy. + - Mitigation: Keep explicit negative route tests (`404`) plus OpenAPI assertions that PATCH/PUT are absent. + +## 10. Approval Checklist +- [ ] Confirm route placement preference: dedicated `agentTokens` route plugin vs extending `projects.js` (recommended: dedicated plugin for lower file-lock contention). +- [ ] Confirm manual UI verification checklist is complete for AC-7/8/9 (automation deferred to future deliverable). +- [ ] Confirm seeded agent token UUIDs in SPEC are final and must remain immutable for test stability. +- [ ] Confirm no additional project-access restrictions are expected in this deliverable beyond leader/non-leader token management rules. diff --git a/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-SPEC.md b/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-SPEC.md index 942351b5..f89bff10 100644 --- a/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-SPEC.md +++ b/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-SPEC.md @@ -41,13 +41,14 @@ Zazz Board currently uses a single token per user (`USERS.access_token`). Both h - Agent tokens scoped to **user + project**—belongs to one user, authorized for one project only - `USERS.access_token` repurposed as **user token** (human only, full project access) - `tokenService` and `authMiddleware` extended to support both token types and user+project-scope validation for agent tokens -- **In-memory cache**: Expand existing user-token cache to include agent tokens; single Map lookup per request (no DB hit). Cache populated on startup. **On create**: add new token to cache as part of the create route (no full refresh). **On delete**: remove token from cache as part of the delete route (no full refresh). Assume single API instance for now; multi-instance shared cache is future work. +- **In-memory cache**: Expand existing user-token cache to include agent tokens and project code/id lookup maps; single Map lookup for token validation (no DB hit). Cache populated on startup. **On create**: add new token to cache as part of the create route (no full refresh). **On delete**: remove token from cache as part of the delete route (no full refresh). Assume single API instance for now; multi-instance shared cache is future work. - API routes: list agent tokens (project + user scoped), create agent token, delete agent token (hard delete) +- Agent token records are immutable after create (no PATCH/PUT update endpoint in API) - Route protection: agent token must match both user context and project in URL - UI: new icon on project row (agent or gear) → modal to manage agent tokens - **Project leader** (PROJECTS.leader_id): sees all users + all their tokens for this project - **Non-leader**: sees only their own tokens for this project -- Create token: optional label (e.g. "planner agent", "worker agent"); token UUID generated on create +- Create token: optional label (e.g. "For all agents access token", "Spec builder agent ONLY access token"); token UUID generated on create - Delete token: two-step confirmation—(1) "Are you sure?" dialog, (2) user must type exact string `delete this token` (lowercase); OK disabled until match; then hard delete - Token display: full token visible; copy icon copies to clipboard @@ -55,6 +56,7 @@ Zazz Board currently uses a single token per user (`USERS.access_token`). Both h - Fine-grained permissions (all tokens have full access to their scope) - Token expiration or rotation +- Editing existing token records (including label changes) after creation - USER_PROJECTS or other project access model changes (use current model; see Project Access below) - Migration of existing agent usage to new tokens (existing `USERS.access_token` becomes user-only; agents must be reconfigured to use new agent tokens) @@ -85,7 +87,7 @@ CREATE TABLE AGENT_TOKENS ( user_id INTEGER NOT NULL REFERENCES USERS(id) ON DELETE CASCADE, project_id INTEGER NOT NULL REFERENCES PROJECTS(id) ON DELETE CASCADE, token VARCHAR(36) NOT NULL UNIQUE, -- UUID, indexed for fast lookup - label VARCHAR(100), -- Optional: "planner agent", "worker agent" + label VARCHAR(100), -- Optional: "For all agents access token", "Spec builder agent ONLY access token" created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL ); @@ -128,15 +130,18 @@ New seeder: `api/scripts/seeders/seedAgentTokens.js`. Add to `reset-and-seed.js` | user_id | project_id | token | label | |---------|------------|-------|-------| -| 5 (Michael) | 1 (ZAZZ) | `660e8400-e29b-41d4-a716-446655440001` | planner agent | -| 5 (Michael) | 1 (ZAZZ) | `660e8400-e29b-41d4-a716-446655440002` | worker agent | -| 5 (Michael) | 2 (MOBDEV) | `660e8400-e29b-41d4-a716-446655440003` | qa agent | -| 2 (Jane) | 2 (MOBDEV) | `660e8400-e29b-41d4-a716-446655440004` | planner agent | -| 3 (Mike) | 3 (APIMOD) | `660e8400-e29b-41d4-a716-446655440005` | coordinator agent | +| 5 (Michael) | 1 (ZAZZ) | `660e8400-e29b-41d4-a716-446655440101` | For all agents access token | +| 5 (Michael) | 1 (ZAZZ) | `660e8400-e29b-41d4-a716-446655440102` | Spec builder agent ONLY access token | +| 2 (Jane) | 2 (ZED_MER) | `660e8400-e29b-41d4-a716-446655440103` | For all agents access token | +| 2 (Jane) | 2 (ZED_MER) | `660e8400-e29b-41d4-a716-446655440104` | Spec builder agent ONLY access token | +| 3 (Steve) | 2 (ZED_MER) | `660e8400-e29b-41d4-a716-446655440105` | For all agents access token | +| 3 (Steve) | 2 (ZED_MER) | `660e8400-e29b-41d4-a716-446655440106` | Spec builder agent ONLY access token | -**Rationale**: Michael (user 5) has the known user token `550e8400-e29b-41d4-a716-446655440000`; agent tokens use a similar prefix for easy identification. Fixed UUIDs allow PactumJS tests to use a known agent token (e.g. `660e8400-e29b-41d4-a716-446655440001`) for ZAZZ project requests. +**Rationale**: Michael (user 5) has the known user token `550e8400-e29b-41d4-a716-446655440000`; agent tokens use a similar prefix for easy identification. Fixed UUIDs allow PactumJS tests to use a known agent token (e.g. `660e8400-e29b-41d4-a716-446655440101`) for ZAZZ project requests. -**Test stability**: Seeded tokens must stay **consistent** across runs—same UUIDs, same projects—so all API tests can rely on them. Agent token tests use these fixed values. Tokens are seeded only in projects used by agent tests (ZAZZ, MOBDEV, APIMOD). +**Test stability**: Seeded tokens must stay **consistent** across runs—same UUIDs, same projects—so all API tests can rely on them. Agent token tests use these fixed values. Tokens are seeded only in projects used by agent tests (ZAZZ, ZED_MER). + +**USERS seed alignment note**: In `seedUsers`, rename `Mike Johnson` to `Steve Johnson`. Add `Jonathon` as an additional seeded user (new row only) so AGENT_TOKENS seed references remain stable (`user_id` 2/3/5 unchanged). **Seeder implementation**: ```javascript @@ -147,11 +152,12 @@ import { AGENT_TOKENS } from '../../lib/db/schema.js'; export async function seedAgentTokens() { console.log(' 📝 Seeding agent tokens...'); await db.insert(AGENT_TOKENS).values([ - { user_id: 5, project_id: 1, token: '660e8400-e29b-41d4-a716-446655440001', label: 'planner agent' }, - { user_id: 5, project_id: 1, token: '660e8400-e29b-41d4-a716-446655440002', label: 'worker agent' }, - { user_id: 5, project_id: 2, token: '660e8400-e29b-41d4-a716-446655440003', label: 'qa agent' }, - { user_id: 2, project_id: 2, token: '660e8400-e29b-41d4-a716-446655440004', label: 'planner agent' }, - { user_id: 3, project_id: 3, token: '660e8400-e29b-41d4-a716-446655440005', label: 'coordinator agent' }, + { user_id: 5, project_id: 1, token: '660e8400-e29b-41d4-a716-446655440101', label: 'For all agents access token' }, + { user_id: 5, project_id: 1, token: '660e8400-e29b-41d4-a716-446655440102', label: 'Spec builder agent ONLY access token' }, + { user_id: 2, project_id: 2, token: '660e8400-e29b-41d4-a716-446655440103', label: 'For all agents access token' }, + { user_id: 2, project_id: 2, token: '660e8400-e29b-41d4-a716-446655440104', label: 'Spec builder agent ONLY access token' }, + { user_id: 3, project_id: 2, token: '660e8400-e29b-41d4-a716-446655440105', label: 'For all agents access token' }, + { user_id: 3, project_id: 2, token: '660e8400-e29b-41d4-a716-446655440106', label: 'Spec builder agent ONLY access token' }, ]); console.log(' ✅ Agent tokens seeded successfully'); } @@ -163,12 +169,15 @@ export async function seedAgentTokens() { ### 5.1 In-Memory Token Cache -The existing `tokenService` caches user tokens on startup. **Expand the cache** to include agent tokens so every request does a single in-memory Map lookup—no DB hit per request. +The existing `tokenService` caches user tokens on startup. **Expand the cache** to include agent tokens and project code/id maps so requests can validate token + project scope from memory. -**Cache structure**: `token → { type: 'user'|'agent', userId, projectId?, email?, fullName? }` +**Cache structure**: +- `token → { type: 'user'|'agent', userId, projectId?, projectCode?, email?, fullName? }` +- `projectIdByCode: Map` +- `projectCodeById: Map` -- **Startup**: Load USERS (access_token → user) and AGENT_TOKENS (token → user+project) into one Map -- **Per request**: `tokenService.validateToken(token)` does one Map lookup +- **Startup**: Load USERS (access_token → user), AGENT_TOKENS (token → user+project), and PROJECTS (`code ↔ id`) into memory maps +- **Per request**: `tokenService.validateToken(token)` does one token Map lookup; project param normalization uses cached `code ↔ id` maps - **On agent token create**: Add new token to cache as part of the create route (e.g. `tokenService.addAgentTokenToCache(...)`) — no full refresh - **On agent token delete**: Remove token from cache as part of the delete route (e.g. `tokenService.removeAgentTokenFromCache(token)`) — no full refresh @@ -178,8 +187,10 @@ The existing `tokenService` caches user tokens on startup. **Expand the cache** 1. Extract token from `TB_TOKEN` or `Authorization: Bearer` header 2. Look up token in cache (single Map lookup) -3. If hit: user token → full access; agent token → project-scoped -4. If miss → 401 Unauthorized +3. If miss → 401 Unauthorized +4. Read cached token type (`'user' | 'agent'`) and set request token context +5. For user-token-only endpoints: if cached type is `agent`, return `403` immediately (no further auth checks) +6. If token type is `user`: full access (current model). If `agent`: project-scoped checks apply. ### 5.3 Request Context @@ -187,6 +198,7 @@ After validation, attach to `request`: - `request.user`: { id, email, fullName } (from USERS via user_id) - `request.tokenType`: `'user'` | `'agent'` - `request.agentTokenProjectId`: (only if agent) — project_id the token is authorized for +- `request.agentTokenProjectCode`: (only if agent) — project_code the token is authorized for - `request.agentTokenUserId`: (only if agent) — user_id the token belongs to ### 5.4 Project-Scoped Route Protection @@ -197,12 +209,21 @@ For routes under `/projects/:param/...` (where param may be `:id` or `:code`): If agent token is used for a different project → 403 Forbidden. -**Route param support**: Some routes use `:id` (numeric), others use `:code` (e.g. ZAZZ). Auth middleware must support **both**: resolve `:id` via `getProjectById`, `:code` via `getProjectByCode`; both yield `project_id`. Agent token's `project_id` must match. Do not expand scope to standardize routes; add inconsistency to future-fixes. +**Route param support**: Some routes use `:id` (numeric), others use `:code` (e.g. ZAZZ). Auth middleware must support **both** and normalize via in-memory project maps: +- `:id` → compare directly to `request.agentTokenProjectId` (and optionally derive code via `projectCodeById`) +- `:code` → resolve `projectIdByCode.get(code)` then compare to `request.agentTokenProjectId` +Do not expand scope to standardize routes; add inconsistency to future-fixes. + +**Agent route convention**: Agent-usable routes must include project code context (`:code` or `:projectCode`) so auth checks are consistent. If a route has no project context, treat it as user-token-only unless explicitly defined otherwise. ### 5.5 Project Access (Current Model) **Explicit for this deliverable**: All authenticated users currently have access to all projects. There are no project-level restrictions (no USER_PROJECTS or membership model). The only distinction is **project leader** (`PROJECTS.leader_id === user.id`): one leader per project; leaders have additional capabilities (e.g. manage agent tokens for all users in the project, update status workflows). Non-leaders see only their own agent tokens. Add to future-fixes: project-level access restrictions, multi-leader support. +**Authorization summary**: +- User token (`USERS.access_token`): full project access (current model). +- Agent token (`AGENT_TOKENS.token`): restricted to one project only; any other project returns `403`. + **Routes using `:id`** (projects.js): `GET /projects/:id`, `PUT /projects/:id`, `DELETE /projects/:id`, `GET /projects/:id/tasks`, `GET /projects/:id/kanban/tasks/column/:status` **Routes using `:code` or `:projectCode`**: All other project-scoped routes (deliverables, tasks, kanban positions, statuses, task graph, etc.) @@ -211,13 +232,19 @@ If agent token is used for a different project → 403 Forbidden. ## 6. API Specification -All routes require authentication. User token or agent token (with matching project) is valid. +All routes require authentication. + +- For general project-scoped routes, user token or matching-project agent token is valid. +- For **agent-token management routes in section 6.1**, **user token only** is valid. Agent tokens are forbidden (`403`) on these routes. ### 6.1 Agent Token CRUD +Agent-token management is intentionally **list/create/delete only**. There is **no PATCH/PUT update endpoint** for agent tokens. + #### GET /projects/:code/users/:userId/agent-tokens List agent tokens for a **user** within a **project**. Both project and user are in the route path. +This endpoint is **human user token only** (agent tokens forbidden). - **userId**: Use `me` for the current user, or a numeric user ID. Non-leader: can only use `me` (403 if numeric userId ≠ self). Leader: can use `me` or any user ID. - **Project leader**: Can request any user's tokens. To get the full tree (all users + tokens), use `GET /projects/:code/agent-tokens` instead. @@ -233,19 +260,20 @@ List agent tokens for a **user** within a **project**. Both project and user are { "id": 1, "token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "label": "planner agent", + "label": "For all agents access token", "createdAt": "2026-03-03T10:00:00Z" } ] } ``` -**403**: Agent token used for different project; or non-leader requesting another user's tokens. +**403**: Agent token used (forbidden on token-management routes); or non-leader requesting another user's tokens. **404**: Project or user not found. #### GET /projects/:code/agent-tokens List all agent tokens for the project. **Project leader only.** Returns a tree: each user with their tokens (labels included). Used for the expandable table/tree UI. +This endpoint is **human user token only** (agent tokens forbidden). **Response 200**: ```json @@ -259,13 +287,13 @@ List all agent tokens for the project. **Project leader only.** Returns a tree: { "id": 1, "token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "label": "planner agent", + "label": "For all agents access token", "createdAt": "2026-03-03T10:00:00Z" }, { "id": 2, "token": "b2c3d4e5-f6a7-8901-bcde-f23456789012", - "label": "worker agent", + "label": "Spec builder agent ONLY access token", "createdAt": "2026-03-03T11:00:00Z" } ] @@ -278,7 +306,7 @@ List all agent tokens for the project. **Project leader only.** Returns a tree: { "id": 3, "token": "c3d4e5f6-a7b8-9012-cdef-345678901234", - "label": "qa agent", + "label": "For all agents access token", "createdAt": "2026-03-03T12:00:00Z" } ] @@ -287,17 +315,18 @@ List all agent tokens for the project. **Project leader only.** Returns a tree: } ``` -**403**: Non-leader (only project leader can call this); or agent token for different project. +**403**: Non-leader (only project leader can call this); or agent token used (forbidden on token-management routes). **404**: Project not found. #### POST /projects/:code/users/:userId/agent-tokens Create a new agent token for a user and this project. **userId** must be `me` (current user) or, for leaders, the target user's ID. Non-leader can only create for `me`. +This endpoint is **human user token only** (agent tokens forbidden). **Request body:** ```json { - "label": "planner agent" + "label": "For all agents access token" } ``` @@ -308,19 +337,20 @@ Create a new agent token for a user and this project. **userId** must be `me` (c { "id": 1, "token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "label": "planner agent", + "label": "For all agents access token", "createdAt": "2026-03-03T10:00:00Z" } ``` -**Note**: Full token is returned **only on create**. Client should show copy-to-clipboard and optionally store for agent config. **Cache**: After creating, call `tokenService.addAgentTokenToCache(token, data)` so the new token is valid on the next request. +**Note**: Full token is returned on create **and in token list responses** for authorized human users (no masking). Client should show copy-to-clipboard. **Cache**: After creating, call `tokenService.addAgentTokenToCache(token, data)` so the new token is valid on the next request. -**403**: Agent token used for different project; or user does not have access to project. +**403**: Agent token used (forbidden on token-management routes); or user does not have access to project. **404**: Project not found. #### DELETE /projects/:code/users/:userId/agent-tokens/:id Hard-delete an agent token (remove row). Token becomes invalid immediately. **userId** in path: `me` for self, or numeric ID for leader deleting another user's token. +This endpoint is **human user token only** (agent tokens forbidden). - **Project leader**: Can delete any token for this project (any user's) - **Non-leader**: Can delete only their own tokens @@ -329,7 +359,7 @@ Hard-delete an agent token (remove row). Token becomes invalid immediately. **us **Cache**: After deleting, call `tokenService.removeAgentTokenFromCache(token)` so the revoked token is rejected immediately on the next request. -**Response 403**: Not authorized to delete this token (e.g. non-leader trying to delete another user's token) +**Response 403**: Agent token used (forbidden on token-management routes), or not authorized to delete this token (e.g. non-leader trying to delete another user's token) **Response 404**: Token or project not found --- @@ -352,7 +382,7 @@ Hard-delete an agent token (remove row). Token becomes invalid immediately. **us - **Title**: "Agent Tokens for [Project Name]" (or project code) - **Project leader view**: Expandable tree or table. One row per user; each user row expands to show rows for each of their tokens (label, token, copy icon, delete button). Data from `GET /projects/:code/agent-tokens`. Structure: User row → token rows (indented or nested). - **Non-leader view**: Same expandable structure but only current user. Data from `GET /projects/:code/users/me/agent-tokens`; display as single user with token rows. -- **Create**: Button "Create token". Optional label field (placeholder: "e.g. planner agent"). On submit, POST to `.../users/me/agent-tokens`; show new token with copy icon and "Copied!" feedback on first copy. +- **Create**: Button "Create token". Optional label field (placeholder: "e.g. For all agents access token"). On submit, POST to `.../users/me/agent-tokens`; show new token with copy icon and "Copied!" feedback on first copy. ### 7.3 Delete Token Flow (Two Steps, Same Modal) @@ -384,14 +414,16 @@ Add translation keys for: - [ ] `token` is VARCHAR(36) UNIQUE, indexed for fast lookup - [ ] `label` is optional VARCHAR(100) - [ ] `USERS.access_token` unchanged (repurposed as user token) -- [ ] `seedAgentTokens.js` seeds 5 agent tokens; reset-and-seed includes it; AGENT_TOKENS dropped in reset order +- [ ] `seedAgentTokens.js` seeds 6 agent tokens; reset-and-seed includes it; AGENT_TOKENS dropped in reset order **Verified by**: schema migration; seed script run; db:reset succeeds ### AC-2: Token Service & Cache - [ ] Cache expanded to include both user tokens and agent tokens; single Map lookup per request -- [ ] `validateToken(token)` returns `{ type, userId, projectId?, ... }` from cache; no DB hit -- [ ] Returns `tokenType: 'user' | 'agent'`, `agentTokenProjectId`, and `agentTokenUserId` when agent +- [ ] Project code/id maps loaded in memory (`projectIdByCode`, `projectCodeById`) for `:code` route normalization +- [ ] `validateToken(token)` returns `{ type, userId, projectId?, projectCode?, ... }` from cache; no DB hit +- [ ] Returns `tokenType: 'user' | 'agent'`, `agentTokenProjectId`, `agentTokenProjectCode`, and `agentTokenUserId` when agent +- [ ] User-only endpoint checks use cached `tokenType` and reject agent tokens with immediate `403` - [ ] `addAgentTokenToCache(token, data)` adds new token on create; `removeAgentTokenFromCache(token)` removes on delete - [ ] Cache loaded on startup; no full refresh on create/delete @@ -400,8 +432,11 @@ Add translation keys for: ### AC-3: Auth Middleware - [ ] For project-scoped routes: agent token must have matching project_id (and belongs to user via user_id) - [ ] Agent token used for wrong project → 403 Forbidden -- [ ] Support both `:id` and `:code` route params—resolve to project_id; agent token's project_id must match +- [ ] Support both `:id` and `:code` route params using in-memory project code/id maps; agent token's project_id must match +- [ ] Agent-usable routes consistently include project code context (`:code` / `:projectCode`) for authorization - [ ] User token: full access (per current project access model) +- [ ] Agent tokens are rejected with 403 on agent-token management endpoints (`/projects/:code/agent-tokens` and `/projects/:code/users/:userId/agent-tokens*`) +- [ ] User-only endpoint guard runs before project/user ownership checks (tokenType-first gating) **Verified by**: PactumJS test (agent token wrong project → 403) @@ -409,14 +444,16 @@ Add translation keys for: - [ ] Returns tokens for specified user in project; `me` resolves to current user - [ ] Non-leader: 403 if userId ≠ me - [ ] Leader: can request any user's tokens -- [ ] 403 if agent token for different project; 404 if project or user not found +- [ ] User token required; agent token gets 403 +- [ ] 404 if project or user not found **Verified by**: PactumJS test ### AC-4b: API — GET /projects/:code/agent-tokens (leader only) - [ ] Project leader receives all users + their tokens (tree format) - [ ] Non-leader: 403 -- [ ] 403 if agent token for different project; 404 if project not found +- [ ] User token required; agent token gets 403 +- [ ] 404 if project not found **Verified by**: PactumJS test @@ -424,7 +461,8 @@ Add translation keys for: - [ ] Creates token for current user + project; UUID generated - [ ] Calls `tokenService.addAgentTokenToCache()` after create so new token is valid immediately - [ ] Optional label in body -- [ ] Returns full token on create (only time it's returned) +- [ ] Returns full token on create +- [ ] User token required; agent token gets 403 - [ ] 403/404 as above **Verified by**: PactumJS test @@ -434,10 +472,18 @@ Add translation keys for: - [ ] Calls `tokenService.removeAgentTokenFromCache(token)` after delete so revoked token is rejected immediately - [ ] Project leader can delete any token for project - [ ] Non-leader can delete only own tokens +- [ ] User token required; agent token gets 403 - [ ] 403 if not authorized; 404 if not found **Verified by**: PactumJS test +### AC-6b: API Immutability — No Update Endpoint +- [ ] No PATCH/PUT endpoint exists for agent tokens +- [ ] Attempting token update routes returns 404 (route not found) +- [ ] OpenAPI does not document PATCH/PUT for agent-token paths + +**Verified by**: PactumJS + OpenAPI tests + ### AC-7: UI — Project Row Icon - [ ] New icon (agent or gear) at end of each project row - [ ] Click opens Agent Tokens modal for that project @@ -464,23 +510,25 @@ Add translation keys for: - [ ] GET .../users/me/agent-tokens and GET .../users/:id/agent-tokens (leader) - [ ] GET .../agent-tokens: leader gets tree; non-leader gets 403 - [ ] Auth tests: agent token wrong project → 403 +- [ ] Auth tests: agent token calling token-management routes (`GET/POST/DELETE agent-tokens`) → 403 +- [ ] Immutability tests: PATCH/PUT token update paths are not available (404) and not in OpenAPI - [ ] Cache invalidation: after DELETE, revoked token returns 401 on next request -- [ ] **Agent persona tests**: Update `agent-workflow.test.mjs` to use agent token (`660e8400-e29b-41d4-a716-446655440001`) instead of user token—proves agents can perform full workflow (create deliverable, tasks, set status, append notes) with agent token. Add test: agent token for ZAZZ used on `/projects/MOBDEV/...` → 403 +- [ ] **Agent persona tests**: Update `agent-workflow.test.mjs` to use agent token (`660e8400-e29b-41d4-a716-446655440101`) instead of user token—proves agents can perform full workflow (create deliverable, tasks, set status, append notes) with agent token. Add test: agent token for ZAZZ used on `/projects/ZED_MER/...` → 403 - [ ] **Planner-sequence tests**: Update `deliverables-approval.test.mjs` and/or `deliverables-status.test.mjs` to use agent token where they simulate planner behavior (approve plan, transition PLANNING → IN_PROGRESS, create non-dependent tasks). These sequential tests validate agent tokens for planner persona. -- [ ] **Wrong-project tests**: Agent token for MOBDEV (`660e8400-e29b-41d4-a716-446655440003`) used on `/projects/ZAZZ/deliverables` or `/projects/ZAZZ/deliverables/1/approve` → 403. Agent token for ZAZZ used on `/projects/MOBDEV/...` → 403. +- [ ] **Wrong-project tests**: Agent token for ZED_MER (`660e8400-e29b-41d4-a716-446655440103`) used on `/projects/ZAZZ/deliverables` or `/projects/ZAZZ/deliverables/1/approve` → 403. Agent token for ZAZZ used on `/projects/ZED_MER/...` → 403. **Verified by**: PactumJS tests ### Test Strategy: Agent Persona **Relevant existing tests**: -- `agent-workflow.test.mjs` — leader creates deliverable + tasks; worker/QA agents set status, append notes. All against ZAZZ. **Update to use agent token** `660e8400-e29b-41d4-a716-446655440001` (Michael, ZAZZ). +- `agent-workflow.test.mjs` — leader creates deliverable + tasks; worker/QA agents set status, append notes. All against ZAZZ. **Update to use agent token** `660e8400-e29b-41d4-a716-446655440101` (Michael, ZAZZ). - `deliverables-approval.test.mjs` — approve plan, transition PLANNING → IN_PROGRESS. Sequential planner flow. **Update to use agent token** where simulating planner. - `deliverables-status.test.mjs` — "should transition from PLANNING to IN_PROGRESS after approval", "should track status history". **Update to use agent token** for planner sequence. **Wrong-project tests**: Add tests (in agent-tokens.test.mjs or deliverables tests) that use agent tokens tied to a different project: -- Token for MOBDEV (`660e8400-e29b-41d4-a716-446655440003`) on `/projects/ZAZZ/...` → 403 -- Token for ZAZZ on `/projects/MOBDEV/...` → 403 +- Token for ZED_MER (`660e8400-e29b-41d4-a716-446655440103`) on `/projects/ZAZZ/...` → 403 +- Token for ZAZZ on `/projects/ZED_MER/...` → 403 **No need to update** every route test—agent-workflow, deliverables-approval, and deliverables-status cover agent persona. Other route tests can continue using user token. @@ -507,17 +555,21 @@ Add translation keys for: - Adding token expiration or rotation ### Never Do -- Return full token on GET list (only on POST create) +- Mask tokens in token list responses for authorized human users +- Allow agent tokens to call token-management routes +- Add PATCH/PUT token update routes - Skip user+project scope check for agent tokens on project routes --- ## 11. Technical Context -- **tokenService**: `api/src/services/tokenService.js` — expand cache to include agent tokens; add `addAgentTokenToCache()` and `removeAgentTokenFromCache()`; call on create/delete -- **authMiddleware**: `api/src/middleware/authMiddleware.js` — attach tokenType, agentTokenProjectId; add project-scope check for project routes (support both `:id` and `:code` params) -- **Project routes**: Resolve `:id` or `:code` to project_id; agent token's project_id must match +- **tokenService**: `api/src/services/tokenService.js` — expand cache to include agent tokens plus `projectIdByCode`/`projectCodeById`; add `addAgentTokenToCache()` and `removeAgentTokenFromCache()`; call on create/delete +- **authMiddleware**: `api/src/middleware/authMiddleware.js` — attach tokenType, agentTokenProjectId, agentTokenProjectCode; add project-scope check for project routes (support both `:id` and `:code` params via cache) +- **Project routes**: Normalize `:id` or `:code` via in-memory project maps; agent token's project_id must match - **Agent token routes**: `GET/POST /projects/:code/users/:userId/agent-tokens`, `GET /projects/:code/agent-tokens` (leader tree), `DELETE .../users/:userId/agent-tokens/:id`; `userId` = `me` or numeric ID + - User token only for these routes; agent token must return 403 + - No update route (`PATCH`/`PUT`) for agent tokens by design - **Client**: `ProjectList.jsx` — add icon; new `AgentTokensModal.jsx` component (expandable tree/table) --- diff --git a/.zazz/deliverables/ZAZZ-8-playwright-ui-testing-SPEC.md b/.zazz/deliverables/ZAZZ-8-playwright-ui-testing-SPEC.md new file mode 100644 index 00000000..05e0f1c0 --- /dev/null +++ b/.zazz/deliverables/ZAZZ-8-playwright-ui-testing-SPEC.md @@ -0,0 +1,174 @@ +# Playwright UI Testing Specification (Draft) + +**Project**: Zazz Board +**Deliverable**: playwright-ui-testing +**Created**: 2026-03-08 +**Status**: Draft +**Mode**: Development (SPEC only, no API sync) + +--- + +## 1. Problem Statement + +Current coverage is strong at API level (Pactum/Vitest), but UI behavior is verified manually. This creates risk for regressions in user workflows (rendering, interactions, role visibility, modal behavior, i18n text presence). + +The team needs UI tests that can run in CI/CD reliably and headlessly, while still allowing local interactive debugging when failures happen. + +--- + +## 2. Recommendation + +Use **Playwright as the canonical UI automation framework**. + +- **CI/CD default**: headless Playwright runs (Chromium) with artifacts (trace/video/screenshot) on failure. +- **Local development**: headed Playwright mode and trace viewer for debugging. +- **Playwright MCP**: optional helper for test authoring/debug workflows; **not** the canonical CI execution path. +- **Owner interaction model**: deliver a simple command-level interface so owners do not need to work directly with Playwright APIs/config internals. + +Rationale: +- Playwright is stable for CI, supports robust waiting/assertions, and provides first-class debugging artifacts. +- MCP is useful for interactive agent/operator workflows but should not be the gate in CI. + +--- + +## 3. Goals + +- Add deterministic, repeatable UI tests for critical user workflows. +- Run UI tests in CI/CD without human intervention. +- Keep execution time practical with a smoke-first strategy. +- Preserve manual verification as a complementary release check (at least initially). +- Make setup and execution **agent-managed** so a human owner can request outcomes without touching Playwright details. + +--- + +## 4. Non-Goals + +- Full UI exhaustiveness in first iteration. +- Replacing API tests with UI tests. +- Browser matrix expansion beyond Chromium in initial rollout. + +--- + +## 5. In Scope + +- Add Playwright project configuration under `client/`. +- Add agent-friendly npm scripts/commands for local and CI execution. +- Add minimum critical-path UI tests for: + - Project list loads for authenticated user. + - Agent token management entry point opens from project row. + - Agent token modal core interactions (leader/non-leader visibility behavior, create/list/delete confirmation gating) once ZAZZ-6 lands. +- Add CI workflow job for headless Playwright. +- Capture test artifacts on failure (trace/screenshot/video). +- Define selector strategy (`data-testid`) for stable tests. +- Define test data/fixture strategy tied to seeded DB. +- Add contributor docs that describe **what to run**, not how to author Playwright internals. + +--- + +## 6. Out of Scope + +- Migrating all existing manual QA scenarios in one deliverable. +- Visual-diff/snapshot testing. +- Cross-browser matrix (Firefox/WebKit) in first phase. + +--- + +## 7. Technical Design + +### 7.1 Tooling + +- Framework: Playwright test runner +- Browser: Chromium (headless in CI) +- Base URL: Vite client URL with API running in parallel +- Artifacts: `trace: on-first-retry`, screenshot/video on failure +- Entry points: + - `npm run test:ui` (headless smoke; default command for owners/agents) + - `npm run test:ui:debug` (headed debug mode; mostly for code-agent triage) + - `npm run test:ui:update` (optional maintenance command, agent-only) + +### 7.2 Selector Strategy + +- Prefer `data-testid` for stable UI anchors. +- Avoid brittle selectors based on icons-only, CSS class names, or translated text where practical. + +### 7.3 Fixture Strategy + +- Use deterministic seeded DB data. +- Ensure at least one known leader token and one known non-leader token for permission checks. +- Provide reset-and-seed step before CI UI tests. + +### 7.4 Test Scope (Initial) + +- Smoke suite only (small, high-signal): + - app boot + authenticated project list + - open/close key modal flows + - one permission-visibility assertion per role + - one destructive action gating assertion (typed confirmation) + +--- + +## 8. CI/CD Strategy + +- Add a dedicated UI test job: + 1. Start/verify DB + 2. Reset/seed test DB + 3. Start API + client + 4. Run Playwright headless + 5. Upload artifacts on failure +- Initially keep as: + - required on mainline PRs touching client or auth routes, or + - non-blocking for first rollout window then move to blocking (team decision) + +--- + +## 9. Acceptance Criteria + +### AC-1: Playwright Foundation +- [ ] Playwright is configured in `client/` and runnable locally. +- [ ] `npm` scripts exist for headless CI and local debug execution. +- [ ] Owners can run UI validation with a single command (`npm run test:ui`) without interacting with Playwright internals. + +### AC-2: Deterministic Test Environment +- [ ] UI tests can run against a reset/seeded environment with stable auth fixtures. +- [ ] Fixtures include at least one leader and one non-leader persona token. + +### AC-3: Core UI Coverage +- [ ] Critical smoke tests implemented and passing locally. +- [ ] Tests include modal interaction and confirmation-gating checks. + +### AC-4: CI/CD Integration +- [ ] UI tests run headlessly in CI. +- [ ] Failure artifacts (trace/screenshot/video) are accessible in CI logs/artifacts. + +### AC-5: Documentation +- [ ] Contributor instructions document local run/debug workflow. +- [ ] CI behavior, gating policy, and fixture prerequisites are documented. +- [ ] Documentation is agent-first: owner asks for behavior verification; code agent handles setup/config specifics. + +--- + +## 10. Risks and Mitigations + +- Risk: Flaky UI tests due to async rendering/network timing. + - Mitigation: Use Playwright locators/assertions with explicit waits; avoid sleeps. +- Risk: Seed data drift breaks auth-role scenarios. + - Mitigation: Keep fixed seed tokens/roles for test personas; validate fixtures in setup. +- Risk: CI runtime increases. + - Mitigation: Keep smoke suite minimal and parallelize where possible. + +--- + +## 11. Open Questions + +- Should first rollout be CI-blocking immediately, or non-blocking for a short stabilization window? +- Should role fixtures be expanded beyond leader/non-leader in phase 1? +- Do we want browser matrix expansion after smoke stabilization? + +--- + +## 12. Recommendation Summary (Decision) + +Adopt **Playwright headless in CI/CD** as the canonical UI test path. +Use headed/local Playwright for debugging. +Use MCP only as an optional assistant workflow, not as the CI test executor. +Treat Playwright as an implementation detail: owner-level workflow should be one-command execution and code-agent-managed maintenance. diff --git a/.zazz/deliverables/index.yaml b/.zazz/deliverables/index.yaml index b4f1c506..5b7c8d6b 100644 --- a/.zazz/deliverables/index.yaml +++ b/.zazz/deliverables/index.yaml @@ -13,6 +13,7 @@ deliverables: - id: ZAZZ-6 name: multiple-agent-tokens-feature spec: ZAZZ-6-multiple-agent-tokens-feature-SPEC.md + plan: ZAZZ-6-multiple-agent-tokens-feature-PLAN.md - id: ZAZZ-7 name: worker-file-locking spec: ZAZZ-7-worker-file-locking-SPEC.md From b079e2f42859c7a345e002fc26273d788083cb58 Mon Sep 17 00:00:00 2001 From: michaelwitz Date: Sun, 8 Mar 2026 14:49:07 -0400 Subject: [PATCH 11/15] chore(zazz-6): tighten worker multi-agent and token auth planning docs --- .agents/skills/worker-agent/SKILL.md | 50 +++++++++++++++++-- .agents/skills/zazz-board-api/SKILL.md | 4 ++ ...ZZ-6-multiple-agent-tokens-feature-PLAN.md | 35 ++++++++++++- ...ZZ-6-multiple-agent-tokens-feature-SPEC.md | 45 +++++++++++++++-- 4 files changed, 122 insertions(+), 12 deletions(-) diff --git a/.agents/skills/worker-agent/SKILL.md b/.agents/skills/worker-agent/SKILL.md index b74c177f..837d953d 100644 --- a/.agents/skills/worker-agent/SKILL.md +++ b/.agents/skills/worker-agent/SKILL.md @@ -149,24 +149,64 @@ Lock workflow: 6. On completion or handoff, run `zazzctl exec complete` (status + release). 7. If worker process crashes/restarts, re-resolve task state from API and reacquire before resuming edits. +### Harness-aware lock exception (Codex/subagent environments) +If the active execution harness provides all of the following guarantees, API lock calls may be skipped for those internal subagents: +1. strict disjoint file ownership assignment per subagent +2. isolated subagent workspaces/branches with parent-controlled integration +3. serialization of overlapping file ownership (no concurrent overlap) + +When using this exception: +1. Parent worker MUST enforce disjoint ownership policy from the parallel execution section. +2. Parent worker MUST serialize tasks when ownership overlaps. +3. Parent worker MUST still keep board task status/notes truthful. +4. If any external worker/process may touch the same deliverable/files, DO NOT use this exception; use API locks. + +Default policy remains: use API file locks unless the harness guarantees above are explicitly present. + --- ## Parallel Execution Policy If subagents/teams are supported, parallelization is required. +### Subagent contract (required when available) +When using subagents/teams, the parent worker must assign explicit ownership and integration rules: +1. Assign exactly one executable task per subagent. +2. Assign an explicit owned file set per subagent (from PLAN file assignments). +3. Require each subagent to edit only its owned files. +4. Require each subagent to report: + - files changed + - tests run + outcome + - blockers and unresolved questions +5. Parent worker is responsible for: + - integrating subagent outputs + - resolving cross-task conflicts + - running final verification + - updating board statuses/notes + ### Parallelization algorithm 1. Compute the ready set (dependencies satisfied). -2. From ready tasks, select tasks with no overlapping file ownership. -3. Spawn subagents for as many safe tasks as possible. -4. Assign one task per subagent. -5. Track and merge outputs; update board statuses for each task. -6. Recompute ready set and repeat. +2. Build each task's ownership set from PLAN file assignments (or the smallest defensible file set if PLAN omits explicit files). +3. Select only tasks whose ownership sets are pairwise disjoint. +4. If two ready tasks overlap on any file, serialize them (do not run in parallel). +5. Spawn subagents for as many safe tasks as possible (default max parallel workers: 3 unless Owner specifies otherwise). +6. Assign one task per subagent with explicit owned files, lock list, and test expectations. +7. Track outputs; integrate changes sequentially in parent worker context; then update board statuses for each task. +8. Recompute ready set and repeat. If subagents are not supported, execute the same dependency order in single-agent mode. Do not run tasks in parallel when they overlap on locked/conflicting files. +### Merge and integration protocol (required) +`Merge results` means parent-worker integration of completed subagent work, not blind acceptance: +1. Wait for subagent completion reports. +2. Review each subagent's changed files against assigned ownership. +3. Integrate non-conflicting outputs first. +4. Resolve conflicts in parent context when outputs touch shared contracts/interfaces. +5. Run required tests for each completed task, then run a cross-task regression sweep. +6. Only after verification, advance task statuses and append notes with integration outcome. + --- ## TDD and Completion Policy diff --git a/.agents/skills/zazz-board-api/SKILL.md b/.agents/skills/zazz-board-api/SKILL.md index 1b41d8ce..d510fce5 100644 --- a/.agents/skills/zazz-board-api/SKILL.md +++ b/.agents/skills/zazz-board-api/SKILL.md @@ -151,6 +151,10 @@ File lock lifecycle (required for worker execution): - While work is active, refresh lease with `POST /projects/{code}/deliverables/{delivId}/locks/heartbeat`. - On completion/handoff, release with `POST /projects/{code}/deliverables/{delivId}/locks/release`. +Harness-aware exception: +- If a worker harness guarantees strict disjoint file ownership, isolated subagent workspaces, and parent-controlled merge/serialization for overlaps, lock calls may be skipped for those internal subagents. +- If any external worker/process can concurrently edit the same deliverable/files, lock calls remain mandatory. + Verification lifecycle (required): - After creating/updating tasks, re-fetch deliverable task list and confirm task `id`, `phaseStep`, `status`, and blocker fields when used. - Re-fetch deliverable graph and confirm task presence and relation edges. diff --git a/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-PLAN.md b/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-PLAN.md index e5b4f8af..0f007c6a 100644 --- a/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-PLAN.md +++ b/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-PLAN.md @@ -22,8 +22,10 @@ ### In scope - Add `AGENT_TOKENS` persistence, seed data, and reset integration. - Expand token/auth stack to support both token types with in-memory cache and project `code↔id` maps. +- Add human-user-token-only cache refresh endpoint (`POST /token-cache/refresh`) to resync token/project maps during test/admin flows. - Enforce project-scoped agent-token authorization for project routes (`:id` and `:code`). - Enforce authorization model: user tokens can access all projects (current model), agent tokens only their bound project. +- Enforce authenticated non-project routes as user-token-only; agent tokens `403` unless route is explicitly public. - Keep agent-usable routes project-code scoped (`:code` / `:projectCode`) for consistent authorization checks. - Add agent-token CRUD/list API routes and OpenAPI schemas. - Enforce **user-token-only** access for agent-token management endpoints (agent tokens must be `403`). @@ -41,7 +43,6 @@ ### Explicit non-goals from SPEC - Do not migrate existing external agents automatically. - Do not add project-level restrictions beyond existing leader/non-leader behavior. -- Do not change non-project-route behavior for agent tokens. ## 3. Verified Current State (Repository Reality) - `api/lib/db/schema.js` has `USERS.access_token` but no `AGENT_TOKENS` table. @@ -49,8 +50,10 @@ - `api/src/middleware/authMiddleware.js` authenticates token but does not attach `tokenType`/agent scope or enforce project match. - `api/scripts/reset-and-seed.js` has no `AGENT_TOKENS` drop/seed integration. - No agent-token routes/schemas exist in `api/src/routes/*` or `api/src/schemas/*`. +- No token-cache refresh endpoint exists. - Live OpenAPI has no `/projects/{code}/agent-tokens` or `/projects/{code}/users/{userId}/agent-tokens` paths. - Existing route surface includes both project param styles (`/projects/{id}` and `/projects/{code}`), so middleware must resolve both. +- Existing authenticated non-project routes (e.g. `/users/*`) do not enforce user-token-only access. - Existing tests use user token `550e8400-e29b-41d4-a716-446655440000` in agent persona suites: - `api/__tests__/routes/agent-workflow.test.mjs` - `api/__tests__/routes/deliverables-approval.test.mjs` @@ -62,8 +65,10 @@ |---|---|---| | DB schema | No `AGENT_TOKENS` | `AGENT_TOKENS(id, user_id, project_id, token, label, created_at)` + indexes | | Token cache | User-token only map | Unified map for user + agent tokens with token type/scope plus in-memory project `code↔id` maps | +| Cache refresh API | None | `POST /token-cache/refresh` (user token only; agent token `403`) to reload cache/maps from DB | | Auth request context | `request.user` only | `request.user`, `request.tokenType`, `request.agentTokenProjectId`, `request.agentTokenProjectCode`, `request.agentTokenUserId` | | Project-route auth | Any valid token accepted | Agent token must match normalized project id (from `:id` or cached `:code -> id` lookup) else `403` | +| Authenticated non-project routes | Any valid token accepted | User-token-only (`403` for agent token), except explicitly public endpoints | | Agent route shape | Mixed assumptions | Agent-usable routes must carry project code context (`:code`/`:projectCode`) for auth consistency | | Token-management endpoint auth | No special split | `/projects/:code/agent-tokens` and `/projects/:code/users/:userId/agent-tokens*` require user token; agent token always `403` | | Agent-token APIs | None | `GET /projects/:code/users/:userId/agent-tokens`, `GET /projects/:code/agent-tokens`, `POST /projects/:code/users/:userId/agent-tokens`, `DELETE /projects/:code/users/:userId/agent-tokens/:id` (no PATCH/PUT update endpoint) | @@ -100,15 +105,17 @@ Merge points: |---|---|---| | AC-1 Schema | `1.1` | Schema push/reset output + seeded data query + route behavior relying on table | | AC-2 Token Service & Cache | `1.2` | New tokenService tests and route integration tests validating add/remove cache behavior | +| AC-2b Cache Refresh Endpoint | `2.3`, `3.1` | `agent-tokens.test.mjs` coverage for `POST /token-cache/refresh` (200/401/403) | | AC-3 Auth Middleware | `1.3` | Pactum wrong-project `403` tests across `:code` and `:id` routes | | AC-4 GET user tokens | `2.2`, `3.1` | `agent-tokens.test.mjs` happy/403/404/401 | | AC-4b GET project tree | `2.2`, `3.1` | Leader/non-leader coverage in `agent-tokens.test.mjs` | | AC-5 POST token | `2.2`, `1.2`, `3.1` | Create returns token + immediate auth usability check | | AC-6 DELETE token | `2.2`, `1.2`, `3.1` | Delete + immediate `401` on revoked token | +| AC-6b API Immutability | `2.2`, `3.1`, `3.3` | PATCH/PUT absent in routes and OpenAPI, explicit `404` assertions | | AC-7 UI icon | `4.1` | Manual owner verification checklist | | AC-8 UI modal behaviors | `4.2` | Manual owner verification checklist (leader/non-leader flows) | | AC-9 UI delete confirmation flow | `4.2` | Manual owner verification checklist (exact phrase gating) | -| AC-10 Tests | `3.1`, `3.2`, `3.3` | Full route/openapi/persona/wrong-project test suite | +| AC-10 Tests | `3.1`, `3.2`, `3.3` | Full route/openapi/persona/wrong-project + non-project-guard + cache-refresh suite | ## 7. Phased Execution Plan ### Phase 1 - Data/Auth foundation @@ -145,6 +152,7 @@ Merge points: - Cache entries include `{ type, userId, projectId?, projectCode?, email?, fullName? }`. - Project maps loaded at startup: `projectIdByCode` and `projectCodeById`. - `addAgentTokenToCache()` and `removeAgentTokenFromCache()` implemented. + - `refreshCache()` remains callable for explicit cache reload route. - Startup initialization includes users + agent tokens + project `code↔id` maps. - DEPENDS_ON: `1.1` - COORDINATES_WITH: `2.2`, `3.1` @@ -168,6 +176,7 @@ Merge points: - Middleware uses cached `request.tokenType` (`user`/`agent`) as primary gate for user-only endpoints. - Middleware resolves project context from `:code`, `:projectCode`, or `:id` on project routes using cached project maps. - Agent token mismatch returns `403`; user token behavior unchanged. + - Agent token on authenticated non-project routes returns `403` (public routes excluded). - Agent token on token-management endpoints returns `403` even when project matches. - Request context fields populated for downstream handlers (`agentTokenProjectCode` included). - Agent-usable route audit confirms project code context is present where agents are expected to operate. @@ -177,6 +186,7 @@ Merge points: - TDD: tests to write first: - Wrong-project tests for both route param styles. - User-only endpoint test proving agent token is rejected by tokenType-first check (`403`) before ownership/path checks. + - Non-project route test proving agent token is rejected on authenticated non-project routes (`/users/me`), while public routes stay accessible. - Tests asserting agent-token access is only exercised on project-scoped routes carrying `:code`/`:projectCode`. - TDD: tests to run for completion: - `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/agent-tokens.test.mjs __tests__/routes/project-id-routes-regression.test.mjs` @@ -231,6 +241,27 @@ Merge points: - Acceptance criteria mapped: `AC-4`, `AC-4b`, `AC-5`, `AC-6`, partial `AC-10` - Completion signal: All new endpoints pass Pactum coverage and return spec-compliant payloads. +#### 2.3 Add user-token-only token-cache refresh endpoint +- Objective: Provide explicit cache resync capability for tests/admin flows after DB reset/direct DB changes. +- Files affected: + - `api/src/routes/index.js` (or dedicated core/auth route plugin) + - `api/src/schemas/core.js` (or new auth schema file) + - `api/src/schemas/index.js` + - `api/src/schemas/validation.js` +- Deliverables/output: + - `POST /token-cache/refresh` + - User token required (`200`), missing/invalid token (`401`), agent token (`403`) + - Endpoint calls `tokenService.refreshCache()` and returns success payload +- DEPENDS_ON: `1.2`, `1.3` +- COORDINATES_WITH: `3.1`, `3.3` +- Parallelizable with: `4.1` +- TDD: tests to write first: + - Route tests for refresh success (`200`) with user token and failure (`401`/`403`) +- TDD: tests to run for completion: + - `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/agent-tokens.test.mjs` +- Acceptance criteria mapped: `AC-2b`, partial `AC-10` +- Completion signal: Refresh endpoint reliably reloads cache and enforces tokenType gating. + ### Phase 3 - Test updates and regression protection #### 3.1 Add dedicated Pactum suite for agent-token endpoints and auth scope diff --git a/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-SPEC.md b/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-SPEC.md index f89bff10..e91ecb9d 100644 --- a/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-SPEC.md +++ b/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-SPEC.md @@ -42,6 +42,7 @@ Zazz Board currently uses a single token per user (`USERS.access_token`). Both h - `USERS.access_token` repurposed as **user token** (human only, full project access) - `tokenService` and `authMiddleware` extended to support both token types and user+project-scope validation for agent tokens - **In-memory cache**: Expand existing user-token cache to include agent tokens and project code/id lookup maps; single Map lookup for token validation (no DB hit). Cache populated on startup. **On create**: add new token to cache as part of the create route (no full refresh). **On delete**: remove token from cache as part of the delete route (no full refresh). Assume single API instance for now; multi-instance shared cache is future work. +- User-token-only cache maintenance endpoint: `POST /token-cache/refresh` reloads token/project maps from DB (for test isolation and admin maintenance). Agent token is forbidden (`403`). - API routes: list agent tokens (project + user scoped), create agent token, delete agent token (hard delete) - Agent token records are immutable after create (no PATCH/PUT update endpoint in API) - Route protection: agent token must match both user context and project in URL @@ -62,9 +63,9 @@ Zazz Board currently uses a single token per user (`USERS.access_token`). Both h ### Future Fixes (Identified During Spec Interview) -Cleanup items identified during the specification interview but **outside the scope** of this deliverable are documented in [future-fixes.md](../future-fixes.md). Includes: project route param inconsistency (`:id` vs `:code`), legacy naming (task_blaster vs zazz_board), non-project route behavior for agent tokens, project-level access restrictions, multi-instance cache. +Cleanup items identified during the specification interview but **outside the scope** of this deliverable are documented in [future-fixes.md](../future-fixes.md). Includes: project route param inconsistency (`:id` vs `:code`), legacy naming (task_blaster vs zazz_board), project-level access restrictions, multi-instance cache. -**Non-project routes (agent tokens)**: Non-project routes just work—no restriction. Agent tokens receive the same response as user tokens. Authorization is limited to project-scoped routes. When routes gain project scoping in the future, add agent-token authorization there. +**Non-project routes (agent tokens)**: Agent tokens are **not allowed** on authenticated non-project routes. Public routes (`/health`, `/db-test`, `/openapi.json`, `/docs`) remain unauthenticated. Any authenticated route intended for agent use must carry project context (`/projects/:code/...` or `/projects/:id/...`). --- @@ -141,8 +142,6 @@ New seeder: `api/scripts/seeders/seedAgentTokens.js`. Add to `reset-and-seed.js` **Test stability**: Seeded tokens must stay **consistent** across runs—same UUIDs, same projects—so all API tests can rely on them. Agent token tests use these fixed values. Tokens are seeded only in projects used by agent tests (ZAZZ, ZED_MER). -**USERS seed alignment note**: In `seedUsers`, rename `Mike Johnson` to `Steve Johnson`. Add `Jonathon` as an additional seeded user (new row only) so AGENT_TOKENS seed references remain stable (`user_id` 2/3/5 unchanged). - **Seeder implementation**: ```javascript // api/scripts/seeders/seedAgentTokens.js @@ -191,6 +190,7 @@ The existing `tokenService` caches user tokens on startup. **Expand the cache** 4. Read cached token type (`'user' | 'agent'`) and set request token context 5. For user-token-only endpoints: if cached type is `agent`, return `403` immediately (no further auth checks) 6. If token type is `user`: full access (current model). If `agent`: project-scoped checks apply. +7. For authenticated non-project endpoints: user token only (`agent` -> `403`) unless endpoint is explicitly public. ### 5.3 Request Context @@ -223,6 +223,7 @@ Do not expand scope to standardize routes; add inconsistency to future-fixes. **Authorization summary**: - User token (`USERS.access_token`): full project access (current model). - Agent token (`AGENT_TOKENS.token`): restricted to one project only; any other project returns `403`. +- Authenticated non-project routes: user token only (`403` for agent token). **Routes using `:id`** (projects.js): `GET /projects/:id`, `PUT /projects/:id`, `DELETE /projects/:id`, `GET /projects/:id/tasks`, `GET /projects/:id/kanban/tasks/column/:status` @@ -232,9 +233,12 @@ Do not expand scope to standardize routes; add inconsistency to future-fixes. ## 6. API Specification -All routes require authentication. +Public routes (no auth): `/health`, `/db-test`, `/openapi.json`, `/docs`. + +All other routes require authentication. - For general project-scoped routes, user token or matching-project agent token is valid. +- For authenticated non-project routes, **user token only** is valid (agent token `403`). - For **agent-token management routes in section 6.1**, **user token only** is valid. Agent tokens are forbidden (`403`) on these routes. ### 6.1 Agent Token CRUD @@ -362,6 +366,25 @@ This endpoint is **human user token only** (agent tokens forbidden). **Response 403**: Agent token used (forbidden on token-management routes), or not authorized to delete this token (e.g. non-leader trying to delete another user's token) **Response 404**: Token or project not found +### 6.2 Token Cache Refresh + +#### POST /token-cache/refresh + +Refreshes in-memory token cache and project code/id maps from the database. This endpoint exists to keep long-running test sessions and admin/debug flows in sync after direct DB resets/changes. + +This endpoint is **human user token only** (agent tokens forbidden). + +**Response 200**: +```json +{ + "message": "Token cache refreshed", + "cacheInitialized": true +} +``` + +**401**: Missing/invalid token +**403**: Agent token used + --- ## 7. UI Specification @@ -429,6 +452,13 @@ Add translation keys for: **Verified by**: unit test or integration test +### AC-2b: API — POST /token-cache/refresh +- [ ] Endpoint refreshes token and project maps from DB +- [ ] User token required; agent token gets 403 +- [ ] Returns success response for test/admin cache resync flows + +**Verified by**: PactumJS test + ### AC-3: Auth Middleware - [ ] For project-scoped routes: agent token must have matching project_id (and belongs to user via user_id) - [ ] Agent token used for wrong project → 403 Forbidden @@ -436,6 +466,7 @@ Add translation keys for: - [ ] Agent-usable routes consistently include project code context (`:code` / `:projectCode`) for authorization - [ ] User token: full access (per current project access model) - [ ] Agent tokens are rejected with 403 on agent-token management endpoints (`/projects/:code/agent-tokens` and `/projects/:code/users/:userId/agent-tokens*`) +- [ ] Agent tokens are rejected with 403 on authenticated non-project routes (except public routes) - [ ] User-only endpoint guard runs before project/user ownership checks (tokenType-first gating) **Verified by**: PactumJS test (agent token wrong project → 403) @@ -513,6 +544,8 @@ Add translation keys for: - [ ] Auth tests: agent token calling token-management routes (`GET/POST/DELETE agent-tokens`) → 403 - [ ] Immutability tests: PATCH/PUT token update paths are not available (404) and not in OpenAPI - [ ] Cache invalidation: after DELETE, revoked token returns 401 on next request +- [ ] Cache refresh endpoint (`POST /token-cache/refresh`): user token 200, agent token 403, missing token 401 +- [ ] Non-project auth guard: agent token on authenticated non-project route (e.g. `/users/me`) returns 403 - [ ] **Agent persona tests**: Update `agent-workflow.test.mjs` to use agent token (`660e8400-e29b-41d4-a716-446655440101`) instead of user token—proves agents can perform full workflow (create deliverable, tasks, set status, append notes) with agent token. Add test: agent token for ZAZZ used on `/projects/ZED_MER/...` → 403 - [ ] **Planner-sequence tests**: Update `deliverables-approval.test.mjs` and/or `deliverables-status.test.mjs` to use agent token where they simulate planner behavior (approve plan, transition PLANNING → IN_PROGRESS, create non-dependent tasks). These sequential tests validate agent tokens for planner persona. - [ ] **Wrong-project tests**: Agent token for ZED_MER (`660e8400-e29b-41d4-a716-446655440103`) used on `/projects/ZAZZ/deliverables` or `/projects/ZAZZ/deliverables/1/approve` → 403. Agent token for ZAZZ used on `/projects/ZED_MER/...` → 403. @@ -557,6 +590,7 @@ Add translation keys for: ### Never Do - Mask tokens in token list responses for authorized human users - Allow agent tokens to call token-management routes +- Allow agent tokens on authenticated non-project routes - Add PATCH/PUT token update routes - Skip user+project scope check for agent tokens on project routes @@ -565,6 +599,7 @@ Add translation keys for: ## 11. Technical Context - **tokenService**: `api/src/services/tokenService.js` — expand cache to include agent tokens plus `projectIdByCode`/`projectCodeById`; add `addAgentTokenToCache()` and `removeAgentTokenFromCache()`; call on create/delete +- **Token cache refresh route**: `POST /token-cache/refresh` (user-token-only) to resync in-memory cache/maps from DB for test/admin workflows - **authMiddleware**: `api/src/middleware/authMiddleware.js` — attach tokenType, agentTokenProjectId, agentTokenProjectCode; add project-scope check for project routes (support both `:id` and `:code` params via cache) - **Project routes**: Normalize `:id` or `:code` via in-memory project maps; agent token's project_id must match - **Agent token routes**: `GET/POST /projects/:code/users/:userId/agent-tokens`, `GET /projects/:code/agent-tokens` (leader tree), `DELETE .../users/:userId/agent-tokens/:id`; `userId` = `me` or numeric ID From bc956f36910381d44572bb94bf94f67dbca10d7b Mon Sep 17 00:00:00 2001 From: michaelwitz Date: Sun, 8 Mar 2026 15:47:48 -0400 Subject: [PATCH 12/15] fix(client): sync editable state updates and add deliverable edit UI test --- .zazz/standards/coding-styles.md | 47 + client/package-lock.json | 1182 ++++++++++++++++- client/package.json | 12 +- client/src/App.jsx | 17 +- client/src/components/DeliverableModal.jsx | 14 +- client/src/components/TaskDetailPanel.jsx | 10 +- .../DeliverableModal.integration.test.jsx | 64 + client/src/hooks/useDeliverables.js | 27 +- client/src/hooks/useProjectEvents.js | 15 +- client/src/hooks/useTaskActions.js | 6 +- client/src/hooks/useTasks.js | 51 +- client/src/pages/DeliverableKanbanPage.jsx | 9 +- client/src/pages/DeliverableListPage.jsx | 9 +- client/src/pages/TaskGraphPage.jsx | 5 +- client/src/test/setup.js | 33 + client/vite.config.js | 5 + 16 files changed, 1443 insertions(+), 63 deletions(-) create mode 100644 client/src/components/__tests__/DeliverableModal.integration.test.jsx create mode 100644 client/src/test/setup.js diff --git a/.zazz/standards/coding-styles.md b/.zazz/standards/coding-styles.md index 08411a51..fea0150a 100644 --- a/.zazz/standards/coding-styles.md +++ b/.zazz/standards/coding-styles.md @@ -47,6 +47,53 @@ See `deliverables.js`, `taskGraph.js`, `projects.js` for examples. Map service/database errors to appropriate HTTP codes in the handler: business rule violations (cycle, self-ref, not found) → 400; duplicate/conflict → 409; authorization failure → 403. See `taskGraph.js` createTaskRelation for error-mapping pattern. +## Client mutation pattern (no stale UI after save) + +When a user edits data in the UI, backend persistence and UI state update must happen in one consistent flow. + +- **Single source of truth per screen**: the page-level hook/state that renders the list/board owns mutations (`create`, `update`, `delete`). +- **No duplicate mutation hooks in child forms/modals**: child components receive `onSubmit` callbacks from the parent; they do not create a second hook instance for the same entity. +- **Save contract**: submit handlers return success/failure (`saved entity` or `false`), so forms only close on success. +- **Immediate state update from API response**: on success, update local state from the returned entity so the user sees changes without reload. +- **Realtime is secondary**: SSE/realtime updates are for cross-tab/cross-user sync and revalidation, not the primary mechanism for reflecting your own save. +- **Failure behavior**: keep the editor open on failed save; surface an error and do not silently close. + +This pattern applies to all editable entities (projects, deliverables, tasks, tags, workflow settings). + +### Required mutation lifecycle (UI) + +For every editable form/panel/modal: + +1. Parent screen owns entity state and mutation hook. +2. Child editor receives `onSubmit` and returns pending/success/failure state. +3. Submit executes one mutation request and awaits response. +4. On success, merge returned entity into parent state by `id` (string-safe id match). +5. Close editor only after success. +6. Optional background revalidation runs after success, but must not overwrite newer local mutation state. + +### Race-safety and revalidation rules + +- Guard against stale GET responses overwriting newer local state: + - Track request sequence/version and ignore outdated responses. + - Track mutation version and ignore refresh results started before a newer mutation. +- For mutation-sensitive list reloads, use `cache: 'no-store'` unless a stronger cache contract (ETag/versioning) is implemented. +- Realtime-triggered refresh must be debounced/throttled and must avoid reconnect storms. + +### Anti-patterns (do not ship) + +- Child modal calls `useXxx()` mutation hook separately from parent list/page hook for same entity. +- Editor closes before mutation promise resolves. +- Save handler does not return explicit success/failure to caller. +- Revalidation response always blindly replaces state, regardless of request ordering. +- Logic depends on hard reload for user to see their own saved change. + +### Minimum test expectations for editable flows + +- Edit field -> Save -> Reopen editor without reload shows updated value. +- Edit field -> Save -> List/card row reflects update immediately. +- Failed save keeps editor open and preserves entered values. +- Concurrent refresh + save does not regress state to pre-save value. + ## UPPER_SNAKE_CASE for status codes and enum-like values Status codes, priorities, and other enum-like values use **UPPER_SNAKE_CASE** (e.g. `TO_DO`, `IN_PROGRESS`, `QA`, `COMPLETED`, `LOW`, `MEDIUM`, `FEATURE`, `BUG_FIX`). These values are stored in the DB, used in the API, and double as i18n translation keys. diff --git a/client/package-lock.json b/client/package-lock.json index b9a2a380..638cca42 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -30,6 +30,10 @@ }, "devDependencies": { "@eslint/js": "^9.30.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", @@ -37,9 +41,25 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", - "vite": "^7.0.4" + "jsdom": "^28.1.0", + "vite": "^7.0.4", + "vitest": "^4.0.18" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -54,6 +74,64 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -345,6 +423,151 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.0.tgz", + "integrity": "sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -960,6 +1183,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -1101,9 +1342,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, @@ -1486,6 +1727,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tabler/icons": { "version": "3.34.1", "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.34.1.tgz", @@ -1512,6 +1760,112 @@ "react": ">= 16" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1557,6 +1911,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -1615,6 +1980,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1797,6 +2169,117 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xyflow/react": { "version": "12.10.0", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz", @@ -1852,6 +2335,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -1869,6 +2362,49 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1916,6 +2452,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -2012,6 +2558,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -2140,6 +2696,53 @@ ], "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -2251,6 +2854,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -2268,6 +2885,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -2338,6 +2962,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -2399,6 +3030,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2666,6 +3304,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2676,6 +3324,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3235,6 +3893,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -3259,9 +3930,37 @@ "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" } }, "node_modules/i18next": { @@ -3324,6 +4023,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inline-style-parser": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", @@ -3409,6 +4118,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3422,6 +4138,60 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3541,6 +4311,26 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -3842,6 +4632,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -4426,6 +5223,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", @@ -4502,6 +5309,17 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4615,6 +5433,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4674,6 +5499,28 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4966,6 +5813,20 @@ "react-dom": ">=16.6.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/refractor": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/refractor/-/refractor-4.9.0.tgz", @@ -5278,6 +6139,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", @@ -5318,6 +6189,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -5363,6 +6247,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5383,6 +6274,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -5397,6 +6302,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/style-to-js": { "version": "1.1.17", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz", @@ -5415,12 +6333,36 @@ "inline-style-parser": "0.2.4" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5438,6 +6380,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", + "integrity": "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.25" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.25.tgz", + "integrity": "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -5489,6 +6487,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -5842,6 +6850,84 @@ } } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -5851,6 +6937,19 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -5861,6 +6960,41 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5877,6 +7011,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5887,6 +7038,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/client/package.json b/client/package.json index d9680efb..ea57c5bf 100644 --- a/client/package.json +++ b/client/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -32,6 +34,10 @@ }, "devDependencies": { "@eslint/js": "^9.30.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", @@ -39,6 +45,8 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", - "vite": "^7.0.4" + "jsdom": "^28.1.0", + "vite": "^7.0.4", + "vitest": "^4.0.18" } } diff --git a/client/src/App.jsx b/client/src/App.jsx index d7f9cc98..93fe7ffb 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -423,12 +423,9 @@ function AppContent() { ? targetProject.statusWorkflow[0] : 'TO_DO'; - - // Map tags to tagNames array for API - const tagNames = newTask.tags; - - const deliverablesResponse = await fetch(`http://localhost:3030/projects/${targetProject.id}/deliverables`, { + const deliverablesResponse = await fetch(`http://localhost:3030/projects/${targetProject.code}/deliverables`, { method: 'GET', + cache: 'no-store', headers: { 'TB_TOKEN': token, 'Content-Type': 'application/json' @@ -444,7 +441,9 @@ function AppContent() { return; } - const response = await fetch('http://localhost:3030/tasks', { + const response = await fetch( + `http://localhost:3030/projects/${targetProject.code}/deliverables/${deliverables[0].id}/tasks`, + { method: 'POST', headers: { 'TB_TOKEN': token, @@ -455,11 +454,7 @@ function AppContent() { prompt: newTask.prompt, priority: newTask.priority, storyPoints: newTask.storyPoints ? parseInt(newTask.storyPoints) : null, - projectId: targetProject.id, - deliverableId: deliverables[0].id, - status: initialStatus, - assigneeId: newTask.assigneeId ? parseInt(newTask.assigneeId) : null, - tagNames: tagNames + status: initialStatus }) }); diff --git a/client/src/components/DeliverableModal.jsx b/client/src/components/DeliverableModal.jsx index ede8f035..52808ce9 100644 --- a/client/src/components/DeliverableModal.jsx +++ b/client/src/components/DeliverableModal.jsx @@ -1,11 +1,9 @@ import { Modal, Stack, TextInput, Textarea, Select, Button, Group } from '@mantine/core'; import { useState, useEffect } from 'react'; import { useTranslation } from '../hooks/useTranslation.js'; -import { useDeliverables } from '../hooks/useDeliverables.js'; -export function DeliverableModal({ opened, onClose, onSubmit, deliverable, selectedProject }) { +export function DeliverableModal({ opened, onClose, onSubmit, deliverable }) { const { t, translateDeliverableType } = useTranslation(); - const { updateDeliverable } = useDeliverables(selectedProject); const [formData, setFormData] = useState({ name: '', @@ -56,12 +54,12 @@ export function DeliverableModal({ opened, onClose, onSubmit, deliverable, selec setIsSubmitting(true); try { - if (deliverable) { - await updateDeliverable(deliverable.id, formData); + const savedDeliverable = await onSubmit(formData); + if (savedDeliverable) { + onClose(); } else { - await onSubmit(formData); + console.error('Failed to save deliverable'); } - onClose(); } catch (error) { console.error('Error submitting deliverable:', error); } finally { @@ -157,4 +155,4 @@ export function DeliverableModal({ opened, onClose, onSubmit, deliverable, selec ); -} \ No newline at end of file +} diff --git a/client/src/components/TaskDetailPanel.jsx b/client/src/components/TaskDetailPanel.jsx index 8dc07b88..3bc7b8f0 100644 --- a/client/src/components/TaskDetailPanel.jsx +++ b/client/src/components/TaskDetailPanel.jsx @@ -105,7 +105,7 @@ export function TaskDetailsPanel({ }, [task, opened]); - const handleSave = () => { + const handleSave = async () => { if (onSave && editedTask) { // Validate that blocked reason is provided when task is blocked if (editedTask.isBlocked && (!editedTask.blockedReason || editedTask.blockedReason.trim() === '')) { @@ -128,13 +128,15 @@ export function TaskDetailsPanel({ prompt: editedTask.prompt === '' ? null : editedTask.prompt }; - onSave({ + const saveResult = await onSave({ ...normalizedTask, tags: tagsWithColors, tagNames: editedTask.tags // Send tag names for API update }); - - onClose(); + + if (saveResult !== false) { + onClose(); + } } }; diff --git a/client/src/components/__tests__/DeliverableModal.integration.test.jsx b/client/src/components/__tests__/DeliverableModal.integration.test.jsx new file mode 100644 index 00000000..78b2f67d --- /dev/null +++ b/client/src/components/__tests__/DeliverableModal.integration.test.jsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MantineProvider } from '@mantine/core'; +import { DeliverableModal } from '../DeliverableModal.jsx'; + +function DeliverableEditHarness() { + const [opened, setOpened] = useState(true); + const [deliverable, setDeliverable] = useState({ + id: 1, + name: 'Deliverable One', + type: 'FEATURE', + description: '', + gitBranch: 'original-branch', + gitWorktree: 'worktree-one', + }); + + const handleSubmit = async (formData) => { + const updated = { ...deliverable, ...formData }; + setDeliverable(updated); + return updated; + }; + + return ( + <> + +
{deliverable.gitBranch}
+ {opened && ( + setOpened(false)} + onSubmit={handleSubmit} + deliverable={deliverable} + /> + )} + + ); +} + +describe('Deliverable edit flow', () => { + it('saves changes and shows updated value when reopened without page reload', async () => { + const user = userEvent.setup(); + render( + + + + ); + + const gitBranchInput = screen.getByLabelText('Git Branch'); + await user.clear(gitBranchInput); + await user.type(gitBranchInput, 'ZZZ'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(screen.queryByLabelText('Git Branch')).not.toBeInTheDocument(); + }); + expect(screen.getByTestId('saved-git-branch')).toHaveTextContent('ZZZ'); + + await user.click(screen.getByRole('button', { name: 'Open Deliverable Editor' })); + const reopenedGitBranchInput = await screen.findByLabelText('Git Branch'); + expect(reopenedGitBranchInput).toHaveValue('ZZZ'); + }); +}); diff --git a/client/src/hooks/useDeliverables.js b/client/src/hooks/useDeliverables.js index 0532497c..4477d936 100644 --- a/client/src/hooks/useDeliverables.js +++ b/client/src/hooks/useDeliverables.js @@ -1,8 +1,12 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; + +const idsMatch = (a, b) => String(a) === String(b); export function useDeliverables(selectedProject) { const [deliverables, setDeliverables] = useState([]); const [loading, setLoading] = useState(false); + const latestRefreshRequestIdRef = useRef(0); + const mutationVersionRef = useRef(0); const refreshDeliverables = useCallback(async () => { if (!selectedProject) { @@ -10,6 +14,8 @@ export function useDeliverables(selectedProject) { return; } + const refreshRequestId = ++latestRefreshRequestIdRef.current; + const mutationVersionAtStart = mutationVersionRef.current; setLoading(true); try { const token = localStorage.getItem('TB_TOKEN'); @@ -20,6 +26,7 @@ export function useDeliverables(selectedProject) { const response = await fetch(`http://localhost:3030/projects/${selectedProject.code}/deliverables`, { method: 'GET', + cache: 'no-store', headers: { 'TB_TOKEN': token, 'Content-Type': 'application/json' @@ -28,6 +35,11 @@ export function useDeliverables(selectedProject) { if (response.ok) { const data = await response.json(); + const isOutdatedRefresh = refreshRequestId !== latestRefreshRequestIdRef.current; + const mutatedDuringRefresh = mutationVersionRef.current !== mutationVersionAtStart; + if (isOutdatedRefresh || mutatedDuringRefresh) { + return; + } console.log('Fetched deliverables:', data); setDeliverables(data); } else if (response.status === 401) { @@ -55,6 +67,7 @@ export function useDeliverables(selectedProject) { const createDeliverable = useCallback(async (deliverableData) => { try { + mutationVersionRef.current += 1; const token = localStorage.getItem('TB_TOKEN'); if (!token) { console.error('No access token found'); @@ -86,6 +99,7 @@ export function useDeliverables(selectedProject) { const updateDeliverable = useCallback(async (deliverableId, updates) => { try { + mutationVersionRef.current += 1; const token = localStorage.getItem('TB_TOKEN'); if (!token) { console.error('No access token found'); @@ -104,7 +118,7 @@ export function useDeliverables(selectedProject) { if (response.ok) { const updatedDeliverable = await response.json(); setDeliverables(prev => prev.map(d => - d.id === deliverableId ? updatedDeliverable : d + idsMatch(d.id, deliverableId) ? updatedDeliverable : d )); return updatedDeliverable; } else { @@ -119,6 +133,7 @@ export function useDeliverables(selectedProject) { const updateDeliverableStatus = useCallback(async (deliverableId, status) => { try { + mutationVersionRef.current += 1; const token = localStorage.getItem('TB_TOKEN'); if (!token) { console.error('No access token found'); @@ -137,7 +152,7 @@ export function useDeliverables(selectedProject) { if (response.ok) { const updatedDeliverable = await response.json(); setDeliverables(prev => prev.map(d => - d.id === deliverableId ? updatedDeliverable : d + idsMatch(d.id, deliverableId) ? updatedDeliverable : d )); return updatedDeliverable; } else { @@ -152,6 +167,7 @@ export function useDeliverables(selectedProject) { const approveDeliverable = useCallback(async (deliverableId) => { try { + mutationVersionRef.current += 1; const token = localStorage.getItem('TB_TOKEN'); if (!token) { console.error('No access token found'); @@ -169,7 +185,7 @@ export function useDeliverables(selectedProject) { if (response.ok) { const updatedDeliverable = await response.json(); setDeliverables(prev => prev.map(d => - d.id === deliverableId ? updatedDeliverable : d + idsMatch(d.id, deliverableId) ? updatedDeliverable : d )); return updatedDeliverable; } else { @@ -184,6 +200,7 @@ export function useDeliverables(selectedProject) { const deleteDeliverable = useCallback(async (deliverableId) => { try { + mutationVersionRef.current += 1; const token = localStorage.getItem('TB_TOKEN'); if (!token) { console.error('No access token found'); @@ -199,7 +216,7 @@ export function useDeliverables(selectedProject) { }); if (response.ok) { - setDeliverables(prev => prev.filter(d => d.id !== deliverableId)); + setDeliverables(prev => prev.filter(d => !idsMatch(d.id, deliverableId))); return true; } else { console.error('Failed to delete deliverable:', response.status); diff --git a/client/src/hooks/useProjectEvents.js b/client/src/hooks/useProjectEvents.js index 50165cad..f490f005 100644 --- a/client/src/hooks/useProjectEvents.js +++ b/client/src/hooks/useProjectEvents.js @@ -46,8 +46,9 @@ export function useProjectEvents(projectCode, { enabled = true, onEvent } = {}) let controller = null; const scheduleReconnect = () => { - if (disposed) return; + if (disposed || reconnectTimer) return; reconnectTimer = setTimeout(() => { + reconnectTimer = null; connect(); }, 1500); }; @@ -57,6 +58,7 @@ export function useProjectEvents(projectCode, { enabled = true, onEvent } = {}) controller = new AbortController(); let reader = null; + let shouldReconnect = false; try { const response = await fetch( `http://localhost:3030/projects/${encodeURIComponent(projectCode)}/events`, @@ -72,7 +74,7 @@ export function useProjectEvents(projectCode, { enabled = true, onEvent } = {}) ); if (!response.ok || !response.body) { - scheduleReconnect(); + shouldReconnect = true; return; } @@ -82,7 +84,10 @@ export function useProjectEvents(projectCode, { enabled = true, onEvent } = {}) while (!disposed) { const { value, done } = await reader.read(); - if (done) break; + if (done) { + shouldReconnect = !disposed; + break; + } buffer += decoder.decode(value, { stream: true }); let boundaryIndex = buffer.indexOf('\n\n'); @@ -101,7 +106,7 @@ export function useProjectEvents(projectCode, { enabled = true, onEvent } = {}) } } catch (error) { if (!disposed && error.name !== 'AbortError') { - scheduleReconnect(); + shouldReconnect = true; } } finally { if (reader) { @@ -111,7 +116,7 @@ export function useProjectEvents(projectCode, { enabled = true, onEvent } = {}) // no-op } } - if (!disposed) { + if (!disposed && shouldReconnect) { scheduleReconnect(); } } diff --git a/client/src/hooks/useTaskActions.js b/client/src/hooks/useTaskActions.js index 021ca867..8db8f662 100644 --- a/client/src/hooks/useTaskActions.js +++ b/client/src/hooks/useTaskActions.js @@ -16,7 +16,7 @@ export function useTaskActions({ refreshTasks, openDetailPanel, closeDetailPanel const token = localStorage.getItem('TB_TOKEN'); if (!token) { console.error('No access token found'); - return; + return false; } const response = await fetch(`http://localhost:3030/projects/${selectedProject.code}/tasks/${updatedTask.id}`, { @@ -48,6 +48,7 @@ export function useTaskActions({ refreshTasks, openDetailPanel, closeDetailPanel // Refresh tasks to update the board await refreshTasks(); + return true; } else if (response.status === 401) { console.error('Unauthorized - access token invalid'); if (window.showToast) { @@ -55,11 +56,14 @@ export function useTaskActions({ refreshTasks, openDetailPanel, closeDetailPanel } else { alert('Access token invalid. Please reload your access token.'); } + return false; } else { console.error('Failed to save task:', response.status); + return false; } } catch (error) { console.error('Error saving task:', error); + return false; } }, [refreshTasks, updateTaskPanel, selectedProject]); diff --git a/client/src/hooks/useTasks.js b/client/src/hooks/useTasks.js index 15a58094..a2d03438 100644 --- a/client/src/hooks/useTasks.js +++ b/client/src/hooks/useTasks.js @@ -1,4 +1,6 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; + +const idsMatch = (a, b) => String(a) === String(b); /** * Hook for managing tasks in a project with deliverables @@ -7,6 +9,8 @@ import { useState, useEffect, useCallback } from 'react'; export function useTasks(selectedProject, deliverables = []) { const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(false); + const latestFetchRequestIdRef = useRef(0); + const mutationVersionRef = useRef(0); // Task statuses from project workflow, fallback to default statuses const taskStatuses = selectedProject?.statusWorkflow || ['READY', 'IN_PROGRESS', 'QA', 'COMPLETED']; @@ -18,6 +22,8 @@ export function useTasks(selectedProject, deliverables = []) { return; } + const fetchRequestId = ++latestFetchRequestIdRef.current; + const mutationVersionAtStart = mutationVersionRef.current; setLoading(true); const fetchTasks = async () => { @@ -36,6 +42,7 @@ export function useTasks(selectedProject, deliverables = []) { `http://localhost:3030/projects/${selectedProject.code}/deliverables/${deliverable.id}/tasks`, { method: 'GET', + cache: 'no-store', headers: { 'TB_TOKEN': token, 'Content-Type': 'application/json' @@ -48,7 +55,9 @@ export function useTasks(selectedProject, deliverables = []) { allTasks.push(...deliverableTasks); } else if (response.status === 401) { console.error('Unauthorized - access token invalid'); - setLoading(false); + if (fetchRequestId === latestFetchRequestIdRef.current) { + setLoading(false); + } return; } } @@ -61,13 +70,21 @@ export function useTasks(selectedProject, deliverables = []) { return (a.position || 0) - (b.position || 0); }); + const isOutdatedFetch = fetchRequestId !== latestFetchRequestIdRef.current; + const mutatedDuringFetch = mutationVersionRef.current !== mutationVersionAtStart; + if (isOutdatedFetch || mutatedDuringFetch) { + return; + } + console.log('Fetched tasks:', allTasks); setTasks(allTasks); } catch (error) { console.error('Error fetching tasks:', error); setTasks([]); } finally { - setLoading(false); + if (fetchRequestId === latestFetchRequestIdRef.current) { + setLoading(false); + } } }; @@ -86,6 +103,7 @@ export function useTasks(selectedProject, deliverables = []) { */ const addTask = useCallback(async (newTask) => { try { + mutationVersionRef.current += 1; const token = localStorage.getItem('TB_TOKEN'); if (!token) { console.error('No access token found'); @@ -136,6 +154,7 @@ export function useTasks(selectedProject, deliverables = []) { */ const updateTask = useCallback(async (taskId, updates) => { try { + mutationVersionRef.current += 1; const token = localStorage.getItem('TB_TOKEN'); if (!token) { console.error('No access token found'); @@ -143,7 +162,7 @@ export function useTasks(selectedProject, deliverables = []) { } // Find the task to get current values - const task = tasks.find(t => t.id === taskId); + const task = tasks.find(t => idsMatch(t.id, taskId)); if (!task) { console.error('Task not found for update'); return; @@ -164,7 +183,7 @@ export function useTasks(selectedProject, deliverables = []) { if (response.ok) { const updatedTask = await response.json(); setTasks(prev => prev.map(t => - t.id === taskId ? updatedTask : t + idsMatch(t.id, taskId) ? updatedTask : t )); } else if (response.status === 401) { console.error('Unauthorized - access token invalid'); @@ -182,6 +201,7 @@ export function useTasks(selectedProject, deliverables = []) { */ const deleteTask = useCallback(async (taskId) => { try { + mutationVersionRef.current += 1; const token = localStorage.getItem('TB_TOKEN'); if (!token) { console.error('No access token found'); @@ -189,7 +209,7 @@ export function useTasks(selectedProject, deliverables = []) { } // Find the task to get current values - const task = tasks.find(t => t.id === taskId); + const task = tasks.find(t => idsMatch(t.id, taskId)); if (!task) { console.error('Task not found for deletion'); return; @@ -207,7 +227,7 @@ export function useTasks(selectedProject, deliverables = []) { ); if (response.ok) { - setTasks(prev => prev.filter(t => t.id !== taskId)); + setTasks(prev => prev.filter(t => !idsMatch(t.id, taskId))); } else if (response.status === 401) { console.error('Unauthorized - access token invalid'); } else { @@ -225,6 +245,8 @@ export function useTasks(selectedProject, deliverables = []) { const refreshTasks = useCallback(async () => { if (!selectedProject || deliverables.length === 0) return; + const fetchRequestId = ++latestFetchRequestIdRef.current; + const mutationVersionAtStart = mutationVersionRef.current; setLoading(true); try { @@ -241,6 +263,7 @@ export function useTasks(selectedProject, deliverables = []) { `http://localhost:3030/projects/${selectedProject.code}/deliverables/${deliverable.id}/tasks`, { method: 'GET', + cache: 'no-store', headers: { 'TB_TOKEN': token, 'Content-Type': 'application/json' @@ -253,7 +276,9 @@ export function useTasks(selectedProject, deliverables = []) { allTasks.push(...deliverableTasks); } else if (response.status === 401) { console.error('Unauthorized - access token invalid'); - setLoading(false); + if (fetchRequestId === latestFetchRequestIdRef.current) { + setLoading(false); + } return; } } @@ -266,12 +291,20 @@ export function useTasks(selectedProject, deliverables = []) { return (a.position || 0) - (b.position || 0); }); + const isOutdatedFetch = fetchRequestId !== latestFetchRequestIdRef.current; + const mutatedDuringFetch = mutationVersionRef.current !== mutationVersionAtStart; + if (isOutdatedFetch || mutatedDuringFetch) { + return; + } + console.log('Refreshed tasks:', allTasks); setTasks(allTasks); } catch (error) { console.error('Error refreshing tasks:', error); } finally { - setLoading(false); + if (fetchRequestId === latestFetchRequestIdRef.current) { + setLoading(false); + } } }, [selectedProject, deliverables, taskStatuses]); diff --git a/client/src/pages/DeliverableKanbanPage.jsx b/client/src/pages/DeliverableKanbanPage.jsx index 216290f3..5aa0ea03 100644 --- a/client/src/pages/DeliverableKanbanPage.jsx +++ b/client/src/pages/DeliverableKanbanPage.jsx @@ -8,7 +8,7 @@ import { DeliverableModal } from '../components/DeliverableModal.jsx'; export function DeliverableKanbanPage({ selectedProject }) { const { t } = useTranslation(); - const { deliverables, loading, createDeliverable, updateDeliverableStatus, deleteDeliverable, refreshDeliverables } = useDeliverables(selectedProject); + const { deliverables, loading, createDeliverable, updateDeliverable, updateDeliverableStatus, deleteDeliverable, refreshDeliverables } = useDeliverables(selectedProject); const [modalOpened, setModalOpened] = useState(false); const [editingDeliverable, setEditingDeliverable] = useState(null); @@ -64,10 +64,10 @@ export function DeliverableKanbanPage({ selectedProject }) { }; const handleModalSubmit = async (data) => { - if (!editingDeliverable) { - await createDeliverable(data); + if (editingDeliverable) { + return await updateDeliverable(editingDeliverable.id, data); } - handleModalClose(); + return await createDeliverable(data); }; const handleDeleteClick = (deliverableId) => { @@ -109,7 +109,6 @@ export function DeliverableKanbanPage({ selectedProject }) { onClose={handleModalClose} onSubmit={handleModalSubmit} deliverable={editingDeliverable} - selectedProject={selectedProject} /> )} diff --git a/client/src/pages/DeliverableListPage.jsx b/client/src/pages/DeliverableListPage.jsx index 222a576d..97f46584 100644 --- a/client/src/pages/DeliverableListPage.jsx +++ b/client/src/pages/DeliverableListPage.jsx @@ -7,7 +7,7 @@ import { DeliverableModal } from '../components/DeliverableModal.jsx'; export function DeliverableListPage({ selectedProject }) { const { t, translateDeliverableType, translateDeliverableStatus } = useTranslation(); - const { deliverables, loading, createDeliverable, deleteDeliverable } = useDeliverables(selectedProject); + const { deliverables, loading, createDeliverable, updateDeliverable, deleteDeliverable } = useDeliverables(selectedProject); const [sortBy, setSortBy] = useState('deliverableCode'); const [sortDirection, setSortDirection] = useState('asc'); @@ -79,10 +79,10 @@ export function DeliverableListPage({ selectedProject }) { }; const handleModalSubmit = async (data) => { - if (!editingDeliverable) { - await createDeliverable(data); + if (editingDeliverable) { + return await updateDeliverable(editingDeliverable.id, data); } - handleModalClose(); + return await createDeliverable(data); }; const handleDeleteClick = async (deliverableId) => { @@ -140,7 +140,6 @@ export function DeliverableListPage({ selectedProject }) { onClose={handleModalClose} onSubmit={handleModalSubmit} deliverable={editingDeliverable} - selectedProject={selectedProject} /> )} diff --git a/client/src/pages/TaskGraphPage.jsx b/client/src/pages/TaskGraphPage.jsx index f4851ed3..30726f78 100644 --- a/client/src/pages/TaskGraphPage.jsx +++ b/client/src/pages/TaskGraphPage.jsx @@ -107,7 +107,7 @@ function TaskGraphContent({ selectedProject, selectedDeliverableId }) { const handleTaskSave = useCallback(async (panelId, updatedTask) => { try { const token = localStorage.getItem('TB_TOKEN'); - if (!token) return; + if (!token) return false; const response = await fetch( `http://localhost:3030/projects/${selectedProject.code}/tasks/${updatedTask.id}`, @@ -135,11 +135,14 @@ function TaskGraphContent({ selectedProject, selectedDeliverableId }) { prev.map(p => p.id === panelId ? { ...p, task: savedTask } : p) ); refreshGraph(); + return true; } else { console.error('Failed to save task:', response.status); + return false; } } catch (err) { console.error('Error saving task:', err); + return false; } }, [selectedProject, refreshGraph]); diff --git a/client/src/test/setup.js b/client/src/test/setup.js new file mode 100644 index 00000000..1afdbfa5 --- /dev/null +++ b/client/src/test/setup.js @@ -0,0 +1,33 @@ +import '@testing-library/jest-dom/vitest'; +import '../i18n/index.js'; + +if (!window.matchMedia) { + window.matchMedia = () => ({ + matches: false, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }); +} + +if (!window.ResizeObserver) { + window.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; +} + +if (!window.IntersectionObserver) { + window.IntersectionObserver = class IntersectionObserver { + observe() {} + unobserve() {} + disconnect() {} + }; +} + +if (!window.HTMLElement.prototype.scrollIntoView) { + window.HTMLElement.prototype.scrollIntoView = () => {}; +} diff --git a/client/vite.config.js b/client/vite.config.js index f95b1f69..407d8bd6 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -8,5 +8,10 @@ export default defineConfig({ port: 3001, strictPort: true, // Fail if port is not available host: true + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: './src/test/setup.js' } }) From a0d82546172f3a3103ba3b08a1f730f442c9cf78 Mon Sep 17 00:00:00 2001 From: michaelwitz Date: Sun, 8 Mar 2026 17:53:02 -0400 Subject: [PATCH 13/15] feat(agent-tokens): add project-scoped agent token management --- .agents/skills/worker-agent/SKILL.md | 15 +- .agents/skills/zazz-board-api/SKILL.md | 7 +- api/__tests__/helpers/testDatabase.js | 49 ++- api/__tests__/helpers/testServer.js | 12 +- .../helpers/testServerWithSwagger.js | 2 +- api/__tests__/routes/agent-tokens.test.mjs | 142 +++++++ .../routes/deliverables-approval.test.mjs | 25 +- .../routes/deliverables-status.test.mjs | 20 +- api/__tests__/routes/openapi.test.mjs | 36 ++ .../project-id-routes-regression.test.mjs | 34 ++ api/lib/db/schema.js | 25 ++ api/scripts/reset-and-seed.js | 15 +- api/scripts/seed-all.js | 24 +- api/scripts/seeders/seedAgentTokens.js | 52 +++ api/src/middleware/authMiddleware.js | 85 ++++- api/src/routes/agentTokens.js | 145 ++++++++ api/src/routes/deliverables.js | 6 + api/src/routes/index.js | 20 + api/src/schemas/agentTokens.js | 139 +++++++ api/src/schemas/core.js | 24 ++ api/src/schemas/index.js | 1 + api/src/schemas/validation.js | 3 +- api/src/services/databaseService.js | 148 +++++++- api/src/services/tokenService.js | 181 +++++++-- client/src/App.jsx | 24 ++ client/src/components/AgentTokensModal.jsx | 352 ++++++++++++++++++ client/src/components/ProjectList.jsx | 52 ++- client/src/hooks/useAgentTokens.js | 113 ++++++ client/src/i18n/locales/de.json | 44 ++- client/src/i18n/locales/en.json | 52 ++- client/src/i18n/locales/es.json | 44 ++- client/src/i18n/locales/fr.json | 44 ++- client/src/pages/HomePage.jsx | 14 +- 33 files changed, 1812 insertions(+), 137 deletions(-) create mode 100644 api/__tests__/routes/agent-tokens.test.mjs create mode 100644 api/scripts/seeders/seedAgentTokens.js create mode 100644 api/src/routes/agentTokens.js create mode 100644 api/src/schemas/agentTokens.js create mode 100644 client/src/components/AgentTokensModal.jsx create mode 100644 client/src/hooks/useAgentTokens.js diff --git a/.agents/skills/worker-agent/SKILL.md b/.agents/skills/worker-agent/SKILL.md index 837d953d..8a5b9c8f 100644 --- a/.agents/skills/worker-agent/SKILL.md +++ b/.agents/skills/worker-agent/SKILL.md @@ -68,6 +68,8 @@ You MUST execute in this order: 4. Compute the dependency-ready set. 5. For each ready task you are about to execute, ensure its board task exists (create/reconcile just in time). 6. Ensure all required `DEPENDS_ON` edges for that task exist before starting. + - If the task has non-`none` `DEPENDS_ON` entries, create those relation rows as soon as the dependent task exists on the board. + - Do not defer edge creation until upstream work completes; the live graph must show planned dependency lines immediately. 7. Before moving `READY -> IN_PROGRESS`, run `zazzctl exec begin ...` (acquire locks + block/unblock synchronization + claim status). 8. If lock acquire conflicts, set `isBlocked=true` with `blockedReason='FILE_LOCK'`, poll every 3 seconds, and retry acquire until success. 9. While task is active, run periodic `zazzctl exec tick ...` heartbeats and keep notes current. @@ -90,6 +92,7 @@ Execution model: 1. Add tasks as the plan is executed. 2. Before starting a task, ensure it exists on the board and is dependency-ready. 3. Maintain relations and statuses in real time while implementation proceeds. + - Graph edges are part of board truth. As soon as a dependent task exists, its required `DEPENDS_ON` relations must also exist. 4. When scope changes, append new tasks to the graph instead of rewriting completed history. 5. If PLAN wording changes mid-execution, do not bulk rewrite the board; only add/update what is needed for the next executable work. 6. Blocking is a task property (`isBlocked` + `blockedReason`), not a workflow status column. @@ -106,7 +109,8 @@ When executing a deliverable, you are responsible for board state integrity: - Create/reconcile tasks just in time for dependency-ready work. - Reuse existing matching tasks; do not duplicate. 2. **Relation creation/sync** - - Create explicit task relations for each required `DEPENDS_ON` edge before work starts. + - Create explicit task relations for each required `DEPENDS_ON` edge as soon as the dependent task exists. + - Re-fetch the deliverable graph after relation writes and confirm the edge is visible before continuing. 3. **Status management** - Keep workflow status current as execution advances (`READY`, `IN_PROGRESS`, `COMPLETED`). - Keep block state current via `isBlocked` + `blockedReason`. @@ -271,10 +275,11 @@ Status changes must match real execution state. Execution is complete only when all are true: 1. Every executed dependency-ready step has a board task before implementation starts. 2. Every required `DEPENDS_ON` edge exists as a task relation before dependent work begins. -3. Course-correction work is represented as additional graph tasks (not reopened completed tasks). -4. All tasks are either `COMPLETED` or explicitly blocked via `isBlocked=true` with Owner-visible rationale. -5. Required tests for completed tasks pass. -6. Deliverable behavior matches SPEC acceptance criteria. +3. The live deliverable graph has been re-fetched and shows every non-`none` planned dependency edge for instantiated tasks. +4. Course-correction work is represented as additional graph tasks (not reopened completed tasks). +5. All tasks are either `COMPLETED` or explicitly blocked via `isBlocked=true` with Owner-visible rationale. +6. Required tests for completed tasks pass. +7. Deliverable behavior matches SPEC acceptance criteria. --- diff --git a/.agents/skills/zazz-board-api/SKILL.md b/.agents/skills/zazz-board-api/SKILL.md index d510fce5..1c947b12 100644 --- a/.agents/skills/zazz-board-api/SKILL.md +++ b/.agents/skills/zazz-board-api/SKILL.md @@ -142,7 +142,9 @@ Dependency lifecycle (required): - Treat `DEPENDS_ON` in PLAN as required `TASK_RELATIONS` rows. - Do not assume task create `dependencies` field is sufficient for graph lines. - After task creation, create each dependency edge explicitly via relation endpoint. -- Since dependency edges are created as predecessors complete, unresolved dependencies should not be represented as blocked status. +- Create dependency edges immediately after the dependent task exists, even if upstream work is not complete yet. +- Live graph lines are required board truth for instantiated tasks; do not defer relation writes as a later cleanup step. +- Unresolved dependencies should not be represented as blocked status unless a separate blocker exists. - Solo tasks are valid and visible without dependencies. File lock lifecycle (required for worker execution): @@ -158,6 +160,7 @@ Harness-aware exception: Verification lifecycle (required): - After creating/updating tasks, re-fetch deliverable task list and confirm task `id`, `phaseStep`, `status`, and blocker fields when used. - Re-fetch deliverable graph and confirm task presence and relation edges. +- For every instantiated task with non-`none` planned `DEPENDS_ON`, verify matching graph edges are present before declaring board sync complete. - If mismatch appears, report exact endpoint + payload + response. --- @@ -180,7 +183,7 @@ Verification lifecycle (required): - Required inputs: `code`, `delivId`, `title` - Required operational fields for planning execution: `phase`, `phaseStep`, `prompt` - Respect deliverable approval prerequisites - - For each planned dependency, create explicit relation (`DEPENDS_ON`) after task creation + - For each planned dependency, create explicit relation (`DEPENDS_ON`) immediately after task creation - Update task status: - Resolve valid transitions from live workflow; common path is `READY` -> `IN_PROGRESS` -> (`QA` optional) -> `COMPLETED` - Include `agentName` when moving to `IN_PROGRESS` to claim work diff --git a/api/__tests__/helpers/testDatabase.js b/api/__tests__/helpers/testDatabase.js index 715c4fc5..e4a46c3f 100644 --- a/api/__tests__/helpers/testDatabase.js +++ b/api/__tests__/helpers/testDatabase.js @@ -1,6 +1,51 @@ import { db } from '../../lib/db/index.js'; -import { USERS, PROJECTS, DELIVERABLES, TASKS, TAGS, TASK_TAGS, TASK_RELATIONS, FILE_LOCKS, IMAGE_METADATA, IMAGE_DATA } from '../../lib/db/schema.js'; +import { USERS, PROJECTS, DELIVERABLES, TASKS, TAGS, TASK_TAGS, TASK_RELATIONS, FILE_LOCKS, IMAGE_METADATA, IMAGE_DATA, AGENT_TOKENS } from '../../lib/db/schema.js'; import { eq, and, sql } from 'drizzle-orm'; +import { tokenService } from '../../src/services/tokenService.js'; + +const SEEDED_AGENT_TOKENS = [ + { + user_id: 5, + project_id: 1, + token: '660e8400-e29b-41d4-a716-446655440101', + label: 'For all agents access token', + }, + { + user_id: 5, + project_id: 1, + token: '660e8400-e29b-41d4-a716-446655440102', + label: 'Spec builder agent ONLY access token', + }, + { + user_id: 2, + project_id: 2, + token: '660e8400-e29b-41d4-a716-446655440103', + label: 'For all agents access token', + }, + { + user_id: 2, + project_id: 2, + token: '660e8400-e29b-41d4-a716-446655440104', + label: 'Spec builder agent ONLY access token', + }, + { + user_id: 3, + project_id: 2, + token: '660e8400-e29b-41d4-a716-446655440105', + label: 'For all agents access token', + }, + { + user_id: 3, + project_id: 2, + token: '660e8400-e29b-41d4-a716-446655440106', + label: 'Spec builder agent ONLY access token', + }, +]; + +async function resetAgentTokens() { + await db.delete(AGENT_TOKENS); + await db.insert(AGENT_TOKENS).values(SEEDED_AGENT_TOKENS); +} /** * Validates we're running against a test database @@ -67,6 +112,8 @@ export async function clearTaskData() { await db.delete(TASK_TAGS); await db.delete(TASKS); await db.delete(DELIVERABLES); + await resetAgentTokens(); + await tokenService.refreshCache(); } /** diff --git a/api/__tests__/helpers/testServer.js b/api/__tests__/helpers/testServer.js index 54a0e19c..576c66f9 100644 --- a/api/__tests__/helpers/testServer.js +++ b/api/__tests__/helpers/testServer.js @@ -36,8 +36,8 @@ export async function createTestServer() { await app.register(routes); - // Initialize token service - await tokenService.initialize(); + // Ensure cache reflects the current test DB contents before requests run. + await tokenService.refreshCache(); testApp = app; return app; @@ -60,3 +60,11 @@ export async function closeTestServer() { export function getTestToken() { return '550e8400-e29b-41d4-a716-446655440000'; } + +export function getTestAgentToken(projectCode = 'ZAZZ') { + if (projectCode === 'ZED_MER') { + return '660e8400-e29b-41d4-a716-446655440103'; + } + + return '660e8400-e29b-41d4-a716-446655440101'; +} diff --git a/api/__tests__/helpers/testServerWithSwagger.js b/api/__tests__/helpers/testServerWithSwagger.js index 944d2fda..bb8ccd70 100644 --- a/api/__tests__/helpers/testServerWithSwagger.js +++ b/api/__tests__/helpers/testServerWithSwagger.js @@ -47,7 +47,7 @@ export async function createTestServerWithSwagger() { await app.register(routes); - await tokenService.initialize(); + await tokenService.refreshCache(); await app.ready(); diff --git a/api/__tests__/routes/agent-tokens.test.mjs b/api/__tests__/routes/agent-tokens.test.mjs new file mode 100644 index 00000000..388f700c --- /dev/null +++ b/api/__tests__/routes/agent-tokens.test.mjs @@ -0,0 +1,142 @@ +import * as pactum from 'pactum'; +import { eq } from 'drizzle-orm'; +import { db } from '../../lib/db/index.js'; +import { PROJECTS } from '../../lib/db/schema.js'; +import { clearTaskData, resetProjectDefaults } from '../helpers/testDatabase.js'; + +const { spec } = pactum; +const USER_TOKEN = '550e8400-e29b-41d4-a716-446655440000'; +const ZAZZ_AGENT_TOKEN = '660e8400-e29b-41d4-a716-446655440101'; +const ZED_MER_AGENT_TOKEN = '660e8400-e29b-41d4-a716-446655440103'; + +describe('Agent token routes', () => { + beforeEach(async () => { + await clearTaskData(); + await resetProjectDefaults(); + }); + + it('lists current user agent tokens for a project', async () => { + const response = await spec() + .get('/projects/ZAZZ/users/me/agent-tokens') + .withHeaders('TB_TOKEN', USER_TOKEN) + .expectStatus(200) + .returns('res.body'); + + expect(response.userId).toBe(5); + expect(response.tokens).toHaveLength(2); + expect(response.tokens[0].token).toMatch(/^660e8400-e29b-41d4-a716-44665544010\d$/); + }); + + it('rejects non-leader access to another users tokens', async () => { + await spec() + .get('/projects/ZED_MER/users/2/agent-tokens') + .withHeaders('TB_TOKEN', USER_TOKEN) + .expectStatus(403); + }); + + it('allows the project leader to view the project token tree', async () => { + await db + .update(PROJECTS) + .set({ leader_id: 5 }) + .where(eq(PROJECTS.code, 'ZED_MER')); + + const response = await spec() + .get('/projects/ZED_MER/agent-tokens') + .withHeaders('TB_TOKEN', USER_TOKEN) + .expectStatus(200) + .returns('res.body'); + + expect(Array.isArray(response.users)).toBe(true); + const jane = response.users.find((user) => user.userId === 2); + expect(jane.tokens).toHaveLength(2); + }); + + it('creates an agent token and it works immediately on the same project', async () => { + const created = await spec() + .post('/projects/ZAZZ/users/me/agent-tokens') + .withHeaders('TB_TOKEN', USER_TOKEN) + .withJson({ label: 'QA agent token' }) + .expectStatus(201) + .returns('res.body'); + + expect(created.label).toBe('QA agent token'); + expect(created.token).toMatch(/^[0-9a-f-]{36}$/); + + await spec() + .get('/projects/ZAZZ/deliverables') + .withHeaders('TB_TOKEN', created.token) + .expectStatus(200); + + await spec() + .get('/projects/ZED_MER/deliverables') + .withHeaders('TB_TOKEN', created.token) + .expectStatus(403); + }); + + it('deletes an agent token and invalidates it immediately', async () => { + const created = await spec() + .post('/projects/ZAZZ/users/me/agent-tokens') + .withHeaders('TB_TOKEN', USER_TOKEN) + .withJson({ label: 'Temporary token' }) + .expectStatus(201) + .returns('res.body'); + + await spec() + .delete(`/projects/ZAZZ/users/me/agent-tokens/${created.id}`) + .withHeaders('TB_TOKEN', USER_TOKEN) + .expectStatus(200) + .expectJsonLike({ message: 'Token revoked' }); + + await spec() + .get('/projects/ZAZZ/deliverables') + .withHeaders('TB_TOKEN', created.token) + .expectStatus(401); + }); + + it('rejects agent tokens on agent-token management endpoints and cache refresh', async () => { + await spec() + .get('/projects/ZAZZ/users/me/agent-tokens') + .withHeaders('TB_TOKEN', ZAZZ_AGENT_TOKEN) + .expectStatus(403); + + await spec() + .post('/token-cache/refresh') + .withHeaders('TB_TOKEN', ZAZZ_AGENT_TOKEN) + .expectStatus(403); + }); + + it('refreshes the token cache for user tokens only', async () => { + const response = await spec() + .post('/token-cache/refresh') + .withHeaders('TB_TOKEN', USER_TOKEN) + .expectStatus(200) + .returns('res.body'); + + expect(response.success).toBe(true); + expect(response.auth.userCount).toBeGreaterThan(0); + expect(response.auth.agentTokenCount).toBeGreaterThan(0); + + await spec().post('/token-cache/refresh').expectStatus(401); + }); + + it('does not expose token update routes', async () => { + await spec() + .patch('/projects/ZAZZ/users/me/agent-tokens/1') + .withHeaders('TB_TOKEN', USER_TOKEN) + .withJson({ label: 'Updated label' }) + .expectStatus(404); + + await spec() + .put('/projects/ZAZZ/users/me/agent-tokens/1') + .withHeaders('TB_TOKEN', USER_TOKEN) + .withJson({ label: 'Updated label' }) + .expectStatus(404); + }); + + it('rejects wrong-project seeded agent tokens on project routes', async () => { + await spec() + .get('/projects/ZAZZ/deliverables') + .withHeaders('TB_TOKEN', ZED_MER_AGENT_TOKEN) + .expectStatus(403); + }); +}); diff --git a/api/__tests__/routes/deliverables-approval.test.mjs b/api/__tests__/routes/deliverables-approval.test.mjs index 99cfb8be..21dc0320 100644 --- a/api/__tests__/routes/deliverables-approval.test.mjs +++ b/api/__tests__/routes/deliverables-approval.test.mjs @@ -100,8 +100,7 @@ describe('Deliverables Plan Approval', () => { .expectStatus(404); }); - it('should allow transition to IN_PROGRESS only after approval', async () => { - // Create without approval + it('should auto-approve when transitioning to IN_PROGRESS with a plan filepath', async () => { const created = await createTestDeliverable(1, { status: 'PLANNING', planFilepath: 'docs/test-plan.md', @@ -109,26 +108,16 @@ describe('Deliverables Plan Approval', () => { approvedBy: null }); - // Try to transition before approval (should fail) - await spec() - .patch(`/projects/ZAZZ/deliverables/${created.id}/status`) - .withHeaders('TB_TOKEN', VALID_TOKEN) - .withJson({ status: 'IN_PROGRESS' }) - .expectStatus(400); - - // Now approve - await spec() - .patch(`/projects/ZAZZ/deliverables/${created.id}/approve`) - .withHeaders('TB_TOKEN', VALID_TOKEN) - .expectStatus(200); - - // Transition should now succeed - await spec() + const response = await spec() .patch(`/projects/ZAZZ/deliverables/${created.id}/status`) .withHeaders('TB_TOKEN', VALID_TOKEN) .withJson({ status: 'IN_PROGRESS' }) .expectStatus(200) - .expectJsonLike({ status: 'IN_PROGRESS' }); + .returns('res.body'); + + expect(response.approvedAt).not.toBeNull(); + expect(response.approvedBy).toBe(5); + expect(response.status).toBe('IN_PROGRESS'); }); it('should approve multiple deliverables independently', async () => { diff --git a/api/__tests__/routes/deliverables-status.test.mjs b/api/__tests__/routes/deliverables-status.test.mjs index 7fba0835..263dac0a 100644 --- a/api/__tests__/routes/deliverables-status.test.mjs +++ b/api/__tests__/routes/deliverables-status.test.mjs @@ -26,19 +26,12 @@ describe('Deliverables Status Transitions', () => { expect(response.statusHistory.length).toBeGreaterThan(0); }); - it('should transition from PLANNING to IN_PROGRESS after approval', async () => { + it('should transition from PLANNING to IN_PROGRESS and auto-approve when a plan exists', async () => { const created = await createTestDeliverable(1, { status: 'PLANNING', planFilepath: 'docs/test-plan.md' }); - // First approve the plan - await spec() - .patch(`/projects/ZAZZ/deliverables/${created.id}/approve`) - .withHeaders('TB_TOKEN', VALID_TOKEN) - .expectStatus(200); - - // Then transition to IN_PROGRESS const response = await spec() .patch(`/projects/ZAZZ/deliverables/${created.id}/status`) .withHeaders('TB_TOKEN', VALID_TOKEN) @@ -47,10 +40,12 @@ describe('Deliverables Status Transitions', () => { .returns('res.body'); expect(response.status).toBe('IN_PROGRESS'); + expect(response.approvedAt).not.toBeNull(); + expect(response.approvedBy).toBe(5); expect(response.statusHistory.length).toBeGreaterThan(1); }); - it('should block transition to IN_PROGRESS without plan approval', async () => { + it('should block transition to IN_PROGRESS without a plan filepath', async () => { const created = await createTestDeliverable(1, { status: 'PLANNING', planFilepath: null @@ -129,12 +124,6 @@ describe('Deliverables Status Transitions', () => { planFilepath: 'docs/test-plan.md' }); - // Approve - await spec() - .patch(`/projects/ZAZZ/deliverables/${created.id}/approve`) - .withHeaders('TB_TOKEN', VALID_TOKEN) - .expectStatus(200); - // Transition to IN_PROGRESS await spec() .patch(`/projects/ZAZZ/deliverables/${created.id}/status`) @@ -152,6 +141,7 @@ describe('Deliverables Status Transitions', () => { expect(result.statusHistory.length).toBeGreaterThanOrEqual(2); expect(result.statusHistory.some(h => h.status === 'PLANNING')).toBe(true); expect(result.statusHistory.some(h => h.status === 'IN_PROGRESS')).toBe(true); + expect(result.approvedAt).not.toBeNull(); }); it('should require authentication for status transitions', async () => { diff --git a/api/__tests__/routes/openapi.test.mjs b/api/__tests__/routes/openapi.test.mjs index 76e9c7d3..dcd3def5 100644 --- a/api/__tests__/routes/openapi.test.mjs +++ b/api/__tests__/routes/openapi.test.mjs @@ -113,12 +113,48 @@ describe('OpenAPI / Swagger documentation', () => { expect(releasePath.post).toBeDefined(); }); + it('should document agent-token management paths and schemas', async () => { + const spec = await app.swagger(); + + const userPath = spec.paths['/projects/{code}/users/{userId}/agent-tokens']; + expect(userPath).toBeDefined(); + expect(userPath.get).toBeDefined(); + expect(userPath.post).toBeDefined(); + expect(userPath.patch).toBeUndefined(); + expect(userPath.put).toBeUndefined(); + + const userParams = userPath.get.parameters || []; + expect(userParams.some((param) => param.name === 'userId')).toBe(true); + expect(userPath.get.responses?.['200']?.content?.['application/json']?.schema?.properties?.tokens).toBeDefined(); + + const createBody = userPath.post.requestBody?.content?.['application/json']?.schema; + expect(createBody?.properties?.label).toBeDefined(); + expect(createBody?.required).toBeUndefined(); + expect(userPath.post.responses?.['201']?.content?.['application/json']?.schema?.properties?.token).toBeDefined(); + + const projectPath = spec.paths['/projects/{code}/agent-tokens']; + expect(projectPath).toBeDefined(); + expect(projectPath.get).toBeDefined(); + expect(projectPath.post).toBeUndefined(); + expect(projectPath.get.responses?.['200']?.content?.['application/json']?.schema?.properties?.users).toBeDefined(); + + const deletePath = spec.paths['/projects/{code}/users/{userId}/agent-tokens/{id}']; + expect(deletePath).toBeDefined(); + expect(deletePath.delete).toBeDefined(); + expect(deletePath.patch).toBeUndefined(); + expect(deletePath.put).toBeUndefined(); + expect(deletePath.delete.responses?.['200']?.content?.['application/json']?.schema?.properties?.message).toBeDefined(); + }); + it('should document key paths with tags and summaries', async () => { const spec = await app.swagger(); const keyPaths = [ '/projects/{projectCode}/deliverables', '/projects/{projectCode}/deliverables/{id}', '/projects/{projectCode}/deliverables/{id}/approve', + '/projects/{code}/users/{userId}/agent-tokens', + '/projects/{code}/users/{userId}/agent-tokens/{id}', + '/projects/{code}/agent-tokens', '/projects/{code}/deliverables/{delivId}/tasks', '/projects/{code}/deliverables/{delivId}/tasks/{taskId}', '/projects/{code}/deliverables/{delivId}/graph', diff --git a/api/__tests__/routes/project-id-routes-regression.test.mjs b/api/__tests__/routes/project-id-routes-regression.test.mjs index 6aee0808..142245c7 100644 --- a/api/__tests__/routes/project-id-routes-regression.test.mjs +++ b/api/__tests__/routes/project-id-routes-regression.test.mjs @@ -3,6 +3,8 @@ import { clearTaskData, createTestDeliverable, createTestTask, resetProjectDefau const { spec } = pactum; const VALID_TOKEN = '550e8400-e29b-41d4-a716-446655440000'; +const ZAZZ_AGENT_TOKEN = '660e8400-e29b-41d4-a716-446655440101'; +const ZED_MER_AGENT_TOKEN = '660e8400-e29b-41d4-a716-446655440103'; describe('Project-id route regressions', () => { beforeEach(async () => { @@ -58,4 +60,36 @@ describe('Project-id route regressions', () => { expect(Array.isArray(columnTasks)).toBe(true); expect(columnTasks.some((row) => row.id === task.id)).toBe(true); }); + + it('should allow a matching-project agent token on :id project routes', async () => { + const project = await spec() + .get('/projects/1') + .withHeaders('TB_TOKEN', ZAZZ_AGENT_TOKEN) + .expectStatus(200) + .returns('res.body'); + + expect(project.id).toBe(1); + expect(project.code).toBe('ZAZZ'); + }); + + it('should reject a wrong-project agent token on :id project routes', async () => { + await spec() + .get('/projects/1') + .withHeaders('TB_TOKEN', ZED_MER_AGENT_TOKEN) + .expectStatus(403); + }); + + it('should reject a wrong-project agent token on :code project routes', async () => { + await spec() + .get('/projects/ZAZZ/deliverables') + .withHeaders('TB_TOKEN', ZED_MER_AGENT_TOKEN) + .expectStatus(403); + }); + + it('should reject agent tokens on authenticated non-project routes', async () => { + await spec() + .get('/users/me') + .withHeaders('TB_TOKEN', ZAZZ_AGENT_TOKEN) + .expectStatus(403); + }); }); diff --git a/api/lib/db/schema.js b/api/lib/db/schema.js index 46f3d253..1f3d4259 100644 --- a/api/lib/db/schema.js +++ b/api/lib/db/schema.js @@ -82,6 +82,18 @@ export const PROJECTS = pgTable('PROJECTS', { updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }); +export const AGENT_TOKENS = pgTable('AGENT_TOKENS', { + id: serial('id').primaryKey(), + user_id: integer('user_id').notNull().references(() => USERS.id, { onDelete: 'cascade' }), + project_id: integer('project_id').notNull().references(() => PROJECTS.id, { onDelete: 'cascade' }), + token: varchar('token', { length: 36 }).notNull().unique(), + label: varchar('label', { length: 100 }), + created_at: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => [ + index('idx_agent_tokens_token').on(table.token), + index('idx_agent_tokens_user_project').on(table.user_id, table.project_id), +]); + // Deliverables table export const DELIVERABLES = pgTable('DELIVERABLES', { id: serial('id').primaryKey(), @@ -227,6 +239,7 @@ export const IMAGE_DATA = pgTable('IMAGE_DATA', { // Relations export const usersRelations = relations(USERS, ({ many }) => ({ + agentTokens: many(AGENT_TOKENS), })); export const projectsRelations = relations(PROJECTS, ({ one, many }) => ({ @@ -234,10 +247,22 @@ export const projectsRelations = relations(PROJECTS, ({ one, many }) => ({ fields: [PROJECTS.leader_id], references: [USERS.id], }), + agentTokens: many(AGENT_TOKENS), deliverables: many(DELIVERABLES), tasks: many(TASKS), })); +export const agentTokensRelations = relations(AGENT_TOKENS, ({ one }) => ({ + user: one(USERS, { + fields: [AGENT_TOKENS.user_id], + references: [USERS.id], + }), + project: one(PROJECTS, { + fields: [AGENT_TOKENS.project_id], + references: [PROJECTS.id], + }), +})); + export const deliverablesRelations = relations(DELIVERABLES, ({ one, many }) => ({ project: one(PROJECTS, { fields: [DELIVERABLES.project_id], diff --git a/api/scripts/reset-and-seed.js b/api/scripts/reset-and-seed.js index 01c413b8..251c0ba4 100644 --- a/api/scripts/reset-and-seed.js +++ b/api/scripts/reset-and-seed.js @@ -8,6 +8,7 @@ import { seedStatusDefinitions } from './seeders/seedStatusDefinitions.js'; import { seedCoordinationTypes } from './seeders/seedCoordinationTypes.js'; import { seedTranslations } from './seeders/seedTranslations.js'; import { seedProjects } from './seeders/seedProjects.js'; +import { seedAgentTokens } from './seeders/seedAgentTokens.js'; import { seedDeliverables } from './seeders/seedDeliverables.js'; import { seedTags } from './seeders/seedTags.js'; import { seedTasks } from './seeders/seedTasks.js'; @@ -27,6 +28,7 @@ async function resetAndSeed() { await db.execute(sql`DROP TABLE IF EXISTS "TASK_TAGS" CASCADE`); await db.execute(sql`DROP TABLE IF EXISTS "TASKS" CASCADE`); await db.execute(sql`DROP TABLE IF EXISTS "DELIVERABLES" CASCADE`); + await db.execute(sql`DROP TABLE IF EXISTS "AGENT_TOKENS" CASCADE`); await db.execute(sql`DROP TABLE IF EXISTS "PROJECTS" CASCADE`); await db.execute(sql`DROP TABLE IF EXISTS "TAGS" CASCADE`); await db.execute(sql`DROP TABLE IF EXISTS "TRANSLATIONS" CASCADE`); @@ -62,19 +64,23 @@ async function resetAndSeed() { await seedProjects(); console.log(''); - console.log('📋 Step 2c: Seeding deliverables...'); + console.log('📋 Step 2c: Seeding agent tokens...'); + await seedAgentTokens(); + console.log(''); + + console.log('📋 Step 2d: Seeding deliverables...'); await seedDeliverables(); console.log(''); - console.log('📋 Step 2d: Seeding tasks...'); + console.log('📋 Step 2e: Seeding tasks...'); await seedTasks(); console.log(''); - console.log('📋 Step 2e: Seeding task-tag relationships...'); + console.log('📋 Step 2f: Seeding task-tag relationships...'); await seedTaskTags(); console.log(''); - console.log('📋 Step 2f: Seeding task relations...'); + console.log('📋 Step 2g: Seeding task relations...'); await seedTaskRelations(); console.log(''); @@ -82,6 +88,7 @@ async function resetAndSeed() { console.log('📊 Summary:'); console.log(' • 5 users created'); console.log(' • 2 projects created (ZAZZ, ZED_MER)'); + console.log(' • 6 agent tokens created'); console.log(' • 4 deliverables created (ZAZZ only)'); console.log(' • 32 ZAZZ tasks seeded from database snapshot'); console.log(' • status definitions + translations seeded'); diff --git a/api/scripts/seed-all.js b/api/scripts/seed-all.js index a37ea5a6..dd23f395 100644 --- a/api/scripts/seed-all.js +++ b/api/scripts/seed-all.js @@ -3,6 +3,7 @@ import { seedStatusDefinitions } from './seeders/seedStatusDefinitions.js'; import { seedCoordinationTypes } from './seeders/seedCoordinationTypes.js'; import { seedTranslations } from './seeders/seedTranslations.js'; import { seedProjects } from './seeders/seedProjects.js'; +import { seedAgentTokens } from './seeders/seedAgentTokens.js'; import { seedDeliverables } from './seeders/seedDeliverables.js'; import { seedTags } from './seeders/seedTags.js'; import { seedTasks } from './seeders/seedTasks.js'; @@ -57,23 +58,28 @@ async function seedAll() { await seedProjects(); console.log(''); - // Step 3: Seed deliverables (depends on projects and users) - console.log('📋 Step 3: Seeding deliverables (depends on projects and users)...'); + // Step 3: Seed agent tokens (depends on users and projects) + console.log('📋 Step 3: Seeding agent tokens (depends on users and projects)...'); + await seedAgentTokens(); + console.log(''); + + // Step 4: Seed deliverables (depends on projects and users) + console.log('📋 Step 4: Seeding deliverables (depends on projects and users)...'); await seedDeliverables(); console.log(''); - // Step 4: Seed tasks (depends on projects, deliverables, and users) - console.log('📋 Step 4: Seeding tasks (depends on projects, deliverables, and users)...'); + // Step 5: Seed tasks (depends on projects, deliverables, and users) + console.log('📋 Step 5: Seeding tasks (depends on projects, deliverables, and users)...'); await seedTasks(); console.log(''); - // Step 5: Seed relationship tables - console.log('📋 Step 5: Seeding relationships (depends on tasks and tags)...'); + // Step 6: Seed relationship tables + console.log('📋 Step 6: Seeding relationships (depends on tasks and tags)...'); await seedTaskTags(); console.log(''); - // Step 6: Seed task relations (depends on tasks existing) - console.log('📋 Step 6: Seeding task relations (depends on tasks)...'); + // Step 7: Seed task relations (depends on tasks existing) + console.log('📋 Step 7: Seeding task relations (depends on tasks)...'); await seedTaskRelations(); console.log(''); @@ -83,6 +89,7 @@ async function seedAll() { console.log(' • 8 status definitions created'); console.log(' • 4 translation sets created (en, es, fr, de)'); console.log(' • 2 projects created (ZAZZ, ZED_MER)'); + console.log(' • 6 agent tokens created'); console.log(' • 4 deliverables created (ZAZZ only)'); console.log(' • 6 tags created'); console.log(' • 32 ZAZZ tasks seeded from database snapshot'); @@ -99,4 +106,3 @@ async function seedAll() { } seedAll(); - diff --git a/api/scripts/seeders/seedAgentTokens.js b/api/scripts/seeders/seedAgentTokens.js new file mode 100644 index 00000000..1f933fb5 --- /dev/null +++ b/api/scripts/seeders/seedAgentTokens.js @@ -0,0 +1,52 @@ +import { db } from '../../lib/db/index.js'; +import { AGENT_TOKENS } from '../../lib/db/schema.js'; + +export async function seedAgentTokens() { + console.log(' 📝 Seeding agent tokens...'); + + try { + await db.insert(AGENT_TOKENS).values([ + { + user_id: 5, + project_id: 1, + token: '660e8400-e29b-41d4-a716-446655440101', + label: 'For all agents access token', + }, + { + user_id: 5, + project_id: 1, + token: '660e8400-e29b-41d4-a716-446655440102', + label: 'Spec builder agent ONLY access token', + }, + { + user_id: 2, + project_id: 2, + token: '660e8400-e29b-41d4-a716-446655440103', + label: 'For all agents access token', + }, + { + user_id: 2, + project_id: 2, + token: '660e8400-e29b-41d4-a716-446655440104', + label: 'Spec builder agent ONLY access token', + }, + { + user_id: 3, + project_id: 2, + token: '660e8400-e29b-41d4-a716-446655440105', + label: 'For all agents access token', + }, + { + user_id: 3, + project_id: 2, + token: '660e8400-e29b-41d4-a716-446655440106', + label: 'Spec builder agent ONLY access token', + }, + ]); + + console.log(' ✅ Agent tokens seeded successfully'); + } catch (error) { + console.error(' ❌ Error seeding agent tokens:', error.message); + throw error; + } +} diff --git a/api/src/middleware/authMiddleware.js b/api/src/middleware/authMiddleware.js index 9aa2e73d..0d986d97 100644 --- a/api/src/middleware/authMiddleware.js +++ b/api/src/middleware/authMiddleware.js @@ -1,5 +1,55 @@ import { tokenService } from '../services/tokenService.js'; +function getRouteTemplate(request) { + return request.routeOptions?.url || request.routerPath || request.url.split('?')[0]; +} + +function getProjectContext(request) { + const routeTemplate = getRouteTemplate(request); + const params = request.params || {}; + const projectCode = params.projectCode || params.code || null; + + if (projectCode) { + return { + routeTemplate, + hasProjectContext: true, + projectId: tokenService.getProjectIdByCode(projectCode), + projectCode, + }; + } + + const isProjectIdRoute = + routeTemplate === '/projects/:id' || routeTemplate.startsWith('/projects/:id/'); + + if (!isProjectIdRoute) { + return { routeTemplate, hasProjectContext: false, projectId: null, projectCode: null }; + } + + const projectId = Number(params.id); + if (!Number.isInteger(projectId)) { + return { routeTemplate, hasProjectContext: true, projectId: null, projectCode: null }; + } + + return { + routeTemplate, + hasProjectContext: true, + projectId, + projectCode: tokenService.getProjectCodeById(projectId), + }; +} + +function isUserTokenOnlyRoute(routeTemplate, hasProjectContext) { + if (routeTemplate === '/token-cache/refresh') { + return true; + } + + if (routeTemplate.includes('/agent-tokens')) { + return true; + } + + return !hasProjectContext; +} + /** * Fastify Authentication Middleware * Validates access tokens and attaches user context to requests. @@ -33,11 +83,37 @@ export async function authMiddleware(request, reply) { email: userInfo.email, fullName: userInfo.fullName }; + request.tokenType = userInfo.type || 'user'; + + if (userInfo.type === 'agent') { + request.agentTokenUserId = userInfo.userId; + request.agentTokenProjectId = userInfo.projectId ?? null; + request.agentTokenProjectCode = userInfo.projectCode ?? null; + } + + const { routeTemplate, hasProjectContext, projectId } = getProjectContext(request); + + if (request.tokenType === 'agent') { + if (isUserTokenOnlyRoute(routeTemplate, hasProjectContext)) { + return reply.code(403).send({ + error: 'Forbidden', + message: 'Agent tokens are not allowed on this endpoint' + }); + } + + if (projectId !== null && userInfo.projectId !== projectId) { + return reply.code(403).send({ + error: 'Forbidden', + message: 'Agent token is not authorized for this project' + }); + } + } // Log successful authentication (for debugging) request.log.info({ userId: userInfo.userId, userEmail: userInfo.email, + tokenType: request.tokenType, path: request.url, method: request.method }, 'User authenticated'); @@ -67,6 +143,13 @@ export async function optionalAuthMiddleware(request, reply) { email: userInfo.email, fullName: userInfo.fullName }; + request.tokenType = userInfo.type || 'user'; + + if (userInfo.type === 'agent') { + request.agentTokenUserId = userInfo.userId; + request.agentTokenProjectId = userInfo.projectId ?? null; + request.agentTokenProjectCode = userInfo.projectCode ?? null; + } } } // Continue even if no valid token (optional auth) @@ -74,4 +157,4 @@ export async function optionalAuthMiddleware(request, reply) { request.log.error(error, 'Optional authentication middleware error'); // Continue without authentication on error } -} \ No newline at end of file +} diff --git a/api/src/routes/agentTokens.js b/api/src/routes/agentTokens.js new file mode 100644 index 00000000..c78d59a1 --- /dev/null +++ b/api/src/routes/agentTokens.js @@ -0,0 +1,145 @@ +import { authMiddleware } from '../middleware/authMiddleware.js'; +import { agentTokenSchemas } from '../schemas/validation.js'; +import { tokenService } from '../services/tokenService.js'; + +function resolveUserId(userIdParam, requestUserId) { + if (userIdParam === 'me') { + return requestUserId; + } + + const parsed = Number(userIdParam); + if (!Number.isInteger(parsed)) { + throw new Error('Invalid user id'); + } + + return parsed; +} + +function assertProjectLeader(project, requestUserId) { + if (project.leaderId !== requestUserId) { + throw new Error('Only project leaders can manage agent tokens for other users'); + } +} + +function assertUserScope(project, userIdParam, requestUserId) { + if (userIdParam === 'me') { + return; + } + + assertProjectLeader(project, requestUserId); +} + +export default async function agentTokenRoutes(fastify, options) { + const { dbService } = options; + + fastify.addHook('preHandler', authMiddleware); + + fastify.get( + '/projects/:code/users/:userId/agent-tokens', + { schema: agentTokenSchemas.getUserAgentTokens }, + async (request, reply) => { + try { + const project = await dbService.getProjectByCode(request.params.code); + if (!project) return reply.code(404).send({ error: 'Project not found' }); + + assertUserScope(project, request.params.userId, request.user.id); + const resolvedUserId = resolveUserId(request.params.userId, request.user.id); + + const result = await dbService.getAgentTokensForUser(project.id, resolvedUserId); + if (!result) return reply.code(404).send({ error: 'User not found' }); + reply.send(result); + } catch (error) { + request.log.error(error); + const code = error.message?.includes('leaders') ? 403 : 400; + reply.code(code).send({ error: error.message || 'Failed to fetch agent tokens' }); + } + }, + ); + + fastify.get( + '/projects/:code/agent-tokens', + { schema: agentTokenSchemas.getProjectAgentTokens }, + async (request, reply) => { + try { + const project = await dbService.getProjectByCode(request.params.code); + if (!project) return reply.code(404).send({ error: 'Project not found' }); + + assertProjectLeader(project, request.user.id); + const users = await dbService.getAgentTokensForProject(project.id); + reply.send({ users }); + } catch (error) { + request.log.error(error); + const code = error.message?.includes('leaders') ? 403 : 400; + reply.code(code).send({ error: error.message || 'Failed to fetch project agent tokens' }); + } + }, + ); + + fastify.post( + '/projects/:code/users/:userId/agent-tokens', + { schema: agentTokenSchemas.createAgentToken }, + async (request, reply) => { + try { + const project = await dbService.getProjectByCode(request.params.code); + if (!project) return reply.code(404).send({ error: 'Project not found' }); + + assertUserScope(project, request.params.userId, request.user.id); + const resolvedUserId = resolveUserId(request.params.userId, request.user.id); + + const created = await dbService.createAgentToken(project.id, resolvedUserId, request.body?.label); + tokenService.addAgentTokenToCache({ + token: created.token, + userId: created.userId, + projectId: created.projectId, + projectCode: created.projectCode, + email: created.userEmail, + fullName: created.userFullName, + label: created.label, + }); + reply.code(201).send({ + id: created.id, + token: created.token, + label: created.label, + createdAt: created.createdAt, + }); + } catch (error) { + request.log.error(error); + if (error.message?.includes('leaders')) { + return reply.code(403).send({ error: error.message }); + } + if (error.message?.toLowerCase().includes('not found')) { + return reply.code(404).send({ error: error.message }); + } + reply.code(400).send({ error: error.message || 'Failed to create agent token' }); + } + }, + ); + + fastify.delete( + '/projects/:code/users/:userId/agent-tokens/:id', + { schema: agentTokenSchemas.deleteAgentToken }, + async (request, reply) => { + try { + const project = await dbService.getProjectByCode(request.params.code); + if (!project) return reply.code(404).send({ error: 'Project not found' }); + + assertUserScope(project, request.params.userId, request.user.id); + const resolvedUserId = resolveUserId(request.params.userId, request.user.id); + + const tokenId = Number(request.params.id); + const existing = await dbService.getAgentTokenById(tokenId); + if (!existing || existing.projectId !== project.id || existing.userId !== resolvedUserId) { + return reply.code(404).send({ error: 'Agent token not found' }); + } + + await dbService.deleteAgentToken(tokenId); + tokenService.removeAgentTokenFromCache(existing.token); + reply.send({ message: 'Token revoked' }); + } catch (error) { + request.log.error(error); + const code = error.message?.includes('leaders') ? 403 : 400; + reply.code(code).send({ error: error.message || 'Failed to delete agent token' }); + } + }, + ); +} diff --git a/api/src/routes/deliverables.js b/api/src/routes/deliverables.js index 5f0f2f31..0d741fa4 100644 --- a/api/src/routes/deliverables.js +++ b/api/src/routes/deliverables.js @@ -114,12 +114,16 @@ export default async function deliverableRoutes(fastify, options) { const statusDef = await dbService.getStatusDefinitionByCode(request.body.status); if (!statusDef) return reply.code(400).send({ error: `Invalid status: ${request.body.status}` }); const updated = await dbService.updateDeliverableStatus(deliverableId, request.body.status, request.user.id); + const autoApproved = !existing.approvedAt && !!updated.approvedAt; publishEvent(project.code, { type: 'deliverable', eventType: 'deliverable.status_changed', deliverableId: updated.id, status: updated.status, previousStatus: existing.status, + approved: autoApproved, + approvedBy: updated.approvedBy, + approvedAt: updated.approvedAt, }); reply.send(updated); } catch (error) { @@ -144,6 +148,8 @@ export default async function deliverableRoutes(fastify, options) { deliverableId: updated.id, status: updated.status, approved: true, + approvedBy: updated.approvedBy, + approvedAt: updated.approvedAt, }); reply.send(updated); } catch (error) { diff --git a/api/src/routes/index.js b/api/src/routes/index.js index c7c9c505..ced5e0ac 100644 --- a/api/src/routes/index.js +++ b/api/src/routes/index.js @@ -14,6 +14,8 @@ import statusDefinitionsRoutes from './statusDefinitions.js'; import taskGraphRoutes from './taskGraph.js'; import deliverableRoutes from './deliverables.js'; import fileLockRoutes from './fileLocks.js'; +import agentTokenRoutes from './agentTokens.js'; +import { authMiddleware } from '../middleware/authMiddleware.js'; const dbService = new DatabaseService(); const realtimeService = new RealtimeService(); @@ -63,6 +65,23 @@ export default async function routes(fastify, options) { }); }); + fastify.post('/token-cache/refresh', { + schema: coreSchemas.refreshTokenCache, + preHandler: authMiddleware, + }, async (request, reply) => { + await tokenService.refreshCache(); + const tokenStats = tokenService.getCacheStats(); + reply.send({ + success: true, + auth: { + tokenCacheInitialized: tokenStats.isInitialized, + userCount: tokenStats.userCount, + agentTokenCount: tokenStats.agentTokenCount, + projectCount: tokenStats.projectCount, + }, + }); + }); + // Register route plugins with shared database service const pluginOptions = { dbService, realtimeService }; @@ -76,4 +95,5 @@ export default async function routes(fastify, options) { await fastify.register(taskGraphRoutes, pluginOptions); await fastify.register(deliverableRoutes, pluginOptions); await fastify.register(fileLockRoutes, pluginOptions); + await fastify.register(agentTokenRoutes, pluginOptions); } diff --git a/api/src/schemas/agentTokens.js b/api/src/schemas/agentTokens.js new file mode 100644 index 00000000..3e0fe96e --- /dev/null +++ b/api/src/schemas/agentTokens.js @@ -0,0 +1,139 @@ +/** + * Agent token management route schemas. + */ + +const projectCodeParam = { + type: 'object', + required: ['code'], + properties: { + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, + }, +}; + +const userAgentTokensParams = { + type: 'object', + required: ['code', 'userId'], + properties: { + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, + userId: { + type: 'string', + pattern: '^(me|\\d+)$', + description: 'Use "me" for the current user or a numeric user id.', + }, + }, +}; + +const deleteAgentTokenParams = { + type: 'object', + required: ['code', 'userId', 'id'], + properties: { + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, + userId: { + type: 'string', + pattern: '^(me|\\d+)$', + description: 'Use "me" for the current user or a numeric user id.', + }, + id: { type: 'string', pattern: '^\\d+$', description: 'Numeric agent token id.' }, + }, +}; + +const agentTokenItemSchema = { + type: 'object', + properties: { + id: { type: 'integer', description: 'Numeric agent token id.' }, + token: { + type: 'string', + pattern: '^[0-9a-fA-F-]{36}$', + description: 'Full UUID token value. Visible to authorized human users.', + }, + label: { + type: 'string', + nullable: true, + description: 'Optional human-readable label for the token.', + }, + createdAt: { type: 'string', format: 'date-time' }, + }, +}; + +const userTokensSchema = { + type: 'object', + properties: { + userId: { type: 'integer' }, + userName: { type: 'string' }, + userEmail: { type: 'string', format: 'email' }, + tokens: { type: 'array', items: agentTokenItemSchema }, + }, +}; + +export const agentTokenSchemas = { + getUserAgentTokens: { + tags: ['agent-tokens'], + summary: 'List agent tokens for one user in a project', + description: + 'Human user token only. Use userId="me" for the current user, or a numeric user id when the caller is the project leader.', + params: userAgentTokensParams, + response: { + 200: { description: 'Agent tokens for one user', ...userTokensSchema }, + }, + }, + + getProjectAgentTokens: { + tags: ['agent-tokens'], + summary: 'List all agent tokens for a project', + description: + 'Human user token only. Project leader view that returns one user row per project user with nested token rows.', + params: projectCodeParam, + response: { + 200: { + description: 'Project agent token tree', + type: 'object', + properties: { + users: { + type: 'array', + items: userTokensSchema, + }, + }, + }, + }, + }, + + createAgentToken: { + tags: ['agent-tokens'], + summary: 'Create an agent token for a project user', + description: + 'Human user token only. Creates a new UUID token for the project/user pair. userId may be "me" or a numeric user id for project leaders.', + params: userAgentTokensParams, + body: { + type: 'object', + properties: { + label: { + type: 'string', + minLength: 1, + maxLength: 100, + description: 'Optional label such as "For all agents access token".', + }, + }, + additionalProperties: false, + }, + response: { + 201: { description: 'Agent token created', ...agentTokenItemSchema }, + }, + }, + + deleteAgentToken: { + tags: ['agent-tokens'], + summary: 'Delete an agent token', + description: + 'Human user token only. Hard-deletes the token row so the credential becomes invalid immediately.', + params: deleteAgentTokenParams, + response: { + 200: { + description: 'Agent token revoked', + type: 'object', + properties: { + message: { type: 'string', enum: ['Token revoked'] }, + }, + }, + }, + }, +}; diff --git a/api/src/schemas/core.js b/api/src/schemas/core.js index 9445980f..396ba3b8 100644 --- a/api/src/schemas/core.js +++ b/api/src/schemas/core.js @@ -86,5 +86,29 @@ export const coreSchemas = { } } } + }, + + refreshTokenCache: { + tags: ['core'], + summary: 'Refresh token cache', + description: 'Reloads user tokens, agent tokens, and project code/id maps from the database. Human user token only.', + response: { + 200: { + description: 'Token cache refreshed', + type: 'object', + properties: { + success: { type: 'boolean' }, + auth: { + type: 'object', + properties: { + tokenCacheInitialized: { type: 'boolean' }, + userCount: { type: 'number' }, + agentTokenCount: { type: 'number' }, + projectCount: { type: 'number' }, + }, + }, + }, + }, + }, } }; diff --git a/api/src/schemas/index.js b/api/src/schemas/index.js index 1d0b6eab..da4effdc 100644 --- a/api/src/schemas/index.js +++ b/api/src/schemas/index.js @@ -23,3 +23,4 @@ export { userSchemas } from './users.js'; export { coreSchemas } from './core.js'; export { imageSchemas } from './images.js'; export { fileLockSchemas } from './fileLocks.js'; +export { agentTokenSchemas } from './agentTokens.js'; diff --git a/api/src/schemas/validation.js b/api/src/schemas/validation.js index 6d612f91..10b420ee 100644 --- a/api/src/schemas/validation.js +++ b/api/src/schemas/validation.js @@ -20,5 +20,6 @@ export { userSchemas, coreSchemas, imageSchemas, - fileLockSchemas + fileLockSchemas, + agentTokenSchemas } from './index.js'; diff --git a/api/src/services/databaseService.js b/api/src/services/databaseService.js index 7dca64fc..8ffa5b65 100644 --- a/api/src/services/databaseService.js +++ b/api/src/services/databaseService.js @@ -1,6 +1,6 @@ import { eq, and, sql, desc, asc, like, or, inArray, ne } from 'drizzle-orm'; import { db } from '../../lib/db/index.js'; -import { USERS, PROJECTS, DELIVERABLES, TASKS, TAGS, TASK_TAGS, IMAGE_METADATA, IMAGE_DATA, STATUS_DEFINITIONS, TRANSLATIONS, TASK_RELATIONS, COORDINATION_TYPES, FILE_LOCKS } from '../../lib/db/schema.js'; +import { USERS, PROJECTS, DELIVERABLES, TASKS, TAGS, TASK_TAGS, IMAGE_METADATA, IMAGE_DATA, STATUS_DEFINITIONS, TRANSLATIONS, TASK_RELATIONS, COORDINATION_TYPES, FILE_LOCKS, AGENT_TOKENS } from '../../lib/db/schema.js'; import { getRandomTagColor } from '../utils/tagColors.js'; import { keysToCamelCase } from '../utils/propertyMapper.js'; import { randomUUID } from 'crypto'; @@ -359,6 +359,132 @@ class DatabaseService { return project || null; } + async getAgentTokensForUser(projectId, userId) { + const user = await this.getUserById(userId); + if (!user) { + return null; + } + + const tokens = await db.select({ + id: AGENT_TOKENS.id, + token: AGENT_TOKENS.token, + label: AGENT_TOKENS.label, + createdAt: AGENT_TOKENS.created_at, + }) + .from(AGENT_TOKENS) + .where(and(eq(AGENT_TOKENS.project_id, projectId), eq(AGENT_TOKENS.user_id, userId))) + .orderBy(asc(AGENT_TOKENS.created_at), asc(AGENT_TOKENS.id)); + + return { + userId: user.id, + userName: user.fullName, + userEmail: user.email, + tokens, + }; + } + + async getAgentTokensForProject(projectId) { + const [users, tokenRows] = await Promise.all([ + this.getUsers(), + db.select({ + id: AGENT_TOKENS.id, + userId: AGENT_TOKENS.user_id, + token: AGENT_TOKENS.token, + label: AGENT_TOKENS.label, + createdAt: AGENT_TOKENS.created_at, + }) + .from(AGENT_TOKENS) + .where(eq(AGENT_TOKENS.project_id, projectId)) + .orderBy(asc(AGENT_TOKENS.user_id), asc(AGENT_TOKENS.created_at), asc(AGENT_TOKENS.id)), + ]); + + const tokensByUserId = new Map(); + for (const tokenRow of tokenRows) { + if (!tokensByUserId.has(tokenRow.userId)) { + tokensByUserId.set(tokenRow.userId, []); + } + tokensByUserId.get(tokenRow.userId).push({ + id: tokenRow.id, + token: tokenRow.token, + label: tokenRow.label, + createdAt: tokenRow.createdAt, + }); + } + + return users.map((user) => ({ + userId: user.id, + userName: user.fullName, + userEmail: user.email, + tokens: tokensByUserId.get(user.id) || [], + })); + } + + async getAgentTokenById(id) { + const [token] = await db.select({ + id: AGENT_TOKENS.id, + userId: AGENT_TOKENS.user_id, + projectId: AGENT_TOKENS.project_id, + token: AGENT_TOKENS.token, + label: AGENT_TOKENS.label, + createdAt: AGENT_TOKENS.created_at, + userEmail: USERS.email, + userFullName: USERS.full_name, + projectCode: PROJECTS.code, + }) + .from(AGENT_TOKENS) + .innerJoin(USERS, eq(AGENT_TOKENS.user_id, USERS.id)) + .innerJoin(PROJECTS, eq(AGENT_TOKENS.project_id, PROJECTS.id)) + .where(eq(AGENT_TOKENS.id, id)) + .limit(1); + + return token || null; + } + + async createAgentToken(projectId, userId, label = null) { + const project = await this.getProjectById(projectId); + if (!project) { + throw new Error('Project not found'); + } + + const user = await this.getUserById(userId); + if (!user) { + throw new Error('User not found'); + } + + const [created] = await db.insert(AGENT_TOKENS) + .values({ + user_id: userId, + project_id: projectId, + token: randomUUID(), + label: label ?? null, + }) + .returning({ + id: AGENT_TOKENS.id, + token: AGENT_TOKENS.token, + label: AGENT_TOKENS.label, + createdAt: AGENT_TOKENS.created_at, + }); + + return { + ...created, + userId: user.id, + userEmail: user.email, + userFullName: user.fullName, + projectId: project.id, + projectCode: project.code, + }; + } + + async deleteAgentToken(id) { + const existing = await this.getAgentTokenById(id); + if (!existing) { + return null; + } + + await db.delete(AGENT_TOKENS).where(eq(AGENT_TOKENS.id, id)); + return existing; + } + async getDeliverablesForProject(projectId, filters = {}) { const conditions = [eq(DELIVERABLES.project_id, projectId)]; if (filters.status) conditions.push(eq(DELIVERABLES.status, filters.status)); @@ -549,16 +675,26 @@ class DatabaseService { .from(PROJECTS).where(eq(PROJECTS.id, deliverable.projectId)).limit(1); if (!project?.workflow?.includes(status)) throw new Error(`Status ${status} not allowed for this project`); + const nextHistory = Array.isArray(deliverable.statusHistory) ? [...deliverable.statusHistory] : []; + nextHistory.push({ status, changedAt: new Date().toISOString(), changedBy: userId }); + + const updateData = { + status, + status_history: nextHistory, + updated_by: userId, + updated_at: new Date(), + }; + if (status === 'IN_PROGRESS') { if (!deliverable.planFilepath) throw new Error('plan_filepath must be set before moving to IN_PROGRESS'); - if (!deliverable.approvedAt) throw new Error('Deliverable must be approved before moving to IN_PROGRESS'); + if (!deliverable.approvedAt) { + updateData.approved_by = userId; + updateData.approved_at = new Date(); + } } - const nextHistory = Array.isArray(deliverable.statusHistory) ? [...deliverable.statusHistory] : []; - nextHistory.push({ status, changedAt: new Date().toISOString(), changedBy: userId }); - const [updated] = await db.update(DELIVERABLES) - .set({ status, status_history: nextHistory, updated_by: userId, updated_at: new Date() }) + .set(updateData) .where(eq(DELIVERABLES.id, id)) .returning(); if (!updated) return null; diff --git a/api/src/services/tokenService.js b/api/src/services/tokenService.js index c53e8234..9523cfaa 100644 --- a/api/src/services/tokenService.js +++ b/api/src/services/tokenService.js @@ -1,14 +1,17 @@ import { db } from '../../lib/db/index.js'; -import { USERS } from '../../lib/db/schema.js'; +import { AGENT_TOKENS, PROJECTS, USERS } from '../../lib/db/schema.js'; +import { eq } from 'drizzle-orm'; /** * Token Service - * Manages user access tokens for API authorization. - * Caches token-to-user mapping for fast validation and audit logging. + * Manages user and agent access tokens for API authorization. + * Caches token-to-context mapping plus project lookup tables for fast auth checks. */ class TokenService { constructor() { this.tokenCache = new Map(); + this.projectIdByCode = new Map(); + this.projectCodeById = new Map(); this.isInitialized = false; } @@ -16,27 +19,93 @@ class TokenService { * Initialize token cache on server startup */ async initialize() { + await this.refreshCache(); + } + + async loadProjects() { + const projects = await db.select({ + id: PROJECTS.id, + code: PROJECTS.code, + }).from(PROJECTS); + + for (const project of projects) { + this.projectIdByCode.set(project.code, project.id); + this.projectCodeById.set(project.id, project.code); + } + } + + async loadUserTokens() { + const users = await db.select({ + id: USERS.id, + fullName: USERS.full_name, + email: USERS.email, + accessToken: USERS.access_token, + }).from(USERS); + + for (const user of users) { + this.tokenCache.set(user.accessToken, { + type: 'user', + userId: user.id, + email: user.email, + fullName: user.fullName, + }); + } + + return users.length; + } + + async loadAgentTokens() { + const agentTokens = await db.select({ + token: AGENT_TOKENS.token, + userId: AGENT_TOKENS.user_id, + projectId: AGENT_TOKENS.project_id, + label: AGENT_TOKENS.label, + email: USERS.email, + fullName: USERS.full_name, + projectCode: PROJECTS.code, + }) + .from(AGENT_TOKENS) + .innerJoin(USERS, eq(AGENT_TOKENS.user_id, USERS.id)) + .innerJoin(PROJECTS, eq(AGENT_TOKENS.project_id, PROJECTS.id)); + + for (const agentToken of agentTokens) { + this.tokenCache.set(agentToken.token, { + type: 'agent', + userId: agentToken.userId, + projectId: agentToken.projectId, + projectCode: agentToken.projectCode, + label: agentToken.label, + email: agentToken.email, + fullName: agentToken.fullName, + }); + } + + return agentTokens.length; + } + + resetCaches() { + this.tokenCache.clear(); + this.projectIdByCode.clear(); + this.projectCodeById.clear(); + this.isInitialized = false; + } + + /** + * Initialize token cache on server startup + */ + async refreshCache() { try { console.log('🔐 Initializing token cache...'); - - const users = await db.select({ - id: USERS.id, - fullName: USERS.full_name, - email: USERS.email, - accessToken: USERS.access_token - }).from(USERS); - - // Build cache: token -> {userId, email, fullName} - for (const user of users) { - this.tokenCache.set(user.accessToken, { - userId: user.id, - email: user.email, - fullName: user.fullName - }); - } + + this.resetCaches(); + await this.loadProjects(); + const userCount = await this.loadUserTokens(); + const agentTokenCount = await this.loadAgentTokens(); this.isInitialized = true; - console.log(`✅ Token cache initialized with ${this.tokenCache.size} users`); + console.log( + `✅ Token cache initialized with ${userCount} user tokens, ${agentTokenCount} agent tokens, ${this.projectIdByCode.size} projects`, + ); } catch (error) { console.error('❌ Failed to initialize token cache:', error); throw error; @@ -82,22 +151,74 @@ class TokenService { * Get cache statistics for monitoring */ getCacheStats() { + let userCount = 0; + let agentTokenCount = 0; + + for (const entry of this.tokenCache.values()) { + if (entry.type === 'agent') { + agentTokenCount += 1; + } else { + userCount += 1; + } + } + return { isInitialized: this.isInitialized, - userCount: this.tokenCache.size, - tokens: Array.from(this.tokenCache.keys()) + userCount, + agentTokenCount, + projectCount: this.projectIdByCode.size, + tokens: Array.from(this.tokenCache.keys()), }; } - /** - * Refresh cache (useful for testing or when users are added) - */ - async refreshCache() { - this.tokenCache.clear(); - this.isInitialized = false; - await this.initialize(); + getProjectIdByCode(projectCode) { + return this.projectIdByCode.get(projectCode) ?? null; + } + + getProjectCodeById(projectId) { + const normalizedProjectId = Number(projectId); + return this.projectCodeById.get(normalizedProjectId) ?? null; + } + + addAgentTokenToCache({ token, userId, projectId, projectCode, email = null, fullName = null, label = null }) { + if (!token) { + throw new Error('token is required to add agent token to cache'); + } + + let resolvedProjectCode = projectCode ?? null; + if (!resolvedProjectCode && projectId !== undefined && projectId !== null) { + resolvedProjectCode = this.getProjectCodeById(projectId); + } + + if (projectId !== undefined && projectId !== null && resolvedProjectCode) { + this.projectIdByCode.set(resolvedProjectCode, projectId); + this.projectCodeById.set(Number(projectId), resolvedProjectCode); + } + + this.tokenCache.set(token, { + type: 'agent', + userId, + projectId, + projectCode: resolvedProjectCode, + email, + fullName, + label, + }); + } + + removeAgentTokenFromCache(token) { + if (!token) { + return; + } + + const cachedToken = this.tokenCache.get(token); + if (cachedToken?.type !== 'agent') { + return; + } + + this.tokenCache.delete(token); } } // Export singleton instance -export const tokenService = new TokenService(); \ No newline at end of file +export const tokenService = new TokenService(); diff --git a/client/src/App.jsx b/client/src/App.jsx index 93fe7ffb..07cfc11f 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -31,6 +31,7 @@ import { } from '@tabler/icons-react'; import { TokenModal } from './components/TokenModal.jsx'; import { ProjectModal } from './components/ProjectModal.jsx'; +import { AgentTokensModal } from './components/AgentTokensModal.jsx'; import { HomePage } from './pages/HomePage.jsx'; import { KanbanPage } from './pages/KanbanPage.jsx'; import { TaskGraphPage } from './pages/TaskGraphPage.jsx'; @@ -61,6 +62,8 @@ function AppContent() { const [opened, { open, close }] = useDisclosure(false); const [projectModalOpened, setProjectModalOpened] = useState(false); const [editingProject, setEditingProject] = useState(null); // null for create, project object for edit + const [agentTokensModalOpened, setAgentTokensModalOpened] = useState(false); + const [agentTokensProject, setAgentTokensProject] = useState(null); const [users, setUsers] = useState([]); const [currentUser, setCurrentUser] = useState(null); const [createTaskModalOpened, setCreateTaskModalOpened] = useState(false); @@ -322,6 +325,16 @@ function AppContent() { setProjectModalOpened(true); }; + const handleManageAgentTokens = (project) => { + setAgentTokensProject(project); + setAgentTokensModalOpened(true); + }; + + const handleCloseAgentTokensModal = () => { + setAgentTokensModalOpened(false); + setAgentTokensProject(null); + }; + const handleProjectSave = async (formData) => { const token = localStorage.getItem('TB_TOKEN'); if (!token) { @@ -703,6 +716,7 @@ function AppContent() { onProjectSelect={handleProjectSelect} onProjectEdit={handleProjectEdit} onProjectCreate={handleProjectCreate} + onManageAgentTokens={handleManageAgentTokens} /> } /> )} + {/* Agent Tokens Modal */} + {agentTokensModalOpened && ( + + )} + {/* Create Task Modal */} + + +
+ {group.userName || t('projects.agentTokens.userSectionFallback', { userId: group.userId })} + {group.userEmail} +
+ + {t('projects.agentTokens.userTokenCount', { count: group.tokens.length })} + +
+ + + onCreateLabelChange(group.userId, event.currentTarget.value)} + style={{ flex: 1 }} + /> + + + + {group.tokens.length === 0 ? ( + + {t('projects.agentTokens.noTokensForUser')} + + ) : ( + + {group.tokens.map((token) => { + const isDeleting = deleteState?.tokenId === token.id; + return ( + + + +
+ {token.label || t('projects.agentTokens.unlabeledToken')} + + {t('projects.agentTokens.createdAt')}: {formatTimestamp(token.createdAt)} + +
+ + + {({ copied, copy }) => ( + + )} + + + isDeleting + ? onCancelDelete() + : onDeleteTarget({ tokenId: token.id, userId: group.userId, phrase: '' }) + } + aria-label={t('projects.agentTokens.deleteAction')} + > + + + +
+ +
+ + {t('projects.agentTokens.tokenValue')} + + + {token.token} + +
+ + {isDeleting && ( + + {t('projects.agentTokens.deleteTitle')} + + {t('projects.agentTokens.deleteBody')} + + onDeletePhraseChange(event.currentTarget.value)} + placeholder={t('projects.agentTokens.deletePhrasePlaceholder')} + /> + + + + + + )} +
+
+ ); + })} +
+ )} +
+ + ); +} + +export function AgentTokensModal({ opened, onClose, selectedProject, currentUser }) { + const { t } = useTranslation(); + const { userGroups, loading, error, isLeader, createAgentToken, deleteAgentToken } = useAgentTokens( + selectedProject, + currentUser, + opened, + ); + + const [createLabels, setCreateLabels] = useState({}); + const [creatingUserId, setCreatingUserId] = useState(null); + const [deletingTokenId, setDeletingTokenId] = useState(null); + const [deleteState, setDeleteState] = useState(null); + const [createdToken, setCreatedToken] = useState(null); + const [actionError, setActionError] = useState(''); + + useEffect(() => { + if (!opened) { + setCreateLabels({}); + setCreatingUserId(null); + setDeletingTokenId(null); + setDeleteState(null); + setCreatedToken(null); + setActionError(''); + } + }, [opened]); + + const visibleGroups = useMemo(() => { + if (!Array.isArray(userGroups)) return []; + if (isLeader) return userGroups; + return userGroups.slice(0, 1); + }, [isLeader, userGroups]); + + const handleCreateLabelChange = (userId, value) => { + setCreateLabels((current) => ({ + ...current, + [userId]: value, + })); + }; + + const handleCreate = async (userId) => { + setActionError(''); + setCreatedToken(null); + setCreatingUserId(userId); + + try { + const created = await createAgentToken({ + userId: isLeader ? userId : undefined, + label: createLabels[userId]?.trim() || undefined, + }); + setCreatedToken(created); + setCreateLabels((current) => ({ + ...current, + [userId]: '', + })); + } catch (error) { + setActionError(getErrorMessage(error, t('projects.agentTokens.createError'))); + } finally { + setCreatingUserId(null); + } + }; + + const handleConfirmDelete = async (userId, tokenId) => { + setActionError(''); + setDeletingTokenId(tokenId); + + try { + await deleteAgentToken({ + userId: isLeader ? userId : undefined, + tokenId, + }); + setDeleteState(null); + } catch (error) { + setActionError(getErrorMessage(error, t('projects.agentTokens.deleteError'))); + } finally { + setDeletingTokenId(null); + } + }; + + const title = selectedProject + ? t('projects.agentTokens.titleWithProject', { project: selectedProject.title }) + : t('projects.agentTokens.title'); + + return ( + + + + {isLeader + ? t('projects.agentTokens.subtitleLeader') + : t('projects.agentTokens.subtitleSelf')} + + + {createdToken && ( + }> + + {t('projects.agentTokens.latestTokenTitle')} + {createdToken.label || t('projects.agentTokens.unlabeledToken')} + {t('projects.agentTokens.copyHelper')} + + + {createdToken.token} + + + {({ copied, copy }) => ( + + )} + + + + + )} + + {actionError && ( + }> + {actionError} + + )} + + {error && !loading && ( + }> + {getErrorMessage(error, t('projects.agentTokens.error'))} + + )} + + {loading ? ( + {t('projects.agentTokens.loading')} + ) : visibleGroups.length === 0 ? ( + + {isLeader ? t('projects.agentTokens.emptyLeader') : t('projects.agentTokens.emptySelf')} + + ) : ( + + {visibleGroups.map((group, index) => ( +
+ {index > 0 && } + + setDeleteState((current) => (current ? { ...current, phrase } : current)) + } + onCancelDelete={() => setDeleteState(null)} + onConfirmDelete={handleConfirmDelete} + t={t} + /> +
+ ))} +
+ )} +
+
+ ); +} diff --git a/client/src/components/ProjectList.jsx b/client/src/components/ProjectList.jsx index d80e9505..e228d559 100644 --- a/client/src/components/ProjectList.jsx +++ b/client/src/components/ProjectList.jsx @@ -1,9 +1,16 @@ -import { Table, Text, Badge, Group, ActionIcon, Tooltip, Box } from '@mantine/core'; -import { IconCalendar, IconEdit } from '@tabler/icons-react'; +import { Table, Text, Group, ActionIcon, Tooltip, Box } from '@mantine/core'; +import { IconCalendar, IconEdit, IconKey } from '@tabler/icons-react'; import { useTranslation } from '../hooks/useTranslation.js'; import { useEffect } from 'react'; -export function ProjectList({ projects, loading, currentUser, onProjectSelect, onProjectEdit }) { +export function ProjectList({ + projects, + loading, + currentUser, + onProjectSelect, + onProjectEdit, + onManageAgentTokens, +}) { const { t } = useTranslation(); // Debug info when project list renders @@ -94,20 +101,37 @@ export function ProjectList({ projects, loading, currentUser, onProjectSelect, o - { - e.stopPropagation(); - onProjectEdit(project); - }} - > - - + + + { + e.stopPropagation(); + onManageAgentTokens?.(project); + }} + aria-label="Manage agent tokens" + disabled={!onManageAgentTokens} + > + + + + { + e.stopPropagation(); + onProjectEdit(project); + }} + aria-label="Edit project" + > + + + ))} ); -} \ No newline at end of file +} diff --git a/client/src/hooks/useAgentTokens.js b/client/src/hooks/useAgentTokens.js new file mode 100644 index 00000000..c9296f61 --- /dev/null +++ b/client/src/hooks/useAgentTokens.js @@ -0,0 +1,113 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +function getToken() { + return localStorage.getItem('TB_TOKEN'); +} + +async function fetchJson(url, options = {}) { + const token = getToken(); + if (!token) { + throw new Error('No access token found'); + } + + const response = await fetch(url, { + ...options, + headers: { + 'TB_TOKEN': token, + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `Request failed with ${response.status}`); + } + + if (response.status === 204) { + return null; + } + + return response.json(); +} + +export function useAgentTokens(selectedProject, currentUser, opened) { + const [userGroups, setUserGroups] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const isLeader = useMemo(() => { + if (!selectedProject || !currentUser) return false; + return String(selectedProject.leaderId) === String(currentUser.id); + }, [selectedProject, currentUser]); + + const refreshAgentTokens = useCallback(async () => { + if (!selectedProject || !opened) { + setUserGroups([]); + setError(null); + return; + } + + setLoading(true); + setError(null); + try { + const url = isLeader + ? `http://localhost:3030/projects/${selectedProject.code}/agent-tokens` + : `http://localhost:3030/projects/${selectedProject.code}/users/me/agent-tokens`; + const data = await fetchJson(url, { method: 'GET' }); + const groups = Array.isArray(data?.users) ? data.users : data ? [data] : []; + setUserGroups(groups); + } catch (error) { + console.error('Error fetching agent tokens:', error); + setUserGroups([]); + setError(error); + } finally { + setLoading(false); + } + }, [isLeader, opened, selectedProject]); + + useEffect(() => { + refreshAgentTokens(); + }, [refreshAgentTokens]); + + const createAgentToken = useCallback(async ({ userId, label }) => { + if (!selectedProject) return null; + + const targetUserId = userId || 'me'; + const created = await fetchJson( + `http://localhost:3030/projects/${selectedProject.code}/users/${targetUserId}/agent-tokens`, + { + method: 'POST', + body: JSON.stringify(label ? { label } : {}), + }, + ); + + await refreshAgentTokens(); + return created; + }, [refreshAgentTokens, selectedProject]); + + const deleteAgentToken = useCallback(async ({ userId, tokenId }) => { + if (!selectedProject) return false; + + const targetUserId = userId || 'me'; + await fetchJson( + `http://localhost:3030/projects/${selectedProject.code}/users/${targetUserId}/agent-tokens/${tokenId}`, + { + method: 'DELETE', + }, + ); + + await refreshAgentTokens(); + return true; + }, [refreshAgentTokens, selectedProject]); + + return { + userGroups, + loading, + error, + isLeader, + refreshAgentTokens, + createAgentToken, + deleteAgentToken, + }; +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 33ce5c18..96632734 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -67,13 +67,49 @@ "dragHelp": "Ziehen zum Neuordnen. Diese Status erscheinen als Spalten in Ihrem Kanban-Board.", "viewHelp": "Diese Status erscheinen als Spalten in Ihrem Kanban-Board.", "addHelp": "Klicken Sie auf \"Hinzufügen\", um einen Status in Ihren Workflow aufzunehmen." + }, + "agentTokens": { + "title": "Agent-Tokens", + "titleWithProject": "{{project}} Agent-Tokens", + "subtitleLeader": "Erstellen und widerrufen Sie projektspezifische Agent-Tokens fur die Mitglieder dieses Projekts.", + "subtitleSelf": "Erstellen und widerrufen Sie Ihre projektspezifischen Agent-Tokens fur dieses Projekt.", + "loading": "Agent-Tokens werden geladen...", + "error": "Agent-Tokens konnten derzeit nicht geladen werden.", + "emptyLeader": "Fur dieses Projekt wurden noch keine Agent-Tokens erstellt.", + "emptySelf": "Sie haben fur dieses Projekt noch keine Agent-Tokens.", + "noTokensForUser": "Noch keine Agent-Tokens vorhanden.", + "userSectionTitle": "{{name}}", + "userSectionFallback": "Benutzer {{userId}}", + "userTokenCount_one": "{{count}} Token", + "userTokenCount_other": "{{count}} Tokens", + "tokenLabel": "Bezeichnung", + "tokenLabelPlaceholder": "Optionale Bezeichnung", + "tokenValue": "Token", + "createdAt": "Erstellt", + "createForUser": "Token fur {{name}} erstellen", + "createForMe": "Token erstellen", + "createAction": "Token generieren", + "createError": "Agent-Token konnte nicht erstellt werden.", + "copyAction": "Token kopieren", + "copied": "Kopiert", + "copyHelper": "Kopieren und speichern Sie dieses Token jetzt. Nach dem Schliessen dieses Modals wird es moglicherweise nicht erneut angezeigt.", + "latestTokenTitle": "Neuestes Token", + "deleteAction": "Token widerrufen", + "deleteTitle": "Widerruf des Tokens bestatigen", + "deleteBody": "Dadurch wird das Token dauerhaft widerrufen. Geben Sie zur Bestatigung die Bestatigungsphrase ein.", + "deletePhraseLabel": "Geben Sie zur Bestatigung \"delete this token\" ein", + "deletePhrasePlaceholder": "delete this token", + "deletePhraseValue": "delete this token", + "deleteConfirm": "Token widerrufen", + "deleteCancel": "Token behalten", + "deleteError": "Agent-Token konnte nicht widerrufen werden.", + "unlabeledToken": "Token ohne Bezeichnung" } }, "tasks": { - "title": "Aufgaben", + "title": "Titel", "newTask": "Neue Aufgabe", "taskId": "Aufgaben-ID", - "title": "Titel", "prompt": "Prompt", "status": "Status", "priority": "Priorität", @@ -84,8 +120,6 @@ "gitWorktreePlaceholder": "feature/aufgabe-123", "gitPullRequestUrl": "Pull-Request-URL", "gitPullRequestUrlPlaceholder": "https://github.com/benutzer/repo/pull/123", - - "prompt": "Prompt", "isBlocked": "Blockiert", "blockedReason": "Blockierungsgrund", "startedAt": "Gestartet", @@ -156,4 +190,4 @@ "tagUpdated": "Tag erfolgreich aktualisiert", "tagDeleted": "Tag erfolgreich gelöscht" } -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 4883ca87..6cdc8cd5 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -68,13 +68,49 @@ "dragHelp": "Drag to reorder. These statuses will appear as columns in your Kanban board.", "viewHelp": "These statuses will appear as columns in your Kanban board.", "addHelp": "Click \"Add\" to include a status in your workflow." + }, + "agentTokens": { + "title": "Agent Tokens", + "titleWithProject": "{{project}} Agent Tokens", + "subtitleLeader": "Create and revoke project-scoped agent tokens for team members in this project.", + "subtitleSelf": "Create and revoke your project-scoped agent tokens for this project.", + "loading": "Loading agent tokens...", + "error": "Unable to load agent tokens right now.", + "emptyLeader": "No agent tokens have been created for this project yet.", + "emptySelf": "You do not have any agent tokens for this project yet.", + "noTokensForUser": "No agent tokens yet.", + "userSectionTitle": "{{name}}", + "userSectionFallback": "User {{userId}}", + "userTokenCount_one": "{{count}} token", + "userTokenCount_other": "{{count}} tokens", + "tokenLabel": "Label", + "tokenLabelPlaceholder": "Optional label", + "tokenValue": "Token", + "createdAt": "Created", + "createForUser": "Create token for {{name}}", + "createForMe": "Create token", + "createAction": "Generate token", + "createError": "Failed to create agent token.", + "copyAction": "Copy token", + "copied": "Copied", + "copyHelper": "Copy and store this token now. You may not see it again after leaving this modal.", + "latestTokenTitle": "Newest token", + "deleteAction": "Revoke token", + "deleteTitle": "Confirm token revocation", + "deleteBody": "This permanently revokes the token. Type the confirmation phrase to continue.", + "deletePhraseLabel": "Type \"delete this token\" to confirm", + "deletePhrasePlaceholder": "delete this token", + "deletePhraseValue": "delete this token", + "deleteConfirm": "Revoke token", + "deleteCancel": "Keep token", + "deleteError": "Failed to revoke agent token.", + "unlabeledToken": "Unlabeled token" } }, "tasks": { - "title": "Tasks", + "title": "Title", "newTask": "New Task", "taskId": "Task ID", - "title": "Title", "prompt": "Prompt", "status": "Status", "priority": "Priority", @@ -85,19 +121,13 @@ "blockedReason": "Blocked Reason", "gitWorktree": "Git Worktree", "gitPullRequestUrl": "Pull Request URL", - "startedAt": "Started At", - "completedAt": "Completed At", + "startedAt": "Started", + "completedAt": "Completed", "gitWorktreePlaceholder": "feature/task-123", "gitPullRequestUrlPlaceholder": "https://github.com/user/repo/pull/123", "selectAssignee": "Select assignee", "unassigned": "Unassigned", "blockedReasonPlaceholder": "Why is this task blocked?", - - "prompt": "Prompt", - "isBlocked": "Blocked", - "blockedReason": "Blocked Reason", - "startedAt": "Started", - "completedAt": "Completed", "createdAt": "Created", "updatedAt": "Updated", "actions": "Actions", @@ -209,4 +239,4 @@ "tagUpdated": "Tag updated successfully", "tagDeleted": "Tag deleted successfully" } -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/es.json b/client/src/i18n/locales/es.json index 98d55199..2c0ce3ef 100644 --- a/client/src/i18n/locales/es.json +++ b/client/src/i18n/locales/es.json @@ -67,13 +67,49 @@ "dragHelp": "Arrastra para reordenar. Estos estados aparecerán como columnas en tu tablero Kanban.", "viewHelp": "Estos estados aparecerán como columnas en tu tablero Kanban.", "addHelp": "Haz clic en \"Agregar\" para incluir un estado en tu flujo de trabajo." + }, + "agentTokens": { + "title": "Tokens de Agente", + "titleWithProject": "Tokens de Agente de {{project}}", + "subtitleLeader": "Crea y revoca tokens de agente con alcance al proyecto para los miembros de este proyecto.", + "subtitleSelf": "Crea y revoca tus tokens de agente con alcance a este proyecto.", + "loading": "Cargando tokens de agente...", + "error": "No se pueden cargar los tokens de agente en este momento.", + "emptyLeader": "Todavia no se han creado tokens de agente para este proyecto.", + "emptySelf": "Todavia no tienes tokens de agente para este proyecto.", + "noTokensForUser": "Todavia no hay tokens de agente.", + "userSectionTitle": "{{name}}", + "userSectionFallback": "Usuario {{userId}}", + "userTokenCount_one": "{{count}} token", + "userTokenCount_other": "{{count}} tokens", + "tokenLabel": "Etiqueta", + "tokenLabelPlaceholder": "Etiqueta opcional", + "tokenValue": "Token", + "createdAt": "Creado", + "createForUser": "Crear token para {{name}}", + "createForMe": "Crear token", + "createAction": "Generar token", + "createError": "No se pudo crear el token de agente.", + "copyAction": "Copiar token", + "copied": "Copiado", + "copyHelper": "Copia y guarda este token ahora. Es posible que no vuelvas a verlo despues de cerrar este modal.", + "latestTokenTitle": "Token mas reciente", + "deleteAction": "Revocar token", + "deleteTitle": "Confirmar revocacion del token", + "deleteBody": "Esto revoca el token de forma permanente. Escribe la frase de confirmacion para continuar.", + "deletePhraseLabel": "Escribe \"delete this token\" para confirmar", + "deletePhrasePlaceholder": "delete this token", + "deletePhraseValue": "delete this token", + "deleteConfirm": "Revocar token", + "deleteCancel": "Conservar token", + "deleteError": "No se pudo revocar el token de agente.", + "unlabeledToken": "Token sin etiqueta" } }, "tasks": { - "title": "Tareas", + "title": "Título", "newTask": "Nueva Tarea", "taskId": "ID de Tarea", - "title": "Título", "prompt": "Prompt", "status": "Estado", "priority": "Prioridad", @@ -84,8 +120,6 @@ "gitWorktreePlaceholder": "feature/tarea-123", "gitPullRequestUrl": "URL de Pull Request", "gitPullRequestUrlPlaceholder": "https://github.com/usuario/repo/pull/123", - - "prompt": "Prompt", "isBlocked": "Bloqueado", "blockedReason": "Razón del Bloqueo", "startedAt": "Iniciado", @@ -156,4 +190,4 @@ "tagUpdated": "Etiqueta actualizada exitosamente", "tagDeleted": "Etiqueta eliminada exitosamente" } -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/fr.json b/client/src/i18n/locales/fr.json index 9fac1c79..348c97df 100644 --- a/client/src/i18n/locales/fr.json +++ b/client/src/i18n/locales/fr.json @@ -67,13 +67,49 @@ "dragHelp": "Glissez pour réorganiser. Ces statuts apparaîtront sous forme de colonnes dans votre tableau Kanban.", "viewHelp": "Ces statuts apparaîtront sous forme de colonnes dans votre tableau Kanban.", "addHelp": "Cliquez sur \"Ajouter\" pour inclure un statut dans votre flux de travail." + }, + "agentTokens": { + "title": "Tokens d'Agent", + "titleWithProject": "Jetons d'Agent de {{project}}", + "subtitleLeader": "Creez et revoquez des tokens d'agent limites au projet pour les membres de ce projet.", + "subtitleSelf": "Creez et revoquez vos tokens d'agent limites a ce projet.", + "loading": "Chargement des tokens d'agent...", + "error": "Impossible de charger les tokens d'agent pour le moment.", + "emptyLeader": "Aucun token d'agent n'a encore ete cree pour ce projet.", + "emptySelf": "Vous n'avez encore aucun token d'agent pour ce projet.", + "noTokensForUser": "Aucun jeton d'agent pour le moment.", + "userSectionTitle": "{{name}}", + "userSectionFallback": "Utilisateur {{userId}}", + "userTokenCount_one": "{{count}} jeton", + "userTokenCount_other": "{{count}} jetons", + "tokenLabel": "Libelle", + "tokenLabelPlaceholder": "Libelle facultatif", + "tokenValue": "Token", + "createdAt": "Cree", + "createForUser": "Creer un token pour {{name}}", + "createForMe": "Creer un token", + "createAction": "Generer le token", + "createError": "Impossible de creer le jeton d'agent.", + "copyAction": "Copier le token", + "copied": "Copie", + "copyHelper": "Copiez et enregistrez ce token maintenant. Il se peut qu'il ne soit plus visible apres la fermeture de cette fenetre.", + "latestTokenTitle": "Token le plus recent", + "deleteAction": "Revoquer le token", + "deleteTitle": "Confirmer la revocation du token", + "deleteBody": "Cette action revoque definitivement le token. Saisissez la phrase de confirmation pour continuer.", + "deletePhraseLabel": "Tapez \"delete this token\" pour confirmer", + "deletePhrasePlaceholder": "delete this token", + "deletePhraseValue": "delete this token", + "deleteConfirm": "Revoquer le token", + "deleteCancel": "Conserver le token", + "deleteError": "Impossible de revoquer le jeton d'agent.", + "unlabeledToken": "Jeton sans libelle" } }, "tasks": { - "title": "Tâches", + "title": "Titre", "newTask": "Nouvelle Tâche", "taskId": "ID de Tâche", - "title": "Titre", "prompt": "Prompt", "status": "Statut", "priority": "Priorité", @@ -84,8 +120,6 @@ "gitWorktreePlaceholder": "feature/tache-123", "gitPullRequestUrl": "URL du Pull Request", "gitPullRequestUrlPlaceholder": "https://github.com/utilisateur/repo/pull/123", - - "prompt": "Prompt", "isBlocked": "Bloqué", "blockedReason": "Raison du Blocage", "startedAt": "Démarré", @@ -156,4 +190,4 @@ "tagUpdated": "Étiquette mise à jour avec succès", "tagDeleted": "Étiquette supprimée avec succès" } -} \ No newline at end of file +} diff --git a/client/src/pages/HomePage.jsx b/client/src/pages/HomePage.jsx index 678200f0..cb3b6c47 100644 --- a/client/src/pages/HomePage.jsx +++ b/client/src/pages/HomePage.jsx @@ -3,7 +3,16 @@ import { IconAlertCircle, IconPlus } from '@tabler/icons-react'; import { ProjectList } from '../components/ProjectList.jsx'; import { useTranslation } from '../hooks/useTranslation.js'; -export function HomePage({ projects, loading, accessToken, currentUser, onProjectSelect, onProjectEdit, onProjectCreate }) { +export function HomePage({ + projects, + loading, + accessToken, + currentUser, + onProjectSelect, + onProjectEdit, + onProjectCreate, + onManageAgentTokens, +}) { const { t } = useTranslation(); if (!accessToken) { @@ -37,7 +46,8 @@ export function HomePage({ projects, loading, accessToken, currentUser, onProjec currentUser={currentUser} onProjectSelect={onProjectSelect} onProjectEdit={onProjectEdit} + onManageAgentTokens={onManageAgentTokens} /> ); -} \ No newline at end of file +} From 840e4bdd4334a2a48e4138c6d1ff5f8314f0c9e9 Mon Sep 17 00:00:00 2001 From: michaelwitz Date: Sun, 8 Mar 2026 18:05:38 -0400 Subject: [PATCH 14/15] docs(planner): add manual test plan and tighten parallel decomposition --- .agents/skills/planner-agent/SKILL.md | 12 ++ docs/ZAZZ-6-manual-test-plan.md | 163 ++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 docs/ZAZZ-6-manual-test-plan.md diff --git a/.agents/skills/planner-agent/SKILL.md b/.agents/skills/planner-agent/SKILL.md index f4b5e730..89a4d53a 100644 --- a/.agents/skills/planner-agent/SKILL.md +++ b/.agents/skills/planner-agent/SKILL.md @@ -103,6 +103,9 @@ Optional sections (for updating an existing active plan, not mandatory on first 6. **No dependency cycles**. 7. **Reality over assumptions**: mark non-existent files/tests as new. 8. **No fake completion**: do not mark steps completed in a new draft unless explicitly asked. +9. **No hidden parallelism inside a step**: if work can be assigned to different owners on disjoint files and completed independently, it MUST be split into separate PLAN steps. +10. **Parallel work needs explicit merge planning**: when parallel streams converge on a shared contract, shared file, or integrated UX/API outcome, add a downstream merge/integration step with explicit `DEPENDS_ON` edges. +11. **Use PLAN step IDs, not execution-time suffixes**: parallel worker-visible work must be represented as separate numbered PLAN steps, not improvised labels like `4.2a`/`4.2b` during execution. ## Step Format (Use for every step) Every step (`1.1`, `1.2`, ...) MUST include: @@ -117,6 +120,10 @@ Every step (`1.1`, `1.2`, ...) MUST include: - Acceptance criteria mapped - Completion signal +Step-level planning rule: +- `Parallelizable with` may only reference other explicit PLAN step IDs. +- Do not describe multiple independently executable work items inside one step and call that "parallelizable"; split them into separate steps first. + ## Dependency Edge Sync Requirement (Zazz Task Graph) When the plan is instantiated as Zazz tasks: - Each non-`none` `DEPENDS_ON` must map to explicit `TASK_RELATIONS` edges (`relation_type = DEPENDS_ON`). @@ -127,6 +134,9 @@ When the plan is instantiated as Zazz tasks: - Maximize concurrency across disjoint files/subsystems. - Call out merge points where parallel streams converge. - Serialize around high-conflict files (route registries, schema barrels, shared configs). +- If one provisional step contains multiple disjoint ownership sets, rewrite it into multiple steps before finalizing the PLAN. +- Prefer one primary owned file set per step; if a step spans multiple independent owned file sets, that is usually a decomposition failure. +- When UI work naturally splits into trigger/wiring/modal/i18n/test slices with disjoint ownership, plan those as separate steps if they can be executed independently. - Prefer stream decomposition: - API route stream - data/schema stream @@ -150,6 +160,8 @@ A PLAN is complete only if all conditions below are true: - Includes development + testing + validation work - Includes AC traceability and test traceability - Explicitly documents dependencies and parallelizable groups +- Splits independently parallelizable owned work into separate numbered steps instead of burying it inside a single broad step +- Includes explicit merge/integration steps wherever parallel streams converge - Includes concrete commands for required verification runs - Includes risks/mitigations and owner approval checkpoints for non-trivial work - Avoids speculative routes/files and aligns to repository reality diff --git a/docs/ZAZZ-6-manual-test-plan.md b/docs/ZAZZ-6-manual-test-plan.md new file mode 100644 index 00000000..b9860db2 --- /dev/null +++ b/docs/ZAZZ-6-manual-test-plan.md @@ -0,0 +1,163 @@ +# ZAZZ-6 Manual Test Plan + +## Purpose +This plan covers the remaining manual verification for deliverable `ZAZZ-6`: +- project-scoped agent token management UI +- leader vs non-leader behavior +- exact-phrase token revocation flow +- deliverable approval baby step when dragging from `PLANNING` to `IN_PROGRESS` + +## Environment +- Start the app normally. +- Client URL: `http://localhost:3001` +- API URL: `http://localhost:3030` +- Use the standard seeded user token in the UI login/access-token modal: + - `550e8400-e29b-41d4-a716-446655440000` + +## Seeded Context +- Project `ZAZZ` + - leader: Michael (user token above) + - seeded agent tokens exist for Michael in `ZAZZ` +- Project `ZED_MER` + - seeded agent tokens exist for Jane/Steve + +## Expected High-Level Outcomes +- Project rows expose a manage-agent-tokens action. +- Leaders can view project-wide token groups for that project. +- Non-leaders can only view their own tokens for that project. +- Creating a token returns a visible value that can be copied. +- Revoking a token requires typing the exact phrase `delete this token`. +- Dragging a deliverable card from `PLANNING` to `IN_PROGRESS` auto-approves it when a plan filepath exists. + +## Test Cases + +### 1. Access Token Login +1. Open `http://localhost:3001`. +2. Set the access token to `550e8400-e29b-41d4-a716-446655440000`. +3. Confirm the projects page loads. + +Expected: +- Project list renders without auth errors. +- `ZAZZ` and other seeded projects are visible. + +### 2. Project Row Entry Point +1. On the projects page, inspect a project row. +2. Find the key/manage-agent-tokens action. +3. Click it for `ZAZZ`. + +Expected: +- The Agent Tokens modal opens. +- The modal title references the selected project. + +### 3. Leader View for ZAZZ +1. Open the Agent Tokens modal for `ZAZZ` while logged in as Michael. +2. Review the modal contents. + +Expected: +- The modal shows leader-oriented text. +- Multiple user sections are visible for the project-wide view, or at minimum the UI is clearly rendering grouped user/token data rather than a single self-only card. +- Existing seeded tokens are visible with labels and token values. + +### 4. Create Token in Leader View +1. In the `ZAZZ` Agent Tokens modal, enter an optional label for a target user row. +2. Click the create/generate button. + +Expected: +- A new token appears successfully. +- A success area or newest-token area shows the created token value. +- The new token is copyable. +- The token value is visible in full, not masked. + +### 5. Copy Token +1. Use the copy action on the newly created token. + +Expected: +- Copy feedback changes to a success state such as `Copied`. +- No modal crash or UI reset occurs. + +### 6. Revoke Token Guard +1. Choose a token and click revoke/delete. +2. Do not type the confirmation phrase yet. + +Expected: +- A confirmation section appears. +- The destructive confirm button remains disabled until the exact phrase is entered. + +### 7. Revoke Token Exact Phrase +1. Type an incorrect value such as `Delete this token` or extra spaces/other text. +2. Verify the destructive action is still gated. +3. Type exactly `delete this token`. +4. Confirm the revocation. + +Expected: +- Incorrect values do not enable the destructive action. +- Exact lowercase phrase enables the destructive action. +- After confirmation, the token disappears from the list. + +### 8. Reopen Modal After Create/Delete +1. Close the Agent Tokens modal. +2. Reopen it for the same project. + +Expected: +- The list reflects the latest created/deleted state. +- The modal opens cleanly without stale confirmation UI. + +### 9. Non-Leader Self View +Use any non-leader user token available in your environment. If you do not have one handy in the UI, this case can be done later once a non-leader login path is available. + +1. Log in as a non-leader user. +2. Open the Agent Tokens modal for a project where that user is not the leader. + +Expected: +- The modal shows self-oriented copy. +- Only that user’s own tokens are visible. +- No project-wide multi-user tree is exposed. +- Create/revoke still works for that user’s own tokens only. + +### 10. Project Isolation Sanity Check +1. Open the Agent Tokens modal for `ZAZZ`. +2. Note the visible users/tokens. +3. Open the Agent Tokens modal for another project. + +Expected: +- Token data changes with the selected project. +- No `ZED_MER` tokens appear in `ZAZZ`, and vice versa. + +### 11. Deliverable Approval Baby Step +1. Navigate to the deliverable board for a project that has a deliverable with: + - status `PLANNING` + - a non-empty `plan_filepath` +2. Drag that deliverable card from `PLANNING` to `IN_PROGRESS`. + +Expected: +- The move succeeds. +- The deliverable becomes approved automatically as part of the move. +- No separate manual approve call is required first. + +### 12. Deliverable Approval Negative Check +1. Find or create a deliverable in `PLANNING` without a `plan_filepath`. +2. Attempt to move it to `IN_PROGRESS`. + +Expected: +- The transition is rejected or blocked. +- A deliverable without a plan cannot enter `IN_PROGRESS`. + +## Suggested Notes to Capture During Testing +- Which user/token was used +- Which project was tested +- Whether the modal content matched leader vs non-leader expectations +- Whether copy feedback was clear +- Whether delete gating behaved exactly as expected +- Whether drag-to-approve worked with and without `plan_filepath` +- Any console/network errors seen in browser devtools + +## Exit Criteria +Manual verification is complete when all of the following are true: +- UI entry point is discoverable and opens reliably +- Leader flow passes +- Non-leader flow passes +- Create/copy/delete flows pass +- Exact-phrase revocation guard passes +- Project isolation is visually correct +- Dragging `PLANNING -> IN_PROGRESS` auto-approves when `plan_filepath` exists +- The negative approval case without `plan_filepath` is blocked From 176742934539e616dcc220638be5cbd38e17f51e Mon Sep 17 00:00:00 2001 From: michaelwitz Date: Sun, 8 Mar 2026 21:22:50 -0400 Subject: [PATCH 15/15] fix: agent tokens UI - key icon, single-user view, modal title - ProjectList: use IconKey, remove Tooltip, add overflow scroll for actions column - AgentTokensModal: always show only current user's tokens (remove leader all-users view) - useAgentTokens: always fetch users/me endpoint - Modal title: {{projectCode}} Project Agent Access Tokens - i18n: createButton -> createAction, update titleWithProject in en/es/fr/de Made-with: Cursor --- .../skills/database-baseline-refresh/SKILL.md | 198 ++ .gitignore | 1 + AGENTS.md | 1 + api/package.json | 2 + api/scripts/export-database-snapshot.js | 144 ++ api/scripts/reset-and-seed.js | 53 +- api/scripts/seed-all.js | 64 +- .../seeders/data/database-snapshot.json | 2117 +++++++++++++++++ .../data/database-snapshot.pre-upgrade.json | 2070 ++++++++++++++++ api/scripts/seeders/databaseSnapshot.js | 52 + api/scripts/seeders/seedAgentTokens.js | 23 +- api/scripts/seeders/seedDatabaseSnapshot.js | 116 + api/scripts/seeders/seedProjects.js | 34 +- client/src/components/AgentTokensModal.jsx | 29 +- client/src/components/ProjectList.jsx | 56 +- client/src/hooks/useAgentTokens.js | 15 +- client/src/i18n/locales/de.json | 2 +- client/src/i18n/locales/en.json | 2 +- client/src/i18n/locales/es.json | 2 +- client/src/i18n/locales/fr.json | 2 +- docs/database-baseline-refresh.md | 153 ++ .../sample-worker-multi-agent-prompt-CODEX.md | 19 + package-lock.json | 307 ++- package.json | 3 +- scripts/screenshot-project-page.mjs | 41 + 25 files changed, 5238 insertions(+), 268 deletions(-) create mode 100644 .agents/skills/database-baseline-refresh/SKILL.md create mode 100644 api/scripts/export-database-snapshot.js create mode 100644 api/scripts/seeders/data/database-snapshot.json create mode 100644 api/scripts/seeders/data/database-snapshot.pre-upgrade.json create mode 100644 api/scripts/seeders/databaseSnapshot.js create mode 100644 api/scripts/seeders/seedDatabaseSnapshot.js create mode 100644 docs/database-baseline-refresh.md create mode 100644 docs/sample-worker-multi-agent-prompt-CODEX.md create mode 100644 scripts/screenshot-project-page.mjs diff --git a/.agents/skills/database-baseline-refresh/SKILL.md b/.agents/skills/database-baseline-refresh/SKILL.md new file mode 100644 index 00000000..2987d6b2 --- /dev/null +++ b/.agents/skills/database-baseline-refresh/SKILL.md @@ -0,0 +1,198 @@ +--- +name: "database-baseline-refresh" +type: "procedure" +description: "Refresh the canonical database seed baseline by preserving the current dev DB, upgrading to the latest schema, restoring accumulated real data, adding new feature baseline rows, and re-freezing the upgraded state." +--- + +# Database Baseline Refresh + +## Purpose + +Use this skill when a feature changes persistent database schema or adds new persisted baseline data and the repo baseline must preserve the real accumulated dev/test data already entered through the app. + +This workflow is for carrying the current development database forward. +It is not for rebuilding from synthetic starter data. + +Canonical companion document: +- `docs/database-baseline-refresh.md` + +## When To Use + +Use this skill when all of the following are true: + +1. The branch changes database schema or adds new persisted tables/columns. +2. The running dev DB contains valid board/app data you do not want to lose. +3. Future resets/tests must reproduce that upgraded real data exactly. + +## Core Principle + +Always follow: + +1. Back up current DB truth. +2. Export current DB truth into the canonical snapshot. +3. Rebuild on the latest schema. +4. Restore captured data. +5. Add new branch-owned baseline rows that did not exist in the old DB yet. +6. Re-export the upgraded DB as the new canonical snapshot. +7. Prove the snapshot round-trips. +8. Verify with the backend test suite. + +Do not skip the backup step. + +## Source Of Truth Files + +Canonical snapshot: +- `api/scripts/seeders/data/database-snapshot.json` + +Typical backup snapshot: +- `api/scripts/seeders/data/database-snapshot.pre-upgrade.json` + +Typical raw SQL rollback dump: +- `/tmp/zazz_board_db-pre-upgrade.sql` + +Implementation files: +- `api/scripts/export-database-snapshot.js` +- `api/scripts/seeders/databaseSnapshot.js` +- `api/scripts/seeders/seedDatabaseSnapshot.js` + +## Preserved Tables + +Include persistent business/config data: + +- `USERS` +- `STATUS_DEFINITIONS` +- `COORDINATION_TYPES` +- `TRANSLATIONS` +- `TAGS` +- `PROJECTS` +- `AGENT_TOKENS` +- `DELIVERABLES` +- `TASKS` +- `TASK_TAGS` +- `TASK_RELATIONS` +- `IMAGE_METADATA` +- `IMAGE_DATA` + +Exclude: + +- `FILE_LOCKS` + +Reason: +- lock leases are operational state, not durable product state + +## Required Guardrails + +1. Back up before any destructive step. +2. Preserve explicit IDs from the source DB. +3. Reset sequences after importing explicit IDs. +4. Do not silently replace real data with hardcoded synthetic seed rows. +5. If a new table did not exist in the old DB, restore old data first, then add branch-owned baseline rows intentionally. +6. Re-export after adding those new baseline rows so the canonical snapshot reflects the upgraded state. + +## Standard Execution Steps + +### 1. Back up current DB truth + +Create a JSON backup snapshot: + +```bash +cd api +node scripts/export-database-snapshot.js scripts/seeders/data/database-snapshot.pre-upgrade.json +``` + +Create a raw SQL rollback dump: + +```bash +docker exec zazz_board_postgres pg_dump -U postgres -d zazz_board_db > /tmp/zazz_board_db-pre-upgrade.sql +``` + +If local `pg_dump` matches server version, that is acceptable too. + +### 2. Export current DB truth into the canonical snapshot + +```bash +cd api +npm run db:export-snapshot +``` + +This captures the real current DB state, even if the running DB is still on the old schema. + +### 3. Rebuild the dev DB on latest schema from the canonical snapshot + +```bash +cd api +npm run db:reset +``` + +This must: + +1. recreate tables from `api/lib/db/schema.js` +2. import `database-snapshot.json` +3. preserve IDs +4. reset sequences + +### 4. Add new branch-owned baseline rows + +If the feature introduced new persisted baseline rows that did not exist in the old DB, add them now. + +Example: + +```bash +cd api +npm run db:seed-agent-tokens +``` + +This step must be idempotent. + +### 5. Re-freeze the upgraded DB into the canonical snapshot + +```bash +cd api +npm run db:export-snapshot +``` + +After this step, `database-snapshot.json` is the new canonical baseline. + +### 6. Prove round-trip reproducibility + +Run another reset from the newly exported snapshot: + +```bash +cd api +npm run db:reset +``` + +Verify expected counts and key rows still exist. + +### 7. Refresh the test DB and run full backend verification + +```bash +cd api +DATABASE_URL=postgres://postgres:password@localhost:5433/zazz_board_test npm run db:reset +set -a && source .env && set +a && NODE_ENV=test npm run test +``` + +Do not declare the refresh complete until this passes. + +## Expected Outcome + +At completion: + +1. The dev DB is on the latest schema. +2. Real accumulated board/app data is preserved. +3. New feature baseline rows are present. +4. `database-snapshot.json` reproduces that upgraded state exactly. +5. Backend tests pass from the refreshed snapshot flow. + +## Notes For Future Features + +Repeat this process every time a schema-affecting feature lands and you want to preserve accumulated real data. + +The pattern is always: + +- preserve +- upgrade +- restore +- add new baseline rows +- re-freeze +- verify diff --git a/.gitignore b/.gitignore index 292ebe98..f1e0dab8 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ api/.env.local .idea .DS_Store .codex/ +project-page-screenshot.png *.suo *.ntvs* *.njsproj diff --git a/AGENTS.md b/AGENTS.md index 3e34e84a..0f61ca65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ This repo **dogfoods** the Zazz Framework: Zazz Board is built with Zazz Board. | coordinator-agent | Orchestrates execution after plan approval | | worker-agent | Implements tasks | | qa-agent | Verifies AC, creates rework tasks | +| database-baseline-refresh | Preserves live dev DB data while upgrading schema and refreshing the canonical seed baseline | **Rules** (`.cursor/rules/`): Always-applied for Cursor (e.g. worktree workflow). diff --git a/api/package.json b/api/package.json index b94460b4..01be8796 100644 --- a/api/package.json +++ b/api/package.json @@ -16,6 +16,8 @@ "db:push": "drizzle-kit push --force", "db:seed": "node scripts/seed-all.js", "db:reset": "node scripts/reset-and-seed.js", + "db:export-snapshot": "node scripts/export-database-snapshot.js", + "db:seed-agent-tokens": "node scripts/seeders/seedAgentTokens.js", "test": "NODE_ENV=test vitest run", "test:watch": "NODE_ENV=test vitest", "test:coverage": "NODE_ENV=test vitest run --coverage" diff --git a/api/scripts/export-database-snapshot.js b/api/scripts/export-database-snapshot.js new file mode 100644 index 00000000..c805063b --- /dev/null +++ b/api/scripts/export-database-snapshot.js @@ -0,0 +1,144 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { db, client } from '../lib/db/index.js'; +import { + USERS, + STATUS_DEFINITIONS, + COORDINATION_TYPES, + TRANSLATIONS, + TAGS, + PROJECTS, + AGENT_TOKENS, + DELIVERABLES, + TASKS, + TASK_TAGS, + TASK_RELATIONS, + IMAGE_METADATA, + IMAGE_DATA, +} from '../lib/db/schema.js'; +import { databaseSnapshotPath } from './seeders/databaseSnapshot.js'; + +const outputPath = process.argv[2] ? resolve(process.argv[2]) : databaseSnapshotPath; + +const tableConfig = [ + { key: 'users', table: USERS, sortBy: ['id'] }, + { key: 'status_definitions', table: STATUS_DEFINITIONS, sortBy: ['code'] }, + { key: 'coordination_types', table: COORDINATION_TYPES, sortBy: ['code'] }, + { key: 'translations', table: TRANSLATIONS, sortBy: ['language_code', 'id'] }, + { key: 'tags', table: TAGS, sortBy: ['tag'] }, + { key: 'projects', table: PROJECTS, sortBy: ['id'] }, + { key: 'agent_tokens', table: AGENT_TOKENS, sortBy: ['id'] }, + { key: 'deliverables', table: DELIVERABLES, sortBy: ['id'] }, + { key: 'tasks', table: TASKS, sortBy: ['id'] }, + { key: 'task_tags', table: TASK_TAGS, sortBy: ['task_id', 'tag'] }, + { key: 'task_relations', table: TASK_RELATIONS, sortBy: ['task_id', 'related_task_id', 'relation_type'] }, + { key: 'image_metadata', table: IMAGE_METADATA, sortBy: ['id'] }, + { key: 'image_data', table: IMAGE_DATA, sortBy: ['id'] }, +]; + +function compareValues(left, right) { + if (left === right) return 0; + if (left == null) return -1; + if (right == null) return 1; + if (left instanceof Date && right instanceof Date) { + return left.getTime() - right.getTime(); + } + if (typeof left === 'number' && typeof right === 'number') { + return left - right; + } + return String(left).localeCompare(String(right)); +} + +function sortRows(rows, sortBy) { + return [...rows].sort((left, right) => { + for (const field of sortBy) { + const comparison = compareValues(left[field], right[field]); + if (comparison !== 0) { + return comparison; + } + } + return 0; + }); +} + +function isMissingRelationError(error) { + return error?.cause?.code === '42P01' || error?.code === '42P01'; +} + +function normalizeValue(value) { + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeValue(item)); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, innerValue]) => [key, normalizeValue(innerValue)]) + ); + } + + return value; +} + +async function selectRows(key, table, sortBy) { + try { + const rows = await db.select().from(table); + return sortRows(rows, sortBy).map((row) => normalizeValue(row)); + } catch (error) { + if (isMissingRelationError(error)) { + return []; + } + + throw error; + } +} + +async function exportSnapshot() { + try { + const snapshot = { + format_version: 1, + metadata: { + exported_at: new Date().toISOString(), + missing_tables: [], + }, + }; + + for (const entry of tableConfig) { + snapshot[entry.key] = await selectRows(entry.key, entry.table, entry.sortBy); + if (snapshot[entry.key].length === 0) { + try { + await db.select().from(entry.table).limit(1); + } catch (error) { + if (isMissingRelationError(error)) { + snapshot.metadata.missing_tables.push(entry.key); + } else { + throw error; + } + } + } + } + + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8'); + + console.log(`✅ Exported database snapshot to ${outputPath}`); + console.log('📊 Row counts:'); + for (const entry of tableConfig) { + console.log(` • ${entry.key}: ${snapshot[entry.key].length}`); + } + if (snapshot.metadata.missing_tables.length) { + console.log(` • missing_tables: ${snapshot.metadata.missing_tables.join(', ')}`); + } + } catch (error) { + console.error('❌ Failed to export database snapshot:', error.message); + console.error('🔍 Full error:', error); + process.exitCode = 1; + } finally { + await client.end(); + } +} + +exportSnapshot(); diff --git a/api/scripts/reset-and-seed.js b/api/scripts/reset-and-seed.js index 251c0ba4..7efdd66a 100644 --- a/api/scripts/reset-and-seed.js +++ b/api/scripts/reset-and-seed.js @@ -3,17 +3,7 @@ import { sql } from 'drizzle-orm'; import { existsSync } from 'fs'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; -import { seedUsers } from './seeders/seedUsers.js'; -import { seedStatusDefinitions } from './seeders/seedStatusDefinitions.js'; -import { seedCoordinationTypes } from './seeders/seedCoordinationTypes.js'; -import { seedTranslations } from './seeders/seedTranslations.js'; -import { seedProjects } from './seeders/seedProjects.js'; -import { seedAgentTokens } from './seeders/seedAgentTokens.js'; -import { seedDeliverables } from './seeders/seedDeliverables.js'; -import { seedTags } from './seeders/seedTags.js'; -import { seedTasks } from './seeders/seedTasks.js'; -import { seedTaskTags } from './seeders/seedTaskTags.js'; -import { seedTaskRelations } from './seeders/seedTaskRelations.js'; +import { seedDatabaseSnapshot } from './seeders/seedDatabaseSnapshot.js'; async function resetAndSeed() { try { @@ -51,47 +41,14 @@ async function resetAndSeed() { console.log('🌱 Seeding fresh data...'); console.log(''); - - console.log('📋 Step 2a: Seeding base entities...'); - await seedUsers(); - await seedTags(); - await seedStatusDefinitions(); - await seedCoordinationTypes(); // Populates COORDINATION_TYPES table - await seedTranslations(); - console.log(''); - - console.log('📋 Step 2b: Seeding projects...'); - await seedProjects(); - console.log(''); - - console.log('📋 Step 2c: Seeding agent tokens...'); - await seedAgentTokens(); - console.log(''); - - console.log('📋 Step 2d: Seeding deliverables...'); - await seedDeliverables(); - console.log(''); - - console.log('📋 Step 2e: Seeding tasks...'); - await seedTasks(); - console.log(''); - - console.log('📋 Step 2f: Seeding task-tag relationships...'); - await seedTaskTags(); - console.log(''); - - console.log('📋 Step 2g: Seeding task relations...'); - await seedTaskRelations(); + const counts = await seedDatabaseSnapshot(); console.log(''); console.log('✅ Database reset and seeding completed successfully!'); console.log('📊 Summary:'); - console.log(' • 5 users created'); - console.log(' • 2 projects created (ZAZZ, ZED_MER)'); - console.log(' • 6 agent tokens created'); - console.log(' • 4 deliverables created (ZAZZ only)'); - console.log(' • 32 ZAZZ tasks seeded from database snapshot'); - console.log(' • status definitions + translations seeded'); + Object.entries(counts).forEach(([key, count]) => { + console.log(` • ${key}: ${count}`); + }); process.exit(0); } catch (error) { console.error('❌ Error resetting and seeding data:', error.message); diff --git a/api/scripts/seed-all.js b/api/scripts/seed-all.js index dd23f395..1f4684e5 100644 --- a/api/scripts/seed-all.js +++ b/api/scripts/seed-all.js @@ -1,14 +1,4 @@ -import { seedUsers } from './seeders/seedUsers.js'; -import { seedStatusDefinitions } from './seeders/seedStatusDefinitions.js'; -import { seedCoordinationTypes } from './seeders/seedCoordinationTypes.js'; -import { seedTranslations } from './seeders/seedTranslations.js'; -import { seedProjects } from './seeders/seedProjects.js'; -import { seedAgentTokens } from './seeders/seedAgentTokens.js'; -import { seedDeliverables } from './seeders/seedDeliverables.js'; -import { seedTags } from './seeders/seedTags.js'; -import { seedTasks } from './seeders/seedTasks.js'; -import { seedTaskTags } from './seeders/seedTaskTags.js'; -import { seedTaskRelations } from './seeders/seedTaskRelations.js'; +import { seedDatabaseSnapshot } from './seeders/seedDatabaseSnapshot.js'; import { client } from '../lib/db/index.js'; // Safety check: Prevent seeding production @@ -41,58 +31,16 @@ console.log(`✅ Safety check passed. Seeding database: ${dbName}`); async function seedAll() { try { - console.log('🌱 Seeding database data in correct order...'); + console.log('🌱 Seeding database from full snapshot...'); console.log(''); - - // Step 1: Seed independent tables first (no foreign keys) - console.log('📋 Step 1: Seeding base entities...'); - await seedUsers(); - await seedStatusDefinitions(); - await seedCoordinationTypes(); - await seedTranslations(); - await seedTags(); - console.log(''); - - // Step 2: Seed tables that depend on users - console.log('📋 Step 2: Seeding projects (depends on users)...'); - await seedProjects(); - console.log(''); - - // Step 3: Seed agent tokens (depends on users and projects) - console.log('📋 Step 3: Seeding agent tokens (depends on users and projects)...'); - await seedAgentTokens(); - console.log(''); - - // Step 4: Seed deliverables (depends on projects and users) - console.log('📋 Step 4: Seeding deliverables (depends on projects and users)...'); - await seedDeliverables(); - console.log(''); - - // Step 5: Seed tasks (depends on projects, deliverables, and users) - console.log('📋 Step 5: Seeding tasks (depends on projects, deliverables, and users)...'); - await seedTasks(); - console.log(''); - - // Step 6: Seed relationship tables - console.log('📋 Step 6: Seeding relationships (depends on tasks and tags)...'); - await seedTaskTags(); - console.log(''); - - // Step 7: Seed task relations (depends on tasks existing) - console.log('📋 Step 7: Seeding task relations (depends on tasks)...'); - await seedTaskRelations(); + const counts = await seedDatabaseSnapshot(); console.log(''); console.log('✅ Database seeding completed successfully!'); console.log('📊 Summary:'); - console.log(' • 5 users created'); - console.log(' • 8 status definitions created'); - console.log(' • 4 translation sets created (en, es, fr, de)'); - console.log(' • 2 projects created (ZAZZ, ZED_MER)'); - console.log(' • 6 agent tokens created'); - console.log(' • 4 deliverables created (ZAZZ only)'); - console.log(' • 6 tags created'); - console.log(' • 32 ZAZZ tasks seeded from database snapshot'); + Object.entries(counts).forEach(([key, count]) => { + console.log(` • ${key}: ${count}`); + }); } catch (error) { console.error('❌ Error seeding data:', error.message); diff --git a/api/scripts/seeders/data/database-snapshot.json b/api/scripts/seeders/data/database-snapshot.json new file mode 100644 index 00000000..abfd61ea --- /dev/null +++ b/api/scripts/seeders/data/database-snapshot.json @@ -0,0 +1,2117 @@ +{ + "format_version": 1, + "metadata": { + "exported_at": "2026-03-08T23:20:09.118Z", + "missing_tables": [] + }, + "users": [ + { + "id": 1, + "full_name": "John Doe", + "email": "john.doe@example.com", + "access_token": "94640eec-04f4-4089-8cd1-14be1b7412f3", + "created_at": "2026-03-08T13:03:36.202Z", + "updated_at": "2026-03-08T13:03:36.202Z" + }, + { + "id": 2, + "full_name": "Jane Smith", + "email": "jane.smith@example.com", + "access_token": "18b3759f-bb53-430c-bbf3-514e8004b769", + "created_at": "2026-03-08T13:03:36.202Z", + "updated_at": "2026-03-08T13:03:36.202Z" + }, + { + "id": 3, + "full_name": "Mike Johnson", + "email": "mike.johnson@example.com", + "access_token": "bc03c021-0ea0-46aa-8cc1-65277ff9e45a", + "created_at": "2026-03-08T13:03:36.202Z", + "updated_at": "2026-03-08T13:03:36.202Z" + }, + { + "id": 4, + "full_name": "Sarah Wilson", + "email": "sarah.wilson@example.com", + "access_token": "9a55982c-589e-44f6-85b4-f7132b795621", + "created_at": "2026-03-08T13:03:36.202Z", + "updated_at": "2026-03-08T13:03:36.202Z" + }, + { + "id": 5, + "full_name": "Michael Woytowitz", + "email": "michael@witzware.com", + "access_token": "550e8400-e29b-41d4-a716-446655440000", + "created_at": "2026-03-08T13:03:36.202Z", + "updated_at": "2026-03-08T13:03:36.202Z" + } + ], + "status_definitions": [ + { + "code": "AWAITING_APPROVAL", + "description": "Tasks waiting for stakeholder approval", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "BACKLOG", + "description": "Tasks in backlog awaiting prioritization", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "COMPLETED", + "description": "Task finished and verified against acceptance criteria", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "DONE", + "description": "Merged to main, deliverable complete", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "ICEBOX", + "description": "Tasks that are deprioritized or on hold", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "IN_PROGRESS", + "description": "Tasks currently being worked on", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "IN_REVIEW", + "description": "Tasks awaiting code review or approval", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "PENDING", + "description": "Tasks waiting for upstream dependencies to complete", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "PLANNING", + "description": "SPEC and implementation plan being created or refined", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "PROD", + "description": "Merged to main and deployed to production (terminal state)", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "QA", + "description": "Task undergoing quality assurance and acceptance testing", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "READY", + "description": "Tasks that are ready to be started", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "READY_FOR_DEPLOY", + "description": "Tasks ready to be deployed to production", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "STAGED", + "description": "Merged to staging branch for integration testing", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "TESTING", + "description": "Tasks in testing phase", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "TO_DO", + "description": "Tasks that are planned but not yet started", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "UAT", + "description": "User acceptance testing in integration environment", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + } + ], + "coordination_types": [ + { + "code": "DEPLOY_TOGETHER", + "description": "Changes must be deployed together to avoid breaking changes", + "created_by": null, + "created_at": "2026-03-08T13:03:36.205Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.205Z" + }, + { + "code": "MERGE_TOGETHER", + "description": "All PRs must merge to dev together", + "created_by": null, + "created_at": "2026-03-08T13:03:36.205Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.205Z" + }, + { + "code": "MIGRATE_TOGETHER", + "description": "Database migration and API changes must merge simultaneously", + "created_by": null, + "created_at": "2026-03-08T13:03:36.205Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.205Z" + }, + { + "code": "RELEASE_TOGETHER", + "description": "All changes must be released to production together", + "created_by": null, + "created_at": "2026-03-08T13:03:36.205Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.205Z" + }, + { + "code": "TEST_TOGETHER", + "description": "Changes must be tested together before deployment", + "created_by": null, + "created_at": "2026-03-08T13:03:36.205Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.205Z" + } + ], + "translations": [ + { + "id": 4, + "language_code": "de", + "translations": "{\"common\":{\"loading\":\"Laden...\",\"error\":\"Fehler\",\"success\":\"Erfolg\",\"cancel\":\"Abbrechen\",\"save\":\"Speichern\",\"delete\":\"Löschen\",\"edit\":\"Bearbeiten\",\"add\":\"Hinzufügen\",\"close\":\"Schließen\",\"back\":\"Zurück\",\"next\":\"Weiter\",\"previous\":\"Zurück\",\"submit\":\"Absenden\",\"confirm\":\"Bestätigen\",\"yes\":\"Ja\",\"no\":\"Nein\",\"ok\":\"OK\",\"search\":\"Suchen\",\"filter\":\"Filtern\",\"sort\":\"Sortieren\",\"refresh\":\"Aktualisieren\",\"settings\":\"Einstellungen\",\"profile\":\"Profil\",\"logout\":\"Abmelden\",\"login\":\"Anmelden\",\"register\":\"Registrieren\",\"accessTokenRequired\":\"Zugriffstoken erforderlich\",\"pleaseSetAccessToken\":\"Bitte setzen Sie Ihren Zugriffstoken über das Menü oben links, um Projekte anzuzeigen.\",\"setAccessToken\":\"Zugriffstoken setzen\",\"accessToken\":\"Zugriffstoken\",\"enterAccessToken\":\"Geben Sie Ihren Zugriffstoken ein\",\"tokenSaved\":\"Zugriffstoken erfolgreich gespeichert\",\"actions\":\"Aktionen\",\"keyboardShortcut\":\"Tastenkürzel\"},\"navigation\":{\"home\":\"Startseite\",\"projects\":\"Projekte\",\"tasks\":\"Aufgaben\",\"kanban\":\"Kanban-Board\",\"dashboard\":\"Dashboard\",\"reports\":\"Berichte\"},\"projects\":{\"title\":\"Projekte\",\"newProject\":\"Neues Projekt\",\"projectName\":\"Projektname\",\"projectCode\":\"Projektcode\",\"description\":\"Beschreibung\",\"leader\":\"Projektleiter\",\"leaderName\":\"Name des Leiters\",\"createdAt\":\"Erstellt\",\"updatedAt\":\"Aktualisiert\",\"actions\":\"Aktionen\",\"noProjects\":\"Keine Projekte gefunden\",\"createFirstProject\":\"Erstellen Sie Ihr erstes Projekt, um zu beginnen\",\"createProject\":\"Projekt erstellen\",\"editProject\":\"Projekt bearbeiten\",\"deleteProject\":\"Projekt löschen\",\"projectDetails\":\"Projektdetails\",\"statusWorkflow\":{\"title\":\"Status-Workflow\",\"selected\":\"Ausgewählter Status-Workflow\",\"available\":\"Verfügbare Status\",\"dragHelp\":\"Ziehen zum Neuordnen. Diese Status erscheinen als Spalten in Ihrem Kanban-Board.\",\"viewHelp\":\"Diese Status erscheinen als Spalten in Ihrem Kanban-Board.\",\"addHelp\":\"Klicken Sie auf \\\"Hinzufügen\\\", um einen Status in Ihren Workflow aufzunehmen.\"}},\"tasks\":{\"title\":\"Titel\",\"newTask\":\"Neue Aufgabe\",\"taskId\":\"Aufgaben-ID\",\"prompt\":\"Prompt\",\"status\":\"Status\",\"priority\":\"Priorität\",\"assignee\":\"Zugewiesen\",\"assigneeName\":\"Name des Zugewiesenen\",\"storyPoints\":\"Story Points\",\"gitWorktree\":\"Git Worktree\",\"gitWorktreePlaceholder\":\"feature/aufgabe-123\",\"gitPullRequestUrl\":\"Pull-Request-URL\",\"gitPullRequestUrlPlaceholder\":\"https://github.com/benutzer/repo/pull/123\",\"isBlocked\":\"Blockiert\",\"blockedReason\":\"Blockierungsgrund\",\"startedAt\":\"Gestartet\",\"completedAt\":\"Abgeschlossen\",\"createdAt\":\"Erstellt\",\"updatedAt\":\"Aktualisiert\",\"actions\":\"Aktionen\",\"noTasks\":\"Keine Aufgaben gefunden\",\"createFirstTask\":\"Erstellen Sie Ihre erste Aufgabe, um zu beginnen\",\"createTask\":\"Aufgabe erstellen\",\"editTask\":\"Aufgabe bearbeiten\",\"deleteTask\":\"Aufgabe löschen\",\"taskDetails\":\"Aufgabendetails\",\"priorities\":{\"LOW\":\"Niedrig\",\"MEDIUM\":\"Mittel\",\"HIGH\":\"Hoch\",\"CRITICAL\":\"Kritisch\"},\"statusDescriptions\":{\"TO_DO\":\"Geplante, aber noch nicht begonnene Aufgaben\",\"IN_PROGRESS\":\"Aufgaben, an denen derzeit gearbeitet wird\",\"IN_REVIEW\":\"Aufgaben, die auf Code-Review oder Genehmigung warten\",\"DONE\":\"Abgeschlossene Aufgaben\",\"TESTING\":\"Aufgaben in der Testphase\",\"AWAITING_APPROVAL\":\"Aufgaben, die auf Stakeholder-Genehmigung warten\",\"READY_FOR_DEPLOY\":\"Aufgaben, die bereit für die Produktionsbereitstellung sind\",\"ICEBOX\":\"Zurückgestellte oder pausierte Aufgaben\",\"READY\":\"Aufgaben, die bereit sind zu starten\",\"BACKLOG\":\"Aufgaben im Backlog, die auf Priorisierung warten\",\"TESTING_DEV\":\"Aufgaben, die in der Entwicklungsumgebung getestet werden\",\"TESTING_STAGE\":\"Aufgaben, die in der Staging-Umgebung getestet werden\",\"PLANNING\":\"Aufgaben in der Planungsphase\"},\"statuses\":{\"PENDING\":\"Ausstehend\",\"TO_DO\":\"Zu Erledigen\",\"IN_PROGRESS\":\"In Bearbeitung\",\"IN_REVIEW\":\"In Überprüfung\",\"DONE\":\"Erledigt\",\"TESTING\":\"Testen\",\"AWAITING_APPROVAL\":\"Wartet auf Genehmigung\",\"READY_FOR_DEPLOY\":\"Bereit für Bereitstellung\",\"ICEBOX\":\"Eiskiste\",\"READY\":\"Bereit zu Starten\",\"BACKLOG\":\"Backlog\",\"TESTING_DEV\":\"Testen (Dev)\",\"TESTING_STAGE\":\"Testen (Stage)\",\"PLANNING\":\"Planung\",\"QA\":\"QA\",\"COMPLETED\":\"Abgeschlossen\"}},\"kanban\":{\"title\":\"Kanban-Board\",\"toDo\":\"Zu Erledigen\",\"inProgress\":\"In Bearbeitung\",\"inReview\":\"In Überprüfung\",\"done\":\"Erledigt\",\"addTask\":\"Aufgabe hinzufügen\",\"noTasksInColumn\":\"Keine Aufgaben in dieser Spalte\",\"dragToReorder\":\"Ziehen Sie, um Aufgaben neu zu ordnen\",\"dropToMove\":\"Lassen Sie los, um Aufgabe zu verschieben\"},\"users\":{\"title\":\"Benutzer\",\"fullName\":\"Vollständiger Name\",\"email\":\"E-Mail\",\"createdAt\":\"Erstellt\",\"updatedAt\":\"Aktualisiert\",\"actions\":\"Aktionen\",\"noUsers\":\"Keine Benutzer gefunden\"},\"tags\":{\"title\":\"Tags\",\"tagName\":\"Tag-Name\",\"color\":\"Farbe\",\"createdAt\":\"Erstellt\",\"actions\":\"Aktionen\",\"noTags\":\"Keine Tags gefunden\",\"createFirstTag\":\"Erstellen Sie Ihren ersten Tag, um zu beginnen\"},\"forms\":{\"required\":\"Dieses Feld ist erforderlich\",\"invalidEmail\":\"Bitte geben Sie eine gültige E-Mail-Adresse ein\",\"minLength\":\"Muss mindestens {{min}} Zeichen lang sein\",\"maxLength\":\"Darf nicht länger als {{max}} Zeichen sein\",\"invalidFormat\":\"Ungültiges Format\"},\"notifications\":{\"projectCreated\":\"Projekt erfolgreich erstellt\",\"projectUpdated\":\"Projekt erfolgreich aktualisiert\",\"projectDeleted\":\"Projekt erfolgreich gelöscht\",\"taskCreated\":\"Aufgabe erfolgreich erstellt\",\"taskUpdated\":\"Aufgabe erfolgreich aktualisiert\",\"taskDeleted\":\"Aufgabe erfolgreich gelöscht\",\"userCreated\":\"Benutzer erfolgreich erstellt\",\"userUpdated\":\"Benutzer erfolgreich aktualisiert\",\"userDeleted\":\"Benutzer erfolgreich gelöscht\",\"tagCreated\":\"Tag erfolgreich erstellt\",\"tagUpdated\":\"Tag erfolgreich aktualisiert\",\"tagDeleted\":\"Tag erfolgreich gelöscht\"},\"deliverables\":{\"title\":\"Lieferobjekte\",\"statuses\":{\"PLANNING\":\"Planung\",\"IN_PROGRESS\":\"In Bearbeitung\",\"IN_REVIEW\":\"In Prüfung\",\"UAT\":\"UAT\",\"STAGED\":\"Staged\",\"PROD\":\"Prod\",\"DONE\":\"Erledigt\"}}}", + "created_by": null, + "created_at": "2026-03-08T13:03:36.206Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.206Z" + }, + { + "id": 1, + "language_code": "en", + "translations": "{\"common\":{\"loading\":\"Loading...\",\"error\":\"Error\",\"success\":\"Success\",\"cancel\":\"Cancel\",\"save\":\"Save\",\"delete\":\"Delete\",\"edit\":\"Edit\",\"add\":\"Add\",\"close\":\"Close\",\"back\":\"Back\",\"next\":\"Next\",\"previous\":\"Previous\",\"submit\":\"Submit\",\"confirm\":\"Confirm\",\"yes\":\"Yes\",\"no\":\"No\",\"ok\":\"OK\",\"search\":\"Search\",\"filter\":\"Filter\",\"sort\":\"Sort\",\"refresh\":\"Refresh\",\"settings\":\"Settings\",\"profile\":\"Profile\",\"logout\":\"Logout\",\"login\":\"Login\",\"register\":\"Register\",\"accessTokenRequired\":\"Access Token Required\",\"pleaseSetAccessToken\":\"Please set your access token using the menu in the top left to view projects.\",\"setAccessToken\":\"Set Access Token\",\"accessToken\":\"Access Token\",\"enterAccessToken\":\"Enter your access token\",\"tokenSaved\":\"Access token saved successfully\",\"actions\":\"Actions\",\"keyboardShortcut\":\"Keyboard shortcut\",\"confirmDelete\":\"Are you sure you want to delete this item? This action cannot be undone.\"},\"navigation\":{\"home\":\"Home\",\"projects\":\"Projects\",\"tasks\":\"Tasks\",\"kanban\":\"Kanban Board\",\"dashboard\":\"Dashboard\",\"reports\":\"Reports\"},\"projects\":{\"title\":\"Projects\",\"newProject\":\"New Project\",\"projectName\":\"Project Name\",\"projectCode\":\"Project Code\",\"description\":\"Description\",\"leader\":\"Project Leader\",\"leaderName\":\"Leader Name\",\"createdAt\":\"Created\",\"updatedAt\":\"Updated\",\"actions\":\"Actions\",\"noProjects\":\"No projects found\",\"createFirstProject\":\"Create your first project to get started\",\"createProject\":\"Create Project\",\"editProject\":\"Edit Project\",\"deleteProject\":\"Delete Project\",\"projectDetails\":\"Project Details\",\"statusWorkflow\":{\"title\":\"Status Workflow\",\"selected\":\"Selected Status Workflow\",\"available\":\"Available Statuses\",\"dragHelp\":\"Drag to reorder. These statuses will appear as columns in your Kanban board.\",\"viewHelp\":\"These statuses will appear as columns in your Kanban board.\",\"addHelp\":\"Click \\\"Add\\\" to include a status in your workflow.\"}},\"tasks\":{\"title\":\"Title\",\"newTask\":\"New Task\",\"taskId\":\"Task ID\",\"prompt\":\"Prompt\",\"status\":\"Status\",\"priority\":\"Priority\",\"assignee\":\"Assignee\",\"assigneeName\":\"Assignee Name\",\"storyPoints\":\"Story Points\",\"isBlocked\":\"Blocked\",\"blockedReason\":\"Blocked Reason\",\"gitWorktree\":\"Git Worktree\",\"gitPullRequestUrl\":\"Pull Request URL\",\"startedAt\":\"Started\",\"completedAt\":\"Completed\",\"gitWorktreePlaceholder\":\"feature/task-123\",\"gitPullRequestUrlPlaceholder\":\"https://github.com/user/repo/pull/123\",\"selectAssignee\":\"Select assignee\",\"unassigned\":\"Unassigned\",\"blockedReasonPlaceholder\":\"Why is this task blocked?\",\"createdAt\":\"Created\",\"updatedAt\":\"Updated\",\"actions\":\"Actions\",\"noTasks\":\"No tasks found\",\"createFirstTask\":\"Create your first task to get started\",\"createTask\":\"Create Task\",\"editTask\":\"Edit Task\",\"deleteTask\":\"Delete Task\",\"taskDetails\":\"Task Details\",\"priorities\":{\"LOW\":\"Low\",\"MEDIUM\":\"Medium\",\"HIGH\":\"High\",\"CRITICAL\":\"Critical\"},\"statusDescriptions\":{\"TO_DO\":\"Tasks that are planned but not yet started\",\"IN_PROGRESS\":\"Tasks currently being worked on\",\"IN_REVIEW\":\"Tasks awaiting code review or approval\",\"DONE\":\"Completed tasks\",\"TESTING\":\"Tasks in testing phase\",\"AWAITING_APPROVAL\":\"Tasks waiting for stakeholder approval\",\"READY_FOR_DEPLOY\":\"Tasks ready to be deployed to production\",\"ICEBOX\":\"Tasks that are deprioritized or on hold\",\"READY\":\"Tasks that are ready to be started\",\"BACKLOG\":\"Tasks in the backlog awaiting prioritization\",\"TESTING_DEV\":\"Tasks being tested in development environment\",\"TESTING_STAGE\":\"Tasks being tested in staging environment\",\"PLANNING\":\"Tasks in planning phase\"},\"statuses\":{\"PENDING\":\"Pending\",\"TO_DO\":\"To Do\",\"IN_PROGRESS\":\"In Progress\",\"IN_REVIEW\":\"In Review\",\"DONE\":\"Done\",\"TESTING\":\"Testing\",\"AWAITING_APPROVAL\":\"Awaiting Approval\",\"READY_FOR_DEPLOY\":\"Ready for Deploy\",\"ICEBOX\":\"Icebox\",\"READY\":\"Ready To Start\",\"BACKLOG\":\"Backlog\",\"TESTING_DEV\":\"Testing (Dev)\",\"TESTING_STAGE\":\"Testing (Stage)\",\"PLANNING\":\"Planning\",\"QA\":\"QA\",\"COMPLETED\":\"Completed\"}},\"kanban\":{\"title\":\"Kanban Board\",\"toDo\":\"To Do\",\"inProgress\":\"In Progress\",\"inReview\":\"In Review\",\"done\":\"Done\",\"addTask\":\"Add Task\",\"noTasksInColumn\":\"No tasks in this column\",\"dragToReorder\":\"Drag to reorder tasks\",\"dropToMove\":\"Drop to move task\"},\"deliverables\":{\"title\":\"Deliverables\",\"createDeliverable\":\"Create Deliverable\",\"editDeliverable\":\"Edit Deliverable\",\"deleteDeliverable\":\"Delete Deliverable\",\"id\":\"ID\",\"name\":\"Name\",\"nameHint\":\"Enter deliverable name\",\"type\":\"Type\",\"selectType\":\"Select deliverable type\",\"status\":\"Status\",\"description\":\"Description\",\"descriptionHint\":\"Enter deliverable description\",\"specPath\":\"SPEC Path\",\"planPath\":\"Plan Path\",\"specFilepath\":\"SPEC Path\",\"planFilepath\":\"Plan File Path\",\"gitWorktree\":\"Git Worktree\",\"gitBranch\":\"Git Branch\",\"pullRequestUrl\":\"Pull Request URL\",\"approvedBy\":\"Approved By\",\"updated\":\"Updated\",\"tasks\":\"Tasks\",\"noDeliverables\":\"No deliverables in this column\",\"statuses\":{\"PLANNING\":\"Planning\",\"IN_PROGRESS\":\"In Progress\",\"IN_REVIEW\":\"In Review\",\"UAT\":\"UAT\",\"STAGED\":\"Staged\",\"PROD\":\"Prod\",\"DONE\":\"Done\"},\"types\":{\"FEATURE\":\"Feature\",\"BUG_FIX\":\"Bug Fix\",\"REFACTOR\":\"Refactor\",\"ENHANCEMENT\":\"Enhancement\",\"CHORE\":\"Chore\",\"DOCUMENTATION\":\"Documentation\"}},\"users\":{\"title\":\"Users\",\"fullName\":\"Full Name\",\"email\":\"Email\",\"createdAt\":\"Created\",\"updatedAt\":\"Updated\",\"actions\":\"Actions\",\"noUsers\":\"No users found\"},\"tags\":{\"title\":\"Tags\",\"tagName\":\"Tag Name\",\"color\":\"Color\",\"createdAt\":\"Created\",\"actions\":\"Actions\",\"noTags\":\"No tags found\",\"createFirstTag\":\"Create your first tag to get started\",\"selectOrCreate\":\"Select or create tags\",\"commaSeparated\":\"Tags (comma separated)\",\"commaSeparatedDescription\":\"Enter tags separated by commas (e.g. frontend, bug, high-priority)\"},\"forms\":{\"required\":\"This field is required\",\"invalidEmail\":\"Please enter a valid email address\",\"minLength\":\"Must be at least {{min}} characters\",\"maxLength\":\"Must be no more than {{max}} characters\",\"invalidFormat\":\"Invalid format\"},\"notifications\":{\"projectCreated\":\"Project created successfully\",\"projectUpdated\":\"Project updated successfully\",\"projectDeleted\":\"Project deleted successfully\",\"taskCreated\":\"Task created successfully\",\"taskUpdated\":\"Task updated successfully\",\"taskDeleted\":\"Task deleted successfully\",\"userCreated\":\"User created successfully\",\"userUpdated\":\"User updated successfully\",\"userDeleted\":\"User deleted successfully\",\"tagCreated\":\"Tag created successfully\",\"tagUpdated\":\"Tag updated successfully\",\"tagDeleted\":\"Tag deleted successfully\"}}", + "created_by": null, + "created_at": "2026-03-08T13:03:36.206Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.206Z" + }, + { + "id": 2, + "language_code": "es", + "translations": "{\"common\":{\"loading\":\"Cargando...\",\"error\":\"Error\",\"success\":\"Éxito\",\"cancel\":\"Cancelar\",\"save\":\"Guardar\",\"delete\":\"Eliminar\",\"edit\":\"Editar\",\"add\":\"Agregar\",\"close\":\"Cerrar\",\"back\":\"Atrás\",\"next\":\"Siguiente\",\"previous\":\"Anterior\",\"submit\":\"Enviar\",\"confirm\":\"Confirmar\",\"yes\":\"Sí\",\"no\":\"No\",\"ok\":\"OK\",\"search\":\"Buscar\",\"filter\":\"Filtrar\",\"sort\":\"Ordenar\",\"refresh\":\"Actualizar\",\"settings\":\"Configuración\",\"profile\":\"Perfil\",\"logout\":\"Cerrar sesión\",\"login\":\"Iniciar sesión\",\"register\":\"Registrarse\",\"accessTokenRequired\":\"Token de Acceso Requerido\",\"pleaseSetAccessToken\":\"Por favor configure su token de acceso usando el menú en la esquina superior izquierda para ver proyectos.\",\"setAccessToken\":\"Configurar Token de Acceso\",\"accessToken\":\"Token de Acceso\",\"enterAccessToken\":\"Ingrese su token de acceso\",\"tokenSaved\":\"Token de acceso guardado exitosamente\",\"actions\":\"Acciones\",\"keyboardShortcut\":\"Atajo de teclado\"},\"navigation\":{\"home\":\"Inicio\",\"projects\":\"Proyectos\",\"tasks\":\"Tareas\",\"kanban\":\"Tablero Kanban\",\"dashboard\":\"Panel\",\"reports\":\"Reportes\"},\"projects\":{\"title\":\"Proyectos\",\"newProject\":\"Nuevo Proyecto\",\"projectName\":\"Nombre del Proyecto\",\"projectCode\":\"Código del Proyecto\",\"description\":\"Descripción\",\"leader\":\"Líder del Proyecto\",\"leaderName\":\"Nombre del Líder\",\"createdAt\":\"Creado\",\"updatedAt\":\"Actualizado\",\"actions\":\"Acciones\",\"noProjects\":\"No se encontraron proyectos\",\"createFirstProject\":\"Crea tu primer proyecto para comenzar\",\"createProject\":\"Crear Proyecto\",\"editProject\":\"Editar Proyecto\",\"deleteProject\":\"Eliminar Proyecto\",\"projectDetails\":\"Detalles del Proyecto\",\"statusWorkflow\":{\"title\":\"Flujo de Trabajo de Estados\",\"selected\":\"Flujo de Trabajo Seleccionado\",\"available\":\"Estados Disponibles\",\"dragHelp\":\"Arrastra para reordenar. Estos estados aparecerán como columnas en tu tablero Kanban.\",\"viewHelp\":\"Estos estados aparecerán como columnas en tu tablero Kanban.\",\"addHelp\":\"Haz clic en \\\"Agregar\\\" para incluir un estado en tu flujo de trabajo.\"}},\"tasks\":{\"title\":\"Título\",\"newTask\":\"Nueva Tarea\",\"taskId\":\"ID de Tarea\",\"prompt\":\"Prompt\",\"status\":\"Estado\",\"priority\":\"Prioridad\",\"assignee\":\"Asignado\",\"assigneeName\":\"Nombre del Asignado\",\"storyPoints\":\"Puntos de Historia\",\"gitWorktree\":\"Git Worktree\",\"gitWorktreePlaceholder\":\"feature/tarea-123\",\"gitPullRequestUrl\":\"URL de Pull Request\",\"gitPullRequestUrlPlaceholder\":\"https://github.com/usuario/repo/pull/123\",\"isBlocked\":\"Bloqueado\",\"blockedReason\":\"Razón del Bloqueo\",\"startedAt\":\"Iniciado\",\"completedAt\":\"Completado\",\"createdAt\":\"Creado\",\"updatedAt\":\"Actualizado\",\"actions\":\"Acciones\",\"noTasks\":\"No se encontraron tareas\",\"createFirstTask\":\"Crea tu primera tarea para comenzar\",\"createTask\":\"Crear Tarea\",\"editTask\":\"Editar Tarea\",\"deleteTask\":\"Eliminar Tarea\",\"taskDetails\":\"Detalles de la Tarea\",\"priorities\":{\"LOW\":\"Baja\",\"MEDIUM\":\"Media\",\"HIGH\":\"Alta\",\"CRITICAL\":\"Crítica\"},\"statusDescriptions\":{\"TO_DO\":\"Tareas planificadas pero no iniciadas\",\"IN_PROGRESS\":\"Tareas en las que se está trabajando actualmente\",\"IN_REVIEW\":\"Tareas en espera de revisión de código o aprobación\",\"DONE\":\"Tareas completadas\",\"TESTING\":\"Tareas en fase de pruebas\",\"AWAITING_APPROVAL\":\"Tareas en espera de aprobación de interesados\",\"READY_FOR_DEPLOY\":\"Tareas listas para desplegarse en producción\",\"ICEBOX\":\"Tareas despriorizadas o en espera\",\"READY\":\"Tareas listas para comenzar\",\"BACKLOG\":\"Tareas en backlog esperando priorización\",\"TESTING_DEV\":\"Tareas siendo probadas en entorno de desarrollo\",\"TESTING_STAGE\":\"Tareas siendo probadas en entorno de staging\",\"PLANNING\":\"Tareas en fase de planificación\"},\"statuses\":{\"PENDING\":\"Pendiente\",\"TO_DO\":\"Por Hacer\",\"IN_PROGRESS\":\"En Progreso\",\"IN_REVIEW\":\"En Revisión\",\"DONE\":\"Completado\",\"TESTING\":\"Pruebas\",\"AWAITING_APPROVAL\":\"En Espera de Aprobación\",\"READY_FOR_DEPLOY\":\"Listo para Desplegar\",\"ICEBOX\":\"Congelador\",\"READY\":\"Listo Para Comenzar\",\"BACKLOG\":\"Backlog\",\"TESTING_DEV\":\"Pruebas (Dev)\",\"TESTING_STAGE\":\"Pruebas (Stage)\",\"PLANNING\":\"Planificación\",\"QA\":\"QA\",\"COMPLETED\":\"Completado\"}},\"kanban\":{\"title\":\"Tablero Kanban\",\"toDo\":\"Por Hacer\",\"inProgress\":\"En Progreso\",\"inReview\":\"En Revisión\",\"done\":\"Completado\",\"addTask\":\"Agregar Tarea\",\"noTasksInColumn\":\"No hay tareas en esta columna\",\"dragToReorder\":\"Arrastra para reordenar tareas\",\"dropToMove\":\"Suelta para mover tarea\"},\"users\":{\"title\":\"Usuarios\",\"fullName\":\"Nombre Completo\",\"email\":\"Correo Electrónico\",\"createdAt\":\"Creado\",\"updatedAt\":\"Actualizado\",\"actions\":\"Acciones\",\"noUsers\":\"No se encontraron usuarios\"},\"tags\":{\"title\":\"Etiquetas\",\"tagName\":\"Nombre de Etiqueta\",\"color\":\"Color\",\"createdAt\":\"Creado\",\"actions\":\"Acciones\",\"noTags\":\"No se encontraron etiquetas\",\"createFirstTag\":\"Crea tu primera etiqueta para comenzar\"},\"forms\":{\"required\":\"Este campo es requerido\",\"invalidEmail\":\"Por favor ingresa un correo electrónico válido\",\"minLength\":\"Debe tener al menos {{min}} caracteres\",\"maxLength\":\"No debe tener más de {{max}} caracteres\",\"invalidFormat\":\"Formato inválido\"},\"notifications\":{\"projectCreated\":\"Proyecto creado exitosamente\",\"projectUpdated\":\"Proyecto actualizado exitosamente\",\"projectDeleted\":\"Proyecto eliminado exitosamente\",\"taskCreated\":\"Tarea creada exitosamente\",\"taskUpdated\":\"Tarea actualizada exitosamente\",\"taskDeleted\":\"Tarea eliminada exitosamente\",\"userCreated\":\"Usuario creado exitosamente\",\"userUpdated\":\"Usuario actualizado exitosamente\",\"userDeleted\":\"Usuario eliminado exitosamente\",\"tagCreated\":\"Etiqueta creada exitosamente\",\"tagUpdated\":\"Etiqueta actualizada exitosamente\",\"tagDeleted\":\"Etiqueta eliminada exitosamente\"},\"deliverables\":{\"title\":\"Entregables\",\"statuses\":{\"PLANNING\":\"Planificación\",\"IN_PROGRESS\":\"En Progreso\",\"IN_REVIEW\":\"En Revisión\",\"UAT\":\"UAT\",\"STAGED\":\"Staged\",\"PROD\":\"Prod\",\"DONE\":\"Hecho\"}}}", + "created_by": null, + "created_at": "2026-03-08T13:03:36.206Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.206Z" + }, + { + "id": 3, + "language_code": "fr", + "translations": "{\"common\":{\"loading\":\"Chargement...\",\"error\":\"Erreur\",\"success\":\"Succès\",\"cancel\":\"Annuler\",\"save\":\"Enregistrer\",\"delete\":\"Supprimer\",\"edit\":\"Modifier\",\"add\":\"Ajouter\",\"close\":\"Fermer\",\"back\":\"Retour\",\"next\":\"Suivant\",\"previous\":\"Précédent\",\"submit\":\"Soumettre\",\"confirm\":\"Confirmer\",\"yes\":\"Oui\",\"no\":\"Non\",\"ok\":\"OK\",\"search\":\"Rechercher\",\"filter\":\"Filtrer\",\"sort\":\"Trier\",\"refresh\":\"Actualiser\",\"settings\":\"Paramètres\",\"profile\":\"Profil\",\"logout\":\"Déconnexion\",\"login\":\"Connexion\",\"register\":\"S'inscrire\",\"accessTokenRequired\":\"Token d'Accès Requis\",\"pleaseSetAccessToken\":\"Veuillez définir votre token d'accès en utilisant le menu en haut à gauche pour voir les projets.\",\"setAccessToken\":\"Définir le Token d'Accès\",\"accessToken\":\"Token d'Accès\",\"enterAccessToken\":\"Entrez votre token d'accès\",\"tokenSaved\":\"Token d'accès enregistré avec succès\",\"actions\":\"Actions\",\"keyboardShortcut\":\"Raccourci clavier\"},\"navigation\":{\"home\":\"Accueil\",\"projects\":\"Projets\",\"tasks\":\"Tâches\",\"kanban\":\"Tableau Kanban\",\"dashboard\":\"Tableau de bord\",\"reports\":\"Rapports\"},\"projects\":{\"title\":\"Projets\",\"newProject\":\"Nouveau Projet\",\"projectName\":\"Nom du Projet\",\"projectCode\":\"Code du Projet\",\"description\":\"Description\",\"leader\":\"Chef de Projet\",\"leaderName\":\"Nom du Chef\",\"createdAt\":\"Créé\",\"updatedAt\":\"Mis à jour\",\"actions\":\"Actions\",\"noProjects\":\"Aucun projet trouvé\",\"createFirstProject\":\"Créez votre premier projet pour commencer\",\"createProject\":\"Créer un Projet\",\"editProject\":\"Modifier le Projet\",\"deleteProject\":\"Supprimer le Projet\",\"projectDetails\":\"Détails du Projet\",\"statusWorkflow\":{\"title\":\"Flux de Travail des Statuts\",\"selected\":\"Flux de Travail Sélectionné\",\"available\":\"Statuts Disponibles\",\"dragHelp\":\"Glissez pour réorganiser. Ces statuts apparaîtront sous forme de colonnes dans votre tableau Kanban.\",\"viewHelp\":\"Ces statuts apparaîtront sous forme de colonnes dans votre tableau Kanban.\",\"addHelp\":\"Cliquez sur \\\"Ajouter\\\" pour inclure un statut dans votre flux de travail.\"}},\"tasks\":{\"title\":\"Titre\",\"newTask\":\"Nouvelle Tâche\",\"taskId\":\"ID de Tâche\",\"prompt\":\"Prompt\",\"status\":\"Statut\",\"priority\":\"Priorité\",\"assignee\":\"Assigné\",\"assigneeName\":\"Nom de l'Assigné\",\"storyPoints\":\"Points de Story\",\"gitWorktree\":\"Git Worktree\",\"gitWorktreePlaceholder\":\"feature/tache-123\",\"gitPullRequestUrl\":\"URL du Pull Request\",\"gitPullRequestUrlPlaceholder\":\"https://github.com/utilisateur/repo/pull/123\",\"isBlocked\":\"Bloqué\",\"blockedReason\":\"Raison du Blocage\",\"startedAt\":\"Démarré\",\"completedAt\":\"Terminé\",\"createdAt\":\"Créé\",\"updatedAt\":\"Mis à jour\",\"actions\":\"Actions\",\"noTasks\":\"Aucune tâche trouvée\",\"createFirstTask\":\"Créez votre première tâche pour commencer\",\"createTask\":\"Créer une Tâche\",\"editTask\":\"Modifier la Tâche\",\"deleteTask\":\"Supprimer la Tâche\",\"taskDetails\":\"Détails de la Tâche\",\"priorities\":{\"LOW\":\"Faible\",\"MEDIUM\":\"Moyenne\",\"HIGH\":\"Élevée\",\"CRITICAL\":\"Critique\"},\"statusDescriptions\":{\"TO_DO\":\"Tâches planifiées mais pas encore commencées\",\"IN_PROGRESS\":\"Tâches en cours de traitement\",\"IN_REVIEW\":\"Tâches en attente de révision de code ou d'approbation\",\"DONE\":\"Tâches terminées\",\"TESTING\":\"Tâches en phase de test\",\"AWAITING_APPROVAL\":\"Tâches en attente d'approbation des parties prenantes\",\"READY_FOR_DEPLOY\":\"Tâches prêtes à être déployées en production\",\"ICEBOX\":\"Tâches déprioritarisées ou en attente\",\"READY\":\"Tâches prêtes à être commencées\",\"BACKLOG\":\"Tâches dans le backlog en attente de priorisation\",\"TESTING_DEV\":\"Tâches testées dans l'environnement de développement\",\"TESTING_STAGE\":\"Tâches testées dans l'environnement de staging\",\"PLANNING\":\"Tâches en phase de planification\"},\"statuses\":{\"PENDING\":\"En attente\",\"TO_DO\":\"À Faire\",\"IN_PROGRESS\":\"En Cours\",\"IN_REVIEW\":\"En Révision\",\"DONE\":\"Terminé\",\"TESTING\":\"Tests\",\"AWAITING_APPROVAL\":\"En Attente d'Approbation\",\"READY_FOR_DEPLOY\":\"Prêt pour le Déploiement\",\"ICEBOX\":\"Frigo\",\"READY\":\"Prêt à Commencer\",\"BACKLOG\":\"Backlog\",\"TESTING_DEV\":\"Tests (Dev)\",\"TESTING_STAGE\":\"Tests (Stage)\",\"PLANNING\":\"Planification\",\"QA\":\"QA\",\"COMPLETED\":\"Terminé\"}},\"kanban\":{\"title\":\"Tableau Kanban\",\"toDo\":\"À Faire\",\"inProgress\":\"En Cours\",\"inReview\":\"En Révision\",\"done\":\"Terminé\",\"addTask\":\"Ajouter une Tâche\",\"noTasksInColumn\":\"Aucune tâche dans cette colonne\",\"dragToReorder\":\"Glissez pour réorganiser les tâches\",\"dropToMove\":\"Déposez pour déplacer la tâche\"},\"users\":{\"title\":\"Utilisateurs\",\"fullName\":\"Nom Complet\",\"email\":\"E-mail\",\"createdAt\":\"Créé\",\"updatedAt\":\"Mis à jour\",\"actions\":\"Actions\",\"noUsers\":\"Aucun utilisateur trouvé\"},\"tags\":{\"title\":\"Étiquettes\",\"tagName\":\"Nom de l'Étiquette\",\"color\":\"Couleur\",\"createdAt\":\"Créé\",\"actions\":\"Actions\",\"noTags\":\"Aucune étiquette trouvée\",\"createFirstTag\":\"Créez votre première étiquette pour commencer\"},\"forms\":{\"required\":\"Ce champ est requis\",\"invalidEmail\":\"Veuillez entrer une adresse e-mail valide\",\"minLength\":\"Doit contenir au moins {{min}} caractères\",\"maxLength\":\"Ne doit pas dépasser {{max}} caractères\",\"invalidFormat\":\"Format invalide\"},\"notifications\":{\"projectCreated\":\"Projet créé avec succès\",\"projectUpdated\":\"Projet mis à jour avec succès\",\"projectDeleted\":\"Projet supprimé avec succès\",\"taskCreated\":\"Tâche créée avec succès\",\"taskUpdated\":\"Tâche mise à jour avec succès\",\"taskDeleted\":\"Tâche supprimée avec succès\",\"userCreated\":\"Utilisateur créé avec succès\",\"userUpdated\":\"Utilisateur mis à jour avec succès\",\"userDeleted\":\"Utilisateur supprimé avec succès\",\"tagCreated\":\"Étiquette créée avec succès\",\"tagUpdated\":\"Étiquette mise à jour avec succès\",\"tagDeleted\":\"Étiquette supprimée avec succès\"},\"deliverables\":{\"title\":\"Livrables\",\"statuses\":{\"PLANNING\":\"Planification\",\"IN_PROGRESS\":\"En cours\",\"IN_REVIEW\":\"En revue\",\"UAT\":\"UAT\",\"STAGED\":\"Staged\",\"PROD\":\"Prod\",\"DONE\":\"Terminé\"}}}", + "created_by": null, + "created_at": "2026-03-08T13:03:36.206Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.206Z" + } + ], + "tags": [ + { + "tag": "backend", + "color": "#10B981", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "bug-fix", + "color": "#F97316", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "database", + "color": "#6366F1", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "feature", + "color": "#8B5CF6", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "frontend", + "color": "#3B82F6", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "performance", + "color": "#84CC16", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "security", + "color": "#F59E0B", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "testing", + "color": "#06B6D4", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "ui-ux", + "color": "#EC4899", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "urgent", + "color": "#EF4444", + "created_at": "2026-03-08T13:03:36.203Z" + } + ], + "projects": [ + { + "id": 1, + "title": "Zazz Board", + "code": "ZAZZ", + "description": "Zazz-Board application development — primary test project", + "leader_id": 5, + "next_deliverable_sequence": 7, + "status_workflow": [ + "READY", + "IN_PROGRESS", + "QA", + "COMPLETED" + ], + "deliverable_status_workflow": [ + "PLANNING", + "IN_PROGRESS", + "IN_REVIEW", + "STAGED", + "DONE" + ], + "completion_criteria_status": "COMPLETED", + "task_graph_layout_direction": "LR", + "created_by": 5, + "created_at": "2026-03-04T23:01:41.016Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.209Z" + }, + { + "id": 2, + "title": "Zed Mermaid", + "code": "ZED_MER", + "description": "Product-only project for real deliverable creation", + "leader_id": 2, + "next_deliverable_sequence": 1, + "status_workflow": [ + "READY", + "IN_PROGRESS", + "QA", + "COMPLETED" + ], + "deliverable_status_workflow": [ + "PLANNING", + "IN_PROGRESS", + "IN_REVIEW", + "STAGED", + "DONE" + ], + "completion_criteria_status": "COMPLETED", + "task_graph_layout_direction": "LR", + "created_by": 2, + "created_at": "2026-03-04T23:01:41.016Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.209Z" + } + ], + "agent_tokens": [ + { + "id": 1, + "user_id": 5, + "project_id": 1, + "token": "660e8400-e29b-41d4-a716-446655440101", + "label": "For all agents access token", + "created_at": "2026-03-08T23:20:02.959Z" + }, + { + "id": 2, + "user_id": 5, + "project_id": 1, + "token": "660e8400-e29b-41d4-a716-446655440102", + "label": "Spec builder agent ONLY access token", + "created_at": "2026-03-08T23:20:02.959Z" + }, + { + "id": 3, + "user_id": 2, + "project_id": 2, + "token": "660e8400-e29b-41d4-a716-446655440103", + "label": "For all agents access token", + "created_at": "2026-03-08T23:20:02.959Z" + }, + { + "id": 4, + "user_id": 2, + "project_id": 2, + "token": "660e8400-e29b-41d4-a716-446655440104", + "label": "Spec builder agent ONLY access token", + "created_at": "2026-03-08T23:20:02.959Z" + }, + { + "id": 5, + "user_id": 3, + "project_id": 2, + "token": "660e8400-e29b-41d4-a716-446655440105", + "label": "For all agents access token", + "created_at": "2026-03-08T23:20:02.959Z" + }, + { + "id": 6, + "user_id": 3, + "project_id": 2, + "token": "660e8400-e29b-41d4-a716-446655440106", + "label": "Spec builder agent ONLY access token", + "created_at": "2026-03-08T23:20:02.959Z" + } + ], + "deliverables": [ + { + "id": 1, + "project_id": 1, + "project_code": "ZAZZ", + "code": "ZAZZ-1", + "name": "Deliverables Feature", + "description": "Add deliverable entity, Kanban board, and task graph swim lanes", + "type": "FEATURE", + "status": "IN_REVIEW", + "status_history": [ + { + "status": "PLANNING", + "changedAt": "2026-01-15T10:00:00Z", + "changedBy": 5 + }, + { + "status": "IN_PROGRESS", + "changedAt": "2026-01-20T14:30:00Z", + "changedBy": 5 + }, + { + "status": "IN_REVIEW", + "changedAt": "2026-03-08T20:00:44.438Z", + "changedBy": 5 + } + ], + "spec_filepath": ".zazz/deliverables/deliverables-feature-SPEC.md", + "plan_filepath": ".zazz/deliverables/deliverables-feature-PLAN.md", + "approved_by": 5, + "approved_at": "2026-01-20T14:30:00.000Z", + "git_worktree": "deliverables-mvp", + "git_branch": "deliverables-mvp", + "pull_request_url": "", + "position": 10, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.016Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:00:44.438Z" + }, + { + "id": 2, + "project_id": 1, + "project_code": "ZAZZ", + "code": "ZAZZ-3", + "name": "Fix Tag Validation Bug", + "description": "Tags with trailing hyphens bypass API validation", + "type": "BUG_FIX", + "status": "IN_REVIEW", + "status_history": [ + { + "status": "PLANNING", + "changedAt": "2026-02-01T10:00:00Z", + "changedBy": 5 + }, + { + "status": "IN_PROGRESS", + "changedAt": "2026-02-02T09:00:00Z", + "changedBy": 5 + }, + { + "status": "IN_REVIEW", + "changedAt": "2026-02-05T16:00:00Z", + "changedBy": null + } + ], + "spec_filepath": "docs/fix-tag-validation-SPEC.md", + "plan_filepath": "docs/fix-tag-validation-plan.md", + "approved_by": 5, + "approved_at": "2026-02-02T09:00:00.000Z", + "git_worktree": "fix-tag-validation", + "git_branch": "fix-tag-validation", + "pull_request_url": "https://github.com/zazzcode/zazz-board/pull/12", + "position": 30, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.016Z", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.016Z" + }, + { + "id": 3, + "project_id": 1, + "project_code": "ZAZZ", + "code": "ZAZZ-5", + "name": "fix-routes-no-project", + "description": null, + "type": "REFACTOR", + "status": "IN_REVIEW", + "status_history": [ + { + "status": "PLANNING", + "changedAt": "2026-03-04T23:18:28.449Z", + "changedBy": 5 + }, + { + "status": "IN_PROGRESS", + "changedAt": "2026-03-07T01:41:53.962Z", + "changedBy": 5 + }, + { + "status": "IN_REVIEW", + "changedAt": "2026-03-07T01:41:58.207Z", + "changedBy": 5 + } + ], + "spec_filepath": ".zazz/deliverables/ZAZZ-5-fix-routes-no-project-SPEC.md", + "plan_filepath": ".zazz/deliverables/ZAZZ-5-fix-routes-no-project-PLAN.md", + "approved_by": 5, + "approved_at": "2026-03-07T00:41:56.710Z", + "git_worktree": null, + "git_branch": null, + "pull_request_url": null, + "position": 50, + "created_by": 5, + "created_at": "2026-03-04T23:18:28.446Z", + "updated_by": 5, + "updated_at": "2026-03-07T01:41:58.207Z" + }, + { + "id": 4, + "project_id": 1, + "project_code": "ZAZZ", + "code": "ZAZZ-6", + "name": "multiple-agent-tokens-feature", + "description": "Zazz Board currently uses a single token per user (`USERS.access_token`). Both human users and AI agents share that token. This creates several problems:\n\n- **No project isolation**: An agent token can access any project the user can access. There is no way to scope an agent to a specific project.\n- **No multi-agent separation**: Multiple agents (planner, worker, QA, etc.) for the same user must share one token; there is no way to distinguish or revoke individual agent credentials.\n- **Audit and security**: Revoking one agent's access requires changing the user's token, which affects all agents and the human user.\n\n**Desired state**: Each user can create multiple **agent tokens**, each tied to a specific project. Agent tokens are scoped to **both user and project**—a token belongs to one user and is authorized for one project only (token for user A + project X cannot access project Y). The user's **user token** (existing `USERS.access_token`) remains for human use and grants full access to all projects the user can access. Agent tokens are stored separately and managed per-project.", + "type": "FEATURE", + "status": "IN_PROGRESS", + "status_history": [ + { + "status": "PLANNING", + "changedAt": "2026-03-05T00:27:06.308Z", + "changedBy": 5 + }, + { + "status": "IN_PROGRESS", + "changedAt": "2026-03-08T20:29:48.822Z", + "changedBy": 5 + } + ], + "spec_filepath": ".zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-SPEC.md", + "plan_filepath": ".zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-PLAN.md", + "approved_by": 5, + "approved_at": "2026-03-08T20:29:42.600Z", + "git_worktree": "multiple-agent-tokens-feature", + "git_branch": "multiple-agent-tokens-feature", + "pull_request_url": "", + "position": 60, + "created_by": 5, + "created_at": "2026-03-05T00:27:06.304Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:29:48.822Z" + } + ], + "tasks": [ + { + "id": 1, + "project_id": 1, + "deliverable_id": 1, + "phase": 1, + "phase_step": "1.1", + "title": "ZAZZ-1: Foundation completed (schema + API read paths)", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 10, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 2, + "project_id": 1, + "deliverable_id": 1, + "phase": 2, + "phase_step": "2.1", + "title": "ZAZZ-1: Remaining work (UI polish + edge cases)", + "status": "READY", + "priority": "MEDIUM", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 20, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": "fix-all-the proiblems", + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 3, + "project_id": 1, + "deliverable_id": 2, + "phase": 1, + "phase_step": "1.1", + "title": "ZAZZ-3: Reproduce bug and capture failing cases", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 10, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 4, + "project_id": 1, + "deliverable_id": 2, + "phase": 1, + "phase_step": "1.2", + "title": "ZAZZ-3: Add regression tests for invalid tag formats", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 20, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 5, + "project_id": 1, + "deliverable_id": 2, + "phase": 1, + "phase_step": "1.3", + "title": "ZAZZ-3: Confirm API validation contract + error messaging", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 30, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 6, + "project_id": 1, + "deliverable_id": 2, + "phase": 2, + "phase_step": "2.1", + "title": "ZAZZ-3: Fix validation for trailing hyphen and edge cases", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 40, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 7, + "project_id": 1, + "deliverable_id": 2, + "phase": 2, + "phase_step": "2.2", + "title": "ZAZZ-3: Add server-side canonicalization (lowercase + hyphens)", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 50, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 8, + "project_id": 1, + "deliverable_id": 2, + "phase": 2, + "phase_step": "2.3", + "title": "ZAZZ-3: Ensure tag creation/upsert handles collisions", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 60, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 9, + "project_id": 1, + "deliverable_id": 2, + "phase": 3, + "phase_step": "3.1", + "title": "ZAZZ-3: QA run (API + UI) for tag flows", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 70, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 10, + "project_id": 1, + "deliverable_id": 2, + "phase": 3, + "phase_step": "3.2", + "title": "ZAZZ-3: Address review feedback / small refactor", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 80, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 11, + "project_id": 1, + "deliverable_id": 2, + "phase": 3, + "phase_step": "3.3", + "title": "ZAZZ-3: Final sign-off checklist", + "status": "COMPLETED", + "priority": "LOW", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 90, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 12, + "project_id": 1, + "deliverable_id": 3, + "phase": 1, + "phase_step": "1.1", + "title": "CODEX 1.1: Test Harness Cleanup for Image Routes", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Goal: update API test helpers so image tables are cleaned per test and image route tests are isolated. Include helper updates in api/__tests__/helpers/testDatabase.js and ensure no cross-test image state bleed. Acceptance: image fixtures can be created/deleted repeatedly in tests without leakage.", + "notes": null, + "story_points": null, + "position": 100, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:42:19.956Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:42:19.956Z" + }, + { + "id": 13, + "project_id": 1, + "deliverable_id": 3, + "phase": 1, + "phase_step": "1.2", + "title": "CODEX 1.2: Remove Project-Wide Graph Endpoint + Add Scoping Tests", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Goal: remove GET /projects/:code/graph route and schema, keep deliverable graph route intact, and add dedicated route tests proving removed path is 404 and deliverable path still works. Acceptance: task-graph-scoping tests pass.", + "notes": null, + "story_points": null, + "position": 110, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:42:19.983Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:42:19.983Z" + }, + { + "id": 14, + "project_id": 1, + "deliverable_id": 3, + "phase": 1, + "phase_step": "1.3", + "title": "CODEX 1.3: Deliverable-Only Graph UI (No Null Fetch)", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Goal: remove project-wide graph mode in client. Update App selector to deliverables-only, update useTaskGraph to skip fetch when deliverableId is null, and show explicit prompt in TaskGraphPage before selection. Acceptance: no /projects/{code}/graph fetch path remains in UI.", + "notes": null, + "story_points": null, + "position": 120, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:47:40.365Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:47:40.365Z" + }, + { + "id": 15, + "project_id": 1, + "deliverable_id": 3, + "phase": 2, + "phase_step": "2.1", + "title": "CODEX 2.1: IMAGE_METADATA Single-Owner Schema Constraint", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Goal: update schema so IMAGE_METADATA supports task or deliverable ownership (task_id nullable, add deliverable_id nullable FK) with DB-level XOR constraint (exactly one owner set). Acceptance: schema enforces both-set and neither-set failures.", + "notes": null, + "story_points": null, + "position": 130, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:47:40.391Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:47:40.391Z" + }, + { + "id": 16, + "project_id": 1, + "deliverable_id": 3, + "phase": 2, + "phase_step": "2.2", + "title": "CODEX 2.2: Refactor Image Service for Project/Owner Validation", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Refactor databaseService image operations to support both task-owned and deliverable-owned images with project-scope ownership checks and scoped URL handling.", + "notes": null, + "story_points": null, + "position": 140, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:56:26.287Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:56:26.287Z" + }, + { + "id": 17, + "project_id": 1, + "deliverable_id": 3, + "phase": 2, + "phase_step": "2.3", + "title": "CODEX 2.3: Replace Legacy Image Routes with Scoped Contracts", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Replace legacy /tasks/:taskId/images and /images/:id routes with project+deliverable scoped task routes, deliverable image routes, and project-scoped image fetch/metadata routes.", + "notes": null, + "story_points": null, + "position": 150, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:56:26.311Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:56:26.311Z" + }, + { + "id": 18, + "project_id": 1, + "deliverable_id": 3, + "phase": 2, + "phase_step": "2.4", + "title": "CODEX 2.4: Align API Metadata Text for Scoped Image Contract", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": "codex", + "prompt": "Update API root endpoint summaries/tag descriptions so docs no longer imply legacy global image routes and reflect project-scoped image operations.", + "notes": null, + "story_points": null, + "position": 170, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:57:52.344Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.344Z" + }, + { + "id": 19, + "project_id": 1, + "deliverable_id": 3, + "phase": 3, + "phase_step": "3.1", + "title": "CODEX 3.1: Add Image Scoping Integration Tests", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Add Pactum tests for scoped task/deliverable image routes, project-scoped image fetch/metadata, 401/403/404 behavior, legacy route 404s, and single-owner DB constraint checks.", + "notes": null, + "story_points": null, + "position": 210, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:57:52.365Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.365Z" + }, + { + "id": 20, + "project_id": 1, + "deliverable_id": 3, + "phase": 3, + "phase_step": "3.2", + "title": "CODEX 3.2: Regression Tests for Unchanged Project-ID Routes", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": "codex", + "prompt": "Add regression tests for /projects/:id, /projects/:id/kanban/tasks/column/:status, and /projects/:id/tasks to ensure these accepted id-based routes remain unchanged.", + "notes": null, + "story_points": null, + "position": 160, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:56:26.330Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:56:26.330Z" + }, + { + "id": 21, + "project_id": 1, + "deliverable_id": 3, + "phase": 3, + "phase_step": "3.3", + "title": "CODEX 3.3: Finalize Graph Removal Regression Tests", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": "codex", + "prompt": "Finalize task-graph scoping regression tests for removed project graph endpoint and preserved deliverable graph behavior.", + "notes": null, + "story_points": null, + "position": 180, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:57:52.383Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.383Z" + }, + { + "id": 22, + "project_id": 1, + "deliverable_id": 3, + "phase": 3, + "phase_step": "3.4", + "title": "CODEX 3.4: Harden OpenAPI Assertions for Graph/Image Contract", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Update openapi.test.mjs to assert removed /projects/{code}/graph and legacy image paths, and assert presence/shape of all new scoped image paths.", + "notes": null, + "story_points": null, + "position": 190, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:57:52.398Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.398Z" + }, + { + "id": 23, + "project_id": 1, + "deliverable_id": 3, + "phase": 3, + "phase_step": "3.5", + "title": "CODEX 3.5: Update API Skill Docs + Final Verification", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Update zazz-board-api skill/docs to new route contract, run final QA verification, and prepare commit-ready summary.", + "notes": null, + "story_points": null, + "position": 200, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:57:52.415Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.415Z" + }, + { + "id": 24, + "project_id": 1, + "deliverable_id": 3, + "phase": 4, + "phase_step": "4.1", + "title": "CODEX 4.1: Replace drizzle-orm symlink workaround", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": "codex", + "prompt": "Replace manual node_modules symlink workaround with a worktree-safe dependency solution and update setup/troubleshooting docs.", + "notes": null, + "story_points": null, + "position": 220, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T01:18:18.923Z", + "updated_by": 5, + "updated_at": "2026-03-07T01:24:36.852Z" + }, + { + "id": 25, + "project_id": 1, + "deliverable_id": 3, + "phase": 4, + "phase_step": "4.2", + "title": "CODEX 4.2: Persist Task Graph deliverable selection", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Persist the last selected deliverable for Task Graph per project. On reload, restore the saved deliverable if it still exists. Keep deliverable-only graph selector behavior.", + "notes": null, + "story_points": null, + "position": 230, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T01:30:52.983Z", + "updated_by": 5, + "updated_at": "2026-03-07T01:30:52.983Z" + }, + { + "id": 26, + "project_id": 1, + "deliverable_id": 3, + "phase": 4, + "phase_step": "4.3", + "title": "CODEX 4.3: Harden Task Graph selection restore timing", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Make Task Graph deliverable persistence deterministic across reload/project hydration timing by hydrating selection on project change and validating against current deliverables.", + "notes": null, + "story_points": null, + "position": 240, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T01:42:36.382Z", + "updated_by": 5, + "updated_at": "2026-03-07T01:42:36.382Z" + }, + { + "id": 27, + "project_id": 1, + "deliverable_id": 3, + "phase": 4, + "phase_step": "4.4", + "title": "CODEX 4.4: Keep completed tasks visible with green outline", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": "Ensure completed tasks are never treated as non-complete by graph styling logic. COMPLETED/DONE must always render with green outline.", + "notes": null, + "story_points": null, + "position": 250, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T01:48:43.273Z", + "updated_by": 5, + "updated_at": "2026-03-07T01:48:43.273Z" + }, + { + "id": 28, + "project_id": 1, + "deliverable_id": 3, + "phase": 4, + "phase_step": "4.5", + "title": "CODEX 4.5: Make API skill lifecycle rules explicit", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Update zazz-board-api skill to require explicit task status transitions, explicit deliverable status updates, and explicit DEPENDS_ON relation creation/verification.", + "notes": null, + "story_points": null, + "position": 290, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T01:53:38.530Z", + "updated_by": 5, + "updated_at": "2026-03-07T01:53:38.530Z" + }, + { + "id": 29, + "project_id": 1, + "deliverable_id": 3, + "phase": 5, + "phase_step": "5.1", + "title": "CODEX 5.1: Add SSE stream + status/relation event emits", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Implement project-scoped SSE endpoint and emit realtime events for task status/position/create/delete, deliverable status changes, and relation (DEPENDS_ON) updates.", + "notes": null, + "story_points": null, + "position": 260, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T02:08:51.638Z", + "updated_by": 5, + "updated_at": "2026-03-07T02:08:51.638Z" + }, + { + "id": 30, + "project_id": 1, + "deliverable_id": 3, + "phase": 5, + "phase_step": "5.2", + "title": "CODEX 5.2: Wire UI realtime subscriptions for Kanban + Graph", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Add client SSE subscription hook and use it to refresh Task Kanban columns, Task Graph node styling/edges, and Deliverable Kanban status colors immediately across clients.", + "notes": null, + "story_points": null, + "position": 270, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T02:08:51.664Z", + "updated_by": 5, + "updated_at": "2026-03-07T02:08:51.664Z" + }, + { + "id": 31, + "project_id": 1, + "deliverable_id": 3, + "phase": 5, + "phase_step": "5.3", + "title": "CODEX 5.3: Add SSE integration tests + regression checks", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Add API integration tests validating SSE events for task status changes, deliverable status changes, and DEPENDS_ON relation updates; run focused regression suite.", + "notes": null, + "story_points": null, + "position": 280, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T02:08:51.697Z", + "updated_by": 5, + "updated_at": "2026-03-07T02:08:51.697Z" + }, + { + "id": 32, + "project_id": 1, + "deliverable_id": 3, + "phase": null, + "phase_step": null, + "title": "Audit routes for project filter", + "status": "READY", + "priority": "MEDIUM", + "agent_name": null, + "prompt": "Read the route information and discuss discover which routes do not filter by project.", + "notes": null, + "story_points": null, + "position": 10, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:21:40.553Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:21:40.553Z" + }, + { + "id": 33, + "project_id": 1, + "deliverable_id": 4, + "phase": 4, + "phase_step": "4.1", + "title": "Add project-row manage-agent-tokens trigger", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": "worker-c", + "prompt": "Add the project-row trigger entry point for agent token management in the client so the modal can be opened from the project list.", + "notes": "[2026-03-08T20:30:12.262Z] [worker-c]: Started under harness-aware isolation. Owned files: client/src/components/ProjectList.jsx. Modal and i18n files remain unclaimed until 4.2 is dependency-ready.\n[2026-03-08T20:33:43.702Z] [parent-worker]: Parent review complete. Project list now exposes the agent-token management trigger with a safe disabled fallback until modal wiring lands in 4.2. Narrow eslint check passed on the owned file.", + "story_points": null, + "position": 300, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T20:29:59.982Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:29:59.982Z" + }, + { + "id": 34, + "project_id": 1, + "deliverable_id": 4, + "phase": 1, + "phase_step": "1.1", + "title": "Add AGENT_TOKENS schema + seed integration", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "worker-a", + "prompt": "Implement AGENT_TOKENS persistence in Drizzle schema and seed/reset integration with deterministic seeded UUIDs for ZAZZ-6.", + "notes": "[2026-03-08T20:30:13.490Z] [worker-a]: Started under harness-aware isolation. Owned files: api/lib/db/schema.js, api/scripts/reset-and-seed.js, api/scripts/seed-all.js, api/scripts/seeders/seedAgentTokens.js. No overlapping ownership assigned.\n[2026-03-08T20:33:14.783Z] [parent-worker]: Parent review complete. AGENT_TOKENS schema, relations, seeder, and reset/seed integration are in place. Verified via api db reset against zazz_board_test.", + "story_points": null, + "position": 310, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T20:30:01.713Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:30:01.713Z" + }, + { + "id": 35, + "project_id": 1, + "deliverable_id": 4, + "phase": 2, + "phase_step": "2.4", + "title": "Fix deliverable approval route regression", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": "Investigate and fix the deliverable approval route behavior in api/src/routes/deliverables.js and related service/tests while implementing ZAZZ-6 backend auth and route changes. Cover the route with regression tests and keep board truth synced.", + "notes": "[2026-03-08T20:36:58.242Z] [parent-worker]: User clarified the approval baby step: dragging a deliverable card from PLANNING to IN_PROGRESS should auto-approve the deliverable when plan_filepath is present, rather than requiring a separate explicit approve call first. Implement this in the later route/service slice after current databaseService ownership is released.\n[2026-03-08T21:09:04.593Z] [parent-worker]: Completed approval-route baby step. Moving a deliverable from PLANNING to IN_PROGRESS now auto-approves it when plan_filepath exists, and approval/status events include approval metadata. Verified by deliverables-approval and deliverables-status route suites.", + "story_points": null, + "position": 340, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T20:31:17.777Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:31:17.777Z" + }, + { + "id": 36, + "project_id": 1, + "deliverable_id": 4, + "phase": 2, + "phase_step": "2.1", + "title": "Add agent-token validation schemas and OpenAPI exports", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "worker-b", + "prompt": "Add agent-token schema definitions and export wiring for OpenAPI, keeping ownership on the API contract slice in preparation for route handlers and the approval-route fix.", + "notes": "[2026-03-08T20:34:17.176Z] [worker-b]: Started under harness-aware isolation. Owned files: api/src/schemas/agentTokens.js, api/src/schemas/index.js, api/src/schemas/validation.js, api/__tests__/routes/openapi.test.mjs. deliverables.js approval-route changes remain queued for the later route slice to avoid overlap.\n[2026-03-08T21:09:08.187Z] [parent-worker]: Completed schema/OpenAPI contract slice. Added agent-token schemas, barrel exports, and OpenAPI assertions. Verified by passing openapi.test.mjs after route registration landed.", + "story_points": null, + "position": 350, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T20:34:06.054Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:34:06.054Z" + }, + { + "id": 37, + "project_id": 1, + "deliverable_id": 4, + "phase": 1, + "phase_step": "1.2", + "title": "Expand tokenService cache to user and agent token model", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "worker-a2", + "prompt": "Extend tokenService to cache user and agent tokens plus project code/id maps, add cache refresh and mutation helpers, and update test server helpers for ZAZZ-6 auth groundwork.", + "notes": "[2026-03-08T20:34:18.355Z] [worker-a2]: Started under harness-aware isolation. Owned files: api/src/services/tokenService.js, api/src/services/databaseService.js, api/__tests__/helpers/testServer.js, api/__tests__/helpers/testServerWithSwagger.js, api/__tests__/helpers/testDatabase.js.\n[2026-03-08T20:57:25.728Z] [parent-worker]: Parent review complete. Unified token cache, project lookup maps, cache refresh semantics, and test helper resets are in place. Verified by the passing project-id regression suite after test DB reset.", + "story_points": null, + "position": 320, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T20:34:07.064Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:34:07.064Z" + }, + { + "id": 38, + "project_id": 1, + "deliverable_id": 4, + "phase": 1, + "phase_step": "1.3", + "title": "Extend auth middleware with agent-token project enforcement", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": "Populate token type/request context, enforce agent token project scoping on :id and :code project routes, and reject agent tokens on authenticated non-project endpoints for ZAZZ-6.", + "notes": "[2026-03-08T20:57:34.688Z] [parent-worker]: Implemented agent-token auth enforcement in authMiddleware: request token context is populated, agent tokens are scoped on both :id and :code project routes, and authenticated non-project endpoints reject agent tokens. Verified by project-id route regression suite.", + "story_points": null, + "position": 330, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T20:57:24.979Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:57:24.979Z" + }, + { + "id": 39, + "project_id": 1, + "deliverable_id": 4, + "phase": 2, + "phase_step": "2.2", + "title": "Implement agent-token routes and cache-backed CRUD", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": "Implement agent-token list/create/delete routes, leader/non-leader visibility rules, cache mutation on create/delete, and route registration for ZAZZ-6.", + "notes": "[2026-03-08T21:09:22.054Z] [parent-worker]: Completed route implementation slice. Added agent-token route plugin, leader/non-leader enforcement, cache-backed create/delete behavior, route registration, and dedicated agent-token route tests. Verified by passing agent-tokens.test.mjs and openapi.test.mjs.", + "story_points": null, + "position": 370, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T21:08:44.281Z", + "updated_by": 5, + "updated_at": "2026-03-08T21:08:44.281Z" + }, + { + "id": 40, + "project_id": 1, + "deliverable_id": 4, + "phase": 2, + "phase_step": "2.3", + "title": "Add token-cache refresh endpoint", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": "Add the user-token-only POST /token-cache/refresh endpoint and document/test the cache refresh behavior for ZAZZ-6.", + "notes": "[2026-03-08T21:09:20.861Z] [parent-worker]: Completed cache-refresh endpoint slice. Added POST /token-cache/refresh as a user-token-only endpoint and verified refresh behavior in the dedicated agent-token route suite.", + "story_points": null, + "position": 360, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T21:08:47.788Z", + "updated_by": 5, + "updated_at": "2026-03-08T21:08:47.788Z" + }, + { + "id": 41, + "project_id": 1, + "deliverable_id": 4, + "phase": 4, + "phase_step": "4.2", + "title": "Implement Agent Tokens modal, API calls, copy/delete UX, and translations", + "status": "IN_PROGRESS", + "priority": "HIGH", + "agent_name": "parent-worker", + "prompt": "Deliver the client-side agent token management UX for ZAZZ-6: modal UI, leader/non-leader views, create token with copy feedback, exact-phrase delete confirmation, modal wiring, and i18n translations.", + "notes": "[2026-03-08T21:32:11.571Z] [parent-worker]: Starting 4.2 under harness-aware isolation. Subagent ownership: worker-ui-modal -> client/src/components/AgentTokensModal.jsx; worker-ui-wire -> client/src/App.jsx, client/src/pages/HomePage.jsx; worker-ui-i18n -> client/src/i18n/locales/en.json, client/src/i18n/locales/es.json, client/src/i18n/locales/fr.json, client/src/i18n/locales/de.json. Parent owns integration, any hook/shared-file fixes, board sync, and verification.\n[2026-03-08T21:34:52.624Z] [parent-worker]: Execution-time decomposition added for visibility: 4.2a modal component, 4.2b app/home wiring, 4.2c locale translations. Task 41 remains the parent integration and verification gate for plan step 4.2.\n[2026-03-08T21:39:45.664Z] [parent-worker]: Parent integration status: 4.2a/4.2b/4.2c are merged in the workspace. Verification passed for locale JSON parse, client production build ('cd client && npm run build'), and full backend suite ('cd api && set -a && source .env && set +a && NODE_ENV=test npm run test' => 15 files, 152 tests passed). Remaining matrix gap is manual UI verification on http://localhost:3001 for leader and non-leader modal flows.\n[2026-03-08T21:41:55.396Z] [parent-worker]: Automated verification is complete. Passed: locale JSON parse, client production build ('cd client && npm run build'), targeted regression suite for agent-workflow/approval/status, and parent-run full backend suite ('cd api && set -a && source .env && set +a && NODE_ENV=test npm run test' => 15 files, 152 tests). Remaining plan-matrix gap: manual UI verification on http://localhost:3001 for leader and non-leader modal flows.", + "story_points": null, + "position": 10, + "is_blocked": true, + "blocked_reason": "OWNER_DECISION", + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T21:30:31.939Z", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:31.939Z" + }, + { + "id": 42, + "project_id": 1, + "deliverable_id": 4, + "phase": 4, + "phase_step": "4.2a", + "title": "Build Agent Tokens modal component", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "worker-ui-modal", + "prompt": "Create client/src/components/AgentTokensModal.jsx for ZAZZ-6. Use the existing useAgentTokens hook to render leader/non-leader token views, create-token UX with copy feedback, and exact-phrase delete confirmation inside the modal.", + "notes": "[2026-03-08T21:34:52.587Z] [parent-worker]: Assigned to worker-ui-modal under harness-aware isolation. Owned file: client/src/components/AgentTokensModal.jsx only. Parent retains hook/shared-file adjustments, board sync, and integration.\n[2026-03-08T21:41:55.397Z] [parent-worker]: Parent integration review complete. Agent Tokens modal is merged in the workspace, shared hook error handling was added in useAgentTokens, client production build passed, and parent-run backend suite passed (15 files / 152 tests).", + "story_points": null, + "position": 420, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T21:34:14.490Z", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:14.490Z" + }, + { + "id": 43, + "project_id": 1, + "deliverable_id": 4, + "phase": 4, + "phase_step": "4.2b", + "title": "Wire Agent Tokens modal into App and HomePage", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "worker-ui-wire", + "prompt": "Update client/src/App.jsx and client/src/pages/HomePage.jsx to own and pass Agent Tokens modal state and callback wiring for ZAZZ-6, reusing selectedProject/currentUser and the existing ProjectList trigger.", + "notes": "[2026-03-08T21:35:00.957Z] [parent-worker]: Assigned to worker-ui-wire under harness-aware isolation. Owned files: client/src/App.jsx and client/src/pages/HomePage.jsx only. Parent retains any cross-file fixes, board sync, and integration.\n[2026-03-08T21:38:45.804Z] [worker-ui-wire]: Integrated Agent Tokens modal wiring in client/src/App.jsx and client/src/pages/HomePage.jsx. App now owns modal open/close state plus selected project context, HomePage forwards the project-row callback, and the existing ProjectList trigger opens the modal with currentUser/project context. Verified by passing client production build via 'cd client && npm run build'.", + "story_points": null, + "position": 410, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T21:34:14.490Z", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:14.490Z" + }, + { + "id": 44, + "project_id": 1, + "deliverable_id": 4, + "phase": 4, + "phase_step": "4.2c", + "title": "Add Agent Tokens modal translations", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": "worker-ui-i18n", + "prompt": "Add the Agent Tokens modal translation keys for en/es/fr/de under the projects namespace for ZAZZ-6, covering list/create/copy/delete/loading/error/empty states and exact-phrase confirmation copy.", + "notes": "[2026-03-08T21:35:00.957Z] [parent-worker]: Completed by worker-ui-i18n under harness-aware isolation. Added projects.agentTokens.* translations to en/es/fr/de and validated all four locale files with JSON.parse. Confirmation phrase was kept literal as 'delete this token' per requirement.", + "story_points": null, + "position": 380, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T21:34:14.490Z", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:14.490Z" + } + ], + "task_tags": [ + { + "task_id": 1, + "tag": "backend" + }, + { + "task_id": 3, + "tag": "bug-fix" + }, + { + "task_id": 4, + "tag": "testing" + }, + { + "task_id": 6, + "tag": "bug-fix" + } + ], + "task_relations": [ + { + "task_id": 2, + "related_task_id": 1, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 4, + "related_task_id": 3, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 5, + "related_task_id": 4, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 6, + "related_task_id": 5, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 7, + "related_task_id": 6, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 8, + "related_task_id": 7, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 9, + "related_task_id": 8, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 10, + "related_task_id": 9, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 11, + "related_task_id": 10, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 14, + "related_task_id": 13, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-07T00:47:40.365Z" + }, + { + "task_id": 15, + "related_task_id": 12, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-07T00:47:40.391Z" + }, + { + "task_id": 16, + "related_task_id": 15, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:56:26.360Z" + }, + { + "task_id": 17, + "related_task_id": 16, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:56:26.379Z" + }, + { + "task_id": 18, + "related_task_id": 17, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.450Z" + }, + { + "task_id": 19, + "related_task_id": 15, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.467Z" + }, + { + "task_id": 19, + "related_task_id": 17, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.483Z" + }, + { + "task_id": 21, + "related_task_id": 13, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.498Z" + }, + { + "task_id": 22, + "related_task_id": 13, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.513Z" + }, + { + "task_id": 22, + "related_task_id": 17, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.529Z" + }, + { + "task_id": 23, + "related_task_id": 14, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.545Z" + }, + { + "task_id": 23, + "related_task_id": 18, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.561Z" + }, + { + "task_id": 23, + "related_task_id": 19, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.579Z" + }, + { + "task_id": 23, + "related_task_id": 20, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.595Z" + }, + { + "task_id": 23, + "related_task_id": 21, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.612Z" + }, + { + "task_id": 23, + "related_task_id": 22, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.630Z" + }, + { + "task_id": 24, + "related_task_id": 23, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T01:19:29.998Z" + }, + { + "task_id": 25, + "related_task_id": 14, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-07T01:30:52.983Z" + }, + { + "task_id": 26, + "related_task_id": 25, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T01:43:15.973Z" + }, + { + "task_id": 27, + "related_task_id": 26, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T02:11:48.188Z" + }, + { + "task_id": 28, + "related_task_id": 27, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T02:11:48.213Z" + }, + { + "task_id": 30, + "related_task_id": 29, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T02:09:03.655Z" + }, + { + "task_id": 31, + "related_task_id": 29, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T02:09:03.677Z" + }, + { + "task_id": 31, + "related_task_id": 30, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T02:09:03.696Z" + }, + { + "task_id": 35, + "related_task_id": 38, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:47.222Z" + }, + { + "task_id": 36, + "related_task_id": 34, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:10:18.484Z" + }, + { + "task_id": 37, + "related_task_id": 34, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:10:20.380Z" + }, + { + "task_id": 38, + "related_task_id": 37, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:10:19.604Z" + }, + { + "task_id": 39, + "related_task_id": 36, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:10:17.550Z" + }, + { + "task_id": 39, + "related_task_id": 37, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:47.136Z" + }, + { + "task_id": 39, + "related_task_id": 38, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:47.148Z" + }, + { + "task_id": 40, + "related_task_id": 37, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:47.149Z" + }, + { + "task_id": 40, + "related_task_id": 38, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:47.158Z" + }, + { + "task_id": 41, + "related_task_id": 33, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:47.190Z" + }, + { + "task_id": 41, + "related_task_id": 39, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:47.197Z" + }, + { + "task_id": 41, + "related_task_id": 42, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.658Z" + }, + { + "task_id": 41, + "related_task_id": 43, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.663Z" + }, + { + "task_id": 41, + "related_task_id": 44, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.657Z" + }, + { + "task_id": 42, + "related_task_id": 33, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.567Z" + }, + { + "task_id": 42, + "related_task_id": 39, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.570Z" + }, + { + "task_id": 43, + "related_task_id": 33, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.581Z" + }, + { + "task_id": 43, + "related_task_id": 39, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.571Z" + }, + { + "task_id": 44, + "related_task_id": 33, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.653Z" + }, + { + "task_id": 44, + "related_task_id": 39, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.661Z" + } + ], + "image_metadata": [], + "image_data": [] +} diff --git a/api/scripts/seeders/data/database-snapshot.pre-upgrade.json b/api/scripts/seeders/data/database-snapshot.pre-upgrade.json new file mode 100644 index 00000000..8f9d702d --- /dev/null +++ b/api/scripts/seeders/data/database-snapshot.pre-upgrade.json @@ -0,0 +1,2070 @@ +{ + "format_version": 1, + "metadata": { + "exported_at": "2026-03-08T23:18:17.778Z", + "missing_tables": [ + "agent_tokens" + ] + }, + "users": [ + { + "id": 1, + "full_name": "John Doe", + "email": "john.doe@example.com", + "access_token": "94640eec-04f4-4089-8cd1-14be1b7412f3", + "created_at": "2026-03-08T13:03:36.202Z", + "updated_at": "2026-03-08T13:03:36.202Z" + }, + { + "id": 2, + "full_name": "Jane Smith", + "email": "jane.smith@example.com", + "access_token": "18b3759f-bb53-430c-bbf3-514e8004b769", + "created_at": "2026-03-08T13:03:36.202Z", + "updated_at": "2026-03-08T13:03:36.202Z" + }, + { + "id": 3, + "full_name": "Mike Johnson", + "email": "mike.johnson@example.com", + "access_token": "bc03c021-0ea0-46aa-8cc1-65277ff9e45a", + "created_at": "2026-03-08T13:03:36.202Z", + "updated_at": "2026-03-08T13:03:36.202Z" + }, + { + "id": 4, + "full_name": "Sarah Wilson", + "email": "sarah.wilson@example.com", + "access_token": "9a55982c-589e-44f6-85b4-f7132b795621", + "created_at": "2026-03-08T13:03:36.202Z", + "updated_at": "2026-03-08T13:03:36.202Z" + }, + { + "id": 5, + "full_name": "Michael Woytowitz", + "email": "michael@witzware.com", + "access_token": "550e8400-e29b-41d4-a716-446655440000", + "created_at": "2026-03-08T13:03:36.202Z", + "updated_at": "2026-03-08T13:03:36.202Z" + } + ], + "status_definitions": [ + { + "code": "AWAITING_APPROVAL", + "description": "Tasks waiting for stakeholder approval", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "BACKLOG", + "description": "Tasks in backlog awaiting prioritization", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "COMPLETED", + "description": "Task finished and verified against acceptance criteria", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "DONE", + "description": "Merged to main, deliverable complete", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "ICEBOX", + "description": "Tasks that are deprioritized or on hold", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "IN_PROGRESS", + "description": "Tasks currently being worked on", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "IN_REVIEW", + "description": "Tasks awaiting code review or approval", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "PENDING", + "description": "Tasks waiting for upstream dependencies to complete", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "PLANNING", + "description": "SPEC and implementation plan being created or refined", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "PROD", + "description": "Merged to main and deployed to production (terminal state)", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "QA", + "description": "Task undergoing quality assurance and acceptance testing", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "READY", + "description": "Tasks that are ready to be started", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "READY_FOR_DEPLOY", + "description": "Tasks ready to be deployed to production", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "STAGED", + "description": "Merged to staging branch for integration testing", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "TESTING", + "description": "Tasks in testing phase", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "TO_DO", + "description": "Tasks that are planned but not yet started", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + }, + { + "code": "UAT", + "description": "User acceptance testing in integration environment", + "created_by": null, + "created_at": "2026-03-08T13:03:36.204Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.204Z" + } + ], + "coordination_types": [ + { + "code": "DEPLOY_TOGETHER", + "description": "Changes must be deployed together to avoid breaking changes", + "created_by": null, + "created_at": "2026-03-08T13:03:36.205Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.205Z" + }, + { + "code": "MERGE_TOGETHER", + "description": "All PRs must merge to dev together", + "created_by": null, + "created_at": "2026-03-08T13:03:36.205Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.205Z" + }, + { + "code": "MIGRATE_TOGETHER", + "description": "Database migration and API changes must merge simultaneously", + "created_by": null, + "created_at": "2026-03-08T13:03:36.205Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.205Z" + }, + { + "code": "RELEASE_TOGETHER", + "description": "All changes must be released to production together", + "created_by": null, + "created_at": "2026-03-08T13:03:36.205Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.205Z" + }, + { + "code": "TEST_TOGETHER", + "description": "Changes must be tested together before deployment", + "created_by": null, + "created_at": "2026-03-08T13:03:36.205Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.205Z" + } + ], + "translations": [ + { + "id": 4, + "language_code": "de", + "translations": "{\"common\":{\"loading\":\"Laden...\",\"error\":\"Fehler\",\"success\":\"Erfolg\",\"cancel\":\"Abbrechen\",\"save\":\"Speichern\",\"delete\":\"Löschen\",\"edit\":\"Bearbeiten\",\"add\":\"Hinzufügen\",\"close\":\"Schließen\",\"back\":\"Zurück\",\"next\":\"Weiter\",\"previous\":\"Zurück\",\"submit\":\"Absenden\",\"confirm\":\"Bestätigen\",\"yes\":\"Ja\",\"no\":\"Nein\",\"ok\":\"OK\",\"search\":\"Suchen\",\"filter\":\"Filtern\",\"sort\":\"Sortieren\",\"refresh\":\"Aktualisieren\",\"settings\":\"Einstellungen\",\"profile\":\"Profil\",\"logout\":\"Abmelden\",\"login\":\"Anmelden\",\"register\":\"Registrieren\",\"accessTokenRequired\":\"Zugriffstoken erforderlich\",\"pleaseSetAccessToken\":\"Bitte setzen Sie Ihren Zugriffstoken über das Menü oben links, um Projekte anzuzeigen.\",\"setAccessToken\":\"Zugriffstoken setzen\",\"accessToken\":\"Zugriffstoken\",\"enterAccessToken\":\"Geben Sie Ihren Zugriffstoken ein\",\"tokenSaved\":\"Zugriffstoken erfolgreich gespeichert\",\"actions\":\"Aktionen\",\"keyboardShortcut\":\"Tastenkürzel\"},\"navigation\":{\"home\":\"Startseite\",\"projects\":\"Projekte\",\"tasks\":\"Aufgaben\",\"kanban\":\"Kanban-Board\",\"dashboard\":\"Dashboard\",\"reports\":\"Berichte\"},\"projects\":{\"title\":\"Projekte\",\"newProject\":\"Neues Projekt\",\"projectName\":\"Projektname\",\"projectCode\":\"Projektcode\",\"description\":\"Beschreibung\",\"leader\":\"Projektleiter\",\"leaderName\":\"Name des Leiters\",\"createdAt\":\"Erstellt\",\"updatedAt\":\"Aktualisiert\",\"actions\":\"Aktionen\",\"noProjects\":\"Keine Projekte gefunden\",\"createFirstProject\":\"Erstellen Sie Ihr erstes Projekt, um zu beginnen\",\"createProject\":\"Projekt erstellen\",\"editProject\":\"Projekt bearbeiten\",\"deleteProject\":\"Projekt löschen\",\"projectDetails\":\"Projektdetails\",\"statusWorkflow\":{\"title\":\"Status-Workflow\",\"selected\":\"Ausgewählter Status-Workflow\",\"available\":\"Verfügbare Status\",\"dragHelp\":\"Ziehen zum Neuordnen. Diese Status erscheinen als Spalten in Ihrem Kanban-Board.\",\"viewHelp\":\"Diese Status erscheinen als Spalten in Ihrem Kanban-Board.\",\"addHelp\":\"Klicken Sie auf \\\"Hinzufügen\\\", um einen Status in Ihren Workflow aufzunehmen.\"}},\"tasks\":{\"title\":\"Titel\",\"newTask\":\"Neue Aufgabe\",\"taskId\":\"Aufgaben-ID\",\"prompt\":\"Prompt\",\"status\":\"Status\",\"priority\":\"Priorität\",\"assignee\":\"Zugewiesen\",\"assigneeName\":\"Name des Zugewiesenen\",\"storyPoints\":\"Story Points\",\"gitWorktree\":\"Git Worktree\",\"gitWorktreePlaceholder\":\"feature/aufgabe-123\",\"gitPullRequestUrl\":\"Pull-Request-URL\",\"gitPullRequestUrlPlaceholder\":\"https://github.com/benutzer/repo/pull/123\",\"isBlocked\":\"Blockiert\",\"blockedReason\":\"Blockierungsgrund\",\"startedAt\":\"Gestartet\",\"completedAt\":\"Abgeschlossen\",\"createdAt\":\"Erstellt\",\"updatedAt\":\"Aktualisiert\",\"actions\":\"Aktionen\",\"noTasks\":\"Keine Aufgaben gefunden\",\"createFirstTask\":\"Erstellen Sie Ihre erste Aufgabe, um zu beginnen\",\"createTask\":\"Aufgabe erstellen\",\"editTask\":\"Aufgabe bearbeiten\",\"deleteTask\":\"Aufgabe löschen\",\"taskDetails\":\"Aufgabendetails\",\"priorities\":{\"LOW\":\"Niedrig\",\"MEDIUM\":\"Mittel\",\"HIGH\":\"Hoch\",\"CRITICAL\":\"Kritisch\"},\"statusDescriptions\":{\"TO_DO\":\"Geplante, aber noch nicht begonnene Aufgaben\",\"IN_PROGRESS\":\"Aufgaben, an denen derzeit gearbeitet wird\",\"IN_REVIEW\":\"Aufgaben, die auf Code-Review oder Genehmigung warten\",\"DONE\":\"Abgeschlossene Aufgaben\",\"TESTING\":\"Aufgaben in der Testphase\",\"AWAITING_APPROVAL\":\"Aufgaben, die auf Stakeholder-Genehmigung warten\",\"READY_FOR_DEPLOY\":\"Aufgaben, die bereit für die Produktionsbereitstellung sind\",\"ICEBOX\":\"Zurückgestellte oder pausierte Aufgaben\",\"READY\":\"Aufgaben, die bereit sind zu starten\",\"BACKLOG\":\"Aufgaben im Backlog, die auf Priorisierung warten\",\"TESTING_DEV\":\"Aufgaben, die in der Entwicklungsumgebung getestet werden\",\"TESTING_STAGE\":\"Aufgaben, die in der Staging-Umgebung getestet werden\",\"PLANNING\":\"Aufgaben in der Planungsphase\"},\"statuses\":{\"PENDING\":\"Ausstehend\",\"TO_DO\":\"Zu Erledigen\",\"IN_PROGRESS\":\"In Bearbeitung\",\"IN_REVIEW\":\"In Überprüfung\",\"DONE\":\"Erledigt\",\"TESTING\":\"Testen\",\"AWAITING_APPROVAL\":\"Wartet auf Genehmigung\",\"READY_FOR_DEPLOY\":\"Bereit für Bereitstellung\",\"ICEBOX\":\"Eiskiste\",\"READY\":\"Bereit zu Starten\",\"BACKLOG\":\"Backlog\",\"TESTING_DEV\":\"Testen (Dev)\",\"TESTING_STAGE\":\"Testen (Stage)\",\"PLANNING\":\"Planung\",\"QA\":\"QA\",\"COMPLETED\":\"Abgeschlossen\"}},\"kanban\":{\"title\":\"Kanban-Board\",\"toDo\":\"Zu Erledigen\",\"inProgress\":\"In Bearbeitung\",\"inReview\":\"In Überprüfung\",\"done\":\"Erledigt\",\"addTask\":\"Aufgabe hinzufügen\",\"noTasksInColumn\":\"Keine Aufgaben in dieser Spalte\",\"dragToReorder\":\"Ziehen Sie, um Aufgaben neu zu ordnen\",\"dropToMove\":\"Lassen Sie los, um Aufgabe zu verschieben\"},\"users\":{\"title\":\"Benutzer\",\"fullName\":\"Vollständiger Name\",\"email\":\"E-Mail\",\"createdAt\":\"Erstellt\",\"updatedAt\":\"Aktualisiert\",\"actions\":\"Aktionen\",\"noUsers\":\"Keine Benutzer gefunden\"},\"tags\":{\"title\":\"Tags\",\"tagName\":\"Tag-Name\",\"color\":\"Farbe\",\"createdAt\":\"Erstellt\",\"actions\":\"Aktionen\",\"noTags\":\"Keine Tags gefunden\",\"createFirstTag\":\"Erstellen Sie Ihren ersten Tag, um zu beginnen\"},\"forms\":{\"required\":\"Dieses Feld ist erforderlich\",\"invalidEmail\":\"Bitte geben Sie eine gültige E-Mail-Adresse ein\",\"minLength\":\"Muss mindestens {{min}} Zeichen lang sein\",\"maxLength\":\"Darf nicht länger als {{max}} Zeichen sein\",\"invalidFormat\":\"Ungültiges Format\"},\"notifications\":{\"projectCreated\":\"Projekt erfolgreich erstellt\",\"projectUpdated\":\"Projekt erfolgreich aktualisiert\",\"projectDeleted\":\"Projekt erfolgreich gelöscht\",\"taskCreated\":\"Aufgabe erfolgreich erstellt\",\"taskUpdated\":\"Aufgabe erfolgreich aktualisiert\",\"taskDeleted\":\"Aufgabe erfolgreich gelöscht\",\"userCreated\":\"Benutzer erfolgreich erstellt\",\"userUpdated\":\"Benutzer erfolgreich aktualisiert\",\"userDeleted\":\"Benutzer erfolgreich gelöscht\",\"tagCreated\":\"Tag erfolgreich erstellt\",\"tagUpdated\":\"Tag erfolgreich aktualisiert\",\"tagDeleted\":\"Tag erfolgreich gelöscht\"},\"deliverables\":{\"title\":\"Lieferobjekte\",\"statuses\":{\"PLANNING\":\"Planung\",\"IN_PROGRESS\":\"In Bearbeitung\",\"IN_REVIEW\":\"In Prüfung\",\"UAT\":\"UAT\",\"STAGED\":\"Staged\",\"PROD\":\"Prod\",\"DONE\":\"Erledigt\"}}}", + "created_by": null, + "created_at": "2026-03-08T13:03:36.206Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.206Z" + }, + { + "id": 1, + "language_code": "en", + "translations": "{\"common\":{\"loading\":\"Loading...\",\"error\":\"Error\",\"success\":\"Success\",\"cancel\":\"Cancel\",\"save\":\"Save\",\"delete\":\"Delete\",\"edit\":\"Edit\",\"add\":\"Add\",\"close\":\"Close\",\"back\":\"Back\",\"next\":\"Next\",\"previous\":\"Previous\",\"submit\":\"Submit\",\"confirm\":\"Confirm\",\"yes\":\"Yes\",\"no\":\"No\",\"ok\":\"OK\",\"search\":\"Search\",\"filter\":\"Filter\",\"sort\":\"Sort\",\"refresh\":\"Refresh\",\"settings\":\"Settings\",\"profile\":\"Profile\",\"logout\":\"Logout\",\"login\":\"Login\",\"register\":\"Register\",\"accessTokenRequired\":\"Access Token Required\",\"pleaseSetAccessToken\":\"Please set your access token using the menu in the top left to view projects.\",\"setAccessToken\":\"Set Access Token\",\"accessToken\":\"Access Token\",\"enterAccessToken\":\"Enter your access token\",\"tokenSaved\":\"Access token saved successfully\",\"actions\":\"Actions\",\"keyboardShortcut\":\"Keyboard shortcut\",\"confirmDelete\":\"Are you sure you want to delete this item? This action cannot be undone.\"},\"navigation\":{\"home\":\"Home\",\"projects\":\"Projects\",\"tasks\":\"Tasks\",\"kanban\":\"Kanban Board\",\"dashboard\":\"Dashboard\",\"reports\":\"Reports\"},\"projects\":{\"title\":\"Projects\",\"newProject\":\"New Project\",\"projectName\":\"Project Name\",\"projectCode\":\"Project Code\",\"description\":\"Description\",\"leader\":\"Project Leader\",\"leaderName\":\"Leader Name\",\"createdAt\":\"Created\",\"updatedAt\":\"Updated\",\"actions\":\"Actions\",\"noProjects\":\"No projects found\",\"createFirstProject\":\"Create your first project to get started\",\"createProject\":\"Create Project\",\"editProject\":\"Edit Project\",\"deleteProject\":\"Delete Project\",\"projectDetails\":\"Project Details\",\"statusWorkflow\":{\"title\":\"Status Workflow\",\"selected\":\"Selected Status Workflow\",\"available\":\"Available Statuses\",\"dragHelp\":\"Drag to reorder. These statuses will appear as columns in your Kanban board.\",\"viewHelp\":\"These statuses will appear as columns in your Kanban board.\",\"addHelp\":\"Click \\\"Add\\\" to include a status in your workflow.\"}},\"tasks\":{\"title\":\"Title\",\"newTask\":\"New Task\",\"taskId\":\"Task ID\",\"prompt\":\"Prompt\",\"status\":\"Status\",\"priority\":\"Priority\",\"assignee\":\"Assignee\",\"assigneeName\":\"Assignee Name\",\"storyPoints\":\"Story Points\",\"isBlocked\":\"Blocked\",\"blockedReason\":\"Blocked Reason\",\"gitWorktree\":\"Git Worktree\",\"gitPullRequestUrl\":\"Pull Request URL\",\"startedAt\":\"Started\",\"completedAt\":\"Completed\",\"gitWorktreePlaceholder\":\"feature/task-123\",\"gitPullRequestUrlPlaceholder\":\"https://github.com/user/repo/pull/123\",\"selectAssignee\":\"Select assignee\",\"unassigned\":\"Unassigned\",\"blockedReasonPlaceholder\":\"Why is this task blocked?\",\"createdAt\":\"Created\",\"updatedAt\":\"Updated\",\"actions\":\"Actions\",\"noTasks\":\"No tasks found\",\"createFirstTask\":\"Create your first task to get started\",\"createTask\":\"Create Task\",\"editTask\":\"Edit Task\",\"deleteTask\":\"Delete Task\",\"taskDetails\":\"Task Details\",\"priorities\":{\"LOW\":\"Low\",\"MEDIUM\":\"Medium\",\"HIGH\":\"High\",\"CRITICAL\":\"Critical\"},\"statusDescriptions\":{\"TO_DO\":\"Tasks that are planned but not yet started\",\"IN_PROGRESS\":\"Tasks currently being worked on\",\"IN_REVIEW\":\"Tasks awaiting code review or approval\",\"DONE\":\"Completed tasks\",\"TESTING\":\"Tasks in testing phase\",\"AWAITING_APPROVAL\":\"Tasks waiting for stakeholder approval\",\"READY_FOR_DEPLOY\":\"Tasks ready to be deployed to production\",\"ICEBOX\":\"Tasks that are deprioritized or on hold\",\"READY\":\"Tasks that are ready to be started\",\"BACKLOG\":\"Tasks in the backlog awaiting prioritization\",\"TESTING_DEV\":\"Tasks being tested in development environment\",\"TESTING_STAGE\":\"Tasks being tested in staging environment\",\"PLANNING\":\"Tasks in planning phase\"},\"statuses\":{\"PENDING\":\"Pending\",\"TO_DO\":\"To Do\",\"IN_PROGRESS\":\"In Progress\",\"IN_REVIEW\":\"In Review\",\"DONE\":\"Done\",\"TESTING\":\"Testing\",\"AWAITING_APPROVAL\":\"Awaiting Approval\",\"READY_FOR_DEPLOY\":\"Ready for Deploy\",\"ICEBOX\":\"Icebox\",\"READY\":\"Ready To Start\",\"BACKLOG\":\"Backlog\",\"TESTING_DEV\":\"Testing (Dev)\",\"TESTING_STAGE\":\"Testing (Stage)\",\"PLANNING\":\"Planning\",\"QA\":\"QA\",\"COMPLETED\":\"Completed\"}},\"kanban\":{\"title\":\"Kanban Board\",\"toDo\":\"To Do\",\"inProgress\":\"In Progress\",\"inReview\":\"In Review\",\"done\":\"Done\",\"addTask\":\"Add Task\",\"noTasksInColumn\":\"No tasks in this column\",\"dragToReorder\":\"Drag to reorder tasks\",\"dropToMove\":\"Drop to move task\"},\"deliverables\":{\"title\":\"Deliverables\",\"createDeliverable\":\"Create Deliverable\",\"editDeliverable\":\"Edit Deliverable\",\"deleteDeliverable\":\"Delete Deliverable\",\"id\":\"ID\",\"name\":\"Name\",\"nameHint\":\"Enter deliverable name\",\"type\":\"Type\",\"selectType\":\"Select deliverable type\",\"status\":\"Status\",\"description\":\"Description\",\"descriptionHint\":\"Enter deliverable description\",\"specPath\":\"SPEC Path\",\"planPath\":\"Plan Path\",\"specFilepath\":\"SPEC Path\",\"planFilepath\":\"Plan File Path\",\"gitWorktree\":\"Git Worktree\",\"gitBranch\":\"Git Branch\",\"pullRequestUrl\":\"Pull Request URL\",\"approvedBy\":\"Approved By\",\"updated\":\"Updated\",\"tasks\":\"Tasks\",\"noDeliverables\":\"No deliverables in this column\",\"statuses\":{\"PLANNING\":\"Planning\",\"IN_PROGRESS\":\"In Progress\",\"IN_REVIEW\":\"In Review\",\"UAT\":\"UAT\",\"STAGED\":\"Staged\",\"PROD\":\"Prod\",\"DONE\":\"Done\"},\"types\":{\"FEATURE\":\"Feature\",\"BUG_FIX\":\"Bug Fix\",\"REFACTOR\":\"Refactor\",\"ENHANCEMENT\":\"Enhancement\",\"CHORE\":\"Chore\",\"DOCUMENTATION\":\"Documentation\"}},\"users\":{\"title\":\"Users\",\"fullName\":\"Full Name\",\"email\":\"Email\",\"createdAt\":\"Created\",\"updatedAt\":\"Updated\",\"actions\":\"Actions\",\"noUsers\":\"No users found\"},\"tags\":{\"title\":\"Tags\",\"tagName\":\"Tag Name\",\"color\":\"Color\",\"createdAt\":\"Created\",\"actions\":\"Actions\",\"noTags\":\"No tags found\",\"createFirstTag\":\"Create your first tag to get started\",\"selectOrCreate\":\"Select or create tags\",\"commaSeparated\":\"Tags (comma separated)\",\"commaSeparatedDescription\":\"Enter tags separated by commas (e.g. frontend, bug, high-priority)\"},\"forms\":{\"required\":\"This field is required\",\"invalidEmail\":\"Please enter a valid email address\",\"minLength\":\"Must be at least {{min}} characters\",\"maxLength\":\"Must be no more than {{max}} characters\",\"invalidFormat\":\"Invalid format\"},\"notifications\":{\"projectCreated\":\"Project created successfully\",\"projectUpdated\":\"Project updated successfully\",\"projectDeleted\":\"Project deleted successfully\",\"taskCreated\":\"Task created successfully\",\"taskUpdated\":\"Task updated successfully\",\"taskDeleted\":\"Task deleted successfully\",\"userCreated\":\"User created successfully\",\"userUpdated\":\"User updated successfully\",\"userDeleted\":\"User deleted successfully\",\"tagCreated\":\"Tag created successfully\",\"tagUpdated\":\"Tag updated successfully\",\"tagDeleted\":\"Tag deleted successfully\"}}", + "created_by": null, + "created_at": "2026-03-08T13:03:36.206Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.206Z" + }, + { + "id": 2, + "language_code": "es", + "translations": "{\"common\":{\"loading\":\"Cargando...\",\"error\":\"Error\",\"success\":\"Éxito\",\"cancel\":\"Cancelar\",\"save\":\"Guardar\",\"delete\":\"Eliminar\",\"edit\":\"Editar\",\"add\":\"Agregar\",\"close\":\"Cerrar\",\"back\":\"Atrás\",\"next\":\"Siguiente\",\"previous\":\"Anterior\",\"submit\":\"Enviar\",\"confirm\":\"Confirmar\",\"yes\":\"Sí\",\"no\":\"No\",\"ok\":\"OK\",\"search\":\"Buscar\",\"filter\":\"Filtrar\",\"sort\":\"Ordenar\",\"refresh\":\"Actualizar\",\"settings\":\"Configuración\",\"profile\":\"Perfil\",\"logout\":\"Cerrar sesión\",\"login\":\"Iniciar sesión\",\"register\":\"Registrarse\",\"accessTokenRequired\":\"Token de Acceso Requerido\",\"pleaseSetAccessToken\":\"Por favor configure su token de acceso usando el menú en la esquina superior izquierda para ver proyectos.\",\"setAccessToken\":\"Configurar Token de Acceso\",\"accessToken\":\"Token de Acceso\",\"enterAccessToken\":\"Ingrese su token de acceso\",\"tokenSaved\":\"Token de acceso guardado exitosamente\",\"actions\":\"Acciones\",\"keyboardShortcut\":\"Atajo de teclado\"},\"navigation\":{\"home\":\"Inicio\",\"projects\":\"Proyectos\",\"tasks\":\"Tareas\",\"kanban\":\"Tablero Kanban\",\"dashboard\":\"Panel\",\"reports\":\"Reportes\"},\"projects\":{\"title\":\"Proyectos\",\"newProject\":\"Nuevo Proyecto\",\"projectName\":\"Nombre del Proyecto\",\"projectCode\":\"Código del Proyecto\",\"description\":\"Descripción\",\"leader\":\"Líder del Proyecto\",\"leaderName\":\"Nombre del Líder\",\"createdAt\":\"Creado\",\"updatedAt\":\"Actualizado\",\"actions\":\"Acciones\",\"noProjects\":\"No se encontraron proyectos\",\"createFirstProject\":\"Crea tu primer proyecto para comenzar\",\"createProject\":\"Crear Proyecto\",\"editProject\":\"Editar Proyecto\",\"deleteProject\":\"Eliminar Proyecto\",\"projectDetails\":\"Detalles del Proyecto\",\"statusWorkflow\":{\"title\":\"Flujo de Trabajo de Estados\",\"selected\":\"Flujo de Trabajo Seleccionado\",\"available\":\"Estados Disponibles\",\"dragHelp\":\"Arrastra para reordenar. Estos estados aparecerán como columnas en tu tablero Kanban.\",\"viewHelp\":\"Estos estados aparecerán como columnas en tu tablero Kanban.\",\"addHelp\":\"Haz clic en \\\"Agregar\\\" para incluir un estado en tu flujo de trabajo.\"}},\"tasks\":{\"title\":\"Título\",\"newTask\":\"Nueva Tarea\",\"taskId\":\"ID de Tarea\",\"prompt\":\"Prompt\",\"status\":\"Estado\",\"priority\":\"Prioridad\",\"assignee\":\"Asignado\",\"assigneeName\":\"Nombre del Asignado\",\"storyPoints\":\"Puntos de Historia\",\"gitWorktree\":\"Git Worktree\",\"gitWorktreePlaceholder\":\"feature/tarea-123\",\"gitPullRequestUrl\":\"URL de Pull Request\",\"gitPullRequestUrlPlaceholder\":\"https://github.com/usuario/repo/pull/123\",\"isBlocked\":\"Bloqueado\",\"blockedReason\":\"Razón del Bloqueo\",\"startedAt\":\"Iniciado\",\"completedAt\":\"Completado\",\"createdAt\":\"Creado\",\"updatedAt\":\"Actualizado\",\"actions\":\"Acciones\",\"noTasks\":\"No se encontraron tareas\",\"createFirstTask\":\"Crea tu primera tarea para comenzar\",\"createTask\":\"Crear Tarea\",\"editTask\":\"Editar Tarea\",\"deleteTask\":\"Eliminar Tarea\",\"taskDetails\":\"Detalles de la Tarea\",\"priorities\":{\"LOW\":\"Baja\",\"MEDIUM\":\"Media\",\"HIGH\":\"Alta\",\"CRITICAL\":\"Crítica\"},\"statusDescriptions\":{\"TO_DO\":\"Tareas planificadas pero no iniciadas\",\"IN_PROGRESS\":\"Tareas en las que se está trabajando actualmente\",\"IN_REVIEW\":\"Tareas en espera de revisión de código o aprobación\",\"DONE\":\"Tareas completadas\",\"TESTING\":\"Tareas en fase de pruebas\",\"AWAITING_APPROVAL\":\"Tareas en espera de aprobación de interesados\",\"READY_FOR_DEPLOY\":\"Tareas listas para desplegarse en producción\",\"ICEBOX\":\"Tareas despriorizadas o en espera\",\"READY\":\"Tareas listas para comenzar\",\"BACKLOG\":\"Tareas en backlog esperando priorización\",\"TESTING_DEV\":\"Tareas siendo probadas en entorno de desarrollo\",\"TESTING_STAGE\":\"Tareas siendo probadas en entorno de staging\",\"PLANNING\":\"Tareas en fase de planificación\"},\"statuses\":{\"PENDING\":\"Pendiente\",\"TO_DO\":\"Por Hacer\",\"IN_PROGRESS\":\"En Progreso\",\"IN_REVIEW\":\"En Revisión\",\"DONE\":\"Completado\",\"TESTING\":\"Pruebas\",\"AWAITING_APPROVAL\":\"En Espera de Aprobación\",\"READY_FOR_DEPLOY\":\"Listo para Desplegar\",\"ICEBOX\":\"Congelador\",\"READY\":\"Listo Para Comenzar\",\"BACKLOG\":\"Backlog\",\"TESTING_DEV\":\"Pruebas (Dev)\",\"TESTING_STAGE\":\"Pruebas (Stage)\",\"PLANNING\":\"Planificación\",\"QA\":\"QA\",\"COMPLETED\":\"Completado\"}},\"kanban\":{\"title\":\"Tablero Kanban\",\"toDo\":\"Por Hacer\",\"inProgress\":\"En Progreso\",\"inReview\":\"En Revisión\",\"done\":\"Completado\",\"addTask\":\"Agregar Tarea\",\"noTasksInColumn\":\"No hay tareas en esta columna\",\"dragToReorder\":\"Arrastra para reordenar tareas\",\"dropToMove\":\"Suelta para mover tarea\"},\"users\":{\"title\":\"Usuarios\",\"fullName\":\"Nombre Completo\",\"email\":\"Correo Electrónico\",\"createdAt\":\"Creado\",\"updatedAt\":\"Actualizado\",\"actions\":\"Acciones\",\"noUsers\":\"No se encontraron usuarios\"},\"tags\":{\"title\":\"Etiquetas\",\"tagName\":\"Nombre de Etiqueta\",\"color\":\"Color\",\"createdAt\":\"Creado\",\"actions\":\"Acciones\",\"noTags\":\"No se encontraron etiquetas\",\"createFirstTag\":\"Crea tu primera etiqueta para comenzar\"},\"forms\":{\"required\":\"Este campo es requerido\",\"invalidEmail\":\"Por favor ingresa un correo electrónico válido\",\"minLength\":\"Debe tener al menos {{min}} caracteres\",\"maxLength\":\"No debe tener más de {{max}} caracteres\",\"invalidFormat\":\"Formato inválido\"},\"notifications\":{\"projectCreated\":\"Proyecto creado exitosamente\",\"projectUpdated\":\"Proyecto actualizado exitosamente\",\"projectDeleted\":\"Proyecto eliminado exitosamente\",\"taskCreated\":\"Tarea creada exitosamente\",\"taskUpdated\":\"Tarea actualizada exitosamente\",\"taskDeleted\":\"Tarea eliminada exitosamente\",\"userCreated\":\"Usuario creado exitosamente\",\"userUpdated\":\"Usuario actualizado exitosamente\",\"userDeleted\":\"Usuario eliminado exitosamente\",\"tagCreated\":\"Etiqueta creada exitosamente\",\"tagUpdated\":\"Etiqueta actualizada exitosamente\",\"tagDeleted\":\"Etiqueta eliminada exitosamente\"},\"deliverables\":{\"title\":\"Entregables\",\"statuses\":{\"PLANNING\":\"Planificación\",\"IN_PROGRESS\":\"En Progreso\",\"IN_REVIEW\":\"En Revisión\",\"UAT\":\"UAT\",\"STAGED\":\"Staged\",\"PROD\":\"Prod\",\"DONE\":\"Hecho\"}}}", + "created_by": null, + "created_at": "2026-03-08T13:03:36.206Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.206Z" + }, + { + "id": 3, + "language_code": "fr", + "translations": "{\"common\":{\"loading\":\"Chargement...\",\"error\":\"Erreur\",\"success\":\"Succès\",\"cancel\":\"Annuler\",\"save\":\"Enregistrer\",\"delete\":\"Supprimer\",\"edit\":\"Modifier\",\"add\":\"Ajouter\",\"close\":\"Fermer\",\"back\":\"Retour\",\"next\":\"Suivant\",\"previous\":\"Précédent\",\"submit\":\"Soumettre\",\"confirm\":\"Confirmer\",\"yes\":\"Oui\",\"no\":\"Non\",\"ok\":\"OK\",\"search\":\"Rechercher\",\"filter\":\"Filtrer\",\"sort\":\"Trier\",\"refresh\":\"Actualiser\",\"settings\":\"Paramètres\",\"profile\":\"Profil\",\"logout\":\"Déconnexion\",\"login\":\"Connexion\",\"register\":\"S'inscrire\",\"accessTokenRequired\":\"Token d'Accès Requis\",\"pleaseSetAccessToken\":\"Veuillez définir votre token d'accès en utilisant le menu en haut à gauche pour voir les projets.\",\"setAccessToken\":\"Définir le Token d'Accès\",\"accessToken\":\"Token d'Accès\",\"enterAccessToken\":\"Entrez votre token d'accès\",\"tokenSaved\":\"Token d'accès enregistré avec succès\",\"actions\":\"Actions\",\"keyboardShortcut\":\"Raccourci clavier\"},\"navigation\":{\"home\":\"Accueil\",\"projects\":\"Projets\",\"tasks\":\"Tâches\",\"kanban\":\"Tableau Kanban\",\"dashboard\":\"Tableau de bord\",\"reports\":\"Rapports\"},\"projects\":{\"title\":\"Projets\",\"newProject\":\"Nouveau Projet\",\"projectName\":\"Nom du Projet\",\"projectCode\":\"Code du Projet\",\"description\":\"Description\",\"leader\":\"Chef de Projet\",\"leaderName\":\"Nom du Chef\",\"createdAt\":\"Créé\",\"updatedAt\":\"Mis à jour\",\"actions\":\"Actions\",\"noProjects\":\"Aucun projet trouvé\",\"createFirstProject\":\"Créez votre premier projet pour commencer\",\"createProject\":\"Créer un Projet\",\"editProject\":\"Modifier le Projet\",\"deleteProject\":\"Supprimer le Projet\",\"projectDetails\":\"Détails du Projet\",\"statusWorkflow\":{\"title\":\"Flux de Travail des Statuts\",\"selected\":\"Flux de Travail Sélectionné\",\"available\":\"Statuts Disponibles\",\"dragHelp\":\"Glissez pour réorganiser. Ces statuts apparaîtront sous forme de colonnes dans votre tableau Kanban.\",\"viewHelp\":\"Ces statuts apparaîtront sous forme de colonnes dans votre tableau Kanban.\",\"addHelp\":\"Cliquez sur \\\"Ajouter\\\" pour inclure un statut dans votre flux de travail.\"}},\"tasks\":{\"title\":\"Titre\",\"newTask\":\"Nouvelle Tâche\",\"taskId\":\"ID de Tâche\",\"prompt\":\"Prompt\",\"status\":\"Statut\",\"priority\":\"Priorité\",\"assignee\":\"Assigné\",\"assigneeName\":\"Nom de l'Assigné\",\"storyPoints\":\"Points de Story\",\"gitWorktree\":\"Git Worktree\",\"gitWorktreePlaceholder\":\"feature/tache-123\",\"gitPullRequestUrl\":\"URL du Pull Request\",\"gitPullRequestUrlPlaceholder\":\"https://github.com/utilisateur/repo/pull/123\",\"isBlocked\":\"Bloqué\",\"blockedReason\":\"Raison du Blocage\",\"startedAt\":\"Démarré\",\"completedAt\":\"Terminé\",\"createdAt\":\"Créé\",\"updatedAt\":\"Mis à jour\",\"actions\":\"Actions\",\"noTasks\":\"Aucune tâche trouvée\",\"createFirstTask\":\"Créez votre première tâche pour commencer\",\"createTask\":\"Créer une Tâche\",\"editTask\":\"Modifier la Tâche\",\"deleteTask\":\"Supprimer la Tâche\",\"taskDetails\":\"Détails de la Tâche\",\"priorities\":{\"LOW\":\"Faible\",\"MEDIUM\":\"Moyenne\",\"HIGH\":\"Élevée\",\"CRITICAL\":\"Critique\"},\"statusDescriptions\":{\"TO_DO\":\"Tâches planifiées mais pas encore commencées\",\"IN_PROGRESS\":\"Tâches en cours de traitement\",\"IN_REVIEW\":\"Tâches en attente de révision de code ou d'approbation\",\"DONE\":\"Tâches terminées\",\"TESTING\":\"Tâches en phase de test\",\"AWAITING_APPROVAL\":\"Tâches en attente d'approbation des parties prenantes\",\"READY_FOR_DEPLOY\":\"Tâches prêtes à être déployées en production\",\"ICEBOX\":\"Tâches déprioritarisées ou en attente\",\"READY\":\"Tâches prêtes à être commencées\",\"BACKLOG\":\"Tâches dans le backlog en attente de priorisation\",\"TESTING_DEV\":\"Tâches testées dans l'environnement de développement\",\"TESTING_STAGE\":\"Tâches testées dans l'environnement de staging\",\"PLANNING\":\"Tâches en phase de planification\"},\"statuses\":{\"PENDING\":\"En attente\",\"TO_DO\":\"À Faire\",\"IN_PROGRESS\":\"En Cours\",\"IN_REVIEW\":\"En Révision\",\"DONE\":\"Terminé\",\"TESTING\":\"Tests\",\"AWAITING_APPROVAL\":\"En Attente d'Approbation\",\"READY_FOR_DEPLOY\":\"Prêt pour le Déploiement\",\"ICEBOX\":\"Frigo\",\"READY\":\"Prêt à Commencer\",\"BACKLOG\":\"Backlog\",\"TESTING_DEV\":\"Tests (Dev)\",\"TESTING_STAGE\":\"Tests (Stage)\",\"PLANNING\":\"Planification\",\"QA\":\"QA\",\"COMPLETED\":\"Terminé\"}},\"kanban\":{\"title\":\"Tableau Kanban\",\"toDo\":\"À Faire\",\"inProgress\":\"En Cours\",\"inReview\":\"En Révision\",\"done\":\"Terminé\",\"addTask\":\"Ajouter une Tâche\",\"noTasksInColumn\":\"Aucune tâche dans cette colonne\",\"dragToReorder\":\"Glissez pour réorganiser les tâches\",\"dropToMove\":\"Déposez pour déplacer la tâche\"},\"users\":{\"title\":\"Utilisateurs\",\"fullName\":\"Nom Complet\",\"email\":\"E-mail\",\"createdAt\":\"Créé\",\"updatedAt\":\"Mis à jour\",\"actions\":\"Actions\",\"noUsers\":\"Aucun utilisateur trouvé\"},\"tags\":{\"title\":\"Étiquettes\",\"tagName\":\"Nom de l'Étiquette\",\"color\":\"Couleur\",\"createdAt\":\"Créé\",\"actions\":\"Actions\",\"noTags\":\"Aucune étiquette trouvée\",\"createFirstTag\":\"Créez votre première étiquette pour commencer\"},\"forms\":{\"required\":\"Ce champ est requis\",\"invalidEmail\":\"Veuillez entrer une adresse e-mail valide\",\"minLength\":\"Doit contenir au moins {{min}} caractères\",\"maxLength\":\"Ne doit pas dépasser {{max}} caractères\",\"invalidFormat\":\"Format invalide\"},\"notifications\":{\"projectCreated\":\"Projet créé avec succès\",\"projectUpdated\":\"Projet mis à jour avec succès\",\"projectDeleted\":\"Projet supprimé avec succès\",\"taskCreated\":\"Tâche créée avec succès\",\"taskUpdated\":\"Tâche mise à jour avec succès\",\"taskDeleted\":\"Tâche supprimée avec succès\",\"userCreated\":\"Utilisateur créé avec succès\",\"userUpdated\":\"Utilisateur mis à jour avec succès\",\"userDeleted\":\"Utilisateur supprimé avec succès\",\"tagCreated\":\"Étiquette créée avec succès\",\"tagUpdated\":\"Étiquette mise à jour avec succès\",\"tagDeleted\":\"Étiquette supprimée avec succès\"},\"deliverables\":{\"title\":\"Livrables\",\"statuses\":{\"PLANNING\":\"Planification\",\"IN_PROGRESS\":\"En cours\",\"IN_REVIEW\":\"En revue\",\"UAT\":\"UAT\",\"STAGED\":\"Staged\",\"PROD\":\"Prod\",\"DONE\":\"Terminé\"}}}", + "created_by": null, + "created_at": "2026-03-08T13:03:36.206Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.206Z" + } + ], + "tags": [ + { + "tag": "backend", + "color": "#10B981", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "bug-fix", + "color": "#F97316", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "database", + "color": "#6366F1", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "feature", + "color": "#8B5CF6", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "frontend", + "color": "#3B82F6", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "performance", + "color": "#84CC16", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "security", + "color": "#F59E0B", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "testing", + "color": "#06B6D4", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "ui-ux", + "color": "#EC4899", + "created_at": "2026-03-08T13:03:36.203Z" + }, + { + "tag": "urgent", + "color": "#EF4444", + "created_at": "2026-03-08T13:03:36.203Z" + } + ], + "projects": [ + { + "id": 1, + "title": "Zazz Board", + "code": "ZAZZ", + "description": "Zazz-Board application development — primary test project", + "leader_id": 5, + "next_deliverable_sequence": 7, + "status_workflow": [ + "READY", + "IN_PROGRESS", + "QA", + "COMPLETED" + ], + "deliverable_status_workflow": [ + "PLANNING", + "IN_PROGRESS", + "IN_REVIEW", + "STAGED", + "DONE" + ], + "completion_criteria_status": "COMPLETED", + "task_graph_layout_direction": "LR", + "created_by": 5, + "created_at": "2026-03-08T13:03:36.209Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.209Z" + }, + { + "id": 2, + "title": "Zed Mermaid", + "code": "ZED_MER", + "description": "Product-only project for real deliverable creation", + "leader_id": 2, + "next_deliverable_sequence": 1, + "status_workflow": [ + "READY", + "IN_PROGRESS", + "QA", + "COMPLETED" + ], + "deliverable_status_workflow": [ + "PLANNING", + "IN_PROGRESS", + "IN_REVIEW", + "STAGED", + "DONE" + ], + "completion_criteria_status": "COMPLETED", + "task_graph_layout_direction": "LR", + "created_by": 2, + "created_at": "2026-03-08T13:03:36.209Z", + "updated_by": null, + "updated_at": "2026-03-08T13:03:36.209Z" + } + ], + "agent_tokens": [], + "deliverables": [ + { + "id": 1, + "project_id": 1, + "project_code": "ZAZZ", + "code": "ZAZZ-1", + "name": "Deliverables Feature", + "description": "Add deliverable entity, Kanban board, and task graph swim lanes", + "type": "FEATURE", + "status": "IN_REVIEW", + "status_history": [ + { + "status": "PLANNING", + "changedAt": "2026-01-15T10:00:00Z", + "changedBy": 5 + }, + { + "status": "IN_PROGRESS", + "changedAt": "2026-01-20T14:30:00Z", + "changedBy": 5 + }, + { + "status": "IN_REVIEW", + "changedAt": "2026-03-08T20:00:44.438Z", + "changedBy": 5 + } + ], + "spec_filepath": ".zazz/deliverables/deliverables-feature-SPEC.md", + "plan_filepath": ".zazz/deliverables/deliverables-feature-PLAN.md", + "approved_by": 5, + "approved_at": "2026-01-20T14:30:00.000Z", + "git_worktree": "deliverables-mvp", + "git_branch": "deliverables-mvp", + "pull_request_url": "", + "position": 10, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.016Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:00:44.438Z" + }, + { + "id": 2, + "project_id": 1, + "project_code": "ZAZZ", + "code": "ZAZZ-3", + "name": "Fix Tag Validation Bug", + "description": "Tags with trailing hyphens bypass API validation", + "type": "BUG_FIX", + "status": "IN_REVIEW", + "status_history": [ + { + "status": "PLANNING", + "changedAt": "2026-02-01T10:00:00Z", + "changedBy": 5 + }, + { + "status": "IN_PROGRESS", + "changedAt": "2026-02-02T09:00:00Z", + "changedBy": 5 + }, + { + "status": "IN_REVIEW", + "changedAt": "2026-02-05T16:00:00Z", + "changedBy": null + } + ], + "spec_filepath": "docs/fix-tag-validation-SPEC.md", + "plan_filepath": "docs/fix-tag-validation-plan.md", + "approved_by": 5, + "approved_at": "2026-02-02T09:00:00.000Z", + "git_worktree": "fix-tag-validation", + "git_branch": "fix-tag-validation", + "pull_request_url": "https://github.com/zazzcode/zazz-board/pull/12", + "position": 30, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.016Z", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.016Z" + }, + { + "id": 3, + "project_id": 1, + "project_code": "ZAZZ", + "code": "ZAZZ-5", + "name": "fix-routes-no-project", + "description": null, + "type": "REFACTOR", + "status": "IN_REVIEW", + "status_history": [ + { + "status": "PLANNING", + "changedAt": "2026-03-04T23:18:28.449Z", + "changedBy": 5 + }, + { + "status": "IN_PROGRESS", + "changedAt": "2026-03-07T01:41:53.962Z", + "changedBy": 5 + }, + { + "status": "IN_REVIEW", + "changedAt": "2026-03-07T01:41:58.207Z", + "changedBy": 5 + } + ], + "spec_filepath": ".zazz/deliverables/ZAZZ-5-fix-routes-no-project-SPEC.md", + "plan_filepath": ".zazz/deliverables/ZAZZ-5-fix-routes-no-project-PLAN.md", + "approved_by": 5, + "approved_at": "2026-03-07T00:41:56.710Z", + "git_worktree": null, + "git_branch": null, + "pull_request_url": null, + "position": 50, + "created_by": 5, + "created_at": "2026-03-04T23:18:28.446Z", + "updated_by": 5, + "updated_at": "2026-03-07T01:41:58.207Z" + }, + { + "id": 4, + "project_id": 1, + "project_code": "ZAZZ", + "code": "ZAZZ-6", + "name": "multiple-agent-tokens-feature", + "description": "Zazz Board currently uses a single token per user (`USERS.access_token`). Both human users and AI agents share that token. This creates several problems:\n\n- **No project isolation**: An agent token can access any project the user can access. There is no way to scope an agent to a specific project.\n- **No multi-agent separation**: Multiple agents (planner, worker, QA, etc.) for the same user must share one token; there is no way to distinguish or revoke individual agent credentials.\n- **Audit and security**: Revoking one agent's access requires changing the user's token, which affects all agents and the human user.\n\n**Desired state**: Each user can create multiple **agent tokens**, each tied to a specific project. Agent tokens are scoped to **both user and project**—a token belongs to one user and is authorized for one project only (token for user A + project X cannot access project Y). The user's **user token** (existing `USERS.access_token`) remains for human use and grants full access to all projects the user can access. Agent tokens are stored separately and managed per-project.", + "type": "FEATURE", + "status": "IN_PROGRESS", + "status_history": [ + { + "status": "PLANNING", + "changedAt": "2026-03-05T00:27:06.308Z", + "changedBy": 5 + }, + { + "status": "IN_PROGRESS", + "changedAt": "2026-03-08T20:29:48.822Z", + "changedBy": 5 + } + ], + "spec_filepath": ".zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-SPEC.md", + "plan_filepath": ".zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-PLAN.md", + "approved_by": 5, + "approved_at": "2026-03-08T20:29:42.600Z", + "git_worktree": "multiple-agent-tokens-feature", + "git_branch": "multiple-agent-tokens-feature", + "pull_request_url": "", + "position": 60, + "created_by": 5, + "created_at": "2026-03-05T00:27:06.304Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:29:48.822Z" + } + ], + "tasks": [ + { + "id": 1, + "project_id": 1, + "deliverable_id": 1, + "phase": 1, + "phase_step": "1.1", + "title": "ZAZZ-1: Foundation completed (schema + API read paths)", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 10, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 2, + "project_id": 1, + "deliverable_id": 1, + "phase": 2, + "phase_step": "2.1", + "title": "ZAZZ-1: Remaining work (UI polish + edge cases)", + "status": "READY", + "priority": "MEDIUM", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 20, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": "fix-all-the proiblems", + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 3, + "project_id": 1, + "deliverable_id": 2, + "phase": 1, + "phase_step": "1.1", + "title": "ZAZZ-3: Reproduce bug and capture failing cases", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 10, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 4, + "project_id": 1, + "deliverable_id": 2, + "phase": 1, + "phase_step": "1.2", + "title": "ZAZZ-3: Add regression tests for invalid tag formats", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 20, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 5, + "project_id": 1, + "deliverable_id": 2, + "phase": 1, + "phase_step": "1.3", + "title": "ZAZZ-3: Confirm API validation contract + error messaging", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 30, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 6, + "project_id": 1, + "deliverable_id": 2, + "phase": 2, + "phase_step": "2.1", + "title": "ZAZZ-3: Fix validation for trailing hyphen and edge cases", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 40, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 7, + "project_id": 1, + "deliverable_id": 2, + "phase": 2, + "phase_step": "2.2", + "title": "ZAZZ-3: Add server-side canonicalization (lowercase + hyphens)", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 50, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 8, + "project_id": 1, + "deliverable_id": 2, + "phase": 2, + "phase_step": "2.3", + "title": "ZAZZ-3: Ensure tag creation/upsert handles collisions", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 60, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 9, + "project_id": 1, + "deliverable_id": 2, + "phase": 3, + "phase_step": "3.1", + "title": "ZAZZ-3: QA run (API + UI) for tag flows", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 70, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 10, + "project_id": 1, + "deliverable_id": 2, + "phase": 3, + "phase_step": "3.2", + "title": "ZAZZ-3: Address review feedback / small refactor", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 80, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 11, + "project_id": 1, + "deliverable_id": 2, + "phase": 3, + "phase_step": "3.3", + "title": "ZAZZ-3: Final sign-off checklist", + "status": "COMPLETED", + "priority": "LOW", + "agent_name": null, + "prompt": null, + "notes": null, + "story_points": null, + "position": 90, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": "2026-03-04T23:01:41.019Z", + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:01:41.019Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:01:41.019Z" + }, + { + "id": 12, + "project_id": 1, + "deliverable_id": 3, + "phase": 1, + "phase_step": "1.1", + "title": "CODEX 1.1: Test Harness Cleanup for Image Routes", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Goal: update API test helpers so image tables are cleaned per test and image route tests are isolated. Include helper updates in api/__tests__/helpers/testDatabase.js and ensure no cross-test image state bleed. Acceptance: image fixtures can be created/deleted repeatedly in tests without leakage.", + "notes": null, + "story_points": null, + "position": 100, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:42:19.956Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:42:19.956Z" + }, + { + "id": 13, + "project_id": 1, + "deliverable_id": 3, + "phase": 1, + "phase_step": "1.2", + "title": "CODEX 1.2: Remove Project-Wide Graph Endpoint + Add Scoping Tests", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Goal: remove GET /projects/:code/graph route and schema, keep deliverable graph route intact, and add dedicated route tests proving removed path is 404 and deliverable path still works. Acceptance: task-graph-scoping tests pass.", + "notes": null, + "story_points": null, + "position": 110, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:42:19.983Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:42:19.983Z" + }, + { + "id": 14, + "project_id": 1, + "deliverable_id": 3, + "phase": 1, + "phase_step": "1.3", + "title": "CODEX 1.3: Deliverable-Only Graph UI (No Null Fetch)", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Goal: remove project-wide graph mode in client. Update App selector to deliverables-only, update useTaskGraph to skip fetch when deliverableId is null, and show explicit prompt in TaskGraphPage before selection. Acceptance: no /projects/{code}/graph fetch path remains in UI.", + "notes": null, + "story_points": null, + "position": 120, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:47:40.365Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:47:40.365Z" + }, + { + "id": 15, + "project_id": 1, + "deliverable_id": 3, + "phase": 2, + "phase_step": "2.1", + "title": "CODEX 2.1: IMAGE_METADATA Single-Owner Schema Constraint", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Goal: update schema so IMAGE_METADATA supports task or deliverable ownership (task_id nullable, add deliverable_id nullable FK) with DB-level XOR constraint (exactly one owner set). Acceptance: schema enforces both-set and neither-set failures.", + "notes": null, + "story_points": null, + "position": 130, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:47:40.391Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:47:40.391Z" + }, + { + "id": 16, + "project_id": 1, + "deliverable_id": 3, + "phase": 2, + "phase_step": "2.2", + "title": "CODEX 2.2: Refactor Image Service for Project/Owner Validation", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Refactor databaseService image operations to support both task-owned and deliverable-owned images with project-scope ownership checks and scoped URL handling.", + "notes": null, + "story_points": null, + "position": 140, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:56:26.287Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:56:26.287Z" + }, + { + "id": 17, + "project_id": 1, + "deliverable_id": 3, + "phase": 2, + "phase_step": "2.3", + "title": "CODEX 2.3: Replace Legacy Image Routes with Scoped Contracts", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Replace legacy /tasks/:taskId/images and /images/:id routes with project+deliverable scoped task routes, deliverable image routes, and project-scoped image fetch/metadata routes.", + "notes": null, + "story_points": null, + "position": 150, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:56:26.311Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:56:26.311Z" + }, + { + "id": 18, + "project_id": 1, + "deliverable_id": 3, + "phase": 2, + "phase_step": "2.4", + "title": "CODEX 2.4: Align API Metadata Text for Scoped Image Contract", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": "codex", + "prompt": "Update API root endpoint summaries/tag descriptions so docs no longer imply legacy global image routes and reflect project-scoped image operations.", + "notes": null, + "story_points": null, + "position": 170, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:57:52.344Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.344Z" + }, + { + "id": 19, + "project_id": 1, + "deliverable_id": 3, + "phase": 3, + "phase_step": "3.1", + "title": "CODEX 3.1: Add Image Scoping Integration Tests", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Add Pactum tests for scoped task/deliverable image routes, project-scoped image fetch/metadata, 401/403/404 behavior, legacy route 404s, and single-owner DB constraint checks.", + "notes": null, + "story_points": null, + "position": 210, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:57:52.365Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.365Z" + }, + { + "id": 20, + "project_id": 1, + "deliverable_id": 3, + "phase": 3, + "phase_step": "3.2", + "title": "CODEX 3.2: Regression Tests for Unchanged Project-ID Routes", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": "codex", + "prompt": "Add regression tests for /projects/:id, /projects/:id/kanban/tasks/column/:status, and /projects/:id/tasks to ensure these accepted id-based routes remain unchanged.", + "notes": null, + "story_points": null, + "position": 160, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:56:26.330Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:56:26.330Z" + }, + { + "id": 21, + "project_id": 1, + "deliverable_id": 3, + "phase": 3, + "phase_step": "3.3", + "title": "CODEX 3.3: Finalize Graph Removal Regression Tests", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": "codex", + "prompt": "Finalize task-graph scoping regression tests for removed project graph endpoint and preserved deliverable graph behavior.", + "notes": null, + "story_points": null, + "position": 180, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:57:52.383Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.383Z" + }, + { + "id": 22, + "project_id": 1, + "deliverable_id": 3, + "phase": 3, + "phase_step": "3.4", + "title": "CODEX 3.4: Harden OpenAPI Assertions for Graph/Image Contract", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Update openapi.test.mjs to assert removed /projects/{code}/graph and legacy image paths, and assert presence/shape of all new scoped image paths.", + "notes": null, + "story_points": null, + "position": 190, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:57:52.398Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.398Z" + }, + { + "id": 23, + "project_id": 1, + "deliverable_id": 3, + "phase": 3, + "phase_step": "3.5", + "title": "CODEX 3.5: Update API Skill Docs + Final Verification", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Update zazz-board-api skill/docs to new route contract, run final QA verification, and prepare commit-ready summary.", + "notes": null, + "story_points": null, + "position": 200, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T00:57:52.415Z", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.415Z" + }, + { + "id": 24, + "project_id": 1, + "deliverable_id": 3, + "phase": 4, + "phase_step": "4.1", + "title": "CODEX 4.1: Replace drizzle-orm symlink workaround", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": "codex", + "prompt": "Replace manual node_modules symlink workaround with a worktree-safe dependency solution and update setup/troubleshooting docs.", + "notes": null, + "story_points": null, + "position": 220, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T01:18:18.923Z", + "updated_by": 5, + "updated_at": "2026-03-07T01:24:36.852Z" + }, + { + "id": 25, + "project_id": 1, + "deliverable_id": 3, + "phase": 4, + "phase_step": "4.2", + "title": "CODEX 4.2: Persist Task Graph deliverable selection", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Persist the last selected deliverable for Task Graph per project. On reload, restore the saved deliverable if it still exists. Keep deliverable-only graph selector behavior.", + "notes": null, + "story_points": null, + "position": 230, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T01:30:52.983Z", + "updated_by": 5, + "updated_at": "2026-03-07T01:30:52.983Z" + }, + { + "id": 26, + "project_id": 1, + "deliverable_id": 3, + "phase": 4, + "phase_step": "4.3", + "title": "CODEX 4.3: Harden Task Graph selection restore timing", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Make Task Graph deliverable persistence deterministic across reload/project hydration timing by hydrating selection on project change and validating against current deliverables.", + "notes": null, + "story_points": null, + "position": 240, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T01:42:36.382Z", + "updated_by": 5, + "updated_at": "2026-03-07T01:42:36.382Z" + }, + { + "id": 27, + "project_id": 1, + "deliverable_id": 3, + "phase": 4, + "phase_step": "4.4", + "title": "CODEX 4.4: Keep completed tasks visible with green outline", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": "Ensure completed tasks are never treated as non-complete by graph styling logic. COMPLETED/DONE must always render with green outline.", + "notes": null, + "story_points": null, + "position": 250, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T01:48:43.273Z", + "updated_by": 5, + "updated_at": "2026-03-07T01:48:43.273Z" + }, + { + "id": 28, + "project_id": 1, + "deliverable_id": 3, + "phase": 4, + "phase_step": "4.5", + "title": "CODEX 4.5: Make API skill lifecycle rules explicit", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Update zazz-board-api skill to require explicit task status transitions, explicit deliverable status updates, and explicit DEPENDS_ON relation creation/verification.", + "notes": null, + "story_points": null, + "position": 290, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T01:53:38.530Z", + "updated_by": 5, + "updated_at": "2026-03-07T01:53:38.530Z" + }, + { + "id": 29, + "project_id": 1, + "deliverable_id": 3, + "phase": 5, + "phase_step": "5.1", + "title": "CODEX 5.1: Add SSE stream + status/relation event emits", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Implement project-scoped SSE endpoint and emit realtime events for task status/position/create/delete, deliverable status changes, and relation (DEPENDS_ON) updates.", + "notes": null, + "story_points": null, + "position": 260, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T02:08:51.638Z", + "updated_by": 5, + "updated_at": "2026-03-07T02:08:51.638Z" + }, + { + "id": 30, + "project_id": 1, + "deliverable_id": 3, + "phase": 5, + "phase_step": "5.2", + "title": "CODEX 5.2: Wire UI realtime subscriptions for Kanban + Graph", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Add client SSE subscription hook and use it to refresh Task Kanban columns, Task Graph node styling/edges, and Deliverable Kanban status colors immediately across clients.", + "notes": null, + "story_points": null, + "position": 270, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T02:08:51.664Z", + "updated_by": 5, + "updated_at": "2026-03-07T02:08:51.664Z" + }, + { + "id": 31, + "project_id": 1, + "deliverable_id": 3, + "phase": 5, + "phase_step": "5.3", + "title": "CODEX 5.3: Add SSE integration tests + regression checks", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "codex", + "prompt": "Add API integration tests validating SSE events for task status changes, deliverable status changes, and DEPENDS_ON relation updates; run focused regression suite.", + "notes": null, + "story_points": null, + "position": 280, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-07T02:08:51.697Z", + "updated_by": 5, + "updated_at": "2026-03-07T02:08:51.697Z" + }, + { + "id": 32, + "project_id": 1, + "deliverable_id": 3, + "phase": null, + "phase_step": null, + "title": "Audit routes for project filter", + "status": "READY", + "priority": "MEDIUM", + "agent_name": null, + "prompt": "Read the route information and discuss discover which routes do not filter by project.", + "notes": null, + "story_points": null, + "position": 10, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-04T23:21:40.553Z", + "updated_by": 5, + "updated_at": "2026-03-04T23:21:40.553Z" + }, + { + "id": 33, + "project_id": 1, + "deliverable_id": 4, + "phase": 4, + "phase_step": "4.1", + "title": "Add project-row manage-agent-tokens trigger", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": "worker-c", + "prompt": "Add the project-row trigger entry point for agent token management in the client so the modal can be opened from the project list.", + "notes": "[2026-03-08T20:30:12.262Z] [worker-c]: Started under harness-aware isolation. Owned files: client/src/components/ProjectList.jsx. Modal and i18n files remain unclaimed until 4.2 is dependency-ready.\n[2026-03-08T20:33:43.702Z] [parent-worker]: Parent review complete. Project list now exposes the agent-token management trigger with a safe disabled fallback until modal wiring lands in 4.2. Narrow eslint check passed on the owned file.", + "story_points": null, + "position": 300, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T20:29:59.982Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:29:59.982Z" + }, + { + "id": 34, + "project_id": 1, + "deliverable_id": 4, + "phase": 1, + "phase_step": "1.1", + "title": "Add AGENT_TOKENS schema + seed integration", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "worker-a", + "prompt": "Implement AGENT_TOKENS persistence in Drizzle schema and seed/reset integration with deterministic seeded UUIDs for ZAZZ-6.", + "notes": "[2026-03-08T20:30:13.490Z] [worker-a]: Started under harness-aware isolation. Owned files: api/lib/db/schema.js, api/scripts/reset-and-seed.js, api/scripts/seed-all.js, api/scripts/seeders/seedAgentTokens.js. No overlapping ownership assigned.\n[2026-03-08T20:33:14.783Z] [parent-worker]: Parent review complete. AGENT_TOKENS schema, relations, seeder, and reset/seed integration are in place. Verified via api db reset against zazz_board_test.", + "story_points": null, + "position": 310, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T20:30:01.713Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:30:01.713Z" + }, + { + "id": 35, + "project_id": 1, + "deliverable_id": 4, + "phase": 2, + "phase_step": "2.4", + "title": "Fix deliverable approval route regression", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": "Investigate and fix the deliverable approval route behavior in api/src/routes/deliverables.js and related service/tests while implementing ZAZZ-6 backend auth and route changes. Cover the route with regression tests and keep board truth synced.", + "notes": "[2026-03-08T20:36:58.242Z] [parent-worker]: User clarified the approval baby step: dragging a deliverable card from PLANNING to IN_PROGRESS should auto-approve the deliverable when plan_filepath is present, rather than requiring a separate explicit approve call first. Implement this in the later route/service slice after current databaseService ownership is released.\n[2026-03-08T21:09:04.593Z] [parent-worker]: Completed approval-route baby step. Moving a deliverable from PLANNING to IN_PROGRESS now auto-approves it when plan_filepath exists, and approval/status events include approval metadata. Verified by deliverables-approval and deliverables-status route suites.", + "story_points": null, + "position": 340, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T20:31:17.777Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:31:17.777Z" + }, + { + "id": 36, + "project_id": 1, + "deliverable_id": 4, + "phase": 2, + "phase_step": "2.1", + "title": "Add agent-token validation schemas and OpenAPI exports", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "worker-b", + "prompt": "Add agent-token schema definitions and export wiring for OpenAPI, keeping ownership on the API contract slice in preparation for route handlers and the approval-route fix.", + "notes": "[2026-03-08T20:34:17.176Z] [worker-b]: Started under harness-aware isolation. Owned files: api/src/schemas/agentTokens.js, api/src/schemas/index.js, api/src/schemas/validation.js, api/__tests__/routes/openapi.test.mjs. deliverables.js approval-route changes remain queued for the later route slice to avoid overlap.\n[2026-03-08T21:09:08.187Z] [parent-worker]: Completed schema/OpenAPI contract slice. Added agent-token schemas, barrel exports, and OpenAPI assertions. Verified by passing openapi.test.mjs after route registration landed.", + "story_points": null, + "position": 350, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T20:34:06.054Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:34:06.054Z" + }, + { + "id": 37, + "project_id": 1, + "deliverable_id": 4, + "phase": 1, + "phase_step": "1.2", + "title": "Expand tokenService cache to user and agent token model", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "worker-a2", + "prompt": "Extend tokenService to cache user and agent tokens plus project code/id maps, add cache refresh and mutation helpers, and update test server helpers for ZAZZ-6 auth groundwork.", + "notes": "[2026-03-08T20:34:18.355Z] [worker-a2]: Started under harness-aware isolation. Owned files: api/src/services/tokenService.js, api/src/services/databaseService.js, api/__tests__/helpers/testServer.js, api/__tests__/helpers/testServerWithSwagger.js, api/__tests__/helpers/testDatabase.js.\n[2026-03-08T20:57:25.728Z] [parent-worker]: Parent review complete. Unified token cache, project lookup maps, cache refresh semantics, and test helper resets are in place. Verified by the passing project-id regression suite after test DB reset.", + "story_points": null, + "position": 320, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T20:34:07.064Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:34:07.064Z" + }, + { + "id": 38, + "project_id": 1, + "deliverable_id": 4, + "phase": 1, + "phase_step": "1.3", + "title": "Extend auth middleware with agent-token project enforcement", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": "Populate token type/request context, enforce agent token project scoping on :id and :code project routes, and reject agent tokens on authenticated non-project endpoints for ZAZZ-6.", + "notes": "[2026-03-08T20:57:34.688Z] [parent-worker]: Implemented agent-token auth enforcement in authMiddleware: request token context is populated, agent tokens are scoped on both :id and :code project routes, and authenticated non-project endpoints reject agent tokens. Verified by project-id route regression suite.", + "story_points": null, + "position": 330, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T20:57:24.979Z", + "updated_by": 5, + "updated_at": "2026-03-08T20:57:24.979Z" + }, + { + "id": 39, + "project_id": 1, + "deliverable_id": 4, + "phase": 2, + "phase_step": "2.2", + "title": "Implement agent-token routes and cache-backed CRUD", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": "Implement agent-token list/create/delete routes, leader/non-leader visibility rules, cache mutation on create/delete, and route registration for ZAZZ-6.", + "notes": "[2026-03-08T21:09:22.054Z] [parent-worker]: Completed route implementation slice. Added agent-token route plugin, leader/non-leader enforcement, cache-backed create/delete behavior, route registration, and dedicated agent-token route tests. Verified by passing agent-tokens.test.mjs and openapi.test.mjs.", + "story_points": null, + "position": 370, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T21:08:44.281Z", + "updated_by": 5, + "updated_at": "2026-03-08T21:08:44.281Z" + }, + { + "id": 40, + "project_id": 1, + "deliverable_id": 4, + "phase": 2, + "phase_step": "2.3", + "title": "Add token-cache refresh endpoint", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": null, + "prompt": "Add the user-token-only POST /token-cache/refresh endpoint and document/test the cache refresh behavior for ZAZZ-6.", + "notes": "[2026-03-08T21:09:20.861Z] [parent-worker]: Completed cache-refresh endpoint slice. Added POST /token-cache/refresh as a user-token-only endpoint and verified refresh behavior in the dedicated agent-token route suite.", + "story_points": null, + "position": 360, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T21:08:47.788Z", + "updated_by": 5, + "updated_at": "2026-03-08T21:08:47.788Z" + }, + { + "id": 41, + "project_id": 1, + "deliverable_id": 4, + "phase": 4, + "phase_step": "4.2", + "title": "Implement Agent Tokens modal, API calls, copy/delete UX, and translations", + "status": "IN_PROGRESS", + "priority": "HIGH", + "agent_name": "parent-worker", + "prompt": "Deliver the client-side agent token management UX for ZAZZ-6: modal UI, leader/non-leader views, create token with copy feedback, exact-phrase delete confirmation, modal wiring, and i18n translations.", + "notes": "[2026-03-08T21:32:11.571Z] [parent-worker]: Starting 4.2 under harness-aware isolation. Subagent ownership: worker-ui-modal -> client/src/components/AgentTokensModal.jsx; worker-ui-wire -> client/src/App.jsx, client/src/pages/HomePage.jsx; worker-ui-i18n -> client/src/i18n/locales/en.json, client/src/i18n/locales/es.json, client/src/i18n/locales/fr.json, client/src/i18n/locales/de.json. Parent owns integration, any hook/shared-file fixes, board sync, and verification.\n[2026-03-08T21:34:52.624Z] [parent-worker]: Execution-time decomposition added for visibility: 4.2a modal component, 4.2b app/home wiring, 4.2c locale translations. Task 41 remains the parent integration and verification gate for plan step 4.2.\n[2026-03-08T21:39:45.664Z] [parent-worker]: Parent integration status: 4.2a/4.2b/4.2c are merged in the workspace. Verification passed for locale JSON parse, client production build ('cd client && npm run build'), and full backend suite ('cd api && set -a && source .env && set +a && NODE_ENV=test npm run test' => 15 files, 152 tests passed). Remaining matrix gap is manual UI verification on http://localhost:3001 for leader and non-leader modal flows.\n[2026-03-08T21:41:55.396Z] [parent-worker]: Automated verification is complete. Passed: locale JSON parse, client production build ('cd client && npm run build'), targeted regression suite for agent-workflow/approval/status, and parent-run full backend suite ('cd api && set -a && source .env && set +a && NODE_ENV=test npm run test' => 15 files, 152 tests). Remaining plan-matrix gap: manual UI verification on http://localhost:3001 for leader and non-leader modal flows.", + "story_points": null, + "position": 10, + "is_blocked": true, + "blocked_reason": "OWNER_DECISION", + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T21:30:31.939Z", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:31.939Z" + }, + { + "id": 42, + "project_id": 1, + "deliverable_id": 4, + "phase": 4, + "phase_step": "4.2a", + "title": "Build Agent Tokens modal component", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "worker-ui-modal", + "prompt": "Create client/src/components/AgentTokensModal.jsx for ZAZZ-6. Use the existing useAgentTokens hook to render leader/non-leader token views, create-token UX with copy feedback, and exact-phrase delete confirmation inside the modal.", + "notes": "[2026-03-08T21:34:52.587Z] [parent-worker]: Assigned to worker-ui-modal under harness-aware isolation. Owned file: client/src/components/AgentTokensModal.jsx only. Parent retains hook/shared-file adjustments, board sync, and integration.\n[2026-03-08T21:41:55.397Z] [parent-worker]: Parent integration review complete. Agent Tokens modal is merged in the workspace, shared hook error handling was added in useAgentTokens, client production build passed, and parent-run backend suite passed (15 files / 152 tests).", + "story_points": null, + "position": 420, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T21:34:14.490Z", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:14.490Z" + }, + { + "id": 43, + "project_id": 1, + "deliverable_id": 4, + "phase": 4, + "phase_step": "4.2b", + "title": "Wire Agent Tokens modal into App and HomePage", + "status": "COMPLETED", + "priority": "HIGH", + "agent_name": "worker-ui-wire", + "prompt": "Update client/src/App.jsx and client/src/pages/HomePage.jsx to own and pass Agent Tokens modal state and callback wiring for ZAZZ-6, reusing selectedProject/currentUser and the existing ProjectList trigger.", + "notes": "[2026-03-08T21:35:00.957Z] [parent-worker]: Assigned to worker-ui-wire under harness-aware isolation. Owned files: client/src/App.jsx and client/src/pages/HomePage.jsx only. Parent retains any cross-file fixes, board sync, and integration.\n[2026-03-08T21:38:45.804Z] [worker-ui-wire]: Integrated Agent Tokens modal wiring in client/src/App.jsx and client/src/pages/HomePage.jsx. App now owns modal open/close state plus selected project context, HomePage forwards the project-row callback, and the existing ProjectList trigger opens the modal with currentUser/project context. Verified by passing client production build via 'cd client && npm run build'.", + "story_points": null, + "position": 410, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T21:34:14.490Z", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:14.490Z" + }, + { + "id": 44, + "project_id": 1, + "deliverable_id": 4, + "phase": 4, + "phase_step": "4.2c", + "title": "Add Agent Tokens modal translations", + "status": "COMPLETED", + "priority": "MEDIUM", + "agent_name": "worker-ui-i18n", + "prompt": "Add the Agent Tokens modal translation keys for en/es/fr/de under the projects namespace for ZAZZ-6, covering list/create/copy/delete/loading/error/empty states and exact-phrase confirmation copy.", + "notes": "[2026-03-08T21:35:00.957Z] [parent-worker]: Completed by worker-ui-i18n under harness-aware isolation. Added projects.agentTokens.* translations to en/es/fr/de and validated all four locale files with JSON.parse. Confirmation phrase was kept literal as 'delete this token' per requirement.", + "story_points": null, + "position": 380, + "is_blocked": false, + "blocked_reason": null, + "is_cancelled": false, + "git_worktree": null, + "started_at": null, + "completed_at": null, + "coordination_code": null, + "created_by": 5, + "created_at": "2026-03-08T21:34:14.490Z", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:14.490Z" + } + ], + "task_tags": [ + { + "task_id": 1, + "tag": "backend" + }, + { + "task_id": 3, + "tag": "bug-fix" + }, + { + "task_id": 4, + "tag": "testing" + }, + { + "task_id": 6, + "tag": "bug-fix" + } + ], + "task_relations": [ + { + "task_id": 2, + "related_task_id": 1, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 4, + "related_task_id": 3, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 5, + "related_task_id": 4, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 6, + "related_task_id": 5, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 7, + "related_task_id": 6, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 8, + "related_task_id": 7, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 9, + "related_task_id": 8, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 10, + "related_task_id": 9, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 11, + "related_task_id": 10, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-04T23:01:41.022Z" + }, + { + "task_id": 14, + "related_task_id": 13, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-07T00:47:40.365Z" + }, + { + "task_id": 15, + "related_task_id": 12, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-07T00:47:40.391Z" + }, + { + "task_id": 16, + "related_task_id": 15, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:56:26.360Z" + }, + { + "task_id": 17, + "related_task_id": 16, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:56:26.379Z" + }, + { + "task_id": 18, + "related_task_id": 17, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.450Z" + }, + { + "task_id": 19, + "related_task_id": 15, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.467Z" + }, + { + "task_id": 19, + "related_task_id": 17, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.483Z" + }, + { + "task_id": 21, + "related_task_id": 13, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.498Z" + }, + { + "task_id": 22, + "related_task_id": 13, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.513Z" + }, + { + "task_id": 22, + "related_task_id": 17, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.529Z" + }, + { + "task_id": 23, + "related_task_id": 14, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.545Z" + }, + { + "task_id": 23, + "related_task_id": 18, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.561Z" + }, + { + "task_id": 23, + "related_task_id": 19, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.579Z" + }, + { + "task_id": 23, + "related_task_id": 20, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.595Z" + }, + { + "task_id": 23, + "related_task_id": 21, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.612Z" + }, + { + "task_id": 23, + "related_task_id": 22, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T00:57:52.630Z" + }, + { + "task_id": 24, + "related_task_id": 23, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T01:19:29.998Z" + }, + { + "task_id": 25, + "related_task_id": 14, + "relation_type": "DEPENDS_ON", + "updated_by": null, + "updated_at": "2026-03-07T01:30:52.983Z" + }, + { + "task_id": 26, + "related_task_id": 25, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T01:43:15.973Z" + }, + { + "task_id": 27, + "related_task_id": 26, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T02:11:48.188Z" + }, + { + "task_id": 28, + "related_task_id": 27, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T02:11:48.213Z" + }, + { + "task_id": 30, + "related_task_id": 29, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T02:09:03.655Z" + }, + { + "task_id": 31, + "related_task_id": 29, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T02:09:03.677Z" + }, + { + "task_id": 31, + "related_task_id": 30, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-07T02:09:03.696Z" + }, + { + "task_id": 35, + "related_task_id": 38, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:47.222Z" + }, + { + "task_id": 36, + "related_task_id": 34, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:10:18.484Z" + }, + { + "task_id": 37, + "related_task_id": 34, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:10:20.380Z" + }, + { + "task_id": 38, + "related_task_id": 37, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:10:19.604Z" + }, + { + "task_id": 39, + "related_task_id": 36, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:10:17.550Z" + }, + { + "task_id": 39, + "related_task_id": 37, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:47.136Z" + }, + { + "task_id": 39, + "related_task_id": 38, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:47.148Z" + }, + { + "task_id": 40, + "related_task_id": 37, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:47.149Z" + }, + { + "task_id": 40, + "related_task_id": 38, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:47.158Z" + }, + { + "task_id": 41, + "related_task_id": 33, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:47.190Z" + }, + { + "task_id": 41, + "related_task_id": 39, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:30:47.197Z" + }, + { + "task_id": 41, + "related_task_id": 42, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.658Z" + }, + { + "task_id": 41, + "related_task_id": 43, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.663Z" + }, + { + "task_id": 41, + "related_task_id": 44, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.657Z" + }, + { + "task_id": 42, + "related_task_id": 33, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.567Z" + }, + { + "task_id": 42, + "related_task_id": 39, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.570Z" + }, + { + "task_id": 43, + "related_task_id": 33, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.581Z" + }, + { + "task_id": 43, + "related_task_id": 39, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.571Z" + }, + { + "task_id": 44, + "related_task_id": 33, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.653Z" + }, + { + "task_id": 44, + "related_task_id": 39, + "relation_type": "DEPENDS_ON", + "updated_by": 5, + "updated_at": "2026-03-08T21:34:33.661Z" + } + ], + "image_metadata": [], + "image_data": [] +} diff --git a/api/scripts/seeders/databaseSnapshot.js b/api/scripts/seeders/databaseSnapshot.js new file mode 100644 index 00000000..c9ab36da --- /dev/null +++ b/api/scripts/seeders/databaseSnapshot.js @@ -0,0 +1,52 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export const databaseSnapshotPath = resolve(__dirname, 'data/database-snapshot.json'); + +let cachedSnapshot = null; +let cachedSnapshotPath = null; + +function resolveSnapshotPath() { + return process.env.DB_SNAPSHOT_PATH + ? resolve(process.cwd(), process.env.DB_SNAPSHOT_PATH) + : databaseSnapshotPath; +} + +function normalizeSnapshot(parsed) { + const normalized = { + format_version: parsed?.format_version ?? 1, + users: Array.isArray(parsed?.users) ? parsed.users : [], + status_definitions: Array.isArray(parsed?.status_definitions) ? parsed.status_definitions : [], + coordination_types: Array.isArray(parsed?.coordination_types) ? parsed.coordination_types : [], + translations: Array.isArray(parsed?.translations) ? parsed.translations : [], + tags: Array.isArray(parsed?.tags) ? parsed.tags : [], + projects: Array.isArray(parsed?.projects) ? parsed.projects : [], + agent_tokens: Array.isArray(parsed?.agent_tokens) ? parsed.agent_tokens : [], + deliverables: Array.isArray(parsed?.deliverables) ? parsed.deliverables : [], + tasks: Array.isArray(parsed?.tasks) ? parsed.tasks : [], + task_tags: Array.isArray(parsed?.task_tags) ? parsed.task_tags : [], + task_relations: Array.isArray(parsed?.task_relations) ? parsed.task_relations : [], + image_metadata: Array.isArray(parsed?.image_metadata) ? parsed.image_metadata : [], + image_data: Array.isArray(parsed?.image_data) ? parsed.image_data : [], + }; + + return normalized; +} + +export async function loadDatabaseSnapshot() { + const snapshotPath = resolveSnapshotPath(); + + if (cachedSnapshot && cachedSnapshotPath === snapshotPath) { + return cachedSnapshot; + } + + const raw = await readFile(snapshotPath, 'utf8'); + const parsed = JSON.parse(raw); + cachedSnapshot = normalizeSnapshot(parsed); + cachedSnapshotPath = snapshotPath; + return cachedSnapshot; +} diff --git a/api/scripts/seeders/seedAgentTokens.js b/api/scripts/seeders/seedAgentTokens.js index 1f933fb5..bdda52d8 100644 --- a/api/scripts/seeders/seedAgentTokens.js +++ b/api/scripts/seeders/seedAgentTokens.js @@ -1,11 +1,12 @@ -import { db } from '../../lib/db/index.js'; +import { db, client } from '../../lib/db/index.js'; import { AGENT_TOKENS } from '../../lib/db/schema.js'; +import { fileURLToPath } from 'node:url'; export async function seedAgentTokens() { console.log(' 📝 Seeding agent tokens...'); try { - await db.insert(AGENT_TOKENS).values([ + const values = [ { user_id: 5, project_id: 1, @@ -42,7 +43,9 @@ export async function seedAgentTokens() { token: '660e8400-e29b-41d4-a716-446655440106', label: 'Spec builder agent ONLY access token', }, - ]); + ]; + + await db.insert(AGENT_TOKENS).values(values).onConflictDoNothing(); console.log(' ✅ Agent tokens seeded successfully'); } catch (error) { @@ -50,3 +53,17 @@ export async function seedAgentTokens() { throw error; } } + +async function runFromCli() { + try { + await seedAgentTokens(); + } catch (error) { + process.exitCode = 1; + } finally { + await client.end(); + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + runFromCli(); +} diff --git a/api/scripts/seeders/seedDatabaseSnapshot.js b/api/scripts/seeders/seedDatabaseSnapshot.js new file mode 100644 index 00000000..9d5b4972 --- /dev/null +++ b/api/scripts/seeders/seedDatabaseSnapshot.js @@ -0,0 +1,116 @@ +import { db } from '../../lib/db/index.js'; +import { + USERS, + STATUS_DEFINITIONS, + COORDINATION_TYPES, + TRANSLATIONS, + TAGS, + PROJECTS, + AGENT_TOKENS, + DELIVERABLES, + TASKS, + TASK_TAGS, + TASK_RELATIONS, + IMAGE_METADATA, + IMAGE_DATA, +} from '../../lib/db/schema.js'; +import { sql } from 'drizzle-orm'; +import { loadDatabaseSnapshot } from './databaseSnapshot.js'; + +const dateFieldsByKey = { + users: ['created_at', 'updated_at'], + status_definitions: ['created_at', 'updated_at'], + coordination_types: ['created_at', 'updated_at'], + translations: ['created_at', 'updated_at'], + tags: ['created_at'], + projects: ['created_at', 'updated_at'], + agent_tokens: ['created_at'], + deliverables: ['approved_at', 'created_at', 'updated_at'], + tasks: ['started_at', 'completed_at', 'created_at', 'updated_at'], + task_relations: ['updated_at'], + image_metadata: ['created_at'], +}; + +const insertOrder = [ + { key: 'users', label: 'users', table: USERS }, + { key: 'status_definitions', label: 'status definitions', table: STATUS_DEFINITIONS }, + { key: 'coordination_types', label: 'coordination types', table: COORDINATION_TYPES }, + { key: 'translations', label: 'translations', table: TRANSLATIONS }, + { key: 'tags', label: 'tags', table: TAGS }, + { key: 'projects', label: 'projects', table: PROJECTS }, + { key: 'agent_tokens', label: 'agent tokens', table: AGENT_TOKENS }, + { key: 'deliverables', label: 'deliverables', table: DELIVERABLES }, + { key: 'tasks', label: 'tasks', table: TASKS }, + { key: 'task_tags', label: 'task tags', table: TASK_TAGS }, + { key: 'task_relations', label: 'task relations', table: TASK_RELATIONS }, + { key: 'image_metadata', label: 'image metadata', table: IMAGE_METADATA }, + { key: 'image_data', label: 'image data', table: IMAGE_DATA }, +]; + +const sequenceTables = [ + 'USERS', + 'TRANSLATIONS', + 'PROJECTS', + 'AGENT_TOKENS', + 'DELIVERABLES', + 'TASKS', + 'IMAGE_METADATA', +]; + +function convertDateFields(record, dateFields = []) { + const converted = { ...record }; + + for (const field of dateFields) { + if (converted[field]) { + converted[field] = new Date(converted[field]); + } + } + + return converted; +} + +async function insertSnapshotRows(key, label, table, rows) { + if (!rows.length) { + console.log(` ⏭️ No ${label} in snapshot. Skipping.`); + return 0; + } + + const preparedRows = rows.map((row) => convertDateFields(row, dateFieldsByKey[key])); + await db.insert(table).values(preparedRows); + console.log(` ✅ Seeded ${preparedRows.length} ${label}`); + return preparedRows.length; +} + +async function resetSequence(tableName) { + const query = ` + SELECT setval( + pg_get_serial_sequence('"${tableName}"', 'id'), + COALESCE((SELECT MAX("id") FROM "${tableName}"), 1), + (SELECT COUNT(*) > 0 FROM "${tableName}") + ); + `; + + await db.execute(sql.raw(query)); +} + +export async function seedDatabaseSnapshot() { + console.log(' 📝 Seeding from full database snapshot...'); + const snapshot = await loadDatabaseSnapshot(); + const counts = {}; + + for (const entry of insertOrder) { + counts[entry.key] = await insertSnapshotRows( + entry.key, + entry.label, + entry.table, + snapshot[entry.key] + ); + } + + for (const tableName of sequenceTables) { + await resetSequence(tableName); + } + + console.log(' ✅ Full database snapshot seeded successfully'); + return counts; +} diff --git a/api/scripts/seeders/seedProjects.js b/api/scripts/seeders/seedProjects.js index fb3d6254..a5bb3a58 100644 --- a/api/scripts/seeders/seedProjects.js +++ b/api/scripts/seeders/seedProjects.js @@ -2,12 +2,38 @@ import { db } from '../../lib/db/index.js'; import { PROJECTS } from '../../lib/db/schema.js'; import { loadZazzProjectSnapshot } from './zazzSnapshot.js'; +function toDateOrNull(value) { + return value ? new Date(value) : null; +} + +function deriveFallbackProjectTimestamp(snapshot) { + const timestamps = [ + snapshot?.project?.created_at, + snapshot?.project?.updated_at, + ...(Array.isArray(snapshot?.deliverables) + ? snapshot.deliverables.flatMap((deliverable) => [deliverable.created_at, deliverable.updated_at]) + : []), + ...(Array.isArray(snapshot?.tasks) + ? snapshot.tasks.flatMap((task) => [task.created_at, task.updated_at, task.completed_at, task.started_at]) + : []), + ] + .filter(Boolean) + .map((value) => new Date(value)) + .filter((value) => !Number.isNaN(value.getTime())) + .sort((left, right) => left.getTime() - right.getTime()); + + return timestamps[0] ?? new Date('2026-03-04T23:01:41.016Z'); +} + export async function seedProjects() { console.log(' 📝 Seeding projects...'); try { const snapshot = await loadZazzProjectSnapshot(); const zazzProject = snapshot.project; const zedLeaderId = zazzProject.leader_id === 2 ? 3 : 2; + const fallbackProjectTimestamp = deriveFallbackProjectTimestamp(snapshot); + const zazzCreatedAt = toDateOrNull(zazzProject.created_at) || fallbackProjectTimestamp; + const zazzUpdatedAt = toDateOrNull(zazzProject.updated_at) || zazzCreatedAt; await db.insert(PROJECTS).values([ { @@ -20,7 +46,9 @@ export async function seedProjects() { deliverable_status_workflow: Array.isArray(zazzProject.deliverable_status_workflow) ? zazzProject.deliverable_status_workflow : ['PLANNING', 'IN_PROGRESS', 'IN_REVIEW', 'STAGED', 'DONE'], task_graph_layout_direction: zazzProject.task_graph_layout_direction || 'LR', completion_criteria_status: zazzProject.completion_criteria_status || 'COMPLETED', - created_by: zazzProject.created_by || 5 + created_by: zazzProject.created_by || 5, + created_at: zazzCreatedAt, + updated_at: zazzUpdatedAt, }, { title: 'Zed Mermaid', @@ -32,7 +60,9 @@ export async function seedProjects() { deliverable_status_workflow: ['PLANNING', 'IN_PROGRESS', 'IN_REVIEW', 'STAGED', 'DONE'], task_graph_layout_direction: 'LR', completion_criteria_status: 'COMPLETED', - created_by: zedLeaderId + created_by: zedLeaderId, + created_at: fallbackProjectTimestamp, + updated_at: fallbackProjectTimestamp, } ]); console.log(' ✅ Projects seeded successfully'); diff --git a/client/src/components/AgentTokensModal.jsx b/client/src/components/AgentTokensModal.jsx index 2f3fbf28..83eed0c5 100644 --- a/client/src/components/AgentTokensModal.jsx +++ b/client/src/components/AgentTokensModal.jsx @@ -85,7 +85,7 @@ function TokenRow({ loading={createLoading} onClick={() => onCreate(group.userId)} > - {t('projects.agentTokens.createButton')} + {t('projects.agentTokens.createAction')} @@ -114,9 +114,9 @@ function TokenRow({ variant="light" size="xs" color={copied ? 'green' : 'blue'} - leftSection={copied ? : } - onClick={copy} - > + leftSection={copied ? : } + onClick={copy} + > {copied ? copiedLabel : t('projects.agentTokens.copyAction')} )} @@ -185,7 +185,7 @@ function TokenRow({ export function AgentTokensModal({ opened, onClose, selectedProject, currentUser }) { const { t } = useTranslation(); - const { userGroups, loading, error, isLeader, createAgentToken, deleteAgentToken } = useAgentTokens( + const { userGroups, loading, error, createAgentToken, deleteAgentToken } = useAgentTokens( selectedProject, currentUser, opened, @@ -211,9 +211,10 @@ export function AgentTokensModal({ opened, onClose, selectedProject, currentUser const visibleGroups = useMemo(() => { if (!Array.isArray(userGroups)) return []; - if (isLeader) return userGroups; - return userGroups.slice(0, 1); - }, [isLeader, userGroups]); + // Always show only current user's tokens + if (!currentUser?.id) return []; + return userGroups.filter((g) => String(g.userId) === String(currentUser.id)); + }, [userGroups, currentUser]); const handleCreateLabelChange = (userId, value) => { setCreateLabels((current) => ({ @@ -229,7 +230,7 @@ export function AgentTokensModal({ opened, onClose, selectedProject, currentUser try { const created = await createAgentToken({ - userId: isLeader ? userId : undefined, + userId: undefined, label: createLabels[userId]?.trim() || undefined, }); setCreatedToken(created); @@ -250,7 +251,7 @@ export function AgentTokensModal({ opened, onClose, selectedProject, currentUser try { await deleteAgentToken({ - userId: isLeader ? userId : undefined, + userId: undefined, tokenId, }); setDeleteState(null); @@ -262,16 +263,14 @@ export function AgentTokensModal({ opened, onClose, selectedProject, currentUser }; const title = selectedProject - ? t('projects.agentTokens.titleWithProject', { project: selectedProject.title }) + ? t('projects.agentTokens.titleWithProject', { projectCode: selectedProject.code }) : t('projects.agentTokens.title'); return ( - {isLeader - ? t('projects.agentTokens.subtitleLeader') - : t('projects.agentTokens.subtitleSelf')} + {t('projects.agentTokens.subtitleSelf')} {createdToken && ( @@ -318,7 +317,7 @@ export function AgentTokensModal({ opened, onClose, selectedProject, currentUser {t('projects.agentTokens.loading')} ) : visibleGroups.length === 0 ? ( - {isLeader ? t('projects.agentTokens.emptyLeader') : t('projects.agentTokens.emptySelf')} + {t('projects.agentTokens.emptySelf')} ) : ( diff --git a/client/src/components/ProjectList.jsx b/client/src/components/ProjectList.jsx index e228d559..c8f76810 100644 --- a/client/src/components/ProjectList.jsx +++ b/client/src/components/ProjectList.jsx @@ -1,7 +1,6 @@ import { Table, Text, Group, ActionIcon, Tooltip, Box } from '@mantine/core'; import { IconCalendar, IconEdit, IconKey } from '@tabler/icons-react'; import { useTranslation } from '../hooks/useTranslation.js'; -import { useEffect } from 'react'; export function ProjectList({ projects, @@ -12,17 +11,7 @@ export function ProjectList({ onManageAgentTokens, }) { const { t } = useTranslation(); - - // Debug info when project list renders - useEffect(() => { - if (!loading && projects) { - console.log('=== PROJECTS DEBUG ==='); - console.log('Projects:', projects.length); - console.log('LOCALE:', navigator.language); - console.log('====================='); - } - }, [projects, loading]); - + if (loading) { return {t('common.loading')}; } @@ -32,9 +21,7 @@ export function ProjectList({ } const formatDate = (dateString) => { - const locale = navigator.language; - console.log('LOCALE:', locale); - return new Date(dateString).toLocaleDateString(locale, { + return new Date(dateString).toLocaleDateString(navigator.language, { year: 'numeric', month: '2-digit', day: '2-digit' @@ -42,14 +29,15 @@ export function ProjectList({ }; return ( - +
+
{t('tasks.title')} {t('projects.leader')} {t('projects.createdAt')} - + @@ -100,25 +88,24 @@ export function ProjectList({ {formatDate(project.createdAt)} - + - - { - e.stopPropagation(); - onManageAgentTokens?.(project); - }} - aria-label="Manage agent tokens" - disabled={!onManageAgentTokens} - > - - - - { + e.stopPropagation(); + onManageAgentTokens?.(project); + }} + aria-label={t('projects.agentTokens.title')} + > + + + { e.stopPropagation(); onProjectEdit(project); @@ -133,5 +120,6 @@ export function ProjectList({ ))}
+ ); } diff --git a/client/src/hooks/useAgentTokens.js b/client/src/hooks/useAgentTokens.js index c9296f61..3f1677a0 100644 --- a/client/src/hooks/useAgentTokens.js +++ b/client/src/hooks/useAgentTokens.js @@ -36,11 +36,6 @@ export function useAgentTokens(selectedProject, currentUser, opened) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const isLeader = useMemo(() => { - if (!selectedProject || !currentUser) return false; - return String(selectedProject.leaderId) === String(currentUser.id); - }, [selectedProject, currentUser]); - const refreshAgentTokens = useCallback(async () => { if (!selectedProject || !opened) { setUserGroups([]); @@ -51,11 +46,10 @@ export function useAgentTokens(selectedProject, currentUser, opened) { setLoading(true); setError(null); try { - const url = isLeader - ? `http://localhost:3030/projects/${selectedProject.code}/agent-tokens` - : `http://localhost:3030/projects/${selectedProject.code}/users/me/agent-tokens`; + // Always fetch only current user's tokens (no leader "all users" view) + const url = `http://localhost:3030/projects/${selectedProject.code}/users/me/agent-tokens`; const data = await fetchJson(url, { method: 'GET' }); - const groups = Array.isArray(data?.users) ? data.users : data ? [data] : []; + const groups = data ? [data] : []; setUserGroups(groups); } catch (error) { console.error('Error fetching agent tokens:', error); @@ -64,7 +58,7 @@ export function useAgentTokens(selectedProject, currentUser, opened) { } finally { setLoading(false); } - }, [isLeader, opened, selectedProject]); + }, [opened, selectedProject]); useEffect(() => { refreshAgentTokens(); @@ -105,7 +99,6 @@ export function useAgentTokens(selectedProject, currentUser, opened) { userGroups, loading, error, - isLeader, refreshAgentTokens, createAgentToken, deleteAgentToken, diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 96632734..98822b89 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -70,7 +70,7 @@ }, "agentTokens": { "title": "Agent-Tokens", - "titleWithProject": "{{project}} Agent-Tokens", + "titleWithProject": "{{projectCode}} Projekt-Agent-Zugangstokens", "subtitleLeader": "Erstellen und widerrufen Sie projektspezifische Agent-Tokens fur die Mitglieder dieses Projekts.", "subtitleSelf": "Erstellen und widerrufen Sie Ihre projektspezifischen Agent-Tokens fur dieses Projekt.", "loading": "Agent-Tokens werden geladen...", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 6cdc8cd5..45911251 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -71,7 +71,7 @@ }, "agentTokens": { "title": "Agent Tokens", - "titleWithProject": "{{project}} Agent Tokens", + "titleWithProject": "{{projectCode}} Project Agent Access Tokens", "subtitleLeader": "Create and revoke project-scoped agent tokens for team members in this project.", "subtitleSelf": "Create and revoke your project-scoped agent tokens for this project.", "loading": "Loading agent tokens...", diff --git a/client/src/i18n/locales/es.json b/client/src/i18n/locales/es.json index 2c0ce3ef..13c90db5 100644 --- a/client/src/i18n/locales/es.json +++ b/client/src/i18n/locales/es.json @@ -70,7 +70,7 @@ }, "agentTokens": { "title": "Tokens de Agente", - "titleWithProject": "Tokens de Agente de {{project}}", + "titleWithProject": "{{projectCode}} Tokens de Acceso de Agente del Proyecto", "subtitleLeader": "Crea y revoca tokens de agente con alcance al proyecto para los miembros de este proyecto.", "subtitleSelf": "Crea y revoca tus tokens de agente con alcance a este proyecto.", "loading": "Cargando tokens de agente...", diff --git a/client/src/i18n/locales/fr.json b/client/src/i18n/locales/fr.json index 348c97df..1b4dcd39 100644 --- a/client/src/i18n/locales/fr.json +++ b/client/src/i18n/locales/fr.json @@ -70,7 +70,7 @@ }, "agentTokens": { "title": "Tokens d'Agent", - "titleWithProject": "Jetons d'Agent de {{project}}", + "titleWithProject": "{{projectCode}} Jetons d'Accès Agent du Projet", "subtitleLeader": "Creez et revoquez des tokens d'agent limites au projet pour les membres de ce projet.", "subtitleSelf": "Creez et revoquez vos tokens d'agent limites a ce projet.", "loading": "Chargement des tokens d'agent...", diff --git a/docs/database-baseline-refresh.md b/docs/database-baseline-refresh.md new file mode 100644 index 00000000..f8de2889 --- /dev/null +++ b/docs/database-baseline-refresh.md @@ -0,0 +1,153 @@ +# Database Baseline Refresh + +Use this process whenever a feature changes persistent database schema or adds new persisted baseline data, and you want to preserve the real accumulated dev data instead of resetting to synthetic starter rows. + +## Goal + +Carry the current development database forward to the latest schema, preserve all real board data already entered through the app, add any new branch-owned baseline rows, then freeze that upgraded state as the new canonical seed snapshot. + +This is not a throwaway seed reset workflow. It is a baseline refresh workflow. + +## What gets preserved + +The canonical snapshot includes persistent business/config data: + +- `USERS` +- `STATUS_DEFINITIONS` +- `COORDINATION_TYPES` +- `TRANSLATIONS` +- `TAGS` +- `PROJECTS` +- `AGENT_TOKENS` +- `DELIVERABLES` +- `TASKS` +- `TASK_TAGS` +- `TASK_RELATIONS` +- `IMAGE_METADATA` +- `IMAGE_DATA` + +The snapshot intentionally excludes `FILE_LOCKS` because lock leases are operational state, not durable product state. + +## Files and commands + +Canonical snapshot: + +- `api/scripts/seeders/data/database-snapshot.json` + +Backup snapshot pattern: + +- `api/scripts/seeders/data/database-snapshot.pre-upgrade.json` + +Raw rollback dump pattern: + +- `/tmp/zazz_board_db-pre-upgrade.sql` + +Commands: + +```bash +cd api + +# Export current DB to canonical snapshot +npm run db:export-snapshot + +# Export a dated/pre-upgrade backup snapshot +node scripts/export-database-snapshot.js scripts/seeders/data/database-snapshot.pre-upgrade.json + +# Rebuild the current DB from the canonical snapshot on latest schema +npm run db:reset + +# Add branch-owned baseline rows introduced by the new feature +npm run db:seed-agent-tokens +``` + +## Standard refresh process + +1. Back up the current dev DB before any destructive action. + + Create both: + - JSON snapshot backup using `node scripts/export-database-snapshot.js scripts/seeders/data/database-snapshot.pre-upgrade.json` + - raw SQL dump using `pg_dump` or `docker exec zazz_board_postgres pg_dump ...` + +2. Export the current dev DB into the canonical snapshot file. + + Run: + + ```bash + cd api + npm run db:export-snapshot + ``` + + At this point the repo contains the current real dev data, even if the running DB is still on an older schema. + +3. Rebuild the dev DB on the latest schema from that snapshot. + + Run: + + ```bash + cd api + npm run db:reset + ``` + + `db:reset` now: + - drops/recreates schema from `api/lib/db/schema.js` + - seeds from `api/scripts/seeders/data/database-snapshot.json` + - preserves explicit IDs and resets sequences afterward + +4. Add any new branch baseline rows that did not exist in the old DB yet. + + Example for ZAZZ-6: + + ```bash + cd api + npm run db:seed-agent-tokens + ``` + + This step is for new persisted feature data that should exist in the refreshed baseline even if the old DB predates the table. + +5. Freeze the upgraded DB back into the canonical snapshot. + + Run: + + ```bash + cd api + npm run db:export-snapshot + ``` + + Now `database-snapshot.json` represents the real upgraded baseline. + +6. Prove the snapshot round-trips. + + Run: + + ```bash + cd api + npm run db:reset + ``` + + Verify expected counts or core rows still exist. + +7. Refresh the test DB and run the full backend suite. + + Example: + + ```bash + cd api + DATABASE_URL=postgres://postgres:password@localhost:5433/zazz_board_test npm run db:reset + set -a && source .env && set +a && NODE_ENV=test npm run test + ``` + +## Why this is the standard + +- It preserves real deliverables, task history, task relations, and other valid dev/test data entered through the UI. +- It avoids drifting back to hardcoded synthetic seed data. +- It keeps schema, seed baseline, and test fixtures aligned. +- It makes future database-affecting features incremental: export current truth, upgrade, add new baseline rows, re-export. + +## Current implementation notes + +- Snapshot exporter: `api/scripts/export-database-snapshot.js` +- Snapshot loader: `api/scripts/seeders/databaseSnapshot.js` +- Snapshot importer: `api/scripts/seeders/seedDatabaseSnapshot.js` +- Agent token baseline seed: `api/scripts/seeders/seedAgentTokens.js` + +`seedAgentTokens.js` is idempotent and safe to rerun because it inserts with conflict-ignore semantics. diff --git a/docs/sample-worker-multi-agent-prompt-CODEX.md b/docs/sample-worker-multi-agent-prompt-CODEX.md new file mode 100644 index 00000000..cb8784c3 --- /dev/null +++ b/docs/sample-worker-multi-agent-prompt-CODEX.md @@ -0,0 +1,19 @@ +Execute deliverable ZAZZ-6 from: +- SPEC: /Users/michael/Dev/zazz-board/multiple-agent-tokens-feature/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-SPEC.md +- PLAN: /Users/michael/Dev/zazz-board/multiple-agent-tokens-feature/.zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-PLAN.md + +Execution requirements: +1. Use multi-agent execution with 3 subagents in parallel whenever dependencies allow. +2. Enforce strict disjoint file ownership per subagent. If file ownership overlaps, serialize those tasks. +3. Use the worker-agent and zazz-board-api skills and the zazzctl CLI for board operations. +4. Apply harness-aware locking policy: + - If subagents are isolated with disjoint ownership + parent-controlled merges, API file locks may be skipped for those internal subagents. + - If any external concurrency risk is detected, use API file locks via zazzctl exec begin/tick/complete. +5. Parent agent must integrate/merge subagent outputs, run required tests, and only then update board statuses. +6. Keep board truth synced continuously (task creation, relations, status, blockers, notes). +7. Run full verification from the PLAN test matrix before declaring done. + +Process: +- Start with a brief execution map: which PLAN steps are assigned to which subagent and file ownership per subagent. +- Then execute. +- Report progress after each phase and include blockers/decisions immediately. Blocked tasks should be updated using the zazzctl CLI diff --git a/package-lock.json b/package-lock.json index 5d75e8ca..da5a3105 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "devDependencies": { "concurrently": "^9.1.0", "drizzle-orm": "^0.45.1", - "pactum-supertest": "^1.0.0" + "pactum-supertest": "^1.0.0", + "playwright": "^1.58.2" } }, "api": { @@ -1410,9 +1411,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz", - "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -1424,9 +1425,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz", - "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -1438,9 +1439,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz", - "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -1452,9 +1453,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz", - "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -1466,9 +1467,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz", - "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -1480,9 +1481,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz", - "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -1494,9 +1495,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz", - "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -1508,9 +1509,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz", - "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -1522,9 +1523,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz", - "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -1536,9 +1537,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz", - "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -1549,10 +1550,24 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz", - "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -1564,9 +1579,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz", - "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -1578,9 +1607,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz", - "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -1592,9 +1621,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz", - "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -1606,9 +1635,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz", - "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -1620,9 +1649,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz", - "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -1634,9 +1663,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz", - "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -1647,10 +1676,38 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz", - "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -1662,9 +1719,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz", - "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -1675,10 +1732,24 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz", - "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -2684,9 +2755,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastify": { - "version": "5.7.4", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", - "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.2.tgz", + "integrity": "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==", "funding": [ { "type": "github", @@ -2708,7 +2779,7 @@ "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", - "pino": "^10.1.0", + "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", @@ -3265,9 +3336,9 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" @@ -3534,6 +3605,53 @@ "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/polka": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/polka/-/polka-0.5.2.tgz", @@ -3673,9 +3791,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", - "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -3689,26 +3807,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.46.2", - "@rollup/rollup-android-arm64": "4.46.2", - "@rollup/rollup-darwin-arm64": "4.46.2", - "@rollup/rollup-darwin-x64": "4.46.2", - "@rollup/rollup-freebsd-arm64": "4.46.2", - "@rollup/rollup-freebsd-x64": "4.46.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", - "@rollup/rollup-linux-arm-musleabihf": "4.46.2", - "@rollup/rollup-linux-arm64-gnu": "4.46.2", - "@rollup/rollup-linux-arm64-musl": "4.46.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", - "@rollup/rollup-linux-ppc64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-musl": "4.46.2", - "@rollup/rollup-linux-s390x-gnu": "4.46.2", - "@rollup/rollup-linux-x64-gnu": "4.46.2", - "@rollup/rollup-linux-x64-musl": "4.46.2", - "@rollup/rollup-win32-arm64-msvc": "4.46.2", - "@rollup/rollup-win32-ia32-msvc": "4.46.2", - "@rollup/rollup-win32-x64-msvc": "4.46.2", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, diff --git a/package.json b/package.json index d78f5df5..8f6968ef 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "devDependencies": { "concurrently": "^9.1.0", "drizzle-orm": "^0.45.1", - "pactum-supertest": "^1.0.0" + "pactum-supertest": "^1.0.0", + "playwright": "^1.58.2" }, "repository": { "type": "git", diff --git a/scripts/screenshot-project-page.mjs b/scripts/screenshot-project-page.mjs new file mode 100644 index 00000000..343c270e --- /dev/null +++ b/scripts/screenshot-project-page.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +/** + * Captures a screenshot of the project page with token pre-set. + * Run: npx playwright run scripts/screenshot-project-page.mjs + * Or: node scripts/screenshot-project-page.mjs (uses playwright programmatically) + */ +import { chromium } from 'playwright'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TOKEN = '550e8400-e29b-41d4-a716-446655440000'; +const BASE = 'http://localhost:3001'; +const OUT = join(__dirname, '../project-page-screenshot.png'); + +async function main() { + const browser = await chromium.launch({ + headless: true, + channel: 'chrome', // Use system Chrome if installed + }); + const context = await browser.newContext(); + const page = await context.newPage(); + + // Set token before any navigation so app sees it on load + await context.addInitScript((token) => { + localStorage.setItem('TB_TOKEN', token); + }, TOKEN); + + await page.goto(BASE, { waitUntil: 'networkidle' }); + // Home page shows projects; ensure we're there + await page.waitForSelector('table', { timeout: 5000 }).catch(() => {}); + await page.screenshot({ path: OUT, fullPage: true }); + console.log('Screenshot saved to', OUT); + + await browser.close(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +});