diff --git a/.compozy/tasks/core-tasks/_meta.md b/.compozy/tasks/core-tasks/_meta.md new file mode 100644 index 000000000..af011923f --- /dev/null +++ b/.compozy/tasks/core-tasks/_meta.md @@ -0,0 +1,9 @@ +--- +created_at: 2026-04-14T03:01:13.414102Z +updated_at: 2026-04-14T13:36:33.887141Z +--- + +## Summary +- Total: 13 +- Completed: 13 +- Pending: 0 diff --git a/.compozy/tasks/core-tasks/_tasks.md b/.compozy/tasks/core-tasks/_tasks.md index db164faad..65e4f4095 100644 --- a/.compozy/tasks/core-tasks/_tasks.md +++ b/.compozy/tasks/core-tasks/_tasks.md @@ -4,16 +4,16 @@ | # | Title | Status | Complexity | Dependencies | |---|-------|--------|------------|--------------| -| 01 | Bootstrap the `internal/task` domain | pending | high | — | -| 02 | Persist core task and run records in `globaldb` | pending | high | task_01 | -| 03 | Persist task dependencies, audit trail, and idempotency | pending | high | task_01, task_02 | -| 04 | Implement `TaskManager` creation, mutation, and identity rules | pending | critical | task_01, task_02, task_03 | -| 05 | Implement `TaskRun` lifecycle and propagated cancellation | pending | critical | task_04 | -| 06 | Wire the session bridge, dedicated subtask sessions, and boot recovery | pending | critical | task_01, task_05 | -| 07 | Add task and run API contracts plus core handlers | pending | high | task_04, task_05 | -| 08 | Expose task and run routes through HTTP and UDS | pending | medium | task_07 | -| 09 | Add the `agh task` CLI command group | pending | medium | task_08 | -| 10 | Integrate automation with task-backed work items | pending | high | task_05, task_06 | -| 11 | Integrate extension host APIs with the task domain | pending | high | task_05, task_06 | -| 12 | Integrate network ingress and channel binding for tasks | pending | high | task_05, task_06 | -| 13 | Add observe projections, health queries, and task metrics | pending | high | task_05, task_06, task_12 | +| 01 | Bootstrap the `internal/task` domain | completed | high | — | +| 02 | Persist core task and run records in `globaldb` | completed | high | task_01 | +| 03 | Persist task dependencies, audit trail, and idempotency | completed | high | task_01, task_02 | +| 04 | Implement `TaskManager` creation, mutation, and identity rules | completed | critical | task_01, task_02, task_03 | +| 05 | Implement `TaskRun` lifecycle and propagated cancellation | completed | critical | task_04 | +| 06 | Wire the session bridge, dedicated subtask sessions, and boot recovery | completed | critical | task_01, task_05 | +| 07 | Add task and run API contracts plus core handlers | completed | high | task_04, task_05 | +| 08 | Expose task and run routes through HTTP and UDS | completed | medium | task_07 | +| 09 | Add the `agh task` CLI command group | completed | medium | task_08 | +| 10 | Integrate automation with task-backed work items | completed | high | task_05, task_06 | +| 11 | Integrate extension host APIs with the task domain | completed | high | task_05, task_06 | +| 12 | Integrate network ingress and channel binding for tasks | completed | high | task_05, task_06 | +| 13 | Add observe projections, health queries, and task metrics | completed | high | task_05, task_06, task_12 | diff --git a/.compozy/tasks/core-tasks/memory/MEMORY.md b/.compozy/tasks/core-tasks/memory/MEMORY.md new file mode 100644 index 000000000..5472c86fb --- /dev/null +++ b/.compozy/tasks/core-tasks/memory/MEMORY.md @@ -0,0 +1,40 @@ +# Workflow Memory + +Keep only durable, cross-task context here. Do not duplicate facts that are obvious from the repository, PRD documents, or git history. + +## Current State +- `task_02` added durable `tasks` and `task_runs` persistence to `globaldb`; later task-manager/API work can rely on those store surfaces existing behind `internal/store/globaldb`. +- `task_03` added durable `task_dependencies`, `task_events`, and `task_run_idempotency` persistence to `globaldb`; later task-manager/network/observe work can rely on those store surfaces existing behind `internal/store/globaldb`. +- `task_05` added manager-owned `TaskRun` lifecycle methods (`enqueue`, `claim`, `start`, `attach`, `complete`, `fail`, `cancel`) plus tree cancellation and canonical task reconciliation in `internal/task`; later daemon/API/observe work should call these lifecycle methods instead of mutating run or task status directly. +- `task_06` now boots a daemon-owned `TaskManager` into `RuntimeDeps.Tasks` with an injected session bridge; executable task runs allocate dedicated system sessions by default, explicit attach remains validated in `internal/task`, and daemon boot reconciles persisted `claimed`/`starting`/`running` runs against live session state before servers start. +- `task_07` added shared task/run contracts in `internal/api/contract/tasks.go` plus transport-agnostic core handlers in `internal/api/core/tasks.go`; follow-on HTTP/UDS/CLI work should reuse those contracts/handlers instead of reimplementing task parsing, payload conversion, or task-domain error mapping. +- `task_08` exposed the full task/task-run route inventory through both `internal/api/httpapi` and `internal/api/udsapi`; both server constructors now fail fast without an injected `TaskService`, and daemon transport wiring passes `RuntimeDeps.Tasks` into both factories so follow-on CLI/web work should call those transport routes instead of bypassing the shared API surface. +- `task_09` added the daemon-backed `agh task` CLI surface in `internal/cli`, including task create/list/get/update/cancel, child/dependency commands, and run lifecycle commands; follow-on CLI work should extend `internal/cli/task.go` and the shared `DaemonClient` task/run transport methods instead of bypassing UDS routes. +- `task_10` added explicit automation/task integration without making tasks the universal automation wrapper: automation jobs may opt into `job.task`, direct task-backed jobs materialize/enqueue work through `internal/task`, and linked `automation_runs` persist as `delegated` activation records with `task_id` / `task_run_id`. +- `task_11` added extension Host API task surfaces for list/create/get/update/cancel and task-run lifecycle flows; extension task access is now explicitly capability-gated and routes through the daemon-owned `TaskManager` instead of direct store mutation. +- `task_12` added capability-gated network task ingress in `internal/network`: authenticated remote peers now create/update/cancel tasks and enqueue runs only through the daemon-owned `TaskManager`, task ingress writes are audited through the existing network audit sinks, and stale/mismatched channel bindings are rejected before mutation. +- `task_13` added read-side task observability in `internal/observe`: task summary, metrics, and health views now aggregate durable `tasks`, `task_runs`, `task_events`, and `network_audit_log` data plus live session liveness while keeping observe read-side only. + +## Shared Decisions +- `globaldb` task writes preflight missing workspace and task references before insert/update paths so callers get `workspace.ErrWorkspaceNotFound` or `task.ErrTaskNotFound` instead of raw SQLite foreign-key failures. +- `task_runs.session_id` is persisted as nullable text without a sessions foreign key, matching the existing automation-run pattern and allowing run/session audit records to survive independently from live session registry state. +- `TaskEvent` persistence includes immutable origin metadata (`origin_kind`, `origin_ref`) alongside actor metadata so later lifecycle and observe work can audit both who acted and which ingress surface produced the write. +- Task-run idempotency lookup/save is scoped by `(idempotency_key, origin_kind, origin_ref)` instead of a bare key so multi-writer replay protection does not collide across ingress surfaces. +- `GlobalDB.CreateDependency` owns the `BEGIN IMMEDIATE` transaction for dependency edge creation, including duplicate detection, per-task edge-limit enforcement, and cycle rejection under the same SQLite write lock. +- Reverse dependency reconciliation is a first-class store surface via `DependencyStore.ListDependents`; manager code uses it to eagerly recalculate downstream task status after dependency, run, and cancellation changes. +- Task cancellation follows a cooperative-then-forced model through `SessionExecutor`: the manager immediately marks queued/open runs cancelled, requests stop for active runs, and escalates to forced stop after the configured grace period while preserving audit events. +- The daemon-owned task/session bridge reuses the existing session manager surface only from `internal/daemon`; workspace-scoped dedicated task sessions bind by workspace ID, global-scoped task sessions bind by the daemon home path, and boot recovery status mutations still flow through task-manager methods rather than direct store writes. +- Automation-linked agent task creation uses `created_by.kind=agent_session` with `origin.kind=automation` and `origin.ref=run:`; future automation, extension, or network ingress work should preserve that separation between actor identity and ingress origin instead of collapsing both to the same session identity. +- Extension-originated task writes derive `created_by` and immutable `origin` server-side from the trusted extension host context via `task.DeriveExtensionActorContext`; follow-on ingress work should preserve this server-owned identity/origin model and ignore payload-supplied actor metadata. +- Network-originated task writes derive `created_by` and immutable `origin` server-side from the authenticated peer/channel context via `task.DeriveNetworkPeerActorContext`; request IDs remain audit metadata and do not change task-run idempotency scope. +- Adding or changing extension Host API method schemas requires regenerating derived API artifacts with `make codegen` so `openapi/agh.json` and generated SDK contracts stay in sync. +- `internal/task.TaskManager` now supports an injected network-channel validator; daemon wiring passes `network.ValidateChannel`, stale task bindings may only be cleared/repaired through network ingress, and stale task/run channel snapshots are rejected before run start or session attach while recording `task.run_rejected`. +- The daemon-backed CLI integration harness in `internal/cli/cli_integration_test.go` now boots a real task manager and its bridge stub must satisfy `observe.BridgeSource`, including `DeliveryMetrics()`, so future end-to-end CLI task coverage should reuse that fixture shape instead of introducing parallel task-only harnesses. + +## Shared Learnings +- Task health now exposes stuck claimed/starting/running runs, queue depth, duplicate-ingress totals, channel-mismatch totals, forced-stop totals, and recovery outcomes through `internal/observe`; transport-layer mapping for those fields remains separate follow-on work if external health APIs need them. + +## Open Risks +- No known shared verification blocker after `make verify` passed on 2026-04-14 during `task_05`. + +## Handoffs diff --git a/.compozy/tasks/core-tasks/memory/task_01.md b/.compozy/tasks/core-tasks/memory/task_01.md new file mode 100644 index 000000000..bbf89ecce --- /dev/null +++ b/.compozy/tasks/core-tasks/memory/task_01.md @@ -0,0 +1,44 @@ +# Task Memory: task_01.md + +Keep only task-local execution context here. Do not duplicate facts that are obvious from the repository, task file, PRD documents, or git history. + +## Objective Snapshot +- Bootstrap `internal/task` with canonical domain types, interfaces, limits, validation helpers, sentinel errors, and required tests. +- Completion gate: task-specific coverage plus `make verify`, then tracking updates and one local commit. +- Implementation, verification, tracking updates, and the local implementation commit are complete. + +## Important Decisions +- Pre-change signal is that `internal/task/` does not exist yet. +- Package must keep the session seam task-owned and injected; no `internal/session` import is allowed. +- Ownership is modeled separately from creator identity via `OwnerKind`/`Ownership`, while `created_by` and `origin` stay immutable server-derived structs. +- The package exports a task-owned `Manager`, aggregate `Store`, and `SessionExecutor` seam so downstream `globaldb`, `daemon`, and API tasks can depend on stable contracts. + +## Learnings +- ADR-005 locks `created_by` and `origin` as immutable, server-derived identity while ownership remains optional and mutable. +- ADR-006 requires a dedicated session bridge defined in `internal/task` for start/attach/request-stop/force-stop operations. +- Package-local test evidence: `go test ./internal/task`, `go test -cover ./internal/task` (82.2%), and `go test -tags integration ./internal/task` all pass. +- Repository gate evidence: `make verify` passed before tracking updates and again after commit hook formatting, validating commit `c1fb9f6`. + +## Files / Surfaces +- `.compozy/tasks/core-tasks/_techspec.md` +- `.compozy/tasks/core-tasks/task_01.md` +- `.compozy/tasks/core-tasks/adrs/adr-001.md` +- `.compozy/tasks/core-tasks/adrs/adr-005.md` +- `.compozy/tasks/core-tasks/adrs/adr-006.md` +- `internal/session/interfaces.go` +- `internal/daemon/boundary.go` +- `internal/task/doc.go` +- `internal/task/errors.go` +- `internal/task/interfaces.go` +- `internal/task/limits.go` +- `internal/task/types.go` +- `internal/task/validate.go` +- `internal/task/validate_test.go` +- `internal/task/interfaces_integration_test.go` + +## Errors / Corrections +- Initial package coverage was 53.7%, then 78.0%; additional branch-level validation tests raised it to 82.2% to satisfy the task gate. + +## Ready for Next Run +- Task 01 is ready to hand off. Downstream tasks should implement the exported `task.Store` and `task.SessionExecutor` contracts rather than introducing parallel task-domain interfaces. +- Local implementation commit: `c1fb9f6` (`feat: bootstrap task domain`). diff --git a/.compozy/tasks/core-tasks/memory/task_02.md b/.compozy/tasks/core-tasks/memory/task_02.md new file mode 100644 index 000000000..b572b8fe2 --- /dev/null +++ b/.compozy/tasks/core-tasks/memory/task_02.md @@ -0,0 +1,28 @@ +# Task Memory: task_02.md + +Keep only task-local execution context here. Do not duplicate facts that are obvious from the repository, task file, PRD documents, or git history. + +## Objective Snapshot +- Persist `internal/task` `Task` and `TaskRun` records in `internal/store/globaldb` with schema, CRUD/list/query support, and tests that satisfy the task_02 coverage/integration requirements. + +## Important Decisions +- Added task/run schema directly to `globaldb` bootstrap statements instead of a standalone migration because these are new tables with no legacy shape to transform. +- Implemented task/run persistence in a dedicated `internal/store/globaldb/global_db_task.go` file and kept task_03 surfaces (`dependencies`, `events`, idempotency store) out of scope. +- Reused `internal/task` validation and immutable-field helpers for canonical shape enforcement, then added store-level reference prechecks for missing workspace, parent task, and run task ids. +- `UpdateTaskRun` enforces single-assignment for `session_id` once a run is already bound. + +## Learnings +- Package coverage for `internal/store/globaldb` cleared the 80% target after adding focused tests for not-found/reference-error branches and normalization defaults; broad happy-path tests alone were not enough. +- Stable ordering assertions for task list limits require distinct timestamps because the list query orders by `updated_at`, `created_at`, then `id`. + +## Files / Surfaces +- `internal/store/globaldb/global_db.go` +- `internal/store/globaldb/global_db_task.go` +- `internal/store/globaldb/global_db_task_test.go` +- `internal/store/globaldb/global_db_task_integration_test.go` + +## Errors / Corrections +- Initial `ListTasks(limit)` test assumed a deterministic order while all fixture timestamps were identical; fixed by assigning distinct timestamps to the filter fixtures before re-running tests. + +## Ready for Next Run +- Task tracking still needs to be updated after verification/self-review, and the automatic local commit should stage only code files, not workflow memory or `.compozy` tracking files. diff --git a/.compozy/tasks/core-tasks/memory/task_03.md b/.compozy/tasks/core-tasks/memory/task_03.md new file mode 100644 index 000000000..8aa1d1ea2 --- /dev/null +++ b/.compozy/tasks/core-tasks/memory/task_03.md @@ -0,0 +1,35 @@ +# Task Memory: task_03.md + +Keep only task-local execution context here. Do not duplicate facts that are obvious from the repository, task file, PRD documents, or git history. + +## Objective Snapshot +- Persist bounded dependency edges, immutable task audit events, and origin-scoped task-run idempotency in `internal/store/globaldb`, with required unit/integration coverage and `make verify` passing. + +## Important Decisions +- Widened `task.TaskEvent` to persist immutable origin metadata alongside actor metadata because the task spec requires audit records to preserve both actor and origin. +- Widened the task idempotency contract to use `(idempotency_key, origin)` scoping instead of a bare key and added a `TaskRunIdempotency` record type for storage. +- Implemented dependency insertion with a dedicated SQLite `BEGIN IMMEDIATE` connection transaction so duplicate detection, edge-limit validation, cycle checks, and the insert happen under one write lock. + +## Learnings +- `internal/task` already had the graph/payload guardrails needed for this task, so the store layer could reuse those validations rather than introducing storage-only rules. +- Package coverage for `internal/store/globaldb` needed a few negative-path tests to clear the `>=80%` requirement; the final unit coverage is `80.0%`. + +## Files / Surfaces +- `internal/task/errors.go` +- `internal/task/interfaces.go` +- `internal/task/interfaces_integration_test.go` +- `internal/task/types.go` +- `internal/task/validate.go` +- `internal/task/validate_test.go` +- `internal/store/globaldb/global_db.go` +- `internal/store/globaldb/global_db_task_aux.go` +- `internal/store/globaldb/global_db_task_graph_audit_test.go` +- `internal/store/globaldb/global_db_task_graph_audit_integration_test.go` +- `internal/store/globaldb/global_db_task_test.go` + +## Errors / Corrections +- Initial package coverage landed at `79.5%`; added negative-path tests for dependency/audit/idempotency error branches to reach the required threshold. + +## Ready for Next Run +- Verification is clean: `go test ./internal/task ./internal/store/globaldb -count=1`, `go test ./internal/store/globaldb -cover -count=1`, `go test -tags integration ./internal/store/globaldb -count=1`, and a post-commit `make verify` all passed after the final code changes. +- Local code commit created: `d93aa60` (`feat: persist task dependency audit idempotency store`). diff --git a/.compozy/tasks/core-tasks/memory/task_04.md b/.compozy/tasks/core-tasks/memory/task_04.md new file mode 100644 index 000000000..5464da12b --- /dev/null +++ b/.compozy/tasks/core-tasks/memory/task_04.md @@ -0,0 +1,39 @@ +# Task Memory: task_04.md + +Keep only task-local execution context here. Do not duplicate facts that are obvious from the repository, task file, PRD documents, or git history. + +## Objective Snapshot +- Add a daemon-owned `TaskManager` in `internal/task` that becomes the canonical surface for task create/get/list/update/child/dependency flows and enforces server-derived identity plus manager-owned task status. +- Meet task_04 deliverables with unit and integration tests, >=80% package coverage, and clean verification. + +## Important Decisions +- Keep authorization in-domain using `task.ActorContext` (`Read`, `Write`, `CreateGlobal`, `CreateWorkspace`) rather than adding transport-specific state to the manager API. +- Add an explicit child-task manager operation so parent/child semantics stay centralized in `internal/task`. +- Treat `created_by` and `origin` as fully server-derived from `ActorContext`; create/update payloads remain unable to override them because those fields are absent from request structs and rewritten from trusted context in manager flows. +- Require `ActorContext` on manager read methods (`GetTask`, `ListTasks`) so the v1 principal-based read contract is enforceable inside `internal/task`, not deferred to transports. +- Enforce actor-kind/origin-kind pairing in `ActorContext.Validate()` so mismatched trusted contexts are rejected before any task mutation or read. +- Allow `global -> global|workspace` child relationships, but constrain `workspace -> workspace` children to the same workspace ID to keep cross-scope hierarchy rules bounded and legible. + +## Learnings +- Storage dependencies from tasks 02/03 already provide everything needed for this task: task CRUD/list, dependency graph helpers, audit events, task runs, and idempotency records all exist behind `internal/store/globaldb`. +- `ActorContext` already models the v1 authorization contract sufficiently for local human, agent session, automation, extension, and network writer surfaces. +- `UpdateTask` must reconcile canonical status against current dependencies and runs before persisting mutable field changes; otherwise blocked or in-progress tasks can be incorrectly forced back to `ready`. +- Package-level verification for this task is clean (`go test ./internal/task`, `go test ./internal/task -cover`, `go test -tags integration ./internal/task`), but repo-wide `make verify` is blocked by unrelated web tests. + +## Files / Surfaces +- `internal/task/` +- `internal/store/globaldb/global_db_task.go` +- `internal/store/globaldb/global_db_task_aux.go` +- `.codex/ledger/2026-04-14-MEMORY-task-manager-rules.md` +- `internal/task/actors.go` +- `internal/task/manager.go` +- `internal/task/manager_test.go` +- `internal/task/manager_integration_test.go` + +## Errors / Corrections +- Pre-change baseline: `internal/task` had interfaces/types/validation only; there was no concrete manager implementation or child-task operation yet. +- Self-review caught and fixed a real regression in the first manager draft: `UpdateTask` was recomputing status without loading current dependencies/runs, which would have incorrectly changed blocked tasks back to `ready`. +- Repo-wide verification failed outside this task’s surface because of unrelated web test failures in `message-bubble.test.tsx` and `-automation.integration.test.tsx`; do not mark task tracking complete or create the automatic commit until those external failures are resolved or explicitly waived. + +## Ready for Next Run +- If repo-wide verification becomes clean, update task tracking files for task 04 and create the single local commit required by the workflow. diff --git a/.compozy/tasks/core-tasks/memory/task_05.md b/.compozy/tasks/core-tasks/memory/task_05.md new file mode 100644 index 000000000..8b29aa556 --- /dev/null +++ b/.compozy/tasks/core-tasks/memory/task_05.md @@ -0,0 +1,38 @@ +# Task Memory: task_05.md + +Keep only task-local execution context here. Do not duplicate facts that are obvious from the repository, task file, PRD documents, or git history. + +## Objective Snapshot +- Add manager-owned `TaskRun` lifecycle methods for enqueue/claim/start/attach/complete/fail/cancel plus task-tree cancellation and canonical task reconciliation. +- Cover invalid transitions, reconciliation outputs, propagated cancellation, and real-storage lifecycle flows with unit and integration tests. + +## Important Decisions +- Use the approved task spec and techspec as the design baseline; no separate design artifact is needed for this implementation run. +- Keep cancellation detail in task events so task/run rows stay aligned with the current persisted schema. +- Drive all `TaskRun.status` changes through explicit manager transition helpers and idempotency guards instead of allowing ad hoc run patches. +- Reconcile dependent task status eagerly after run/dependency/cancellation changes so persisted task rows stay canonical instead of only fixing status on reads. + +## Learnings +- The current `TaskManager` already centralizes create/update/dependency flows, but it has no run lifecycle implementation and only derives non-terminal task states from dependencies and active runs. +- `globaldb` already persists tasks, runs, dependency edges, audit events, and idempotency records; task_05 can stay inside `internal/task` and consume those surfaces. +- Reverse dependency reconciliation needs a dedicated store query; `DependencyStore.ListDependents` became the minimal addition needed to cascade status changes without leaking storage details into callers. +- Cooperative cancellation needs both immediate run-state updates and delayed forced-stop follow-through; the manager can keep that boundary in `SessionExecutor` without importing session internals. + +## Files / Surfaces +- `internal/task/interfaces.go` +- `internal/task/manager.go` +- `internal/task/manager_test.go` +- `internal/task/manager_integration_test.go` +- `internal/task/types.go` +- `internal/task/validate.go` +- `internal/task/errors.go` +- `internal/store/globaldb/global_db_task.go` +- `internal/store/globaldb/global_db_task_aux.go` +- `internal/task/interfaces_integration_test.go` + +## Errors / Corrections +- Initial repo-wide verification failed on one lint issue in `internal/task/manager.go`; corrected by converting `CancelTask` normalization output directly into `CancelRun` instead of rebuilding an equivalent struct literal. + +## Ready for Next Run +- `TaskManager` now owns run lifecycle, canonical task reconciliation, and propagated cancellation; follow-on work can integrate transport/session wiring against these methods instead of inventing new lifecycle state machines. +- Verified with `go test ./internal/task -cover -count=1` (`80.0%`), `go test -tags integration ./internal/task -count=1`, `go test ./internal/task ./internal/store/globaldb -count=1`, `go test -tags integration ./internal/task ./internal/store/globaldb -count=1`, and `make verify`. diff --git a/.compozy/tasks/core-tasks/memory/task_06.md b/.compozy/tasks/core-tasks/memory/task_06.md new file mode 100644 index 000000000..fcdb92160 --- /dev/null +++ b/.compozy/tasks/core-tasks/memory/task_06.md @@ -0,0 +1,41 @@ +# Task Memory: task_06.md + +Keep only task-local execution context here. Do not duplicate facts that are obvious from the repository, task file, PRD documents, or git history. + +## Objective Snapshot +- Wire `internal/task` to live session execution through a daemon-owned adapter, make executable subtasks create dedicated sessions by default, preserve explicit attach-session flows, and reconcile orphaned non-terminal runs during daemon boot. +- Deliver the required unit/integration coverage, keep scope tight to task_06, and keep tracking/commit closeout behind clean verification. + +## Important Decisions +- Treat the PRD, TechSpec, and ADRs as the approved design baseline; no separate design artifact is needed for this run. +- Keep task/run status mutations manager-owned. Daemon will classify session liveness during boot recovery, but recovery actions should still flow through task-manager logic instead of direct ad hoc store writes whenever possible. +- Mirror existing automation session-creation behavior for dedicated task sessions: system session type, workspace-bound tasks by workspace ID, and global tasks by the daemon home/global workspace path. +- Keep cooperative task cancellation rooted in the session manager by adding a request-stop path in `internal/session` and letting the daemon bridge prefer cooperative cancel before the existing forced stop surface. +- Preserve healthy extensions after partial extension-manager boot errors; hook rebuild must still run when at least one registered extension survives startup. + +## Learnings +- `internal/task` already exposes the accepted `SessionExecutor` seam and `TaskManager.StartRun` already depends on it, but no daemon adapter or boot-time task recovery wiring exists yet. +- `internal/daemon` currently has no `internal/task` integration at all; `rg` over `internal/daemon`, `internal/api/core`, `internal/automation`, and `internal/extension` returned no task-domain wiring. +- Session status queries already repair stale on-disk metadata through `session.Status`, which gives boot recovery a reliable way to treat non-active sessions as not live after restart. +- The real extension manager can return a joined startup error while still keeping healthy extensions registered, so daemon boot cannot treat every `manager.Start()` error as a total extension-runtime loss. +- Coverage for the touched packages is now above the required floor: `internal/task` 80.1%, `internal/daemon` 80.4%, and `internal/session` 81.3%. + +## Files / Surfaces +- `internal/daemon/task_runtime.go` +- `internal/daemon/task_runtime_test.go` +- `internal/task/interfaces.go` +- `internal/task/manager.go` +- `internal/task/manager_test.go` +- `internal/task/manager_integration_test.go` +- `internal/session/manager.go` +- `internal/session/manager_lifecycle.go` +- `internal/daemon/boot.go` +- `internal/daemon/daemon.go` +- `internal/daemon/daemon_test.go` +- `internal/daemon/daemon_integration_test.go` + +## Errors / Corrections +- Corrected daemon extension boot handling so partial extension startup failures still publish healthy registered extensions and rebuild hooks; without that fix, the daemon integration suite failed while verifying this task. + +## Ready for Next Run +- Implementation and verification are complete; remaining closeout work is task tracking updates plus the single local commit with code/test changes only. diff --git a/.compozy/tasks/core-tasks/memory/task_07.md b/.compozy/tasks/core-tasks/memory/task_07.md new file mode 100644 index 000000000..7a7c5d9b5 --- /dev/null +++ b/.compozy/tasks/core-tasks/memory/task_07.md @@ -0,0 +1,30 @@ +# Task Memory: task_07.md + +Keep only task-local execution context here. Do not duplicate facts that are obvious from the repository, task file, PRD documents, or git history. + +## Objective Snapshot +- Completed `task_07` by adding shared task/run contracts and `internal/api/core` handlers for the `internal/task.Manager` surface without duplicating lifecycle rules in transports. +- Kept scope to contract/core/test work; transport route wiring remains deferred to `task_08`. + +## Important Decisions +- Use existing task-domain identity types (`created_by`, `origin`, `owner`) directly in API payloads to preserve semantics from ADR-005. +- Treat the PRD/TechSpec/ADRs as the approved design input for this scoped execution task rather than inventing a separate design artifact. +- Add `BaseHandlerConfig.Tasks` plus `TaskActorContextResolver` as the transport seam so HTTP/UDS can share the same handlers while still deriving actor context server-side. +- Keep task-run list reads thin by filtering `GetTask(...).Runs` in the core layer instead of inventing a separate transport-only rule surface. + +## Learnings +- `internal/task.Manager` already exposes all needed lifecycle methods, including `CreateChildTask`, `GetTask/ListTasks` with actor context, `AttachRunSession`, and `CancelRun`. +- Task-domain errors already distinguish validation, permission, not-found, invalid-transition, and session-binding failures, which the API layer can map directly. +- There is currently no task/run API surface in `internal/api/core` or `internal/api/contract`; the baseline `rg` search returned no matches. +- Reusing `network.ValidateChannel` kept task channel validation consistent with the network ingress rules already enforced elsewhere in the daemon. +- The `internal/api/core` package coverage gate needed helper/error-path test additions outside the new task handlers because package coverage spans the whole shared API/core package, not just task files. + +## Files / Surfaces +- Implemented: `internal/api/contract/tasks.go`, `internal/api/contract/responses.go`, `internal/api/contract/contract_test.go`, `internal/api/core/interfaces.go`, `internal/api/core/handlers.go`, `internal/api/core/errors.go`, `internal/api/core/tasks.go`, `internal/api/core/test_helpers_test.go`, `internal/api/core/tasks_test.go`, `internal/api/core/tasks_internal_test.go`, `internal/api/core/tasks_integration_test.go`, `internal/api/core/automation_additional_test.go`, `internal/api/core/errors_test.go`, `internal/api/core/handlers_internal_test.go`, and `internal/api/testutil/apitest.go`. + +## Errors / Corrections +- The worktree already contains unrelated task tracking edits and an untracked memory directory; avoid touching unrelated files while implementing `task_07`. +- `internal/api/core` coverage initially stalled below the required gate (`79.3%`); corrected that with shared helper/error-path tests and reverified at `80.0%` without widening product scope. + +## Ready for Next Run +- `task_08` should bind HTTP and UDS routes directly to the new task/run handler methods and install transport-specific actor-context resolution where needed. diff --git a/.compozy/tasks/core-tasks/memory/task_08.md b/.compozy/tasks/core-tasks/memory/task_08.md new file mode 100644 index 000000000..654217609 --- /dev/null +++ b/.compozy/tasks/core-tasks/memory/task_08.md @@ -0,0 +1,49 @@ +# Task Memory: task_08.md + +Keep only task-local execution context here. Do not duplicate facts that are obvious from the repository, task file, PRD documents, or git history. + +## Objective Snapshot +- Complete `task_08` by exposing the task/task-run API surface through both HTTP and UDS with route parity, thin transport wiring over `internal/api/core`, and transport-specific fail-fast construction when the task service dependency is missing. + +## Important Decisions +- Keep route registration inside the existing grouped router structure (`registerTaskRoutes`) instead of creating a task-specific routing style. +- Require `core.TaskService` during HTTP and UDS server construction so missing daemon wiring fails immediately rather than silently shipping partial route coverage. +- Use real `task.TaskManager` instances in HTTP and UDS integration tests so route parity is verified against shared business logic, not transport-local stubs. + +## Learnings +- `task_07` already provided everything the transports needed: request/response contracts, task error mapping, and thin handler entrypoints. +- The daemon boot order already created the task runtime before transport factories; the only regression was a daemon unit-test registry stub that did not satisfy the full task store interface, which caused `bootTasks` to skip manager creation and left `deps.Tasks` nil. +- HTTP and UDS package coverage both cleared the required gate once route-registration tests, constructor precondition tests, and real round-trip lifecycle integration tests were added. + +## Files / Surfaces +- `internal/api/httpapi/handlers.go` +- `internal/api/httpapi/routes.go` +- `internal/api/httpapi/server.go` +- `internal/api/httpapi/helpers_test.go` +- `internal/api/httpapi/handlers_test.go` +- `internal/api/httpapi/server_test.go` +- `internal/api/httpapi/httpapi_integration_test.go` +- `internal/api/udsapi/routes.go` +- `internal/api/udsapi/server.go` +- `internal/api/udsapi/helpers_test.go` +- `internal/api/udsapi/handlers_test.go` +- `internal/api/udsapi/server_test.go` +- `internal/api/udsapi/udsapi_integration_test.go` +- `internal/daemon/daemon.go` +- `internal/daemon/daemon_test.go` +- `openapi/agh.json` +- `sdk/typescript/src/generated/contracts.ts` + +## Errors / Corrections +- After making task service injection required in both transports, daemon tests that booted real servers against `recordingRegistry` started failing with `httpapi: task service is required`; corrected by extending the registry stub to satisfy the full task store surface so `bootTasks` constructs a manager before transport boot. + +## Ready for Next Run +- Task implementation is complete and fully verified. +- Evidence: + - `go test ./internal/api/httpapi ./internal/api/udsapi` + - `go test -tags integration ./internal/api/httpapi ./internal/api/udsapi` + - `go test -cover ./internal/api/httpapi ./internal/api/udsapi` (`83.2%`, `83.9%`) + - `go test -race ./internal/daemon -run TestBootRemovesStaleSocketAndCleansOrphans -v` + - `go test -race ./internal/daemon -run TestRunShutsDownOnInjectedSignal -timeout 20s -v` + - `go test -race ./internal/daemon` + - `make verify` diff --git a/.compozy/tasks/core-tasks/memory/task_09.md b/.compozy/tasks/core-tasks/memory/task_09.md new file mode 100644 index 000000000..486c6ead6 --- /dev/null +++ b/.compozy/tasks/core-tasks/memory/task_09.md @@ -0,0 +1,41 @@ +# Task Memory: task_09.md + +Keep only task-local execution context here. Do not duplicate facts that are obvious from the repository, task file, PRD documents, or git history. + +## Objective Snapshot +- Add the daemon-backed `agh task` CLI command group for task create/list/get/update, child creation, dependency management, and run lifecycle actions without bypassing the shared UDS/API contract. +- Deliver unit tests for flag validation and payload/query mapping plus integration tests that exercise `agh task` end-to-end against a live UDS daemon. + +## Important Decisions +- Treat `task_09.md`, `_techspec.md`, and ADR-004/ADR-005 as the approved design baseline; no conflicting requirements were found. +- Reuse `internal/api/contract/tasks.go`, `internal/api/udsapi/routes.go`, and the CLI organization style from `internal/cli/automation.go` instead of introducing task-specific transport shapes or direct store access. +- Extend the existing CLI daemon-backed integration harness with task-manager wiring because it currently boots UDS without the task service. +- Keep task/run output formatting inside `internal/cli/task.go` alongside command construction so future CLI additions reuse the same scope/owner/channel rendering rules instead of scattering task formatting across helpers. + +## Learnings +- `internal/cli` currently has no task CLI implementation or task-related client methods, so this task needs both command-tree and client transport work. +- The existing `internal/cli/cli_integration_test.go` harness wires automation/network/memory/extension services into the UDS server but not `WithTasks`, so end-to-end task CLI coverage requires fixture expansion. +- The CLI integration harness also needs an observe-compatible bridge stub that implements `DeliveryMetrics()` because `observe.WithBridgeSource(...)` now depends on the widened `observe.BridgeSource` interface. +- `internal/cli` package coverage for this task clears the required bar at `80.6%` after exercising task mutation success paths and TOON/detail renderers in `internal/cli/task_test.go`. + +## Files / Surfaces +- `internal/cli/root.go` +- `internal/cli/client.go` +- `internal/cli/helpers_test.go` +- `internal/cli/cli_integration_test.go` +- `internal/cli/automation.go` and existing CLI test files as reference patterns +- `internal/api/contract/tasks.go` +- `internal/api/udsapi/routes.go` + +## Errors / Corrections +- Fixed a stale integration-harness compile break by adding `DeliveryMetrics()` to `integrationBridgeService` after `observe.BridgeSource` expanded. +- Relaxed the task-run lifecycle integration assertion to validate result JSON semantically instead of requiring an exact compact byte string, because persisted JSON may be normalized with whitespace. + +## Ready for Next Run +- Implementation and verification are complete. Fresh evidence: +- `go test ./internal/cli -cover -count=1` -> `coverage: 80.6% of statements` +- `go test -tags integration ./internal/cli -count=1` -> pass +- `make verify` -> pass +- Local commit created: `f81534f` (`feat: add task cli commands`) +- Post-commit `make verify` also passed on the committed state. +- Tracking and workflow memory files remain intentionally unstaged; existing unrelated task-tracking/generated-file changes in the worktree were left untouched. diff --git a/.compozy/tasks/core-tasks/memory/task_10.md b/.compozy/tasks/core-tasks/memory/task_10.md new file mode 100644 index 000000000..91a5ce8a2 --- /dev/null +++ b/.compozy/tasks/core-tasks/memory/task_10.md @@ -0,0 +1,65 @@ +# Task Memory: task_10.md + +Keep only task-local execution context here. Do not duplicate facts that are obvious from the repository, task file, PRD documents, or git history. + +## Objective Snapshot +- Integrate automation with explicit task-backed work for task_10 without forcing all automation jobs into the task domain. +- Required outputs: direct automation task creation/enqueue, agent-mediated `task.create` with agent authorship and automation-linked origin, non-overlap between automation/task execution state, required unit/integration coverage, clean verification, tracking updates, and one local commit. + +## Important Decisions +- Use the approved PRD/techspec/ADR design as the brainstorming baseline instead of starting a new design approval loop. +- Keep the integration explicit and daemon-owned: automation decides when a job is task-backed, and task-backed work must stop using the normal automation dispatch runtime once materialized. +- Model task-backed automation explicitly on `automation.Job.Task`; jobs without that block stay on the existing session-backed dispatch path. +- Represent direct task-backed automation runs as `delegated` activation records linked to the canonical `task_id` / `task_run_id` instead of creating a second automation-owned execution lifecycle. +- Preserve automation provenance for automation-launched agent sessions through a manager-owned session actor registry that is cleaned up when the session stops. + +## Learnings +- Current automation dispatch always persists an `automation.Run`, creates a session, prompts it, and finalizes that run; this is the pre-change evidence that task-backed automation is not implemented and would currently duplicate task execution state. +- `task.ActorContext` currently allows `agent_session` actors only with `agent_session` origin, so the task_10 requirement for agent-authored tasks with automation-linked origin will need an intentional provenance path. +- Session creation currently has no general metadata field for automation provenance, so the automation-linked agent path needs explicit daemon-owned context propagation. +- Automation API/spec surfaces also needed task-aware job/run fields so direct task-backed jobs can be configured and delegated runs can expose their linked task/task-run identifiers. +- The global automation store needed schema support for both job-level task config and delegated run linkage; otherwise task-backed activation could not survive restart/reload paths. + +## Files / Surfaces +- `internal/automation/manager.go` +- `internal/automation/dispatch.go` +- `internal/automation/types.go` +- `internal/automation/model/types.go` +- `internal/automation/model/validate.go` +- `internal/task/actors.go` +- `internal/task/manager_test.go` +- `internal/task/manager_integration_test.go` +- `internal/daemon/boot.go` +- `internal/daemon/daemon.go` +- `internal/api/core/automation.go` +- `internal/api/core/conversions.go` +- `internal/api/core/automation_test.go` +- `internal/api/contract/automation.go` +- `internal/api/contract/contract_test.go` +- `internal/api/spec/spec.go` +- `internal/api/spec/spec_test.go` +- `internal/store/globaldb/global_db.go` +- `internal/store/globaldb/global_db_automation.go` +- `internal/store/globaldb/global_db_automation_test.go` +- `internal/config/automation.go` +- `internal/automation/dispatch_test.go` +- `internal/automation/manager_test.go` +- `internal/automation/manager_integration_test.go` +- `openapi/agh.json` +- `sdk/typescript/src/generated/contracts.ts` +- `web/src/generated/agh-openapi.d.ts` + +## Errors / Corrections +- `make verify` initially failed because generated API artifacts were stale after the automation contract/spec changes; running `make codegen` and then rerunning `make verify` resolved the mismatch. + +## Ready for Next Run +- Task complete. Commit `3cc4f2a` contains the implementation and generated artifacts; workflow-memory and task-tracking files were intentionally left unstaged. + +## Verification +- `go test ./internal/store/globaldb` +- `go test ./internal/automation ./internal/task ./internal/store/globaldb ./internal/api/core ./internal/api/contract ./internal/api/spec ./internal/daemon` +- `go test -tags integration ./internal/automation` +- `go test -cover ./internal/automation ./internal/task` with `internal/automation` at `80.0%` and `internal/task` at `80.1%` +- `make codegen` +- `make verify` +- Post-commit `make verify` passed on the committed state after the staged-file hooks reformatted the staged diff during `git commit`. diff --git a/.compozy/tasks/core-tasks/memory/task_11.md b/.compozy/tasks/core-tasks/memory/task_11.md new file mode 100644 index 000000000..5dc1a1765 --- /dev/null +++ b/.compozy/tasks/core-tasks/memory/task_11.md @@ -0,0 +1,45 @@ +# Task Memory: task_11.md + +Keep only task-local execution context here. Do not duplicate facts that are obvious from the repository, task file, PRD documents, or git history. + +## Objective Snapshot +- Add capability-gated extension Host API methods for task list/create/get/update/cancel plus task-run list/enqueue/claim/start/attach/complete/fail/cancel, and preserve trusted extension-derived actor/origin metadata. +- Verification must include denied-access and trusted-identity unit coverage, dedicated-session execution integration coverage, package coverage at or above 80%, and clean repository verification. + +## Important Decisions +- Reuse shared task request/response contracts and conversion helpers from `internal/api/contract` and `internal/api/core/tasks.go` instead of inventing extension-only task payload types. +- Use `task.DeriveExtensionActorContext` at the Host API ingress so payload-supplied identity fields remain ignored and immutable identity/origin come from extension context. +- Keep task lifecycle authority in `internal/task.Manager`; the Host API only delegates lifecycle requests and maps domain errors into RPC-safe responses. +- Keep scope to create/update/query/run flows from the task spec; do not add extension Host API dependency/child mutation methods that were not required by task 11 deliverables. + +## Learnings +- The Host API test harness already boots real `globaldb`, `session.Manager`, `observe`, `automation`, and workspace resolution, so it can support true task/session integration once a task manager is wired in. +- Current Host API protocol/contract registries have no task methods yet; adding them requires touching protocol constants, contract specs, capability mapping, and handler method registration together. +- Adding Host API methods changes generated API artifacts; `make codegen` was required to refresh `openapi/agh.json` and `sdk/typescript/src/generated/contracts.ts`. +- Package-level coverage for `internal/extension` stayed below the workspace floor until `manager_test.go` covered option wiring and reload behavior alongside the new Host API task tests. + +## Files / Surfaces +- `internal/extension/protocol/host_api.go` +- `internal/extension/contract/host_api.go` +- `internal/extension/capability.go` +- `internal/extension/host_api.go` +- `internal/extension/host_api_tasks.go` +- `internal/extension/host_api_test.go` +- `internal/extension/host_api_integration_test.go` +- `internal/extension/manager_test.go` +- `internal/daemon/daemon.go` +- `internal/daemon/boot.go` +- `openapi/agh.json` +- `sdk/typescript/src/generated/contracts.ts` + +## Errors / Corrections +- Initial `make verify` hit a transient pre-existing automation test failure in `internal/automation` (`TestSchedulerAtJobUnregistersAfterFiringOnce`); isolated rerun passed and the final full `make verify` passed cleanly. +- An early task-memory note mentioned dependency management, but task 11 implementation intentionally stayed within the specified create/update/query/run scope. + +## Ready for Next Run +- Task 11 implementation is complete: extension Host API now exposes capability-gated task create/query/update/run flows, derives actor/origin from trusted extension context, and keeps executable subtask starts on the dedicated-session TaskManager path. +- Verification evidence: + - `go test ./internal/extension -count=1` + - `go test ./internal/extension -cover -count=1` (`coverage: 80.0% of statements`) + - `go test -tags integration ./internal/extension -count=1` + - `make verify` diff --git a/.compozy/tasks/core-tasks/memory/task_12.md b/.compozy/tasks/core-tasks/memory/task_12.md new file mode 100644 index 000000000..28b204435 --- /dev/null +++ b/.compozy/tasks/core-tasks/memory/task_12.md @@ -0,0 +1,51 @@ +# Task Memory: task_12.md + +Keep only task-local execution context here. Do not duplicate facts that are obvious from the repository, task file, PRD documents, or git history. + +## Objective Snapshot +- Make authenticated network peers first-class task-domain writers for create/update/cancel/enqueue flows while preserving server-derived identity/origin, channel-binding enforcement, stale-channel policy, idempotency, and auditability. + +## Important Decisions +- `internal/network` owns the peer-authenticated ingress seam, but all task/run mutations still route through `internal/task.TaskManager`. +- Network-originated actor/origin metadata is always derived server-side from `(peer_id, channel)` using `task.DeriveNetworkPeerActorContext`; request IDs are audit-only and do not affect idempotency scope. +- Channel validation is injected into `internal/task.TaskManager` from the daemon (`network.ValidateChannel`) so stale-channel checks can run without introducing a `task -> network` import cycle. +- Stale task bindings block network ingress unless the update explicitly clears or repairs `network_channel`; stale task/run channel snapshots block `StartRun` and `AttachRunSession` before run-state mutation. +- Task-aware network audit entries reuse the existing network audit sinks (`store.NetworkAuditEntry`) via an optional `TaskIngressAuditWriter` extension rather than a parallel audit subsystem. + +## Learnings +- `internal/network` already had the right peer registry and audit primitives; the missing piece was a narrow task service seam plus capability-checked peer resolution. +- Origin-scoped task-run idempotency from earlier task work was sufficient for network retries once the network ingress layer reused a stable origin ref (`peer:/channel:`). +- Stale-channel handling needed to happen in two places: ingress-time repair/rejection for task records and start/attach-time rejection for persisted run snapshots. + +## Files / Surfaces +- `internal/network/manager.go` +- `internal/network/tasks.go` +- `internal/network/audit.go` +- `internal/network/tasks_test.go` +- `internal/network/tasks_integration_test.go` +- `internal/network/audit_test.go` +- `internal/task/manager.go` +- `internal/task/errors.go` +- `internal/task/manager_test.go` +- `internal/daemon/boot.go` +- `internal/daemon/task_runtime.go` +- `internal/api/core/errors.go` +- `internal/api/core/errors_test.go` +- `internal/api/core/tasks_test.go` +- `internal/extension/host_api_tasks.go` +- `internal/extension/host_api_test.go` + +## Errors / Corrections +- Initial `internal/network/tasks.go` wiring left the task service option and clock helper incomplete; fixed by wiring `TaskService` into `Manager`/`managerOptions`, removing the stubbed helper, and passing action names through peer-resolution rejections so rejected ingress audits have valid kinds. +- Package coverage initially left `internal/network` below the 80% gate; added focused unit coverage for create/cancel paths, ingress validation, and reason mapping. + +## Ready for Next Run +- Task implementation is complete and fully verified. +- Evidence: + - `go test ./internal/task ./internal/network ./internal/api/core ./internal/extension -count=1` + - `go test -tags integration ./internal/network -count=1` + - `go test -cover ./internal/task -count=1` (`80.0%`) + - `go test -cover ./internal/network -count=1` (`81.0%`) + - `go test -cover ./internal/api/core -count=1` (`80.0%`) + - `go test -cover ./internal/extension -count=1` (`80.0%`) + - `make verify` diff --git a/.compozy/tasks/core-tasks/memory/task_13.md b/.compozy/tasks/core-tasks/memory/task_13.md new file mode 100644 index 000000000..e071bcaed --- /dev/null +++ b/.compozy/tasks/core-tasks/memory/task_13.md @@ -0,0 +1,40 @@ +# Task Memory: task_13.md + +Keep only task-local execution context here. Do not duplicate facts that are obvious from the repository, task file, PRD documents, or git history. + +## Objective Snapshot +- Add task-domain read-side observability in `internal/observe` so operators can inspect queue depth, stuck runs, task/run totals, ownership/channel/origin breakdowns, and recovery/cancellation outcomes without creating a second lifecycle authority. + +## Important Decisions +- Use the approved PRD/TechSpec/ADR set as the design baseline for this implementation task instead of running a separate design approval loop. +- Prefer read-only aggregation over durable `tasks`, `task_runs`, `task_events`, and `network_audit_log` records rather than introducing new authoritative projection tables. +- Add task observability as exported `internal/observe` query and health surfaces (`QueryTaskSummary`, `QueryTaskMetrics`, `Health.Tasks`) instead of extending transport contracts in the same task. +- Use configurable stuck thresholds on the observer with default windows of 5m for `claimed`/`starting` and 30m for `running`. + +## Learnings +- `internal/observe` is still session/bridge-centric; it has no task-specific query model or health block yet. +- Task lifecycle already emits durable audit events for enqueue/claim/start/complete/fail/cancel/force-stop/recovery/rejection in `internal/task`, and network task ingress already writes normalized accepted/rejected audit rows in `network_audit_log`. +- Duplicate network ingress can be derived read-side by comparing accepted `task.run.enqueue` audit rows against canonical network-origin `task.run_enqueued` task events, clamping the mismatch at zero. +- Recovery outcomes needed by observe are already encoded in `task.run_recovered` event payloads, so no extra persistence shape was required. + +## Files / Surfaces +- `internal/observe/observer.go` +- `internal/observe/query.go` +- `internal/observe/health.go` +- `internal/observe/reconcile.go` +- `internal/observe/tasks.go` +- `internal/observe/tasks_test.go` +- `internal/observe/tasks_integration_test.go` +- `internal/store/globaldb/global_db_observe.go` +- `internal/store/globaldb/global_db_network_audit.go` +- `internal/task/manager.go` +- `internal/network/tasks.go` +- `internal/daemon/daemon_test.go` + +## Errors / Corrections +- `make verify` initially failed on an unused helper type in `internal/observe/tasks.go`; removing the dead type cleared lint without changing behavior. + +## Ready for Next Run +- Task 13 is complete. Task tracking files were updated in the working tree, and the task-owned source/test changes were committed as `778f663` (`feat: add task observe projections`). +- A final post-commit `make verify` pass also succeeded against the current tree. +- If follow-on API work needs task health externally, transport-layer mapping from `internal/observe.Health.Tasks` remains the next logical seam. diff --git a/.compozy/tasks/core-tasks/qa/case-execution-matrix.md b/.compozy/tasks/core-tasks/qa/case-execution-matrix.md new file mode 100644 index 000000000..4e8fb3632 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/case-execution-matrix.md @@ -0,0 +1,94 @@ +# Case Execution Matrix + +Generated: 2026-04-14 + +Summary: +- PASS: 68 +- FAIL: 1 +- FAIL case: `TC-SEC-003` (`.compozy/tasks/core-tasks/issues/BUG-001.md`) + +Common evidence: +- Unit/transport logs: `.compozy/tasks/core-tasks/runtime/full-suite-20260414-160548/logs/case-suite-unit.json`, `.compozy/tasks/core-tasks/runtime/full-suite-20260414-160548/logs/case-suite-unit.log` +- Integration logs: `.compozy/tasks/core-tasks/runtime/full-suite-20260414-160548/logs/case-suite-integration.json`, `.compozy/tasks/core-tasks/runtime/full-suite-20260414-160548/logs/case-suite-integration.log` +- Live CLI/API suite: `.compozy/tasks/core-tasks/runtime/full-suite-20260414-160548/live/live-summary.json` +- Live security suite: `.compozy/tasks/core-tasks/runtime/full-suite-20260414-160548/security/tc-sec-006-007-summary.json`, `tc-sec-003-*.txt/json`, `tc-sec-006-db-tables.txt`, `tc-sec-006-post-health.json` +- Performance suite: `.compozy/tasks/core-tasks/runtime/full-suite-20260414-160548/perf/perf-summary.json` + +## Smoke +- `SMOKE-001` PASS — isolated daemon booted and exposed healthy task/observe surfaces. Evidence: `live/observe-health.http.json`, live daemon on `127.0.0.1:50512`. +- `SMOKE-002` PASS — global task creation via HTTP. Evidence: `live/http-create-primary.http.json`, `live/live-summary.json`. +- `SMOKE-003` PASS — task listing via HTTP. Evidence: `live/http-list-global.http.json`, `live/live-summary.json`. +- `SMOKE-004` PASS — task detail via HTTP with child/dependency expansion. Evidence: `live/http-get-primary.http.json`, `live/live-summary.json`. +- `SMOKE-005` PASS — task title update via PATCH. Evidence: `live/http-patch-primary.http.json`, `live/live-summary.json`. +- `SMOKE-006` PASS — enqueue and claim task run. Evidence: `live/cli-enqueue-run.stdout.json`, `live/cli-claim-run.stdout.json`, `live/live-summary.json`. +- `SMOKE-007` PASS — start and complete task run. Evidence: `live/cli-start-run.stdout.json`, `live/cli-complete-run.stdout.json`, `live/live-summary.json`. +- `SMOKE-008` PASS — cancel task tree and active runs. Evidence: `live/cli-cancel-root.stdout.json`, `live/cli-get-cancel-child.stdout.json`, `live/cli-get-cancel-grandchild.stdout.json`, `live/live-summary.json`. +- `SMOKE-009` PASS — CLI task list returns results. Evidence: `live/cli-list-workspace-scope.stdout.json`, `live/cli-list-ready.stdout.json`, `live/live-summary.json`. +- `SMOKE-010` PASS — observe projections return task metrics. Evidence: `live/observe-health.http.json`, `perf/perf-summary.json` (`TC-PERF-006`), `internal/observe` tests in `case-suite-unit.json`. + +## Functional +- `TC-FUNC-001` PASS — `internal/task`: `TestManagerCreateTaskUsesTrustedActorContext`, integration `TestTaskManagerCreateTaskPersistsAgentSessionIdentity`. +- `TC-FUNC-002` PASS — workspace-scoped task creation covered by integration `TestTaskManagerChildAndDependencyFlowsPersistAudit` and live `live/cli-create-workspace-task.stdout.json`. +- `TC-FUNC-003` PASS — invalid scope binding rejected by `TestValidateScopeBinding/global_with_workspace`. +- `TC-FUNC-004` PASS — mutable field updates covered by `TestManagerUpdateTaskAllowsMutableOwnershipAndChannelFields`. +- `TC-FUNC-005` PASS — immutable field rejection covered by `TestValidateImmutableTaskFields/scope_immutable`, `workspace_id_immutable`, `parent_task_id_immutable`, `created_by_immutable`, `origin_immutable`. +- `TC-FUNC-006` PASS — valid child creation covered by `TestManagerCreateChildTaskEnforcesParentRulesAndEmitsAudit/global_parent_allows_workspace_child_and_emits_parent_event`. +- `TC-FUNC-007` PASS — max-depth creation covered by `TestGraphLimitGuards/depth_at_limit` and `perf/perf-summary.json` (`TC-PERF-003`). +- `TC-FUNC-008` PASS — direct-child limit covered by `TestGraphLimitGuards/direct_child_count_over_limit` and `perf/perf-summary.json` (`TC-PERF-003`). +- `TC-FUNC-009` PASS — valid dependency edge covered by `TestManagerAddAndRemoveDependencyReconcileStatusAndEvents` and integration `TestTaskManagerChildAndDependencyFlowsPersistAudit`. +- `TC-FUNC-010` PASS — self-dependency rejection covered by `TestDomainValidationHelpers/task_dependency_self_dependency`. +- `TC-FUNC-011` PASS — cycle rejection covered by `perf/perf-summary.json` (`TC-PERF-002` cycle and chain-cycle checks). +- `TC-FUNC-012` PASS — dependency-count limit covered by `TestGraphLimitGuards/dependency_count_over_limit` and `perf/perf-summary.json` (`TC-PERF-002`). +- `TC-FUNC-013` PASS — dependency removal/status reconciliation covered by `TestManagerAddAndRemoveDependencyReconcileStatusAndEvents`. +- `TC-FUNC-014` PASS — enqueue on ready task covered by integration `TestTaskManagerRunLifecyclePersistsAndReconcilesAgainstStorage` and live `live/cli-enqueue-run.stdout.json`. +- `TC-FUNC-015` PASS — claim queued run covered by integration `TestTaskManagerRunLifecyclePersistsAndReconcilesAgainstStorage` and live `live/cli-claim-run.stdout.json`. +- `TC-FUNC-016` PASS — start claimed run/dedicated session covered by integration `TestTaskManagerRunLifecyclePersistsAndReconcilesAgainstStorage`, daemon `TestTaskSessionBridgeStartTaskSessionUsesDedicatedSystemSessions`, and live `live/cli-start-run.stdout.json`. +- `TC-FUNC-017` PASS — complete running run covered by integration `TestTaskManagerRunLifecyclePersistsAndReconcilesAgainstStorage` and live `live/cli-complete-run.stdout.json`. +- `TC-FUNC-018` PASS — fail running run covered by `TestManagerBlockedExecutionAndFailureGuardrails` and `TestManagerGetTaskAndFailRunGuardrails`. +- `TC-FUNC-019` PASS — invalid transition rejection covered by `TestManagerRunLifecycleRejectsInvalidTransitions`. +- `TC-FUNC-020` PASS — attach-session and second-attach rejection covered by `TestManagerAttachRunSessionAndRetryLatestRunOutcome` and live `live/cli-attach-session-first.stdout.json`, `live/cli-attach-session-second.stderr.txt`. +- `TC-FUNC-021` PASS — idempotent enqueue semantics covered by `TestManagerNonHumanIdempotencyAndExecutionGuards` and `TestManagerNetworkPeerEnqueueRunUsesOriginScopedIdempotency`. +- `TC-FUNC-022` PASS — cancel queued task runs covered by `TestManagerCancelTaskPropagatesAcrossTree` and integration `TestTaskManagerCancelTaskTreePersistsCancellationAudit`. +- `TC-FUNC-023` PASS — cooperative then forced stop on running child runs covered by `TestManagerCancelTaskPropagatesAcrossTree`, integration `TestTaskManagerCancelTaskTreePersistsCancellationAudit`, and `perf/perf-summary.json` (`TC-PERF-004`). +- `TC-FUNC-024` PASS — cancel propagation to grandchildren covered by `TestManagerCancelTaskPropagatesAcrossTree`, integration `TestTaskManagerCancelTaskTreePersistsCancellationAudit`, and `perf/perf-summary.json` (`TC-PERF-004`). +- `TC-FUNC-025` PASS — cancel terminal task rejection covered by `TestManagerBlockedExecutionAndFailureGuardrails` (`CancelTask(completedTask)`). +- `TC-FUNC-026` PASS — oversized task metadata rejected by `TestPayloadSizeGuards/metadata_over_limit` and live `security/tc-sec-006-007-summary.json`. +- `TC-FUNC-027` PASS — oversized run result rejected by `TestPayloadSizeGuards/result_over_limit` and API mapping `TestStatusForTaskError/payload_too_large`. +- `TC-FUNC-028` PASS — oversized task-event payload rejected by `TestPayloadSizeGuards/payload_over_limit`. +- `TC-FUNC-029` PASS — orphaned claimed run re-queued on boot covered by `TestManagerRecoverRunOnBoot/claimed_run_requeues_and_records_recovery_event` and daemon `TestPlanTaskRunRecoveryClassifiesClaimedStartingRunning/claimed_without_session_requeues`. +- `TC-FUNC-030` PASS — orphaned running run marked failed on boot covered by `TestManagerRecoverRunOnBoot/running_run_fails_closed_when_the_attached_session_is_not_live` and daemon `TestPlanTaskRunRecoveryClassifiesClaimedStartingRunning/running_with_missing_session_fails`. + +## Integration +- `TC-INT-001` PASS — HTTP create with server-derived identity. Evidence: `live/http-create-primary.http.json`, `TestHTTPTaskRoutesRoundTrip`. +- `TC-INT-002` PASS — HTTP list with filters. Evidence: `live/http-list-global.http.json`, `TestHTTPTaskRoutesRoundTrip`. +- `TC-INT-003` PASS — HTTP get detail payload. Evidence: `live/http-get-primary.http.json`, `TestHTTPTaskRoutesRoundTrip`. +- `TC-INT-004` PASS — immutable PATCH rejected with 400. Evidence: `live/http-patch-immutable.http.json`, `live/http-patch-immutable-identity.http.json`. +- `TC-INT-005` PASS — HTTP/UDS task-route parity covered by `TestHTTPTaskRoutesRoundTrip`, `TestHTTPTaskRunLifecycleRoutesRoundTrip`, `TestUDSTaskRoutesRoundTrip`, and `TestUDSTaskRunLifecycleRoutesRoundTrip`. +- `TC-INT-006` PASS — CLI task create via daemon API. Evidence: `live/cli-create-workspace-task.stdout.json`, `TestCLITaskCreateListGetIntegration`. +- `TC-INT-007` PASS — CLI filtered task list. Evidence: `live/cli-list-workspace-ready.stdout.json`, `live/cli-list-owner.stdout.json`, `TestCLITaskCreateListGetIntegration`. +- `TC-INT-008` PASS — CLI task cancel propagates to children. Evidence: `live/cli-cancel-root.stdout.json`, `live/cli-get-cancel-child.stdout.json`, `live/cli-get-cancel-grandchild.stdout.json`. +- `TC-INT-009` PASS — start run binds dedicated session. Evidence: `live/cli-start-run.stdout.json`, daemon `TestTaskSessionBridgeStartTaskSessionUsesDedicatedSystemSessions`, `TestBootWiresTaskRuntimeWithDedicatedSessionBridge`. +- `TC-INT-010` PASS — attach-session binds once and rejects second bind. Evidence: `live/cli-attach-session-first.stdout.json`, `live/cli-attach-session-second.stderr.txt`, `TestManagerAttachRunSessionAndRetryLatestRunOutcome`. +- `TC-INT-011` PASS — automation direct task creation with automation actor/origin covered by automation integration `TestManagerIntegrationDirectTaskBackedJobDelegatesIntoTaskDomain`. +- `TC-INT-012` PASS — automation-linked agent session origin covered by automation integration `TestManagerIntegrationAutomationSessionCanCreateTaskWithAutomationOrigin`. +- `TC-INT-013` PASS — extension task creation and capability checks covered by `TestHostAPIHandlerTasksCreateUsesTrustedExtensionIdentity` and `TestHostAPIHandlerTaskOperationsRequireCapabilities/ShouldDenyCreate`. +- `TC-INT-014` PASS — network peer task creation with channel binding covered by network `TestCreateTaskFromPeerUsesServerDerivedIdentityAndAcceptedAudit`. +- `TC-INT-015` PASS — stale-channel write rejection covered by network `TestUpdateTaskFromPeerAllowsOnlyStaleChannelRepair/rejects_unrelated_writes_while_stale_channel_remains` and task `TestManagerStartRunRejectsStaleRunChannelWithoutMutation`. + +## Security +- `TC-SEC-001` PASS — server-derived `created_by` ignores client payload. Evidence: `live/http-create-primary.http.json`, `live/live-summary.json`. +- `TC-SEC-002` PASS — server-derived `origin` ignores client payload. Evidence: `live/http-create-primary.http.json`, `live/live-summary.json`. +- `TC-SEC-003` FAIL — unauthenticated HTTP task requests were accepted (`200/201`) instead of rejected. Evidence: `security/tc-sec-003-get-headers.txt`, `security/tc-sec-003-post-headers.txt`, `security/tc-sec-003-get-body.json`, `security/tc-sec-003-post-body.json`, issue `.compozy/tasks/core-tasks/issues/BUG-001.md`. +- `TC-SEC-004` PASS — extension without `task.write` denied task creation via `TestHostAPIHandlerTaskOperationsRequireCapabilities/ShouldDenyCreate` and capability tests in `internal/extension`. +- `TC-SEC-005` PASS — channel-mismatch write rejected via `TestEnqueueRunFromPeerRejectsChannelMismatchAndAudits` and `TestUpdateTaskFromPeerAllowsOnlyStaleChannelRepair/rejects_unrelated_writes_while_stale_channel_remains`. +- `TC-SEC-006` PASS — SQL injection strings stored/validated literally with database intact. Evidence: `security/tc-sec-006-007-summary.json`, `security/tc-sec-006-db-tables.txt`, `security/tc-sec-006-post-health.json`. +- `TC-SEC-007` PASS — oversized metadata rejected with `413` and boundary accepted. Evidence: `security/tc-sec-006-007-summary.json`, task guards `TestPayloadSizeGuards/metadata_over_limit`, `result_over_limit`, `payload_over_limit`, and API mapping `TestStatusForTaskError/payload_too_large`. +- `TC-SEC-008` PASS — read-denied access rejected by `TestManagerGetAndListTasksRequireReadAuthorityAndBuildView`, integration `TestTaskManagerGetTaskRequiresReadAuthorityIntegration`, and API mapping `TestStatusForTaskError/permission_denied`. + +## Performance +- `TC-PERF-001` PASS — 1000 sequential creates within thresholds. Evidence: `perf/perf-summary.json`. +- `TC-PERF-002` PASS — dependency fill, limit rejection, and cycle detection within thresholds. Evidence: `perf/perf-summary.json`. +- `TC-PERF-003` PASS — hierarchy depth and child fan-out within thresholds. Evidence: `perf/perf-summary.json`. +- `TC-PERF-004` PASS — cancellation propagation across 100 descendants within thresholds. Evidence: `perf/perf-summary.json`. +- `TC-PERF-005` PASS — composite `ListTasks` queries on 10K tasks within thresholds. Evidence: `perf/perf-summary.json`. +- `TC-PERF-006` PASS — observe summary/metrics/stuck-work queries on 10K tasks + 50K runs within thresholds after the `observe.Health()` snapshot reuse fix. Evidence: `perf/perf-summary.json`, `internal/observe/tasks_health_optimization_test.go`. diff --git a/.compozy/tasks/core-tasks/qa/issues/BUG-001.md b/.compozy/tasks/core-tasks/qa/issues/BUG-001.md new file mode 100644 index 000000000..128de0836 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/issues/BUG-001.md @@ -0,0 +1,59 @@ +# BUG-001: HTTP task endpoints accept unauthenticated requests + +**Severity:** Critical +**Priority:** P0 +**Type:** Security +**Status:** Open + +## Environment + +- **Build:** `95712b7-dirty` +- **OS:** macOS +- **Browser:** N/A +- **URL:** `http://127.0.0.1:50512/api/tasks` + +## Summary + +The HTTP task surface accepts anonymous requests as a trusted local human principal instead of rejecting them with `401/403`. During `TC-SEC-003`, unauthenticated `GET /api/tasks` returned task data with `200 OK`, and unauthenticated `POST /api/tasks` created a new task with `201 Created`. + +## Reproduction + +```bash +curl -i http://127.0.0.1:50512/api/tasks +curl -i -H 'Content-Type: application/json' \ + -X POST http://127.0.0.1:50512/api/tasks \ + --data '{"scope":"global","title":"anon task tc-sec-003"}' +``` + +Observed before the fix: + +- `GET /api/tasks` returned `200 OK` with task summaries +- `POST /api/tasks` returned `201 Created` and persisted a task with `created_by.kind = "human"` and `origin.kind = "http"` + +## Expected + +Anonymous HTTP requests to all task endpoints should be rejected with `401 Unauthorized` or `403 Forbidden`, and no task data should be returned. + +## Root cause + +The HTTP task handlers derive a trusted local actor for incoming requests without an authentication gate in front of the task routes. As a result, unauthenticated HTTP calls are treated as authenticated local-user writes instead of being rejected before actor resolution. + +## Fix + +Not fixed in this QA round. The reproduction is stable, but addressing it requires an explicit authentication policy for the local HTTP surface and corresponding handler/middleware changes. + +## Verification + +- Reproduced against the isolated QA daemon on `127.0.0.1:50512` +- Saved evidence under `.compozy/tasks/core-tasks/runtime/full-suite-20260414-160548/security/` + +## Impact + +- **Users Affected:** Any local process that can reach the daemon's HTTP listener +- **Frequency:** Always +- **Workaround:** None + +## Related + +- Test Case: `TC-SEC-003` +- Figma Design: N/A diff --git a/.compozy/tasks/core-tasks/review-round-1.md b/.compozy/tasks/core-tasks/qa/review-round-1.md similarity index 100% rename from .compozy/tasks/core-tasks/review-round-1.md rename to .compozy/tasks/core-tasks/qa/review-round-1.md diff --git a/.compozy/tasks/core-tasks/qa/screenshots/ui-404.png b/.compozy/tasks/core-tasks/qa/screenshots/ui-404.png new file mode 100644 index 000000000..3e30f3e18 Binary files /dev/null and b/.compozy/tasks/core-tasks/qa/screenshots/ui-404.png differ diff --git a/.compozy/tasks/core-tasks/qa/screenshots/ui-automation-job-created.png b/.compozy/tasks/core-tasks/qa/screenshots/ui-automation-job-created.png new file mode 100644 index 000000000..af0c18afe Binary files /dev/null and b/.compozy/tasks/core-tasks/qa/screenshots/ui-automation-job-created.png differ diff --git a/.compozy/tasks/core-tasks/qa/screenshots/ui-automation-post-verify-final.png b/.compozy/tasks/core-tasks/qa/screenshots/ui-automation-post-verify-final.png new file mode 100644 index 000000000..ace6015b5 Binary files /dev/null and b/.compozy/tasks/core-tasks/qa/screenshots/ui-automation-post-verify-final.png differ diff --git a/.compozy/tasks/core-tasks/qa/screenshots/ui-automation-post-verify.png b/.compozy/tasks/core-tasks/qa/screenshots/ui-automation-post-verify.png new file mode 100644 index 000000000..5e6321bb9 Binary files /dev/null and b/.compozy/tasks/core-tasks/qa/screenshots/ui-automation-post-verify.png differ diff --git a/.compozy/tasks/core-tasks/qa/screenshots/ui-network-disabled.png b/.compozy/tasks/core-tasks/qa/screenshots/ui-network-disabled.png new file mode 100644 index 000000000..2a9271b09 Binary files /dev/null and b/.compozy/tasks/core-tasks/qa/screenshots/ui-network-disabled.png differ diff --git a/.compozy/tasks/core-tasks/qa/screenshots/ui-network-post-verify-final.png b/.compozy/tasks/core-tasks/qa/screenshots/ui-network-post-verify-final.png new file mode 100644 index 000000000..c3adc445d Binary files /dev/null and b/.compozy/tasks/core-tasks/qa/screenshots/ui-network-post-verify-final.png differ diff --git a/.compozy/tasks/core-tasks/qa/screenshots/ui-network-post-verify.png b/.compozy/tasks/core-tasks/qa/screenshots/ui-network-post-verify.png new file mode 100644 index 000000000..c2e127dd0 Binary files /dev/null and b/.compozy/tasks/core-tasks/qa/screenshots/ui-network-post-verify.png differ diff --git a/.compozy/tasks/core-tasks/qa/screenshots/ui-root.png b/.compozy/tasks/core-tasks/qa/screenshots/ui-root.png new file mode 100644 index 000000000..0b01ce754 Binary files /dev/null and b/.compozy/tasks/core-tasks/qa/screenshots/ui-root.png differ diff --git a/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-001.md b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-001.md new file mode 100644 index 000000000..f004506be --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-001.md @@ -0,0 +1,48 @@ +## SMOKE-001: Daemon Starts with Task Subsystem Initialized + +**Priority:** P0 +**Type:** Smoke +**Status:** Not Run +**Estimated Time:** 2-3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Quick sanity check that the AGH daemon boots successfully with the task subsystem fully initialized, including the TaskManager, task store (SQLite tables), observe layer task projections, and HTTP/UDS route registration. + +--- + +### Preconditions +- [ ] AGH binary built (`make build`) +- [ ] No existing daemon process running (clean start) +- [ ] Valid configuration file or defaults available + +--- + +### Test Steps +1. **Start the AGH daemon** + - Input: `agh daemon start` (or equivalent) + - **Expected:** Daemon process starts without errors. Exit code 0 for background mode. Log output includes task subsystem initialization messages. + +2. **Verify task store tables created** + - Input: Check daemon startup logs for SQLite migration or table creation + - **Expected:** Logs confirm `tasks`, `task_runs`, `task_events`, `task_dependencies` tables initialized in `agh.db`. + +3. **Verify HTTP task routes registered** + - Input: `curl -s http://localhost:/api/tasks` (with auth) + - **Expected:** 200 OK with empty array `[]` (no tasks yet). Confirms route is live. + +4. **Verify UDS task routes registered** + - Input: `agh task list` via CLI (which uses UDS) + - **Expected:** Empty list returned. No connection errors. + +5. **Verify observe layer initialized** + - Input: Query task metrics endpoint or observe summary + - **Expected:** Response with zero-valued metrics (no tasks yet). No errors. + +--- + +### Related Test Cases +- SMOKE-002: Create a global task via HTTP API +- SMOKE-009: CLI `agh task list` returns results diff --git a/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-002.md b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-002.md new file mode 100644 index 000000000..f4874186e --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-002.md @@ -0,0 +1,54 @@ +## SMOKE-002: Create a Global Task via HTTP API + +**Priority:** P0 +**Type:** Smoke +**Status:** Not Run +**Estimated Time:** 2-3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Quick sanity check that a global-scoped task can be created through the HTTP API, persisted, and returned with server-derived identity fields and correct initial status. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] Authenticated HTTP client available + +--- + +### Test Steps +1. **Create a global task** + - Input: `POST /api/tasks` with body: + ```json + { + "scope": "global", + "title": "Smoke test task", + "description": "Created during smoke testing" + } + ``` + - **Expected:** 201 Created. Response includes: + - `id`: non-empty string (server-generated, e.g., `"tsk_..."`) + - `scope`: `"global"` + - `title`: `"Smoke test task"` + - `status`: `"pending"` or `"ready"` + - `created_by.kind`: matches authenticated principal kind + - `origin.kind`: `"http"` + - `created_at`: valid ISO 8601 timestamp + +2. **Verify task appears in list** + - Input: `GET /api/tasks` + - **Expected:** 200 OK. Array contains at least one task with the title `"Smoke test task"`. + +3. **Verify task detail retrieval** + - Input: `GET /api/tasks/` using the ID from step 1 + - **Expected:** 200 OK. Full task detail including empty `children`, `dependencies`, `runs`, and `events` arrays (except the `task.created` event). + +--- + +### Related Test Cases +- SMOKE-001: Daemon starts with task subsystem +- SMOKE-003: List tasks via HTTP API +- TC-SEC-001: Server-derived created_by identity diff --git a/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-003.md b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-003.md new file mode 100644 index 000000000..107b976e1 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-003.md @@ -0,0 +1,48 @@ +## SMOKE-003: List Tasks via HTTP API + +**Priority:** P0 +**Type:** Smoke +**Status:** Not Run +**Estimated Time:** 2-3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Quick sanity check that the task list endpoint returns a 200 response with an array of task summaries, supporting basic query filter parameters. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] At least 2 tasks exist (1 global, 1 workspace-scoped) created via prior smoke tests or seeding + +--- + +### Test Steps +1. **List all tasks (no filters)** + - Input: `GET /api/tasks` + - **Expected:** 200 OK. Response body is a JSON array. Each element contains at minimum: `id`, `scope`, `title`, `status`, `created_by`, `origin`, `created_at`. + +2. **List tasks with scope filter** + - Input: `GET /api/tasks?scope=global` + - **Expected:** 200 OK. All returned tasks have `scope: "global"`. No workspace-scoped tasks in results. + +3. **List tasks with status filter** + - Input: `GET /api/tasks?status=pending` + - **Expected:** 200 OK. All returned tasks have `status: "pending"`. + +4. **List tasks with limit parameter** + - Input: `GET /api/tasks?limit=1` + - **Expected:** 200 OK. Array contains at most 1 element. + +5. **List tasks with invalid filter value** + - Input: `GET /api/tasks?scope=invalid_scope` + - **Expected:** 400 Bad Request or empty results (depending on validation strategy). No 500 error. + +--- + +### Related Test Cases +- SMOKE-002: Create a global task +- SMOKE-004: Get task detail by ID +- TC-PERF-005: ListTasks filter performance on large dataset diff --git a/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-004.md b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-004.md new file mode 100644 index 000000000..c7a3304ac --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-004.md @@ -0,0 +1,59 @@ +## SMOKE-004: Get Task Detail by ID with Children, Dependencies, Runs, and Events + +**Priority:** P0 +**Type:** Smoke +**Status:** Not Run +**Estimated Time:** 2-3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Quick sanity check that the task detail endpoint returns the full `TaskDetailPayload` including the task record, children array, dependencies array, runs array, and events audit trail. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] At least one task exists with: + - At least 1 child task + - At least 1 dependency edge + - At least 1 run (any status) + - At least 1 audit event (automatically created with the task) + +--- + +### Test Steps +1. **Create a parent task with children and dependencies** + - Input: Create parent task, create 1 child via `POST /api/tasks//children`, create a second independent task, add dependency via `POST /api/tasks//dependencies` + - **Expected:** All created successfully. + +2. **Get task detail by ID** + - Input: `GET /api/tasks/` + - **Expected:** 200 OK. Response matches `TaskDetailPayload` structure: + ```json + { + "task": { "id": "...", "scope": "...", "title": "...", ... }, + "children": [{ "id": "...", "title": "...", ... }], + "dependencies": [{ "task_id": "...", "depends_on_task_id": "...", "kind": "blocks", ... }], + "runs": [...], + "events": [{ "event_type": "task.created", ... }, ...] + } + ``` + +3. **Verify children array** + - **Expected:** `children` contains the child task created in step 1. Each child has `parent_task_id` matching the parent ID. + +4. **Verify events audit trail** + - **Expected:** `events` contains at minimum a `"task.created"` event with `actor` and `origin` fields populated. + +5. **Get non-existent task** + - Input: `GET /api/tasks/nonexistent-id-12345` + - **Expected:** 404 Not Found. No task data in response. + +--- + +### Related Test Cases +- SMOKE-002: Create a global task +- SMOKE-003: List tasks +- SMOKE-006: Enqueue and claim a run diff --git a/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-005.md b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-005.md new file mode 100644 index 000000000..2a80dcc3d --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-005.md @@ -0,0 +1,59 @@ +## SMOKE-005: Update Task Title via PATCH + +**Priority:** P0 +**Type:** Smoke +**Status:** Not Run +**Estimated Time:** 2-3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Quick sanity check that a task's mutable fields (title, description, metadata, owner) can be updated via the PATCH endpoint, while immutable fields (scope, workspace_id, parent_task_id, created_by, origin) remain unchanged. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] At least one task exists + +--- + +### Test Steps +1. **Update task title** + - Input: `PATCH /api/tasks/` with body: + ```json + { + "title": "Updated smoke test title" + } + ``` + - **Expected:** 200 OK. Response shows `title: "Updated smoke test title"`. `updated_at` timestamp is newer than `created_at`. + +2. **Verify update persisted** + - Input: `GET /api/tasks/` + - **Expected:** Task detail shows the updated title. All immutable fields (scope, created_by, origin) unchanged from original values. + +3. **Update multiple mutable fields** + - Input: `PATCH /api/tasks/` with body: + ```json + { + "description": "Updated description", + "owner": {"kind": "human", "ref": "user-2"} + } + ``` + - **Expected:** 200 OK. Both description and owner updated. + +4. **Verify audit event for update** + - Input: `GET /api/tasks/` and inspect events + - **Expected:** Events array contains a `"task.updated"` event. + +5. **PATCH with empty body (no changes)** + - Input: `PATCH /api/tasks/` with body `{}` + - **Expected:** 200 OK or 400 (no changes). Task unchanged. No spurious audit events. + +--- + +### Related Test Cases +- SMOKE-002: Create a global task +- SMOKE-004: Get task detail +- TC-SEC-006: SQL injection in title field diff --git a/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-006.md b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-006.md new file mode 100644 index 000000000..42017d839 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-006.md @@ -0,0 +1,62 @@ +## SMOKE-006: Enqueue and Claim a Task Run + +**Priority:** P0 +**Type:** Smoke +**Status:** Not Run +**Estimated Time:** 2-3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Quick sanity check that a task run can be enqueued and then claimed, transitioning through the expected lifecycle states: queued -> claimed. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] At least one task exists in `pending` or `ready` status + +--- + +### Test Steps +1. **Enqueue a run for an existing task** + - Input: `POST /api/tasks//runs` with body: + ```json + { + "idempotency_key": "smoke-run-001" + } + ``` + - **Expected:** 201 Created (or 200 OK). Response includes: + - `id`: non-empty run ID (e.g., `"run_..."`) + - `task_id`: matches the parent task ID + - `status`: `"queued"` + - `attempt`: 1 + - `queued_at`: valid timestamp + - `origin`: server-derived + +2. **Verify task status updated** + - Input: `GET /api/tasks/` + - **Expected:** Task status may have transitioned (e.g., to `"in_progress"` or remain `"ready"` depending on run state). + +3. **Claim the run** + - Input: `POST /api/task-runs//claim` with body `{}` + - **Expected:** 200 OK. Response shows: + - `status`: `"claimed"` + - `claimed_by`: populated with the authenticated principal identity + - `claimed_at`: valid timestamp + +4. **Verify run appears in task detail** + - Input: `GET /api/tasks/` + - **Expected:** `runs` array contains the run with status `"claimed"`. + +5. **Verify audit events** + - Input: Check events in task detail + - **Expected:** Events include `"task.run_enqueued"` and `"task.run_claimed"` entries. + +--- + +### Related Test Cases +- SMOKE-007: Start and complete a run +- SMOKE-008: Cancel a task +- TC-PERF-001: Task creation throughput diff --git a/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-007.md b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-007.md new file mode 100644 index 000000000..dc43e5b64 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-007.md @@ -0,0 +1,58 @@ +## SMOKE-007: Start and Complete a Task Run + +**Priority:** P0 +**Type:** Smoke +**Status:** Not Run +**Estimated Time:** 2-3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Quick sanity check that a claimed task run can progress through the full lifecycle: claimed -> starting -> running -> completed. Validates the run state machine transitions and task status reconciliation. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] A task run exists in `claimed` status (from SMOKE-006 or fresh setup) + +--- + +### Test Steps +1. **Start the claimed run** + - Input: `POST /api/task-runs//start` with body `{}` + - **Expected:** 200 OK. Response shows: + - `status`: `"starting"` (transitional) or `"running"` (if session starts immediately) + - `started_at`: populated once running + +2. **Verify task transitions to in_progress** + - Input: `GET /api/tasks/` + - **Expected:** Task status is `"in_progress"`. + +3. **Complete the run** + - Input: `POST /api/task-runs//complete` with body: + ```json + { + "result": {"output": "smoke test passed"} + } + ``` + - **Expected:** 200 OK. Response shows: + - `status`: `"completed"` + - `ended_at`: valid timestamp + - `result`: `{"output": "smoke test passed"}` + +4. **Verify task status after run completion** + - Input: `GET /api/tasks/` + - **Expected:** Task status is `"completed"`. `closed_at` is populated. + +5. **Verify audit trail** + - Input: Check events in task detail + - **Expected:** Events include `"task.run_started"` (or `"task.run_starting"`) and `"task.run_completed"` entries with correct timestamps. + +--- + +### Related Test Cases +- SMOKE-006: Enqueue and claim a run +- SMOKE-008: Cancel a task +- TC-PERF-004: Cancellation propagation diff --git a/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-008.md b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-008.md new file mode 100644 index 000000000..5892b6c2e --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-008.md @@ -0,0 +1,57 @@ +## SMOKE-008: Cancel a Task and Its Active Runs + +**Priority:** P0 +**Type:** Smoke +**Status:** Not Run +**Estimated Time:** 2-3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Quick sanity check that cancelling a task transitions both the task and its active (non-terminal) runs to cancelled status, with proper audit events and reason propagation. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] A task exists with at least one active run (queued or claimed) + +--- + +### Test Steps +1. **Set up: create task with an active run** + - Input: Create a task, enqueue a run, optionally claim it + - **Expected:** Task in `ready` or `in_progress` status. Run in `queued` or `claimed` status. + +2. **Cancel the task** + - Input: `POST /api/tasks//cancel` with body: + ```json + { + "reason": "Smoke test cancellation" + } + ``` + - **Expected:** 200 OK. Response shows task with `status: "cancelled"`. + +3. **Verify task is cancelled** + - Input: `GET /api/tasks/` + - **Expected:** Task `status` is `"cancelled"`. `closed_at` is populated. + +4. **Verify active runs are cancelled** + - Input: Check `runs` array in task detail + - **Expected:** All previously active runs now have `status: "cancelled"`. `ended_at` is populated. + +5. **Verify audit events** + - Input: Check events in task detail + - **Expected:** Events include `"task.cancelled"` and `"task.run_cancelled"` entries. The `"task.cancelled"` event payload includes the reason `"Smoke test cancellation"`. + +6. **Verify cancelled task cannot be re-activated** + - Input: Attempt to enqueue a new run on the cancelled task + - **Expected:** Error returned (invalid status transition). No new run created. + +--- + +### Related Test Cases +- SMOKE-006: Enqueue and claim a run +- SMOKE-007: Start and complete a run +- TC-PERF-004: Cancellation propagation on large tree diff --git a/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-009.md b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-009.md new file mode 100644 index 000000000..63b79c9fc --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-009.md @@ -0,0 +1,49 @@ +## SMOKE-009: CLI `agh task list` Returns Results + +**Priority:** P0 +**Type:** Smoke +**Status:** Not Run +**Estimated Time:** 2-3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Quick sanity check that the CLI command `agh task list` communicates with the daemon over UDS, queries the task store, and returns formatted task results to the terminal. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem and UDS API initialized +- [ ] At least one task exists in the store (created via HTTP or CLI) +- [ ] AGH CLI binary available on PATH + +--- + +### Test Steps +1. **Run `agh task list` with no filters** + - Input: `agh task list` + - **Expected:** Output displays a list of tasks. Each entry shows at minimum: task ID, title, status, scope. Exit code 0. + +2. **Run `agh task list` with scope filter** + - Input: `agh task list --scope global` + - **Expected:** Output shows only global-scoped tasks. Exit code 0. + +3. **Run `agh task list` with status filter** + - Input: `agh task list --status pending` + - **Expected:** Output shows only tasks with pending status. Exit code 0. + +4. **Run `agh task list` when no tasks match** + - Input: `agh task list --status completed` (assuming no completed tasks) + - **Expected:** Empty output or "no tasks found" message. Exit code 0 (not an error). + +5. **Verify UDS communication** + - Input: Check that the CLI uses the UDS socket (not HTTP) by verifying no HTTP requests in daemon access logs + - **Expected:** CLI communicates via UDS socket at the configured path. + +--- + +### Related Test Cases +- SMOKE-001: Daemon starts with task subsystem +- SMOKE-003: List tasks via HTTP API +- SMOKE-002: Create a global task diff --git a/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-010.md b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-010.md new file mode 100644 index 000000000..1631ba6fc --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/SMOKE-010.md @@ -0,0 +1,53 @@ +## SMOKE-010: Observe Projections Return Task Metrics + +**Priority:** P0 +**Type:** Smoke +**Status:** Not Run +**Estimated Time:** 2-3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Quick sanity check that the observe layer's task projection endpoints return valid metrics, including queue depth totals, per-channel breakdowns, and task status distribution. Validates end-to-end wiring from store through Observer to HTTP/UDS response. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem and observe layer initialized +- [ ] At least 1 task with 1 queued run exists (to produce non-zero queue metrics) + +--- + +### Test Steps +1. **Query task summary projection** + - Input: Request task summary from observe endpoint (HTTP or UDS) + - **Expected:** 200 OK. Response includes: + - `queue_depth_total`: integer >= 0 + - `queue_depth`: array of per-channel queue depth entries (may include unbound entry) + - Task status distribution counters + +2. **Verify queue depth reflects active runs** + - Input: Create a task, enqueue a run (leaving it queued), then query summary + - **Expected:** `queue_depth_total` >= 1. Queue depth array includes an entry for the unbound channel (or the run's channel) with count >= 1. + +3. **Query task metrics** + - Input: Request task metrics from observe endpoint + - **Expected:** 200 OK. Response includes `TaskMetrics` structure with: + - `task_queue_depth`: per-channel breakdown + - Counts for tasks by status + +4. **Verify metrics update after state change** + - Input: Claim the queued run, then re-query metrics + - **Expected:** `queue_depth_total` decremented by 1. Metrics reflect the new state. + +5. **Verify metrics with filter parameter** + - Input: Query metrics with `network_channel` filter + - **Expected:** Response scoped to the specified channel. No data from other channels. + +--- + +### Related Test Cases +- SMOKE-006: Enqueue and claim a run +- TC-PERF-006: Observe projection query performance +- SMOKE-001: Daemon starts with task subsystem diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-001.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-001.md new file mode 100644 index 000000000..773760f6f --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-001.md @@ -0,0 +1,79 @@ +## TC-FUNC-001: Create global task with valid fields + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that creating a task with scope "global", a non-empty title, valid actor identity, and valid origin produces a persisted task record with server-derived ID, status "pending", correct timestamps, and all supplied fields stored verbatim. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] ActorContext with Authority.Write=true, Authority.CreateGlobal=true +- [ ] No pre-existing tasks in the store + +--- + +### Test Steps + +1. **Create a global task via TaskManager.CreateTask** + - Input: + ```json + { + "scope": "global", + "title": "Bootstrap infrastructure", + "description": "Set up core services", + "metadata": {"priority": "high"} + } + ``` + ActorContext: Actor={Kind:"human", Ref:"user-1"}, Origin={Kind:"cli", Ref:"terminal-1"}, Authority={Read:true, Write:true, CreateGlobal:true} + - **Expected:** No error returned + +2. **Inspect the returned Task record** + - **Expected:** + - `task.ID` is non-empty and server-generated (prefixed with task domain prefix) + - `task.Scope` == "global" + - `task.WorkspaceID` == "" (empty for global) + - `task.Title` == "Bootstrap infrastructure" + - `task.Description` == "Set up core services" + - `task.Status` == "pending" + - `task.CreatedBy` == {Kind:"human", Ref:"user-1"} + - `task.Origin` == {Kind:"cli", Ref:"terminal-1"} + - `task.CreatedAt` is non-zero and close to now + - `task.UpdatedAt` == `task.CreatedAt` + - `task.Metadata` contains `{"priority": "high"}` + - `task.ParentTaskID` == "" + - `task.Owner` == nil + +3. **Read the task back from the store by ID** + - **Expected:** Returned task matches all fields from step 2 exactly + +4. **Verify a task.created event was recorded** + - **Expected:** TaskEvent exists with EventType="task.created", TaskID=task.ID, Actor and Origin matching the ActorContext + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Minimal valid fields | scope="global", title="x" (no description, no metadata, no owner) | Task created with empty description, nil metadata, nil owner | +| With optional owner | scope="global", title="x", owner={kind:"human", ref:"owner-1"} | Task created with owner set | +| With optional identifier | scope="global", title="x", identifier="INFRA-001" | Task created with identifier stored | +| With network_channel | scope="global", title="x", network_channel="chan-1" | Task created with network_channel set (if channel validator passes) | +| Empty title | scope="global", title="" | ErrValidation returned | +| Missing scope | scope="", title="x" | ErrValidation returned | +| Invalid scope value | scope="unknown", title="x" | ErrValidation returned | + +--- + +### Related Test Cases +- TC-FUNC-002: Create workspace-scoped task +- TC-FUNC-003: Create task with invalid scope binding +- TC-FUNC-004: Update mutable fields diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-002.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-002.md new file mode 100644 index 000000000..b1b817a0b --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-002.md @@ -0,0 +1,76 @@ +## TC-FUNC-002: Create workspace-scoped task + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that creating a task with scope "workspace" requires a non-empty workspace_id, produces a persisted task bound to that workspace, and that omitting workspace_id with scope "workspace" is rejected with ErrInvalidScopeBinding. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] ActorContext with Authority.Write=true, Authority.CreateWorkspace=true +- [ ] A valid workspace identifier "ws-abc123" available + +--- + +### Test Steps + +1. **Create a workspace-scoped task with valid workspace_id** + - Input: + ```json + { + "scope": "workspace", + "workspace_id": "ws-abc123", + "title": "Implement feature X" + } + ``` + ActorContext: Actor={Kind:"human", Ref:"user-1"}, Origin={Kind:"web", Ref:"browser-session-1"}, Authority={Read:true, Write:true, CreateWorkspace:true} + - **Expected:** No error returned + +2. **Inspect the returned Task record** + - **Expected:** + - `task.Scope` == "workspace" + - `task.WorkspaceID` == "ws-abc123" + - `task.Status` == "pending" + - `task.Title` == "Implement feature X" + - All server-derived fields (ID, CreatedAt, UpdatedAt, CreatedBy, Origin) populated correctly + +3. **Attempt to create workspace-scoped task without workspace_id** + - Input: + ```json + { + "scope": "workspace", + "workspace_id": "", + "title": "Missing workspace" + } + ``` + - **Expected:** Error wrapping ErrInvalidScopeBinding; message indicates workspace_id is required when scope is "workspace" + +4. **Attempt to create workspace-scoped task with whitespace-only workspace_id** + - Input: scope="workspace", workspace_id=" ", title="Whitespace workspace" + - **Expected:** Error wrapping ErrInvalidScopeBinding + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Valid workspace binding | scope="workspace", workspace_id="ws-1" | Task persisted with workspace binding | +| Empty workspace_id | scope="workspace", workspace_id="" | ErrInvalidScopeBinding | +| Whitespace workspace_id | scope="workspace", workspace_id=" " | ErrInvalidScopeBinding | +| Missing CreateWorkspace authority | Authority.CreateWorkspace=false | ErrPermissionDenied | +| Very long workspace_id | scope="workspace", workspace_id=string(500 chars) | Task created (no length limit on workspace_id itself) | + +--- + +### Related Test Cases +- TC-FUNC-001: Create global task with valid fields +- TC-FUNC-003: Create task with invalid scope binding diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-003.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-003.md new file mode 100644 index 000000000..49a518ad2 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-003.md @@ -0,0 +1,59 @@ +## TC-FUNC-003: Create task with invalid scope binding (global + workspace_id) + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that creating a task with scope "global" and a non-empty workspace_id is rejected with ErrInvalidScopeBinding. The scope/workspace invariant enforces that global tasks must have an empty workspace_id and workspace tasks must have a non-empty workspace_id. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] ActorContext with Authority.Write=true, Authority.CreateGlobal=true + +--- + +### Test Steps + +1. **Attempt to create a global task with a workspace_id** + - Input: + ```json + { + "scope": "global", + "workspace_id": "ws-unexpected", + "title": "Invalid binding" + } + ``` + ActorContext: valid with CreateGlobal authority + - **Expected:** Error returned; `errors.Is(err, ErrInvalidScopeBinding)` == true; error message indicates workspace_id must be empty when scope is "global" + +2. **Verify no task was persisted** + - Query store for tasks + - **Expected:** No task with title "Invalid binding" exists + +3. **Verify no task event was recorded** + - **Expected:** No task.created event in the event store + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| global + non-empty workspace_id | scope="global", workspace_id="ws-1" | ErrInvalidScopeBinding | +| workspace + empty workspace_id | scope="workspace", workspace_id="" | ErrInvalidScopeBinding | +| global + whitespace workspace_id | scope="global", workspace_id=" " | Depends on trim behavior; if trimmed to empty, may pass as valid global | +| Both scope and workspace_id empty | scope="", workspace_id="" | ErrValidation (scope is required) | + +--- + +### Related Test Cases +- TC-FUNC-001: Create global task with valid fields +- TC-FUNC-002: Create workspace-scoped task +- TC-FUNC-005: Attempt to update immutable fields diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-004.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-004.md new file mode 100644 index 000000000..bd8fea550 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-004.md @@ -0,0 +1,81 @@ +## TC-FUNC-004: Update mutable fields (title, description, metadata, owner, network_channel) + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that updating each mutable task field (title, description, metadata_json, owner, network_channel) via TaskPatch succeeds, persists the change, updates updated_at, and that immutable fields remain unchanged. Also validates ClearOwner behavior and the requirement that at least one mutable field must be present in the patch. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] One existing global task created with known ID, title="Original Title", description="Original", owner={kind:"human", ref:"owner-1"}, metadata={"key":"val"} +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Update title** + - Input: TaskPatch{Title: ptr("Updated Title")} + - **Expected:** Returned task has Title="Updated Title"; UpdatedAt > original UpdatedAt; all other fields unchanged + +2. **Update description** + - Input: TaskPatch{Description: ptr("Updated description")} + - **Expected:** Returned task has Description="Updated description"; UpdatedAt advanced + +3. **Update metadata** + - Input: TaskPatch{Metadata: ptr(json.RawMessage(`{"new_key":"new_val"}`))} + - **Expected:** Returned task has Metadata=`{"new_key":"new_val"}`; previous metadata fully replaced (not merged) + +4. **Update owner** + - Input: TaskPatch{Owner: &Ownership{Kind:"agent_session", Ref:"session-42"}} + - **Expected:** Returned task has Owner={Kind:"agent_session", Ref:"session-42"} + +5. **Clear owner** + - Input: TaskPatch{ClearOwner: true} + - **Expected:** Returned task has Owner=nil + +6. **Update network_channel** + - Input: TaskPatch{NetworkChannel: ptr("chan-new")} + - **Expected:** Returned task has NetworkChannel="chan-new" + +7. **Attempt empty patch (no fields set)** + - Input: TaskPatch{} (all nil, ClearOwner=false) + - **Expected:** ErrValidation returned; message indicates at least one mutable field is required + +8. **Attempt to set both Owner and ClearOwner** + - Input: TaskPatch{Owner: &Ownership{Kind:"human", Ref:"x"}, ClearOwner: true} + - **Expected:** ErrValidation returned; cannot set both owner and clear_owner + +9. **Attempt to set title to empty string** + - Input: TaskPatch{Title: ptr("")} + - **Expected:** ErrValidation returned; title is required when provided + +10. **Verify task.updated event recorded for each successful update** + - **Expected:** TaskEvent with EventType="task.updated" for each successful step + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Multiple fields in one patch | Title + Description both set | Both updated atomically | +| Metadata set to null JSON | Metadata: ptr(json.RawMessage("null")) | Metadata cleared or stored as null | +| Owner with invalid kind | Owner={Kind:"invalid", Ref:"x"} | ErrValidation | +| Whitespace-only title | Title: ptr(" ") | ErrValidation (title required when provided) | +| Update nonexistent task | ID="nonexistent" | ErrTaskNotFound | + +--- + +### Related Test Cases +- TC-FUNC-001: Create global task with valid fields +- TC-FUNC-005: Attempt to update immutable fields +- TC-FUNC-026: Metadata payload size limit diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-005.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-005.md new file mode 100644 index 000000000..7b2d4b7f2 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-005.md @@ -0,0 +1,73 @@ +## TC-FUNC-005: Attempt to update immutable fields (scope, workspace_id, parent_task_id, created_by, origin) + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that each immutable task field (scope, workspace_id, parent_task_id, created_by_kind, created_by_ref, origin_kind, origin_ref) cannot be changed after task creation. Each attempt must return ErrImmutableField with the field name in the error message. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] One existing global task (scope="global", workspace_id="") created by actor {Kind:"human", Ref:"creator-1"} via origin {Kind:"cli", Ref:"term-1"}, with no parent +- [ ] Direct access to ValidateImmutableTaskFields or equivalent update path + +--- + +### Test Steps + +1. **Attempt to change scope from "global" to "workspace"** + - Construct a modified task record with Scope="workspace" + - Call ValidateImmutableTaskFields(original, modified) + - **Expected:** Error returned; `errors.Is(err, ErrImmutableField)` == true; error message contains "scope" + +2. **Attempt to change workspace_id from "" to "ws-1"** + - Construct a modified task record with WorkspaceID="ws-1" + - **Expected:** Error returned; `errors.Is(err, ErrImmutableField)` == true; error message contains "workspace_id" + +3. **Attempt to change parent_task_id from "" to "task-parent"** + - Construct a modified task record with ParentTaskID="task-parent" + - **Expected:** Error returned; `errors.Is(err, ErrImmutableField)` == true; error message contains "parent_task_id" + +4. **Attempt to change created_by kind from "human" to "agent_session"** + - Construct a modified task record with CreatedBy.Kind="agent_session" + - **Expected:** Error returned; `errors.Is(err, ErrImmutableField)` == true; error message contains "created_by" + +5. **Attempt to change created_by ref from "creator-1" to "creator-2"** + - Construct a modified task record with CreatedBy.Ref="creator-2" + - **Expected:** Error returned; `errors.Is(err, ErrImmutableField)` == true; error message contains "created_by" + +6. **Attempt to change origin kind from "cli" to "web"** + - Construct a modified task record with Origin.Kind="web" + - **Expected:** Error returned; `errors.Is(err, ErrImmutableField)` == true; error message contains "origin" + +7. **Attempt to change origin ref from "term-1" to "browser-1"** + - Construct a modified task record with Origin.Ref="browser-1" + - **Expected:** Error returned; `errors.Is(err, ErrImmutableField)` == true; error message contains "origin" + +8. **Verify no changes were persisted for any failed attempt** + - Read the task back from the store + - **Expected:** All fields match the original values exactly + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Same immutable value (no-op) | scope remains "global" | No error (values unchanged) | +| Case-sensitive scope change | scope="Global" vs "global" | Depends on normalization; after Normalize() they match, so no error | +| Whitespace-padded ref | created_by.ref=" creator-1 " vs "creator-1" | Depends on TrimSpace; if trimmed values match, no error | +| Multiple immutable fields changed | scope + workspace_id both changed | First immutable violation detected is reported | + +--- + +### Related Test Cases +- TC-FUNC-004: Update mutable fields +- TC-FUNC-001: Create global task with valid fields diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-006.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-006.md new file mode 100644 index 000000000..59fe937a7 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-006.md @@ -0,0 +1,73 @@ +## TC-FUNC-006: Create child task under valid parent + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that creating a task with a valid parent_task_id sets the parent linkage correctly, that the child inherits the parent's scope and workspace binding, and that depth is tracked and validated within the MaxHierarchyDepth limit of 8 levels. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] One existing parent task with known ID (e.g., scope="workspace", workspace_id="ws-1") +- [ ] ActorContext with Authority.Write=true, Authority.CreateWorkspace=true + +--- + +### Test Steps + +1. **Create a child task under a valid parent** + - Input: + ```json + { + "scope": "workspace", + "workspace_id": "ws-1", + "parent_task_id": "", + "title": "Child task" + } + ``` + - **Expected:** No error returned + +2. **Inspect the returned child Task record** + - **Expected:** + - `task.ParentTaskID` == parent task ID + - `task.Scope` == parent's scope ("workspace") + - `task.WorkspaceID` == parent's workspace_id ("ws-1") + - `task.Status` == "pending" + - Server-derived ID is different from parent ID + +3. **Verify a task.child_created event was recorded on the parent** + - **Expected:** TaskEvent with EventType="task.child_created" and TaskID=parent task ID + +4. **Create a grandchild task (depth=2)** + - Input: parent_task_id=child task ID, scope="workspace", workspace_id="ws-1", title="Grandchild" + - **Expected:** Task created successfully at depth 2 + +5. **Verify the parent task's children list includes the child** + - Query TaskView for parent + - **Expected:** Children array contains the child task summary + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Child of global parent | parent scope="global", child scope="global" | Child created with no workspace_id | +| Child scope mismatch | parent scope="workspace", child scope="global" | Rejected; child must match parent scope | +| Child workspace mismatch | parent ws_id="ws-1", child ws_id="ws-2" | Rejected; child must match parent workspace | +| Nonexistent parent | parent_task_id="nonexistent" | ErrTaskNotFound | +| Self-referencing parent | parent_task_id == own ID (client-supplied) | ErrValidation (cannot equal own ID) | + +--- + +### Related Test Cases +- TC-FUNC-007: Create child at max hierarchy depth +- TC-FUNC-008: Create 65th direct child +- TC-FUNC-001: Create global task with valid fields diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-007.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-007.md new file mode 100644 index 000000000..b63dfcf92 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-007.md @@ -0,0 +1,63 @@ +## TC-FUNC-007: Create child at max hierarchy depth (8) + +**Priority:** P1 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that the task hierarchy depth is bounded at MaxHierarchyDepth=8 levels. Creating a child that would exceed depth 8 must be rejected with ErrGraphLimitExceeded. Creating a child exactly at depth 8 must succeed. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] ActorContext with Authority.Write=true, Authority.CreateGlobal=true +- [ ] No pre-existing tasks + +--- + +### Test Steps + +1. **Create a chain of 8 nested tasks (depth 0 through 7)** + - Create root task (depth 0): scope="global", title="Level 0" + - Create child of Level 0 (depth 1): parent_task_id=Level0.ID, title="Level 1" + - Continue chaining until Level 7 (depth 7): parent_task_id=Level6.ID, title="Level 7" + - **Expected:** All 8 tasks created successfully; no errors + +2. **Verify depth 7 task exists and is valid** + - Read "Level 7" task from store + - **Expected:** ParentTaskID points to "Level 6" task; all fields valid + +3. **Attempt to create a child at depth 8 (Level 8)** + - Input: parent_task_id=Level7.ID, scope="global", title="Level 8 (too deep)" + - **Expected:** Error returned; `errors.Is(err, ErrGraphLimitExceeded)` == true; error message contains "hierarchy depth" and "8" + +4. **Verify the rejected task was not persisted** + - Query store for task with title "Level 8 (too deep)" + - **Expected:** No such task exists + +5. **Verify that a sibling at depth 7 can still be created** + - Input: parent_task_id=Level6.ID, scope="global", title="Level 7 sibling" + - **Expected:** Task created successfully (same depth as Level 7, not exceeding limit) + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Exactly at MaxHierarchyDepth boundary | depth=7 (8th level, 0-indexed) | Depends on whether limit is inclusive; test the exact boundary | +| Deep chain with workspace scope | All tasks scope="workspace", ws="ws-1" | Same depth limit applies | +| Negative depth value | ValidateHierarchyDepth(-1) | ErrValidation (cannot be negative) | +| Zero depth (root) | ValidateHierarchyDepth(0) | Valid | + +--- + +### Related Test Cases +- TC-FUNC-006: Create child task under valid parent +- TC-FUNC-008: Create 65th direct child +- TC-FUNC-012: Add 33rd dependency diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-008.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-008.md new file mode 100644 index 000000000..4995385db --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-008.md @@ -0,0 +1,61 @@ +## TC-FUNC-008: Create 65th direct child exceeds MaxDirectChildren=64 + +**Priority:** P1 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that the direct child fan-out limit of MaxDirectChildren=64 is enforced. A parent task with 64 children must accept the 64th child but reject the 65th with ErrGraphLimitExceeded. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] ActorContext with Authority.Write=true, Authority.CreateGlobal=true +- [ ] One existing parent task with known ID + +--- + +### Test Steps + +1. **Create 64 child tasks under the parent** + - Loop: for i in 1..64, create child task with parent_task_id=parent.ID, title="Child {i}" + - **Expected:** All 64 children created successfully; no errors + +2. **Verify parent has exactly 64 children** + - Query TaskView for parent or count children in store + - **Expected:** Children count == 64 + +3. **Attempt to create the 65th child** + - Input: parent_task_id=parent.ID, scope=parent scope, title="Child 65 (overflow)" + - **Expected:** Error returned; `errors.Is(err, ErrGraphLimitExceeded)` == true; error message contains "direct child count" and "64" + +4. **Verify the 65th child was not persisted** + - Query store for task with title "Child 65 (overflow)" + - **Expected:** No such task exists + +5. **Verify that creating a grandchild under one of the 64 children still works** + - Input: parent_task_id=Child1.ID, title="Grandchild of child 1" + - **Expected:** Task created successfully (different parent, independent limit) + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Exactly 64 children | 64th child creation | Success | +| 65th child | 65th child creation | ErrGraphLimitExceeded | +| Delete a child then re-add | Remove one child, add new one | Depends on whether cancelled/deleted children count toward limit | +| Concurrent child creation | Two clients race to create the 64th and 65th | Exactly one succeeds; the other gets ErrGraphLimitExceeded | + +--- + +### Related Test Cases +- TC-FUNC-006: Create child task under valid parent +- TC-FUNC-007: Create child at max hierarchy depth +- TC-FUNC-012: Add 33rd dependency diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-009.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-009.md new file mode 100644 index 000000000..ee80c142d --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-009.md @@ -0,0 +1,70 @@ +## TC-FUNC-009: Add valid dependency edge + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that adding a dependency edge between two tasks with kind "blocks" persists the edge, triggers status reconciliation on the dependent task (moving it to "blocked" if the dependency is not completed), and records an audit event. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] Two existing tasks: Task A (status="pending") and Task B (status="pending"), both scope="global" +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Add dependency: Task A depends on Task B (kind="blocks")** + - Input: + ```json + { + "task_id": "", + "depends_on_task_id": "", + "kind": "blocks" + } + ``` + - **Expected:** No error returned + +2. **Verify the dependency edge is persisted** + - Query dependencies for Task A + - **Expected:** One dependency edge with DependsOnTaskID=Task B ID, Kind="blocks" + +3. **Verify Task A's status was reconciled to "blocked"** + - Read Task A from store + - **Expected:** Task A status == "blocked" (since Task B is not completed) + +4. **Verify a task.dependency_added event was recorded** + - Query events for Task A + - **Expected:** TaskEvent with EventType="task.dependency_added" + +5. **Complete Task B, then verify Task A reconciles to "ready"** + - Complete a run on Task B successfully + - Read Task A from store + - **Expected:** Task A status reconciles from "blocked" to "ready" (all dependencies satisfied) + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Dependency on already-completed task | Task B status="completed" | Task A stays "pending" or "ready" (not blocked) | +| Multiple dependencies | A depends on B and C | A is "blocked" until both B and C are completed | +| Duplicate dependency edge | Add same A->B edge twice | Idempotent or rejected depending on implementation | +| Cross-scope dependency | Task A scope="global", Task B scope="workspace" | Depends on policy; verify behavior | + +--- + +### Related Test Cases +- TC-FUNC-010: Add self-dependency +- TC-FUNC-011: Add dependency creating a cycle +- TC-FUNC-012: Add 33rd dependency +- TC-FUNC-013: Remove dependency edge diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-010.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-010.md new file mode 100644 index 000000000..44e0f2e42 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-010.md @@ -0,0 +1,61 @@ +## TC-FUNC-010: Add self-dependency rejected + +**Priority:** P1 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that adding a dependency where task_id == depends_on_task_id is rejected at the validation layer with ErrValidation. A task cannot depend on itself. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] One existing task with known ID +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Attempt to add a self-dependency** + - Input: + ```json + { + "task_id": "", + "depends_on_task_id": "", + "kind": "blocks" + } + ``` + - **Expected:** Error returned; `errors.Is(err, ErrValidation)` == true; error message contains "cannot depend on itself" + +2. **Verify no dependency edge was persisted** + - Query dependencies for the task + - **Expected:** No dependency edges exist + +3. **Verify no task.dependency_added event was recorded** + - **Expected:** No events of type "task.dependency_added" for this task + +4. **Verify the task status is unchanged** + - Read the task from store + - **Expected:** Status remains at its pre-test value (not "blocked") + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Self-dependency with whitespace | task_id=" task-1 ", depends_on=" task-1 " | Rejected after TrimSpace normalization | +| Self-dependency via AddDependency.Validate | Direct validation call | ErrValidation | +| Self-dependency via TaskDependency.Validate | Direct struct validation | ErrValidation | + +--- + +### Related Test Cases +- TC-FUNC-009: Add valid dependency edge +- TC-FUNC-011: Add dependency creating a cycle diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-011.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-011.md new file mode 100644 index 000000000..142a15abf --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-011.md @@ -0,0 +1,72 @@ +## TC-FUNC-011: Add dependency creating a cycle (A->B->C->A) rejected + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that adding a dependency edge that would create a cycle in the dependency graph is detected and rejected with ErrCycleDetected. This covers both direct two-node cycles (A->B->A) and transitive multi-node cycles (A->B->C->A). + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] Three existing tasks: Task A, Task B, Task C (all scope="global", status="pending") +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Set up the chain: A depends on B, B depends on C** + - Add dependency: A->B (kind="blocks") -- **Expected:** Success + - Add dependency: B->C (kind="blocks") -- **Expected:** Success + +2. **Verify the chain is established** + - Query dependencies for A: **Expected:** A depends on B + - Query dependencies for B: **Expected:** B depends on C + +3. **Attempt to close the cycle: C depends on A** + - Input: + ```json + { + "task_id": "", + "depends_on_task_id": "", + "kind": "blocks" + } + ``` + - **Expected:** Error returned; `errors.Is(err, ErrCycleDetected)` == true + +4. **Verify no C->A edge was persisted** + - Query dependencies for Task C + - **Expected:** No dependency edges exist for Task C + +5. **Verify Task C status is unchanged** + - **Expected:** Task C status remains "pending" (not "blocked") + +6. **Test direct two-node cycle: B depends on A (while A depends on B)** + - Input: task_id=B, depends_on_task_id=A, kind="blocks" + - **Expected:** Error returned; `errors.Is(err, ErrCycleDetected)` == true + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Direct 2-node cycle (A->B, B->A) | B depends on A after A depends on B | ErrCycleDetected | +| 3-node cycle (A->B->C->A) | C depends on A after chain | ErrCycleDetected | +| Long chain (A->B->C->D->E->A) | E depends on A after 4-hop chain | ErrCycleDetected | +| Diamond (no cycle): A->B, A->C, B->D, C->D | D->A would be a cycle; D->B is not | D->A: ErrCycleDetected; legitimate diamond: no error | +| Disconnected graphs | Add dependency in separate subgraph | No cycle; succeeds | + +--- + +### Related Test Cases +- TC-FUNC-009: Add valid dependency edge +- TC-FUNC-010: Add self-dependency +- TC-FUNC-012: Add 33rd dependency diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-012.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-012.md new file mode 100644 index 000000000..feccee116 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-012.md @@ -0,0 +1,75 @@ +## TC-FUNC-012: Add 33rd dependency exceeds MaxDependencyCount=32 + +**Priority:** P1 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that a task cannot have more than MaxDependencyCount=32 dependency edges. Adding the 33rd dependency must be rejected with ErrGraphLimitExceeded. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] 33 existing tasks: one dependent task (Task A) and 33 potential dependency targets (Task D1..D33) +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Add 32 dependencies to Task A** + - Loop: for i in 1..32, add dependency A->Di (kind="blocks") + - **Expected:** All 32 dependencies added successfully; no errors + +2. **Verify Task A has exactly 32 dependencies** + - Query dependencies for Task A + - **Expected:** 32 dependency edges returned + +3. **Verify Task A is "blocked"** + - Read Task A from store + - **Expected:** Task A status == "blocked" (32 pending dependencies) + +4. **Attempt to add the 33rd dependency (A->D33)** + - Input: + ```json + { + "task_id": "", + "depends_on_task_id": "", + "kind": "blocks" + } + ``` + - **Expected:** Error returned; `errors.Is(err, ErrGraphLimitExceeded)` == true; error message contains "dependency count" and "32" + +5. **Verify the 33rd edge was not persisted** + - Query dependencies for Task A + - **Expected:** Still exactly 32 edges + +6. **Remove one dependency, then add a new one** + - Remove A->D1 dependency + - Add A->D33 dependency + - **Expected:** Both operations succeed; Task A now has 32 dependencies (D2..D33) + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Exactly 32 dependencies | 32nd addition | Success | +| 33rd dependency | 33rd addition | ErrGraphLimitExceeded | +| Remove then re-add | Remove one, add new | Success (back to 32) | +| ValidateDependencyCount(33) | Direct validation call | ErrGraphLimitExceeded | +| ValidateDependencyCount(32) | Direct validation call | No error | + +--- + +### Related Test Cases +- TC-FUNC-009: Add valid dependency edge +- TC-FUNC-013: Remove dependency edge +- TC-FUNC-007: Create child at max hierarchy depth +- TC-FUNC-008: Create 65th direct child diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-013.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-013.md new file mode 100644 index 000000000..16071a8ad --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-013.md @@ -0,0 +1,65 @@ +## TC-FUNC-013: Remove dependency edge triggers status reconciliation + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that removing a dependency edge from a task persists the removal, records an audit event, and triggers status reconciliation. Specifically, when the last blocking dependency is removed from a "blocked" task, the task should reconcile to "ready". + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] Task A (status="blocked") with exactly one dependency: A depends on Task B (status="pending") +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Verify initial state** + - Read Task A from store + - **Expected:** Status == "blocked"; one dependency edge A->B exists + +2. **Remove the dependency: A no longer depends on B** + - Call RemoveDependency(ctx, taskA.ID, taskB.ID, actor) + - **Expected:** No error returned + +3. **Verify the dependency edge is removed** + - Query dependencies for Task A + - **Expected:** No dependency edges exist for Task A + +4. **Verify Task A status reconciled from "blocked" to "ready"** + - Read Task A from store + - **Expected:** Status == "ready" (no remaining blocking dependencies, assuming no other blockers) + +5. **Verify a task.dependency_removed event was recorded** + - Query events for Task A + - **Expected:** TaskEvent with EventType="task.dependency_removed" + +6. **Attempt to remove a non-existent dependency** + - Call RemoveDependency(ctx, taskA.ID, "nonexistent-task", actor) + - **Expected:** Error returned (ErrTaskDependencyNotFound or similar) + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Remove one of multiple deps | A depends on B and C; remove A->B | A stays "blocked" (still depends on C) | +| Remove last dep | A depends only on B; remove A->B | A reconciles from "blocked" to "ready" | +| Remove dep from non-blocked task | A is "pending" with dependency on completed B; remove A->B | A remains in current reconciled state | +| Remove dependency that was already completed | A depends on completed B; remove edge | No status change (was already unblocked by B's completion) | + +--- + +### Related Test Cases +- TC-FUNC-009: Add valid dependency edge +- TC-FUNC-012: Add 33rd dependency +- TC-FUNC-014: Enqueue run on ready task diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-014.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-014.md new file mode 100644 index 000000000..450816901 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-014.md @@ -0,0 +1,79 @@ +## TC-FUNC-014: Enqueue run on ready task + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that enqueueing a run on a task in "ready" (or "pending" with no deps) status creates a TaskRun record with status "queued", attempt=1, correct origin, and records a task.run_enqueued audit event. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] One existing task with status="pending" and no dependencies (will reconcile to ready or be treated as executable) +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Enqueue a run on the task** + - Input: + ```json + { + "task_id": "" + } + ``` + - **Expected:** No error returned + +2. **Inspect the returned TaskRun record** + - **Expected:** + - `run.ID` is non-empty and server-generated + - `run.TaskID` == task ID + - `run.Status` == "queued" + - `run.Attempt` == 1 + - `run.Origin` matches actor's origin + - `run.QueuedAt` is non-zero and close to now + - `run.ClaimedBy` == nil + - `run.ClaimedAt` is zero + - `run.SessionID` == "" + - `run.StartedAt` is zero + - `run.EndedAt` is zero + - `run.Result` is nil/empty + - `run.Error` == "" + +3. **Verify the run is persisted in the store** + - Query runs for the task + - **Expected:** One run matching all fields from step 2 + +4. **Verify a task.run_enqueued event was recorded** + - Query events for the task + - **Expected:** TaskEvent with EventType="task.run_enqueued", RunID=run.ID + +5. **Enqueue a second run on the same task** + - **Expected:** Second run created with Attempt=2 (or new attempt number), separate ID + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Enqueue on blocked task | Task status="blocked" | Rejected (task not executable) | +| Enqueue on completed task | Task status="completed" | Rejected | +| Enqueue on cancelled task | Task status="cancelled" | Rejected | +| Enqueue with idempotency_key | idempotency_key="key-1" | Run created with idempotency tracking | +| Enqueue with network_channel | network_channel="chan-1" | Run created with channel binding | +| Empty task_id | task_id="" | ErrValidation | + +--- + +### Related Test Cases +- TC-FUNC-015: Claim queued run +- TC-FUNC-019: Invalid transition (queued to running) +- TC-FUNC-021: Idempotent run enqueue diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-015.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-015.md new file mode 100644 index 000000000..16b002b10 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-015.md @@ -0,0 +1,70 @@ +## TC-FUNC-015: Claim queued run + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that claiming a queued run transitions it to status "claimed", sets claimed_by to the actor identity, sets claimed_at timestamp, and records a task.run_claimed audit event. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] One existing task with a queued run (status="queued") +- [ ] ActorContext with Authority.Write=true, Actor={Kind:"agent_session", Ref:"session-42"} + +--- + +### Test Steps + +1. **Claim the queued run** + - Call ClaimRun(ctx, runID, ClaimRun{}, actor) + - **Expected:** No error returned + +2. **Inspect the returned TaskRun record** + - **Expected:** + - `run.Status` == "claimed" + - `run.ClaimedBy` == {Kind:"agent_session", Ref:"session-42"} + - `run.ClaimedAt` is non-zero and close to now + - `run.SessionID` still == "" (session attached later) + - `run.StartedAt` still zero + - All other fields unchanged from queued state + +3. **Verify the claim is persisted in the store** + - Read the run back by ID + - **Expected:** All fields match step 2 + +4. **Verify a task.run_claimed event was recorded** + - Query events for the task + - **Expected:** TaskEvent with EventType="task.run_claimed", RunID=run.ID + +5. **Attempt to claim the same run again** + - Call ClaimRun(ctx, runID, ClaimRun{}, actor) + - **Expected:** Error returned; ErrInvalidStatusTransition (run is already claimed, not queued) + +6. **Attempt to claim a non-existent run** + - Call ClaimRun(ctx, "nonexistent", ClaimRun{}, actor) + - **Expected:** ErrTaskRunNotFound + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Claim by human actor | Actor={Kind:"human", Ref:"user-1"} | claimed_by reflects human actor | +| Claim by daemon | Actor={Kind:"daemon", Ref:"agh"} | claimed_by reflects daemon actor | +| Claim with idempotency_key | ClaimRun{IdempotencyKey:"key-1"} | Idempotency tracked | +| Claim run on non-executable task | Task became cancelled between enqueue and claim | Rejected | + +--- + +### Related Test Cases +- TC-FUNC-014: Enqueue run on ready task +- TC-FUNC-016: Start claimed run +- TC-FUNC-019: Invalid transition (queued to running) diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-016.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-016.md new file mode 100644 index 000000000..722c4968d --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-016.md @@ -0,0 +1,78 @@ +## TC-FUNC-016: Start claimed run creates dedicated session + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that starting a claimed run transitions it through "starting" to "running" status, invokes the SessionExecutor to create a dedicated session, binds the session to the run, sets started_at timestamp, reconciles the parent task to "in_progress", and records both task.run_starting and task.run_started audit events. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager, backing Store, and mock SessionExecutor) +- [ ] One existing task with a claimed run (status="claimed") +- [ ] SessionExecutor mock configured to return a valid SessionRef +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Start the claimed run** + - Call StartRun(ctx, runID, StartRun{}, actor) + - **Expected:** No error returned + +2. **Verify SessionExecutor.StartTaskSession was called** + - **Expected:** Called once with StartTaskSession containing the task, run, and actor context + +3. **Inspect the returned TaskRun record** + - **Expected:** + - `run.Status` == "running" + - `run.SessionID` is non-empty (bound to the session returned by SessionExecutor) + - `run.StartedAt` is non-zero and close to now + - `run.ClaimedBy` unchanged from claim step + - `run.ClaimedAt` unchanged from claim step + +4. **Verify the parent task reconciled to "in_progress"** + - Read the task from store + - **Expected:** Task status == "in_progress" + +5. **Verify audit events were recorded** + - Query events for the task + - **Expected:** + - TaskEvent with EventType="task.run_starting" (intermediate state) + - TaskEvent with EventType="task.run_started" (final state) + - Both events reference the run ID and session ID + +6. **Test SessionExecutor failure during start** + - Configure mock to return an error + - Create a new claimed run, attempt to start it + - **Expected:** Run transitions to "failed" status with error message containing the session failure; task reconciles accordingly + +7. **Test SessionExecutor returns nil SessionRef** + - Configure mock to return nil + - **Expected:** Run transitions to "failed" with appropriate error + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Start already-running run | run.Status="running" | ErrInvalidStatusTransition | +| Start a "starting" run (with session) | run.Status="starting", session_id set | Transitions to "running" (re-entry allowed) | +| Start a "starting" run (no session) | run.Status="starting", session_id="" | ErrInvalidStatusTransition (needs session binding) | +| SessionRef with empty session_id | SessionRef{SessionID:""} | Run fails; ErrValidation from SessionRef.Validate() | + +--- + +### Related Test Cases +- TC-FUNC-015: Claim queued run +- TC-FUNC-017: Complete running run with result +- TC-FUNC-018: Fail running run with error +- TC-FUNC-020: Attach session to claimed run diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-017.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-017.md new file mode 100644 index 000000000..2958e13c9 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-017.md @@ -0,0 +1,74 @@ +## TC-FUNC-017: Complete running run with result + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that completing a running task run transitions it to "completed" status, persists the result_json payload, sets ended_at timestamp, reconciles the parent task status (potentially to "completed"), and records a task.run_completed audit event. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] One existing task with a running run (status="running", session_id set) +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Complete the running run with a result** + - Input: + ```json + { + "value": {"output": "deployment successful", "duration_ms": 1234} + } + ``` + - Call CompleteRun(ctx, runID, RunResult{Value: resultJSON}, actor) + - **Expected:** No error returned + +2. **Inspect the returned TaskRun record** + - **Expected:** + - `run.Status` == "completed" + - `run.Result` contains the provided JSON result + - `run.EndedAt` is non-zero and close to now + - `run.Error` == "" (no error on successful completion) + - `run.SessionID` unchanged + - `run.StartedAt` < `run.EndedAt` + +3. **Verify the parent task reconciled** + - Read the task from store + - **Expected:** If this was the only run, task status reconciles to "completed" + +4. **Verify a task.run_completed event was recorded** + - Query events for the task + - **Expected:** TaskEvent with EventType="task.run_completed", RunID=run.ID + +5. **Complete the run with empty/nil result** + - Create another running run; complete with RunResult{Value: nil} + - **Expected:** Run completes successfully with nil result stored + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Complete with large valid result (under 64KB) | 60KB JSON | Success | +| Complete with result exceeding 64KB | 65KB JSON | ErrPayloadTooLarge | +| Complete non-running run (queued) | run.Status="queued" | ErrInvalidStatusTransition | +| Complete non-running run (claimed) | run.Status="claimed" | ErrInvalidStatusTransition | +| Complete already-completed run | run.Status="completed" | ErrInvalidStatusTransition | +| Complete with invalid JSON in result | result=`{broken` | ErrValidation | + +--- + +### Related Test Cases +- TC-FUNC-016: Start claimed run +- TC-FUNC-018: Fail running run with error +- TC-FUNC-027: Complete run with result_json > 64KB diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-018.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-018.md new file mode 100644 index 000000000..6065889b2 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-018.md @@ -0,0 +1,74 @@ +## TC-FUNC-018: Fail running run with error + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that failing a running task run transitions it to "failed" status, persists the error message and optional metadata, sets ended_at timestamp, reconciles the parent task status (potentially to "failed"), and records a task.run_failed audit event. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] One existing task with a running run (status="running", session_id set) +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Fail the running run with an error** + - Input: + ```json + { + "error": "out of memory during build step", + "metadata": {"exit_code": 137} + } + ``` + - Call FailRun(ctx, runID, RunFailure{Error: "out of memory during build step", Metadata: metadataJSON}, actor) + - **Expected:** No error returned + +2. **Inspect the returned TaskRun record** + - **Expected:** + - `run.Status` == "failed" + - `run.Error` == "out of memory during build step" + - `run.EndedAt` is non-zero and close to now + - `run.Result` is nil/empty (failure, not completion) + - `run.SessionID` unchanged + +3. **Verify the parent task reconciled** + - Read the task from store + - **Expected:** Task status reconciles to "failed" (if no other active runs) + +4. **Verify a task.run_failed event was recorded** + - Query events for the task + - **Expected:** TaskEvent with EventType="task.run_failed", RunID=run.ID + +5. **Attempt to fail with empty error message** + - Input: RunFailure{Error: ""} + - **Expected:** ErrValidation returned; error field is required + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Fail with only error (no metadata) | RunFailure{Error: "timeout"} | Success; metadata is nil | +| Fail with large metadata | metadata > 64KB | ErrPayloadTooLarge | +| Fail non-running run (queued) | run.Status="queued" | ErrInvalidStatusTransition | +| Fail non-running run (completed) | run.Status="completed" | ErrInvalidStatusTransition | +| Fail with whitespace-only error | RunFailure{Error: " "} | ErrValidation (error required after trim) | +| Multiple runs: fail one, task has another running | Fail run-1, run-2 still running | Task stays "in_progress" | + +--- + +### Related Test Cases +- TC-FUNC-016: Start claimed run +- TC-FUNC-017: Complete running run with result +- TC-FUNC-019: Invalid transition (queued to running) diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-019.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-019.md new file mode 100644 index 000000000..5ac2ae9a6 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-019.md @@ -0,0 +1,70 @@ +## TC-FUNC-019: Invalid transition (queued to running, skipping claim) + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that the run lifecycle state machine enforces valid transitions. Specifically, attempting to start (transition to "running") a run that is still "queued" (skipping the "claimed" step) must be rejected with ErrInvalidStatusTransition. All invalid transition paths are covered. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] One existing task with a queued run (status="queued") +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Attempt to start a queued run (skip claim)** + - Call StartRun(ctx, runID, StartRun{}, actor) + - **Expected:** Error returned; `errors.Is(err, ErrInvalidStatusTransition)` == true + +2. **Verify run status is unchanged** + - Read the run from store + - **Expected:** Status still == "queued" + +3. **Attempt to complete a queued run (skip claim+start)** + - Call CompleteRun(ctx, runID, RunResult{}, actor) + - **Expected:** Error returned; ErrInvalidStatusTransition + +4. **Attempt to fail a queued run (skip claim+start)** + - Call FailRun(ctx, runID, RunFailure{Error: "forced"}, actor) + - **Expected:** Error returned; ErrInvalidStatusTransition + +5. **Attempt to complete a claimed run (skip start)** + - Claim the run first, then call CompleteRun + - **Expected:** Error returned; ErrInvalidStatusTransition (must be "running" to complete) + +6. **Verify valid transition path works: queued -> claimed -> running -> completed** + - Create new run, claim it, start it, complete it + - **Expected:** All transitions succeed in order + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| queued -> running | StartRun on queued | ErrInvalidStatusTransition | +| queued -> completed | CompleteRun on queued | ErrInvalidStatusTransition | +| queued -> failed | FailRun on queued | ErrInvalidStatusTransition | +| claimed -> completed | CompleteRun on claimed | ErrInvalidStatusTransition | +| completed -> running | StartRun on completed | ErrInvalidStatusTransition | +| failed -> running | StartRun on failed | ErrInvalidStatusTransition | +| cancelled -> claimed | ClaimRun on cancelled | ErrInvalidStatusTransition | +| running -> claimed | ClaimRun on running | ErrInvalidStatusTransition | + +--- + +### Related Test Cases +- TC-FUNC-014: Enqueue run on ready task +- TC-FUNC-015: Claim queued run +- TC-FUNC-016: Start claimed run +- TC-FUNC-017: Complete running run with result diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-020.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-020.md new file mode 100644 index 000000000..c44ff1e87 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-020.md @@ -0,0 +1,72 @@ +## TC-FUNC-020: Attach session to claimed run; attach again returns ErrSessionAlreadyBound + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that attaching an existing session to a claimed run binds the session_id, transitions the run to "starting", records a task.run_session_bound audit event, and that attempting to attach a second session to the same run is rejected with ErrSessionAlreadyBound. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager, backing Store, and mock SessionExecutor) +- [ ] One existing task with a claimed run (status="claimed", session_id="") +- [ ] SessionExecutor mock configured to return a valid SessionRef for AttachTaskSession +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Attach a session to the claimed run** + - Call AttachRunSession(ctx, runID, "session-abc", actor) + - **Expected:** No error returned + +2. **Inspect the returned TaskRun record** + - **Expected:** + - `run.SessionID` == "session-abc" + - `run.Status` == "starting" (claimed -> starting on session bind) + - Other fields (ClaimedBy, ClaimedAt, Attempt) unchanged + +3. **Verify a task.run_session_bound event was recorded** + - Query events for the task + - **Expected:** TaskEvent with EventType="task.run_session_bound", RunID=run.ID + +4. **Attempt to attach another session to the same run** + - Call AttachRunSession(ctx, runID, "session-def", actor) + - **Expected:** Error returned; `errors.Is(err, ErrSessionAlreadyBound)` == true + +5. **Verify the run's session_id was not changed** + - Read the run from store + - **Expected:** `run.SessionID` still == "session-abc" + +6. **Attempt to attach a session to a running run** + - Start the run (moving to "running"), then call AttachRunSession + - **Expected:** ErrSessionAttachNotAllowed (only claimed/starting runs accept attach) + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Attach to claimed run | Valid | run transitions to "starting", session bound | +| Attach to starting run (no session) | run.Status="starting", session_id="" | Session bound successfully | +| Attach to starting run (with session) | run.Status="starting", session_id="existing" | ErrSessionAlreadyBound | +| Attach to queued run | run.Status="queued" | ErrSessionAttachNotAllowed | +| Attach to completed run | run.Status="completed" | ErrSessionAttachNotAllowed | +| Attach empty session_id | session_id="" | ErrValidation (session id required) | +| Attach whitespace session_id | session_id=" " | ErrValidation | +| Session already bound to another run | session-abc active on run-1, try on run-2 | ErrSessionAlreadyBound | + +--- + +### Related Test Cases +- TC-FUNC-015: Claim queued run +- TC-FUNC-016: Start claimed run +- TC-FUNC-019: Invalid transition diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-021.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-021.md new file mode 100644 index 000000000..642fb3ab3 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-021.md @@ -0,0 +1,74 @@ +## TC-FUNC-021: Idempotent run enqueue with same key+origin returns same run + +**Priority:** P1 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that enqueueing a run with the same idempotency_key and same origin returns the previously created run (idempotent behavior). Enqueueing with the same idempotency_key but a different origin creates a new, separate run (idempotency is scoped to origin). + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] One existing task in executable state (pending/ready with no blockers) +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Enqueue a run with idempotency_key="idem-1"** + - Input: + ```json + { + "task_id": "", + "idempotency_key": "idem-1" + } + ``` + ActorContext: Origin={Kind:"cli", Ref:"term-1"} + - **Expected:** Run created with status="queued"; idempotency record persisted + +2. **Record the returned run ID (run-A)** + +3. **Enqueue again with same key="idem-1" and same origin** + - Same ActorContext as step 1 + - **Expected:** Returns the same run (run-A); no new run created; run ID matches step 2 + +4. **Verify only one run exists for the task** + - Query runs for the task + - **Expected:** Exactly one run record + +5. **Enqueue with same key="idem-1" but different origin** + - ActorContext: Origin={Kind:"web", Ref:"browser-1"} (different from step 1) + - **Expected:** A new, separate run is created (run-B) with a different ID; idempotency is per-origin + +6. **Verify two runs now exist for the task** + - Query runs for the task + - **Expected:** Two run records (run-A and run-B) + +7. **Enqueue with no idempotency_key (empty)** + - Input: idempotency_key="" + - **Expected:** Always creates a new run (no deduplication without a key) + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Same key + same origin | Re-enqueue | Returns existing run (idempotent) | +| Same key + different origin | Different origin context | New run created | +| No idempotency_key | key="" | Always new run | +| Same key after run completed | Re-enqueue after original run finished | Depends on implementation; may create new run or return old | +| Whitespace-only key | key=" " | Treated as empty (no deduplication) | + +--- + +### Related Test Cases +- TC-FUNC-014: Enqueue run on ready task +- TC-FUNC-015: Claim queued run diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-022.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-022.md new file mode 100644 index 000000000..573021bef --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-022.md @@ -0,0 +1,73 @@ +## TC-FUNC-022: Cancel task with queued runs + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that cancelling a task with queued (non-started) runs immediately transitions the runs to "cancelled", transitions the task to "cancelled", records task.cancelled and task.run_cancelled audit events, and sets appropriate timestamps. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] One existing task with two queued runs (run-A status="queued", run-B status="queued") +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Cancel the task** + - Input: + ```json + { + "reason": "Requirements changed" + } + ``` + - Call CancelTask(ctx, taskID, CancelTask{Reason: "Requirements changed"}, actor) + - **Expected:** No error returned + +2. **Inspect the returned Task record** + - **Expected:** + - `task.Status` == "cancelled" + - `task.ClosedAt` is non-zero and close to now + - `task.UpdatedAt` >= original UpdatedAt + +3. **Verify all queued runs are cancelled** + - Query runs for the task + - **Expected:** + - run-A status == "cancelled", EndedAt set + - run-B status == "cancelled", EndedAt set + +4. **Verify audit events were recorded** + - Query events for the task + - **Expected:** + - TaskEvent with EventType="task.cancelled" + - TaskEvent with EventType="task.run_cancelled" for run-A + - TaskEvent with EventType="task.run_cancelled" for run-B + +5. **Verify no session stop was requested for queued runs** + - **Expected:** SessionExecutor.StopTaskSession was NOT called (queued runs have no sessions) + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Cancel task with no runs | Task has no runs | Task cancelled; no run events | +| Cancel task with mix of queued and claimed | One queued, one claimed | Both cancelled immediately (no session to stop) | +| Cancel with reason | CancelTask{Reason:"budget cut"} | Reason stored in event payload | +| Cancel with metadata | CancelTask{Metadata: json} | Metadata stored; validated against payload size limit | + +--- + +### Related Test Cases +- TC-FUNC-023: Cancel parent with running child runs +- TC-FUNC-024: Cancel propagation to grandchildren +- TC-FUNC-025: Cancel already-terminal task diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-023.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-023.md new file mode 100644 index 000000000..89ce8bb52 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-023.md @@ -0,0 +1,77 @@ +## TC-FUNC-023: Cancel parent with running child runs (cooperative stop then forced) + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 10 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that cancelling a parent task with active (running) child runs triggers cooperative session stop requests, waits for the grace period, and then force-stops sessions that did not terminate cooperatively. All runs and child tasks transition to "cancelled", and appropriate audit events are recorded. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager, backing Store, and mock SessionExecutor) +- [ ] Parent task with one child task +- [ ] Child task has a running run (status="running", session_id="session-child") +- [ ] SessionExecutor mock configured to track StopTaskSession calls +- [ ] CancelGracePeriod configured (e.g., 5 seconds for testing) +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Cancel the parent task** + - Call CancelTask(ctx, parentID, CancelTask{Reason: "project terminated"}, actor) + - **Expected:** No error returned + +2. **Verify the parent task is cancelled** + - Read parent task + - **Expected:** Status == "cancelled", ClosedAt set + +3. **Verify the child task is cancelled** + - Read child task + - **Expected:** Status == "cancelled", ClosedAt set + +4. **Verify the child's running run is cancelled** + - Read child's runs + - **Expected:** Run status == "cancelled", EndedAt set + +5. **Verify cooperative stop was requested** + - Check SessionExecutor mock + - **Expected:** StopTaskSession called with reason="cancellation" for the child's session + +6. **Verify audit events recorded** + - **Expected:** + - task.cancelled event on parent + - task.cancelled event on child + - task.run_cancelled event on child's run + - Possibly task.run_force_stopped if the session did not stop cooperatively within grace period + +7. **Test force-stop after grace period** + - Configure SessionExecutor mock to delay/not respond to cooperative stop + - Cancel a new parent with running child + - **Expected:** After grace period, force stop is triggered; task.run_force_stopped event recorded + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Child cooperatively stops within grace period | Session stops before timeout | Clean cancellation; no force-stop event | +| Child does not stop within grace period | Session ignores stop | Force-stop triggered after grace period | +| Multiple running children | Parent has 3 children, each with running runs | All 3 sessions receive stop requests | +| Child has queued run (no session) | Run status="queued" | Run cancelled immediately; no session stop needed | +| Child has claimed run (no session) | Run status="claimed" | Run cancelled immediately | + +--- + +### Related Test Cases +- TC-FUNC-022: Cancel task with queued runs +- TC-FUNC-024: Cancel propagation to grandchildren +- TC-FUNC-025: Cancel already-terminal task diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-024.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-024.md new file mode 100644 index 000000000..cabd2130f --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-024.md @@ -0,0 +1,76 @@ +## TC-FUNC-024: Cancel propagation to grandchildren (entire subtree) + +**Priority:** P1 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 10 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that cancelling a task propagates cancellation recursively through the entire subtree -- children, grandchildren, and all their associated runs. All non-terminal tasks and runs in the subtree must transition to "cancelled". + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager, backing Store, and mock SessionExecutor) +- [ ] Task hierarchy: + - Root task (status="in_progress") + - Child A (status="in_progress", has running run with session) + - Grandchild A1 (status="pending", has queued run) + - Grandchild A2 (status="ready", no runs) + - Child B (status="blocked", has no runs) +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Cancel the root task** + - Call CancelTask(ctx, rootID, CancelTask{Reason: "project cancelled"}, actor) + - **Expected:** No error returned + +2. **Verify root task is cancelled** + - **Expected:** Status == "cancelled", ClosedAt set + +3. **Verify Child A is cancelled** + - **Expected:** Status == "cancelled", ClosedAt set + - **Expected:** Child A's running run is cancelled, session stop requested + +4. **Verify Grandchild A1 is cancelled** + - **Expected:** Status == "cancelled", ClosedAt set + - **Expected:** Grandchild A1's queued run is cancelled immediately + +5. **Verify Grandchild A2 is cancelled** + - **Expected:** Status == "cancelled", ClosedAt set (even with no runs) + +6. **Verify Child B is cancelled** + - **Expected:** Status == "cancelled", ClosedAt set (was blocked, now cancelled) + +7. **Verify audit trail** + - **Expected:** task.cancelled events for: root, Child A, Grandchild A1, Grandchild A2, Child B + - **Expected:** task.run_cancelled events for all active runs in the subtree + +8. **Verify no sibling tasks outside the subtree are affected** + - Create a separate top-level task before the test + - **Expected:** Unrelated task remains unaffected + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Deep subtree (3+ levels) | Root -> Child -> Grandchild -> Great-grandchild | All levels cancelled | +| Mixed terminal and non-terminal | Some children already completed | Completed children remain "completed"; only non-terminal children cancelled | +| Empty subtree | Root has no children | Only root cancelled | +| Subtree with fan-out | Root has 10 children, each with 5 grandchildren | All 60 tasks in subtree cancelled | + +--- + +### Related Test Cases +- TC-FUNC-022: Cancel task with queued runs +- TC-FUNC-023: Cancel parent with running child runs +- TC-FUNC-025: Cancel already-terminal task +- TC-FUNC-006: Create child task under valid parent diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-025.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-025.md new file mode 100644 index 000000000..d72b5099d --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-025.md @@ -0,0 +1,72 @@ +## TC-FUNC-025: Cancel already-terminal task (no-op or appropriate error) + +**Priority:** P1 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that attempting to cancel a task that is already in a terminal state ("completed", "failed", or "cancelled") either returns a no-op response or an appropriate error, and does not modify the task or its runs. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] Three tasks in terminal states: + - Task A: status="completed", ClosedAt set + - Task B: status="failed", ClosedAt set + - Task C: status="cancelled", ClosedAt set +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Attempt to cancel a completed task** + - Call CancelTask(ctx, taskA.ID, CancelTask{Reason: "too late"}, actor) + - **Expected:** Error returned (ErrInvalidStatusTransition or similar) OR no-op return; task status remains "completed" + +2. **Verify completed task was not modified** + - Read Task A from store + - **Expected:** Status == "completed"; UpdatedAt unchanged; ClosedAt unchanged + +3. **Attempt to cancel a failed task** + - Call CancelTask(ctx, taskB.ID, CancelTask{Reason: "cleanup"}, actor) + - **Expected:** Error returned OR no-op; task status remains "failed" + +4. **Verify failed task was not modified** + - Read Task B from store + - **Expected:** Status == "failed"; all timestamps unchanged + +5. **Attempt to cancel an already-cancelled task** + - Call CancelTask(ctx, taskC.ID, CancelTask{Reason: "double cancel"}, actor) + - **Expected:** Error returned OR idempotent no-op; task status remains "cancelled" + +6. **Verify cancelled task was not modified** + - Read Task C from store + - **Expected:** Status == "cancelled"; all timestamps unchanged + +7. **Verify no new audit events were recorded for any of these attempts** + - **Expected:** No new task.cancelled events (or only one if idempotent) + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Cancel completed task | CancelTask on completed | No-op or error; no state change | +| Cancel failed task | CancelTask on failed | No-op or error; no state change | +| Cancel cancelled task | CancelTask on cancelled | Idempotent no-op or error | +| Cancel in_progress task | CancelTask on in_progress | Should succeed (non-terminal) | +| Cancel pending task | CancelTask on pending | Should succeed (non-terminal) | + +--- + +### Related Test Cases +- TC-FUNC-022: Cancel task with queued runs +- TC-FUNC-023: Cancel parent with running child runs +- TC-FUNC-024: Cancel propagation to grandchildren diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-026.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-026.md new file mode 100644 index 000000000..7c5e96af5 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-026.md @@ -0,0 +1,65 @@ +## TC-FUNC-026: Create task with metadata_json > 16KB returns ErrPayloadTooLarge + +**Priority:** P1 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that creating a task with metadata_json exceeding MaxMetadataBytes (16 KiB = 16,384 bytes) is rejected with ErrPayloadTooLarge. Valid metadata at or just below the limit must be accepted. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] ActorContext with Authority.Write=true, Authority.CreateGlobal=true + +--- + +### Test Steps + +1. **Create a task with metadata exactly at the 16KB boundary** + - Generate valid JSON metadata of exactly 16,384 bytes + - Input: scope="global", title="At limit", metadata=<16384 byte JSON> + - **Expected:** Task created successfully; no error + +2. **Create a task with metadata at 16KB + 1 byte** + - Generate valid JSON metadata of 16,385 bytes + - Input: scope="global", title="Over limit", metadata=<16385 byte JSON> + - **Expected:** Error returned; `errors.Is(err, ErrPayloadTooLarge)` == true; error message contains "metadata" and "16384" + +3. **Verify the over-limit task was not persisted** + - Query store for task with title "Over limit" + - **Expected:** No such task exists + +4. **Create a task with empty metadata** + - Input: scope="global", title="No metadata", metadata=nil + - **Expected:** Task created successfully; metadata is nil/empty + +5. **Update a task with metadata exceeding 16KB via TaskPatch** + - Patch existing task with Metadata > 16,384 bytes + - **Expected:** ErrPayloadTooLarge + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Exactly 16,384 bytes | At boundary | Success | +| 16,385 bytes | One over | ErrPayloadTooLarge | +| 0 bytes (nil) | No metadata | Success | +| Valid JSON "null" | metadata=null | Success (0 effective bytes) | +| Invalid JSON | metadata=`{broken` | ErrValidation (not valid JSON) | +| Whitespace-padded JSON | ` {"k":"v"} ` | Size computed after trimming whitespace | + +--- + +### Related Test Cases +- TC-FUNC-001: Create global task with valid fields +- TC-FUNC-004: Update mutable fields +- TC-FUNC-027: Complete run with result_json > 64KB +- TC-FUNC-028: Create task event with payload_json > 64KB diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-027.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-027.md new file mode 100644 index 000000000..746957720 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-027.md @@ -0,0 +1,64 @@ +## TC-FUNC-027: Complete run with result_json > 64KB returns ErrPayloadTooLarge + +**Priority:** P1 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that completing a run with result_json exceeding MaxResultBytes (64 KiB = 65,536 bytes) is rejected with ErrPayloadTooLarge. Valid results at or just below the limit must be accepted. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] One existing task with a running run (status="running") +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Complete the run with result exactly at the 64KB boundary** + - Generate valid JSON result of exactly 65,536 bytes + - Call CompleteRun(ctx, runID, RunResult{Value: <65536 byte JSON>}, actor) + - **Expected:** Run completed successfully; result stored + +2. **Create another running run and attempt to complete with result at 64KB + 1 byte** + - Generate valid JSON result of 65,537 bytes + - Call CompleteRun(ctx, run2ID, RunResult{Value: <65537 byte JSON>}, actor) + - **Expected:** Error returned; `errors.Is(err, ErrPayloadTooLarge)` == true; error message contains "result" and "65536" + +3. **Verify the over-limit run was not modified** + - Read run2 from store + - **Expected:** Status still == "running"; no result stored; no EndedAt set + +4. **Complete a run with nil/empty result** + - Create another running run, complete with RunResult{Value: nil} + - **Expected:** Run completed successfully; result is nil + +5. **Direct validation: ValidateResultSize** + - Call ValidateResultSize with 65,537 byte payload + - **Expected:** ErrPayloadTooLarge + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Exactly 65,536 bytes | At boundary | Success | +| 65,537 bytes | One over | ErrPayloadTooLarge | +| 0 bytes (nil) | No result | Success | +| Invalid JSON result | `{broken` | ErrValidation | +| Large valid JSON under limit | 60KB | Success | + +--- + +### Related Test Cases +- TC-FUNC-017: Complete running run with result +- TC-FUNC-026: Create task with metadata_json > 16KB +- TC-FUNC-028: Create task event with payload_json > 64KB diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-028.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-028.md new file mode 100644 index 000000000..cc35ef904 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-028.md @@ -0,0 +1,69 @@ +## TC-FUNC-028: Create task event with payload_json > 64KB returns ErrPayloadTooLarge + +**Priority:** P1 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 3 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that creating a task event with payload_json exceeding MaxPayloadBytes (64 KiB = 65,536 bytes) is rejected with ErrPayloadTooLarge via the TaskEvent.Validate() method and any operation that emits events with oversized payloads. + +--- + +### Preconditions +- [ ] AGH daemon running (or test harness initialized with TaskManager and backing Store) +- [ ] One existing task with known ID +- [ ] ActorContext with Authority.Write=true + +--- + +### Test Steps + +1. **Validate a TaskEvent with payload at the 64KB boundary** + - Construct a TaskEvent with Payload of exactly 65,536 bytes of valid JSON + - Call TaskEvent.Validate() + - **Expected:** No error returned + +2. **Validate a TaskEvent with payload at 64KB + 1 byte** + - Construct a TaskEvent with Payload of 65,537 bytes of valid JSON + - Call TaskEvent.Validate() + - **Expected:** Error returned; `errors.Is(err, ErrPayloadTooLarge)` == true; error message contains "task_event.payload" and "65536" + +3. **Validate a TaskEvent with nil payload** + - Construct a TaskEvent with Payload = nil + - Call TaskEvent.Validate() + - **Expected:** No error (payload is optional) + +4. **Test through CancelTask metadata (which becomes event payload)** + - Call CancelTask with Metadata > 64KB + - **Expected:** ErrPayloadTooLarge from CancelTask.Validate + +5. **Test through CancelRun metadata** + - Call CancelRun with Metadata > 64KB + - **Expected:** ErrPayloadTooLarge from CancelRun.Validate + +6. **Direct validation: ValidatePayloadSize** + - Call ValidatePayloadSize with 65,537 byte payload + - **Expected:** ErrPayloadTooLarge + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Exactly 65,536 bytes | At boundary | Success | +| 65,537 bytes | One over | ErrPayloadTooLarge | +| 0 bytes (nil) | No payload | Success | +| Empty JSON object | `{}` | Success (2 bytes) | +| Invalid JSON payload | `{broken` | ErrValidation (not ErrPayloadTooLarge) | +| Whitespace-padded JSON | ` {"k":"v"} ` | Size computed after trimming | + +--- + +### Related Test Cases +- TC-FUNC-026: Create task with metadata_json > 16KB +- TC-FUNC-027: Complete run with result_json > 64KB diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-029.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-029.md new file mode 100644 index 000000000..a78a0328a --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-029.md @@ -0,0 +1,79 @@ +## TC-FUNC-029: Boot with orphaned claimed run (no session) re-queued + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that during daemon boot recovery, a run discovered in "claimed" status with no session binding (session_id="") is re-queued via RunBootRecoveryRequeue action. The run transitions back to "queued", claimed_by is cleared, and a task.run_recovered audit event is recorded with the recovery reason. + +--- + +### Preconditions +- [ ] Test harness initialized with TaskManager and backing Store +- [ ] Store pre-populated with: + - One task (status="in_progress") + - One run in "claimed" status with session_id="" (orphaned -- claimed but never started, daemon crashed) +- [ ] ActorContext representing the daemon boot actor (Kind:"daemon", Ref:"agh-boot") + +--- + +### Test Steps + +1. **Call RecoverRunOnBoot with RunBootRecoveryRequeue action** + - Input: + ```json + { + "action": "requeue", + "reason": "orphaned claimed run discovered on boot" + } + ``` + - Call RecoverRunOnBoot(ctx, runID, RunBootRecovery{Action: "requeue", Reason: "orphaned claimed run discovered on boot"}, actor) + - **Expected:** No error returned + +2. **Inspect the returned TaskRun record** + - **Expected:** + - `run.Status` == "queued" + - `run.ClaimedBy` cleared (nil) + - `run.ClaimedAt` reset or zeroed + - `run.SessionID` == "" (unchanged, was already empty) + - `run.Attempt` unchanged + +3. **Verify the run is persisted with queued status** + - Read the run from store + - **Expected:** All fields match step 2 + +4. **Verify the parent task reconciled** + - Read the task from store + - **Expected:** Task status reconciles appropriately (may go back to "ready" or "pending" if no other active runs) + +5. **Verify a task.run_recovered event was recorded** + - Query events for the task + - **Expected:** TaskEvent with EventType="task.run_recovered" containing the recovery action and reason in the payload + +6. **Verify the run can be re-claimed after recovery** + - Claim the re-queued run + - **Expected:** Run transitions to "claimed" successfully + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Claimed run with no session | action="requeue" | Run re-queued | +| Claimed run with session (should not happen) | action="requeue" | Depends on implementation; may still requeue or reject | +| Recovery with empty reason | reason="" | Normalized to default reason | +| Invalid recovery action | action="unknown" | ErrValidation | +| Run already in terminal state | action="requeue" on completed run | Error or no-op | + +--- + +### Related Test Cases +- TC-FUNC-030: Boot with orphaned running run (dead session) +- TC-FUNC-015: Claim queued run +- TC-FUNC-014: Enqueue run on ready task diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-030.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-030.md new file mode 100644 index 000000000..5eb4dc7c7 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-FUNC-030.md @@ -0,0 +1,91 @@ +## TC-FUNC-030: Boot with orphaned running run (dead session) marked failed + +**Priority:** P0 +**Type:** Functional +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that during daemon boot recovery, a run discovered in "running" status with a dead/unreachable session is marked as failed via RunBootRecoveryFail action. The run transitions to "failed" with an error describing the orphaned-on-boot reason, ended_at is set, and a task.run_recovered audit event is recorded. The parent task reconciles accordingly. + +--- + +### Preconditions +- [ ] Test harness initialized with TaskManager and backing Store +- [ ] Store pre-populated with: + - One task (status="in_progress") + - One run in "running" status with session_id="session-dead" (session process is gone after restart) +- [ ] ActorContext representing the daemon boot actor (Kind:"daemon", Ref:"agh-boot") +- [ ] SessionExecutor confirms the session is no longer alive + +--- + +### Test Steps + +1. **Call RecoverRunOnBoot with RunBootRecoveryFail action** + - Input: + ```json + { + "action": "fail", + "reason": "orphaned running run with dead session discovered on boot", + "session_state": "not_found" + } + ``` + - Call RecoverRunOnBoot(ctx, runID, RunBootRecovery{Action: "fail", Reason: "orphaned running run with dead session discovered on boot", SessionState: "not_found"}, actor) + - **Expected:** No error returned + +2. **Inspect the returned TaskRun record** + - **Expected:** + - `run.Status` == "failed" + - `run.Error` is non-empty and contains "orphaned" or boot recovery context + - `run.EndedAt` is non-zero and close to now + - `run.SessionID` == "session-dead" (preserved for audit trail) + - `run.Result` is nil (failure, not completion) + +3. **Verify the run is persisted with failed status** + - Read the run from store + - **Expected:** All fields match step 2 + +4. **Verify the parent task reconciled** + - Read the task from store + - **Expected:** Task status reconciles to "failed" (if this was the only run) or stays "in_progress" (if other runs exist) + +5. **Verify a task.run_recovered event was recorded** + - Query events for the task + - **Expected:** TaskEvent with EventType="task.run_recovered" containing: + - action="fail" in payload + - reason containing "orphaned" context + - session_state="not_found" + +6. **Test RunBootRecoveryMarkRunning for a starting run with live session** + - Pre-populate a run in "starting" status with session_id="session-alive" + - Call RecoverRunOnBoot with action="mark_running" + - **Expected:** Run transitions to "running"; task reconciles to "in_progress" + +7. **Verify that a new run can be enqueued on the task after recovery** + - Enqueue a new run on the task + - **Expected:** New run created successfully (task is not stuck in a bad state) + +--- + +### Edge Cases & Variations + +| Variation | Input | Expected Result | +|-----------|-------|-----------------| +| Running run with dead session | action="fail" | Run failed with orphan error | +| Starting run with dead session | action="fail" | Run failed with orphan error | +| Starting run with live session | action="mark_running" | Run promoted to "running" | +| Claimed run with no session | action="requeue" | Run re-queued (TC-FUNC-029 scenario) | +| Recovery on terminal run | action="fail" on already-completed run | Error or no-op | +| Recovery with empty reason | reason="" | Normalized to default reason | +| Multiple orphaned runs on boot | Two running runs from different tasks | Each recovered independently | + +--- + +### Related Test Cases +- TC-FUNC-029: Boot with orphaned claimed run (no session) re-queued +- TC-FUNC-018: Fail running run with error +- TC-FUNC-016: Start claimed run creates dedicated session diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-001.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-001.md new file mode 100644 index 000000000..c21f6efaa --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-001.md @@ -0,0 +1,119 @@ +## TC-INT-001: HTTP POST /api/v1/tasks with valid JSON returns 201 with server-derived identity + +**Priority:** P0 +**Type:** Integration +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that an HTTP POST to `/api/tasks` with a well-formed CreateTaskRequest body returns HTTP 201 and a TaskPayload whose server-derived fields (id, created_by, origin, status, timestamps) are correctly populated regardless of caller-supplied values. + +--- + +### Preconditions +- [ ] AGH daemon running with all subsystems (task manager, store, session executor) +- [ ] HTTP server listening on TCP :2123 +- [ ] No pre-existing tasks in the store (clean state) + +--- + +### Test Steps + +1. **Create a global-scope task with minimal required fields** + - Input: + ```http + POST http://localhost:2123/api/tasks + Content-Type: application/json + + { + "scope": "global", + "title": "TC-INT-001 global task" + } + ``` + - **Expected:** HTTP 201 Created + - **Expected:** Response body contains `{"task": {...}}` with a TaskPayload + +2. **Verify server-derived identity fields on the response** + - **Expected:** `task.id` is a non-empty string (server-generated UUID) + - **Expected:** `task.status` equals `"pending"` (initial lifecycle state) + - **Expected:** `task.created_by.kind` equals `"human"` (default actor for HTTP ingress) + - **Expected:** `task.created_by.ref` equals `"local-user"` (default actor ref) + - **Expected:** `task.origin.kind` equals `"http"` (HTTP transport origin) + - **Expected:** `task.origin.ref` is a non-empty string containing `"tasks.create"` + - **Expected:** `task.created_at` is a valid RFC3339 timestamp within the last 5 seconds + - **Expected:** `task.updated_at` is a valid RFC3339 timestamp equal to or after `created_at` + - **Expected:** `task.closed_at` is null (task is not closed) + +3. **Verify echo of caller-supplied fields** + - **Expected:** `task.scope` equals `"global"` + - **Expected:** `task.title` equals `"TC-INT-001 global task"` + - **Expected:** `task.workspace_id` is empty (global scope has no workspace binding) + - **Expected:** `task.parent_task_id` is empty (no parent specified) + - **Expected:** `task.description` is empty (not supplied) + - **Expected:** `task.owner` is null (not supplied) + - **Expected:** `task.metadata` is null or empty (not supplied) + +4. **Create a workspace-scope task with all optional fields** + - Input: + ```http + POST http://localhost:2123/api/tasks + Content-Type: application/json + + { + "scope": "workspace", + "workspace": "", + "title": "TC-INT-001 workspace task", + "description": "Full task with all optional fields", + "identifier": "test-001", + "owner": {"kind": "human", "ref": "alice"}, + "metadata": {"priority": "high", "labels": ["qa", "integration"]} + } + ``` + - **Expected:** HTTP 201 Created + - **Expected:** `task.scope` equals `"workspace"` + - **Expected:** `task.workspace_id` is a resolved non-empty workspace ID + - **Expected:** `task.identifier` equals `"test-001"` + - **Expected:** `task.description` equals `"Full task with all optional fields"` + - **Expected:** `task.owner.kind` equals `"human"` and `task.owner.ref` equals `"alice"` + - **Expected:** `task.metadata` is the echoed JSON object + +5. **Verify the created task persists via GET** + - Input: `GET http://localhost:2123/api/tasks/` + - **Expected:** HTTP 200 with TaskDetailPayload matching the created task + +--- + +### Data Validation +| Field | Source Value | Expected Value | Status | +|-------|-------------|----------------|--------| +| task.id | (server-generated) | Non-empty UUID string | [ ] | +| task.status | (server-derived) | "pending" | [ ] | +| task.created_by.kind | (server-derived) | "human" | [ ] | +| task.created_by.ref | (server-derived) | "local-user" | [ ] | +| task.origin.kind | (server-derived) | "http" | [ ] | +| task.origin.ref | (server-derived) | Contains "tasks.create" | [ ] | +| task.scope | "global" | "global" | [ ] | +| task.title | "TC-INT-001 global task" | "TC-INT-001 global task" | [ ] | +| task.created_at | (server-derived) | Valid RFC3339 within 5s | [ ] | +| task.updated_at | (server-derived) | >= created_at | [ ] | +| task.closed_at | (server-derived) | null | [ ] | + +--- + +### Error Scenarios +- [ ] POST with empty body returns 400 (missing required fields: scope, title) +- [ ] POST with `scope: "workspace"` but no `workspace` field returns 400 (ErrInvalidScopeBinding) +- [ ] POST with `scope: "global"` and a `workspace` value returns 400 (ErrInvalidScopeBinding) +- [ ] POST with `scope: "invalid_value"` returns 400 (ErrValidation) +- [ ] POST with title as empty string returns 400 (ErrValidation) +- [ ] POST with metadata exceeding 16 KiB returns 413 (ErrPayloadTooLarge) + +--- + +### Related Test Cases +- TC-INT-003: GET /api/tasks/:id validates the full TaskDetailPayload shape +- TC-INT-005: UDS endpoint parity confirms the same 201 behavior on UDS transport +- TC-INT-006: CLI task create exercises the same endpoint via daemon API diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-002.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-002.md new file mode 100644 index 000000000..fee7bcca8 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-002.md @@ -0,0 +1,117 @@ +## TC-INT-002: HTTP GET /api/v1/tasks with query filters returns filtered list + +**Priority:** P0 +**Type:** Integration +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that HTTP GET `/api/tasks` correctly applies scope, status, owner_kind, owner_ref, network_channel, parent_task_id, and limit query parameters, returning only matching TaskSummaryPayload records in the `tasks` array. + +--- + +### Preconditions +- [ ] AGH daemon running with all subsystems +- [ ] HTTP server listening on TCP :2123 +- [ ] At least one workspace registered (for workspace-scoped tasks) +- [ ] Seed data: create the following tasks before running filter tests: + - Task A: scope=global, status=pending, title="Global Pending A" + - Task B: scope=global, status=ready, title="Global Ready B" + - Task C: scope=workspace, status=pending, workspace=, owner={kind:"human", ref:"alice"}, title="WS Pending C" + - Task D: scope=workspace, status=in_progress, workspace=, owner={kind:"agent_session", ref:"sess-1"}, title="WS InProgress D" + - Task E: scope=global, status=pending, title="Global Pending E", network_channel="ch-test" + - Task F: child of Task A, scope=global, status=pending, title="Child of A" + +--- + +### Test Steps + +1. **List all tasks without filters** + - Input: `GET http://localhost:2123/api/tasks` + - **Expected:** HTTP 200 + - **Expected:** Response `{"tasks": [...]}` contains all 6 seeded tasks (A through F) + - **Expected:** Each entry is a TaskSummaryPayload with id, scope, status, title, created_by, origin, timestamps + +2. **Filter by scope=global** + - Input: `GET http://localhost:2123/api/tasks?scope=global` + - **Expected:** HTTP 200 + - **Expected:** Only tasks A, B, E, F returned (all global-scope tasks) + - **Expected:** Tasks C, D excluded (workspace-scoped) + +3. **Filter by status=pending** + - Input: `GET http://localhost:2123/api/tasks?status=pending` + - **Expected:** HTTP 200 + - **Expected:** Tasks A, C, E, F returned + - **Expected:** Tasks B, D excluded + +4. **Filter by scope=global AND status=pending (combined)** + - Input: `GET http://localhost:2123/api/tasks?scope=global&status=pending` + - **Expected:** HTTP 200 + - **Expected:** Tasks A, E, F returned + - **Expected:** Tasks B, C, D excluded + +5. **Filter by owner_kind=human AND owner_ref=alice** + - Input: `GET http://localhost:2123/api/tasks?owner_kind=human&owner_ref=alice` + - **Expected:** HTTP 200 + - **Expected:** Only Task C returned + +6. **Filter by workspace** + - Input: `GET http://localhost:2123/api/tasks?scope=workspace&workspace=` + - **Expected:** HTTP 200 + - **Expected:** Tasks C, D returned + +7. **Filter by network_channel** + - Input: `GET http://localhost:2123/api/tasks?network_channel=ch-test` + - **Expected:** HTTP 200 + - **Expected:** Only Task E returned + +8. **Filter by parent_task_id** + - Input: `GET http://localhost:2123/api/tasks?parent_task_id=` + - **Expected:** HTTP 200 + - **Expected:** Only Task F returned + +9. **Apply limit=2** + - Input: `GET http://localhost:2123/api/tasks?limit=2` + - **Expected:** HTTP 200 + - **Expected:** Exactly 2 tasks returned + +10. **Empty result set** + - Input: `GET http://localhost:2123/api/tasks?status=completed` + - **Expected:** HTTP 200 + - **Expected:** `{"tasks": []}` (empty array, not null) + +--- + +### Data Validation +| Field | Source Value | Expected Value | Status | +|-------|-------------|----------------|--------| +| Response code (all valid queries) | HTTP status | 200 | [ ] | +| tasks array (unfiltered) | length | 6 | [ ] | +| tasks array (scope=global) | length | 4 | [ ] | +| tasks array (status=pending) | length | 4 | [ ] | +| tasks array (scope=global&status=pending) | length | 3 | [ ] | +| tasks array (owner filter) | length | 1 | [ ] | +| tasks array (workspace filter) | length | 2 | [ ] | +| tasks array (channel filter) | length | 1 | [ ] | +| tasks array (parent filter) | length | 1 | [ ] | +| tasks array (limit=2) | length | 2 | [ ] | +| tasks array (no matches) | length | 0 | [ ] | + +--- + +### Error Scenarios +- [ ] Invalid scope value `?scope=bogus` returns 400 (ErrValidation) +- [ ] Invalid status value `?status=bogus` returns 400 (ErrValidation) +- [ ] `owner_kind` without `owner_ref` returns 400 (ErrValidation: both required together) +- [ ] Negative limit `?limit=-1` returns 400 (ErrValidation) +- [ ] `scope=global&workspace=some-ws` returns 400 (ErrInvalidScopeBinding) + +--- + +### Related Test Cases +- TC-INT-001: Creates the tasks used as seed data +- TC-INT-005: UDS endpoint parity for list filtering +- TC-INT-007: CLI `agh task list` exercises the same filters via daemon API diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-003.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-003.md new file mode 100644 index 000000000..ce482fff3 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-003.md @@ -0,0 +1,103 @@ +## TC-INT-003: HTTP GET /api/v1/tasks/:id returns 200 with full TaskDetailPayload + +**Priority:** P0 +**Type:** Integration +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that HTTP GET `/api/tasks/:id` returns a complete TaskDetailPayload containing the task record, its children, dependencies, runs, and audit events, all in the correct contract shape. + +--- + +### Preconditions +- [ ] AGH daemon running with all subsystems +- [ ] HTTP server listening on TCP :2123 +- [ ] Seed data prepared: + - Parent task P created (scope=global, title="Parent Task P") + - Child task C1 created under P (via POST /api/tasks//children) + - Child task C2 created under P + - Dependency: P depends on an independent task D1 (via POST /api/tasks//dependencies) + - Run R1 enqueued for P (via POST /api/tasks//runs) + - Run R1 claimed, started (lifecycle progressed to generate events) + +--- + +### Test Steps + +1. **GET the parent task with full detail** + - Input: `GET http://localhost:2123/api/tasks/` + - **Expected:** HTTP 200 + - **Expected:** Response shape is `{"task": }` + +2. **Validate the task object within the detail payload** + - **Expected:** `task.task.id` equals P.id + - **Expected:** `task.task.scope` equals `"global"` + - **Expected:** `task.task.title` equals `"Parent Task P"` + - **Expected:** `task.task.status` is a valid TaskStatus + - **Expected:** `task.task.created_by` is a valid ActorIdentity + - **Expected:** `task.task.origin` is a valid Origin + - **Expected:** `task.task.created_at` and `task.task.updated_at` are valid timestamps + +3. **Validate the children array** + - **Expected:** `task.children` is an array with exactly 2 entries + - **Expected:** Each child has `parent_task_id` equal to P.id + - **Expected:** C1 and C2 IDs are present in the children list + - **Expected:** Each child entry is a TaskSummaryPayload (contains id, scope, status, title, created_by, origin, but no description or metadata) + +4. **Validate the dependencies array** + - **Expected:** `task.dependencies` is an array with at least 1 entry + - **Expected:** Entry contains `task_id` equal to P.id and `depends_on_task_id` equal to D1.id + - **Expected:** `kind` equals `"blocks"` (default dependency kind) + - **Expected:** `created_at` is a valid timestamp + +5. **Validate the runs array** + - **Expected:** `task.runs` is an array with at least 1 entry (R1) + - **Expected:** R1 entry contains `id`, `task_id` (equals P.id), `status`, `attempt` (equals 1), `queued_at` + - **Expected:** If R1 was claimed: `claimed_by` is populated, `claimed_at` is a valid timestamp + - **Expected:** If R1 was started: `started_at` is a valid timestamp + - **Expected:** `origin` is a valid Origin + +6. **Validate the events array** + - **Expected:** `task.events` is an array with multiple entries (task_created, run_enqueued, run_claimed, run_started at minimum) + - **Expected:** Each event has `id`, `task_id` (equals P.id), `event_type`, `actor`, `origin`, `timestamp` + - **Expected:** Events are ordered by timestamp (ascending or consistent ordering) + - **Expected:** Run-related events have `run_id` set to R1.id + +7. **GET a non-existent task** + - Input: `GET http://localhost:2123/api/tasks/nonexistent-uuid-here` + - **Expected:** HTTP 404 + - **Expected:** Response body contains error message referencing ErrTaskNotFound + +8. **GET with empty ID path parameter** + - Input: `GET http://localhost:2123/api/tasks/` + - **Expected:** HTTP 400 or 404 (validation: task id is required) + +--- + +### Data Validation +| Field | Source Value | Expected Value | Status | +|-------|-------------|----------------|--------| +| HTTP status (valid ID) | Response code | 200 | [ ] | +| task.task.id | P.id | Matches created task ID | [ ] | +| task.children | Array | Length 2, both match C1/C2 | [ ] | +| task.dependencies | Array | Length >= 1, contains D1 edge | [ ] | +| task.runs | Array | Length >= 1, contains R1 | [ ] | +| task.events | Array | Length >= 4 (create + run lifecycle) | [ ] | +| HTTP status (missing ID) | Response code | 404 | [ ] | + +--- + +### Error Scenarios +- [ ] Non-existent task ID returns 404 (ErrTaskNotFound) +- [ ] Malformed or empty path parameter returns 400 or 404 + +--- + +### Related Test Cases +- TC-INT-001: Creates the parent task +- TC-INT-002: Lists tasks that should include the parent +- TC-INT-004: Tests immutable field update on the same task diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-004.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-004.md new file mode 100644 index 000000000..a11145fe1 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-004.md @@ -0,0 +1,175 @@ +## TC-INT-004: HTTP PATCH /api/v1/tasks/:id with immutable field returns 400 ErrImmutableField + +**Priority:** P0 +**Type:** Integration +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that HTTP PATCH `/api/tasks/:id` correctly rejects attempts to change immutable fields (created_by, origin, scope, workspace_id, parent_task_id), returns HTTP 400 with an appropriate error message, and confirms that mutable fields (title, description, metadata, network_channel, owner) are accepted. + +--- + +### Preconditions +- [ ] AGH daemon running with all subsystems +- [ ] HTTP server listening on TCP :2123 +- [ ] One task T exists (scope=global, title="Original Title", no owner) + +--- + +### Test Steps + +1. **Attempt to patch an immutable field: scope** + - Input: + ```http + PATCH http://localhost:2123/api/tasks/ + Content-Type: application/json + + { + "scope": "workspace" + } + ``` + - **Expected:** HTTP 400 Bad Request + - **Expected:** Error message contains "immutable" or the UpdateTaskRequest rejects unknown fields; the server does not silently ignore the field + - **Note:** The UpdateTaskRequest contract only accepts title, description, metadata, network_channel, owner, clear_owner. Fields outside this set are either silently dropped by JSON binding or the `HasChanges()` check returns false, yielding a 400 "must include at least one mutable field." + +2. **Attempt to patch with no mutable fields (empty patch)** + - Input: + ```http + PATCH http://localhost:2123/api/tasks/ + Content-Type: application/json + + {} + ``` + - **Expected:** HTTP 400 Bad Request + - **Expected:** Error message indicates "at least one mutable field" is required + +3. **Attempt to patch with only immutable-named fields in body** + - Input: + ```http + PATCH http://localhost:2123/api/tasks/ + Content-Type: application/json + + { + "created_by": {"kind": "automation", "ref": "attacker"}, + "origin": {"kind": "network", "ref": "spoofed"} + } + ``` + - **Expected:** HTTP 400 Bad Request + - **Expected:** Since created_by and origin are not in UpdateTaskRequest, `HasChanges()` returns false, yielding "must include at least one mutable field" + - **Expected:** The task's actual `created_by` and `origin` remain unchanged (verify with GET) + +4. **Verify task is unchanged after rejected patches** + - Input: `GET http://localhost:2123/api/tasks/` + - **Expected:** HTTP 200 + - **Expected:** `task.task.title` still equals "Original Title" + - **Expected:** `task.task.scope` still equals "global" + - **Expected:** `task.task.created_by` still equals the original server-derived identity + - **Expected:** `task.task.origin` still equals the original server-derived origin + +5. **Successfully patch a mutable field: title** + - Input: + ```http + PATCH http://localhost:2123/api/tasks/ + Content-Type: application/json + + { + "title": "Updated Title" + } + ``` + - **Expected:** HTTP 200 + - **Expected:** `task.title` equals "Updated Title" + - **Expected:** `task.updated_at` is later than previous `updated_at` + - **Expected:** `task.scope`, `task.created_by`, `task.origin` remain unchanged + +6. **Successfully patch mutable field: description** + - Input: + ```http + PATCH http://localhost:2123/api/tasks/ + Content-Type: application/json + + { + "description": "A new description" + } + ``` + - **Expected:** HTTP 200 + - **Expected:** `task.description` equals "A new description" + +7. **Successfully set and then clear owner** + - Input (set owner): + ```http + PATCH http://localhost:2123/api/tasks/ + Content-Type: application/json + + { + "owner": {"kind": "human", "ref": "bob"} + } + ``` + - **Expected:** HTTP 200, `task.owner.kind` = "human", `task.owner.ref` = "bob" + - Input (clear owner): + ```http + PATCH http://localhost:2123/api/tasks/ + Content-Type: application/json + + { + "clear_owner": true + } + ``` + - **Expected:** HTTP 200, `task.owner` is null + +8. **Reject combined owner + clear_owner** + - Input: + ```http + PATCH http://localhost:2123/api/tasks/ + Content-Type: application/json + + { + "owner": {"kind": "human", "ref": "charlie"}, + "clear_owner": true + } + ``` + - **Expected:** HTTP 400 (cannot set both owner and clear_owner) + +9. **Reject blank title** + - Input: + ```http + PATCH http://localhost:2123/api/tasks/ + Content-Type: application/json + + { + "title": "" + } + ``` + - **Expected:** HTTP 400 (title is required when provided) + +--- + +### Data Validation +| Field | Source Value | Expected Value | Status | +|-------|-------------|----------------|--------| +| Immutable scope change | PATCH {scope: "workspace"} | 400 rejected | [ ] | +| Empty patch | PATCH {} | 400 rejected | [ ] | +| Immutable created_by/origin | PATCH with those fields | 400 rejected | [ ] | +| Mutable title | PATCH {title: "Updated Title"} | 200, title updated | [ ] | +| Mutable description | PATCH {description: "..."} | 200, description updated | [ ] | +| Set owner | PATCH {owner: {...}} | 200, owner set | [ ] | +| Clear owner | PATCH {clear_owner: true} | 200, owner null | [ ] | +| Combined owner+clear_owner | PATCH with both | 400 rejected | [ ] | +| Blank title | PATCH {title: ""} | 400 rejected | [ ] | + +--- + +### Error Scenarios +- [ ] PATCH on non-existent task ID returns 404 (ErrTaskNotFound) +- [ ] PATCH with invalid JSON body returns 400 (decode error) +- [ ] PATCH with metadata exceeding 16 KiB returns 413 (ErrPayloadTooLarge) + +--- + +### Related Test Cases +- TC-INT-001: Creates the task under test +- TC-INT-003: Verifies the full detail payload after updates +- TC-INT-005: UDS endpoint parity for PATCH behavior diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-005.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-005.md new file mode 100644 index 000000000..3d25305f4 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-005.md @@ -0,0 +1,139 @@ +## TC-INT-005: UDS endpoint parity -- all 18 task endpoints return identical results as HTTP + +**Priority:** P0 +**Type:** Integration +**Status:** Not Run +**Estimated Time:** 15 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that all 18 task-related endpoints registered on the UDS transport (`/tmp/.agh/daemon.sock`) produce identical response shapes, status codes, and data as the HTTP transport (TCP :2123), differing only in the `origin.kind` field (`"uds"` vs `"http"`). + +--- + +### Preconditions +- [ ] AGH daemon running with all subsystems +- [ ] HTTP server listening on TCP :2123 +- [ ] UDS server listening on `/tmp/.agh/daemon.sock` +- [ ] At least one workspace registered (for workspace-scoped test data) +- [ ] Clean task store (no pre-existing tasks) + +--- + +### Test Steps + +Each step issues the same request to both transports and compares the responses. + +1. **POST /api/tasks (CreateTask)** + - HTTP: `POST http://localhost:2123/api/tasks` with `{"scope":"global","title":"Parity Test"}` + - UDS: `curl --unix-socket /tmp/.agh/daemon.sock http://localhost/api/tasks` with same body + - **Expected:** Both return 201 + - **Expected:** Both return TaskPayload with identical field shapes + - **Expected:** HTTP task has `origin.kind = "http"`, UDS task has `origin.kind = "uds"` + - Record both task IDs as HTTP_TASK_ID and UDS_TASK_ID + +2. **GET /api/tasks (ListTasks)** + - Both transports: `GET /api/tasks` + - **Expected:** Both return 200 with `{"tasks": [...]}` + - **Expected:** Both contain the 2 tasks just created + - **Expected:** Array element shapes are identical TaskSummaryPayload + +3. **GET /api/tasks/:id (GetTask)** + - HTTP: `GET /api/tasks/` via HTTP + - UDS: `GET /api/tasks/` via UDS + - **Expected:** Both return 200 with TaskDetailPayload + - **Expected:** Detail payload includes task, children (empty), dependencies (empty), runs (empty), events (non-empty: task_created) + +4. **PATCH /api/tasks/:id (UpdateTask)** + - Both transports: `PATCH /api/tasks/` with `{"title":"Updated Parity"}` + - **Expected:** Both return 200 with updated TaskPayload + - **Expected:** `task.title` equals "Updated Parity" on both + +5. **POST /api/tasks/:id/cancel (CancelTask)** + - Create two fresh tasks (one per transport) for this step + - Both: `POST /api/tasks//cancel` with `{"reason":"parity test"}` + - **Expected:** Both return 200 with TaskPayload where `status = "cancelled"` + +6. **POST /api/tasks/:id/children (CreateChildTask)** + - Create parent tasks on each transport + - Both: `POST /api/tasks//children` with `{"scope":"global","title":"Child Parity"}` + - **Expected:** Both return 201 with TaskPayload where `parent_task_id` matches + +7. **POST /api/tasks/:id/dependencies (AddTaskDependency)** + - Create dependency target tasks on each transport + - Both: `POST /api/tasks//dependencies` with `{"depends_on_task_id":""}` + - **Expected:** Both return 200 with TaskDetailPayload including the dependency + +8. **DELETE /api/tasks/:id/dependencies/:depends_on_id (RemoveTaskDependency)** + - Both: `DELETE /api/tasks//dependencies/` + - **Expected:** Both return 200 with TaskDetailPayload, dependency removed + +9. **POST /api/tasks/:id/runs (EnqueueTaskRun)** + - Create ready tasks on each transport + - Both: `POST /api/tasks//runs` + - **Expected:** Both return 201 with TaskRunPayload, `status = "queued"` + +10. **GET /api/tasks/:id/runs (ListTaskRuns)** + - Both: `GET /api/tasks//runs` + - **Expected:** Both return 200 with `{"runs": [...]}` + +11. **POST /api/task-runs/:id/claim (ClaimTaskRun)** + - Both: `POST /api/task-runs//claim` + - **Expected:** Both return 200 with TaskRunPayload, `status = "claimed"` + +12. **POST /api/task-runs/:id/start (StartTaskRun)** + - Both: `POST /api/task-runs//start` + - **Expected:** Both return 200, `status = "starting"` + +13. **POST /api/task-runs/:id/attach-session (AttachTaskRunSession)** + - Both: `POST /api/task-runs//attach-session` with `{"session_id":""}` + - **Expected:** Both return 200, `session_id` populated + +14. **POST /api/task-runs/:id/complete (CompleteTaskRun)** + - Progress runs to "running" on each transport, then: + - Both: `POST /api/task-runs//complete` with `{"result":{"ok":true}}` + - **Expected:** Both return 200, `status = "completed"` + +15. **POST /api/task-runs/:id/fail (FailTaskRun)** + - Create and progress separate runs to "running" + - Both: `POST /api/task-runs//fail` with `{"error":"test failure"}` + - **Expected:** Both return 200, `status = "failed"` + +16. **POST /api/task-runs/:id/cancel (CancelTaskRun)** + - Create and progress separate runs to "claimed" + - Both: `POST /api/task-runs//cancel` with `{"reason":"no longer needed"}` + - **Expected:** Both return 200, `status = "cancelled"` + +--- + +### Data Validation +| Field | HTTP Value | UDS Value | Status | +|-------|-----------|-----------|--------| +| CreateTask status code | 201 | 201 | [ ] | +| CreateTask response shape | TaskPayload | TaskPayload | [ ] | +| origin.kind on create | "http" | "uds" | [ ] | +| ListTasks status code | 200 | 200 | [ ] | +| GetTask status code | 200 | 200 | [ ] | +| GetTask detail shape | TaskDetailPayload | TaskDetailPayload | [ ] | +| UpdateTask status code | 200 | 200 | [ ] | +| CancelTask status code | 200 | 200 | [ ] | +| EnqueueRun status code | 201 | 201 | [ ] | +| ClaimRun status code | 200 | 200 | [ ] | +| Error status codes | Identical | Identical | [ ] | + +--- + +### Error Scenarios +- [ ] 404 on non-existent task ID returns same status on both transports +- [ ] 400 on invalid JSON returns same status on both transports +- [ ] 409 on invalid status transition returns same status on both transports + +--- + +### Related Test Cases +- TC-INT-001: HTTP POST /api/tasks baseline behavior +- TC-INT-002: HTTP GET /api/tasks list behavior +- TC-INT-003: HTTP GET /api/tasks/:id detail behavior +- TC-INT-004: HTTP PATCH immutable field rejection diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-006.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-006.md new file mode 100644 index 000000000..21fad45ab --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-006.md @@ -0,0 +1,109 @@ +## TC-INT-006: CLI `agh task create --scope global --title "Test"` creates task via daemon API + +**Priority:** P0 +**Type:** Integration +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that the `agh task create` CLI command sends a well-formed CreateTaskRequest to the daemon UDS API, receives the created task, and renders the output correctly in both human-readable and JSON formats. + +--- + +### Preconditions +- [ ] AGH daemon running with all subsystems +- [ ] UDS server listening on `/tmp/.agh/daemon.sock` +- [ ] `agh` binary available in PATH +- [ ] No pre-existing tasks in the store + +--- + +### Test Steps + +1. **Create a global task with minimal flags** + - Input: + ```bash + agh task create --scope global --title "CLI Test Task" + ``` + - **Expected:** Exit code 0 + - **Expected:** Human-readable output contains: + - "Task" section header + - ID: a non-empty UUID + - Scope: global + - Title: CLI Test Task + - Status: pending + +2. **Create a global task with JSON output** + - Input: + ```bash + agh task create --scope global --title "CLI JSON Test" --output json + ``` + - **Expected:** Exit code 0 + - **Expected:** Valid JSON output parseable as TaskPayload + - **Expected:** `id` is non-empty, `scope` is "global", `title` is "CLI JSON Test", `status` is "pending" + - **Expected:** `created_by.kind` is "human", `origin.kind` is "uds" (CLI uses UDS transport) + +3. **Create a workspace-scoped task with all flags** + - Input: + ```bash + agh task create \ + --scope workspace \ + --workspace \ + --title "Full CLI Task" \ + --description "Created via CLI with all options" \ + --identifier "cli-001" \ + --owner-kind human \ + --owner-ref alice \ + --metadata '{"source":"cli-test"}' + ``` + - **Expected:** Exit code 0 + - **Expected:** Output shows all provided fields correctly + - **Expected:** `scope` is "workspace", `workspace_id` is resolved, `identifier` is "cli-001" + - **Expected:** `owner` is "human:alice", `metadata` contains `{"source":"cli-test"}` + +4. **Verify created task exists via HTTP API** + - Input: `GET http://localhost:2123/api/tasks` (or parse task ID from step 1 output) + - **Expected:** The CLI-created tasks appear in the list + - **Expected:** All field values match what the CLI reported + +5. **Create with explicit ID** + - Input: + ```bash + agh task create --scope global --title "Explicit ID Task" --id "custom-test-id-001" + ``` + - **Expected:** Exit code 0 + - **Expected:** `id` in output equals "custom-test-id-001" + +--- + +### Data Validation +| Field | CLI Output | API Verification | Status | +|-------|-----------|-----------------|--------| +| task.id | Non-empty UUID | Matches GET response | [ ] | +| task.scope | "global" | "global" | [ ] | +| task.title | "CLI Test Task" | "CLI Test Task" | [ ] | +| task.status | "pending" | "pending" | [ ] | +| task.created_by.kind | "human" | "human" | [ ] | +| task.origin.kind | "uds" | "uds" | [ ] | +| task.workspace_id (ws-scope) | Resolved ID | Matches | [ ] | +| task.owner (when set) | "human:alice" | Matches | [ ] | + +--- + +### Error Scenarios +- [ ] Missing `--scope` flag exits with non-zero code and error message "scope is required" +- [ ] Missing `--title` flag exits with non-zero code and error message "title is required" +- [ ] `--scope workspace` without `--workspace` exits with error "workspace is required when scope is workspace" +- [ ] `--scope global` with `--workspace` exits with error "workspace must be empty when scope is global" +- [ ] Invalid `--scope bogus` exits with validation error +- [ ] Invalid `--metadata` (not valid JSON) exits with error about invalid JSON + +--- + +### Related Test Cases +- TC-INT-001: HTTP POST /api/tasks validates the same contract the CLI calls +- TC-INT-005: UDS parity (CLI uses UDS transport) +- TC-INT-007: CLI list command verifies created tasks appear diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-007.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-007.md new file mode 100644 index 000000000..d9d4cc21f --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-007.md @@ -0,0 +1,115 @@ +## TC-INT-007: CLI `agh task list --status ready --scope workspace` returns filtered results + +**Priority:** P1 +**Type:** Integration +**Status:** Not Run +**Estimated Time:** 5 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that the `agh task list` CLI command correctly translates filter flags into TaskListQuery parameters, sends them to the daemon UDS API, and renders the filtered task list in both human-readable table and JSON formats. + +--- + +### Preconditions +- [ ] AGH daemon running with all subsystems +- [ ] UDS server listening on `/tmp/.agh/daemon.sock` +- [ ] `agh` binary available in PATH +- [ ] At least one workspace registered +- [ ] Seed data: create the following tasks before running tests: + - Task A: scope=global, status=pending, title="Global Pending" + - Task B: scope=workspace, workspace=, title="WS Pending" (status=pending after creation) + - Task C: scope=workspace, workspace=, title="WS Ready" (advance to ready status by satisfying dependencies or direct state) + - Task D: scope=global, status=pending, owner={kind:"human", ref:"alice"}, title="Owned Global" + - Task E: scope=workspace, workspace=, network_channel="ch-qa", title="WS Channeled" + +--- + +### Test Steps + +1. **List all tasks with no filters** + - Input: `agh task list` + - **Expected:** Exit code 0 + - **Expected:** Human-readable table output with columns: ID, Identifier, Scope, Workspace, Parent, Status, Owner, Channel, Title + - **Expected:** All 5 seeded tasks appear in the output + +2. **Filter by scope=workspace** + - Input: `agh task list --scope workspace` + - **Expected:** Exit code 0 + - **Expected:** Only tasks B, C, E appear (workspace-scoped tasks) + - **Expected:** Tasks A, D excluded (global-scoped) + +3. **Filter by status=ready** + - Input: `agh task list --status ready` + - **Expected:** Exit code 0 + - **Expected:** Only Task C appears (the one with ready status) + +4. **Combined filter: scope=workspace AND status=ready** + - Input: `agh task list --scope workspace --status ready` + - **Expected:** Exit code 0 + - **Expected:** Only Task C appears + +5. **Filter by workspace reference** + - Input: `agh task list --scope workspace --workspace ` + - **Expected:** Exit code 0 + - **Expected:** Tasks B, C, E appear (all workspace-scoped tasks in that workspace) + +6. **Filter by owner** + - Input: `agh task list --owner-kind human --owner-ref alice` + - **Expected:** Exit code 0 + - **Expected:** Only Task D appears + +7. **Filter by channel** + - Input: `agh task list --channel ch-qa` + - **Expected:** Exit code 0 + - **Expected:** Only Task E appears + +8. **Apply --last limit** + - Input: `agh task list --last 2` + - **Expected:** Exit code 0 + - **Expected:** Exactly 2 tasks returned + +9. **JSON output with filters** + - Input: `agh task list --scope global --output json` + - **Expected:** Exit code 0 + - **Expected:** Valid JSON array of TaskSummaryPayload objects + - **Expected:** All entries have `scope = "global"` + +10. **Empty result set** + - Input: `agh task list --status completed` + - **Expected:** Exit code 0 + - **Expected:** Output indicates no tasks found (empty table or empty JSON array) + +--- + +### Data Validation +| Field | Filter Applied | Expected Count | Status | +|-------|---------------|----------------|--------| +| No filter | none | 5 | [ ] | +| scope=workspace | --scope workspace | 3 | [ ] | +| status=ready | --status ready | 1 | [ ] | +| scope+status | --scope workspace --status ready | 1 | [ ] | +| workspace ref | --scope workspace --workspace | 3 | [ ] | +| owner | --owner-kind human --owner-ref alice | 1 | [ ] | +| channel | --channel ch-qa | 1 | [ ] | +| limit | --last 2 | 2 | [ ] | +| no matches | --status completed | 0 | [ ] | + +--- + +### Error Scenarios +- [ ] Invalid `--status bogus` exits with non-zero code and validation error +- [ ] Invalid `--scope bogus` exits with non-zero code and validation error +- [ ] `--owner-kind` without `--owner-ref` exits with error (must be provided together) +- [ ] `--owner-ref` without `--owner-kind` exits with error (must be provided together) +- [ ] Negative `--last -1` exits with error +- [ ] `--scope global --workspace some-ws` exits with error (workspace must be empty for global scope) + +--- + +### Related Test Cases +- TC-INT-002: HTTP GET /api/tasks with query filters (same filtering logic) +- TC-INT-005: UDS endpoint parity +- TC-INT-006: CLI create command used to seed test data diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-008.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-008.md new file mode 100644 index 000000000..c9f34b9ab --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-008.md @@ -0,0 +1,123 @@ +## TC-INT-008: CLI `agh task cancel --reason "No longer needed"` cancels task and children + +**Priority:** P0 +**Type:** Integration +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that the `agh task cancel` CLI command cancels the target task and propagates cancellation to all its child tasks, including stopping any active runs and their bound sessions. + +--- + +### Preconditions +- [ ] AGH daemon running with all subsystems +- [ ] UDS server listening on `/tmp/.agh/daemon.sock` +- [ ] `agh` binary available in PATH +- [ ] Seed task hierarchy: + - Parent task P (scope=global, title="Parent To Cancel", status=pending) + - Child C1 under P (scope=global, title="Child 1", status=pending) + - Child C2 under P (scope=global, title="Child 2", status=pending) + - Grandchild GC1 under C1 (scope=global, title="Grandchild 1", status=pending) + - Optional: an active run on C1 to verify run cancellation cascade + +--- + +### Test Steps + +1. **Cancel the parent task with reason** + - Input: + ```bash + agh task cancel --reason "No longer needed" + ``` + - **Expected:** Exit code 0 + - **Expected:** Output shows Task section with: + - ID: + - Status: cancelled + - Updated timestamp later than previous + +2. **Verify parent task status via API** + - Input: `GET http://localhost:2123/api/tasks/` + - **Expected:** HTTP 200 + - **Expected:** `task.task.status` equals "cancelled" + - **Expected:** `task.task.closed_at` is a valid timestamp (set on cancellation) + +3. **Verify child C1 is cancelled** + - Input: `GET http://localhost:2123/api/tasks/` + - **Expected:** HTTP 200 + - **Expected:** `task.task.status` equals "cancelled" + - **Expected:** `task.task.closed_at` is set + +4. **Verify child C2 is cancelled** + - Input: `GET http://localhost:2123/api/tasks/` + - **Expected:** HTTP 200 + - **Expected:** `task.task.status` equals "cancelled" + +5. **Verify grandchild GC1 is cancelled (cascade depth)** + - Input: `GET http://localhost:2123/api/tasks/` + - **Expected:** HTTP 200 + - **Expected:** `task.task.status` equals "cancelled" + +6. **Verify cancellation events were recorded** + - Input: `GET http://localhost:2123/api/tasks/` (check events array) + - **Expected:** Events array contains a "task_cancelled" event + - **Expected:** Event includes the cancellation reason in its payload + +7. **Cancel with metadata** + - Create a fresh task T2 + - Input: + ```bash + agh task cancel --reason "Budget cut" --metadata '{"ticket":"JIRA-123"}' + ``` + - **Expected:** Exit code 0 + - **Expected:** Task status is cancelled + +8. **Cancel an already-cancelled task** + - Input: + ```bash + agh task cancel --reason "Double cancel" + ``` + - **Expected:** Non-zero exit code or 409 Conflict (ErrInvalidStatusTransition) + - **Expected:** Error message indicates the task cannot transition from cancelled state + +9. **Cancel with active run** + - Create task T3, enqueue and start a run on T3 + - Input: `agh task cancel --reason "abort run"` + - **Expected:** Exit code 0 + - **Expected:** Task T3 status is "cancelled" + - **Expected:** The active run on T3 is also cancelled (verify via GET runs) + - **Expected:** If the run had a bound session, SessionExecutor.RequestTaskStop was called + +--- + +### Data Validation +| Field | Source Value | Expected Value | Status | +|-------|-------------|----------------|--------| +| Parent P status | After cancel | "cancelled" | [ ] | +| Parent P closed_at | After cancel | Non-null timestamp | [ ] | +| Child C1 status | After parent cancel | "cancelled" | [ ] | +| Child C2 status | After parent cancel | "cancelled" | [ ] | +| Grandchild GC1 status | After parent cancel | "cancelled" | [ ] | +| Cancellation event | events array | Contains "task_cancelled" | [ ] | +| Active run status | After task cancel | "cancelled" | [ ] | +| Exit code (success) | CLI exit | 0 | [ ] | +| Exit code (double cancel) | CLI exit | Non-zero | [ ] | + +--- + +### Error Scenarios +- [ ] Cancel non-existent task ID returns error (ErrTaskNotFound, exit non-zero) +- [ ] Cancel a completed task returns 409 (ErrInvalidStatusTransition) +- [ ] Cancel with metadata exceeding 64 KiB returns 413 (ErrPayloadTooLarge) +- [ ] Cancel with invalid metadata JSON exits with parse error + +--- + +### Related Test Cases +- TC-INT-001: Creates tasks for cancellation +- TC-INT-003: Verifies detail payload after cancellation +- TC-INT-006: CLI create used to set up the hierarchy +- TC-INT-009: Session bridge interaction when runs are active during cancel diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-009.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-009.md new file mode 100644 index 000000000..816b3cd0d --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-009.md @@ -0,0 +1,129 @@ +## TC-INT-009: Start run triggers SessionExecutor.StartTaskSession with dedicated session bound to run + +**Priority:** P0 +**Type:** Integration +**Status:** Not Run +**Estimated Time:** 10 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate the session bridge integration: when a task run transitions through the start lifecycle (enqueue -> claim -> start), the TaskManager calls `SessionExecutor.StartTaskSession` with the correct `StartTaskSession` spec, a dedicated session is created, and the returned `session_id` is durably bound to the run record. + +--- + +### Preconditions +- [ ] AGH daemon running with all subsystems including a functional SessionExecutor +- [ ] HTTP server listening on TCP :2123 +- [ ] At least one agent configured and available for session creation +- [ ] A mock or real SessionExecutor implementation that tracks StartTaskSession calls +- [ ] One task T created (scope=global, title="Session Bridge Test", status=pending or ready) + +--- + +### Test Steps + +1. **Enqueue a run for the task** + - Input: + ```http + POST http://localhost:2123/api/tasks//runs + Content-Type: application/json + + {} + ``` + - **Expected:** HTTP 201 + - **Expected:** Run R returned with `status = "queued"`, `session_id = ""` + - Record R.id + +2. **Claim the run** + - Input: + ```http + POST http://localhost:2123/api/task-runs//claim + Content-Type: application/json + + {} + ``` + - **Expected:** HTTP 200 + - **Expected:** Run returned with `status = "claimed"` + - **Expected:** `claimed_by` is populated with the claiming actor identity + - **Expected:** `claimed_at` is a valid timestamp + - **Expected:** `session_id` is still empty (session not yet started) + +3. **Start the run (triggers SessionExecutor.StartTaskSession)** + - Input: + ```http + POST http://localhost:2123/api/task-runs//start + Content-Type: application/json + + {} + ``` + - **Expected:** HTTP 200 + - **Expected:** Run returned with `status = "starting"` or `"running"` (depending on whether the session start is synchronous) + - **Expected:** SessionExecutor.StartTaskSession was called with a `StartTaskSession` spec containing: + - `task.id` = T.id + - `task.title` = "Session Bridge Test" + - `task.scope` = "global" + - `run.id` = R.id + - `run.task_id` = T.id + - `run.status` = current run status at call time + - `actor` = valid ActorContext + - **Expected:** The returned `SessionRef.session_id` is non-empty + +4. **Verify session_id is bound to the run** + - Input: `GET http://localhost:2123/api/tasks/` (or list runs) + - **Expected:** The run in `task.runs` has `session_id` populated with the value from SessionRef + - **Expected:** The `started_at` timestamp is set + +5. **Verify the session actually exists** + - Input: `GET http://localhost:2123/api/sessions/` + - **Expected:** HTTP 200 + - **Expected:** Session exists and is associated with the task context + +6. **Complete the run and verify session lifecycle** + - Input: + ```http + POST http://localhost:2123/api/task-runs//complete + Content-Type: application/json + + {"result": {"output": "done"}} + ``` + - **Expected:** HTTP 200 + - **Expected:** Run returned with `status = "completed"`, `ended_at` set, `result` contains the output + - **Expected:** Task T status transitions to "completed" (if this was the successful run) + +7. **Verify audit events include session binding** + - Input: `GET http://localhost:2123/api/tasks/` (check events) + - **Expected:** Events include run_enqueued, run_claimed, run_started (with session binding info), run_completed + +--- + +### Data Validation +| Field | Source Value | Expected Value | Status | +|-------|-------------|----------------|--------| +| Run status after enqueue | queued | "queued" | [ ] | +| Run session_id after enqueue | empty | "" | [ ] | +| Run status after claim | claimed | "claimed" | [ ] | +| Run claimed_by after claim | (populated) | Valid ActorIdentity | [ ] | +| Run status after start | starting/running | "starting" or "running" | [ ] | +| Run session_id after start | (populated) | Non-empty session UUID | [ ] | +| StartTaskSession spec.task.id | (passed to executor) | T.id | [ ] | +| StartTaskSession spec.run.id | (passed to executor) | R.id | [ ] | +| Session existence | GET /sessions/ | 200 | [ ] | +| Run status after complete | completed | "completed" | [ ] | +| Run ended_at after complete | (set) | Valid timestamp | [ ] | + +--- + +### Error Scenarios +- [ ] Start run when SessionExecutor returns error: run transitions to failed, error recorded +- [ ] Start run on a task that is already cancelled: returns 409 (ErrInvalidStatusTransition) +- [ ] Start run that is not in "claimed" status: returns 409 (ErrInvalidStatusTransition) +- [ ] Enqueue run on a cancelled task: returns 409 + +--- + +### Related Test Cases +- TC-INT-010: AttachRunSession for the resume flow (alternative to StartTaskSession) +- TC-INT-008: Cancel propagation stops active sessions +- TC-INT-003: GetTask detail includes run with session binding diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-010.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-010.md new file mode 100644 index 000000000..557bd4cef --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-010.md @@ -0,0 +1,148 @@ +## TC-INT-010: AttachRunSession binds existing session; second attach returns ErrSessionAlreadyBound + +**Priority:** P0 +**Type:** Integration +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate the session resume flow: `AttachRunSession` binds an existing session to a claimed/starting run, and a second attach attempt on the same run returns `ErrSessionAlreadyBound` (HTTP 409). Also verify that the `SessionExecutor.AttachTaskSession` is called with the correct parameters. + +--- + +### Preconditions +- [ ] AGH daemon running with all subsystems including a functional SessionExecutor +- [ ] HTTP server listening on TCP :2123 +- [ ] An existing session S1 created independently (via POST /api/sessions or agent launch) +- [ ] A second session S2 created independently +- [ ] One task T created (scope=global, title="Attach Session Test") +- [ ] One run R enqueued and claimed for task T (status="claimed") + +--- + +### Test Steps + +1. **Attach session S1 to the claimed run** + - Input: + ```http + POST http://localhost:2123/api/task-runs//attach-session + Content-Type: application/json + + { + "session_id": "" + } + ``` + - **Expected:** HTTP 200 + - **Expected:** Run returned with `session_id` equal to S1.id + - **Expected:** `SessionExecutor.AttachTaskSession` was called with `runID = R.id` and `sessionID = S1.id` + - **Expected:** The returned SessionRef is valid + +2. **Verify the session binding persists** + - Input: `GET http://localhost:2123/api/tasks/` + - **Expected:** HTTP 200 + - **Expected:** The run in `task.runs` has `session_id = S1.id` + +3. **Attempt to attach session S2 to the same run (already bound)** + - Input: + ```http + POST http://localhost:2123/api/task-runs//attach-session + Content-Type: application/json + + { + "session_id": "" + } + ``` + - **Expected:** HTTP 409 Conflict + - **Expected:** Error message references ErrSessionAlreadyBound + - **Expected:** The run's session_id remains S1.id (unchanged) + +4. **Attempt to re-attach the same session S1 to the same run** + - Input: + ```http + POST http://localhost:2123/api/task-runs//attach-session + Content-Type: application/json + + { + "session_id": "" + } + ``` + - **Expected:** HTTP 409 Conflict (session is already bound, even if it's the same session) + +5. **Verify run state is not corrupted after failed attach attempts** + - Input: `GET http://localhost:2123/api/tasks/` + - **Expected:** HTTP 200 + - **Expected:** Run still has `session_id = S1.id` + - **Expected:** Run status is still "claimed" or "starting" (not corrupted) + +6. **Attach session to a run in "starting" status** + - Create new task T2, enqueue run R2, claim R2, then start R2 (status="starting") + - If R2 was started without a session (manual start flow): + ```http + POST http://localhost:2123/api/task-runs//attach-session + Content-Type: application/json + + { + "session_id": "" + } + ``` + - **Expected:** HTTP 200 if the run is in a state that allows attachment + - **Expected:** Or HTTP 409 (ErrSessionAttachNotAllowed) if the run already has a session from the start flow + +7. **Attach session to a completed run** + - Create a run that has been completed + - Input: + ```http + POST http://localhost:2123/api/task-runs//attach-session + Content-Type: application/json + + { + "session_id": "" + } + ``` + - **Expected:** HTTP 409 (ErrSessionAttachNotAllowed -- cannot attach to a terminal run) + +8. **Attach with empty session_id** + - Input: + ```http + POST http://localhost:2123/api/task-runs//attach-session + Content-Type: application/json + + { + "session_id": "" + } + ``` + - **Expected:** HTTP 400 (ErrValidation: session_id is required) + +--- + +### Data Validation +| Field | Source Value | Expected Value | Status | +|-------|-------------|----------------|--------| +| Run session_id after first attach | S1.id | S1.id | [ ] | +| HTTP status (first attach) | Response code | 200 | [ ] | +| HTTP status (second attach, different session) | Response code | 409 | [ ] | +| HTTP status (re-attach same session) | Response code | 409 | [ ] | +| Run session_id after failed attach | S1.id | S1.id (unchanged) | [ ] | +| HTTP status (attach to completed run) | Response code | 409 | [ ] | +| HTTP status (empty session_id) | Response code | 400 | [ ] | +| Error type (second attach) | Error | ErrSessionAlreadyBound | [ ] | +| Error type (terminal run) | Error | ErrSessionAttachNotAllowed | [ ] | + +--- + +### Error Scenarios +- [ ] Attach to non-existent run ID returns 404 (ErrTaskRunNotFound) +- [ ] Attach with non-existent session ID returns 404 (session not found) or appropriate error +- [ ] Attach to a "queued" run returns 409 (ErrSessionAttachNotAllowed -- must be claimed or starting first) +- [ ] Attach to a "cancelled" run returns 409 (ErrSessionAttachNotAllowed) +- [ ] Attach to a "failed" run returns 409 (ErrSessionAttachNotAllowed) + +--- + +### Related Test Cases +- TC-INT-009: StartTaskSession flow (creates session automatically via bridge) +- TC-INT-008: Cancel propagation affects runs with attached sessions +- TC-INT-005: UDS parity for attach-session endpoint diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-011.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-011.md new file mode 100644 index 000000000..d6d17c5e2 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-011.md @@ -0,0 +1,90 @@ +## TC-INT-011: Automation creates task directly with ActorKindAutomation and OriginKindAutomation + +**Priority:** P0 +**Type:** Integration +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that when the automation subsystem creates a task directly (not through an agent session), the persisted task record carries `created_by.kind = "automation"` and `origin.kind = "automation"`, with the correct actor and origin references derived from `DeriveAutomationActorContext`. + +--- + +### Preconditions +- [ ] AGH daemon running with all subsystems including automation engine +- [ ] At least one automation job configured and enabled +- [ ] Task manager accessible to the automation subsystem +- [ ] Clean task store (or known baseline) + +--- + +### Test Steps + +1. **Trigger an automation job that creates a task directly** + - Input: Trigger the automation job via: + ```http + POST http://localhost:2123/api/automation/jobs//trigger + ``` + Or use a configured trigger that fires automatically. + - **Expected:** The automation job runs successfully + +2. **Identify the task created by automation** + - Input: `GET http://localhost:2123/api/tasks` (list all tasks) + - **Expected:** A new task appears that was not present before the trigger + - **Expected:** The task can be identified by its origin or metadata + +3. **Verify ActorKindAutomation on the created task** + - Input: `GET http://localhost:2123/api/tasks/` + - **Expected:** HTTP 200 + - **Expected:** `task.task.created_by.kind` equals `"automation"` + - **Expected:** `task.task.created_by.ref` is non-empty and identifies the automation job/flow (e.g., job ID or automation name) + +4. **Verify OriginKindAutomation on the created task** + - **Expected:** `task.task.origin.kind` equals `"automation"` + - **Expected:** `task.task.origin.ref` is non-empty (matches the automation actor ref or includes job context) + +5. **Verify the actor-origin pair is valid** + - **Expected:** The combination `actor.kind = "automation"` with `origin.kind = "automation"` passes the `validateActorOriginPair` check (this is a valid pair per the actors.go rules) + +6. **Verify authority was granted** + - **Expected:** The automation context has `FullAccessAuthority()` (read=true, write=true, create_global=true, create_workspace=true) + - **Expected:** The task was created without ErrPermissionDenied + +7. **Verify task fields are correct** + - **Expected:** `task.task.scope` is valid (global or workspace as configured by automation) + - **Expected:** `task.task.title` is non-empty + - **Expected:** `task.task.status` is "pending" + +8. **Verify audit events carry automation actor** + - **Expected:** The task_created event in `task.events` has `actor.kind = "automation"` and `origin.kind = "automation"` + +--- + +### Data Validation +| Field | Source Value | Expected Value | Status | +|-------|-------------|----------------|--------| +| task.created_by.kind | (server-derived) | "automation" | [ ] | +| task.created_by.ref | (server-derived) | Non-empty automation ref | [ ] | +| task.origin.kind | (server-derived) | "automation" | [ ] | +| task.origin.ref | (server-derived) | Non-empty automation ref | [ ] | +| task.status | (server-derived) | "pending" | [ ] | +| Actor-origin pair validity | validateActorOriginPair | No error | [ ] | +| Event actor.kind | task_created event | "automation" | [ ] | +| Event origin.kind | task_created event | "automation" | [ ] | + +--- + +### Error Scenarios +- [ ] Automation with incorrect origin kind (e.g., "http") is rejected by `validateActorOriginPair` (ActorKindAutomation requires OriginKindAutomation) +- [ ] Automation with empty actor ref is rejected by `ActorIdentity.Validate` +- [ ] Automation without write authority cannot create tasks (ErrPermissionDenied) + +--- + +### Related Test Cases +- TC-INT-012: Automation-linked agent session creates task (ActorKindAgentSession + OriginKindAutomation) +- TC-INT-013: Extension creates task (ActorKindExtension) +- TC-INT-014: Network peer creates task (ActorKindNetworkPeer) diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-012.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-012.md new file mode 100644 index 000000000..646030757 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-012.md @@ -0,0 +1,96 @@ +## TC-INT-012: Automation-linked agent session creates task with ActorKindAgentSession and automation origin + +**Priority:** P1 +**Type:** Integration +**Status:** Not Run +**Estimated Time:** 10 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that when an agent session launched by automation creates a task (via tool call or session API), the persisted task record carries `created_by.kind = "agent_session"` and `origin.kind = "automation"`, correctly linking the session actor to its automation origin via `DeriveAutomationLinkedAgentSessionActorContext`. + +--- + +### Preconditions +- [ ] AGH daemon running with all subsystems including automation engine and ACP client +- [ ] At least one automation job configured that launches an agent session +- [ ] The agent session has task-creation capability (tool or API access) +- [ ] Task manager accessible to agent sessions +- [ ] Clean task store (or known baseline count) + +--- + +### Test Steps + +1. **Trigger automation that launches an agent session** + - Input: Trigger the automation job: + ```http + POST http://localhost:2123/api/automation/jobs//trigger + ``` + - **Expected:** Automation job starts and spawns an agent session + - Record the automation run ID and the spawned session ID + +2. **Agent session creates a task during execution** + - The agent session, while executing its automation-assigned work, creates a task via tool call or direct API invocation + - This may happen automatically as part of the agent's workflow + - Alternatively, prompt the session to create a task if the agent supports tool-based task creation + +3. **Identify the task created by the agent session** + - Input: `GET http://localhost:2123/api/tasks` + - **Expected:** A new task appears + - **Expected:** The task was created after the automation trigger + +4. **Verify ActorKindAgentSession on the created task** + - Input: `GET http://localhost:2123/api/tasks/` + - **Expected:** HTTP 200 + - **Expected:** `task.task.created_by.kind` equals `"agent_session"` + - **Expected:** `task.task.created_by.ref` equals the session ID that created it + +5. **Verify OriginKindAutomation (not OriginKindAgentSession)** + - **Expected:** `task.task.origin.kind` equals `"automation"` (the session was launched by automation) + - **Expected:** `task.task.origin.ref` is non-empty and references the automation context (may be the session ref or automation activation ref) + +6. **Verify the actor-origin pair is valid** + - **Expected:** The combination `actor.kind = "agent_session"` with `origin.kind = "automation"` passes `validateActorOriginPair` (this is a valid pair per actors.go: AgentSession allows OriginKindAgentSession or OriginKindAutomation) + +7. **Contrast with a non-automation agent session** + - Create a standalone agent session (not launched by automation) + - Have it create a task + - **Expected:** `task.task.created_by.kind` equals `"agent_session"` + - **Expected:** `task.task.origin.kind` equals `"agent_session"` (not automation, since this session was not automation-launched) + - **Expected:** `task.task.origin.ref` equals the session ID + +8. **Verify audit events carry the correct actor-origin** + - Input: Check events on the automation-linked task + - **Expected:** task_created event has `actor.kind = "agent_session"` and `origin.kind = "automation"` + - Input: Check events on the standalone session task + - **Expected:** task_created event has `actor.kind = "agent_session"` and `origin.kind = "agent_session"` + +--- + +### Data Validation +| Field | Automation-Linked Session | Standalone Session | Status | +|-------|--------------------------|-------------------|--------| +| created_by.kind | "agent_session" | "agent_session" | [ ] | +| created_by.ref | Session ID | Session ID | [ ] | +| origin.kind | "automation" | "agent_session" | [ ] | +| origin.ref | Automation ref or session ref | Session ID | [ ] | +| Actor-origin pair | Valid (agent_session + automation) | Valid (agent_session + agent_session) | [ ] | +| Event actor.kind | "agent_session" | "agent_session" | [ ] | +| Event origin.kind | "automation" | "agent_session" | [ ] | + +--- + +### Error Scenarios +- [ ] Agent session with `origin.kind = "http"` is rejected by `validateActorOriginPair` (agent_session requires agent_session or automation origin) +- [ ] Agent session with empty actor ref (no session ID) is rejected by validation +- [ ] Automation-linked session with `origin.kind = "cli"` is rejected + +--- + +### Related Test Cases +- TC-INT-011: Automation creates task directly (ActorKindAutomation) +- TC-INT-009: Session bridge creates sessions for task runs +- TC-INT-013: Extension creates task (different actor kind) diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-013.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-013.md new file mode 100644 index 000000000..ffc607d0c --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-013.md @@ -0,0 +1,105 @@ +## TC-INT-013: Extension creates task via host API with ActorKindExtension and capability check + +**Priority:** P1 +**Type:** Integration +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that an authenticated extension runtime creates a task through the extension host API, the persisted task carries `created_by.kind = "extension"` and `origin.kind = "extension"`, and the operation is gated by the extension's task.write capability. + +--- + +### Preconditions +- [ ] AGH daemon running with all subsystems including extension host +- [ ] At least one extension installed and enabled with `task.write` capability +- [ ] A second extension installed with only `task.read` capability (no write) +- [ ] Task manager accessible to extension host handlers +- [ ] Clean task store (or known baseline count) + +--- + +### Test Steps + +1. **Extension with task.write capability creates a task** + - The extension host invokes `handleTasksCreate` (or equivalent) on behalf of the authorized extension + - The extension's actor context is derived via `DeriveExtensionActorContext(extensionName, originRef)` + - Input: Extension sends create-task request through host API with: + ```json + { + "scope": "global", + "title": "Extension Created Task" + } + ``` + - **Expected:** Task created successfully (no error) + +2. **Verify ActorKindExtension on the created task** + - Input: `GET http://localhost:2123/api/tasks/` + - **Expected:** HTTP 200 + - **Expected:** `task.task.created_by.kind` equals `"extension"` + - **Expected:** `task.task.created_by.ref` is non-empty and identifies the extension (e.g., extension name or ID) + +3. **Verify OriginKindExtension on the created task** + - **Expected:** `task.task.origin.kind` equals `"extension"` + - **Expected:** `task.task.origin.ref` is non-empty (extension name or actor ref if originRef was empty) + +4. **Verify the actor-origin pair is valid** + - **Expected:** The combination `actor.kind = "extension"` with `origin.kind = "extension"` passes `validateActorOriginPair` (this is the only valid origin for extensions) + +5. **Extension without task.write capability is denied** + - The read-only extension attempts to create a task + - **Expected:** The operation is rejected with ErrPermissionDenied + - **Expected:** If surfaced via HTTP: HTTP 403 Forbidden + - **Expected:** No task is created in the store + +6. **Extension reads tasks (task.read capability)** + - The read-only extension attempts to list or get tasks + - **Expected:** Read operations succeed (read capability is sufficient) + - **Expected:** The tasks list is returned normally + +7. **Extension updates a task (requires task.write)** + - The write-capable extension patches the task title + - **Expected:** Update succeeds, `updated_at` is refreshed + - **Expected:** The update event has `actor.kind = "extension"` + +8. **Extension without task.write cannot update** + - The read-only extension attempts to patch a task + - **Expected:** ErrPermissionDenied / HTTP 403 + +9. **Verify audit events carry extension actor** + - Input: Check events on the extension-created task + - **Expected:** task_created event has `actor.kind = "extension"` and `origin.kind = "extension"` + +--- + +### Data Validation +| Field | Source Value | Expected Value | Status | +|-------|-------------|----------------|--------| +| task.created_by.kind | (server-derived) | "extension" | [ ] | +| task.created_by.ref | (server-derived) | Extension name/ID | [ ] | +| task.origin.kind | (server-derived) | "extension" | [ ] | +| task.origin.ref | (server-derived) | Extension name/ID | [ ] | +| Actor-origin pair | validateActorOriginPair | No error | [ ] | +| Read-only ext create | Permission check | ErrPermissionDenied (403) | [ ] | +| Read-only ext list | Permission check | Allowed | [ ] | +| Write ext update | Permission check | Allowed | [ ] | +| Read-only ext update | Permission check | ErrPermissionDenied (403) | [ ] | +| Event actor.kind | task_created event | "extension" | [ ] | + +--- + +### Error Scenarios +- [ ] Extension with `origin.kind = "http"` is rejected by `validateActorOriginPair` (extension requires extension origin) +- [ ] Extension with empty actor ref is rejected by validation +- [ ] Extension with no capabilities at all is denied both read and write +- [ ] Extension attempting to create workspace-scoped task without `create_workspace` authority is denied + +--- + +### Related Test Cases +- TC-INT-011: Automation creates task (different actor kind, same capability pattern) +- TC-INT-012: Agent session creates task (different actor kind) +- TC-INT-014: Network peer creates task (different actor kind, channel binding) diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-014.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-014.md new file mode 100644 index 000000000..a6de0ec0b --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-014.md @@ -0,0 +1,116 @@ +## TC-INT-014: Network peer creates task with channel binding and ActorKindNetworkPeer + +**Priority:** P1 +**Type:** Integration +**Status:** Not Run +**Estimated Time:** 10 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that an authenticated network peer creates a task with a channel binding, the persisted task carries `created_by.kind = "network_peer"` and `origin.kind = "network"`, the channel is validated, and the task is correctly bound to the specified network channel. + +--- + +### Preconditions +- [ ] AGH daemon running with all subsystems including network subsystem +- [ ] At least one network peer connected and authenticated +- [ ] A valid network channel exists (e.g., "ch-collab-001") +- [ ] The peer has task.write capability +- [ ] Task manager accessible to network ingress handlers +- [ ] Clean task store (or known baseline count) + +--- + +### Test Steps + +1. **Network peer creates a task with channel binding** + - The network peer sends a task creation request through the network ingress layer + - Actor context is derived via `DeriveNetworkPeerActorContext(peerRef, originRef)` + - Input (effective request): + ```json + { + "scope": "global", + "title": "Network Peer Task", + "network_channel": "ch-collab-001" + } + ``` + - **Expected:** Task created successfully + +2. **Verify ActorKindNetworkPeer on the created task** + - Input: `GET http://localhost:2123/api/tasks/` + - **Expected:** HTTP 200 + - **Expected:** `task.task.created_by.kind` equals `"network_peer"` + - **Expected:** `task.task.created_by.ref` is non-empty and identifies the peer (e.g., peer ID or name) + +3. **Verify OriginKindNetwork on the created task** + - **Expected:** `task.task.origin.kind` equals `"network"` + - **Expected:** `task.task.origin.ref` is non-empty (peer ref or peer/channel context) + +4. **Verify the actor-origin pair is valid** + - **Expected:** The combination `actor.kind = "network_peer"` with `origin.kind = "network"` passes `validateActorOriginPair` + +5. **Verify channel binding** + - **Expected:** `task.task.network_channel` equals `"ch-collab-001"` + - **Expected:** The channel was validated against `network.ValidateChannel` before creation + +6. **List tasks filtered by network_channel** + - Input: `GET http://localhost:2123/api/tasks?network_channel=ch-collab-001` + - **Expected:** HTTP 200 + - **Expected:** The network peer's task appears in the filtered results + +7. **Network peer creates task with invalid channel** + - Input: Task creation with `network_channel: "invalid!!channel"` + - **Expected:** Rejected with ErrValidation (channel format invalid) + - **Expected:** No task persisted + +8. **Network peer creates task without channel (allowed if policy permits)** + - Input: Task creation without `network_channel` field + - **Expected:** Either succeeds with empty channel or rejected based on network ingress policy + - **Expected:** If allowed, `network_channel` is empty on the persisted task + +9. **Enqueue run with network channel override** + - Input: Enqueue a run for the network task with a different channel + ```json + { + "network_channel": "ch-collab-002" + } + ``` + - **Expected:** Run created with `network_channel = "ch-collab-002"` (run-level override) + +10. **Verify audit events carry network peer actor** + - Input: Check events on the created task + - **Expected:** task_created event has `actor.kind = "network_peer"` and `origin.kind = "network"` + +--- + +### Data Validation +| Field | Source Value | Expected Value | Status | +|-------|-------------|----------------|--------| +| task.created_by.kind | (server-derived) | "network_peer" | [ ] | +| task.created_by.ref | (server-derived) | Peer ID/name | [ ] | +| task.origin.kind | (server-derived) | "network" | [ ] | +| task.origin.ref | (server-derived) | Peer ref or context | [ ] | +| task.network_channel | "ch-collab-001" | "ch-collab-001" | [ ] | +| Actor-origin pair | validateActorOriginPair | No error | [ ] | +| Channel filter result | List with channel filter | Contains task | [ ] | +| Invalid channel | creation attempt | Rejected (400) | [ ] | +| Run channel override | "ch-collab-002" | "ch-collab-002" | [ ] | +| Event actor.kind | task_created event | "network_peer" | [ ] | +| Event origin.kind | task_created event | "network" | [ ] | + +--- + +### Error Scenarios +- [ ] Network peer with `origin.kind = "http"` is rejected by `validateActorOriginPair` (network_peer requires network origin) +- [ ] Network peer with empty actor ref is rejected by validation +- [ ] Network peer without task.write capability is denied (ErrPermissionDenied, 403) +- [ ] Task creation with channel that does not exist in the network layer may be rejected depending on validation policy + +--- + +### Related Test Cases +- TC-INT-011: Automation creates task (different actor kind) +- TC-INT-013: Extension creates task (different actor kind) +- TC-INT-015: Network peer writes to task with stale channel diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-015.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-015.md new file mode 100644 index 000000000..33d8ca4d1 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-INT-015.md @@ -0,0 +1,138 @@ +## TC-INT-015: Network peer writes to task with stale channel returns ErrStaleNetworkChannel; task still readable + +**Priority:** P1 +**Type:** Integration +**Status:** Not Run +**Estimated Time:** 8 minutes +**Created:** 2026-04-14 + +--- + +### Objective +Validate that when a network peer attempts a write operation (update, cancel, enqueue run, etc.) on a task whose stored `network_channel` no longer passes the active channel validator, the operation is rejected with `ErrStaleNetworkChannel` (HTTP 409), but the task remains fully readable via GET endpoints. + +--- + +### Preconditions +- [ ] AGH daemon running with all subsystems including network subsystem +- [ ] A network peer connected and authenticated with task.write capability +- [ ] A valid network channel "ch-stale-test" initially exists and passes validation +- [ ] One task T created by the network peer with `network_channel = "ch-stale-test"` +- [ ] A run R enqueued for task T (to test run-level stale channel rejection) +- [ ] Mechanism to invalidate/remove the channel "ch-stale-test" from the active validator (e.g., disconnect the channel, remove the peer, or reconfigure the network layer so the channel is no longer valid) + +--- + +### Test Steps + +1. **Verify the task is writable while channel is valid** + - Input: Update task T via network peer: + ```json + PATCH /api/tasks/ + {"title": "Updated While Valid"} + ``` + - **Expected:** HTTP 200 + - **Expected:** Task title updated to "Updated While Valid" + +2. **Invalidate the network channel** + - Action: Remove or invalidate "ch-stale-test" from the active network channel validator + - This could be done by: + - Disconnecting the network peer that owns the channel + - Removing the channel from the network configuration + - Simulating a channel expiry event + - **Expected:** `network.ValidateChannel("ch-stale-test")` now returns an error + +3. **Attempt to update the task with the stale channel** + - Input: Network peer attempts: + ```json + PATCH /api/tasks/ + {"title": "Should Fail - Stale Channel"} + ``` + - **Expected:** HTTP 409 Conflict + - **Expected:** Error message references ErrStaleNetworkChannel or "stale network channel" + - **Expected:** Task title remains "Updated While Valid" (unchanged) + +4. **Attempt to cancel the task with the stale channel** + - Input: + ```json + POST /api/tasks//cancel + {"reason": "Stale channel cancel attempt"} + ``` + - **Expected:** HTTP 409 Conflict (ErrStaleNetworkChannel) + - **Expected:** Task status remains unchanged (not cancelled) + +5. **Attempt to enqueue a run with the stale channel** + - Input: + ```json + POST /api/tasks//runs + {} + ``` + - **Expected:** HTTP 409 Conflict (ErrStaleNetworkChannel) + - **Expected:** No new run created + +6. **Attempt to claim an existing run on the stale-channel task** + - Input: + ```json + POST /api/task-runs//claim + {} + ``` + - **Expected:** HTTP 409 Conflict (ErrStaleNetworkChannel) if channel validation applies to run operations + - **Expected:** Or: claim succeeds if channel validation only applies to task-level writes + +7. **Verify the task is still fully readable** + - Input: `GET http://localhost:2123/api/tasks/` + - **Expected:** HTTP 200 + - **Expected:** Full TaskDetailPayload returned with all fields intact + - **Expected:** `task.task.network_channel` still equals "ch-stale-test" (the stored value is preserved) + - **Expected:** Children, dependencies, runs, events all accessible + +8. **List tasks includes the stale-channel task** + - Input: `GET http://localhost:2123/api/tasks` + - **Expected:** HTTP 200 + - **Expected:** Task T appears in the list + +9. **Filter by the stale channel still works for reads** + - Input: `GET http://localhost:2123/api/tasks?network_channel=ch-stale-test` + - **Expected:** HTTP 200 + - **Expected:** Task T appears in the filtered results (read operations do not validate channel liveness) + +10. **Restore the channel and verify writes resume** + - Action: Re-validate "ch-stale-test" by re-adding it to the active network validator + - Input: Network peer updates the task: + ```json + PATCH /api/tasks/ + {"title": "Updated After Channel Restored"} + ``` + - **Expected:** HTTP 200 + - **Expected:** Task title updated to "Updated After Channel Restored" + +--- + +### Data Validation +| Field | Source Value | Expected Value | Status | +|-------|-------------|----------------|--------| +| Write while valid | PATCH title | 200, title updated | [ ] | +| Write after invalidation | PATCH title | 409, ErrStaleNetworkChannel | [ ] | +| Cancel after invalidation | POST cancel | 409, ErrStaleNetworkChannel | [ ] | +| Enqueue after invalidation | POST runs | 409, ErrStaleNetworkChannel | [ ] | +| GET after invalidation | GET task | 200, full payload | [ ] | +| List after invalidation | GET tasks | 200, task included | [ ] | +| Filter by stale channel | GET tasks?channel | 200, task included | [ ] | +| task.network_channel (stored) | "ch-stale-test" | "ch-stale-test" (preserved) | [ ] | +| Write after restoration | PATCH title | 200, title updated | [ ] | + +--- + +### Error Scenarios +- [ ] Multiple consecutive write attempts all return 409 (no partial state corruption) +- [ ] Stale channel does not cause 500 (the error is a clean 409, not an internal server error) +- [ ] Other tasks without channel binding remain fully writable during channel invalidity +- [ ] Tasks on different valid channels remain fully writable + +--- + +### Related Test Cases +- TC-INT-014: Network peer creates task with channel binding (prerequisite setup) +- TC-INT-004: PATCH behavior for mutable fields (same endpoint, different error path) +- TC-INT-005: UDS parity for stale-channel rejection +- TC-INT-002: List filtering behavior when channel tasks are present diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-001.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-001.md new file mode 100644 index 000000000..4e1720fab --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-001.md @@ -0,0 +1,61 @@ +## TC-PERF-001: Sequential Task Creation Throughput (1000 Tasks) + +**Priority:** P1 +**Type:** Performance +**Status:** Not Run +**Created:** 2026-04-14 + +--- + +### Objective +Validate that the task system can create 1000 tasks sequentially within acceptable latency bounds. This measures the core write path through validation, identity derivation, store persistence, audit event emission, and dependency status reconciliation. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] Clean task store (no pre-existing tasks to avoid index contention noise) +- [ ] Authenticated human principal via HTTP or UDS ingress +- [ ] System under normal load (no concurrent heavy operations) + +--- + +### Performance Criteria +| Metric | Target | Acceptable | Actual | Status | +|--------|--------|------------|--------|--------| +| Total wall-clock time for 1000 creates | <500ms | <1000ms | | [ ] | +| Average latency per task creation | <0.5ms | <1ms | | [ ] | +| P99 latency per task creation | <2ms | <5ms | | [ ] | +| Memory delta during creation burst | <50MB | <100MB | | [ ] | +| Zero validation or persistence errors | 0 errors | 0 errors | | [ ] | + +--- + +### Test Steps +1. **Seed authenticated actor context** + - Derive a human actor context with `FullAccessAuthority()` for CLI origin + - **Expected:** Actor context valid, no error + +2. **Create 1000 global-scoped tasks sequentially** + - Input: Loop 1000 iterations, each calling `CreateTask` with unique title `"perf-task-NNN"` and scope `"global"` + - Record wall-clock start time before loop, end time after loop + - **Expected:** All 1000 tasks created successfully. Total duration < 500ms. + +3. **Verify all tasks persisted** + - Input: `ListTasks` with no filters, limit 1001 + - **Expected:** Exactly 1000 task summaries returned. All have status `"pending"` or `"ready"`. + +4. **Measure individual operation latency** + - Input: Record per-iteration latency during step 2 + - **Expected:** P50 < 0.3ms, P99 < 2ms. No outlier > 10ms. + +5. **Repeat with workspace-scoped tasks** + - Input: Create 1000 workspace-scoped tasks with workspace ID `"perf-ws"` + - **Expected:** Similar throughput. Workspace binding does not introduce significant overhead. + +--- + +### Related Test Cases +- TC-PERF-003: Hierarchy depth and child count limits +- TC-PERF-005: ListTasks filter performance on large dataset +- SMOKE-002: Basic task creation diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-002.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-002.md new file mode 100644 index 000000000..fde8ef4f9 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-002.md @@ -0,0 +1,63 @@ +## TC-PERF-002: Dependency Cycle Detection at MaxDependencyCount (32 Edges) + +**Priority:** P1 +**Type:** Performance +**Status:** Not Run +**Created:** 2026-04-14 + +--- + +### Objective +Validate that cycle detection remains performant when a task reaches the maximum dependency count (`MaxDependencyCount = 32`). Each dependency edge addition must complete cycle detection within 50ms even at the limit. The system must correctly detect and reject cycles without performance degradation. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] Authenticated principal with full write access +- [ ] 33+ tasks created (1 target task + 32 dependency targets) +- [ ] Clean dependency graph (no pre-existing edges) + +--- + +### Performance Criteria +| Metric | Target | Acceptable | Actual | Status | +|--------|--------|------------|--------|--------| +| Cycle detection per edge at 32 deps | <50ms | <100ms | | [ ] | +| Total time to fill 32 dependency slots | <1600ms | <3200ms | | [ ] | +| Cycle detection on rejection (cycle found) | <50ms | <100ms | | [ ] | +| Memory usage for dependency graph traversal | <10MB | <25MB | | [ ] | + +--- + +### Test Steps +1. **Create 33 tasks for dependency graph** + - Input: Create tasks `dep-target-01` through `dep-target-32` and one `dep-source` task + - **Expected:** All 33 tasks created successfully. + +2. **Add dependencies up to MaxDependencyCount** + - Input: For each of the 32 target tasks, call `AddDependency` from `dep-source` to `dep-target-NN` with `kind: "blocks"` + - Record per-edge latency + - **Expected:** All 32 edges added successfully. Each edge addition completes in < 50ms. No `ErrCycleDetected` or `ErrGraphLimitExceeded`. + +3. **Attempt to exceed MaxDependencyCount** + - Input: Add a 33rd dependency edge + - **Expected:** `ErrGraphLimitExceeded` returned. Edge not persisted. Error message mentions dependency count limit. + +4. **Attempt to create a cycle at the limit** + - Input: Add a dependency from `dep-target-01` back to `dep-source` (creating a cycle) + - **Expected:** `ErrCycleDetected` returned within 50ms. Cycle detection does not degrade with 32 existing edges. + +5. **Verify status reconciliation** + - Input: Complete all 32 dependency tasks, then check `dep-source` status + - **Expected:** `dep-source` transitions from `"blocked"` to `"ready"` as dependencies resolve. + +6. **Measure cycle detection in chain topology** + - Input: Create a chain of 32 tasks (A->B->C->...->AF). Attempt to add AF->A (closing the cycle). + - **Expected:** `ErrCycleDetected` returned within 50ms despite the graph traversal depth of 32. + +--- + +### Related Test Cases +- TC-PERF-003: Hierarchy depth performance +- TC-SEC-006: SQL injection in dependency operations diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-003.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-003.md new file mode 100644 index 000000000..f032332e9 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-003.md @@ -0,0 +1,71 @@ +## TC-PERF-003: Maximum Hierarchy Depth (8 Levels) with 64 Children Per Level + +**Priority:** P1 +**Type:** Performance +**Status:** Not Run +**Created:** 2026-04-14 + +--- + +### Objective +Validate that creating the maximum allowed task hierarchy (8 levels deep, `MaxHierarchyDepth = 8`) with the maximum children per parent (`MaxDirectChildren = 64`) completes within acceptable time bounds and does not exhibit exponential slowdown. Total potential tree: up to 64^8 nodes at full fan-out, but the test focuses on creating one full path and one level of max children. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] Authenticated principal with full write access +- [ ] Clean task store +- [ ] Sufficient disk space for SQLite (estimated ~5MB for this test) + +--- + +### Performance Criteria +| Metric | Target | Acceptable | Actual | Status | +|--------|--------|------------|--------|--------| +| Create 8-level deep chain (1 child/level) | <100ms total | <200ms | | [ ] | +| Create 64 children at level 1 | <500ms total | <1000ms | | [ ] | +| Create 64 children at level 8 (deepest) | <500ms total | <1000ms | | [ ] | +| Depth validation per child creation | <5ms | <10ms | | [ ] | +| No exponential slowdown across levels | Level N+1 <= 1.5x Level N | Level N+1 <= 2x Level N | | [ ] | + +--- + +### Test Steps +1. **Create 8-level deep chain** + - Input: Create root task, then sequentially create one child at each level via `CreateChildTask` (root -> L1 -> L2 -> ... -> L8) + - Record per-level creation latency + - **Expected:** All 8 levels created successfully. Total time < 100ms. No `ErrGraphLimitExceeded`. + +2. **Attempt to create 9th level (exceeds MaxHierarchyDepth)** + - Input: Create a child of the level-8 task + - **Expected:** `ErrGraphLimitExceeded` returned. Error message mentions hierarchy depth. Child not persisted. + +3. **Create 64 children at root level** + - Input: Create 64 children of the root task via `CreateChildTask` + - Record per-child creation latency + - **Expected:** All 64 children created. Total time < 500ms. No exponential slowdown. + +4. **Attempt to create 65th child (exceeds MaxDirectChildren)** + - Input: Create a 65th child of the root task + - **Expected:** `ErrGraphLimitExceeded` returned. Child not persisted. + +5. **Create 64 children at the deepest valid level (level 7, so children are at level 8)** + - Input: Create 64 children at level 7 (children become level 8) + - Record total creation time + - **Expected:** Total time < 500ms. Depth validation at level 8 does not add significant overhead compared to level 1. + +6. **Compare creation latency across levels** + - Input: Analyze per-level latency from steps 1, 3, and 5 + - **Expected:** No level shows > 1.5x the latency of the previous level. Linear scaling, not exponential. + +7. **Verify parent-child relationships via GetTask detail** + - Input: `GetTask` for the root task + - **Expected:** `children` array contains all direct children. Response time < 100ms even with 64 children. + +--- + +### Related Test Cases +- TC-PERF-001: Sequential task creation throughput +- TC-PERF-004: Cancellation propagation on large tree +- SMOKE-004: Task detail retrieval with children diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-004.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-004.md new file mode 100644 index 000000000..da4e64197 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-004.md @@ -0,0 +1,66 @@ +## TC-PERF-004: Cancellation Propagation on Tree with 100 Descendants + +**Priority:** P1 +**Type:** Performance +**Status:** Not Run +**Created:** 2026-04-14 + +--- + +### Objective +Validate that cancelling a parent task propagates cancellation to all descendant tasks and their active runs within 2 seconds. The cancellation must be transitive (parent -> children -> grandchildren) and must handle active run lifecycle transitions (cooperative stop then force stop after grace period). + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem and session executor initialized +- [ ] Authenticated principal with full write access +- [ ] Tree structure created: root task with 100 descendants across multiple levels + - Suggested: 10 children, each with 10 grandchildren (2 levels, 110 total including root) +- [ ] Some descendant tasks have active runs in `queued`, `claimed`, `starting`, or `running` states +- [ ] Mock session executor configured to respond to stop requests + +--- + +### Performance Criteria +| Metric | Target | Acceptable | Actual | Status | +|--------|--------|------------|--------|--------| +| Total cancellation propagation time | <2s | <5s | | [ ] | +| All 100 descendants reach `cancelled` status | 100% | 100% | | [ ] | +| All active runs reach `cancelled` status | 100% | 100% | | [ ] | +| Cancellation audit events emitted for all nodes | 100 events | 100 events | | [ ] | +| No orphaned runs left in non-terminal state | 0 orphans | 0 orphans | | [ ] | + +--- + +### Test Steps +1. **Build the task tree** + - Input: Create root task, 10 children, and 10 grandchildren per child (110 total tasks) + - **Expected:** All tasks created successfully. + +2. **Enqueue and advance runs on select descendants** + - Input: Enqueue runs on 20 descendant tasks. Claim 10, start 5, leave 10 queued. + - **Expected:** Runs in expected states: 10 queued, 5 claimed, 5 running. + +3. **Cancel the root task** + - Input: `CancelTask(ctx, rootID, CancelTask{Reason: "perf-test"}, actor)`. Start timer. + - **Expected:** Root task transitions to `"cancelled"`. Cancellation propagation begins. + +4. **Verify all descendants cancelled within 2 seconds** + - Input: Poll or wait up to 2s, then `ListTasks` with `parent_task_id` filters down the tree + - **Expected:** All 100 descendant tasks have status `"cancelled"`. All 20 active runs have status `"cancelled"`. Timer shows < 2s total. + +5. **Verify audit trail completeness** + - Input: Query task events for `"task.cancelled"` event type + - **Expected:** At least 101 cancellation events (root + 100 descendants). Each event includes the cancellation reason. + +6. **Verify session stop requests issued** + - Input: Check mock session executor for stop requests + - **Expected:** Stop requests issued for all 5 running sessions. Grace period respected before force-stop. + +--- + +### Related Test Cases +- TC-PERF-003: Hierarchy depth and child count limits +- SMOKE-008: Basic task cancellation +- TC-SEC-004: Permission check during cancellation diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-005.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-005.md new file mode 100644 index 000000000..7118d6aef --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-005.md @@ -0,0 +1,70 @@ +## TC-PERF-005: ListTasks with Composite Filter on 10K Tasks + +**Priority:** P2 +**Type:** Performance +**Status:** Not Run +**Created:** 2026-04-14 + +--- + +### Objective +Validate that `ListTasks` with scope + status + owner composite filters returns results within 200ms when querying against a store containing 10,000 tasks. This measures SQLite query plan efficiency, index utilization, and the Go-side filtering and serialization overhead. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] 10,000 tasks seeded in the store with varied attributes: + - 5,000 global-scoped, 5,000 workspace-scoped (across 10 workspaces) + - Even distribution across all 7 task statuses + - 5 distinct owner kinds with varied refs + - 3 distinct network channels +- [ ] SQLite WAL mode enabled (default AGH configuration) +- [ ] System under normal load + +--- + +### Performance Criteria +| Metric | Target | Acceptable | Actual | Status | +|--------|--------|------------|--------|--------| +| ListTasks(scope=global, status=ready) | <200ms | <500ms | | [ ] | +| ListTasks(scope=workspace, workspace=ws-01, status=in_progress, owner_kind=human) | <200ms | <500ms | | [ ] | +| ListTasks(network_channel=builders, status=pending) | <200ms | <500ms | | [ ] | +| ListTasks with limit=50 on 10K dataset | <100ms | <200ms | | [ ] | +| ListTasks with no filters (full scan, limit=100) | <200ms | <500ms | | [ ] | +| Result serialization overhead | <50ms | <100ms | | [ ] | + +--- + +### Test Steps +1. **Seed 10,000 tasks with varied attributes** + - Input: Programmatically create tasks with distributed scope, status, owner, and channel values + - **Expected:** All 10K tasks persisted. Seeding completes within 30s (bulk insert acceptable). + +2. **Query with scope + status filter** + - Input: `ListTasks(ctx, TaskQuery{Scope: "global", Status: "ready"}, actor)` + - Record response time + - **Expected:** Results returned in < 200ms. Only global + ready tasks in response. + +3. **Query with scope + workspace + status + owner composite filter** + - Input: `ListTasks(ctx, TaskQuery{Scope: "workspace", WorkspaceID: "ws-01", Status: "in_progress", OwnerKind: "human", OwnerRef: "user-1"}, actor)` + - **Expected:** Results returned in < 200ms. Only matching tasks in response. + +4. **Query with network channel filter** + - Input: `ListTasks(ctx, TaskQuery{NetworkChannel: "builders", Status: "pending"}, actor)` + - **Expected:** Results returned in < 200ms. Only channel-matched pending tasks. + +5. **Query with limit parameter** + - Input: `ListTasks(ctx, TaskQuery{Limit: 50}, actor)` -- broad query, limited results + - **Expected:** Exactly 50 results returned in < 100ms. + +6. **Concurrent query load** + - Input: 10 concurrent `ListTasks` queries with different filters + - **Expected:** All 10 complete within < 500ms. SQLite WAL handles concurrent reads. + +--- + +### Related Test Cases +- TC-PERF-001: Sequential task creation throughput +- TC-PERF-006: Observe projection query performance +- SMOKE-003: Basic task listing diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-006.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-006.md new file mode 100644 index 000000000..973ebd3fa --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-PERF-006.md @@ -0,0 +1,72 @@ +## TC-PERF-006: Observe Projection Queries on 10K Tasks + 50K Runs + +**Priority:** P2 +**Type:** Performance +**Status:** Not Run +**Created:** 2026-04-14 + +--- + +### Objective +Validate that the observe layer's task projection queries (queue depth, stuck work detection, task metrics) return results within 500ms when operating on a store containing 10,000 tasks and 50,000 runs. This measures the efficiency of the `Observer.QueryTaskSummary` and `Observer.QueryTaskMetrics` aggregation paths. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem and observe layer initialized +- [ ] 10,000 tasks seeded across global and workspace scopes +- [ ] 50,000 task runs seeded with varied statuses: + - 10,000 queued, 10,000 claimed, 5,000 starting, 10,000 running, 10,000 completed, 3,000 failed, 2,000 cancelled +- [ ] Runs distributed across 5 network channels and unbound runs +- [ ] Task events seeded for audit trail queries (estimated 100K+ events) +- [ ] SQLite WAL mode enabled + +--- + +### Performance Criteria +| Metric | Target | Acceptable | Actual | Status | +|--------|--------|------------|--------|--------| +| QueryTaskSummary (queue depth) | <500ms | <1000ms | | [ ] | +| QueryTaskMetrics (unfiltered) | <500ms | <1000ms | | [ ] | +| QueryTaskMetrics (filtered by origin_kind) | <500ms | <1000ms | | [ ] | +| QueryTaskMetrics (filtered by network_channel) | <300ms | <500ms | | [ ] | +| Queue depth by channel breakdown | <200ms | <500ms | | [ ] | +| Stuck work detection (runs > threshold age) | <500ms | <1000ms | | [ ] | + +--- + +### Test Steps +1. **Seed 10K tasks and 50K runs** + - Input: Programmatically create tasks with 5 runs per task on average, distributed across statuses and channels + - **Expected:** All data persisted. Seeding completes within 2 minutes. + +2. **Query task summary projection** + - Input: `Observer.QueryTaskSummary(ctx)` -- full snapshot + - Record response time + - **Expected:** Response in < 500ms. `QueueDepth` array populated with per-channel counts. `QueueDepthTotal` matches sum of queued runs. + +3. **Query task metrics (unfiltered)** + - Input: `Observer.QueryTaskMetrics(ctx, TaskMetricsQuery{})` + - **Expected:** Response in < 500ms. `TaskQueueDepth` shows per-channel breakdown. Counters match expected distributions. + +4. **Query task metrics filtered by origin kind** + - Input: `Observer.QueryTaskMetrics(ctx, TaskMetricsQuery{OriginKind: "cli"})` + - **Expected:** Response in < 500ms. Only CLI-originated metrics included. + +5. **Query task metrics filtered by network channel** + - Input: `Observer.QueryTaskMetrics(ctx, TaskMetricsQuery{NetworkChannel: "builders"})` + - **Expected:** Response in < 300ms. Channel filter narrows the dataset significantly. + +6. **Stuck work detection query** + - Input: Seed 100 runs with `claimed_at` > 10 minutes ago but still in `claimed` status (stuck). Query for stuck work. + - **Expected:** Detection completes in < 500ms. All 100 stuck runs identified. + +7. **Concurrent projection queries** + - Input: 5 concurrent `QueryTaskMetrics` with different filters + - **Expected:** All complete within < 1000ms. SQLite WAL handles concurrent reads without blocking. + +--- + +### Related Test Cases +- TC-PERF-005: ListTasks filter performance +- SMOKE-010: Observe projections return task metrics diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-001.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-001.md new file mode 100644 index 000000000..9f9773804 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-001.md @@ -0,0 +1,61 @@ +## TC-SEC-001: Server-Derived created_by Identity Ignores Client Payload + +**Priority:** P0 +**Type:** Security +**Risk Level:** Critical +**Status:** Not Run +**Created:** 2026-04-14 + +--- + +### Objective +Validate that the task system enforces server-derived identity for the `created_by` field. When a client submits `created_by_kind` and `created_by_ref` in the create-task payload, the server MUST ignore those values and derive the actor identity from the authenticated principal context instead. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] Authenticated human principal available via CLI or HTTP ingress +- [ ] Access to task creation endpoint (`POST /api/tasks`) + +--- + +### Test Steps +1. **Submit create-task request with spoofed created_by fields** + - Input: `POST /api/tasks` with JSON body: + ```json + { + "scope": "global", + "title": "Spoofed identity task", + "created_by_kind": "daemon", + "created_by_ref": "injected-daemon-ref" + } + ``` + - **Expected:** 201 Created. Response `created_by.kind` equals the authenticated principal kind (e.g., `"human"`), NOT `"daemon"`. Response `created_by.ref` equals the authenticated user ref, NOT `"injected-daemon-ref"`. + +2. **Verify persisted task via GET** + - Input: `GET /api/tasks/:id` using the ID from step 1 + - **Expected:** `task.created_by.kind` and `task.created_by.ref` match server-derived values. No trace of the spoofed values in the response or audit events. + +3. **Attempt spoofing via all 6 actor kinds** + - Input: Repeat step 1 with `created_by_kind` set to each of: `human`, `agent_session`, `automation`, `extension`, `network_peer`, `daemon` + - **Expected:** All responses ignore the submitted `created_by_kind` and derive identity from the actual authenticated principal. + +4. **Verify audit event records server-derived identity** + - Input: `GET /api/tasks/:id` and inspect the `events` array for the `task.created` event + - **Expected:** The `actor` field on the audit event matches the server-derived principal, not the spoofed payload values. + +--- + +### Attack Vectors +- [ ] Privilege escalation by spoofing `created_by_kind: "daemon"` to gain system-level attribution +- [ ] Identity impersonation by setting `created_by_ref` to another user's reference +- [ ] Replay of a legitimate principal's identity fields from a captured response +- [ ] Injection of `created_by_kind` and `created_by_ref` as top-level JSON fields +- [ ] Injection of nested `created_by: {"kind": "daemon", "ref": "..."}` object in payload + +--- + +### Related Test Cases +- TC-SEC-002: Server-derived origin identity ignores client payload +- TC-SEC-003: Unauthenticated request rejection diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-002.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-002.md new file mode 100644 index 000000000..3e8329aa0 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-002.md @@ -0,0 +1,60 @@ +## TC-SEC-002: Server-Derived Origin Identity Ignores Client Payload + +**Priority:** P0 +**Type:** Security +**Risk Level:** Critical +**Status:** Not Run +**Created:** 2026-04-14 + +--- + +### Objective +Validate that the task system enforces server-derived identity for the `origin` field. When a client submits `origin_kind` and `origin_ref` in the create-task payload, the server MUST ignore those values and derive the origin from the authenticated ingress context instead. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] Authenticated human principal available via HTTP ingress +- [ ] Access to task creation endpoint (`POST /api/tasks`) + +--- + +### Test Steps +1. **Submit create-task request with spoofed origin fields** + - Input: `POST /api/tasks` with JSON body: + ```json + { + "scope": "global", + "title": "Spoofed origin task", + "origin_kind": "network", + "origin_ref": "injected-peer-channel" + } + ``` + - **Expected:** 201 Created. Response `origin.kind` equals the actual ingress origin (e.g., `"http"` for HTTP API, `"cli"` for CLI, `"uds"` for UDS). Response `origin.ref` equals the server-determined reference, NOT `"injected-peer-channel"`. + +2. **Verify persisted task origin via GET** + - Input: `GET /api/tasks/:id` using the ID from step 1 + - **Expected:** `task.origin.kind` and `task.origin.ref` match the real ingress surface, not the spoofed values. + +3. **Attempt spoofing via all 9 origin kinds** + - Input: Repeat step 1 with `origin_kind` set to each of: `cli`, `web`, `uds`, `http`, `automation`, `extension`, `network`, `agent_session`, `daemon` + - **Expected:** All responses ignore the submitted `origin_kind` and derive the origin from the actual transport layer. + +4. **Cross-verify with audit trail** + - Input: Inspect the `task.created` event in the task detail response + - **Expected:** The `origin` field on the audit event matches the server-derived ingress origin, confirming no spoofing occurred at any layer. + +--- + +### Attack Vectors +- [ ] Origin spoofing to masquerade HTTP requests as CLI or daemon-internal writes +- [ ] Network origin injection to bypass channel validation on HTTP-originated requests +- [ ] Injection of `origin_kind: "daemon"` to make writes appear as trusted system operations +- [ ] Simultaneous spoofing of both `created_by` and `origin` fields to fabricate a fully forged identity + +--- + +### Related Test Cases +- TC-SEC-001: Server-derived created_by identity ignores client payload +- TC-SEC-005: Network peer channel mismatch validation diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-003.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-003.md new file mode 100644 index 000000000..ae5e4120a --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-003.md @@ -0,0 +1,81 @@ +## TC-SEC-003: Unauthenticated Request Rejection on All Task Endpoints + +**Priority:** P0 +**Type:** Security +**Risk Level:** Critical +**Status:** Not Run +**Created:** 2026-04-14 + +--- + +### Objective +Validate that every task-domain endpoint rejects unauthenticated requests with a 403 (or 401) response and returns no task data. The task system requires an authenticated principal for all operations; anonymous access must be impossible. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] HTTP client configured to send requests WITHOUT authentication credentials +- [ ] At least one task exists in the store (for read endpoint testing) + +--- + +### Test Steps +1. **Unauthenticated POST /api/tasks (create)** + - Input: `POST /api/tasks` with valid JSON body `{"scope":"global","title":"anon task"}`, no auth headers + - **Expected:** 403 Forbidden (or 401 Unauthorized). Response body contains no task data. + +2. **Unauthenticated GET /api/tasks (list)** + - Input: `GET /api/tasks`, no auth headers + - **Expected:** 403 Forbidden. No task summaries returned. + +3. **Unauthenticated GET /api/tasks/:id (detail)** + - Input: `GET /api/tasks/`, no auth headers + - **Expected:** 403 Forbidden. No task detail returned. Must NOT return 404 (which would leak existence). + +4. **Unauthenticated PATCH /api/tasks/:id (update)** + - Input: `PATCH /api/tasks/` with `{"title":"hacked"}`, no auth headers + - **Expected:** 403 Forbidden. Task title unchanged when verified by authenticated read. + +5. **Unauthenticated POST /api/tasks/:id/cancel** + - Input: `POST /api/tasks//cancel`, no auth headers + - **Expected:** 403 Forbidden. Task status unchanged. + +6. **Unauthenticated POST /api/tasks/:id/runs (enqueue)** + - Input: `POST /api/tasks//runs`, no auth headers + - **Expected:** 403 Forbidden. No run created. + +7. **Unauthenticated POST /api/task-runs/:id/claim** + - Input: `POST /api/task-runs//claim`, no auth headers + - **Expected:** 403 Forbidden. Run status unchanged. + +8. **Unauthenticated POST /api/task-runs/:id/complete** + - Input: `POST /api/task-runs//complete`, no auth headers + - **Expected:** 403 Forbidden. Run status unchanged. + +9. **Unauthenticated POST /api/tasks/:id/children (create child)** + - Input: `POST /api/tasks//children` with valid body, no auth headers + - **Expected:** 403 Forbidden. + +10. **Unauthenticated POST /api/tasks/:id/dependencies (add dependency)** + - Input: `POST /api/tasks//dependencies` with valid body, no auth headers + - **Expected:** 403 Forbidden. + +11. **Unauthenticated DELETE /api/tasks/:id/dependencies/:depends_on_id** + - Input: `DELETE /api/tasks//dependencies/`, no auth headers + - **Expected:** 403 Forbidden. + +--- + +### Attack Vectors +- [ ] Direct HTTP request without any authentication headers +- [ ] Empty or malformed authentication token +- [ ] Expired authentication token +- [ ] Authentication header with invalid scheme +- [ ] Probing task existence via differential 403 vs 404 responses (information leakage) + +--- + +### Related Test Cases +- TC-SEC-004: Extension without task.write capability +- TC-SEC-008: Unauthorized scope read rejection diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-004.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-004.md new file mode 100644 index 000000000..90afd6af5 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-004.md @@ -0,0 +1,63 @@ +## TC-SEC-004: Extension Without task.write Capability Denied Task Creation + +**Priority:** P0 +**Type:** Security +**Risk Level:** High +**Status:** Not Run +**Created:** 2026-04-14 + +--- + +### Objective +Validate that an authenticated extension principal that lacks the `task.write` capability is denied task creation (and all other write operations). The task system's `requireWriteAuthority` check must enforce that `Authority.Write == true` before allowing any mutation. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem and extension host API initialized +- [ ] Extension runtime registered with read-only authority (`Authority{Read: true, Write: false}`) +- [ ] Extension actor context derived via `DeriveExtensionActorContext` + +--- + +### Test Steps +1. **Extension attempts task creation without write capability** + - Input: Extension calls `CreateTask` with actor context `Authority{Read: true, Write: false, CreateGlobal: false, CreateWorkspace: false}` + - **Expected:** `ErrPermissionDenied` returned. No task persisted in the store. + +2. **Extension attempts task update without write capability** + - Input: Extension calls `UpdateTask` on an existing task with read-only authority + - **Expected:** `ErrPermissionDenied` returned. Task unchanged. + +3. **Extension attempts task cancellation without write capability** + - Input: Extension calls `CancelTask` with read-only authority + - **Expected:** `ErrPermissionDenied` returned. Task status unchanged. + +4. **Extension attempts run enqueue without write capability** + - Input: Extension calls `EnqueueRun` with read-only authority + - **Expected:** `ErrPermissionDenied` returned. No run created. + +5. **Extension with write but without CreateGlobal attempts global task creation** + - Input: Extension calls `CreateTask` with `Authority{Read: true, Write: true, CreateGlobal: false, CreateWorkspace: true}` and `scope: "global"` + - **Expected:** `ErrPermissionDenied` returned. Global scope creation blocked by `requireCreateAuthority`. + +6. **Extension with write but without CreateWorkspace attempts workspace task creation** + - Input: Extension calls `CreateTask` with `Authority{Read: true, Write: true, CreateGlobal: true, CreateWorkspace: false}` and `scope: "workspace"` + - **Expected:** `ErrPermissionDenied` returned. Workspace scope creation blocked. + +7. **Extension with full write capability succeeds (control)** + - Input: Extension calls `CreateTask` with `FullAccessAuthority()` + - **Expected:** Task created successfully. This confirms the capability gate is the only barrier. + +--- + +### Attack Vectors +- [ ] Extension attempts to bypass capability check by crafting an actor context with elevated authority +- [ ] Extension host API maps `ErrPermissionDenied` to appropriate error code (not leaking internals) +- [ ] Extension attempts write via run lifecycle endpoints (claim, start, complete, fail) without write authority + +--- + +### Related Test Cases +- TC-SEC-003: Unauthenticated request rejection +- TC-SEC-008: Unauthorized scope read for extensions diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-005.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-005.md new file mode 100644 index 000000000..2831479b7 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-005.md @@ -0,0 +1,58 @@ +## TC-SEC-005: Network Peer Write Rejected on Channel Mismatch + +**Priority:** P0 +**Type:** Security +**Risk Level:** High +**Status:** Not Run +**Created:** 2026-04-14 + +--- + +### Objective +Validate that a network peer is denied task operations when the requested or bound network channel does not match the peer's authenticated ingress channel. The `ErrTaskChannelMismatch` sentinel must be returned and no data mutation occurs. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem and network layer initialized +- [ ] Network peer authenticated on ingress channel `"builders"` +- [ ] Task exists with `network_channel: "builders"` (for channel-bound operations) +- [ ] Task exists with `network_channel: "ops"` (for mismatch testing) +- [ ] Network channel validator configured on the TaskManager + +--- + +### Test Steps +1. **Network peer enqueues run on matching channel (control)** + - Input: Peer on channel `"builders"` calls `EnqueueRunFromPeer` for a task bound to channel `"builders"` + - **Expected:** Run enqueued successfully. No error. + +2. **Network peer enqueues run with mismatched requested channel** + - Input: Peer on ingress channel `"builders"` calls `EnqueueRunFromPeer` requesting channel `"ops"` + - **Expected:** `ErrTaskChannelMismatch` returned. Error message includes both channel names. No run persisted. + +3. **Network peer enqueues run for task bound to different channel** + - Input: Peer on ingress channel `"builders"` targets a task with `network_channel: "ops"` + - **Expected:** `ErrTaskChannelMismatch` returned. Error message identifies the task ID and both channel values. + +4. **Network peer creates task on mismatched channel** + - Input: Peer on ingress channel `"builders"` attempts to create a task with `network_channel: "ops"` via network task bridge + - **Expected:** Channel validation fails. Task not created. + +5. **Verify HTTP error mapping for channel mismatch** + - Input: Trigger channel mismatch via HTTP-facing network endpoint + - **Expected:** Response maps to appropriate HTTP status (403 or 409). Error body includes `"channel_mismatch"` classification. + +--- + +### Attack Vectors +- [ ] Network peer attempts cross-channel task manipulation to interfere with work on another channel +- [ ] Channel name casing mismatch bypass (e.g., `"Builders"` vs `"builders"`) -- validated via TrimSpace and normalization +- [ ] Channel name with leading/trailing whitespace to bypass exact match +- [ ] Empty channel string to bypass channel validation + +--- + +### Related Test Cases +- TC-SEC-002: Server-derived origin identity +- TC-SEC-008: Unauthorized scope read for network peers diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-006.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-006.md new file mode 100644 index 000000000..6c203c623 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-006.md @@ -0,0 +1,70 @@ +## TC-SEC-006: SQL Injection Resistance in Task Fields and Filter Parameters + +**Priority:** P0 +**Type:** Security +**Risk Level:** Critical +**Status:** Not Run +**Created:** 2026-04-14 + +--- + +### Objective +Validate that the task system is immune to SQL injection attacks through all user-controllable input surfaces: task title, description, metadata, filter query parameters, and identifier fields. SQLite parameterized queries must prevent any injection. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] Authenticated principal with full write access +- [ ] Access to both create and list task endpoints + +--- + +### Test Steps +1. **SQL injection in task title** + - Input: `POST /api/tasks` with title: `"'; DROP TABLE tasks; --"` + - **Expected:** 201 Created. Task persisted with the literal string as the title. Database tables intact. Subsequent `GET /api/tasks` returns the task with the injection string as-is. + +2. **SQL injection in task description** + - Input: `POST /api/tasks` with description: `"\" OR 1=1; UPDATE tasks SET status='cancelled' WHERE 1=1; --"` + - **Expected:** 201 Created. Description stored literally. No side-effect mutations. + +3. **SQL injection in task identifier** + - Input: `POST /api/tasks` with identifier: `"TASK-1' UNION SELECT * FROM sqlite_master--"` + - **Expected:** 201 Created (or validation error if identifier format is restricted). No schema leakage. + +4. **SQL injection in list filter: scope parameter** + - Input: `GET /api/tasks?scope=global' OR '1'='1` + - **Expected:** 400 Bad Request (invalid scope value) or empty results. No data from other scopes returned. + +5. **SQL injection in list filter: status parameter** + - Input: `GET /api/tasks?status=pending' UNION SELECT sql FROM sqlite_master--` + - **Expected:** 400 Bad Request (invalid status value). No schema information leaked. + +6. **SQL injection in list filter: owner_ref parameter** + - Input: `GET /api/tasks?owner_ref=user-1' OR '1'='1` + - **Expected:** Empty results or only tasks matching the literal string. No unfiltered data returned. + +7. **SQL injection in metadata JSON field** + - Input: `POST /api/tasks` with metadata: `{"key": "value'); DROP TABLE task_events; --"}` + - **Expected:** 201 Created. Metadata stored as valid JSON with the injection string as a literal value. + +8. **Verify database integrity after all injection attempts** + - Input: Confirm all core tables exist and row counts are correct + - **Expected:** Tables `tasks`, `task_runs`, `task_events`, `task_dependencies` all present with expected row counts. No data corruption. + +--- + +### Attack Vectors +- [ ] Classic SQL injection via single quotes in string fields +- [ ] UNION-based injection to extract schema metadata +- [ ] Stacked query injection (`;` followed by destructive SQL) +- [ ] Blind SQL injection via boolean-based filter parameter manipulation +- [ ] Time-based blind injection via SQLite `LIKE` or `GLOB` operators in filters +- [ ] Second-order injection where stored payload is later used in a query + +--- + +### Related Test Cases +- TC-SEC-007: Oversized payload rejection +- SMOKE-002: Basic task creation diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-007.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-007.md new file mode 100644 index 000000000..dfcd7085d --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-007.md @@ -0,0 +1,65 @@ +## TC-SEC-007: Oversized JSON Payload Rejected with 413 ErrPayloadTooLarge + +**Priority:** P0 +**Type:** Security +**Risk Level:** High +**Status:** Not Run +**Created:** 2026-04-14 + +--- + +### Objective +Validate that the task system enforces payload size limits and rejects oversized JSON payloads with `ErrPayloadTooLarge`. Event payloads are capped at 64KB (`MaxPayloadBytes = 65536`), task metadata at 16KB (`MaxMetadataBytes = 16384`), and run results at 64KB (`MaxResultBytes = 65536`). Oversized payloads must not be persisted. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] Authenticated principal with full write access +- [ ] Existing task and run available for event and result testing + +--- + +### Test Steps +1. **Task metadata exceeds 16KB limit** + - Input: `POST /api/tasks` with `metadata` field containing a JSON object > 16,384 bytes (e.g., `{"data": "<16KB+ string>"}`) + - **Expected:** 413 Request Entity Too Large. Error wraps `ErrPayloadTooLarge`. Task NOT persisted in the store. + +2. **Task metadata at exactly 16KB boundary** + - Input: `POST /api/tasks` with `metadata` JSON object at exactly 16,384 bytes + - **Expected:** 201 Created. Payload accepted at the boundary. + +3. **Task event payload exceeds 64KB limit** + - Input: Create a task event (e.g., via cancel with oversized metadata) with payload > 65,536 bytes + - **Expected:** `ErrPayloadTooLarge` returned. Event NOT persisted. + +4. **Run result exceeds 64KB limit** + - Input: `POST /api/task-runs/:id/complete` with `result` JSON > 65,536 bytes + - **Expected:** 413 Request Entity Too Large. Run status NOT updated to completed. Result NOT persisted. + +5. **Run failure metadata exceeds limit** + - Input: `POST /api/task-runs/:id/fail` with oversized `metadata` field + - **Expected:** `ErrPayloadTooLarge` returned. Run status unchanged. + +6. **Update task with oversized metadata** + - Input: `PATCH /api/tasks/:id` with `metadata` field > 16KB + - **Expected:** 413 Request Entity Too Large. Original metadata unchanged. + +7. **Verify HTTP error mapping** + - Input: Trigger any payload-too-large error via HTTP API + - **Expected:** HTTP status code is exactly 413. Response body includes error classification. No 500 Internal Server Error. + +--- + +### Attack Vectors +- [ ] Denial-of-service via repeated oversized payload submissions to exhaust server memory or disk +- [ ] Payload just over the limit (boundary testing at MaxPayloadBytes + 1) +- [ ] Deeply nested JSON within size limits but designed to consume parsing resources +- [ ] Large number of small fields that collectively exceed the byte limit +- [ ] Compressed payload that expands beyond limits after decompression (if applicable) + +--- + +### Related Test Cases +- TC-SEC-006: SQL injection resistance +- TC-PERF-001: Task creation throughput diff --git a/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-008.md b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-008.md new file mode 100644 index 000000000..b9f633187 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-cases/TC-SEC-008.md @@ -0,0 +1,65 @@ +## TC-SEC-008: Unauthorized Scope Read Rejected with ErrPermissionDenied + +**Priority:** P0 +**Type:** Security +**Risk Level:** High +**Status:** Not Run +**Created:** 2026-04-14 + +--- + +### Objective +Validate that the task system enforces read authority checks. Principals with `Authority.Read == false` must receive `ErrPermissionDenied` when attempting to read tasks via `GetTask` or `ListTasks`. Extensions and network peers must have appropriate capabilities to read task data. + +--- + +### Preconditions +- [ ] AGH daemon running with task subsystem initialized +- [ ] Extension actor context with `Authority{Read: false, Write: true}` (read-denied) +- [ ] Network peer actor context with read-denied authority +- [ ] At least one global task and one workspace-scoped task exist in the store + +--- + +### Test Steps +1. **Extension with no read authority attempts GetTask** + - Input: Extension calls `GetTask(ctx, taskID, actorCtx)` with `Authority{Read: false}` + - **Expected:** `ErrPermissionDenied` returned. No task data in the response. + +2. **Extension with no read authority attempts ListTasks** + - Input: Extension calls `ListTasks(ctx, TaskQuery{}, actorCtx)` with `Authority{Read: false}` + - **Expected:** `ErrPermissionDenied` returned. Empty response, no task summaries leaked. + +3. **Network peer with no read authority attempts task read** + - Input: Network peer calls `GetTask` with `Authority{Read: false}` + - **Expected:** `ErrPermissionDenied` returned. + +4. **Read-denied principal attempts to read task runs** + - Input: Call task run list with `Authority{Read: false}` + - **Expected:** `ErrPermissionDenied` returned. No run data exposed. + +5. **Extension with read authority succeeds (control)** + - Input: Extension calls `GetTask` with `Authority{Read: true}` + - **Expected:** Task data returned successfully. Confirms the read gate is the only barrier. + +6. **Verify no data leakage in error response** + - Input: Inspect the error response from steps 1-4 + - **Expected:** Error contains only `"task: permission denied"`. No task IDs, titles, or metadata leaked in the error message. + +7. **HTTP error mapping for permission denied** + - Input: Trigger `ErrPermissionDenied` via HTTP API + - **Expected:** HTTP status 403 Forbidden. Response body does not include any task data. + +--- + +### Attack Vectors +- [ ] Extension with write-only authority attempts to read task details (write access should not imply read access) +- [ ] Read-denied principal probes task existence via differential error responses (403 vs 404) +- [ ] Read-denied principal attempts to access task events or audit trail +- [ ] Capability escalation by re-deriving actor context with elevated read authority + +--- + +### Related Test Cases +- TC-SEC-003: Unauthenticated request rejection +- TC-SEC-004: Extension without task.write capability diff --git a/.compozy/tasks/core-tasks/qa/test-plans/core-tasks-regression.md b/.compozy/tasks/core-tasks/qa/test-plans/core-tasks-regression.md new file mode 100644 index 000000000..88cace5d5 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-plans/core-tasks-regression.md @@ -0,0 +1,399 @@ +# AGH Core Tasks -- Regression Test Suite + +**Feature:** Core Tasks and Subtasks +**Version:** v1 +**Date:** 2026-04-14 +**Status:** Active + +--- + +## Table of Contents + +1. [Purpose and Scope](#1-purpose-and-scope) +2. [Smoke Suite](#2-smoke-suite) +3. [Priority Classification](#3-priority-classification) +4. [Targeted Regression by Change Area](#4-targeted-regression-by-change-area) +5. [Full Regression Suite](#5-full-regression-suite) +6. [Sanity Suite](#6-sanity-suite) +7. [Pass/Fail Criteria](#7-passfail-criteria) +8. [Execution Checklist](#8-execution-checklist) + +--- + +## 1. Purpose and Scope + +This document defines the regression testing strategy for the AGH Core Tasks feature. It covers all test case ranges: + +- **TC-FUNC-001 to TC-FUNC-030:** Functional (task CRUD, dependencies, run lifecycle, cancellation, limits, cold-start) +- **TC-INT-001 to TC-INT-015:** Integration (API HTTP/UDS, CLI, session bridge, automation, extension, network) +- **TC-SEC-001 to TC-SEC-008:** Security (identity spoofing, authorization, injection, payload limits) +- **TC-PERF-001 to TC-PERF-006:** Performance (throughput, graph operations, propagation, queries) +- **SMOKE-001 to SMOKE-010:** Smoke (daemon boot, basic CRUD, run lifecycle, CLI, observe) + +The regression suite ensures that changes to any package within the Core Tasks scope do not introduce regressions in task coordination, execution, security, or observability. + +--- + +## 2. Smoke Suite + +**Duration:** 15-20 minutes +**Frequency:** Daily, and before any detailed regression testing +**Gate rule:** If ANY smoke test fails, STOP immediately. Do not proceed to targeted or full regression. + +### Smoke Test IDs + +| ID | Area | Description | +|----|------|-------------| +| SMOKE-001 | Daemon boot | Daemon starts successfully with task schema migrated and TaskManager initialized | +| SMOKE-002 | Task create | Create a global task via HTTP API; verify 201 response and persisted record | +| SMOKE-003 | Task list/get | List tasks returns the created task; get by ID returns full detail with correct fields | +| SMOKE-004 | Task update | Patch mutable fields (title, description, owner); verify updated values | +| SMOKE-005 | Child task | Create a child task under the global task; verify parent linkage | +| SMOKE-006 | Run enqueue | Enqueue a run for the task; verify queued status | +| SMOKE-007 | Run lifecycle | Progress a run through claim, start, complete; verify terminal state | +| SMOKE-008 | CLI basic | Execute `agh task create`, `agh task list`, `agh task get` via CLI; verify output | +| SMOKE-009 | Cancellation basic | Cancel a task with a queued run; verify both task and run reach cancelled state | +| SMOKE-010 | Observe health | Query task health/metrics endpoint; verify queue depth and task count projections appear | + +### Smoke Execution Notes + +- Execute in the order listed (SMOKE-001 through SMOKE-010). +- Each test should be self-contained and leave the system in a clean state for the next. +- If SMOKE-001 fails, the daemon cannot start -- no further testing is meaningful. +- If SMOKE-002 through SMOKE-005 fail, task CRUD is broken -- run lifecycle tests will also fail. +- SMOKE-010 depends on observe projections; failure here indicates a wiring or projection issue. + +--- + +## 3. Priority Classification + +### P0 -- Critical Path (must all pass for release) + +These tests cover the core task lifecycle, data integrity, and security boundaries. Any P0 failure is a release blocker. + +| TC-ID | Area | Rationale | +|-------|------|-----------| +| TC-FUNC-001 | Task create (global scope) | Foundational CRUD; all other tests depend on task creation | +| TC-FUNC-002 | Task create (workspace scope) | Scope enforcement is a core invariant | +| TC-FUNC-003 | Task get by ID | Read path correctness; underpins all inspection flows | +| TC-FUNC-004 | Task list with filters | Query correctness across scope, status, owner, workspace | +| TC-FUNC-005 | Task update mutable fields | Mutation rules are a contract boundary | +| TC-FUNC-006 | Immutable field rejection | Identity and structural integrity protection | +| TC-FUNC-007 | Child task creation | Hierarchy is a first-class coordination mechanism | +| TC-FUNC-008 | Dependency add | Dependency graph is required for blocked/ready reconciliation | +| TC-FUNC-009 | Dependency remove | Must not leave orphan edges or corrupt graph state | +| TC-FUNC-010 | Cycle detection | Transactional cycle rejection prevents infinite loops | +| TC-FUNC-011 | Graph depth limit | Guardrail enforcement; unbounded depth corrupts reconciliation | +| TC-FUNC-012 | Dependency edge limit | Guardrail enforcement; unbounded edges degrade performance | +| TC-FUNC-013 | Direct children limit | Guardrail enforcement; unbounded children degrade queries | +| TC-FUNC-014 | Run enqueue | Queue-first execution is the only valid entry point | +| TC-FUNC-015 | Run claim | Claim transition is the only path from queued to active | +| TC-FUNC-016 | Run start (dedicated session) | Dedicated-session default is a core architectural decision | +| TC-FUNC-017 | Run complete | Terminal success path; reconciles task to completed | +| TC-FUNC-018 | Run fail | Terminal failure path; reconciles task to failed | +| TC-FUNC-019 | Run cancel | Cooperative cancellation of individual runs | +| TC-FUNC-020 | Task cancel with propagation | Tree cancellation propagates to descendants and active runs | +| TC-FUNC-021 | Forced stop escalation | Cooperative-then-forced model prevents leaked sessions | +| TC-FUNC-022 | Task status reconciliation | Manager-owned reconciliation from deps and runs | +| TC-FUNC-023 | Cold-start recovery (claimed runs) | Orphaned claimed runs re-queued on boot | +| TC-FUNC-024 | Cold-start recovery (running runs) | Orphaned running runs failed on boot | +| TC-SEC-001 | Identity spoofing rejection | Payload-supplied identity must be ignored | +| TC-SEC-002 | Unauthenticated write rejection | No anonymous task writes | +| TC-SEC-003 | Authorization enforcement (HTTP) | HTTP routes enforce principal resolution | +| TC-SEC-004 | Extension capability check | Extensions without task capability are rejected | +| TC-SEC-005 | Network peer validation | Network writes require authenticated peer context | +| TC-SEC-006 | Injection resistance | Malformed inputs do not corrupt stored data | +| TC-SEC-007 | Payload size enforcement | Oversize metadata/result/event payloads rejected before persistence | +| TC-SEC-008 | Extension origin immutability | Extension-supplied origin fields cannot override server-derived values | + +### P1 -- Important (90%+ must pass) + +These tests cover integration surfaces, advanced lifecycle flows, and cross-package correctness. P1 failures require documented workarounds if not fixed before release. + +| TC-ID | Area | Rationale | +|-------|------|-----------| +| TC-FUNC-025 | Attach-session for resume/handoff | Explicit attach is a secondary but supported execution path | +| TC-FUNC-026 | Attach-session single-assignment | One session per live run; prevents resource contention | +| TC-FUNC-027 | Attach-session state gating | Attachment only valid in claimed/starting states | +| TC-FUNC-028 | Idempotency key deduplication | Multi-writer ingress safety for non-human callers | +| TC-FUNC-029 | Audit event persistence | Lifecycle actions produce immutable audit records | +| TC-FUNC-030 | Cold-start recovery (starting runs) | Starting runs with dead sessions failed on boot | +| TC-INT-001 | HTTP task CRUD | HTTP transport correctness for task operations | +| TC-INT-002 | HTTP run lifecycle | HTTP transport correctness for run operations | +| TC-INT-003 | HTTP response codes/envelopes | Transport consistency; stable error contract | +| TC-INT-004 | HTTP filter parity | Query filters work identically to UDS | +| TC-INT-005 | UDS task and run parity | UDS exposes the same operations as HTTP | +| TC-INT-006 | CLI task create/list/get | CLI-to-daemon round-trip for task CRUD | +| TC-INT-007 | CLI run lifecycle | CLI-to-daemon round-trip for run operations | +| TC-INT-008 | CLI flag validation | Invalid flag combinations rejected before daemon call | +| TC-INT-009 | Session bridge create | Run start creates dedicated session through injected bridge | +| TC-INT-010 | Session bridge stop | Cancellation stop request flows through bridge to session | +| TC-INT-011 | Automation direct task create | Automation creates tasks with correct origin | +| TC-INT-012 | Automation non-overlap | Task-backed automation does not duplicate execution state | +| TC-INT-013 | Extension host API task flow | Extension creates and runs tasks through capability-checked host API | +| TC-INT-014 | Network peer task create | Network peer creates channel-bound task | +| TC-INT-015 | Network channel mismatch rejection | Channel-bound task rejects mismatched ingress | + +### P2 -- Supplementary (informational, not release-blocking) + +These tests measure system behavior under load and at scale. Failures are tracked but do not block release. + +| TC-ID | Area | Rationale | +|-------|------|-----------| +| TC-PERF-001 | Task creation throughput | Baseline throughput measurement | +| TC-PERF-002 | Graph operation performance | Cycle detection and depth checks at scale | +| TC-PERF-003 | Cancellation propagation latency | Tree cancellation timing under deep hierarchies | +| TC-PERF-004 | List query performance | Filter queries under high task count | +| TC-PERF-005 | Observe projection throughput | Metric and health projection under event load | +| TC-PERF-006 | Queue depth query performance | Queue depth calculation under high run count | + +--- + +## 4. Targeted Regression by Change Area + +When a change is scoped to a specific package, run the smoke suite first, then only the test cases mapped to the changed package(s). If a change spans multiple packages, union the test sets. + +| Changed Package | Must Re-Run | Rationale | +|-----------------|-------------|-----------| +| `internal/task/` | TC-FUNC-001 to TC-FUNC-030, TC-SEC-001 to TC-SEC-008 | Core domain; all functional and security tests depend on task types, validation, lifecycle, and manager logic | +| `internal/store/globaldb/` | TC-FUNC-001 to TC-FUNC-013, TC-PERF-001 to TC-PERF-006 | Persistence layer; CRUD, graph limits, query filters, and performance all depend on store correctness | +| `internal/api/httpapi/` | TC-INT-001 to TC-INT-005, TC-SEC-003 to TC-SEC-007 | HTTP transport; route registration, response codes, payload validation, and authz enforcement | +| `internal/api/udsapi/` | TC-INT-005 | UDS transport; must maintain parity with HTTP | +| `internal/api/core/` | TC-INT-001 to TC-INT-005, TC-SEC-003, TC-SEC-006, TC-SEC-007 | Shared handlers; validation, error mapping, and payload conversion used by both transports | +| `internal/cli/` | TC-INT-006 to TC-INT-008 | CLI commands; flag parsing, UDS communication, output formatting | +| `internal/session/` | TC-INT-009 to TC-INT-010, TC-FUNC-016 to TC-FUNC-020 | Session bridge; dedicated-session creation, attach, stop, and cancellation flows | +| `internal/automation/` | TC-INT-011 to TC-INT-012 | Automation integration; direct task creation, non-overlap with automation runs | +| `internal/extension/` | TC-INT-013, TC-SEC-004, TC-SEC-008 | Extension host API; capability checks, identity derivation, origin immutability | +| `internal/network/` | TC-INT-014 to TC-INT-015, TC-SEC-005 | Network ingress; peer validation, channel binding, mismatch rejection | +| `internal/observe/` | TC-PERF-005 to TC-PERF-006, SMOKE-010 | Observe projections; health queries, metrics, queue depth calculations | +| `internal/daemon/` | SMOKE-001, TC-FUNC-023, TC-FUNC-024, TC-FUNC-030 | Composition root; boot sequence, cold-start recovery, service wiring | + +### Cross-Cutting Change Rules + +- **Schema migration changes** (`internal/store/globaldb/` migration files): Run the full `internal/store/globaldb/` set plus `internal/task/` set. +- **Contract/payload changes** (`internal/api/contract/`): Run TC-INT-001 to TC-INT-008, TC-SEC-003, TC-SEC-006, TC-SEC-007. +- **Domain type changes** (`internal/task/` types/enums): Run everything in `internal/task/` column plus all integration tests. +- **Boot sequence changes** (`internal/daemon/boot.go`): Run all smoke tests plus cold-start recovery tests. + +--- + +## 5. Full Regression Suite + +**Duration:** 2-3 hours +**Frequency:** Before every release, after major refactors, weekly CI gate + +### Execution Order + +The full regression runs in strict priority order. If a higher-priority group fails, evaluate whether to continue based on the pass/fail criteria in Section 7. + +#### Phase 1: Smoke Gate (15-20 min) + +Run all smoke tests first. If any fail, STOP. + +| Order | Test IDs | +|-------|----------| +| 1 | SMOKE-001 | +| 2 | SMOKE-002 | +| 3 | SMOKE-003 | +| 4 | SMOKE-004 | +| 5 | SMOKE-005 | +| 6 | SMOKE-006 | +| 7 | SMOKE-007 | +| 8 | SMOKE-008 | +| 9 | SMOKE-009 | +| 10 | SMOKE-010 | + +#### Phase 2: P0 Critical Path (45-60 min) + +If any P0 test fails, the regression is FAILED. Log the failure, investigate root cause, and do not proceed until the failure is understood. + +| Order | Test IDs | Area | +|-------|----------|------| +| 11-13 | TC-FUNC-001 to TC-FUNC-003 | Task create and read | +| 14-15 | TC-FUNC-004, TC-FUNC-005 | Task list and update | +| 16 | TC-FUNC-006 | Immutable field rejection | +| 17-19 | TC-FUNC-007 to TC-FUNC-009 | Hierarchy and dependencies | +| 20-22 | TC-FUNC-010 to TC-FUNC-012 | Graph limits and cycle detection | +| 23 | TC-FUNC-013 | Children limit | +| 24-26 | TC-FUNC-014 to TC-FUNC-016 | Run enqueue, claim, start | +| 27-29 | TC-FUNC-017 to TC-FUNC-019 | Run complete, fail, cancel | +| 30-31 | TC-FUNC-020, TC-FUNC-021 | Cancellation propagation and forced stop | +| 32 | TC-FUNC-022 | Status reconciliation | +| 33-34 | TC-FUNC-023, TC-FUNC-024 | Cold-start recovery | +| 35-42 | TC-SEC-001 to TC-SEC-008 | Full security suite | + +#### Phase 3: P1 Integration and Advanced Lifecycle (45-60 min) + +P1 failures are tracked. The suite continues but failures are flagged for investigation. + +| Order | Test IDs | Area | +|-------|----------|------| +| 43-45 | TC-FUNC-025 to TC-FUNC-027 | Attach-session flows | +| 46-48 | TC-FUNC-028 to TC-FUNC-030 | Idempotency, audit, cold-start (starting) | +| 49-53 | TC-INT-001 to TC-INT-005 | HTTP and UDS transport | +| 54-56 | TC-INT-006 to TC-INT-008 | CLI commands | +| 57-58 | TC-INT-009 to TC-INT-010 | Session bridge | +| 59-60 | TC-INT-011 to TC-INT-012 | Automation integration | +| 61 | TC-INT-013 | Extension integration | +| 62-63 | TC-INT-014 to TC-INT-015 | Network integration | + +#### Phase 4: P2 Performance (30-45 min) + +P2 failures are informational. Record baseline numbers and compare against previous runs. + +| Order | Test IDs | Area | +|-------|----------|------| +| 64 | TC-PERF-001 | Task creation throughput | +| 65 | TC-PERF-002 | Graph operation performance | +| 66 | TC-PERF-003 | Cancellation propagation latency | +| 67 | TC-PERF-004 | List query performance | +| 68 | TC-PERF-005 | Observe projection throughput | +| 69 | TC-PERF-006 | Queue depth query performance | + +#### Phase 5: Exploratory Testing (15-30 min) + +After all scripted tests pass, allocate time for exploratory testing focused on: + +1. **Concurrent writer stress:** Multiple simultaneous task creates, updates, and cancellations from different writer surfaces (HTTP, CLI, automation). +2. **Deep hierarchy edge cases:** Create tasks at max depth (8), add max dependencies (32), then cancel the root -- verify propagation completes cleanly. +3. **Session bridge failure modes:** Simulate bridge timeouts and session creation failures during run start -- verify the run transitions to failed, not stuck. +4. **Cold-start under load:** Seed multiple in-flight runs, restart the daemon, verify all orphaned runs are correctly reconciled before new traffic is accepted. +5. **Network channel lifecycle:** Create a channel-bound task, invalidate the channel configuration, attempt a new run -- verify stale-channel rejection and audit trail. +6. **Extension capability revocation:** Grant task capability, create a task, revoke capability, attempt update -- verify clean rejection. + +--- + +## 6. Sanity Suite + +**Duration:** 10 minutes +**Use case:** Post-hotfix verification when the fix scope is known and narrow + +### Procedure + +1. Run SMOKE-001 through SMOKE-005 (daemon boot + basic CRUD). If any fail, STOP. +2. Run the specific TC-ID(s) that validate the hotfix. The fix author must identify these. +3. If the hotfix touches a security boundary, additionally run the relevant TC-SEC test(s). + +### Examples + +| Hotfix Area | Sanity Set | +|-------------|------------| +| Task creation validation bug | SMOKE-001 to SMOKE-005, TC-FUNC-001, TC-FUNC-002 | +| Run lifecycle state transition bug | SMOKE-001 to SMOKE-005, SMOKE-006, SMOKE-007, TC-FUNC-014 to TC-FUNC-019 | +| Cancellation propagation fix | SMOKE-001 to SMOKE-005, SMOKE-009, TC-FUNC-020, TC-FUNC-021 | +| Cold-start recovery fix | SMOKE-001 to SMOKE-005, TC-FUNC-023, TC-FUNC-024, TC-FUNC-030 | +| HTTP API response code fix | SMOKE-001 to SMOKE-005, TC-INT-001 to TC-INT-003 | +| CLI command parsing fix | SMOKE-001 to SMOKE-005, SMOKE-008, TC-INT-006 to TC-INT-008 | +| Security vulnerability fix | SMOKE-001 to SMOKE-005, all TC-SEC-001 to TC-SEC-008 | +| Extension integration fix | SMOKE-001 to SMOKE-005, TC-INT-013, TC-SEC-004, TC-SEC-008 | +| Network channel fix | SMOKE-001 to SMOKE-005, TC-INT-014, TC-INT-015, TC-SEC-005 | +| Observe/metrics fix | SMOKE-001 to SMOKE-005, SMOKE-010, TC-PERF-005, TC-PERF-006 | +| Dependency graph fix | SMOKE-001 to SMOKE-005, TC-FUNC-008 to TC-FUNC-013 | + +--- + +## 7. Pass/Fail Criteria + +### PASS + +All of the following must be true: + +- All SMOKE tests pass (SMOKE-001 to SMOKE-010). +- All P0 tests pass (TC-FUNC-001 to TC-FUNC-024, TC-SEC-001 to TC-SEC-008). +- 90% or more of P1 tests pass (at most 2 of 21 P1 tests may fail). +- No critical bugs discovered (data loss, unrecoverable state, security vulnerability). +- No security vulnerabilities found in any test tier. + +### FAIL + +Any of the following is an automatic FAIL: + +- Any SMOKE test fails. +- Any P0 test fails. +- A critical bug is found: data loss, task state corruption, orphaned sessions after cancellation, identity spoofing succeeds, unauthenticated write succeeds. +- A security vulnerability is confirmed in any test tier. +- Cold-start recovery leaves orphaned runs in non-terminal state. + +### CONDITIONAL PASS + +The regression receives a conditional pass when: + +- All SMOKE and P0 tests pass. +- Between 1 and 2 P1 tests fail, AND each failure has a documented workaround that does not impact core task lifecycle. +- P2 performance regressions are within 20% of the established baseline. +- No security or data integrity issues exist. + +A conditional pass requires sign-off from the tech lead with: +- A tracking issue for each P1 failure. +- A written workaround for each P1 failure. +- Confirmation that the failures do not cascade into P0 areas. + +--- + +## 8. Execution Checklist + +### Before Regression + +- [ ] Verify the daemon builds cleanly (`make build` succeeds). +- [ ] Verify `make verify` passes (fmt, lint, test, build). +- [ ] Confirm the test environment has a clean database (no leftover state from previous runs). +- [ ] Confirm the branch under test is identified and the commit SHA is recorded. +- [ ] Confirm the test runner has access to both HTTP and UDS daemon interfaces. +- [ ] Confirm the `agh` CLI binary is built from the same commit as the daemon. +- [ ] Record the baseline performance numbers from the previous regression run (for P2 comparison). +- [ ] Verify no known environment issues (disk space, port conflicts, stale UDS sockets). + +### During Regression + +- [ ] Execute phases in strict order: Smoke, P0, P1, P2, Exploratory. +- [ ] Stop immediately on SMOKE failure -- do not proceed to later phases. +- [ ] On P0 failure: log the failure, capture daemon logs and database state, investigate root cause before deciding whether to continue. +- [ ] On P1 failure: log the failure, continue execution, flag for post-regression investigation. +- [ ] On P2 failure: log the measured values, compare against baseline, continue execution. +- [ ] Record the actual execution time for each phase. +- [ ] Capture daemon structured logs for the entire regression duration. +- [ ] For any exploratory finding, document: steps to reproduce, observed behavior, expected behavior, severity estimate. + +### After Regression + +- [ ] Compile results: total pass/fail/skip counts per priority tier. +- [ ] Apply pass/fail criteria from Section 7 to determine the overall result. +- [ ] For PASS: record the result with commit SHA, test duration, and any noteworthy observations. +- [ ] For CONDITIONAL PASS: file tracking issues for each P1 failure, document workarounds, obtain tech lead sign-off. +- [ ] For FAIL: file blocking issues for each P0 or SMOKE failure, escalate to the feature owner, and schedule a re-run after fixes land. +- [ ] Archive the full test results, daemon logs, and database snapshots for the run. +- [ ] Update the baseline performance numbers if P2 results improved or regressed significantly. +- [ ] Communicate the regression outcome to the team. + +--- + +## Appendix A: Test Case Quick Reference + +| Range | Count | Category | Priority Mix | +|-------|-------|----------|-------------| +| TC-FUNC-001 to TC-FUNC-024 | 24 | Functional (critical path) | P0 | +| TC-FUNC-025 to TC-FUNC-030 | 6 | Functional (advanced) | P1 | +| TC-INT-001 to TC-INT-015 | 15 | Integration | P1 | +| TC-SEC-001 to TC-SEC-008 | 8 | Security | P0 | +| TC-PERF-001 to TC-PERF-006 | 6 | Performance | P2 | +| SMOKE-001 to SMOKE-010 | 10 | Smoke | Gate | +| **Total** | **69** | | | + +## Appendix B: Package-to-Task Mapping + +For traceability, this maps each changed package to the implementation tasks that defined its Core Tasks behavior: + +| Package | Implementation Tasks | +|---------|---------------------| +| `internal/task/` | Task 01, 04, 05 | +| `internal/store/globaldb/` | Task 02, 03 | +| `internal/session/` | Task 06 | +| `internal/api/core/` | Task 07 | +| `internal/api/httpapi/` | Task 08 | +| `internal/api/udsapi/` | Task 08 | +| `internal/cli/` | Task 09 | +| `internal/automation/` | Task 10 | +| `internal/extension/` | Task 11 | +| `internal/network/` | Task 12 | +| `internal/observe/` | Task 13 | +| `internal/daemon/` | Task 06 (boot recovery), wiring across all tasks | diff --git a/.compozy/tasks/core-tasks/qa/test-plans/core-tasks-test-plan.md b/.compozy/tasks/core-tasks/qa/test-plans/core-tasks-test-plan.md new file mode 100644 index 000000000..a7491b70d --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/test-plans/core-tasks-test-plan.md @@ -0,0 +1,435 @@ +# AGH Core Tasks -- Comprehensive Test Plan + +**Version:** 1.0 +**Date:** 2026-04-14 +**Author:** QA Engineering +**Status:** Draft +**Feature Branch:** `core-tasks` + +--- + +## 1. Executive Summary + +### 1.1 Objectives + +This test plan defines the verification strategy for the AGH Core Tasks feature, which introduces a complete task coordination system into the AGH Agent Operating System. The feature spans 13 implemented components across domain logic, persistence, API transport, CLI, automation, extension, network, and observability layers. + +The primary objectives are: + +- Validate the correctness of the 7-state task lifecycle and 7-state run lifecycle state machines, including all legal and illegal transitions. +- Verify that identity invariants (server-derived `created_by` and `origin`, 6 actor kinds, 9 origin kinds) are enforced at every ingress surface and never accepted from client payloads. +- Confirm immutability guarantees for `scope`, `workspace_id`, `parent_task_id`, `created_by`, and `origin` fields after task creation. +- Ensure bounded resource limits (metadata 16KB, payload/result 64KB, hierarchy depth 8, dependencies 32/task, children 64/parent) are enforced and produce correct errors. +- Validate cycle detection in the dependency graph under concurrent write conditions via recursive CTE within `BEGIN IMMEDIATE` transactions. +- Confirm cascading cancellation propagates correctly through task hierarchies with cooperative-then-forced session stop semantics. +- Verify cold-start boot recovery correctly reclassifies orphaned claimed/starting/running runs. +- Validate idempotency key deduplication scoped to origin for non-human callers. +- Confirm that stale network channels block new runs while preserving task readability. +- Validate API parity between HTTP and UDS transports across all 18 endpoints. +- Verify CLI command coverage for the `agh task` command group. +- Confirm observability metrics (queue depth, stuck work, forced-stop audit, recovery totals) are accurate. + +### 1.2 Key Risks + +| Risk | Severity | +|------|----------| +| State machine transitions allow invalid paths under concurrent writes | Critical | +| Cycle detection fails under high-contention dependency insertion | Critical | +| Cascading cancellation leaves orphaned runs or sessions | Critical | +| Immutable field bypass through malformed API payloads | High | +| Boot recovery misclassifies live sessions as orphaned | High | +| Idempotency key collision across different origins | High | +| SQLite lock contention causes timeouts under concurrent task creation | Medium | +| Observability metrics drift from actual task state | Medium | + +--- + +## 2. Scope Definition + +### 2.1 In-Scope Features + +| Component | Package | Description | +|-----------|---------|-------------| +| Domain types and validation | `internal/task/` | Task, TaskRun, TaskEvent, TaskDependency, TaskRunIdempotency types; all Validate() methods; ActorContext derivation; limit constants | +| Task Manager | `internal/task/` | Manager interface implementation: CreateTask, CreateChildTask, UpdateTask, CancelTask, AddDependency, RemoveDependency, EnqueueRun, ClaimRun, StartRun, AttachRunSession, CompleteRun, FailRun, CancelRun, RecoverRunOnBoot, GetTask, ListTasks, ListTaskRuns | +| SQLite persistence | `internal/store/globaldb/` | 5 tables (tasks, task_runs, task_dependencies, task_events, task_run_idempotency); CreateDependency with cycle detection CTE; immutable field enforcement at store layer | +| HTTP API | `internal/api/httpapi/` | 18 REST endpoints under `/tasks` and `/task-runs` groups | +| UDS API | `internal/api/udsapi/` | Mirror of all 18 REST endpoints for CLI IPC | +| Contract types | `internal/api/contract/` | Shared request/response payloads for both transports | +| CLI commands | `internal/cli/` | `agh task` group: list, create, get, update, cancel, child, dependency, run | +| Automation dispatch | `internal/automation/` | Direct and agent-mediated task creation through automation triggers/schedules | +| Extension host API | `internal/extension/` | Capability-checked task writes via extension runtime | +| Network ingress | `internal/network/` | Channel-bound peer task ingress with capability checks | +| Observability | `internal/observe/` | TaskSummary, TaskMetrics, TaskHealth queries; queue depth; stuck run detection; forced-stop and recovery audit | + +### 2.2 Out-of-Scope Items + +- Web UI (React SPA) rendering of task views -- covered by separate frontend test plan +- ACP subprocess protocol internals -- tested independently in `internal/acp/` +- Session lifecycle management beyond the `SessionExecutor` interface contract +- Memory/Skills/State layers (Phase 2 features) +- Agent network protocol (Phase 3 features) +- Database migration tooling (greenfield alpha -- no migrations exist) +- Load testing beyond the performance benchmarks defined in section 3.6 +- Cross-platform binary builds (darwin/linux/windows) + +--- + +## 3. Test Strategy + +### 3.1 Unit Tests + +**Scope:** Pure domain logic within `internal/task/`, validation functions, state machine transitions, normalization, helper functions. + +**Approach:** +- Table-driven tests with `t.Run` subtests for all validation paths +- `t.Parallel()` for independent subtests +- Mock `Store` and `SessionExecutor` interfaces via test doubles +- Deterministic clocks via `WithManagerNow` and deterministic IDs via `WithIDGenerator` +- Cover every valid and invalid status transition in `allowsRunTransition` +- Cover every actor/origin pair validation in `validateActorOriginPair` +- Cover all 5 immutable field checks in `ValidateImmutableTaskFields` +- Cover size limit enforcement for metadata (16KB), payload (64KB), and result (64KB) +- Cover bounded count checks: hierarchy depth (8), dependencies (32), children (64) + +**Coverage target:** 90%+ for `internal/task/` package. + +### 3.2 Integration Tests + +**Scope:** Manager operations against real SQLite persistence; full store round-trips; cycle detection under transaction isolation. + +**Approach:** +- Build tag: `//go:build integration` +- Co-located with packages under test +- Real SQLite databases via `t.TempDir()` +- Full `GlobalDB` initialization with schema creation +- Test dependency cycle detection with the recursive CTE under `BEGIN IMMEDIATE` +- Test cascading cancellation through 3-level task hierarchies +- Test boot recovery with pre-seeded orphaned runs +- Test idempotency key deduplication across origins +- Test concurrent task/run creation under SQLite write contention +- `TestMain` for expensive one-time setup where needed +- Target execution time: <30s per package + +**Coverage target:** 85%+ for `internal/store/globaldb/` task operations. + +### 3.3 API Tests + +**Scope:** HTTP and UDS endpoint behavior, request/response contract validation, error mapping. + +**Approach:** +- Test both transports (HTTP via `httptest.Server`, UDS via real socket in `t.TempDir()`) +- Verify response status codes for success and all error categories (400, 404, 409, 413, 422) +- Verify JSON response shapes match `contract.*Payload` types +- Verify that `created_by` and `origin` fields are never accepted from request bodies +- Verify query parameter parsing and filter behavior for list endpoints +- Verify PATCH semantics: partial updates, no-op detection, immutable field rejection +- Confirm API parity: every endpoint that exists on HTTP also exists on UDS with identical behavior + +**Coverage target:** 80%+ for `internal/api/httpapi/` and `internal/api/udsapi/` handler code. + +### 3.4 CLI Tests + +**Scope:** `agh task` command group flag parsing, output formatting, error handling. + +**Approach:** +- Test Cobra command registration and flag binding +- Test output formatting for JSON and table modes +- Test error messages for invalid flag combinations +- Test workspace resolution for `--workspace` flag +- Mock UDS client for deterministic responses + +**Coverage target:** 80%+ for CLI task command code. + +### 3.5 Security Tests + +**Scope:** Identity enforcement, permission checks, payload injection, field tampering. + +**Approach:** +- Verify that `ActorContext` is always server-derived, never from client payload +- Verify that `Authority` checks gate every Manager method (read, write, create_global, create_workspace) +- Verify that `requireLifecycleIdempotency` enforces idempotency keys for non-human actors +- Verify that actors cannot escape their allowed origin kinds (e.g., human actor with automation origin) +- Verify that extensions without `task.write` capability are rejected +- Verify that network peers without channel-bound `task.write` capability are rejected +- Verify that payload size limits prevent resource exhaustion (metadata, result, payload) +- Verify SQL injection resistance in query filter parameters + +### 3.6 Performance Tests + +**Scope:** Throughput and latency under representative concurrent load. + +**Approach:** +- Benchmark task creation throughput with 100 concurrent goroutines +- Benchmark dependency cycle detection with deep dependency chains (30 edges) +- Benchmark cascading cancellation with wide hierarchies (64 children) +- Measure SQLite write lock contention under concurrent EnqueueRun calls +- Measure queue depth query latency with 10,000 tasks +- Measure observability summary computation latency with 10,000 tasks and 50,000 runs + +### 3.7 Regression Approach + +- All test cases are automated and run in CI via `make verify` +- Integration tests run separately via `make test-integration` +- Smoke tests (SMOKE-001 through SMOKE-010) form the minimum regression gate for every PR +- Any test failure blocks merge -- zero tolerance per CLAUDE.md + +--- + +## 4. Environment Requirements + +| Requirement | Specification | +|-------------|---------------| +| Go version | 1.25.0 (per `go.mod`) | +| SQLite | Embedded via `modernc.org/sqlite` (CGo-free) | +| OS | darwin (development), linux (CI) | +| Test runner | `go test -race` via `make test` | +| Lint | `golangci-lint` via `make lint` (zero issues) | +| Integration tests | `go test -race -tags integration` via `make test-integration` | +| Temp storage | `t.TempDir()` for all file/database isolation | +| Mocking | Interface-based test doubles (no reflection mocking frameworks) | +| Build gate | `make verify` (fmt, lint, test, build) must pass | + +--- + +## 5. Entry Criteria + +All of the following must be true before test execution begins: + +1. All 13 component packages compile without errors (`make build` passes) +2. `make fmt` produces no changes (code is properly formatted) +3. `make lint` reports zero issues +4. SQLite schema creation succeeds in `t.TempDir()` without migration errors +5. All task domain types, interfaces, and error sentinels are defined and exported +6. The `task.Manager` interface implementation (`TaskManager`) compiles and satisfies `var _ Manager = (*TaskManager)(nil)` +7. All 18 API endpoints are registered on both HTTP and UDS routers +8. All 8 CLI subcommands are registered under `agh task` +9. Feature branch is rebased on current `main` with no merge conflicts +10. PRD and technical design documents are reviewed and approved + +--- + +## 6. Exit Criteria + +| Criterion | Threshold | +|-----------|-----------| +| Unit test pass rate | 100% (zero failures) | +| Integration test pass rate | 100% (zero failures) | +| Unit test coverage for `internal/task/` | >= 90% | +| Unit test coverage for `internal/store/globaldb/` task ops | >= 85% | +| Unit test coverage for API handlers | >= 80% | +| Unit test coverage for CLI commands | >= 80% | +| Overall package coverage | >= 80% (per CLAUDE.md requirement) | +| `make verify` | Passes with zero warnings, zero errors | +| Race detector | Zero races detected under `-race` | +| All P0 (blocker) test cases | Pass | +| All P1 (critical) test cases | Pass | +| P2 (major) test cases | >= 95% pass rate | +| P3 (minor) test cases | >= 90% pass rate | +| Smoke tests (SMOKE-001 to SMOKE-010) | 100% pass | +| Performance benchmarks | No regression > 20% from baseline | +| Security test cases | 100% pass | + +--- + +## 7. Risk Assessment Table + +| # | Risk | Probability | Impact | Mitigation | +|---|------|-------------|--------|------------| +| R1 | Task state machine allows invalid transitions under concurrent run mutations | Medium | Critical | Integration tests with concurrent goroutines racing ClaimRun/CompleteRun; `-race` flag enforcement | +| R2 | Dependency cycle detection CTE fails or deadlocks under write contention | Low | Critical | Integration test with 10+ concurrent AddDependency calls forming near-cycle topologies; `BEGIN IMMEDIATE` transaction isolation | +| R3 | Cascading cancellation leaves orphaned sessions without force-stop | Medium | Critical | Integration test with 3-level hierarchy, running sessions, and verified ForceTaskStop calls; mock SessionExecutor tracking | +| R4 | Immutable field bypass through API PATCH with created_by/origin in body | Low | High | Security test: send PATCH with immutable fields in JSON body; verify 422/ignored at handler and store layers | +| R5 | Boot recovery marks live sessions as orphaned, killing active work | Medium | High | Integration test: pre-seed running run with mock live session; verify RecoverRunOnBoot chooses `mark_running` not `fail` | +| R6 | Idempotency key collision across different origin kinds returns wrong run | Low | High | Unit test: same key, two different origins; verify independent deduplication scopes | +| R7 | SQLite write lock timeout under burst task creation (100+ concurrent) | Medium | Medium | Performance benchmark with 100 concurrent CreateTask calls; measure p99 latency and failure rate | +| R8 | Observability metrics drift from actual persisted task/run state | Medium | Medium | Integration test: create known task/run distribution; query TaskSummary/TaskMetrics; assert exact counts match | +| R9 | Network channel validation accepts stale channels after peer disconnect | Medium | Medium | Unit test: configure channel validator that returns error; verify EnqueueRun and StartRun reject with `ErrStaleNetworkChannel` | +| R10 | Task hierarchy depth check off-by-one allows depth 9 | Low | Medium | Unit test: create chain of 8 parents; verify 9th creation fails with `ErrGraphLimitExceeded` | +| R11 | Workspace-scoped child task created under global parent violates scope invariant | Low | Medium | Unit test: global parent, workspace child -- verify success; workspace parent, global child -- verify rejection | +| R12 | Extension host API bypasses capability check for task writes | Low | High | Integration test: register extension without `task.write` capability; attempt task creation; verify rejection | +| R13 | CancelTask on already-terminal task returns misleading success | Medium | Low | Unit test: cancel completed task; verify `ErrInvalidStatusTransition` | +| R14 | ListTasks query filters silently ignore invalid enum values | Low | Low | Unit test: pass invalid scope/status/owner_kind values; verify validation error before query execution | +| R15 | Metadata/payload size validation uses `len()` on non-trimmed JSON | Low | Medium | Unit test: 16KB metadata with leading/trailing whitespace; verify trimmed size is checked | + +--- + +## 8. Test Case Summary Matrix + +### 8.1 By Component Area and Test Type + +| Area | Unit | Integration | API | CLI | Security | Performance | Total | +|------|------|-------------|-----|-----|----------|-------------|-------| +| Task lifecycle (status transitions) | 12 | 3 | 2 | -- | -- | -- | 17 | +| Run lifecycle (status transitions) | 10 | 3 | 2 | -- | -- | -- | 15 | +| Identity & actor context | 6 | -- | 2 | -- | 4 | -- | 12 | +| Immutability enforcement | 5 | 2 | 1 | -- | 2 | -- | 10 | +| Size limits & validation | 8 | 1 | 1 | -- | 1 | 1 | 12 | +| Dependency graph & cycles | 4 | 4 | 1 | 1 | -- | 1 | 11 | +| Cascading cancellation | 3 | 3 | 1 | 1 | -- | 1 | 9 | +| Boot recovery | 3 | 3 | -- | -- | -- | -- | 6 | +| Session bridge | 4 | 2 | 1 | -- | -- | -- | 7 | +| Idempotency | 3 | 2 | 1 | -- | 1 | -- | 7 | +| Network channel | 3 | 2 | -- | -- | -- | -- | 5 | +| API endpoints (HTTP) | -- | 3 | 10 | -- | -- | -- | 13 | +| API endpoints (UDS) | -- | 3 | 6 | -- | -- | -- | 9 | +| CLI commands | -- | -- | -- | 8 | -- | -- | 8 | +| Automation dispatch | 3 | 2 | -- | -- | -- | -- | 5 | +| Extension host API | 2 | 2 | -- | -- | 2 | -- | 6 | +| Network ingress | 3 | 2 | -- | -- | 2 | -- | 7 | +| Observability | 4 | 3 | 2 | -- | -- | 2 | 11 | +| Smoke tests | -- | -- | -- | -- | -- | -- | 10 | + +### 8.2 By Priority + +| Priority | Count | Description | +|----------|-------|-------------| +| P0 (Blocker) | 22 | State machine correctness, cycle detection, identity enforcement, immutability, boot recovery | +| P1 (Critical) | 25 | Cascading cancellation, session bridge, idempotency, API contract parity, size limits | +| P2 (Major) | 18 | Query filters, CLI output, observability metrics, network channel validation | +| P3 (Minor) | 4 | Edge cases in normalization, optional field handling, metadata whitespace | +| Smoke | 10 | End-to-end happy paths covering critical user journeys | + +--- + +## 9. Timeline and Deliverables + +| Phase | Duration | Deliverables | +|-------|----------|------------| +| Phase 1: Unit tests for `internal/task/` | 2 days | TC-FUNC-001 to TC-FUNC-020 implemented; 90%+ coverage for task package | +| Phase 2: Store integration tests | 2 days | TC-INT-001 to TC-INT-008 implemented; cycle detection, immutability, concurrency verified | +| Phase 3: API and CLI tests | 2 days | TC-FUNC-021 to TC-FUNC-030, TC-INT-009 to TC-INT-015 implemented; API parity verified | +| Phase 4: Security and performance tests | 1 day | TC-SEC-001 to TC-SEC-008, TC-PERF-001 to TC-PERF-006 implemented | +| Phase 5: Smoke tests and regression | 1 day | SMOKE-001 to SMOKE-010 implemented; full regression pass; `make verify` green | +| Phase 6: Test report and sign-off | 0.5 day | Test execution report, coverage report, defect summary | + +**Total estimated duration:** 8.5 working days + +--- + +## 10. Traceability Matrix + +### 10.1 Functional Tests (TC-FUNC-001 to TC-FUNC-030) + +| TC-ID | Feature | Description | Priority | Type | +|-------|---------|-------------|----------|------| +| TC-FUNC-001 | Task lifecycle | CreateTask produces `ready` status with server-derived created_by/origin | P0 | Unit | +| TC-FUNC-002 | Task lifecycle | CreateTask with workspace scope requires non-empty workspace_id | P0 | Unit | +| TC-FUNC-003 | Task lifecycle | CreateTask with global scope rejects non-empty workspace_id | P0 | Unit | +| TC-FUNC-004 | Task lifecycle | CreateChildTask increments parent child count and emits child_created event | P1 | Unit | +| TC-FUNC-005 | Task lifecycle | CreateChildTask enforces MaxDirectChildren (64) limit | P1 | Unit | +| TC-FUNC-006 | Task lifecycle | CreateChildTask enforces MaxHierarchyDepth (8) limit | P1 | Unit | +| TC-FUNC-007 | Task lifecycle | UpdateTask applies partial patch and preserves immutable fields | P0 | Unit | +| TC-FUNC-008 | Task lifecycle | UpdateTask with no changed fields returns current task without write | P2 | Unit | +| TC-FUNC-009 | Task lifecycle | CancelTask on ready task transitions to cancelled with ClosedAt set | P0 | Unit | +| TC-FUNC-010 | Task lifecycle | CancelTask on terminal (completed/failed) task returns ErrInvalidStatusTransition | P1 | Unit | +| TC-FUNC-011 | Run lifecycle | EnqueueRun on ready task creates queued run with correct attempt number | P0 | Unit | +| TC-FUNC-012 | Run lifecycle | EnqueueRun on cancelled task returns ErrInvalidStatusTransition | P0 | Unit | +| TC-FUNC-013 | Run lifecycle | ClaimRun transitions queued run to claimed with actor identity | P0 | Unit | +| TC-FUNC-014 | Run lifecycle | StartRun from claimed state: starting -> session bind -> running | P0 | Unit | +| TC-FUNC-015 | Run lifecycle | StartRun from starting state with session binding transitions to running | P0 | Unit | +| TC-FUNC-016 | Run lifecycle | CompleteRun transitions running run to completed with result payload | P1 | Unit | +| TC-FUNC-017 | Run lifecycle | FailRun transitions running/starting run to failed with error message | P1 | Unit | +| TC-FUNC-018 | Run lifecycle | CancelRun on queued/claimed run cancels immediately without session stop | P1 | Unit | +| TC-FUNC-019 | Run lifecycle | CancelRun on running run triggers cooperative then forced session stop | P0 | Unit | +| TC-FUNC-020 | Run lifecycle | All invalid run transitions return ErrInvalidStatusTransition | P0 | Unit | +| TC-FUNC-021 | Dependency graph | AddDependency creates edge and reconciles task to blocked if unresolved | P0 | Unit | +| TC-FUNC-022 | Dependency graph | RemoveDependency deletes edge and reconciles task to ready if all resolved | P1 | Unit | +| TC-FUNC-023 | Dependency graph | AddDependency self-referential (A depends on A) rejected by validation | P1 | Unit | +| TC-FUNC-024 | Cascading cancel | CancelTask propagates to all non-terminal descendants | P0 | Unit | +| TC-FUNC-025 | Cascading cancel | CancelTask skips already-terminal descendants | P1 | Unit | +| TC-FUNC-026 | Boot recovery | RecoverRunOnBoot requeue resets claimed run to queued | P0 | Unit | +| TC-FUNC-027 | Boot recovery | RecoverRunOnBoot mark_running promotes starting run with session to running | P0 | Unit | +| TC-FUNC-028 | Boot recovery | RecoverRunOnBoot fail marks orphaned run as failed with recovery metadata | P0 | Unit | +| TC-FUNC-029 | Session bridge | AttachRunSession on claimed run transitions to starting with session_id | P1 | Unit | +| TC-FUNC-030 | Session bridge | AttachRunSession rejects if session already bound to active run | P1 | Unit | + +### 10.2 Integration Tests (TC-INT-001 to TC-INT-015) + +| TC-ID | Feature | Description | Priority | Type | +|-------|---------|-------------|----------|------| +| TC-INT-001 | Store: task CRUD | Create, read, update, list tasks round-trip through real SQLite | P0 | Integration | +| TC-INT-002 | Store: run CRUD | Create, read, update, list runs round-trip through real SQLite | P0 | Integration | +| TC-INT-003 | Store: dependency cycle | AddDependency detects cycle via recursive CTE under BEGIN IMMEDIATE | P0 | Integration | +| TC-INT-004 | Store: dependency limit | AddDependency enforces 32-edge limit within transaction | P1 | Integration | +| TC-INT-005 | Store: immutability | UpdateTask rejects changes to scope, workspace_id, parent_task_id, created_by, origin | P0 | Integration | +| TC-INT-006 | Store: idempotency | GetTaskRunByIdempotencyKey returns correct run scoped to origin | P1 | Integration | +| TC-INT-007 | Manager: full lifecycle | Create task -> enqueue -> claim -> start -> complete; verify all events recorded | P0 | Integration | +| TC-INT-008 | Manager: cascading cancel | 3-level hierarchy with running sessions; cancel root; verify all descendants cancelled and sessions stopped | P0 | Integration | +| TC-INT-009 | API: HTTP task CRUD | POST/GET/PATCH/LIST tasks via HTTP transport with real manager and store | P1 | Integration | +| TC-INT-010 | API: UDS task CRUD | POST/GET/PATCH/LIST tasks via UDS transport with real manager and store | P1 | Integration | +| TC-INT-011 | API: HTTP run lifecycle | Enqueue/claim/start/complete run via HTTP with correct status transitions | P1 | Integration | +| TC-INT-012 | API: UDS run lifecycle | Enqueue/claim/start/complete run via UDS with correct status transitions | P1 | Integration | +| TC-INT-013 | Observe: task summary | Create mixed task/run state; verify TaskSummary buckets match expected counts | P2 | Integration | +| TC-INT-014 | Observe: task metrics | Full lifecycle with cancellation; verify forced-stop and recovery counters | P2 | Integration | +| TC-INT-015 | Observe: stuck runs | Create claimed run; advance clock past threshold; verify stuck run detection | P2 | Integration | + +### 10.3 Security Tests (TC-SEC-001 to TC-SEC-008) + +| TC-ID | Feature | Description | Priority | Type | +|-------|---------|-------------|----------|------| +| TC-SEC-001 | Identity enforcement | CreateTask ignores created_by/origin from request body; uses server-derived values | P0 | Security | +| TC-SEC-002 | Identity enforcement | All 6 actor kinds validated against allowed origin kinds | P0 | Security | +| TC-SEC-003 | Permission checks | Read-only authority cannot call CreateTask/UpdateTask/CancelTask | P0 | Security | +| TC-SEC-004 | Permission checks | Write authority without create_global cannot create global-scope tasks | P1 | Security | +| TC-SEC-005 | Idempotency enforcement | Non-human actors without idempotency_key rejected for EnqueueRun/ClaimRun/StartRun | P1 | Security | +| TC-SEC-006 | Extension capability | Extension without task.write capability rejected at host API | P1 | Security | +| TC-SEC-007 | Network capability | Network peer without task.write capability rejected at ingress | P1 | Security | +| TC-SEC-008 | Payload injection | Oversized metadata (>16KB), result (>64KB), and event payload (>64KB) rejected with ErrPayloadTooLarge | P1 | Security | + +### 10.4 Performance Tests (TC-PERF-001 to TC-PERF-006) + +| TC-ID | Feature | Description | Priority | Type | +|-------|---------|-------------|----------|------| +| TC-PERF-001 | Task creation throughput | 100 concurrent CreateTask calls complete within 5s with zero failures | P2 | Performance | +| TC-PERF-002 | Dependency cycle detection | Cycle check on 30-edge dependency chain completes within 100ms | P2 | Performance | +| TC-PERF-003 | Cascading cancellation | Cancel root of 64-child hierarchy completes within 2s | P2 | Performance | +| TC-PERF-004 | SQLite write contention | 100 concurrent EnqueueRun calls; measure p99 latency and failure rate | P2 | Performance | +| TC-PERF-005 | Queue depth query | TaskSummary query with 10,000 tasks completes within 500ms | P3 | Performance | +| TC-PERF-006 | Observability computation | TaskMetrics with 10,000 tasks and 50,000 runs completes within 1s | P3 | Performance | + +### 10.5 Smoke Tests (SMOKE-001 to SMOKE-010) + +| TC-ID | Feature | Description | Priority | +|-------|---------|-------------|----------| +| SMOKE-001 | Task creation | Create global task via CLI; verify `agh task get` returns it | P0 | +| SMOKE-002 | Workspace task | Create workspace-scoped task via HTTP; verify scope and workspace_id in response | P0 | +| SMOKE-003 | Task list | Create 3 tasks; list with status filter; verify filtered results | P0 | +| SMOKE-004 | Task update | Create task; PATCH title; verify updated title in GET response | P0 | +| SMOKE-005 | Task cancel | Create task; cancel; verify status is cancelled with closed_at set | P0 | +| SMOKE-006 | Child task | Create parent; create child via POST /:id/children; verify parent_task_id | P0 | +| SMOKE-007 | Dependency | Create two tasks; add dependency A->B; verify A is blocked | P0 | +| SMOKE-008 | Run lifecycle | Create task; enqueue run; claim; start; complete; verify task status is completed | P0 | +| SMOKE-009 | Run cancel | Create task; enqueue run; cancel run; verify run status is cancelled | P0 | +| SMOKE-010 | Observability | Create tasks and runs; query task summary via API; verify non-zero totals | P1 | + +### 10.6 Full Traceability: Feature to Test Cases + +| Feature | TC IDs | +|---------|--------| +| Task create (global/workspace) | TC-FUNC-001, TC-FUNC-002, TC-FUNC-003, TC-INT-001, TC-SEC-001, TC-PERF-001, SMOKE-001, SMOKE-002 | +| Task update (partial patch) | TC-FUNC-007, TC-FUNC-008, TC-INT-005, SMOKE-004 | +| Task cancel (single + cascade) | TC-FUNC-009, TC-FUNC-010, TC-FUNC-024, TC-FUNC-025, TC-INT-008, TC-PERF-003, SMOKE-005 | +| Child task create | TC-FUNC-004, TC-FUNC-005, TC-FUNC-006, SMOKE-006 | +| Dependency graph | TC-FUNC-021, TC-FUNC-022, TC-FUNC-023, TC-INT-003, TC-INT-004, TC-PERF-002, SMOKE-007 | +| Run enqueue | TC-FUNC-011, TC-FUNC-012, TC-INT-002, TC-SEC-005, TC-PERF-004, SMOKE-008 | +| Run claim | TC-FUNC-013, TC-FUNC-020 | +| Run start | TC-FUNC-014, TC-FUNC-015, TC-FUNC-020 | +| Run complete | TC-FUNC-016, TC-FUNC-020, SMOKE-008 | +| Run fail | TC-FUNC-017, TC-FUNC-020 | +| Run cancel | TC-FUNC-018, TC-FUNC-019, TC-FUNC-020, SMOKE-009 | +| Boot recovery | TC-FUNC-026, TC-FUNC-027, TC-FUNC-028 | +| Session bridge | TC-FUNC-029, TC-FUNC-030 | +| Idempotency | TC-INT-006, TC-SEC-005 | +| Identity enforcement | TC-SEC-001, TC-SEC-002, TC-SEC-003, TC-SEC-004 | +| Extension host API | TC-SEC-006 | +| Network ingress | TC-SEC-007 | +| Payload limits | TC-SEC-008 | +| HTTP API | TC-INT-009, TC-INT-011, SMOKE-001 through SMOKE-010 | +| UDS API | TC-INT-010, TC-INT-012 | +| CLI commands | SMOKE-001, SMOKE-003, SMOKE-004, SMOKE-005 | +| Observability | TC-INT-013, TC-INT-014, TC-INT-015, TC-PERF-005, TC-PERF-006, SMOKE-010 | +| Task list with filters | TC-INT-001, SMOKE-003 | +| Immutability | TC-FUNC-007, TC-INT-005, TC-SEC-001, TC-SEC-004 | +| Network channel validation | TC-FUNC-011, TC-FUNC-014 | +| Size limits | TC-SEC-008, TC-PERF-001 | diff --git a/.compozy/tasks/core-tasks/qa/verification-report.md b/.compozy/tasks/core-tasks/qa/verification-report.md new file mode 100644 index 000000000..d1efb0a51 --- /dev/null +++ b/.compozy/tasks/core-tasks/qa/verification-report.md @@ -0,0 +1,59 @@ +VERIFICATION REPORT +------------------- +Claim: `make verify` passes on the current branch state, and the full 69-case suite under `.compozy/tasks/core-tasks/test-cases` was executed with 68 PASS / 1 FAIL. The only remaining failed case is `TC-SEC-003` (unauthenticated HTTP task access), filed as `.compozy/tasks/core-tasks/issues/BUG-001.md`. +Command: `make verify` +Executed: 2026-04-14 16:51:13 -0300 +Exit code: 0 +Output summary: `Found 0 warnings and 0 errors.` `0 issues.` `DONE 3222 tests in 14.745s` `OK: all package boundaries respected` +Warnings: none +Errors: `TC-SEC-003` failed in live isolated HTTP validation because anonymous `GET /api/tasks` returned `200` and anonymous `POST /api/tasks` returned `201`. +Verdict: FAIL + +Supporting runtime evidence: +- Full case matrix: `.compozy/tasks/core-tasks/case-execution-matrix.md` +- Fresh unit/transport logs: `.compozy/tasks/core-tasks/runtime/full-suite-20260414-160548/logs/case-suite-unit.json`, `.log` +- Fresh integration logs: `.compozy/tasks/core-tasks/runtime/full-suite-20260414-160548/logs/case-suite-integration.json`, `.log` +- Live CLI/API suite on isolated daemon: `.compozy/tasks/core-tasks/runtime/full-suite-20260414-160548/live/live-summary.json` +- Live security suite: `.compozy/tasks/core-tasks/runtime/full-suite-20260414-160548/security/tc-sec-006-007-summary.json` +- Performance suite: `.compozy/tasks/core-tasks/runtime/full-suite-20260414-160548/perf/perf-summary.json` +- Final repository gate log: `.compozy/tasks/core-tasks/logs/make-verify-final.log` + +Notable fixes validated in this round: +- Automation fixtures now use valid `network_channel` values instead of dotted channel names. +- Bridge test manifests now emit required `[bridge]` metadata when bridge-adapter capability is requested. +- Daemon/session shutdown no longer races store shutdown against `session.post_stop` finalization. +- `observe.Health()` no longer reloads the full task snapshot three times; the health path now reuses one snapshot, which moved `TC-PERF-006` from FAIL to PASS. + +BROWSER EVIDENCE (when Web UI flows were tested) +------------------------------------------------- +Dev server: isolated AGH daemon with `AGH_HOME=/tmp/agh-core-qa-post-verify-56451` serving `http://127.0.0.1:56451` +Flows tested: 2 +Flow details: + - Automation job creation: `http://127.0.0.1:56451/automation` -> `http://127.0.0.1:56451/automation` | Verdict: PASS + Evidence: `.compozy/tasks/core-tasks/screenshots/ui-automation-post-verify-final.png` + - Network disabled state: `http://127.0.0.1:56451/network` -> `http://127.0.0.1:56451/network` | Verdict: PASS + Evidence: `.compozy/tasks/core-tasks/screenshots/ui-network-post-verify-final.png` +Viewports tested: default only +Authentication: not required +Blocked flows: none +Note: the final follow-up after browser validation only touched backend internals (`internal/observe`, `internal/task`) and test/QA artifacts; no web assets changed after the recorded browser pass. + +TEST CASE COVERAGE (when qa-report artifacts exist) +---------------------------------------------------------- +Test cases found: 69 +Executed: 69 +Results: + - PASS: 68 + - FAIL: 1 (`TC-SEC-003`) +Coverage details: `.compozy/tasks/core-tasks/case-execution-matrix.md` + +ISSUES FILED +------------- +Total: 1 +By severity: + - Critical: 1 + - High: 0 + - Medium: 0 + - Low: 0 +Details: + - `.compozy/tasks/core-tasks/issues/BUG-001.md` — `TC-SEC-003`: HTTP task endpoints accept unauthenticated requests diff --git a/.compozy/tasks/core-tasks/reviews-001/_meta.md b/.compozy/tasks/core-tasks/reviews-001/_meta.md new file mode 100644 index 000000000..92941ad26 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/_meta.md @@ -0,0 +1,11 @@ +--- +provider: coderabbit +pr: "19" +round: 1 +created_at: 2026-04-14T15:15:47.774702Z +--- + +## Summary +- Total: 31 +- Resolved: 31 +- Unresolved: 0 diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_001.md b/.compozy/tasks/core-tasks/reviews-001/issue_001.md new file mode 100644 index 000000000..75dbd8bb5 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_001.md @@ -0,0 +1,29 @@ +--- +status: resolved +file: internal/api/contract/contract_test.go +line: 452 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4106878777,nitpick_hash:1e1d2da2ea92 +review_hash: 1e1d2da2ea92 +source_review_id: "4106878777" +source_review_submitted_at: "2026-04-14T14:46:54Z" +--- + +# Issue 001: Use t.Run("Should...") consistently for the new task contract tests. +## Review Comment + +This block introduces bare task JSON-shape tests and short case names like `"empty"` / `"title"`, which drifts from the repo’s required subtest convention. + +As per coding guidelines, "MUST use t.Run("Should...") pattern for ALL test cases". + +## Triage + +- Decision: `valid` +- Root cause: the new task contract coverage was added as flat tests, and the `UpdateTaskRequest.HasChanges` table still uses terse case labels like `"empty"` and `"title"` instead of the required `Should...` convention. +- Fix approach: wrap the new task contract assertions in `t.Run("Should...")` subtests and rename the task change-detection cases to descriptive `Should...` names. + +## Resolution + +- Wrapped the new task contract JSON-shape assertions in `Should...` subtests and renamed the `UpdateTaskRequest.HasChanges` cases to descriptive `Should...` labels. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_002.md b/.compozy/tasks/core-tasks/reviews-001/issue_002.md new file mode 100644 index 000000000..1bc2a8d26 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_002.md @@ -0,0 +1,111 @@ +--- +status: resolved +file: internal/api/contract/tasks.go +line: 25 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562anN,comment:PRRC_kwDOR5y4QM63mgQP +--- + +# Issue 002: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +
+🧩 Analysis chain + +🏁 Script executed: + +```shell +# Find and examine the tasks.go file +fd -type f -name 'tasks.go' | head -20 +``` + +Repository: compozy/agh + +Length of output: 225 + +--- + +🏁 Script executed: + +```shell +# Read the specific file mentioned in the review +head -100 internal/api/contract/tasks.go +``` + +Repository: compozy/agh + +Length of output: 4790 + +--- + +🏁 Script executed: + +```shell +# Check lines around the mentioned line numbers +sed -n '20,50p' internal/api/contract/tasks.go +sed -n '65,75p' internal/api/contract/tasks.go +``` + +Repository: compozy/agh + +Length of output: 2265 + +--- + +🏁 Script executed: + +```shell +# Search for any custom JSON marshaling in the file +rg -n 'MarshalJSON|UnmarshalJSON' internal/api/contract/ +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + + + +
+ +**Use `*time.Time` or custom marshaling for optional timestamps.** + +Go's `encoding/json` does not treat zero-valued `time.Time` as empty for `omitempty`, so unset fields like `closed_at`, `claimed_at`, `started_at`, and `ended_at` will serialize as `0001-01-01T00:00:00Z`. This breaks the API contract by leaking sentinel values to clients instead of truly omitting optional fields. + +Apply to: `TaskSummaryPayload.ClosedAt` (line 25), `TaskPayload.ClosedAt` (line 44), and `TaskRunPayload.ClaimedAt`, `StartedAt`, `EndedAt` (lines 68-70). + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/api/contract/tasks.go` at line 25, Task timestamp fields are +currently time.Time so omitempty won't work; change TaskSummaryPayload.ClosedAt +and TaskPayload.ClosedAt, and TaskRunPayload.ClaimedAt, StartedAt, EndedAt to +use *time.Time (or implement custom JSON marshaling) so unset values serialize +as omitted. Update any constructors/assignment sites that populate these fields +to take pointers (use &t for existing time values and nil for absent values) and +ensure JSON consumers/validators expect nullable timestamps. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: `TaskSummaryPayload`, `TaskPayload`, and `TaskRunPayload` model optional timestamps as value `time.Time`, so `omitempty` still serializes zero values as `0001-01-01T00:00:00Z`. +- Fix approach: change optional contract timestamp fields to `*time.Time`, map zero-valued task/task-run timestamps to `nil` in the API conversion helpers, and add JSON-shape regression coverage for omitted timestamps. + +## Resolution + +- Changed the shared optional task and task-run timestamp payload fields to pointers, mapped zero values to `nil` in the core and extension converters, and added JSON-shape regression coverage for omitted timestamps. +- Applied the minimal required out-of-scope follow-up in `internal/cli/task.go` so the CLI renders the shared pointer-based timestamps correctly after the contract change. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_003.md b/.compozy/tasks/core-tasks/reviews-001/issue_003.md new file mode 100644 index 000000000..47b7f3686 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_003.md @@ -0,0 +1,447 @@ +--- +status: resolved +file: internal/api/core/errors_test.go +line: 196 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562anW,comment:PRRC_kwDOR5y4QM63mgQZ +--- + +# Issue 003: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +
+🧩 Analysis chain + +🏁 Script executed: + +```shell +# First, let's look at the full test function context around lines 195-196 +cd /tmp && find . -name "errors_test.go" -path "*/api/core/*" 2>/dev/null | head -1 | xargs wc -l +``` + +Repository: compozy/agh + +Length of output: 56 + +--- + +🏁 Script executed: + +```shell +# Read the test file to understand the context +cat -n internal/api/core/errors_test.go | sed -n '180,210p' +``` + +Repository: compozy/agh + +Length of output: 1136 + +--- + +🏁 Script executed: + +```shell +# Get more context on the test function structure +cat -n internal/api/core/errors_test.go | sed -n '150,220p' +``` + +Repository: compozy/agh + +Length of output: 2946 + +--- + +🏁 Script executed: + +```shell +# Let's also look at the errors implementation to understand what's being tested +cat -n internal/api/core/errors.go | head -100 +``` + +Repository: compozy/agh + +Length of output: 4019 + +--- + +🏁 Script executed: + +```shell +# Check the Respond or error handling function being tested +rg -n "func.*Respond" internal/api/core/errors.go | head -20 +``` + +Repository: compozy/agh + +Length of output: 141 + +--- + +🏁 Script executed: + +```shell +# Verify Go httptest.ResponseRecorder behavior with invalid status codes +python3 << 'EOF' +# Check Go standard library documentation behavior +# httptest.ResponseRecorder.WriteHeader panics if code is not in [100, 599] +# http.StatusText returns empty string for codes outside standard ranges + +import subprocess +import sys + +# Verify http.StatusText behavior for edge cases +result = subprocess.run(['go', 'run', '-'], input=b''' +package main +import ( + "fmt" + "net/http" +) +func main() { + fmt.Printf("http.StatusText(0) = %q\\n", http.StatusText(0)) + fmt.Printf("http.StatusText(200) = %q\\n", http.StatusText(200)) + fmt.Printf("http.StatusText(599) = %q\\n", http.StatusText(599)) + fmt.Printf("http.StatusText(600) = %q\\n", http.StatusText(600)) +} +''', capture_output=True, text=True, cwd='/tmp') + +print("StatusText validation:") +print(result.stdout) +if result.stderr: + print("Error:", result.stderr) + +# Now check ResponseRecorder behavior +result2 = subprocess.run(['go', 'run', '-'], input=b''' +package main +import ( + "fmt" + "net/http/httptest" +) +func main() { + // Test what happens with status 0 + rec := httptest.NewRecorder() + defer func() { + if r := recover(); r != nil { + fmt.Printf("WriteHeader(0) panics: %v\\n", r) + } else { + fmt.Printf("WriteHeader(0) succeeded\\n") + } + }() + rec.WriteHeader(0) +} +''', capture_output=True, text=True, cwd='/tmp') + +print("\nResponseRecorder.WriteHeader behavior:") +print(result2.stdout) +if result2.stderr: + print("Error:", result2.stderr) +EOF +``` + +Repository: compozy/agh + +Length of output: 862 + +--- + +🏁 Script executed: + +```shell +# Verify Go httptest.ResponseRecorder behavior with invalid status codes +python3 << 'EOF' +import subprocess + +# Check http.StatusText behavior for edge cases +code = ''' +package main +import ( + "fmt" + "net/http" +) +func main() { + fmt.Printf("http.StatusText(0) = %q\\n", http.StatusText(0)) + fmt.Printf("http.StatusText(200) = %q\\n", http.StatusText(200)) + fmt.Printf("http.StatusText(599) = %q\\n", http.StatusText(599)) + fmt.Printf("http.StatusText(600) = %q\\n", http.StatusText(600)) +} +''' + +result = subprocess.run(['go', 'run', '-'], input=code, capture_output=True, text=True, cwd='/tmp') +print("StatusText validation:") +print(result.stdout) +if result.stderr: + print("Stderr:", result.stderr) + +# Now check ResponseRecorder behavior with invalid codes +code2 = ''' +package main +import ( + "fmt" + "net/http/httptest" +) +func main() { + // Test what happens with status 0 + rec := httptest.NewRecorder() + defer func() { + if r := recover(); r != nil { + fmt.Printf("WriteHeader(0) panics: %v\\n", r) + } else { + fmt.Printf("WriteHeader(0) succeeded\\n") + } + }() + rec.WriteHeader(0) +} +''' + +result2 = subprocess.run(['go', 'run', '-'], input=code2, capture_output=True, text=True, cwd='/tmp') +print("\nResponseRecorder.WriteHeader behavior with status 0:") +print(result2.stdout) +if result2.stderr: + print("Stderr:", result2.stderr) + +# Test with valid status 599 +code3 = ''' +package main +import ( + "fmt" + "net/http/httptest" +) +func main() { + rec := httptest.NewRecorder() + defer func() { + if r := recover(); r != nil { + fmt.Printf("WriteHeader(599) panics: %v\\n", r) + } else { + fmt.Printf("WriteHeader(599) succeeded, Code=%d\\n", rec.Code) + } + }() + rec.WriteHeader(599) +} +''' + +result3 = subprocess.run(['go', 'run', '-'], input=code3, capture_output=True, text=True, cwd='/tmp') +print("\nResponseRecorder.WriteHeader behavior with status 599:") +print(result3.stdout) +if result3.stderr: + print("Stderr:", result3.stderr) +EOF +``` + +Repository: compozy/agh + +Length of output: 286 + +--- + +🏁 Script executed: + +```shell +# Let's verify the Go standard library behavior directly by checking documentation and source +# Create a simple test file to verify the behavior + +cat > /tmp/test_status.go << 'EOF' +package main +import ( + "fmt" + "net/http" + "net/http/httptest" +) +func main() { + // Check StatusText values + fmt.Printf("http.StatusText(0) = %q\n", http.StatusText(0)) + fmt.Printf("http.StatusText(200) = %q\n", http.StatusText(200)) + fmt.Printf("http.StatusText(599) = %q\n", http.StatusText(599)) + fmt.Printf("http.StatusText(600) = %q\n", http.StatusText(600)) + + // Test ResponseRecorder with status 0 + rec := httptest.NewRecorder() + defer func() { + if r := recover(); r != nil { + fmt.Printf("WriteHeader(0) panics: %v\n", r) + } + }() + rec.WriteHeader(0) +} +EOF + +cd /tmp && go run test_status.go 2>&1 +``` + +Repository: compozy/agh + +Length of output: 208 + +--- + +🏁 Script executed: + +```shell +# Also check the Gin source behavior with c.JSON +# Look at how Gin's c.JSON handles status codes +rg -A 5 "func.*JSON.*gin.Context" --type go | head -50 +``` + +Repository: compozy/agh + +Length of output: 379 + +--- + +🏁 Script executed: + +```shell +# Check the actual test behavior - run the test to see if it panics +cd / && go test -v internal/api/core -run TestRespondErrorFallbackBranches 2>&1 | head -100 +``` + +Repository: compozy/agh + +Length of output: 195 + +--- + +🏁 Script executed: + +```shell +# Verify that Gin's c.JSON calls WriteHeader and would trigger the panic +# Check Gin's implementation to understand the flow + +cat > /tmp/test_gin_json.go << 'EOF' +package main +import ( + "fmt" + "net/http/httptest" + "github.com/gin-gonic/gin" +) +func main() { + // Create a test context with status 0 + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + + defer func() { + if r := recover(); r != nil { + fmt.Printf("c.JSON with status 0 panics: %v\n", r) + } else { + fmt.Printf("c.JSON with status 0 succeeded\n") + } + }() + + // Try to call JSON with status 0 + ctx.JSON(0, map[string]string{"error": "test"}) +} +EOF + +cd / && go run /tmp/test_gin_json.go 2>&1 +``` + +Repository: compozy/agh + +Length of output: 232 + +--- + +🏁 Script executed: + +```shell +# Also verify with status 599 that it works +cat > /tmp/test_gin_json_599.go << 'EOF' +package main +import ( + "fmt" + "net/http/httptest" + "github.com/gin-gonic/gin" +) +func main() { + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + + defer func() { + if r := recover(); r != nil { + fmt.Printf("c.JSON with status 599 panics: %v\n", r) + } else { + fmt.Printf("c.JSON with status 599 succeeded, Code=%d\n", recorder.Code) + } + }() + + // Try to call JSON with status 599 + ctx.JSON(599, map[string]string{"error": "test"}) +} +EOF + +cd / && go run /tmp/test_gin_json_599.go 2>&1 +``` + +Repository: compozy/agh + +Length of output: 236 + +--- + + + +
+ +**`status: 0` will panic instead of exercising the fallback.** + +Gin's `c.JSON` calls `WriteHeader(code)` internally, and Go's `httptest.ResponseRecorder.WriteHeader` panics for codes outside the valid range (100–599). The test case at line 195 will panic during `RespondError(ctx, 0, ...)` before reaching any assertions. Use status `599` instead—it's valid, produces an empty `http.StatusText()` like 0 would, and correctly tests the `"unknown error"` fallback branch. + +
+Proposed test fix + +```diff +- {name: "unknown error fallback", status: 0, err: nil, mask: false, wantErr: "unknown error", wantStatus: 200}, ++ {name: "unknown error fallback", status: 599, err: nil, mask: false, wantErr: "unknown error", wantStatus: 599}, +``` +
+ + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion + {name: "unknown error fallback", status: 599, err: nil, mask: false, wantErr: "unknown error", wantStatus: 599}, + {name: "masked internal fallback", status: 599, err: nil, mask: true, wantErr: "internal server error", wantStatus: 599}, +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/api/core/errors_test.go` around lines 195 - 196, The test uses an +invalid status code 0 which causes httptest.ResponseRecorder.WriteHeader to +panic when RespondError(ctx, 0, ...) is called; update the test case (the entry +with name "unknown error fallback") to use a valid status code such as 599 +instead of 0 so the test exercises the unknown-error fallback path in +RespondError without panicking and still yields an empty http.StatusText() for +assertion. +``` + +
+ + + + + +## Triage + +- Decision: `invalid` +- Root cause check: reproduced the reported scenario with `go test ./internal/api/core -run TestRespondErrorFallbackBranches -count=1`, and the test passes on the current code. +- Why invalid: `RespondError` already covers the fallback branches correctly here; with Gin plus `httptest.ResponseRecorder`, the `status=0` branch retains the recorder's default HTTP 200 instead of panicking, so the review comment does not describe an actual bug in this test. + +## Resolution + +- No code change was required because the reported behavior is not reproducible on the current code path. +- The batch still passed the final `make verify` run unchanged for this issue. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_004.md b/.compozy/tasks/core-tasks/reviews-001/issue_004.md new file mode 100644 index 000000000..2b9e6f4ae --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_004.md @@ -0,0 +1,27 @@ +--- +status: resolved +file: internal/api/core/tasks.go +line: 1077 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4106878777,nitpick_hash:c7253e5eaa27 +review_hash: c7253e5eaa27 +source_review_id: "4106878777" +source_review_submitted_at: "2026-04-14T14:46:54Z" +--- + +# Issue 004: In-memory filtering may not scale well for large task run sets. +## Review Comment + +The `filterTaskRuns` function loads all runs from the view and filters in-memory. For tasks with many runs, this could impact performance. Consider whether the `ListTaskRuns` endpoint should delegate filtering to the store layer with proper SQL clauses. + +## Triage + +- Decision: `invalid` +- Root cause check: this is a performance suggestion, not a correctness defect in the current implementation. +- Why invalid: `ListTaskRuns` filters the already-loaded task view in memory and respects `status`, `session_id`, and `limit` correctly. Pushing those filters into the store would require a broader API redesign without a failing behavior to fix in this batch. + +## Resolution + +- No localized correctness fix was warranted for this batch because the comment describes a broader performance redesign, not a failing behavior. +- The batch still passed the final `make verify` run unchanged for this issue. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_005.md b/.compozy/tasks/core-tasks/reviews-001/issue_005.md new file mode 100644 index 000000000..ba98ba38e --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_005.md @@ -0,0 +1,29 @@ +--- +status: resolved +file: internal/api/core/tasks.go +line: 1122 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4106878777,nitpick_hash:5dddb7599a61 +review_hash: 5dddb7599a61 +source_review_id: "4106878777" +source_review_submitted_at: "2026-04-14T14:46:54Z" +--- + +# Issue 005: Consider nil slice handling for cloned raw message. +## Review Comment + +The `cloneRawMessage` function (called on line 1126) should handle the case where the source `json.RawMessage` is an empty slice vs nil. Currently, if `*source` is `[]byte{}`, it will be passed to `cloneRawMessage` which returns `nil` for empty slices. Verify this is the intended behavior for JSON semantics (empty vs absent). + +--- + +## Triage + +- Decision: `invalid` +- Root cause check: `cloneRawMessage` intentionally normalizes zero-length `json.RawMessage` values to `nil`. +- Why invalid: a zero-length raw message is not valid JSON, and for these `omitempty` payload fields the API does not rely on distinguishing `[]byte{}` from `nil`. Preserving an empty byte slice would only risk emitting invalid/ambiguous payload state. + +## Resolution + +- No code change was required because the current normalization is intentional and matches the API's JSON semantics. +- The batch still passed the final `make verify` run unchanged for this issue. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_006.md b/.compozy/tasks/core-tasks/reviews-001/issue_006.md new file mode 100644 index 000000000..0c6be4ecd --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_006.md @@ -0,0 +1,52 @@ +--- +status: resolved +file: internal/api/core/tasks_internal_test.go +line: 205 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562anq,comment:PRRC_kwDOR5y4QM63mgQ0 +--- + +# Issue 006: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**Assert the expected failure, not just “some error”.** + +These branches only check `err != nil`, so the tests still pass if a helper starts returning the wrong validation or decode error. Pin each case to the expected error type/message to keep the coverage meaningful. + +As per coding guidelines, `MUST have specific error assertions (ErrorContains, ErrorAs)`. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/api/core/tasks_internal_test.go` around lines 168 - 205, The tests +currently only assert err != nil; change each assertion to verify the specific +expected error type or message (using ErrorContains/ErrorAs or errors.Is) for +the functions attachTaskRunSessionIDFromRequest, failTaskRunFromRequest, +validateTaskChannel, enqueueTaskRunFromRequest, requiredPathID, +handlers.parseTaskListQuery, parseTaskRunListQuery, and decodeOptionalJSON so +they fail if a different error is returned; replace generic nil checks with +targeted assertions that match the exact validation/decoding error text or +exported error value for each case. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: the negative helper coverage only asserts `err != nil`, so the tests would still pass if those helpers started returning the wrong validation or decode error. +- Fix approach: assert the expected sentinel or error substring for each negative case so the tests only pass when the correct validation path fires. + +## Resolution + +- Strengthened the negative helper coverage to assert the task validation sentinel and the expected error substrings instead of only checking for a non-nil error. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_007.md b/.compozy/tasks/core-tasks/reviews-001/issue_007.md new file mode 100644 index 000000000..a283df1fa --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_007.md @@ -0,0 +1,29 @@ +--- +status: resolved +file: internal/api/httpapi/httpapi_integration_test.go +line: 1033 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4106878777,nitpick_hash:a77a821ada7e +review_hash: a77a821ada7e +source_review_id: "4106878777" +source_review_submitted_at: "2026-04-14T14:46:54Z" +--- + +# Issue 007: Split the run-lifecycle matrix into subtests. +## Review Comment + +This single test covers three independent flows (`complete`, `attach-session/fail`, `cancel`). When one step breaks, the rest of the route coverage disappears behind the first failure. + +As per coding guidelines, `**/*_test.go`: Use table-driven tests with subtests (t.Run) as default in Go tests. + +## Triage + +- Decision: `valid` +- Root cause: `TestHTTPTaskRunLifecycleRoutesRoundTrip` bundles three independent lifecycle flows into one long sequence, so the first failure hides the rest of the route coverage. +- Fix approach: split the complete, attach/fail, and cancel flows into isolated `Should...` subtests that each assert one lifecycle path end to end. + +## Resolution + +- Split the HTTP task-run lifecycle integration coverage into isolated `Should...` subtests for complete, attach/fail, and cancel flows. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_008.md b/.compozy/tasks/core-tasks/reviews-001/issue_008.md new file mode 100644 index 000000000..ce5e31752 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_008.md @@ -0,0 +1,95 @@ +--- +status: resolved +file: internal/api/httpapi/httpapi_integration_test.go +line: 1192 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562anr,comment:PRRC_kwDOR5y4QM63mgQ4 +--- + +# Issue 008: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Protect the session counter from concurrent access.** + +`StartTaskSession` increments `started` without synchronization. These HTTP tests exercise a real server, so concurrent starts can race here and make `-race` fail or reuse session IDs. + + +
+Suggested fix + +```diff + type integrationTaskSessionExecutor struct { ++ mu sync.Mutex + started int + } + + func (e *integrationTaskSessionExecutor) StartTaskSession(_ context.Context, _ taskpkg.StartTaskSession) (*taskpkg.SessionRef, error) { ++ e.mu.Lock() ++ defer e.mu.Unlock() + e.started++ + return &taskpkg.SessionRef{SessionID: fmt.Sprintf("task-sess-%d", e.started)}, nil + } +``` +
+As per coding guidelines, `**/*_test.go`: Run tests with -race flag before committing — zero tolerance for race conditions. + + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion +type integrationTaskSessionExecutor struct { + mu sync.Mutex + started int +} + +func (e *integrationTaskSessionExecutor) StartTaskSession(_ context.Context, _ taskpkg.StartTaskSession) (*taskpkg.SessionRef, error) { + e.mu.Lock() + defer e.mu.Unlock() + e.started++ + return &taskpkg.SessionRef{SessionID: fmt.Sprintf("task-sess-%d", e.started)}, nil +} +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/api/httpapi/httpapi_integration_test.go` around lines 1185 - 1192, +The StartTaskSession method on integrationTaskSessionExecutor increments the +started counter without synchronization which can race under concurrent HTTP +tests; fix by making started a thread-safe counter (e.g., change started to an +int32/int64 and use atomic.AddInt32/AddInt64 in StartTaskSession, or add a +sync.Mutex on integrationTaskSessionExecutor and lock/unlock while +incrementing/reading started) so that StartTaskSession and the SessionID +generation are safe under concurrent calls. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: `integrationTaskSessionExecutor.StartTaskSession` increments shared mutable state without synchronization even though the integration server can service concurrent requests under `-race`. +- Fix approach: make the counter increment and session-id generation thread-safe. + +## Resolution + +- Guarded the integration task-session executor counter with a mutex so the test harness is race-safe under concurrent request handling. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_009.md b/.compozy/tasks/core-tasks/reviews-001/issue_009.md new file mode 100644 index 000000000..dfde3d389 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_009.md @@ -0,0 +1,52 @@ +--- +status: resolved +file: internal/api/udsapi/routes.go +line: 113 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562anu,comment:PRRC_kwDOR5y4QM63mgQ9 +--- + +# Issue 009: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**New task endpoints are not reflected in the canonical OpenAPI registry.** + +`RegisterRoutes` now exposes `/api/tasks` and `/api/task-runs`, but the provided `internal/api/spec/spec.go` `Operations()` set does not include those paths. This creates API contract drift for generated docs/clients and schema-based validation. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/api/udsapi/routes.go` around lines 91 - 113, RegisterRoutes now +exposes /api/tasks and /api/task-runs but the OpenAPI registry returned by +internal/api/spec/spec.go::Operations() is missing those paths; update +Operations() to add path objects and operation entries for all task-related +endpoints (map HTTP methods and operationIds to CreateTask, ListTasks, GetTask, +UpdateTask, CancelTask, CreateChildTask, AddTaskDependency, +RemoveTaskDependency, EnqueueTaskRun, ListTaskRuns and for task-runs: +ClaimTaskRun, StartTaskRun, AttachTaskRunSession, CompleteTaskRun, FailTaskRun, +CancelTaskRun) so the canonical spec matches the handlers and methods defined in +RegisterRoutes. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: `internal/api/udsapi/routes.go` now exposes task and task-run endpoints, but `internal/api/spec/spec.go::Operations()` does not register any of them, so the canonical OpenAPI document has drifted from the live transport. +- Fix approach: add the missing task/task-run operations to the spec registry. This requires a minimal out-of-scope edit in `internal/api/spec/spec.go` because there is no in-scope file that owns the canonical OpenAPI registry. + +## Resolution + +- Added the missing task and task-run operations plus task-domain enums to the canonical OpenAPI registry, and expanded the spec tests to assert the new paths and schemas. +- Regenerated the canonical outputs with `make codegen`, which updated the required out-of-scope generated artifacts `openapi/agh.json` and `web/src/generated/agh-openapi.d.ts`. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_010.md b/.compozy/tasks/core-tasks/reviews-001/issue_010.md new file mode 100644 index 000000000..f6d6a979d --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_010.md @@ -0,0 +1,55 @@ +--- +status: resolved +file: internal/api/udsapi/server_test.go +line: 113 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562an2,comment:PRRC_kwDOR5y4QM63mgRG +--- + +# Issue 010: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**These constructor cases don't isolate the missing dependency.** + +The "missing session manager", "missing task service", and "missing observer" branches each omit multiple required options and only assert `err != nil`, so they can pass for the wrong reason if constructor validation order changes. Add the other required deps in each case and assert the specific error you expect. + +As per coding guidelines, `**/*_test.go`: MUST have specific error assertions (ErrorContains, ErrorAs). + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/api/udsapi/server_test.go` around lines 103 - 113, The test cases +calling New(...) in server_test.go currently omit multiple required options so +failures are ambiguous; update each case to include all other required +dependencies except the one under test (use WithHomePaths, +WithSessionManager(stubSessionManager{}), WithTaskService(stubTaskManager{}), +WithObserver(stubObserver{}), WithWorkspaceResolver(stubWorkspaceResolver{}) as +appropriate) so only the intended missing dependency triggers the error, and +replace generic nil-checks with specific assertions (t.ErrorContains or +errors.As) that verify the exact error value or message returned by New for the +missing dependency (reference New, WithSessionManager, WithTaskService, +WithObserver, WithWorkspaceResolver and the stub* types to locate and fix each +case). +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: the constructor tests omit multiple required dependencies at once and only assert that some error occurred, which makes the failure source ambiguous. +- Fix approach: isolate each missing dependency by supplying the others and assert the exact constructor error string returned by `New`. + +## Resolution + +- Reworked the constructor coverage into isolated `Should...` cases that provide all unrelated dependencies and assert the specific missing-dependency error message. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_011.md b/.compozy/tasks/core-tasks/reviews-001/issue_011.md new file mode 100644 index 000000000..678987b03 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_011.md @@ -0,0 +1,88 @@ +--- +status: resolved +file: internal/automation/dispatch.go +line: 456 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562an8,comment:PRRC_kwDOR5y4QM63mgRN +--- + +# Issue 011: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Task-backed dispatch drops the pre-fire hook’s prompt rewrite.** + +`dispatchPreFireHook` returns the patched prompt, but this path discards it and `directTaskSpec` still falls back to `job.Prompt`. Any hook-based prompt rewrite/sanitization never reaches the delegated task payload. + +
+💡 Proposed fix + +```diff +- _, cancelled, hookErr := d.dispatchPreFireHook(ctx, req, preFirePrompt, attempt) ++ prompt, cancelled, hookErr := d.dispatchPreFireHook(ctx, req, preFirePrompt, attempt) + if hookErr != nil { + return d.finishRun(ctx, scheduledRun, RunFailed, hookErr) + } + if cancelled { + return d.finishRun(ctx, scheduledRun, RunCancelled, nil) + } +@@ +- taskRecord, err := d.tasks.CreateTask(ctx, directTaskSpec(req.Job), actor) ++ taskRecord, err := d.tasks.CreateTask(ctx, directTaskSpec(req.Job, prompt), actor) +``` + +```diff +-func directTaskSpec(job *Job) taskpkg.CreateTask { ++func directTaskSpec(job *Job, prompt string) taskpkg.CreateTask { + if job == nil || job.Task == nil { + return taskpkg.CreateTask{} + } +@@ + description := strings.TrimSpace(job.Task.Description) + if description == "" { +- description = strings.TrimSpace(job.Prompt) ++ description = strings.TrimSpace(prompt) ++ } ++ if description == "" { ++ description = strings.TrimSpace(job.Prompt) + } +``` +
+ + +Also applies to: 469-472, 920-923 + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/automation/dispatch.go` around lines 452 - 456, The pre-fire hook's +rewritten prompt returned by dispatchPreFireHook is being ignored; update the +call sites (where preFirePrompt is set and where directTaskSpec is constructed) +to assign the returned patched prompt (the first return value) back into +preFirePrompt and use that variable when building directTaskSpec instead of +falling back to req.Job.Prompt or req.Prompt; specifically, capture the +patchedPrompt from dispatchPreFireHook and pass that into the delegated task +payload so hook-based rewrites/sanitization are preserved (adjust the +occurrences around dispatchPreFireHook and where directTaskSpec is populated). +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: task-backed dispatch ignores the prompt returned by `dispatchPreFireHook` and still builds the durable task from the original job prompt. +- Fix approach: propagate the rewritten prompt through the task-backed path and use it when constructing the delegated task payload. + +## Resolution + +- Propagated the rewritten pre-fire prompt through task-backed dispatch and added a regression test that asserts the durable delegated task description uses the hook-rewritten prompt. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_012.md b/.compozy/tasks/core-tasks/reviews-001/issue_012.md new file mode 100644 index 000000000..64016dca9 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_012.md @@ -0,0 +1,51 @@ +--- +status: resolved +file: internal/automation/dispatch.go +line: 489 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562an9,comment:PRRC_kwDOR5y4QM63mgRO +--- + +# Issue 012: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Retries can create duplicate or orphaned tasks before enqueue succeeds.** + +`CreateTask` happens before the idempotent `EnqueueRun`. If enqueue fails after a successful create, the retry path will create another task for the same automation run, leaving the previous task orphaned. This needs an idempotent task identity derived from `scheduledRun.ID`, or a task-service operation that creates the task and initial run atomically. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/automation/dispatch.go` around lines 469 - 489, The current flow +calls tasks.CreateTask (directTaskSpec) before the idempotent tasks.EnqueueRun, +so retries can create duplicate/orphaned tasks; change to an idempotent creation +path by deriving a stable task identity from scheduledRun.ID (e.g., pass a +TaskID or idempotency key computed from scheduledRun.ID into tasks.CreateTask or +use a new tasks.CreateOrGetTask/CreateTaskWithInitialRun API that atomically +creates the task and first run), or update the call sites (directTaskSpec, +automationTaskRunIdempotencyKey, tasks.CreateTask, tasks.EnqueueRun, +delegateRun) to use a single atomic operation on the tasks service that returns +both task and run, ensuring retries are safe and no orphaned tasks are produced. +``` + +
+ + + + + +## Triage + +- Decision: `invalid` +- Root cause check: the current flow intentionally materializes the durable task before enqueueing its first run. +- Why invalid: a task without an initial run is still a valid durable artifact in this model, and retries create distinct automation attempt records with distinct run IDs rather than duplicate writes for the same persisted attempt. A truly atomic task-plus-run API would be broader design work, not a localized correctness fix inside this batch. + +## Resolution + +- No code change was required because the current durable-task-first flow is intentional and consistent with the task model. +- The batch still passed the final `make verify` run unchanged for this issue. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_013.md b/.compozy/tasks/core-tasks/reviews-001/issue_013.md new file mode 100644 index 000000000..748e8d747 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_013.md @@ -0,0 +1,52 @@ +--- +status: resolved +file: internal/automation/dispatch_test.go +line: 267 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562an5,comment:PRRC_kwDOR5y4QM63mgRK +--- + +# Issue 013: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**Assert the expected missing-task-service failure.** + +This only checks `err != nil`, so the test still passes if `Dispatch` fails for some unrelated reason before it reaches the task-service availability branch. Please assert the expected sentinel or message with `errors.Is` / `ErrorContains`. + +As per coding guidelines, `**/*_test.go`: MUST have specific error assertions (ErrorContains, ErrorAs). + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/automation/dispatch_test.go` around lines 261 - 267, The test +currently only checks that dispatcher.Dispatch returned a non-nil error; change +it to assert the specific "missing task service" failure by using errors.Is or +ErrorContains instead of a nil check: after calling dispatcher.Dispatch(...) +replace the generic err != nil check with an assertion that errors.Is(err, +) or +testutil.ErrorContains(err, ""), referencing the +Dispatch function call and DispatchRequest/DispatchKindManual/job variables so +the test fails if Dispatch errors for any other reason. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: the missing-task-service regression test only checks that `Dispatch` returns some error, so it could pass for an unrelated failure earlier in the dispatch path. +- Fix approach: assert the specific missing task service error/message alongside the failed run status. + +## Resolution + +- Tightened the missing-task-service regression test to assert the specific error message in addition to the failed automation run status. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_014.md b/.compozy/tasks/core-tasks/reviews-001/issue_014.md new file mode 100644 index 000000000..337e9281d --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_014.md @@ -0,0 +1,175 @@ +--- +status: resolved +file: internal/automation/manager_test.go +line: 622 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562an_,comment:PRRC_kwDOR5y4QM63mgRQ +--- + +# Issue 014: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Use required `t.Run("Should...")` structure for this new test case.** + +`TestManagerSessionTaskActorLifecycle` is added as a flat test body. This repo requires test cases to be expressed with `t.Run("Should...")`. + +
+♻️ Proposed refactor + +```diff + func TestManagerSessionTaskActorLifecycle(t *testing.T) { + t.Parallel() +- +- h := newManagerHarness(t) +- manager := h.newManager(t, aghconfig.AutomationConfig{ +- Enabled: true, +- Timezone: DefaultTimezone, +- MaxConcurrentJobs: DefaultMaxConcurrentJobs, +- DefaultFireLimit: DefaultFireLimitConfig(), +- }) +- +- actor, err := taskpkg.DeriveAutomationLinkedAgentSessionActorContext("sess-actor-1", "run:run-1") +- if err != nil { +- t.Fatalf("DeriveAutomationLinkedAgentSessionActorContext() error = %v", err) +- } +- if err := manager.RecordAutomationSessionTaskActor("sess-actor-1", actor); err != nil { +- t.Fatalf("RecordAutomationSessionTaskActor() error = %v", err) +- } +- +- loaded, err := manager.TaskActorContextForSession("sess-actor-1") +- if err != nil { +- t.Fatalf("TaskActorContextForSession() error = %v", err) +- } +- if loaded != actor { +- t.Fatalf("TaskActorContextForSession() = %#v, want %#v", loaded, actor) +- } +- +- manager.DeleteAutomationSessionTaskActor("sess-actor-1") +- if _, err := manager.TaskActorContextForSession("sess-actor-1"); !errors.Is(err, ErrSessionTaskActorNotFound) { +- t.Fatalf("TaskActorContextForSession(after delete) error = %v, want ErrSessionTaskActorNotFound", err) +- } ++ t.Run("Should record, load, and delete a session task actor", func(t *testing.T) { ++ t.Parallel() ++ ++ h := newManagerHarness(t) ++ manager := h.newManager(t, aghconfig.AutomationConfig{ ++ Enabled: true, ++ Timezone: DefaultTimezone, ++ MaxConcurrentJobs: DefaultMaxConcurrentJobs, ++ DefaultFireLimit: DefaultFireLimitConfig(), ++ }) ++ ++ actor, err := taskpkg.DeriveAutomationLinkedAgentSessionActorContext("sess-actor-1", "run:run-1") ++ if err != nil { ++ t.Fatalf("DeriveAutomationLinkedAgentSessionActorContext() error = %v", err) ++ } ++ if err := manager.RecordAutomationSessionTaskActor("sess-actor-1", actor); err != nil { ++ t.Fatalf("RecordAutomationSessionTaskActor() error = %v", err) ++ } ++ ++ loaded, err := manager.TaskActorContextForSession("sess-actor-1") ++ if err != nil { ++ t.Fatalf("TaskActorContextForSession() error = %v", err) ++ } ++ if loaded != actor { ++ t.Fatalf("TaskActorContextForSession() = %#v, want %#v", loaded, actor) ++ } ++ ++ manager.DeleteAutomationSessionTaskActor("sess-actor-1") ++ if _, err := manager.TaskActorContextForSession("sess-actor-1"); !errors.Is(err, ErrSessionTaskActorNotFound) { ++ t.Fatalf("TaskActorContextForSession(after delete) error = %v, want ErrSessionTaskActorNotFound", err) ++ } ++ }) + } +``` +
+ + +As per coding guidelines, `**/*_test.go`: MUST use t.Run("Should...") pattern for ALL test cases. + + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion +func TestManagerSessionTaskActorLifecycle(t *testing.T) { + t.Parallel() + + t.Run("Should record, load, and delete a session task actor", func(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t) + manager := h.newManager(t, aghconfig.AutomationConfig{ + Enabled: true, + Timezone: DefaultTimezone, + MaxConcurrentJobs: DefaultMaxConcurrentJobs, + DefaultFireLimit: DefaultFireLimitConfig(), + }) + + actor, err := taskpkg.DeriveAutomationLinkedAgentSessionActorContext("sess-actor-1", "run:run-1") + if err != nil { + t.Fatalf("DeriveAutomationLinkedAgentSessionActorContext() error = %v", err) + } + if err := manager.RecordAutomationSessionTaskActor("sess-actor-1", actor); err != nil { + t.Fatalf("RecordAutomationSessionTaskActor() error = %v", err) + } + + loaded, err := manager.TaskActorContextForSession("sess-actor-1") + if err != nil { + t.Fatalf("TaskActorContextForSession() error = %v", err) + } + if loaded != actor { + t.Fatalf("TaskActorContextForSession() = %#v, want %#v", loaded, actor) + } + + manager.DeleteAutomationSessionTaskActor("sess-actor-1") + if _, err := manager.TaskActorContextForSession("sess-actor-1"); !errors.Is(err, ErrSessionTaskActorNotFound) { + t.Fatalf("TaskActorContextForSession(after delete) error = %v, want ErrSessionTaskActorNotFound", err) + } + }) +} +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/automation/manager_test.go` around lines 591 - 622, The new test +TestManagerSessionTaskActorLifecycle violates the repo test convention by being +a flat test; wrap its assertions inside a t.Run("Should ...") subtest. Modify +TestManagerSessionTaskActorLifecycle to call t.Run with a descriptive "Should +..." name and move the existing setup and assertions (including calls to +newManager(...), taskpkg.DeriveAutomationLinkedAgentSessionActorContext, +manager.RecordAutomationSessionTaskActor, manager.TaskActorContextForSession and +manager.DeleteAutomationSessionTaskActor) into the subtest function body, +preserving the existing error checks and t.Fatalf calls. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: `TestManagerSessionTaskActorLifecycle` is written as one flat body instead of the required `t.Run("Should...")` structure. +- Fix approach: wrap the existing assertions in one descriptive `Should...` subtest without changing the covered behavior. + +## Resolution + +- Wrapped the automation session task-actor lifecycle assertions in one descriptive `Should...` subtest. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_015.md b/.compozy/tasks/core-tasks/reviews-001/issue_015.md new file mode 100644 index 000000000..e0217c333 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_015.md @@ -0,0 +1,97 @@ +--- +status: resolved +file: internal/automation/model/validate.go +line: 340 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562aoF,comment:PRRC_kwDOR5y4QM63mgRW +--- + +# Issue 015: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**`Run.Validate` still accepts one invalid delegated state.** + +This only checks the `task_id set / task_run_id missing` half of the invariant. `Run{Status: RunDelegated, TaskRunID: "tr-1"}` still passes model validation with an empty `TaskID`, even though the store later rejects it. Keep the model-layer rule symmetric so invalid delegated runs fail early. + + + +
+✅ Suggested validation alignment + +```diff +- if strings.TrimSpace(r.TaskID) != "" && strings.TrimSpace(r.TaskRunID) == "" && r.Status == RunDelegated { +- return fmt.Errorf("%s is required when %s is %q and %s is set", nestedPath(path, "task_run_id"), nestedPath(path, "status"), RunDelegated, nestedPath(path, "task_id")) +- } ++ if r.Status == RunDelegated { ++ if strings.TrimSpace(r.TaskID) == "" { ++ return fmt.Errorf("%s is required when %s is %q", nestedPath(path, "task_id"), nestedPath(path, "status"), RunDelegated) ++ } ++ if strings.TrimSpace(r.TaskRunID) == "" { ++ return fmt.Errorf("%s is required when %s is %q", nestedPath(path, "task_run_id"), nestedPath(path, "status"), RunDelegated) ++ } ++ } + return nil + } +``` + +
+ + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion + if r.Status == RunDelegated { + if strings.TrimSpace(r.TaskID) == "" { + return fmt.Errorf("%s is required when %s is %q", nestedPath(path, "task_id"), nestedPath(path, "status"), RunDelegated) + } + if strings.TrimSpace(r.TaskRunID) == "" { + return fmt.Errorf("%s is required when %s is %q", nestedPath(path, "task_run_id"), nestedPath(path, "status"), RunDelegated) + } + } + return nil +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/automation/model/validate.go` around lines 337 - 340, The current +Run.Validate only enforces that when TaskID is set and Status == RunDelegated, +TaskRunID must be present; add the symmetric check so that when TaskRunID is set +and Status == RunDelegated, TaskID must not be empty. In the Run.Validate +function (referencing r.TaskID, r.TaskRunID, r.Status and the RunDelegated +constant), add a second conditional mirroring the existing one that returns +fmt.Errorf(...) using nestedPath(path, "task_id") and nestedPath(path, +"task_run_id") to produce the appropriate error message when TaskRunID is +provided but TaskID is missing. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: `Run.Validate` only enforces `TaskRunID` when `TaskID` is present for delegated runs, but it does not enforce the symmetric `TaskID` requirement when `TaskRunID` is set. +- Fix approach: make delegated-run validation require both fields and add regression coverage for the missing `TaskID` case. + +## Resolution + +- Made delegated automation runs require both `task_id` and `task_run_id`, and added regression coverage for the missing-`task_id` branch. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_016.md b/.compozy/tasks/core-tasks/reviews-001/issue_016.md new file mode 100644 index 000000000..93689cdc2 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_016.md @@ -0,0 +1,31 @@ +--- +status: resolved +file: internal/cli/task_test.go +line: 151 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4106878777,nitpick_hash:cfeb56ec3e70 +review_hash: cfeb56ec3e70 +source_review_id: "4106878777" +source_review_submitted_at: "2026-04-14T14:46:54Z" +--- + +# Issue 016: Break the command-mapping coverage into table-driven subtests. +## Review Comment + +Each of these tests covers many unrelated commands in one flow, so the first failure hides the rest of the parsing surface. + +As per coding guidelines, `**/*_test.go`: Use table-driven tests with subtests (t.Run) as default in Go tests. + +Also applies to: 257-376 + +## Triage + +- Decision: `valid` +- Root cause: the CLI task command mapping tests pack many unrelated command paths into single monolithic flows, so one failure hides the rest of the parsing surface. +- Fix approach: convert those flows into table-driven `Should...` subtests so each command mapping is isolated. + +## Resolution + +- Reworked the CLI task command mapping coverage into table-driven `Should...` subtests so each create/list/run/mutation path fails independently. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_017.md b/.compozy/tasks/core-tasks/reviews-001/issue_017.md new file mode 100644 index 000000000..1272bfbd9 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_017.md @@ -0,0 +1,31 @@ +--- +status: resolved +file: internal/cli/task_test.go +line: 484 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4106878777,nitpick_hash:b832aeb7ce17 +review_hash: b832aeb7ce17 +source_review_id: "4106878777" +source_review_submitted_at: "2026-04-14T14:46:54Z" +--- + +# Issue 017: Make the sample run fixture match the requested status. +## Review Comment + +`sampleTaskRunRecord` populates end-state fields even for queued/claimed/starting runs. That makes it harder for these tests to catch status-dependent rendering or JSON-shaping bugs. + +As per coding guidelines, `**/*_test.go`: Ensure tests verify behavior outcomes, not just function calls. + +--- + +## Triage + +- Decision: `valid` +- Root cause: `sampleTaskRunRecord` currently populates claimed, started, ended, error, and result fields for every status, which can hide status-dependent rendering regressions in the CLI tests. +- Fix approach: make the fixture populate lifecycle fields conditionally based on the requested task-run status. + +## Resolution + +- Reworked `sampleTaskRunRecord` so claimed, session-bound, started, ended, error, and result fields are only populated for statuses that should actually carry them. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_018.md b/.compozy/tasks/core-tasks/reviews-001/issue_018.md new file mode 100644 index 000000000..5d81bd601 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_018.md @@ -0,0 +1,27 @@ +--- +status: resolved +file: internal/config/automation.go +line: 258 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4106878777,nitpick_hash:e6581020c011 +review_hash: e6581020c011 +source_review_id: "4106878777" +source_review_submitted_at: "2026-04-14T14:46:54Z" +--- + +# Issue 018: Avoid shallow-copying JobTaskConfig. +## Review Comment + +`taskCfg := *j.Task` only copies the top-level struct, so nested pointers remain shared between the parsed config and the runtime job. A small `cloneJobTaskConfig` helper would prevent runtime mutations from leaking back into config state. + +## Triage + +- Decision: `valid` +- Root cause: `taskCfg := *j.Task` only clones the top-level `JobTaskConfig`; nested pointer fields like `Owner` remain shared between parsed config state and runtime job state. +- Fix approach: add a local deep-clone helper for parsed task config and use it in `toAutomationJob`. + +## Resolution + +- Added a local task-config clone helper that also deep-copies the nested owner pointer before wiring parsed automation jobs into runtime state. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_019.md b/.compozy/tasks/core-tasks/reviews-001/issue_019.md new file mode 100644 index 000000000..84114daa2 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_019.md @@ -0,0 +1,284 @@ +--- +status: resolved +file: internal/daemon/daemon.go +line: 228 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562aoY,comment:PRRC_kwDOR5y4QM63mgRs +--- + +# Issue 019: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +
+🧩 Analysis chain + +🏁 Script executed: + +```shell +#!/bin/bash +# Check if taskRuntime has a Shutdown or Close method that should be called +ast-grep --pattern $'type taskRuntime struct { + $$$ +}' + +# Search for taskRuntime definition and any shutdown/close methods +rg -n "func.*taskRuntime.*Shutdown|func.*taskRuntime.*Close|func.*taskRuntime.*Stop" --type go +``` + +Repository: compozy/agh + +Length of output: 271 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Read the Daemon.Shutdown() method to see what it currently does +sed -n '565,669p' internal/daemon/daemon.go +``` + +Repository: compozy/agh + +Length of output: 2575 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Check what taskStore is and if it has cleanup methods +rg -n "type taskStore" --type go -A 5 +``` + +Repository: compozy/agh + +Length of output: 365 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Check if taskpkg.TaskManager has a Shutdown or Close method +rg -n "func.*TaskManager.*Shutdown|func.*TaskManager.*Close|func.*TaskManager.*Stop" --type go +``` + +Repository: compozy/agh + +Length of output: 173 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Also check the taskpkg package for cleanup methods +fd -e go taskpkg | head -20 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Find TaskManager definition and interface +rg -n "type TaskManager" --type go -A 20 | head -40 +``` + +Repository: compozy/agh + +Length of output: 1264 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Check for any Close, Shutdown, or Stop methods on TaskManager +rg -n "func \(.*\*?TaskManager\)" internal/task/ --type go | grep -i "close\|shutdown\|stop" +``` + +Repository: compozy/agh + +Length of output: 173 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Check the taskpkg.Store interface for cleanup methods +rg -n "type Store interface" --type go -A 30 +``` + +Repository: compozy/agh + +Length of output: 4066 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Search for how tasks is initialized to understand what it's doing +rg -n "d\.tasks\s*=" internal/daemon/daemon.go --type go -B 2 -A 2 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Check all methods on the Store interface by looking at the embedded sub-interfaces +rg -n "type (TaskStore|DependencyStore|RunStore|EventStore|IdempotencyStore) interface" --type go -A 15 +``` + +Repository: compozy/agh + +Length of output: 5244 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Search for where tasks field gets assigned in daemon +rg -n "tasks\s*=" internal/daemon/daemon.go --type go -B 2 -A 2 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Look for NewTaskRuntime or taskRuntime initialization +rg -n "NewTaskRuntime\|&taskRuntime\|NewTaskManager" internal/daemon/ --type go -B 2 -A 2 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Check if there are any goroutines started by TaskManager that might need cleanup +rg -n "go\s\|goroutine\|spawn" internal/task/manager.go --type go | head -20 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Look at the Daemon struct definition to see how tasks is declared +rg -n "type Daemon struct" internal/daemon/daemon.go -A 100 | head -120 +``` + +Repository: compozy/agh + +Length of output: 3608 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Search for taskRuntime usage in daemon.go +rg -n "taskRuntime\|tasks\." internal/daemon/daemon.go --type go -B 1 -A 1 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Check internal/daemon/task_runtime.go to see what methods taskRuntime has +cat -n internal/daemon/task_runtime.go +``` + +Repository: compozy/agh + +Length of output: 13568 + +--- + + + +
+ +**Ensure `tasks` field is cleared in `Shutdown()` for consistency.** + +The `tasks` field is missing from the `Shutdown()` method. While the underlying task manager and store don't have explicit cleanup methods and their resources are managed through the registry and sessions cleanup paths, the field should be set to `nil` alongside other daemon components for defensive programming and consistency with the pattern used for other fields (lines 565–669). + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/daemon/daemon.go` at line 228, The daemon struct's tasks field (type +*taskRuntime) is not being cleared in Shutdown(); update the Shutdown() method +to set d.tasks = nil alongside the other component fields so the tasks pointer +is defensively released after shutdown; locate the Shutdown() function on the +daemon type and add a line clearing the tasks field (d.tasks = nil) after any +stop/cleanup calls handling taskRuntime to match the existing pattern used for +other fields. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: `Shutdown()` nils most daemon-owned runtime fields but leaves `d.tasks` populated, which is inconsistent with the rest of the teardown path. +- Fix approach: clear `d.tasks` together with the other runtime fields during shutdown. + +## Resolution + +- Cleared `d.tasks` during daemon shutdown and extended the shutdown regression test to assert the task runtime is nilled together with the other daemon-owned state. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_020.md b/.compozy/tasks/core-tasks/reviews-001/issue_020.md new file mode 100644 index 000000000..6dc75a1bc --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_020.md @@ -0,0 +1,57 @@ +--- +status: resolved +file: internal/daemon/daemon_test.go +line: 755 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562aoS,comment:PRRC_kwDOR5y4QM63mgRl +--- + +# Issue 020: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**This only covers the “all survivors are healthy” path.** + +Because the fixture set contains just one extension and that one is reported healthy/registered, this test never proves that the fallback excludes unhealthy or unregistered extensions. Add a second installed extension that `runtime.Get()` reports as missing, disabled, or unregistered, then assert it does *not* survive the partial-start recovery. + + + +As per coding guidelines, `**/*_test.go`: "Focus on critical paths: workflow execution, state management, error handling" and "Ensure tests verify behavior outcomes, not just function calls". + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/daemon/daemon_test.go` around lines 686 - 755, Test only covers the +healthy survivor path; add a second extension fixture and make the +fakeExtensionRuntime return an unhealthy/unregistered result for it so the +partial-start recovery excludes it. Specifically, in +TestBootExtensionsKeepsHealthyRegisteredExtensionsAfterPartialStartFailure add +another installDaemonTestExtension call (e.g., "ext-bad") and update the +fakeExtensionRuntime.getExt / get behavior to return a Status with +Registered=false or Enabled=false (or nil/missing) for "ext-bad"; after +d.bootExtensions assert that the bad extension is not present (e.g., +state.currentExtensionRuntime() != runtime for that name or check +registry/cleanup entries do not include "ext-bad"), keeping the existing +assertions for the healthy "ext-healthy". +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Root cause: the partial-start recovery test only proves the happy path where the lone installed extension is healthy and registered, so it never verifies that unhealthy/unregistered extensions are excluded. +- Fix approach: add a second installed extension and make the fake runtime report it as unhealthy or unregistered so the partial-start fallback proves exclusion as well as survival. + +## Resolution + +- Added a second installed extension to the partial-start recovery test, taught the fake runtime to resolve extensions by name, and asserted that only the healthy extension survives as registered while the bad one remains merely enabled. +- Verified in the final `make verify` run. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_021.md b/.compozy/tasks/core-tasks/reviews-001/issue_021.md new file mode 100644 index 000000000..5d9335b81 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_021.md @@ -0,0 +1,27 @@ +--- +status: resolved +file: internal/extension/host_api_tasks.go +line: 162 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4106878777,nitpick_hash:8599893dc8d5 +review_hash: 8599893dc8d5 +source_review_id: "4106878777" +source_review_submitted_at: "2026-04-14T14:46:54Z" +--- + +# Issue 021: Consider adding server-side filtering for task runs. +## Review Comment + +`handleTasksRuns` fetches the entire `TaskView` (including all runs, events, dependencies, children) and then filters runs in-memory. For tasks with many runs, this could be inefficient. + +Consider adding a dedicated `ListTaskRuns(ctx, taskID, query, actor)` method to the manager that applies filters at the database level, rather than fetching everything and filtering client-side. + +## Triage + +- Decision: `INVALID` +- Notes: + The current implementation is functionally correct and already enforces read authority through the existing `task.Manager` surface. This comment is proposing a new manager capability (`ListTaskRuns` scoped by task) and corresponding Host API contract expansion, not pointing to an incorrect result or a failing behavior in the current code path. + `handleTasksRuns` is scoped to a single task and uses the existing canonical `GetTask` view, which keeps the endpoint aligned with the current manager contract. Adding a database-level run listing path would require widening `internal/task` and `internal/extension` interfaces outside this batch’s scoped files for a speculative optimization. + No production defect or regression was confirmed here, so this batch will not expand the task manager API solely for this suggestion. + Resolution: Closed as a design enhancement request rather than a defect. No code change was made. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_022.md b/.compozy/tasks/core-tasks/reviews-001/issue_022.md new file mode 100644 index 000000000..794ce6067 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_022.md @@ -0,0 +1,26 @@ +--- +status: resolved +file: internal/extension/host_api_test.go +line: 1006 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4106878777,nitpick_hash:26351a3e8ad4 +review_hash: 26351a3e8ad4 +source_review_id: "4106878777" +source_review_submitted_at: "2026-04-14T14:46:54Z" +--- + +# Issue 022: Avoid wall-clock polling in this test. +## Review Comment + +The `time.Sleep` loop makes this test timing-sensitive and prone to CI flakiness. Prefer a completion signal from the fake driver/session recorder, or another deterministic synchronization primitive, instead of polling for `done`. + +As per coding guidelines "Never use `time.Sleep()` in orchestration — use proper synchronization primitives". + +## Triage + +- Decision: `VALID` +- Notes: + The test currently polls session storage with a `time.Sleep(10 * time.Millisecond)` loop until a `done` event appears. That makes the assertion timing-sensitive and violates the workspace rule against sleep-based orchestration. + The root cause is that the test is waiting for prompt completion indirectly even though the session manager already exposes a deterministic turn-finished callback path. I will switch the test to synchronize on that completion signal instead of wall-clock polling. + Resolution: Replaced the polling loop with `session.Manager.SetTurnEndNotifier`, waited on the explicit completion signal, and then asserted the stored `done` event after prompt completion. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_023.md b/.compozy/tasks/core-tasks/reviews-001/issue_023.md new file mode 100644 index 000000000..939809de5 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_023.md @@ -0,0 +1,52 @@ +--- +status: resolved +file: internal/extension/manager_test.go +line: 788 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562aoe,comment:PRRC_kwDOR5y4QM63mgRz +--- + +# Issue 023: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**The “restart” case can pass without proving that anything was reloaded.** + +The success path starts a manager against an empty registry and only checks that `Reload()` returns `nil`, so a no-op implementation would still satisfy this test. Please seed at least one extension and assert an observable reload side effect, and split the scenarios into `t.Run("Should...")` cases so each branch stands on its own. + + +As per coding guidelines, `**/*_test.go`: "MUST use t.Run("Should...") pattern for ALL test cases", "Focus on critical paths: workflow execution, state management, error handling", and "Ensure tests verify behavior outcomes, not just function calls". + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/extension/manager_test.go` around lines 753 - 788, +TestManagerReloadValidatesAndRestarts currently only verifies Reload() returns +nil and can be satisfied by a no-op; update the test to seed the registry with +at least one extension (use newRegistryTestEnv / env.registry or helper that +registers an extension) before starting the manager (NewManager / +startedManager), then call startedManager.Reload and assert an observable side +effect such as the extension's Start/Reload handler being invoked, a changed +Manager state, or a registry reload counter; also split the existing checks into +separate t.Run("Should ...") subtests for the nil manager case, canceled context +case, missing registry case, and the successful reload case so each scenario is +isolated and verifies behavior, not just return values. +``` + +
+ + + + + +## Triage + +- Decision: `VALID` +- Notes: + The current success case starts a manager against an empty registry and only checks that `Reload()` returns `nil`. A no-op implementation would satisfy that branch, so the test does not prove restart behavior. + The fix is to split the scenarios into explicit `t.Run("Should ...")` subtests, seed the registry with a real extension, and assert an observable reload side effect through the fake launcher/process state so the test proves `Reload()` actually stops and starts managed extensions. + Resolution: Split the test into `Should ...` subtests, installed a real registry fixture, and asserted restart behavior via a second process launch, first-process shutdown, and updated runtime PID after `Reload()`. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_024.md b/.compozy/tasks/core-tasks/reviews-001/issue_024.md new file mode 100644 index 000000000..3e2103960 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_024.md @@ -0,0 +1,49 @@ +--- +status: resolved +file: internal/network/audit_test.go +line: 159 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562aok,comment:PRRC_kwDOR5y4QM63mgR5 +--- + +# Issue 024: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**Wrap this new case in `t.Run("Should...")` (table-driven style default).** + +The assertions are good, but this newly added test should follow the repository’s required subtest pattern for consistency with test review rules. +As per coding guidelines, `**/*_test.go`: "MUST use t.Run("Should...") pattern for ALL test cases" and "Use table-driven tests with subtests (t.Run) as default in Go tests". + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/network/audit_test.go` around lines 114 - 159, The new +TestAuditWriterRecordTaskIngress must be converted to use a subtest via t.Run to +follow the repository's required subtest pattern: wrap the existing test body +(setup of storeSink, NewAuditWriter, writer.now, the call to +writer.RecordTaskIngress, and all assertions on storeSink.entries[0] including +SessionID, Kind, Direction, Reason, and Size) inside a t.Run("Should record task +ingress", func(t *testing.T) { ... }) closure (and keep/relocate t.Parallel +inside the subtest if desired); ensure the same unique symbols +(TestAuditWriterRecordTaskIngress, writer.RecordTaskIngress, storeSink.entries) +are used unchanged, and keep all assertions intact inside the subtest. +``` + +
+ + + + + +## Triage + +- Decision: `VALID` +- Notes: + This test body was added as a single top-level case even though the repository standard for Go tests is subtests with `t.Run("Should ...")`. + The behavior assertion itself is fine; the required change is structural. I will wrap the existing task-ingress assertions in a `Should ...` subtest so the file follows the enforced testing pattern consistently. + Resolution: Wrapped the task-ingress assertions in `t.Run("Should record task ingress", ...)` without weakening the existing checks. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_025.md b/.compozy/tasks/core-tasks/reviews-001/issue_025.md new file mode 100644 index 000000000..c23d5d3e5 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_025.md @@ -0,0 +1,103 @@ +--- +status: resolved +file: internal/network/tasks_integration_test.go +line: 315 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562aon,comment:PRRC_kwDOR5y4QM63mgR9 +--- + +# Issue 025: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**Fail if a request writes more than one audit row.** + +This helper returns `entries[0]` on any non-empty match, so duplicate audit writes for the same `MessageID` still make the test pass. + + +
+Suggested fix + +```diff + func findNetworkAuditByMessageID(t *testing.T, db *globaldb.GlobalDB, messageID string) store.NetworkAuditEntry { + t.Helper() + + entries, err := db.ListNetworkAudit(testutil.Context(t), store.NetworkAuditQuery{ + MessageID: messageID, + Limit: 10, + }) + if err != nil { + t.Fatalf("ListNetworkAudit(%q) error = %v", messageID, err) + } +- if len(entries) == 0 { +- t.Fatalf("ListNetworkAudit(%q) = no entries", messageID) ++ if len(entries) != 1 { ++ t.Fatalf("ListNetworkAudit(%q) returned %d entries, want exactly 1", messageID, len(entries)) + } + return entries[0] + } +``` +
+As per coding guidelines, `**/*_test.go`: Ensure tests verify behavior outcomes, not just function calls. + + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion +func findNetworkAuditByMessageID(t *testing.T, db *globaldb.GlobalDB, messageID string) store.NetworkAuditEntry { + t.Helper() + + entries, err := db.ListNetworkAudit(testutil.Context(t), store.NetworkAuditQuery{ + MessageID: messageID, + Limit: 10, + }) + if err != nil { + t.Fatalf("ListNetworkAudit(%q) error = %v", messageID, err) + } + if len(entries) != 1 { + t.Fatalf("ListNetworkAudit(%q) returned %d entries, want exactly 1", messageID, len(entries)) + } + return entries[0] +} +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/network/tasks_integration_test.go` around lines 302 - 315, The +helper findNetworkAuditByMessageID currently returns entries[0] for any +non-empty result; change it to assert exactly one matching audit row to catch +duplicate writes: after calling db.ListNetworkAudit in +findNetworkAuditByMessageID, fail the test if len(entries) == 0 or len(entries) +> 1 (use t.Fatalf with context including messageID and len(entries)), and only +return entries[0] when len(entries) == 1 so tests detect duplicate audit rows; +reference ListNetworkAudit, NetworkAuditQuery, and NetworkAuditEntry when making +the check. +``` + +
+ + + + + +## Triage + +- Decision: `VALID` +- Notes: + The helper currently treats any non-empty result set as success and returns the first audit row. That can hide duplicate writes for a single request id and let the test pass even when the system emits multiple audit entries. + I will tighten the helper to require exactly one matching row so duplicate audit writes fail the integration test instead of being silently accepted. + Resolution: Updated `findNetworkAuditByMessageID` to require exactly one matching audit row and fail on duplicates as well as missing entries. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_026.md b/.compozy/tasks/core-tasks/reviews-001/issue_026.md new file mode 100644 index 000000000..11cf231b5 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_026.md @@ -0,0 +1,80 @@ +--- +status: resolved +file: internal/observe/health.go +line: 52 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562aot,comment:PRRC_kwDOR5y4QM63mgSG +--- + +# Issue 026: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Wrap task health errors with operation context.** + +At Line 51, returning raw `err` drops useful context in health failures. + +
+🛠️ Proposed fix + +```diff + taskHealth, err := o.collectTaskHealth(ctx) + if err != nil { +- return Health{}, err ++ return Health{}, fmt.Errorf("observe: collect task health: %w", err) + } +``` +
+ + +As per coding guidelines, Use explicit error returns with wrapped context: `fmt.Errorf("context: %w", err)`. + + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion + taskHealth, err := o.collectTaskHealth(ctx) + if err != nil { + return Health{}, fmt.Errorf("observe: collect task health: %w", err) + } +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/observe/health.go` around lines 49 - 52, The call to +collectTaskHealth in observe.health.go returns an unwrapped error which loses +context; update the error return in the observe.collectTaskHealth call inside +function Get/Collect/whatever the surrounding function is (the one that +currently assigns taskHealth, err := o.collectTaskHealth(ctx)) to wrap the error +with contextual text using fmt.Errorf, e.g. return Health{}, +fmt.Errorf("collecting task health: %w", err), so callers see the operation that +failed; ensure you import fmt if not already present. +``` + +
+ + + + + +## Triage + +- Decision: `VALID` +- Notes: + `Observer.Health` wraps neighboring operations with contextual `fmt.Errorf(...)` messages, but the `collectTaskHealth` failure path returns the raw error. That drops operation context and makes health failures harder to trace. + I will wrap the task-health failure with an `observe: ...` message so the exported health surface is consistent with the rest of the method. + Resolution: Wrapped the `collectTaskHealth` failure with `observe: collect task health: ...` and added a regression test that forces a task-health query failure and checks the wrapped error text. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_027.md b/.compozy/tasks/core-tasks/reviews-001/issue_027.md new file mode 100644 index 000000000..08ae22857 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_027.md @@ -0,0 +1,24 @@ +--- +status: resolved +file: internal/observe/tasks.go +line: 198 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4106878777,nitpick_hash:ce8dc085b0d2 +review_hash: ce8dc085b0d2 +source_review_id: "4106878777" +source_review_submitted_at: "2026-04-14T14:46:54Z" +--- + +# Issue 027: Consider adding explicit context nil check for consistency. +## Review Comment + +While `loadTaskSnapshot` performs the nil context check, adding it at the start of `QueryTaskSummary` (like `QueryTaskMetrics` does) would provide consistent API behavior and clearer error messages. + +## Triage + +- Decision: `INVALID` +- Notes: + `QueryTaskSummary` already returns the precise nil-context error on its first operation because `loadTaskSnapshot` begins with `if ctx == nil { return taskSnapshot{}, errors.New("observe: task summary context is required") }`. + Adding the same check again at the top of `QueryTaskSummary` would be redundant and would not change the exported behavior, error text, or diagnostics. No correctness gap was confirmed, so this suggestion does not warrant a code change. + Resolution: Closed after analysis. The exported method already returns the same nil-context error through its first delegated call, so no code change was needed. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_028.md b/.compozy/tasks/core-tasks/reviews-001/issue_028.md new file mode 100644 index 000000000..59de55f85 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_028.md @@ -0,0 +1,53 @@ +--- +status: resolved +file: internal/observe/tasks_test.go +line: 396 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562aoy,comment:PRRC_kwDOR5y4QM63mgSM +--- + +# Issue 028: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**Assert the validation error shape, not only that it is non-nil.** + +These checks stay green for any failure path. If `Validate()` starts rejecting the wrong field, this test still passes. + + + +As per coding guidelines, `**/*_test.go`: MUST have specific error assertions (ErrorContains, ErrorAs). + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/observe/tasks_test.go` around lines 388 - 396, The tests currently +only assert that Validate() returns non-nil, which is too weak; update the three +assertions to check the error shape/content for the specific invalid field. For +the TaskSummaryQuery cases (TaskSummaryQuery{Scope: taskpkg.Scope("bogus")} and +TaskSummaryQuery{OwnerKind: taskpkg.OwnerKind("bogus")}) and the +TaskMetricsQuery case (TaskMetricsQuery{OriginKind: +taskpkg.OriginKind("bogus")}), replace the plain nil checks with assertions that +the returned error either matches the expected validation error type via +errors.As or contains an expected substring (e.g., "scope", "owner kind", +"origin kind") using ErrorContains / strings.Contains so the test fails if +Validate() rejects a different field. +``` + +
+ + + + + +## Triage + +- Decision: `VALID` +- Notes: + The current assertions only check that validation returns a non-nil error. That allows the tests to stay green if validation fails for the wrong field or with the wrong error class. + I will strengthen the assertions to verify both the validation error type and field-specific error text so the tests fail if `Validate()` starts rejecting the wrong input. + Resolution: Tightened the validation tests to assert `task.ErrValidation` and field-specific error text for invalid `scope`, `owner_kind`, and `origin_kind` cases. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_029.md b/.compozy/tasks/core-tasks/reviews-001/issue_029.md new file mode 100644 index 000000000..05480fde7 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_029.md @@ -0,0 +1,77 @@ +--- +status: resolved +file: internal/session/stop_reason.go +line: 81 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM562ao4,comment:PRRC_kwDOR5y4QM63mgSR +--- + +# Issue 029: _⚠️ Potential issue_ | _🔴 Critical_ +## Review Comment + +_⚠️ Potential issue_ | _🔴 Critical_ + +**Guard nil receivers before calling `prepareStopWithCause()`.** + +Both public methods dereference `m` immediately, so a typed-nil `*Manager` will panic instead of returning a normal error. + + + +
+🛡️ Proposed fix + +```diff + func (m *Manager) RequestStopWithCause(ctx context.Context, id string, cause StopCause, detail string) error { ++ if m == nil { ++ return errors.New("session: manager is required") ++ } + if ctx == nil { + return errors.New("session: request stop context is required") + } + if cause == CauseNone { + cause = CauseUserRequested +@@ + func (m *Manager) StopWithCause(ctx context.Context, id string, cause StopCause, detail string) error { ++ if m == nil { ++ return errors.New("session: manager is required") ++ } + if ctx == nil { + return errors.New("session: stop context is required") + } + if cause == CauseNone { + cause = CauseUserRequested +``` +
+ + +Also applies to: 84-116 + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/session/stop_reason.go` around lines 54 - 81, RequestStopWithCause +(and the sibling method around lines 84-116) dereference the receiver m before +checking for a typed nil, causing a panic for a nil *Manager; add an initial +guard like "if m == nil { return errors.New(\"session: manager is required\") }" +at the top of RequestStopWithCause and the other public stop method. Keep the +rest of the flow (calls to m.prepareStopWithCause, m.finalizeStopped, +m.driver.Cancel and isProcessDone) unchanged so the nil-receive case returns a +proper error instead of panicking. +``` + +
+ + + + + +## Triage + +- Decision: `VALID` +- Notes: + Both public methods dereference the receiver by calling `m.prepareStopWithCause(...)` before checking whether `m` is a typed-nil `*Manager`. That will panic instead of returning a normal validation error. + I will add explicit nil-receiver guards at the top of both public stop methods and add a regression test. That test is not covered by the current batch file list, so a minimal session test file update outside the listed code files will be required and is justified by the lack of any scoped session test file for this path. + Resolution: Added nil-receiver guards to both public stop methods and extended `internal/session/stop_reason_test.go` with `Should ...` regression coverage for the nil-manager path. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_030.md b/.compozy/tasks/core-tasks/reviews-001/issue_030.md new file mode 100644 index 000000000..763568780 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_030.md @@ -0,0 +1,26 @@ +--- +status: resolved +file: internal/store/globaldb/global_db_task.go +line: 183 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4106878777,nitpick_hash:24316e62aa3d +review_hash: 24316e62aa3d +source_review_id: "4106878777" +source_review_submitted_at: "2026-04-14T14:46:54Z" +--- + +# Issue 030: Ignored error on rows.Close() is acceptable but consider logging. +## Review Comment + +While ignoring the `rows.Close()` error in a defer is a common Go pattern (since the error often isn't actionable at this point), the coding guidelines state "Never ignore errors with `_` — every error must be handled or have a written justification." Consider adding a brief comment explaining why the error is ignored here. + +--- + +## Triage + +- Decision: `VALID` +- Notes: + The deferred `rows.Close()` error is intentionally ignored, but the file gives no written justification even though the workspace rule requires ignored errors to be either handled or explicitly justified. + I will add a brief justification comment at the defer site so the choice is documented without changing the query behavior. + Resolution: Added succinct justification comments at the `rows.Close()` defer sites in `global_db_task.go` to document why those close errors are intentionally ignored. diff --git a/.compozy/tasks/core-tasks/reviews-001/issue_031.md b/.compozy/tasks/core-tasks/reviews-001/issue_031.md new file mode 100644 index 000000000..d3ceeec16 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-001/issue_031.md @@ -0,0 +1,24 @@ +--- +status: resolved +file: internal/store/globaldb/global_db_task.go +line: 567 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4106878777,nitpick_hash:1912770d11aa +review_hash: 1912770d11aa +source_review_id: "4106878777" +source_review_submitted_at: "2026-04-14T14:46:54Z" +--- + +# Issue 031: Consider returning more specific error when taskID is empty. +## Review Comment + +The `ensureTaskExists` function returns `taskpkg.ErrTaskNotFound` when the `taskID` is empty (line 570). This conflates "missing input" with "record not found". Consider returning a validation error for empty input to aid debugging. + +## Triage + +- Decision: `INVALID` +- Notes: + No reachable public API path in this file relies on `ensureTaskExists` to validate an empty task id. The public callers normalize and validate task ids earlier (`requireTaskValue`, `TaskRun.Validate`, and parent-id guards) before they ever invoke this helper. + Changing `ensureTaskExists` to return `ErrValidation` for blank input would therefore not fix an observed defect in the current external behavior; it would only alter an internal fallback branch that should already be unreachable through supported entry points. + Resolution: Closed after analysis. The blank-id branch is not reachable from the supported public entry points in this file, so no user-visible defect was fixed here. diff --git a/.compozy/tasks/core-tasks/reviews-002/_meta.md b/.compozy/tasks/core-tasks/reviews-002/_meta.md new file mode 100644 index 000000000..46054ddd7 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/_meta.md @@ -0,0 +1,11 @@ +--- +provider: coderabbit +pr: "19" +round: 2 +created_at: 2026-04-14T16:31:25.573839Z +--- + +## Summary +- Total: 17 +- Resolved: 17 +- Unresolved: 0 diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_001.md b/.compozy/tasks/core-tasks/reviews-002/issue_001.md new file mode 100644 index 000000000..12636bfc4 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_001.md @@ -0,0 +1,31 @@ +--- +status: resolved +file: internal/api/core/tasks.go +line: 395 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4107556463,nitpick_hash:d9fff2a61f74 +review_hash: d9fff2a61f74 +source_review_id: "4107556463" +source_review_submitted_at: "2026-04-14T16:23:06Z" +--- + +# Issue 001: Push run filtering and limiting into the task service. +## Review Comment + +`ListTaskRuns` currently loads the full task view and then filters runs in memory. For tasks with many runs, that turns a paged run listing into a full detail read and bypasses storage-side filtering/limit enforcement. A dedicated `ListTaskRuns`/`GetTaskRuns` service call would scale much better here. + +Also applies to: 1078-1092 + +## Triage + +- Decision: `valid` +- Notes: + The handler currently calls `manager.GetTask(...)`, which loads the full task view including children, dependencies, events, and every run, then applies `filterTaskRuns(...)` in memory. That defeats store-side run filtering and limit enforcement for `/tasks/:id/runs`. + Root cause: the API task service does not expose a dedicated run-list method, so the handler reuses the full-detail read path. + Planned fix: add a task-service `ListTaskRuns` path that preserves read authorization and task-not-found semantics, switch the handler to use it, and add regression coverage for the handler call path. + +## Resolution + +- Added a dedicated `ListTaskRuns` manager/service path in `internal/task/manager.go`, surfaced it through the API task-service interface/stub, and switched `internal/api/core/tasks.go` to use it instead of loading the full task graph. +- Updated `internal/api/core/tasks_test.go` to verify the runs handler delegates through the new list path rather than adding another full `GetTask` fetch. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_002.md b/.compozy/tasks/core-tasks/reviews-002/issue_002.md new file mode 100644 index 000000000..36d30cc11 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_002.md @@ -0,0 +1,58 @@ +--- +status: resolved +file: internal/api/core/tasks.go +line: 705 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM564LfE,comment:PRRC_kwDOR5y4QM63o2Op +--- + +# Issue 002: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Validate `scope` before resolving `workspace`.** + +These paths look up the workspace even when the normalized scope may be `global`. That makes an invalid request return `404` when the workspace ref does not exist and `400` when it does, which both leaks workspace existence and makes status codes depend on unrelated data. Only resolve the workspace ref after you know the request is actually workspace-scoped. + + + + +Also applies to: 738-741, 764-767 + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/api/core/tasks.go` around lines 699 - 705, The handler currently +resolves workspaceRef via h.lookupWorkspaceID whenever a workspace query param +is supplied, which can leak workspace existence; change the logic to first +inspect the normalized scope (the request's scope parameter or the code path +that sets query.Scope) and only call h.lookupWorkspaceID and set +query.WorkspaceID when the request is actually workspace-scoped (i.e., scope != +"global" and matches the workspace-scoped enum/value); apply the same +conditional change to the other occurrences noted (the similar blocks around the +other occurrences for lines 738-741 and 764-767) so workspace resolution only +happens for workspace-scoped requests. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + `parseTaskListQuery`, `createTaskSpecFromRequest`, and `createChildTaskSpecFromRequest` resolve the workspace reference before validating the normalized scope/workspace binding. A request like `scope=global&workspace=missing` can therefore leak workspace existence via `404` instead of consistently failing with `400`. + Root cause: workspace lookup is performed before the scope-specific validation path. + Planned fix: validate the normalized scope first and only resolve workspace references for workspace-scoped requests; add handler tests that lock in the non-leaking `400` behavior. + +## Resolution + +- Reordered the task API request parsing so scope/workspace binding is validated before any workspace lookup occurs, and only workspace-scoped requests resolve workspace references. +- Added regression coverage in `internal/api/core/tasks_test.go` to prove `scope=global` plus `workspace=...` fails with `400` without touching the workspace lookup path. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_003.md b/.compozy/tasks/core-tasks/reviews-002/issue_003.md new file mode 100644 index 000000000..98d833e77 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_003.md @@ -0,0 +1,30 @@ +--- +status: resolved +file: internal/api/httpapi/httpapi_integration_test.go +line: 1034 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4107556463,nitpick_hash:047960890d8a +review_hash: 047960890d8a +source_review_id: "4107556463" +source_review_submitted_at: "2026-04-14T16:23:06Z" +--- + +# Issue 003: Mark these new subtests as parallel. +## Review Comment + +Each subtest spins up its own isolated runtime, so they look independent. Adding `t.Parallel()` here keeps the integration suite aligned with the repo test rules and makes race coverage more useful. + +As per coding guidelines, `**/*_test.go`: Use `t.Parallel()` for independent subtests in Go tests. + +## Triage + +- Decision: `valid` +- Notes: + The three new subtests in `TestHTTPTaskRunLifecycleRoutesRoundTrip` each create their own isolated integration runtime and do not share mutable test state. They should participate in parallel execution like the rest of the suite. + Root cause: the new subtests were added without `t.Parallel()`. + Planned fix: mark each new subtest as parallel. + +## Resolution + +- Added `t.Parallel()` to each independent subtest in `TestHTTPTaskRunLifecycleRoutesRoundTrip` so the new task lifecycle integration cases follow the repo’s parallel-test convention. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_004.md b/.compozy/tasks/core-tasks/reviews-002/issue_004.md new file mode 100644 index 000000000..415aa5b20 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_004.md @@ -0,0 +1,55 @@ +--- +status: resolved +file: internal/automation/dispatch.go +line: 144 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM564LfH,comment:PRRC_kwDOR5y4QM63o2Ot +--- + +# Issue 004: _🛠️ Refactor suggestion_ | _🟠 Major_ +## Review Comment + +_🛠️ Refactor suggestion_ | _🟠 Major_ + +**Pass `context.Context` through the task-actor recorder boundary.** + +This interface crosses out of the dispatcher into storage/provenance code, but callers cannot propagate cancellation or deadlines because both methods omit `context.Context`. It would be safer to shape this as `Record...(ctx, ...) error` / `Delete...(ctx, ...) error` so implementations do not have to invent background contexts or swallow shutdown signals. + + +As per coding guidelines, `Use context.Context as first argument to functions crossing runtime boundaries — avoid context.Background() outside main and focused tests`. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/automation/dispatch.go` around lines 139 - 144, The interface +AutomationSessionTaskActorRecorder must accept context propagation: change +RecordAutomationSessionTaskActor(sessionID string, actor taskpkg.ActorContext) +error to RecordAutomationSessionTaskActor(ctx context.Context, sessionID string, +actor taskpkg.ActorContext) error and change +DeleteAutomationSessionTaskActor(sessionID string) to +DeleteAutomationSessionTaskActor(ctx context.Context) error (make it return +error so callers can handle failures); then update all implementations and call +sites to pass the inbound ctx through (do not create background contexts) and +handle/propagate returned errors accordingly. +``` + +
+ + + + + +## Triage + +- Decision: `invalid` +- Notes: + The `AutomationSessionTaskActorRecorder` boundary is not currently a storage or blocking-runtime boundary. The concrete implementation in `internal/automation/manager.go` only mutates an in-memory map under a mutex, and `DeleteAutomationSessionTaskActor` cannot fail. + Passing `context.Context` through this interface would add API churn without changing cancellation behavior or fixing a real bug in the current implementation. + If this recorder later becomes persistent or performs I/O, adding context and error propagation would make sense, but that is not the case today. + +## Resolution + +- No code change was made. The finding is a design suggestion against an in-memory mutex-protected recorder, not a correctness bug in the current implementation. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_005.md b/.compozy/tasks/core-tasks/reviews-002/issue_005.md new file mode 100644 index 000000000..663dd3d82 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_005.md @@ -0,0 +1,75 @@ +--- +status: resolved +file: internal/automation/dispatch.go +line: 483 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM564LfM,comment:PRRC_kwDOR5y4QM63o2Oz +--- + +# Issue 005: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Classify task-service cancellations as `RunCancelled`, not `RunFailed`.** + +The session-backed path uses `classifyDispatchError`, but the task-backed path hard-codes `RunFailed` for `CreateTask`/`EnqueueRun` errors. A canceled or timed-out request will therefore be persisted as a failure and can trigger the wrong lifecycle hooks and retry decisions. + + +
+💡 Suggested fix + +```diff + taskRecord, err := d.tasks.CreateTask(ctx, directTaskSpec(req.Job, preFirePrompt), actor) + if err != nil { +- return d.finishRun(ctx, scheduledRun, RunFailed, err) ++ return d.finishRun(ctx, scheduledRun, classifyDispatchError(err), err) + } +@@ + taskRun, err := d.tasks.EnqueueRun(ctx, taskpkg.EnqueueRun{ + TaskID: taskRecord.ID, + IdempotencyKey: automationTaskRunIdempotencyKey(scheduledRun.ID), + NetworkChannel: strings.TrimSpace(taskRecord.NetworkChannel), + }, actor) + if err != nil { +- return d.finishRun(ctx, scheduledRun, RunFailed, err) ++ return d.finishRun(ctx, scheduledRun, classifyDispatchError(err), err) + } +``` +
+ +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/automation/dispatch.go` around lines 469 - 483, The task-backed path +currently always records CreateTask/EnqueueRun errors as RunFailed; change it to +classify the error like the session-backed path by calling +classifyDispatchError(err) and passing that status to d.finishRun instead of +hard-coding RunFailed so cancellations/timeouts become RunCancelled; update both +error returns after d.tasks.CreateTask(ctx, ...) and d.tasks.EnqueueRun(ctx, +...) to compute status := classifyDispatchError(err) (or equivalent) and call +d.finishRun(ctx, scheduledRun, status, err) so lifecycle hooks and retry logic +behave correctly. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + The task-backed dispatch path hard-codes `RunFailed` for `CreateTask` and `EnqueueRun` failures, while the session-backed path already classifies cancellations and deadlines through `classifyDispatchError`. + Root cause: cancellation-aware status classification was only applied to the session runtime branch. + Planned fix: classify both task-service failure sites with `classifyDispatchError(err)` and add regression tests covering cancellation on task creation and run enqueue. + +## Resolution + +- Updated `internal/automation/dispatch.go` so task-backed `CreateTask` and `EnqueueRun` failures use `classifyDispatchError(err)` instead of always recording `RunFailed`. +- Added regression coverage in `internal/automation/dispatch_test.go` for task-service cancellation and deadline-exceeded cases so canceled work is persisted as `RunCancelled`. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_006.md b/.compozy/tasks/core-tasks/reviews-002/issue_006.md new file mode 100644 index 000000000..1750c2aae --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_006.md @@ -0,0 +1,73 @@ +--- +status: resolved +file: internal/automation/model/validate.go +line: 355 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM564LfU,comment:PRRC_kwDOR5y4QM63o2O8 +--- + +# Issue 006: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Validate `job.task.network_channel` here as well.** + +`JobTaskConfig.Validate()` only checks `owner`, so an invalid `job.task.network_channel` now passes both model and config validation and fails later when the delegated task is materialized. That turns a static misconfiguration into a runtime job failure. + +
+Suggested fix + +```diff ++// add github.com/pedronauck/agh/internal/network to imports + func (c JobTaskConfig) Validate(path string) error { ++ if channel := strings.TrimSpace(c.NetworkChannel); channel != "" { ++ if err := network.ValidateChannel(channel); err != nil { ++ return fmt.Errorf("%s is invalid: %w", nestedPath(path, "network_channel"), err) ++ } ++ } + if c.Owner != nil { + if err := c.Owner.Validate(nestedPath(path, "owner")); err != nil { + return err + } + } + return nil + } +``` +
+ +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/automation/model/validate.go` around lines 349 - 355, +JobTaskConfig.Validate currently only validates c.Owner and ignores +c.NetworkChannel (job.task.network_channel), so invalid network_channel values +slip through; update the JobTaskConfig.Validate(path string) error method to +validate c.NetworkChannel (e.g., call its Validate method or perform the same +checks used elsewhere) using nestedPath(path, "network_channel") and return any +error found; reference the JobTaskConfig type, the Validate method, +c.NetworkChannel (or network_channel field) and nestedPath to locate where to +add the check. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + `JobTaskConfig.Validate()` currently validates only `owner` and ignores `network_channel`, so malformed `job.task.network_channel` values survive static validation and fail later during task materialization. + Root cause: the direct-task config validator never adopted the same network-channel validation used elsewhere in the task/network stack. + Planned fix: validate the trimmed `network_channel` in `JobTaskConfig.Validate()` and add regression coverage. + +## Resolution + +- Added `job.task.network_channel` validation in `internal/automation/model/validate.go` so invalid direct-task channels fail during config validation instead of at runtime. +- Kept the validation local to the automation model layer to avoid introducing a package import cycle, and locked it down with a regression case in `internal/automation/validate_test.go`. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_007.md b/.compozy/tasks/core-tasks/reviews-002/issue_007.md new file mode 100644 index 000000000..20d7d0896 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_007.md @@ -0,0 +1,30 @@ +--- +status: resolved +file: internal/automation/validate_test.go +line: 832 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4107556463,nitpick_hash:30095d4b6862 +review_hash: 30095d4b6862 +source_review_id: "4107556463" +source_review_submitted_at: "2026-04-14T16:23:06Z" +--- + +# Issue 007: Cover the mirrored delegated validation case too. +## Review Comment + +This only locks down the missing `task_id` branch. A regression in the `task_run_id` requirement for delegated runs would still slip through untested. + +As per coding guidelines, `**/*_test.go`: Focus on critical paths: workflow execution, state management, error handling. + +## Triage + +- Decision: `valid` +- Notes: + The new delegated-run validation regression covers the missing `task_id` branch but does not cover the mirrored missing `task_run_id` requirement for `RunDelegated`. + Root cause: the regression test only locked down one half of the delegated-run invariant. + Planned fix: add the complementary `task_run_id` failure assertion in `TestRunAndEnvelopeValidate`. + +## Resolution + +- Added the missing delegated-run regression in `internal/automation/validate_test.go` so both required delegated identifiers, `task_id` and `task_run_id`, are enforced by tests. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_008.md b/.compozy/tasks/core-tasks/reviews-002/issue_008.md new file mode 100644 index 000000000..ef85e612d --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_008.md @@ -0,0 +1,145 @@ +--- +status: resolved +file: internal/cli/task.go +line: 780 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM564Lfw,comment:PRRC_kwDOR5y4QM63o2Pi +--- + +# Issue 008: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**Reject half-specified owner filters in `task list`.** + +`parseTaskListFilters()` lets `--owner-kind` or `--owner-ref` through on their own, so the CLI pushes an obviously invalid filter to the API instead of failing fast with a local usage error. + +
+Suggested fix + +```diff + func parseTaskListFilters(scopeRaw string, workspaceRef string, statusRaw string, ownerKindRaw string, ownerRef string, parentTaskID string, channelRaw string, last int) (TaskListQuery, error) { + scope, workspace, err := resolveTaskScopeWorkspace(scopeRaw, workspaceRef, false) + if err != nil { + return TaskListQuery{}, err + } + status, err := parseOptionalTaskStatus(statusRaw) + if err != nil { + return TaskListQuery{}, err + } + ownerKind, err := parseOptionalTaskOwnerKind(ownerKindRaw) + if err != nil { + return TaskListQuery{}, err + } ++ trimmedOwnerRef := strings.TrimSpace(ownerRef) ++ if (ownerKind != "" && trimmedOwnerRef == "") || (ownerKind == "" && trimmedOwnerRef != "") { ++ return TaskListQuery{}, errors.New("cli: --owner-kind and --owner-ref must be provided together") ++ } + if err := validateTaskChannelFlag("channel", channelRaw); err != nil { + return TaskListQuery{}, err + } + if err := validateTaskLast(last); err != nil { + return TaskListQuery{}, err + } + + return TaskListQuery{ + Scope: scope, + Workspace: workspace, + Status: status, + OwnerKind: ownerKind, +- OwnerRef: strings.TrimSpace(ownerRef), ++ OwnerRef: trimmedOwnerRef, + ParentTaskID: strings.TrimSpace(parentTaskID), + NetworkChannel: strings.TrimSpace(channelRaw), + Limit: last, + }, nil + } +``` +
+ + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion +func parseTaskListFilters(scopeRaw string, workspaceRef string, statusRaw string, ownerKindRaw string, ownerRef string, parentTaskID string, channelRaw string, last int) (TaskListQuery, error) { + scope, workspace, err := resolveTaskScopeWorkspace(scopeRaw, workspaceRef, false) + if err != nil { + return TaskListQuery{}, err + } + status, err := parseOptionalTaskStatus(statusRaw) + if err != nil { + return TaskListQuery{}, err + } + ownerKind, err := parseOptionalTaskOwnerKind(ownerKindRaw) + if err != nil { + return TaskListQuery{}, err + } + trimmedOwnerRef := strings.TrimSpace(ownerRef) + if (ownerKind != "" && trimmedOwnerRef == "") || (ownerKind == "" && trimmedOwnerRef != "") { + return TaskListQuery{}, errors.New("cli: --owner-kind and --owner-ref must be provided together") + } + if err := validateTaskChannelFlag("channel", channelRaw); err != nil { + return TaskListQuery{}, err + } + if err := validateTaskLast(last); err != nil { + return TaskListQuery{}, err + } + + return TaskListQuery{ + Scope: scope, + Workspace: workspace, + Status: status, + OwnerKind: ownerKind, + OwnerRef: trimmedOwnerRef, + ParentTaskID: strings.TrimSpace(parentTaskID), + NetworkChannel: strings.TrimSpace(channelRaw), + Limit: last, + }, nil +} +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/cli/task.go` around lines 751 - 780, parseTaskListFilters currently +allows a half-specified owner filter (only owner-kind or only owner-ref) which +results in invalid API requests; update parseTaskListFilters to validate that +ownerKindRaw and ownerRef are provided together: trim ownerRef +(strings.TrimSpace(ownerRef)) and if one is non-empty while the other is empty +return a usage error (e.g., fmt.Errorf("both --owner-kind and --owner-ref must +be specified together")) before calling parseOptionalTaskOwnerKind and before +building TaskListQuery; ensure the error is returned early so TaskListQuery +never contains a mismatched owner filter. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + `parseTaskListFilters()` trims and forwards `owner_kind` and `owner_ref` independently, so the CLI can send a half-specified owner filter that the server will reject later. + Root cause: unlike create/update paths, the task-list filter parser never enforced the paired owner flag invariant locally. + Planned fix: require `--owner-kind` and `--owner-ref` together in `parseTaskListFilters()` and add unit coverage for the usage error. + +## Resolution + +- Tightened `parseTaskListFilters()` in `internal/cli/task.go` to reject half-specified owner filters locally and reuse the trimmed owner ref in the emitted query. +- Added `TestParseTaskListFiltersRejectsHalfSpecifiedOwnerFilter` in `internal/cli/task_test.go` to lock in the CLI usage error. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_009.md b/.compozy/tasks/core-tasks/reviews-002/issue_009.md new file mode 100644 index 000000000..5f9eb704c --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_009.md @@ -0,0 +1,52 @@ +--- +status: resolved +file: internal/cli/task_test.go +line: 604 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM564Lff,comment:PRRC_kwDOR5y4QM63o2PL +--- + +# Issue 009: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Fix these bundle assertions to match the current renderers.** + +The expectations here drift from the current helper implementations: `taskDetailBundle(...).toon()` only combines task/runs/dependencies, `taskRunBundle(...).human()` does not render `Idempotency Key` or `Result`, and `taskRunListBundle(...).toon()` emits a much smaller schema. As written, this block will fail against `internal/cli/task.go:1013-1043`, `internal/cli/task.go:1045-1089`, and `internal/cli/task.go:1091-1123`. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/cli/task_test.go` around lines 580 - 604, The assertions are out of +sync with the current renderers: update the tests to match what +taskDetailBundle(...).toon() actually emits (check for task, runs and +dependencies sections rather than child/event sections), remove expectations for +"Idempotency Key" and "Result" from taskRunBundle(...).human() (keep a generic +check like "Task Run" and at least one run field such as "Status" or "Started +At"), and tighten the taskRunListBundle(...).toon() assertion to the smaller +schema it emits (e.g., assert for a compact task_runs array like +"task_runs[1]{id,status,attempt}" instead of the long field list). Ensure you +modify the three assertions that reference taskDetailBundle().toon(), +taskRunBundle().human(), and taskRunListBundle().toon() accordingly. +``` + +
+ + + + + +## Triage + +- Decision: `invalid` +- Notes: + The current renderers still match the assertions in this test block. `taskDetailBundle(...).toon()` includes child, dependency, run, and event sections; `taskRunBundle(...).human()` still renders `Idempotency Key` and `Result`; and `taskRunListBundle(...).toon()` still emits the full task-run schema asserted here. + The review comment appears to reference an older or different renderer shape, not the current implementation under test. + +## Resolution + +- No code change was made. The current CLI renderers and assertions are already aligned, so this review comment does not reflect the present implementation. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_010.md b/.compozy/tasks/core-tasks/reviews-002/issue_010.md new file mode 100644 index 000000000..0dbafc972 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_010.md @@ -0,0 +1,87 @@ +--- +status: resolved +file: internal/cli/task_test.go +line: 611 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM564Lfi,comment:PRRC_kwDOR5y4QM63o2PP +--- + +# Issue 010: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**`relates` is a valid dependency kind, so this negative test is inverted.** + +`parseOptionalTaskDependencyKind` explicitly accepts `blocks`, `blocked_by`, and `relates`. This assertion currently expects an error for a supported value, so it will fail once exercised. + + +
+💡 Suggested fix + +```diff +- if _, err := parseOptionalTaskDependencyKind("relates"); err == nil || !strings.Contains(err.Error(), "unsupported value") { +- t.Fatalf("parseOptionalTaskDependencyKind(relates) error = %v, want unsupported value validation", err) +- } ++ if kind, err := parseOptionalTaskDependencyKind("relates"); err != nil || kind != taskpkg.DependencyKindRelates { ++ t.Fatalf("parseOptionalTaskDependencyKind(relates) = (%q, %v), want relates", kind, err) ++ } +``` +
+ + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion + if kind, err := parseOptionalTaskDependencyKind("blocks"); err != nil || kind != taskpkg.DependencyKindBlocks { + t.Fatalf("parseOptionalTaskDependencyKind(blocks) = (%q, %v), want blocks", kind, err) + } + if kind, err := parseOptionalTaskDependencyKind("relates"); err != nil || kind != taskpkg.DependencyKindRelates { + t.Fatalf("parseOptionalTaskDependencyKind(relates) = (%q, %v), want relates", kind, err) + } +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/cli/task_test.go` around lines 607 - 611, The test is inverted: +parseOptionalTaskDependencyKind accepts "relates" so the negative assertion +should be changed to expect success; update the second assertion in the test to +call parseOptionalTaskDependencyKind("relates") and assert err == nil and kind +== taskpkg.DependencyKindRelates (or the appropriate constant), instead of +checking for an "unsupported value" error, so both supported values ("blocks" +and "relates") are validated as successful parses. +``` + +
+ + + + + +## Triage + +- Decision: `invalid` +- Notes: + This review comment is stale relative to the current task domain. `parseOptionalTaskDependencyKind` delegates to `taskpkg.DependencyKind.Validate`, and the current enum only supports `blocks`. + Evidence: + `internal/task/types.go` defines only `DependencyKindBlocks`. + `internal/task/validate.go` accepts only `DependencyKindBlocks` and returns `unsupported value` for everything else. + `internal/cli/task_test.go` is therefore correct to keep `"relates"` as the negative regression case. + No production or test change is required for this issue. + +## Resolution + +- No code change was made. The current dependency-kind enum only supports `blocks`, so keeping `"relates"` as the unsupported-value regression case is correct. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_011.md b/.compozy/tasks/core-tasks/reviews-002/issue_011.md new file mode 100644 index 000000000..1e619d26a --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_011.md @@ -0,0 +1,184 @@ +--- +status: resolved +file: internal/daemon/daemon_test.go +line: 783 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM564Lf6,comment:PRRC_kwDOR5y4QM63o2Pv +--- + +# Issue 011: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Use a `t.Run("Should...")` subtest wrapper for this new test case.** + +This new test is added as a top-level body only, but this repo requires the “Should...” subtest pattern for test cases. + +
+🔧 Minimal refactor shape + +```diff + func TestBootExtensionsKeepsHealthyRegisteredExtensionsAfterPartialStartFailure(t *testing.T) { + t.Parallel() ++ t.Run("ShouldKeepHealthyRegisteredExtensionsAfterPartialStartFailure", func(t *testing.T) { ++ t.Parallel() + +- // current test body... ++ // current test body... ++ }) + } +``` +
+ + +As per coding guidelines, `**/*_test.go`: "MUST use t.Run("Should...") pattern for ALL test cases" and "Use table-driven tests with subtests (t.Run) as default in Go tests". + + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion +func TestBootExtensionsKeepsHealthyRegisteredExtensionsAfterPartialStartFailure(t *testing.T) { + t.Parallel() + t.Run("ShouldKeepHealthyRegisteredExtensionsAfterPartialStartFailure", func(t *testing.T) { + t.Parallel() + + db := openDaemonTestGlobalDB(t) + installDaemonTestExtension(t, db, "ext-healthy", daemonTestExtensionOptions{}, true) + installDaemonTestExtension(t, db, "ext-bad", daemonTestExtensionOptions{}, true) + + var logBuffer bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logBuffer, nil)) + runtime := &fakeExtensionRuntime{ + startErr: errors.New("boom"), + getFn: func(name string) (*extensionpkg.Extension, error) { + switch name { + case "ext-healthy": + return &extensionpkg.Extension{ + Info: extensionpkg.ExtensionInfo{ + Name: "ext-healthy", + Enabled: true, + }, + Status: extensionpkg.ExtensionStatus{ + Name: "ext-healthy", + Enabled: true, + Registered: true, + }, + }, nil + case "ext-bad": + return nil, extensionpkg.ErrExtensionNotFound + default: + return nil, extensionpkg.ErrExtensionNotFound + } + }, + } + homePaths := testHomePaths(t) + d := newTestDaemon(t, homePaths, testConfig(t, homePaths)) + d.newExtensionManager = func(extensionManagerDeps) extensionRuntime { + return runtime + } + + rebuilds := 0 + state := &bootState{ + logger: logger, + registry: db, + sessions: &fakeSessionManager{}, + observer: &fakeObserver{}, + bridges: &bridgeRuntime{broker: bridgepkg.NewBroker(nil)}, + hooks: &fakeHookRuntime{ + onRebuild: func(context.Context) error { + rebuilds++ + return nil + }, + }, + } + cleanup := &bootCleanup{} + + if err := d.bootExtensions(testutil.Context(t), state, cleanup); err != nil { + t.Fatalf("bootExtensions() error = %v, want nil", err) + } + + if runtime.startCount != 1 { + t.Fatalf("extension runtime start count = %d, want 1", runtime.startCount) + } + if rebuilds != 1 { + t.Fatalf("hook rebuild count = %d, want 1 after partial start", rebuilds) + } + if len(cleanup.fns) != 1 { + t.Fatalf("cleanup fns = %d, want 1", len(cleanup.fns)) + } + if state.currentExtensionRuntime() != runtime { + t.Fatalf("state.extensions = %#v, want runtime", state.currentExtensionRuntime()) + } + if state.deps.Extensions == nil { + t.Fatal("state.deps.Extensions = nil, want extension service") + } + if state.bridges.extensions != runtime { + t.Fatalf("state.bridges.extensions = %#v, want runtime", state.bridges.extensions) + } + healthy, err := state.deps.Extensions.Status(testutil.Context(t), "ext-healthy") + if err != nil { + t.Fatalf("Extensions.Status(ext-healthy) error = %v", err) + } + if got, want := healthy.State, "registered"; got != want { + t.Fatalf("ext-healthy state = %q, want %q", got, want) + } + bad, err := state.deps.Extensions.Status(testutil.Context(t), "ext-bad") + if err != nil { + t.Fatalf("Extensions.Status(ext-bad) error = %v", err) + } + if got, want := bad.State, "enabled"; got != want { + t.Fatalf("ext-bad state = %q, want %q", got, want) + } + if !strings.Contains(logBuffer.String(), "healthy extensions only") { + t.Fatalf("log output = %q, want partial start continuation message", logBuffer.String()) + } + }) +} +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/daemon/daemon_test.go` around lines 690 - 783, Wrap the entire test +body of +TestBootExtensionsKeepsHealthyRegisteredExtensionsAfterPartialStartFailure in a +t.Run subtest using the "Should..." naming convention (e.g. t.Run("Should keep +healthy registered extensions after partial start failure", func(t *testing.T) { +... })); move t.Parallel() into that subtest and keep all setup/assertions (db +:= openDaemonTestGlobalDB, installDaemonTestExtension, runtime := +&fakeExtensionRuntime{...}, d.newExtensionManager, state := &bootState{...}, +cleanup := &bootCleanup{}, call to d.bootExtensions, and all checks) inside the +subtest closure so the top-level Test... function only invokes t.Run with the +described name. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + The new daemon test body was added directly under the top-level `Test...` function without the repo-standard `t.Run("Should...")` wrapper. + Root cause: the new case skipped the required subtest structure used throughout the Go test suite. + Planned fix: wrap the test body in a `Should...` subtest and keep `t.Parallel()` inside that closure. + +## Resolution + +- Wrapped the daemon extension boot regression case in a `t.Run("Should...")` subtest and kept `t.Parallel()` inside that closure so the test follows the suite’s required structure. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_012.md b/.compozy/tasks/core-tasks/reviews-002/issue_012.md new file mode 100644 index 000000000..e603b9a7a --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_012.md @@ -0,0 +1,81 @@ +--- +status: resolved +file: internal/extension/host_api_test.go +line: 1019 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM564LgJ,comment:PRRC_kwDOR5y4QM63o2QD +--- + +# Issue 012: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**Increase the notifier wait timeout to reduce CI flakiness.** + +A `500ms` timeout can be too tight under loaded CI and cause nondeterministic failures despite correct behavior. + +
+💡 Suggested tweak + +```diff +- case <-time.After(500 * time.Millisecond): ++ case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for prompt completion") +``` +
+ + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion + select { + case notifiedSessionID := <-turnEnded: + if got, want := notifiedSessionID, sess.ID; got != want { + t.Fatalf("turn end notifier session id = %q, want %q", got, want) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for prompt completion") + } +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/extension/host_api_test.go` around lines 1012 - 1019, The test's +select that waits on the turnEnded notifier is using a 500ms timeout which is +too short for CI; update the time.After(500 * time.Millisecond) to a larger +duration (e.g. 2s or make it configurable) so the case reading from the +turnEnded channel (notifiedSessionID compared to sess.ID) has more leeway and +reduces flakiness in CI. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + The notifier wait is condition-based, but the current `500ms` timeout is still tighter than the rest of the async integration tests and can become flaky under loaded CI. + Root cause: the completion-notification timeout is overly aggressive for an asynchronous delivery path. + Planned fix: increase the timeout to a safer value so legitimate notifier delivery is not misclassified as a failure. + +## Resolution + +- Increased the host API notifier wait timeout from `500ms` to `2s` in `internal/extension/host_api_test.go` to reduce CI flakiness on the async prompt-completion path. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_013.md b/.compozy/tasks/core-tasks/reviews-002/issue_013.md new file mode 100644 index 000000000..dea4277d4 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_013.md @@ -0,0 +1,58 @@ +--- +status: resolved +file: internal/extension/host_api_test.go +line: 1770 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM564LgU,comment:PRRC_kwDOR5y4QM63o2QR +--- + +# Issue 013: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Use `t.Run("Should...")` naming for all subtests in new task test tables.** + +Several new subtest names (`CreateDenied`, `UnknownWorkspace`, `GetTask`, etc.) don’t follow the required `Should...` convention. + +As per coding guidelines, "MUST use t.Run("Should...") pattern for ALL test cases". + + + +Also applies to: 2308-2331, 2344-2396, 2421-2448, 2472-2499, 2525-2547 + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/extension/host_api_test.go` around lines 1740 - 1770, The subtest +names in the table (the slice named tests and its entries used in t.Run(tt.name, +...)) don't follow the required "Should..." pattern; update each test case's +name field (e.g., "CreateDenied", "UpdateDenied", "RunStartDenied" and all +similar names in the other blocks) to be prefixed with "Should" and a +descriptive action (e.g., "Should deny create", "Should deny update", "Should +deny run start"), and keep the test body unchanged (the call to env.call and the +assertCapabilityDenied assertions). Ensure you update the string used in +t.Run(...) to the new "Should..." value so all subtests comply with the naming +convention. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + Several new table-driven subtests in `internal/extension/host_api_test.go` use names like `CreateDenied`, `UnknownWorkspace`, and `GetTask`, which do not follow the repository's `Should...` convention. + Root cause: the new task-related table rows were added with terse identifiers rather than full `Should...` test-case names. + Planned fix: rename the affected table-entry names so every `t.Run(tt.name, ...)` in the touched task tables uses a `Should...` label. + +## Resolution + +- Renamed the affected task-related table-driven subtests in `internal/extension/host_api_test.go` so each `t.Run(tt.name, ...)` now uses the required `Should...` naming convention. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_014.md b/.compozy/tasks/core-tasks/reviews-002/issue_014.md new file mode 100644 index 000000000..b68f0ee96 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_014.md @@ -0,0 +1,164 @@ +--- +status: resolved +file: internal/extension/host_api_test.go +line: 2785 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM564Lga,comment:PRRC_kwDOR5y4QM63o2QY +--- + +# Issue 014: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**Wrap executor errors with operation context.** + +The new task session executor returns raw errors from session operations, which makes failures harder to diagnose and violates the explicit wrapping rule. + +
+🛠️ Suggested fix + +```diff + created, err := e.sessions.Create(ctx, opts) + if err != nil { +- return nil, err ++ return nil, fmt.Errorf("start task session: create session: %w", err) + } +@@ + info, err := e.sessions.Status(ctx, strings.TrimSpace(sessionID)) + if err != nil { +- return nil, err ++ return nil, fmt.Errorf("attach task session: read session status: %w", err) + } +@@ + func (e *hostAPITestTaskSessionExecutor) RequestTaskStop(ctx context.Context, sessionID string, _ taskpkg.StopReason) error { +- return e.sessions.RequestStopWithCause(ctx, strings.TrimSpace(sessionID), session.CauseUserRequested, "task cancellation") ++ if ctx == nil { ++ return errors.New("extension: host api test task request stop context is required") ++ } ++ if err := e.sessions.RequestStopWithCause(ctx, strings.TrimSpace(sessionID), session.CauseUserRequested, "task cancellation"); err != nil { ++ return fmt.Errorf("request task stop: %w", err) ++ } ++ return nil + } + + func (e *hostAPITestTaskSessionExecutor) ForceTaskStop(ctx context.Context, sessionID string, _ taskpkg.StopReason) error { +- return e.sessions.StopWithCause(ctx, strings.TrimSpace(sessionID), session.CauseUserRequested, "task cancellation") ++ if ctx == nil { ++ return errors.New("extension: host api test task force stop context is required") ++ } ++ if err := e.sessions.StopWithCause(ctx, strings.TrimSpace(sessionID), session.CauseUserRequested, "task cancellation"); err != nil { ++ return fmt.Errorf("force task stop: %w", err) ++ } ++ return nil + } +``` +
+ +As per coding guidelines, "Use explicit error returns with wrapped context: `fmt.Errorf(\"context: %w\", err)`". + + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion + created, err := e.sessions.Create(ctx, opts) + if err != nil { + return nil, fmt.Errorf("start task session: create session: %w", err) + } + info := created.Info() + if info == nil { + return nil, fmt.Errorf("%w: task session create returned nil session info", taskpkg.ErrValidation) + } + return &taskpkg.SessionRef{ + SessionID: info.ID, + WorkspaceID: info.WorkspaceID, + StartedAt: info.CreatedAt, + }, nil +} + +func (e *hostAPITestTaskSessionExecutor) AttachTaskSession(ctx context.Context, _ string, sessionID string) (*taskpkg.SessionRef, error) { + if ctx == nil { + return nil, errors.New("extension: host api test task attach context is required") + } + + info, err := e.sessions.Status(ctx, strings.TrimSpace(sessionID)) + if err != nil { + return nil, fmt.Errorf("attach task session: read session status: %w", err) + } + if info == nil || info.State != session.StateActive { + return nil, fmt.Errorf("%w: session %q is not active", taskpkg.ErrSessionAttachNotAllowed, strings.TrimSpace(sessionID)) + } + return &taskpkg.SessionRef{ + SessionID: info.ID, + WorkspaceID: info.WorkspaceID, + StartedAt: info.CreatedAt, + }, nil +} + +func (e *hostAPITestTaskSessionExecutor) RequestTaskStop(ctx context.Context, sessionID string, _ taskpkg.StopReason) error { + if ctx == nil { + return errors.New("extension: host api test task request stop context is required") + } + if err := e.sessions.RequestStopWithCause(ctx, strings.TrimSpace(sessionID), session.CauseUserRequested, "task cancellation"); err != nil { + return fmt.Errorf("request task stop: %w", err) + } + return nil +} + +func (e *hostAPITestTaskSessionExecutor) ForceTaskStop(ctx context.Context, sessionID string, _ taskpkg.StopReason) error { + if ctx == nil { + return errors.New("extension: host api test task force stop context is required") + } + if err := e.sessions.StopWithCause(ctx, strings.TrimSpace(sessionID), session.CauseUserRequested, "task cancellation"); err != nil { + return fmt.Errorf("force task stop: %w", err) + } + return nil +} +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/extension/host_api_test.go` around lines 2745 - 2785, The executor +currently returns raw errors from session operations; wrap each returned error +with contextual text using fmt.Errorf so failures are identifiable: when +creating sessions wrap the error from e.sessions.Create (in the Create flow that +assigns created, err), when attaching wrap the error from e.sessions.Status in +AttachTaskSession, and when stopping wrap the errors returned by +e.sessions.RequestStopWithCause in RequestTaskStop and e.sessions.StopWithCause +in ForceTaskStop — use messages like "create session: %w", "attach session +: %w", "request stop : %w" and "force stop : +%w" (trim sessionID where appropriate) to preserve the original error while +adding operation context. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + The host-api task session executor in the test harness returns raw errors from `sessions.Create`, `sessions.Status`, `RequestStopWithCause`, and `StopWithCause`, which strips operation context from failures. + Root cause: these helper methods pass session-layer errors through directly instead of wrapping them at the boundary. + Planned fix: wrap each returned error with operation-specific context while preserving the original error via `%w`. + +## Resolution + +- Wrapped the task-session executor helper errors in `internal/extension/host_api_test.go` with operation-specific context and added nil-context guards for the stop helpers. +- This keeps failures diagnosable while preserving the underlying errors for assertions. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_015.md b/.compozy/tasks/core-tasks/reviews-002/issue_015.md new file mode 100644 index 000000000..93337dae6 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_015.md @@ -0,0 +1,151 @@ +--- +status: resolved +file: internal/extension/manager_test.go +line: 762 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM564Lgw,comment:PRRC_kwDOR5y4QM63o2Qx +--- + +# Issue 015: _🛠️ Refactor suggestion_ | _🟠 Major_ +## Review Comment + +_🛠️ Refactor suggestion_ | _🟠 Major_ + +
+🧩 Analysis chain + +🏁 Script executed: + +```shell +#!/bin/bash +# Verify string-based error matching in the changed reload tests. +rg -nP 'strings\.Contains\(\s*err\.Error\(\)\s*,\s*"[^"]+"\s*\)' internal/extension/manager_test.go +``` + +Repository: compozy/agh + +Length of output: 613 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Step 1: Find the Reload() method definition and its error returns +rg -A 10 'func.*Reload\(' internal/extension/ | head -30 +``` + +Repository: compozy/agh + +Length of output: 674 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Step 2: Check if ErrorContains helper exists in testutil +rg -r 'ErrorContains|ErrorAs' internal/testutil/ internal/api/testutil/ 2>/dev/null | head -20 +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Step 3: Check error definitions in manager.go - look for custom error types or sentinel errors +rg -B 2 -A 2 'errors\.(New|Is|As)|fmt\.Errorf|var.*Error' internal/extension/manager.go | head -40 +``` + +Repository: compozy/agh + +Length of output: 1058 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +# Step 4: Get context around the specific error messages to see what functions return them +rg -B 5 '"manager is required"' internal/extension/ +rg -B 5 '"registry is required"' internal/extension/ +``` + +Repository: compozy/agh + +Length of output: 1472 + +--- + + + +
+ +**Refactor errors to use sentinel values instead of string matching.** + +Lines 760 and 781 use `strings.Contains(err.Error(), "...")` to match errors, which violates the coding guidelines. Errors returned by `Reload()`, `Start()`, `Stop()`, and related methods are currently created with `errors.New("extension: ...")`, making them unsuitable for `errors.Is()` matching. + +Define these errors as package-level sentinels in `manager.go`: +```go +var ( + ErrContextRequired = errors.New("extension: context is required") + ErrManagerRequired = errors.New("extension: manager is required") + ErrRegistryRequired = errors.New("extension: registry is required") + // ... others as needed +) +``` + +Then update the test assertions to use `errors.Is()`: +```go +if err := nilManager.Reload(testutil.Context(t)); !errors.Is(err, ErrManagerRequired) { + t.Fatalf("nil manager Reload() error = %v, want %v", err, ErrManagerRequired) +} +``` + +Apply the same refactor to other string-based error checks in the file (lines 271, 1015, 1019). + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/extension/manager_test.go` around lines 760 - 762, Tests currently +assert errors by matching substrings of err.Error() (e.g., in manager_test.go +around Reload/Start/Stop checks); define package-level sentinel errors in +manager.go (e.g., ErrContextRequired, ErrManagerRequired, ErrRegistryRequired, +etc.) and replace the string-based errors.New(...) returns in the manager +methods with those sentinel variables, then update the tests (checks around +Reload, Start, Stop and other mentioned lines) to use errors.Is(err, +ErrManagerRequired) (or the appropriate sentinel) instead of strings.Contains, +ensuring all relevant error returns and test assertions reference the new +sentinel symbols like ErrManagerRequired, ErrContextRequired, +ErrRegistryRequired and the methods Reload/Start/Stop. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + The touched tests in `internal/extension/manager_test.go` currently verify manager failures by matching substrings in `err.Error()`, and those call sites are backed by ad hoc `errors.New(...)` values in `internal/extension/manager.go`. + Root cause: the manager exposes several repeated error conditions without stable sentinel values, forcing brittle string matching in tests and callers. + Planned fix: introduce minimal package-level sentinel errors in `internal/extension/manager.go`, return or wrap them from the relevant manager methods, and update the touched tests to use `errors.Is(...)`. This requires a minimal supporting production change outside the scoped file. + +## Resolution + +- Added stable sentinel errors in `internal/extension/manager.go` for the repeated manager failure conditions and updated the touched tests in `internal/extension/manager_test.go` to assert them with `errors.Is(...)`. +- This required a minimal supporting production change outside the scoped test file, as noted in triage. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_016.md b/.compozy/tasks/core-tasks/reviews-002/issue_016.md new file mode 100644 index 000000000..71cb6e118 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_016.md @@ -0,0 +1,62 @@ +--- +status: resolved +file: internal/network/tasks_integration_test.go +line: 247 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM564Lg1,comment:PRRC_kwDOR5y4QM63o2Q5 +--- + +# Issue 016: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**Use `context.Background()` in cleanup to avoid cancelled context.** + +The cleanup function uses `ctx` from `testutil.Context(t)`, but test contexts are typically cancelled when the test completes. This could cause `db.Close()` to fail if the context is already cancelled. The cleanup at line 280 correctly uses `context.Background()` for the same reason. + +
+🔧 Suggested fix for consistent cleanup context + +```diff + t.Cleanup(func() { +- if err := db.Close(ctx); err != nil { ++ if err := db.Close(context.Background()); err != nil { + t.Fatalf("GlobalDB.Close() error = %v", err) + } + }) +``` +
+ +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/network/tasks_integration_test.go` around lines 243 - 247, The test +cleanup currently calls db.Close(ctx) using the test context obtained via +testutil.Context(t), which may be cancelled when the test finishes; change the +cleanup to call db.Close(context.Background()) so the close runs with a +non-cancelled context—update the t.Cleanup anonymous function to call +db.Close(context.Background()) instead of db.Close(ctx) (referencing the +existing t.Cleanup(func() { ... }) and db.Close(...) call). +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + The cleanup in `openNetworkTaskIngressDB()` closes the database with the per-test context captured earlier in the test, which may already be canceled when cleanup runs. + Root cause: cleanup reused a test-scoped context instead of a fresh background context for teardown. + Planned fix: use `context.Background()` for the close call, matching the other cleanup path in the same file. + +## Resolution + +- Updated the cleanup path in `internal/network/tasks_integration_test.go` to close the database with `context.Background()` so teardown does not depend on a possibly canceled test context. diff --git a/.compozy/tasks/core-tasks/reviews-002/issue_017.md b/.compozy/tasks/core-tasks/reviews-002/issue_017.md new file mode 100644 index 000000000..1e945f226 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-002/issue_017.md @@ -0,0 +1,27 @@ +--- +status: resolved +file: internal/observe/health.go +line: 49 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4107556463,nitpick_hash:48134b88e4d0 +review_hash: 48134b88e4d0 +source_review_id: "4107556463" +source_review_submitted_at: "2026-04-14T16:23:06Z" +--- + +# Issue 017: Consider graceful degradation instead of failing the full health snapshot. +## Review Comment + +`collectTaskHealth` now gates the entire `Health(ctx)` response. Since task health aggregation depends on multiple queries, transient task-store failures can make the whole health endpoint fail. Consider returning core health with a degraded task status (plus telemetry) rather than hard-failing the full snapshot. + +## Triage + +- Decision: `invalid` +- Notes: + This comment proposes a different health-endpoint contract rather than identifying a correctness bug in the current implementation. The current behavior is intentional and already codified by `TestObserverHealthWrapsTaskHealthErrors`, which expects task-health failures to bubble out of `Health()`. + Changing `Health()` to degrade gracefully would require a broader API/observability contract decision and corresponding test updates, not a localized fix in this review batch. + +## Resolution + +- No code change was made. This is an API-contract/design suggestion, and the current fail-fast `Health()` behavior remains intentional and covered by existing tests. diff --git a/.compozy/tasks/core-tasks/reviews-003/_meta.md b/.compozy/tasks/core-tasks/reviews-003/_meta.md new file mode 100644 index 000000000..b46b86bb9 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-003/_meta.md @@ -0,0 +1,11 @@ +--- +provider: coderabbit +pr: "19" +round: 3 +created_at: 2026-04-14T20:02:46.615313Z +--- + +## Summary +- Total: 14 +- Resolved: 0 +- Unresolved: 14 diff --git a/.compozy/tasks/core-tasks/reviews-003/issue_001.md b/.compozy/tasks/core-tasks/reviews-003/issue_001.md new file mode 100644 index 000000000..a4c4971d9 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-003/issue_001.md @@ -0,0 +1,53 @@ +--- +status: resolved +file: internal/api/core/automation_additional_test.go +line: 114 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM565Hy-,comment:PRRC_kwDOR5y4QM63qGaR +--- + +# Issue 001: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Status-only subtests make this coverage too shallow.** + +Every case here only checks `200/204`, so a handler can return the wrong payload or miss the endpoint-specific behavior and this table still passes. Please assert at least one response field or one stub side effect per route; if you keep the table, the subtest names should also follow the required `Should...` pattern. + + + +As per coding guidelines, `**/*_test.go`: MUST use `t.Run("Should...")` pattern for ALL test cases; MUST test meaningful business logic, not trivial operations. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/api/core/automation_additional_test.go` around lines 87 - 114, The +table-driven subtests (using performRequest, fixture.Engine and t.Run) only +assert HTTP status codes, which is too shallow and the t.Run names don't follow +the "Should..." pattern; update each subtest to use t.Run("Should ...") and add +at least one meaningful assertion per route: for GETs assert a specific JSON +field/value or structure in resp.Body (e.g., job id/name for +"/automation/jobs/job-1", trigger fields for "/automation/triggers/trigger-1", +run entries for runs endpoints) and for DELETEs assert side-effects such as a +subsequent GET to the same resource returns 404 or that a list endpoint no +longer contains the deleted id (performRequest + resp.Body checks); keep using +the existing table but include an expected-check function or switch on +request.path to validate payloads/side effects rather than only status. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + The test only checks HTTP status codes and uses non-compliant subtest labels, so a broken payload mapper or missing delete side effect would still pass. I will rename the subtests to `Should...`, make the automation stub stateful for deletes, and assert endpoint-specific payload fields or delete side effects. + Resolution: Reworked the endpoint table to use `Should...` names, stateful delete behavior, wrapped-response assertions for job/trigger/run payloads, and follow-up 404 checks after deletes. diff --git a/.compozy/tasks/core-tasks/reviews-003/issue_002.md b/.compozy/tasks/core-tasks/reviews-003/issue_002.md new file mode 100644 index 000000000..74b3089c1 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-003/issue_002.md @@ -0,0 +1,27 @@ +--- +status: resolved +file: internal/api/core/automation_test.go +line: 625 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4108760624,nitpick_hash:e441fb3a58c3 +review_hash: e441fb3a58c3 +source_review_id: "4108760624" +source_review_submitted_at: "2026-04-14T20:02:29Z" +--- + +# Issue 002: Split the new helper assertions into t.Run("Should ...") subtests. +## Review Comment + +These added task-related checks are currently embedded in one large test, which reduces failure isolation and diverges from the repo’s test-case structure policy. + +As per coding guidelines, `**/*_test.go`: `MUST use t.Run("Should...") pattern for ALL test cases`. + +--- + +## Triage + +- Decision: `valid` +- Notes: + The helper coverage in `TestAutomationHelperFunctionsAndErrors` is currently one large block, which weakens failure isolation and violates the repo test-structure rule. I will split the task-related helper checks into focused `t.Run("Should ...")` subtests. + Resolution: Split the helper coverage into focused `Should ...` subtests for validation wrapping, timestamp parsing, payload decoding, job mapping, job patch cloning, trigger mapping, and status-code mapping. diff --git a/.compozy/tasks/core-tasks/reviews-003/issue_003.md b/.compozy/tasks/core-tasks/reviews-003/issue_003.md new file mode 100644 index 000000000..00b8b8f4c --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-003/issue_003.md @@ -0,0 +1,83 @@ +--- +status: resolved +file: internal/api/core/automation_test.go +line: 645 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM567YIh,comment:PRRC_kwDOR5y4QM63tHj6 +--- + +# Issue 003: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**Assert `Owner.Kind` in the new task mapping check.** + +Line 644 validates `Owner.Ref` but skips `Owner.Kind`, so a regression in ownership-kind mapping could pass unnoticed. + +
+Proposed test assertion update + +```diff +- if createdJob.Scope != automationpkg.AutomationScopeWorkspace || createdJob.Name != "build review" || createdJob.AgentName != "coder" || createdJob.WorkspaceID != "ws-alpha" || createdJob.Prompt != "inspect repo" || createdJob.Schedule == nil || createdJob.Schedule.Interval != "2h" || createdJob.Task == nil || createdJob.Task.Title != "Review repo" || createdJob.Task.NetworkChannel != "ops-automation" || createdJob.Task.Owner == nil || createdJob.Task.Owner.Ref != "rule:build-review" { ++ if createdJob.Scope != automationpkg.AutomationScopeWorkspace || createdJob.Name != "build review" || createdJob.AgentName != "coder" || createdJob.WorkspaceID != "ws-alpha" || createdJob.Prompt != "inspect repo" || createdJob.Schedule == nil || createdJob.Schedule.Interval != "2h" || createdJob.Task == nil || createdJob.Task.Title != "Review repo" || createdJob.Task.NetworkChannel != "ops-automation" || createdJob.Task.Owner == nil || createdJob.Task.Owner.Kind != taskpkg.OwnerKindAutomation || createdJob.Task.Owner.Ref != "rule:build-review" { + t.Fatalf("jobFromCreateRequest() = %#v", createdJob) + } +``` +
+ + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion + Task: &automationpkg.JobTaskConfig{ + Title: " Review repo ", + NetworkChannel: " ops-automation ", + Owner: &taskpkg.Ownership{ + Kind: taskpkg.OwnerKindAutomation, + Ref: " rule:build-review ", + }, + }, + }) + if createdJob.Scope != automationpkg.AutomationScopeWorkspace || createdJob.Name != "build review" || createdJob.AgentName != "coder" || createdJob.WorkspaceID != "ws-alpha" || createdJob.Prompt != "inspect repo" || createdJob.Schedule == nil || createdJob.Schedule.Interval != "2h" || createdJob.Task == nil || createdJob.Task.Title != "Review repo" || createdJob.Task.NetworkChannel != "ops-automation" || createdJob.Task.Owner == nil || createdJob.Task.Owner.Kind != taskpkg.OwnerKindAutomation || createdJob.Task.Owner.Ref != "rule:build-review" { + t.Fatalf("jobFromCreateRequest() = %#v", createdJob) + } +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/api/core/automation_test.go` around lines 635 - 645, The test +assertion after creating the job (checking createdJob from jobFromCreateRequest) +currently verifies Task.Owner.Ref but omits verifying Task.Owner.Kind; update +the assertion to also check createdJob.Task.Owner.Kind == +taskpkg.OwnerKindAutomation so ownership-kind mapping is validated alongside +Owner.Ref and other fields (adjust the combined conditional that compares +createdJob.Scope, Name, AgentName, WorkspaceID, Prompt, Schedule, Task.Title, +Task.NetworkChannel, Task.Owner.Ref to include Task.Owner.Kind). +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + The `jobFromCreateRequest` assertion checks `Task.Owner.Ref` but not `Task.Owner.Kind`, so an ownership-kind mapping regression would escape. I will extend the assertion to verify `taskpkg.OwnerKindAutomation`. + Resolution: Extended the `jobFromCreateRequest` assertion to verify both `Task.Owner.Kind` and `Task.Owner.Ref`. diff --git a/.compozy/tasks/core-tasks/reviews-003/issue_004.md b/.compozy/tasks/core-tasks/reviews-003/issue_004.md new file mode 100644 index 000000000..de095a39a --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-003/issue_004.md @@ -0,0 +1,23 @@ +--- +status: resolved +file: internal/api/core/automation_test.go +line: 654 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4108760624,nitpick_hash:79e0eafc5809 +review_hash: 79e0eafc5809 +source_review_id: "4108760624" +source_review_submitted_at: "2026-04-14T20:02:29Z" +--- + +# Issue 004: Add an anti-aliasing assertion for patched Task. +## Review Comment + +The new patch assertions verify values, but not clone semantics. A direct pointer assignment regression would still pass. + +## Triage + +- Decision: `valid` +- Notes: + The patched job assertion validates copied values but not clone semantics, so a future pointer-alias regression would still pass. I will mutate the source task config after patching and assert the patched job stays unchanged. + Resolution: Strengthened the patch test by giving the patched task an owner and mutating the source config after `applyJobPatch`; the assertions now prove the patched task was cloned rather than aliased. diff --git a/.compozy/tasks/core-tasks/reviews-003/issue_005.md b/.compozy/tasks/core-tasks/reviews-003/issue_005.md new file mode 100644 index 000000000..14d209166 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-003/issue_005.md @@ -0,0 +1,218 @@ +--- +status: resolved +file: internal/api/udsapi/server_test.go +line: 158 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM565HzD,comment:PRRC_kwDOR5y4QM63qGaY +--- + +# Issue 005: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +
+🧩 Analysis chain + +🏁 Script executed: + +```shell +#!/bin/bash +set -euo pipefail + +# 1) Check if udsapi defines sentinel/typed errors for constructor validation. +rg -n --type=go -C3 'var\s+Err\w+\s*=\s*errors\.New|type\s+\w+Error\s+struct' internal/api/udsapi + +# 2) Inspect New(...) validation returns to see if errors are wrapped for errors.Is(). +rg -n --type=go -C3 'session manager is required|task service is required|observer is required|workspace resolver is required|fmt\.Errorf\(' internal/api/udsapi/server.go + +# 3) Find current error assertion style in this test file. +rg -n --type=go -C2 'err\.Error\(\)|strings\.Contains|errors\.Is|errors\.As' internal/api/udsapi/server_test.go +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + +🏁 Script executed: + +```shell +# First, verify the file exists and get basic info +find . -name "server_test.go" -path "*/udsapi/*" 2>/dev/null | head -20 +``` + +Repository: compozy/agh + +Length of output: 91 + +--- + +🏁 Script executed: + +```shell +# If the above finds the file, examine lines around 157-158 +if [ -f "internal/api/udsapi/server_test.go" ]; then + echo "=== Lines 150-170 of server_test.go ===" + sed -n '150,170p' internal/api/udsapi/server_test.go +fi +``` + +Repository: compozy/agh + +Length of output: 611 + +--- + +🏁 Script executed: + +```shell +# Check the New() function implementation in server.go +if [ -f "internal/api/udsapi/server.go" ]; then + echo "=== Searching for New() constructor ===" + rg -n "^func New" internal/api/udsapi/server.go -A 20 +fi +``` + +Repository: compozy/agh + +Length of output: 685 + +--- + +🏁 Script executed: + +```shell +# Search for error definitions in the udsapi package +if [ -d "internal/api/udsapi" ]; then + echo "=== Looking for error definitions ===" + rg -n "var Err|type.*Error" internal/api/udsapi/ -t go +fi +``` + +Repository: compozy/agh + +Length of output: 92 + +--- + +🏁 Script executed: + +```shell +# Get the full New() function +sed -n '253,310p' internal/api/udsapi/server.go +``` + +Repository: compozy/agh + +Length of output: 1615 + +--- + +🏁 Script executed: + +```shell +# Get the test cases to see what wantErr values are +sed -n '100,160p' internal/api/udsapi/server_test.go +``` + +Repository: compozy/agh + +Length of output: 1679 + +--- + +🏁 Script executed: + +```shell +# Check the entire udsapi directory for any error type definitions +ls -la internal/api/udsapi/ +``` + +Repository: compozy/agh + +Length of output: 1440 + +--- + +🏁 Script executed: + +```shell +# Search more broadly for error types in udsapi +find internal/api/udsapi -name "*.go" -type f -exec basename {} \; +``` + +Repository: compozy/agh + +Length of output: 375 + +--- + +🏁 Script executed: + +```shell +# Check if errors are wrapped with fmt.Errorf anywhere in the file +rg -n "fmt\.Errorf.*session manager is required|fmt\.Errorf.*task service is required" internal/api/udsapi/server.go +``` + +Repository: compozy/agh + +Length of output: 37 + +--- + + + +
+ +**Use sentinel errors instead of string matching for New() validation errors.** + +The test currently matches error text with `strings.Contains(err.Error(), ...)`, which violates the coding guideline: "Use `errors.Is()` and `errors.As()` for error matching — never compare error strings." + +The New() function in server.go returns plain errors.New() for validation failures. To fix this: + +1. Define sentinel errors in server.go: + ```go + var ( + ErrSessionManagerRequired = errors.New("udsapi: session manager is required") + ErrTaskServiceRequired = errors.New("udsapi: task service is required") + ErrObserverRequired = errors.New("udsapi: observer is required") + ErrWorkspaceResolverRequired = errors.New("udsapi: workspace resolver is required") + ) + ``` + +2. Return these sentinel errors from New() instead of creating new errors each time. + +3. Update test assertions to use `errors.Is(err, ErrSessionManagerRequired)` instead of string matching. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/api/udsapi/server_test.go` around lines 157 - 158, Replace +string-based error matching with sentinel errors: define exported package-level +sentinel errors (ErrSessionManagerRequired, ErrTaskServiceRequired, +ErrObserverRequired, ErrWorkspaceResolverRequired) in server.go and have +New(...) return these sentinel errors for the respective validation failures +instead of constructing new errors each call; then update the test in +server_test.go to assert using errors.Is(err, ErrSessionManagerRequired) (and +the other sentinels) rather than strings.Contains(err.Error(), ...), locating +the logic around the New() call and the validation branches that currently +return errors.New. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + The test matches constructor failures with `strings.Contains`, which conflicts with the repo rule to use `errors.Is`/`errors.As` instead of error-string matching. Fixing this requires a minimal adjacent production change in `internal/api/udsapi/server.go`: introduce constructor sentinel errors and assert with `errors.Is` in the scoped test file. + Resolution: Added exported constructor sentinel errors in `internal/api/udsapi/server.go` and updated the scoped tests to assert them with `errors.Is`. diff --git a/.compozy/tasks/core-tasks/reviews-003/issue_006.md b/.compozy/tasks/core-tasks/reviews-003/issue_006.md new file mode 100644 index 000000000..5e4f47889 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-003/issue_006.md @@ -0,0 +1,23 @@ +--- +status: resolved +file: internal/automation/model/validate.go +line: 351 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4107787502,nitpick_hash:05ee35958032 +review_hash: 05ee35958032 +source_review_id: "4107787502" +source_review_submitted_at: "2026-04-14T17:02:50Z" +--- + +# Issue 006: Reuse the shared channel validator here. +## Review Comment + +`JobTaskConfig` now hardcodes its own regex, but task transport validation goes through `internal/network.ValidateChannel` in `internal/api/core/tasks.go`. If those rules drift, automation config can pass model validation and still fail when the delegated task is materialized. Prefer calling the shared validator instead of maintaining a second pattern. + +## Triage + +- Decision: `valid` +- Notes: + `JobTaskConfig.Validate` currently maintains its own channel regex while task transport validation uses `internal/network.ValidateChannel`, so the two validation paths can drift. I will replace the local regex check with the shared network validator and preserve the existing field-path context in the returned error. + Resolution: Removed the duplicated regex and moved the channel grammar into a shared leaf helper under `internal/network/rules`, so both network validation and automation-model validation use the same rule without introducing a package cycle. diff --git a/.compozy/tasks/core-tasks/reviews-003/issue_007.md b/.compozy/tasks/core-tasks/reviews-003/issue_007.md new file mode 100644 index 000000000..b43d73425 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-003/issue_007.md @@ -0,0 +1,45 @@ +--- +status: resolved +file: internal/cli/task_test.go +line: 51 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM565HzJ,comment:PRRC_kwDOR5y4QM63qGah +--- + +# Issue 007: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**Rename these subtests to the required `Should...` form.** + +This table still feeds `t.Run(...)` with names that do not follow the enforced Go test naming convention. As per coding guidelines, `**/*_test.go`: MUST use `t.Run("Should...")` pattern for ALL test cases. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/cli/task_test.go` around lines 16 - 51, The table-driven tests in +the tests variable use non-conforming names passed through t.Run(tt.name); +rename each tt.name value to the "Should ..." form (e.g. "Should require +workspace for workspace scope", "Should forbid workspace for global scope", +"Should require change flags on update", "Should reject clear owner with owner +mutation") so t.Run receives names that follow the enforced pattern; no +behavioral code changes needed beyond updating the string literals in the tests +slice. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + The table-driven CLI test uses non-compliant subtest labels even though the assertions themselves are fine. I will rename the table entries to `Should ...` without changing behavior. + Resolution: Renamed the CLI table entries to the required `Should ...` form with no behavioral changes. diff --git a/.compozy/tasks/core-tasks/reviews-003/issue_008.md b/.compozy/tasks/core-tasks/reviews-003/issue_008.md new file mode 100644 index 000000000..4a7ddc9f5 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-003/issue_008.md @@ -0,0 +1,27 @@ +--- +status: resolved +file: internal/daemon/task_runtime_test.go +line: 18 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4107909499,nitpick_hash:c00b2ac040ed +review_hash: c00b2ac040ed +source_review_id: "4107909499" +source_review_submitted_at: "2026-04-14T17:26:14Z" +--- + +# Issue 008: Test case names should use "Should" prefix pattern. +## Review Comment + +Per coding guidelines, test cases should use `t.Run("Should...")` pattern. Current names like `"workspace task uses workspace id"` and `"claimed without session requeues"` are descriptive but don't follow the prescribed format. + +As per coding guidelines, "MUST use t.Run('Should...') pattern for ALL test cases". + +Also applies to: 167-172 + +## Triage + +- Decision: `valid` +- Notes: + Several task-runtime test tables still use descriptive names that do not follow the enforced `Should...` subtest convention. I will rename the affected table entries only. + Resolution: Renamed the affected task-runtime table entries to `Should ...` labels only. diff --git a/.compozy/tasks/core-tasks/reviews-003/issue_009.md b/.compozy/tasks/core-tasks/reviews-003/issue_009.md new file mode 100644 index 000000000..14e134bf3 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-003/issue_009.md @@ -0,0 +1,23 @@ +--- +status: resolved +file: internal/extension/host_api_tasks.go +line: 187 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4107909499,nitpick_hash:d1c438039c88 +review_hash: d1c438039c88 +source_review_id: "4107909499" +source_review_submitted_at: "2026-04-14T17:26:14Z" +--- + +# Issue 009: Use ListTaskRuns for the runs endpoint instead of filtering GetTask().Runs. +## Review Comment + +There is already a dedicated service query for this path. Pulling the full task view here means limit/status/session filtering happens after the fetch, so behavior and cost can drift from the transport API that already uses `TaskService.ListTaskRuns`. + +## Triage + +- Decision: `valid` +- Notes: + The extension `tasks/runs` handler currently fetches the full task view and filters runs locally, even though the task manager already exposes `ListTaskRuns` with canonical filtering and lower fetch cost. I will route this endpoint through `ListTaskRuns` and adjust any required interface/test wiring. + Resolution: Updated the Host API task-manager interface and `tasks/runs` handler to use `ListTaskRuns(...)` directly instead of fetching a full task view and filtering in-memory. diff --git a/.compozy/tasks/core-tasks/reviews-003/issue_010.md b/.compozy/tasks/core-tasks/reviews-003/issue_010.md new file mode 100644 index 000000000..749bd9a1a --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-003/issue_010.md @@ -0,0 +1,53 @@ +--- +status: resolved +file: internal/extension/host_api_tasks.go +line: 456 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM565HzM,comment:PRRC_kwDOR5y4QM63qGak +--- + +# Issue 010: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Validate scope binding before resolving `workspace`.** + +Both helpers resolve the workspace reference first, so `scope=global` or an empty/invalid scope can turn an invalid-params request into `workspace not found`, and that makes the response depend on whether the workspace exists. + + + +Also applies to: 471-494 + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/extension/host_api_tasks.go` around lines 434 - 456, +taskQueryFromParams currently resolves the workspace (resolveTaskWorkspaceID) +before validating the requested scope, which can convert +invalid-scope/empty-scope errors into "workspace not found"; instead, normalize +and validate the scope binding from params.Scope (call Scope.Normalize() and any +scope-binding validation you have) first and only call resolveTaskWorkspaceID +when the scope indicates a workspace-bound query. Update taskQueryFromParams to: +normalize/validate params.Scope, then conditionally call resolveTaskWorkspaceID +and set WorkspaceID; keep the existing validateTaskChannel and query.Validate +flow. Apply the same change to the other function in this file that also calls +resolveTaskWorkspaceID before scope validation (search for other usages of +resolveTaskWorkspaceID and fix them similarly). +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + Both `taskQueryFromParams` and `createTaskSpecFromRequest` resolve the workspace before validating scope binding, which can turn an invalid-scope request into a misleading workspace-not-found error. I will validate/normalize scope first and only resolve a workspace when the scope is workspace-bound. + Resolution: Normalized and validated scope before workspace lookup, added `resolveTaskWorkspaceBinding(...)` for create flows, and added Host API regression coverage proving invalid scope/global binding errors win over workspace lookup failures. diff --git a/.compozy/tasks/core-tasks/reviews-003/issue_011.md b/.compozy/tasks/core-tasks/reviews-003/issue_011.md new file mode 100644 index 000000000..147fa54f3 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-003/issue_011.md @@ -0,0 +1,45 @@ +--- +status: resolved +file: internal/extension/host_api_test.go +line: 2398 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM565HzR,comment:PRRC_kwDOR5y4QM63qGap +--- + +# Issue 011: _⚠️ Potential issue_ | _🟡 Minor_ +## Review Comment + +_⚠️ Potential issue_ | _🟡 Minor_ + +**Rename the nested suite labels to `Should...`.** + +`MissingManager` and `InvalidInputs` still break the required `t.Run("Should...")` convention for Go tests. As per coding guidelines, `**/*_test.go`: MUST use `t.Run("Should...")` pattern for ALL test cases. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/extension/host_api_test.go` around lines 2288 - 2398, Rename the two +outer test suite labels that violate the t.Run("Should...") convention: change +the t.Run call using the literal "MissingManager" to a Should-prefixed label +(e.g., "ShouldRejectWhenManagerMissing") and change the t.Run call using +"InvalidInputs" to a Should-prefixed label (e.g., "ShouldRejectInvalidInputs"); +locate the offending calls by finding the t.Run invocations that wrap the +MissingManager and InvalidInputs blocks in internal/extension/host_api_test.go +and update only the string names to follow the "Should..." pattern. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + The outer suite labels `MissingManager` and `InvalidInputs` violate the repo’s `Should...` test naming convention. I will rename those two wrapper labels only. + Resolution: Renamed the two outer Host API task-validation suites to `Should...` labels only. diff --git a/.compozy/tasks/core-tasks/reviews-003/issue_012.md b/.compozy/tasks/core-tasks/reviews-003/issue_012.md new file mode 100644 index 000000000..4d9a828a5 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-003/issue_012.md @@ -0,0 +1,25 @@ +--- +status: resolved +file: internal/extension/manager_test.go +line: 31 +severity: nitpick +author: coderabbitai[bot] +provider_ref: review:4107909499,nitpick_hash:e5bde992e722 +review_hash: e5bde992e722 +source_review_id: "4107909499" +source_review_submitted_at: "2026-04-14T17:26:14Z" +--- + +# Issue 012: Pin the new sink stub to its interface. +## Review Comment + +Since `noopBridgeTelemetrySink` is now a reusable test double, add a compile-time interface assertion next to it so interface drift fails immediately instead of only where the stub is wired in. + +As per coding guidelines, `**/*.go`: "Use compile-time interface verification: `var _ Interface = (*Type)(nil)`". + +## Triage + +- Decision: `valid` +- Notes: + `noopBridgeTelemetrySink` is a reusable test double without compile-time interface pinning, so interface drift would surface later and less locally. I will add a compile-time assertion next to the stub definition. + Resolution: Added a compile-time `BridgeTelemetrySink` assertion beside the reusable sink stub. diff --git a/.compozy/tasks/core-tasks/reviews-003/issue_013.md b/.compozy/tasks/core-tasks/reviews-003/issue_013.md new file mode 100644 index 000000000..a35de78aa --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-003/issue_013.md @@ -0,0 +1,135 @@ +--- +status: resolved +file: internal/network/audit.go +line: 147 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM565Hzm,comment:PRRC_kwDOR5y4QM63qGbA +--- + +# Issue 013: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +
+🧩 Analysis chain + +🏁 Script executed: + +```shell +# First, locate and examine the audit.go file +find . -name "audit.go" -type f | head -20 +``` + +Repository: compozy/agh + +Length of output: 82 + +--- + +🏁 Script executed: + +```shell +# Read the relevant lines from internal/network/audit.go +if [ -f "internal/network/audit.go" ]; then + echo "=== Lines 130-155 ===" + sed -n '130,155p' internal/network/audit.go | cat -n + echo "" + echo "=== Lines 280-295 ===" + sed -n '280,295p' internal/network/audit.go | cat -n +else + echo "File not found" +fi +``` + +Repository: compozy/agh + +Length of output: 1530 + +--- + +🏁 Script executed: + +```shell +# Get the full file size to understand context better +wc -l internal/network/audit.go +``` + +Repository: compozy/agh + +Length of output: 84 + +--- + +🏁 Script executed: + +```shell +# Let me get more context around these lines to understand the function names +sed -n '120,160p' internal/network/audit.go | cat -n +``` + +Repository: compozy/agh + +Length of output: 1663 + +--- + +🏁 Script executed: + +```shell +# Also check around lines 287-289 for more context +sed -n '275,300p' internal/network/audit.go | cat -n +``` + +Repository: compozy/agh + +Length of output: 1132 + +--- + + + +
+ +**Wrap propagated errors with operation context.** + +Two locations in this function return or join raw errors without local operation context: + +1. **Line 139** (`return err`): The error from `normalizeTaskIngressAuditEntry()` should be wrapped with operation context before returning. +2. **Lines 144 and 147**: Errors from `w.appendFile(entry)` and `w.store.WriteNetworkAudit(ctx, entry)` should be wrapped before joining. +3. **Line 289**: The error from `entry.Validate()` should be wrapped before returning. + +Wrap each error with `fmt.Errorf()` to add operation-specific context (e.g., `fmt.Errorf("network: normalize task ingress audit entry: %w", err)`), making root-cause tracing easier in production. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/network/audit.go` around lines 136 - 147, The errors returned from +normalizeTaskIngressAuditEntry, w.appendFile, w.store.WriteNetworkAudit, and +entry.Validate need to be wrapped with operation context using fmt.Errorf and %w +before being returned or joined; specifically, replace the raw return of err +from normalizeTaskIngressAuditEntry with a wrapped error like +fmt.Errorf("network: normalize task ingress audit entry: %w", err), wrap the +results of w.appendFile(entry) and w.store.WriteNetworkAudit(ctx, entry) +similarly before they are combined into recordErr (so recordErr = +errors.Join(recordErr, fmt.Errorf("network: append file audit entry: %w", err))) +and wrap the error returned from entry.Validate() with context (e.g., +fmt.Errorf("network: validate audit entry: %w", err)) so all propagated errors +include clear operation-specific context. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + `RecordTaskIngress` and `normalizeTaskIngressAuditEntry` currently return/join raw downstream errors without local operation context, which weakens root-cause tracing. I will wrap the normalization, file append, store write, and entry validation errors with operation-specific context and add regression coverage. + Resolution: Wrapped task-ingress normalization, append, persistence, and entry-validation failures with local operation context and added regression tests for normalization and sink-write failure paths. diff --git a/.compozy/tasks/core-tasks/reviews-003/issue_014.md b/.compozy/tasks/core-tasks/reviews-003/issue_014.md new file mode 100644 index 000000000..842e53fc7 --- /dev/null +++ b/.compozy/tasks/core-tasks/reviews-003/issue_014.md @@ -0,0 +1,127 @@ +--- +status: resolved +file: internal/network/audit.go +line: 149 +author: coderabbitai[bot] +provider_ref: thread:PRRT_kwDOR5y4QM565HzW,comment:PRRC_kwDOR5y4QM63qGau +--- + +# Issue 014: _⚠️ Potential issue_ | _🟠 Major_ +## Review Comment + +_⚠️ Potential issue_ | _🟠 Major_ + +**Guard zero-value writer paths to avoid panic and silent audit drops.** + +At Line 136, `w.now()` can panic if `FileAuditWriter` is zero-initialized, and when both sinks are unset (`w.path == "" && w.store == nil`) this method currently returns `nil` without recording anything. + + + +
+🔧 Proposed hardening + +```diff + func (w *FileAuditWriter) RecordTaskIngress(ctx context.Context, audit TaskIngressAudit) error { +- if ctx == nil { +- return errors.New("network: audit context is required") +- } + if w == nil { + return errors.New("network: audit writer is required") + } ++ if ctx == nil { ++ return errors.New("network: audit context is required") ++ } ++ if w.path == "" && w.store == nil { ++ return errors.New("network: audit sink is required") ++ } ++ ++ now := w.now ++ if now == nil { ++ now = func() time.Time { return time.Now().UTC() } ++ } + +- entry, err := normalizeTaskIngressAuditEntry(audit, w.now()) ++ entry, err := normalizeTaskIngressAuditEntry(audit, now()) + if err != nil { + return err + } +``` +
+ + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion +func (w *FileAuditWriter) RecordTaskIngress(ctx context.Context, audit TaskIngressAudit) error { + if w == nil { + return errors.New("network: audit writer is required") + } + if ctx == nil { + return errors.New("network: audit context is required") + } + if w.path == "" && w.store == nil { + return errors.New("network: audit sink is required") + } + + now := w.now + if now == nil { + now = func() time.Time { return time.Now().UTC() } + } + + entry, err := normalizeTaskIngressAuditEntry(audit, now()) + if err != nil { + return err + } + + var recordErr error + if w.path != "" { + recordErr = errors.Join(recordErr, w.appendFile(entry)) + } + if w.store != nil { + recordErr = errors.Join(recordErr, w.store.WriteNetworkAudit(ctx, entry)) + } + + return recordErr +} +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against the current code and only fix it if needed. + +In `@internal/network/audit.go` around lines 128 - 149, The RecordTaskIngress +method calls w.now() before verifying the writer's sinks and before guarding +against a nil now func, which can panic on a zero-value FileAuditWriter and also +silently return when no sinks are configured; update RecordTaskIngress (in +FileAuditWriter) to first ensure at least one sink is set (w.path != "" || +w.store != nil) and return a descriptive error if none are configured, then +compute the timestamp using a safe now: use w.now() only if w.now != nil +otherwise fall back to time.Now(), then pass that timestamp to +normalizeTaskIngressAuditEntry; keep conditional calls to appendFile and +store.WriteNetworkAudit unchanged but ensure they run after these guards to +avoid panics and silent drops. +``` + +
+ + + + + +## Triage + +- Decision: `valid` +- Notes: + `RecordTaskIngress` can panic on a zero-value writer because it calls `w.now()` before guarding `w.now`, and it silently returns `nil` when no sinks are configured. I will add sink/clock guards plus regression tests for zero-value and sinkless writer paths. + Resolution: Hardened `RecordTaskIngress` with nil-writer, sink-required, and nil-clock guards, plus regression tests for sinkless writers and fallback timestamp behavior. diff --git a/.compozy/tasks/core-tasks/task_01.md b/.compozy/tasks/core-tasks/task_01.md index 1a19a3b09..da90317c7 100644 --- a/.compozy/tasks/core-tasks/task_01.md +++ b/.compozy/tasks/core-tasks/task_01.md @@ -1,5 +1,5 @@ --- -status: pending +status: completed title: "Bootstrap the `internal/task` domain" type: backend complexity: high @@ -26,11 +26,11 @@ Create the new `internal/task` package as the canonical home for task coordinati ## Subtasks -- [ ] 1.1 Create the `internal/task` package and its initial file layout for types, interfaces, validation, and errors. -- [ ] 1.2 Define the core domain structs and enums used throughout the TechSpec and ADRs. -- [ ] 1.3 Define actor, origin, and ownership models with server-derived semantics and optional ownership. -- [ ] 1.4 Define shared limit constants and validation helpers for sizes, tree depth, dependency count, and direct child count. -- [ ] 1.5 Define the store and session bridge interfaces that downstream implementations must satisfy. +- [x] 1.1 Create the `internal/task` package and its initial file layout for types, interfaces, validation, and errors. +- [x] 1.2 Define the core domain structs and enums used throughout the TechSpec and ADRs. +- [x] 1.3 Define actor, origin, and ownership models with server-derived semantics and optional ownership. +- [x] 1.4 Define shared limit constants and validation helpers for sizes, tree depth, dependency count, and direct child count. +- [x] 1.5 Define the store and session bridge interfaces that downstream implementations must satisfy. ## Implementation Details Use the TechSpec sections "Core Interfaces", "Actor and Identity Model", "Authorization Contract", "Data Models", and "Guardrails and Limits" as the source of truth. Keep the package boundary strict: `internal/task` defines its own interfaces and errors, while concrete implementations remain in `globaldb`, `daemon`, and transport packages. @@ -60,12 +60,12 @@ Use the TechSpec sections "Core Interfaces", "Actor and Identity Model", "Author ## Tests - Unit tests: - - [ ] Validate `global` vs `workspace` scope rules reject invalid `workspace_id` combinations. - - [ ] Validate immutable field helpers reject updates to `created_by`, `origin`, `scope`, `workspace_id`, and `parent_task_id`. - - [ ] Validate payload-size guardrails reject metadata over 16 KB and payload/result values over 64 KB. - - [ ] Validate graph-limit helpers reject depth over 8, dependencies over 32, and direct children over 64. + - [x] Validate `global` vs `workspace` scope rules reject invalid `workspace_id` combinations. + - [x] Validate immutable field helpers reject updates to `created_by`, `origin`, `scope`, `workspace_id`, and `parent_task_id`. + - [x] Validate payload-size guardrails reject metadata over 16 KB and payload/result values over 64 KB. + - [x] Validate graph-limit helpers reject depth over 8, dependencies over 32, and direct children over 64. - Integration tests: - - [ ] Verify the package composes against a fake store and fake session bridge without importing `internal/session`. + - [x] Verify the package composes against a fake store and fake session bridge without importing `internal/session`. - Test coverage target: >=80% - All tests must pass diff --git a/.compozy/tasks/core-tasks/task_02.md b/.compozy/tasks/core-tasks/task_02.md index 58206bbbc..3f86475ca 100644 --- a/.compozy/tasks/core-tasks/task_02.md +++ b/.compozy/tasks/core-tasks/task_02.md @@ -1,5 +1,5 @@ --- -status: pending +status: completed title: "Persist core task and run records in `globaldb`" type: backend complexity: high @@ -27,11 +27,11 @@ Add durable storage for `Task` and `TaskRun` so the new domain can persist coord ## Subtasks -- [ ] 2.1 Add migrations and schema definitions for task and task-run tables. -- [ ] 2.2 Implement create, get, update, and list operations for tasks in `globaldb`. -- [ ] 2.3 Implement enqueue, get, list, and state-write operations for task runs in `globaldb`. -- [ ] 2.4 Add indexes and filtering paths for scope, workspace, parent, status, owner, and channel queries. -- [ ] 2.5 Align storage validation and marshalling with the domain types introduced in `internal/task`. +- [x] 2.1 Add migrations and schema definitions for task and task-run tables. +- [x] 2.2 Implement create, get, update, and list operations for tasks in `globaldb`. +- [x] 2.3 Implement enqueue, get, list, and state-write operations for task runs in `globaldb`. +- [x] 2.4 Add indexes and filtering paths for scope, workspace, parent, status, owner, and channel queries. +- [x] 2.5 Align storage validation and marshalling with the domain types introduced in `internal/task`. ## Implementation Details Use the TechSpec "Data Models" and "API Surface" sections for the canonical field set. Follow the patterns already used by `global_db_session.go`, `global_db_automation.go`, and `global_db_network_audit.go` for schema evolution, row mapping, and integration tests. @@ -63,12 +63,12 @@ Use the TechSpec "Data Models" and "API Surface" sections for the canonical fiel ## Tests - Unit tests: - - [ ] Verify row encoding and decoding preserve nullable `workspace_id`, `parent_task_id`, `owner`, and `network_channel` fields. - - [ ] Verify create and update paths reject invalid scope combinations passed from callers. - - [ ] Verify list queries filter correctly by scope, workspace, status, parent, owner, and channel. + - [x] Verify row encoding and decoding preserve nullable `workspace_id`, `parent_task_id`, `owner`, and `network_channel` fields. + - [x] Verify create and update paths reject invalid scope combinations passed from callers. + - [x] Verify list queries filter correctly by scope, workspace, status, parent, owner, and channel. - Integration tests: - - [ ] Verify migrated databases can create and query a `global` task and a `workspace` task in the same store. - - [ ] Verify task runs persist queued records without `session_id` and later persist attached `session_id` values correctly. + - [x] Verify migrated databases can create and query a `global` task and a `workspace` task in the same store. + - [x] Verify task runs persist queued records without `session_id` and later persist attached `session_id` values correctly. - Test coverage target: >=80% - All tests must pass diff --git a/.compozy/tasks/core-tasks/task_03.md b/.compozy/tasks/core-tasks/task_03.md index d1015801e..95210261f 100644 --- a/.compozy/tasks/core-tasks/task_03.md +++ b/.compozy/tasks/core-tasks/task_03.md @@ -1,5 +1,5 @@ --- -status: pending +status: completed title: "Persist task dependencies, audit trail, and idempotency" type: backend complexity: high @@ -28,11 +28,11 @@ Extend the storage layer so the task domain can safely represent bounded depende ## Subtasks -- [ ] 3.1 Add schema and storage methods for dependency edges between tasks. -- [ ] 3.2 Add immutable task-event persistence for lifecycle and audit records. -- [ ] 3.3 Add idempotency-key persistence and lookup for multi-writer ingress surfaces. -- [ ] 3.4 Implement transactional dependency-edge writes with cycle detection and graph-limit enforcement. -- [ ] 3.5 Add query helpers needed for dependency inspection, audit reads, and duplicate-ingress detection. +- [x] 3.1 Add schema and storage methods for dependency edges between tasks. +- [x] 3.2 Add immutable task-event persistence for lifecycle and audit records. +- [x] 3.3 Add idempotency-key persistence and lookup for multi-writer ingress surfaces. +- [x] 3.4 Implement transactional dependency-edge writes with cycle detection and graph-limit enforcement. +- [x] 3.5 Add query helpers needed for dependency inspection, audit reads, and duplicate-ingress detection. ## Implementation Details Use the TechSpec "Data Models", "Run Authority and Attachment Rules", "Guardrails and Limits", and "Monitoring and Observability" sections. The transaction shape called out in the revised spec matters here: dependency-edge creation should use the documented transactional boundary instead of optimistic post-write repair. @@ -64,12 +64,12 @@ Use the TechSpec "Data Models", "Run Authority and Attachment Rules", "Guardrail ## Tests - Unit tests: - - [ ] Verify dependency-edge validation rejects self-dependency, duplicate edges, and limit overflows before persistence. - - [ ] Verify event payload writes reject oversize payloads and preserve immutable actor/origin metadata. - - [ ] Verify idempotency lookups return the original result for duplicate keys from the same origin scope. + - [x] Verify dependency-edge validation rejects self-dependency, duplicate edges, and limit overflows before persistence. + - [x] Verify event payload writes reject oversize payloads and preserve immutable actor/origin metadata. + - [x] Verify idempotency lookups return the original result for duplicate keys from the same origin scope. - Integration tests: - - [ ] Verify creating an edge that would introduce a cycle fails transactionally and does not leave partial graph state behind. - - [ ] Verify two duplicate non-human writes with the same idempotency key are deduplicated against the same persisted record. + - [x] Verify creating an edge that would introduce a cycle fails transactionally and does not leave partial graph state behind. + - [x] Verify two duplicate non-human writes with the same idempotency key are deduplicated against the same persisted record. - Test coverage target: >=80% - All tests must pass diff --git a/.compozy/tasks/core-tasks/task_04.md b/.compozy/tasks/core-tasks/task_04.md index 922f7cdf7..e05d4208d 100644 --- a/.compozy/tasks/core-tasks/task_04.md +++ b/.compozy/tasks/core-tasks/task_04.md @@ -1,12 +1,12 @@ --- -status: pending +status: completed title: "Implement `TaskManager` creation, mutation, and identity rules" type: backend complexity: critical dependencies: - - task_01 - - task_02 - - task_03 + - task_01 + - task_02 + - task_03 --- # Task 04: Implement `TaskManager` creation, mutation, and identity rules diff --git a/.compozy/tasks/core-tasks/task_05.md b/.compozy/tasks/core-tasks/task_05.md index 0b9147950..ffe1d800e 100644 --- a/.compozy/tasks/core-tasks/task_05.md +++ b/.compozy/tasks/core-tasks/task_05.md @@ -1,5 +1,5 @@ --- -status: pending +status: completed title: "Implement `TaskRun` lifecycle and propagated cancellation" type: backend complexity: critical @@ -27,11 +27,11 @@ Implement the execution-side lifecycle that turns stored task coordination into ## Subtasks -- [ ] 5.1 Implement enqueue, claim, start, complete, fail, and cancel operations for `TaskRun`. -- [ ] 5.2 Implement task-status reconciliation from dependencies and active or terminal run state. -- [ ] 5.3 Implement cancellation propagation across task trees, including queued work and active runs. -- [ ] 5.4 Persist lifecycle and cancellation audit events through the task store. -- [ ] 5.5 Implement manager-side state gating that prevents invalid run transitions and arbitrary status mutation. +- [x] 5.1 Implement enqueue, claim, start, complete, fail, and cancel operations for `TaskRun`. +- [x] 5.2 Implement task-status reconciliation from dependencies and active or terminal run state. +- [x] 5.3 Implement cancellation propagation across task trees, including queued work and active runs. +- [x] 5.4 Persist lifecycle and cancellation audit events through the task store. +- [x] 5.5 Implement manager-side state gating that prevents invalid run transitions and arbitrary status mutation. ## Implementation Details Use the TechSpec sections "Lifecycle Model", "Run Authority and Attachment Rules", "Mutability Rules", and "Cancellation Model". This task should leave transport and session concerns out of the manager core except through the interfaces defined in `internal/task`. @@ -60,12 +60,12 @@ Use the TechSpec sections "Lifecycle Model", "Run Authority and Attachment Rules ## Tests - Unit tests: - - [ ] Verify invalid run transitions such as `queued -> completed` and `running -> claimed` are rejected. - - [ ] Verify task reconciliation moves tasks into `blocked`, `ready`, `in_progress`, and terminal states only from valid inputs. - - [ ] Verify parent cancellation cancels queued descendant runs immediately and marks active descendant runs for cooperative stop. + - [x] Verify invalid run transitions such as `queued -> completed` and `running -> claimed` are rejected. + - [x] Verify task reconciliation moves tasks into `blocked`, `ready`, `in_progress`, and terminal states only from valid inputs. + - [x] Verify parent cancellation cancels queued descendant runs immediately and marks active descendant runs for cooperative stop. - Integration tests: - - [ ] Verify a queued run can progress through claim, start, complete, and task reconciliation against real storage. - - [ ] Verify cancelling a task tree records cancellation events for the parent task, descendant tasks, and affected runs. + - [x] Verify a queued run can progress through claim, start, complete, and task reconciliation against real storage. + - [x] Verify cancelling a task tree records cancellation events for the parent task, descendant tasks, and affected runs. - Test coverage target: >=80% - All tests must pass diff --git a/.compozy/tasks/core-tasks/task_06.md b/.compozy/tasks/core-tasks/task_06.md index d15a47c57..84376ed6f 100644 --- a/.compozy/tasks/core-tasks/task_06.md +++ b/.compozy/tasks/core-tasks/task_06.md @@ -1,5 +1,5 @@ --- -status: pending +status: completed title: "Wire the session bridge, dedicated subtask sessions, and boot recovery" type: backend complexity: critical @@ -28,11 +28,11 @@ Connect task execution to the existing session runtime through the injected brid ## Subtasks -- [ ] 6.1 Implement the bridge adapter between `internal/task` and the existing session manager. -- [ ] 6.2 Wire dedicated-session allocation for executable subtasks started through `TaskRun`. -- [ ] 6.3 Implement explicit attach-session flows with single-assignment and lifecycle gating. -- [ ] 6.4 Add daemon boot reconciliation for orphaned or stale task runs after restart. -- [ ] 6.5 Ensure stop requests from task cancellation use the cooperative-then-forced shutdown path. +- [x] 6.1 Implement the bridge adapter between `internal/task` and the existing session manager. +- [x] 6.2 Wire dedicated-session allocation for executable subtasks started through `TaskRun`. +- [x] 6.3 Implement explicit attach-session flows with single-assignment and lifecycle gating. +- [x] 6.4 Add daemon boot reconciliation for orphaned or stale task runs after restart. +- [x] 6.5 Ensure stop requests from task cancellation use the cooperative-then-forced shutdown path. ## Implementation Details Use the TechSpec sections "Run Authority and Attachment Rules", "Cancellation Model", "Cold-Start Recovery", and the ADR for the injected session bridge. The daemon remains the composition root and should own bridge construction and boot-time recovery orchestration. @@ -64,12 +64,12 @@ Use the TechSpec sections "Run Authority and Attachment Rules", "Cancellation Mo ## Tests - Unit tests: - - [ ] Verify attach-session rejects attempts after a run is already bound to another session. - - [ ] Verify run start requests choose dedicated session allocation when no explicit attach target is supplied. - - [ ] Verify cold-start reconciliation classifies `claimed`, `starting`, and `running` runs correctly when their sessions are missing or stopped. + - [x] Verify attach-session rejects attempts after a run is already bound to another session. + - [x] Verify run start requests choose dedicated session allocation when no explicit attach target is supplied. + - [x] Verify cold-start reconciliation classifies `claimed`, `starting`, and `running` runs correctly when their sessions are missing or stopped. - Integration tests: - - [ ] Verify starting an executable subtask creates a dedicated session and persists the attached `session_id`. - - [ ] Verify daemon restart reclassifies orphaned in-flight runs and records recovery audit events. + - [x] Verify starting an executable subtask creates a dedicated session and persists the attached `session_id`. + - [x] Verify daemon restart reclassifies orphaned in-flight runs and records recovery audit events. - Test coverage target: >=80% - All tests must pass diff --git a/.compozy/tasks/core-tasks/task_07.md b/.compozy/tasks/core-tasks/task_07.md index 495c7b180..25630ef76 100644 --- a/.compozy/tasks/core-tasks/task_07.md +++ b/.compozy/tasks/core-tasks/task_07.md @@ -1,5 +1,5 @@ --- -status: pending +status: completed title: "Add task and run API contracts plus core handlers" type: backend complexity: high @@ -28,11 +28,11 @@ Add the shared contract and handler layer that lets daemon transports expose the ## Subtasks -- [ ] 7.1 Add shared request and response payloads for task and run operations under `internal/api/contract`. -- [ ] 7.2 Add core service interfaces and handler methods for task and run operations. -- [ ] 7.3 Add request parsing and validation for scope, workspace, owner, channel, and lifecycle operations. -- [ ] 7.4 Add error mapping for task-domain validation, authorization, not-found, and invalid-transition failures. -- [ ] 7.5 Add payload conversion helpers for task summaries, task details, runs, dependencies, and audit-facing fields. +- [x] 7.1 Add shared request and response payloads for task and run operations under `internal/api/contract`. +- [x] 7.2 Add core service interfaces and handler methods for task and run operations. +- [x] 7.3 Add request parsing and validation for scope, workspace, owner, channel, and lifecycle operations. +- [x] 7.4 Add error mapping for task-domain validation, authorization, not-found, and invalid-transition failures. +- [x] 7.5 Add payload conversion helpers for task summaries, task details, runs, dependencies, and audit-facing fields. ## Implementation Details Use the TechSpec sections "API Surface", "Actor and Identity Model", and "Authorization Contract". Follow the patterns already present in `automation.go`, `network.go`, `payloads.go`, and `errors.go` so new task handlers fit the existing API/core organization. @@ -65,12 +65,12 @@ Use the TechSpec sections "API Surface", "Actor and Identity Model", and "Author ## Tests - Unit tests: - - [ ] Verify invalid scope, workspace, owner, and channel inputs are rejected with stable validation errors. - - [ ] Verify task-domain not-found, invalid-transition, and authorization errors map to the correct transport statuses. - - [ ] Verify payload builders preserve immutable `created_by`, immutable `origin`, optional `owner`, and run/session attachment fields. + - [x] Verify invalid scope, workspace, owner, and channel inputs are rejected with stable validation errors. + - [x] Verify task-domain not-found, invalid-transition, and authorization errors map to the correct transport statuses. + - [x] Verify payload builders preserve immutable `created_by`, immutable `origin`, optional `owner`, and run/session attachment fields. - Integration tests: - - [ ] Verify a create-task request reaches the manager with the expected parsed filters and actor context envelope. - - [ ] Verify run lifecycle handler calls sequence correctly against a fake manager implementation with no duplicate business logic in the handler layer. + - [x] Verify a create-task request reaches the manager with the expected parsed filters and actor context envelope. + - [x] Verify run lifecycle handler calls sequence correctly against a fake manager implementation with no duplicate business logic in the handler layer. - Test coverage target: >=80% - All tests must pass diff --git a/.compozy/tasks/core-tasks/task_08.md b/.compozy/tasks/core-tasks/task_08.md index 2ca49881a..a5f22be21 100644 --- a/.compozy/tasks/core-tasks/task_08.md +++ b/.compozy/tasks/core-tasks/task_08.md @@ -1,5 +1,5 @@ --- -status: pending +status: completed title: "Expose task and run routes through HTTP and UDS" type: backend complexity: medium @@ -27,10 +27,10 @@ Expose the new task domain consistently through both daemon transports. This tas ## Subtasks -- [ ] 8.1 Add task and run route groups to the HTTP API router. -- [ ] 8.2 Add matching task and run route groups to the UDS API router. -- [ ] 8.3 Extend HTTP and UDS server configuration to accept the task handler dependency. -- [ ] 8.4 Ensure response envelopes, status codes, and path parameters match across both transports. +- [x] 8.1 Add task and run route groups to the HTTP API router. +- [x] 8.2 Add matching task and run route groups to the UDS API router. +- [x] 8.3 Extend HTTP and UDS server configuration to accept the task handler dependency. +- [x] 8.4 Ensure response envelopes, status codes, and path parameters match across both transports. ## Implementation Details Use the TechSpec "API Surface" section as the route inventory. Follow the route-group organization already used for automation and network surfaces in both `httpapi` and `udsapi`. @@ -60,11 +60,11 @@ Use the TechSpec "API Surface" section as the route inventory. Follow the route- ## Tests - Unit tests: - - [ ] Verify route registration covers the expected task and run paths in both HTTP and UDS routers. - - [ ] Verify server construction fails fast when task handlers are missing from required transport configuration. + - [x] Verify route registration covers the expected task and run paths in both HTTP and UDS routers. + - [x] Verify server construction fails fast when task handlers are missing from required transport configuration. - Integration tests: - - [ ] Verify the same create/list/get/update task flows succeed through both HTTP and UDS surfaces. - - [ ] Verify run lifecycle endpoints behave identically through both transports for the same manager behavior. + - [x] Verify the same create/list/get/update task flows succeed through both HTTP and UDS surfaces. + - [x] Verify run lifecycle endpoints behave identically through both transports for the same manager behavior. - Test coverage target: >=80% - All tests must pass diff --git a/.compozy/tasks/core-tasks/task_09.md b/.compozy/tasks/core-tasks/task_09.md index 75166b3c7..b7718e729 100644 --- a/.compozy/tasks/core-tasks/task_09.md +++ b/.compozy/tasks/core-tasks/task_09.md @@ -1,5 +1,5 @@ --- -status: pending +status: completed title: "Add the `agh task` CLI command group" type: backend complexity: medium @@ -27,11 +27,11 @@ Add the user-facing CLI surface for creating, inspecting, and controlling tasks ## Subtasks -- [ ] 9.1 Add the top-level `agh task` command group and its subcommands. -- [ ] 9.2 Add create/list/get/update task commands with filters and mutation flags. -- [ ] 9.3 Add child-task and dependency-management subcommands. -- [ ] 9.4 Add run enqueue, claim, start, complete, fail, and cancel subcommands. -- [ ] 9.5 Add CLI output formatting aligned with existing daemon-backed command patterns. +- [x] 9.1 Add the top-level `agh task` command group and its subcommands. +- [x] 9.2 Add create/list/get/update task commands with filters and mutation flags. +- [x] 9.3 Add child-task and dependency-management subcommands. +- [x] 9.4 Add run enqueue, claim, start, complete, fail, and cancel subcommands. +- [x] 9.5 Add CLI output formatting aligned with existing daemon-backed command patterns. ## Implementation Details Use the TechSpec "API Surface" section and follow the command organization used by `internal/cli/automation.go`. The CLI should stay transport-backed, using the same contract and UDS flows the daemon exposes. @@ -59,12 +59,12 @@ Use the TechSpec "API Surface" section and follow the command organization used ## Tests - Unit tests: - - [ ] Verify create and update commands reject invalid flag combinations before issuing daemon calls. - - [ ] Verify list filters serialize scope, workspace, owner, status, parent, and channel arguments correctly. - - [ ] Verify run lifecycle subcommands map CLI inputs onto the expected daemon request payloads. + - [x] Verify create and update commands reject invalid flag combinations before issuing daemon calls. + - [x] Verify list filters serialize scope, workspace, owner, status, parent, and channel arguments correctly. + - [x] Verify run lifecycle subcommands map CLI inputs onto the expected daemon request payloads. - Integration tests: - - [ ] Verify `agh task create`, `agh task list`, and `agh task get` work end-to-end against a live UDS daemon. - - [ ] Verify run lifecycle commands can enqueue and complete a run through the daemon-backed UDS flow. + - [x] Verify `agh task create`, `agh task list`, and `agh task get` work end-to-end against a live UDS daemon. + - [x] Verify run lifecycle commands can enqueue and complete a run through the daemon-backed UDS flow. - Test coverage target: >=80% - All tests must pass diff --git a/.compozy/tasks/core-tasks/task_10.md b/.compozy/tasks/core-tasks/task_10.md index 3f8d8754b..10e24d61d 100644 --- a/.compozy/tasks/core-tasks/task_10.md +++ b/.compozy/tasks/core-tasks/task_10.md @@ -1,5 +1,5 @@ --- -status: pending +status: completed title: "Integrate automation with task-backed work items" type: backend complexity: high @@ -28,11 +28,11 @@ Integrate the new task domain with `internal/automation` without turning tasks i ## Subtasks -- [ ] 10.1 Define the automation-to-task integration seam and explicit task-backed job behavior. -- [ ] 10.2 Add direct automation-originated task creation and enqueue flows with trusted origin metadata. -- [ ] 10.3 Add support for agent-mediated task creation inside automation-driven sessions. -- [ ] 10.4 Prevent duplicate execution tracking between automation runs and task runs for task-backed work. -- [ ] 10.5 Carry workspace, channel, ownership, and idempotency context through automation-originated task flows. +- [x] 10.1 Define the automation-to-task integration seam and explicit task-backed job behavior. +- [x] 10.2 Add direct automation-originated task creation and enqueue flows with trusted origin metadata. +- [x] 10.3 Add support for agent-mediated task creation inside automation-driven sessions. +- [x] 10.4 Prevent duplicate execution tracking between automation runs and task runs for task-backed work. +- [x] 10.5 Carry workspace, channel, ownership, and idempotency context through automation-originated task flows. ## Implementation Details Use the TechSpec "Integration Points" section for automation and the revised review outcome that tasks are explicit resources, not the universal wrapper for all work. Follow the patterns in `internal/automation/manager.go`, `dispatch.go`, and `types.go` to keep the integration daemon-owned and explicit. @@ -62,12 +62,12 @@ Use the TechSpec "Integration Points" section for automation and the revised rev ## Tests - Unit tests: - - [ ] Verify direct automation-originated task creation derives immutable origin and creator metadata server-side. - - [ ] Verify non-task automation jobs continue to use the existing automation runtime unchanged. - - [ ] Verify task-backed automation dispatch refuses to maintain a second execution state machine for the same work item. + - [x] Verify direct automation-originated task creation derives immutable origin and creator metadata server-side. + - [x] Verify non-task automation jobs continue to use the existing automation runtime unchanged. + - [x] Verify task-backed automation dispatch refuses to maintain a second execution state machine for the same work item. - Integration tests: - - [ ] Verify an automation job can create a task directly and enqueue a task run with the expected origin and idempotency metadata. - - [ ] Verify an automation-launched agent session can explicitly call `task.create` and produce a task whose `created_by` is the agent while `origin` remains automation-linked. + - [x] Verify an automation job can create a task directly and enqueue a task run with the expected origin and idempotency metadata. + - [x] Verify an automation-launched agent session can explicitly call `task.create` and produce a task whose `created_by` is the agent while `origin` remains automation-linked. - Test coverage target: >=80% - All tests must pass diff --git a/.compozy/tasks/core-tasks/task_11.md b/.compozy/tasks/core-tasks/task_11.md index 6e1fac34c..9e309d221 100644 --- a/.compozy/tasks/core-tasks/task_11.md +++ b/.compozy/tasks/core-tasks/task_11.md @@ -1,5 +1,5 @@ --- -status: pending +status: completed title: "Integrate extension host APIs with the task domain" type: backend complexity: high @@ -28,11 +28,11 @@ Expose the task domain safely to extensions through the existing host API and ca ## Subtasks -- [ ] 11.1 Add task-domain methods to the extension host API surface. -- [ ] 11.2 Extend capability checks to cover task create/update/run operations. -- [ ] 11.3 Carry trusted extension identity and origin metadata into task manager calls. -- [ ] 11.4 Support extension-originated task run operations without bypassing lifecycle guards. -- [ ] 11.5 Add host API tests for permitted and forbidden task operations. +- [x] 11.1 Add task-domain methods to the extension host API surface. +- [x] 11.2 Extend capability checks to cover task create/update/run operations. +- [x] 11.3 Carry trusted extension identity and origin metadata into task manager calls. +- [x] 11.4 Support extension-originated task run operations without bypassing lifecycle guards. +- [x] 11.5 Add host API tests for permitted and forbidden task operations. ## Implementation Details Use the TechSpec "Integration Points" section for extensions and the authorization model accepted during the redesign. Follow the existing host API and capability-checker patterns already used for bridges and other extension-backed operations. @@ -63,12 +63,12 @@ Use the TechSpec "Integration Points" section for extensions and the authorizati ## Tests - Unit tests: - - [ ] Verify extensions without the required capability cannot create, mutate, or run tasks. - - [ ] Verify payload-supplied identity fields are ignored in favor of trusted extension context. - - [ ] Verify extension-originated run lifecycle requests still pass through manager-owned transition checks. + - [x] Verify extensions without the required capability cannot create, mutate, or run tasks. + - [x] Verify payload-supplied identity fields are ignored in favor of trusted extension context. + - [x] Verify extension-originated run lifecycle requests still pass through manager-owned transition checks. - Integration tests: - - [ ] Verify a capability-granted extension can create a task and enqueue a run through the host API. - - [ ] Verify an extension can start an executable subtask and receive a dedicated session through the bridge-backed path. + - [x] Verify a capability-granted extension can create a task and enqueue a run through the host API. + - [x] Verify an extension can start an executable subtask and receive a dedicated session through the bridge-backed path. - Test coverage target: >=80% - All tests must pass diff --git a/.compozy/tasks/core-tasks/task_12.md b/.compozy/tasks/core-tasks/task_12.md index 01d9f2481..9c3f008f2 100644 --- a/.compozy/tasks/core-tasks/task_12.md +++ b/.compozy/tasks/core-tasks/task_12.md @@ -1,5 +1,5 @@ --- -status: pending +status: completed title: "Integrate network ingress and channel binding for tasks" type: backend complexity: high @@ -28,11 +28,11 @@ Integrate the task domain with network peers and channel-oriented routing so tas ## Subtasks -- [ ] 12.1 Define the network-to-task integration seam for create/update/run operations. -- [ ] 12.2 Validate network channel binding and mismatch handling for task-backed ingress. -- [ ] 12.3 Carry peer identity, origin, and idempotency metadata into task manager calls. -- [ ] 12.4 Apply stale-channel behavior for task records and run snapshots as defined in the revised spec. -- [ ] 12.5 Add audit coverage for network-originated task writes and rejections. +- [x] 12.1 Define the network-to-task integration seam for create/update/run operations. +- [x] 12.2 Validate network channel binding and mismatch handling for task-backed ingress. +- [x] 12.3 Carry peer identity, origin, and idempotency metadata into task manager calls. +- [x] 12.4 Apply stale-channel behavior for task records and run snapshots as defined in the revised spec. +- [x] 12.5 Add audit coverage for network-originated task writes and rejections. ## Implementation Details Use the TechSpec sections "Authorization Contract", "API Surface", "Integration Points", and "Known Risks" plus ADR-004 for channel semantics. Follow the existing patterns in the `internal/network` package for peer validation, delivery, routing, and auditing. @@ -63,12 +63,12 @@ Use the TechSpec sections "Authorization Contract", "API Surface", "Integration ## Tests - Unit tests: - - [ ] Verify ingress with a mismatched channel is rejected for a channel-bound task. - - [ ] Verify stale channel snapshots are handled according to the revised spec without mutating immutable task history unexpectedly. - - [ ] Verify duplicate network writes with the same idempotency key resolve to a single canonical task-domain effect. + - [x] Verify ingress with a mismatched channel is rejected for a channel-bound task. + - [x] Verify stale channel snapshots are handled according to the revised spec without mutating immutable task history unexpectedly. + - [x] Verify duplicate network writes with the same idempotency key resolve to a single canonical task-domain effect. - Integration tests: - - [ ] Verify a network peer can create a task with `network_channel` binding and later enqueue a run through the validated manager path. - - [ ] Verify mismatched network-channel ingress records an audit event and does not mutate the target task. + - [x] Verify a network peer can create a task with `network_channel` binding and later enqueue a run through the validated manager path. + - [x] Verify mismatched network-channel ingress records an audit event and does not mutate the target task. - Test coverage target: >=80% - All tests must pass diff --git a/.compozy/tasks/core-tasks/task_13.md b/.compozy/tasks/core-tasks/task_13.md index d81a7a24f..de6149371 100644 --- a/.compozy/tasks/core-tasks/task_13.md +++ b/.compozy/tasks/core-tasks/task_13.md @@ -1,5 +1,5 @@ --- -status: pending +status: completed title: "Add observe projections, health queries, and task metrics" type: backend complexity: high @@ -29,11 +29,11 @@ Add the read-side observability needed to operate the new task domain confidentl ## Subtasks -- [ ] 13.1 Extend observe-side projections to consume task, run, and audit events. -- [ ] 13.2 Add query and health surfaces for queue depth, stuck runs, task totals, and ownership/channel filters. -- [ ] 13.3 Add metrics emission for task counts, run counts, queue latency, duplicate ingress, and channel mismatch events. -- [ ] 13.4 Integrate recovery and cancellation signals so health views reflect orphan recovery and forced-stop outcomes. -- [ ] 13.5 Add tests covering channel-aware and origin-aware observability outputs. +- [x] 13.1 Extend observe-side projections to consume task, run, and audit events. +- [x] 13.2 Add query and health surfaces for queue depth, stuck runs, task totals, and ownership/channel filters. +- [x] 13.3 Add metrics emission for task counts, run counts, queue latency, duplicate ingress, and channel mismatch events. +- [x] 13.4 Integrate recovery and cancellation signals so health views reflect orphan recovery and forced-stop outcomes. +- [x] 13.5 Add tests covering channel-aware and origin-aware observability outputs. ## Implementation Details Use the TechSpec sections "Monitoring and Observability", "Cold-Start Recovery", and "Cancellation Model". Follow the existing patterns in `internal/observe/observer.go`, `query.go`, `health.go`, and `reconcile.go`, plus the `global_db_observe.go` read-side storage helpers. @@ -65,12 +65,12 @@ Use the TechSpec sections "Monitoring and Observability", "Cold-Start Recovery", ## Tests - Unit tests: - - [ ] Verify task and run projections aggregate counts by status, scope, origin, and channel correctly. - - [ ] Verify health logic flags stuck `claimed`, `starting`, or `running` task runs according to the configured rules. - - [ ] Verify duplicate-ingress and channel-mismatch counters increment from the expected audit inputs. + - [x] Verify task and run projections aggregate counts by status, scope, origin, and channel correctly. + - [x] Verify health logic flags stuck `claimed`, `starting`, or `running` task runs according to the configured rules. + - [x] Verify duplicate-ingress and channel-mismatch counters increment from the expected audit inputs. - Integration tests: - - [ ] Verify a full task lifecycle from queue to completion appears in observe queries and metrics with the expected channel and origin metadata. - - [ ] Verify orphan-run recovery and forced-stop cancellation outcomes are reflected in task health views after daemon restart or shutdown scenarios. + - [x] Verify a full task lifecycle from queue to completion appears in observe queries and metrics with the expected channel and origin metadata. + - [x] Verify orphan-run recovery and forced-stop cancellation outcomes are reflected in task health views after daemon restart or shutdown scenarios. - Test coverage target: >=80% - All tests must pass diff --git a/internal/api/contract/automation.go b/internal/api/contract/automation.go index b84973de9..34b7c4d3e 100644 --- a/internal/api/contract/automation.go +++ b/internal/api/contract/automation.go @@ -32,6 +32,7 @@ type JobPayload struct { WorkspaceID string `json:"workspace_id,omitempty"` Prompt string `json:"prompt"` Schedule *automationpkg.ScheduleSpec `json:"schedule,omitempty"` + Task *automationpkg.JobTaskConfig `json:"task,omitempty"` Enabled bool `json:"enabled"` Retry automationpkg.RetryConfig `json:"retry"` FireLimit automationpkg.FireLimitConfig `json:"fire_limit"` @@ -67,6 +68,8 @@ type RunPayload struct { JobID string `json:"job_id,omitempty"` TriggerID string `json:"trigger_id,omitempty"` SessionID string `json:"session_id,omitempty"` + TaskID string `json:"task_id,omitempty"` + TaskRunID string `json:"task_run_id,omitempty"` Status automationpkg.RunStatus `json:"status"` Attempt int `json:"attempt"` StartedAt *time.Time `json:"started_at,omitempty"` @@ -88,6 +91,7 @@ type CreateJobRequest struct { WorkspaceID string `json:"workspace_id,omitempty"` Prompt string `json:"prompt"` Schedule automationpkg.ScheduleSpec `json:"schedule"` + Task *automationpkg.JobTaskConfig `json:"task,omitempty"` Enabled *bool `json:"enabled,omitempty"` Retry *automationpkg.RetryConfig `json:"retry,omitempty"` FireLimit *automationpkg.FireLimitConfig `json:"fire_limit,omitempty"` @@ -100,6 +104,7 @@ type UpdateJobRequest struct { WorkspaceID *string `json:"workspace_id,omitempty"` Prompt *string `json:"prompt,omitempty"` Schedule *automationpkg.ScheduleSpec `json:"schedule,omitempty"` + Task *automationpkg.JobTaskConfig `json:"task,omitempty"` Enabled *bool `json:"enabled,omitempty"` Retry *automationpkg.RetryConfig `json:"retry,omitempty"` FireLimit *automationpkg.FireLimitConfig `json:"fire_limit,omitempty"` @@ -112,6 +117,7 @@ func (r UpdateJobRequest) HasChanges() bool { r.WorkspaceID != nil || r.Prompt != nil || r.Schedule != nil || + r.Task != nil || r.Enabled != nil || r.Retry != nil || r.FireLimit != nil diff --git a/internal/api/contract/contract_test.go b/internal/api/contract/contract_test.go index 951a74904..45f7cd45a 100644 --- a/internal/api/contract/contract_test.go +++ b/internal/api/contract/contract_test.go @@ -11,6 +11,7 @@ import ( automationpkg "github.com/pedronauck/agh/internal/automation" "github.com/pedronauck/agh/internal/session" "github.com/pedronauck/agh/internal/store" + taskpkg "github.com/pedronauck/agh/internal/task" ) func TestSessionPayloadJSONShape(t *testing.T) { @@ -174,6 +175,14 @@ func TestAutomationJobPayloadJSONShape(t *testing.T) { Mode: automationpkg.ScheduleModeEvery, Interval: "1h", }, + Task: &automationpkg.JobTaskConfig{ + Title: "Review findings", + NetworkChannel: "ops-automation", + Owner: &taskpkg.Ownership{ + Kind: taskpkg.OwnerKindAutomation, + Ref: "rule:nightly-review", + }, + }, Enabled: true, Retry: automationpkg.RetryConfig{ Strategy: automationpkg.RetryStrategyBackoff, @@ -202,6 +211,10 @@ func TestAutomationJobPayloadJSONShape(t *testing.T) { if got["source"] != string(automationpkg.JobSourceDynamic) { t.Fatalf("source = %#v, want %q", got["source"], automationpkg.JobSourceDynamic) } + taskValue, ok := got["task"].(map[string]any) + if !ok || taskValue["title"] != "Review findings" || taskValue["network_channel"] != "ops-automation" { + t.Fatalf("task = %#v, want populated task config", got["task"]) + } if _, exists := got["next_run"]; !exists { t.Fatalf("job payload missing next_run: %#v", got) } @@ -436,6 +449,165 @@ func TestAutomationUpdateRequestsHasChanges(t *testing.T) { }) } +func TestTaskPayloadJSONShape(t *testing.T) { + t.Parallel() + + t.Run("Should marshal task payload JSON shape", func(t *testing.T) { + t.Parallel() + + payload := contract.TaskPayload{ + ID: "task-1", + Identifier: "TASK-1", + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: "ws-alpha", + ParentTaskID: "task-root", + NetworkChannel: "builders", + Title: "Review task", + Description: "Check the API layer", + Status: taskpkg.TaskStatusInProgress, + Owner: &taskpkg.Ownership{Kind: taskpkg.OwnerKindPool, Ref: "reviewers"}, + CreatedBy: taskpkg.ActorIdentity{Kind: taskpkg.ActorKindHuman, Ref: "local-user"}, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindHTTP, Ref: "tasks.create"}, + CreatedAt: time.Date(2026, 4, 14, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 14, 10, 5, 0, 0, time.UTC), + Metadata: json.RawMessage(`{"priority":"high"}`), + } + + var got map[string]any + marshalJSON(t, payload, &got) + + if got["workspace_id"] != "ws-alpha" || got["network_channel"] != "builders" { + t.Fatalf("task JSON = %#v", got) + } + createdBy, ok := got["created_by"].(map[string]any) + if !ok || createdBy["kind"] != string(taskpkg.ActorKindHuman) || createdBy["ref"] != "local-user" { + t.Fatalf("created_by JSON = %#v", got["created_by"]) + } + origin, ok := got["origin"].(map[string]any) + if !ok || origin["kind"] != string(taskpkg.OriginKindHTTP) || origin["ref"] != "tasks.create" { + t.Fatalf("origin JSON = %#v", got["origin"]) + } + owner, ok := got["owner"].(map[string]any) + if !ok || owner["kind"] != string(taskpkg.OwnerKindPool) || owner["ref"] != "reviewers" { + t.Fatalf("owner JSON = %#v", got["owner"]) + } + if _, exists := got["metadata"]; !exists { + t.Fatalf("task JSON missing metadata: %#v", got) + } + }) + + t.Run("Should omit zero-valued optional task timestamps", func(t *testing.T) { + t.Parallel() + + payload := contract.TaskPayload{ + ID: "task-1", + Scope: taskpkg.ScopeGlobal, + Title: "Review task", + Status: taskpkg.TaskStatusReady, + CreatedBy: taskpkg.ActorIdentity{Kind: taskpkg.ActorKindHuman, Ref: "local-user"}, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindHTTP, Ref: "tasks.create"}, + CreatedAt: time.Date(2026, 4, 14, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 14, 10, 5, 0, 0, time.UTC), + } + + var got map[string]any + marshalJSON(t, payload, &got) + + if _, exists := got["closed_at"]; exists { + t.Fatalf("task JSON unexpectedly included closed_at: %#v", got) + } + }) +} + +func TestTaskRunPayloadJSONShape(t *testing.T) { + t.Parallel() + + t.Run("Should marshal task run payload JSON shape", func(t *testing.T) { + t.Parallel() + + startedAt := time.Date(2026, 4, 14, 10, 1, 0, 0, time.UTC) + payload := contract.TaskRunPayload{ + ID: "run-1", + TaskID: "task-1", + Status: taskpkg.TaskRunStatusRunning, + Attempt: 2, + ClaimedBy: &taskpkg.ActorIdentity{Kind: taskpkg.ActorKindHuman, Ref: "local-user"}, + SessionID: "sess-1", + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindHTTP, Ref: "tasks.start_run"}, + IdempotencyKey: "key-1", + NetworkChannel: "builders", + QueuedAt: time.Date(2026, 4, 14, 10, 0, 0, 0, time.UTC), + StartedAt: &startedAt, + Result: json.RawMessage(`{"ok":true}`), + } + + var got map[string]any + marshalJSON(t, payload, &got) + + if got["session_id"] != "sess-1" || got["idempotency_key"] != "key-1" { + t.Fatalf("task run JSON = %#v", got) + } + if got["network_channel"] != "builders" || got["status"] != string(taskpkg.TaskRunStatusRunning) { + t.Fatalf("task run JSON = %#v", got) + } + }) + + t.Run("Should omit zero-valued optional run timestamps", func(t *testing.T) { + t.Parallel() + + payload := contract.TaskRunPayload{ + ID: "run-1", + TaskID: "task-1", + Status: taskpkg.TaskRunStatusQueued, + Attempt: 1, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindHTTP, Ref: "tasks.enqueue_run"}, + QueuedAt: time.Date(2026, 4, 14, 10, 0, 0, 0, time.UTC), + } + + var got map[string]any + marshalJSON(t, payload, &got) + + for _, field := range []string{"claimed_at", "started_at", "ended_at"} { + if _, exists := got[field]; exists { + t.Fatalf("task run JSON unexpectedly included %s: %#v", field, got) + } + } + }) +} + +func TestUpdateTaskRequestHasChanges(t *testing.T) { + t.Parallel() + + title := "updated" + channel := "builders" + owner := &taskpkg.Ownership{Kind: taskpkg.OwnerKindPool, Ref: "reviewers"} + metadata := json.RawMessage(`{"priority":"high"}`) + + testCases := []struct { + name string + req contract.UpdateTaskRequest + want bool + }{ + {name: "Should return false when no task changes are set", req: contract.UpdateTaskRequest{}, want: false}, + {name: "Should return true when title is set", req: contract.UpdateTaskRequest{Title: &title}, want: true}, + {name: "Should return true when network channel is set", req: contract.UpdateTaskRequest{NetworkChannel: &channel}, want: true}, + {name: "Should return true when owner is set", req: contract.UpdateTaskRequest{Owner: owner}, want: true}, + {name: "Should return true when metadata is set", req: contract.UpdateTaskRequest{Metadata: &metadata}, want: true}, + {name: "Should return true when clear owner is set", req: contract.UpdateTaskRequest{ClearOwner: true}, want: true}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := tc.req.HasChanges(); got != tc.want { + t.Fatalf("UpdateTaskRequest.HasChanges() = %v, want %v", got, tc.want) + } + }) + } +} + func marshalJSON[T any](t *testing.T, value any, target *T) { t.Helper() diff --git a/internal/api/contract/responses.go b/internal/api/contract/responses.go index fc0f33b91..605f4967e 100644 --- a/internal/api/contract/responses.go +++ b/internal/api/contract/responses.go @@ -74,6 +74,31 @@ type RunResponse struct { Run RunPayload `json:"run"` } +// TasksResponse wraps the shared task list payload. +type TasksResponse struct { + Tasks []TaskSummaryPayload `json:"tasks"` +} + +// TaskResponse wraps one shared task payload. +type TaskResponse struct { + Task TaskPayload `json:"task"` +} + +// TaskDetailResponse wraps one shared expanded task payload. +type TaskDetailResponse struct { + Task TaskDetailPayload `json:"task"` +} + +// TaskRunsResponse wraps the shared task-run list payload. +type TaskRunsResponse struct { + Runs []TaskRunPayload `json:"runs"` +} + +// TaskRunResponse wraps one shared task-run payload. +type TaskRunResponse struct { + Run TaskRunPayload `json:"run"` +} + // WebhookDeliveryResponse wraps the shared webhook delivery result payload. type WebhookDeliveryResponse struct { Result WebhookDeliveryPayload `json:"result"` diff --git a/internal/api/contract/tasks.go b/internal/api/contract/tasks.go new file mode 100644 index 000000000..f9ba80b90 --- /dev/null +++ b/internal/api/contract/tasks.go @@ -0,0 +1,209 @@ +package contract + +import ( + "encoding/json" + "time" + + taskpkg "github.com/pedronauck/agh/internal/task" +) + +// TaskSummaryPayload is the shared list-oriented task response payload. +type TaskSummaryPayload struct { + ID string `json:"id"` + Identifier string `json:"identifier,omitempty"` + Scope taskpkg.Scope `json:"scope"` + WorkspaceID string `json:"workspace_id,omitempty"` + ParentTaskID string `json:"parent_task_id,omitempty"` + NetworkChannel string `json:"network_channel,omitempty"` + Title string `json:"title"` + Status taskpkg.TaskStatus `json:"status"` + Owner *taskpkg.Ownership `json:"owner,omitempty"` + CreatedBy taskpkg.ActorIdentity `json:"created_by"` + Origin taskpkg.Origin `json:"origin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ClosedAt *time.Time `json:"closed_at,omitempty"` +} + +// TaskPayload is the shared full task response payload. +type TaskPayload struct { + ID string `json:"id"` + Identifier string `json:"identifier,omitempty"` + Scope taskpkg.Scope `json:"scope"` + WorkspaceID string `json:"workspace_id,omitempty"` + ParentTaskID string `json:"parent_task_id,omitempty"` + NetworkChannel string `json:"network_channel,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Status taskpkg.TaskStatus `json:"status"` + Owner *taskpkg.Ownership `json:"owner,omitempty"` + CreatedBy taskpkg.ActorIdentity `json:"created_by"` + Origin taskpkg.Origin `json:"origin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +// TaskDependencyPayload is the shared dependency-edge response payload. +type TaskDependencyPayload struct { + TaskID string `json:"task_id"` + DependsOnTaskID string `json:"depends_on_task_id"` + Kind taskpkg.DependencyKind `json:"kind"` + CreatedAt time.Time `json:"created_at"` +} + +// TaskRunPayload is the shared task-run response payload. +type TaskRunPayload struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Status taskpkg.TaskRunStatus `json:"status"` + Attempt int `json:"attempt"` + ClaimedBy *taskpkg.ActorIdentity `json:"claimed_by,omitempty"` + SessionID string `json:"session_id,omitempty"` + Origin taskpkg.Origin `json:"origin"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + NetworkChannel string `json:"network_channel,omitempty"` + QueuedAt time.Time `json:"queued_at"` + ClaimedAt *time.Time `json:"claimed_at,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + EndedAt *time.Time `json:"ended_at,omitempty"` + Error string `json:"error,omitempty"` + Result json.RawMessage `json:"result,omitempty"` +} + +// TaskEventPayload is the shared task audit-event response payload. +type TaskEventPayload struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + RunID string `json:"run_id,omitempty"` + EventType string `json:"event_type"` + Actor taskpkg.ActorIdentity `json:"actor"` + Origin taskpkg.Origin `json:"origin"` + Payload json.RawMessage `json:"payload,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// TaskDetailPayload is the shared expanded task response payload. +type TaskDetailPayload struct { + Task TaskPayload `json:"task"` + Children []TaskSummaryPayload `json:"children,omitempty"` + Dependencies []TaskDependencyPayload `json:"dependencies,omitempty"` + Runs []TaskRunPayload `json:"runs,omitempty"` + Events []TaskEventPayload `json:"events,omitempty"` +} + +// TaskListQuery captures the shared task list filters. +type TaskListQuery struct { + Scope taskpkg.Scope `json:"scope,omitempty"` + Workspace string `json:"workspace,omitempty"` + Status taskpkg.TaskStatus `json:"status,omitempty"` + OwnerKind taskpkg.OwnerKind `json:"owner_kind,omitempty"` + OwnerRef string `json:"owner_ref,omitempty"` + ParentTaskID string `json:"parent_task_id,omitempty"` + NetworkChannel string `json:"network_channel,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// TaskRunListQuery captures the shared task-run list filters. +type TaskRunListQuery struct { + Status taskpkg.TaskRunStatus `json:"status,omitempty"` + SessionID string `json:"session_id,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// CreateTaskRequest is the shared task-create request payload. +type CreateTaskRequest struct { + ID string `json:"id,omitempty"` + Identifier string `json:"identifier,omitempty"` + Scope taskpkg.Scope `json:"scope"` + Workspace string `json:"workspace,omitempty"` + NetworkChannel string `json:"network_channel,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Owner *taskpkg.Ownership `json:"owner,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +// CreateTaskChildRequest is the shared child-task create payload. +type CreateTaskChildRequest struct { + ID string `json:"id,omitempty"` + Identifier string `json:"identifier,omitempty"` + Scope taskpkg.Scope `json:"scope"` + Workspace string `json:"workspace,omitempty"` + NetworkChannel string `json:"network_channel,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Owner *taskpkg.Ownership `json:"owner,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +// UpdateTaskRequest is the shared task patch payload. +type UpdateTaskRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Metadata *json.RawMessage `json:"metadata,omitempty"` + NetworkChannel *string `json:"network_channel,omitempty"` + Owner *taskpkg.Ownership `json:"owner,omitempty"` + ClearOwner bool `json:"clear_owner,omitempty"` +} + +// HasChanges reports whether the patch includes any mutable task field. +func (r UpdateTaskRequest) HasChanges() bool { + return r.Title != nil || + r.Description != nil || + r.Metadata != nil || + r.NetworkChannel != nil || + r.Owner != nil || + r.ClearOwner +} + +// CancelTaskRequest is the shared task-cancel request payload. +type CancelTaskRequest struct { + Reason string `json:"reason,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +// AddTaskDependencyRequest is the shared dependency-create request payload. +type AddTaskDependencyRequest struct { + DependsOnTaskID string `json:"depends_on_task_id"` + Kind taskpkg.DependencyKind `json:"kind,omitempty"` +} + +// EnqueueTaskRunRequest is the shared run-enqueue request payload. +type EnqueueTaskRunRequest struct { + IdempotencyKey string `json:"idempotency_key,omitempty"` + NetworkChannel string `json:"network_channel,omitempty"` +} + +// ClaimTaskRunRequest is the shared run-claim request payload. +type ClaimTaskRunRequest struct { + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +// StartTaskRunRequest is the shared run-start request payload. +type StartTaskRunRequest struct { + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +// AttachTaskRunSessionRequest is the shared run-session attach request payload. +type AttachTaskRunSessionRequest struct { + SessionID string `json:"session_id"` +} + +// CompleteTaskRunRequest is the shared run-complete request payload. +type CompleteTaskRunRequest struct { + Result json.RawMessage `json:"result,omitempty"` +} + +// FailTaskRunRequest is the shared run-fail request payload. +type FailTaskRunRequest struct { + Error string `json:"error"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +// CancelTaskRunRequest is the shared run-cancel request payload. +type CancelTaskRunRequest struct { + Reason string `json:"reason,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} diff --git a/internal/api/core/automation.go b/internal/api/core/automation.go index 38000e667..87808295b 100644 --- a/internal/api/core/automation.go +++ b/internal/api/core/automation.go @@ -14,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pedronauck/agh/internal/api/contract" automationpkg "github.com/pedronauck/agh/internal/automation" + taskpkg "github.com/pedronauck/agh/internal/task" ) const ( @@ -707,6 +708,7 @@ func jobFromCreateRequest(req contract.CreateJobRequest) automationpkg.Job { } schedule := req.Schedule + taskConfig := cloneAutomationJobTaskConfig(req.Task) return automationpkg.Job{ Scope: req.Scope, Name: strings.TrimSpace(req.Name), @@ -714,6 +716,7 @@ func jobFromCreateRequest(req contract.CreateJobRequest) automationpkg.Job { WorkspaceID: strings.TrimSpace(req.WorkspaceID), Prompt: strings.TrimSpace(req.Prompt), Schedule: &schedule, + Task: taskConfig, Enabled: enabled, Retry: retry, FireLimit: fireLimit, @@ -739,6 +742,9 @@ func applyJobPatch(current automationpkg.Job, req contract.UpdateJobRequest) aut schedule := *req.Schedule next.Schedule = &schedule } + if req.Task != nil { + next.Task = cloneAutomationJobTaskConfig(req.Task) + } if req.Enabled != nil { next.Enabled = *req.Enabled } @@ -755,13 +761,30 @@ func validateConfigJobUpdate(req contract.UpdateJobRequest) error { switch { case req.Enabled == nil: return errors.New("config-backed automation jobs only accept enabled updates") - case req.Name != nil || req.AgentName != nil || req.WorkspaceID != nil || req.Prompt != nil || req.Schedule != nil || req.Retry != nil || req.FireLimit != nil: + case req.Name != nil || req.AgentName != nil || req.WorkspaceID != nil || req.Prompt != nil || req.Schedule != nil || req.Task != nil || req.Retry != nil || req.FireLimit != nil: return errors.New("config-backed automation jobs only accept enabled updates") default: return nil } } +func cloneAutomationJobTaskConfig(config *automationpkg.JobTaskConfig) *automationpkg.JobTaskConfig { + if config == nil { + return nil + } + cloned := *config + cloned.Title = strings.TrimSpace(cloned.Title) + cloned.Description = strings.TrimSpace(cloned.Description) + cloned.NetworkChannel = strings.TrimSpace(cloned.NetworkChannel) + if config.Owner != nil { + owner := *config.Owner + owner.Kind = taskpkg.OwnerKind(strings.TrimSpace(string(owner.Kind))) + owner.Ref = strings.TrimSpace(owner.Ref) + cloned.Owner = &owner + } + return &cloned +} + func triggerFromCreateRequest(req contract.CreateTriggerRequest) automationpkg.Trigger { enabled := true if req.Enabled != nil { diff --git a/internal/api/core/automation_additional_test.go b/internal/api/core/automation_additional_test.go new file mode 100644 index 000000000..692596d85 --- /dev/null +++ b/internal/api/core/automation_additional_test.go @@ -0,0 +1,207 @@ +package core_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/pedronauck/agh/internal/api/contract" + "github.com/pedronauck/agh/internal/api/testutil" + automationpkg "github.com/pedronauck/agh/internal/automation" +) + +func TestAutomationEndpointsAdditionalCoverage(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC) + job := automationpkg.Job{ + ID: "job-1", + Scope: automationpkg.AutomationScopeWorkspace, + Name: "nightly-review", + AgentName: "coder", + Prompt: "review repo", + Enabled: true, + Retry: automationpkg.DefaultRetryConfig(), + FireLimit: automationpkg.DefaultFireLimitConfig(), + Source: automationpkg.JobSourceDynamic, + CreatedAt: now, + UpdatedAt: now, + } + trigger := automationpkg.Trigger{ + ID: "trigger-1", + Scope: automationpkg.AutomationScopeWorkspace, + Name: "deploy-review", + AgentName: "coder", + Prompt: "review deploy", + Event: "webhook", + Enabled: true, + Retry: automationpkg.DefaultRetryConfig(), + FireLimit: automationpkg.DefaultFireLimitConfig(), + Source: automationpkg.JobSourceDynamic, + CreatedAt: now, + UpdatedAt: now, + } + run := automationpkg.Run{ + ID: "run-1", + JobID: job.ID, + TriggerID: trigger.ID, + Status: automationpkg.RunCompleted, + Attempt: 1, + StartedAt: &now, + EndedAt: &now, + } + + newFixture := func(t *testing.T) handlerFixture { + t.Helper() + + jobs := map[string]automationpkg.Job{job.ID: job} + triggers := map[string]automationpkg.Trigger{trigger.ID: trigger} + automation := testutil.StubAutomationManager{ + ListTriggersFn: func(context.Context, automationpkg.TriggerListQuery) ([]automationpkg.Trigger, error) { + list := make([]automationpkg.Trigger, 0, len(triggers)) + for _, record := range triggers { + list = append(list, record) + } + return list, nil + }, + GetJobFn: func(context.Context, string) (automationpkg.Job, error) { + record, ok := jobs[job.ID] + if !ok { + return automationpkg.Job{}, automationpkg.ErrJobNotFound + } + return record, nil + }, + DeleteJobFn: func(context.Context, string) error { + if _, ok := jobs[job.ID]; !ok { + return automationpkg.ErrJobNotFound + } + delete(jobs, job.ID) + return nil + }, + GetTriggerFn: func(context.Context, string) (automationpkg.Trigger, error) { + record, ok := triggers[trigger.ID] + if !ok { + return automationpkg.Trigger{}, automationpkg.ErrTriggerNotFound + } + return record, nil + }, + DeleteTriggerFn: func(context.Context, string) error { + if _, ok := triggers[trigger.ID]; !ok { + return automationpkg.ErrTriggerNotFound + } + delete(triggers, trigger.ID) + return nil + }, + ListRunsFn: func(_ context.Context, query automationpkg.RunQuery) ([]automationpkg.Run, error) { + switch { + case query.JobID == job.ID: + return []automationpkg.Run{run}, nil + case query.TriggerID == trigger.ID: + return []automationpkg.Run{run}, nil + default: + return []automationpkg.Run{run}, nil + } + }, + GetRunFn: func(context.Context, string) (automationpkg.Run, error) { + return run, nil + }, + } + + return newHandlerFixtureWithAutomation(t, testutil.StubSessionManager{}, testutil.StubObserver{}, automation, testutil.StubWorkspaceService{}, nil, nil) + } + + for _, request := range []struct { + name string + method string + path string + }{ + {name: "Should return automation job details", method: http.MethodGet, path: "/automation/jobs/job-1"}, + {name: "Should delete automation jobs and hide them from subsequent reads", method: http.MethodDelete, path: "/automation/jobs/job-1"}, + {name: "Should list automation triggers", method: http.MethodGet, path: "/automation/triggers"}, + {name: "Should return automation trigger details", method: http.MethodGet, path: "/automation/triggers/trigger-1"}, + {name: "Should delete automation triggers and hide them from subsequent reads", method: http.MethodDelete, path: "/automation/triggers/trigger-1"}, + {name: "Should list job runs for one automation job", method: http.MethodGet, path: "/automation/jobs/job-1/runs"}, + {name: "Should list trigger runs for one automation trigger", method: http.MethodGet, path: "/automation/triggers/trigger-1/runs"}, + {name: "Should list automation runs", method: http.MethodGet, path: "/automation/runs?limit=1"}, + {name: "Should return automation run details", method: http.MethodGet, path: "/automation/runs/run-1"}, + } { + request := request + t.Run(request.name, func(t *testing.T) { + fixture := newFixture(t) + resp := performRequest(t, fixture.Engine, request.method, request.path, nil) + if request.method == http.MethodDelete { + if resp.Code != http.StatusNoContent { + t.Fatalf("%s %s status = %d, want %d; body=%s", request.method, request.path, resp.Code, http.StatusNoContent, resp.Body.String()) + } + followUp := performRequest(t, fixture.Engine, http.MethodGet, request.path, nil) + if followUp.Code != http.StatusNotFound { + t.Fatalf("follow-up GET %s status = %d, want %d; body=%s", request.path, followUp.Code, http.StatusNotFound, followUp.Body.String()) + } + return + } + if resp.Code != http.StatusOK { + t.Fatalf("%s %s status = %d, want %d; body=%s", request.method, request.path, resp.Code, http.StatusOK, resp.Body.String()) + } + + switch request.path { + case "/automation/jobs/job-1": + var payload contract.JobResponse + if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal(job detail) error = %v", err) + } + if got, want := payload.Job.ID, job.ID; got != want { + t.Fatalf("job payload id = %v, want %q", got, want) + } + if got, want := payload.Job.Name, job.Name; got != want { + t.Fatalf("job payload name = %v, want %q", got, want) + } + case "/automation/triggers": + var payload contract.TriggersResponse + if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal(trigger list) error = %v", err) + } + if got, want := len(payload.Triggers), 1; got != want { + t.Fatalf("len(trigger list) = %d, want %d", got, want) + } + if got, want := payload.Triggers[0].ID, trigger.ID; got != want { + t.Fatalf("trigger list id = %v, want %q", got, want) + } + case "/automation/triggers/trigger-1": + var payload contract.TriggerResponse + if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal(trigger detail) error = %v", err) + } + if got, want := payload.Trigger.ID, trigger.ID; got != want { + t.Fatalf("trigger payload id = %v, want %q", got, want) + } + if got, want := payload.Trigger.Event, trigger.Event; got != want { + t.Fatalf("trigger payload event = %v, want %q", got, want) + } + case "/automation/jobs/job-1/runs", "/automation/triggers/trigger-1/runs", "/automation/runs?limit=1": + var payload contract.RunsResponse + if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal(run list) error = %v", err) + } + if got, want := len(payload.Runs), 1; got != want { + t.Fatalf("len(run list) = %d, want %d", got, want) + } + if got, want := payload.Runs[0].ID, run.ID; got != want { + t.Fatalf("run list id = %v, want %q", got, want) + } + case "/automation/runs/run-1": + var payload contract.RunResponse + if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal(run detail) error = %v", err) + } + if got, want := payload.Run.ID, run.ID; got != want { + t.Fatalf("run payload id = %v, want %q", got, want) + } + if got, want := payload.Run.Status, run.Status; got != want { + t.Fatalf("run payload status = %v, want %q", got, want) + } + } + }) + } +} diff --git a/internal/api/core/automation_test.go b/internal/api/core/automation_test.go index 4ecf0ec4f..cb19cd587 100644 --- a/internal/api/core/automation_test.go +++ b/internal/api/core/automation_test.go @@ -19,6 +19,7 @@ import ( "github.com/pedronauck/agh/internal/api/contract" automationpkg "github.com/pedronauck/agh/internal/automation" aghconfig "github.com/pedronauck/agh/internal/config" + taskpkg "github.com/pedronauck/agh/internal/task" ) func TestUpdateAutomationJobConfigBackedRejectsDefinitionEditsButAllowsEnabledToggle(t *testing.T) { @@ -585,173 +586,217 @@ func TestAutomationDynamicHandlersRoundTripAndHelperCoverage(t *testing.T) { func TestAutomationHelperFunctionsAndErrors(t *testing.T) { t.Parallel() - rootCause := errors.New("bad request") - validationErr := NewAutomationValidationError(rootCause) - if !errors.Is(validationErr, ErrAutomationValidation) { - t.Fatalf("NewAutomationValidationError() = %v, want ErrAutomationValidation", validationErr) - } - if !errors.Is(validationErr, rootCause) { - t.Fatalf("NewAutomationValidationError() = %v, want wrapped root cause", validationErr) - } - - if _, err := parseWebhookTimestampHeader(""); err == nil { - t.Fatal("parseWebhookTimestampHeader(empty) error = nil, want error") - } + t.Run("Should wrap automation validation errors", func(t *testing.T) { + rootCause := errors.New("bad request") + validationErr := NewAutomationValidationError(rootCause) + if !errors.Is(validationErr, ErrAutomationValidation) { + t.Fatalf("NewAutomationValidationError() = %v, want ErrAutomationValidation", validationErr) + } + if !errors.Is(validationErr, rootCause) { + t.Fatalf("NewAutomationValidationError() = %v, want wrapped root cause", validationErr) + } + }) - seconds := time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC).Unix() - parsed, err := parseWebhookTimestampHeader(strconv.FormatInt(seconds, 10)) - if err != nil { - t.Fatalf("parseWebhookTimestampHeader(unix) error = %v", err) - } - if parsed.Unix() != seconds { - t.Fatalf("parseWebhookTimestampHeader(unix) = %v, want unix %d", parsed, seconds) - } - if _, err := parseWebhookTimestampHeader("not-a-time"); err == nil { - t.Fatal("parseWebhookTimestampHeader(invalid) error = nil, want error") - } + t.Run("Should parse webhook timestamps from unix seconds and reject invalid values", func(t *testing.T) { + if _, err := parseWebhookTimestampHeader(""); err == nil { + t.Fatal("parseWebhookTimestampHeader(empty) error = nil, want error") + } - if data := decodeWebhookPayloadData(nil); data != nil { - t.Fatalf("decodeWebhookPayloadData(nil) = %#v, want nil", data) - } - if data := decodeWebhookPayloadData([]byte("not-json")); data != nil { - t.Fatalf("decodeWebhookPayloadData(invalid) = %#v, want nil", data) - } - data := decodeWebhookPayloadData([]byte(`{"payload":"deploy"}`)) - if data["payload"] != "deploy" { - t.Fatalf("decodeWebhookPayloadData(json) = %#v", data) - } + seconds := time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC).Unix() + parsed, err := parseWebhookTimestampHeader(strconv.FormatInt(seconds, 10)) + if err != nil { + t.Fatalf("parseWebhookTimestampHeader(unix) error = %v", err) + } + if parsed.Unix() != seconds { + t.Fatalf("parseWebhookTimestampHeader(unix) = %v, want unix %d", parsed, seconds) + } + if _, err := parseWebhookTimestampHeader("not-a-time"); err == nil { + t.Fatal("parseWebhookTimestampHeader(invalid) error = nil, want error") + } + }) - createdJob := jobFromCreateRequest(contract.CreateJobRequest{ - Scope: automationpkg.AutomationScopeWorkspace, - Name: " build review ", - AgentName: " coder ", - WorkspaceID: " ws-alpha ", - Prompt: " inspect repo ", - Schedule: automationpkg.ScheduleSpec{ - Mode: automationpkg.ScheduleModeEvery, - Interval: "2h", - }, + t.Run("Should decode webhook payload JSON and ignore empty inputs", func(t *testing.T) { + if data := decodeWebhookPayloadData(nil); data != nil { + t.Fatalf("decodeWebhookPayloadData(nil) = %#v, want nil", data) + } + if data := decodeWebhookPayloadData([]byte("not-json")); data != nil { + t.Fatalf("decodeWebhookPayloadData(invalid) = %#v, want nil", data) + } + data := decodeWebhookPayloadData([]byte(`{"payload":"deploy"}`)) + if data["payload"] != "deploy" { + t.Fatalf("decodeWebhookPayloadData(json) = %#v", data) + } }) - if createdJob.Scope != automationpkg.AutomationScopeWorkspace || createdJob.Name != "build review" || createdJob.AgentName != "coder" || createdJob.WorkspaceID != "ws-alpha" || createdJob.Prompt != "inspect repo" || createdJob.Schedule == nil || createdJob.Schedule.Interval != "2h" { - t.Fatalf("jobFromCreateRequest() = %#v", createdJob) - } - - jobName := " renamed " - jobAgent := " reviewer " - jobWorkspace := " ws-beta " - jobPrompt := " next prompt " - jobEnabled := false - jobSchedule := automationpkg.ScheduleSpec{Mode: automationpkg.ScheduleModeCron, Expr: "0 * * * *"} - jobRetry := automationpkg.RetryConfig{Strategy: automationpkg.RetryStrategyBackoff, MaxRetries: 3, BaseDelay: "2m"} - jobFireLimit := automationpkg.FireLimitConfig{Max: 4, Window: "24h"} - updatedJob := applyJobPatch(automationpkg.Job{ - ID: "job-1", - Name: "before", - AgentName: "old-agent", - WorkspaceID: "ws-alpha", - Prompt: "old", - Enabled: true, - Schedule: &automationpkg.ScheduleSpec{Mode: automationpkg.ScheduleModeEvery, Interval: "1h"}, - Source: automationpkg.JobSourceDynamic, - Retry: automationpkg.DefaultRetryConfig(), - FireLimit: automationpkg.DefaultFireLimitConfig(), - }, contract.UpdateJobRequest{ - Name: &jobName, - AgentName: &jobAgent, - WorkspaceID: &jobWorkspace, - Prompt: &jobPrompt, - Schedule: &jobSchedule, - Enabled: &jobEnabled, - Retry: &jobRetry, - FireLimit: &jobFireLimit, + + t.Run("Should map create requests to trimmed task-backed jobs", func(t *testing.T) { + createdJob := jobFromCreateRequest(contract.CreateJobRequest{ + Scope: automationpkg.AutomationScopeWorkspace, + Name: " build review ", + AgentName: " coder ", + WorkspaceID: " ws-alpha ", + Prompt: " inspect repo ", + Schedule: automationpkg.ScheduleSpec{ + Mode: automationpkg.ScheduleModeEvery, + Interval: "2h", + }, + Task: &automationpkg.JobTaskConfig{ + Title: " Review repo ", + NetworkChannel: " ops-automation ", + Owner: &taskpkg.Ownership{ + Kind: taskpkg.OwnerKindAutomation, + Ref: " rule:build-review ", + }, + }, + }) + if createdJob.Scope != automationpkg.AutomationScopeWorkspace || createdJob.Name != "build review" || createdJob.AgentName != "coder" || createdJob.WorkspaceID != "ws-alpha" || createdJob.Prompt != "inspect repo" || createdJob.Schedule == nil || createdJob.Schedule.Interval != "2h" || createdJob.Task == nil || createdJob.Task.Title != "Review repo" || createdJob.Task.NetworkChannel != "ops-automation" || createdJob.Task.Owner == nil || createdJob.Task.Owner.Kind != taskpkg.OwnerKindAutomation || createdJob.Task.Owner.Ref != "rule:build-review" { + t.Fatalf("jobFromCreateRequest() = %#v", createdJob) + } }) - if updatedJob.Name != "renamed" || updatedJob.AgentName != "reviewer" || updatedJob.WorkspaceID != "ws-beta" || updatedJob.Prompt != "next prompt" || updatedJob.Enabled || updatedJob.Schedule == nil || updatedJob.Schedule.Expr != "0 * * * *" || updatedJob.Retry.MaxRetries != 3 || updatedJob.FireLimit.Max != 4 { - t.Fatalf("applyJobPatch() = %#v", updatedJob) - } - createdTrigger := triggerFromCreateRequest(contract.CreateTriggerRequest{ - Scope: automationpkg.AutomationScopeWorkspace, - Name: " deploy-review ", - AgentName: " coder ", - WorkspaceID: " ws-alpha ", - Prompt: ` review {{ index .Data "payload" }} `, - Event: " webhook ", - Filter: map[string]string{"data.branch": "main"}, - WebhookID: " wbh_456 ", - EndpointSlug: " deploy-review ", + t.Run("Should clone patched job task configuration instead of aliasing the request", func(t *testing.T) { + jobName := " renamed " + jobAgent := " reviewer " + jobWorkspace := " ws-beta " + jobPrompt := " next prompt " + jobEnabled := false + jobSchedule := automationpkg.ScheduleSpec{Mode: automationpkg.ScheduleModeCron, Expr: "0 * * * *"} + jobTask := automationpkg.JobTaskConfig{ + Title: "Delegate review", + NetworkChannel: "ops-queue", + Owner: &taskpkg.Ownership{ + Kind: taskpkg.OwnerKindPool, + Ref: "ops-review", + }, + } + jobRetry := automationpkg.RetryConfig{Strategy: automationpkg.RetryStrategyBackoff, MaxRetries: 3, BaseDelay: "2m"} + jobFireLimit := automationpkg.FireLimitConfig{Max: 4, Window: "24h"} + updatedJob := applyJobPatch(automationpkg.Job{ + ID: "job-1", + Name: "before", + AgentName: "old-agent", + WorkspaceID: "ws-alpha", + Prompt: "old", + Enabled: true, + Schedule: &automationpkg.ScheduleSpec{Mode: automationpkg.ScheduleModeEvery, Interval: "1h"}, + Task: &automationpkg.JobTaskConfig{Title: "Before"}, + Source: automationpkg.JobSourceDynamic, + Retry: automationpkg.DefaultRetryConfig(), + FireLimit: automationpkg.DefaultFireLimitConfig(), + }, contract.UpdateJobRequest{ + Name: &jobName, + AgentName: &jobAgent, + WorkspaceID: &jobWorkspace, + Prompt: &jobPrompt, + Schedule: &jobSchedule, + Task: &jobTask, + Enabled: &jobEnabled, + Retry: &jobRetry, + FireLimit: &jobFireLimit, + }) + if updatedJob.Name != "renamed" || updatedJob.AgentName != "reviewer" || updatedJob.WorkspaceID != "ws-beta" || updatedJob.Prompt != "next prompt" || updatedJob.Enabled || updatedJob.Schedule == nil || updatedJob.Schedule.Expr != "0 * * * *" || updatedJob.Task == nil || updatedJob.Task.Title != "Delegate review" || updatedJob.Task.NetworkChannel != "ops-queue" || updatedJob.Task.Owner == nil || updatedJob.Task.Owner.Kind != taskpkg.OwnerKindPool || updatedJob.Task.Owner.Ref != "ops-review" || updatedJob.Retry.MaxRetries != 3 || updatedJob.FireLimit.Max != 4 { + t.Fatalf("applyJobPatch() = %#v", updatedJob) + } + + jobTask.Title = "mutated" + jobTask.NetworkChannel = "mutated" + jobTask.Owner.Ref = "mutated" + if updatedJob.Task.Title != "Delegate review" || updatedJob.Task.NetworkChannel != "ops-queue" || updatedJob.Task.Owner.Ref != "ops-review" { + t.Fatalf("applyJobPatch() task clone = %#v", updatedJob.Task) + } }) - if createdTrigger.Scope != automationpkg.AutomationScopeWorkspace || createdTrigger.Name != "deploy-review" || createdTrigger.AgentName != "coder" || createdTrigger.WorkspaceID != "ws-alpha" || createdTrigger.Event != "webhook" || createdTrigger.WebhookID != "wbh_456" || createdTrigger.EndpointSlug != "deploy-review" || createdTrigger.Filter["data.branch"] != "main" { - t.Fatalf("triggerFromCreateRequest() = %#v", createdTrigger) - } - - triggerEvent := "session.stopped" - triggerFilter := map[string]string{"kind": "session"} - triggerEnabled := false - triggerRetry := automationpkg.RetryConfig{Strategy: automationpkg.RetryStrategyBackoff, MaxRetries: 2, BaseDelay: "30s"} - triggerFireLimit := automationpkg.FireLimitConfig{Max: 2, Window: "1h"} - updatedTrigger := applyTriggerPatch(automationpkg.Trigger{ - ID: "trigger-1", - Name: "before", - AgentName: "old-agent", - WorkspaceID: "ws-alpha", - Prompt: "old", - Event: "webhook", - Filter: map[string]string{"branch": "main"}, - Enabled: true, - WebhookID: "wbh_123", - EndpointSlug: "deploy-review", - Source: automationpkg.JobSourceDynamic, - Retry: automationpkg.DefaultRetryConfig(), - FireLimit: automationpkg.DefaultFireLimitConfig(), - }, contract.UpdateTriggerRequest{ - Name: &jobName, - AgentName: &jobAgent, - WorkspaceID: &jobWorkspace, - Prompt: &jobPrompt, - Event: &triggerEvent, - Filter: triggerFilter, - Enabled: &triggerEnabled, - Retry: &triggerRetry, - FireLimit: &triggerFireLimit, + + t.Run("Should map and clone trigger patch fields", func(t *testing.T) { + createdTrigger := triggerFromCreateRequest(contract.CreateTriggerRequest{ + Scope: automationpkg.AutomationScopeWorkspace, + Name: " deploy-review ", + AgentName: " coder ", + WorkspaceID: " ws-alpha ", + Prompt: ` review {{ index .Data "payload" }} `, + Event: " webhook ", + Filter: map[string]string{"data.branch": "main"}, + WebhookID: " wbh_456 ", + EndpointSlug: " deploy-review ", + }) + if createdTrigger.Scope != automationpkg.AutomationScopeWorkspace || createdTrigger.Name != "deploy-review" || createdTrigger.AgentName != "coder" || createdTrigger.WorkspaceID != "ws-alpha" || createdTrigger.Event != "webhook" || createdTrigger.WebhookID != "wbh_456" || createdTrigger.EndpointSlug != "deploy-review" || createdTrigger.Filter["data.branch"] != "main" { + t.Fatalf("triggerFromCreateRequest() = %#v", createdTrigger) + } + + jobName := " renamed " + jobAgent := " reviewer " + jobWorkspace := " ws-beta " + jobPrompt := " next prompt " + triggerEvent := "session.stopped" + triggerFilter := map[string]string{"kind": "session"} + triggerEnabled := false + triggerRetry := automationpkg.RetryConfig{Strategy: automationpkg.RetryStrategyBackoff, MaxRetries: 2, BaseDelay: "30s"} + triggerFireLimit := automationpkg.FireLimitConfig{Max: 2, Window: "1h"} + updatedTrigger := applyTriggerPatch(automationpkg.Trigger{ + ID: "trigger-1", + Name: "before", + AgentName: "old-agent", + WorkspaceID: "ws-alpha", + Prompt: "old", + Event: "webhook", + Filter: map[string]string{"branch": "main"}, + Enabled: true, + WebhookID: "wbh_123", + EndpointSlug: "deploy-review", + Source: automationpkg.JobSourceDynamic, + Retry: automationpkg.DefaultRetryConfig(), + FireLimit: automationpkg.DefaultFireLimitConfig(), + }, contract.UpdateTriggerRequest{ + Name: &jobName, + AgentName: &jobAgent, + WorkspaceID: &jobWorkspace, + Prompt: &jobPrompt, + Event: &triggerEvent, + Filter: triggerFilter, + Enabled: &triggerEnabled, + Retry: &triggerRetry, + FireLimit: &triggerFireLimit, + }) + if updatedTrigger.Name != "renamed" || updatedTrigger.AgentName != "reviewer" || updatedTrigger.WorkspaceID != "ws-beta" || updatedTrigger.Prompt != "next prompt" || updatedTrigger.Event != "session.stopped" || updatedTrigger.WebhookID != "" || updatedTrigger.EndpointSlug != "" || updatedTrigger.Enabled || updatedTrigger.Retry.MaxRetries != 2 || updatedTrigger.FireLimit.Max != 2 { + t.Fatalf("applyTriggerPatch() = %#v", updatedTrigger) + } + triggerFilter["kind"] = "mutated" + if updatedTrigger.Filter["kind"] != "session" { + t.Fatalf("applyTriggerPatch() filter clone = %#v", updatedTrigger.Filter) + } + if clone := cloneAutomationFilter(nil); clone != nil { + t.Fatalf("cloneAutomationFilter(nil) = %#v, want nil", clone) + } }) - if updatedTrigger.Name != "renamed" || updatedTrigger.AgentName != "reviewer" || updatedTrigger.WorkspaceID != "ws-beta" || updatedTrigger.Prompt != "next prompt" || updatedTrigger.Event != "session.stopped" || updatedTrigger.WebhookID != "" || updatedTrigger.EndpointSlug != "" || updatedTrigger.Enabled || updatedTrigger.Retry.MaxRetries != 2 || updatedTrigger.FireLimit.Max != 2 { - t.Fatalf("applyTriggerPatch() = %#v", updatedTrigger) - } - triggerFilter["kind"] = "mutated" - if updatedTrigger.Filter["kind"] != "session" { - t.Fatalf("applyTriggerPatch() filter clone = %#v", updatedTrigger.Filter) - } - if clone := cloneAutomationFilter(nil); clone != nil { - t.Fatalf("cloneAutomationFilter(nil) = %#v, want nil", clone) - } - if status := StatusForAutomationError(validationErr); status != http.StatusBadRequest { - t.Fatalf("StatusForAutomationError(validation) = %d, want %d", status, http.StatusBadRequest) - } - if status := StatusForAutomationError(&http.MaxBytesError{Limit: maxWebhookPayloadSize}); status != http.StatusRequestEntityTooLarge { - t.Fatalf("StatusForAutomationError(max bytes) = %d, want %d", status, http.StatusRequestEntityTooLarge) - } - if status := StatusForAutomationError(automationpkg.ErrWebhookSignatureInvalid); status != http.StatusUnauthorized { - t.Fatalf("StatusForAutomationError(signature) = %d, want %d", status, http.StatusUnauthorized) - } - if status := StatusForAutomationError(automationpkg.ErrRunNotFound); status != http.StatusNotFound { - t.Fatalf("StatusForAutomationError(not found) = %d, want %d", status, http.StatusNotFound) - } - if status := StatusForAutomationError(automationpkg.ErrJobOverlayNotFound); status != http.StatusNotFound { - t.Fatalf("StatusForAutomationError(job overlay not found) = %d, want %d", status, http.StatusNotFound) - } - if status := StatusForAutomationError(automationpkg.ErrFireLimitReached); status != http.StatusConflict { - t.Fatalf("StatusForAutomationError(conflict) = %d, want %d", status, http.StatusConflict) - } - if status := StatusForAutomationError(automationpkg.ErrOverlayRequiresConfigSource); status != http.StatusConflict { - t.Fatalf("StatusForAutomationError(overlay requires config source) = %d, want %d", status, http.StatusConflict) - } - if status := StatusForAutomationError(automationpkg.ErrWebhookReplayDetected); status != http.StatusConflict { - t.Fatalf("StatusForAutomationError(webhook replay) = %d, want %d", status, http.StatusConflict) - } - if status := StatusForAutomationError(automationpkg.ErrManagerNotRunning); status != http.StatusServiceUnavailable { - t.Fatalf("StatusForAutomationError(unavailable) = %d, want %d", status, http.StatusServiceUnavailable) - } + t.Run("Should map automation errors to HTTP status codes", func(t *testing.T) { + validationErr := NewAutomationValidationError(errors.New("bad request")) + if status := StatusForAutomationError(validationErr); status != http.StatusBadRequest { + t.Fatalf("StatusForAutomationError(validation) = %d, want %d", status, http.StatusBadRequest) + } + if status := StatusForAutomationError(&http.MaxBytesError{Limit: maxWebhookPayloadSize}); status != http.StatusRequestEntityTooLarge { + t.Fatalf("StatusForAutomationError(max bytes) = %d, want %d", status, http.StatusRequestEntityTooLarge) + } + if status := StatusForAutomationError(automationpkg.ErrWebhookSignatureInvalid); status != http.StatusUnauthorized { + t.Fatalf("StatusForAutomationError(signature) = %d, want %d", status, http.StatusUnauthorized) + } + if status := StatusForAutomationError(automationpkg.ErrRunNotFound); status != http.StatusNotFound { + t.Fatalf("StatusForAutomationError(not found) = %d, want %d", status, http.StatusNotFound) + } + if status := StatusForAutomationError(automationpkg.ErrJobOverlayNotFound); status != http.StatusNotFound { + t.Fatalf("StatusForAutomationError(job overlay not found) = %d, want %d", status, http.StatusNotFound) + } + if status := StatusForAutomationError(automationpkg.ErrFireLimitReached); status != http.StatusConflict { + t.Fatalf("StatusForAutomationError(conflict) = %d, want %d", status, http.StatusConflict) + } + if status := StatusForAutomationError(automationpkg.ErrOverlayRequiresConfigSource); status != http.StatusConflict { + t.Fatalf("StatusForAutomationError(overlay requires config source) = %d, want %d", status, http.StatusConflict) + } + if status := StatusForAutomationError(automationpkg.ErrWebhookReplayDetected); status != http.StatusConflict { + t.Fatalf("StatusForAutomationError(webhook replay) = %d, want %d", status, http.StatusConflict) + } + if status := StatusForAutomationError(automationpkg.ErrManagerNotRunning); status != http.StatusServiceUnavailable { + t.Fatalf("StatusForAutomationError(unavailable) = %d, want %d", status, http.StatusServiceUnavailable) + } + }) } func TestStatusForAutomationErrorMapsAdditionalSentinels(t *testing.T) { diff --git a/internal/api/core/conversions.go b/internal/api/core/conversions.go index f22ea1d1f..3dd739f06 100644 --- a/internal/api/core/conversions.go +++ b/internal/api/core/conversions.go @@ -239,6 +239,14 @@ func JobPayloadFromJob(job automationpkg.Job, nextRun *time.Time) contract.JobPa schedule := *job.Schedule payload.Schedule = &schedule } + if job.Task != nil { + taskConfig := *job.Task + if job.Task.Owner != nil { + owner := *job.Task.Owner + taskConfig.Owner = &owner + } + payload.Task = &taskConfig + } return payload } @@ -291,6 +299,8 @@ func RunPayloadFromRun(run automationpkg.Run) contract.RunPayload { JobID: run.JobID, TriggerID: run.TriggerID, SessionID: run.SessionID, + TaskID: run.TaskID, + TaskRunID: run.TaskRunID, Status: run.Status, Attempt: run.Attempt, StartedAt: run.StartedAt, diff --git a/internal/api/core/errors.go b/internal/api/core/errors.go index ab226462e..4c02c0183 100644 --- a/internal/api/core/errors.go +++ b/internal/api/core/errors.go @@ -13,6 +13,8 @@ import ( bridgepkg "github.com/pedronauck/agh/internal/bridges" "github.com/pedronauck/agh/internal/memory" "github.com/pedronauck/agh/internal/network" + "github.com/pedronauck/agh/internal/session" + taskpkg "github.com/pedronauck/agh/internal/task" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) @@ -65,6 +67,50 @@ func StatusForMemoryError(err error) int { } } +// NewTaskValidationError wraps a task validation failure with the shared sentinel. +func NewTaskValidationError(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("%w: %w", taskpkg.ErrValidation, err) +} + +// StatusForTaskError maps task-domain, workspace, and session failures to transport statuses. +func StatusForTaskError(err error) int { + switch { + case err == nil: + return http.StatusOK + case errors.Is(err, taskpkg.ErrValidation), + errors.Is(err, taskpkg.ErrInvalidScopeBinding), + errors.Is(err, taskpkg.ErrImmutableField): + return http.StatusBadRequest + case errors.Is(err, taskpkg.ErrPayloadTooLarge): + return http.StatusRequestEntityTooLarge + case errors.Is(err, taskpkg.ErrPermissionDenied): + return http.StatusForbidden + case errors.Is(err, taskpkg.ErrTaskNotFound), + errors.Is(err, taskpkg.ErrTaskRunNotFound), + errors.Is(err, taskpkg.ErrTaskDependencyNotFound), + errors.Is(err, taskpkg.ErrTaskEventNotFound), + errors.Is(err, taskpkg.ErrTaskRunIdempotencyNotFound), + errors.Is(err, workspacepkg.ErrWorkspaceNotFound), + errors.Is(err, session.ErrSessionNotFound), + errors.Is(err, os.ErrNotExist): + return http.StatusNotFound + case errors.Is(err, workspacepkg.ErrWorkspaceRootMissing): + return http.StatusGone + case errors.Is(err, taskpkg.ErrInvalidStatusTransition), + errors.Is(err, taskpkg.ErrGraphLimitExceeded), + errors.Is(err, taskpkg.ErrCycleDetected), + errors.Is(err, taskpkg.ErrSessionAlreadyBound), + errors.Is(err, taskpkg.ErrSessionAttachNotAllowed), + errors.Is(err, taskpkg.ErrStaleNetworkChannel): + return http.StatusConflict + default: + return http.StatusInternalServerError + } +} + // StatusForBridgeError maps bridge-domain and workspace-domain errors to transport statuses. func StatusForBridgeError(err error) int { switch { diff --git a/internal/api/core/errors_test.go b/internal/api/core/errors_test.go index 4b9896bb3..f329b5412 100644 --- a/internal/api/core/errors_test.go +++ b/internal/api/core/errors_test.go @@ -1,12 +1,20 @@ package core import ( + "encoding/json" "errors" "net/http" + "net/http/httptest" + "os" "testing" + "github.com/gin-gonic/gin" "github.com/pedronauck/agh/internal/api/contract" + automationpkg "github.com/pedronauck/agh/internal/automation" bridgepkg "github.com/pedronauck/agh/internal/bridges" + "github.com/pedronauck/agh/internal/network" + "github.com/pedronauck/agh/internal/session" + taskpkg "github.com/pedronauck/agh/internal/task" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) @@ -80,3 +88,133 @@ func TestStatusForBridgeError(t *testing.T) { }) } } + +func TestTaskErrorHelpers(t *testing.T) { + t.Parallel() + + wrapped := NewTaskValidationError(errors.New("bad input")) + if !errors.Is(wrapped, taskpkg.ErrValidation) { + t.Fatalf("NewTaskValidationError() = %v, want wrapped task validation sentinel", wrapped) + } + if got := NewTaskValidationError(nil); got != nil { + t.Fatalf("NewTaskValidationError(nil) = %v, want nil", got) + } + + tests := []struct { + name string + err error + want int + }{ + {name: "nil", err: nil, want: http.StatusOK}, + {name: "invalid scope", err: taskpkg.ErrInvalidScopeBinding, want: http.StatusBadRequest}, + {name: "immutable field", err: taskpkg.ErrImmutableField, want: http.StatusBadRequest}, + {name: "run missing", err: taskpkg.ErrTaskRunNotFound, want: http.StatusNotFound}, + {name: "session missing", err: session.ErrSessionNotFound, want: http.StatusNotFound}, + {name: "os not exist", err: os.ErrNotExist, want: http.StatusNotFound}, + {name: "workspace root missing", err: workspacepkg.ErrWorkspaceRootMissing, want: http.StatusGone}, + {name: "attach forbidden", err: taskpkg.ErrSessionAttachNotAllowed, want: http.StatusConflict}, + {name: "stale network channel", err: taskpkg.ErrStaleNetworkChannel, want: http.StatusConflict}, + {name: "default", err: errors.New("boom"), want: http.StatusInternalServerError}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := StatusForTaskError(tt.err); got != tt.want { + t.Fatalf("StatusForTaskError(%v) = %d, want %d", tt.err, got, tt.want) + } + }) + } +} + +func TestAutomationAndNetworkErrorHelpers(t *testing.T) { + t.Parallel() + + automationErr := NewAutomationValidationError(errors.New("bad automation request")) + if !errors.Is(automationErr, ErrAutomationValidation) { + t.Fatalf("NewAutomationValidationError() = %v, want ErrAutomationValidation", automationErr) + } + if got := NewAutomationValidationError(nil); got != nil { + t.Fatalf("NewAutomationValidationError(nil) = %v, want nil", got) + } + + networkErr := NewNetworkValidationError(errors.New("bad network request")) + if !errors.Is(networkErr, ErrNetworkValidation) { + t.Fatalf("NewNetworkValidationError() = %v, want ErrNetworkValidation", networkErr) + } + if got := NewNetworkValidationError(nil); got != nil { + t.Fatalf("NewNetworkValidationError(nil) = %v, want nil", got) + } + + if got := StatusForAutomationError(nil); got != http.StatusOK { + t.Fatalf("StatusForAutomationError(nil) = %d, want %d", got, http.StatusOK) + } + if got := StatusForAutomationError(automationpkg.ErrManagerNotRunning); got != http.StatusServiceUnavailable { + t.Fatalf("StatusForAutomationError(manager not running) = %d, want %d", got, http.StatusServiceUnavailable) + } + if got := StatusForAutomationError(automationpkg.ErrWebhookSignatureInvalid); got != http.StatusUnauthorized { + t.Fatalf("StatusForAutomationError(signature invalid) = %d, want %d", got, http.StatusUnauthorized) + } + + tests := []struct { + name string + err error + want int + }{ + {name: "nil", err: nil, want: http.StatusOK}, + {name: "validation", err: ErrNetworkValidation, want: http.StatusBadRequest}, + {name: "local peer missing", err: network.ErrLocalPeerNotFound, want: http.StatusNotFound}, + {name: "missing field", err: network.ErrMissingField, want: http.StatusBadRequest}, + {name: "default", err: errors.New("boom"), want: http.StatusInternalServerError}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := StatusForNetworkError(tt.err); got != tt.want { + t.Fatalf("StatusForNetworkError(%v) = %d, want %d", tt.err, got, tt.want) + } + }) + } +} + +func TestRespondErrorFallbackBranches(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + status int + err error + mask bool + wantErr string + wantStatus int + }{ + {name: "unknown error fallback", status: 0, err: nil, mask: false, wantErr: "unknown error", wantStatus: 200}, + {name: "masked internal fallback", status: 599, err: nil, mask: true, wantErr: "internal server error", wantStatus: 599}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + RespondError(ctx, tt.status, tt.err, tt.mask) + + var payload contract.ErrorPayload + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if payload.Error != tt.wantErr { + t.Fatalf("payload.Error = %q, want %q", payload.Error, tt.wantErr) + } + if recorder.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d", recorder.Code, tt.wantStatus) + } + }) + } +} diff --git a/internal/api/core/handlers.go b/internal/api/core/handlers.go index c56de267a..914a1cd54 100644 --- a/internal/api/core/handlers.go +++ b/internal/api/core/handlers.go @@ -19,10 +19,14 @@ import ( aghconfig "github.com/pedronauck/agh/internal/config" "github.com/pedronauck/agh/internal/memory" "github.com/pedronauck/agh/internal/session" + taskpkg "github.com/pedronauck/agh/internal/task" ) const defaultPollInterval = 100 * time.Millisecond +// TaskActorContextResolver derives the trusted task-domain actor envelope for one API request. +type TaskActorContextResolver func(c *gin.Context, action string) (taskpkg.ActorContext, error) + // BaseHandlerConfig configures a shared handler set for one transport. type BaseHandlerConfig struct { TransportName string @@ -33,9 +37,11 @@ type BaseHandlerConfig struct { NetworkStore NetworkStore Observer Observer Automation AutomationManager + Tasks TaskService Bridges BridgeService Workspaces WorkspaceService SkillsRegistry SkillsRegistry + TaskActorContextResolver TaskActorContextResolver MemoryStore *memory.Store DreamTrigger DreamTrigger HomePaths aghconfig.HomePaths @@ -60,9 +66,11 @@ type BaseHandlers struct { NetworkStore NetworkStore Observer Observer Automation AutomationManager + Tasks TaskService Bridges BridgeService Workspaces WorkspaceService SkillsRegistry SkillsRegistry + TaskActorContextResolver TaskActorContextResolver MemoryStore *memory.Store DreamTrigger DreamTrigger HomePaths aghconfig.HomePaths @@ -122,9 +130,11 @@ func NewBaseHandlers(cfg BaseHandlerConfig) *BaseHandlers { NetworkStore: cfg.NetworkStore, Observer: cfg.Observer, Automation: cfg.Automation, + Tasks: cfg.Tasks, Bridges: cfg.Bridges, Workspaces: cfg.Workspaces, SkillsRegistry: cfg.SkillsRegistry, + TaskActorContextResolver: cfg.TaskActorContextResolver, MemoryStore: cfg.MemoryStore, DreamTrigger: cfg.DreamTrigger, HomePaths: cfg.HomePaths, diff --git a/internal/api/core/handlers_internal_test.go b/internal/api/core/handlers_internal_test.go index 144259a2f..b60a6dfbe 100644 --- a/internal/api/core/handlers_internal_test.go +++ b/internal/api/core/handlers_internal_test.go @@ -1,14 +1,70 @@ package core import ( + "context" "errors" "path/filepath" "strings" "testing" + "github.com/pedronauck/agh/internal/acp" aghconfig "github.com/pedronauck/agh/internal/config" + "github.com/pedronauck/agh/internal/session" + "github.com/pedronauck/agh/internal/store" + "github.com/pedronauck/agh/internal/transcript" ) +type sessionManagerStub struct { + status func(context.Context, string) (*session.SessionInfo, error) +} + +func (s sessionManagerStub) Create(context.Context, session.CreateOpts) (*session.Session, error) { + return nil, nil +} + +func (s sessionManagerStub) List() []*session.SessionInfo { return nil } + +func (s sessionManagerStub) ListAll(context.Context) ([]*session.SessionInfo, error) { return nil, nil } + +func (s sessionManagerStub) Status(ctx context.Context, id string) (*session.SessionInfo, error) { + if s.status != nil { + return s.status(ctx, id) + } + return nil, session.ErrSessionNotFound +} + +func (s sessionManagerStub) Events(context.Context, string, store.EventQuery) ([]store.SessionEvent, error) { + return nil, nil +} + +func (s sessionManagerStub) History(context.Context, string, store.EventQuery) ([]store.TurnHistory, error) { + return nil, nil +} + +func (s sessionManagerStub) Transcript(context.Context, string) ([]transcript.Message, error) { + return nil, nil +} + +func (s sessionManagerStub) Stop(context.Context, string) error { return nil } + +func (s sessionManagerStub) StopWithCause(context.Context, string, session.StopCause, string) error { + return nil +} + +func (s sessionManagerStub) Resume(context.Context, string) (*session.Session, error) { + return nil, nil +} + +func (s sessionManagerStub) Prompt(context.Context, string, string) (<-chan acp.AgentEvent, error) { + ch := make(chan acp.AgentEvent) + close(ch) + return ch, nil +} + +func (s sessionManagerStub) ApprovePermission(context.Context, string, acp.ApproveRequest) error { + return nil +} + func TestResolveUserHomeDir(t *testing.T) { t.Parallel() @@ -113,3 +169,96 @@ func TestResolveUserHomeDir(t *testing.T) { }) } } + +func TestBaseHandlersAccessorsAndSessionInfoHelpers(t *testing.T) { + t.Parallel() + + var nilHandlers *BaseHandlers + if got := nilHandlers.HTTPPortValue(); got != 0 { + t.Fatalf("HTTPPortValue(nil) = %d, want 0", got) + } + if got := nilHandlers.StreamDoneChannel(); got != nil { + t.Fatalf("StreamDoneChannel(nil) = %v, want nil", got) + } + + done := make(chan struct{}) + calls := 0 + info := &session.SessionInfo{ID: "sess-1", WorkspaceID: "ws-alpha"} + handlers := &BaseHandlers{ + Sessions: sessionManagerStub{ + status: func(_ context.Context, id string) (*session.SessionInfo, error) { + calls++ + if id != "sess-1" { + t.Fatalf("Status() id = %q, want sess-1", id) + } + return info, nil + }, + }, + } + handlers.SetHTTPPort(4510) + handlers.SetStreamDone(done) + + if got := handlers.HTTPPortValue(); got != 4510 { + t.Fatalf("HTTPPortValue() = %d, want 4510", got) + } + if got := handlers.StreamDoneChannel(); got != done { + t.Fatalf("StreamDoneChannel() = %v, want %v", got, done) + } + if got := handlers.transportName(); got != "apicore" { + t.Fatalf("transportName(default) = %q, want apicore", got) + } + + handlers.TransportName = "uds-core" + if got := handlers.transportName(); got != "uds-core" { + t.Fatalf("transportName(custom) = %q, want uds-core", got) + } + + eventInfo, err := handlers.sessionEventInfo(context.Background(), "sess-1") + if err != nil { + t.Fatalf("sessionEventInfo(disabled) error = %v", err) + } + if eventInfo != nil { + t.Fatalf("sessionEventInfo(disabled) = %#v, want nil", eventInfo) + } + if calls != 0 { + t.Fatalf("Status() called %d times with IncludeSessionWorkspaceInSSE disabled, want 0", calls) + } + + handlers.IncludeSessionWorkspaceInSSE = true + eventInfo, err = handlers.sessionEventInfo(context.Background(), "sess-1") + if err != nil { + t.Fatalf("sessionEventInfo(enabled) error = %v", err) + } + if eventInfo != info { + t.Fatalf("sessionEventInfo(enabled) = %#v, want %#v", eventInfo, info) + } + + streamInfo, err := handlers.streamSessionInfo(context.Background(), "sess-1") + if err != nil { + t.Fatalf("streamSessionInfo(enabled) error = %v", err) + } + if streamInfo != info { + t.Fatalf("streamSessionInfo(enabled) = %#v, want %#v", streamInfo, info) + } + + handlers.IncludeSessionWorkspaceInSSE = false + streamInfo, err = handlers.streamSessionInfo(context.Background(), "sess-1") + if err != nil { + t.Fatalf("streamSessionInfo(disabled) error = %v", err) + } + if streamInfo != nil { + t.Fatalf("streamSessionInfo(disabled) = %#v, want nil", streamInfo) + } + if calls != 3 { + t.Fatalf("Status() calls = %d, want 3", calls) + } + + handlers.Sessions = sessionManagerStub{ + status: func(context.Context, string) (*session.SessionInfo, error) { + return nil, errors.New("boom") + }, + } + if _, err := handlers.streamSessionInfo(context.Background(), "sess-1"); err == nil { + t.Fatal("streamSessionInfo(error) error = nil, want non-nil") + } +} diff --git a/internal/api/core/interfaces.go b/internal/api/core/interfaces.go index ff54dd2c9..34da6ecc7 100644 --- a/internal/api/core/interfaces.go +++ b/internal/api/core/interfaces.go @@ -15,6 +15,7 @@ import ( "github.com/pedronauck/agh/internal/session" "github.com/pedronauck/agh/internal/skills" "github.com/pedronauck/agh/internal/store" + taskpkg "github.com/pedronauck/agh/internal/task" "github.com/pedronauck/agh/internal/transcript" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) @@ -106,6 +107,29 @@ type AutomationManager interface { HandleWebhook(ctx context.Context, request automationpkg.WebhookRequest) (automationpkg.TriggerResult, error) } +// TaskService exposes task-domain state and lifecycle surfaces to the API layer. +type TaskService interface { + CreateTask(ctx context.Context, spec taskpkg.CreateTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) + CreateChildTask(ctx context.Context, parentTaskID string, spec taskpkg.CreateTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) + UpdateTask(ctx context.Context, id string, patch taskpkg.TaskPatch, actor taskpkg.ActorContext) (*taskpkg.Task, error) + CancelTask(ctx context.Context, id string, req taskpkg.CancelTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) + + AddDependency(ctx context.Context, spec taskpkg.AddDependency, actor taskpkg.ActorContext) error + RemoveDependency(ctx context.Context, taskID string, dependsOnID string, actor taskpkg.ActorContext) error + + EnqueueRun(ctx context.Context, spec taskpkg.EnqueueRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) + ClaimRun(ctx context.Context, runID string, claim taskpkg.ClaimRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) + StartRun(ctx context.Context, runID string, req taskpkg.StartRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) + AttachRunSession(ctx context.Context, runID string, sessionID string, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) + CompleteRun(ctx context.Context, runID string, result taskpkg.RunResult, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) + FailRun(ctx context.Context, runID string, failure taskpkg.RunFailure, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) + CancelRun(ctx context.Context, runID string, req taskpkg.CancelRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) + + GetTask(ctx context.Context, id string, actor taskpkg.ActorContext) (*taskpkg.TaskView, error) + ListTaskRuns(ctx context.Context, taskID string, query taskpkg.TaskRunQuery, actor taskpkg.ActorContext) ([]taskpkg.TaskRun, error) + ListTasks(ctx context.Context, query taskpkg.TaskQuery, actor taskpkg.ActorContext) ([]taskpkg.TaskSummary, error) +} + // SkillsRegistry exposes the skill catalog to the API layer. type SkillsRegistry interface { Get(name string) (*skills.Skill, bool) diff --git a/internal/api/core/tasks.go b/internal/api/core/tasks.go new file mode 100644 index 000000000..e65fd76e0 --- /dev/null +++ b/internal/api/core/tasks.go @@ -0,0 +1,1128 @@ +package core + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/pedronauck/agh/internal/api/contract" + "github.com/pedronauck/agh/internal/network" + taskpkg "github.com/pedronauck/agh/internal/task" +) + +const ( + defaultTaskActorRef = "local-user" + taskActionList = "list" + taskActionCreate = "create" + taskActionGet = "get" + taskActionUpdate = "update" + taskActionCancel = "cancel" + taskActionCreateChild = "create_child" + taskActionAddDependency = "add_dependency" + taskActionRemoveDependency = "remove_dependency" + taskActionListRuns = "list_runs" + taskActionEnqueueRun = "enqueue_run" + taskActionClaimRun = "claim_run" + taskActionStartRun = "start_run" + taskActionAttachRun = "attach_run_session" + taskActionCompleteRun = "complete_run" + taskActionFailRun = "fail_run" + taskActionCancelRun = "cancel_run" +) + +func (h *BaseHandlers) requireTaskManager(c *gin.Context) (TaskService, bool) { + if h.Tasks == nil { + h.respondError(c, http.StatusServiceUnavailable, fmt.Errorf("%s: task service is not configured", h.transportName())) + return nil, false + } + return h.Tasks, true +} + +func (h *BaseHandlers) taskActorContext(c *gin.Context, action string) (taskpkg.ActorContext, error) { + if h.TaskActorContextResolver != nil { + return h.TaskActorContextResolver(c, action) + } + return taskpkg.DeriveHumanActorContext( + defaultTaskActorRef, + taskOriginKindForTransport(h.transportName()), + "tasks."+strings.TrimSpace(action), + ) +} + +func taskOriginKindForTransport(name string) taskpkg.OriginKind { + normalized := strings.ToLower(strings.TrimSpace(name)) + switch { + case strings.Contains(normalized, "uds"): + return taskpkg.OriginKindUDS + case strings.Contains(normalized, "web"): + return taskpkg.OriginKindWeb + case strings.Contains(normalized, "cli"): + return taskpkg.OriginKindCLI + default: + return taskpkg.OriginKindHTTP + } +} + +// ListTasks returns the filtered task list. +func (h *BaseHandlers) ListTasks(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + actor, err := h.taskActorContext(c, taskActionList) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + query, err := h.parseTaskListQuery(c.Request.Context(), c) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + tasks, err := manager.ListTasks(c.Request.Context(), query, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusOK, contract.TasksResponse{Tasks: TaskSummaryPayloadsFromSummaries(tasks)}) +} + +// CreateTask creates one new task. +func (h *BaseHandlers) CreateTask(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + var req contract.CreateTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, http.StatusBadRequest, NewTaskValidationError(fmt.Errorf("%s: decode create task request: %w", h.transportName(), err))) + return + } + + actor, err := h.taskActorContext(c, taskActionCreate) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + spec, err := h.createTaskSpecFromRequest(c.Request.Context(), req) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + record, err := manager.CreateTask(c.Request.Context(), spec, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusCreated, contract.TaskResponse{Task: TaskPayloadFromTask(record)}) +} + +// GetTask returns one expanded task view. +func (h *BaseHandlers) GetTask(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + taskID, err := requiredPathID(c.Param("id"), "task id") + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + actor, err := h.taskActorContext(c, taskActionGet) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + view, err := manager.GetTask(c.Request.Context(), taskID, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusOK, contract.TaskDetailResponse{Task: TaskDetailPayloadFromView(view)}) +} + +// UpdateTask patches one mutable task surface. +func (h *BaseHandlers) UpdateTask(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + taskID, err := requiredPathID(c.Param("id"), "task id") + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + var req contract.UpdateTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, http.StatusBadRequest, NewTaskValidationError(fmt.Errorf("%s: decode update task request: %w", h.transportName(), err))) + return + } + if !req.HasChanges() { + err := NewTaskValidationError(errors.New("task update must include at least one mutable field")) + h.respondError(c, http.StatusBadRequest, err) + return + } + + actor, err := h.taskActorContext(c, taskActionUpdate) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + patch, err := taskPatchFromRequest(req) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + record, err := manager.UpdateTask(c.Request.Context(), taskID, patch, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusOK, contract.TaskResponse{Task: TaskPayloadFromTask(record)}) +} + +// CancelTask requests cancellation for one task tree. +func (h *BaseHandlers) CancelTask(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + taskID, err := requiredPathID(c.Param("id"), "task id") + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + var req contract.CancelTaskRequest + if err := decodeOptionalJSON(c, &req); err != nil { + h.respondError(c, http.StatusBadRequest, NewTaskValidationError(fmt.Errorf("%s: decode cancel task request: %w", h.transportName(), err))) + return + } + + actor, err := h.taskActorContext(c, taskActionCancel) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + cancelReq, err := cancelTaskFromRequest(req) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + record, err := manager.CancelTask(c.Request.Context(), taskID, cancelReq, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusOK, contract.TaskResponse{Task: TaskPayloadFromTask(record)}) +} + +// CreateChildTask creates one child task beneath the supplied parent. +func (h *BaseHandlers) CreateChildTask(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + parentTaskID, err := requiredPathID(c.Param("id"), "task id") + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + var req contract.CreateTaskChildRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, http.StatusBadRequest, NewTaskValidationError(fmt.Errorf("%s: decode create child task request: %w", h.transportName(), err))) + return + } + + actor, err := h.taskActorContext(c, taskActionCreateChild) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + spec, err := h.createChildTaskSpecFromRequest(c.Request.Context(), req) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + record, err := manager.CreateChildTask(c.Request.Context(), parentTaskID, spec, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusCreated, contract.TaskResponse{Task: TaskPayloadFromTask(record)}) +} + +// AddTaskDependency adds one blocking dependency edge. +func (h *BaseHandlers) AddTaskDependency(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + taskID, err := requiredPathID(c.Param("id"), "task id") + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + var req contract.AddTaskDependencyRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, http.StatusBadRequest, NewTaskValidationError(fmt.Errorf("%s: decode add dependency request: %w", h.transportName(), err))) + return + } + + actor, err := h.taskActorContext(c, taskActionAddDependency) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + spec, err := addTaskDependencyFromRequest(taskID, req) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + if err := manager.AddDependency(c.Request.Context(), spec, actor); err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + view, err := manager.GetTask(c.Request.Context(), taskID, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusOK, contract.TaskDetailResponse{Task: TaskDetailPayloadFromView(view)}) +} + +// RemoveTaskDependency removes one blocking dependency edge. +func (h *BaseHandlers) RemoveTaskDependency(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + taskID, err := requiredPathID(c.Param("id"), "task id") + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + dependsOnID, err := requiredPathID(c.Param("depends_on_id"), "depends_on_id") + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + actor, err := h.taskActorContext(c, taskActionRemoveDependency) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + if err := manager.RemoveDependency(c.Request.Context(), taskID, dependsOnID, actor); err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + view, err := manager.GetTask(c.Request.Context(), taskID, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusOK, contract.TaskDetailResponse{Task: TaskDetailPayloadFromView(view)}) +} + +// ListTaskRuns returns the filtered run list for one task. +func (h *BaseHandlers) ListTaskRuns(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + taskID, err := requiredPathID(c.Param("id"), "task id") + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + actor, err := h.taskActorContext(c, taskActionListRuns) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + query, err := parseTaskRunListQuery(c) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + runs, err := manager.ListTaskRuns(c.Request.Context(), taskID, query, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusOK, contract.TaskRunsResponse{Runs: TaskRunPayloadsFromRuns(runs)}) +} + +// EnqueueTaskRun creates one new queue-first run for the supplied task. +func (h *BaseHandlers) EnqueueTaskRun(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + taskID, err := requiredPathID(c.Param("id"), "task id") + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + var req contract.EnqueueTaskRunRequest + if err := decodeOptionalJSON(c, &req); err != nil { + h.respondError(c, http.StatusBadRequest, NewTaskValidationError(fmt.Errorf("%s: decode enqueue run request: %w", h.transportName(), err))) + return + } + + actor, err := h.taskActorContext(c, taskActionEnqueueRun) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + spec, err := enqueueTaskRunFromRequest(taskID, req) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + run, err := manager.EnqueueRun(c.Request.Context(), spec, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusCreated, contract.TaskRunResponse{Run: TaskRunPayloadFromRun(run)}) +} + +// ClaimTaskRun claims one queued run. +func (h *BaseHandlers) ClaimTaskRun(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + runID, err := requiredPathID(c.Param("id"), "run id") + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + var req contract.ClaimTaskRunRequest + if err := decodeOptionalJSON(c, &req); err != nil { + h.respondError(c, http.StatusBadRequest, NewTaskValidationError(fmt.Errorf("%s: decode claim run request: %w", h.transportName(), err))) + return + } + + actor, err := h.taskActorContext(c, taskActionClaimRun) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + claim, err := claimTaskRunFromRequest(req) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + run, err := manager.ClaimRun(c.Request.Context(), runID, claim, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusOK, contract.TaskRunResponse{Run: TaskRunPayloadFromRun(run)}) +} + +// StartTaskRun starts one claimed run. +func (h *BaseHandlers) StartTaskRun(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + runID, err := requiredPathID(c.Param("id"), "run id") + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + var req contract.StartTaskRunRequest + if err := decodeOptionalJSON(c, &req); err != nil { + h.respondError(c, http.StatusBadRequest, NewTaskValidationError(fmt.Errorf("%s: decode start run request: %w", h.transportName(), err))) + return + } + + actor, err := h.taskActorContext(c, taskActionStartRun) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + startReq, err := startTaskRunFromRequest(req) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + run, err := manager.StartRun(c.Request.Context(), runID, startReq, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusOK, contract.TaskRunResponse{Run: TaskRunPayloadFromRun(run)}) +} + +// AttachTaskRunSession binds one existing session to a run. +func (h *BaseHandlers) AttachTaskRunSession(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + runID, err := requiredPathID(c.Param("id"), "run id") + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + var req contract.AttachTaskRunSessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, http.StatusBadRequest, NewTaskValidationError(fmt.Errorf("%s: decode attach run session request: %w", h.transportName(), err))) + return + } + + actor, err := h.taskActorContext(c, taskActionAttachRun) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + sessionID, err := attachTaskRunSessionIDFromRequest(req) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + run, err := manager.AttachRunSession(c.Request.Context(), runID, sessionID, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusOK, contract.TaskRunResponse{Run: TaskRunPayloadFromRun(run)}) +} + +// CompleteTaskRun marks one running run as completed. +func (h *BaseHandlers) CompleteTaskRun(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + runID, err := requiredPathID(c.Param("id"), "run id") + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + var req contract.CompleteTaskRunRequest + if err := decodeOptionalJSON(c, &req); err != nil { + h.respondError(c, http.StatusBadRequest, NewTaskValidationError(fmt.Errorf("%s: decode complete run request: %w", h.transportName(), err))) + return + } + + actor, err := h.taskActorContext(c, taskActionCompleteRun) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + result, err := completeTaskRunFromRequest(req) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + run, err := manager.CompleteRun(c.Request.Context(), runID, result, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusOK, contract.TaskRunResponse{Run: TaskRunPayloadFromRun(run)}) +} + +// FailTaskRun marks one run as failed. +func (h *BaseHandlers) FailTaskRun(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + runID, err := requiredPathID(c.Param("id"), "run id") + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + var req contract.FailTaskRunRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, http.StatusBadRequest, NewTaskValidationError(fmt.Errorf("%s: decode fail run request: %w", h.transportName(), err))) + return + } + + actor, err := h.taskActorContext(c, taskActionFailRun) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + failure, err := failTaskRunFromRequest(req) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + run, err := manager.FailRun(c.Request.Context(), runID, failure, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusOK, contract.TaskRunResponse{Run: TaskRunPayloadFromRun(run)}) +} + +// CancelTaskRun cancels one non-terminal run. +func (h *BaseHandlers) CancelTaskRun(c *gin.Context) { + manager, ok := h.requireTaskManager(c) + if !ok { + return + } + + runID, err := requiredPathID(c.Param("id"), "run id") + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + var req contract.CancelTaskRunRequest + if err := decodeOptionalJSON(c, &req); err != nil { + h.respondError(c, http.StatusBadRequest, NewTaskValidationError(fmt.Errorf("%s: decode cancel run request: %w", h.transportName(), err))) + return + } + + actor, err := h.taskActorContext(c, taskActionCancelRun) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + cancelReq, err := cancelTaskRunFromRequest(req) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + run, err := manager.CancelRun(c.Request.Context(), runID, cancelReq, actor) + if err != nil { + h.respondError(c, StatusForTaskError(err), err) + return + } + + c.JSON(http.StatusOK, contract.TaskRunResponse{Run: TaskRunPayloadFromRun(run)}) +} + +func (h *BaseHandlers) parseTaskListQuery(ctx context.Context, c *gin.Context) (taskpkg.TaskQuery, error) { + limit, err := ParseOptionalInt(c.Query("limit")) + if err != nil { + return taskpkg.TaskQuery{}, NewTaskValidationError(err) + } + + query := taskpkg.TaskQuery{ + Scope: taskpkg.Scope(strings.TrimSpace(c.Query("scope"))).Normalize(), + Status: taskpkg.TaskStatus(strings.TrimSpace(c.Query("status"))).Normalize(), + OwnerKind: taskpkg.OwnerKind(strings.TrimSpace(c.Query("owner_kind"))).Normalize(), + OwnerRef: strings.TrimSpace(c.Query("owner_ref")), + ParentTaskID: strings.TrimSpace(c.Query("parent_task_id")), + Limit: limit, + } + + if workspaceRef := strings.TrimSpace(c.Query("workspace")); workspaceRef != "" { + if query.Scope.Normalize() == taskpkg.ScopeGlobal { + return taskpkg.TaskQuery{}, taskpkg.ValidateScopeBinding(query.Scope, workspaceRef, "task_query", "workspace") + } + workspaceID, err := h.lookupWorkspaceID(ctx, workspaceRef) + if err != nil { + return taskpkg.TaskQuery{}, err + } + query.WorkspaceID = workspaceID + } + + if networkChannel := strings.TrimSpace(c.Query("network_channel")); networkChannel != "" { + if err := validateTaskChannel("task_query.network_channel", networkChannel); err != nil { + return taskpkg.TaskQuery{}, err + } + query.NetworkChannel = networkChannel + } + + if err := query.Validate("task_query"); err != nil { + return taskpkg.TaskQuery{}, err + } + return query, nil +} + +func parseTaskRunListQuery(c *gin.Context) (taskpkg.TaskRunQuery, error) { + limit, err := ParseOptionalInt(c.Query("limit")) + if err != nil { + return taskpkg.TaskRunQuery{}, NewTaskValidationError(err) + } + + query := taskpkg.TaskRunQuery{ + Status: taskpkg.TaskRunStatus(strings.TrimSpace(c.Query("status"))).Normalize(), + SessionID: strings.TrimSpace(c.Query("session_id")), + Limit: limit, + } + if err := query.Validate("task_run_query"); err != nil { + return taskpkg.TaskRunQuery{}, err + } + return query, nil +} + +func (h *BaseHandlers) createTaskSpecFromRequest(ctx context.Context, req contract.CreateTaskRequest) (taskpkg.CreateTask, error) { + scope := req.Scope.Normalize() + workspaceID, err := h.resolveTaskWorkspaceBinding(ctx, scope, req.Workspace, "create_task") + if err != nil { + return taskpkg.CreateTask{}, err + } + if err := validateTaskChannel("create_task.network_channel", req.NetworkChannel); err != nil { + return taskpkg.CreateTask{}, err + } + + spec := taskpkg.CreateTask{ + ID: strings.TrimSpace(req.ID), + Identifier: strings.TrimSpace(req.Identifier), + Scope: scope, + WorkspaceID: workspaceID, + NetworkChannel: strings.TrimSpace(req.NetworkChannel), + Title: strings.TrimSpace(req.Title), + Description: strings.TrimSpace(req.Description), + Owner: cloneOwnership(req.Owner), + Metadata: cloneRawMessage(req.Metadata), + } + if err := spec.Validate("create_task"); err != nil { + return taskpkg.CreateTask{}, err + } + return spec, nil +} + +func (h *BaseHandlers) createChildTaskSpecFromRequest(ctx context.Context, req contract.CreateTaskChildRequest) (taskpkg.CreateTask, error) { + scope := req.Scope.Normalize() + workspaceID, err := h.resolveTaskWorkspaceBinding(ctx, scope, req.Workspace, "create_child_task") + if err != nil { + return taskpkg.CreateTask{}, err + } + if err := validateTaskChannel("create_child_task.network_channel", req.NetworkChannel); err != nil { + return taskpkg.CreateTask{}, err + } + + spec := taskpkg.CreateTask{ + ID: strings.TrimSpace(req.ID), + Identifier: strings.TrimSpace(req.Identifier), + Scope: scope, + WorkspaceID: workspaceID, + NetworkChannel: strings.TrimSpace(req.NetworkChannel), + Title: strings.TrimSpace(req.Title), + Description: strings.TrimSpace(req.Description), + Owner: cloneOwnership(req.Owner), + Metadata: cloneRawMessage(req.Metadata), + } + if err := spec.Validate("create_child_task"); err != nil { + return taskpkg.CreateTask{}, err + } + return spec, nil +} + +func taskPatchFromRequest(req contract.UpdateTaskRequest) (taskpkg.TaskPatch, error) { + if req.NetworkChannel != nil { + if err := validateTaskChannel("task_patch.network_channel", *req.NetworkChannel); err != nil { + return taskpkg.TaskPatch{}, err + } + } + + patch := taskpkg.TaskPatch{ + Title: trimStringPtr(req.Title), + Description: trimStringPtr(req.Description), + Metadata: cloneRawMessagePtr(req.Metadata), + NetworkChannel: trimStringPtr(req.NetworkChannel), + Owner: cloneOwnership(req.Owner), + ClearOwner: req.ClearOwner, + } + if err := patch.Validate("task_patch"); err != nil { + return taskpkg.TaskPatch{}, err + } + return patch, nil +} + +func cancelTaskFromRequest(req contract.CancelTaskRequest) (taskpkg.CancelTask, error) { + cancelReq := taskpkg.CancelTask{ + Reason: strings.TrimSpace(req.Reason), + Metadata: cloneRawMessage(req.Metadata), + } + if err := cancelReq.Validate("cancel_task"); err != nil { + return taskpkg.CancelTask{}, err + } + return cancelReq, nil +} + +func addTaskDependencyFromRequest(taskID string, req contract.AddTaskDependencyRequest) (taskpkg.AddDependency, error) { + kind := req.Kind.Normalize() + if kind == "" { + kind = taskpkg.DependencyKindBlocks + } + + spec := taskpkg.AddDependency{ + TaskID: strings.TrimSpace(taskID), + DependsOnTaskID: strings.TrimSpace(req.DependsOnTaskID), + Kind: kind, + } + if err := spec.Validate("add_dependency"); err != nil { + return taskpkg.AddDependency{}, err + } + return spec, nil +} + +func enqueueTaskRunFromRequest(taskID string, req contract.EnqueueTaskRunRequest) (taskpkg.EnqueueRun, error) { + if err := validateTaskChannel("enqueue_run.network_channel", req.NetworkChannel); err != nil { + return taskpkg.EnqueueRun{}, err + } + + spec := taskpkg.EnqueueRun{ + TaskID: strings.TrimSpace(taskID), + IdempotencyKey: strings.TrimSpace(req.IdempotencyKey), + NetworkChannel: strings.TrimSpace(req.NetworkChannel), + } + if err := spec.Validate("enqueue_run"); err != nil { + return taskpkg.EnqueueRun{}, err + } + return spec, nil +} + +func claimTaskRunFromRequest(req contract.ClaimTaskRunRequest) (taskpkg.ClaimRun, error) { + claim := taskpkg.ClaimRun{IdempotencyKey: strings.TrimSpace(req.IdempotencyKey)} + if err := claim.Validate("claim_run"); err != nil { + return taskpkg.ClaimRun{}, err + } + return claim, nil +} + +func startTaskRunFromRequest(req contract.StartTaskRunRequest) (taskpkg.StartRun, error) { + startReq := taskpkg.StartRun{IdempotencyKey: strings.TrimSpace(req.IdempotencyKey)} + if err := startReq.Validate("start_run"); err != nil { + return taskpkg.StartRun{}, err + } + return startReq, nil +} + +func attachTaskRunSessionIDFromRequest(req contract.AttachTaskRunSessionRequest) (string, error) { + sessionID := strings.TrimSpace(req.SessionID) + if sessionID == "" { + return "", NewTaskValidationError(errors.New("session_id is required")) + } + return sessionID, nil +} + +func completeTaskRunFromRequest(req contract.CompleteTaskRunRequest) (taskpkg.RunResult, error) { + result := taskpkg.RunResult{Value: cloneRawMessage(req.Result)} + if err := result.Validate("run_result"); err != nil { + return taskpkg.RunResult{}, err + } + return result, nil +} + +func failTaskRunFromRequest(req contract.FailTaskRunRequest) (taskpkg.RunFailure, error) { + failure := taskpkg.RunFailure{ + Error: strings.TrimSpace(req.Error), + Metadata: cloneRawMessage(req.Metadata), + } + if err := failure.Validate("run_failure"); err != nil { + return taskpkg.RunFailure{}, err + } + return failure, nil +} + +func cancelTaskRunFromRequest(req contract.CancelTaskRunRequest) (taskpkg.CancelRun, error) { + cancelReq := taskpkg.CancelRun{ + Reason: strings.TrimSpace(req.Reason), + Metadata: cloneRawMessage(req.Metadata), + } + if err := cancelReq.Validate("cancel_run"); err != nil { + return taskpkg.CancelRun{}, err + } + return cancelReq, nil +} + +func (h *BaseHandlers) resolveTaskWorkspaceBinding(ctx context.Context, scope taskpkg.Scope, workspaceRef string, path string) (string, error) { + trimmed := strings.TrimSpace(workspaceRef) + if err := taskpkg.ValidateScopeBinding(scope, trimmed, path, "workspace"); err != nil { + return "", err + } + if scope.Normalize() != taskpkg.ScopeWorkspace { + return "", nil + } + return h.lookupWorkspaceID(ctx, trimmed) +} + +func validateTaskChannel(path string, channel string) error { + trimmed := strings.TrimSpace(channel) + if trimmed == "" { + return nil + } + if err := network.ValidateChannel(trimmed); err != nil { + return NewTaskValidationError(fmt.Errorf("%s: %w", path, err)) + } + return nil +} + +func requiredPathID(raw string, field string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", NewTaskValidationError(fmt.Errorf("%s is required", field)) + } + return trimmed, nil +} + +func decodeOptionalJSON(c *gin.Context, dest any) error { + if err := c.ShouldBindJSON(dest); err != nil && !errors.Is(err, io.EOF) { + return err + } + return nil +} + +// TaskSummaryPayloadsFromSummaries converts task summaries into shared payloads. +func TaskSummaryPayloadsFromSummaries(tasks []taskpkg.TaskSummary) []contract.TaskSummaryPayload { + payloads := make([]contract.TaskSummaryPayload, 0, len(tasks)) + for _, record := range tasks { + payloads = append(payloads, TaskSummaryPayloadFromSummary(record)) + } + return payloads +} + +// TaskSummaryPayloadFromSummary converts one task summary into the shared payload. +func TaskSummaryPayloadFromSummary(record taskpkg.TaskSummary) contract.TaskSummaryPayload { + return contract.TaskSummaryPayload{ + ID: record.ID, + Identifier: record.Identifier, + Scope: record.Scope, + WorkspaceID: record.WorkspaceID, + ParentTaskID: record.ParentTaskID, + NetworkChannel: record.NetworkChannel, + Title: record.Title, + Status: record.Status, + Owner: cloneOwnership(record.Owner), + CreatedBy: record.CreatedBy, + Origin: record.Origin, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + ClosedAt: optionalTime(record.ClosedAt), + } +} + +// TaskPayloadFromTask converts one task record into the shared payload. +func TaskPayloadFromTask(record *taskpkg.Task) contract.TaskPayload { + if record == nil { + return contract.TaskPayload{} + } + + return contract.TaskPayload{ + ID: record.ID, + Identifier: record.Identifier, + Scope: record.Scope, + WorkspaceID: record.WorkspaceID, + ParentTaskID: record.ParentTaskID, + NetworkChannel: record.NetworkChannel, + Title: record.Title, + Description: record.Description, + Status: record.Status, + Owner: cloneOwnership(record.Owner), + CreatedBy: record.CreatedBy, + Origin: record.Origin, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + ClosedAt: optionalTime(record.ClosedAt), + Metadata: cloneRawMessage(record.Metadata), + } +} + +// TaskDependencyPayloadsFromDependencies converts dependency records into shared payloads. +func TaskDependencyPayloadsFromDependencies(dependencies []taskpkg.TaskDependency) []contract.TaskDependencyPayload { + payloads := make([]contract.TaskDependencyPayload, 0, len(dependencies)) + for _, dependency := range dependencies { + payloads = append(payloads, contract.TaskDependencyPayload{ + TaskID: dependency.TaskID, + DependsOnTaskID: dependency.DependsOnTaskID, + Kind: dependency.Kind, + CreatedAt: dependency.CreatedAt, + }) + } + return payloads +} + +// TaskRunPayloadsFromRuns converts task runs into shared payloads. +func TaskRunPayloadsFromRuns(runs []taskpkg.TaskRun) []contract.TaskRunPayload { + payloads := make([]contract.TaskRunPayload, 0, len(runs)) + for _, run := range runs { + payloads = append(payloads, TaskRunPayloadFromRun(&run)) + } + return payloads +} + +// TaskRunPayloadFromRun converts one task run into the shared payload. +func TaskRunPayloadFromRun(run *taskpkg.TaskRun) contract.TaskRunPayload { + if run == nil { + return contract.TaskRunPayload{} + } + + return contract.TaskRunPayload{ + ID: run.ID, + TaskID: run.TaskID, + Status: run.Status, + Attempt: run.Attempt, + ClaimedBy: cloneActorIdentity(run.ClaimedBy), + SessionID: run.SessionID, + Origin: run.Origin, + IdempotencyKey: run.IdempotencyKey, + NetworkChannel: run.NetworkChannel, + QueuedAt: run.QueuedAt, + ClaimedAt: optionalTime(run.ClaimedAt), + StartedAt: optionalTime(run.StartedAt), + EndedAt: optionalTime(run.EndedAt), + Error: run.Error, + Result: cloneRawMessage(run.Result), + } +} + +// TaskEventPayloadsFromEvents converts task events into shared payloads. +func TaskEventPayloadsFromEvents(events []taskpkg.TaskEvent) []contract.TaskEventPayload { + payloads := make([]contract.TaskEventPayload, 0, len(events)) + for _, event := range events { + payloads = append(payloads, contract.TaskEventPayload{ + ID: event.ID, + TaskID: event.TaskID, + RunID: event.RunID, + EventType: event.EventType, + Actor: event.Actor, + Origin: event.Origin, + Payload: cloneRawMessage(event.Payload), + Timestamp: event.Timestamp, + }) + } + return payloads +} + +// TaskDetailPayloadFromView converts one expanded task view into the shared payload. +func TaskDetailPayloadFromView(view *taskpkg.TaskView) contract.TaskDetailPayload { + if view == nil { + return contract.TaskDetailPayload{} + } + + return contract.TaskDetailPayload{ + Task: TaskPayloadFromTask(&view.Task), + Children: TaskSummaryPayloadsFromSummaries(view.Children), + Dependencies: TaskDependencyPayloadsFromDependencies(view.Dependencies), + Runs: TaskRunPayloadsFromRuns(view.Runs), + Events: TaskEventPayloadsFromEvents(view.Events), + } +} + +func cloneOwnership(source *taskpkg.Ownership) *taskpkg.Ownership { + if source == nil { + return nil + } + return &taskpkg.Ownership{ + Kind: source.Kind.Normalize(), + Ref: strings.TrimSpace(source.Ref), + } +} + +func cloneActorIdentity(source *taskpkg.ActorIdentity) *taskpkg.ActorIdentity { + if source == nil { + return nil + } + return &taskpkg.ActorIdentity{ + Kind: source.Kind.Normalize(), + Ref: strings.TrimSpace(source.Ref), + } +} + +func trimStringPtr(source *string) *string { + if source == nil { + return nil + } + trimmed := strings.TrimSpace(*source) + return &trimmed +} + +func optionalTime(value time.Time) *time.Time { + if value.IsZero() { + return nil + } + cloned := value + return &cloned +} + +func cloneRawMessagePtr(source *json.RawMessage) *json.RawMessage { + if source == nil { + return nil + } + copyValue := cloneRawMessage(*source) + return ©Value +} diff --git a/internal/api/core/tasks_integration_test.go b/internal/api/core/tasks_integration_test.go new file mode 100644 index 000000000..f1e49c566 --- /dev/null +++ b/internal/api/core/tasks_integration_test.go @@ -0,0 +1,198 @@ +//go:build integration + +package core_test + +import ( + "context" + "reflect" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/pedronauck/agh/internal/api/testutil" + taskpkg "github.com/pedronauck/agh/internal/task" + workspacepkg "github.com/pedronauck/agh/internal/workspace" +) + +func TestTaskHandlersCreateTaskAndListFiltersReachManagerIntegration(t *testing.T) { + t.Parallel() + + var capturedCreate taskpkg.CreateTask + var capturedCreateActor taskpkg.ActorContext + var capturedList taskpkg.TaskQuery + var capturedListActor taskpkg.ActorContext + + tasks := testutil.StubTaskManager{ + CreateTaskFn: func(_ context.Context, spec taskpkg.CreateTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) { + capturedCreate = spec + capturedCreateActor = actor + return &taskpkg.Task{ + ID: "task-1", + Identifier: spec.Identifier, + Scope: spec.Scope, + WorkspaceID: spec.WorkspaceID, + NetworkChannel: spec.NetworkChannel, + Title: spec.Title, + Description: spec.Description, + Status: taskpkg.TaskStatusPending, + Owner: spec.Owner, + CreatedBy: actor.Actor, + Origin: actor.Origin, + CreatedAt: time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC), + Metadata: spec.Metadata, + }, nil + }, + ListTasksFn: func(_ context.Context, query taskpkg.TaskQuery, actor taskpkg.ActorContext) ([]taskpkg.TaskSummary, error) { + capturedList = query + capturedListActor = actor + return []taskpkg.TaskSummary{{ + ID: "task-1", + Scope: query.Scope, + Title: "Review task API", + Status: query.Status, + CreatedBy: actor.Actor, + Origin: actor.Origin, + CreatedAt: time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC), + }}, nil + }, + } + workspaces := testutil.StubWorkspaceService{ + GetFn: func(_ context.Context, ref string) (workspacepkg.Workspace, error) { + if ref != "alpha" { + t.Fatalf("workspace ref = %q, want %q", ref, "alpha") + } + return workspacepkg.Workspace{ID: "ws-alpha", Name: "alpha"}, nil + }, + } + + fixture := newHandlerFixtureWithTasks(t, testutil.StubSessionManager{}, testutil.StubObserver{}, tasks, workspaces, nil, nil) + fixture.Handlers.TaskActorContextResolver = func(_ *gin.Context, action string) (taskpkg.ActorContext, error) { + return taskpkg.DeriveHumanActorContext("user-1", taskpkg.OriginKindHTTP, "tasks."+action) + } + + createResp := performRequest(t, fixture.Engine, "POST", "/tasks", []byte(`{"scope":"workspace","workspace":"alpha","identifier":"TASK-1","network_channel":"builders","title":"Review task API","description":"Check handler wiring","owner":{"kind":"pool","ref":"reviewers"},"metadata":{"priority":"high"}}`)) + if createResp.Code != 201 { + t.Fatalf("create status = %d, want %d; body=%s", createResp.Code, 201, createResp.Body.String()) + } + + if capturedCreate.Scope != taskpkg.ScopeWorkspace || capturedCreate.WorkspaceID != "ws-alpha" { + t.Fatalf("create spec = %#v", capturedCreate) + } + if capturedCreate.NetworkChannel != "builders" || capturedCreate.Owner == nil || capturedCreate.Owner.Ref != "reviewers" { + t.Fatalf("create spec = %#v", capturedCreate) + } + if capturedCreateActor.Actor.Ref != "user-1" || capturedCreateActor.Origin.Ref != "tasks.create" { + t.Fatalf("create actor = %#v", capturedCreateActor) + } + + listResp := performRequest(t, fixture.Engine, "GET", "/tasks?scope=workspace&workspace=alpha&status=ready&owner_kind=pool&owner_ref=reviewers&parent_task_id=task-root&network_channel=builders&limit=5", nil) + if listResp.Code != 200 { + t.Fatalf("list status = %d, want %d; body=%s", listResp.Code, 200, listResp.Body.String()) + } + + if capturedList.Scope != taskpkg.ScopeWorkspace || capturedList.WorkspaceID != "ws-alpha" { + t.Fatalf("list query = %#v", capturedList) + } + if capturedList.Status != taskpkg.TaskStatusReady || capturedList.OwnerKind != taskpkg.OwnerKindPool || capturedList.OwnerRef != "reviewers" { + t.Fatalf("list query = %#v", capturedList) + } + if capturedList.ParentTaskID != "task-root" || capturedList.NetworkChannel != "builders" || capturedList.Limit != 5 { + t.Fatalf("list query = %#v", capturedList) + } + if capturedListActor.Actor.Ref != "user-1" || capturedListActor.Origin.Ref != "tasks.list" { + t.Fatalf("list actor = %#v", capturedListActor) + } +} + +func TestTaskRunHandlersDelegateLifecycleSequenceIntegration(t *testing.T) { + t.Parallel() + + calls := make([]string, 0, 4) + now := time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC) + + tasks := testutil.StubTaskManager{ + EnqueueRunFn: func(_ context.Context, spec taskpkg.EnqueueRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + calls = append(calls, "enqueue") + return &taskpkg.TaskRun{ + ID: "run-1", + TaskID: spec.TaskID, + Status: taskpkg.TaskRunStatusQueued, + Attempt: 1, + Origin: actor.Origin, + IdempotencyKey: spec.IdempotencyKey, + NetworkChannel: spec.NetworkChannel, + QueuedAt: now, + }, nil + }, + ClaimRunFn: func(_ context.Context, runID string, claim taskpkg.ClaimRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + calls = append(calls, "claim") + return &taskpkg.TaskRun{ + ID: runID, + TaskID: "task-1", + Status: taskpkg.TaskRunStatusClaimed, + Attempt: 1, + ClaimedBy: &actor.Actor, + Origin: actor.Origin, + QueuedAt: now, + ClaimedAt: now.Add(time.Minute), + }, nil + }, + StartRunFn: func(_ context.Context, runID string, _ taskpkg.StartRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + calls = append(calls, "start") + return &taskpkg.TaskRun{ + ID: runID, + TaskID: "task-1", + Status: taskpkg.TaskRunStatusRunning, + Attempt: 1, + SessionID: "sess-1", + Origin: actor.Origin, + QueuedAt: now, + StartedAt: now.Add(2 * time.Minute), + }, nil + }, + CompleteRunFn: func(_ context.Context, runID string, result taskpkg.RunResult, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + calls = append(calls, "complete") + return &taskpkg.TaskRun{ + ID: runID, + TaskID: "task-1", + Status: taskpkg.TaskRunStatusCompleted, + Attempt: 1, + Origin: actor.Origin, + QueuedAt: now, + EndedAt: now.Add(3 * time.Minute), + Result: result.Value, + }, nil + }, + } + + fixture := newHandlerFixtureWithTasks(t, testutil.StubSessionManager{}, testutil.StubObserver{}, tasks, testutil.StubWorkspaceService{}, nil, nil) + fixture.Handlers.TaskActorContextResolver = func(_ *gin.Context, action string) (taskpkg.ActorContext, error) { + return taskpkg.DeriveHumanActorContext("user-1", taskpkg.OriginKindHTTP, "tasks."+action) + } + + resp := performRequest(t, fixture.Engine, "POST", "/tasks/task-1/runs", []byte(`{"idempotency_key":"key-1","network_channel":"builders"}`)) + if resp.Code != 201 { + t.Fatalf("enqueue status = %d, want %d; body=%s", resp.Code, 201, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, "POST", "/task-runs/run-1/claim", []byte(`{}`)) + if resp.Code != 200 { + t.Fatalf("claim status = %d, want %d; body=%s", resp.Code, 200, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, "POST", "/task-runs/run-1/start", []byte(`{}`)) + if resp.Code != 200 { + t.Fatalf("start status = %d, want %d; body=%s", resp.Code, 200, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, "POST", "/task-runs/run-1/complete", []byte(`{"result":{"ok":true}}`)) + if resp.Code != 200 { + t.Fatalf("complete status = %d, want %d; body=%s", resp.Code, 200, resp.Body.String()) + } + + if want := []string{"enqueue", "claim", "start", "complete"}; !reflect.DeepEqual(calls, want) { + t.Fatalf("call order = %#v, want %#v", calls, want) + } +} diff --git a/internal/api/core/tasks_internal_test.go b/internal/api/core/tasks_internal_test.go new file mode 100644 index 000000000..9c286f6d1 --- /dev/null +++ b/internal/api/core/tasks_internal_test.go @@ -0,0 +1,265 @@ +package core + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/pedronauck/agh/internal/api/contract" + taskpkg "github.com/pedronauck/agh/internal/task" + workspacepkg "github.com/pedronauck/agh/internal/workspace" +) + +type workspaceGetterFunc func(context.Context, string) (workspacepkg.Workspace, error) + +func (f workspaceGetterFunc) Get(ctx context.Context, ref string) (workspacepkg.Workspace, error) { + return f(ctx, ref) +} + +type workspaceServiceStub struct { + get workspaceGetterFunc +} + +func (s workspaceServiceStub) Register(context.Context, workspacepkg.RegisterOptions) (workspacepkg.Workspace, error) { + return workspacepkg.Workspace{}, workspacepkg.ErrWorkspaceNotFound +} + +func (s workspaceServiceStub) Unregister(context.Context, string) error { + return workspacepkg.ErrWorkspaceNotFound +} + +func (s workspaceServiceStub) Update(context.Context, string, workspacepkg.UpdateOptions) error { + return workspacepkg.ErrWorkspaceNotFound +} + +func (s workspaceServiceStub) List(context.Context) ([]workspacepkg.Workspace, error) { + return nil, nil +} + +func (s workspaceServiceStub) Get(ctx context.Context, ref string) (workspacepkg.Workspace, error) { + return s.get(ctx, ref) +} + +func (s workspaceServiceStub) Resolve(context.Context, string) (workspacepkg.ResolvedWorkspace, error) { + return workspacepkg.ResolvedWorkspace{}, workspacepkg.ErrWorkspaceNotFound +} + +func (s workspaceServiceStub) ResolveOrRegister(context.Context, string) (workspacepkg.ResolvedWorkspace, error) { + return workspacepkg.ResolvedWorkspace{}, workspacepkg.ErrWorkspaceNotFound +} + +func assertTaskValidationError(t *testing.T, err error, wantSubstring string) { + t.Helper() + if !errors.Is(err, taskpkg.ErrValidation) { + t.Fatalf("error = %v, want task validation error", err) + } + if wantSubstring != "" && !strings.Contains(err.Error(), wantSubstring) { + t.Fatalf("error = %q, want substring %q", err.Error(), wantSubstring) + } +} + +func TestTaskActorContextAndTransportHelpers(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + + testCases := []struct { + name string + transport string + wantKind taskpkg.OriginKind + }{ + {name: "uds", transport: "uds-api", wantKind: taskpkg.OriginKindUDS}, + {name: "web", transport: "web-ui", wantKind: taskpkg.OriginKindWeb}, + {name: "cli", transport: "agh-cli", wantKind: taskpkg.OriginKindCLI}, + {name: "default", transport: "api-core-test", wantKind: taskpkg.OriginKindHTTP}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + handlers := &BaseHandlers{TransportName: tc.transport} + + actor, err := handlers.taskActorContext(ctx, taskActionGet) + if err != nil { + t.Fatalf("taskActorContext() error = %v", err) + } + if actor.Actor.Ref != defaultTaskActorRef || actor.Origin.Kind != tc.wantKind || actor.Origin.Ref != "tasks.get" { + t.Fatalf("taskActorContext() = %#v", actor) + } + }) + } + + t.Run("custom resolver", func(t *testing.T) { + t.Parallel() + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + handlers := &BaseHandlers{ + TaskActorContextResolver: func(_ *gin.Context, action string) (taskpkg.ActorContext, error) { + return taskpkg.DeriveHumanActorContext("user-2", taskpkg.OriginKindHTTP, "custom."+action) + }, + } + + actor, err := handlers.taskActorContext(ctx, taskActionList) + if err != nil { + t.Fatalf("taskActorContext(custom) error = %v", err) + } + if actor.Actor.Ref != "user-2" || actor.Origin.Ref != "custom.list" { + t.Fatalf("taskActorContext(custom) = %#v", actor) + } + }) +} + +func TestTaskParsingAndValidationHelpers(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + handlers := &BaseHandlers{ + TransportName: "api-core-test", + Workspaces: workspaceServiceStub{get: func(_ context.Context, ref string) (workspacepkg.Workspace, error) { + if ref != "alpha" { + t.Fatalf("workspace ref = %q, want %q", ref, "alpha") + } + return workspacepkg.Workspace{ID: "ws-alpha"}, nil + }}, + } + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("GET", "/tasks?scope=workspace&workspace=alpha&status=ready&owner_kind=pool&owner_ref=reviewers&parent_task_id=task-root&network_channel=builders&limit=3", nil) + + query, err := handlers.parseTaskListQuery(context.Background(), ctx) + if err != nil { + t.Fatalf("parseTaskListQuery() error = %v", err) + } + if query.WorkspaceID != "ws-alpha" || query.Status != taskpkg.TaskStatusReady || query.OwnerKind != taskpkg.OwnerKindPool { + t.Fatalf("parseTaskListQuery() = %#v", query) + } + + runRecorder := httptest.NewRecorder() + runCtx, _ := gin.CreateTestContext(runRecorder) + runCtx.Request = httptest.NewRequest("GET", "/tasks/task-1/runs?status=running&session_id=sess-1&limit=1", nil) + + runQuery, err := parseTaskRunListQuery(runCtx) + if err != nil { + t.Fatalf("parseTaskRunListQuery() error = %v", err) + } + if runQuery.Status != taskpkg.TaskRunStatusRunning || runQuery.SessionID != "sess-1" || runQuery.Limit != 1 { + t.Fatalf("parseTaskRunListQuery() = %#v", runQuery) + } + + if _, err := addTaskDependencyFromRequest("task-1", contract.AddTaskDependencyRequest{DependsOnTaskID: "task-2"}); err != nil { + t.Fatalf("addTaskDependencyFromRequest() error = %v", err) + } + if _, err := claimTaskRunFromRequest(contract.ClaimTaskRunRequest{IdempotencyKey: "claim-1"}); err != nil { + t.Fatalf("claimTaskRunFromRequest() error = %v", err) + } + if _, err := startTaskRunFromRequest(contract.StartTaskRunRequest{IdempotencyKey: "start-1"}); err != nil { + t.Fatalf("startTaskRunFromRequest() error = %v", err) + } + if _, err := completeTaskRunFromRequest(contract.CompleteTaskRunRequest{Result: json.RawMessage(`{"ok":true}`)}); err != nil { + t.Fatalf("completeTaskRunFromRequest() error = %v", err) + } + if _, err := cancelTaskRunFromRequest(contract.CancelTaskRunRequest{Reason: "stop", Metadata: json.RawMessage(`{"source":"test"}`)}); err != nil { + t.Fatalf("cancelTaskRunFromRequest() error = %v", err) + } + if _, err := cancelTaskFromRequest(contract.CancelTaskRequest{Reason: "stop", Metadata: json.RawMessage(`{"source":"test"}`)}); err != nil { + t.Fatalf("cancelTaskFromRequest() error = %v", err) + } + + if _, err := attachTaskRunSessionIDFromRequest(contract.AttachTaskRunSessionRequest{}); err == nil { + t.Fatal("attachTaskRunSessionIDFromRequest() error = nil, want non-nil") + } else { + assertTaskValidationError(t, err, "session_id is required") + } + if _, err := failTaskRunFromRequest(contract.FailTaskRunRequest{}); err == nil { + t.Fatal("failTaskRunFromRequest() error = nil, want non-nil") + } else { + assertTaskValidationError(t, err, "run_failure.error is required") + } + if err := validateTaskChannel("task.network_channel", "bad.channel"); err == nil { + t.Fatal("validateTaskChannel(invalid) error = nil, want non-nil") + } else { + assertTaskValidationError(t, err, `task.network_channel: network: invalid field: channel="bad.channel"`) + } + if _, err := enqueueTaskRunFromRequest("task-1", contract.EnqueueTaskRunRequest{NetworkChannel: "bad.channel"}); err == nil { + t.Fatal("enqueueTaskRunFromRequest(invalid) error = nil, want non-nil") + } else { + assertTaskValidationError(t, err, `enqueue_run.network_channel: network: invalid field: channel="bad.channel"`) + } + if _, err := requiredPathID("", "task id"); err == nil { + t.Fatal("requiredPathID(empty) error = nil, want non-nil") + } else { + assertTaskValidationError(t, err, "task id is required") + } + + invalidRecorder := httptest.NewRecorder() + invalidCtx, _ := gin.CreateTestContext(invalidRecorder) + invalidCtx.Request = httptest.NewRequest("GET", "/tasks?limit=bad", nil) + if _, err := handlers.parseTaskListQuery(context.Background(), invalidCtx); err == nil { + t.Fatal("parseTaskListQuery(invalid limit) error = nil, want non-nil") + } else { + assertTaskValidationError(t, err, `invalid integer "bad"`) + } + + invalidRunRecorder := httptest.NewRecorder() + invalidRunCtx, _ := gin.CreateTestContext(invalidRunRecorder) + invalidRunCtx.Request = httptest.NewRequest("GET", "/tasks/task-1/runs?limit=bad", nil) + if _, err := parseTaskRunListQuery(invalidRunCtx); err == nil { + t.Fatal("parseTaskRunListQuery(invalid limit) error = nil, want non-nil") + } else { + assertTaskValidationError(t, err, `invalid integer "bad"`) + } + + decodeRecorder := httptest.NewRecorder() + decodeCtx, _ := gin.CreateTestContext(decodeRecorder) + decodeCtx.Request = httptest.NewRequest("POST", "/tasks", bytes.NewBufferString(`{"broken":`)) + decodeCtx.Request.Header.Set("Content-Type", "application/json") + var payload contract.CancelTaskRequest + if err := decodeOptionalJSON(decodeCtx, &payload); err == nil { + t.Fatal("decodeOptionalJSON(invalid) error = nil, want non-nil") + } else if !strings.Contains(err.Error(), "unexpected EOF") { + t.Fatalf("decodeOptionalJSON(invalid) error = %q, want unexpected EOF", err.Error()) + } +} + +func TestTaskHandlerInfrastructureHelpers(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + handlers := &BaseHandlers{TransportName: "api-core-test"} + + service, ok := handlers.requireTaskManager(ctx) + if ok || service != nil { + t.Fatalf("requireTaskManager() = (%v, %v), want (nil, false)", service, ok) + } + + if !reflect.DeepEqual(TaskPayloadFromTask(nil), contract.TaskPayload{}) { + t.Fatal("TaskPayloadFromTask(nil) should return zero payload") + } + if !reflect.DeepEqual(TaskRunPayloadFromRun(nil), contract.TaskRunPayload{}) { + t.Fatal("TaskRunPayloadFromRun(nil) should return zero payload") + } + if !reflect.DeepEqual(TaskDetailPayloadFromView(nil), contract.TaskDetailPayload{}) { + t.Fatal("TaskDetailPayloadFromView(nil) should return zero payload") + } + + idOnly := json.RawMessage(`{"ok":true}`) + ptr := cloneRawMessagePtr(&idOnly) + if ptr == nil || string(*ptr) != `{"ok":true}` { + t.Fatalf("cloneRawMessagePtr() = %v", ptr) + } +} diff --git a/internal/api/core/tasks_test.go b/internal/api/core/tasks_test.go new file mode 100644 index 000000000..ac9f9b7aa --- /dev/null +++ b/internal/api/core/tasks_test.go @@ -0,0 +1,884 @@ +package core_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/pedronauck/agh/internal/api/core" + "github.com/pedronauck/agh/internal/api/testutil" + taskpkg "github.com/pedronauck/agh/internal/task" + workspacepkg "github.com/pedronauck/agh/internal/workspace" +) + +func TestTaskPayloadBuildersPreserveIdentityOwnershipAndRunBindings(t *testing.T) { + t.Parallel() + + taskMetadata := json.RawMessage(`{"priority":"high"}`) + runResult := json.RawMessage(`{"ok":true}`) + eventPayload := json.RawMessage(`{"action":"claim"}`) + + view := &taskpkg.TaskView{ + Task: taskpkg.Task{ + ID: "task-1", + Identifier: "TASK-1", + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: "ws-alpha", + ParentTaskID: "task-root", + NetworkChannel: "builders", + Title: "Review task API", + Description: "Check handler wiring", + Status: taskpkg.TaskStatusInProgress, + Owner: &taskpkg.Ownership{Kind: taskpkg.OwnerKindPool, Ref: "reviewers"}, + CreatedBy: taskpkg.ActorIdentity{Kind: taskpkg.ActorKindHuman, Ref: "local-user"}, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindHTTP, Ref: "tasks.create"}, + CreatedAt: time.Date(2026, 4, 14, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 14, 10, 1, 0, 0, time.UTC), + Metadata: taskMetadata, + }, + Children: []taskpkg.TaskSummary{{ + ID: "task-child", + Title: "Follow up", + Scope: taskpkg.ScopeWorkspace, + Status: taskpkg.TaskStatusReady, + CreatedBy: taskpkg.ActorIdentity{Kind: taskpkg.ActorKindHuman, Ref: "local-user"}, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindHTTP, Ref: "tasks.create_child"}, + CreatedAt: time.Date(2026, 4, 14, 10, 2, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 14, 10, 2, 0, 0, time.UTC), + }}, + Dependencies: []taskpkg.TaskDependency{{ + TaskID: "task-1", + DependsOnTaskID: "task-blocker", + Kind: taskpkg.DependencyKindBlocks, + CreatedAt: time.Date(2026, 4, 14, 10, 3, 0, 0, time.UTC), + }}, + Runs: []taskpkg.TaskRun{{ + ID: "run-1", + TaskID: "task-1", + Status: taskpkg.TaskRunStatusRunning, + Attempt: 2, + ClaimedBy: &taskpkg.ActorIdentity{Kind: taskpkg.ActorKindHuman, Ref: "local-user"}, + SessionID: "sess-1", + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindHTTP, Ref: "tasks.start_run"}, + IdempotencyKey: "key-1", + NetworkChannel: "builders", + QueuedAt: time.Date(2026, 4, 14, 10, 0, 0, 0, time.UTC), + StartedAt: time.Date(2026, 4, 14, 10, 4, 0, 0, time.UTC), + Result: runResult, + }}, + Events: []taskpkg.TaskEvent{{ + ID: "evt-1", + TaskID: "task-1", + RunID: "run-1", + EventType: "task.run.started", + Actor: taskpkg.ActorIdentity{Kind: taskpkg.ActorKindHuman, Ref: "local-user"}, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindHTTP, Ref: "tasks.start_run"}, + Payload: eventPayload, + Timestamp: time.Date(2026, 4, 14, 10, 4, 0, 0, time.UTC), + }}, + } + + payload := core.TaskDetailPayloadFromView(view) + if payload.Task.CreatedBy.Ref != "local-user" || payload.Task.Origin.Ref != "tasks.create" { + t.Fatalf("task payload identity = %#v", payload.Task) + } + if payload.Task.Owner == nil || payload.Task.Owner.Ref != "reviewers" { + t.Fatalf("task payload owner = %#v", payload.Task.Owner) + } + if got := payload.Runs[0].SessionID; got != "sess-1" { + t.Fatalf("run payload session_id = %q, want %q", got, "sess-1") + } + if got := payload.Runs[0].ClaimedBy; got == nil || got.Ref != "local-user" { + t.Fatalf("run payload claimed_by = %#v", got) + } + if string(payload.Task.Metadata) != `{"priority":"high"}` { + t.Fatalf("task payload metadata = %s", string(payload.Task.Metadata)) + } + if string(payload.Runs[0].Result) != `{"ok":true}` { + t.Fatalf("run payload result = %s", string(payload.Runs[0].Result)) + } + + taskMetadata[2] = 'X' + runResult[2] = 'Y' + eventPayload[2] = 'Z' + if string(payload.Task.Metadata) != `{"priority":"high"}` { + t.Fatalf("task payload metadata mutated = %s", string(payload.Task.Metadata)) + } + if string(payload.Runs[0].Result) != `{"ok":true}` { + t.Fatalf("run payload result mutated = %s", string(payload.Runs[0].Result)) + } + if string(payload.Events[0].Payload) != `{"action":"claim"}` { + t.Fatalf("event payload mutated = %s", string(payload.Events[0].Payload)) + } +} + +func TestStatusForTaskError(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + err error + want int + }{ + {name: "validation", err: core.NewTaskValidationError(context.Canceled), want: http.StatusBadRequest}, + {name: "payload too large", err: taskpkg.ErrPayloadTooLarge, want: http.StatusRequestEntityTooLarge}, + {name: "permission denied", err: taskpkg.ErrPermissionDenied, want: http.StatusForbidden}, + {name: "task not found", err: taskpkg.ErrTaskNotFound, want: http.StatusNotFound}, + {name: "workspace missing", err: workspacepkg.ErrWorkspaceNotFound, want: http.StatusNotFound}, + {name: "invalid transition", err: taskpkg.ErrInvalidStatusTransition, want: http.StatusConflict}, + {name: "stale network channel", err: taskpkg.ErrStaleNetworkChannel, want: http.StatusConflict}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := core.StatusForTaskError(tc.err); got != tc.want { + t.Fatalf("StatusForTaskError(%v) = %d, want %d", tc.err, got, tc.want) + } + }) + } +} + +func TestBaseHandlersTaskValidationAndErrorMapping(t *testing.T) { + t.Parallel() + + t.Run("ShouldRejectInvalidScopeWorkspaceAndChannelInputs", func(t *testing.T) { + t.Parallel() + + tasks := testutil.StubTaskManager{ + CreateTaskFn: func(context.Context, taskpkg.CreateTask, taskpkg.ActorContext) (*taskpkg.Task, error) { + t.Fatal("CreateTask should not be called for invalid task input") + return nil, nil + }, + } + fixture := newHandlerFixtureWithTasks(t, testutil.StubSessionManager{}, testutil.StubObserver{}, tasks, testutil.StubWorkspaceService{}, nil, nil) + + resp := performRequest(t, fixture.Engine, http.MethodPost, "/tasks", []byte(`{"scope":"workspace","title":"Broken"}`)) + if resp.Code != http.StatusBadRequest { + t.Fatalf("workspace create status = %d, want %d; body=%s", resp.Code, http.StatusBadRequest, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPost, "/tasks", []byte(`{"scope":"global","title":"Broken","network_channel":"bad.channel"}`)) + if resp.Code != http.StatusBadRequest { + t.Fatalf("channel create status = %d, want %d; body=%s", resp.Code, http.StatusBadRequest, resp.Body.String()) + } + }) + + t.Run("ShouldRejectUnknownWorkspaceAndInvalidOwnerInput", func(t *testing.T) { + t.Parallel() + + tasks := testutil.StubTaskManager{ + CreateTaskFn: func(context.Context, taskpkg.CreateTask, taskpkg.ActorContext) (*taskpkg.Task, error) { + t.Fatal("CreateTask should not be called when workspace lookup fails") + return nil, nil + }, + UpdateTaskFn: func(context.Context, string, taskpkg.TaskPatch, taskpkg.ActorContext) (*taskpkg.Task, error) { + t.Fatal("UpdateTask should not be called for invalid owner input") + return nil, nil + }, + } + workspaces := testutil.StubWorkspaceService{ + GetFn: func(context.Context, string) (workspacepkg.Workspace, error) { + return workspacepkg.Workspace{}, workspacepkg.ErrWorkspaceNotFound + }, + } + fixture := newHandlerFixtureWithTasks(t, testutil.StubSessionManager{}, testutil.StubObserver{}, tasks, workspaces, nil, nil) + + resp := performRequest(t, fixture.Engine, http.MethodPost, "/tasks", []byte(`{"scope":"workspace","workspace":"missing","title":"Broken"}`)) + if resp.Code != http.StatusNotFound { + t.Fatalf("workspace lookup status = %d, want %d; body=%s", resp.Code, http.StatusNotFound, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPatch, "/tasks/task-1", []byte(`{"owner":{"kind":"bogus","ref":"ops"}}`)) + if resp.Code != http.StatusBadRequest { + t.Fatalf("invalid owner status = %d, want %d; body=%s", resp.Code, http.StatusBadRequest, resp.Body.String()) + } + }) + + t.Run("ShouldRejectGlobalWorkspaceBindingsWithoutWorkspaceLookup", func(t *testing.T) { + t.Parallel() + + workspaceLookups := 0 + tasks := testutil.StubTaskManager{ + ListTasksFn: func(context.Context, taskpkg.TaskQuery, taskpkg.ActorContext) ([]taskpkg.TaskSummary, error) { + t.Fatal("ListTasks should not be called when global scope includes workspace filter") + return nil, nil + }, + CreateTaskFn: func(context.Context, taskpkg.CreateTask, taskpkg.ActorContext) (*taskpkg.Task, error) { + t.Fatal("CreateTask should not be called when global scope includes workspace binding") + return nil, nil + }, + CreateChildTaskFn: func(context.Context, string, taskpkg.CreateTask, taskpkg.ActorContext) (*taskpkg.Task, error) { + t.Fatal("CreateChildTask should not be called when global scope includes workspace binding") + return nil, nil + }, + } + workspaces := testutil.StubWorkspaceService{ + GetFn: func(context.Context, string) (workspacepkg.Workspace, error) { + workspaceLookups++ + return workspacepkg.Workspace{}, workspacepkg.ErrWorkspaceNotFound + }, + } + fixture := newHandlerFixtureWithTasks(t, testutil.StubSessionManager{}, testutil.StubObserver{}, tasks, workspaces, nil, nil) + + resp := performRequest(t, fixture.Engine, http.MethodGet, "/tasks?scope=global&workspace=missing", nil) + if resp.Code != http.StatusBadRequest { + t.Fatalf("global list status = %d, want %d; body=%s", resp.Code, http.StatusBadRequest, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPost, "/tasks", []byte(`{"scope":"global","workspace":"missing","title":"Broken"}`)) + if resp.Code != http.StatusBadRequest { + t.Fatalf("global create status = %d, want %d; body=%s", resp.Code, http.StatusBadRequest, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPost, "/tasks/task-root/children", []byte(`{"scope":"global","workspace":"missing","title":"Broken child"}`)) + if resp.Code != http.StatusBadRequest { + t.Fatalf("global child create status = %d, want %d; body=%s", resp.Code, http.StatusBadRequest, resp.Body.String()) + } + + if workspaceLookups != 0 { + t.Fatalf("workspace lookup count = %d, want 0", workspaceLookups) + } + }) + + t.Run("ShouldMapTaskDomainErrorsToStableStatuses", func(t *testing.T) { + t.Parallel() + + tasks := testutil.StubTaskManager{ + GetTaskFn: func(context.Context, string, taskpkg.ActorContext) (*taskpkg.TaskView, error) { + return nil, taskpkg.ErrTaskNotFound + }, + UpdateTaskFn: func(context.Context, string, taskpkg.TaskPatch, taskpkg.ActorContext) (*taskpkg.Task, error) { + return nil, taskpkg.ErrPermissionDenied + }, + StartRunFn: func(context.Context, string, taskpkg.StartRun, taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + return nil, taskpkg.ErrInvalidStatusTransition + }, + } + fixture := newHandlerFixtureWithTasks(t, testutil.StubSessionManager{}, testutil.StubObserver{}, tasks, testutil.StubWorkspaceService{}, nil, nil) + + resp := performRequest(t, fixture.Engine, http.MethodGet, "/tasks/missing", nil) + if resp.Code != http.StatusNotFound { + t.Fatalf("get missing status = %d, want %d; body=%s", resp.Code, http.StatusNotFound, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPatch, "/tasks/task-1", []byte(`{"title":"rename"}`)) + if resp.Code != http.StatusForbidden { + t.Fatalf("update forbidden status = %d, want %d; body=%s", resp.Code, http.StatusForbidden, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPost, "/task-runs/run-1/start", []byte(`{}`)) + if resp.Code != http.StatusConflict { + t.Fatalf("start conflict status = %d, want %d; body=%s", resp.Code, http.StatusConflict, resp.Body.String()) + } + }) +} + +func TestBaseHandlersTaskHappyPathEndpoints(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC) + + var listedQuery taskpkg.TaskQuery + var createdSpec taskpkg.CreateTask + var childSpec taskpkg.CreateTask + var updatedPatch taskpkg.TaskPatch + var cancelledTask taskpkg.CancelTask + var addedDependency taskpkg.AddDependency + var removedTaskID string + var removedDependsOnID string + var enqueuedRun taskpkg.EnqueueRun + var listedRunTaskID string + var listedRunQuery taskpkg.TaskRunQuery + var claimedRun taskpkg.ClaimRun + var startedRun taskpkg.StartRun + var attachedRunID string + var attachedSessionID string + var completedRun taskpkg.RunResult + var failedRun taskpkg.RunFailure + var cancelledRun taskpkg.CancelRun + + taskView := &taskpkg.TaskView{ + Task: taskpkg.Task{ + ID: "task-1", + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: "ws-alpha", + NetworkChannel: "builders", + Title: "Review task API", + Description: "Check handler wiring", + Status: taskpkg.TaskStatusInProgress, + Owner: &taskpkg.Ownership{Kind: taskpkg.OwnerKindPool, Ref: "reviewers"}, + CreatedBy: taskpkg.ActorIdentity{Kind: taskpkg.ActorKindHuman, Ref: "local-user"}, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindHTTP, Ref: "tasks.create"}, + CreatedAt: now, + UpdatedAt: now, + Metadata: json.RawMessage(`{"priority":"high"}`), + }, + Dependencies: []taskpkg.TaskDependency{{ + TaskID: "task-1", + DependsOnTaskID: "task-blocker", + Kind: taskpkg.DependencyKindBlocks, + CreatedAt: now, + }}, + Runs: []taskpkg.TaskRun{ + { + ID: "run-1", + TaskID: "task-1", + Status: taskpkg.TaskRunStatusRunning, + Attempt: 1, + SessionID: "sess-1", + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindHTTP, Ref: "tasks.start_run"}, + QueuedAt: now, + StartedAt: now, + }, + { + ID: "run-2", + TaskID: "task-1", + Status: taskpkg.TaskRunStatusQueued, + Attempt: 2, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindHTTP, Ref: "tasks.enqueue_run"}, + QueuedAt: now, + }, + }, + Events: []taskpkg.TaskEvent{{ + ID: "evt-1", + TaskID: "task-1", + EventType: "task.created", + Actor: taskpkg.ActorIdentity{Kind: taskpkg.ActorKindHuman, Ref: "local-user"}, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindHTTP, Ref: "tasks.create"}, + Timestamp: now, + }}, + } + + getTaskCalls := 0 + tasks := testutil.StubTaskManager{ + ListTasksFn: func(_ context.Context, query taskpkg.TaskQuery, _ taskpkg.ActorContext) ([]taskpkg.TaskSummary, error) { + listedQuery = query + return []taskpkg.TaskSummary{{ + ID: "task-1", + Scope: query.Scope, + WorkspaceID: query.WorkspaceID, + ParentTaskID: query.ParentTaskID, + NetworkChannel: query.NetworkChannel, + Title: "Review task API", + Status: query.Status, + Owner: &taskpkg.Ownership{Kind: query.OwnerKind, Ref: query.OwnerRef}, + CreatedBy: taskpkg.ActorIdentity{Kind: taskpkg.ActorKindHuman, Ref: "local-user"}, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindHTTP, Ref: "tasks.list"}, + CreatedAt: now, + UpdatedAt: now, + }}, nil + }, + CreateTaskFn: func(_ context.Context, spec taskpkg.CreateTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) { + createdSpec = spec + record := taskView.Task + record.CreatedBy = actor.Actor + record.Origin = actor.Origin + record.Title = spec.Title + record.Description = spec.Description + record.Metadata = spec.Metadata + return &record, nil + }, + GetTaskFn: func(_ context.Context, id string, _ taskpkg.ActorContext) (*taskpkg.TaskView, error) { + getTaskCalls++ + if id != "task-1" { + t.Fatalf("GetTask id = %q, want %q", id, "task-1") + } + return taskView, nil + }, + ListTaskRunsFn: func(_ context.Context, taskID string, query taskpkg.TaskRunQuery, _ taskpkg.ActorContext) ([]taskpkg.TaskRun, error) { + listedRunTaskID = taskID + listedRunQuery = query + return []taskpkg.TaskRun{taskView.Runs[0]}, nil + }, + UpdateTaskFn: func(_ context.Context, _ string, patch taskpkg.TaskPatch, _ taskpkg.ActorContext) (*taskpkg.Task, error) { + updatedPatch = patch + record := taskView.Task + if patch.Title != nil { + record.Title = *patch.Title + } + return &record, nil + }, + CancelTaskFn: func(_ context.Context, _ string, req taskpkg.CancelTask, _ taskpkg.ActorContext) (*taskpkg.Task, error) { + cancelledTask = req + record := taskView.Task + record.Status = taskpkg.TaskStatusCancelled + return &record, nil + }, + CreateChildTaskFn: func(_ context.Context, parentTaskID string, spec taskpkg.CreateTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) { + if parentTaskID != "task-root" { + t.Fatalf("CreateChildTask parentTaskID = %q, want %q", parentTaskID, "task-root") + } + childSpec = spec + return &taskpkg.Task{ + ID: "task-child", + Scope: spec.Scope, + WorkspaceID: spec.WorkspaceID, + Title: spec.Title, + Description: spec.Description, + Status: taskpkg.TaskStatusReady, + CreatedBy: actor.Actor, + Origin: actor.Origin, + CreatedAt: now, + UpdatedAt: now, + ParentTaskID: parentTaskID, + }, nil + }, + AddDependencyFn: func(_ context.Context, spec taskpkg.AddDependency, _ taskpkg.ActorContext) error { + addedDependency = spec + return nil + }, + RemoveDependencyFn: func(_ context.Context, taskID string, dependsOnID string, _ taskpkg.ActorContext) error { + removedTaskID = taskID + removedDependsOnID = dependsOnID + return nil + }, + EnqueueRunFn: func(_ context.Context, spec taskpkg.EnqueueRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + enqueuedRun = spec + return &taskpkg.TaskRun{ + ID: "run-3", + TaskID: spec.TaskID, + Status: taskpkg.TaskRunStatusQueued, + Attempt: 3, + Origin: actor.Origin, + IdempotencyKey: spec.IdempotencyKey, + NetworkChannel: spec.NetworkChannel, + QueuedAt: now, + }, nil + }, + ClaimRunFn: func(_ context.Context, _ string, claim taskpkg.ClaimRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + claimedRun = claim + return &taskpkg.TaskRun{ + ID: "run-1", + TaskID: "task-1", + Status: taskpkg.TaskRunStatusClaimed, + Attempt: 1, + ClaimedBy: &actor.Actor, + Origin: actor.Origin, + QueuedAt: now, + ClaimedAt: now, + }, nil + }, + StartRunFn: func(_ context.Context, _ string, req taskpkg.StartRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + startedRun = req + return &taskpkg.TaskRun{ + ID: "run-1", + TaskID: "task-1", + Status: taskpkg.TaskRunStatusRunning, + Attempt: 1, + SessionID: "sess-1", + Origin: actor.Origin, + QueuedAt: now, + StartedAt: now, + }, nil + }, + AttachRunSessionFn: func(_ context.Context, runID string, sessionID string, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + attachedRunID = runID + attachedSessionID = sessionID + return &taskpkg.TaskRun{ + ID: runID, + TaskID: "task-1", + Status: taskpkg.TaskRunStatusStarting, + Attempt: 1, + SessionID: sessionID, + Origin: actor.Origin, + QueuedAt: now, + }, nil + }, + CompleteRunFn: func(_ context.Context, _ string, result taskpkg.RunResult, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + completedRun = result + return &taskpkg.TaskRun{ + ID: "run-1", + TaskID: "task-1", + Status: taskpkg.TaskRunStatusCompleted, + Attempt: 1, + Origin: actor.Origin, + QueuedAt: now, + EndedAt: now, + Result: result.Value, + }, nil + }, + FailRunFn: func(_ context.Context, _ string, failure taskpkg.RunFailure, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + failedRun = failure + return &taskpkg.TaskRun{ + ID: "run-2", + TaskID: "task-1", + Status: taskpkg.TaskRunStatusFailed, + Attempt: 2, + Origin: actor.Origin, + QueuedAt: now, + EndedAt: now, + Error: failure.Error, + }, nil + }, + CancelRunFn: func(_ context.Context, _ string, req taskpkg.CancelRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + cancelledRun = req + return &taskpkg.TaskRun{ + ID: "run-2", + TaskID: "task-1", + Status: taskpkg.TaskRunStatusCancelled, + Attempt: 2, + Origin: actor.Origin, + QueuedAt: now, + EndedAt: now, + }, nil + }, + } + workspaces := testutil.StubWorkspaceService{ + GetFn: func(_ context.Context, ref string) (workspacepkg.Workspace, error) { + if ref != "alpha" { + t.Fatalf("workspace ref = %q, want %q", ref, "alpha") + } + return workspacepkg.Workspace{ID: "ws-alpha", Name: "alpha"}, nil + }, + } + + fixture := newHandlerFixtureWithTasks(t, testutil.StubSessionManager{}, testutil.StubObserver{}, tasks, workspaces, nil, nil) + + resp := performRequest(t, fixture.Engine, http.MethodGet, "/tasks?scope=workspace&workspace=alpha&status=ready&owner_kind=pool&owner_ref=reviewers&parent_task_id=task-root&network_channel=builders&limit=2", nil) + if resp.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPost, "/tasks", []byte(`{"scope":"workspace","workspace":"alpha","title":"Review task API","description":"Check handler wiring","network_channel":"builders","owner":{"kind":"pool","ref":"reviewers"},"metadata":{"priority":"high"}}`)) + if resp.Code != http.StatusCreated { + t.Fatalf("create status = %d, want %d; body=%s", resp.Code, http.StatusCreated, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodGet, "/tasks/task-1", nil) + if resp.Code != http.StatusOK { + t.Fatalf("get status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPatch, "/tasks/task-1", []byte(`{"title":"Renamed task","network_channel":"builders","metadata":{"priority":"medium"}}`)) + if resp.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPost, "/tasks/task-1/cancel", []byte(`{"reason":"no longer needed","metadata":{"source":"test"}}`)) + if resp.Code != http.StatusOK { + t.Fatalf("cancel task status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPost, "/tasks/task-root/children", []byte(`{"scope":"workspace","workspace":"alpha","title":"Child task","description":"Follow-up work"}`)) + if resp.Code != http.StatusCreated { + t.Fatalf("create child status = %d, want %d; body=%s", resp.Code, http.StatusCreated, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPost, "/tasks/task-1/dependencies", []byte(`{"depends_on_task_id":"task-blocker"}`)) + if resp.Code != http.StatusOK { + t.Fatalf("add dependency status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodDelete, "/tasks/task-1/dependencies/task-blocker", nil) + if resp.Code != http.StatusOK { + t.Fatalf("remove dependency status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodGet, "/tasks/task-1/runs?status=running&session_id=sess-1&limit=1", nil) + if resp.Code != http.StatusOK { + t.Fatalf("list runs status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPost, "/tasks/task-1/runs", []byte(`{"idempotency_key":"key-3","network_channel":"builders"}`)) + if resp.Code != http.StatusCreated { + t.Fatalf("enqueue status = %d, want %d; body=%s", resp.Code, http.StatusCreated, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPost, "/task-runs/run-1/claim", []byte(`{"idempotency_key":"claim-1"}`)) + if resp.Code != http.StatusOK { + t.Fatalf("claim status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPost, "/task-runs/run-1/start", []byte(`{"idempotency_key":"start-1"}`)) + if resp.Code != http.StatusOK { + t.Fatalf("start status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPost, "/task-runs/run-1/attach-session", []byte(`{"session_id":"sess-1"}`)) + if resp.Code != http.StatusOK { + t.Fatalf("attach status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPost, "/task-runs/run-1/complete", []byte(`{"result":{"ok":true}}`)) + if resp.Code != http.StatusOK { + t.Fatalf("complete status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPost, "/task-runs/run-2/fail", []byte(`{"error":"boom","metadata":{"step":"claim"}}`)) + if resp.Code != http.StatusOK { + t.Fatalf("fail status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + + resp = performRequest(t, fixture.Engine, http.MethodPost, "/task-runs/run-2/cancel", []byte(`{"reason":"operator cancelled","metadata":{"step":"cancel"}}`)) + if resp.Code != http.StatusOK { + t.Fatalf("cancel run status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + + if listedQuery.WorkspaceID != "ws-alpha" || listedQuery.Scope != taskpkg.ScopeWorkspace || listedQuery.NetworkChannel != "builders" { + t.Fatalf("listed query = %#v", listedQuery) + } + if listedQuery.Status != taskpkg.TaskStatusReady || listedQuery.OwnerKind != taskpkg.OwnerKindPool || listedQuery.OwnerRef != "reviewers" || listedQuery.Limit != 2 { + t.Fatalf("listed query = %#v", listedQuery) + } + if listedRunTaskID != "task-1" { + t.Fatalf("listed run task id = %q, want %q", listedRunTaskID, "task-1") + } + if listedRunQuery.Status != taskpkg.TaskRunStatusRunning || listedRunQuery.SessionID != "sess-1" || listedRunQuery.Limit != 1 { + t.Fatalf("listed run query = %#v", listedRunQuery) + } + if getTaskCalls != 3 { + t.Fatalf("GetTask() calls = %d, want 3 detail reads without extra run-list fetch", getTaskCalls) + } + if createdSpec.WorkspaceID != "ws-alpha" || createdSpec.NetworkChannel != "builders" || createdSpec.Owner == nil || createdSpec.Owner.Ref != "reviewers" { + t.Fatalf("created spec = %#v", createdSpec) + } + if childSpec.WorkspaceID != "ws-alpha" || childSpec.Title != "Child task" { + t.Fatalf("child spec = %#v", childSpec) + } + if updatedPatch.Title == nil || *updatedPatch.Title != "Renamed task" { + t.Fatalf("updated patch = %#v", updatedPatch) + } + if cancelledTask.Reason != "no longer needed" { + t.Fatalf("cancelled task = %#v", cancelledTask) + } + if addedDependency.Kind != taskpkg.DependencyKindBlocks || addedDependency.DependsOnTaskID != "task-blocker" { + t.Fatalf("added dependency = %#v", addedDependency) + } + if removedTaskID != "task-1" || removedDependsOnID != "task-blocker" { + t.Fatalf("removed dependency = task=%q dependsOn=%q", removedTaskID, removedDependsOnID) + } + if enqueuedRun.IdempotencyKey != "key-3" || enqueuedRun.NetworkChannel != "builders" { + t.Fatalf("enqueued run = %#v", enqueuedRun) + } + if claimedRun.IdempotencyKey != "claim-1" { + t.Fatalf("claimed run = %#v", claimedRun) + } + if startedRun.IdempotencyKey != "start-1" { + t.Fatalf("started run = %#v", startedRun) + } + if attachedRunID != "run-1" || attachedSessionID != "sess-1" { + t.Fatalf("attached run = %q session = %q", attachedRunID, attachedSessionID) + } + if string(completedRun.Value) != `{"ok":true}` { + t.Fatalf("completed run = %#v", completedRun) + } + if failedRun.Error != "boom" { + t.Fatalf("failed run = %#v", failedRun) + } + if cancelledRun.Reason != "operator cancelled" { + t.Fatalf("cancelled run = %#v", cancelledRun) + } +} + +func TestBaseHandlersTaskActorResolverErrors(t *testing.T) { + t.Parallel() + + fixture := newHandlerFixtureWithTasks(t, testutil.StubSessionManager{}, testutil.StubObserver{}, testutil.StubTaskManager{}, testutil.StubWorkspaceService{}, nil, nil) + fixture.Handlers.TaskActorContextResolver = func(*gin.Context, string) (taskpkg.ActorContext, error) { + return taskpkg.ActorContext{}, errors.New("resolver failed") + } + + requests := []struct { + method string + path string + body []byte + }{ + {method: http.MethodGet, path: "/tasks"}, + {method: http.MethodPost, path: "/tasks", body: []byte(`{"scope":"global","title":"Review task API"}`)}, + {method: http.MethodGet, path: "/tasks/task-1"}, + {method: http.MethodPatch, path: "/tasks/task-1", body: []byte(`{"title":"Renamed task"}`)}, + {method: http.MethodPost, path: "/tasks/task-1/cancel", body: []byte(`{}`)}, + {method: http.MethodPost, path: "/tasks/task-root/children", body: []byte(`{"scope":"global","title":"Child task"}`)}, + {method: http.MethodPost, path: "/tasks/task-1/dependencies", body: []byte(`{"depends_on_task_id":"task-blocker"}`)}, + {method: http.MethodDelete, path: "/tasks/task-1/dependencies/task-blocker"}, + {method: http.MethodGet, path: "/tasks/task-1/runs"}, + {method: http.MethodPost, path: "/tasks/task-1/runs", body: []byte(`{}`)}, + {method: http.MethodPost, path: "/task-runs/run-1/claim", body: []byte(`{}`)}, + {method: http.MethodPost, path: "/task-runs/run-1/start", body: []byte(`{}`)}, + {method: http.MethodPost, path: "/task-runs/run-1/attach-session", body: []byte(`{"session_id":"sess-1"}`)}, + {method: http.MethodPost, path: "/task-runs/run-1/complete", body: []byte(`{}`)}, + {method: http.MethodPost, path: "/task-runs/run-1/fail", body: []byte(`{"error":"boom"}`)}, + {method: http.MethodPost, path: "/task-runs/run-1/cancel", body: []byte(`{}`)}, + } + + for _, request := range requests { + request := request + t.Run(request.method+" "+request.path, func(t *testing.T) { + resp := performRequest(t, fixture.Engine, request.method, request.path, request.body) + if resp.Code != http.StatusInternalServerError { + t.Fatalf("%s %s status = %d, want %d; body=%s", request.method, request.path, resp.Code, http.StatusInternalServerError, resp.Body.String()) + } + }) + } +} + +func TestBaseHandlersTaskServiceUnavailable(t *testing.T) { + t.Parallel() + + fixture := newHandlerFixtureWithTasks(t, testutil.StubSessionManager{}, testutil.StubObserver{}, testutil.StubTaskManager{}, testutil.StubWorkspaceService{}, nil, nil) + fixture.Handlers.Tasks = nil + + requests := []struct { + method string + path string + body []byte + }{ + {method: http.MethodGet, path: "/tasks"}, + {method: http.MethodPost, path: "/tasks", body: []byte(`{"scope":"global","title":"Review task API"}`)}, + {method: http.MethodGet, path: "/tasks/task-1"}, + {method: http.MethodPatch, path: "/tasks/task-1", body: []byte(`{"title":"Renamed task"}`)}, + {method: http.MethodPost, path: "/tasks/task-1/cancel", body: []byte(`{}`)}, + {method: http.MethodPost, path: "/tasks/task-root/children", body: []byte(`{"scope":"global","title":"Child task"}`)}, + {method: http.MethodPost, path: "/tasks/task-1/dependencies", body: []byte(`{"depends_on_task_id":"task-blocker"}`)}, + {method: http.MethodDelete, path: "/tasks/task-1/dependencies/task-blocker"}, + {method: http.MethodGet, path: "/tasks/task-1/runs"}, + {method: http.MethodPost, path: "/tasks/task-1/runs", body: []byte(`{}`)}, + {method: http.MethodPost, path: "/task-runs/run-1/claim", body: []byte(`{}`)}, + {method: http.MethodPost, path: "/task-runs/run-1/start", body: []byte(`{}`)}, + {method: http.MethodPost, path: "/task-runs/run-1/attach-session", body: []byte(`{"session_id":"sess-1"}`)}, + {method: http.MethodPost, path: "/task-runs/run-1/complete", body: []byte(`{}`)}, + {method: http.MethodPost, path: "/task-runs/run-1/fail", body: []byte(`{"error":"boom"}`)}, + {method: http.MethodPost, path: "/task-runs/run-1/cancel", body: []byte(`{}`)}, + } + + for _, request := range requests { + request := request + t.Run(request.method+" "+request.path, func(t *testing.T) { + resp := performRequest(t, fixture.Engine, request.method, request.path, request.body) + if resp.Code != http.StatusServiceUnavailable { + t.Fatalf("%s %s status = %d, want %d; body=%s", request.method, request.path, resp.Code, http.StatusServiceUnavailable, resp.Body.String()) + } + }) + } +} + +func TestBaseHandlersTaskManagerErrors(t *testing.T) { + t.Parallel() + + fixture := newHandlerFixtureWithTasks(t, testutil.StubSessionManager{}, testutil.StubObserver{}, testutil.StubTaskManager{ + ListTasksFn: func(context.Context, taskpkg.TaskQuery, taskpkg.ActorContext) ([]taskpkg.TaskSummary, error) { + return nil, taskpkg.ErrPermissionDenied + }, + CreateTaskFn: func(context.Context, taskpkg.CreateTask, taskpkg.ActorContext) (*taskpkg.Task, error) { + return nil, taskpkg.ErrPermissionDenied + }, + GetTaskFn: func(context.Context, string, taskpkg.ActorContext) (*taskpkg.TaskView, error) { + return nil, taskpkg.ErrTaskNotFound + }, + ListTaskRunsFn: func(context.Context, string, taskpkg.TaskRunQuery, taskpkg.ActorContext) ([]taskpkg.TaskRun, error) { + return nil, taskpkg.ErrTaskNotFound + }, + UpdateTaskFn: func(context.Context, string, taskpkg.TaskPatch, taskpkg.ActorContext) (*taskpkg.Task, error) { + return nil, taskpkg.ErrPermissionDenied + }, + CancelTaskFn: func(context.Context, string, taskpkg.CancelTask, taskpkg.ActorContext) (*taskpkg.Task, error) { + return nil, taskpkg.ErrInvalidStatusTransition + }, + CreateChildTaskFn: func(context.Context, string, taskpkg.CreateTask, taskpkg.ActorContext) (*taskpkg.Task, error) { + return nil, taskpkg.ErrPermissionDenied + }, + AddDependencyFn: func(context.Context, taskpkg.AddDependency, taskpkg.ActorContext) error { + return taskpkg.ErrCycleDetected + }, + RemoveDependencyFn: func(context.Context, string, string, taskpkg.ActorContext) error { + return taskpkg.ErrTaskDependencyNotFound + }, + EnqueueRunFn: func(context.Context, taskpkg.EnqueueRun, taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + return nil, taskpkg.ErrInvalidStatusTransition + }, + ClaimRunFn: func(context.Context, string, taskpkg.ClaimRun, taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + return nil, taskpkg.ErrTaskRunNotFound + }, + StartRunFn: func(context.Context, string, taskpkg.StartRun, taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + return nil, taskpkg.ErrInvalidStatusTransition + }, + AttachRunSessionFn: func(context.Context, string, string, taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + return nil, taskpkg.ErrSessionAlreadyBound + }, + CompleteRunFn: func(context.Context, string, taskpkg.RunResult, taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + return nil, taskpkg.ErrTaskRunNotFound + }, + FailRunFn: func(context.Context, string, taskpkg.RunFailure, taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + return nil, taskpkg.ErrTaskRunNotFound + }, + CancelRunFn: func(context.Context, string, taskpkg.CancelRun, taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + return nil, taskpkg.ErrTaskRunNotFound + }, + }, testutil.StubWorkspaceService{}, nil, nil) + + requests := []struct { + method string + path string + body []byte + want int + }{ + {method: http.MethodGet, path: "/tasks", want: http.StatusForbidden}, + {method: http.MethodPost, path: "/tasks", body: []byte(`{"scope":"global","title":"Review task API"}`), want: http.StatusForbidden}, + {method: http.MethodGet, path: "/tasks/task-1", want: http.StatusNotFound}, + {method: http.MethodPatch, path: "/tasks/task-1", body: []byte(`{"title":"Renamed task"}`), want: http.StatusForbidden}, + {method: http.MethodPost, path: "/tasks/task-1/cancel", body: []byte(`{}`), want: http.StatusConflict}, + {method: http.MethodPost, path: "/tasks/task-root/children", body: []byte(`{"scope":"global","title":"Child task"}`), want: http.StatusForbidden}, + {method: http.MethodPost, path: "/tasks/task-1/dependencies", body: []byte(`{"depends_on_task_id":"task-blocker"}`), want: http.StatusConflict}, + {method: http.MethodDelete, path: "/tasks/task-1/dependencies/task-blocker", want: http.StatusNotFound}, + {method: http.MethodGet, path: "/tasks/task-1/runs", want: http.StatusNotFound}, + {method: http.MethodPost, path: "/tasks/task-1/runs", body: []byte(`{}`), want: http.StatusConflict}, + {method: http.MethodPost, path: "/task-runs/run-1/claim", body: []byte(`{}`), want: http.StatusNotFound}, + {method: http.MethodPost, path: "/task-runs/run-1/start", body: []byte(`{}`), want: http.StatusConflict}, + {method: http.MethodPost, path: "/task-runs/run-1/attach-session", body: []byte(`{"session_id":"sess-1"}`), want: http.StatusConflict}, + {method: http.MethodPost, path: "/task-runs/run-1/complete", body: []byte(`{}`), want: http.StatusNotFound}, + {method: http.MethodPost, path: "/task-runs/run-1/fail", body: []byte(`{"error":"boom"}`), want: http.StatusNotFound}, + {method: http.MethodPost, path: "/task-runs/run-1/cancel", body: []byte(`{}`), want: http.StatusNotFound}, + } + + for _, request := range requests { + request := request + t.Run(request.method+" "+request.path, func(t *testing.T) { + resp := performRequest(t, fixture.Engine, request.method, request.path, request.body) + if resp.Code != request.want { + t.Fatalf("%s %s status = %d, want %d; body=%s", request.method, request.path, resp.Code, request.want, resp.Body.String()) + } + }) + } +} + +func TestBaseHandlersTaskDecodeErrors(t *testing.T) { + t.Parallel() + + fixture := newHandlerFixtureWithTasks(t, testutil.StubSessionManager{}, testutil.StubObserver{}, testutil.StubTaskManager{}, testutil.StubWorkspaceService{}, nil, nil) + + requests := []struct { + method string + path string + body []byte + }{ + {method: http.MethodPost, path: "/tasks", body: []byte(`{"scope":`)}, + {method: http.MethodPatch, path: "/tasks/task-1", body: []byte(`{"title":`)}, + {method: http.MethodPost, path: "/tasks/task-1/cancel", body: []byte(`{"reason":`)}, + {method: http.MethodPost, path: "/tasks/task-root/children", body: []byte(`{"scope":`)}, + {method: http.MethodPost, path: "/tasks/task-1/dependencies", body: []byte(`{"depends_on_task_id":`)}, + {method: http.MethodPost, path: "/tasks/task-1/runs", body: []byte(`{"idempotency_key":`)}, + {method: http.MethodPost, path: "/task-runs/run-1/claim", body: []byte(`{"idempotency_key":`)}, + {method: http.MethodPost, path: "/task-runs/run-1/start", body: []byte(`{"idempotency_key":`)}, + {method: http.MethodPost, path: "/task-runs/run-1/attach-session", body: []byte(`{"session_id":`)}, + {method: http.MethodPost, path: "/task-runs/run-1/complete", body: []byte(`{"result":`)}, + {method: http.MethodPost, path: "/task-runs/run-1/fail", body: []byte(`{"error":`)}, + {method: http.MethodPost, path: "/task-runs/run-1/cancel", body: []byte(`{"reason":`)}, + } + + for _, request := range requests { + request := request + t.Run(request.method+" "+request.path, func(t *testing.T) { + resp := performRequest(t, fixture.Engine, request.method, request.path, request.body) + if resp.Code != http.StatusBadRequest { + t.Fatalf("%s %s status = %d, want %d; body=%s", request.method, request.path, resp.Code, http.StatusBadRequest, resp.Body.String()) + } + }) + } +} diff --git a/internal/api/core/test_helpers_test.go b/internal/api/core/test_helpers_test.go index b65a4bddc..0494e9383 100644 --- a/internal/api/core/test_helpers_test.go +++ b/internal/api/core/test_helpers_test.go @@ -53,7 +53,7 @@ func newHandlerFixture( store *memory.Store, dream core.DreamTrigger, ) handlerFixture { - return newHandlerFixtureWithAutomation(t, manager, observer, testutil.StubAutomationManager{}, workspaces, store, dream) + return newHandlerFixtureWithAutomationAndTasks(t, manager, observer, testutil.StubAutomationManager{}, testutil.StubTaskManager{}, workspaces, store, dream) } func newHandlerFixtureWithAutomation( @@ -64,6 +64,31 @@ func newHandlerFixtureWithAutomation( workspaces testutil.StubWorkspaceService, store *memory.Store, dream core.DreamTrigger, +) handlerFixture { + return newHandlerFixtureWithAutomationAndTasks(t, manager, observer, automation, testutil.StubTaskManager{}, workspaces, store, dream) +} + +func newHandlerFixtureWithTasks( + t *testing.T, + manager testutil.StubSessionManager, + observer testutil.StubObserver, + tasks testutil.StubTaskManager, + workspaces testutil.StubWorkspaceService, + store *memory.Store, + dream core.DreamTrigger, +) handlerFixture { + return newHandlerFixtureWithAutomationAndTasks(t, manager, observer, testutil.StubAutomationManager{}, tasks, workspaces, store, dream) +} + +func newHandlerFixtureWithAutomationAndTasks( + t *testing.T, + manager testutil.StubSessionManager, + observer testutil.StubObserver, + automation testutil.StubAutomationManager, + tasks testutil.StubTaskManager, + workspaces testutil.StubWorkspaceService, + store *memory.Store, + dream core.DreamTrigger, ) handlerFixture { t.Helper() @@ -81,6 +106,7 @@ func newHandlerFixtureWithAutomation( Sessions: manager, Observer: observer, Automation: automation, + Tasks: tasks, Workspaces: workspaces, MemoryStore: store, DreamTrigger: dream, @@ -141,6 +167,22 @@ func newHandlerFixtureWithAutomation( engine.GET("/network/channels/:channel/messages", handlers.NetworkChannelMessages) engine.POST("/network/send", handlers.NetworkSend) engine.GET("/network/inbox", handlers.NetworkInbox) + engine.GET("/tasks", handlers.ListTasks) + engine.POST("/tasks", handlers.CreateTask) + engine.GET("/tasks/:id", handlers.GetTask) + engine.PATCH("/tasks/:id", handlers.UpdateTask) + engine.POST("/tasks/:id/cancel", handlers.CancelTask) + engine.POST("/tasks/:id/children", handlers.CreateChildTask) + engine.POST("/tasks/:id/dependencies", handlers.AddTaskDependency) + engine.DELETE("/tasks/:id/dependencies/:depends_on_id", handlers.RemoveTaskDependency) + engine.GET("/tasks/:id/runs", handlers.ListTaskRuns) + engine.POST("/tasks/:id/runs", handlers.EnqueueTaskRun) + engine.POST("/task-runs/:id/claim", handlers.ClaimTaskRun) + engine.POST("/task-runs/:id/start", handlers.StartTaskRun) + engine.POST("/task-runs/:id/attach-session", handlers.AttachTaskRunSession) + engine.POST("/task-runs/:id/complete", handlers.CompleteTaskRun) + engine.POST("/task-runs/:id/fail", handlers.FailTaskRun) + engine.POST("/task-runs/:id/cancel", handlers.CancelTaskRun) engine.GET("/memory", handlers.ListMemory) engine.GET("/memory/:filename", handlers.ReadMemory) engine.PUT("/memory/:filename", handlers.WriteMemory) diff --git a/internal/api/httpapi/handlers.go b/internal/api/httpapi/handlers.go index e6cffca6c..33e842f77 100644 --- a/internal/api/httpapi/handlers.go +++ b/internal/api/httpapi/handlers.go @@ -12,6 +12,7 @@ import ( type handlerConfig struct { sessions core.SessionManager + tasks core.TaskService network core.NetworkService networkStore core.NetworkStore observer core.Observer @@ -52,6 +53,7 @@ func newHandlers(cfg handlerConfig) *Handlers { MaskInternalErrors: true, IncludeSessionWorkspaceInSSE: false, Sessions: cfg.sessions, + Tasks: cfg.tasks, Network: cfg.network, NetworkStore: cfg.networkStore, Observer: cfg.observer, diff --git a/internal/api/httpapi/handlers_test.go b/internal/api/httpapi/handlers_test.go index 68d3a0b8d..210bff0ca 100644 --- a/internal/api/httpapi/handlers_test.go +++ b/internal/api/httpapi/handlers_test.go @@ -41,6 +41,7 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "DELETE /api/automation/triggers/:id", "DELETE /api/memory/:filename", "DELETE /api/sessions/:id", + "DELETE /api/tasks/:id/dependencies/:depends_on_id", "DELETE /api/workspaces/:id", "GET /api/agents", "GET /api/agents/:name", @@ -81,11 +82,15 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "GET /api/skills", "GET /api/skills/:name", "GET /api/skills/:name/content", + "GET /api/tasks", + "GET /api/tasks/:id", + "GET /api/tasks/:id/runs", "GET /api/workspaces", "GET /api/workspaces/:id", "PATCH /api/automation/jobs/:id", "PATCH /api/automation/triggers/:id", "PATCH /api/bridges/:id", + "PATCH /api/tasks/:id", "PATCH /api/workspaces/:id", "POST /api/automation/jobs", "POST /api/automation/jobs/:id/trigger", @@ -104,6 +109,17 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "POST /api/sessions/:id/resume", "POST /api/skills/:name/disable", "POST /api/skills/:name/enable", + "POST /api/task-runs/:id/attach-session", + "POST /api/task-runs/:id/cancel", + "POST /api/task-runs/:id/claim", + "POST /api/task-runs/:id/complete", + "POST /api/task-runs/:id/fail", + "POST /api/task-runs/:id/start", + "POST /api/tasks", + "POST /api/tasks/:id/cancel", + "POST /api/tasks/:id/children", + "POST /api/tasks/:id/dependencies", + "POST /api/tasks/:id/runs", "POST /api/webhooks/global/:endpoint", "POST /api/webhooks/workspaces/:workspace_id/:endpoint", "POST /api/workspaces", diff --git a/internal/api/httpapi/helpers_test.go b/internal/api/httpapi/helpers_test.go index b95c23cd5..7bea806ab 100644 --- a/internal/api/httpapi/helpers_test.go +++ b/internal/api/httpapi/helpers_test.go @@ -19,13 +19,14 @@ import ( type stubSessionManager = testutil.StubSessionManager type stubObserver = testutil.StubObserver +type stubTaskManager = testutil.StubTaskManager type stubBridgeService = testutil.StubBridgeService type stubWorkspaceService = testutil.StubWorkspaceService type sseRecord = testutil.SSERecord func newTestHandlers(t *testing.T, manager core.SessionManager, observer core.Observer, homePaths aghconfig.HomePaths) *Handlers { t.Helper() - return newTestHandlersWithAutomationBridgesAndWorkspace(t, manager, observer, nil, nil, stubWorkspaceService{}, homePaths) + return newTestHandlersWithAutomationBridgesTasksAndWorkspace(t, manager, observer, nil, stubTaskManager{}, nil, stubWorkspaceService{}, homePaths) } func newTestHandlersWithBridges( @@ -37,14 +38,15 @@ func newTestHandlersWithBridges( homePaths aghconfig.HomePaths, ) *Handlers { t.Helper() - return newTestHandlersWithAutomationBridgesAndWorkspace(t, manager, observer, nil, bridges, workspaces, homePaths) + return newTestHandlersWithAutomationBridgesTasksAndWorkspace(t, manager, observer, nil, stubTaskManager{}, bridges, workspaces, homePaths) } -func newTestHandlersWithAutomationBridgesAndWorkspace( +func newTestHandlersWithAutomationBridgesTasksAndWorkspace( t *testing.T, manager core.SessionManager, observer core.Observer, automation core.AutomationManager, + tasks core.TaskService, bridges core.BridgeService, workspaces core.WorkspaceService, homePaths aghconfig.HomePaths, @@ -57,6 +59,7 @@ func newTestHandlersWithAutomationBridgesAndWorkspace( return newHandlers(handlerConfig{ sessions: manager, + tasks: tasks, observer: observer, automation: automation, bridges: bridges, diff --git a/internal/api/httpapi/httpapi_integration_test.go b/internal/api/httpapi/httpapi_integration_test.go index 5f2af8c0a..d360356c4 100644 --- a/internal/api/httpapi/httpapi_integration_test.go +++ b/internal/api/httpapi/httpapi_integration_test.go @@ -27,6 +27,7 @@ import ( "github.com/pedronauck/agh/internal/session" "github.com/pedronauck/agh/internal/store" "github.com/pedronauck/agh/internal/store/globaldb" + taskpkg "github.com/pedronauck/agh/internal/task" "github.com/pedronauck/agh/internal/transcript" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) @@ -852,6 +853,7 @@ func TestHTTPShutdownWaitsForInflightRequests(t *testing.T) { return []*session.SessionInfo{newSessionInfo("sess-1")}, nil }, }), + WithTaskService(stubTaskManager{}), WithObserver(stubObserver{ HealthFn: func(context.Context) (observe.Health, error) { return observe.Health{Status: "ok"}, nil }, }), @@ -929,10 +931,256 @@ func TestHTTPShutdownWaitsForInflightRequests(t *testing.T) { } } +func TestHTTPTaskRoutesRoundTrip(t *testing.T) { + runtime := newIntegrationRuntime(t) + + created := createIntegrationTask(t, runtime, []byte(`{ + "scope":"global", + "title":"Ship task routes", + "description":"Expose the transport routes", + "network_channel":"builders", + "owner":{"kind":"pool","ref":"ops"}, + "metadata":{"priority":"high"} + }`)) + if created.ID == "" { + t.Fatal("expected created task id") + } + if created.Scope != taskpkg.ScopeGlobal { + t.Fatalf("created scope = %q, want %q", created.Scope, taskpkg.ScopeGlobal) + } + if created.NetworkChannel != "builders" { + t.Fatalf("created network_channel = %q, want %q", created.NetworkChannel, "builders") + } + if created.Owner == nil || created.Owner.Kind != taskpkg.OwnerKindPool || created.Owner.Ref != "ops" { + t.Fatalf("created owner = %#v, want pool/ops", created.Owner) + } + if created.Origin.Kind != taskpkg.OriginKindHTTP { + t.Fatalf("created origin.kind = %q, want %q", created.Origin.Kind, taskpkg.OriginKindHTTP) + } + if created.CreatedBy.Ref != "local-user" { + t.Fatalf("created created_by.ref = %q, want %q", created.CreatedBy.Ref, "local-user") + } + if got := strings.TrimSpace(string(created.Metadata)); got != `{"priority":"high"}` { + t.Fatalf("created metadata = %s, want %s", got, `{"priority":"high"}`) + } + + listResp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/tasks?scope=global&status=ready&owner_kind=pool&owner_ref=ops&network_channel=builders"), nil, nil) + if listResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(listResp.Body) + _ = listResp.Body.Close() + t.Fatalf("list tasks status = %d, want %d; body=%s", listResp.StatusCode, http.StatusOK, string(body)) + } + var listed contract.TasksResponse + decodeHTTPJSON(t, listResp, &listed) + if len(listed.Tasks) != 1 || listed.Tasks[0].ID != created.ID { + t.Fatalf("listed tasks = %#v, want created task", listed.Tasks) + } + + getResp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/tasks/"+created.ID), nil, nil) + if getResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(getResp.Body) + _ = getResp.Body.Close() + t.Fatalf("get task status = %d, want %d; body=%s", getResp.StatusCode, http.StatusOK, string(body)) + } + var detail contract.TaskDetailResponse + decodeHTTPJSON(t, getResp, &detail) + if detail.Task.Task.ID != created.ID { + t.Fatalf("detail task id = %q, want %q", detail.Task.Task.ID, created.ID) + } + if len(detail.Task.Children) != 0 || len(detail.Task.Runs) != 0 { + t.Fatalf("detail task children/runs = %#v, want empty", detail.Task) + } + + updateResp := mustHTTPRequest(t, runtime.client, http.MethodPatch, mustURL(runtime.host, runtime.port, "/api/tasks/"+created.ID), []byte(`{ + "title":"Ship task routes now", + "description":"Expose the task and run transports everywhere", + "network_channel":"ops", + "clear_owner":true + }`), nil) + if updateResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(updateResp.Body) + _ = updateResp.Body.Close() + t.Fatalf("update task status = %d, want %d; body=%s", updateResp.StatusCode, http.StatusOK, string(body)) + } + var updated contract.TaskResponse + decodeHTTPJSON(t, updateResp, &updated) + if updated.Task.Title != "Ship task routes now" { + t.Fatalf("updated title = %q, want %q", updated.Task.Title, "Ship task routes now") + } + if updated.Task.Description != "Expose the task and run transports everywhere" { + t.Fatalf("updated description = %q", updated.Task.Description) + } + if updated.Task.NetworkChannel != "ops" { + t.Fatalf("updated network_channel = %q, want %q", updated.Task.NetworkChannel, "ops") + } + if updated.Task.Owner != nil { + t.Fatalf("updated owner = %#v, want nil", updated.Task.Owner) + } + + updatedListResp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/tasks?scope=global&status=ready&network_channel=ops"), nil, nil) + if updatedListResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(updatedListResp.Body) + _ = updatedListResp.Body.Close() + t.Fatalf("updated list tasks status = %d, want %d; body=%s", updatedListResp.StatusCode, http.StatusOK, string(body)) + } + var updatedList contract.TasksResponse + decodeHTTPJSON(t, updatedListResp, &updatedList) + if len(updatedList.Tasks) != 1 || updatedList.Tasks[0].ID != created.ID { + t.Fatalf("updated list tasks = %#v, want created task", updatedList.Tasks) + } +} + +func TestHTTPTaskRunLifecycleRoutesRoundTrip(t *testing.T) { + t.Run("Should enqueue claim start and complete a task run", func(t *testing.T) { + t.Parallel() + + runtime := newIntegrationRuntime(t) + created := createIntegrationTask(t, runtime, []byte(`{"scope":"global","title":"Run task routes"}`)) + + enqueueResp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/tasks/"+created.ID+"/runs"), []byte(`{"idempotency_key":"enqueue-1","network_channel":"builders"}`), nil) + if enqueueResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(enqueueResp.Body) + _ = enqueueResp.Body.Close() + t.Fatalf("enqueue run status = %d, want %d; body=%s", enqueueResp.StatusCode, http.StatusCreated, string(body)) + } + var queued contract.TaskRunResponse + decodeHTTPJSON(t, enqueueResp, &queued) + if queued.Run.Status != taskpkg.TaskRunStatusQueued { + t.Fatalf("queued status = %q, want %q", queued.Run.Status, taskpkg.TaskRunStatusQueued) + } + if queued.Run.NetworkChannel != "builders" { + t.Fatalf("queued network_channel = %q, want %q", queued.Run.NetworkChannel, "builders") + } + + listQueuedResp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/tasks/"+created.ID+"/runs?status=queued&limit=1"), nil, nil) + if listQueuedResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(listQueuedResp.Body) + _ = listQueuedResp.Body.Close() + t.Fatalf("list queued runs status = %d, want %d; body=%s", listQueuedResp.StatusCode, http.StatusOK, string(body)) + } + var queuedList contract.TaskRunsResponse + decodeHTTPJSON(t, listQueuedResp, &queuedList) + if len(queuedList.Runs) != 1 || queuedList.Runs[0].ID != queued.Run.ID { + t.Fatalf("queued runs = %#v, want queued run", queuedList.Runs) + } + + claimResp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/task-runs/"+queued.Run.ID+"/claim"), []byte(`{"idempotency_key":"claim-1"}`), nil) + if claimResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(claimResp.Body) + _ = claimResp.Body.Close() + t.Fatalf("claim run status = %d, want %d; body=%s", claimResp.StatusCode, http.StatusOK, string(body)) + } + var claimed contract.TaskRunResponse + decodeHTTPJSON(t, claimResp, &claimed) + if claimed.Run.Status != taskpkg.TaskRunStatusClaimed { + t.Fatalf("claimed status = %q, want %q", claimed.Run.Status, taskpkg.TaskRunStatusClaimed) + } + if claimed.Run.ClaimedBy == nil || claimed.Run.ClaimedBy.Ref != "local-user" { + t.Fatalf("claimed claimed_by = %#v, want local-user", claimed.Run.ClaimedBy) + } + + startResp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/task-runs/"+queued.Run.ID+"/start"), []byte(`{"idempotency_key":"start-1"}`), nil) + if startResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(startResp.Body) + _ = startResp.Body.Close() + t.Fatalf("start run status = %d, want %d; body=%s", startResp.StatusCode, http.StatusOK, string(body)) + } + var started contract.TaskRunResponse + decodeHTTPJSON(t, startResp, &started) + if started.Run.Status != taskpkg.TaskRunStatusRunning { + t.Fatalf("started status = %q, want %q", started.Run.Status, taskpkg.TaskRunStatusRunning) + } + if started.Run.SessionID == "" { + t.Fatal("expected started run session id") + } + + completeResp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/task-runs/"+queued.Run.ID+"/complete"), []byte(`{"result":{"ok":true}}`), nil) + if completeResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(completeResp.Body) + _ = completeResp.Body.Close() + t.Fatalf("complete run status = %d, want %d; body=%s", completeResp.StatusCode, http.StatusOK, string(body)) + } + var completed contract.TaskRunResponse + decodeHTTPJSON(t, completeResp, &completed) + if completed.Run.Status != taskpkg.TaskRunStatusCompleted { + t.Fatalf("completed status = %q, want %q", completed.Run.Status, taskpkg.TaskRunStatusCompleted) + } + }) + + t.Run("Should attach a claimed run session and then fail it", func(t *testing.T) { + t.Parallel() + + runtime := newIntegrationRuntime(t) + created := createIntegrationTask(t, runtime, []byte(`{"scope":"global","title":"Run task routes"}`)) + run := enqueueIntegrationTaskRun(t, runtime, created.ID, `{"idempotency_key":"enqueue-2"}`) + claimIntegrationTaskRun(t, runtime, run.ID, `{"idempotency_key":"claim-2"}`) + + attachResp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/task-runs/"+run.ID+"/attach-session"), []byte(`{"session_id":"sess-resume-1"}`), nil) + if attachResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(attachResp.Body) + _ = attachResp.Body.Close() + t.Fatalf("attach run session status = %d, want %d; body=%s", attachResp.StatusCode, http.StatusOK, string(body)) + } + var attached contract.TaskRunResponse + decodeHTTPJSON(t, attachResp, &attached) + if attached.Run.Status != taskpkg.TaskRunStatusStarting { + t.Fatalf("attached status = %q, want %q", attached.Run.Status, taskpkg.TaskRunStatusStarting) + } + if attached.Run.SessionID != "sess-resume-1" { + t.Fatalf("attached session_id = %q, want %q", attached.Run.SessionID, "sess-resume-1") + } + + failResp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/task-runs/"+run.ID+"/fail"), []byte(`{"error":"boom","metadata":{"step":"attach"}}`), nil) + if failResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(failResp.Body) + _ = failResp.Body.Close() + t.Fatalf("fail run status = %d, want %d; body=%s", failResp.StatusCode, http.StatusOK, string(body)) + } + var failed contract.TaskRunResponse + decodeHTTPJSON(t, failResp, &failed) + if failed.Run.Status != taskpkg.TaskRunStatusFailed { + t.Fatalf("failed status = %q, want %q", failed.Run.Status, taskpkg.TaskRunStatusFailed) + } + }) + + t.Run("Should cancel one queued task run", func(t *testing.T) { + t.Parallel() + + runtime := newIntegrationRuntime(t) + created := createIntegrationTask(t, runtime, []byte(`{"scope":"global","title":"Run task routes"}`)) + run := enqueueIntegrationTaskRun(t, runtime, created.ID, `{"idempotency_key":"enqueue-3"}`) + + cancelResp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/task-runs/"+run.ID+"/cancel"), []byte(`{"reason":"operator cancelled"}`), nil) + if cancelResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(cancelResp.Body) + _ = cancelResp.Body.Close() + t.Fatalf("cancel run status = %d, want %d; body=%s", cancelResp.StatusCode, http.StatusOK, string(body)) + } + var cancelled contract.TaskRunResponse + decodeHTTPJSON(t, cancelResp, &cancelled) + if cancelled.Run.Status != taskpkg.TaskRunStatusCancelled { + t.Fatalf("cancelled status = %q, want %q", cancelled.Run.Status, taskpkg.TaskRunStatusCancelled) + } + + finalRunsResp := mustHTTPRequest(t, runtime.client, http.MethodGet, mustURL(runtime.host, runtime.port, "/api/tasks/"+created.ID+"/runs"), nil, nil) + if finalRunsResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(finalRunsResp.Body) + _ = finalRunsResp.Body.Close() + t.Fatalf("final list runs status = %d, want %d; body=%s", finalRunsResp.StatusCode, http.StatusOK, string(body)) + } + var finalRuns contract.TaskRunsResponse + decodeHTTPJSON(t, finalRunsResp, &finalRuns) + if len(finalRuns.Runs) != 1 || finalRuns.Runs[0].Status != taskpkg.TaskRunStatusCancelled { + t.Fatalf("final runs = %#v, want one cancelled run", finalRuns.Runs) + } + }) +} + type integrationRuntime struct { client *http.Client server *Server manager *session.Manager + tasks *taskpkg.TaskManager driver *integrationDriver observer *observe.Observer registry *globaldb.GlobalDB @@ -944,6 +1192,30 @@ type integrationRuntime struct { workspace string } +type integrationTaskSessionExecutor struct { + mu sync.Mutex + started int +} + +func (e *integrationTaskSessionExecutor) StartTaskSession(_ context.Context, _ taskpkg.StartTaskSession) (*taskpkg.SessionRef, error) { + e.mu.Lock() + defer e.mu.Unlock() + e.started++ + return &taskpkg.SessionRef{SessionID: fmt.Sprintf("task-sess-%d", e.started)}, nil +} + +func (*integrationTaskSessionExecutor) AttachTaskSession(_ context.Context, _ string, sessionID string) (*taskpkg.SessionRef, error) { + return &taskpkg.SessionRef{SessionID: sessionID}, nil +} + +func (*integrationTaskSessionExecutor) RequestTaskStop(context.Context, string, taskpkg.StopReason) error { + return nil +} + +func (*integrationTaskSessionExecutor) ForceTaskStop(context.Context, string, taskpkg.StopReason) error { + return nil +} + type integrationDreamTrigger struct { enabled bool triggered bool @@ -1437,6 +1709,15 @@ func newIntegrationRuntimeWithPermissionWait(t *testing.T, permissionWait time.D } }) + taskExecutor := &integrationTaskSessionExecutor{} + taskManager, err := taskpkg.NewManager( + taskpkg.WithStore(registry), + taskpkg.WithSessionExecutor(taskExecutor), + ) + if err != nil { + t.Fatalf("task.NewManager() error = %v", err) + } + server, err := New( WithHomePaths(homePaths), WithConfig(cfg), @@ -1444,6 +1725,7 @@ func newIntegrationRuntimeWithPermissionWait(t *testing.T, permissionWait time.D WithPort(cfg.HTTP.Port), WithLogger(discardLogger()), WithSessionManager(manager), + WithTaskService(taskManager), WithObserver(observer), WithAutomation(automationManager), WithBridgeService(bridgeService), @@ -1470,6 +1752,7 @@ func newIntegrationRuntimeWithPermissionWait(t *testing.T, permissionWait time.D client: &http.Client{}, server: server, manager: manager, + tasks: taskManager, driver: driver, observer: observer, registry: registry, @@ -1620,6 +1903,48 @@ func createIntegrationSession(t *testing.T, runtime integrationRuntime) string { return created.Session.ID } +func createIntegrationTask(t *testing.T, runtime integrationRuntime, body []byte) contract.TaskPayload { + t.Helper() + + resp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/tasks"), body, nil) + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + t.Fatalf("create task status = %d, want %d; body=%s", resp.StatusCode, http.StatusCreated, string(body)) + } + var created contract.TaskResponse + decodeHTTPJSON(t, resp, &created) + return created.Task +} + +func enqueueIntegrationTaskRun(t *testing.T, runtime integrationRuntime, taskID string, body string) contract.TaskRunPayload { + t.Helper() + + resp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/tasks/"+taskID+"/runs"), []byte(body), nil) + if resp.StatusCode != http.StatusCreated { + payload, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + t.Fatalf("enqueue run status = %d, want %d; body=%s", resp.StatusCode, http.StatusCreated, string(payload)) + } + var created contract.TaskRunResponse + decodeHTTPJSON(t, resp, &created) + return created.Run +} + +func claimIntegrationTaskRun(t *testing.T, runtime integrationRuntime, runID string, body string) contract.TaskRunPayload { + t.Helper() + + resp := mustHTTPRequest(t, runtime.client, http.MethodPost, mustURL(runtime.host, runtime.port, "/api/task-runs/"+runID+"/claim"), []byte(body), nil) + if resp.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + t.Fatalf("claim run status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, string(payload)) + } + var claimed contract.TaskRunResponse + decodeHTTPJSON(t, resp, &claimed) + return claimed.Run +} + func sendPrompt(t *testing.T, runtime integrationRuntime, sessionID string, message string) { t.Helper() diff --git a/internal/api/httpapi/routes.go b/internal/api/httpapi/routes.go index 7c7c71753..812100a58 100644 --- a/internal/api/httpapi/routes.go +++ b/internal/api/httpapi/routes.go @@ -17,6 +17,7 @@ func RegisterRoutes(router gin.IRouter, handlers *Handlers) { registerObserveRoutes(api, handlers) registerHookRoutes(api, handlers) registerAutomationRoutes(api, handlers) + registerTaskRoutes(api, handlers) registerSkillRoutes(api, handlers) registerMemoryRoutes(api, handlers) registerDaemonRoutes(api, handlers) @@ -112,6 +113,28 @@ func registerAutomationRoutes(api gin.IRouter, handlers *Handlers) { runs.GET("/:id", handlers.GetAutomationRun) } +func registerTaskRoutes(api gin.IRouter, handlers *Handlers) { + tasks := api.Group("/tasks") + tasks.POST("", handlers.CreateTask) + tasks.GET("", handlers.ListTasks) + tasks.GET("/:id", handlers.GetTask) + tasks.PATCH("/:id", handlers.UpdateTask) + tasks.POST("/:id/cancel", handlers.CancelTask) + tasks.POST("/:id/children", handlers.CreateChildTask) + tasks.POST("/:id/dependencies", handlers.AddTaskDependency) + tasks.DELETE("/:id/dependencies/:depends_on_id", handlers.RemoveTaskDependency) + tasks.POST("/:id/runs", handlers.EnqueueTaskRun) + tasks.GET("/:id/runs", handlers.ListTaskRuns) + + taskRuns := api.Group("/task-runs") + taskRuns.POST("/:id/claim", handlers.ClaimTaskRun) + taskRuns.POST("/:id/start", handlers.StartTaskRun) + taskRuns.POST("/:id/attach-session", handlers.AttachTaskRunSession) + taskRuns.POST("/:id/complete", handlers.CompleteTaskRun) + taskRuns.POST("/:id/fail", handlers.FailTaskRun) + taskRuns.POST("/:id/cancel", handlers.CancelTaskRun) +} + func registerSkillRoutes(api gin.IRouter, handlers *Handlers) { skillsGroup := api.Group("/skills") skillsGroup.GET("", handlers.ListSkills) diff --git a/internal/api/httpapi/server.go b/internal/api/httpapi/server.go index 543f2242d..44492a9d4 100644 --- a/internal/api/httpapi/server.go +++ b/internal/api/httpapi/server.go @@ -41,6 +41,7 @@ type Server struct { now func() time.Time pollInterval time.Duration sessions core.SessionManager + tasks core.TaskService network core.NetworkService networkStore core.NetworkStore observer core.Observer @@ -126,6 +127,13 @@ func WithSessionManager(manager core.SessionManager) Option { } } +// WithTaskService injects the daemon-owned task service. +func WithTaskService(service core.TaskService) Option { + return func(server *Server) { + server.tasks = service + } +} + // WithNetworkService injects the runtime network manager. func WithNetworkService(service core.NetworkService) Option { return func(server *Server) { @@ -246,6 +254,9 @@ func New(opts ...Option) (*Server, error) { if server.sessions == nil { return nil, errors.New("httpapi: session manager is required") } + if server.tasks == nil { + return nil, errors.New("httpapi: task service is required") + } if server.observer == nil { return nil, errors.New("httpapi: observer is required") } @@ -278,6 +289,7 @@ func New(opts ...Option) (*Server, error) { server.handlers = newHandlers(handlerConfig{ sessions: server.sessions, + tasks: server.tasks, network: server.network, networkStore: server.networkStore, observer: server.observer, diff --git a/internal/api/httpapi/server_test.go b/internal/api/httpapi/server_test.go index 331b7a969..1bde1f2c0 100644 --- a/internal/api/httpapi/server_test.go +++ b/internal/api/httpapi/server_test.go @@ -39,6 +39,7 @@ func TestNewHonorsOptionsAndDefaults(t *testing.T) { WithNow(now), WithPollInterval(25*time.Millisecond), WithSessionManager(stubSessionManager{}), + WithTaskService(stubTaskManager{}), WithObserver(stubObserver{}), WithWorkspaceResolver(stubWorkspaceService{}), WithMemoryStore(store), @@ -82,16 +83,19 @@ func TestPortHandlesNilServer(t *testing.T) { } } -func TestNewRequiresSessionManagerObserverAndWorkspaceResolver(t *testing.T) { +func TestNewRequiresSessionManagerTaskServiceObserverAndWorkspaceResolver(t *testing.T) { homePaths := newTestHomePaths(t) if _, err := New(WithHomePaths(homePaths), WithObserver(stubObserver{})); err == nil { t.Fatal("New() without session manager error = nil, want non-nil") } if _, err := New(WithHomePaths(homePaths), WithSessionManager(stubSessionManager{})); err == nil { + t.Fatal("New() without task service error = nil, want non-nil") + } + if _, err := New(WithHomePaths(homePaths), WithSessionManager(stubSessionManager{}), WithTaskService(stubTaskManager{})); err == nil { t.Fatal("New() without observer error = nil, want non-nil") } - if _, err := New(WithHomePaths(homePaths), WithSessionManager(stubSessionManager{}), WithObserver(stubObserver{})); err == nil { + if _, err := New(WithHomePaths(homePaths), WithSessionManager(stubSessionManager{}), WithTaskService(stubTaskManager{}), WithObserver(stubObserver{})); err == nil { t.Fatal("New() without workspace resolver error = nil, want non-nil") } } @@ -111,6 +115,7 @@ func TestServerStartAndShutdownServeRequests(t *testing.T) { WithSessionManager(stubSessionManager{ ListAllFn: func(context.Context) ([]*session.SessionInfo, error) { return nil, nil }, }), + WithTaskService(stubTaskManager{}), WithObserver(stubObserver{ HealthFn: func(context.Context) (observe.Health, error) { return observe.Health{Status: "ok"}, nil }, }), @@ -158,6 +163,7 @@ func TestServerStartRejectsNilContextAndDuplicateStart(t *testing.T) { WithPort(cfg.HTTP.Port), WithLogger(discardLogger()), WithSessionManager(stubSessionManager{}), + WithTaskService(stubTaskManager{}), WithObserver(stubObserver{}), WithWorkspaceResolver(stubWorkspaceService{}), ) @@ -203,6 +209,7 @@ func TestServerStartReportsListenFailure(t *testing.T) { WithPort(cfg.HTTP.Port), WithLogger(discardLogger()), WithSessionManager(stubSessionManager{}), + WithTaskService(stubTaskManager{}), WithObserver(stubObserver{}), WithWorkspaceResolver(stubWorkspaceService{}), ) @@ -223,6 +230,7 @@ func TestServerStartReportsListenFailure(t *testing.T) { WithPort(cfg.HTTP.Port), WithLogger(discardLogger()), WithSessionManager(stubSessionManager{}), + WithTaskService(stubTaskManager{}), WithObserver(stubObserver{}), WithWorkspaceResolver(stubWorkspaceService{}), ) diff --git a/internal/api/spec/spec.go b/internal/api/spec/spec.go index 3ecc7b3c4..996b1309c 100644 --- a/internal/api/spec/spec.go +++ b/internal/api/spec/spec.go @@ -21,6 +21,7 @@ import ( "github.com/pedronauck/agh/internal/memory" "github.com/pedronauck/agh/internal/session" "github.com/pedronauck/agh/internal/store" + taskpkg "github.com/pedronauck/agh/internal/task" "github.com/pedronauck/agh/internal/tools" ) @@ -94,6 +95,7 @@ func Document() (*openapi3.T, error) { {Name: "observe"}, {Name: "sessions"}, {Name: "skills"}, + {Name: "tasks"}, {Name: "workspaces"}, }, } @@ -1219,6 +1221,326 @@ func Operations() []OperationSpec { {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, }, }, + { + Method: "GET", + Path: "/api/tasks", + OperationID: "listTasks", + Summary: "List tasks", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + enumQueryParam("scope", "Filter by task scope", false, taskScopeValues()), + queryParam("workspace", "Filter by workspace path, name, or ID", false), + enumQueryParam("status", "Filter by task status", false, taskStatusValues()), + enumQueryParam("owner_kind", "Filter by owner kind", false, taskOwnerKindValues()), + queryParam("owner_ref", "Filter by owner reference", false), + queryParam("parent_task_id", "Filter by parent task ID", false), + queryParam("network_channel", "Filter by network channel", false), + intQueryParam("limit", "Maximum number of records to return", false), + }, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.TasksResponse{}}, + {Status: 400, Description: "Invalid task filter", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Workspace not found", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: "POST", + Path: "/api/tasks", + OperationID: "createTask", + Summary: "Create a task", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + RequestBody: contract.CreateTaskRequest{}, + Responses: []ResponseSpec{ + {Status: 201, Description: "Created", Body: contract.TaskResponse{}}, + {Status: 400, Description: "Invalid task request", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Workspace not found", Body: contract.ErrorPayload{}}, + {Status: 413, Description: "Payload too large", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: "GET", + Path: "/api/tasks/{id}", + OperationID: "getTask", + Summary: "Get one task with detail", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + pathParam("id", "Task id"), + }, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.TaskDetailResponse{}}, + {Status: 400, Description: "Invalid task id", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Task not found", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: "PATCH", + Path: "/api/tasks/{id}", + OperationID: "updateTask", + Summary: "Update one task", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + pathParam("id", "Task id"), + }, + RequestBody: contract.UpdateTaskRequest{}, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.TaskResponse{}}, + {Status: 400, Description: "Invalid task update", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Task not found", Body: contract.ErrorPayload{}}, + {Status: 409, Description: "Task update conflict", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: "POST", + Path: "/api/tasks/{id}/cancel", + OperationID: "cancelTask", + Summary: "Cancel one task tree", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + pathParam("id", "Task id"), + }, + RequestBody: contract.CancelTaskRequest{}, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.TaskResponse{}}, + {Status: 400, Description: "Invalid task cancel request", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Task not found", Body: contract.ErrorPayload{}}, + {Status: 409, Description: "Task cancel conflict", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: "POST", + Path: "/api/tasks/{id}/children", + OperationID: "createChildTask", + Summary: "Create one child task", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + pathParam("id", "Parent task id"), + }, + RequestBody: contract.CreateTaskChildRequest{}, + Responses: []ResponseSpec{ + {Status: 201, Description: "Created", Body: contract.TaskResponse{}}, + {Status: 400, Description: "Invalid child task request", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Task or workspace not found", Body: contract.ErrorPayload{}}, + {Status: 413, Description: "Payload too large", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: "POST", + Path: "/api/tasks/{id}/dependencies", + OperationID: "addTaskDependency", + Summary: "Add one task dependency", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + pathParam("id", "Task id"), + }, + RequestBody: contract.AddTaskDependencyRequest{}, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.TaskDetailResponse{}}, + {Status: 400, Description: "Invalid dependency request", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Task not found", Body: contract.ErrorPayload{}}, + {Status: 409, Description: "Dependency conflict", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: "DELETE", + Path: "/api/tasks/{id}/dependencies/{depends_on_id}", + OperationID: "removeTaskDependency", + Summary: "Remove one task dependency", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + pathParam("id", "Task id"), + pathParam("depends_on_id", "Dependency task id"), + }, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.TaskDetailResponse{}}, + {Status: 400, Description: "Invalid dependency request", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Task or dependency not found", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: "GET", + Path: "/api/tasks/{id}/runs", + OperationID: "listTaskRuns", + Summary: "List runs for one task", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + pathParam("id", "Task id"), + enumQueryParam("status", "Filter by run status", false, taskRunStatusValues()), + queryParam("session_id", "Filter by attached session id", false), + intQueryParam("limit", "Maximum number of records to return", false), + }, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.TaskRunsResponse{}}, + {Status: 400, Description: "Invalid task-run filter", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Task not found", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: "POST", + Path: "/api/tasks/{id}/runs", + OperationID: "enqueueTaskRun", + Summary: "Enqueue one task run", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + pathParam("id", "Task id"), + }, + RequestBody: contract.EnqueueTaskRunRequest{}, + Responses: []ResponseSpec{ + {Status: 201, Description: "Created", Body: contract.TaskRunResponse{}}, + {Status: 400, Description: "Invalid task-run enqueue request", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Task not found", Body: contract.ErrorPayload{}}, + {Status: 409, Description: "Task-run enqueue conflict", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: "POST", + Path: "/api/task-runs/{id}/claim", + OperationID: "claimTaskRun", + Summary: "Claim one queued task run", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + pathParam("id", "Task run id"), + }, + RequestBody: contract.ClaimTaskRunRequest{}, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.TaskRunResponse{}}, + {Status: 400, Description: "Invalid task-run claim request", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Task run not found", Body: contract.ErrorPayload{}}, + {Status: 409, Description: "Task-run claim conflict", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: "POST", + Path: "/api/task-runs/{id}/start", + OperationID: "startTaskRun", + Summary: "Start one claimed task run", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + pathParam("id", "Task run id"), + }, + RequestBody: contract.StartTaskRunRequest{}, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.TaskRunResponse{}}, + {Status: 400, Description: "Invalid task-run start request", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Task run not found", Body: contract.ErrorPayload{}}, + {Status: 409, Description: "Task-run start conflict", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: "POST", + Path: "/api/task-runs/{id}/attach-session", + OperationID: "attachTaskRunSession", + Summary: "Attach an existing session to one task run", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + pathParam("id", "Task run id"), + }, + RequestBody: contract.AttachTaskRunSessionRequest{}, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.TaskRunResponse{}}, + {Status: 400, Description: "Invalid attach-session request", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Task run or session not found", Body: contract.ErrorPayload{}}, + {Status: 409, Description: "Attach-session conflict", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: "POST", + Path: "/api/task-runs/{id}/complete", + OperationID: "completeTaskRun", + Summary: "Complete one running task run", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + pathParam("id", "Task run id"), + }, + RequestBody: contract.CompleteTaskRunRequest{}, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.TaskRunResponse{}}, + {Status: 400, Description: "Invalid task-run completion request", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Task run not found", Body: contract.ErrorPayload{}}, + {Status: 409, Description: "Task-run completion conflict", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: "POST", + Path: "/api/task-runs/{id}/fail", + OperationID: "failTaskRun", + Summary: "Fail one task run", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + pathParam("id", "Task run id"), + }, + RequestBody: contract.FailTaskRunRequest{}, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.TaskRunResponse{}}, + {Status: 400, Description: "Invalid task-run failure request", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Task run not found", Body: contract.ErrorPayload{}}, + {Status: 409, Description: "Task-run failure conflict", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: "POST", + Path: "/api/task-runs/{id}/cancel", + OperationID: "cancelTaskRun", + Summary: "Cancel one task run", + Tags: []string{"tasks"}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + pathParam("id", "Task run id"), + }, + RequestBody: contract.CancelTaskRunRequest{}, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.TaskRunResponse{}}, + {Status: 400, Description: "Invalid task-run cancel request", Body: contract.ErrorPayload{}}, + {Status: 404, Description: "Task run not found", Body: contract.ErrorPayload{}}, + {Status: 409, Description: "Task-run cancel conflict", Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Task service is not configured", Body: contract.ErrorPayload{}}, + {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}}, + }, + }, { Method: "GET", Path: "/api/skills", @@ -1557,6 +1879,27 @@ func schemaCustomizer(_ string, t reflect.Type, _ reflect.StructTag, schema *ope case reflect.TypeOf(automationpkg.RunStatus("")): setStringEnum(schema, automationRunStatusValues()) return nil + case reflect.TypeOf(taskpkg.Scope("")): + setStringEnum(schema, taskScopeValues()) + return nil + case reflect.TypeOf(taskpkg.TaskStatus("")): + setStringEnum(schema, taskStatusValues()) + return nil + case reflect.TypeOf(taskpkg.TaskRunStatus("")): + setStringEnum(schema, taskRunStatusValues()) + return nil + case reflect.TypeOf(taskpkg.ActorKind("")): + setStringEnum(schema, taskActorKindValues()) + return nil + case reflect.TypeOf(taskpkg.OwnerKind("")): + setStringEnum(schema, taskOwnerKindValues()) + return nil + case reflect.TypeOf(taskpkg.OriginKind("")): + setStringEnum(schema, taskOriginKindValues()) + return nil + case reflect.TypeOf(taskpkg.DependencyKind("")): + setStringEnum(schema, taskDependencyKindValues()) + return nil case reflect.TypeOf(hooks.HookEvent("")): setStringEnum(schema, hookEventValues()) return nil @@ -1822,12 +2165,86 @@ func automationRunStatusValues() []string { return []string{ string(automationpkg.RunScheduled), string(automationpkg.RunRunning), + string(automationpkg.RunDelegated), string(automationpkg.RunCompleted), string(automationpkg.RunFailed), string(automationpkg.RunCancelled), } } +func taskScopeValues() []string { + return []string{ + string(taskpkg.ScopeGlobal), + string(taskpkg.ScopeWorkspace), + } +} + +func taskStatusValues() []string { + return []string{ + string(taskpkg.TaskStatusPending), + string(taskpkg.TaskStatusBlocked), + string(taskpkg.TaskStatusReady), + string(taskpkg.TaskStatusInProgress), + string(taskpkg.TaskStatusCompleted), + string(taskpkg.TaskStatusFailed), + string(taskpkg.TaskStatusCancelled), + } +} + +func taskRunStatusValues() []string { + return []string{ + string(taskpkg.TaskRunStatusQueued), + string(taskpkg.TaskRunStatusClaimed), + string(taskpkg.TaskRunStatusStarting), + string(taskpkg.TaskRunStatusRunning), + string(taskpkg.TaskRunStatusCompleted), + string(taskpkg.TaskRunStatusFailed), + string(taskpkg.TaskRunStatusCancelled), + } +} + +func taskActorKindValues() []string { + return []string{ + string(taskpkg.ActorKindHuman), + string(taskpkg.ActorKindAgentSession), + string(taskpkg.ActorKindAutomation), + string(taskpkg.ActorKindExtension), + string(taskpkg.ActorKindNetworkPeer), + string(taskpkg.ActorKindDaemon), + } +} + +func taskOwnerKindValues() []string { + return []string{ + string(taskpkg.OwnerKindHuman), + string(taskpkg.OwnerKindAgentSession), + string(taskpkg.OwnerKindAutomation), + string(taskpkg.OwnerKindExtension), + string(taskpkg.OwnerKindNetworkPeer), + string(taskpkg.OwnerKindPool), + } +} + +func taskOriginKindValues() []string { + return []string{ + string(taskpkg.OriginKindCLI), + string(taskpkg.OriginKindWeb), + string(taskpkg.OriginKindUDS), + string(taskpkg.OriginKindHTTP), + string(taskpkg.OriginKindAutomation), + string(taskpkg.OriginKindExtension), + string(taskpkg.OriginKindNetwork), + string(taskpkg.OriginKindAgentSession), + string(taskpkg.OriginKindDaemon), + } +} + +func taskDependencyKindValues() []string { + return []string{ + string(taskpkg.DependencyKindBlocks), + } +} + func hookEventValues() []string { events := hooks.AllHookEvents() values := make([]string, 0, len(events)) diff --git a/internal/api/spec/spec_test.go b/internal/api/spec/spec_test.go index a9bfdf6dd..f4314f228 100644 --- a/internal/api/spec/spec_test.go +++ b/internal/api/spec/spec_test.go @@ -12,6 +12,7 @@ import ( "github.com/getkin/kin-openapi/openapi3" extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol" "github.com/pedronauck/agh/internal/hooks" + taskpkg "github.com/pedronauck/agh/internal/task" "github.com/pedronauck/agh/internal/tools" ) @@ -143,7 +144,7 @@ func TestDocumentTracksRequiredFieldsAndEnums(t *testing.T) { runOperation := operationFor(t, doc, "/api/automation/runs/{id}", "GET") runSchema := jsonResponseSchema(t, runOperation, 200) runPayloadSchema := propertySchema(t, runSchema, "run") - assertEnumValues(t, propertySchema(t, runPayloadSchema, "status"), "cancelled", "completed", "failed", "running", "scheduled") + assertEnumValues(t, propertySchema(t, runPayloadSchema, "status"), "cancelled", "completed", "delegated", "failed", "running", "scheduled") }, }, { @@ -200,6 +201,134 @@ func TestDocumentTracksRequiredFieldsAndEnums(t *testing.T) { assertNotRequired(t, healthSchema, "last_success_at", "last_error", "last_error_at") }, }, + { + name: "ShouldRegisterTaskAndTaskRunOperations", + check: func(t *testing.T, doc *openapi3.T) { + t.Helper() + + operations := []struct { + path string + method string + }{ + {path: "/api/tasks", method: "GET"}, + {path: "/api/tasks", method: "POST"}, + {path: "/api/tasks/{id}", method: "GET"}, + {path: "/api/tasks/{id}", method: "PATCH"}, + {path: "/api/tasks/{id}/cancel", method: "POST"}, + {path: "/api/tasks/{id}/children", method: "POST"}, + {path: "/api/tasks/{id}/dependencies", method: "POST"}, + {path: "/api/tasks/{id}/dependencies/{depends_on_id}", method: "DELETE"}, + {path: "/api/tasks/{id}/runs", method: "GET"}, + {path: "/api/tasks/{id}/runs", method: "POST"}, + {path: "/api/task-runs/{id}/claim", method: "POST"}, + {path: "/api/task-runs/{id}/start", method: "POST"}, + {path: "/api/task-runs/{id}/attach-session", method: "POST"}, + {path: "/api/task-runs/{id}/complete", method: "POST"}, + {path: "/api/task-runs/{id}/fail", method: "POST"}, + {path: "/api/task-runs/{id}/cancel", method: "POST"}, + } + + for _, operation := range operations { + operation := operation + t.Run(operation.method+" "+operation.path, func(t *testing.T) { + t.Parallel() + operationFor(t, doc, operation.path, operation.method) + }) + } + }, + }, + { + name: "ShouldDescribeTaskSchemasAndEnums", + check: func(t *testing.T, doc *openapi3.T) { + t.Helper() + + createTask := operationFor(t, doc, "/api/tasks", "POST") + createTaskSchema := jsonRequestSchema(t, createTask) + assertRequired(t, createTaskSchema, "scope", "title") + assertNotRequired(t, createTaskSchema, "id", "identifier", "workspace", "network_channel", "description", "owner", "metadata") + assertEnumValues(t, propertySchema(t, createTaskSchema, "scope"), "global", "workspace") + + createTaskResponse := jsonResponseSchema(t, createTask, 201) + assertRequired(t, createTaskResponse, "task") + taskSchema := propertySchema(t, createTaskResponse, "task") + assertEnumValues(t, propertySchema(t, taskSchema, "scope"), "global", "workspace") + assertEnumValues(t, propertySchema(t, taskSchema, "status"), + string(taskpkg.TaskStatusPending), + string(taskpkg.TaskStatusBlocked), + string(taskpkg.TaskStatusReady), + string(taskpkg.TaskStatusInProgress), + string(taskpkg.TaskStatusCompleted), + string(taskpkg.TaskStatusFailed), + string(taskpkg.TaskStatusCancelled), + ) + assertEnumValues(t, propertySchema(t, propertySchema(t, taskSchema, "owner"), "kind"), + string(taskpkg.OwnerKindHuman), + string(taskpkg.OwnerKindAgentSession), + string(taskpkg.OwnerKindAutomation), + string(taskpkg.OwnerKindExtension), + string(taskpkg.OwnerKindNetworkPeer), + string(taskpkg.OwnerKindPool), + ) + assertEnumValues(t, propertySchema(t, propertySchema(t, taskSchema, "created_by"), "kind"), + string(taskpkg.ActorKindHuman), + string(taskpkg.ActorKindAgentSession), + string(taskpkg.ActorKindAutomation), + string(taskpkg.ActorKindExtension), + string(taskpkg.ActorKindNetworkPeer), + string(taskpkg.ActorKindDaemon), + ) + assertEnumValues(t, propertySchema(t, propertySchema(t, taskSchema, "origin"), "kind"), + string(taskpkg.OriginKindCLI), + string(taskpkg.OriginKindWeb), + string(taskpkg.OriginKindUDS), + string(taskpkg.OriginKindHTTP), + string(taskpkg.OriginKindAutomation), + string(taskpkg.OriginKindExtension), + string(taskpkg.OriginKindNetwork), + string(taskpkg.OriginKindAgentSession), + string(taskpkg.OriginKindDaemon), + ) + + listTaskRuns := operationFor(t, doc, "/api/tasks/{id}/runs", "GET") + assertParameter(t, listTaskRuns, "status", openapi3.ParameterInQuery, false) + assertParameter(t, listTaskRuns, "session_id", openapi3.ParameterInQuery, false) + + claimRun := operationFor(t, doc, "/api/task-runs/{id}/claim", "POST") + claimRunSchema := jsonResponseSchema(t, claimRun, 200) + assertRequired(t, claimRunSchema, "run") + runSchema := propertySchema(t, claimRunSchema, "run") + assertEnumValues(t, propertySchema(t, runSchema, "status"), + string(taskpkg.TaskRunStatusQueued), + string(taskpkg.TaskRunStatusClaimed), + string(taskpkg.TaskRunStatusStarting), + string(taskpkg.TaskRunStatusRunning), + string(taskpkg.TaskRunStatusCompleted), + string(taskpkg.TaskRunStatusFailed), + string(taskpkg.TaskRunStatusCancelled), + ) + assertEnumValues(t, propertySchema(t, propertySchema(t, runSchema, "origin"), "kind"), + string(taskpkg.OriginKindCLI), + string(taskpkg.OriginKindWeb), + string(taskpkg.OriginKindUDS), + string(taskpkg.OriginKindHTTP), + string(taskpkg.OriginKindAutomation), + string(taskpkg.OriginKindExtension), + string(taskpkg.OriginKindNetwork), + string(taskpkg.OriginKindAgentSession), + string(taskpkg.OriginKindDaemon), + ) + + addDependency := operationFor(t, doc, "/api/tasks/{id}/dependencies", "POST") + addDependencySchema := jsonRequestSchema(t, addDependency) + assertRequired(t, addDependencySchema, "depends_on_task_id") + assertNotRequired(t, addDependencySchema, "kind") + assertEnumValues(t, propertySchema(t, addDependencySchema, "kind"), string(taskpkg.DependencyKindBlocks)) + + attachRun := operationFor(t, doc, "/api/task-runs/{id}/attach-session", "POST") + attachRunSchema := jsonRequestSchema(t, attachRun) + assertRequired(t, attachRunSchema, "session_id") + }, + }, } for _, tt := range tests { @@ -251,6 +380,13 @@ func TestSchemaCustomizerCoversAdditionalEnums(t *testing.T) { name string typ any }{ + {name: "TaskScope", typ: taskpkg.Scope(taskpkg.ScopeGlobal)}, + {name: "TaskStatus", typ: taskpkg.TaskStatus(taskpkg.TaskStatusReady)}, + {name: "TaskRunStatus", typ: taskpkg.TaskRunStatus(taskpkg.TaskRunStatusQueued)}, + {name: "TaskActorKind", typ: taskpkg.ActorKind(taskpkg.ActorKindHuman)}, + {name: "TaskOwnerKind", typ: taskpkg.OwnerKind(taskpkg.OwnerKindPool)}, + {name: "TaskOriginKind", typ: taskpkg.OriginKind(taskpkg.OriginKindHTTP)}, + {name: "TaskDependencyKind", typ: taskpkg.DependencyKind(taskpkg.DependencyKindBlocks)}, {name: "HookSkillSource", typ: hooks.HookSkillSource(hooks.HookSkillSourceBundled)}, {name: "HookExecutorKind", typ: hooks.HookExecutorKind(hooks.HookExecutorNative)}, {name: "ToolSource", typ: tools.ToolSource(0)}, @@ -296,6 +432,41 @@ func TestEnumHelpersReturnStableValues(t *testing.T) { got: toolSourceValues(), want: []string{"builtin", "mcp", "extension", "dynamic"}, }, + { + name: "task scope values", + got: taskScopeValues(), + want: []string{"global", "workspace"}, + }, + { + name: "task status values", + got: taskStatusValues(), + want: []string{"pending", "blocked", "ready", "in_progress", "completed", "failed", "cancelled"}, + }, + { + name: "task run status values", + got: taskRunStatusValues(), + want: []string{"queued", "claimed", "starting", "running", "completed", "failed", "cancelled"}, + }, + { + name: "task actor kind values", + got: taskActorKindValues(), + want: []string{"human", "agent_session", "automation", "extension", "network_peer", "daemon"}, + }, + { + name: "task owner kind values", + got: taskOwnerKindValues(), + want: []string{"human", "agent_session", "automation", "extension", "network_peer", "pool"}, + }, + { + name: "task origin kind values", + got: taskOriginKindValues(), + want: []string{"cli", "web", "uds", "http", "automation", "extension", "network", "agent_session", "daemon"}, + }, + { + name: "task dependency kind values", + got: taskDependencyKindValues(), + want: []string{"blocks"}, + }, } for _, tt := range tests { diff --git a/internal/api/testutil/apitest.go b/internal/api/testutil/apitest.go index c3f51fde2..9f0f391c8 100644 --- a/internal/api/testutil/apitest.go +++ b/internal/api/testutil/apitest.go @@ -27,6 +27,7 @@ import ( "github.com/pedronauck/agh/internal/session" "github.com/pedronauck/agh/internal/skills" "github.com/pedronauck/agh/internal/store" + taskpkg "github.com/pedronauck/agh/internal/task" "github.com/pedronauck/agh/internal/transcript" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) @@ -176,6 +177,27 @@ type StubAutomationManager struct { HandleWebhookFn func(context.Context, automationpkg.WebhookRequest) (automationpkg.TriggerResult, error) } +type StubTaskManager struct { + CreateTaskFn func(context.Context, taskpkg.CreateTask, taskpkg.ActorContext) (*taskpkg.Task, error) + CreateChildTaskFn func(context.Context, string, taskpkg.CreateTask, taskpkg.ActorContext) (*taskpkg.Task, error) + UpdateTaskFn func(context.Context, string, taskpkg.TaskPatch, taskpkg.ActorContext) (*taskpkg.Task, error) + CancelTaskFn func(context.Context, string, taskpkg.CancelTask, taskpkg.ActorContext) (*taskpkg.Task, error) + AddDependencyFn func(context.Context, taskpkg.AddDependency, taskpkg.ActorContext) error + RemoveDependencyFn func(context.Context, string, string, taskpkg.ActorContext) error + EnqueueRunFn func(context.Context, taskpkg.EnqueueRun, taskpkg.ActorContext) (*taskpkg.TaskRun, error) + ClaimRunFn func(context.Context, string, taskpkg.ClaimRun, taskpkg.ActorContext) (*taskpkg.TaskRun, error) + StartRunFn func(context.Context, string, taskpkg.StartRun, taskpkg.ActorContext) (*taskpkg.TaskRun, error) + AttachRunSessionFn func(context.Context, string, string, taskpkg.ActorContext) (*taskpkg.TaskRun, error) + CompleteRunFn func(context.Context, string, taskpkg.RunResult, taskpkg.ActorContext) (*taskpkg.TaskRun, error) + FailRunFn func(context.Context, string, taskpkg.RunFailure, taskpkg.ActorContext) (*taskpkg.TaskRun, error) + CancelRunFn func(context.Context, string, taskpkg.CancelRun, taskpkg.ActorContext) (*taskpkg.TaskRun, error) + GetTaskFn func(context.Context, string, taskpkg.ActorContext) (*taskpkg.TaskView, error) + ListTaskRunsFn func(context.Context, string, taskpkg.TaskRunQuery, taskpkg.ActorContext) ([]taskpkg.TaskRun, error) + ListTasksFn func(context.Context, taskpkg.TaskQuery, taskpkg.ActorContext) ([]taskpkg.TaskSummary, error) +} + +var _ core.TaskService = (*StubTaskManager)(nil) + func (s StubAutomationManager) ListJobs(ctx context.Context, query automationpkg.JobListQuery) ([]automationpkg.Job, error) { if s.ListJobsFn != nil { return s.ListJobsFn(ctx, query) @@ -325,6 +347,118 @@ func (s StubAutomationManager) HandleWebhook(ctx context.Context, request automa return automationpkg.TriggerResult{}, nil } +func (s StubTaskManager) CreateTask(ctx context.Context, spec taskpkg.CreateTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) { + if s.CreateTaskFn != nil { + return s.CreateTaskFn(ctx, spec, actor) + } + return nil, taskpkg.ErrTaskNotFound +} + +func (s StubTaskManager) CreateChildTask(ctx context.Context, parentTaskID string, spec taskpkg.CreateTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) { + if s.CreateChildTaskFn != nil { + return s.CreateChildTaskFn(ctx, parentTaskID, spec, actor) + } + return nil, taskpkg.ErrTaskNotFound +} + +func (s StubTaskManager) UpdateTask(ctx context.Context, id string, patch taskpkg.TaskPatch, actor taskpkg.ActorContext) (*taskpkg.Task, error) { + if s.UpdateTaskFn != nil { + return s.UpdateTaskFn(ctx, id, patch, actor) + } + return nil, taskpkg.ErrTaskNotFound +} + +func (s StubTaskManager) CancelTask(ctx context.Context, id string, req taskpkg.CancelTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) { + if s.CancelTaskFn != nil { + return s.CancelTaskFn(ctx, id, req, actor) + } + return nil, taskpkg.ErrTaskNotFound +} + +func (s StubTaskManager) AddDependency(ctx context.Context, spec taskpkg.AddDependency, actor taskpkg.ActorContext) error { + if s.AddDependencyFn != nil { + return s.AddDependencyFn(ctx, spec, actor) + } + return taskpkg.ErrTaskNotFound +} + +func (s StubTaskManager) RemoveDependency(ctx context.Context, taskID string, dependsOnID string, actor taskpkg.ActorContext) error { + if s.RemoveDependencyFn != nil { + return s.RemoveDependencyFn(ctx, taskID, dependsOnID, actor) + } + return taskpkg.ErrTaskNotFound +} + +func (s StubTaskManager) EnqueueRun(ctx context.Context, spec taskpkg.EnqueueRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + if s.EnqueueRunFn != nil { + return s.EnqueueRunFn(ctx, spec, actor) + } + return nil, taskpkg.ErrTaskRunNotFound +} + +func (s StubTaskManager) ClaimRun(ctx context.Context, runID string, claim taskpkg.ClaimRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + if s.ClaimRunFn != nil { + return s.ClaimRunFn(ctx, runID, claim, actor) + } + return nil, taskpkg.ErrTaskRunNotFound +} + +func (s StubTaskManager) StartRun(ctx context.Context, runID string, req taskpkg.StartRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + if s.StartRunFn != nil { + return s.StartRunFn(ctx, runID, req, actor) + } + return nil, taskpkg.ErrTaskRunNotFound +} + +func (s StubTaskManager) AttachRunSession(ctx context.Context, runID string, sessionID string, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + if s.AttachRunSessionFn != nil { + return s.AttachRunSessionFn(ctx, runID, sessionID, actor) + } + return nil, taskpkg.ErrTaskRunNotFound +} + +func (s StubTaskManager) CompleteRun(ctx context.Context, runID string, result taskpkg.RunResult, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + if s.CompleteRunFn != nil { + return s.CompleteRunFn(ctx, runID, result, actor) + } + return nil, taskpkg.ErrTaskRunNotFound +} + +func (s StubTaskManager) FailRun(ctx context.Context, runID string, failure taskpkg.RunFailure, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + if s.FailRunFn != nil { + return s.FailRunFn(ctx, runID, failure, actor) + } + return nil, taskpkg.ErrTaskRunNotFound +} + +func (s StubTaskManager) CancelRun(ctx context.Context, runID string, req taskpkg.CancelRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + if s.CancelRunFn != nil { + return s.CancelRunFn(ctx, runID, req, actor) + } + return nil, taskpkg.ErrTaskRunNotFound +} + +func (s StubTaskManager) GetTask(ctx context.Context, id string, actor taskpkg.ActorContext) (*taskpkg.TaskView, error) { + if s.GetTaskFn != nil { + return s.GetTaskFn(ctx, id, actor) + } + return nil, taskpkg.ErrTaskNotFound +} + +func (s StubTaskManager) ListTaskRuns(ctx context.Context, taskID string, query taskpkg.TaskRunQuery, actor taskpkg.ActorContext) ([]taskpkg.TaskRun, error) { + if s.ListTaskRunsFn != nil { + return s.ListTaskRunsFn(ctx, taskID, query, actor) + } + return nil, nil +} + +func (s StubTaskManager) ListTasks(ctx context.Context, query taskpkg.TaskQuery, actor taskpkg.ActorContext) ([]taskpkg.TaskSummary, error) { + if s.ListTasksFn != nil { + return s.ListTasksFn(ctx, query, actor) + } + return nil, nil +} + func (s StubObserver) QueryEvents(ctx context.Context, query store.EventSummaryQuery) ([]store.EventSummary, error) { if s.QueryEventsFn != nil { return s.QueryEventsFn(ctx, query) diff --git a/internal/api/udsapi/handlers_test.go b/internal/api/udsapi/handlers_test.go index 163077e02..9ea2861ea 100644 --- a/internal/api/udsapi/handlers_test.go +++ b/internal/api/udsapi/handlers_test.go @@ -81,6 +81,7 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "DELETE /api/automation/triggers/:id", "DELETE /api/memory/:filename", "DELETE /api/sessions/:id", + "DELETE /api/tasks/:id/dependencies/:depends_on_id", "DELETE /api/workspaces/:id", "GET /api/agents", "GET /api/agents/:name", @@ -123,11 +124,15 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "GET /api/skills", "GET /api/skills/:name", "GET /api/skills/:name/content", + "GET /api/tasks", + "GET /api/tasks/:id", + "GET /api/tasks/:id/runs", "GET /api/workspaces", "GET /api/workspaces/:id", "PATCH /api/automation/jobs/:id", "PATCH /api/automation/triggers/:id", "PATCH /api/bridges/:id", + "PATCH /api/tasks/:id", "PATCH /api/workspaces/:id", "POST /api/automation/jobs", "POST /api/automation/jobs/:id/trigger", @@ -149,6 +154,17 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "POST /api/sessions/:id/resume", "POST /api/skills/:name/disable", "POST /api/skills/:name/enable", + "POST /api/task-runs/:id/attach-session", + "POST /api/task-runs/:id/cancel", + "POST /api/task-runs/:id/claim", + "POST /api/task-runs/:id/complete", + "POST /api/task-runs/:id/fail", + "POST /api/task-runs/:id/start", + "POST /api/tasks", + "POST /api/tasks/:id/cancel", + "POST /api/tasks/:id/children", + "POST /api/tasks/:id/dependencies", + "POST /api/tasks/:id/runs", "POST /api/workspaces", "POST /api/workspaces/resolve", "PUT /api/memory/:filename", diff --git a/internal/api/udsapi/helpers_test.go b/internal/api/udsapi/helpers_test.go index f2d6f4621..f21811a0b 100644 --- a/internal/api/udsapi/helpers_test.go +++ b/internal/api/udsapi/helpers_test.go @@ -25,6 +25,7 @@ var errStubWorkspaceServiceNotImplemented = testutil.ErrStubWorkspaceServiceNotI type stubSessionManager = testutil.StubSessionManager type stubObserver = testutil.StubObserver +type stubTaskManager = testutil.StubTaskManager type stubBridgeService = testutil.StubBridgeService type stubNetworkService = testutil.StubNetworkService type stubWorkspaceService = testutil.StubWorkspaceService @@ -33,7 +34,7 @@ type sseRecord = testutil.SSERecord func newTestHandlers(t *testing.T, manager core.SessionManager, observer core.Observer, homePaths aghconfig.HomePaths) *Handlers { t.Helper() - return newTestHandlersWithRuntime(t, manager, observer, nil, nil, stubWorkspaceService{}, nil, homePaths) + return newTestHandlersWithRuntime(t, manager, observer, nil, stubTaskManager{}, nil, stubWorkspaceService{}, nil, homePaths) } func newTestHandlersWithBridges( @@ -45,12 +46,12 @@ func newTestHandlersWithBridges( homePaths aghconfig.HomePaths, ) *Handlers { t.Helper() - return newTestHandlersWithRuntime(t, manager, observer, nil, bridges, workspaces, nil, homePaths) + return newTestHandlersWithRuntime(t, manager, observer, nil, stubTaskManager{}, bridges, workspaces, nil, homePaths) } func newTestHandlersWithExtensions(t *testing.T, manager core.SessionManager, observer core.Observer, extensions ExtensionService, homePaths aghconfig.HomePaths) *Handlers { t.Helper() - return newTestHandlersWithRuntime(t, manager, observer, nil, nil, stubWorkspaceService{}, extensions, homePaths) + return newTestHandlersWithRuntime(t, manager, observer, nil, stubTaskManager{}, nil, stubWorkspaceService{}, extensions, homePaths) } func newTestHandlersWithRuntime( @@ -58,6 +59,7 @@ func newTestHandlersWithRuntime( manager core.SessionManager, observer core.Observer, automation core.AutomationManager, + tasks core.TaskService, bridges core.BridgeService, workspaces core.WorkspaceService, extensions ExtensionService, @@ -67,6 +69,7 @@ func newTestHandlersWithRuntime( return newHandlers(handlerConfig{ sessions: manager, + tasks: tasks, observer: observer, automation: automation, bridges: bridges, diff --git a/internal/api/udsapi/routes.go b/internal/api/udsapi/routes.go index 3dd592457..91faf704d 100644 --- a/internal/api/udsapi/routes.go +++ b/internal/api/udsapi/routes.go @@ -89,6 +89,30 @@ func RegisterRoutes(router gin.IRouter, handlers *Handlers) { runs.GET("/:id", handlers.GetAutomationRun) } + tasks := api.Group("/tasks") + { + tasks.POST("", handlers.CreateTask) + tasks.GET("", handlers.ListTasks) + tasks.GET("/:id", handlers.GetTask) + tasks.PATCH("/:id", handlers.UpdateTask) + tasks.POST("/:id/cancel", handlers.CancelTask) + tasks.POST("/:id/children", handlers.CreateChildTask) + tasks.POST("/:id/dependencies", handlers.AddTaskDependency) + tasks.DELETE("/:id/dependencies/:depends_on_id", handlers.RemoveTaskDependency) + tasks.POST("/:id/runs", handlers.EnqueueTaskRun) + tasks.GET("/:id/runs", handlers.ListTaskRuns) + } + + taskRuns := api.Group("/task-runs") + { + taskRuns.POST("/:id/claim", handlers.ClaimTaskRun) + taskRuns.POST("/:id/start", handlers.StartTaskRun) + taskRuns.POST("/:id/attach-session", handlers.AttachTaskRunSession) + taskRuns.POST("/:id/complete", handlers.CompleteTaskRun) + taskRuns.POST("/:id/fail", handlers.FailTaskRun) + taskRuns.POST("/:id/cancel", handlers.CancelTaskRun) + } + skillsGroup := api.Group("/skills") { skillsGroup.GET("", handlers.ListSkills) diff --git a/internal/api/udsapi/server.go b/internal/api/udsapi/server.go index cfc671295..f699fb336 100644 --- a/internal/api/udsapi/server.go +++ b/internal/api/udsapi/server.go @@ -27,6 +27,13 @@ const ( defaultIdleTimeout = 60 * time.Second ) +var ( + ErrSessionManagerRequired = errors.New("udsapi: session manager is required") + ErrTaskServiceRequired = errors.New("udsapi: task service is required") + ErrObserverRequired = errors.New("udsapi: observer is required") + ErrWorkspaceResolverRequired = errors.New("udsapi: workspace resolver is required") +) + // Option customizes UDS server construction. type Option func(*Server) @@ -51,6 +58,7 @@ type Server struct { now func() time.Time pollInterval time.Duration sessions core.SessionManager + tasks core.TaskService network core.NetworkService networkStore core.NetworkStore observer core.Observer @@ -75,6 +83,7 @@ type Server struct { type handlerConfig struct { sessions core.SessionManager + tasks core.TaskService network core.NetworkService networkStore core.NetworkStore observer core.Observer @@ -156,6 +165,13 @@ func WithSessionManager(manager core.SessionManager) Option { } } +// WithTaskService injects the daemon-owned task service. +func WithTaskService(service core.TaskService) Option { + return func(server *Server) { + server.tasks = service + } +} + // WithNetworkService injects the runtime network manager. func WithNetworkService(service core.NetworkService) Option { return func(server *Server) { @@ -281,13 +297,16 @@ func New(opts ...Option) (*Server, error) { server.agentLoader = aghconfig.LoadAgentDef } if server.sessions == nil { - return nil, errors.New("udsapi: session manager is required") + return nil, ErrSessionManagerRequired + } + if server.tasks == nil { + return nil, ErrTaskServiceRequired } if server.observer == nil { - return nil, errors.New("udsapi: observer is required") + return nil, ErrObserverRequired } if server.workspaces == nil { - return nil, errors.New("udsapi: workspace resolver is required") + return nil, ErrWorkspaceResolverRequired } if strings.TrimSpace(server.config.Daemon.Socket) == "" { server.config.Daemon.Socket = server.homePaths.DaemonSocket @@ -305,6 +324,7 @@ func New(opts ...Option) (*Server, error) { server.handlers = newHandlers(handlerConfig{ sessions: server.sessions, + tasks: server.tasks, network: server.network, networkStore: server.networkStore, observer: server.observer, @@ -516,6 +536,7 @@ func newHandlers(cfg handlerConfig) *Handlers { MaskInternalErrors: false, IncludeSessionWorkspaceInSSE: true, Sessions: cfg.sessions, + Tasks: cfg.tasks, Network: cfg.network, NetworkStore: cfg.networkStore, Observer: cfg.observer, diff --git a/internal/api/udsapi/server_test.go b/internal/api/udsapi/server_test.go index 0ea8fd824..592a9821c 100644 --- a/internal/api/udsapi/server_test.go +++ b/internal/api/udsapi/server_test.go @@ -41,6 +41,7 @@ func TestNewHonorsOptionsAndDefaults(t *testing.T) { WithNow(now), WithPollInterval(25*time.Millisecond), WithSessionManager(stubSessionManager{}), + WithTaskService(stubTaskManager{}), WithObserver(stubObserver{}), WithBridgeService(bridgeService), WithWorkspaceResolver(stubWorkspaceService{}), @@ -96,26 +97,81 @@ func TestPathHandlesNilServer(t *testing.T) { } } -func TestNewRequiresSessionManagerObserverAndWorkspaceResolver(t *testing.T) { - homePaths := newTestHomePaths(t) +func TestNewRequiresSessionManagerTaskServiceObserverAndWorkspaceResolver(t *testing.T) { + t.Parallel() - if _, err := New(WithHomePaths(homePaths), WithObserver(stubObserver{})); err == nil { - t.Fatal("New() without session manager error = nil, want non-nil") - } - if _, err := New(WithHomePaths(homePaths), WithSessionManager(stubSessionManager{})); err == nil { - t.Fatal("New() without observer error = nil, want non-nil") - } - if _, err := New(WithHomePaths(homePaths), WithSessionManager(stubSessionManager{}), WithObserver(stubObserver{})); err == nil { - t.Fatal("New() without workspace resolver error = nil, want non-nil") + homePaths := newTestHomePaths(t) + testCases := []struct { + name string + opts []Option + wantErr error + }{ + { + name: "Should require a session manager", + opts: []Option{ + WithHomePaths(homePaths), + WithTaskService(stubTaskManager{}), + WithObserver(stubObserver{}), + WithWorkspaceResolver(stubWorkspaceService{}), + }, + wantErr: ErrSessionManagerRequired, + }, + { + name: "Should require a task service", + opts: []Option{ + WithHomePaths(homePaths), + WithSessionManager(stubSessionManager{}), + WithObserver(stubObserver{}), + WithWorkspaceResolver(stubWorkspaceService{}), + }, + wantErr: ErrTaskServiceRequired, + }, + { + name: "Should require an observer", + opts: []Option{ + WithHomePaths(homePaths), + WithSessionManager(stubSessionManager{}), + WithTaskService(stubTaskManager{}), + WithWorkspaceResolver(stubWorkspaceService{}), + }, + wantErr: ErrObserverRequired, + }, + { + name: "Should require a workspace resolver", + opts: []Option{ + WithHomePaths(homePaths), + WithSessionManager(stubSessionManager{}), + WithTaskService(stubTaskManager{}), + WithObserver(stubObserver{}), + }, + wantErr: ErrWorkspaceResolverRequired, + }, } - if _, err := New( - WithHomePaths(homePaths), - WithSessionManager(stubSessionManager{}), - WithObserver(stubObserver{}), - WithWorkspaceResolver(stubWorkspaceService{}), - ); err != nil { - t.Fatalf("New() without skills registry error = %v, want nil", err) + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if _, err := New(tc.opts...); err == nil || !errors.Is(err, tc.wantErr) { + t.Fatalf("New() error = %v, want %v", err, tc.wantErr) + } + }) } + + t.Run("Should allow missing skills registry", func(t *testing.T) { + t.Parallel() + + if _, err := New( + WithHomePaths(homePaths), + WithSessionManager(stubSessionManager{}), + WithTaskService(stubTaskManager{}), + WithObserver(stubObserver{}), + WithWorkspaceResolver(stubWorkspaceService{}), + ); err != nil { + t.Fatalf("New() without skills registry error = %v, want nil", err) + } + }) } func TestServerStartAndShutdownCreatesAndRemovesSocket(t *testing.T) { @@ -132,6 +188,7 @@ func TestServerStartAndShutdownCreatesAndRemovesSocket(t *testing.T) { WithSessionManager(stubSessionManager{ ListAllFn: func(context.Context) ([]*session.SessionInfo, error) { return nil, nil }, }), + WithTaskService(stubTaskManager{}), WithObserver(stubObserver{ HealthFn: func(context.Context) (observe.Health, error) { return observe.Health{Status: "ok"}, nil }, }), @@ -190,6 +247,7 @@ func TestServerStartRejectsNilContextAndDuplicateStart(t *testing.T) { WithSocketPath(socketPath), WithLogger(discardLogger()), WithSessionManager(stubSessionManager{}), + WithTaskService(stubTaskManager{}), WithObserver(stubObserver{}), WithWorkspaceResolver(stubWorkspaceService{}), WithSkillsRegistry(stubSkillsRegistry{}), @@ -223,6 +281,7 @@ func TestServerStartRejectsRegularFileAtSocketPath(t *testing.T) { WithSocketPath(socketPath), WithLogger(discardLogger()), WithSessionManager(stubSessionManager{}), + WithTaskService(stubTaskManager{}), WithObserver(stubObserver{}), WithWorkspaceResolver(stubWorkspaceService{}), WithSkillsRegistry(stubSkillsRegistry{}), diff --git a/internal/api/udsapi/udsapi_integration_test.go b/internal/api/udsapi/udsapi_integration_test.go index b5e3c53f5..67d5450f2 100644 --- a/internal/api/udsapi/udsapi_integration_test.go +++ b/internal/api/udsapi/udsapi_integration_test.go @@ -26,6 +26,7 @@ import ( "github.com/pedronauck/agh/internal/observe" "github.com/pedronauck/agh/internal/session" "github.com/pedronauck/agh/internal/store/globaldb" + taskpkg "github.com/pedronauck/agh/internal/task" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) @@ -364,6 +365,7 @@ func TestUDSShutdownWaitsForInflightRequests(t *testing.T) { return []*session.SessionInfo{newSessionInfo("sess-1")}, nil }, }), + WithTaskService(stubTaskManager{}), WithObserver(stubObserver{ HealthFn: func(context.Context) (observe.Health, error) { return observe.Health{Status: "ok"}, nil }, }), @@ -506,10 +508,232 @@ func TestUDSSessionChannelRoundTrip(t *testing.T) { } } +func TestUDSTaskRoutesRoundTrip(t *testing.T) { + runtime := newIntegrationRuntime(t) + + created := createIntegrationTask(t, runtime, []byte(`{ + "scope":"global", + "title":"Ship task routes", + "description":"Expose the transport routes", + "network_channel":"builders", + "owner":{"kind":"pool","ref":"ops"}, + "metadata":{"priority":"high"} + }`)) + if created.ID == "" { + t.Fatal("expected created task id") + } + if created.Scope != taskpkg.ScopeGlobal { + t.Fatalf("created scope = %q, want %q", created.Scope, taskpkg.ScopeGlobal) + } + if created.NetworkChannel != "builders" { + t.Fatalf("created network_channel = %q, want %q", created.NetworkChannel, "builders") + } + if created.Owner == nil || created.Owner.Kind != taskpkg.OwnerKindPool || created.Owner.Ref != "ops" { + t.Fatalf("created owner = %#v, want pool/ops", created.Owner) + } + if created.Origin.Kind != taskpkg.OriginKindUDS { + t.Fatalf("created origin.kind = %q, want %q", created.Origin.Kind, taskpkg.OriginKindUDS) + } + if created.CreatedBy.Ref != "local-user" { + t.Fatalf("created created_by.ref = %q, want %q", created.CreatedBy.Ref, "local-user") + } + if got := strings.TrimSpace(string(created.Metadata)); got != `{"priority":"high"}` { + t.Fatalf("created metadata = %s, want %s", got, `{"priority":"high"}`) + } + + listResp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/tasks?scope=global&status=ready&owner_kind=pool&owner_ref=ops&network_channel=builders", nil, nil) + if listResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(listResp.Body) + _ = listResp.Body.Close() + t.Fatalf("list tasks status = %d, want %d; body=%s", listResp.StatusCode, http.StatusOK, string(body)) + } + var listed contract.TasksResponse + decodeHTTPJSON(t, listResp, &listed) + if len(listed.Tasks) != 1 || listed.Tasks[0].ID != created.ID { + t.Fatalf("listed tasks = %#v, want created task", listed.Tasks) + } + + getResp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/tasks/"+created.ID, nil, nil) + if getResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(getResp.Body) + _ = getResp.Body.Close() + t.Fatalf("get task status = %d, want %d; body=%s", getResp.StatusCode, http.StatusOK, string(body)) + } + var detail contract.TaskDetailResponse + decodeHTTPJSON(t, getResp, &detail) + if detail.Task.Task.ID != created.ID { + t.Fatalf("detail task id = %q, want %q", detail.Task.Task.ID, created.ID) + } + if len(detail.Task.Children) != 0 || len(detail.Task.Runs) != 0 { + t.Fatalf("detail task children/runs = %#v, want empty", detail.Task) + } + + updateResp := mustUnixRequest(t, runtime.client, http.MethodPatch, "http://unix/api/tasks/"+created.ID, []byte(`{ + "title":"Ship task routes now", + "description":"Expose the task and run transports everywhere", + "network_channel":"ops", + "clear_owner":true + }`), nil) + if updateResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(updateResp.Body) + _ = updateResp.Body.Close() + t.Fatalf("update task status = %d, want %d; body=%s", updateResp.StatusCode, http.StatusOK, string(body)) + } + var updated contract.TaskResponse + decodeHTTPJSON(t, updateResp, &updated) + if updated.Task.Title != "Ship task routes now" { + t.Fatalf("updated title = %q, want %q", updated.Task.Title, "Ship task routes now") + } + if updated.Task.Description != "Expose the task and run transports everywhere" { + t.Fatalf("updated description = %q", updated.Task.Description) + } + if updated.Task.NetworkChannel != "ops" { + t.Fatalf("updated network_channel = %q, want %q", updated.Task.NetworkChannel, "ops") + } + if updated.Task.Owner != nil { + t.Fatalf("updated owner = %#v, want nil", updated.Task.Owner) + } + + updatedListResp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/tasks?scope=global&status=ready&network_channel=ops", nil, nil) + if updatedListResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(updatedListResp.Body) + _ = updatedListResp.Body.Close() + t.Fatalf("updated list tasks status = %d, want %d; body=%s", updatedListResp.StatusCode, http.StatusOK, string(body)) + } + var updatedList contract.TasksResponse + decodeHTTPJSON(t, updatedListResp, &updatedList) + if len(updatedList.Tasks) != 1 || updatedList.Tasks[0].ID != created.ID { + t.Fatalf("updated list tasks = %#v, want created task", updatedList.Tasks) + } +} + +func TestUDSTaskRunLifecycleRoutesRoundTrip(t *testing.T) { + runtime := newIntegrationRuntime(t) + created := createIntegrationTask(t, runtime, []byte(`{"scope":"global","title":"Run task routes"}`)) + + queued := enqueueIntegrationTaskRun(t, runtime, created.ID, `{"idempotency_key":"enqueue-1","network_channel":"builders"}`) + if queued.Status != taskpkg.TaskRunStatusQueued { + t.Fatalf("queued status = %q, want %q", queued.Status, taskpkg.TaskRunStatusQueued) + } + if queued.NetworkChannel != "builders" { + t.Fatalf("queued network_channel = %q, want %q", queued.NetworkChannel, "builders") + } + + listQueuedResp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/tasks/"+created.ID+"/runs?status=queued&limit=1", nil, nil) + if listQueuedResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(listQueuedResp.Body) + _ = listQueuedResp.Body.Close() + t.Fatalf("list queued runs status = %d, want %d; body=%s", listQueuedResp.StatusCode, http.StatusOK, string(body)) + } + var queuedList contract.TaskRunsResponse + decodeHTTPJSON(t, listQueuedResp, &queuedList) + if len(queuedList.Runs) != 1 || queuedList.Runs[0].ID != queued.ID { + t.Fatalf("queued runs = %#v, want queued run", queuedList.Runs) + } + + claimed := claimIntegrationTaskRun(t, runtime, queued.ID, `{"idempotency_key":"claim-1"}`) + if claimed.Status != taskpkg.TaskRunStatusClaimed { + t.Fatalf("claimed status = %q, want %q", claimed.Status, taskpkg.TaskRunStatusClaimed) + } + if claimed.ClaimedBy == nil || claimed.ClaimedBy.Ref != "local-user" { + t.Fatalf("claimed claimed_by = %#v, want local-user", claimed.ClaimedBy) + } + + startResp := mustUnixRequest(t, runtime.client, http.MethodPost, "http://unix/api/task-runs/"+queued.ID+"/start", []byte(`{"idempotency_key":"start-1"}`), nil) + if startResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(startResp.Body) + _ = startResp.Body.Close() + t.Fatalf("start run status = %d, want %d; body=%s", startResp.StatusCode, http.StatusOK, string(body)) + } + var started contract.TaskRunResponse + decodeHTTPJSON(t, startResp, &started) + if started.Run.Status != taskpkg.TaskRunStatusRunning { + t.Fatalf("started status = %q, want %q", started.Run.Status, taskpkg.TaskRunStatusRunning) + } + if started.Run.SessionID == "" { + t.Fatal("expected started run session id") + } + + completeResp := mustUnixRequest(t, runtime.client, http.MethodPost, "http://unix/api/task-runs/"+queued.ID+"/complete", []byte(`{"result":{"ok":true}}`), nil) + if completeResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(completeResp.Body) + _ = completeResp.Body.Close() + t.Fatalf("complete run status = %d, want %d; body=%s", completeResp.StatusCode, http.StatusOK, string(body)) + } + var completed contract.TaskRunResponse + decodeHTTPJSON(t, completeResp, &completed) + if completed.Run.Status != taskpkg.TaskRunStatusCompleted { + t.Fatalf("completed status = %q, want %q", completed.Run.Status, taskpkg.TaskRunStatusCompleted) + } + + secondRun := enqueueIntegrationTaskRun(t, runtime, created.ID, `{"idempotency_key":"enqueue-2"}`) + claimIntegrationTaskRun(t, runtime, secondRun.ID, `{"idempotency_key":"claim-2"}`) + attachResp := mustUnixRequest(t, runtime.client, http.MethodPost, "http://unix/api/task-runs/"+secondRun.ID+"/attach-session", []byte(`{"session_id":"sess-resume-1"}`), nil) + if attachResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(attachResp.Body) + _ = attachResp.Body.Close() + t.Fatalf("attach run session status = %d, want %d; body=%s", attachResp.StatusCode, http.StatusOK, string(body)) + } + var attached contract.TaskRunResponse + decodeHTTPJSON(t, attachResp, &attached) + if attached.Run.Status != taskpkg.TaskRunStatusStarting { + t.Fatalf("attached status = %q, want %q", attached.Run.Status, taskpkg.TaskRunStatusStarting) + } + if attached.Run.SessionID != "sess-resume-1" { + t.Fatalf("attached session_id = %q, want %q", attached.Run.SessionID, "sess-resume-1") + } + + failResp := mustUnixRequest(t, runtime.client, http.MethodPost, "http://unix/api/task-runs/"+secondRun.ID+"/fail", []byte(`{"error":"boom","metadata":{"step":"attach"}}`), nil) + if failResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(failResp.Body) + _ = failResp.Body.Close() + t.Fatalf("fail run status = %d, want %d; body=%s", failResp.StatusCode, http.StatusOK, string(body)) + } + var failed contract.TaskRunResponse + decodeHTTPJSON(t, failResp, &failed) + if failed.Run.Status != taskpkg.TaskRunStatusFailed { + t.Fatalf("failed status = %q, want %q", failed.Run.Status, taskpkg.TaskRunStatusFailed) + } + + thirdRun := enqueueIntegrationTaskRun(t, runtime, created.ID, `{"idempotency_key":"enqueue-3"}`) + cancelResp := mustUnixRequest(t, runtime.client, http.MethodPost, "http://unix/api/task-runs/"+thirdRun.ID+"/cancel", []byte(`{"reason":"operator cancelled"}`), nil) + if cancelResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(cancelResp.Body) + _ = cancelResp.Body.Close() + t.Fatalf("cancel run status = %d, want %d; body=%s", cancelResp.StatusCode, http.StatusOK, string(body)) + } + var cancelled contract.TaskRunResponse + decodeHTTPJSON(t, cancelResp, &cancelled) + if cancelled.Run.Status != taskpkg.TaskRunStatusCancelled { + t.Fatalf("cancelled status = %q, want %q", cancelled.Run.Status, taskpkg.TaskRunStatusCancelled) + } + + finalRunsResp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/tasks/"+created.ID+"/runs", nil, nil) + if finalRunsResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(finalRunsResp.Body) + _ = finalRunsResp.Body.Close() + t.Fatalf("final list runs status = %d, want %d; body=%s", finalRunsResp.StatusCode, http.StatusOK, string(body)) + } + var finalRuns contract.TaskRunsResponse + decodeHTTPJSON(t, finalRunsResp, &finalRuns) + if len(finalRuns.Runs) != 3 { + t.Fatalf("len(final runs) = %d, want 3", len(finalRuns.Runs)) + } + + seenStatuses := map[taskpkg.TaskRunStatus]int{} + for _, run := range finalRuns.Runs { + seenStatuses[run.Status]++ + } + if seenStatuses[taskpkg.TaskRunStatusCompleted] != 1 || seenStatuses[taskpkg.TaskRunStatusFailed] != 1 || seenStatuses[taskpkg.TaskRunStatusCancelled] != 1 { + t.Fatalf("final run statuses = %#v, want one completed, failed, cancelled", seenStatuses) + } +} + type integrationRuntime struct { client *http.Client server *Server manager *session.Manager + tasks *taskpkg.TaskManager observer *observe.Observer registry *globaldb.GlobalDB bridges *integrationBridgeService @@ -519,6 +743,27 @@ type integrationRuntime struct { workspace string } +type integrationTaskSessionExecutor struct { + started int +} + +func (e *integrationTaskSessionExecutor) StartTaskSession(_ context.Context, _ taskpkg.StartTaskSession) (*taskpkg.SessionRef, error) { + e.started++ + return &taskpkg.SessionRef{SessionID: fmt.Sprintf("task-sess-%d", e.started)}, nil +} + +func (*integrationTaskSessionExecutor) AttachTaskSession(_ context.Context, _ string, sessionID string) (*taskpkg.SessionRef, error) { + return &taskpkg.SessionRef{SessionID: sessionID}, nil +} + +func (*integrationTaskSessionExecutor) RequestTaskStop(context.Context, string, taskpkg.StopReason) error { + return nil +} + +func (*integrationTaskSessionExecutor) ForceTaskStop(context.Context, string, taskpkg.StopReason) error { + return nil +} + type integrationDreamTrigger struct { enabled bool triggered bool @@ -806,12 +1051,22 @@ func newIntegrationRuntime(t *testing.T) integrationRuntime { } }) + taskExecutor := &integrationTaskSessionExecutor{} + taskManager, err := taskpkg.NewManager( + taskpkg.WithStore(registry), + taskpkg.WithSessionExecutor(taskExecutor), + ) + if err != nil { + t.Fatalf("task.NewManager() error = %v", err) + } + server, err := New( WithHomePaths(homePaths), WithConfig(cfg), WithSocketPath(socketPath), WithLogger(discardLogger()), WithSessionManager(manager), + WithTaskService(taskManager), WithObserver(observer), WithAutomation(automationManager), WithBridgeService(bridgeService), @@ -838,6 +1093,7 @@ func newIntegrationRuntime(t *testing.T) integrationRuntime { client: newUnixClient(t, socketPath), server: server, manager: manager, + tasks: taskManager, observer: observer, registry: registry, bridges: bridgeService, @@ -864,6 +1120,48 @@ func createIntegrationSession(t *testing.T, runtime integrationRuntime) string { return created.Session.ID } +func createIntegrationTask(t *testing.T, runtime integrationRuntime, body []byte) contract.TaskPayload { + t.Helper() + + resp := mustUnixRequest(t, runtime.client, http.MethodPost, "http://unix/api/tasks", body, nil) + if resp.StatusCode != http.StatusCreated { + payload, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + t.Fatalf("create task status = %d, want %d; body=%s", resp.StatusCode, http.StatusCreated, string(payload)) + } + var created contract.TaskResponse + decodeHTTPJSON(t, resp, &created) + return created.Task +} + +func enqueueIntegrationTaskRun(t *testing.T, runtime integrationRuntime, taskID string, body string) contract.TaskRunPayload { + t.Helper() + + resp := mustUnixRequest(t, runtime.client, http.MethodPost, "http://unix/api/tasks/"+taskID+"/runs", []byte(body), nil) + if resp.StatusCode != http.StatusCreated { + payload, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + t.Fatalf("enqueue run status = %d, want %d; body=%s", resp.StatusCode, http.StatusCreated, string(payload)) + } + var created contract.TaskRunResponse + decodeHTTPJSON(t, resp, &created) + return created.Run +} + +func claimIntegrationTaskRun(t *testing.T, runtime integrationRuntime, runID string, body string) contract.TaskRunPayload { + t.Helper() + + resp := mustUnixRequest(t, runtime.client, http.MethodPost, "http://unix/api/task-runs/"+runID+"/claim", []byte(body), nil) + if resp.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + t.Fatalf("claim run status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, string(payload)) + } + var claimed contract.TaskRunResponse + decodeHTTPJSON(t, resp, &claimed) + return claimed.Run +} + func sendPrompt(t *testing.T, runtime integrationRuntime, sessionID string, message string) { t.Helper() diff --git a/internal/automation/dispatch.go b/internal/automation/dispatch.go index f94d13df1..13a55aaa0 100644 --- a/internal/automation/dispatch.go +++ b/internal/automation/dispatch.go @@ -13,6 +13,7 @@ import ( "github.com/pedronauck/agh/internal/acp" hookspkg "github.com/pedronauck/agh/internal/hooks" "github.com/pedronauck/agh/internal/session" + taskpkg "github.com/pedronauck/agh/internal/task" ) var ( @@ -128,6 +129,20 @@ type RunStore interface { CountRuns(ctx context.Context, query RunQuery) (int64, error) } +// TaskService exposes the minimal task-domain surface used by task-backed +// automation jobs. +type TaskService interface { + CreateTask(ctx context.Context, spec taskpkg.CreateTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) + EnqueueRun(ctx context.Context, spec taskpkg.EnqueueRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) +} + +// AutomationSessionTaskActorRecorder stores trusted task-domain provenance for +// automation-launched sessions that may later create tasks explicitly. +type AutomationSessionTaskActorRecorder interface { + RecordAutomationSessionTaskActor(sessionID string, actor taskpkg.ActorContext) error + DeleteAutomationSessionTaskActor(sessionID string) +} + // AutomationHookDispatcher emits automation lifecycle hooks around shared dispatch. type AutomationHookDispatcher interface { DispatchAutomationJobPreFire(ctx context.Context, payload hookspkg.AutomationJobPreFirePayload) (hookspkg.AutomationJobPreFirePayload, error) @@ -148,6 +163,7 @@ type DispatcherOption func(*Dispatcher) type Dispatcher struct { sessions SessionCreator runs RunStore + tasks TaskService logger *slog.Logger now func() time.Time @@ -155,6 +171,7 @@ type Dispatcher struct { globalWorkspacePath string maxConcurrent int hooks AutomationHookDispatcher + taskActors AutomationSessionTaskActorRecorder fireLimitMu sync.Mutex gate chan struct{} @@ -246,6 +263,22 @@ func WithDispatcherHooks(hooks AutomationHookDispatcher) DispatcherOption { } } +// WithDispatcherTasks injects the task-domain service used for direct +// task-backed automation jobs. +func WithDispatcherTasks(tasks TaskService) DispatcherOption { + return func(dispatcher *Dispatcher) { + dispatcher.tasks = tasks + } +} + +// WithDispatcherTaskActorRecorder injects the session provenance recorder used +// to support automation-linked agent task creation. +func WithDispatcherTaskActorRecorder(recorder AutomationSessionTaskActorRecorder) DispatcherOption { + return func(dispatcher *Dispatcher) { + dispatcher.taskActors = recorder + } +} + // Dispatch executes one automation request through the shared governance path. func (d *Dispatcher) Dispatch(ctx context.Context, req DispatchRequest) (*Run, error) { if ctx == nil { @@ -308,6 +341,9 @@ func (d *Dispatcher) dispatchAttempt(ctx context.Context, req DispatchRequest, a if err != nil { return nil, err } + if req.Job != nil && req.Job.Task != nil { + return d.dispatchTaskBackedAttempt(ctx, req, scheduledRun, attempt) + } prompt, promptErr := req.prompt() if promptErr != nil { @@ -337,6 +373,9 @@ func (d *Dispatcher) dispatchAttempt(ctx context.Context, req DispatchRequest, a if err != nil { return cloneRun(scheduledRun), err } + if err := d.recordAutomationSessionTaskActor(createdSession.ID, runningRun); err != nil { + return d.finishRunAfterSessionStop(ctx, runningRun, createdSession.ID, RunFailed, err) + } events, promptErr := d.sessions.Prompt(ctx, createdSession.ID, prompt) if promptErr != nil { @@ -405,6 +444,56 @@ func (d *Dispatcher) reserveRun(ctx context.Context, req DispatchRequest, attemp return &created, nil } +func (d *Dispatcher) dispatchTaskBackedAttempt(ctx context.Context, req DispatchRequest, scheduledRun *Run, attempt int) (*Run, error) { + if d.tasks == nil { + return d.finishRun(ctx, scheduledRun, RunFailed, errors.New("automation: task-backed job requires task service")) + } + + preFirePrompt := strings.TrimSpace(req.Prompt) + if preFirePrompt == "" && req.Job != nil { + preFirePrompt = strings.TrimSpace(req.Job.Prompt) + } + preFirePrompt, cancelled, hookErr := d.dispatchPreFireHook(ctx, req, preFirePrompt, attempt) + if hookErr != nil { + return d.finishRun(ctx, scheduledRun, RunFailed, hookErr) + } + if cancelled { + return d.finishRun(ctx, scheduledRun, RunCancelled, nil) + } + + actor, err := directTaskActorContext(req.Job, scheduledRun.ID) + if err != nil { + return d.finishRun(ctx, scheduledRun, RunFailed, err) + } + + taskRecord, err := d.tasks.CreateTask(ctx, directTaskSpec(req.Job, preFirePrompt), actor) + if err != nil { + return d.finishRun(ctx, scheduledRun, classifyDispatchError(err), err) + } + if taskRecord == nil || strings.TrimSpace(taskRecord.ID) == "" { + return d.finishRun(ctx, scheduledRun, RunFailed, errors.New("automation: task service returned empty task")) + } + + taskRun, err := d.tasks.EnqueueRun(ctx, taskpkg.EnqueueRun{ + TaskID: taskRecord.ID, + IdempotencyKey: automationTaskRunIdempotencyKey(scheduledRun.ID), + NetworkChannel: strings.TrimSpace(taskRecord.NetworkChannel), + }, actor) + if err != nil { + return d.finishRun(ctx, scheduledRun, classifyDispatchError(err), err) + } + if taskRun == nil || strings.TrimSpace(taskRun.ID) == "" { + return d.finishRun(ctx, scheduledRun, RunFailed, errors.New("automation: task service returned empty task run")) + } + + delegatedRun, err := d.delegateRun(ctx, scheduledRun, taskRecord.ID, taskRun.ID) + if err != nil { + return delegatedRun, err + } + d.dispatchPostFireHook(ctx, req, *delegatedRun) + return delegatedRun, nil +} + func (d *Dispatcher) transitionRun(ctx context.Context, current *Run, mutate func(run *Run, now time.Time)) (*Run, error) { if current == nil { return nil, errors.New("automation: run is required") @@ -420,6 +509,30 @@ func (d *Dispatcher) transitionRun(ctx context.Context, current *Run, mutate fun return &updated, nil } +func (d *Dispatcher) delegateRun(ctx context.Context, current *Run, taskID string, taskRunID string) (*Run, error) { + updatedRun, updateErr := d.transitionRun(ctx, current, func(run *Run, now time.Time) { + run.TaskID = strings.TrimSpace(taskID) + run.TaskRunID = strings.TrimSpace(taskRunID) + run.Status = RunDelegated + run.EndedAt = timePointer(now) + run.Error = "" + }) + if updateErr != nil { + return updatedRun, updateErr + } + + d.logger.Info( + "automation.dispatch.delegated", + "run_id", updatedRun.ID, + "job_id", strings.TrimSpace(updatedRun.JobID), + "trigger_id", strings.TrimSpace(updatedRun.TriggerID), + "task_id", strings.TrimSpace(updatedRun.TaskID), + "task_run_id", strings.TrimSpace(updatedRun.TaskRunID), + "attempt", updatedRun.Attempt, + ) + return updatedRun, nil +} + func (d *Dispatcher) finishRun(ctx context.Context, current *Run, status RunStatus, runErr error) (*Run, error) { updatedRun, updateErr := d.transitionRun(ctx, current, func(run *Run, now time.Time) { run.Status = status @@ -480,6 +593,9 @@ func (d *Dispatcher) finishRun(ctx context.Context, current *Run, status RunStat func (d *Dispatcher) finishRunAfterSessionStop(ctx context.Context, current *Run, sessionID string, status RunStatus, runErr error) (*Run, error) { stopErr := d.stopAutomationSession(ctx, sessionID, status, runErr) + if stopErr == nil { + d.deleteAutomationSessionTaskActor(sessionID) + } if stopErr != nil { wrappedStopErr := fmt.Errorf("automation: stop session %q: %w", strings.TrimSpace(sessionID), stopErr) if runErr == nil { @@ -645,6 +761,24 @@ func (d *Dispatcher) createOpts(req DispatchRequest) session.CreateOpts { return opts } +func (d *Dispatcher) recordAutomationSessionTaskActor(sessionID string, run *Run) error { + if d == nil || d.taskActors == nil { + return nil + } + actor, err := automationSessionTaskActorContext(sessionID, run) + if err != nil { + return err + } + return d.taskActors.RecordAutomationSessionTaskActor(strings.TrimSpace(sessionID), actor) +} + +func (d *Dispatcher) deleteAutomationSessionTaskActor(sessionID string) { + if d == nil || d.taskActors == nil { + return + } + d.taskActors.DeleteAutomationSessionTaskActor(strings.TrimSpace(sessionID)) +} + func (d *Dispatcher) tryAcquire() bool { select { case d.gate <- struct{}{}: @@ -760,6 +894,72 @@ func executePromptTemplate(builder *strings.Builder, tmpl *template.Template, en return nil } +func directTaskActorContext(job *Job, runID string) (taskpkg.ActorContext, error) { + if job == nil { + return taskpkg.ActorContext{}, errors.New("automation: task-backed dispatch job is required") + } + return taskpkg.DeriveAutomationActorContext(strings.TrimSpace(job.ID), automationTaskOriginRef(runID)) +} + +func automationSessionTaskActorContext(sessionID string, run *Run) (taskpkg.ActorContext, error) { + if run == nil { + return taskpkg.ActorContext{}, errors.New("automation: run is required for session task actor context") + } + return taskpkg.DeriveAutomationLinkedAgentSessionActorContext(strings.TrimSpace(sessionID), automationTaskOriginRef(run.ID)) +} + +func directTaskSpec(job *Job, prompt string) taskpkg.CreateTask { + if job == nil || job.Task == nil { + return taskpkg.CreateTask{} + } + + title := strings.TrimSpace(job.Task.Title) + if title == "" { + title = strings.TrimSpace(job.Name) + } + description := strings.TrimSpace(job.Task.Description) + if description == "" { + description = strings.TrimSpace(prompt) + } + if description == "" { + description = strings.TrimSpace(job.Prompt) + } + + return taskpkg.CreateTask{ + Scope: taskScopeForAutomationScope(job.Scope), + WorkspaceID: strings.TrimSpace(job.WorkspaceID), + NetworkChannel: strings.TrimSpace(job.Task.NetworkChannel), + Title: title, + Description: description, + Owner: cloneTaskOwnership(job.Task.Owner), + } +} + +func taskScopeForAutomationScope(scope AutomationScope) taskpkg.Scope { + switch scope { + case AutomationScopeWorkspace: + return taskpkg.ScopeWorkspace + default: + return taskpkg.ScopeGlobal + } +} + +func cloneTaskOwnership(owner *taskpkg.Ownership) *taskpkg.Ownership { + if owner == nil { + return nil + } + cloned := *owner + return &cloned +} + +func automationTaskOriginRef(runID string) string { + return "run:" + strings.TrimSpace(runID) +} + +func automationTaskRunIdempotencyKey(runID string) string { + return "automation-run:" + strings.TrimSpace(runID) +} + func classifyDispatchError(err error) RunStatus { if err == nil { return RunCompleted diff --git a/internal/automation/dispatch_test.go b/internal/automation/dispatch_test.go index 4aba42a99..3d8706ba5 100644 --- a/internal/automation/dispatch_test.go +++ b/internal/automation/dispatch_test.go @@ -18,6 +18,7 @@ import ( "github.com/pedronauck/agh/internal/session" "github.com/pedronauck/agh/internal/store" "github.com/pedronauck/agh/internal/store/globaldb" + taskpkg "github.com/pedronauck/agh/internal/task" "github.com/pedronauck/agh/internal/testutil" ) @@ -104,6 +105,274 @@ func TestDispatchGlobalAutomationUsesGlobalWorkspacePath(t *testing.T) { } } +func TestDispatchTaskBackedJobDelegatesToTaskServiceWithoutSessionRuntime(t *testing.T) { + t.Parallel() + + store := newMemoryRunStore() + creator := newRecordingSessionCreator() + tasks := newRecordingTaskService() + dispatcher := newTestDispatcher(t, creator, store, WithDispatcherTasks(tasks)) + + job := testJob(AutomationScopeWorkspace, "job-task-backed", "ws_alpha") + job.Task = &JobTaskConfig{ + Title: "Review automation findings", + Description: "Create a durable review task.", + NetworkChannel: "ops-automation", + Owner: &taskpkg.Ownership{ + Kind: taskpkg.OwnerKindAutomation, + Ref: "job-task-backed", + }, + } + + run, err := dispatcher.Dispatch(testutil.Context(t), DispatchRequest{ + Kind: DispatchKindSchedule, + Job: &job, + }) + if err != nil { + t.Fatalf("Dispatch() error = %v", err) + } + + if got := len(creator.createCalls()); got != 0 { + t.Fatalf("len(Create calls) = %d, want 0", got) + } + if got := len(creator.promptCalls()); got != 0 { + t.Fatalf("len(Prompt calls) = %d, want 0", got) + } + if got := len(creator.stopCalls()); got != 0 { + t.Fatalf("len(StopWithCause calls) = %d, want 0", got) + } + if got, want := run.Status, RunDelegated; got != want { + t.Fatalf("run.Status = %q, want %q", got, want) + } + if got := run.SessionID; got != "" { + t.Fatalf("run.SessionID = %q, want empty", got) + } + if got, want := run.TaskID, "task-1"; got != want { + t.Fatalf("run.TaskID = %q, want %q", got, want) + } + if got, want := run.TaskRunID, "task-run-1"; got != want { + t.Fatalf("run.TaskRunID = %q, want %q", got, want) + } + + if got, want := len(tasks.createCalls), 1; got != want { + t.Fatalf("len(CreateTask calls) = %d, want %d", got, want) + } + createCall := tasks.createCalls[0] + if got, want := createCall.actor.Actor.Kind, taskpkg.ActorKindAutomation; got != want { + t.Fatalf("CreateTask().actor.kind = %q, want %q", got, want) + } + if got, want := createCall.actor.Actor.Ref, job.ID; got != want { + t.Fatalf("CreateTask().actor.ref = %q, want %q", got, want) + } + if got, want := createCall.actor.Origin.Kind, taskpkg.OriginKindAutomation; got != want { + t.Fatalf("CreateTask().origin.kind = %q, want %q", got, want) + } + if got, want := createCall.actor.Origin.Ref, "run:"+run.ID; got != want { + t.Fatalf("CreateTask().origin.ref = %q, want %q", got, want) + } + if got, want := createCall.spec.Scope, taskpkg.ScopeWorkspace; got != want { + t.Fatalf("CreateTask().scope = %q, want %q", got, want) + } + if got, want := createCall.spec.WorkspaceID, "ws_alpha"; got != want { + t.Fatalf("CreateTask().workspace_id = %q, want %q", got, want) + } + if got, want := createCall.spec.NetworkChannel, "ops-automation"; got != want { + t.Fatalf("CreateTask().network_channel = %q, want %q", got, want) + } + + if got, want := len(tasks.enqueueCalls), 1; got != want { + t.Fatalf("len(EnqueueRun calls) = %d, want %d", got, want) + } + enqueueCall := tasks.enqueueCalls[0] + if got, want := enqueueCall.spec.TaskID, "task-1"; got != want { + t.Fatalf("EnqueueRun().task_id = %q, want %q", got, want) + } + if got, want := enqueueCall.spec.IdempotencyKey, "automation-run:"+run.ID; got != want { + t.Fatalf("EnqueueRun().idempotency_key = %q, want %q", got, want) + } + if got, want := enqueueCall.spec.NetworkChannel, "ops-automation"; got != want { + t.Fatalf("EnqueueRun().network_channel = %q, want %q", got, want) + } +} + +func TestDispatchNonTaskJobStillUsesSessionRuntimeAndRecordsAutomationSessionActor(t *testing.T) { + t.Parallel() + + store := newMemoryRunStore() + creator := newRecordingSessionCreator(sessionAttemptPlan{sessionID: "sess-automation-1"}) + recorder := &recordingTaskActorRecorder{} + dispatcher := newTestDispatcher(t, creator, store, WithDispatcherTaskActorRecorder(recorder)) + + job := testJob(AutomationScopeGlobal, "job-runtime", "") + run, err := dispatcher.Dispatch(testutil.Context(t), DispatchRequest{ + Kind: DispatchKindManual, + Job: &job, + }) + if err != nil { + t.Fatalf("Dispatch() error = %v", err) + } + + if got, want := len(creator.createCalls()), 1; got != want { + t.Fatalf("len(Create calls) = %d, want %d", got, want) + } + if got, want := len(creator.promptCalls()), 1; got != want { + t.Fatalf("len(Prompt calls) = %d, want %d", got, want) + } + if got, want := len(recorder.recorded), 1; got != want { + t.Fatalf("len(recorded actors) = %d, want %d", got, want) + } + recordedActor, ok := recorder.recorded["sess-automation-1"] + if !ok { + t.Fatalf("recorded actors = %#v, want session key", recorder.recorded) + } + if got, want := recordedActor.Actor.Kind, taskpkg.ActorKindAgentSession; got != want { + t.Fatalf("recorded actor.kind = %q, want %q", got, want) + } + if got, want := recordedActor.Actor.Ref, "sess-automation-1"; got != want { + t.Fatalf("recorded actor.ref = %q, want %q", got, want) + } + if got, want := recordedActor.Origin.Kind, taskpkg.OriginKindAutomation; got != want { + t.Fatalf("recorded origin.kind = %q, want %q", got, want) + } + if got, want := recordedActor.Origin.Ref, "run:"+run.ID; got != want { + t.Fatalf("recorded origin.ref = %q, want %q", got, want) + } + if got, want := len(recorder.deleted), 1; got != want { + t.Fatalf("len(deleted actors) = %d, want %d", got, want) + } + if got, want := recorder.deleted[0], "sess-automation-1"; got != want { + t.Fatalf("deleted session id = %q, want %q", got, want) + } + if got, want := run.Status, RunCompleted; got != want { + t.Fatalf("run.Status = %q, want %q", got, want) + } +} + +func TestDispatchTaskBackedJobFailsWhenTaskServiceIsUnavailable(t *testing.T) { + t.Parallel() + + store := newMemoryRunStore() + creator := newRecordingSessionCreator() + dispatcher := newTestDispatcher(t, creator, store) + + job := testJob(AutomationScopeGlobal, "job-task-missing-service", "") + job.Task = &JobTaskConfig{Title: "Create durable task"} + + run, err := dispatcher.Dispatch(testutil.Context(t), DispatchRequest{ + Kind: DispatchKindManual, + Job: &job, + }) + if err == nil || !strings.Contains(err.Error(), "task-backed job requires task service") { + t.Fatalf("Dispatch() error = %v, want missing task service failure", err) + } + if got, want := run.Status, RunFailed; got != want { + t.Fatalf("run.Status = %q, want %q", got, want) + } + if got := len(creator.createCalls()); got != 0 { + t.Fatalf("len(Create calls) = %d, want 0", got) + } +} + +func TestDispatchTaskBackedJobMarksRunCancelledForTaskServiceCancellation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + createErr error + enqueueErr error + }{ + {name: "ShouldClassifyCreateTaskCancellation", createErr: context.Canceled}, + {name: "ShouldClassifyEnqueueRunDeadlineExceeded", enqueueErr: context.DeadlineExceeded}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + store := newMemoryRunStore() + creator := newRecordingSessionCreator() + tasks := &recordingTaskService{ + createErr: tt.createErr, + enqueueErr: tt.enqueueErr, + } + dispatcher := newTestDispatcher(t, creator, store, WithDispatcherTasks(tasks)) + + job := testJob(AutomationScopeGlobal, "job-task-cancelled", "") + job.Task = &JobTaskConfig{Title: "Create durable task"} + + run, err := dispatcher.Dispatch(testutil.Context(t), DispatchRequest{ + Kind: DispatchKindManual, + Job: &job, + }) + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Dispatch() error = %v, want cancellation-classified error", err) + } + if got, want := run.Status, RunCancelled; got != want { + t.Fatalf("run.Status = %q, want %q", got, want) + } + if got := len(creator.createCalls()); got != 0 { + t.Fatalf("len(Create calls) = %d, want 0", got) + } + }) + } +} + +func TestDirectTaskSpecFallsBackToJobNameAndPrompt(t *testing.T) { + t.Parallel() + + job := testJob(AutomationScopeGlobal, "job-fallbacks", "") + job.Prompt = " Review the latest automation output. " + job.Task = &JobTaskConfig{} + + spec := directTaskSpec(&job, job.Prompt) + if got, want := spec.Scope, taskpkg.ScopeGlobal; got != want { + t.Fatalf("spec.Scope = %q, want %q", got, want) + } + if got, want := spec.Title, "job-fallbacks"; got != want { + t.Fatalf("spec.Title = %q, want %q", got, want) + } + if got, want := spec.Description, "Review the latest automation output."; got != want { + t.Fatalf("spec.Description = %q, want %q", got, want) + } +} + +func TestDispatchTaskBackedJobUsesRewrittenPreFirePromptForTaskDescription(t *testing.T) { + t.Parallel() + + store := newMemoryRunStore() + creator := newRecordingSessionCreator() + tasks := newRecordingTaskService() + hooks := &recordingAutomationHooks{ + onJobPreFire: func(_ context.Context, payload hookspkg.AutomationJobPreFirePayload) (hookspkg.AutomationJobPreFirePayload, error) { + payload.Prompt = "Hook-rewritten durable task prompt." + return payload, nil + }, + } + dispatcher := newTestDispatcher(t, creator, store, WithDispatcherTasks(tasks), WithDispatcherHooks(hooks)) + + job := testJob(AutomationScopeGlobal, "job-hook-task", "") + job.Prompt = "Original automation prompt." + job.Task = &JobTaskConfig{Title: "Create durable task"} + + run, err := dispatcher.Dispatch(testutil.Context(t), DispatchRequest{ + Kind: DispatchKindManual, + Job: &job, + }) + if err != nil { + t.Fatalf("Dispatch() error = %v", err) + } + if got, want := run.Status, RunDelegated; got != want { + t.Fatalf("run.Status = %q, want %q", got, want) + } + if got, want := len(tasks.createCalls), 1; got != want { + t.Fatalf("len(CreateTask calls) = %d, want %d", got, want) + } + if got, want := tasks.createCalls[0].spec.Description, "Hook-rewritten durable task prompt."; got != want { + t.Fatalf("CreateTask().description = %q, want %q", got, want) + } +} + func TestDispatchStopsCreatedSessionWhenRunCompletes(t *testing.T) { t.Parallel() @@ -786,6 +1055,72 @@ type stopCall struct { detail string } +type taskCreateCall struct { + spec taskpkg.CreateTask + actor taskpkg.ActorContext +} + +type taskEnqueueCall struct { + spec taskpkg.EnqueueRun + actor taskpkg.ActorContext +} + +type recordingTaskService struct { + createCalls []taskCreateCall + enqueueCalls []taskEnqueueCall + createErr error + enqueueErr error +} + +func newRecordingTaskService() *recordingTaskService { + return &recordingTaskService{} +} + +func (s *recordingTaskService) CreateTask(_ context.Context, spec taskpkg.CreateTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) { + s.createCalls = append(s.createCalls, taskCreateCall{spec: spec, actor: actor}) + if s.createErr != nil { + return nil, s.createErr + } + return &taskpkg.Task{ + ID: "task-1", + Scope: spec.Scope, + WorkspaceID: spec.WorkspaceID, + NetworkChannel: spec.NetworkChannel, + Owner: cloneTaskOwnership(spec.Owner), + }, nil +} + +func (s *recordingTaskService) EnqueueRun(_ context.Context, spec taskpkg.EnqueueRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + s.enqueueCalls = append(s.enqueueCalls, taskEnqueueCall{spec: spec, actor: actor}) + if s.enqueueErr != nil { + return nil, s.enqueueErr + } + return &taskpkg.TaskRun{ + ID: "task-run-1", + TaskID: spec.TaskID, + Origin: actor.Origin, + IdempotencyKey: spec.IdempotencyKey, + NetworkChannel: spec.NetworkChannel, + }, nil +} + +type recordingTaskActorRecorder struct { + recorded map[string]taskpkg.ActorContext + deleted []string +} + +func (r *recordingTaskActorRecorder) RecordAutomationSessionTaskActor(sessionID string, actor taskpkg.ActorContext) error { + if r.recorded == nil { + r.recorded = make(map[string]taskpkg.ActorContext) + } + r.recorded[sessionID] = actor + return nil +} + +func (r *recordingTaskActorRecorder) DeleteAutomationSessionTaskActor(sessionID string) { + r.deleted = append(r.deleted, sessionID) +} + type recordingSessionCreator struct { mu sync.Mutex plans []sessionAttemptPlan diff --git a/internal/automation/manager.go b/internal/automation/manager.go index ba35b561c..0c680b127 100644 --- a/internal/automation/manager.go +++ b/internal/automation/manager.go @@ -17,6 +17,7 @@ import ( aghconfig "github.com/pedronauck/agh/internal/config" hookspkg "github.com/pedronauck/agh/internal/hooks" "github.com/pedronauck/agh/internal/session" + taskpkg "github.com/pedronauck/agh/internal/task" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) @@ -27,6 +28,9 @@ var ( // ErrDefinitionReadOnly reports that a config-backed definition cannot be // mutated through the runtime CRUD surface. ErrDefinitionReadOnly = errors.New("automation: definition is config-backed and read-only") + // ErrSessionTaskActorNotFound reports that no automation-linked task actor + // context is recorded for the supplied session. + ErrSessionTaskActorNotFound = errors.New("automation: session task actor context not found") ) const managerRuntimeCleanupTimeout = 2 * time.Second @@ -125,6 +129,7 @@ type Option func(*managerOptions) type managerOptions struct { store Store sessions SessionManager + tasks TaskService workspaceResolver workspacepkg.WorkspaceResolver config aghconfig.AutomationConfig logger *slog.Logger @@ -141,6 +146,7 @@ type managerOptions struct { type Manager struct { store Store sessions SessionManager + tasks TaskService workspaceResolver workspacepkg.WorkspaceResolver config aghconfig.AutomationConfig logger *slog.Logger @@ -158,6 +164,9 @@ type Manager struct { scheduler *Scheduler triggers *TriggerEngine lastSync SyncStats + + taskActorMu sync.RWMutex + sessionTaskActors map[string]taskpkg.ActorContext } // WithStore injects the automation persistence store. @@ -175,6 +184,14 @@ func WithSessions(sessions SessionManager) Option { } } +// WithTasks injects the task-domain service used for task-backed automation +// jobs. +func WithTasks(tasks TaskService) Option { + return func(opts *managerOptions) { + opts.tasks = tasks + } +} + // WithWorkspaceResolver injects the canonical workspace resolver used to turn // TOML workspace references into registered workspace IDs. func WithWorkspaceResolver(resolver workspacepkg.WorkspaceResolver) Option { @@ -311,6 +328,9 @@ func New(opts ...Option) (*Manager, error) { WithDispatcherGlobalWorkspacePath(options.globalWorkspacePath), WithDispatcherMaxConcurrent(options.config.MaxConcurrentJobs), } + if options.tasks != nil { + dispatcherOpts = append(dispatcherOpts, WithDispatcherTasks(options.tasks)) + } dispatcherOpts = append(dispatcherOpts, options.dispatcherOptions...) dispatcher, err := NewDispatcher(options.sessions, options.store, dispatcherOpts...) if err != nil { @@ -320,6 +340,7 @@ func New(opts ...Option) (*Manager, error) { manager := &Manager{ store: options.store, sessions: options.sessions, + tasks: options.tasks, workspaceResolver: options.workspaceResolver, config: options.config, logger: options.logger, @@ -329,6 +350,10 @@ func New(opts ...Option) (*Manager, error) { schedulerOptions: append([]SchedulerOption(nil), options.schedulerOptions...), triggerOptions: append([]TriggerEngineOption(nil), options.triggerOptions...), now: options.now, + sessionTaskActors: make(map[string]taskpkg.ActorContext), + } + if manager.tasks != nil { + manager.dispatcher.taskActors = manager } return manager, nil @@ -1006,6 +1031,62 @@ func (m *Manager) MemoryObserver() MemoryConsolidationObserver { return managerMemoryObserver{manager: m} } +// RecordAutomationSessionTaskActor stores the trusted task-domain actor +// context for one automation-launched session. +func (m *Manager) RecordAutomationSessionTaskActor(sessionID string, actor taskpkg.ActorContext) error { + if m == nil { + return nil + } + trimmedSessionID := strings.TrimSpace(sessionID) + if trimmedSessionID == "" { + return errors.New("automation: session id is required") + } + if err := actor.Validate(); err != nil { + return err + } + + m.taskActorMu.Lock() + defer m.taskActorMu.Unlock() + m.sessionTaskActors[trimmedSessionID] = actor + return nil +} + +// TaskActorContextForSession returns the automation-linked task actor context +// previously recorded for one automation-launched session. +func (m *Manager) TaskActorContextForSession(sessionID string) (taskpkg.ActorContext, error) { + if m == nil { + return taskpkg.ActorContext{}, ErrSessionTaskActorNotFound + } + trimmedSessionID := strings.TrimSpace(sessionID) + if trimmedSessionID == "" { + return taskpkg.ActorContext{}, errors.New("automation: session id is required") + } + + m.taskActorMu.RLock() + actor, ok := m.sessionTaskActors[trimmedSessionID] + m.taskActorMu.RUnlock() + if !ok { + return taskpkg.ActorContext{}, ErrSessionTaskActorNotFound + } + return actor, nil +} + +// DeleteAutomationSessionTaskActor removes any recorded task actor context for +// the supplied automation-launched session. +func (m *Manager) DeleteAutomationSessionTaskActor(sessionID string) { + if m == nil { + return + } + trimmedSessionID := strings.TrimSpace(sessionID) + if trimmedSessionID == "" { + return + } + + m.taskActorMu.Lock() + defer m.taskActorMu.Unlock() + delete(m.sessionTaskActors, trimmedSessionID) +} + func (m *Manager) loadEffectiveJobs(ctx context.Context, query JobListQuery) ([]Job, error) { jobs, err := m.store.ListJobs(ctx, query) if err != nil { @@ -1360,6 +1441,7 @@ func (m *Manager) resolveConfigJob(ctx context.Context, raw aghconfig.Automation WorkspaceID: workspaceID, Prompt: strings.TrimSpace(raw.Prompt), Schedule: &schedule, + Task: cloneJobTaskConfig(raw.Task), Enabled: raw.Enabled, Retry: retry, FireLimit: fireLimit, @@ -1928,6 +2010,7 @@ func sameJobDefinition(left Job, right Job) bool { left.WorkspaceID == right.WorkspaceID && left.Prompt == right.Prompt && sameSchedule(left.Schedule, right.Schedule) && + sameJobTaskConfig(left.Task, right.Task) && left.Enabled == right.Enabled && left.Retry == right.Retry && left.FireLimit == right.FireLimit && @@ -1998,9 +2081,47 @@ func cloneJob(job Job) Job { schedule := *job.Schedule cloned.Schedule = &schedule } + cloned.Task = cloneJobTaskConfig(job.Task) return cloned } +func cloneJobTaskConfig(config *JobTaskConfig) *JobTaskConfig { + if config == nil { + return nil + } + cloned := *config + if config.Owner != nil { + owner := *config.Owner + cloned.Owner = &owner + } + return &cloned +} + +func sameJobTaskConfig(left *JobTaskConfig, right *JobTaskConfig) bool { + switch { + case left == nil && right == nil: + return true + case left == nil || right == nil: + return false + default: + return left.Title == right.Title && + left.Description == right.Description && + left.NetworkChannel == right.NetworkChannel && + sameTaskOwnership(left.Owner, right.Owner) + } +} + +func sameTaskOwnership(left *taskpkg.Ownership, right *taskpkg.Ownership) bool { + switch { + case left == nil && right == nil: + return true + case left == nil || right == nil: + return false + default: + return left.Kind == right.Kind && left.Ref == right.Ref + } +} + func cloneTrigger(trigger Trigger) Trigger { cloned := trigger cloned.Filter = cloneFilter(trigger.Filter) @@ -2034,6 +2155,9 @@ func (o managerSessionObserver) OnSessionCreated(ctx context.Context, sess *sess func (o managerSessionObserver) OnSessionStopped(ctx context.Context, sess *session.Session) { if o.manager != nil { + if sess != nil { + o.manager.DeleteAutomationSessionTaskActor(sess.ID) + } o.manager.fireSessionStopped(ctx, sess) } } diff --git a/internal/automation/manager_integration_test.go b/internal/automation/manager_integration_test.go new file mode 100644 index 000000000..a8f68c3dc --- /dev/null +++ b/internal/automation/manager_integration_test.go @@ -0,0 +1,280 @@ +//go:build integration + +package automation + +import ( + "errors" + "testing" + "time" + + aghconfig "github.com/pedronauck/agh/internal/config" + taskpkg "github.com/pedronauck/agh/internal/task" + "github.com/pedronauck/agh/internal/testutil" +) + +func TestManagerIntegrationDirectTaskBackedJobDelegatesIntoTaskDomain(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t) + taskManager, err := taskpkg.NewManager(taskpkg.WithStore(h.db)) + if err != nil { + t.Fatalf("task.NewManager() error = %v", err) + } + + manager := h.newManager(t, integrationAutomationConfig(), WithTasks(taskManager)) + if err := manager.Start(h.ctx); err != nil { + t.Fatalf("manager.Start() error = %v", err) + } + t.Cleanup(func() { + if err := manager.Shutdown(testutil.Context(t)); err != nil { + t.Fatalf("manager.Shutdown() error = %v", err) + } + }) + + job, err := manager.CreateJob(h.ctx, Job{ + Scope: AutomationScopeWorkspace, + Name: "direct-task-backed", + WorkspaceID: h.workspace.ID, + Schedule: &ScheduleSpec{ + Mode: ScheduleModeEvery, + Interval: "1h", + }, + Task: &JobTaskConfig{ + Title: "Direct automation review", + Description: "Persist a durable review task.", + NetworkChannel: "ops-automation", + Owner: &taskpkg.Ownership{ + Kind: taskpkg.OwnerKindAutomation, + Ref: "job:direct-task-backed", + }, + }, + Enabled: true, + Retry: DefaultRetryConfig(), + FireLimit: DefaultFireLimitConfig(), + }) + if err != nil { + t.Fatalf("manager.CreateJob() error = %v", err) + } + + run, err := manager.TriggerJob(h.ctx, job.ID) + if err != nil { + t.Fatalf("manager.TriggerJob() error = %v", err) + } + if got, want := run.Status, RunDelegated; got != want { + t.Fatalf("run.Status = %q, want %q", got, want) + } + if got := run.SessionID; got != "" { + t.Fatalf("run.SessionID = %q, want empty", got) + } + if got, want := len(h.sessions.creator.createCalls()), 0; got != want { + t.Fatalf("len(Create calls) = %d, want %d", got, want) + } + if got, want := len(h.sessions.creator.promptCalls()), 0; got != want { + t.Fatalf("len(Prompt calls) = %d, want %d", got, want) + } + + storedRun, err := h.db.GetRun(h.ctx, run.ID) + if err != nil { + t.Fatalf("GetRun() error = %v", err) + } + if got, want := storedRun.TaskID, run.TaskID; got != want { + t.Fatalf("storedRun.TaskID = %q, want %q", got, want) + } + if got, want := storedRun.TaskRunID, run.TaskRunID; got != want { + t.Fatalf("storedRun.TaskRunID = %q, want %q", got, want) + } + + taskRecord, err := h.db.GetTask(h.ctx, run.TaskID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if got, want := taskRecord.Scope, taskpkg.ScopeWorkspace; got != want { + t.Fatalf("task.Scope = %q, want %q", got, want) + } + if got, want := taskRecord.WorkspaceID, h.workspace.ID; got != want { + t.Fatalf("task.WorkspaceID = %q, want %q", got, want) + } + if got, want := taskRecord.NetworkChannel, "ops-automation"; got != want { + t.Fatalf("task.NetworkChannel = %q, want %q", got, want) + } + if taskRecord.Owner == nil || taskRecord.Owner.Kind != taskpkg.OwnerKindAutomation || taskRecord.Owner.Ref != "job:direct-task-backed" { + t.Fatalf("task.Owner = %#v, want automation owner", taskRecord.Owner) + } + if got, want := taskRecord.CreatedBy.Kind, taskpkg.ActorKindAutomation; got != want { + t.Fatalf("task.CreatedBy.Kind = %q, want %q", got, want) + } + if got, want := taskRecord.CreatedBy.Ref, job.ID; got != want { + t.Fatalf("task.CreatedBy.Ref = %q, want %q", got, want) + } + if got, want := taskRecord.Origin.Kind, taskpkg.OriginKindAutomation; got != want { + t.Fatalf("task.Origin.Kind = %q, want %q", got, want) + } + if got, want := taskRecord.Origin.Ref, "run:"+run.ID; got != want { + t.Fatalf("task.Origin.Ref = %q, want %q", got, want) + } + + taskRun, err := h.db.GetTaskRun(h.ctx, run.TaskRunID) + if err != nil { + t.Fatalf("GetTaskRun() error = %v", err) + } + if got, want := taskRun.Origin.Kind, taskpkg.OriginKindAutomation; got != want { + t.Fatalf("taskRun.Origin.Kind = %q, want %q", got, want) + } + if got, want := taskRun.Origin.Ref, "run:"+run.ID; got != want { + t.Fatalf("taskRun.Origin.Ref = %q, want %q", got, want) + } + if got, want := taskRun.IdempotencyKey, "automation-run:"+run.ID; got != want { + t.Fatalf("taskRun.IdempotencyKey = %q, want %q", got, want) + } + if got, want := taskRun.NetworkChannel, "ops-automation"; got != want { + t.Fatalf("taskRun.NetworkChannel = %q, want %q", got, want) + } +} + +func TestManagerIntegrationAutomationSessionCanCreateTaskWithAutomationOrigin(t *testing.T) { + t.Parallel() + + promptStarted := make(chan struct{}, 1) + promptRelease := make(chan struct{}) + + h := newManagerHarness(t) + h.sessions = newManagerSessionStub(sessionAttemptPlan{ + sessionID: "sess-automation-agent", + promptStarted: promptStarted, + promptRelease: promptRelease, + }) + + taskManager, err := taskpkg.NewManager(taskpkg.WithStore(h.db)) + if err != nil { + t.Fatalf("task.NewManager() error = %v", err) + } + + manager := h.newManager(t, integrationAutomationConfig(), WithTasks(taskManager)) + if err := manager.Start(h.ctx); err != nil { + t.Fatalf("manager.Start() error = %v", err) + } + t.Cleanup(func() { + if err := manager.Shutdown(testutil.Context(t)); err != nil { + t.Fatalf("manager.Shutdown() error = %v", err) + } + }) + + job, err := manager.CreateJob(h.ctx, Job{ + Scope: AutomationScopeWorkspace, + Name: "agent-mediated-task-create", + AgentName: "researcher", + WorkspaceID: h.workspace.ID, + Prompt: "Inspect the repo and decide whether to create a task.", + Schedule: &ScheduleSpec{ + Mode: ScheduleModeEvery, + Interval: "1h", + }, + Enabled: true, + Retry: DefaultRetryConfig(), + FireLimit: DefaultFireLimitConfig(), + }) + if err != nil { + t.Fatalf("manager.CreateJob() error = %v", err) + } + + runCh := make(chan Run, 1) + errCh := make(chan error, 1) + go func() { + run, err := manager.TriggerJob(h.ctx, job.ID) + runCh <- run + errCh <- err + }() + + select { + case <-promptStarted: + case <-time.After(2 * time.Second): + t.Fatal("automation session did not reach Prompt() in time") + } + + runs, err := h.db.ListRuns(h.ctx, RunQuery{JobID: job.ID}) + if err != nil { + t.Fatalf("ListRuns() error = %v", err) + } + if got, want := len(runs), 1; got != want { + t.Fatalf("len(runs) = %d, want %d", got, want) + } + if got, want := runs[0].Status, RunRunning; got != want { + t.Fatalf("runs[0].Status = %q, want %q", got, want) + } + if got, want := runs[0].SessionID, "sess-automation-agent"; got != want { + t.Fatalf("runs[0].SessionID = %q, want %q", got, want) + } + + actor, err := manager.TaskActorContextForSession("sess-automation-agent") + if err != nil { + t.Fatalf("TaskActorContextForSession() error = %v", err) + } + if got, want := actor.Actor.Kind, taskpkg.ActorKindAgentSession; got != want { + t.Fatalf("actor.Actor.Kind = %q, want %q", got, want) + } + if got, want := actor.Origin.Kind, taskpkg.OriginKindAutomation; got != want { + t.Fatalf("actor.Origin.Kind = %q, want %q", got, want) + } + if got, want := actor.Origin.Ref, "run:"+runs[0].ID; got != want { + t.Fatalf("actor.Origin.Ref = %q, want %q", got, want) + } + + createdTask, err := taskManager.CreateTask(h.ctx, taskpkg.CreateTask{ + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: h.workspace.ID, + Title: "Agent-created follow-up", + }, actor) + if err != nil { + t.Fatalf("taskManager.CreateTask() error = %v", err) + } + + storedTask, err := h.db.GetTask(h.ctx, createdTask.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if got, want := storedTask.CreatedBy.Kind, taskpkg.ActorKindAgentSession; got != want { + t.Fatalf("storedTask.CreatedBy.Kind = %q, want %q", got, want) + } + if got, want := storedTask.CreatedBy.Ref, "sess-automation-agent"; got != want { + t.Fatalf("storedTask.CreatedBy.Ref = %q, want %q", got, want) + } + if got, want := storedTask.Origin.Kind, taskpkg.OriginKindAutomation; got != want { + t.Fatalf("storedTask.Origin.Kind = %q, want %q", got, want) + } + if got, want := storedTask.Origin.Ref, "run:"+runs[0].ID; got != want { + t.Fatalf("storedTask.Origin.Ref = %q, want %q", got, want) + } + + close(promptRelease) + + var completedRun Run + select { + case completedRun = <-runCh: + case <-time.After(2 * time.Second): + t.Fatal("manager.TriggerJob() did not return after prompt release") + } + select { + case err := <-errCh: + if err != nil { + t.Fatalf("manager.TriggerJob() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("manager.TriggerJob() error channel did not return") + } + if got, want := completedRun.Status, RunCompleted; got != want { + t.Fatalf("completedRun.Status = %q, want %q", got, want) + } + + if _, err := manager.TaskActorContextForSession("sess-automation-agent"); !errors.Is(err, ErrSessionTaskActorNotFound) { + t.Fatalf("TaskActorContextForSession(after completion) error = %v, want ErrSessionTaskActorNotFound", err) + } +} + +func integrationAutomationConfig() aghconfig.AutomationConfig { + return aghconfig.AutomationConfig{ + Enabled: true, + Timezone: DefaultTimezone, + MaxConcurrentJobs: DefaultMaxConcurrentJobs, + DefaultFireLimit: DefaultFireLimitConfig(), + } +} diff --git a/internal/automation/manager_test.go b/internal/automation/manager_test.go index c06d36878..26b620d33 100644 --- a/internal/automation/manager_test.go +++ b/internal/automation/manager_test.go @@ -18,6 +18,7 @@ import ( "github.com/pedronauck/agh/internal/session" "github.com/pedronauck/agh/internal/store" "github.com/pedronauck/agh/internal/store/globaldb" + taskpkg "github.com/pedronauck/agh/internal/task" "github.com/pedronauck/agh/internal/testutil" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) @@ -587,6 +588,43 @@ func TestManagerObserversAndRunsRouteTriggerEvents(t *testing.T) { } } +func TestManagerSessionTaskActorLifecycle(t *testing.T) { + t.Parallel() + + t.Run("Should record, load, and delete a session task actor", func(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t) + manager := h.newManager(t, aghconfig.AutomationConfig{ + Enabled: true, + Timezone: DefaultTimezone, + MaxConcurrentJobs: DefaultMaxConcurrentJobs, + DefaultFireLimit: DefaultFireLimitConfig(), + }) + + actor, err := taskpkg.DeriveAutomationLinkedAgentSessionActorContext("sess-actor-1", "run:run-1") + if err != nil { + t.Fatalf("DeriveAutomationLinkedAgentSessionActorContext() error = %v", err) + } + if err := manager.RecordAutomationSessionTaskActor("sess-actor-1", actor); err != nil { + t.Fatalf("RecordAutomationSessionTaskActor() error = %v", err) + } + + loaded, err := manager.TaskActorContextForSession("sess-actor-1") + if err != nil { + t.Fatalf("TaskActorContextForSession() error = %v", err) + } + if loaded != actor { + t.Fatalf("TaskActorContextForSession() = %#v, want %#v", loaded, actor) + } + + manager.DeleteAutomationSessionTaskActor("sess-actor-1") + if _, err := manager.TaskActorContextForSession("sess-actor-1"); !errors.Is(err, ErrSessionTaskActorNotFound) { + t.Fatalf("TaskActorContextForSession(after delete) error = %v, want ErrSessionTaskActorNotFound", err) + } + }) +} + func TestManagerHandleWebhookWithSecretResolver(t *testing.T) { h := newManagerHarness(t) t.Setenv("AGH_TEST_WEBHOOK_SECRET", "super-secret") diff --git a/internal/automation/model/types.go b/internal/automation/model/types.go index dc32743e8..0026c8cd9 100644 --- a/internal/automation/model/types.go +++ b/internal/automation/model/types.go @@ -1,6 +1,10 @@ package model -import "time" +import ( + "time" + + taskpkg "github.com/pedronauck/agh/internal/task" +) // DefaultTimezone is the default schedule timezone used by automation config. const DefaultTimezone = "UTC" @@ -58,6 +62,8 @@ const ( RunScheduled RunStatus = "scheduled" // RunRunning reports a run that is actively dispatching or executing. RunRunning RunStatus = "running" + // RunDelegated reports an automation activation that delegated execution into the task domain. + RunDelegated RunStatus = "delegated" // RunCompleted reports a run that finished successfully. RunCompleted RunStatus = "completed" // RunFailed reports a run that finished with an error. @@ -80,6 +86,14 @@ const ( ActivationSourceExtension ActivationSource = "extension" ) +// JobTaskConfig configures direct automation-to-task materialization for one job. +type JobTaskConfig struct { + Title string `json:"title,omitempty" toml:"title,omitempty"` + Description string `json:"description,omitempty" toml:"description,omitempty"` + Owner *taskpkg.Ownership `json:"owner,omitempty" toml:"owner,omitempty"` + NetworkChannel string `json:"network_channel,omitempty" toml:"network_channel,omitempty"` +} + // Job is the canonical scheduled automation definition used by runtime and storage layers. type Job struct { ID string `json:"id"` @@ -89,6 +103,7 @@ type Job struct { WorkspaceID string `json:"workspace_id,omitempty"` Prompt string `json:"prompt"` Schedule *ScheduleSpec `json:"schedule,omitempty"` + Task *JobTaskConfig `json:"task,omitempty"` Enabled bool `json:"enabled"` Retry RetryConfig `json:"retry"` FireLimit FireLimitConfig `json:"fire_limit"` @@ -144,6 +159,8 @@ type Run struct { JobID string `json:"job_id,omitempty"` TriggerID string `json:"trigger_id,omitempty"` SessionID string `json:"session_id,omitempty"` + TaskID string `json:"task_id,omitempty"` + TaskRunID string `json:"task_run_id,omitempty"` Status RunStatus `json:"status"` Attempt int `json:"attempt"` StartedAt *time.Time `json:"started_at,omitempty"` diff --git a/internal/automation/model/validate.go b/internal/automation/model/validate.go index 00c3ef606..ae2756431 100644 --- a/internal/automation/model/validate.go +++ b/internal/automation/model/validate.go @@ -6,6 +6,7 @@ import ( "strings" "time" + networkrules "github.com/pedronauck/agh/internal/network/rules" cron "github.com/robfig/cron/v3" ) @@ -64,10 +65,10 @@ func (s JobSource) Validate(path string) error { // Validate ensures the run status is one of the supported lifecycle states. func (s RunStatus) Validate(path string) error { switch s { - case RunScheduled, RunRunning, RunCompleted, RunFailed, RunCancelled: + case RunScheduled, RunRunning, RunDelegated, RunCompleted, RunFailed, RunCancelled: return nil default: - return fmt.Errorf("%s must be one of %q, %q, %q, %q, or %q: %q", path, RunScheduled, RunRunning, RunCompleted, RunFailed, RunCancelled, s) + return fmt.Errorf("%s must be one of %q, %q, %q, %q, %q, or %q: %q", path, RunScheduled, RunRunning, RunDelegated, RunCompleted, RunFailed, RunCancelled, s) } } @@ -236,10 +237,10 @@ func (j Job) Validate(path string) error { if strings.TrimSpace(j.Name) == "" { return errors.New(nestedPath(path, "name") + " is required") } - if strings.TrimSpace(j.AgentName) == "" { + if j.Task == nil && strings.TrimSpace(j.AgentName) == "" { return errors.New(nestedPath(path, "agent_name") + " is required") } - if strings.TrimSpace(j.Prompt) == "" { + if j.Task == nil && strings.TrimSpace(j.Prompt) == "" { return errors.New(nestedPath(path, "prompt") + " is required") } if err := ValidateScopeBinding(j.Scope, j.WorkspaceID, path, "workspace_id"); err != nil { @@ -260,6 +261,14 @@ func (j Job) Validate(path string) error { if err := j.FireLimit.Validate(nestedPath(path, "fire_limit")); err != nil { return err } + if j.Task != nil { + if err := j.Task.Validate(nestedPath(path, "task")); err != nil { + return err + } + if j.Retry.Strategy != RetryStrategyNone { + return fmt.Errorf("%s.strategy must be %q when %s is configured", nestedPath(path, "retry"), RetryStrategyNone, nestedPath(path, "task")) + } + } return nil } @@ -326,6 +335,29 @@ func (r Run) Validate(path string) error { if r.StartedAt != nil && r.EndedAt != nil && r.EndedAt.Before(*r.StartedAt) { return fmt.Errorf("%s must not be before %s", nestedPath(path, "ended_at"), nestedPath(path, "started_at")) } + if r.Status == RunDelegated { + if strings.TrimSpace(r.TaskID) == "" { + return fmt.Errorf("%s is required when %s is %q", nestedPath(path, "task_id"), nestedPath(path, "status"), RunDelegated) + } + if strings.TrimSpace(r.TaskRunID) == "" { + return fmt.Errorf("%s is required when %s is %q", nestedPath(path, "task_run_id"), nestedPath(path, "status"), RunDelegated) + } + } + return nil +} + +// Validate ensures the direct task materialization configuration is internally consistent. +func (c JobTaskConfig) Validate(path string) error { + if channel := strings.TrimSpace(c.NetworkChannel); channel != "" { + if !networkrules.ValidChannel(channel) { + return fmt.Errorf("%s is invalid: channel=%q", nestedPath(path, "network_channel"), channel) + } + } + if c.Owner != nil { + if err := c.Owner.Validate(nestedPath(path, "owner")); err != nil { + return err + } + } return nil } diff --git a/internal/automation/types.go b/internal/automation/types.go index 905f34dc4..0ee3af807 100644 --- a/internal/automation/types.go +++ b/internal/automation/types.go @@ -58,6 +58,8 @@ const ( RunScheduled = modelpkg.RunScheduled // RunRunning reports a run that is actively dispatching or executing. RunRunning = modelpkg.RunRunning + // RunDelegated reports a run that delegated execution into the task domain. + RunDelegated = modelpkg.RunDelegated // RunCompleted reports a run that finished successfully. RunCompleted = modelpkg.RunCompleted // RunFailed reports a run that finished with an error. @@ -80,6 +82,9 @@ const ( ActivationSourceExtension = modelpkg.ActivationSourceExtension ) +// JobTaskConfig configures direct automation-to-task materialization for one job. +type JobTaskConfig = modelpkg.JobTaskConfig + // Job is the canonical scheduled automation definition used by runtime and storage layers. type Job = modelpkg.Job diff --git a/internal/automation/validate_test.go b/internal/automation/validate_test.go index 9fc463845..d224449a5 100644 --- a/internal/automation/validate_test.go +++ b/internal/automation/validate_test.go @@ -829,6 +829,36 @@ func TestRunAndEnvelopeValidate(t *testing.T) { t.Fatalf("Run.Validate(valid) error = %v", err) } + delegatedMissingTaskID := Run{ + Status: RunDelegated, + Attempt: 1, + TaskRunID: "task-run-1", + } + if err := delegatedMissingTaskID.Validate("run"); err == nil { + t.Fatal("Run.Validate(delegated missing task id) error = nil, want non-nil") + } else if got := err.Error(); !strings.Contains(got, "run.task_id is required when run.status is \"delegated\"") { + t.Fatalf("Run.Validate(delegated missing task id) error = %q, want delegated task_id failure", got) + } + + delegatedMissingTaskRunID := Run{ + Status: RunDelegated, + Attempt: 1, + TaskID: "task-1", + } + if err := delegatedMissingTaskRunID.Validate("run"); err == nil { + t.Fatal("Run.Validate(delegated missing task run id) error = nil, want non-nil") + } else if got := err.Error(); !strings.Contains(got, "run.task_run_id is required when run.status is \"delegated\"") { + t.Fatalf("Run.Validate(delegated missing task run id) error = %q, want delegated task_run_id failure", got) + } + + if err := (JobTaskConfig{ + NetworkChannel: "bad channel", + }).Validate("job.task"); err == nil { + t.Fatal("JobTaskConfig.Validate(invalid channel) error = nil, want non-nil") + } else if got := err.Error(); !strings.Contains(got, "job.task.network_channel is invalid") { + t.Fatalf("JobTaskConfig.Validate(invalid channel) error = %q, want network_channel validation", got) + } + envelope := ActivationEnvelope{ Kind: "session.stopped", Scope: AutomationScopeWorkspace, diff --git a/internal/cli/cli_integration_test.go b/internal/cli/cli_integration_test.go index 916609173..2e5913ade 100644 --- a/internal/cli/cli_integration_test.go +++ b/internal/cli/cli_integration_test.go @@ -32,6 +32,7 @@ import ( "github.com/pedronauck/agh/internal/observe" "github.com/pedronauck/agh/internal/session" "github.com/pedronauck/agh/internal/store/globaldb" + taskpkg "github.com/pedronauck/agh/internal/task" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) @@ -1063,6 +1064,149 @@ func TestBridgeRoutesIntegration(t *testing.T) { } } +func TestCLITaskCreateListGetIntegration(t *testing.T) { + t.Parallel() + + h := newIntegrationHarness(t) + mustExecuteRoot(t, h.deps, "daemon", "start", "-o", "json") + defer func() { + _, _, _ = executeRootCommand(t, h.deps, "daemon", "stop", "-o", "json") + _ = h.runner.waitForExit() + }() + + if _, _, err := executeRootCommand(t, h.deps, "workspace", "add", h.workspace, "--name", "alpha", "-o", "json"); err != nil { + t.Fatalf("workspace add error = %v", err) + } + + createOut, _, err := executeRootCommand( + t, + h.deps, + "task", "create", + "--scope", "workspace", + "--workspace", "alpha", + "--channel", "builders", + "--title", "Investigate flaky task runs", + "--description", "Capture root cause", + "--owner-kind", "pool", + "--owner-ref", "triage", + "--metadata", `{"priority":"high"}`, + "-o", "json", + ) + if err != nil { + t.Fatalf("task create error = %v", err) + } + + var created TaskRecord + if err := json.Unmarshal([]byte(createOut), &created); err != nil { + t.Fatalf("json.Unmarshal(task create) error = %v", err) + } + if created.ID == "" || created.Scope != taskpkg.ScopeWorkspace || created.WorkspaceID == "" || created.NetworkChannel != "builders" { + t.Fatalf("created task = %#v, want workspace task with id/channel", created) + } + + listOut, _, err := executeRootCommand(t, h.deps, "task", "list", "--scope", "workspace", "--workspace", "alpha", "--status", "ready", "-o", "json") + if err != nil { + t.Fatalf("task list error = %v", err) + } + var listed []TaskSummaryRecord + if err := json.Unmarshal([]byte(listOut), &listed); err != nil { + t.Fatalf("json.Unmarshal(task list) error = %v", err) + } + if len(listed) != 1 || listed[0].ID != created.ID { + t.Fatalf("listed tasks = %#v, want created task", listed) + } + + getOut, _, err := executeRootCommand(t, h.deps, "task", "get", created.ID, "-o", "json") + if err != nil { + t.Fatalf("task get error = %v", err) + } + var detail TaskDetailRecord + if err := json.Unmarshal([]byte(getOut), &detail); err != nil { + t.Fatalf("json.Unmarshal(task get) error = %v", err) + } + if detail.Task.ID != created.ID || detail.Task.Owner == nil || detail.Task.Owner.Kind != taskpkg.OwnerKindPool { + t.Fatalf("task detail = %#v, want created task detail with owner", detail) + } +} + +func TestCLITaskRunLifecycleIntegration(t *testing.T) { + t.Parallel() + + h := newIntegrationHarness(t) + mustExecuteRoot(t, h.deps, "daemon", "start", "-o", "json") + defer func() { + _, _, _ = executeRootCommand(t, h.deps, "daemon", "stop", "-o", "json") + _ = h.runner.waitForExit() + }() + + createOut := mustExecuteRoot(t, h.deps, "task", "create", "--scope", "global", "--title", "Review task lifecycle", "-o", "json") + var created TaskRecord + if err := json.Unmarshal([]byte(createOut), &created); err != nil { + t.Fatalf("json.Unmarshal(task create) error = %v", err) + } + if created.ID == "" { + t.Fatal("expected created task id") + } + + enqueueOut := mustExecuteRoot(t, h.deps, "task", "run", "enqueue", created.ID, "--idempotency-key", "idem-1", "--channel", "builders", "-o", "json") + var enqueued TaskRunRecord + if err := json.Unmarshal([]byte(enqueueOut), &enqueued); err != nil { + t.Fatalf("json.Unmarshal(task run enqueue) error = %v", err) + } + if enqueued.Status != taskpkg.TaskRunStatusQueued { + t.Fatalf("enqueued run = %#v, want queued", enqueued) + } + + claimOut := mustExecuteRoot(t, h.deps, "task", "run", "claim", enqueued.ID, "-o", "json") + var claimed TaskRunRecord + if err := json.Unmarshal([]byte(claimOut), &claimed); err != nil { + t.Fatalf("json.Unmarshal(task run claim) error = %v", err) + } + if claimed.Status != taskpkg.TaskRunStatusClaimed { + t.Fatalf("claimed run = %#v, want claimed", claimed) + } + + startOut := mustExecuteRoot(t, h.deps, "task", "run", "start", enqueued.ID, "-o", "json") + var started TaskRunRecord + if err := json.Unmarshal([]byte(startOut), &started); err != nil { + t.Fatalf("json.Unmarshal(task run start) error = %v", err) + } + if started.Status != taskpkg.TaskRunStatusRunning || started.SessionID == "" { + t.Fatalf("started run = %#v, want running run with session", started) + } + + completeOut := mustExecuteRoot(t, h.deps, "task", "run", "complete", enqueued.ID, "--result", `{"ok":true}`, "-o", "json") + var completed TaskRunRecord + if err := json.Unmarshal([]byte(completeOut), &completed); err != nil { + t.Fatalf("json.Unmarshal(task run complete) error = %v", err) + } + var resultPayload map[string]bool + if err := json.Unmarshal(completed.Result, &resultPayload); err != nil { + t.Fatalf("json.Unmarshal(completed result) error = %v", err) + } + if completed.Status != taskpkg.TaskRunStatusCompleted || !resultPayload["ok"] { + t.Fatalf("completed run = %#v, want completed with JSON result", completed) + } + + runsOut := mustExecuteRoot(t, h.deps, "task", "run", "list", created.ID, "-o", "json") + var runs []TaskRunRecord + if err := json.Unmarshal([]byte(runsOut), &runs); err != nil { + t.Fatalf("json.Unmarshal(task run list) error = %v", err) + } + if len(runs) != 1 || runs[0].Status != taskpkg.TaskRunStatusCompleted { + t.Fatalf("runs = %#v, want completed run history", runs) + } + + getOut := mustExecuteRoot(t, h.deps, "task", "get", created.ID, "-o", "json") + var detail TaskDetailRecord + if err := json.Unmarshal([]byte(getOut), &detail); err != nil { + t.Fatalf("json.Unmarshal(task get) error = %v", err) + } + if detail.Task.Status != taskpkg.TaskStatusCompleted || len(detail.Runs) != 1 || detail.Runs[0].SessionID == "" { + t.Fatalf("task detail = %#v, want completed task with persisted run", detail) + } +} + type integrationHarness struct { deps commandDeps homePaths aghconfig.HomePaths @@ -1134,6 +1278,11 @@ type integrationDriver struct { blocked map[string]chan struct{} } +type integrationTaskExecutor struct { + mu sync.Mutex + next int +} + type lockedBuffer struct { mu sync.Mutex buffer bytes.Buffer @@ -1143,6 +1292,13 @@ func newIntegrationBridgeService(store bridgepkg.RegistryStore) *integrationBrid return &integrationBridgeService{Service: bridgepkg.NewRegistry(store)} } +func (s *integrationBridgeService) DeliveryMetrics() map[string]bridgepkg.BridgeDeliveryMetrics { + if s == nil { + return nil + } + return nil +} + func (s *integrationBridgeService) StartInstance(ctx context.Context, id string) (*bridgepkg.BridgeInstance, error) { return s.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{ ID: id, @@ -1394,6 +1550,15 @@ func (d *integrationDaemon) Run(ctx context.Context) error { d.manager = manager d.mu.Unlock() + taskManager, err := taskpkg.NewManager( + taskpkg.WithStore(registry), + taskpkg.WithSessionExecutor(&integrationTaskExecutor{}), + taskpkg.WithNetworkChannelValidator(network.ValidateChannel), + ) + if err != nil { + return fmt.Errorf("new task manager: %w", err) + } + bridgeService := newIntegrationBridgeService(registry) observer, err := observe.New( context.Background(), @@ -1480,6 +1645,7 @@ func (d *integrationDaemon) Run(ctx context.Context) error { udsapi.WithStartedAt(d.startedAt), udsapi.WithPollInterval(10*time.Millisecond), udsapi.WithSessionManager(manager), + udsapi.WithTaskService(taskManager), udsapi.WithNetworkService(networkManager), udsapi.WithObserver(observer), udsapi.WithAutomation(automationManager), @@ -1598,6 +1764,25 @@ func newIntegrationDriver() *integrationDriver { } } +func (e *integrationTaskExecutor) StartTaskSession(_ context.Context, _ taskpkg.StartTaskSession) (*taskpkg.SessionRef, error) { + e.mu.Lock() + defer e.mu.Unlock() + e.next++ + return &taskpkg.SessionRef{SessionID: fmt.Sprintf("task-sess-%d", e.next)}, nil +} + +func (e *integrationTaskExecutor) AttachTaskSession(_ context.Context, _ string, sessionID string) (*taskpkg.SessionRef, error) { + return &taskpkg.SessionRef{SessionID: strings.TrimSpace(sessionID)}, nil +} + +func (e *integrationTaskExecutor) RequestTaskStop(context.Context, string, taskpkg.StopReason) error { + return nil +} + +func (e *integrationTaskExecutor) ForceTaskStop(context.Context, string, taskpkg.StopReason) error { + return nil +} + func (d *integrationDriver) Start(_ context.Context, opts acp.StartOpts) (*session.AgentProcess, error) { d.mu.Lock() defer d.mu.Unlock() diff --git a/internal/cli/client.go b/internal/cli/client.go index b5f0b7ec9..6a68eb153 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -90,6 +90,22 @@ type DaemonClient interface { AutomationTriggerRuns(ctx context.Context, id string, query AutomationRunQuery) ([]RunRecord, error) ListAutomationRuns(ctx context.Context, query AutomationRunQuery) ([]RunRecord, error) GetAutomationRun(ctx context.Context, id string) (RunRecord, error) + ListTasks(ctx context.Context, query TaskListQuery) ([]TaskSummaryRecord, error) + CreateTask(ctx context.Context, request CreateTaskRequest) (TaskRecord, error) + GetTask(ctx context.Context, id string) (TaskDetailRecord, error) + UpdateTask(ctx context.Context, id string, request UpdateTaskRequest) (TaskRecord, error) + CancelTask(ctx context.Context, id string, request CancelTaskRequest) (TaskRecord, error) + CreateChildTask(ctx context.Context, id string, request CreateTaskChildRequest) (TaskRecord, error) + AddTaskDependency(ctx context.Context, id string, request AddTaskDependencyRequest) (TaskDetailRecord, error) + RemoveTaskDependency(ctx context.Context, id string, dependsOnID string) (TaskDetailRecord, error) + EnqueueTaskRun(ctx context.Context, id string, request EnqueueTaskRunRequest) (TaskRunRecord, error) + ListTaskRuns(ctx context.Context, id string, query TaskRunListQuery) ([]TaskRunRecord, error) + ClaimTaskRun(ctx context.Context, id string, request ClaimTaskRunRequest) (TaskRunRecord, error) + StartTaskRun(ctx context.Context, id string, request StartTaskRunRequest) (TaskRunRecord, error) + AttachTaskRunSession(ctx context.Context, id string, request AttachTaskRunSessionRequest) (TaskRunRecord, error) + CompleteTaskRun(ctx context.Context, id string, request CompleteTaskRunRequest) (TaskRunRecord, error) + FailTaskRun(ctx context.Context, id string, request FailTaskRunRequest) (TaskRunRecord, error) + CancelTaskRun(ctx context.Context, id string, request CancelTaskRunRequest) (TaskRunRecord, error) } // CreateSessionRequest captures the shared daemon session creation payload. @@ -224,6 +240,66 @@ type TriggerRecord = contract.TriggerPayload // RunRecord is the shared automation run payload. type RunRecord = contract.RunPayload +// TaskSummaryRecord is the shared list-oriented task payload. +type TaskSummaryRecord = contract.TaskSummaryPayload + +// TaskRecord is the shared single-task payload. +type TaskRecord = contract.TaskPayload + +// TaskDetailRecord is the shared expanded task payload. +type TaskDetailRecord = contract.TaskDetailPayload + +// TaskDependencyRecord is the shared dependency-edge payload. +type TaskDependencyRecord = contract.TaskDependencyPayload + +// TaskRunRecord is the shared task-run payload. +type TaskRunRecord = contract.TaskRunPayload + +// TaskEventRecord is the shared task audit-event payload. +type TaskEventRecord = contract.TaskEventPayload + +// TaskListQuery captures CLI filters for task list calls. +type TaskListQuery = contract.TaskListQuery + +// TaskRunListQuery captures CLI filters for task-run list calls. +type TaskRunListQuery = contract.TaskRunListQuery + +// CreateTaskRequest captures the shared task-create payload. +type CreateTaskRequest = contract.CreateTaskRequest + +// CreateTaskChildRequest captures the shared child-task create payload. +type CreateTaskChildRequest = contract.CreateTaskChildRequest + +// UpdateTaskRequest captures mutable task fields. +type UpdateTaskRequest = contract.UpdateTaskRequest + +// CancelTaskRequest captures the shared task-cancel payload. +type CancelTaskRequest = contract.CancelTaskRequest + +// AddTaskDependencyRequest captures the shared dependency-create payload. +type AddTaskDependencyRequest = contract.AddTaskDependencyRequest + +// EnqueueTaskRunRequest captures the shared run-enqueue payload. +type EnqueueTaskRunRequest = contract.EnqueueTaskRunRequest + +// ClaimTaskRunRequest captures the shared run-claim payload. +type ClaimTaskRunRequest = contract.ClaimTaskRunRequest + +// StartTaskRunRequest captures the shared run-start payload. +type StartTaskRunRequest = contract.StartTaskRunRequest + +// AttachTaskRunSessionRequest captures the shared run-session attach payload. +type AttachTaskRunSessionRequest = contract.AttachTaskRunSessionRequest + +// CompleteTaskRunRequest captures the shared run-complete payload. +type CompleteTaskRunRequest = contract.CompleteTaskRunRequest + +// FailTaskRunRequest captures the shared run-fail payload. +type FailTaskRunRequest = contract.FailTaskRunRequest + +// CancelTaskRunRequest captures the shared run-cancel payload. +type CancelTaskRunRequest = contract.CancelTaskRunRequest + // HealthStatus is the daemon API observability health payload. type HealthStatus = contract.ObserveHealthPayload @@ -870,6 +946,118 @@ func (c *unixSocketClient) GetAutomationRun(ctx context.Context, id string) (Run return response.Run, nil } +func (c *unixSocketClient) ListTasks(ctx context.Context, query TaskListQuery) ([]TaskSummaryRecord, error) { + var response contract.TasksResponse + if err := c.doJSON(ctx, http.MethodGet, "/api/tasks", taskValues(query), nil, &response); err != nil { + return nil, err + } + return response.Tasks, nil +} + +func (c *unixSocketClient) CreateTask(ctx context.Context, request CreateTaskRequest) (TaskRecord, error) { + var response contract.TaskResponse + if err := c.doJSON(ctx, http.MethodPost, "/api/tasks", nil, request, &response); err != nil { + return TaskRecord{}, err + } + return response.Task, nil +} + +func (c *unixSocketClient) GetTask(ctx context.Context, id string) (TaskDetailRecord, error) { + var response contract.TaskDetailResponse + path := "/api/tasks/" + url.PathEscape(strings.TrimSpace(id)) + if err := c.doJSON(ctx, http.MethodGet, path, nil, nil, &response); err != nil { + return TaskDetailRecord{}, err + } + return response.Task, nil +} + +func (c *unixSocketClient) UpdateTask(ctx context.Context, id string, request UpdateTaskRequest) (TaskRecord, error) { + var response contract.TaskResponse + path := "/api/tasks/" + url.PathEscape(strings.TrimSpace(id)) + if err := c.doJSON(ctx, http.MethodPatch, path, nil, request, &response); err != nil { + return TaskRecord{}, err + } + return response.Task, nil +} + +func (c *unixSocketClient) CancelTask(ctx context.Context, id string, request CancelTaskRequest) (TaskRecord, error) { + var response contract.TaskResponse + path := "/api/tasks/" + url.PathEscape(strings.TrimSpace(id)) + "/cancel" + if err := c.doJSON(ctx, http.MethodPost, path, nil, request, &response); err != nil { + return TaskRecord{}, err + } + return response.Task, nil +} + +func (c *unixSocketClient) CreateChildTask(ctx context.Context, id string, request CreateTaskChildRequest) (TaskRecord, error) { + var response contract.TaskResponse + path := "/api/tasks/" + url.PathEscape(strings.TrimSpace(id)) + "/children" + if err := c.doJSON(ctx, http.MethodPost, path, nil, request, &response); err != nil { + return TaskRecord{}, err + } + return response.Task, nil +} + +func (c *unixSocketClient) AddTaskDependency(ctx context.Context, id string, request AddTaskDependencyRequest) (TaskDetailRecord, error) { + var response contract.TaskDetailResponse + path := "/api/tasks/" + url.PathEscape(strings.TrimSpace(id)) + "/dependencies" + if err := c.doJSON(ctx, http.MethodPost, path, nil, request, &response); err != nil { + return TaskDetailRecord{}, err + } + return response.Task, nil +} + +func (c *unixSocketClient) RemoveTaskDependency(ctx context.Context, id string, dependsOnID string) (TaskDetailRecord, error) { + var response contract.TaskDetailResponse + path := "/api/tasks/" + url.PathEscape(strings.TrimSpace(id)) + "/dependencies/" + url.PathEscape(strings.TrimSpace(dependsOnID)) + if err := c.doJSON(ctx, http.MethodDelete, path, nil, nil, &response); err != nil { + return TaskDetailRecord{}, err + } + return response.Task, nil +} + +func (c *unixSocketClient) EnqueueTaskRun(ctx context.Context, id string, request EnqueueTaskRunRequest) (TaskRunRecord, error) { + var response contract.TaskRunResponse + path := "/api/tasks/" + url.PathEscape(strings.TrimSpace(id)) + "/runs" + if err := c.doJSON(ctx, http.MethodPost, path, nil, request, &response); err != nil { + return TaskRunRecord{}, err + } + return response.Run, nil +} + +func (c *unixSocketClient) ListTaskRuns(ctx context.Context, id string, query TaskRunListQuery) ([]TaskRunRecord, error) { + var response contract.TaskRunsResponse + path := "/api/tasks/" + url.PathEscape(strings.TrimSpace(id)) + "/runs" + if err := c.doJSON(ctx, http.MethodGet, path, taskRunValues(query), nil, &response); err != nil { + return nil, err + } + return response.Runs, nil +} + +func (c *unixSocketClient) ClaimTaskRun(ctx context.Context, id string, request ClaimTaskRunRequest) (TaskRunRecord, error) { + return c.taskRunAction(ctx, strings.TrimSpace(id), "claim", request) +} + +func (c *unixSocketClient) StartTaskRun(ctx context.Context, id string, request StartTaskRunRequest) (TaskRunRecord, error) { + return c.taskRunAction(ctx, strings.TrimSpace(id), "start", request) +} + +func (c *unixSocketClient) AttachTaskRunSession(ctx context.Context, id string, request AttachTaskRunSessionRequest) (TaskRunRecord, error) { + return c.taskRunAction(ctx, strings.TrimSpace(id), "attach-session", request) +} + +func (c *unixSocketClient) CompleteTaskRun(ctx context.Context, id string, request CompleteTaskRunRequest) (TaskRunRecord, error) { + return c.taskRunAction(ctx, strings.TrimSpace(id), "complete", request) +} + +func (c *unixSocketClient) FailTaskRun(ctx context.Context, id string, request FailTaskRunRequest) (TaskRunRecord, error) { + return c.taskRunAction(ctx, strings.TrimSpace(id), "fail", request) +} + +func (c *unixSocketClient) CancelTaskRun(ctx context.Context, id string, request CancelTaskRunRequest) (TaskRunRecord, error) { + return c.taskRunAction(ctx, strings.TrimSpace(id), "cancel", request) +} + func (c *unixSocketClient) extensionAction(ctx context.Context, name string, action string) (ExtensionRecord, error) { var response struct { Extension ExtensionRecord `json:"extension"` @@ -892,6 +1080,15 @@ func (c *unixSocketClient) bridgeAction(ctx context.Context, id string, action s return response.Bridge, nil } +func (c *unixSocketClient) taskRunAction(ctx context.Context, id string, action string, requestBody any) (TaskRunRecord, error) { + var response contract.TaskRunResponse + path := "/api/task-runs/" + url.PathEscape(id) + "/" + action + if err := c.doJSON(ctx, http.MethodPost, path, nil, requestBody, &response); err != nil { + return TaskRunRecord{}, err + } + return response.Run, nil +} + func (c *unixSocketClient) doJSON(ctx context.Context, method string, path string, query url.Values, requestBody any, responseBody any) error { response, err := c.doRequest(ctx, method, path, query, requestBody, "") if err != nil { @@ -1165,6 +1362,49 @@ func automationRunValues(query AutomationRunQuery) url.Values { return values } +func taskValues(query TaskListQuery) url.Values { + values := url.Values{} + if trimmed := strings.TrimSpace(string(query.Scope)); trimmed != "" { + values.Set("scope", trimmed) + } + if trimmed := strings.TrimSpace(query.Workspace); trimmed != "" { + values.Set("workspace", trimmed) + } + if trimmed := strings.TrimSpace(string(query.Status)); trimmed != "" { + values.Set("status", trimmed) + } + if trimmed := strings.TrimSpace(string(query.OwnerKind)); trimmed != "" { + values.Set("owner_kind", trimmed) + } + if trimmed := strings.TrimSpace(query.OwnerRef); trimmed != "" { + values.Set("owner_ref", trimmed) + } + if trimmed := strings.TrimSpace(query.ParentTaskID); trimmed != "" { + values.Set("parent_task_id", trimmed) + } + if trimmed := strings.TrimSpace(query.NetworkChannel); trimmed != "" { + values.Set("network_channel", trimmed) + } + if query.Limit > 0 { + values.Set("limit", strconv.Itoa(query.Limit)) + } + return values +} + +func taskRunValues(query TaskRunListQuery) url.Values { + values := url.Values{} + if trimmed := strings.TrimSpace(string(query.Status)); trimmed != "" { + values.Set("status", trimmed) + } + if trimmed := strings.TrimSpace(query.SessionID); trimmed != "" { + values.Set("session_id", trimmed) + } + if query.Limit > 0 { + values.Set("limit", strconv.Itoa(query.Limit)) + } + return values +} + func readAPIError(response *http.Response) error { body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) if err != nil { diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index bcb1308ea..c462943c3 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -14,6 +14,7 @@ import ( automationpkg "github.com/pedronauck/agh/internal/automation" bridgepkg "github.com/pedronauck/agh/internal/bridges" "github.com/pedronauck/agh/internal/memory" + taskpkg "github.com/pedronauck/agh/internal/task" ) type roundTripperFunc func(*http.Request) (*http.Response, error) @@ -706,6 +707,321 @@ func TestUnixSocketClientAutomationMethods(t *testing.T) { }) } +func TestUnixSocketClientTaskMethods(t *testing.T) { + t.Parallel() + + client := &unixSocketClient{ + socketPath: "/tmp/agh.sock", + httpClient: &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + switch { + case req.Method == http.MethodGet && req.URL.Path == "/api/tasks": + if got := req.URL.Query().Get("scope"); got != "workspace" { + t.Fatalf("task scope query = %q, want %q", got, "workspace") + } + if got := req.URL.Query().Get("workspace"); got != "alpha" { + t.Fatalf("task workspace query = %q, want %q", got, "alpha") + } + if got := req.URL.Query().Get("status"); got != "ready" { + t.Fatalf("task status query = %q, want %q", got, "ready") + } + if got := req.URL.Query().Get("owner_kind"); got != "pool" { + t.Fatalf("task owner_kind query = %q, want %q", got, "pool") + } + if got := req.URL.Query().Get("owner_ref"); got != "triage" { + t.Fatalf("task owner_ref query = %q, want %q", got, "triage") + } + if got := req.URL.Query().Get("parent_task_id"); got != "task-root" { + t.Fatalf("task parent_task_id query = %q, want %q", got, "task-root") + } + if got := req.URL.Query().Get("network_channel"); got != "builders" { + t.Fatalf("task network_channel query = %q, want %q", got, "builders") + } + if got := req.URL.Query().Get("limit"); got != "3" { + t.Fatalf("task limit query = %q, want %q", got, "3") + } + body := mustJSON(t, contract.TasksResponse{Tasks: []contract.TaskSummaryPayload{sampleTaskSummaryRecord()}}) + return newHTTPResponse(http.StatusOK, string(body)), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/tasks": + var payload contract.CreateTaskRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("json.Decode(create task body) error = %v", err) + } + if payload.Scope != taskpkg.ScopeWorkspace || + payload.Workspace != "alpha" || + payload.NetworkChannel != "builders" || + payload.Title != "Investigate flaky task runs" || + payload.Owner == nil || + payload.Owner.Kind != taskpkg.OwnerKindPool || + payload.Owner.Ref != "triage" { + t.Fatalf("create task payload = %#v", payload) + } + body := mustJSON(t, contract.TaskResponse{Task: sampleTaskRecord()}) + return newHTTPResponse(http.StatusCreated, string(body)), nil + case req.Method == http.MethodGet && req.URL.Path == "/api/tasks/task-1": + body := mustJSON(t, contract.TaskDetailResponse{Task: sampleTaskDetailRecord()}) + return newHTTPResponse(http.StatusOK, string(body)), nil + case req.Method == http.MethodPatch && req.URL.Path == "/api/tasks/task-1": + var payload contract.UpdateTaskRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("json.Decode(update task body) error = %v", err) + } + if payload.Title == nil || *payload.Title != "Investigate resolved" || payload.NetworkChannel == nil || *payload.NetworkChannel != "ops" { + t.Fatalf("update task payload = %#v", payload) + } + updated := sampleTaskRecord() + updated.Title = "Investigate resolved" + updated.NetworkChannel = "ops" + body := mustJSON(t, contract.TaskResponse{Task: updated}) + return newHTTPResponse(http.StatusOK, string(body)), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/tasks/task-1/cancel": + var payload contract.CancelTaskRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("json.Decode(cancel task body) error = %v", err) + } + if payload.Reason != "operator-request" { + t.Fatalf("cancel task payload = %#v, want reason", payload) + } + cancelled := sampleTaskRecord() + cancelled.Status = taskpkg.TaskStatusCancelled + body := mustJSON(t, contract.TaskResponse{Task: cancelled}) + return newHTTPResponse(http.StatusOK, string(body)), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/tasks/task-1/children": + var payload contract.CreateTaskChildRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("json.Decode(create child body) error = %v", err) + } + if payload.Scope != taskpkg.ScopeWorkspace || payload.Workspace != "alpha" || payload.Title != "Check runtime logs" { + t.Fatalf("create child payload = %#v", payload) + } + child := sampleTaskRecord() + child.ID = "task-child" + child.Title = "Check runtime logs" + body := mustJSON(t, contract.TaskResponse{Task: child}) + return newHTTPResponse(http.StatusCreated, string(body)), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/tasks/task-1/dependencies": + var payload contract.AddTaskDependencyRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("json.Decode(add dependency body) error = %v", err) + } + if payload.DependsOnTaskID != "task-blocker" || payload.Kind != taskpkg.DependencyKindBlocks { + t.Fatalf("add dependency payload = %#v", payload) + } + body := mustJSON(t, contract.TaskDetailResponse{Task: sampleTaskDetailRecord()}) + return newHTTPResponse(http.StatusOK, string(body)), nil + case req.Method == http.MethodDelete && req.URL.Path == "/api/tasks/task-1/dependencies/task-blocker": + body := mustJSON(t, contract.TaskDetailResponse{Task: sampleTaskDetailRecord()}) + return newHTTPResponse(http.StatusOK, string(body)), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/tasks/task-1/runs": + var payload contract.EnqueueTaskRunRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("json.Decode(enqueue run body) error = %v", err) + } + if payload.IdempotencyKey != "idem-1" || payload.NetworkChannel != "builders" { + t.Fatalf("enqueue run payload = %#v", payload) + } + body := mustJSON(t, contract.TaskRunResponse{Run: sampleTaskRunRecord(taskpkg.TaskRunStatusQueued)}) + return newHTTPResponse(http.StatusCreated, string(body)), nil + case req.Method == http.MethodGet && req.URL.Path == "/api/tasks/task-1/runs": + if got := req.URL.Query().Get("status"); got != "running" { + t.Fatalf("task runs status query = %q, want %q", got, "running") + } + if got := req.URL.Query().Get("session_id"); got != "sess-1" { + t.Fatalf("task runs session_id query = %q, want %q", got, "sess-1") + } + if got := req.URL.Query().Get("limit"); got != "2" { + t.Fatalf("task runs limit query = %q, want %q", got, "2") + } + body := mustJSON(t, contract.TaskRunsResponse{Runs: []contract.TaskRunPayload{sampleTaskRunRecord(taskpkg.TaskRunStatusRunning)}}) + return newHTTPResponse(http.StatusOK, string(body)), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/task-runs/run-1/claim": + var payload contract.ClaimTaskRunRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("json.Decode(claim run body) error = %v", err) + } + if payload.IdempotencyKey != "idem-claim" { + t.Fatalf("claim run payload = %#v", payload) + } + body := mustJSON(t, contract.TaskRunResponse{Run: sampleTaskRunRecord(taskpkg.TaskRunStatusClaimed)}) + return newHTTPResponse(http.StatusOK, string(body)), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/task-runs/run-1/start": + var payload contract.StartTaskRunRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("json.Decode(start run body) error = %v", err) + } + if payload.IdempotencyKey != "idem-start" { + t.Fatalf("start run payload = %#v", payload) + } + body := mustJSON(t, contract.TaskRunResponse{Run: sampleTaskRunRecord(taskpkg.TaskRunStatusRunning)}) + return newHTTPResponse(http.StatusOK, string(body)), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/task-runs/run-1/attach-session": + var payload contract.AttachTaskRunSessionRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("json.Decode(attach session body) error = %v", err) + } + if payload.SessionID != "sess-attach" { + t.Fatalf("attach session payload = %#v", payload) + } + body := mustJSON(t, contract.TaskRunResponse{Run: sampleTaskRunRecord(taskpkg.TaskRunStatusStarting)}) + return newHTTPResponse(http.StatusOK, string(body)), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/task-runs/run-1/complete": + var payload contract.CompleteTaskRunRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("json.Decode(complete run body) error = %v", err) + } + if string(payload.Result) != `{"ok":true}` { + t.Fatalf("complete run payload = %#v", payload) + } + body := mustJSON(t, contract.TaskRunResponse{Run: sampleTaskRunRecord(taskpkg.TaskRunStatusCompleted)}) + return newHTTPResponse(http.StatusOK, string(body)), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/task-runs/run-1/fail": + var payload contract.FailTaskRunRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("json.Decode(fail run body) error = %v", err) + } + if payload.Error != "boom" || string(payload.Metadata) != `{"code":"E_TASK"}` { + t.Fatalf("fail run payload = %#v", payload) + } + body := mustJSON(t, contract.TaskRunResponse{Run: sampleTaskRunRecord(taskpkg.TaskRunStatusFailed)}) + return newHTTPResponse(http.StatusOK, string(body)), nil + case req.Method == http.MethodPost && req.URL.Path == "/api/task-runs/run-1/cancel": + var payload contract.CancelTaskRunRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("json.Decode(cancel run body) error = %v", err) + } + if payload.Reason != "operator-request" || string(payload.Metadata) != `{"source":"cli"}` { + t.Fatalf("cancel run payload = %#v", payload) + } + body := mustJSON(t, contract.TaskRunResponse{Run: sampleTaskRunRecord(taskpkg.TaskRunStatusCancelled)}) + return newHTTPResponse(http.StatusOK, string(body)), nil + default: + return newHTTPResponse(http.StatusNotFound, `{"error":"missing"}`), nil + } + }), + }, + } + + ctx := context.Background() + + t.Run("Should list tasks", func(t *testing.T) { + tasks, err := client.ListTasks(ctx, TaskListQuery{ + Scope: taskpkg.ScopeWorkspace, + Workspace: "alpha", + Status: taskpkg.TaskStatusReady, + OwnerKind: taskpkg.OwnerKindPool, + OwnerRef: "triage", + ParentTaskID: "task-root", + NetworkChannel: "builders", + Limit: 3, + }) + if err != nil || len(tasks) != 1 || tasks[0].ID != "task-1" { + t.Fatalf("ListTasks() = %#v, %v", tasks, err) + } + }) + + t.Run("Should create get update and cancel tasks", func(t *testing.T) { + created, err := client.CreateTask(ctx, CreateTaskRequest{ + Scope: taskpkg.ScopeWorkspace, + Workspace: "alpha", + NetworkChannel: "builders", + Title: "Investigate flaky task runs", + Owner: &taskpkg.Ownership{Kind: taskpkg.OwnerKindPool, Ref: "triage"}, + }) + if err != nil || created.ID != "task-1" { + t.Fatalf("CreateTask() = %#v, %v", created, err) + } + + detail, err := client.GetTask(ctx, "task-1") + if err != nil || detail.Task.ID != "task-1" || len(detail.Dependencies) != 1 { + t.Fatalf("GetTask() = %#v, %v", detail, err) + } + + updated, err := client.UpdateTask(ctx, "task-1", UpdateTaskRequest{ + Title: ptr("Investigate resolved"), + NetworkChannel: ptr("ops"), + }) + if err != nil || updated.Title != "Investigate resolved" || updated.NetworkChannel != "ops" { + t.Fatalf("UpdateTask() = %#v, %v", updated, err) + } + + cancelled, err := client.CancelTask(ctx, "task-1", CancelTaskRequest{Reason: "operator-request"}) + if err != nil || cancelled.Status != taskpkg.TaskStatusCancelled { + t.Fatalf("CancelTask() = %#v, %v", cancelled, err) + } + }) + + t.Run("Should manage child tasks dependencies and runs", func(t *testing.T) { + child, err := client.CreateChildTask(ctx, "task-1", CreateTaskChildRequest{ + Scope: taskpkg.ScopeWorkspace, + Workspace: "alpha", + Title: "Check runtime logs", + }) + if err != nil || child.ID != "task-child" { + t.Fatalf("CreateChildTask() = %#v, %v", child, err) + } + + detail, err := client.AddTaskDependency(ctx, "task-1", AddTaskDependencyRequest{ + DependsOnTaskID: "task-blocker", + Kind: taskpkg.DependencyKindBlocks, + }) + if err != nil || len(detail.Dependencies) != 1 { + t.Fatalf("AddTaskDependency() = %#v, %v", detail, err) + } + + detail, err = client.RemoveTaskDependency(ctx, "task-1", "task-blocker") + if err != nil || len(detail.Runs) != 1 { + t.Fatalf("RemoveTaskDependency() = %#v, %v", detail, err) + } + + enqueued, err := client.EnqueueTaskRun(ctx, "task-1", EnqueueTaskRunRequest{ + IdempotencyKey: "idem-1", + NetworkChannel: "builders", + }) + if err != nil || enqueued.Status != taskpkg.TaskRunStatusQueued { + t.Fatalf("EnqueueTaskRun() = %#v, %v", enqueued, err) + } + + runs, err := client.ListTaskRuns(ctx, "task-1", TaskRunListQuery{ + Status: taskpkg.TaskRunStatusRunning, + SessionID: "sess-1", + Limit: 2, + }) + if err != nil || len(runs) != 1 || runs[0].Status != taskpkg.TaskRunStatusRunning { + t.Fatalf("ListTaskRuns() = %#v, %v", runs, err) + } + + claimed, err := client.ClaimTaskRun(ctx, "run-1", ClaimTaskRunRequest{IdempotencyKey: "idem-claim"}) + if err != nil || claimed.Status != taskpkg.TaskRunStatusClaimed { + t.Fatalf("ClaimTaskRun() = %#v, %v", claimed, err) + } + + started, err := client.StartTaskRun(ctx, "run-1", StartTaskRunRequest{IdempotencyKey: "idem-start"}) + if err != nil || started.Status != taskpkg.TaskRunStatusRunning { + t.Fatalf("StartTaskRun() = %#v, %v", started, err) + } + + attached, err := client.AttachTaskRunSession(ctx, "run-1", AttachTaskRunSessionRequest{SessionID: "sess-attach"}) + if err != nil || attached.Status != taskpkg.TaskRunStatusStarting { + t.Fatalf("AttachTaskRunSession() = %#v, %v", attached, err) + } + + completed, err := client.CompleteTaskRun(ctx, "run-1", CompleteTaskRunRequest{Result: mustJSON(t, map[string]bool{"ok": true})}) + if err != nil || completed.Status != taskpkg.TaskRunStatusCompleted { + t.Fatalf("CompleteTaskRun() = %#v, %v", completed, err) + } + + failed, err := client.FailTaskRun(ctx, "run-1", FailTaskRunRequest{Error: "boom", Metadata: mustJSON(t, map[string]string{"code": "E_TASK"})}) + if err != nil || failed.Status != taskpkg.TaskRunStatusFailed { + t.Fatalf("FailTaskRun() = %#v, %v", failed, err) + } + + cancelled, err := client.CancelTaskRun(ctx, "run-1", CancelTaskRunRequest{Reason: "operator-request", Metadata: mustJSON(t, map[string]string{"source": "cli"})}) + if err != nil || cancelled.Status != taskpkg.TaskRunStatusCancelled { + t.Fatalf("CancelTaskRun() = %#v, %v", cancelled, err) + } + }) +} + func TestUnixSocketClientBridgeMethods(t *testing.T) { t.Parallel() @@ -933,6 +1249,27 @@ func TestReadAPIErrorAndHelpers(t *testing.T) { t.Fatalf("automationRunValues() = %v, want all run filters", got) } + if got := taskValues(TaskListQuery{ + Scope: taskpkg.ScopeWorkspace, + Workspace: "alpha", + Status: taskpkg.TaskStatusReady, + OwnerKind: taskpkg.OwnerKindPool, + OwnerRef: "triage", + ParentTaskID: "task-root", + NetworkChannel: "builders", + Limit: 3, + }); got.Get("scope") != "workspace" || got.Get("workspace") != "alpha" || got.Get("status") != "ready" || got.Get("owner_kind") != "pool" || got.Get("owner_ref") != "triage" || got.Get("parent_task_id") != "task-root" || got.Get("network_channel") != "builders" || got.Get("limit") != "3" { + t.Fatalf("taskValues() = %v, want all task filters", got) + } + + if got := taskRunValues(TaskRunListQuery{ + Status: taskpkg.TaskRunStatusRunning, + SessionID: "sess-1", + Limit: 2, + }); got.Get("status") != "running" || got.Get("session_id") != "sess-1" || got.Get("limit") != "2" { + t.Fatalf("taskRunValues() = %v, want all task run filters", got) + } + plain := newHTTPResponse(http.StatusInternalServerError, "plain failure") if err := readAPIError(plain); err == nil || !strings.Contains(err.Error(), "plain failure") { t.Fatalf("readAPIError(plain) = %v, want plain failure", err) diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go index 9ee29bfff..2fc1378bf 100644 --- a/internal/cli/helpers_test.go +++ b/internal/cli/helpers_test.go @@ -79,6 +79,22 @@ type stubClient struct { automationTriggerRunsFn func(context.Context, string, AutomationRunQuery) ([]RunRecord, error) listAutomationRunsFn func(context.Context, AutomationRunQuery) ([]RunRecord, error) getAutomationRunFn func(context.Context, string) (RunRecord, error) + listTasksFn func(context.Context, TaskListQuery) ([]TaskSummaryRecord, error) + createTaskFn func(context.Context, CreateTaskRequest) (TaskRecord, error) + getTaskFn func(context.Context, string) (TaskDetailRecord, error) + updateTaskFn func(context.Context, string, UpdateTaskRequest) (TaskRecord, error) + cancelTaskFn func(context.Context, string, CancelTaskRequest) (TaskRecord, error) + createChildTaskFn func(context.Context, string, CreateTaskChildRequest) (TaskRecord, error) + addTaskDependencyFn func(context.Context, string, AddTaskDependencyRequest) (TaskDetailRecord, error) + removeTaskDependencyFn func(context.Context, string, string) (TaskDetailRecord, error) + enqueueTaskRunFn func(context.Context, string, EnqueueTaskRunRequest) (TaskRunRecord, error) + listTaskRunsFn func(context.Context, string, TaskRunListQuery) ([]TaskRunRecord, error) + claimTaskRunFn func(context.Context, string, ClaimTaskRunRequest) (TaskRunRecord, error) + startTaskRunFn func(context.Context, string, StartTaskRunRequest) (TaskRunRecord, error) + attachTaskRunSessionFn func(context.Context, string, AttachTaskRunSessionRequest) (TaskRunRecord, error) + completeTaskRunFn func(context.Context, string, CompleteTaskRunRequest) (TaskRunRecord, error) + failTaskRunFn func(context.Context, string, FailTaskRunRequest) (TaskRunRecord, error) + cancelTaskRunFn func(context.Context, string, CancelTaskRunRequest) (TaskRunRecord, error) } var _ DaemonClient = stubClient{} @@ -517,6 +533,118 @@ func (s stubClient) GetAutomationRun(ctx context.Context, id string) (RunRecord, return RunRecord{}, errors.New("unexpected GetAutomationRun call") } +func (s stubClient) ListTasks(ctx context.Context, query TaskListQuery) ([]TaskSummaryRecord, error) { + if s.listTasksFn != nil { + return s.listTasksFn(ctx, query) + } + return nil, errors.New("unexpected ListTasks call") +} + +func (s stubClient) CreateTask(ctx context.Context, request CreateTaskRequest) (TaskRecord, error) { + if s.createTaskFn != nil { + return s.createTaskFn(ctx, request) + } + return TaskRecord{}, errors.New("unexpected CreateTask call") +} + +func (s stubClient) GetTask(ctx context.Context, id string) (TaskDetailRecord, error) { + if s.getTaskFn != nil { + return s.getTaskFn(ctx, id) + } + return TaskDetailRecord{}, errors.New("unexpected GetTask call") +} + +func (s stubClient) UpdateTask(ctx context.Context, id string, request UpdateTaskRequest) (TaskRecord, error) { + if s.updateTaskFn != nil { + return s.updateTaskFn(ctx, id, request) + } + return TaskRecord{}, errors.New("unexpected UpdateTask call") +} + +func (s stubClient) CancelTask(ctx context.Context, id string, request CancelTaskRequest) (TaskRecord, error) { + if s.cancelTaskFn != nil { + return s.cancelTaskFn(ctx, id, request) + } + return TaskRecord{}, errors.New("unexpected CancelTask call") +} + +func (s stubClient) CreateChildTask(ctx context.Context, id string, request CreateTaskChildRequest) (TaskRecord, error) { + if s.createChildTaskFn != nil { + return s.createChildTaskFn(ctx, id, request) + } + return TaskRecord{}, errors.New("unexpected CreateChildTask call") +} + +func (s stubClient) AddTaskDependency(ctx context.Context, id string, request AddTaskDependencyRequest) (TaskDetailRecord, error) { + if s.addTaskDependencyFn != nil { + return s.addTaskDependencyFn(ctx, id, request) + } + return TaskDetailRecord{}, errors.New("unexpected AddTaskDependency call") +} + +func (s stubClient) RemoveTaskDependency(ctx context.Context, id string, dependsOnID string) (TaskDetailRecord, error) { + if s.removeTaskDependencyFn != nil { + return s.removeTaskDependencyFn(ctx, id, dependsOnID) + } + return TaskDetailRecord{}, errors.New("unexpected RemoveTaskDependency call") +} + +func (s stubClient) EnqueueTaskRun(ctx context.Context, id string, request EnqueueTaskRunRequest) (TaskRunRecord, error) { + if s.enqueueTaskRunFn != nil { + return s.enqueueTaskRunFn(ctx, id, request) + } + return TaskRunRecord{}, errors.New("unexpected EnqueueTaskRun call") +} + +func (s stubClient) ListTaskRuns(ctx context.Context, id string, query TaskRunListQuery) ([]TaskRunRecord, error) { + if s.listTaskRunsFn != nil { + return s.listTaskRunsFn(ctx, id, query) + } + return nil, errors.New("unexpected ListTaskRuns call") +} + +func (s stubClient) ClaimTaskRun(ctx context.Context, id string, request ClaimTaskRunRequest) (TaskRunRecord, error) { + if s.claimTaskRunFn != nil { + return s.claimTaskRunFn(ctx, id, request) + } + return TaskRunRecord{}, errors.New("unexpected ClaimTaskRun call") +} + +func (s stubClient) StartTaskRun(ctx context.Context, id string, request StartTaskRunRequest) (TaskRunRecord, error) { + if s.startTaskRunFn != nil { + return s.startTaskRunFn(ctx, id, request) + } + return TaskRunRecord{}, errors.New("unexpected StartTaskRun call") +} + +func (s stubClient) AttachTaskRunSession(ctx context.Context, id string, request AttachTaskRunSessionRequest) (TaskRunRecord, error) { + if s.attachTaskRunSessionFn != nil { + return s.attachTaskRunSessionFn(ctx, id, request) + } + return TaskRunRecord{}, errors.New("unexpected AttachTaskRunSession call") +} + +func (s stubClient) CompleteTaskRun(ctx context.Context, id string, request CompleteTaskRunRequest) (TaskRunRecord, error) { + if s.completeTaskRunFn != nil { + return s.completeTaskRunFn(ctx, id, request) + } + return TaskRunRecord{}, errors.New("unexpected CompleteTaskRun call") +} + +func (s stubClient) FailTaskRun(ctx context.Context, id string, request FailTaskRunRequest) (TaskRunRecord, error) { + if s.failTaskRunFn != nil { + return s.failTaskRunFn(ctx, id, request) + } + return TaskRunRecord{}, errors.New("unexpected FailTaskRun call") +} + +func (s stubClient) CancelTaskRun(ctx context.Context, id string, request CancelTaskRunRequest) (TaskRunRecord, error) { + if s.cancelTaskRunFn != nil { + return s.cancelTaskRunFn(ctx, id, request) + } + return TaskRunRecord{}, errors.New("unexpected CancelTaskRun call") +} + func newTestDeps(t *testing.T, client DaemonClient) commandDeps { t.Helper() diff --git a/internal/cli/root.go b/internal/cli/root.go index c7840cce2..5d18aa13a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -90,6 +90,7 @@ func newRootCommand(deps commandDeps) *cobra.Command { cmd.AddCommand(newExtensionCommand(deps)) cmd.AddCommand(newHooksCommand(deps)) cmd.AddCommand(newAutomationCommand(deps)) + cmd.AddCommand(newTaskCommand(deps)) cmd.AddCommand(newSkillCommand(deps)) cmd.AddCommand(newMemoryCommand(deps)) cmd.AddCommand(newObserveCommand(deps)) diff --git a/internal/cli/task.go b/internal/cli/task.go new file mode 100644 index 000000000..07979c620 --- /dev/null +++ b/internal/cli/task.go @@ -0,0 +1,1280 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/pedronauck/agh/internal/network" + taskpkg "github.com/pedronauck/agh/internal/task" + "github.com/spf13/cobra" +) + +func newTaskCommand(deps commandDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "task", + Short: "Manage tasks and task runs", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(newTaskListCommand(deps)) + cmd.AddCommand(newTaskCreateCommand(deps)) + cmd.AddCommand(newTaskGetCommand(deps)) + cmd.AddCommand(newTaskUpdateCommand(deps)) + cmd.AddCommand(newTaskCancelCommand(deps)) + cmd.AddCommand(newTaskChildCommand(deps)) + cmd.AddCommand(newTaskDependencyCommand(deps)) + cmd.AddCommand(newTaskRunCommand(deps)) + return cmd +} + +func newTaskListCommand(deps commandDeps) *cobra.Command { + var ( + scopeRaw string + workspaceRef string + statusRaw string + ownerKindRaw string + ownerRef string + parentTaskID string + networkRaw string + last int + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List tasks", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + + query, err := parseTaskListFilters(scopeRaw, workspaceRef, statusRaw, ownerKindRaw, ownerRef, parentTaskID, networkRaw, last) + if err != nil { + return err + } + + tasks, err := client.ListTasks(cmd.Context(), query) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskSummaryListBundle(tasks)) + }, + } + cmd.Flags().StringVar(&scopeRaw, "scope", "", "Filter by scope: global or workspace") + cmd.Flags().StringVar(&workspaceRef, "workspace", "", "Filter by workspace path, name, or ID") + cmd.Flags().StringVar(&statusRaw, "status", "", "Filter by task status") + cmd.Flags().StringVar(&ownerKindRaw, "owner-kind", "", "Filter by owner kind") + cmd.Flags().StringVar(&ownerRef, "owner-ref", "", "Filter by owner reference") + cmd.Flags().StringVar(&parentTaskID, "parent", "", "Filter by parent task ID") + cmd.Flags().StringVar(&networkRaw, "channel", "", "Filter by network channel") + cmd.Flags().IntVar(&last, "last", 0, "Show only the most recent N tasks") + return cmd +} + +func newTaskCreateCommand(deps commandDeps) *cobra.Command { + var ( + id string + identifier string + scopeRaw string + workspaceRef string + networkRaw string + title string + description string + ownerKindRaw string + ownerRef string + metadataRaw string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a task", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + + scope, workspace, err := resolveTaskScopeWorkspace(scopeRaw, workspaceRef, true) + if err != nil { + return err + } + if err := validateTaskChannelFlag("channel", networkRaw); err != nil { + return err + } + + var owner *taskpkg.Ownership + if cmd.Flags().Changed("owner-kind") || cmd.Flags().Changed("owner-ref") { + owner, err = parseRequiredTaskOwnership(ownerKindRaw, ownerRef) + if err != nil { + return err + } + } + + var metadata json.RawMessage + if cmd.Flags().Changed("metadata") { + metadata, err = parseJSONFlag("metadata", metadataRaw) + if err != nil { + return err + } + } + + request := CreateTaskRequest{ + ID: strings.TrimSpace(id), + Identifier: strings.TrimSpace(identifier), + Scope: scope, + Workspace: workspace, + NetworkChannel: strings.TrimSpace(networkRaw), + Title: strings.TrimSpace(title), + Description: strings.TrimSpace(description), + Owner: owner, + Metadata: metadata, + } + if request.Title == "" { + return errors.New("cli: --title is required") + } + + created, err := client.CreateTask(cmd.Context(), request) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskBundle(created)) + }, + } + cmd.Flags().StringVar(&id, "id", "", "Explicit task ID") + cmd.Flags().StringVar(&identifier, "identifier", "", "Human-friendly task identifier") + cmd.Flags().StringVar(&scopeRaw, "scope", "", "Task scope: global or workspace") + cmd.Flags().StringVar(&workspaceRef, "workspace", "", "Workspace path, name, or ID (required when --scope=workspace)") + cmd.Flags().StringVar(&networkRaw, "channel", "", "Optional network channel binding") + cmd.Flags().StringVar(&title, "title", "", "Task title") + cmd.Flags().StringVar(&description, "description", "", "Task description") + cmd.Flags().StringVar(&ownerKindRaw, "owner-kind", "", "Optional owner kind") + cmd.Flags().StringVar(&ownerRef, "owner-ref", "", "Optional owner reference") + cmd.Flags().StringVar(&metadataRaw, "metadata", "", "Optional metadata JSON") + mustMarkFlagRequired(cmd, "scope") + mustMarkFlagRequired(cmd, "title") + return cmd +} + +func newTaskGetCommand(deps commandDeps) *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Show one task with related detail", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + + taskDetail, err := client.GetTask(cmd.Context(), args[0]) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskDetailBundle(taskDetail)) + }, + } +} + +func newTaskUpdateCommand(deps commandDeps) *cobra.Command { + var ( + title string + description string + metadataRaw string + networkRaw string + ownerKindRaw string + ownerRef string + clearOwner bool + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update mutable task fields", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + + request := UpdateTaskRequest{} + if cmd.Flags().Changed("title") { + trimmed := strings.TrimSpace(title) + if trimmed == "" { + return errors.New("cli: --title cannot be blank") + } + request.Title = stringPointer(trimmed) + } + if cmd.Flags().Changed("description") { + request.Description = stringPointer(strings.TrimSpace(description)) + } + if cmd.Flags().Changed("metadata") { + metadata, err := parseJSONFlag("metadata", metadataRaw) + if err != nil { + return err + } + request.Metadata = &metadata + } + if cmd.Flags().Changed("channel") { + if err := validateTaskChannelFlag("channel", networkRaw); err != nil { + return err + } + request.NetworkChannel = stringPointer(strings.TrimSpace(networkRaw)) + } + + ownerChanged := cmd.Flags().Changed("owner-kind") || cmd.Flags().Changed("owner-ref") + if clearOwner && ownerChanged { + return errors.New("cli: --clear-owner cannot be combined with --owner-kind or --owner-ref") + } + if ownerChanged { + owner, err := parseRequiredTaskOwnership(ownerKindRaw, ownerRef) + if err != nil { + return err + } + request.Owner = owner + } + if clearOwner { + request.ClearOwner = true + } + if !request.HasChanges() { + return errors.New("cli: task update requires at least one change flag") + } + + updated, err := client.UpdateTask(cmd.Context(), args[0], request) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskBundle(updated)) + }, + } + cmd.Flags().StringVar(&title, "title", "", "Update the task title") + cmd.Flags().StringVar(&description, "description", "", "Update the task description") + cmd.Flags().StringVar(&metadataRaw, "metadata", "", "Update metadata JSON") + cmd.Flags().StringVar(&networkRaw, "channel", "", "Update the network channel; pass an empty value to clear it") + cmd.Flags().StringVar(&ownerKindRaw, "owner-kind", "", "Update the owner kind") + cmd.Flags().StringVar(&ownerRef, "owner-ref", "", "Update the owner reference") + cmd.Flags().BoolVar(&clearOwner, "clear-owner", false, "Remove the current owner") + return cmd +} + +func newTaskCancelCommand(deps commandDeps) *cobra.Command { + var ( + reason string + metadataRaw string + ) + + cmd := &cobra.Command{ + Use: "cancel ", + Short: "Cancel a task tree", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + + request := CancelTaskRequest{Reason: strings.TrimSpace(reason)} + if cmd.Flags().Changed("metadata") { + request.Metadata, err = parseJSONFlag("metadata", metadataRaw) + if err != nil { + return err + } + } + + cancelled, err := client.CancelTask(cmd.Context(), args[0], request) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskBundle(cancelled)) + }, + } + cmd.Flags().StringVar(&reason, "reason", "", "Optional cancellation reason") + cmd.Flags().StringVar(&metadataRaw, "metadata", "", "Optional cancellation metadata JSON") + return cmd +} + +func newTaskChildCommand(deps commandDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "child", + Short: "Manage child tasks", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newTaskChildCreateCommand(deps)) + return cmd +} + +func newTaskChildCreateCommand(deps commandDeps) *cobra.Command { + var ( + id string + identifier string + scopeRaw string + workspaceRef string + networkRaw string + title string + description string + ownerKindRaw string + ownerRef string + metadataRaw string + ) + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a child task beneath a parent", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + + scope, workspace, err := resolveTaskScopeWorkspace(scopeRaw, workspaceRef, true) + if err != nil { + return err + } + if err := validateTaskChannelFlag("channel", networkRaw); err != nil { + return err + } + + var owner *taskpkg.Ownership + if cmd.Flags().Changed("owner-kind") || cmd.Flags().Changed("owner-ref") { + owner, err = parseRequiredTaskOwnership(ownerKindRaw, ownerRef) + if err != nil { + return err + } + } + + var metadata json.RawMessage + if cmd.Flags().Changed("metadata") { + metadata, err = parseJSONFlag("metadata", metadataRaw) + if err != nil { + return err + } + } + + request := CreateTaskChildRequest{ + ID: strings.TrimSpace(id), + Identifier: strings.TrimSpace(identifier), + Scope: scope, + Workspace: workspace, + NetworkChannel: strings.TrimSpace(networkRaw), + Title: strings.TrimSpace(title), + Description: strings.TrimSpace(description), + Owner: owner, + Metadata: metadata, + } + if request.Title == "" { + return errors.New("cli: --title is required") + } + + created, err := client.CreateChildTask(cmd.Context(), args[0], request) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskBundle(created)) + }, + } + cmd.Flags().StringVar(&id, "id", "", "Explicit child task ID") + cmd.Flags().StringVar(&identifier, "identifier", "", "Human-friendly child task identifier") + cmd.Flags().StringVar(&scopeRaw, "scope", "", "Child task scope: global or workspace") + cmd.Flags().StringVar(&workspaceRef, "workspace", "", "Workspace path, name, or ID (required when --scope=workspace)") + cmd.Flags().StringVar(&networkRaw, "channel", "", "Optional network channel binding") + cmd.Flags().StringVar(&title, "title", "", "Child task title") + cmd.Flags().StringVar(&description, "description", "", "Child task description") + cmd.Flags().StringVar(&ownerKindRaw, "owner-kind", "", "Optional child owner kind") + cmd.Flags().StringVar(&ownerRef, "owner-ref", "", "Optional child owner reference") + cmd.Flags().StringVar(&metadataRaw, "metadata", "", "Optional child metadata JSON") + mustMarkFlagRequired(cmd, "scope") + mustMarkFlagRequired(cmd, "title") + return cmd +} + +func newTaskDependencyCommand(deps commandDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "dependency", + Short: "Manage task dependencies", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newTaskDependencyAddCommand(deps)) + cmd.AddCommand(newTaskDependencyRemoveCommand(deps)) + return cmd +} + +func newTaskDependencyAddCommand(deps commandDeps) *cobra.Command { + var ( + dependsOnID string + kindRaw string + ) + + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a dependency edge to a task", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + + request := AddTaskDependencyRequest{DependsOnTaskID: strings.TrimSpace(dependsOnID)} + if request.DependsOnTaskID == "" { + return errors.New("cli: --depends-on is required") + } + if strings.TrimSpace(kindRaw) != "" { + kind, err := parseOptionalTaskDependencyKind(kindRaw) + if err != nil { + return err + } + request.Kind = kind + } + + updated, err := client.AddTaskDependency(cmd.Context(), args[0], request) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskDetailBundle(updated)) + }, + } + cmd.Flags().StringVar(&dependsOnID, "depends-on", "", "Dependency task ID") + cmd.Flags().StringVar(&kindRaw, "kind", "", "Dependency kind") + mustMarkFlagRequired(cmd, "depends-on") + return cmd +} + +func newTaskDependencyRemoveCommand(deps commandDeps) *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove a dependency edge from a task", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + + updated, err := client.RemoveTaskDependency(cmd.Context(), args[0], args[1]) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskDetailBundle(updated)) + }, + } +} + +func newTaskRunCommand(deps commandDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "run", + Short: "Manage task runs", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newTaskRunListCommand(deps)) + cmd.AddCommand(newTaskRunEnqueueCommand(deps)) + cmd.AddCommand(newTaskRunClaimCommand(deps)) + cmd.AddCommand(newTaskRunStartCommand(deps)) + cmd.AddCommand(newTaskRunAttachSessionCommand(deps)) + cmd.AddCommand(newTaskRunCompleteCommand(deps)) + cmd.AddCommand(newTaskRunFailCommand(deps)) + cmd.AddCommand(newTaskRunCancelCommand(deps)) + return cmd +} + +func newTaskRunListCommand(deps commandDeps) *cobra.Command { + var ( + statusRaw string + sessionID string + last int + ) + + cmd := &cobra.Command{ + Use: "list ", + Short: "List runs for a task", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + + query, err := parseTaskRunListFilters(statusRaw, sessionID, last) + if err != nil { + return err + } + + runs, err := client.ListTaskRuns(cmd.Context(), args[0], query) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskRunListBundle(runs)) + }, + } + cmd.Flags().StringVar(&statusRaw, "status", "", "Filter by run status") + cmd.Flags().StringVar(&sessionID, "session", "", "Filter by attached session ID") + cmd.Flags().IntVar(&last, "last", 0, "Show only the most recent N runs") + return cmd +} + +func newTaskRunEnqueueCommand(deps commandDeps) *cobra.Command { + var ( + idempotencyKey string + networkRaw string + ) + + cmd := &cobra.Command{ + Use: "enqueue ", + Short: "Enqueue a task run", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + if err := validateTaskChannelFlag("channel", networkRaw); err != nil { + return err + } + + run, err := client.EnqueueTaskRun(cmd.Context(), args[0], EnqueueTaskRunRequest{ + IdempotencyKey: strings.TrimSpace(idempotencyKey), + NetworkChannel: strings.TrimSpace(networkRaw), + }) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskRunBundle(run)) + }, + } + cmd.Flags().StringVar(&idempotencyKey, "idempotency-key", "", "Optional idempotency key") + cmd.Flags().StringVar(&networkRaw, "channel", "", "Optional run channel override") + return cmd +} + +func newTaskRunClaimCommand(deps commandDeps) *cobra.Command { + var idempotencyKey string + + cmd := &cobra.Command{ + Use: "claim ", + Short: "Claim a queued task run", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + + run, err := client.ClaimTaskRun(cmd.Context(), args[0], ClaimTaskRunRequest{ + IdempotencyKey: strings.TrimSpace(idempotencyKey), + }) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskRunBundle(run)) + }, + } + cmd.Flags().StringVar(&idempotencyKey, "idempotency-key", "", "Optional idempotency key") + return cmd +} + +func newTaskRunStartCommand(deps commandDeps) *cobra.Command { + var idempotencyKey string + + cmd := &cobra.Command{ + Use: "start ", + Short: "Start a claimed task run", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + + run, err := client.StartTaskRun(cmd.Context(), args[0], StartTaskRunRequest{ + IdempotencyKey: strings.TrimSpace(idempotencyKey), + }) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskRunBundle(run)) + }, + } + cmd.Flags().StringVar(&idempotencyKey, "idempotency-key", "", "Optional idempotency key") + return cmd +} + +func newTaskRunAttachSessionCommand(deps commandDeps) *cobra.Command { + var sessionID string + + cmd := &cobra.Command{ + Use: "attach-session ", + Short: "Attach an existing session to a claimed or starting run", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + if strings.TrimSpace(sessionID) == "" { + return errors.New("cli: --session is required") + } + + run, err := client.AttachTaskRunSession(cmd.Context(), args[0], AttachTaskRunSessionRequest{ + SessionID: strings.TrimSpace(sessionID), + }) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskRunBundle(run)) + }, + } + cmd.Flags().StringVar(&sessionID, "session", "", "Existing session ID to attach") + mustMarkFlagRequired(cmd, "session") + return cmd +} + +func newTaskRunCompleteCommand(deps commandDeps) *cobra.Command { + var resultRaw string + + cmd := &cobra.Command{ + Use: "complete ", + Short: "Complete a running task run", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + + request := CompleteTaskRunRequest{} + if cmd.Flags().Changed("result") { + request.Result, err = parseJSONFlag("result", resultRaw) + if err != nil { + return err + } + } + + run, err := client.CompleteTaskRun(cmd.Context(), args[0], request) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskRunBundle(run)) + }, + } + cmd.Flags().StringVar(&resultRaw, "result", "", "Optional result JSON") + return cmd +} + +func newTaskRunFailCommand(deps commandDeps) *cobra.Command { + var ( + errorMessage string + metadataRaw string + ) + + cmd := &cobra.Command{ + Use: "fail ", + Short: "Fail a task run", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + + request := FailTaskRunRequest{Error: strings.TrimSpace(errorMessage)} + if request.Error == "" { + return errors.New("cli: --error is required") + } + if cmd.Flags().Changed("metadata") { + request.Metadata, err = parseJSONFlag("metadata", metadataRaw) + if err != nil { + return err + } + } + + run, err := client.FailTaskRun(cmd.Context(), args[0], request) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskRunBundle(run)) + }, + } + cmd.Flags().StringVar(&errorMessage, "error", "", "Failure message") + cmd.Flags().StringVar(&metadataRaw, "metadata", "", "Optional failure metadata JSON") + mustMarkFlagRequired(cmd, "error") + return cmd +} + +func newTaskRunCancelCommand(deps commandDeps) *cobra.Command { + var ( + reason string + metadataRaw string + ) + + cmd := &cobra.Command{ + Use: "cancel ", + Short: "Cancel a task run", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := clientFromDeps(deps) + if err != nil { + return err + } + + request := CancelTaskRunRequest{Reason: strings.TrimSpace(reason)} + if cmd.Flags().Changed("metadata") { + request.Metadata, err = parseJSONFlag("metadata", metadataRaw) + if err != nil { + return err + } + } + + run, err := client.CancelTaskRun(cmd.Context(), args[0], request) + if err != nil { + return err + } + return writeCommandOutput(cmd, taskRunBundle(run)) + }, + } + cmd.Flags().StringVar(&reason, "reason", "", "Optional cancellation reason") + cmd.Flags().StringVar(&metadataRaw, "metadata", "", "Optional cancellation metadata JSON") + return cmd +} + +func parseTaskListFilters(scopeRaw string, workspaceRef string, statusRaw string, ownerKindRaw string, ownerRef string, parentTaskID string, channelRaw string, last int) (TaskListQuery, error) { + scope, workspace, err := resolveTaskScopeWorkspace(scopeRaw, workspaceRef, false) + if err != nil { + return TaskListQuery{}, err + } + status, err := parseOptionalTaskStatus(statusRaw) + if err != nil { + return TaskListQuery{}, err + } + ownerKind, err := parseOptionalTaskOwnerKind(ownerKindRaw) + if err != nil { + return TaskListQuery{}, err + } + trimmedOwnerRef := strings.TrimSpace(ownerRef) + if (ownerKind != "" && trimmedOwnerRef == "") || (ownerKind == "" && trimmedOwnerRef != "") { + return TaskListQuery{}, errors.New("cli: --owner-kind and --owner-ref must be provided together") + } + if err := validateTaskChannelFlag("channel", channelRaw); err != nil { + return TaskListQuery{}, err + } + if err := validateTaskLast(last); err != nil { + return TaskListQuery{}, err + } + + return TaskListQuery{ + Scope: scope, + Workspace: workspace, + Status: status, + OwnerKind: ownerKind, + OwnerRef: trimmedOwnerRef, + ParentTaskID: strings.TrimSpace(parentTaskID), + NetworkChannel: strings.TrimSpace(channelRaw), + Limit: last, + }, nil +} + +func parseTaskRunListFilters(statusRaw string, sessionID string, last int) (TaskRunListQuery, error) { + status, err := parseOptionalTaskRunStatus(statusRaw) + if err != nil { + return TaskRunListQuery{}, err + } + if err := validateTaskLast(last); err != nil { + return TaskRunListQuery{}, err + } + return TaskRunListQuery{ + Status: status, + SessionID: strings.TrimSpace(sessionID), + Limit: last, + }, nil +} + +func resolveTaskScopeWorkspace(rawScope string, workspaceRef string, scopeRequired bool) (taskpkg.Scope, string, error) { + scope, err := parseOptionalTaskScope(rawScope) + if err != nil { + return "", "", err + } + if scopeRequired && scope == "" { + return "", "", errors.New("cli: --scope is required") + } + + workspace := strings.TrimSpace(workspaceRef) + switch scope.Normalize() { + case taskpkg.ScopeGlobal: + if workspace != "" { + return "", "", errors.New("cli: --workspace must be empty when --scope is global") + } + case taskpkg.ScopeWorkspace: + if workspace == "" { + return "", "", errors.New("cli: --workspace is required when --scope is workspace") + } + } + return scope, workspace, nil +} + +func parseOptionalTaskScope(raw string) (taskpkg.Scope, error) { + trimmed := strings.ToLower(strings.TrimSpace(raw)) + if trimmed == "" { + return "", nil + } + scope := taskpkg.Scope(trimmed) + if err := scope.Validate("scope"); err != nil { + return "", fmt.Errorf("cli: %w", err) + } + return scope, nil +} + +func parseOptionalTaskStatus(raw string) (taskpkg.TaskStatus, error) { + trimmed := strings.ToLower(strings.TrimSpace(raw)) + if trimmed == "" { + return "", nil + } + status := taskpkg.TaskStatus(trimmed) + if err := status.Validate("status"); err != nil { + return "", fmt.Errorf("cli: %w", err) + } + return status, nil +} + +func parseOptionalTaskRunStatus(raw string) (taskpkg.TaskRunStatus, error) { + trimmed := strings.ToLower(strings.TrimSpace(raw)) + if trimmed == "" { + return "", nil + } + status := taskpkg.TaskRunStatus(trimmed) + if err := status.Validate("status"); err != nil { + return "", fmt.Errorf("cli: %w", err) + } + return status, nil +} + +func parseOptionalTaskOwnerKind(raw string) (taskpkg.OwnerKind, error) { + trimmed := strings.ToLower(strings.TrimSpace(raw)) + if trimmed == "" { + return "", nil + } + kind := taskpkg.OwnerKind(trimmed) + if err := kind.Validate("owner_kind"); err != nil { + return "", fmt.Errorf("cli: %w", err) + } + return kind, nil +} + +func parseOptionalTaskDependencyKind(raw string) (taskpkg.DependencyKind, error) { + trimmed := strings.ToLower(strings.TrimSpace(raw)) + if trimmed == "" { + return "", nil + } + kind := taskpkg.DependencyKind(trimmed) + if err := kind.Validate("kind"); err != nil { + return "", fmt.Errorf("cli: %w", err) + } + return kind, nil +} + +func parseRequiredTaskOwnership(kindRaw string, refRaw string) (*taskpkg.Ownership, error) { + kindText := strings.TrimSpace(kindRaw) + ref := strings.TrimSpace(refRaw) + if kindText == "" || ref == "" { + return nil, errors.New("cli: --owner-kind and --owner-ref must be provided together") + } + kind, err := parseOptionalTaskOwnerKind(kindText) + if err != nil { + return nil, err + } + owner := &taskpkg.Ownership{Kind: kind, Ref: ref} + if err := owner.Validate("owner"); err != nil { + return nil, fmt.Errorf("cli: %w", err) + } + return owner, nil +} + +func parseJSONFlag(flagName string, raw string) (json.RawMessage, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, fmt.Errorf("cli: --%s requires valid JSON", flagName) + } + var decoded any + if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil { + return nil, fmt.Errorf("cli: invalid --%s JSON: %w", flagName, err) + } + return json.RawMessage(trimmed), nil +} + +func validateTaskChannelFlag(flagName string, channel string) error { + trimmed := strings.TrimSpace(channel) + if trimmed == "" { + return nil + } + if err := network.ValidateChannel(trimmed); err != nil { + return fmt.Errorf("cli: invalid --%s value %q: %w", flagName, trimmed, err) + } + return nil +} + +func validateTaskLast(last int) error { + if last < 0 { + return fmt.Errorf("cli: --last must be zero or positive: %d", last) + } + return nil +} + +func taskBundle(item TaskRecord) outputBundle { + return outputBundle{ + jsonValue: item, + human: func() (string, error) { + return renderHumanSection("Task", []keyValue{ + {Label: "ID", Value: stringOrDash(item.ID)}, + {Label: "Identifier", Value: stringOrDash(item.Identifier)}, + {Label: "Scope", Value: stringOrDash(string(item.Scope))}, + {Label: "Workspace", Value: stringOrDash(item.WorkspaceID)}, + {Label: "Parent", Value: stringOrDash(item.ParentTaskID)}, + {Label: "Title", Value: stringOrDash(item.Title)}, + {Label: "Description", Value: stringOrDash(item.Description)}, + {Label: "Status", Value: stringOrDash(string(item.Status))}, + {Label: "Owner", Value: stringOrDash(formatTaskOwnership(item.Owner))}, + {Label: "Created By", Value: stringOrDash(formatTaskActor(item.CreatedBy))}, + {Label: "Origin", Value: stringOrDash(formatTaskOrigin(item.Origin))}, + {Label: "Channel", Value: stringOrDash(item.NetworkChannel)}, + {Label: "Created", Value: stringOrDash(formatTime(item.CreatedAt))}, + {Label: "Updated", Value: stringOrDash(formatTime(item.UpdatedAt))}, + {Label: "Closed", Value: stringOrDash(formatTimePtr(item.ClosedAt))}, + {Label: "Metadata", Value: stringOrDash(compactJSON(item.Metadata))}, + }), nil + }, + toon: func() (string, error) { + return renderToonObject("task", []string{ + "id", "identifier", "scope", "workspace_id", "parent_task_id", "title", "description", "status", "owner", "created_by", "origin", "network_channel", "created_at", "updated_at", "closed_at", "metadata", + }, []string{ + item.ID, + item.Identifier, + string(item.Scope), + item.WorkspaceID, + item.ParentTaskID, + item.Title, + item.Description, + string(item.Status), + formatTaskOwnership(item.Owner), + formatTaskActor(item.CreatedBy), + formatTaskOrigin(item.Origin), + item.NetworkChannel, + formatTime(item.CreatedAt), + formatTime(item.UpdatedAt), + formatTimePtr(item.ClosedAt), + compactJSON(item.Metadata), + }), nil + }, + } +} + +func taskSummaryListBundle(items []TaskSummaryRecord) outputBundle { + return listBundle( + items, + items, + "Tasks", + []string{"ID", "Identifier", "Scope", "Workspace", "Parent", "Status", "Owner", "Channel", "Title"}, + "tasks", + []string{"id", "identifier", "scope", "workspace_id", "parent_task_id", "status", "owner", "network_channel", "title"}, + func(item TaskSummaryRecord) []string { + return []string{ + stringOrDash(item.ID), + stringOrDash(item.Identifier), + stringOrDash(string(item.Scope)), + stringOrDash(item.WorkspaceID), + stringOrDash(item.ParentTaskID), + stringOrDash(string(item.Status)), + stringOrDash(formatTaskOwnership(item.Owner)), + stringOrDash(item.NetworkChannel), + stringOrDash(item.Title), + } + }, + func(item TaskSummaryRecord) []string { + return []string{ + item.ID, + item.Identifier, + string(item.Scope), + item.WorkspaceID, + item.ParentTaskID, + string(item.Status), + formatTaskOwnership(item.Owner), + item.NetworkChannel, + item.Title, + } + }, + ) +} + +func taskDetailBundle(detail TaskDetailRecord) outputBundle { + return outputBundle{ + jsonValue: detail, + human: func() (string, error) { + taskBlock, err := taskBundle(detail.Task).human() + if err != nil { + return "", err + } + return renderHumanBlocks( + taskBlock, + renderHumanTable("Child Tasks", []string{"ID", "Identifier", "Scope", "Workspace", "Status", "Owner", "Title"}, taskChildRows(detail.Children)), + renderHumanTable("Dependencies", []string{"Task", "Depends On", "Kind", "Created"}, taskDependencyRows(detail.Dependencies)), + renderHumanTable("Task Runs", []string{"ID", "Status", "Attempt", "Session", "Claimed By", "Channel", "Queued", "Started", "Ended", "Error"}, taskRunRows(detail.Runs)), + renderHumanTable("Task Events", []string{"ID", "Type", "Run", "Actor", "Origin", "Time"}, taskEventRows(detail.Events)), + ), nil + }, + toon: func() (string, error) { + taskBlock, err := taskBundle(detail.Task).toon() + if err != nil { + return "", err + } + return renderHumanBlocks( + taskBlock, + renderToonArray("task_children", []string{"id", "identifier", "scope", "workspace_id", "status", "owner", "title"}, taskChildToonRows(detail.Children)), + renderToonArray("task_dependencies", []string{"task_id", "depends_on_task_id", "kind", "created_at"}, taskDependencyToonRows(detail.Dependencies)), + renderToonArray("task_runs", []string{"id", "status", "attempt", "session_id", "claimed_by", "network_channel", "queued_at", "started_at", "ended_at", "error"}, taskRunToonRows(detail.Runs)), + renderToonArray("task_events", []string{"id", "event_type", "run_id", "actor", "origin", "timestamp"}, taskEventToonRows(detail.Events)), + ), nil + }, + } +} + +func taskRunBundle(item TaskRunRecord) outputBundle { + return outputBundle{ + jsonValue: item, + human: func() (string, error) { + return renderHumanSection("Task Run", []keyValue{ + {Label: "ID", Value: stringOrDash(item.ID)}, + {Label: "Task", Value: stringOrDash(item.TaskID)}, + {Label: "Status", Value: stringOrDash(string(item.Status))}, + {Label: "Attempt", Value: intOrDash(item.Attempt)}, + {Label: "Claimed By", Value: stringOrDash(formatTaskActorPtr(item.ClaimedBy))}, + {Label: "Session", Value: stringOrDash(item.SessionID)}, + {Label: "Origin", Value: stringOrDash(formatTaskOrigin(item.Origin))}, + {Label: "Idempotency Key", Value: stringOrDash(item.IdempotencyKey)}, + {Label: "Channel", Value: stringOrDash(item.NetworkChannel)}, + {Label: "Queued", Value: stringOrDash(formatTime(item.QueuedAt))}, + {Label: "Claimed", Value: stringOrDash(formatTimePtr(item.ClaimedAt))}, + {Label: "Started", Value: stringOrDash(formatTimePtr(item.StartedAt))}, + {Label: "Ended", Value: stringOrDash(formatTimePtr(item.EndedAt))}, + {Label: "Error", Value: stringOrDash(item.Error)}, + {Label: "Result", Value: stringOrDash(compactJSON(item.Result))}, + }), nil + }, + toon: func() (string, error) { + return renderToonObject("task_run", []string{ + "id", "task_id", "status", "attempt", "claimed_by", "session_id", "origin", "idempotency_key", "network_channel", "queued_at", "claimed_at", "started_at", "ended_at", "error", "result", + }, []string{ + item.ID, + item.TaskID, + string(item.Status), + strconv.Itoa(item.Attempt), + formatTaskActorPtr(item.ClaimedBy), + item.SessionID, + formatTaskOrigin(item.Origin), + item.IdempotencyKey, + item.NetworkChannel, + formatTime(item.QueuedAt), + formatTimePtr(item.ClaimedAt), + formatTimePtr(item.StartedAt), + formatTimePtr(item.EndedAt), + item.Error, + compactJSON(item.Result), + }), nil + }, + } +} + +func taskRunListBundle(items []TaskRunRecord) outputBundle { + return listBundle( + items, + items, + "Task Runs", + []string{"ID", "Status", "Attempt", "Session", "Claimed By", "Channel", "Queued", "Started", "Ended", "Error"}, + "task_runs", + []string{"id", "status", "attempt", "session_id", "claimed_by", "network_channel", "queued_at", "started_at", "ended_at", "error"}, + func(item TaskRunRecord) []string { + return []string{ + stringOrDash(item.ID), + stringOrDash(string(item.Status)), + intOrDash(item.Attempt), + stringOrDash(item.SessionID), + stringOrDash(formatTaskActorPtr(item.ClaimedBy)), + stringOrDash(item.NetworkChannel), + stringOrDash(formatTime(item.QueuedAt)), + stringOrDash(formatTimePtr(item.StartedAt)), + stringOrDash(formatTimePtr(item.EndedAt)), + stringOrDash(item.Error), + } + }, + func(item TaskRunRecord) []string { + return []string{ + item.ID, + string(item.Status), + strconv.Itoa(item.Attempt), + item.SessionID, + formatTaskActorPtr(item.ClaimedBy), + item.NetworkChannel, + formatTime(item.QueuedAt), + formatTimePtr(item.StartedAt), + formatTimePtr(item.EndedAt), + item.Error, + } + }, + ) +} + +func taskChildRows(items []TaskSummaryRecord) [][]string { + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + stringOrDash(item.ID), + stringOrDash(item.Identifier), + stringOrDash(string(item.Scope)), + stringOrDash(item.WorkspaceID), + stringOrDash(string(item.Status)), + stringOrDash(formatTaskOwnership(item.Owner)), + stringOrDash(item.Title), + }) + } + return rows +} + +func taskChildToonRows(items []TaskSummaryRecord) [][]string { + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + item.ID, + item.Identifier, + string(item.Scope), + item.WorkspaceID, + string(item.Status), + formatTaskOwnership(item.Owner), + item.Title, + }) + } + return rows +} + +func taskDependencyRows(items []TaskDependencyRecord) [][]string { + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + stringOrDash(item.TaskID), + stringOrDash(item.DependsOnTaskID), + stringOrDash(string(item.Kind)), + stringOrDash(formatTime(item.CreatedAt)), + }) + } + return rows +} + +func taskDependencyToonRows(items []TaskDependencyRecord) [][]string { + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + item.TaskID, + item.DependsOnTaskID, + string(item.Kind), + formatTime(item.CreatedAt), + }) + } + return rows +} + +func taskRunRows(items []TaskRunRecord) [][]string { + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + stringOrDash(item.ID), + stringOrDash(string(item.Status)), + intOrDash(item.Attempt), + stringOrDash(item.SessionID), + stringOrDash(formatTaskActorPtr(item.ClaimedBy)), + stringOrDash(item.NetworkChannel), + stringOrDash(formatTime(item.QueuedAt)), + stringOrDash(formatTimePtr(item.StartedAt)), + stringOrDash(formatTimePtr(item.EndedAt)), + stringOrDash(item.Error), + }) + } + return rows +} + +func taskRunToonRows(items []TaskRunRecord) [][]string { + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + item.ID, + string(item.Status), + strconv.Itoa(item.Attempt), + item.SessionID, + formatTaskActorPtr(item.ClaimedBy), + item.NetworkChannel, + formatTime(item.QueuedAt), + formatTimePtr(item.StartedAt), + formatTimePtr(item.EndedAt), + item.Error, + }) + } + return rows +} + +func taskEventRows(items []TaskEventRecord) [][]string { + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + stringOrDash(item.ID), + stringOrDash(item.EventType), + stringOrDash(item.RunID), + stringOrDash(formatTaskActor(item.Actor)), + stringOrDash(formatTaskOrigin(item.Origin)), + stringOrDash(formatTime(item.Timestamp)), + }) + } + return rows +} + +func taskEventToonRows(items []TaskEventRecord) [][]string { + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + item.ID, + item.EventType, + item.RunID, + formatTaskActor(item.Actor), + formatTaskOrigin(item.Origin), + formatTime(item.Timestamp), + }) + } + return rows +} + +func formatTaskOwnership(owner *taskpkg.Ownership) string { + if owner == nil { + return "" + } + return firstNonEmpty(string(owner.Kind)+":"+strings.TrimSpace(owner.Ref), strings.TrimSpace(owner.Ref)) +} + +func formatTaskActor(actor taskpkg.ActorIdentity) string { + return firstNonEmpty(string(actor.Kind)+":"+strings.TrimSpace(actor.Ref), strings.TrimSpace(actor.Ref)) +} + +func formatTaskActorPtr(actor *taskpkg.ActorIdentity) string { + if actor == nil { + return "" + } + return formatTaskActor(*actor) +} + +func formatTaskOrigin(origin taskpkg.Origin) string { + return firstNonEmpty(string(origin.Kind)+":"+strings.TrimSpace(origin.Ref), strings.TrimSpace(origin.Ref)) +} diff --git a/internal/cli/task_test.go b/internal/cli/task_test.go new file mode 100644 index 000000000..f3ea6a0d5 --- /dev/null +++ b/internal/cli/task_test.go @@ -0,0 +1,771 @@ +package cli + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + taskpkg "github.com/pedronauck/agh/internal/task" +) + +func TestTaskCreateAndUpdateRejectInvalidFlagCombos(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "Should require workspace for workspace scope", + args: []string{"task", "create", "--scope", "workspace", "--title", "Investigate"}, + wantErr: "--workspace is required when --scope is workspace", + }, + { + name: "Should forbid workspace for global scope", + args: []string{"task", "create", "--scope", "global", "--workspace", "alpha", "--title", "Investigate"}, + wantErr: "--workspace must be empty when --scope is global", + }, + { + name: "Should require change flags on update", + args: []string{"task", "update", "task-1"}, + wantErr: "task update requires at least one change flag", + }, + { + name: "Should reject clear owner with owner mutation", + args: []string{"task", "update", "task-1", "--clear-owner", "--owner-kind", "pool", "--owner-ref", "triage"}, + wantErr: "--clear-owner cannot be combined with --owner-kind or --owner-ref", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, _, err := executeRootCommand(t, newTestDeps(t, stubClient{}), tt.args...) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("executeRootCommand(%v) error = %v, want %q", tt.args, err, tt.wantErr) + } + }) + } +} + +func TestTaskCreateAndListCommandsParseTaskFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + run func(t *testing.T) + }{ + { + name: "Should parse task create fields", + run: func(t *testing.T) { + t.Helper() + + var createRequest CreateTaskRequest + deps := newTestDeps(t, stubClient{ + createTaskFn: func(_ context.Context, got CreateTaskRequest) (TaskRecord, error) { + createRequest = got + return sampleTaskRecord(), nil + }, + }) + + createJSON, _, err := executeRootCommand( + t, + deps, + "task", "create", + "--id", "task-1", + "--identifier", "OPS-42", + "--scope", "workspace", + "--workspace", "alpha", + "--channel", "builders", + "--title", "Investigate flaky task runs", + "--description", "Capture root cause", + "--owner-kind", "pool", + "--owner-ref", "triage", + "--metadata", `{"priority":"high"}`, + "-o", "json", + ) + if err != nil { + t.Fatalf("task create error = %v", err) + } + + if createRequest.Scope != taskpkg.ScopeWorkspace || + createRequest.Workspace != "alpha" || + createRequest.NetworkChannel != "builders" || + createRequest.Title != "Investigate flaky task runs" || + createRequest.Owner == nil || + createRequest.Owner.Kind != taskpkg.OwnerKindPool || + createRequest.Owner.Ref != "triage" || + string(createRequest.Metadata) != `{"priority":"high"}` { + t.Fatalf("createRequest = %#v, want parsed workspace/channel/owner/metadata", createRequest) + } + + var created TaskRecord + if err := json.Unmarshal([]byte(createJSON), &created); err != nil { + t.Fatalf("json.Unmarshal(task create) error = %v", err) + } + if created.ID != "task-1" || created.Title != "Investigate flaky task runs" { + t.Fatalf("created task = %#v, want sample task output", created) + } + }, + }, + { + name: "Should parse task list filters", + run: func(t *testing.T) { + t.Helper() + + var listQuery TaskListQuery + deps := newTestDeps(t, stubClient{ + listTasksFn: func(_ context.Context, query TaskListQuery) ([]TaskSummaryRecord, error) { + listQuery = query + return []TaskSummaryRecord{sampleTaskSummaryRecord()}, nil + }, + }) + + listJSON, _, err := executeRootCommand( + t, + deps, + "task", "list", + "--scope", "workspace", + "--workspace", "alpha", + "--status", "ready", + "--owner-kind", "pool", + "--owner-ref", "triage", + "--parent", "task-root", + "--channel", "builders", + "--last", "3", + "-o", "json", + ) + if err != nil { + t.Fatalf("task list error = %v", err) + } + + if listQuery.Scope != taskpkg.ScopeWorkspace || + listQuery.Workspace != "alpha" || + listQuery.Status != taskpkg.TaskStatusReady || + listQuery.OwnerKind != taskpkg.OwnerKindPool || + listQuery.OwnerRef != "triage" || + listQuery.ParentTaskID != "task-root" || + listQuery.NetworkChannel != "builders" || + listQuery.Limit != 3 { + t.Fatalf("listQuery = %#v, want parsed filters", listQuery) + } + + var listed []TaskSummaryRecord + if err := json.Unmarshal([]byte(listJSON), &listed); err != nil { + t.Fatalf("json.Unmarshal(task list) error = %v", err) + } + if len(listed) != 1 || listed[0].ID != "task-1" { + t.Fatalf("listed tasks = %#v, want one task summary", listed) + } + }, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tc.run(t) + }) + } +} + +func TestTaskRunCommandsMapLifecycleRequests(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + run func(t *testing.T) + }{ + { + name: "Should parse task run list filters", + run: func(t *testing.T) { + t.Helper() + + var runListQuery TaskRunListQuery + deps := newTestDeps(t, stubClient{ + listTaskRunsFn: func(_ context.Context, _ string, query TaskRunListQuery) ([]TaskRunRecord, error) { + runListQuery = query + return []TaskRunRecord{sampleTaskRunRecord(taskpkg.TaskRunStatusRunning)}, nil + }, + }) + + if _, _, err := executeRootCommand(t, deps, "task", "run", "list", "task-1", "--status", "running", "--session", "sess-1", "--last", "2", "-o", "json"); err != nil { + t.Fatalf("task run list error = %v", err) + } + if runListQuery.Status != taskpkg.TaskRunStatusRunning || runListQuery.SessionID != "sess-1" || runListQuery.Limit != 2 { + t.Fatalf("runListQuery = %#v, want parsed run filters", runListQuery) + } + }, + }, + { + name: "Should parse task run enqueue request", + run: func(t *testing.T) { + t.Helper() + + var enqueueRequest EnqueueTaskRunRequest + deps := newTestDeps(t, stubClient{ + enqueueTaskRunFn: func(_ context.Context, _ string, request EnqueueTaskRunRequest) (TaskRunRecord, error) { + enqueueRequest = request + return sampleTaskRunRecord(taskpkg.TaskRunStatusQueued), nil + }, + }) + + if _, _, err := executeRootCommand(t, deps, "task", "run", "enqueue", "task-1", "--idempotency-key", "idem-1", "--channel", "builders", "-o", "json"); err != nil { + t.Fatalf("task run enqueue error = %v", err) + } + if enqueueRequest.IdempotencyKey != "idem-1" || enqueueRequest.NetworkChannel != "builders" { + t.Fatalf("enqueueRequest = %#v, want idempotency key and channel", enqueueRequest) + } + }, + }, + { + name: "Should parse task run claim request", + run: func(t *testing.T) { + t.Helper() + + var claimRequest ClaimTaskRunRequest + deps := newTestDeps(t, stubClient{ + claimTaskRunFn: func(_ context.Context, _ string, request ClaimTaskRunRequest) (TaskRunRecord, error) { + claimRequest = request + return sampleTaskRunRecord(taskpkg.TaskRunStatusClaimed), nil + }, + }) + + if _, _, err := executeRootCommand(t, deps, "task", "run", "claim", "run-1", "--idempotency-key", "idem-claim", "-o", "json"); err != nil { + t.Fatalf("task run claim error = %v", err) + } + if claimRequest.IdempotencyKey != "idem-claim" { + t.Fatalf("claimRequest = %#v, want idempotency key", claimRequest) + } + }, + }, + { + name: "Should parse task run start request", + run: func(t *testing.T) { + t.Helper() + + var startRequest StartTaskRunRequest + deps := newTestDeps(t, stubClient{ + startTaskRunFn: func(_ context.Context, _ string, request StartTaskRunRequest) (TaskRunRecord, error) { + startRequest = request + return sampleTaskRunRecord(taskpkg.TaskRunStatusRunning), nil + }, + }) + + if _, _, err := executeRootCommand(t, deps, "task", "run", "start", "run-1", "--idempotency-key", "idem-start", "-o", "json"); err != nil { + t.Fatalf("task run start error = %v", err) + } + if startRequest.IdempotencyKey != "idem-start" { + t.Fatalf("startRequest = %#v, want idempotency key", startRequest) + } + }, + }, + { + name: "Should parse task run attach-session request", + run: func(t *testing.T) { + t.Helper() + + var attachRequest AttachTaskRunSessionRequest + deps := newTestDeps(t, stubClient{ + attachTaskRunSessionFn: func(_ context.Context, _ string, request AttachTaskRunSessionRequest) (TaskRunRecord, error) { + attachRequest = request + return sampleTaskRunRecord(taskpkg.TaskRunStatusStarting), nil + }, + }) + + if _, _, err := executeRootCommand(t, deps, "task", "run", "attach-session", "run-1", "--session", "sess-attach", "-o", "json"); err != nil { + t.Fatalf("task run attach-session error = %v", err) + } + if attachRequest.SessionID != "sess-attach" { + t.Fatalf("attachRequest = %#v, want session id", attachRequest) + } + }, + }, + { + name: "Should parse task run complete request", + run: func(t *testing.T) { + t.Helper() + + var completeRequest CompleteTaskRunRequest + deps := newTestDeps(t, stubClient{ + completeTaskRunFn: func(_ context.Context, _ string, request CompleteTaskRunRequest) (TaskRunRecord, error) { + completeRequest = request + return sampleTaskRunRecord(taskpkg.TaskRunStatusCompleted), nil + }, + }) + + if _, _, err := executeRootCommand(t, deps, "task", "run", "complete", "run-1", "--result", `{"ok":true}`, "-o", "json"); err != nil { + t.Fatalf("task run complete error = %v", err) + } + if string(completeRequest.Result) != `{"ok":true}` { + t.Fatalf("completeRequest = %#v, want JSON result", completeRequest) + } + }, + }, + { + name: "Should parse task run fail request", + run: func(t *testing.T) { + t.Helper() + + var failRequest FailTaskRunRequest + deps := newTestDeps(t, stubClient{ + failTaskRunFn: func(_ context.Context, _ string, request FailTaskRunRequest) (TaskRunRecord, error) { + failRequest = request + return sampleTaskRunRecord(taskpkg.TaskRunStatusFailed), nil + }, + }) + + if _, _, err := executeRootCommand(t, deps, "task", "run", "fail", "run-1", "--error", "boom", "--metadata", `{"code":"E_TASK"}`, "-o", "json"); err != nil { + t.Fatalf("task run fail error = %v", err) + } + if failRequest.Error != "boom" || string(failRequest.Metadata) != `{"code":"E_TASK"}` { + t.Fatalf("failRequest = %#v, want error and metadata", failRequest) + } + }, + }, + { + name: "Should parse task run cancel request", + run: func(t *testing.T) { + t.Helper() + + var cancelRequest CancelTaskRunRequest + deps := newTestDeps(t, stubClient{ + cancelTaskRunFn: func(_ context.Context, _ string, request CancelTaskRunRequest) (TaskRunRecord, error) { + cancelRequest = request + return sampleTaskRunRecord(taskpkg.TaskRunStatusCancelled), nil + }, + }) + + if _, _, err := executeRootCommand(t, deps, "task", "run", "cancel", "run-1", "--reason", "operator-request", "--metadata", `{"source":"cli"}`, "-o", "json"); err != nil { + t.Fatalf("task run cancel error = %v", err) + } + if cancelRequest.Reason != "operator-request" || string(cancelRequest.Metadata) != `{"source":"cli"}` { + t.Fatalf("cancelRequest = %#v, want reason and metadata", cancelRequest) + } + }, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tc.run(t) + }) + } +} + +func TestTaskMutationCommandsMapRequests(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + run func(t *testing.T) + }{ + { + name: "Should parse task update request", + run: func(t *testing.T) { + t.Helper() + + var ( + updateTaskID string + updateRequest UpdateTaskRequest + ) + deps := newTestDeps(t, stubClient{ + updateTaskFn: func(_ context.Context, taskID string, request UpdateTaskRequest) (TaskRecord, error) { + updateTaskID = taskID + updateRequest = request + return sampleTaskRecord(), nil + }, + }) + + if _, _, err := executeRootCommand( + t, + deps, + "task", "update", "task-1", + "--title", "Retitle triage task", + "--description", "Refined scope", + "--channel", "builders", + "--owner-kind", "pool", + "--owner-ref", "triage", + "--metadata", `{"priority":"low"}`, + "-o", "json", + ); err != nil { + t.Fatalf("task update error = %v", err) + } + if updateTaskID != "task-1" || + updateRequest.Title == nil || *updateRequest.Title != "Retitle triage task" || + updateRequest.Description == nil || *updateRequest.Description != "Refined scope" || + updateRequest.NetworkChannel == nil || *updateRequest.NetworkChannel != "builders" || + updateRequest.Owner == nil || updateRequest.Owner.Kind != taskpkg.OwnerKindPool || updateRequest.Owner.Ref != "triage" || + updateRequest.ClearOwner || + updateRequest.Metadata == nil || string(*updateRequest.Metadata) != `{"priority":"low"}` { + t.Fatalf("update request = %#v, want parsed task mutation payload", updateRequest) + } + }, + }, + { + name: "Should parse task cancel request", + run: func(t *testing.T) { + t.Helper() + + var ( + cancelTaskID string + cancelRequest CancelTaskRequest + ) + deps := newTestDeps(t, stubClient{ + cancelTaskFn: func(_ context.Context, taskID string, request CancelTaskRequest) (TaskRecord, error) { + cancelTaskID = taskID + cancelRequest = request + return sampleTaskRecord(), nil + }, + }) + + if _, _, err := executeRootCommand(t, deps, "task", "cancel", "task-1", "--reason", "operator-request", "--metadata", `{"source":"cli"}`, "-o", "json"); err != nil { + t.Fatalf("task cancel error = %v", err) + } + if cancelTaskID != "task-1" || cancelRequest.Reason != "operator-request" || string(cancelRequest.Metadata) != `{"source":"cli"}` { + t.Fatalf("cancel request = %#v, want parsed cancel payload", cancelRequest) + } + }, + }, + { + name: "Should parse child task create request", + run: func(t *testing.T) { + t.Helper() + + var ( + childParentID string + childCreateRequest CreateTaskChildRequest + ) + deps := newTestDeps(t, stubClient{ + createChildTaskFn: func(_ context.Context, parentID string, request CreateTaskChildRequest) (TaskRecord, error) { + childParentID = parentID + childCreateRequest = request + return sampleTaskRecord(), nil + }, + }) + + if _, _, err := executeRootCommand( + t, + deps, + "task", "child", "create", "task-root", + "--id", "task-child", + "--identifier", "OPS-43", + "--scope", "workspace", + "--workspace", "alpha", + "--channel", "builders", + "--title", "Check runtime logs", + "--description", "Focus on worker output", + "--owner-kind", "human", + "--owner-ref", "alice", + "--metadata", `{"phase":"two"}`, + "-o", "json", + ); err != nil { + t.Fatalf("task child create error = %v", err) + } + if childParentID != "task-root" || + childCreateRequest.ID != "task-child" || + childCreateRequest.Identifier != "OPS-43" || + childCreateRequest.Scope != taskpkg.ScopeWorkspace || + childCreateRequest.Workspace != "alpha" || + childCreateRequest.NetworkChannel != "builders" || + childCreateRequest.Title != "Check runtime logs" || + childCreateRequest.Description != "Focus on worker output" || + childCreateRequest.Owner == nil || childCreateRequest.Owner.Kind != taskpkg.OwnerKindHuman || childCreateRequest.Owner.Ref != "alice" || + string(childCreateRequest.Metadata) != `{"phase":"two"}` { + t.Fatalf("childCreateRequest = %#v, want parsed child task payload", childCreateRequest) + } + }, + }, + { + name: "Should parse add dependency request", + run: func(t *testing.T) { + t.Helper() + + var ( + dependencyTaskID string + dependencyRequest AddTaskDependencyRequest + ) + deps := newTestDeps(t, stubClient{ + addTaskDependencyFn: func(_ context.Context, taskID string, request AddTaskDependencyRequest) (TaskDetailRecord, error) { + dependencyTaskID = taskID + dependencyRequest = request + return sampleTaskDetailRecord(), nil + }, + }) + + if _, _, err := executeRootCommand(t, deps, "task", "dependency", "add", "task-1", "--depends-on", "task-root", "--kind", "blocks", "-o", "json"); err != nil { + t.Fatalf("task dependency add error = %v", err) + } + if dependencyTaskID != "task-1" || dependencyRequest.DependsOnTaskID != "task-root" || dependencyRequest.Kind != taskpkg.DependencyKindBlocks { + t.Fatalf("dependencyRequest = %#v, want dependency payload", dependencyRequest) + } + }, + }, + { + name: "Should parse remove dependency arguments", + run: func(t *testing.T) { + t.Helper() + + var ( + removeTaskID string + removeDependsOnID string + ) + deps := newTestDeps(t, stubClient{ + removeTaskDependencyFn: func(_ context.Context, taskID string, dependsOnID string) (TaskDetailRecord, error) { + removeTaskID = taskID + removeDependsOnID = dependsOnID + return sampleTaskDetailRecord(), nil + }, + }) + + if _, _, err := executeRootCommand(t, deps, "task", "dependency", "remove", "task-1", "task-root", "-o", "json"); err != nil { + t.Fatalf("task dependency remove error = %v", err) + } + if removeTaskID != "task-1" || removeDependsOnID != "task-root" { + t.Fatalf("remove dependency args = (%q, %q), want task-1/task-root", removeTaskID, removeDependsOnID) + } + }, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tc.run(t) + }) + } +} + +func TestTaskCommandsSupportDetailAndToonOutput(t *testing.T) { + t.Parallel() + + deps := newTestDeps(t, stubClient{ + getTaskFn: func(context.Context, string) (TaskDetailRecord, error) { + return sampleTaskDetailRecord(), nil + }, + listTasksFn: func(context.Context, TaskListQuery) ([]TaskSummaryRecord, error) { + return []TaskSummaryRecord{sampleTaskSummaryRecord()}, nil + }, + }) + + humanOut, _, err := executeRootCommand(t, deps, "task", "get", "task-1", "-o", "human") + if err != nil { + t.Fatalf("task get human error = %v", err) + } + if !strings.Contains(humanOut, "Task") || !strings.Contains(humanOut, "Dependencies") || !strings.Contains(humanOut, "Task Runs") { + t.Fatalf("task get human output = %q, want detail sections", humanOut) + } + + toonOut, _, err := executeRootCommand(t, deps, "task", "list", "-o", "toon") + if err != nil { + t.Fatalf("task list toon error = %v", err) + } + if !strings.Contains(toonOut, "tasks[1]{id,identifier,scope,workspace_id,parent_task_id,status,owner,network_channel,title}:") { + t.Fatalf("task list toon output = %q, want tasks TOON array", toonOut) + } +} + +func TestTaskBundlesRenderTaskRunAndDetailSections(t *testing.T) { + t.Parallel() + + detail := sampleTaskDetailRecord() + detailToon, err := taskDetailBundle(detail).toon() + if err != nil { + t.Fatalf("taskDetailBundle().toon() error = %v", err) + } + if !strings.Contains(detailToon, "task_children[1]{id,identifier,scope,workspace_id,status,owner,title}:") || + !strings.Contains(detailToon, "task_dependencies[1]{task_id,depends_on_task_id,kind,created_at}:") || + !strings.Contains(detailToon, "task_runs[1]{id,status,attempt,session_id,claimed_by,network_channel,queued_at,started_at,ended_at,error}:") || + !strings.Contains(detailToon, "task_events[1]{id,event_type,run_id,actor,origin,timestamp}:") { + t.Fatalf("task detail toon output = %q, want child/dependency/run/event sections", detailToon) + } + + runHuman, err := taskRunBundle(sampleTaskRunRecord(taskpkg.TaskRunStatusCompleted)).human() + if err != nil { + t.Fatalf("taskRunBundle().human() error = %v", err) + } + if !strings.Contains(runHuman, "Task Run") || !strings.Contains(runHuman, "Idempotency Key") || !strings.Contains(runHuman, "Result") { + t.Fatalf("task run human output = %q, want task run detail section", runHuman) + } + + runToon, err := taskRunListBundle([]TaskRunRecord{sampleTaskRunRecord(taskpkg.TaskRunStatusCompleted)}).toon() + if err != nil { + t.Fatalf("taskRunListBundle().toon() error = %v", err) + } + if !strings.Contains(runToon, "task_runs[1]{id,status,attempt,session_id,claimed_by,network_channel,queued_at,started_at,ended_at,error}:") { + t.Fatalf("task run toon output = %q, want task run TOON array", runToon) + } + + if kind, err := parseOptionalTaskDependencyKind("blocks"); err != nil || kind != taskpkg.DependencyKindBlocks { + t.Fatalf("parseOptionalTaskDependencyKind(blocks) = (%q, %v), want blocks", kind, err) + } + if _, err := parseOptionalTaskDependencyKind("relates"); err == nil || !strings.Contains(err.Error(), "unsupported value") { + t.Fatalf("parseOptionalTaskDependencyKind(relates) error = %v, want unsupported value validation", err) + } +} + +func TestParseTaskListFiltersRejectsHalfSpecifiedOwnerFilter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ownerKindRaw string + ownerRef string + }{ + {name: "ShouldRejectOwnerKindWithoutOwnerRef", ownerKindRaw: "pool"}, + {name: "ShouldRejectOwnerRefWithoutOwnerKind", ownerRef: "triage"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := parseTaskListFilters("", "", "", tt.ownerKindRaw, tt.ownerRef, "", "", 0) + if err == nil || !strings.Contains(err.Error(), "--owner-kind and --owner-ref must be provided together") { + t.Fatalf("parseTaskListFilters() error = %v, want paired owner filter validation", err) + } + }) + } +} + +func sampleTaskSummaryRecord() TaskSummaryRecord { + return TaskSummaryRecord{ + ID: "task-1", + Identifier: "OPS-42", + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: "ws-alpha", + ParentTaskID: "task-root", + NetworkChannel: "builders", + Title: "Investigate flaky task runs", + Status: taskpkg.TaskStatusReady, + Owner: &taskpkg.Ownership{Kind: taskpkg.OwnerKindPool, Ref: "triage"}, + CreatedBy: taskpkg.ActorIdentity{Kind: taskpkg.ActorKindHuman, Ref: "local-user"}, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindCLI, Ref: "tasks.create"}, + CreatedAt: fixedTestNow, + UpdatedAt: fixedTestNow, + } +} + +func sampleTaskRecord() TaskRecord { + return TaskRecord{ + ID: "task-1", + Identifier: "OPS-42", + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: "ws-alpha", + ParentTaskID: "task-root", + NetworkChannel: "builders", + Title: "Investigate flaky task runs", + Description: "Capture root cause", + Status: taskpkg.TaskStatusReady, + Owner: &taskpkg.Ownership{Kind: taskpkg.OwnerKindPool, Ref: "triage"}, + CreatedBy: taskpkg.ActorIdentity{Kind: taskpkg.ActorKindHuman, Ref: "local-user"}, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindCLI, Ref: "tasks.create"}, + CreatedAt: fixedTestNow, + UpdatedAt: fixedTestNow, + Metadata: json.RawMessage(`{"priority":"high"}`), + } +} + +func timePointer(value time.Time) *time.Time { + cloned := value + return &cloned +} + +func sampleTaskRunRecord(status taskpkg.TaskRunStatus) TaskRunRecord { + record := TaskRunRecord{ + ID: "run-1", + TaskID: "task-1", + Status: status, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindCLI, Ref: "tasks.run.start"}, + Attempt: 1, + IdempotencyKey: "idem-run", + NetworkChannel: "builders", + QueuedAt: fixedTestNow, + } + + claimedAt := fixedTestNow.Add(time.Minute) + startedAt := fixedTestNow.Add(2 * time.Minute) + endedAt := fixedTestNow.Add(3 * time.Minute) + claimedBy := &taskpkg.ActorIdentity{Kind: taskpkg.ActorKindHuman, Ref: "local-user"} + + switch status { + case taskpkg.TaskRunStatusClaimed: + record.ClaimedBy = claimedBy + record.ClaimedAt = timePointer(claimedAt) + case taskpkg.TaskRunStatusStarting: + record.ClaimedBy = claimedBy + record.SessionID = "sess-1" + record.ClaimedAt = timePointer(claimedAt) + case taskpkg.TaskRunStatusRunning: + record.ClaimedBy = claimedBy + record.SessionID = "sess-1" + record.ClaimedAt = timePointer(claimedAt) + record.StartedAt = timePointer(startedAt) + case taskpkg.TaskRunStatusCompleted: + record.ClaimedBy = claimedBy + record.SessionID = "sess-1" + record.ClaimedAt = timePointer(claimedAt) + record.StartedAt = timePointer(startedAt) + record.EndedAt = timePointer(endedAt) + record.Result = json.RawMessage(`{"ok":true}`) + case taskpkg.TaskRunStatusFailed: + record.ClaimedBy = claimedBy + record.SessionID = "sess-1" + record.ClaimedAt = timePointer(claimedAt) + record.StartedAt = timePointer(startedAt) + record.EndedAt = timePointer(endedAt) + record.Error = "boom" + case taskpkg.TaskRunStatusCancelled: + record.EndedAt = timePointer(endedAt) + } + + return record +} + +func sampleTaskDetailRecord() TaskDetailRecord { + return TaskDetailRecord{ + Task: sampleTaskRecord(), + Children: []TaskSummaryRecord{ + { + ID: "task-child", + Identifier: "OPS-43", + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: "ws-alpha", + Title: "Check runtime logs", + Status: taskpkg.TaskStatusInProgress, + Owner: &taskpkg.Ownership{Kind: taskpkg.OwnerKindHuman, Ref: "alice"}, + }, + }, + Dependencies: []TaskDependencyRecord{ + { + TaskID: "task-1", + DependsOnTaskID: "task-blocker", + Kind: taskpkg.DependencyKindBlocks, + CreatedAt: fixedTestNow, + }, + }, + Runs: []TaskRunRecord{ + sampleTaskRunRecord(taskpkg.TaskRunStatusRunning), + }, + Events: []TaskEventRecord{ + { + ID: "evt-1", + TaskID: "task-1", + RunID: "run-1", + EventType: "task.run_started", + Actor: taskpkg.ActorIdentity{Kind: taskpkg.ActorKindHuman, Ref: "local-user"}, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindCLI, Ref: "tasks.run.start"}, + Timestamp: fixedTestNow, + }, + }, + } +} diff --git a/internal/config/automation.go b/internal/config/automation.go index 7e9cea28e..f299d8d5f 100644 --- a/internal/config/automation.go +++ b/internal/config/automation.go @@ -28,6 +28,7 @@ type AutomationJob struct { Workspace string `toml:"workspace,omitempty"` Prompt string `toml:"prompt"` Schedule automationpkg.ScheduleSpec `toml:"schedule"` + Task *automationpkg.JobTaskConfig `toml:"task,omitempty"` Enabled bool `toml:"enabled"` Retry automationpkg.RetryConfig `toml:"retry,omitempty"` FireLimit automationpkg.FireLimitConfig `toml:"fire_limit,omitempty"` @@ -67,6 +68,7 @@ type parsedAutomationJob struct { Workspace string `toml:"workspace"` Prompt string `toml:"prompt"` Schedule *automationpkg.ScheduleSpec `toml:"schedule"` + Task *automationpkg.JobTaskConfig `toml:"task"` Enabled *bool `toml:"enabled"` Retry *automationpkg.RetryConfig `toml:"retry"` FireLimit *automationpkg.FireLimitConfig `toml:"fire_limit"` @@ -121,10 +123,10 @@ func (j AutomationJob) Validate(path string) error { if strings.TrimSpace(j.Name) == "" { return errors.New(path + ".name is required") } - if strings.TrimSpace(j.AgentName) == "" { + if j.Task == nil && strings.TrimSpace(j.AgentName) == "" { return errors.New(path + ".agent is required") } - if strings.TrimSpace(j.Prompt) == "" { + if j.Task == nil && strings.TrimSpace(j.Prompt) == "" { return errors.New(path + ".prompt is required") } if err := automationpkg.ValidateScopeBinding(j.Scope, j.Workspace, path, "workspace"); err != nil { @@ -142,6 +144,14 @@ func (j AutomationJob) Validate(path string) error { if err := j.FireLimit.Validate(path + ".fire_limit"); err != nil { return err } + if j.Task != nil { + if err := j.Task.Validate(path + ".task"); err != nil { + return err + } + if j.Retry.Strategy != automationpkg.RetryStrategyNone { + return fmt.Errorf("%s.strategy must be %q when %s is configured", path+".retry", automationpkg.RetryStrategyNone, path+".task") + } + } return nil } @@ -245,6 +255,7 @@ func (j parsedAutomationJob) toAutomationJob(defaultFireLimit automationpkg.Fire if j.Schedule != nil { job.Schedule = *j.Schedule } + job.Task = cloneParsedJobTaskConfig(j.Task) if j.Enabled != nil { job.Enabled = *j.Enabled } @@ -258,6 +269,18 @@ func (j parsedAutomationJob) toAutomationJob(defaultFireLimit automationpkg.Fire return job } +func cloneParsedJobTaskConfig(config *automationpkg.JobTaskConfig) *automationpkg.JobTaskConfig { + if config == nil { + return nil + } + cloned := *config + if config.Owner != nil { + owner := *config.Owner + cloned.Owner = &owner + } + return &cloned +} + func (t parsedAutomationTrigger) toAutomationTrigger(defaultFireLimit automationpkg.FireLimitConfig) AutomationTrigger { trigger := AutomationTrigger{ Scope: t.Scope, diff --git a/internal/daemon/boot.go b/internal/daemon/boot.go index efbe7ee08..d8411cf6f 100644 --- a/internal/daemon/boot.go +++ b/internal/daemon/boot.go @@ -24,6 +24,7 @@ import ( "github.com/pedronauck/agh/internal/session" "github.com/pedronauck/agh/internal/skills" "github.com/pedronauck/agh/internal/skills/bundled" + taskpkg "github.com/pedronauck/agh/internal/task" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) @@ -43,6 +44,7 @@ type bootState struct { registry Registry workspaceResolver workspacepkg.WorkspaceResolver sessions SessionManager + tasks *taskRuntime network networkRuntime observer Observer lifecycleObservers *sessionLifecycleFanout @@ -127,6 +129,9 @@ func (d *Daemon) boot(ctx context.Context) (err error) { if err := d.bootRuntime(ctx, state, cleanup); err != nil { return err } + if err := d.bootTasks(ctx, state); err != nil { + return err + } if err := d.bootNetwork(ctx, state, cleanup); err != nil { return err } @@ -424,6 +429,7 @@ func (d *Daemon) bootNetwork(ctx context.Context, state *bootState, cleanup *boo d.homePaths.NetworkAuditFile, state.registry, network.WithManagerLogger(state.logger), + network.WithManagerTaskService(state.deps.Tasks), ) if err != nil { return fmt.Errorf("daemon: create network manager: %w", err) @@ -513,9 +519,15 @@ func (d *Daemon) bootAutomation(ctx context.Context, state *bootState, cleanup * return errors.New("daemon: automation manager factory is required") } + var tasks taskpkg.Manager + if state.tasks != nil { + tasks = state.tasks.manager + } + manager, err := d.newAutomationManager(automationManagerDeps{ Store: store, Sessions: state.sessions, + Tasks: tasks, WorkspaceResolver: state.workspaceResolver, Config: state.cfg.Automation, Hooks: state.hooks, @@ -565,6 +577,7 @@ func (d *Daemon) bootExtensions(ctx context.Context, state *bootState, cleanup * Automation: func() extensionpkg.HostAPIAutomationManager { return state.automation }, + Tasks: state.deps.Tasks, MemoryStore: state.memoryStore, Observer: state.observer, SkillsRegistry: state.skillsRegistry, @@ -584,9 +597,16 @@ func (d *Daemon) bootExtensions(ctx context.Context, state *bootState, cleanup * return manager.Stop(ctx) }) - startErr := manager.Start(ctx) - if errors.Is(startErr, context.Canceled) || errors.Is(startErr, context.DeadlineExceeded) { - return startErr + if err := manager.Start(ctx); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + + if extensionRuntimeHasRegisteredEntries(ctx, extRegistry, manager) { + state.logger.Error("daemon: extension manager start failed; continuing with healthy extensions only", "error", err) + } else { + state.logger.Error("daemon: extension manager start failed; continuing without blocking boot", "error", err) + } } if state.bridges != nil { state.bridges.setExtensionRuntime(manager) @@ -598,13 +618,40 @@ func (d *Daemon) bootExtensions(ctx context.Context, state *bootState, cleanup * state.logger.Error("daemon: rebuild hooks after extension boot failed; continuing without extension hooks", "error", err) } } - if startErr != nil { - state.logger.Error("daemon: extension manager start failed; continuing without blocking boot", "error", startErr) - } return nil } +func extensionRuntimeHasRegisteredEntries(ctx context.Context, registry *extensionpkg.Registry, runtime extensionRuntime) bool { + if ctx == nil || registry == nil || runtime == nil { + return false + } + if err := ctx.Err(); err != nil { + return false + } + + infos, err := registry.List() + if err != nil { + return false + } + + for _, info := range infos { + if !info.Enabled { + continue + } + + ext, err := runtime.Get(info.Name) + if err != nil || ext == nil { + continue + } + if ext.Status.Registered { + return true + } + } + + return false +} + func (d *Daemon) bootServers(ctx context.Context, state *bootState, cleanup *bootCleanup) error { httpServer, err := d.httpFactory(ctx, state.deps) if err != nil { @@ -709,6 +756,7 @@ func (d *Daemon) publishBootState(state *bootState) { d.registry = state.registry d.memoryStore = state.memoryStore d.sessions = state.sessions + d.tasks = state.tasks d.network = state.network d.hooks = state.hooks d.extensions = state.currentExtensionRuntime() diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index d510525a0..5b1dbe52e 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -29,6 +29,7 @@ import ( "github.com/pedronauck/agh/internal/skills" "github.com/pedronauck/agh/internal/store" "github.com/pedronauck/agh/internal/store/globaldb" + taskpkg "github.com/pedronauck/agh/internal/task" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) @@ -72,6 +73,7 @@ type RuntimeDeps struct { HomePaths aghconfig.HomePaths Logger *slog.Logger Sessions SessionManager + Tasks taskpkg.Manager Network core.NetworkService Observer Observer Automation core.AutomationManager @@ -116,6 +118,10 @@ type shutdownStopper interface { StopWithCause(ctx context.Context, id string, cause session.StopCause, detail string) error } +type finalizationWaiter interface { + WaitForFinalizations(ctx context.Context) error +} + type extensionDBSource interface { DB() *sql.DB } @@ -140,6 +146,7 @@ type extensionManagerDeps struct { Registry *extensionpkg.Registry Sessions SessionManager Automation func() extensionpkg.HostAPIAutomationManager + Tasks taskpkg.Manager MemoryStore *memory.Store Observer Observer SkillsRegistry *skills.Registry @@ -164,6 +171,7 @@ type automationRuntime interface { type automationManagerDeps struct { Store automationpkg.Store Sessions SessionManager + Tasks taskpkg.Manager WorkspaceResolver workspacepkg.WorkspaceResolver Config aghconfig.AutomationConfig Hooks automationpkg.AutomationHookDispatcher @@ -222,6 +230,7 @@ type Daemon struct { registry Registry memoryStore *memory.Store sessions SessionManager + tasks *taskRuntime network networkRuntime hooks hookRuntime extensions extensionRuntime @@ -399,6 +408,7 @@ func (d *Daemon) applyDefaults() error { capChecker := &extensionpkg.CapabilityChecker{} hostAPIOpts := []extensionpkg.HostAPIOption{ extensionpkg.WithHostAPIAutomationGetter(deps.Automation), + extensionpkg.WithHostAPITaskManager(deps.Tasks), extensionpkg.WithHostAPICapabilityChecker(capChecker), extensionpkg.WithHostAPIWorkspaceResolver(deps.WorkspaceResolver), } @@ -443,6 +453,7 @@ func (d *Daemon) applyDefaults() error { manager, err := automationpkg.New( automationpkg.WithStore(deps.Store), automationpkg.WithSessions(deps.Sessions), + automationpkg.WithTasks(deps.Tasks), automationpkg.WithWorkspaceResolver(deps.WorkspaceResolver), automationpkg.WithConfig(deps.Config), automationpkg.WithHooks(deps.Hooks), @@ -463,6 +474,7 @@ func (d *Daemon) applyDefaults() error { httpapi.WithLogger(deps.Logger), httpapi.WithStartedAt(deps.StartedAt), httpapi.WithSessionManager(deps.Sessions), + httpapi.WithTaskService(deps.Tasks), httpapi.WithNetworkService(deps.Network), httpapi.WithNetworkStore(deps.Registry), httpapi.WithObserver(deps.Observer), @@ -483,6 +495,7 @@ func (d *Daemon) applyDefaults() error { udsapi.WithLogger(deps.Logger), udsapi.WithStartedAt(deps.StartedAt), udsapi.WithSessionManager(deps.Sessions), + udsapi.WithTaskService(deps.Tasks), udsapi.WithNetworkService(deps.Network), udsapi.WithNetworkStore(deps.Registry), udsapi.WithObserver(deps.Observer), @@ -579,6 +592,7 @@ func (d *Daemon) Shutdown(ctx context.Context) error { skillsDone := d.skillsDone d.sessions = nil + d.tasks = nil d.hooks = nil d.extensions = nil d.automation = nil @@ -704,6 +718,11 @@ func (d *Daemon) stopSessions(ctx context.Context, sessions SessionManager) erro errs = append(errs, fmt.Errorf("daemon: stop session %q: %w", info.ID, err)) } } + if waiter, ok := sessions.(finalizationWaiter); ok { + if err := waiter.WaitForFinalizations(ctx); err != nil { + errs = append(errs, fmt.Errorf("daemon: wait for session finalizations: %w", err)) + } + } return errors.Join(errs...) } diff --git a/internal/daemon/daemon_integration_test.go b/internal/daemon/daemon_integration_test.go index cdf94e8ec..d986b0bcc 100644 --- a/internal/daemon/daemon_integration_test.go +++ b/internal/daemon/daemon_integration_test.go @@ -29,6 +29,7 @@ import ( "github.com/pedronauck/agh/internal/session" "github.com/pedronauck/agh/internal/store" "github.com/pedronauck/agh/internal/store/globaldb" + taskpkg "github.com/pedronauck/agh/internal/task" "github.com/pedronauck/agh/internal/testutil" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) @@ -120,6 +121,252 @@ func TestBootSequenceReady(t *testing.T) { } } +func TestBootWiresTaskRuntimeWithDedicatedSessionBridge(t *testing.T) { + homePaths := integrationHomePaths(t) + cfg := testConfig(t, homePaths) + sessions := &fakeSessionManager{} + + d, err := New( + WithHomePaths(homePaths), + WithConfig(cfg), + WithLogger(discardLogger()), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + d.newSessionManager = func(context.Context, SessionManagerDeps) (SessionManager, error) { + return sessions, nil + } + d.newObserver = func(context.Context, RuntimeDeps) (Observer, error) { + return &fakeObserver{}, nil + } + d.httpFactory = func(context.Context, RuntimeDeps) (Server, error) { + return &fakeServer{name: "http"}, nil + } + d.udsFactory = func(context.Context, RuntimeDeps) (Server, error) { + return &fakeServer{name: "uds"}, nil + } + + if err := d.boot(testutil.Context(t)); err != nil { + t.Fatalf("boot() error = %v", err) + } + t.Cleanup(func() { + if err := d.Shutdown(testutil.Context(t)); err != nil { + t.Fatalf("Shutdown() error = %v", err) + } + }) + + if d.tasks == nil || d.tasks.manager == nil { + t.Fatal("boot() did not publish the task runtime") + } + + workspaceRoot := filepath.Join(t.TempDir(), "task-runtime-workspace") + resolved := resolveDaemonWorkspace(t, d.workspaceResolver, workspaceRoot) + actor, err := taskpkg.DeriveHumanActorContext("user-1", taskpkg.OriginKindCLI, "agh task run") + if err != nil { + t.Fatalf("DeriveHumanActorContext() error = %v", err) + } + + taskRecord, err := d.tasks.manager.CreateTask(testutil.Context(t), taskpkg.CreateTask{ + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: resolved.ID, + Title: "Bridge task", + NetworkChannel: "builders", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + run, err := d.tasks.manager.EnqueueRun(testutil.Context(t), taskpkg.EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun() error = %v", err) + } + run, err = d.tasks.manager.ClaimRun(testutil.Context(t), run.ID, taskpkg.ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun() error = %v", err) + } + run, err = d.tasks.manager.StartRun(testutil.Context(t), run.ID, taskpkg.StartRun{}, actor) + if err != nil { + t.Fatalf("StartRun() error = %v", err) + } + + if got, want := sessions.createCount(), 1; got != want { + t.Fatalf("createCount() = %d, want %d", got, want) + } + createCall := sessions.createCall(0) + if got, want := createCall.Type, session.SessionTypeSystem; got != want { + t.Fatalf("createCall.Type = %q, want %q", got, want) + } + if got, want := createCall.Workspace, resolved.ID; got != want { + t.Fatalf("createCall.Workspace = %q, want %q", got, want) + } + if got, want := createCall.Channel, "builders"; got != want { + t.Fatalf("createCall.Channel = %q, want %q", got, want) + } + + storedRun, err := d.tasks.store.GetTaskRun(testutil.Context(t), run.ID) + if err != nil { + t.Fatalf("GetTaskRun() error = %v", err) + } + if got, want := storedRun.Status, taskpkg.TaskRunStatusRunning; got != want { + t.Fatalf("storedRun.Status = %q, want %q", got, want) + } + if strings.TrimSpace(storedRun.SessionID) == "" { + t.Fatal("storedRun.SessionID = empty, want dedicated session id") + } +} + +func TestBootRecoversOrphanedTaskRunsAndRecordsAudit(t *testing.T) { + homePaths := integrationHomePaths(t) + cfg := testConfig(t, homePaths) + + seedDB, err := globaldb.OpenGlobalDB(testutil.Context(t), homePaths.DatabaseFile) + if err != nil { + t.Fatalf("OpenGlobalDB(seed) error = %v", err) + } + + seedManager, err := taskpkg.NewManager(taskpkg.WithStore(seedDB)) + if err != nil { + t.Fatalf("task.NewManager(seed) error = %v", err) + } + actor, err := taskpkg.DeriveHumanActorContext("user-1", taskpkg.OriginKindCLI, "agh task seed") + if err != nil { + t.Fatalf("DeriveHumanActorContext() error = %v", err) + } + + createTask := func(title string) taskpkg.Task { + taskRecord, err := seedManager.CreateTask(testutil.Context(t), taskpkg.CreateTask{ + Scope: taskpkg.ScopeGlobal, + Title: title, + }, actor) + if err != nil { + t.Fatalf("CreateTask(%q) error = %v", title, err) + } + return *taskRecord + } + + claimedTask := createTask("Claimed run") + startingTask := createTask("Starting run") + runningTask := createTask("Running run") + + now := time.Date(2026, 4, 14, 19, 0, 0, 0, time.UTC) + for _, run := range []taskpkg.TaskRun{ + { + ID: "run-claimed", + TaskID: claimedTask.ID, + Status: taskpkg.TaskRunStatusClaimed, + Attempt: 1, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindCLI, Ref: "agh task seed"}, + QueuedAt: now, + ClaimedAt: now.Add(30 * time.Second), + }, + { + ID: "run-starting", + TaskID: startingTask.ID, + Status: taskpkg.TaskRunStatusStarting, + Attempt: 1, + SessionID: "sess-stopped", + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindCLI, Ref: "agh task seed"}, + QueuedAt: now, + StartedAt: now.Add(time.Minute), + }, + { + ID: "run-running", + TaskID: runningTask.ID, + Status: taskpkg.TaskRunStatusRunning, + Attempt: 1, + SessionID: "sess-missing", + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindCLI, Ref: "agh task seed"}, + QueuedAt: now, + StartedAt: now.Add(2 * time.Minute), + }, + } { + if err := seedDB.CreateTaskRun(testutil.Context(t), run); err != nil { + t.Fatalf("CreateTaskRun(%q) error = %v", run.ID, err) + } + } + + if err := seedDB.Close(testutil.Context(t)); err != nil { + t.Fatalf("seedDB.Close() error = %v", err) + } + + sessions := &fakeSessionManager{ + infos: []*session.SessionInfo{ + {ID: "sess-stopped", State: session.StateStopped}, + }, + } + + d, err := New( + WithHomePaths(homePaths), + WithConfig(cfg), + WithLogger(discardLogger()), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + d.newSessionManager = func(context.Context, SessionManagerDeps) (SessionManager, error) { + return sessions, nil + } + d.newObserver = func(context.Context, RuntimeDeps) (Observer, error) { + return &fakeObserver{}, nil + } + d.httpFactory = func(context.Context, RuntimeDeps) (Server, error) { + return &fakeServer{name: "http"}, nil + } + d.udsFactory = func(context.Context, RuntimeDeps) (Server, error) { + return &fakeServer{name: "uds"}, nil + } + + if err := d.boot(testutil.Context(t)); err != nil { + t.Fatalf("boot() error = %v", err) + } + t.Cleanup(func() { + if err := d.Shutdown(testutil.Context(t)); err != nil { + t.Fatalf("Shutdown() error = %v", err) + } + }) + + claimedRun, err := d.tasks.store.GetTaskRun(testutil.Context(t), "run-claimed") + if err != nil { + t.Fatalf("GetTaskRun(run-claimed) error = %v", err) + } + if got, want := claimedRun.Status, taskpkg.TaskRunStatusQueued; got != want { + t.Fatalf("claimedRun.Status = %q, want %q", got, want) + } + + startingRun, err := d.tasks.store.GetTaskRun(testutil.Context(t), "run-starting") + if err != nil { + t.Fatalf("GetTaskRun(run-starting) error = %v", err) + } + if got, want := startingRun.Status, taskpkg.TaskRunStatusFailed; got != want { + t.Fatalf("startingRun.Status = %q, want %q", got, want) + } + + runningRun, err := d.tasks.store.GetTaskRun(testutil.Context(t), "run-running") + if err != nil { + t.Fatalf("GetTaskRun(run-running) error = %v", err) + } + if got, want := runningRun.Status, taskpkg.TaskRunStatusFailed; got != want { + t.Fatalf("runningRun.Status = %q, want %q", got, want) + } + + claimedEvents, err := d.tasks.store.ListTaskEvents(testutil.Context(t), taskpkg.TaskEventQuery{TaskID: claimedTask.ID}) + if err != nil { + t.Fatalf("ListTaskEvents(claimed) error = %v", err) + } + if !containsTaskEventType(claimedEvents, "task.run_recovered") { + t.Fatalf("claimed task events = %#v, want task.run_recovered", taskEventTypes(claimedEvents)) + } + + startingEvents, err := d.tasks.store.ListTaskEvents(testutil.Context(t), taskpkg.TaskEventQuery{TaskID: startingTask.ID}) + if err != nil { + t.Fatalf("ListTaskEvents(starting) error = %v", err) + } + if !containsTaskEventType(startingEvents, "task.run_failed") || !containsTaskEventType(startingEvents, "task.run_recovered") { + t.Fatalf("starting task events = %#v, want task.run_failed + task.run_recovered", taskEventTypes(startingEvents)) + } +} + func TestBootPublishesRunningAutomationBeforeServersStart(t *testing.T) { homePaths := integrationHomePaths(t) cfg := testConfig(t, homePaths) @@ -2116,3 +2363,20 @@ func assertLifecycleHookPayload(t *testing.T, path string, wantEvent hookspkg.Ho } }) } + +func containsTaskEventType(events []taskpkg.TaskEvent, want string) bool { + for _, event := range events { + if event.EventType == want { + return true + } + } + return false +} + +func taskEventTypes(events []taskpkg.TaskEvent) []string { + types := make([]string, 0, len(events)) + for _, event := range events { + types = append(types, event.EventType) + } + return types +} diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 94339c828..2df121ccc 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -39,6 +39,7 @@ import ( "github.com/pedronauck/agh/internal/store" "github.com/pedronauck/agh/internal/store/globaldb" "github.com/pedronauck/agh/internal/subprocess" + taskpkg "github.com/pedronauck/agh/internal/task" "github.com/pedronauck/agh/internal/testutil" "github.com/pedronauck/agh/internal/transcript" workspacepkg "github.com/pedronauck/agh/internal/workspace" @@ -456,6 +457,7 @@ func TestShutdownTearsDownInRequiredOrder(t *testing.T) { events = append(events, "session:"+id) }, } + d.tasks = &taskRuntime{} d.network = &fakeNetworkRuntime{ onShutdown: func() { events = append(events, "network") @@ -489,6 +491,9 @@ func TestShutdownTearsDownInRequiredOrder(t *testing.T) { if err := d.Shutdown(testutil.Context(t)); err != nil { t.Fatalf("Shutdown() error = %v", err) } + if d.tasks != nil { + t.Fatalf("Shutdown() left task runtime = %#v, want nil", d.tasks) + } want := []string{"extensions", "automation", "session:sess-a", "session:sess-b", "http", "uds", "network", "hooks", "db", "lock", "logger"} if !testutil.EqualStringSlices(events, want) { @@ -682,6 +687,105 @@ func TestBootExtensionsLogsStartFailureAndKeepsPartialRuntime(t *testing.T) { } } +func TestBootExtensionsKeepsHealthyRegisteredExtensionsAfterPartialStartFailure(t *testing.T) { + t.Parallel() + + t.Run("ShouldKeepHealthyRegisteredExtensionsAfterPartialStartFailure", func(t *testing.T) { + t.Parallel() + + db := openDaemonTestGlobalDB(t) + installDaemonTestExtension(t, db, "ext-healthy", daemonTestExtensionOptions{}, true) + installDaemonTestExtension(t, db, "ext-bad", daemonTestExtensionOptions{}, true) + + var logBuffer bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logBuffer, nil)) + runtime := &fakeExtensionRuntime{ + startErr: errors.New("boom"), + getFn: func(name string) (*extensionpkg.Extension, error) { + switch name { + case "ext-healthy": + return &extensionpkg.Extension{ + Info: extensionpkg.ExtensionInfo{ + Name: "ext-healthy", + Enabled: true, + }, + Status: extensionpkg.ExtensionStatus{ + Name: "ext-healthy", + Enabled: true, + Registered: true, + }, + }, nil + case "ext-bad": + return nil, extensionpkg.ErrExtensionNotFound + default: + return nil, extensionpkg.ErrExtensionNotFound + } + }, + } + homePaths := testHomePaths(t) + d := newTestDaemon(t, homePaths, testConfig(t, homePaths)) + d.newExtensionManager = func(extensionManagerDeps) extensionRuntime { + return runtime + } + + rebuilds := 0 + state := &bootState{ + logger: logger, + registry: db, + sessions: &fakeSessionManager{}, + observer: &fakeObserver{}, + bridges: &bridgeRuntime{broker: bridgepkg.NewBroker(nil)}, + hooks: &fakeHookRuntime{ + onRebuild: func(context.Context) error { + rebuilds++ + return nil + }, + }, + } + cleanup := &bootCleanup{} + + if err := d.bootExtensions(testutil.Context(t), state, cleanup); err != nil { + t.Fatalf("bootExtensions() error = %v, want nil", err) + } + + if runtime.startCount != 1 { + t.Fatalf("extension runtime start count = %d, want 1", runtime.startCount) + } + if rebuilds != 1 { + t.Fatalf("hook rebuild count = %d, want 1 after partial start", rebuilds) + } + if len(cleanup.fns) != 1 { + t.Fatalf("cleanup fns = %d, want 1", len(cleanup.fns)) + } + if state.currentExtensionRuntime() != runtime { + t.Fatalf("state.extensions = %#v, want runtime", state.currentExtensionRuntime()) + } + if state.deps.Extensions == nil { + t.Fatal("state.deps.Extensions = nil, want extension service") + } + if state.bridges.extensions != runtime { + t.Fatalf("state.bridges.extensions = %#v, want runtime", state.bridges.extensions) + } + healthy, err := state.deps.Extensions.Status(testutil.Context(t), "ext-healthy") + if err != nil { + t.Fatalf("Extensions.Status(ext-healthy) error = %v", err) + } + if got, want := healthy.State, "registered"; got != want { + t.Fatalf("ext-healthy state = %q, want %q", got, want) + } + bad, err := state.deps.Extensions.Status(testutil.Context(t), "ext-bad") + if err != nil { + t.Fatalf("Extensions.Status(ext-bad) error = %v", err) + } + if got, want := bad.State, "enabled"; got != want { + t.Fatalf("ext-bad state = %q, want %q", got, want) + } + if !strings.Contains(logBuffer.String(), "healthy extensions only") { + t.Fatalf("log output = %q, want partial start continuation message", logBuffer.String()) + } + }) +} + func TestBootExtensionsPropagatesContextCancellation(t *testing.T) { t.Parallel() @@ -1439,6 +1543,39 @@ func TestStopSessionsUsesShutdownCauseWhenSupported(t *testing.T) { } } +func TestStopSessionsWaitsForInFlightFinalizations(t *testing.T) { + d, err := New(WithLogger(discardLogger())) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + release := make(chan struct{}) + manager := &fakeSessionManager{ + infos: []*session.SessionInfo{{ID: "sess-a"}}, + waitFinalizationsRelease: release, + } + + stopDone := make(chan error, 1) + go func() { + stopDone <- d.stopSessions(testutil.Context(t), manager) + }() + + select { + case err := <-stopDone: + t.Fatalf("stopSessions() returned before finalizations completed: %v", err) + case <-time.After(50 * time.Millisecond): + } + + close(release) + + if err := <-stopDone; err != nil { + t.Fatalf("stopSessions() error = %v", err) + } + if got := manager.waitFinalizationsCalls; got != 1 { + t.Fatalf("WaitForFinalizations() calls = %d, want 1", got) + } +} + func TestCleanupOrphansHandlesListAndSignalErrors(t *testing.T) { d, err := New(WithLogger(discardLogger())) if err != nil { @@ -2645,16 +2782,20 @@ type fakeSessionManager struct { onStop func(string) stopErr func(string) error stopWithCauseErr func(string, session.StopCause, string) error + requestStopErr func(string, session.StopCause, string) error createCalls []session.CreateOpts promptCalls []struct { id string msg string } - promptStarted chan struct{} - promptRelease <-chan struct{} - promptCtxCancelled chan struct{} - stopCalls []string - stopWithCauseCalls []fakeStopWithCauseCall + promptStarted chan struct{} + promptRelease <-chan struct{} + promptCtxCancelled chan struct{} + stopCalls []string + stopWithCauseCalls []fakeStopWithCauseCall + requestStopCalls []fakeStopWithCauseCall + waitFinalizationsRelease <-chan struct{} + waitFinalizationsCalls int } type fakeStopWithCauseCall struct { @@ -2751,6 +2892,38 @@ func (f *fakeSessionManager) StopWithCause(_ context.Context, id string, cause s return nil } +func (f *fakeSessionManager) RequestStopWithCause(_ context.Context, id string, cause session.StopCause, detail string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.requestStopCalls = append(f.requestStopCalls, fakeStopWithCauseCall{ + id: id, + cause: cause, + detail: detail, + }) + if f.requestStopErr != nil { + return f.requestStopErr(id, cause, detail) + } + return nil +} + +func (f *fakeSessionManager) WaitForFinalizations(ctx context.Context) error { + f.mu.Lock() + f.waitFinalizationsCalls++ + release := f.waitFinalizationsRelease + f.mu.Unlock() + + if release == nil { + return nil + } + + select { + case <-release: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + func (f *fakeSessionManager) Resume(context.Context, string) (*session.Session, error) { return nil, nil } @@ -3124,6 +3297,88 @@ func (r *recordingRegistry) ListNetworkMessages(context.Context, store.NetworkMe return nil, nil } +func (r *recordingRegistry) CreateTask(context.Context, taskpkg.Task) error { + return nil +} + +func (r *recordingRegistry) UpdateTask(context.Context, taskpkg.Task) error { + return nil +} + +func (r *recordingRegistry) GetTask(context.Context, string) (taskpkg.Task, error) { + return taskpkg.Task{}, taskpkg.ErrTaskNotFound +} +func (r *recordingRegistry) ListTasks(context.Context, taskpkg.TaskQuery) ([]taskpkg.TaskSummary, error) { + return nil, nil +} + +func (r *recordingRegistry) CountDirectChildren(context.Context, string) (int, error) { + return 0, nil +} + +func (r *recordingRegistry) CreateDependency(context.Context, taskpkg.TaskDependency) error { + return nil +} + +func (r *recordingRegistry) DeleteDependency(context.Context, string, string) error { + return nil +} + +func (r *recordingRegistry) ListDependencies(context.Context, string) ([]taskpkg.TaskDependency, error) { + return nil, nil +} + +func (r *recordingRegistry) ListDependents(context.Context, string) ([]taskpkg.TaskDependency, error) { + return nil, nil +} + +func (r *recordingRegistry) CountDependencies(context.Context, string) (int, error) { + return 0, nil +} + +func (r *recordingRegistry) HasDependencyPath(context.Context, string, string) (bool, error) { + return false, nil +} + +func (r *recordingRegistry) CreateTaskRun(context.Context, taskpkg.TaskRun) error { + return nil +} + +func (r *recordingRegistry) UpdateTaskRun(context.Context, taskpkg.TaskRun) error { + return nil +} + +func (r *recordingRegistry) GetTaskRun(context.Context, string) (taskpkg.TaskRun, error) { + return taskpkg.TaskRun{}, taskpkg.ErrTaskRunNotFound +} + +func (r *recordingRegistry) ListTaskRuns(context.Context, taskpkg.TaskRunQuery) ([]taskpkg.TaskRun, error) { + return nil, nil +} + +func (r *recordingRegistry) ListTaskRunsByStatus(context.Context, []taskpkg.TaskRunStatus) ([]taskpkg.TaskRun, error) { + return nil, nil +} + +func (r *recordingRegistry) CountActiveSessionBindings(context.Context, string) (int, error) { + return 0, nil +} + +func (r *recordingRegistry) CreateTaskEvent(context.Context, taskpkg.TaskEvent) error { + return nil +} + +func (r *recordingRegistry) ListTaskEvents(context.Context, taskpkg.TaskEventQuery) ([]taskpkg.TaskEvent, error) { + return nil, nil +} + +func (r *recordingRegistry) GetTaskRunByIdempotencyKey(context.Context, string, taskpkg.Origin) (taskpkg.TaskRun, error) { + return taskpkg.TaskRun{}, taskpkg.ErrTaskRunIdempotencyNotFound +} + +func (r *recordingRegistry) SaveTaskRunIdempotency(context.Context, taskpkg.TaskRunIdempotency) error { + return nil +} func (r *recordingRegistry) Close(context.Context) error { if r.onClose != nil { r.onClose() @@ -3670,6 +3925,7 @@ type fakeExtensionRuntime struct { hookErr error getExt *extensionpkg.Extension getErr error + getFn func(string) (*extensionpkg.Extension, error) onStart func() onStop func() } @@ -3695,11 +3951,17 @@ func (f *fakeExtensionRuntime) Reload(context.Context) error { return f.reloadErr } -func (f *fakeExtensionRuntime) Get(string) (*extensionpkg.Extension, error) { +func (f *fakeExtensionRuntime) Get(name string) (*extensionpkg.Extension, error) { + if f.getFn != nil { + return f.getFn(name) + } if f.getErr != nil { return nil, f.getErr } - return f.getExt, nil + if f.getExt != nil { + return f.getExt, nil + } + return nil, extensionpkg.ErrExtensionNotFound } func (f *fakeExtensionRuntime) HookDeclarations(context.Context) ([]hookspkg.HookDecl, error) { @@ -3725,17 +3987,17 @@ func (f *fakeExtensionRuntime) HookDeclarations(context.Context) ([]hookspkg.Hoo } type daemonTestExtensionOptions struct { - runtimeCommand string - runtimeArgs []string - runtimeEnv map[string]string - hookCommand string - hookArgs []string - hookEvent hookspkg.HookEvent - capabilities []string - actions []string - security []string - bridgePlatform string - bridgeName string + runtimeCommand string + runtimeArgs []string + runtimeEnv map[string]string + hookCommand string + hookArgs []string + hookEvent hookspkg.HookEvent + capabilities []string + actions []string + security []string + bridgePlatform string + bridgeDisplayName string } func openDaemonTestGlobalDB(t *testing.T) *globaldb.GlobalDB { @@ -3809,13 +4071,13 @@ func daemonTestExtensionManifest(name string, opts daemonTestExtensionOptions) s security = []string{"session.read"} } bridgePlatform := strings.TrimSpace(opts.bridgePlatform) - bridgeName := strings.TrimSpace(opts.bridgeName) + bridgeDisplayName := strings.TrimSpace(opts.bridgeDisplayName) if slices.Contains(capabilities, extensionprotocol.CapabilityProvideBridgeAdapter) { if bridgePlatform == "" { bridgePlatform = "telegram" } - if bridgeName == "" { - bridgeName = "Telegram" + if bridgeDisplayName == "" { + bridgeDisplayName = "Telegram" } } @@ -3853,13 +4115,6 @@ executor.command = %q provides = ` + daemonTOMLStringArray(capabilities) + ` `) - if bridgePlatform != "" || bridgeName != "" { - fmt.Fprintf(&builder, `[bridge] -platform = %q -display_name = %q - -`, bridgePlatform, bridgeName) - } builder.WriteString(` [actions] requires = ` + daemonTOMLStringArray(actions) + ` @@ -3886,6 +4141,13 @@ command = ` + fmt.Sprintf("%q", command) + ` [security] capabilities = ` + daemonTOMLStringArray(security) + ` `) + if bridgePlatform != "" || bridgeDisplayName != "" { + fmt.Fprintf(&builder, ` +[bridge] +platform = %q +display_name = %q +`, bridgePlatform, bridgeDisplayName) + } return builder.String() } @@ -3939,14 +4201,14 @@ func TestDaemonTestExtensionManifest(t *testing.T) { } }) - t.Run("ShouldInjectBridgeMetadataWhenBridgeCapabilityIsRequested", func(t *testing.T) { + t.Run("ShouldEmitBridgeMetadataForBridgeAdapters", func(t *testing.T) { t.Parallel() manifest := daemonTestExtensionManifest("bridge-ext", daemonTestExtensionOptions{ capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter}, }) - for _, expected := range []string{ + `provides = ["bridge.adapter"]`, `[bridge]`, `platform = "telegram"`, `display_name = "Telegram"`, diff --git a/internal/daemon/task_runtime.go b/internal/daemon/task_runtime.go new file mode 100644 index 000000000..b73e1f72e --- /dev/null +++ b/internal/daemon/task_runtime.go @@ -0,0 +1,379 @@ +package daemon + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/pedronauck/agh/internal/network" + "github.com/pedronauck/agh/internal/session" + taskpkg "github.com/pedronauck/agh/internal/task" +) + +const ( + defaultTaskCancelGrace = 5 * time.Second + taskRecoveryReasonBoot = "orphaned_on_boot" + taskRecoverySessionMissing = "missing" +) + +type taskStore interface { + taskpkg.Store +} + +type taskRuntime struct { + manager *taskpkg.TaskManager + store taskStore +} + +type taskBridgeSessionManager interface { + Create(ctx context.Context, opts session.CreateOpts) (*session.Session, error) + Status(ctx context.Context, id string) (*session.SessionInfo, error) + StopWithCause(ctx context.Context, id string, cause session.StopCause, detail string) error +} + +type taskBridgeSessionRequestStopper interface { + RequestStopWithCause(ctx context.Context, id string, cause session.StopCause, detail string) error +} + +type taskSessionBridge struct { + sessions taskBridgeSessionManager + globalWorkspacePath string + logger *slog.Logger +} + +type taskRecoveryStats struct { + requeued int + markedRunning int + failed int +} + +var _ taskpkg.SessionExecutor = (*taskSessionBridge)(nil) + +func newTaskSessionBridge(sessions taskBridgeSessionManager, globalWorkspacePath string, logger *slog.Logger) (*taskSessionBridge, error) { + if sessions == nil { + return nil, errors.New("daemon: task session bridge requires a session manager") + } + if logger == nil { + logger = slog.Default() + } + return &taskSessionBridge{ + sessions: sessions, + globalWorkspacePath: strings.TrimSpace(globalWorkspacePath), + logger: logger, + }, nil +} + +func (b *taskSessionBridge) StartTaskSession(ctx context.Context, spec taskpkg.StartTaskSession) (*taskpkg.SessionRef, error) { + if ctx == nil { + return nil, errors.New("daemon: start task session context is required") + } + + opts := session.CreateOpts{ + Name: taskSessionName(spec), + Channel: strings.TrimSpace(spec.Run.NetworkChannel), + Type: session.SessionTypeSystem, + } + switch spec.Task.Scope.Normalize() { + case taskpkg.ScopeWorkspace: + opts.Workspace = strings.TrimSpace(spec.Task.WorkspaceID) + case taskpkg.ScopeGlobal: + if b.globalWorkspacePath == "" { + return nil, errors.New("daemon: task session bridge global workspace path is required") + } + opts.WorkspacePath = b.globalWorkspacePath + default: + return nil, fmt.Errorf("%w: unsupported task scope %q for task session start", taskpkg.ErrValidation, spec.Task.Scope) + } + + created, err := b.sessions.Create(ctx, opts) + if err != nil { + return nil, err + } + if created == nil { + return nil, fmt.Errorf("%w: task session bridge create returned nil session", taskpkg.ErrValidation) + } + info := created.Info() + if info == nil { + return nil, fmt.Errorf("%w: task session bridge create returned nil session info", taskpkg.ErrValidation) + } + return &taskpkg.SessionRef{ + SessionID: strings.TrimSpace(info.ID), + WorkspaceID: strings.TrimSpace(info.WorkspaceID), + StartedAt: info.CreatedAt, + }, nil +} + +func (b *taskSessionBridge) AttachTaskSession(ctx context.Context, _ string, sessionID string) (*taskpkg.SessionRef, error) { + if ctx == nil { + return nil, errors.New("daemon: attach task session context is required") + } + + info, err := b.sessions.Status(ctx, strings.TrimSpace(sessionID)) + if err != nil { + return nil, err + } + if info == nil { + return nil, fmt.Errorf("%w: session %q is unavailable", taskpkg.ErrSessionAttachNotAllowed, strings.TrimSpace(sessionID)) + } + if !isTaskSessionStateLive(info.State) { + return nil, fmt.Errorf("%w: session %q is %q", taskpkg.ErrSessionAttachNotAllowed, strings.TrimSpace(sessionID), info.State) + } + + return &taskpkg.SessionRef{ + SessionID: strings.TrimSpace(info.ID), + WorkspaceID: strings.TrimSpace(info.WorkspaceID), + StartedAt: info.CreatedAt, + }, nil +} + +func (b *taskSessionBridge) RequestTaskStop(ctx context.Context, sessionID string, reason taskpkg.StopReason) error { + if ctx == nil { + return errors.New("daemon: request task stop context is required") + } + + trimmedID := strings.TrimSpace(sessionID) + if trimmedID == "" { + return fmt.Errorf("%w: task session stop id is required", taskpkg.ErrValidation) + } + + if requester, ok := b.sessions.(taskBridgeSessionRequestStopper); ok { + if err := requester.RequestStopWithCause(ctx, trimmedID, taskStopCause(reason), taskStopDetail(reason)); err != nil { + if errors.Is(err, session.ErrSessionNotFound) { + return nil + } + return err + } + return nil + } + + return b.ForceTaskStop(ctx, trimmedID, reason) +} + +func (b *taskSessionBridge) ForceTaskStop(ctx context.Context, sessionID string, reason taskpkg.StopReason) error { + if ctx == nil { + return errors.New("daemon: force task stop context is required") + } + + trimmedID := strings.TrimSpace(sessionID) + if trimmedID == "" { + return fmt.Errorf("%w: task session stop id is required", taskpkg.ErrValidation) + } + + if err := b.sessions.StopWithCause(ctx, trimmedID, taskStopCause(reason), taskStopDetail(reason)); err != nil { + if errors.Is(err, session.ErrSessionNotFound) { + return nil + } + return err + } + return nil +} + +func (d *Daemon) bootTasks(ctx context.Context, state *bootState) error { + if state == nil || state.registry == nil || state.sessions == nil { + return nil + } + + store, ok := state.registry.(taskStore) + if !ok { + state.logger.Warn("daemon: task runtime skipped because registry does not implement task store") + return nil + } + + bridge, err := newTaskSessionBridge(state.sessions, d.homePaths.HomeDir, state.logger) + if err != nil { + return err + } + manager, err := taskpkg.NewManager( + taskpkg.WithStore(store), + taskpkg.WithSessionExecutor(bridge), + taskpkg.WithNetworkChannelValidator(network.ValidateChannel), + taskpkg.WithCancelGracePeriod(defaultTaskCancelGrace), + ) + if err != nil { + return fmt.Errorf("daemon: create task manager: %w", err) + } + + state.tasks = &taskRuntime{ + manager: manager, + store: store, + } + state.deps.Tasks = manager + + actor, err := taskpkg.DeriveDaemonActorContext("boot-recovery", "daemon.boot") + if err != nil { + return fmt.Errorf("daemon: derive task boot recovery actor: %w", err) + } + + stats, err := recoverTaskRunsOnBoot(ctx, manager, store, state.sessions, actor) + if err != nil { + return err + } + if stats.requeued+stats.markedRunning+stats.failed > 0 { + state.logger.Info( + "daemon: task boot recovery complete", + "requeued_runs", stats.requeued, + "resumed_running_runs", stats.markedRunning, + "failed_runs", stats.failed, + ) + } + return nil +} + +func recoverTaskRunsOnBoot( + ctx context.Context, + manager *taskpkg.TaskManager, + store taskStore, + sessions taskBridgeSessionManager, + actor taskpkg.ActorContext, +) (taskRecoveryStats, error) { + runs, err := store.ListTaskRunsByStatus(ctx, []taskpkg.TaskRunStatus{ + taskpkg.TaskRunStatusClaimed, + taskpkg.TaskRunStatusStarting, + taskpkg.TaskRunStatusRunning, + }) + if err != nil { + return taskRecoveryStats{}, fmt.Errorf("daemon: list task runs for boot recovery: %w", err) + } + + stats := taskRecoveryStats{} + for _, run := range runs { + recovery, err := planTaskRunRecovery(ctx, sessions, run) + if err != nil { + return taskRecoveryStats{}, fmt.Errorf("daemon: plan boot recovery for task run %q: %w", run.ID, err) + } + if recovery == nil { + continue + } + if _, err := manager.RecoverRunOnBoot(ctx, run.ID, *recovery, actor); err != nil { + return taskRecoveryStats{}, fmt.Errorf("daemon: recover task run %q on boot: %w", run.ID, err) + } + switch recovery.Action.Normalize() { + case taskpkg.RunBootRecoveryRequeue: + stats.requeued++ + case taskpkg.RunBootRecoveryMarkRunning: + stats.markedRunning++ + case taskpkg.RunBootRecoveryFail: + stats.failed++ + } + } + + return stats, nil +} + +func planTaskRunRecovery(ctx context.Context, sessions taskBridgeSessionManager, run taskpkg.TaskRun) (*taskpkg.RunBootRecovery, error) { + if sessions == nil { + return nil, errors.New("daemon: task recovery requires a session manager") + } + + sessionLive, sessionState, err := taskSessionRuntimeState(ctx, sessions, strings.TrimSpace(run.SessionID)) + if err != nil { + return nil, err + } + + switch run.Status.Normalize() { + case taskpkg.TaskRunStatusClaimed: + if sessionLive { + return &taskpkg.RunBootRecovery{ + Action: taskpkg.RunBootRecoveryMarkRunning, + Reason: taskRecoveryReasonBoot, + SessionState: sessionState, + }, nil + } + return &taskpkg.RunBootRecovery{ + Action: taskpkg.RunBootRecoveryRequeue, + Reason: taskRecoveryReasonBoot, + SessionState: sessionState, + }, nil + + case taskpkg.TaskRunStatusStarting: + if sessionLive { + return &taskpkg.RunBootRecovery{ + Action: taskpkg.RunBootRecoveryMarkRunning, + Reason: taskRecoveryReasonBoot, + SessionState: sessionState, + }, nil + } + return &taskpkg.RunBootRecovery{ + Action: taskpkg.RunBootRecoveryFail, + Reason: taskRecoveryReasonBoot, + SessionState: sessionState, + }, nil + + case taskpkg.TaskRunStatusRunning: + if sessionLive { + return nil, nil + } + return &taskpkg.RunBootRecovery{ + Action: taskpkg.RunBootRecoveryFail, + Reason: taskRecoveryReasonBoot, + SessionState: sessionState, + }, nil + + default: + return nil, nil + } +} + +func taskSessionRuntimeState(ctx context.Context, sessions taskBridgeSessionManager, sessionID string) (bool, string, error) { + trimmedID := strings.TrimSpace(sessionID) + if trimmedID == "" { + return false, taskRecoverySessionMissing, nil + } + + info, err := sessions.Status(ctx, trimmedID) + if err != nil { + if errors.Is(err, session.ErrSessionNotFound) { + return false, taskRecoverySessionMissing, nil + } + return false, "", err + } + if info == nil { + return false, taskRecoverySessionMissing, nil + } + return isTaskSessionStateLive(info.State), string(info.State), nil +} + +func isTaskSessionStateLive(state session.SessionState) bool { + switch state { + case session.StateStarting, session.StateActive, session.StateStopping: + return true + default: + return false + } +} + +func taskSessionName(spec taskpkg.StartTaskSession) string { + base := strings.TrimSpace(spec.Task.Title) + if base == "" { + base = strings.TrimSpace(spec.Task.Identifier) + } + if base == "" { + base = strings.TrimSpace(spec.Run.ID) + } + return fmt.Sprintf("task:%s#%d", base, spec.Run.Attempt) +} + +func taskStopCause(reason taskpkg.StopReason) session.StopCause { + switch reason.Normalize() { + case taskpkg.StopReasonShutdown: + return session.CauseShutdown + case taskpkg.StopReasonOrphanedRun: + return session.CauseFailed + default: + return session.CauseUserRequested + } +} + +func taskStopDetail(reason taskpkg.StopReason) string { + switch reason.Normalize() { + case taskpkg.StopReasonShutdown: + return "task shutdown" + case taskpkg.StopReasonOrphanedRun: + return "task run orphaned" + default: + return "task cancellation" + } +} diff --git a/internal/daemon/task_runtime_test.go b/internal/daemon/task_runtime_test.go new file mode 100644 index 000000000..8d2a9b118 --- /dev/null +++ b/internal/daemon/task_runtime_test.go @@ -0,0 +1,440 @@ +package daemon + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/pedronauck/agh/internal/session" + taskpkg "github.com/pedronauck/agh/internal/task" +) + +func TestTaskSessionBridgeStartTaskSessionUsesDedicatedSystemSessions(t *testing.T) { + t.Parallel() + + globalPath := t.TempDir() + testCases := []struct { + name string + taskRecord taskpkg.Task + wantWorkspace string + wantPath string + }{ + { + name: "Should use the workspace identifier for workspace-scoped tasks", + taskRecord: taskpkg.Task{ + ID: "task-workspace", + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: "ws-123", + Title: "Workspace Task", + }, + wantWorkspace: "ws-123", + }, + { + name: "Should use the global workspace path for global tasks", + taskRecord: taskpkg.Task{ + ID: "task-global", + Scope: taskpkg.ScopeGlobal, + Title: "Global Task", + }, + wantPath: globalPath, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + sessions := &fakeSessionManager{} + bridge, err := newTaskSessionBridge(sessions, globalPath, discardLogger()) + if err != nil { + t.Fatalf("newTaskSessionBridge() error = %v", err) + } + + ref, err := bridge.StartTaskSession(context.Background(), taskpkg.StartTaskSession{ + Task: tc.taskRecord, + Run: taskpkg.TaskRun{ + ID: "run-1", + TaskID: tc.taskRecord.ID, + Status: taskpkg.TaskRunStatusStarting, + Attempt: 2, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindCLI, Ref: "agh task run"}, + NetworkChannel: "builders", + QueuedAt: time.Date(2026, 4, 14, 18, 0, 0, 0, time.UTC), + }, + }) + if err != nil { + t.Fatalf("StartTaskSession() error = %v", err) + } + + if ref == nil || strings.TrimSpace(ref.SessionID) == "" { + t.Fatalf("StartTaskSession() ref = %#v, want non-empty session id", ref) + } + if got, want := sessions.createCount(), 1; got != want { + t.Fatalf("createCount() = %d, want %d", got, want) + } + + createCall := sessions.createCall(0) + if got, want := createCall.Type, session.SessionTypeSystem; got != want { + t.Fatalf("createCall.Type = %q, want %q", got, want) + } + if got, want := createCall.Channel, "builders"; got != want { + t.Fatalf("createCall.Channel = %q, want %q", got, want) + } + if got, want := createCall.Workspace, tc.wantWorkspace; got != want { + t.Fatalf("createCall.Workspace = %q, want %q", got, want) + } + if got, want := createCall.WorkspacePath, tc.wantPath; got != want { + t.Fatalf("createCall.WorkspacePath = %q, want %q", got, want) + } + if !strings.Contains(createCall.Name, tc.taskRecord.Title) { + t.Fatalf("createCall.Name = %q, want task title %q", createCall.Name, tc.taskRecord.Title) + } + }) + } +} + +func TestTaskSessionBridgeAttachTaskSessionRejectsStoppedSessions(t *testing.T) { + t.Parallel() + + sessions := &fakeSessionManager{ + infos: []*session.SessionInfo{ + {ID: "sess-active", State: session.StateActive, WorkspaceID: "ws-active", CreatedAt: time.Date(2026, 4, 14, 18, 0, 0, 0, time.UTC)}, + {ID: "sess-stopped", State: session.StateStopped, WorkspaceID: "ws-stopped", CreatedAt: time.Date(2026, 4, 14, 17, 0, 0, 0, time.UTC)}, + }, + } + bridge, err := newTaskSessionBridge(sessions, t.TempDir(), discardLogger()) + if err != nil { + t.Fatalf("newTaskSessionBridge() error = %v", err) + } + + ref, err := bridge.AttachTaskSession(context.Background(), "run-1", "sess-active") + if err != nil { + t.Fatalf("AttachTaskSession(active) error = %v", err) + } + if got, want := ref.SessionID, "sess-active"; got != want { + t.Fatalf("AttachTaskSession(active).SessionID = %q, want %q", got, want) + } + + if _, err := bridge.AttachTaskSession(context.Background(), "run-1", "sess-stopped"); !errors.Is(err, taskpkg.ErrSessionAttachNotAllowed) { + t.Fatalf("AttachTaskSession(stopped) error = %v, want %v", err, taskpkg.ErrSessionAttachNotAllowed) + } +} + +func TestTaskSessionBridgeStopPathsUseCooperativeThenForcedCalls(t *testing.T) { + t.Parallel() + + sessions := &fakeSessionManager{} + bridge, err := newTaskSessionBridge(sessions, t.TempDir(), discardLogger()) + if err != nil { + t.Fatalf("newTaskSessionBridge() error = %v", err) + } + + if err := bridge.RequestTaskStop(context.Background(), "sess-1", taskpkg.StopReasonCancellation); err != nil { + t.Fatalf("RequestTaskStop() error = %v", err) + } + if err := bridge.ForceTaskStop(context.Background(), "sess-1", taskpkg.StopReasonCancellation); err != nil { + t.Fatalf("ForceTaskStop() error = %v", err) + } + + if got, want := len(sessions.requestStopCalls), 1; got != want { + t.Fatalf("len(requestStopCalls) = %d, want %d", got, want) + } + if got, want := sessions.requestStopCalls[0].cause, session.CauseUserRequested; got != want { + t.Fatalf("requestStopCalls[0].cause = %v, want %v", got, want) + } + if got, want := sessions.requestStopCalls[0].detail, "task cancellation"; got != want { + t.Fatalf("requestStopCalls[0].detail = %q, want %q", got, want) + } + if got, want := len(sessions.stopWithCauseCalls), 1; got != want { + t.Fatalf("len(stopWithCauseCalls) = %d, want %d", got, want) + } +} + +func TestPlanTaskRunRecoveryClassifiesClaimedStartingRunning(t *testing.T) { + t.Parallel() + + sessions := &fakeSessionManager{ + infos: []*session.SessionInfo{ + {ID: "sess-active", State: session.StateActive}, + {ID: "sess-stopping", State: session.StateStopping}, + {ID: "sess-stopped", State: session.StateStopped}, + }, + } + + testCases := []struct { + name string + run taskpkg.TaskRun + wantAction taskpkg.RunBootRecoveryAction + wantState string + wantNil bool + }{ + { + name: "Should requeue claimed runs without a bound session", + run: taskpkg.TaskRun{ + ID: "run-claimed", + TaskID: "task-1", + Status: taskpkg.TaskRunStatusClaimed, + Attempt: 1, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindCLI, Ref: "agh task run"}, + QueuedAt: time.Now().UTC(), + }, + wantAction: taskpkg.RunBootRecoveryRequeue, + wantState: taskRecoverySessionMissing, + }, + { + name: "Should resume starting runs when the bound session is active", + run: taskpkg.TaskRun{ + ID: "run-starting", + TaskID: "task-2", + Status: taskpkg.TaskRunStatusStarting, + Attempt: 1, + SessionID: "sess-active", + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindCLI, Ref: "agh task run"}, + QueuedAt: time.Now().UTC(), + }, + wantAction: taskpkg.RunBootRecoveryMarkRunning, + wantState: string(session.StateActive), + }, + { + name: "Should keep running runs live while the bound session is stopping", + run: taskpkg.TaskRun{ + ID: "run-running", + TaskID: "task-3", + Status: taskpkg.TaskRunStatusRunning, + Attempt: 1, + SessionID: "sess-stopping", + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindCLI, Ref: "agh task run"}, + QueuedAt: time.Now().UTC(), + }, + wantNil: true, + }, + { + name: "Should fail starting runs when the bound session is stopped", + run: taskpkg.TaskRun{ + ID: "run-orphaned-starting", + TaskID: "task-4", + Status: taskpkg.TaskRunStatusStarting, + Attempt: 1, + SessionID: "sess-stopped", + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindCLI, Ref: "agh task run"}, + QueuedAt: time.Now().UTC(), + }, + wantAction: taskpkg.RunBootRecoveryFail, + wantState: string(session.StateStopped), + }, + { + name: "Should fail running runs when the bound session is missing", + run: taskpkg.TaskRun{ + ID: "run-orphaned-running", + TaskID: "task-5", + Status: taskpkg.TaskRunStatusRunning, + Attempt: 1, + SessionID: "sess-missing", + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindCLI, Ref: "agh task run"}, + QueuedAt: time.Now().UTC(), + }, + wantAction: taskpkg.RunBootRecoveryFail, + wantState: taskRecoverySessionMissing, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + recovery, err := planTaskRunRecovery(context.Background(), sessions, tc.run) + if err != nil { + t.Fatalf("planTaskRunRecovery() error = %v", err) + } + if tc.wantNil { + if recovery != nil { + t.Fatalf("planTaskRunRecovery() = %#v, want nil", recovery) + } + return + } + if recovery == nil { + t.Fatal("planTaskRunRecovery() = nil, want recovery action") + } + if got, want := recovery.Action, tc.wantAction; got != want { + t.Fatalf("recovery.Action = %q, want %q", got, want) + } + if got, want := recovery.SessionState, tc.wantState; got != want { + t.Fatalf("recovery.SessionState = %q, want %q", got, want) + } + }) + } +} + +func TestTaskSessionBridgeGuardsAndFallbackStopPaths(t *testing.T) { + t.Parallel() + + if _, err := newTaskSessionBridge(nil, t.TempDir(), discardLogger()); err == nil { + t.Fatal("newTaskSessionBridge(nil) error = nil, want validation error") + } + + bridge, err := newTaskSessionBridge(&fakeSessionManager{}, "", discardLogger()) + if err != nil { + t.Fatalf("newTaskSessionBridge() error = %v", err) + } + + if _, err := bridge.StartTaskSession(nilTaskRuntimeContext(), taskpkg.StartTaskSession{}); err == nil { + t.Fatal("StartTaskSession(nil ctx) error = nil, want validation error") + } + if _, err := bridge.StartTaskSession(context.Background(), taskpkg.StartTaskSession{ + Task: taskpkg.Task{ + ID: "task-global", + Scope: taskpkg.ScopeGlobal, + }, + Run: taskpkg.TaskRun{ + ID: "run-global", + Attempt: 1, + }, + }); err == nil { + t.Fatal("StartTaskSession(global without workspace path) error = nil, want validation error") + } + if _, err := bridge.StartTaskSession(context.Background(), taskpkg.StartTaskSession{ + Task: taskpkg.Task{ + ID: "task-invalid", + Scope: taskpkg.Scope("invalid"), + }, + Run: taskpkg.TaskRun{ + ID: "run-invalid", + Attempt: 1, + }, + }); !errors.Is(err, taskpkg.ErrValidation) { + t.Fatalf("StartTaskSession(invalid scope) error = %v, want %v", err, taskpkg.ErrValidation) + } + if _, err := bridge.AttachTaskSession(nilTaskRuntimeContext(), "run-1", "sess-1"); err == nil { + t.Fatal("AttachTaskSession(nil ctx) error = nil, want validation error") + } + if err := bridge.RequestTaskStop(nilTaskRuntimeContext(), "sess-1", taskpkg.StopReasonCancellation); err == nil { + t.Fatal("RequestTaskStop(nil ctx) error = nil, want validation error") + } + if err := bridge.ForceTaskStop(context.Background(), " ", taskpkg.StopReasonCancellation); !errors.Is(err, taskpkg.ErrValidation) { + t.Fatalf("ForceTaskStop(blank id) error = %v, want %v", err, taskpkg.ErrValidation) + } + + sessions := &fakeSessionManager{ + requestStopErr: func(string, session.StopCause, string) error { + return session.ErrSessionNotFound + }, + stopWithCauseErr: func(string, session.StopCause, string) error { + return session.ErrSessionNotFound + }, + } + bridge, err = newTaskSessionBridge(sessions, t.TempDir(), discardLogger()) + if err != nil { + t.Fatalf("newTaskSessionBridge() error = %v", err) + } + if err := bridge.RequestTaskStop(context.Background(), "sess-missing", taskpkg.StopReasonShutdown); err != nil { + t.Fatalf("RequestTaskStop(missing) error = %v, want nil", err) + } + if err := bridge.ForceTaskStop(context.Background(), "sess-missing", taskpkg.StopReasonOrphanedRun); err != nil { + t.Fatalf("ForceTaskStop(missing) error = %v, want nil", err) + } + + stopOnlyBridge, err := newTaskSessionBridge(&taskBridgeStopOnlySessionManager{}, t.TempDir(), discardLogger()) + if err != nil { + t.Fatalf("newTaskSessionBridge(stop-only) error = %v", err) + } + if err := stopOnlyBridge.RequestTaskStop(context.Background(), "sess-fallback", taskpkg.StopReasonShutdown); err != nil { + t.Fatalf("RequestTaskStop(fallback) error = %v", err) + } + + stopOnly := stopOnlyBridge.sessions.(*taskBridgeStopOnlySessionManager) + if got, want := len(stopOnly.stopCalls), 1; got != want { + t.Fatalf("len(stopCalls) = %d, want %d", got, want) + } + if got, want := stopOnly.stopCalls[0].cause, session.CauseShutdown; got != want { + t.Fatalf("stopCalls[0].cause = %v, want %v", got, want) + } + if got, want := stopOnly.stopCalls[0].detail, "task shutdown"; got != want { + t.Fatalf("stopCalls[0].detail = %q, want %q", got, want) + } +} + +func TestTaskRuntimeHelpers(t *testing.T) { + t.Parallel() + + if got, want := taskSessionName(taskpkg.StartTaskSession{ + Task: taskpkg.Task{ + Identifier: "build-index", + }, + Run: taskpkg.TaskRun{ + ID: "run-identifier", + Attempt: 3, + }, + }), "task:build-index#3"; got != want { + t.Fatalf("taskSessionName(identifier) = %q, want %q", got, want) + } + if got, want := taskSessionName(taskpkg.StartTaskSession{ + Run: taskpkg.TaskRun{ + ID: "run-fallback", + Attempt: 4, + }, + }), "task:run-fallback#4"; got != want { + t.Fatalf("taskSessionName(run fallback) = %q, want %q", got, want) + } + + if got, want := taskStopCause(taskpkg.StopReasonShutdown), session.CauseShutdown; got != want { + t.Fatalf("taskStopCause(shutdown) = %v, want %v", got, want) + } + if got, want := taskStopCause(taskpkg.StopReasonOrphanedRun), session.CauseFailed; got != want { + t.Fatalf("taskStopCause(orphaned) = %v, want %v", got, want) + } + if got, want := taskStopCause(taskpkg.StopReasonCancellation), session.CauseUserRequested; got != want { + t.Fatalf("taskStopCause(cancellation) = %v, want %v", got, want) + } + if got, want := taskStopDetail(taskpkg.StopReasonShutdown), "task shutdown"; got != want { + t.Fatalf("taskStopDetail(shutdown) = %q, want %q", got, want) + } + if got, want := taskStopDetail(taskpkg.StopReasonOrphanedRun), "task run orphaned"; got != want { + t.Fatalf("taskStopDetail(orphaned) = %q, want %q", got, want) + } + if got, want := taskStopDetail(taskpkg.StopReasonCancellation), "task cancellation"; got != want { + t.Fatalf("taskStopDetail(cancellation) = %q, want %q", got, want) + } + + live, state, err := taskSessionRuntimeState(context.Background(), &taskBridgeStopOnlySessionManager{}, "") + if err != nil { + t.Fatalf("taskSessionRuntimeState(blank id) error = %v", err) + } + if live { + t.Fatal("taskSessionRuntimeState(blank id) live = true, want false") + } + if got, want := state, taskRecoverySessionMissing; got != want { + t.Fatalf("taskSessionRuntimeState(blank id) state = %q, want %q", got, want) + } + + if _, err := planTaskRunRecovery(context.Background(), nil, taskpkg.TaskRun{ + ID: "run-1", + Status: taskpkg.TaskRunStatusClaimed, + }); err == nil { + t.Fatal("planTaskRunRecovery(nil sessions) error = nil, want validation error") + } +} + +type taskBridgeStopOnlySessionManager struct { + stopCalls []fakeStopWithCauseCall +} + +func (m *taskBridgeStopOnlySessionManager) Create(context.Context, session.CreateOpts) (*session.Session, error) { + return nil, nil +} + +func (m *taskBridgeStopOnlySessionManager) Status(context.Context, string) (*session.SessionInfo, error) { + return nil, session.ErrSessionNotFound +} + +func (m *taskBridgeStopOnlySessionManager) StopWithCause(_ context.Context, id string, cause session.StopCause, detail string) error { + m.stopCalls = append(m.stopCalls, fakeStopWithCauseCall{id: id, cause: cause, detail: detail}) + return nil +} + +func nilTaskRuntimeContext() context.Context { + return nil +} diff --git a/internal/extension/capability.go b/internal/extension/capability.go index bbd20d7e7..43281e25b 100644 --- a/internal/extension/capability.go +++ b/internal/extension/capability.go @@ -31,6 +31,19 @@ var ( "automation/triggers/runs": "automation.read", "automation/triggers/fire": "automation.write", "automation/runs": "automation.read", + "tasks": "task.read", + "tasks/get": "task.read", + "tasks/create": "task.write", + "tasks/update": "task.write", + "tasks/cancel": "task.write", + "tasks/runs": "task.read", + "tasks/runs/enqueue": "task.write", + "tasks/runs/claim": "task.write", + "tasks/runs/start": "task.write", + "tasks/runs/attach_session": "task.write", + "tasks/runs/complete": "task.write", + "tasks/runs/fail": "task.write", + "tasks/runs/cancel": "task.write", "bridges/instances/get": "bridge.read", "bridges/instances/report_state": "bridge.write", "bridges/messages/ingest": "bridge.write", diff --git a/internal/extension/contract/host_api.go b/internal/extension/contract/host_api.go index accc023c7..53fa46d85 100644 --- a/internal/extension/contract/host_api.go +++ b/internal/extension/contract/host_api.go @@ -44,6 +44,19 @@ const ( HostAPIMethodAutomationTriggersRuns = extensionprotocol.HostAPIMethodAutomationTriggersRuns HostAPIMethodAutomationTriggersFire = extensionprotocol.HostAPIMethodAutomationTriggersFire HostAPIMethodAutomationRuns = extensionprotocol.HostAPIMethodAutomationRuns + HostAPIMethodTasks = extensionprotocol.HostAPIMethodTasks + HostAPIMethodTasksGet = extensionprotocol.HostAPIMethodTasksGet + HostAPIMethodTasksCreate = extensionprotocol.HostAPIMethodTasksCreate + HostAPIMethodTasksUpdate = extensionprotocol.HostAPIMethodTasksUpdate + HostAPIMethodTasksCancel = extensionprotocol.HostAPIMethodTasksCancel + HostAPIMethodTasksRuns = extensionprotocol.HostAPIMethodTasksRuns + HostAPIMethodTasksRunsEnqueue = extensionprotocol.HostAPIMethodTasksRunsEnqueue + HostAPIMethodTasksRunsClaim = extensionprotocol.HostAPIMethodTasksRunsClaim + HostAPIMethodTasksRunsStart = extensionprotocol.HostAPIMethodTasksRunsStart + HostAPIMethodTasksRunsAttachSession = extensionprotocol.HostAPIMethodTasksRunsAttachSession + HostAPIMethodTasksRunsComplete = extensionprotocol.HostAPIMethodTasksRunsComplete + HostAPIMethodTasksRunsFail = extensionprotocol.HostAPIMethodTasksRunsFail + HostAPIMethodTasksRunsCancel = extensionprotocol.HostAPIMethodTasksRunsCancel HostAPIMethodBridgesMessagesIngest = extensionprotocol.HostAPIMethodBridgesMessagesIngest HostAPIMethodBridgesInstancesGet = extensionprotocol.HostAPIMethodBridgesInstancesGet HostAPIMethodBridgesInstancesReportState = extensionprotocol.HostAPIMethodBridgesInstancesReportState @@ -212,6 +225,77 @@ type AutomationTriggerFireParams struct { Payload map[string]any `json:"payload,omitempty"` } +// TasksParams filters visible tasks. +type TasksParams = apicontract.TaskListQuery + +// TaskTargetParams identifies one task by id. +type TaskTargetParams struct { + ID string `json:"id"` +} + +// TaskCreateParams creates one task. +type TaskCreateParams = apicontract.CreateTaskRequest + +// TaskUpdateParams patches one task. +type TaskUpdateParams struct { + ID string `json:"id"` + apicontract.UpdateTaskRequest +} + +// TaskCancelParams requests cancellation for one task. +type TaskCancelParams struct { + ID string `json:"id"` + apicontract.CancelTaskRequest +} + +// TaskRunsParams filters runs for one task. +type TaskRunsParams struct { + ID string `json:"id"` + apicontract.TaskRunListQuery +} + +// TaskRunEnqueueParams enqueues one run for a task. +type TaskRunEnqueueParams struct { + TaskID string `json:"task_id"` + apicontract.EnqueueTaskRunRequest +} + +// TaskRunClaimParams claims one queued run. +type TaskRunClaimParams struct { + ID string `json:"id"` + apicontract.ClaimTaskRunRequest +} + +// TaskRunStartParams starts one claimed run. +type TaskRunStartParams struct { + ID string `json:"id"` + apicontract.StartTaskRunRequest +} + +// TaskRunAttachSessionParams attaches one existing session to a run. +type TaskRunAttachSessionParams struct { + ID string `json:"id"` + apicontract.AttachTaskRunSessionRequest +} + +// TaskRunCompleteParams completes one run. +type TaskRunCompleteParams struct { + ID string `json:"id"` + apicontract.CompleteTaskRunRequest +} + +// TaskRunFailParams fails one run. +type TaskRunFailParams struct { + ID string `json:"id"` + apicontract.FailTaskRunRequest +} + +// TaskRunCancelParams cancels one run. +type TaskRunCancelParams struct { + ID string `json:"id"` + apicontract.CancelTaskRunRequest +} + // BridgesMessagesIngestParams carries one normalized inbound bridge message. type BridgesMessagesIngestParams = bridgepkg.InboundMessageEnvelope @@ -431,6 +515,72 @@ func HostAPIMethodSpecs() []HostAPIMethodSpec { Result: NamedType{Name: "Run", Value: []automationpkg.Run{}}, OptionalParams: true, }, + { + Method: HostAPIMethodTasks, + Params: NamedType{Name: "TasksParams", Value: TasksParams{}}, + Result: NamedType{Name: "TaskSummary", Value: []apicontract.TaskSummaryPayload{}}, + OptionalParams: true, + }, + { + Method: HostAPIMethodTasksGet, + Params: NamedType{Name: "TaskTargetParams", Value: TaskTargetParams{}}, + Result: NamedType{Name: "TaskDetail", Value: apicontract.TaskDetailPayload{}}, + }, + { + Method: HostAPIMethodTasksCreate, + Params: NamedType{Name: "TaskCreateParams", Value: TaskCreateParams{}}, + Result: NamedType{Name: "Task", Value: apicontract.TaskPayload{}}, + }, + { + Method: HostAPIMethodTasksUpdate, + Params: NamedType{Name: "TaskUpdateParams", Value: TaskUpdateParams{}}, + Result: NamedType{Name: "Task", Value: apicontract.TaskPayload{}}, + }, + { + Method: HostAPIMethodTasksCancel, + Params: NamedType{Name: "TaskCancelParams", Value: TaskCancelParams{}}, + Result: NamedType{Name: "Task", Value: apicontract.TaskPayload{}}, + }, + { + Method: HostAPIMethodTasksRuns, + Params: NamedType{Name: "TaskRunsParams", Value: TaskRunsParams{}}, + Result: NamedType{Name: "TaskRun", Value: []apicontract.TaskRunPayload{}}, + }, + { + Method: HostAPIMethodTasksRunsEnqueue, + Params: NamedType{Name: "TaskRunEnqueueParams", Value: TaskRunEnqueueParams{}}, + Result: NamedType{Name: "TaskRun", Value: apicontract.TaskRunPayload{}}, + }, + { + Method: HostAPIMethodTasksRunsClaim, + Params: NamedType{Name: "TaskRunClaimParams", Value: TaskRunClaimParams{}}, + Result: NamedType{Name: "TaskRun", Value: apicontract.TaskRunPayload{}}, + }, + { + Method: HostAPIMethodTasksRunsStart, + Params: NamedType{Name: "TaskRunStartParams", Value: TaskRunStartParams{}}, + Result: NamedType{Name: "TaskRun", Value: apicontract.TaskRunPayload{}}, + }, + { + Method: HostAPIMethodTasksRunsAttachSession, + Params: NamedType{Name: "TaskRunAttachSessionParams", Value: TaskRunAttachSessionParams{}}, + Result: NamedType{Name: "TaskRun", Value: apicontract.TaskRunPayload{}}, + }, + { + Method: HostAPIMethodTasksRunsComplete, + Params: NamedType{Name: "TaskRunCompleteParams", Value: TaskRunCompleteParams{}}, + Result: NamedType{Name: "TaskRun", Value: apicontract.TaskRunPayload{}}, + }, + { + Method: HostAPIMethodTasksRunsFail, + Params: NamedType{Name: "TaskRunFailParams", Value: TaskRunFailParams{}}, + Result: NamedType{Name: "TaskRun", Value: apicontract.TaskRunPayload{}}, + }, + { + Method: HostAPIMethodTasksRunsCancel, + Params: NamedType{Name: "TaskRunCancelParams", Value: TaskRunCancelParams{}}, + Result: NamedType{Name: "TaskRun", Value: apicontract.TaskRunPayload{}}, + }, { Method: HostAPIMethodBridgesMessagesIngest, Params: NamedType{Name: "InboundMessageEnvelope", Value: bridgepkg.InboundMessageEnvelope{}}, diff --git a/internal/extension/host_api.go b/internal/extension/host_api.go index d84accc00..374179194 100644 --- a/internal/extension/host_api.go +++ b/internal/extension/host_api.go @@ -24,6 +24,7 @@ import ( skillspkg "github.com/pedronauck/agh/internal/skills" "github.com/pedronauck/agh/internal/store" "github.com/pedronauck/agh/internal/subprocess" + taskpkg "github.com/pedronauck/agh/internal/task" "github.com/pedronauck/agh/internal/transcript" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) @@ -62,6 +63,7 @@ type HostAPIOption func(*HostAPIHandler) type HostAPIHandler struct { sessions hostAPISessionManager automation HostAPIAutomationManager + tasks hostAPITaskManager memory *memory.Store observer hostAPIObserver skills hostAPISkillsRegistry @@ -120,6 +122,22 @@ type HostAPIAutomationManager interface { FireExtensionTrigger(ctx context.Context, request automationpkg.ExtensionTriggerRequest) (automationpkg.TriggerResult, error) } +type hostAPITaskManager interface { + ListTasks(ctx context.Context, query taskpkg.TaskQuery, actor taskpkg.ActorContext) ([]taskpkg.TaskSummary, error) + GetTask(ctx context.Context, id string, actor taskpkg.ActorContext) (*taskpkg.TaskView, error) + ListTaskRuns(ctx context.Context, taskID string, query taskpkg.TaskRunQuery, actor taskpkg.ActorContext) ([]taskpkg.TaskRun, error) + CreateTask(ctx context.Context, spec taskpkg.CreateTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) + UpdateTask(ctx context.Context, id string, patch taskpkg.TaskPatch, actor taskpkg.ActorContext) (*taskpkg.Task, error) + CancelTask(ctx context.Context, id string, req taskpkg.CancelTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) + EnqueueRun(ctx context.Context, spec taskpkg.EnqueueRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) + ClaimRun(ctx context.Context, runID string, claim taskpkg.ClaimRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) + StartRun(ctx context.Context, runID string, req taskpkg.StartRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) + AttachRunSession(ctx context.Context, runID string, sessionID string, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) + CompleteRun(ctx context.Context, runID string, result taskpkg.RunResult, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) + FailRun(ctx context.Context, runID string, failure taskpkg.RunFailure, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) + CancelRun(ctx context.Context, runID string, req taskpkg.CancelRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) +} + type hostAPIDeliveryBroker interface { RegisterPromptDelivery(ctx context.Context, reg bridgepkg.PromptDeliveryRegistration) (*bridgepkg.DeliverySnapshot, error) ProjectEvent(ctx context.Context, sessionID string, event bridgepkg.DeliveryProjectionEvent) error @@ -144,6 +162,13 @@ func WithHostAPIAutomationManager(manager HostAPIAutomationManager) HostAPIOptio } } +// WithHostAPITaskManager injects the task manager used for task Host API methods. +func WithHostAPITaskManager(manager hostAPITaskManager) HostAPIOption { + return func(handler *HostAPIHandler) { + handler.tasks = manager + } +} + // WithHostAPIAutomationGetter injects a lazy automation lookup used when the runtime boots after extensions. func WithHostAPIAutomationGetter(getter func() HostAPIAutomationManager) HostAPIOption { return func(handler *HostAPIHandler) { @@ -267,6 +292,19 @@ func NewHostAPIHandler( "automation/triggers/runs": handler.handleAutomationTriggersRuns, "automation/triggers/fire": handler.handleAutomationTriggersFire, "automation/runs": handler.handleAutomationRuns, + "tasks": handler.handleTasks, + "tasks/get": handler.handleTasksGet, + "tasks/create": handler.handleTasksCreate, + "tasks/update": handler.handleTasksUpdate, + "tasks/cancel": handler.handleTasksCancel, + "tasks/runs": handler.handleTasksRuns, + "tasks/runs/enqueue": handler.handleTasksRunsEnqueue, + "tasks/runs/claim": handler.handleTasksRunsClaim, + "tasks/runs/start": handler.handleTasksRunsStart, + "tasks/runs/attach_session": handler.handleTasksRunsAttachSession, + "tasks/runs/complete": handler.handleTasksRunsComplete, + "tasks/runs/fail": handler.handleTasksRunsFail, + "tasks/runs/cancel": handler.handleTasksRunsCancel, "bridges/instances/get": handler.handleBridgesInstancesGet, "bridges/instances/report_state": handler.handleBridgesInstancesReportState, "bridges/messages/ingest": handler.handleBridgesMessagesIngest, @@ -394,6 +432,32 @@ type hostAPIAutomationTriggerRunsParams = extensioncontract.AutomationTriggerRun type hostAPIAutomationTriggerFireParams = extensioncontract.AutomationTriggerFireParams +type hostAPITasksParams = extensioncontract.TasksParams + +type hostAPITaskTargetParams = extensioncontract.TaskTargetParams + +type hostAPITaskCreateParams = extensioncontract.TaskCreateParams + +type hostAPITaskUpdateParams = extensioncontract.TaskUpdateParams + +type hostAPITaskCancelParams = extensioncontract.TaskCancelParams + +type hostAPITaskRunsParams = extensioncontract.TaskRunsParams + +type hostAPITaskRunEnqueueParams = extensioncontract.TaskRunEnqueueParams + +type hostAPITaskRunClaimParams = extensioncontract.TaskRunClaimParams + +type hostAPITaskRunStartParams = extensioncontract.TaskRunStartParams + +type hostAPITaskRunAttachSessionParams = extensioncontract.TaskRunAttachSessionParams + +type hostAPITaskRunCompleteParams = extensioncontract.TaskRunCompleteParams + +type hostAPITaskRunFailParams = extensioncontract.TaskRunFailParams + +type hostAPITaskRunCancelParams = extensioncontract.TaskRunCancelParams + type hostAPIBridgesMessagesIngestParams = extensioncontract.BridgesMessagesIngestParams type hostAPIBridgesMessagesIngestResult = extensioncontract.BridgesMessagesIngestResult diff --git a/internal/extension/host_api_integration_test.go b/internal/extension/host_api_integration_test.go index 4f8062e3b..02f406467 100644 --- a/internal/extension/host_api_integration_test.go +++ b/internal/extension/host_api_integration_test.go @@ -10,9 +10,12 @@ import ( "time" "github.com/pedronauck/agh/internal/acp" + apicontract "github.com/pedronauck/agh/internal/api/contract" automationpkg "github.com/pedronauck/agh/internal/automation" bridgepkg "github.com/pedronauck/agh/internal/bridges" hookspkg "github.com/pedronauck/agh/internal/hooks" + "github.com/pedronauck/agh/internal/session" + taskpkg "github.com/pedronauck/agh/internal/task" "github.com/pedronauck/agh/internal/testutil" ) @@ -113,6 +116,160 @@ func TestHostAPIIntegrationStoresAndRecallsMemory(t *testing.T) { } } +func TestHostAPIIntegrationExtensionCanCreateTaskAndEnqueueRun(t *testing.T) { + env := newHostAPITestEnv(t) + env.grant( + "ext-tasks", + []string{"tasks/create", "tasks/get", "tasks/runs/enqueue"}, + []string{"task.write", "task.read"}, + ) + + createResult, err := env.call(t, "ext-tasks", "tasks/create", map[string]any{ + "scope": taskpkg.ScopeWorkspace, + "workspace": env.workspaceID, + "title": "Extension-created task", + }) + if err != nil { + t.Fatalf("Handle(tasks/create) error = %v", err) + } + + var created apicontract.TaskPayload + decodeResult(t, createResult, &created) + if created.ID == "" { + t.Fatal("tasks/create id = empty, want non-empty") + } + + enqueueResult, err := env.call(t, "ext-tasks", "tasks/runs/enqueue", map[string]any{ + "task_id": created.ID, + "idempotency_key": "enqueue-int", + }) + if err != nil { + t.Fatalf("Handle(tasks/runs/enqueue) error = %v", err) + } + + var run apicontract.TaskRunPayload + decodeResult(t, enqueueResult, &run) + if run.ID == "" { + t.Fatal("tasks/runs/enqueue id = empty, want non-empty") + } + if got, want := run.Status, taskpkg.TaskRunStatusQueued; got != want { + t.Fatalf("tasks/runs/enqueue status = %q, want %q", got, want) + } + + storedTask, err := env.registry.GetTask(testutil.Context(t), created.ID) + if err != nil { + t.Fatalf("registry.GetTask(%q) error = %v", created.ID, err) + } + if got, want := storedTask.CreatedBy.Kind, taskpkg.ActorKindExtension; got != want { + t.Fatalf("storedTask.CreatedBy.Kind = %q, want %q", got, want) + } + if got, want := storedTask.CreatedBy.Ref, "ext-tasks"; got != want { + t.Fatalf("storedTask.CreatedBy.Ref = %q, want %q", got, want) + } + + storedRun, err := env.registry.GetTaskRun(testutil.Context(t), run.ID) + if err != nil { + t.Fatalf("registry.GetTaskRun(%q) error = %v", run.ID, err) + } + if got, want := storedRun.Origin.Kind, taskpkg.OriginKindExtension; got != want { + t.Fatalf("storedRun.Origin.Kind = %q, want %q", got, want) + } + if got, want := storedRun.Origin.Ref, "ext-tasks"; got != want { + t.Fatalf("storedRun.Origin.Ref = %q, want %q", got, want) + } + + getResult, err := env.call(t, "ext-tasks", "tasks/get", map[string]any{"id": created.ID}) + if err != nil { + t.Fatalf("Handle(tasks/get) error = %v", err) + } + + var detail apicontract.TaskDetailPayload + decodeResult(t, getResult, &detail) + if got, want := len(detail.Runs), 1; got != want { + t.Fatalf("len(tasks/get.runs) = %d, want %d", got, want) + } + if got, want := detail.Runs[0].ID, run.ID; got != want { + t.Fatalf("tasks/get.runs[0].ID = %q, want %q", got, want) + } +} + +func TestHostAPIIntegrationStartRunAllocatesDedicatedSession(t *testing.T) { + env := newHostAPITestEnv(t) + env.grant( + "ext-tasks", + []string{"tasks/create", "tasks/runs/enqueue", "tasks/runs/claim", "tasks/runs/start"}, + []string{"task.write"}, + ) + + createResult, err := env.call(t, "ext-tasks", "tasks/create", map[string]any{ + "scope": taskpkg.ScopeWorkspace, + "workspace": env.workspaceID, + "title": "Executable extension task", + }) + if err != nil { + t.Fatalf("Handle(tasks/create) error = %v", err) + } + + var created apicontract.TaskPayload + decodeResult(t, createResult, &created) + + enqueueResult, err := env.call(t, "ext-tasks", "tasks/runs/enqueue", map[string]any{ + "task_id": created.ID, + "idempotency_key": "enqueue-start-int", + }) + if err != nil { + t.Fatalf("Handle(tasks/runs/enqueue) error = %v", err) + } + + var queued apicontract.TaskRunPayload + decodeResult(t, enqueueResult, &queued) + + claimResult, err := env.call(t, "ext-tasks", "tasks/runs/claim", map[string]any{ + "id": queued.ID, + "idempotency_key": "claim-start-int", + }) + if err != nil { + t.Fatalf("Handle(tasks/runs/claim) error = %v", err) + } + + var claimed apicontract.TaskRunPayload + decodeResult(t, claimResult, &claimed) + if got, want := claimed.Status, taskpkg.TaskRunStatusClaimed; got != want { + t.Fatalf("tasks/runs/claim status = %q, want %q", got, want) + } + + startResult, err := env.call(t, "ext-tasks", "tasks/runs/start", map[string]any{ + "id": queued.ID, + "idempotency_key": "start-start-int", + }) + if err != nil { + t.Fatalf("Handle(tasks/runs/start) error = %v", err) + } + + var started apicontract.TaskRunPayload + decodeResult(t, startResult, &started) + if got, want := started.Status, taskpkg.TaskRunStatusRunning; got != want { + t.Fatalf("tasks/runs/start status = %q, want %q", got, want) + } + if started.SessionID == "" { + t.Fatal("tasks/runs/start session_id = empty, want non-empty") + } + + info, err := env.sessions.Status(testutil.Context(t), started.SessionID) + if err != nil { + t.Fatalf("sessions.Status(%q) error = %v", started.SessionID, err) + } + if got, want := info.WorkspaceID, env.workspaceID; got != want { + t.Fatalf("session.WorkspaceID = %q, want %q", got, want) + } + if got, want := info.Type, session.SessionTypeSystem; got != want { + t.Fatalf("session.Type = %q, want %q", got, want) + } + if got, want := info.State, session.StateActive; got != want { + t.Fatalf("session.State = %q, want %q", got, want) + } +} + func TestHostAPIIntegrationBridgesMessagesIngestCreatesRouteAndSession(t *testing.T) { env := newHostAPITestEnv(t) env.grant("telegram-adapter", []string{"bridges/messages/ingest"}, []string{"bridge.write"}) diff --git a/internal/extension/host_api_tasks.go b/internal/extension/host_api_tasks.go new file mode 100644 index 000000000..d9ddf625d --- /dev/null +++ b/internal/extension/host_api_tasks.go @@ -0,0 +1,874 @@ +package extension + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + apicontract "github.com/pedronauck/agh/internal/api/contract" + "github.com/pedronauck/agh/internal/network" + taskpkg "github.com/pedronauck/agh/internal/task" + workspacepkg "github.com/pedronauck/agh/internal/workspace" +) + +func (h *HostAPIHandler) handleTasks(ctx context.Context, raw json.RawMessage) (any, error) { + manager, err := h.taskManager() + if err != nil { + return nil, err + } + + var params hostAPITasksParams + if err := decodeHostAPIParams(raw, ¶ms); err != nil { + return nil, err + } + + actor, err := h.taskActorContext(ctx) + if err != nil { + return nil, err + } + query, err := h.taskQueryFromParams(ctx, params) + if err != nil { + return nil, err + } + + tasks, err := manager.ListTasks(ctx, query, actor) + if err != nil { + return nil, mapTaskRPCError("task", "", err) + } + return taskSummaryPayloadsFromSummaries(tasks), nil +} + +func (h *HostAPIHandler) handleTasksGet(ctx context.Context, raw json.RawMessage) (any, error) { + manager, err := h.taskManager() + if err != nil { + return nil, err + } + + var params hostAPITaskTargetParams + if err := decodeHostAPIParams(raw, ¶ms); err != nil { + return nil, err + } + taskID := strings.TrimSpace(params.ID) + if taskID == "" { + return nil, invalidParamsRPCError(errors.New("id is required")) + } + + actor, err := h.taskActorContext(ctx) + if err != nil { + return nil, err + } + + view, err := manager.GetTask(ctx, taskID, actor) + if err != nil { + return nil, mapTaskRPCError("task", taskID, err) + } + return taskDetailPayloadFromView(view), nil +} + +func (h *HostAPIHandler) handleTasksCreate(ctx context.Context, raw json.RawMessage) (any, error) { + manager, err := h.taskManager() + if err != nil { + return nil, err + } + + var params hostAPITaskCreateParams + if err := decodeHostAPIParams(raw, ¶ms); err != nil { + return nil, err + } + + actor, err := h.taskActorContext(ctx) + if err != nil { + return nil, err + } + spec, err := h.createTaskSpecFromRequest(ctx, params) + if err != nil { + return nil, err + } + + record, err := manager.CreateTask(ctx, spec, actor) + if err != nil { + return nil, mapTaskRPCError("task", spec.ID, err) + } + return taskPayloadFromTask(record), nil +} + +func (h *HostAPIHandler) handleTasksUpdate(ctx context.Context, raw json.RawMessage) (any, error) { + manager, err := h.taskManager() + if err != nil { + return nil, err + } + + var params hostAPITaskUpdateParams + if err := decodeHostAPIParams(raw, ¶ms); err != nil { + return nil, err + } + taskID := strings.TrimSpace(params.ID) + if taskID == "" { + return nil, invalidParamsRPCError(errors.New("id is required")) + } + if !params.HasChanges() { + return nil, invalidParamsRPCError(errors.New("task update must include at least one mutable field")) + } + + actor, err := h.taskActorContext(ctx) + if err != nil { + return nil, err + } + patch, err := taskPatchFromRequest(params.UpdateTaskRequest) + if err != nil { + return nil, err + } + + record, err := manager.UpdateTask(ctx, taskID, patch, actor) + if err != nil { + return nil, mapTaskRPCError("task", taskID, err) + } + return taskPayloadFromTask(record), nil +} + +func (h *HostAPIHandler) handleTasksCancel(ctx context.Context, raw json.RawMessage) (any, error) { + manager, err := h.taskManager() + if err != nil { + return nil, err + } + + var params hostAPITaskCancelParams + if err := decodeHostAPIParams(raw, ¶ms); err != nil { + return nil, err + } + taskID := strings.TrimSpace(params.ID) + if taskID == "" { + return nil, invalidParamsRPCError(errors.New("id is required")) + } + + actor, err := h.taskActorContext(ctx) + if err != nil { + return nil, err + } + cancelReq, err := cancelTaskFromRequest(params.CancelTaskRequest) + if err != nil { + return nil, err + } + + record, err := manager.CancelTask(ctx, taskID, cancelReq, actor) + if err != nil { + return nil, mapTaskRPCError("task", taskID, err) + } + return taskPayloadFromTask(record), nil +} + +func (h *HostAPIHandler) handleTasksRuns(ctx context.Context, raw json.RawMessage) (any, error) { + manager, err := h.taskManager() + if err != nil { + return nil, err + } + + var params hostAPITaskRunsParams + if err := decodeHostAPIParams(raw, ¶ms); err != nil { + return nil, err + } + taskID := strings.TrimSpace(params.ID) + if taskID == "" { + return nil, invalidParamsRPCError(errors.New("id is required")) + } + + actor, err := h.taskActorContext(ctx) + if err != nil { + return nil, err + } + query, err := taskRunQueryFromParams(params.TaskRunListQuery) + if err != nil { + return nil, err + } + + runs, err := manager.ListTaskRuns(ctx, taskID, query, actor) + if err != nil { + return nil, mapTaskRPCError("task", taskID, err) + } + return taskRunPayloadsFromRuns(runs), nil +} + +func (h *HostAPIHandler) handleTasksRunsEnqueue(ctx context.Context, raw json.RawMessage) (any, error) { + manager, err := h.taskManager() + if err != nil { + return nil, err + } + + var params hostAPITaskRunEnqueueParams + if err := decodeHostAPIParams(raw, ¶ms); err != nil { + return nil, err + } + taskID := strings.TrimSpace(params.TaskID) + if taskID == "" { + return nil, invalidParamsRPCError(errors.New("task_id is required")) + } + + actor, err := h.taskActorContext(ctx) + if err != nil { + return nil, err + } + spec, err := enqueueTaskRunFromRequest(taskID, params.EnqueueTaskRunRequest) + if err != nil { + return nil, err + } + + run, err := manager.EnqueueRun(ctx, spec, actor) + if err != nil { + return nil, mapTaskRPCError("task", taskID, err) + } + return taskRunPayloadFromRun(run), nil +} + +func (h *HostAPIHandler) handleTasksRunsClaim(ctx context.Context, raw json.RawMessage) (any, error) { + manager, err := h.taskManager() + if err != nil { + return nil, err + } + + var params hostAPITaskRunClaimParams + if err := decodeHostAPIParams(raw, ¶ms); err != nil { + return nil, err + } + runID := strings.TrimSpace(params.ID) + if runID == "" { + return nil, invalidParamsRPCError(errors.New("id is required")) + } + + actor, err := h.taskActorContext(ctx) + if err != nil { + return nil, err + } + claim, err := claimTaskRunFromRequest(params.ClaimTaskRunRequest) + if err != nil { + return nil, err + } + + run, err := manager.ClaimRun(ctx, runID, claim, actor) + if err != nil { + return nil, mapTaskRPCError("task_run", runID, err) + } + return taskRunPayloadFromRun(run), nil +} + +func (h *HostAPIHandler) handleTasksRunsStart(ctx context.Context, raw json.RawMessage) (any, error) { + manager, err := h.taskManager() + if err != nil { + return nil, err + } + + var params hostAPITaskRunStartParams + if err := decodeHostAPIParams(raw, ¶ms); err != nil { + return nil, err + } + runID := strings.TrimSpace(params.ID) + if runID == "" { + return nil, invalidParamsRPCError(errors.New("id is required")) + } + + actor, err := h.taskActorContext(ctx) + if err != nil { + return nil, err + } + startReq, err := startTaskRunFromRequest(params.StartTaskRunRequest) + if err != nil { + return nil, err + } + + run, err := manager.StartRun(ctx, runID, startReq, actor) + if err != nil { + return nil, mapTaskRPCError("task_run", runID, err) + } + return taskRunPayloadFromRun(run), nil +} + +func (h *HostAPIHandler) handleTasksRunsAttachSession(ctx context.Context, raw json.RawMessage) (any, error) { + manager, err := h.taskManager() + if err != nil { + return nil, err + } + + var params hostAPITaskRunAttachSessionParams + if err := decodeHostAPIParams(raw, ¶ms); err != nil { + return nil, err + } + runID := strings.TrimSpace(params.ID) + if runID == "" { + return nil, invalidParamsRPCError(errors.New("id is required")) + } + + actor, err := h.taskActorContext(ctx) + if err != nil { + return nil, err + } + sessionID, err := attachTaskRunSessionIDFromRequest(params.AttachTaskRunSessionRequest) + if err != nil { + return nil, err + } + + run, err := manager.AttachRunSession(ctx, runID, sessionID, actor) + if err != nil { + return nil, mapTaskRPCError("task_run", runID, err) + } + return taskRunPayloadFromRun(run), nil +} + +func (h *HostAPIHandler) handleTasksRunsComplete(ctx context.Context, raw json.RawMessage) (any, error) { + manager, err := h.taskManager() + if err != nil { + return nil, err + } + + var params hostAPITaskRunCompleteParams + if err := decodeHostAPIParams(raw, ¶ms); err != nil { + return nil, err + } + runID := strings.TrimSpace(params.ID) + if runID == "" { + return nil, invalidParamsRPCError(errors.New("id is required")) + } + + actor, err := h.taskActorContext(ctx) + if err != nil { + return nil, err + } + result, err := completeTaskRunFromRequest(params.CompleteTaskRunRequest) + if err != nil { + return nil, err + } + + run, err := manager.CompleteRun(ctx, runID, result, actor) + if err != nil { + return nil, mapTaskRPCError("task_run", runID, err) + } + return taskRunPayloadFromRun(run), nil +} + +func (h *HostAPIHandler) handleTasksRunsFail(ctx context.Context, raw json.RawMessage) (any, error) { + manager, err := h.taskManager() + if err != nil { + return nil, err + } + + var params hostAPITaskRunFailParams + if err := decodeHostAPIParams(raw, ¶ms); err != nil { + return nil, err + } + runID := strings.TrimSpace(params.ID) + if runID == "" { + return nil, invalidParamsRPCError(errors.New("id is required")) + } + + actor, err := h.taskActorContext(ctx) + if err != nil { + return nil, err + } + failure, err := failTaskRunFromRequest(params.FailTaskRunRequest) + if err != nil { + return nil, err + } + + run, err := manager.FailRun(ctx, runID, failure, actor) + if err != nil { + return nil, mapTaskRPCError("task_run", runID, err) + } + return taskRunPayloadFromRun(run), nil +} + +func (h *HostAPIHandler) handleTasksRunsCancel(ctx context.Context, raw json.RawMessage) (any, error) { + manager, err := h.taskManager() + if err != nil { + return nil, err + } + + var params hostAPITaskRunCancelParams + if err := decodeHostAPIParams(raw, ¶ms); err != nil { + return nil, err + } + runID := strings.TrimSpace(params.ID) + if runID == "" { + return nil, invalidParamsRPCError(errors.New("id is required")) + } + + actor, err := h.taskActorContext(ctx) + if err != nil { + return nil, err + } + cancelReq, err := cancelTaskRunFromRequest(params.CancelTaskRunRequest) + if err != nil { + return nil, err + } + + run, err := manager.CancelRun(ctx, runID, cancelReq, actor) + if err != nil { + return nil, mapTaskRPCError("task_run", runID, err) + } + return taskRunPayloadFromRun(run), nil +} + +func (h *HostAPIHandler) taskManager() (hostAPITaskManager, error) { + if h == nil { + return nil, errors.New("extension: host api handler is required") + } + if h.tasks == nil { + return nil, errors.New("extension: task manager is not configured") + } + return h.tasks, nil +} + +func (h *HostAPIHandler) taskActorContext(ctx context.Context) (taskpkg.ActorContext, error) { + extName := hostAPIExtensionNameFromContext(ctx) + if extName == "" { + return taskpkg.ActorContext{}, unavailableRPCError(errors.New("extension name is not available")) + } + + actor, err := taskpkg.DeriveExtensionActorContext(extName, "") + if err != nil { + return taskpkg.ActorContext{}, invalidParamsRPCError(err) + } + return actor, nil +} + +func (h *HostAPIHandler) taskQueryFromParams(ctx context.Context, params hostAPITasksParams) (taskpkg.TaskQuery, error) { + query := taskpkg.TaskQuery{ + Scope: params.Scope.Normalize(), + Status: params.Status.Normalize(), + OwnerKind: params.OwnerKind.Normalize(), + OwnerRef: strings.TrimSpace(params.OwnerRef), + ParentTaskID: strings.TrimSpace(params.ParentTaskID), + Limit: params.Limit, + } + if query.Scope.Normalize() != "" { + if err := query.Scope.Validate("task_query.scope"); err != nil { + return taskpkg.TaskQuery{}, invalidParamsRPCError(err) + } + } + if workspaceRef := strings.TrimSpace(params.Workspace); workspaceRef != "" { + if query.Scope.Normalize() == taskpkg.ScopeGlobal { + if err := taskpkg.ValidateScopeBinding(query.Scope, workspaceRef, "task_query", "workspace"); err != nil { + return taskpkg.TaskQuery{}, invalidParamsRPCError(err) + } + } + workspaceID, err := h.resolveTaskWorkspaceID(ctx, workspaceRef) + if err != nil { + return taskpkg.TaskQuery{}, err + } + query.WorkspaceID = workspaceID + } + if err := validateTaskChannel("task_query.network_channel", params.NetworkChannel); err != nil { + return taskpkg.TaskQuery{}, err + } + query.NetworkChannel = strings.TrimSpace(params.NetworkChannel) + if err := query.Validate("task_query"); err != nil { + return taskpkg.TaskQuery{}, invalidParamsRPCError(err) + } + return query, nil +} + +func taskRunQueryFromParams(params apicontract.TaskRunListQuery) (taskpkg.TaskRunQuery, error) { + query := taskpkg.TaskRunQuery{ + Status: params.Status.Normalize(), + SessionID: strings.TrimSpace(params.SessionID), + Limit: params.Limit, + } + if err := query.Validate("task_run_query"); err != nil { + return taskpkg.TaskRunQuery{}, invalidParamsRPCError(err) + } + return query, nil +} + +func (h *HostAPIHandler) createTaskSpecFromRequest(ctx context.Context, req apicontract.CreateTaskRequest) (taskpkg.CreateTask, error) { + scope := req.Scope.Normalize() + if err := scope.Validate("create_task.scope"); err != nil { + return taskpkg.CreateTask{}, invalidParamsRPCError(err) + } + workspaceID, err := h.resolveTaskWorkspaceBinding(ctx, scope, strings.TrimSpace(req.Workspace), "create_task") + if err != nil { + return taskpkg.CreateTask{}, err + } + if err := validateTaskChannel("create_task.network_channel", req.NetworkChannel); err != nil { + return taskpkg.CreateTask{}, err + } + + spec := taskpkg.CreateTask{ + ID: strings.TrimSpace(req.ID), + Identifier: strings.TrimSpace(req.Identifier), + Scope: scope, + WorkspaceID: workspaceID, + NetworkChannel: strings.TrimSpace(req.NetworkChannel), + Title: strings.TrimSpace(req.Title), + Description: strings.TrimSpace(req.Description), + Owner: cloneOwnership(req.Owner), + Metadata: cloneRawMessage(req.Metadata), + } + if err := spec.Validate("create_task"); err != nil { + return taskpkg.CreateTask{}, invalidParamsRPCError(err) + } + return spec, nil +} + +func taskPatchFromRequest(req apicontract.UpdateTaskRequest) (taskpkg.TaskPatch, error) { + if req.NetworkChannel != nil { + if err := validateTaskChannel("task_patch.network_channel", *req.NetworkChannel); err != nil { + return taskpkg.TaskPatch{}, err + } + } + + patch := taskpkg.TaskPatch{ + Title: trimStringPtr(req.Title), + Description: trimStringPtr(req.Description), + Metadata: cloneRawMessagePtr(req.Metadata), + NetworkChannel: trimStringPtr(req.NetworkChannel), + Owner: cloneOwnership(req.Owner), + ClearOwner: req.ClearOwner, + } + if err := patch.Validate("task_patch"); err != nil { + return taskpkg.TaskPatch{}, invalidParamsRPCError(err) + } + return patch, nil +} + +func cancelTaskFromRequest(req apicontract.CancelTaskRequest) (taskpkg.CancelTask, error) { + cancelReq := taskpkg.CancelTask{ + Reason: strings.TrimSpace(req.Reason), + Metadata: cloneRawMessage(req.Metadata), + } + if err := cancelReq.Validate("cancel_task"); err != nil { + return taskpkg.CancelTask{}, invalidParamsRPCError(err) + } + return cancelReq, nil +} + +func enqueueTaskRunFromRequest(taskID string, req apicontract.EnqueueTaskRunRequest) (taskpkg.EnqueueRun, error) { + if err := validateTaskChannel("enqueue_run.network_channel", req.NetworkChannel); err != nil { + return taskpkg.EnqueueRun{}, err + } + + spec := taskpkg.EnqueueRun{ + TaskID: strings.TrimSpace(taskID), + IdempotencyKey: strings.TrimSpace(req.IdempotencyKey), + NetworkChannel: strings.TrimSpace(req.NetworkChannel), + } + if err := spec.Validate("enqueue_run"); err != nil { + return taskpkg.EnqueueRun{}, invalidParamsRPCError(err) + } + return spec, nil +} + +func claimTaskRunFromRequest(req apicontract.ClaimTaskRunRequest) (taskpkg.ClaimRun, error) { + claim := taskpkg.ClaimRun{IdempotencyKey: strings.TrimSpace(req.IdempotencyKey)} + if err := claim.Validate("claim_run"); err != nil { + return taskpkg.ClaimRun{}, invalidParamsRPCError(err) + } + return claim, nil +} + +func startTaskRunFromRequest(req apicontract.StartTaskRunRequest) (taskpkg.StartRun, error) { + startReq := taskpkg.StartRun{IdempotencyKey: strings.TrimSpace(req.IdempotencyKey)} + if err := startReq.Validate("start_run"); err != nil { + return taskpkg.StartRun{}, invalidParamsRPCError(err) + } + return startReq, nil +} + +func attachTaskRunSessionIDFromRequest(req apicontract.AttachTaskRunSessionRequest) (string, error) { + sessionID := strings.TrimSpace(req.SessionID) + if sessionID == "" { + return "", invalidParamsRPCError(errors.New("session_id is required")) + } + return sessionID, nil +} + +func completeTaskRunFromRequest(req apicontract.CompleteTaskRunRequest) (taskpkg.RunResult, error) { + result := taskpkg.RunResult{Value: cloneRawMessage(req.Result)} + if err := result.Validate("run_result"); err != nil { + return taskpkg.RunResult{}, invalidParamsRPCError(err) + } + return result, nil +} + +func failTaskRunFromRequest(req apicontract.FailTaskRunRequest) (taskpkg.RunFailure, error) { + failure := taskpkg.RunFailure{ + Error: strings.TrimSpace(req.Error), + Metadata: cloneRawMessage(req.Metadata), + } + if err := failure.Validate("run_failure"); err != nil { + return taskpkg.RunFailure{}, invalidParamsRPCError(err) + } + return failure, nil +} + +func cancelTaskRunFromRequest(req apicontract.CancelTaskRunRequest) (taskpkg.CancelRun, error) { + cancelReq := taskpkg.CancelRun{ + Reason: strings.TrimSpace(req.Reason), + Metadata: cloneRawMessage(req.Metadata), + } + if err := cancelReq.Validate("cancel_run"); err != nil { + return taskpkg.CancelRun{}, invalidParamsRPCError(err) + } + return cancelReq, nil +} + +func (h *HostAPIHandler) resolveTaskWorkspaceBinding(ctx context.Context, scope taskpkg.Scope, workspaceRef string, path string) (string, error) { + trimmed := strings.TrimSpace(workspaceRef) + if err := taskpkg.ValidateScopeBinding(scope, trimmed, path, "workspace"); err != nil { + return "", invalidParamsRPCError(err) + } + if scope.Normalize() != taskpkg.ScopeWorkspace { + return "", nil + } + return h.resolveTaskWorkspaceID(ctx, trimmed) +} + +func (h *HostAPIHandler) resolveTaskWorkspaceID(ctx context.Context, workspaceRef string) (string, error) { + trimmed := strings.TrimSpace(workspaceRef) + if trimmed == "" { + return "", nil + } + if h.workspaces == nil { + return trimmed, nil + } + + resolved, err := h.workspaces.Resolve(ctx, trimmed) + if err != nil { + if errors.Is(err, workspacepkg.ErrWorkspaceNotFound) { + return "", notFoundRPCError("workspace", trimmed, err) + } + return "", err + } + return strings.TrimSpace(resolved.ID), nil +} + +func validateTaskChannel(path string, channel string) error { + trimmed := strings.TrimSpace(channel) + if trimmed == "" { + return nil + } + if err := network.ValidateChannel(trimmed); err != nil { + return invalidParamsRPCError(fmt.Errorf("%s: %w", path, err)) + } + return nil +} + +func mapTaskRPCError(resource string, id string, err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, workspacepkg.ErrWorkspaceNotFound): + return notFoundRPCError("workspace", id, err) + case errors.Is(err, taskpkg.ErrTaskNotFound): + return notFoundRPCError("task", id, err) + case errors.Is(err, taskpkg.ErrTaskRunNotFound): + return notFoundRPCError("task_run", id, err) + case errors.Is(err, taskpkg.ErrTaskDependencyNotFound): + return notFoundRPCError("task_dependency", id, err) + case errors.Is(err, taskpkg.ErrValidation), + errors.Is(err, taskpkg.ErrImmutableField), + errors.Is(err, taskpkg.ErrInvalidScopeBinding), + errors.Is(err, taskpkg.ErrPayloadTooLarge), + errors.Is(err, taskpkg.ErrGraphLimitExceeded), + errors.Is(err, taskpkg.ErrCycleDetected), + errors.Is(err, taskpkg.ErrInvalidStatusTransition), + errors.Is(err, taskpkg.ErrSessionAlreadyBound), + errors.Is(err, taskpkg.ErrSessionAttachNotAllowed), + errors.Is(err, taskpkg.ErrStaleNetworkChannel), + errors.Is(err, taskpkg.ErrPermissionDenied): + return invalidParamsRPCError(err) + default: + return err + } +} + +func taskSummaryPayloadsFromSummaries(tasks []taskpkg.TaskSummary) []apicontract.TaskSummaryPayload { + payloads := make([]apicontract.TaskSummaryPayload, 0, len(tasks)) + for _, record := range tasks { + payloads = append(payloads, taskSummaryPayloadFromSummary(record)) + } + return payloads +} + +func taskSummaryPayloadFromSummary(record taskpkg.TaskSummary) apicontract.TaskSummaryPayload { + return apicontract.TaskSummaryPayload{ + ID: record.ID, + Identifier: record.Identifier, + Scope: record.Scope, + WorkspaceID: record.WorkspaceID, + ParentTaskID: record.ParentTaskID, + NetworkChannel: record.NetworkChannel, + Title: record.Title, + Status: record.Status, + Owner: cloneOwnership(record.Owner), + CreatedBy: record.CreatedBy, + Origin: record.Origin, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + ClosedAt: optionalTime(record.ClosedAt), + } +} + +func taskPayloadFromTask(record *taskpkg.Task) apicontract.TaskPayload { + if record == nil { + return apicontract.TaskPayload{} + } + + return apicontract.TaskPayload{ + ID: record.ID, + Identifier: record.Identifier, + Scope: record.Scope, + WorkspaceID: record.WorkspaceID, + ParentTaskID: record.ParentTaskID, + NetworkChannel: record.NetworkChannel, + Title: record.Title, + Description: record.Description, + Status: record.Status, + Owner: cloneOwnership(record.Owner), + CreatedBy: record.CreatedBy, + Origin: record.Origin, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + ClosedAt: optionalTime(record.ClosedAt), + Metadata: cloneRawMessage(record.Metadata), + } +} + +func taskDependencyPayloadsFromDependencies(dependencies []taskpkg.TaskDependency) []apicontract.TaskDependencyPayload { + payloads := make([]apicontract.TaskDependencyPayload, 0, len(dependencies)) + for _, dependency := range dependencies { + payloads = append(payloads, apicontract.TaskDependencyPayload{ + TaskID: dependency.TaskID, + DependsOnTaskID: dependency.DependsOnTaskID, + Kind: dependency.Kind, + CreatedAt: dependency.CreatedAt, + }) + } + return payloads +} + +func taskRunPayloadsFromRuns(runs []taskpkg.TaskRun) []apicontract.TaskRunPayload { + payloads := make([]apicontract.TaskRunPayload, 0, len(runs)) + for _, run := range runs { + payloads = append(payloads, taskRunPayloadFromRun(&run)) + } + return payloads +} + +func taskRunPayloadFromRun(run *taskpkg.TaskRun) apicontract.TaskRunPayload { + if run == nil { + return apicontract.TaskRunPayload{} + } + + return apicontract.TaskRunPayload{ + ID: run.ID, + TaskID: run.TaskID, + Status: run.Status, + Attempt: run.Attempt, + ClaimedBy: cloneActorIdentity(run.ClaimedBy), + SessionID: run.SessionID, + Origin: run.Origin, + IdempotencyKey: run.IdempotencyKey, + NetworkChannel: run.NetworkChannel, + QueuedAt: run.QueuedAt, + ClaimedAt: optionalTime(run.ClaimedAt), + StartedAt: optionalTime(run.StartedAt), + EndedAt: optionalTime(run.EndedAt), + Error: run.Error, + Result: cloneRawMessage(run.Result), + } +} + +func optionalTime(value time.Time) *time.Time { + if value.IsZero() { + return nil + } + cloned := value + return &cloned +} + +func taskEventPayloadsFromEvents(events []taskpkg.TaskEvent) []apicontract.TaskEventPayload { + payloads := make([]apicontract.TaskEventPayload, 0, len(events)) + for _, event := range events { + payloads = append(payloads, apicontract.TaskEventPayload{ + ID: event.ID, + TaskID: event.TaskID, + RunID: event.RunID, + EventType: event.EventType, + Actor: event.Actor, + Origin: event.Origin, + Payload: cloneRawMessage(event.Payload), + Timestamp: event.Timestamp, + }) + } + return payloads +} + +func taskDetailPayloadFromView(view *taskpkg.TaskView) apicontract.TaskDetailPayload { + if view == nil { + return apicontract.TaskDetailPayload{} + } + + return apicontract.TaskDetailPayload{ + Task: taskPayloadFromTask(&view.Task), + Children: taskSummaryPayloadsFromSummaries(view.Children), + Dependencies: taskDependencyPayloadsFromDependencies(view.Dependencies), + Runs: taskRunPayloadsFromRuns(view.Runs), + Events: taskEventPayloadsFromEvents(view.Events), + } +} + +func filterTaskRuns(runs []taskpkg.TaskRun, query taskpkg.TaskRunQuery) []taskpkg.TaskRun { + filtered := make([]taskpkg.TaskRun, 0, len(runs)) + for _, run := range runs { + if query.Status.Normalize() != "" && run.Status.Normalize() != query.Status.Normalize() { + continue + } + if strings.TrimSpace(query.SessionID) != "" && strings.TrimSpace(run.SessionID) != strings.TrimSpace(query.SessionID) { + continue + } + filtered = append(filtered, run) + } + if query.Limit > 0 && len(filtered) > query.Limit { + return filtered[:query.Limit] + } + return filtered +} + +func cloneOwnership(source *taskpkg.Ownership) *taskpkg.Ownership { + if source == nil { + return nil + } + cloned := *source + return &cloned +} + +func cloneActorIdentity(source *taskpkg.ActorIdentity) *taskpkg.ActorIdentity { + if source == nil { + return nil + } + cloned := *source + return &cloned +} + +func trimStringPtr(source *string) *string { + if source == nil { + return nil + } + value := strings.TrimSpace(*source) + return &value +} + +func cloneRawMessagePtr(source *json.RawMessage) *json.RawMessage { + if source == nil { + return nil + } + cloned := cloneRawMessage(*source) + return &cloned +} + +func cloneRawMessage(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return nil + } + return append(json.RawMessage(nil), raw...) +} diff --git a/internal/extension/host_api_test.go b/internal/extension/host_api_test.go index 2d3d1b458..992bb9cff 100644 --- a/internal/extension/host_api_test.go +++ b/internal/extension/host_api_test.go @@ -17,6 +17,7 @@ import ( "time" "github.com/pedronauck/agh/internal/acp" + apicontract "github.com/pedronauck/agh/internal/api/contract" automationpkg "github.com/pedronauck/agh/internal/automation" bridgepkg "github.com/pedronauck/agh/internal/bridges" aghconfig "github.com/pedronauck/agh/internal/config" @@ -30,6 +31,7 @@ import ( "github.com/pedronauck/agh/internal/store/globaldb" "github.com/pedronauck/agh/internal/store/sessiondb" "github.com/pedronauck/agh/internal/subprocess" + taskpkg "github.com/pedronauck/agh/internal/task" "github.com/pedronauck/agh/internal/testutil" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) @@ -977,6 +979,13 @@ func TestHostAPIHandlerRegisterPromptDeliveryReplaysStoredPromptEvents(t *testin env := newHostAPITestEnv(t) env.grant("delivery-replayer", []string{"sessions/prompt"}, []string{"session.write"}) + turnEnded := make(chan string, 1) + env.sessions.SetTurnEndNotifier(func(sessionID string) { + select { + case turnEnded <- sessionID: + default: + } + }) broker := &recordingPromptDeliveryBroker{} env.handler = NewHostAPIHandler( @@ -1000,24 +1009,23 @@ func TestHostAPIHandlerRegisterPromptDeliveryReplaysStoredPromptEvents(t *testin t.Fatalf("submitPrompt() error = %v", err) } - var promptEvents []store.SessionEvent - deadline := time.Now().Add(500 * time.Millisecond) - for time.Now().Before(deadline) { - promptEvents, err = env.sessions.Events(testutil.Context(t), sess.ID, store.EventQuery{TurnID: prompt.TurnID}) - if err != nil { - t.Fatalf("sessions.Events(%q) error = %v", sess.ID, err) - } - hasDone := false - for _, storedEvent := range promptEvents { - if strings.TrimSpace(storedEvent.Type) == acp.EventTypeDone { - hasDone = true - break - } - } - if hasDone { - break + select { + case notifiedSessionID := <-turnEnded: + if got, want := notifiedSessionID, sess.ID; got != want { + t.Fatalf("turn end notifier session id = %q, want %q", got, want) } - time.Sleep(10 * time.Millisecond) + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for prompt completion") + } + + promptEvents, err := env.sessions.Events(testutil.Context(t), sess.ID, store.EventQuery{TurnID: prompt.TurnID}) + if err != nil { + t.Fatalf("sessions.Events(%q) error = %v", sess.ID, err) + } + if !slices.ContainsFunc(promptEvents, func(storedEvent store.SessionEvent) bool { + return strings.TrimSpace(storedEvent.Type) == acp.EventTypeDone + }) { + t.Fatalf("prompt events = %#v, want done event after turn completion notification", promptEvents) } instance := env.createBridgeInstance(t, bridgepkg.CreateInstanceRequest{ @@ -1723,6 +1731,977 @@ func TestHostAPIHandlerAutomationGetterAndMethodHandlers(t *testing.T) { } } +func TestHostAPIHandlerTaskOperationsRequireCapabilities(t *testing.T) { + t.Parallel() + + env := newHostAPITestEnv(t) + env.grant("ext-denied", nil, nil) + + tests := []struct { + name string + method string + params map[string]any + }{ + { + name: "ShouldDenyCreate", + method: "tasks/create", + params: map[string]any{"scope": taskpkg.ScopeGlobal, "title": "Denied create"}, + }, + { + name: "ShouldDenyUpdate", + method: "tasks/update", + params: map[string]any{"id": "task-denied", "title": "Denied update"}, + }, + { + name: "ShouldDenyRunStart", + method: "tasks/runs/start", + params: map[string]any{"id": "run-denied", "idempotency_key": "idem-denied"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := env.call(t, "ext-denied", tt.method, tt.params) + assertCapabilityDenied(t, err, tt.method) + }) + } +} + +func TestHostAPIHandlerTasksCreateUsesTrustedExtensionIdentity(t *testing.T) { + t.Parallel() + + env := newHostAPITestEnv(t) + env.grant("ext-tasks", []string{"tasks/create"}, []string{"task.write"}) + + result, err := env.call(t, "ext-tasks", "tasks/create", map[string]any{ + "scope": taskpkg.ScopeGlobal, + "title": "Trusted extension task", + "created_by": map[string]any{ + "kind": "human", + "ref": "spoofed-user", + }, + "origin": map[string]any{ + "kind": "cli", + "ref": "spoofed-origin", + }, + }) + if err != nil { + t.Fatalf("Handle(tasks/create) error = %v", err) + } + + var created apicontract.TaskPayload + decodeResult(t, result, &created) + stored, err := env.registry.GetTask(testutil.Context(t), created.ID) + if err != nil { + t.Fatalf("registry.GetTask(%q) error = %v", created.ID, err) + } + if got, want := stored.CreatedBy.Kind, taskpkg.ActorKindExtension; got != want { + t.Fatalf("stored.CreatedBy.Kind = %q, want %q", got, want) + } + if got, want := stored.CreatedBy.Ref, "ext-tasks"; got != want { + t.Fatalf("stored.CreatedBy.Ref = %q, want %q", got, want) + } + if got, want := stored.Origin.Kind, taskpkg.OriginKindExtension; got != want { + t.Fatalf("stored.Origin.Kind = %q, want %q", got, want) + } + if got, want := stored.Origin.Ref, "ext-tasks"; got != want { + t.Fatalf("stored.Origin.Ref = %q, want %q", got, want) + } +} + +func TestHostAPIHandlerTaskRunStartRespectsManagerTransitions(t *testing.T) { + t.Parallel() + + env := newHostAPITestEnv(t) + env.grant( + "ext-tasks", + []string{"tasks/create", "tasks/runs/enqueue", "tasks/runs/start"}, + []string{"task.write"}, + ) + + createResult, err := env.call(t, "ext-tasks", "tasks/create", map[string]any{ + "scope": taskpkg.ScopeWorkspace, + "title": "Lifecycle guard task", + "workspace": env.workspaceID, + }) + if err != nil { + t.Fatalf("Handle(tasks/create) error = %v", err) + } + + var created apicontract.TaskPayload + decodeResult(t, createResult, &created) + + enqueueResult, err := env.call(t, "ext-tasks", "tasks/runs/enqueue", map[string]any{ + "task_id": created.ID, + "idempotency_key": "enqueue-guard", + }) + if err != nil { + t.Fatalf("Handle(tasks/runs/enqueue) error = %v", err) + } + + var run apicontract.TaskRunPayload + decodeResult(t, enqueueResult, &run) + + _, err = env.call(t, "ext-tasks", "tasks/runs/start", map[string]any{ + "id": run.ID, + "idempotency_key": "start-guard", + }) + assertRPCErrorCode(t, err, HostAPIInvalidParamsCode) + assertErrorContains(t, err, "invalid status transition") +} + +func TestHostAPIHandlerTasksListAndGetReturnFilteredDetail(t *testing.T) { + t.Parallel() + + env := newHostAPITestEnv(t) + env.grant("ext-reader", []string{"tasks", "tasks/get"}, []string{"task.read"}) + + actor := mustExtensionTaskActorContext(t, "seed-writer") + parent, err := env.tasks.CreateTask(testutil.Context(t), taskpkg.CreateTask{ + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: env.workspaceID, + Title: "Parent task", + Owner: &taskpkg.Ownership{ + Kind: taskpkg.OwnerKindExtension, + Ref: "ops", + }, + NetworkChannel: "tasks_ops", + }, actor) + if err != nil { + t.Fatalf("tasks.CreateTask(parent) error = %v", err) + } + + child, err := env.tasks.CreateChildTask(testutil.Context(t), parent.ID, taskpkg.CreateTask{ + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: env.workspaceID, + Title: "Filtered child", + Owner: &taskpkg.Ownership{ + Kind: taskpkg.OwnerKindExtension, + Ref: "ops", + }, + NetworkChannel: "tasks_ops", + }, actor) + if err != nil { + t.Fatalf("tasks.CreateChildTask(filtered) error = %v", err) + } + + if _, err := env.tasks.CreateChildTask(testutil.Context(t), parent.ID, taskpkg.CreateTask{ + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: env.workspaceID, + Title: "Other child", + Owner: &taskpkg.Ownership{ + Kind: taskpkg.OwnerKindPool, + Ref: "backlog", + }, + NetworkChannel: "tasks_other", + }, actor); err != nil { + t.Fatalf("tasks.CreateChildTask(other) error = %v", err) + } + + blocker, err := env.tasks.CreateTask(testutil.Context(t), taskpkg.CreateTask{ + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: env.workspaceID, + Title: "Blocking task", + }, actor) + if err != nil { + t.Fatalf("tasks.CreateTask(blocker) error = %v", err) + } + if err := env.tasks.AddDependency(testutil.Context(t), taskpkg.AddDependency{ + TaskID: child.ID, + DependsOnTaskID: blocker.ID, + Kind: taskpkg.DependencyKindBlocks, + }, actor); err != nil { + t.Fatalf("tasks.AddDependency() error = %v", err) + } + + run, err := env.tasks.EnqueueRun(testutil.Context(t), taskpkg.EnqueueRun{ + TaskID: child.ID, + IdempotencyKey: "seed-list-detail", + }, actor) + if err != nil { + t.Fatalf("tasks.EnqueueRun() error = %v", err) + } + + listResult, err := env.call(t, "ext-reader", "tasks", map[string]any{ + "scope": taskpkg.ScopeWorkspace, + "workspace": env.workspaceID, + "owner_kind": taskpkg.OwnerKindExtension, + "owner_ref": "ops", + "parent_task_id": parent.ID, + "network_channel": "tasks_ops", + "limit": 1, + }) + if err != nil { + t.Fatalf("Handle(tasks) error = %v", err) + } + + var listed []apicontract.TaskSummaryPayload + decodeResult(t, listResult, &listed) + if got, want := len(listed), 1; got != want { + t.Fatalf("len(tasks) = %d, want %d", got, want) + } + if got, want := listed[0].ID, child.ID; got != want { + t.Fatalf("tasks[0].ID = %q, want %q", got, want) + } + if listed[0].Owner == nil { + t.Fatal("tasks[0].Owner = nil, want extension owner") + } + if got, want := listed[0].Owner.Ref, "ops"; got != want { + t.Fatalf("tasks[0].Owner.Ref = %q, want %q", got, want) + } + + getResult, err := env.call(t, "ext-reader", "tasks/get", map[string]any{"id": child.ID}) + if err != nil { + t.Fatalf("Handle(tasks/get) error = %v", err) + } + + var detail apicontract.TaskDetailPayload + decodeResult(t, getResult, &detail) + if got, want := detail.Task.ID, child.ID; got != want { + t.Fatalf("tasks/get.task.id = %q, want %q", got, want) + } + if got, want := len(detail.Dependencies), 1; got != want { + t.Fatalf("len(tasks/get.dependencies) = %d, want %d", got, want) + } + if got, want := detail.Dependencies[0].DependsOnTaskID, blocker.ID; got != want { + t.Fatalf("tasks/get.dependencies[0].depends_on_task_id = %q, want %q", got, want) + } + if got, want := len(detail.Runs), 1; got != want { + t.Fatalf("len(tasks/get.runs) = %d, want %d", got, want) + } + if got, want := detail.Runs[0].ID, run.ID; got != want { + t.Fatalf("tasks/get.runs[0].id = %q, want %q", got, want) + } + if len(detail.Events) == 0 { + t.Fatal("tasks/get.events = 0, want audit events") + } +} + +func TestHostAPIHandlerTasksUpdateAndCancelMutateTask(t *testing.T) { + t.Parallel() + + env := newHostAPITestEnv(t) + env.grant("ext-writer", []string{"tasks/create", "tasks/update", "tasks/cancel"}, []string{"task.write"}) + + createResult, err := env.call(t, "ext-writer", "tasks/create", map[string]any{ + "scope": taskpkg.ScopeWorkspace, + "workspace": env.workspaceID, + "title": "Original title", + "description": "Original description", + "network_channel": "tasks_initial", + "owner": map[string]any{ + "kind": taskpkg.OwnerKindPool, + "ref": "triage", + }, + "metadata": map[string]any{"phase": "initial"}, + }) + if err != nil { + t.Fatalf("Handle(tasks/create) error = %v", err) + } + + var created apicontract.TaskPayload + decodeResult(t, createResult, &created) + + updateResult, err := env.call(t, "ext-writer", "tasks/update", map[string]any{ + "id": created.ID, + "title": " Updated title ", + "description": " Updated description ", + "network_channel": "tasks_updated", + "owner": map[string]any{ + "kind": taskpkg.OwnerKindExtension, + "ref": "ext-writer", + }, + "metadata": map[string]any{"phase": "updated"}, + }) + if err != nil { + t.Fatalf("Handle(tasks/update) error = %v", err) + } + + var updated apicontract.TaskPayload + decodeResult(t, updateResult, &updated) + if got, want := updated.Title, "Updated title"; got != want { + t.Fatalf("tasks/update title = %q, want %q", got, want) + } + if got, want := updated.Description, "Updated description"; got != want { + t.Fatalf("tasks/update description = %q, want %q", got, want) + } + if got, want := updated.NetworkChannel, "tasks_updated"; got != want { + t.Fatalf("tasks/update network_channel = %q, want %q", got, want) + } + if updated.Owner == nil { + t.Fatal("tasks/update owner = nil, want extension owner") + } + if got, want := updated.Owner.Ref, "ext-writer"; got != want { + t.Fatalf("tasks/update owner.ref = %q, want %q", got, want) + } + if !strings.Contains(string(updated.Metadata), `"updated"`) { + t.Fatalf("tasks/update metadata = %s, want updated marker", string(updated.Metadata)) + } + + clearOwnerResult, err := env.call(t, "ext-writer", "tasks/update", map[string]any{ + "id": created.ID, + "clear_owner": true, + }) + if err != nil { + t.Fatalf("Handle(tasks/update clear_owner) error = %v", err) + } + + var cleared apicontract.TaskPayload + decodeResult(t, clearOwnerResult, &cleared) + if cleared.Owner != nil { + t.Fatalf("tasks/update clear_owner owner = %#v, want nil", cleared.Owner) + } + + cancelResult, err := env.call(t, "ext-writer", "tasks/cancel", map[string]any{ + "id": created.ID, + "reason": " user requested ", + "metadata": map[string]any{ + "source": "host-api", + }, + }) + if err != nil { + t.Fatalf("Handle(tasks/cancel) error = %v", err) + } + + var cancelled apicontract.TaskPayload + decodeResult(t, cancelResult, &cancelled) + if got, want := cancelled.Status, taskpkg.TaskStatusCancelled; got != want { + t.Fatalf("tasks/cancel status = %q, want %q", got, want) + } + if cancelled.ClosedAt.IsZero() { + t.Fatal("tasks/cancel closed_at = zero, want terminal timestamp") + } + + stored, err := env.registry.GetTask(testutil.Context(t), created.ID) + if err != nil { + t.Fatalf("registry.GetTask(%q) error = %v", created.ID, err) + } + if got, want := stored.Status, taskpkg.TaskStatusCancelled; got != want { + t.Fatalf("stored.Status = %q, want %q", got, want) + } + if stored.Owner != nil { + t.Fatalf("stored.Owner = %#v, want nil after clear_owner", stored.Owner) + } +} + +func TestHostAPIHandlerTaskRunLifecycleOperationsAndFiltering(t *testing.T) { + t.Parallel() + + env := newHostAPITestEnv(t) + env.grant( + "ext-runs", + []string{ + "tasks/create", + "tasks/runs", + "tasks/runs/enqueue", + "tasks/runs/claim", + "tasks/runs/attach_session", + "tasks/runs/start", + "tasks/runs/complete", + "tasks/runs/fail", + "tasks/runs/cancel", + }, + []string{"task.read", "task.write"}, + ) + + createTask := func(title string) apicontract.TaskPayload { + t.Helper() + + result, err := env.call(t, "ext-runs", "tasks/create", map[string]any{ + "scope": taskpkg.ScopeWorkspace, + "workspace": env.workspaceID, + "title": title, + }) + if err != nil { + t.Fatalf("Handle(tasks/create %q) error = %v", title, err) + } + var task apicontract.TaskPayload + decodeResult(t, result, &task) + return task + } + + enqueueRun := func(taskID string, idempotencyKey string) apicontract.TaskRunPayload { + t.Helper() + + result, err := env.call(t, "ext-runs", "tasks/runs/enqueue", map[string]any{ + "task_id": taskID, + "idempotency_key": idempotencyKey, + }) + if err != nil { + t.Fatalf("Handle(tasks/runs/enqueue %q) error = %v", taskID, err) + } + var run apicontract.TaskRunPayload + decodeResult(t, result, &run) + return run + } + + claimRun := func(runID string, idempotencyKey string) apicontract.TaskRunPayload { + t.Helper() + + result, err := env.call(t, "ext-runs", "tasks/runs/claim", map[string]any{ + "id": runID, + "idempotency_key": idempotencyKey, + }) + if err != nil { + t.Fatalf("Handle(tasks/runs/claim %q) error = %v", runID, err) + } + var run apicontract.TaskRunPayload + decodeResult(t, result, &run) + return run + } + + completedTask := createTask("Completed run task") + completedQueued := enqueueRun(completedTask.ID, "enqueue-complete") + completedClaimed := claimRun(completedQueued.ID, "claim-complete") + if got, want := completedClaimed.Status, taskpkg.TaskRunStatusClaimed; got != want { + t.Fatalf("tasks/runs/claim status = %q, want %q", got, want) + } + if completedClaimed.ClaimedBy == nil { + t.Fatal("tasks/runs/claim claimed_by = nil, want extension actor") + } + + boundSession := env.createSession(t) + attachResult, err := env.call(t, "ext-runs", "tasks/runs/attach_session", map[string]any{ + "id": completedQueued.ID, + "session_id": boundSession.ID, + }) + if err != nil { + t.Fatalf("Handle(tasks/runs/attach_session) error = %v", err) + } + + var attached apicontract.TaskRunPayload + decodeResult(t, attachResult, &attached) + if got, want := attached.SessionID, boundSession.ID; got != want { + t.Fatalf("tasks/runs/attach_session session_id = %q, want %q", got, want) + } + + startResult, err := env.call(t, "ext-runs", "tasks/runs/start", map[string]any{ + "id": completedQueued.ID, + "idempotency_key": "start-complete", + }) + if err != nil { + t.Fatalf("Handle(tasks/runs/start) error = %v", err) + } + + var started apicontract.TaskRunPayload + decodeResult(t, startResult, &started) + if got, want := started.Status, taskpkg.TaskRunStatusRunning; got != want { + t.Fatalf("tasks/runs/start status = %q, want %q", got, want) + } + + completeResult, err := env.call(t, "ext-runs", "tasks/runs/complete", map[string]any{ + "id": completedQueued.ID, + "result": map[string]any{"ok": true}, + }) + if err != nil { + t.Fatalf("Handle(tasks/runs/complete) error = %v", err) + } + + var completed apicontract.TaskRunPayload + decodeResult(t, completeResult, &completed) + if got, want := completed.Status, taskpkg.TaskRunStatusCompleted; got != want { + t.Fatalf("tasks/runs/complete status = %q, want %q", got, want) + } + if !strings.Contains(string(completed.Result), `"ok":true`) { + t.Fatalf("tasks/runs/complete result = %s, want ok marker", string(completed.Result)) + } + + failedTask := createTask("Failed run task") + failedQueued := enqueueRun(failedTask.ID, "enqueue-fail") + _ = claimRun(failedQueued.ID, "claim-fail") + _, err = env.call(t, "ext-runs", "tasks/runs/start", map[string]any{ + "id": failedQueued.ID, + "idempotency_key": "start-fail", + }) + if err != nil { + t.Fatalf("Handle(tasks/runs/start fail path) error = %v", err) + } + failResult, err := env.call(t, "ext-runs", "tasks/runs/fail", map[string]any{ + "id": failedQueued.ID, + "error": " execution failed ", + "metadata": map[string]any{ + "retryable": false, + }, + }) + if err != nil { + t.Fatalf("Handle(tasks/runs/fail) error = %v", err) + } + + var failed apicontract.TaskRunPayload + decodeResult(t, failResult, &failed) + if got, want := failed.Status, taskpkg.TaskRunStatusFailed; got != want { + t.Fatalf("tasks/runs/fail status = %q, want %q", got, want) + } + if got, want := failed.Error, "execution failed"; got != want { + t.Fatalf("tasks/runs/fail error = %q, want %q", got, want) + } + + cancelledTask := createTask("Cancelled run task") + cancelledQueued := enqueueRun(cancelledTask.ID, "enqueue-cancel") + _ = claimRun(cancelledQueued.ID, "claim-cancel") + cancelRunResult, err := env.call(t, "ext-runs", "tasks/runs/cancel", map[string]any{ + "id": cancelledQueued.ID, + "reason": " no longer needed ", + "metadata": map[string]any{ + "source": "extension", + }, + }) + if err != nil { + t.Fatalf("Handle(tasks/runs/cancel) error = %v", err) + } + + var cancelled apicontract.TaskRunPayload + decodeResult(t, cancelRunResult, &cancelled) + if got, want := cancelled.Status, taskpkg.TaskRunStatusCancelled; got != want { + t.Fatalf("tasks/runs/cancel status = %q, want %q", got, want) + } + + runsResult, err := env.call(t, "ext-runs", "tasks/runs", map[string]any{ + "id": completedTask.ID, + "status": taskpkg.TaskRunStatusCompleted, + "session_id": boundSession.ID, + "limit": 1, + }) + if err != nil { + t.Fatalf("Handle(tasks/runs) error = %v", err) + } + + var filtered []apicontract.TaskRunPayload + decodeResult(t, runsResult, &filtered) + if got, want := len(filtered), 1; got != want { + t.Fatalf("len(tasks/runs) = %d, want %d", got, want) + } + if got, want := filtered[0].ID, completedQueued.ID; got != want { + t.Fatalf("tasks/runs[0].id = %q, want %q", got, want) + } + if got, want := filtered[0].SessionID, boundSession.ID; got != want { + t.Fatalf("tasks/runs[0].session_id = %q, want %q", got, want) + } +} + +func TestHostAPIHandlerTaskMethodsValidateInputsAndConfiguration(t *testing.T) { + t.Parallel() + + t.Run("ShouldRejectWhenTaskManagerIsMissing", func(t *testing.T) { + t.Parallel() + + checker := &CapabilityChecker{} + checker.Register("ext-tasks", SourceUser, &Manifest{ + Actions: ActionsConfig{Requires: []string{"tasks", "tasks/get", "tasks/runs"}}, + Security: SecurityConfig{ + Capabilities: []string{"task.read"}, + }, + }) + + handler := NewHostAPIHandler( + nil, + nil, + nil, + nil, + WithHostAPICapabilityChecker(checker), + WithHostAPIRateLimit(1000, 1000), + ) + + tests := []struct { + name string + method string + params map[string]any + }{ + {name: "ShouldRejectList", method: "tasks", params: map[string]any{}}, + {name: "ShouldRejectGet", method: "tasks/get", params: map[string]any{"id": "task-1"}}, + {name: "ShouldRejectRuns", method: "tasks/runs", params: map[string]any{"id": "task-1"}}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params, err := marshalParams(tt.params) + if err != nil { + t.Fatalf("marshalParams() error = %v", err) + } + + _, err = handler.Handle(testutil.Context(t), "ext-tasks", tt.method, params) + assertErrorContains(t, err, "task manager is not configured") + }) + } + }) + + t.Run("ShouldRejectInvalidTaskMethodInputs", func(t *testing.T) { + t.Parallel() + + env := newHostAPITestEnv(t) + env.grant( + "ext-tasks", + []string{"tasks", "tasks/create", "tasks/update", "tasks/runs/attach_session"}, + []string{"task.read", "task.write"}, + ) + + tests := []struct { + name string + method string + params map[string]any + wantCode int + wantText string + }{ + { + name: "ShouldRejectUnknownWorkspace", + method: "tasks/create", + params: map[string]any{ + "scope": taskpkg.ScopeWorkspace, + "workspace": "ws-missing", + "title": "Missing workspace task", + }, + wantCode: HostAPINotFoundCode, + wantText: "workspace", + }, + { + name: "ShouldRejectInvalidQueryScopeBeforeWorkspaceLookup", + method: "tasks", + params: map[string]any{ + "scope": "invalid", + "workspace": "ws-missing", + }, + wantCode: HostAPIInvalidParamsCode, + wantText: "task_query.scope", + }, + { + name: "ShouldRejectGlobalCreateWorkspaceBindingBeforeWorkspaceLookup", + method: "tasks/create", + params: map[string]any{ + "scope": taskpkg.ScopeGlobal, + "workspace": "ws-missing", + "title": "Global task", + }, + wantCode: HostAPIInvalidParamsCode, + wantText: "create_task.workspace", + }, + { + name: "ShouldRejectInvalidListChannel", + method: "tasks", + params: map[string]any{ + "network_channel": "not valid", + }, + wantCode: HostAPIInvalidParamsCode, + wantText: "task_query.network_channel", + }, + { + name: "ShouldRequireUpdateChanges", + method: "tasks/update", + params: map[string]any{"id": "task-1"}, + wantCode: HostAPIInvalidParamsCode, + wantText: "at least one mutable field", + }, + { + name: "ShouldRequireAttachSessionID", + method: "tasks/runs/attach_session", + params: map[string]any{"id": "run-1"}, + wantCode: HostAPIInvalidParamsCode, + wantText: "session_id is required", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := env.call(t, "ext-tasks", tt.method, tt.params) + assertRPCErrorCode(t, err, tt.wantCode) + assertErrorContains(t, err, tt.wantText) + }) + } + }) +} + +func TestHostAPIHandlerTaskMethodsRequireIdentifiers(t *testing.T) { + t.Parallel() + + env := newHostAPITestEnv(t) + env.grant( + "ext-ids", + []string{ + "tasks/get", + "tasks/update", + "tasks/cancel", + "tasks/runs", + "tasks/runs/enqueue", + "tasks/runs/claim", + "tasks/runs/start", + "tasks/runs/complete", + "tasks/runs/fail", + "tasks/runs/cancel", + }, + []string{"task.read", "task.write"}, + ) + + tests := []struct { + name string + method string + params map[string]any + wantText string + }{ + {name: "ShouldRequireTaskIDForGet", method: "tasks/get", params: map[string]any{}, wantText: "id is required"}, + {name: "ShouldRequireTaskIDForUpdate", method: "tasks/update", params: map[string]any{"title": "rename"}, wantText: "id is required"}, + {name: "ShouldRequireTaskIDForCancel", method: "tasks/cancel", params: map[string]any{"reason": "stop"}, wantText: "id is required"}, + {name: "ShouldRequireTaskIDForRunsList", method: "tasks/runs", params: map[string]any{}, wantText: "id is required"}, + {name: "ShouldRequireTaskIDForRunEnqueue", method: "tasks/runs/enqueue", params: map[string]any{"idempotency_key": "idem"}, wantText: "task_id is required"}, + {name: "ShouldRequireTaskIDForRunClaim", method: "tasks/runs/claim", params: map[string]any{"idempotency_key": "idem"}, wantText: "id is required"}, + {name: "ShouldRequireTaskIDForRunStart", method: "tasks/runs/start", params: map[string]any{"idempotency_key": "idem"}, wantText: "id is required"}, + {name: "ShouldRequireTaskIDForRunComplete", method: "tasks/runs/complete", params: map[string]any{"result": map[string]any{"ok": true}}, wantText: "id is required"}, + {name: "ShouldRequireTaskIDForRunFail", method: "tasks/runs/fail", params: map[string]any{"error": "boom"}, wantText: "id is required"}, + {name: "ShouldRequireTaskIDForRunCancel", method: "tasks/runs/cancel", params: map[string]any{"reason": "cancel"}, wantText: "id is required"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := env.call(t, "ext-ids", tt.method, tt.params) + assertRPCErrorCode(t, err, HostAPIInvalidParamsCode) + assertErrorContains(t, err, tt.wantText) + }) + } +} + +func TestHostAPIHandlerTaskMethodsReturnNotFoundForMissingRecords(t *testing.T) { + t.Parallel() + + env := newHostAPITestEnv(t) + env.grant( + "ext-missing", + []string{ + "tasks/get", + "tasks/update", + "tasks/cancel", + "tasks/runs", + "tasks/runs/claim", + "tasks/runs/start", + "tasks/runs/attach_session", + "tasks/runs/complete", + "tasks/runs/fail", + "tasks/runs/cancel", + }, + []string{"task.read", "task.write"}, + ) + + tests := []struct { + name string + method string + params map[string]any + wantText string + }{ + {name: "ShouldReturnTaskNotFoundForGet", method: "tasks/get", params: map[string]any{"id": "task-missing"}, wantText: "task not found"}, + {name: "ShouldReturnTaskNotFoundForUpdate", method: "tasks/update", params: map[string]any{"id": "task-missing", "title": "rename"}, wantText: "task not found"}, + {name: "ShouldReturnTaskNotFoundForCancel", method: "tasks/cancel", params: map[string]any{"id": "task-missing"}, wantText: "task not found"}, + {name: "ShouldReturnTaskNotFoundForListRuns", method: "tasks/runs", params: map[string]any{"id": "task-missing"}, wantText: "task not found"}, + {name: "ShouldReturnRunNotFoundForClaim", method: "tasks/runs/claim", params: map[string]any{"id": "run-missing", "idempotency_key": "idem"}, wantText: "task run not found"}, + {name: "ShouldReturnRunNotFoundForStart", method: "tasks/runs/start", params: map[string]any{"id": "run-missing", "idempotency_key": "idem"}, wantText: "task run not found"}, + {name: "ShouldReturnRunNotFoundForAttach", method: "tasks/runs/attach_session", params: map[string]any{"id": "run-missing", "session_id": "sess-missing"}, wantText: "task run not found"}, + {name: "ShouldReturnRunNotFoundForComplete", method: "tasks/runs/complete", params: map[string]any{"id": "run-missing", "result": map[string]any{"ok": true}}, wantText: "task run not found"}, + {name: "ShouldReturnRunNotFoundForFail", method: "tasks/runs/fail", params: map[string]any{"id": "run-missing", "error": "boom"}, wantText: "task run not found"}, + {name: "ShouldReturnRunNotFoundForCancel", method: "tasks/runs/cancel", params: map[string]any{"id": "run-missing"}, wantText: "task run not found"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := env.call(t, "ext-missing", tt.method, tt.params) + assertRPCErrorCode(t, err, HostAPINotFoundCode) + assertErrorContains(t, err, tt.wantText) + }) + } +} + +func TestMapTaskRPCErrorTranslatesKnownErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resource string + id string + err error + wantCode int + wantText string + wantNil bool + wantSame bool + }{ + {name: "ShouldReturnNilForNilError", err: nil, wantNil: true}, + {name: "ShouldMapWorkspaceNotFound", resource: "workspace", id: "ws-1", err: workspacepkg.ErrWorkspaceNotFound, wantCode: HostAPINotFoundCode, wantText: "workspace not found"}, + {name: "ShouldMapTaskNotFound", resource: "task", id: "task-1", err: taskpkg.ErrTaskNotFound, wantCode: HostAPINotFoundCode, wantText: "task not found"}, + {name: "ShouldMapRunNotFound", resource: "task_run", id: "run-1", err: taskpkg.ErrTaskRunNotFound, wantCode: HostAPINotFoundCode, wantText: "task run not found"}, + {name: "ShouldMapDependencyNotFound", resource: "task_dependency", id: "dep-1", err: taskpkg.ErrTaskDependencyNotFound, wantCode: HostAPINotFoundCode, wantText: "task dependency not found"}, + {name: "ShouldMapPermissionDenied", resource: "task", id: "task-1", err: taskpkg.ErrPermissionDenied, wantCode: HostAPIInvalidParamsCode, wantText: "permission denied"}, + {name: "ShouldMapStaleNetworkChannel", resource: "task_run", id: "run-1", err: taskpkg.ErrStaleNetworkChannel, wantCode: HostAPIInvalidParamsCode, wantText: "stale network channel"}, + {name: "ShouldPassThroughUnknownErrors", resource: "task", id: "task-1", err: errors.New("boom"), wantSame: true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mapped := mapTaskRPCError(tt.resource, tt.id, tt.err) + if tt.wantNil { + if mapped != nil { + t.Fatalf("mapTaskRPCError() = %v, want nil", mapped) + } + return + } + if tt.wantSame { + if !errors.Is(mapped, tt.err) { + t.Fatalf("mapTaskRPCError() = %v, want same error %v", mapped, tt.err) + } + return + } + + assertRPCErrorCode(t, mapped, tt.wantCode) + assertErrorContains(t, mapped, tt.wantText) + }) + } +} + +func TestHostAPITaskHelpersHandleZeroAndUnavailableCases(t *testing.T) { + t.Parallel() + + var nilHandler *HostAPIHandler + _, err := nilHandler.taskManager() + assertErrorContains(t, err, "host api handler is required") + + _, err = (&HostAPIHandler{}).taskManager() + assertErrorContains(t, err, "task manager is not configured") + + env := newHostAPITestEnv(t) + raw, err := marshalParams(map[string]any{ + "scope": taskpkg.ScopeGlobal, + "title": "No context task", + }) + if err != nil { + t.Fatalf("marshalParams() error = %v", err) + } + + _, err = env.handler.handleTasksCreate(testutil.Context(t), raw) + assertRPCErrorCode(t, err, HostAPIUnavailableCode) + assertErrorContains(t, err, "extension name is not available") + + zeroTask := taskPayloadFromTask(nil) + if zeroTask.ID != "" { + t.Fatalf("taskPayloadFromTask(nil).ID = %q, want empty", zeroTask.ID) + } + + zeroRun := taskRunPayloadFromRun(nil) + if zeroRun.ID != "" { + t.Fatalf("taskRunPayloadFromRun(nil).ID = %q, want empty", zeroRun.ID) + } + + zeroDetail := taskDetailPayloadFromView(nil) + if zeroDetail.Task.ID != "" { + t.Fatalf("taskDetailPayloadFromView(nil).Task.ID = %q, want empty", zeroDetail.Task.ID) + } + + filtered := filterTaskRuns([]taskpkg.TaskRun{ + {ID: "run-1", Status: taskpkg.TaskRunStatusRunning, SessionID: "sess-1"}, + {ID: "run-2", Status: taskpkg.TaskRunStatusCompleted, SessionID: "sess-2"}, + {ID: "run-3", Status: taskpkg.TaskRunStatusCompleted, SessionID: "sess-1"}, + }, taskpkg.TaskRunQuery{ + Status: taskpkg.TaskRunStatusCompleted, + SessionID: "sess-1", + Limit: 1, + }) + if got, want := len(filtered), 1; got != want { + t.Fatalf("len(filterTaskRuns) = %d, want %d", got, want) + } + if got, want := filtered[0].ID, "run-3"; got != want { + t.Fatalf("filterTaskRuns()[0].ID = %q, want %q", got, want) + } +} + +func TestHostAPIHandlerTaskMethodsRejectInvalidPayloadCombinations(t *testing.T) { + t.Parallel() + + env := newHostAPITestEnv(t) + env.grant( + "ext-invalid", + []string{"tasks/create", "tasks/update", "tasks/runs/enqueue"}, + []string{"task.write"}, + ) + + _, err := env.call(t, "ext-invalid", "tasks/create", map[string]any{ + "scope": taskpkg.ScopeGlobal, + "title": "Invalid channel task", + "network_channel": "not valid", + }) + assertRPCErrorCode(t, err, HostAPIInvalidParamsCode) + assertErrorContains(t, err, "create_task.network_channel") + + createResult, err := env.call(t, "ext-invalid", "tasks/create", map[string]any{ + "scope": taskpkg.ScopeWorkspace, + "workspace": env.workspaceID, + "title": "Mutable task", + }) + if err != nil { + t.Fatalf("Handle(tasks/create mutable task) error = %v", err) + } + + var created apicontract.TaskPayload + decodeResult(t, createResult, &created) + + _, err = env.call(t, "ext-invalid", "tasks/update", map[string]any{ + "id": created.ID, + "owner": map[string]any{"kind": taskpkg.OwnerKindPool, "ref": "triage"}, + "clear_owner": true, + }) + assertRPCErrorCode(t, err, HostAPIInvalidParamsCode) + assertErrorContains(t, err, "cannot both be set") + + _, err = env.call(t, "ext-invalid", "tasks/runs/enqueue", map[string]any{ + "task_id": created.ID, + "idempotency_key": "idem-invalid-channel", + "network_channel": "not valid", + }) + assertRPCErrorCode(t, err, HostAPIInvalidParamsCode) + assertErrorContains(t, err, "enqueue_run.network_channel") +} + +func TestHostAPITaskRequestHelpersRejectInvalidPayloads(t *testing.T) { + t.Parallel() + + oversizedMetadata := json.RawMessage(fmt.Sprintf("%q", strings.Repeat("m", taskpkg.MaxPayloadBytes+1))) + oversizedResult := json.RawMessage(fmt.Sprintf("%q", strings.Repeat("r", taskpkg.MaxResultBytes+1))) + + _, err := cancelTaskFromRequest(apicontract.CancelTaskRequest{Metadata: oversizedMetadata}) + assertRPCErrorCode(t, err, HostAPIInvalidParamsCode) + assertErrorContains(t, err, "cancel_task.metadata") + + _, err = completeTaskRunFromRequest(apicontract.CompleteTaskRunRequest{Result: oversizedResult}) + assertRPCErrorCode(t, err, HostAPIInvalidParamsCode) + assertErrorContains(t, err, "run_result.value") + + _, err = failTaskRunFromRequest(apicontract.FailTaskRunRequest{}) + assertRPCErrorCode(t, err, HostAPIInvalidParamsCode) + assertErrorContains(t, err, "run_failure.error") + + _, err = cancelTaskRunFromRequest(apicontract.CancelTaskRunRequest{Metadata: oversizedMetadata}) + assertRPCErrorCode(t, err, HostAPIInvalidParamsCode) + assertErrorContains(t, err, "cancel_run.metadata") + + _, err = taskRunQueryFromParams(apicontract.TaskRunListQuery{Limit: -1}) + assertRPCErrorCode(t, err, HostAPIInvalidParamsCode) + assertErrorContains(t, err, "task_run_query.limit") + + env := newHostAPITestEnv(t) + _, err = env.handler.taskQueryFromParams(testutil.Context(t), hostAPITasksParams{Limit: -1}) + assertRPCErrorCode(t, err, HostAPIInvalidParamsCode) + assertErrorContains(t, err, "task_query.limit") +} + type hostAPITestEnv struct { nowMu sync.RWMutex now time.Time @@ -1733,6 +2712,7 @@ type hostAPITestEnv struct { bridges *bridgepkg.Service sessions *session.Manager automation HostAPIAutomationManager + tasks taskpkg.Manager observer *observepkg.Observer memory *memory.Store skills *skillspkg.Registry @@ -1748,6 +2728,95 @@ type hostAPITestEnvConfig struct { type hostAPITestEnvOption func(*hostAPITestEnvConfig) +type hostAPITestTaskSessionExecutor struct { + sessions *session.Manager + globalWorkspacePath string +} + +func mustExtensionTaskActorContext(t testing.TB, extensionName string) taskpkg.ActorContext { + t.Helper() + + actor, err := taskpkg.DeriveExtensionActorContext(extensionName, "") + if err != nil { + t.Fatalf("DeriveExtensionActorContext(%q) error = %v", extensionName, err) + } + return actor +} + +func (e *hostAPITestTaskSessionExecutor) StartTaskSession(ctx context.Context, spec taskpkg.StartTaskSession) (*taskpkg.SessionRef, error) { + if ctx == nil { + return nil, errors.New("extension: host api test task start context is required") + } + + opts := session.CreateOpts{ + AgentName: "coder", + Name: "task:" + strings.TrimSpace(spec.Task.Title), + Channel: strings.TrimSpace(spec.Run.NetworkChannel), + Type: session.SessionTypeSystem, + } + switch spec.Task.Scope.Normalize() { + case taskpkg.ScopeWorkspace: + opts.Workspace = strings.TrimSpace(spec.Task.WorkspaceID) + case taskpkg.ScopeGlobal: + opts.WorkspacePath = strings.TrimSpace(e.globalWorkspacePath) + default: + return nil, fmt.Errorf("%w: unsupported task scope %q", taskpkg.ErrValidation, spec.Task.Scope) + } + + created, err := e.sessions.Create(ctx, opts) + if err != nil { + return nil, fmt.Errorf("start task session: create session: %w", err) + } + info := created.Info() + if info == nil { + return nil, fmt.Errorf("%w: task session create returned nil session info", taskpkg.ErrValidation) + } + return &taskpkg.SessionRef{ + SessionID: info.ID, + WorkspaceID: info.WorkspaceID, + StartedAt: info.CreatedAt, + }, nil +} + +func (e *hostAPITestTaskSessionExecutor) AttachTaskSession(ctx context.Context, _ string, sessionID string) (*taskpkg.SessionRef, error) { + if ctx == nil { + return nil, errors.New("extension: host api test task attach context is required") + } + + info, err := e.sessions.Status(ctx, strings.TrimSpace(sessionID)) + if err != nil { + return nil, fmt.Errorf("attach task session: read session status: %w", err) + } + if info == nil || info.State != session.StateActive { + return nil, fmt.Errorf("%w: session %q is not active", taskpkg.ErrSessionAttachNotAllowed, strings.TrimSpace(sessionID)) + } + return &taskpkg.SessionRef{ + SessionID: info.ID, + WorkspaceID: info.WorkspaceID, + StartedAt: info.CreatedAt, + }, nil +} + +func (e *hostAPITestTaskSessionExecutor) RequestTaskStop(ctx context.Context, sessionID string, _ taskpkg.StopReason) error { + if ctx == nil { + return errors.New("extension: host api test task request stop context is required") + } + if err := e.sessions.RequestStopWithCause(ctx, strings.TrimSpace(sessionID), session.CauseUserRequested, "task cancellation"); err != nil { + return fmt.Errorf("request task stop: %w", err) + } + return nil +} + +func (e *hostAPITestTaskSessionExecutor) ForceTaskStop(ctx context.Context, sessionID string, _ taskpkg.StopReason) error { + if ctx == nil { + return errors.New("extension: host api test task force stop context is required") + } + if err := e.sessions.StopWithCause(ctx, strings.TrimSpace(sessionID), session.CauseUserRequested, "task cancellation"); err != nil { + return fmt.Errorf("force task stop: %w", err) + } + return nil +} + func newHostAPITestEnv(t *testing.T, opts ...hostAPITestEnvOption) *hostAPITestEnv { t.Helper() @@ -1892,12 +2961,25 @@ Review the workspace changes carefully. } }) + taskManager, err := taskpkg.NewManager( + taskpkg.WithStore(registry), + taskpkg.WithSessionExecutor(&hostAPITestTaskSessionExecutor{ + sessions: sessions, + globalWorkspacePath: homePaths.HomeDir, + }), + taskpkg.WithManagerNow(func() time.Time { return env.currentTime() }), + ) + if err != nil { + t.Fatalf("task.NewManager() error = %v", err) + } + handler := NewHostAPIHandler( sessions, memoryStore, observer, skillsRegistry, WithHostAPIAutomationManager(automationManager), + WithHostAPITaskManager(taskManager), WithHostAPICapabilityChecker(checker), WithHostAPIWorkspaceResolver(workspaces), WithHostAPIBridgeRegistry(bridgeRegistry), @@ -1913,6 +2995,7 @@ Review the workspace changes carefully. env.bridges = bridgeRegistry env.sessions = sessions env.automation = automationManager + env.tasks = taskManager env.observer = observer env.memory = memoryStore env.skills = skillsRegistry @@ -2059,12 +3142,26 @@ func (e *hostAPITestEnv) useSessionsWithoutObserver(t *testing.T) { t.Fatalf("session.NewManager(without observer) error = %v", err) } + taskManager, err := taskpkg.NewManager( + taskpkg.WithStore(e.registry), + taskpkg.WithSessionExecutor(&hostAPITestTaskSessionExecutor{ + sessions: sessions, + globalWorkspacePath: e.homePaths.HomeDir, + }), + taskpkg.WithManagerNow(func() time.Time { return e.currentTime() }), + ) + if err != nil { + t.Fatalf("task.NewManager(without observer) error = %v", err) + } + e.sessions = sessions + e.tasks = taskManager e.handler = NewHostAPIHandler( e.sessions, e.memory, nil, e.skills, + WithHostAPITaskManager(e.tasks), WithHostAPICapabilityChecker(e.checker), WithHostAPIWorkspaceResolver(e.workspaces), WithHostAPIBridgeRegistry(e.bridges), diff --git a/internal/extension/manager.go b/internal/extension/manager.go index 50572b0c2..2d4a62141 100644 --- a/internal/extension/manager.go +++ b/internal/extension/manager.go @@ -38,6 +38,18 @@ const ( ) var ( + // ErrContextRequired reports that a manager operation requires a non-nil context. + ErrContextRequired = errors.New("extension: context is required") + // ErrManagerRequired reports that a manager-backed operation was invoked on a nil manager. + ErrManagerRequired = errors.New("extension: manager is required") + // ErrRegistryRequired reports that a manager operation requires a configured registry. + ErrRegistryRequired = errors.New("extension: registry is required") + // ErrBridgeRuntimeResolverRequired reports that a bridge-capable extension cannot start + // without a bridge runtime resolver. + ErrBridgeRuntimeResolverRequired = errors.New("extension: bridge runtime resolver is required") + // ErrPathEscapesExtensionRoot reports that a requested resource path resolves outside the + // extension root. + ErrPathEscapesExtensionRoot = errors.New("extension: path escapes extension root") // ErrBridgeRuntimeDeferred reports that a bridge-capable extension is // installed and registered, but no enabled bridge instance exists yet for // the runtime launch handshake. @@ -414,16 +426,16 @@ func NewManager(registry *Registry, opts ...Option) *Manager { // Start loads every enabled extension through the six-phase pipeline. func (m *Manager) Start(ctx context.Context) error { if ctx == nil { - return errors.New("extension: context is required") + return ErrContextRequired } if err := ctx.Err(); err != nil { return err } if m == nil { - return errors.New("extension: manager is required") + return ErrManagerRequired } if m.registry == nil { - return errors.New("extension: registry is required") + return ErrRegistryRequired } m.mu.Lock() @@ -468,10 +480,10 @@ func (m *Manager) Start(ctx context.Context) error { // Stop gracefully drains all active extension subprocesses. func (m *Manager) Stop(ctx context.Context) error { if ctx == nil { - return errors.New("extension: context is required") + return ErrContextRequired } if m == nil { - return errors.New("extension: manager is required") + return ErrManagerRequired } m.mu.Lock() @@ -547,10 +559,10 @@ func (m *Manager) Stop(ctx context.Context) error { // Reload restarts the manager from the current registry state. func (m *Manager) Reload(ctx context.Context) error { if ctx == nil { - return errors.New("extension: context is required") + return ErrContextRequired } if m == nil { - return errors.New("extension: manager is required") + return ErrManagerRequired } stopErr := m.Stop(ctx) @@ -561,7 +573,7 @@ func (m *Manager) Reload(ctx context.Context) error { // Get returns the current snapshot for one installed extension. func (m *Manager) Get(name string) (*Extension, error) { if m == nil { - return nil, errors.New("extension: manager is required") + return nil, ErrManagerRequired } trimmed := strings.TrimSpace(name) @@ -641,7 +653,7 @@ func (m *Manager) DeliverBridge( return bridgepkg.DeliveryAck{}, err } if m == nil { - return bridgepkg.DeliveryAck{}, errors.New("extension: manager is required") + return bridgepkg.DeliveryAck{}, ErrManagerRequired } if err := req.Validate(); err != nil { return bridgepkg.DeliveryAck{}, err @@ -681,13 +693,13 @@ func (m *Manager) DeliverBridge( // HookDeclarations returns the manifest-declared hook resources from loaded extensions. func (m *Manager) HookDeclarations(ctx context.Context) ([]hookspkg.HookDecl, error) { if ctx == nil { - return nil, errors.New("extension: context is required") + return nil, ErrContextRequired } if err := ctx.Err(); err != nil { return nil, err } if m == nil { - return nil, errors.New("extension: manager is required") + return nil, ErrManagerRequired } m.mu.RLock() @@ -1880,7 +1892,7 @@ func (m *Manager) resolveBridgeRuntime(ctx context.Context, ext *managedExtensio return nil, nil } if m.bridgeRuntimeResolver == nil { - return nil, fmt.Errorf("extension: bridge runtime resolver is required for %q", ext.info.Name) + return nil, fmt.Errorf("%w for %q", ErrBridgeRuntimeResolverRequired, ext.info.Name) } bridgeRuntime, err := m.bridgeRuntimeResolver.ResolveBridgeRuntime(ctx, ext.info.Name) @@ -1967,7 +1979,7 @@ func resolvePathWithinRoot(rootDir string, value string) (string, error) { return "", fmt.Errorf("extension: resolve path %q: %w", resolved, err) } if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("extension: path %q escapes extension root %q", resolved, trimmedRoot) + return "", fmt.Errorf("%w: path %q escapes extension root %q", ErrPathEscapesExtensionRoot, resolved, trimmedRoot) } return candidate, nil diff --git a/internal/extension/manager_test.go b/internal/extension/manager_test.go index 80f9693a6..5d0b72121 100644 --- a/internal/extension/manager_test.go +++ b/internal/extension/manager_test.go @@ -28,6 +28,16 @@ const ( extensionHelperMarkerKey = "AGH_TEST_EXTENSION_MARKER" ) +type noopBridgeTelemetrySink struct{} + +var _ BridgeTelemetrySink = (*noopBridgeTelemetrySink)(nil) + +func (noopBridgeTelemetrySink) RecordBridgeAuthFailure(string) {} + +func (noopBridgeTelemetrySink) RecordBridgeRuntimeIssue(string, bridgepkg.BridgeStatus, string) {} + +func (noopBridgeTelemetrySink) ClearBridgeRuntimeIssue(string) {} + func TestExtensionManagerHelperProcess(t *testing.T) { if os.Getenv(extensionHelperEnvKey) != "1" { return @@ -260,8 +270,8 @@ func TestManagerStartBridgeAdapterRequiresScopedLaunchRuntime(t *testing.T) { if err == nil { t.Fatal("Start() error = nil, want missing bridge runtime resolver failure") } - if !strings.Contains(err.Error(), "bridge runtime resolver is required") { - t.Fatalf("Start() error = %v, want bridge runtime resolver failure", err) + if !errors.Is(err, ErrBridgeRuntimeResolverRequired) { + t.Fatalf("Start() error = %v, want %v", err, ErrBridgeRuntimeResolverRequired) } } @@ -674,9 +684,11 @@ func TestManagerStopKillsHungSubprocessAfterTimeout(t *testing.T) { func TestNewManagerAppliesOptionsAndRestoresDefaults(t *testing.T) { t.Parallel() + telemetrySink := &noopBridgeTelemetrySink{} manager := NewManager( nil, WithCapabilityChecker(nil), + WithBridgeTelemetrySink(telemetrySink), WithLogger(nil), WithNow(nil), WithGetenv(nil), @@ -705,6 +717,9 @@ func TestNewManagerAppliesOptionsAndRestoresDefaults(t *testing.T) { if manager.getenv == nil { t.Fatal("getenv = nil, want default env resolver") } + if manager.bridgeTelemetrySink != telemetrySink { + t.Fatalf("bridgeTelemetrySink = %#v, want injected sink %#v", manager.bridgeTelemetrySink, telemetrySink) + } if manager.launch == nil { t.Fatal("launch = nil, want default launcher") } @@ -737,6 +752,96 @@ func TestNewManagerAppliesOptionsAndRestoresDefaults(t *testing.T) { } } +func TestManagerReloadValidatesAndRestarts(t *testing.T) { + t.Parallel() + + t.Run("Should reject nil manager", func(t *testing.T) { + t.Parallel() + + var nilManager *Manager + if err := nilManager.Reload(testutil.Context(t)); !errors.Is(err, ErrManagerRequired) { + t.Fatalf("nil manager Reload() error = %v, want %v", err, ErrManagerRequired) + } + }) + + t.Run("Should reject canceled context", func(t *testing.T) { + t.Parallel() + + manager := NewManager(nil) + ctx, cancel := context.WithCancel(testutil.Context(t)) + cancel() + if err := manager.Reload(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("Reload(canceled context) error = %v, want %v", err, context.Canceled) + } + }) + + t.Run("Should reject missing registry", func(t *testing.T) { + t.Parallel() + + manager := NewManager(nil) + err := manager.Reload(testutil.Context(t)) + if !errors.Is(err, ErrRegistryRequired) { + t.Fatalf("Reload() error = %v, want %v", err, ErrRegistryRequired) + } + }) + + t.Run("Should restart loaded extensions", func(t *testing.T) { + t.Parallel() + + withDaemonVersion(t, "0.5.0") + env := newRegistryTestEnv(t) + fixture := createManagerTestExtension(t, managerTestManifest("ext-reload", managerManifestOptions{ + command: "fake-extension", + capabilities: []string{"memory.backend"}, + actions: []string{"sessions/list"}, + security: []string{"session.read"}, + }), nil) + installManagerFixture(t, env.registry, fixture, SourceUser, true) + + firstProc := newFakeProcess(101) + secondProc := newFakeProcess(202) + launcher := &fakeLauncher{queue: []*fakeProcess{firstProc, secondProc}} + startedManager := NewManager( + env.registry, + withProcessLauncher(launcher.launch), + withHealthPollBounds(time.Millisecond, 2*time.Millisecond), + ) + if err := startedManager.Start(testutil.Context(t)); err != nil { + t.Fatalf("Start() error = %v", err) + } + t.Cleanup(func() { + if err := startedManager.Stop(testutil.Context(t)); err != nil { + t.Fatalf("Stop() cleanup error = %v", err) + } + }) + + if got, want := launcher.launchCount(), 1; got != want { + t.Fatalf("launch count after Start() = %d, want %d", got, want) + } + + if err := startedManager.Reload(testutil.Context(t)); err != nil { + t.Fatalf("Reload(started manager) error = %v", err) + } + + if got, want := launcher.launchCount(), 2; got != want { + t.Fatalf("launch count after Reload() = %d, want %d", got, want) + } + if got, want := firstProc.shutdownCnt, 1; got != want { + t.Fatalf("first process shutdown count = %d, want %d", got, want) + } + if got, want := len(secondProc.initRequests()), 1; got != want { + t.Fatalf("len(second process initialize requests) = %d, want %d", got, want) + } + statuses := startedManager.Statuses() + if got, want := len(statuses), 1; got != want { + t.Fatalf("len(Statuses()) = %d, want %d", got, want) + } + if got, want := statuses[0].PID, 202; got != want { + t.Fatalf("Statuses()[0].PID = %d, want %d after reload restart", got, want) + } + }) +} + func TestManagerHelperPathsAndAccessors(t *testing.T) { t.Parallel() @@ -909,12 +1014,12 @@ func TestManagerResolveCommandKeepsPathLikeValuesInsideExtensionRoot(t *testing. t.Fatalf("resolveCommand(bare) = %q, want %q", got, "node") } - if _, err := manager.resolveCommand(root, "../outside/tool"); err == nil || !strings.Contains(err.Error(), "escapes extension root") { - t.Fatalf("resolveCommand(escape) error = %v, want extension-root escape failure", err) + if _, err := manager.resolveCommand(root, "../outside/tool"); !errors.Is(err, ErrPathEscapesExtensionRoot) { + t.Fatalf("resolveCommand(escape) error = %v, want %v", err, ErrPathEscapesExtensionRoot) } - if _, err := resolveResourcePath(root, "../skills"); err == nil || !strings.Contains(err.Error(), "escapes extension root") { - t.Fatalf("resolveResourcePath(escape) error = %v, want extension-root escape failure", err) + if _, err := resolveResourcePath(root, "../skills"); !errors.Is(err, ErrPathEscapesExtensionRoot) { + t.Fatalf("resolveResourcePath(escape) error = %v, want %v", err, ErrPathEscapesExtensionRoot) } resourceRoot, err := resolveResourcePath(root, "skills") diff --git a/internal/extension/protocol/host_api.go b/internal/extension/protocol/host_api.go index 9f596b5cc..2084f0b79 100644 --- a/internal/extension/protocol/host_api.go +++ b/internal/extension/protocol/host_api.go @@ -53,6 +53,19 @@ const ( HostAPIMethodAutomationTriggersRuns HostAPIMethod = "automation/triggers/runs" HostAPIMethodAutomationTriggersFire HostAPIMethod = "automation/triggers/fire" HostAPIMethodAutomationRuns HostAPIMethod = "automation/runs" + HostAPIMethodTasks HostAPIMethod = "tasks" + HostAPIMethodTasksGet HostAPIMethod = "tasks/get" + HostAPIMethodTasksCreate HostAPIMethod = "tasks/create" + HostAPIMethodTasksUpdate HostAPIMethod = "tasks/update" + HostAPIMethodTasksCancel HostAPIMethod = "tasks/cancel" + HostAPIMethodTasksRuns HostAPIMethod = "tasks/runs" + HostAPIMethodTasksRunsEnqueue HostAPIMethod = "tasks/runs/enqueue" + HostAPIMethodTasksRunsClaim HostAPIMethod = "tasks/runs/claim" + HostAPIMethodTasksRunsStart HostAPIMethod = "tasks/runs/start" + HostAPIMethodTasksRunsAttachSession HostAPIMethod = "tasks/runs/attach_session" + HostAPIMethodTasksRunsComplete HostAPIMethod = "tasks/runs/complete" + HostAPIMethodTasksRunsFail HostAPIMethod = "tasks/runs/fail" + HostAPIMethodTasksRunsCancel HostAPIMethod = "tasks/runs/cancel" HostAPIMethodBridgesMessagesIngest HostAPIMethod = "bridges/messages/ingest" HostAPIMethodBridgesInstancesGet HostAPIMethod = "bridges/instances/get" HostAPIMethodBridgesInstancesReportState HostAPIMethod = "bridges/instances/report_state" @@ -88,6 +101,19 @@ func AllHostAPIMethods() []HostAPIMethod { HostAPIMethodAutomationTriggersRuns, HostAPIMethodAutomationTriggersFire, HostAPIMethodAutomationRuns, + HostAPIMethodTasks, + HostAPIMethodTasksGet, + HostAPIMethodTasksCreate, + HostAPIMethodTasksUpdate, + HostAPIMethodTasksCancel, + HostAPIMethodTasksRuns, + HostAPIMethodTasksRunsEnqueue, + HostAPIMethodTasksRunsClaim, + HostAPIMethodTasksRunsStart, + HostAPIMethodTasksRunsAttachSession, + HostAPIMethodTasksRunsComplete, + HostAPIMethodTasksRunsFail, + HostAPIMethodTasksRunsCancel, HostAPIMethodBridgesMessagesIngest, HostAPIMethodBridgesInstancesGet, HostAPIMethodBridgesInstancesReportState, diff --git a/internal/extension/protocol/host_api_test.go b/internal/extension/protocol/host_api_test.go index 38b22c6b2..5eabd0db4 100644 --- a/internal/extension/protocol/host_api_test.go +++ b/internal/extension/protocol/host_api_test.go @@ -33,6 +33,19 @@ func TestAllHostAPIMethodsReturnsCanonicalWireOrder(t *testing.T) { HostAPIMethodAutomationTriggersRuns, HostAPIMethodAutomationTriggersFire, HostAPIMethodAutomationRuns, + HostAPIMethodTasks, + HostAPIMethodTasksGet, + HostAPIMethodTasksCreate, + HostAPIMethodTasksUpdate, + HostAPIMethodTasksCancel, + HostAPIMethodTasksRuns, + HostAPIMethodTasksRunsEnqueue, + HostAPIMethodTasksRunsClaim, + HostAPIMethodTasksRunsStart, + HostAPIMethodTasksRunsAttachSession, + HostAPIMethodTasksRunsComplete, + HostAPIMethodTasksRunsFail, + HostAPIMethodTasksRunsCancel, HostAPIMethodBridgesMessagesIngest, HostAPIMethodBridgesInstancesGet, HostAPIMethodBridgesInstancesReportState, diff --git a/internal/network/audit.go b/internal/network/audit.go index 840633853..3237118b9 100644 --- a/internal/network/audit.go +++ b/internal/network/audit.go @@ -43,6 +43,24 @@ type AuditWriter interface { RecordDelivered(ctx context.Context, sessionID string, envelope Envelope) error } +// TaskIngressAudit captures one task-domain ingress decision originating from a +// validated network peer. +type TaskIngressAudit struct { + Action string + Direction string + PeerID string + Channel string + RequestID string + Reason string + Payload any +} + +// TaskIngressAuditWriter is the optional audit extension used by task-aware +// network ingress. Existing protocol-message auditing remains unchanged. +type TaskIngressAuditWriter interface { + RecordTaskIngress(ctx context.Context, audit TaskIngressAudit) error +} + // FileAuditWriter writes normalized network audit records to a JSONL file and // optionally mirrors them into a persistent store. type FileAuditWriter struct { @@ -83,6 +101,7 @@ func messageStoreFromAuditStore(auditStore AuditStore) MessageStore { } var _ AuditWriter = (*FileAuditWriter)(nil) +var _ TaskIngressAuditWriter = (*FileAuditWriter)(nil) // RecordSent stores a sent network audit record. func (w *FileAuditWriter) RecordSent(ctx context.Context, sessionID string, envelope Envelope) error { @@ -104,6 +123,45 @@ func (w *FileAuditWriter) RecordDelivered(ctx context.Context, sessionID string, return w.record(ctx, sessionID, AuditDirectionDelivered, envelope, "") } +// RecordTaskIngress stores one accepted or rejected task-ingress audit record +// using the existing network audit sinks. +func (w *FileAuditWriter) RecordTaskIngress(ctx context.Context, audit TaskIngressAudit) error { + if w == nil { + return errors.New("network: audit writer is required") + } + if ctx == nil { + return errors.New("network: audit context is required") + } + if w.path == "" && w.store == nil { + return errors.New("network: audit sink is required") + } + + now := w.now + if now == nil { + now = func() time.Time { + return time.Now().UTC() + } + } + + entry, err := normalizeTaskIngressAuditEntry(audit, now()) + if err != nil { + return fmt.Errorf("network: normalize task ingress audit entry: %w", err) + } + + var recordErr error + if w.path != "" { + if err := w.appendFile(entry); err != nil { + recordErr = errors.Join(recordErr, fmt.Errorf("network: append file audit entry: %w", err)) + } + } + if w.store != nil { + if err := w.store.WriteNetworkAudit(ctx, entry); err != nil { + recordErr = errors.Join(recordErr, fmt.Errorf("network: persist audit entry: %w", err)) + } + } + + return recordErr +} func (w *FileAuditWriter) record(ctx context.Context, sessionID string, direction string, envelope Envelope, reason string) error { if ctx == nil { return errors.New("network: audit context is required") @@ -215,6 +273,36 @@ func normalizeTimelineMessageEntry(sessionID string, direction string, envelope return entry, true, nil } +func normalizeTaskIngressAuditEntry(audit TaskIngressAudit, at time.Time) (store.NetworkAuditEntry, error) { + payloadSize := 0 + if audit.Payload != nil { + payload, err := json.Marshal(audit.Payload) + if err != nil { + return store.NetworkAuditEntry{}, fmt.Errorf("network: marshal task ingress audit payload: %w", err) + } + payloadSize = len(payload) + } + if at.IsZero() { + at = time.Now().UTC() + } + + entry := store.NetworkAuditEntry{ + ID: store.NewID("naud"), + SessionID: "netpeer:" + strings.TrimSpace(audit.PeerID), + Direction: strings.TrimSpace(audit.Direction), + Kind: strings.TrimSpace(audit.Action), + Channel: strings.TrimSpace(audit.Channel), + PeerFrom: strings.TrimSpace(audit.PeerID), + MessageID: strings.TrimSpace(audit.RequestID), + Reason: strings.TrimSpace(audit.Reason), + Size: payloadSize, + Timestamp: at.UTC(), + } + if err := entry.Validate(); err != nil { + return store.NetworkAuditEntry{}, fmt.Errorf("network: validate audit entry: %w", err) + } + return entry, nil +} func (w *FileAuditWriter) appendFile(entry store.NetworkAuditEntry) error { w.mu.Lock() defer w.mu.Unlock() diff --git a/internal/network/audit_test.go b/internal/network/audit_test.go index d9083b93b..d89d20f81 100644 --- a/internal/network/audit_test.go +++ b/internal/network/audit_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" "time" @@ -261,6 +262,146 @@ func TestAuditWriterSkipsTimelineWriteWhenAuditStoreFails(t *testing.T) { }) } +func TestAuditWriterRecordTaskIngress(t *testing.T) { + t.Parallel() + + t.Run("Should record task ingress", func(t *testing.T) { + storeSink := &recordingAuditStore{} + writer, err := NewAuditWriter("", storeSink) + if err != nil { + t.Fatalf("NewAuditWriter() error = %v", err) + } + writer.now = func() time.Time { + return time.Date(2026, 4, 14, 18, 15, 0, 0, time.UTC) + } + + if err := writer.RecordTaskIngress(context.Background(), TaskIngressAudit{ + Action: networkTaskActionEnqueue, + Direction: AuditDirectionRejected, + PeerID: "reviewer.sess-ops", + Channel: "ops", + RequestID: "req-enqueue-1", + Reason: "channel_mismatch", + Payload: map[string]any{ + "task_id": "task-1", + }, + }); err != nil { + t.Fatalf("RecordTaskIngress() error = %v", err) + } + + if got, want := len(storeSink.entries), 1; got != want { + t.Fatalf("len(store entries) = %d, want %d", got, want) + } + entry := storeSink.entries[0] + if got, want := entry.SessionID, "netpeer:reviewer.sess-ops"; got != want { + t.Fatalf("entry.SessionID = %q, want %q", got, want) + } + if got, want := entry.Kind, networkTaskActionEnqueue; got != want { + t.Fatalf("entry.Kind = %q, want %q", got, want) + } + if got, want := entry.Direction, AuditDirectionRejected; got != want { + t.Fatalf("entry.Direction = %q, want %q", got, want) + } + if got, want := entry.Reason, "channel_mismatch"; got != want { + t.Fatalf("entry.Reason = %q, want %q", got, want) + } + if entry.Size <= 0 { + t.Fatalf("entry.Size = %d, want positive payload size", entry.Size) + } + }) + + t.Run("Should reject task ingress when no audit sink is configured", func(t *testing.T) { + writer := &FileAuditWriter{} + + err := writer.RecordTaskIngress(context.Background(), TaskIngressAudit{ + Action: networkTaskActionEnqueue, + Direction: AuditDirectionRejected, + PeerID: "reviewer.sess-ops", + Channel: "ops", + RequestID: "req-enqueue-2", + Reason: "channel_mismatch", + }) + if err == nil || !strings.Contains(err.Error(), "audit sink is required") { + t.Fatalf("RecordTaskIngress(no sink) error = %v, want audit sink validation", err) + } + }) + + t.Run("Should fall back to the current time when the writer clock is unset", func(t *testing.T) { + storeSink := &recordingAuditStore{} + writer := &FileAuditWriter{store: storeSink} + + if err := writer.RecordTaskIngress(context.Background(), TaskIngressAudit{ + Action: networkTaskActionEnqueue, + Direction: AuditDirectionRejected, + PeerID: "reviewer.sess-ops", + Channel: "ops", + RequestID: "req-enqueue-3", + Reason: "channel_mismatch", + }); err != nil { + t.Fatalf("RecordTaskIngress(nil now) error = %v", err) + } + if got, want := len(storeSink.entries), 1; got != want { + t.Fatalf("len(store entries) = %d, want %d", got, want) + } + if storeSink.entries[0].Timestamp.IsZero() { + t.Fatal("entry.Timestamp = zero, want fallback timestamp") + } + }) + + t.Run("Should wrap task ingress normalization failures with operation context", func(t *testing.T) { + writer := &FileAuditWriter{store: &recordingAuditStore{}} + + err := writer.RecordTaskIngress(context.Background(), TaskIngressAudit{ + Direction: AuditDirectionRejected, + PeerID: "reviewer.sess-ops", + Channel: "ops", + RequestID: "req-enqueue-4", + }) + if err == nil || !strings.Contains(err.Error(), "network: normalize task ingress audit entry") { + t.Fatalf("RecordTaskIngress(normalize) error = %v, want normalize context", err) + } + if !strings.Contains(err.Error(), "network: validate audit entry") { + t.Fatalf("RecordTaskIngress(normalize) error = %v, want validate context", err) + } + }) + + t.Run("Should wrap task ingress sink failures with operation context", func(t *testing.T) { + storeErr := errors.New("audit store unavailable") + storeSink := &failingAuditStore{auditErr: storeErr} + writer := &FileAuditWriter{ + path: filepath.Join(t.TempDir(), "audit-dir"), + store: storeSink, + now: func() time.Time { + return time.Date(2026, 4, 14, 18, 15, 0, 0, time.UTC) + }, + } + if err := os.MkdirAll(writer.path, 0o755); err != nil { + t.Fatalf("MkdirAll(%q) error = %v", writer.path, err) + } + + err := writer.RecordTaskIngress(context.Background(), TaskIngressAudit{ + Action: networkTaskActionEnqueue, + Direction: AuditDirectionRejected, + PeerID: "reviewer.sess-ops", + Channel: "ops", + RequestID: "req-enqueue-5", + Reason: "channel_mismatch", + }) + if err == nil { + t.Fatal("RecordTaskIngress(sink failures) error = nil, want joined error") + } + if !errors.Is(err, storeErr) { + t.Fatalf("RecordTaskIngress(sink failures) error = %v, want wrapped store error", err) + } + if !strings.Contains(err.Error(), "network: append file audit entry") { + t.Fatalf("RecordTaskIngress(sink failures) error = %v, want append context", err) + } + if !strings.Contains(err.Error(), "network: persist audit entry") { + t.Fatalf("RecordTaskIngress(sink failures) error = %v, want persist context", err) + } + }) +} + func TestAuditWriterAllowsFileOnlySinksWithoutTimelineNormalization(t *testing.T) { t.Parallel() @@ -280,7 +421,6 @@ func TestAuditWriterAllowsFileOnlySinksWithoutTimelineNormalization(t *testing.T } }) } - func testAuditEnvelope(t *testing.T) Envelope { t.Helper() diff --git a/internal/network/manager.go b/internal/network/manager.go index 0d61f9800..ea946275f 100644 --- a/internal/network/manager.go +++ b/internal/network/manager.go @@ -56,6 +56,7 @@ type managerOptions struct { logger *slog.Logger now func() time.Time auditor AuditWriter + tasks TaskService } type managedSession struct { @@ -85,6 +86,7 @@ type Manager struct { peers *PeerRegistry router *Router auditor AuditWriter + tasks TaskService deliveries *deliveryCoordinator stats *runtimeStats @@ -171,6 +173,7 @@ func NewManager( channels: make(map[string]*managedChannel), connected: true, stats: newRuntimeStats(), + tasks: options.tasks, } transport, err := NewTransport( diff --git a/internal/network/rules/channel.go b/internal/network/rules/channel.go new file mode 100644 index 000000000..48bffc9a1 --- /dev/null +++ b/internal/network/rules/channel.go @@ -0,0 +1,10 @@ +package rules + +import "regexp" + +var channelPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`) + +// ValidChannel reports whether the channel matches the shared network grammar. +func ValidChannel(channel string) bool { + return channelPattern.MatchString(channel) +} diff --git a/internal/network/tasks.go b/internal/network/tasks.go new file mode 100644 index 000000000..e86fbdec4 --- /dev/null +++ b/internal/network/tasks.go @@ -0,0 +1,364 @@ +package network + +import ( + "context" + "errors" + "fmt" + "strings" + + taskpkg "github.com/pedronauck/agh/internal/task" +) + +const ( + networkTaskWriteCapability = "task.write" + + networkTaskActionCreate = "task.create" + networkTaskActionUpdate = "task.update" + networkTaskActionCancel = "task.cancel" + networkTaskActionEnqueue = "task.run.enqueue" +) + +var ( + // ErrTaskIngressUnavailable reports that the network runtime was not wired + // with a task service. + ErrTaskIngressUnavailable = errors.New("network: task ingress is not configured") + // ErrTaskIngressPeerNotFound reports that the supplied peer is not currently + // authenticated in the requested channel. + ErrTaskIngressPeerNotFound = errors.New("network: task ingress peer not found") + // ErrTaskIngressCapabilityDenied reports that the peer lacks the capability + // needed for task ingress. + ErrTaskIngressCapabilityDenied = errors.New("network: task ingress capability denied") + // ErrTaskChannelMismatch reports a request whose bound or requested task + // channel does not match the authenticated ingress channel. + ErrTaskChannelMismatch = errors.New("network: task channel mismatch") + // ErrTaskChannelStale reports a stored task binding that no longer validates + // under the current channel grammar. + ErrTaskChannelStale = errors.New("network: stale task channel") +) + +// TaskService is the narrowed task-domain surface consumed by network ingress. +type TaskService interface { + GetTask(ctx context.Context, id string, actor taskpkg.ActorContext) (*taskpkg.TaskView, error) + CreateTask(ctx context.Context, spec taskpkg.CreateTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) + UpdateTask(ctx context.Context, id string, patch taskpkg.TaskPatch, actor taskpkg.ActorContext) (*taskpkg.Task, error) + CancelTask(ctx context.Context, id string, req taskpkg.CancelTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) + EnqueueRun(ctx context.Context, spec taskpkg.EnqueueRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) +} + +// TaskIngressContext captures the trusted peer identity and delivery metadata +// that network ingress derives from the live runtime rather than the payload. +type TaskIngressContext struct { + PeerID string + Channel string + RequestID string +} + +// Validate reports whether the ingress context contains the mandatory peer and +// delivery identifiers. +func (c TaskIngressContext) Validate() error { + if strings.TrimSpace(c.PeerID) == "" { + return fmt.Errorf("%w: peer id is required", ErrMissingField) + } + if err := ValidatePeerID(strings.TrimSpace(c.PeerID)); err != nil { + return err + } + if strings.TrimSpace(c.Channel) == "" { + return fmt.Errorf("%w: channel is required", ErrMissingField) + } + if err := ValidateChannel(strings.TrimSpace(c.Channel)); err != nil { + return err + } + if strings.TrimSpace(c.RequestID) == "" { + return fmt.Errorf("%w: request id is required", ErrMissingField) + } + return nil +} + +// WithManagerTaskService injects the daemon-owned task manager used for +// authenticated network task ingress. +func WithManagerTaskService(tasks TaskService) ManagerOption { + return func(opts *managerOptions) { + opts.tasks = tasks + } +} + +type resolvedTaskPeerContext struct { + ingress TaskIngressContext + peer RemotePeerEntry + actor taskpkg.ActorContext +} + +// CreateTaskFromPeer creates one task on behalf of an authenticated network +// peer after channel and capability validation succeed. +func (m *Manager) CreateTaskFromPeer(ctx context.Context, ingress TaskIngressContext, spec taskpkg.CreateTask) (*taskpkg.Task, error) { + peerCtx, err := m.resolveTaskPeerContext(ctx, ingress, networkTaskActionCreate, networkTaskWriteCapability) + if err != nil { + return nil, err + } + if err := validateRequestedTaskChannel(peerCtx.ingress.Channel, spec.NetworkChannel); err != nil { + return nil, m.rejectTaskIngress(ctx, peerCtx.ingress, networkTaskActionCreate, err, map[string]any{ + "network_channel": strings.TrimSpace(spec.NetworkChannel), + }) + } + + record, err := m.tasks.CreateTask(ctx, spec, peerCtx.actor) + if err != nil { + return nil, m.rejectTaskIngress(ctx, peerCtx.ingress, networkTaskActionCreate, err, map[string]any{ + "network_channel": strings.TrimSpace(spec.NetworkChannel), + }) + } + m.recordTaskIngress(ctx, peerCtx.ingress, networkTaskActionCreate, AuditDirectionReceived, "", map[string]any{ + "task_id": record.ID, + "network_channel": strings.TrimSpace(record.NetworkChannel), + }) + return record, nil +} + +// UpdateTaskFromPeer applies one mutable task patch through the task manager +// after enforcing channel-bound ingress rules. +func (m *Manager) UpdateTaskFromPeer(ctx context.Context, ingress TaskIngressContext, taskID string, patch taskpkg.TaskPatch) (*taskpkg.Task, error) { + peerCtx, err := m.resolveTaskPeerContext(ctx, ingress, networkTaskActionUpdate, networkTaskWriteCapability) + if err != nil { + return nil, err + } + view, err := m.tasks.GetTask(ctx, strings.TrimSpace(taskID), peerCtx.actor) + if err != nil { + return nil, m.rejectTaskIngress(ctx, peerCtx.ingress, networkTaskActionUpdate, err, nil) + } + if err := enforceBoundTaskChannel(view.Task.ID, view.Task.NetworkChannel, peerCtx.ingress.Channel, &patch); err != nil { + return nil, m.rejectTaskIngress(ctx, peerCtx.ingress, networkTaskActionUpdate, err, map[string]any{ + "task_id": view.Task.ID, + "network_channel": strings.TrimSpace(view.Task.NetworkChannel), + }) + } + if patch.NetworkChannel != nil { + if err := validateRequestedTaskChannel(peerCtx.ingress.Channel, *patch.NetworkChannel); err != nil { + return nil, m.rejectTaskIngress(ctx, peerCtx.ingress, networkTaskActionUpdate, err, map[string]any{ + "task_id": view.Task.ID, + "network_channel": strings.TrimSpace(*patch.NetworkChannel), + }) + } + } + + record, err := m.tasks.UpdateTask(ctx, strings.TrimSpace(taskID), patch, peerCtx.actor) + if err != nil { + return nil, m.rejectTaskIngress(ctx, peerCtx.ingress, networkTaskActionUpdate, err, map[string]any{ + "task_id": view.Task.ID, + }) + } + m.recordTaskIngress(ctx, peerCtx.ingress, networkTaskActionUpdate, AuditDirectionReceived, "", map[string]any{ + "task_id": record.ID, + "network_channel": strings.TrimSpace(record.NetworkChannel), + }) + return record, nil +} + +// CancelTaskFromPeer requests manager-owned task cancellation after validating +// the authenticated peer context and task channel binding. +func (m *Manager) CancelTaskFromPeer(ctx context.Context, ingress TaskIngressContext, taskID string, req taskpkg.CancelTask) (*taskpkg.Task, error) { + peerCtx, err := m.resolveTaskPeerContext(ctx, ingress, networkTaskActionCancel, networkTaskWriteCapability) + if err != nil { + return nil, err + } + view, err := m.tasks.GetTask(ctx, strings.TrimSpace(taskID), peerCtx.actor) + if err != nil { + return nil, m.rejectTaskIngress(ctx, peerCtx.ingress, networkTaskActionCancel, err, nil) + } + if err := enforceBoundTaskChannel(view.Task.ID, view.Task.NetworkChannel, peerCtx.ingress.Channel, nil); err != nil { + return nil, m.rejectTaskIngress(ctx, peerCtx.ingress, networkTaskActionCancel, err, map[string]any{ + "task_id": view.Task.ID, + "network_channel": strings.TrimSpace(view.Task.NetworkChannel), + }) + } + + record, err := m.tasks.CancelTask(ctx, strings.TrimSpace(taskID), req, peerCtx.actor) + if err != nil { + return nil, m.rejectTaskIngress(ctx, peerCtx.ingress, networkTaskActionCancel, err, map[string]any{ + "task_id": view.Task.ID, + }) + } + m.recordTaskIngress(ctx, peerCtx.ingress, networkTaskActionCancel, AuditDirectionReceived, "", map[string]any{ + "task_id": record.ID, + }) + return record, nil +} + +// EnqueueRunFromPeer enqueues one task run from an authenticated network peer +// while preserving origin-scoped idempotency inside the task manager. +func (m *Manager) EnqueueRunFromPeer(ctx context.Context, ingress TaskIngressContext, spec taskpkg.EnqueueRun) (*taskpkg.TaskRun, error) { + peerCtx, err := m.resolveTaskPeerContext(ctx, ingress, networkTaskActionEnqueue, networkTaskWriteCapability) + if err != nil { + return nil, err + } + view, err := m.tasks.GetTask(ctx, strings.TrimSpace(spec.TaskID), peerCtx.actor) + if err != nil { + return nil, m.rejectTaskIngress(ctx, peerCtx.ingress, networkTaskActionEnqueue, err, nil) + } + if err := enforceBoundTaskChannel(view.Task.ID, view.Task.NetworkChannel, peerCtx.ingress.Channel, nil); err != nil { + return nil, m.rejectTaskIngress(ctx, peerCtx.ingress, networkTaskActionEnqueue, err, map[string]any{ + "task_id": view.Task.ID, + "network_channel": strings.TrimSpace(view.Task.NetworkChannel), + }) + } + if err := validateRequestedTaskChannel(peerCtx.ingress.Channel, spec.NetworkChannel); err != nil { + return nil, m.rejectTaskIngress(ctx, peerCtx.ingress, networkTaskActionEnqueue, err, map[string]any{ + "task_id": view.Task.ID, + "network_channel": strings.TrimSpace(spec.NetworkChannel), + }) + } + + run, err := m.tasks.EnqueueRun(ctx, spec, peerCtx.actor) + if err != nil { + return nil, m.rejectTaskIngress(ctx, peerCtx.ingress, networkTaskActionEnqueue, err, map[string]any{ + "task_id": view.Task.ID, + "idempotency_key": strings.TrimSpace(spec.IdempotencyKey), + }) + } + m.recordTaskIngress(ctx, peerCtx.ingress, networkTaskActionEnqueue, AuditDirectionReceived, "", map[string]any{ + "task_id": run.TaskID, + "run_id": run.ID, + "idempotency_key": strings.TrimSpace(run.IdempotencyKey), + "network_channel": strings.TrimSpace(run.NetworkChannel), + }) + return run, nil +} + +func (m *Manager) resolveTaskPeerContext(ctx context.Context, ingress TaskIngressContext, action string, capability string) (resolvedTaskPeerContext, error) { + if ctx == nil { + return resolvedTaskPeerContext{}, errors.New("network: task ingress context is required") + } + if m == nil { + return resolvedTaskPeerContext{}, errors.New("network: manager is required") + } + if err := ingress.Validate(); err != nil { + return resolvedTaskPeerContext{}, err + } + if m.tasks == nil { + return resolvedTaskPeerContext{}, m.rejectTaskIngress(ctx, ingress, action, ErrTaskIngressUnavailable, nil) + } + if m.peers == nil { + return resolvedTaskPeerContext{}, m.rejectTaskIngress(ctx, ingress, action, ErrTaskIngressPeerNotFound, nil) + } + + peer, ok := m.peers.RemoteByPeer(ingress.Channel, ingress.PeerID, m.now().UTC()) + if !ok { + return resolvedTaskPeerContext{}, m.rejectTaskIngress(ctx, ingress, action, ErrTaskIngressPeerNotFound, nil) + } + if !containsString(peer.PeerCard.Capabilities, capability) { + return resolvedTaskPeerContext{}, m.rejectTaskIngress(ctx, ingress, action, ErrTaskIngressCapabilityDenied, nil) + } + + actor, err := taskpkg.DeriveNetworkPeerActorContext(strings.TrimSpace(ingress.PeerID), networkTaskOriginRef(ingress)) + if err != nil { + return resolvedTaskPeerContext{}, m.rejectTaskIngress(ctx, ingress, action, err, nil) + } + return resolvedTaskPeerContext{ + ingress: ingress, + peer: peer, + actor: actor, + }, nil +} + +func (m *Manager) rejectTaskIngress(ctx context.Context, ingress TaskIngressContext, action string, err error, payload any) error { + if err == nil { + return nil + } + m.recordTaskIngress(ctx, ingress, action, AuditDirectionRejected, taskIngressReason(err), payload) + return err +} + +func (m *Manager) recordTaskIngress(ctx context.Context, ingress TaskIngressContext, action string, direction string, reason string, payload any) { + if m == nil || m.auditor == nil { + return + } + writer, ok := m.auditor.(TaskIngressAuditWriter) + if !ok { + return + } + if err := writer.RecordTaskIngress(ctx, TaskIngressAudit{ + Action: strings.TrimSpace(action), + Direction: strings.TrimSpace(direction), + PeerID: strings.TrimSpace(ingress.PeerID), + Channel: strings.TrimSpace(ingress.Channel), + RequestID: strings.TrimSpace(ingress.RequestID), + Reason: strings.TrimSpace(reason), + Payload: payload, + }); err != nil { + m.logger.Warn("network.audit.record_task_ingress_failed", "action", action, "peer_id", ingress.PeerID, "request_id", ingress.RequestID, "error", err) + } +} + +func validateRequestedTaskChannel(ingressChannel string, requestedChannel string) error { + trimmed := strings.TrimSpace(requestedChannel) + if trimmed == "" { + return nil + } + if err := ValidateChannel(trimmed); err != nil { + return err + } + if trimmed != strings.TrimSpace(ingressChannel) { + return fmt.Errorf("%w: requested channel %q does not match ingress channel %q", ErrTaskChannelMismatch, trimmed, strings.TrimSpace(ingressChannel)) + } + return nil +} + +func enforceBoundTaskChannel(taskID string, boundChannel string, ingressChannel string, patch *taskpkg.TaskPatch) error { + trimmedBound := strings.TrimSpace(boundChannel) + if trimmedBound == "" { + return nil + } + if err := ValidateChannel(trimmedBound); err != nil { + if patch != nil && patchAllowsStaleChannelRepair(strings.TrimSpace(ingressChannel), *patch) { + return nil + } + return fmt.Errorf("%w: task %q channel %q no longer validates", ErrTaskChannelStale, strings.TrimSpace(taskID), trimmedBound) + } + if trimmedBound != strings.TrimSpace(ingressChannel) { + return fmt.Errorf("%w: task %q channel %q does not match ingress channel %q", ErrTaskChannelMismatch, strings.TrimSpace(taskID), trimmedBound, strings.TrimSpace(ingressChannel)) + } + return nil +} + +func patchAllowsStaleChannelRepair(ingressChannel string, patch taskpkg.TaskPatch) bool { + if patch.NetworkChannel == nil { + return false + } + trimmed := strings.TrimSpace(*patch.NetworkChannel) + if trimmed == "" { + return true + } + return trimmed == strings.TrimSpace(ingressChannel) +} + +func networkTaskOriginRef(ingress TaskIngressContext) string { + return fmt.Sprintf("peer:%s/channel:%s", strings.TrimSpace(ingress.PeerID), strings.TrimSpace(ingress.Channel)) +} + +func taskIngressReason(err error) string { + switch { + case err == nil: + return "" + case errors.Is(err, ErrTaskChannelMismatch): + return "channel_mismatch" + case errors.Is(err, ErrTaskChannelStale): + return "stale_channel" + case errors.Is(err, ErrTaskIngressCapabilityDenied): + return "capability_denied" + case errors.Is(err, ErrTaskIngressPeerNotFound): + return "peer_not_found" + case errors.Is(err, ErrTaskIngressUnavailable): + return "task_ingress_unavailable" + case errors.Is(err, taskpkg.ErrTaskNotFound): + return "task_not_found" + case errors.Is(err, taskpkg.ErrValidation): + return "validation_failed" + case errors.Is(err, taskpkg.ErrPermissionDenied): + return "permission_denied" + case errors.Is(err, taskpkg.ErrStaleNetworkChannel): + return "stale_channel" + case errors.Is(err, ErrMissingField), errors.Is(err, ErrInvalidField): + return "invalid_request" + default: + return "task_ingress_failed" + } +} diff --git a/internal/network/tasks_integration_test.go b/internal/network/tasks_integration_test.go new file mode 100644 index 000000000..217d0a6e7 --- /dev/null +++ b/internal/network/tasks_integration_test.go @@ -0,0 +1,316 @@ +//go:build integration + +package network + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/pedronauck/agh/internal/store" + "github.com/pedronauck/agh/internal/store/globaldb" + taskpkg "github.com/pedronauck/agh/internal/task" + "github.com/pedronauck/agh/internal/testutil" +) + +func TestNetworkTaskIngressCreateAndEnqueueRun(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t) + now := time.Date(2026, 4, 14, 18, 30, 0, 0, time.UTC) + db := openNetworkTaskIngressDB(t) + taskManager := newNetworkTaskIntegrationManager(t, db, taskpkg.WithNetworkChannelValidator(ValidateChannel)) + manager := newNetworkTaskIngressManager(t, ctx, now, db, taskManager) + + peerID := "reviewer.sess-ops" + registerRemoteTaskPeer(t, manager, now, "ops", peerID, []string{networkTaskWriteCapability, "task.read"}) + + created, err := manager.CreateTaskFromPeer(ctx, TaskIngressContext{ + PeerID: peerID, + Channel: "ops", + RequestID: "req-create-1", + }, taskpkg.CreateTask{ + Scope: taskpkg.ScopeGlobal, + Title: "Peer-created task", + NetworkChannel: "ops", + }) + if err != nil { + t.Fatalf("CreateTaskFromPeer() error = %v", err) + } + if got, want := created.CreatedBy.Kind, taskpkg.ActorKindNetworkPeer; got != want { + t.Fatalf("created.CreatedBy.Kind = %q, want %q", got, want) + } + if got, want := created.CreatedBy.Ref, peerID; got != want { + t.Fatalf("created.CreatedBy.Ref = %q, want %q", got, want) + } + if got, want := created.Origin.Ref, "peer:"+peerID+"/channel:ops"; got != want { + t.Fatalf("created.Origin.Ref = %q, want %q", got, want) + } + + run, err := manager.EnqueueRunFromPeer(ctx, TaskIngressContext{ + PeerID: peerID, + Channel: "ops", + RequestID: "req-enqueue-1", + }, taskpkg.EnqueueRun{ + TaskID: created.ID, + IdempotencyKey: "idem-peer-enqueue-1", + }) + if err != nil { + t.Fatalf("EnqueueRunFromPeer() error = %v", err) + } + if got, want := run.TaskID, created.ID; got != want { + t.Fatalf("run.TaskID = %q, want %q", got, want) + } + if got, want := run.NetworkChannel, "ops"; got != want { + t.Fatalf("run.NetworkChannel = %q, want %q", got, want) + } + if got, want := run.Origin.Ref, "peer:"+peerID+"/channel:ops"; got != want { + t.Fatalf("run.Origin.Ref = %q, want %q", got, want) + } + + storedTask, err := db.GetTask(ctx, created.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if got, want := storedTask.NetworkChannel, "ops"; got != want { + t.Fatalf("storedTask.NetworkChannel = %q, want %q", got, want) + } + + storedRun, err := db.GetTaskRun(ctx, run.ID) + if err != nil { + t.Fatalf("GetTaskRun() error = %v", err) + } + if got, want := storedRun.IdempotencyKey, "idem-peer-enqueue-1"; got != want { + t.Fatalf("storedRun.IdempotencyKey = %q, want %q", got, want) + } + + createAudit := findNetworkAuditByMessageID(t, db, "req-create-1") + if got, want := createAudit.Direction, AuditDirectionReceived; got != want { + t.Fatalf("create audit direction = %q, want %q", got, want) + } + if got, want := createAudit.Kind, networkTaskActionCreate; got != want { + t.Fatalf("create audit kind = %q, want %q", got, want) + } + + enqueueAudit := findNetworkAuditByMessageID(t, db, "req-enqueue-1") + if got, want := enqueueAudit.Direction, AuditDirectionReceived; got != want { + t.Fatalf("enqueue audit direction = %q, want %q", got, want) + } + if got, want := enqueueAudit.Kind, networkTaskActionEnqueue; got != want { + t.Fatalf("enqueue audit kind = %q, want %q", got, want) + } +} + +func TestNetworkTaskIngressMismatchRecordsAuditWithoutMutation(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t) + now := time.Date(2026, 4, 14, 18, 35, 0, 0, time.UTC) + db := openNetworkTaskIngressDB(t) + taskManager := newNetworkTaskIntegrationManager(t, db, taskpkg.WithNetworkChannelValidator(ValidateChannel)) + manager := newNetworkTaskIngressManager(t, ctx, now, db, taskManager) + + peerID := "reviewer.sess-ops" + registerRemoteTaskPeer(t, manager, now, "ops", peerID, []string{networkTaskWriteCapability}) + + actor, err := taskpkg.DeriveHumanActorContext("user-1", taskpkg.OriginKindCLI, "agh task create") + if err != nil { + t.Fatalf("DeriveHumanActorContext() error = %v", err) + } + taskRecord, err := taskManager.CreateTask(ctx, taskpkg.CreateTask{ + Scope: taskpkg.ScopeGlobal, + Title: "Finance task", + NetworkChannel: "finance", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + originalUpdatedAt := taskRecord.UpdatedAt + + _, err = manager.EnqueueRunFromPeer(ctx, TaskIngressContext{ + PeerID: peerID, + Channel: "ops", + RequestID: "req-enqueue-mismatch", + }, taskpkg.EnqueueRun{ + TaskID: taskRecord.ID, + IdempotencyKey: "idem-mismatch", + }) + if !errors.Is(err, ErrTaskChannelMismatch) { + t.Fatalf("EnqueueRunFromPeer() error = %v, want %v", err, ErrTaskChannelMismatch) + } + + storedTask, err := db.GetTask(ctx, taskRecord.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if got, want := storedTask.NetworkChannel, "finance"; got != want { + t.Fatalf("storedTask.NetworkChannel = %q, want %q", got, want) + } + if !storedTask.UpdatedAt.Equal(originalUpdatedAt) { + t.Fatalf("storedTask.UpdatedAt = %s, want unchanged %s", storedTask.UpdatedAt, originalUpdatedAt) + } + + runs, err := db.ListTaskRuns(ctx, taskpkg.TaskRunQuery{TaskID: taskRecord.ID}) + if err != nil { + t.Fatalf("ListTaskRuns() error = %v", err) + } + if got := len(runs); got != 0 { + t.Fatalf("len(runs) = %d, want 0", got) + } + + audit := findNetworkAuditByMessageID(t, db, "req-enqueue-mismatch") + if got, want := audit.Direction, AuditDirectionRejected; got != want { + t.Fatalf("audit.Direction = %q, want %q", got, want) + } + if got, want := audit.Kind, networkTaskActionEnqueue; got != want { + t.Fatalf("audit.Kind = %q, want %q", got, want) + } + if got, want := audit.Reason, "channel_mismatch"; got != want { + t.Fatalf("audit.Reason = %q, want %q", got, want) + } +} + +func TestNetworkTaskIngressDuplicateEnqueueUsesCanonicalRun(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t) + now := time.Date(2026, 4, 14, 18, 40, 0, 0, time.UTC) + db := openNetworkTaskIngressDB(t) + taskManager := newNetworkTaskIntegrationManager(t, db, taskpkg.WithNetworkChannelValidator(ValidateChannel)) + manager := newNetworkTaskIngressManager(t, ctx, now, db, taskManager) + + peerID := "reviewer.sess-ops" + registerRemoteTaskPeer(t, manager, now, "ops", peerID, []string{networkTaskWriteCapability}) + + taskRecord, err := manager.CreateTaskFromPeer(ctx, TaskIngressContext{ + PeerID: peerID, + Channel: "ops", + RequestID: "req-create-dup", + }, taskpkg.CreateTask{ + Scope: taskpkg.ScopeGlobal, + Title: "Idempotent peer task", + NetworkChannel: "ops", + }) + if err != nil { + t.Fatalf("CreateTaskFromPeer() error = %v", err) + } + + firstRun, err := manager.EnqueueRunFromPeer(ctx, TaskIngressContext{ + PeerID: peerID, + Channel: "ops", + RequestID: "req-enqueue-dup-1", + }, taskpkg.EnqueueRun{ + TaskID: taskRecord.ID, + IdempotencyKey: "idem-dup-1", + }) + if err != nil { + t.Fatalf("EnqueueRunFromPeer(first) error = %v", err) + } + secondRun, err := manager.EnqueueRunFromPeer(ctx, TaskIngressContext{ + PeerID: peerID, + Channel: "ops", + RequestID: "req-enqueue-dup-2", + }, taskpkg.EnqueueRun{ + TaskID: taskRecord.ID, + IdempotencyKey: "idem-dup-1", + }) + if err != nil { + t.Fatalf("EnqueueRunFromPeer(duplicate) error = %v", err) + } + if got, want := secondRun.ID, firstRun.ID; got != want { + t.Fatalf("duplicate run id = %q, want %q", got, want) + } + + runs, err := db.ListTaskRuns(ctx, taskpkg.TaskRunQuery{TaskID: taskRecord.ID}) + if err != nil { + t.Fatalf("ListTaskRuns() error = %v", err) + } + if got, want := len(runs), 1; got != want { + t.Fatalf("len(runs) = %d, want %d", got, want) + } +} + +func openNetworkTaskIngressDB(t *testing.T) *globaldb.GlobalDB { + t.Helper() + + ctx := testutil.Context(t) + db, err := globaldb.OpenGlobalDB(ctx, filepath.Join(t.TempDir(), "agh.db")) + if err != nil { + t.Fatalf("OpenGlobalDB() error = %v", err) + } + t.Cleanup(func() { + if err := db.Close(context.Background()); err != nil { + t.Fatalf("GlobalDB.Close() error = %v", err) + } + }) + return db +} + +func newNetworkTaskIntegrationManager(t *testing.T, store taskpkg.Store, extraOpts ...taskpkg.Option) *taskpkg.TaskManager { + t.Helper() + + options := []taskpkg.Option{taskpkg.WithStore(store)} + options = append(options, extraOpts...) + manager, err := taskpkg.NewManager(options...) + if err != nil { + t.Fatalf("task.NewManager() error = %v", err) + } + return manager +} + +func newNetworkTaskIngressManager(t *testing.T, ctx context.Context, now time.Time, auditStore AuditStore, tasks TaskService) *Manager { + t.Helper() + + manager, err := NewManager( + ctx, + testManagerConfig(), + newFakeDeliveryPrompter(), + filepath.Join(t.TempDir(), "network.audit"), + auditStore, + WithManagerLogger(discardManagerLogger()), + WithManagerClock(func() time.Time { return now }), + WithManagerTaskService(tasks), + ) + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + t.Cleanup(func() { + if err := manager.Shutdown(context.Background()); err != nil { + t.Fatalf("Shutdown() error = %v", err) + } + }) + return manager +} + +func registerRemoteTaskPeer(t *testing.T, manager *Manager, now time.Time, channel string, peerID string, capabilities []string) { + t.Helper() + + card, err := DefaultPeerCard(peerID) + if err != nil { + t.Fatalf("DefaultPeerCard(%q) error = %v", peerID, err) + } + card.Capabilities = append([]string(nil), capabilities...) + if _, stored, err := manager.peers.RefreshRemote(channel, card, now); err != nil { + t.Fatalf("RefreshRemote(%q, %q) error = %v", channel, peerID, err) + } else if !stored { + t.Fatalf("RefreshRemote(%q, %q) stored = false, want true", channel, peerID) + } +} + +func findNetworkAuditByMessageID(t *testing.T, db *globaldb.GlobalDB, messageID string) store.NetworkAuditEntry { + t.Helper() + + entries, err := db.ListNetworkAudit(testutil.Context(t), store.NetworkAuditQuery{ + MessageID: messageID, + Limit: 10, + }) + if err != nil { + t.Fatalf("ListNetworkAudit(%q) error = %v", messageID, err) + } + if len(entries) != 1 { + t.Fatalf("ListNetworkAudit(%q) returned %d entries, want exactly 1", messageID, len(entries)) + } + return entries[0] +} diff --git a/internal/network/tasks_test.go b/internal/network/tasks_test.go new file mode 100644 index 000000000..0b70c5176 --- /dev/null +++ b/internal/network/tasks_test.go @@ -0,0 +1,462 @@ +package network + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + taskpkg "github.com/pedronauck/agh/internal/task" +) + +type fakeNetworkTaskService struct { + getTaskFn func(context.Context, string, taskpkg.ActorContext) (*taskpkg.TaskView, error) + createTaskFn func(context.Context, taskpkg.CreateTask, taskpkg.ActorContext) (*taskpkg.Task, error) + updateTaskFn func(context.Context, string, taskpkg.TaskPatch, taskpkg.ActorContext) (*taskpkg.Task, error) + cancelTaskFn func(context.Context, string, taskpkg.CancelTask, taskpkg.ActorContext) (*taskpkg.Task, error) + enqueueRunFn func(context.Context, taskpkg.EnqueueRun, taskpkg.ActorContext) (*taskpkg.TaskRun, error) +} + +func (f fakeNetworkTaskService) GetTask(ctx context.Context, id string, actor taskpkg.ActorContext) (*taskpkg.TaskView, error) { + if f.getTaskFn == nil { + return nil, errors.New("unexpected GetTask call") + } + return f.getTaskFn(ctx, id, actor) +} + +func (f fakeNetworkTaskService) CreateTask(ctx context.Context, spec taskpkg.CreateTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) { + if f.createTaskFn == nil { + return nil, errors.New("unexpected CreateTask call") + } + return f.createTaskFn(ctx, spec, actor) +} + +func (f fakeNetworkTaskService) UpdateTask(ctx context.Context, id string, patch taskpkg.TaskPatch, actor taskpkg.ActorContext) (*taskpkg.Task, error) { + if f.updateTaskFn == nil { + return nil, errors.New("unexpected UpdateTask call") + } + return f.updateTaskFn(ctx, id, patch, actor) +} + +func (f fakeNetworkTaskService) CancelTask(ctx context.Context, id string, req taskpkg.CancelTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) { + if f.cancelTaskFn == nil { + return nil, errors.New("unexpected CancelTask call") + } + return f.cancelTaskFn(ctx, id, req, actor) +} + +func (f fakeNetworkTaskService) EnqueueRun(ctx context.Context, spec taskpkg.EnqueueRun, actor taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + if f.enqueueRunFn == nil { + return nil, errors.New("unexpected EnqueueRun call") + } + return f.enqueueRunFn(ctx, spec, actor) +} + +type taskIngressAuditRecorder struct { + mu sync.Mutex + records []TaskIngressAudit +} + +var _ AuditWriter = (*taskIngressAuditRecorder)(nil) +var _ TaskIngressAuditWriter = (*taskIngressAuditRecorder)(nil) + +func (r *taskIngressAuditRecorder) RecordSent(context.Context, string, Envelope) error { + return nil +} + +func (r *taskIngressAuditRecorder) RecordReceived(context.Context, string, Envelope) error { + return nil +} + +func (r *taskIngressAuditRecorder) RecordRejected(context.Context, string, Envelope, string) error { + return nil +} + +func (r *taskIngressAuditRecorder) RecordDelivered(context.Context, string, Envelope) error { + return nil +} + +func (r *taskIngressAuditRecorder) RecordTaskIngress(_ context.Context, audit TaskIngressAudit) error { + r.mu.Lock() + defer r.mu.Unlock() + r.records = append(r.records, audit) + return nil +} + +func (r *taskIngressAuditRecorder) snapshot() []TaskIngressAudit { + r.mu.Lock() + defer r.mu.Unlock() + return append([]TaskIngressAudit(nil), r.records...) +} + +func TestEnqueueRunFromPeerRejectsChannelMismatchAndAudits(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 14, 18, 0, 0, 0, time.UTC) + peerID := "reviewer.sess-ops" + auditor := &taskIngressAuditRecorder{} + var getActor taskpkg.ActorContext + enqueueCalled := false + manager := &Manager{ + logger: discardManagerLogger(), + now: func() time.Time { return now }, + peers: newRemotePeerRegistry(t, now, "ops", peerID, []string{networkTaskWriteCapability}), + auditor: auditor, + tasks: fakeNetworkTaskService{ + getTaskFn: func(_ context.Context, id string, actor taskpkg.ActorContext) (*taskpkg.TaskView, error) { + getActor = actor + return &taskpkg.TaskView{ + Task: taskpkg.Task{ + ID: id, + Scope: taskpkg.ScopeGlobal, + Title: "Bound task", + NetworkChannel: "finance", + }, + }, nil + }, + enqueueRunFn: func(context.Context, taskpkg.EnqueueRun, taskpkg.ActorContext) (*taskpkg.TaskRun, error) { + enqueueCalled = true + return nil, nil + }, + }, + } + + _, err := manager.EnqueueRunFromPeer(context.Background(), TaskIngressContext{ + PeerID: peerID, + Channel: "ops", + RequestID: "req-enqueue-1", + }, taskpkg.EnqueueRun{ + TaskID: "task-1", + IdempotencyKey: "idem-1", + }) + if !errors.Is(err, ErrTaskChannelMismatch) { + t.Fatalf("EnqueueRunFromPeer() error = %v, want %v", err, ErrTaskChannelMismatch) + } + if enqueueCalled { + t.Fatal("EnqueueRunFromPeer() called task service enqueue on channel mismatch") + } + if got, want := getActor.Actor.Kind, taskpkg.ActorKindNetworkPeer; got != want { + t.Fatalf("GetTask actor kind = %q, want %q", got, want) + } + if got, want := getActor.Actor.Ref, peerID; got != want { + t.Fatalf("GetTask actor ref = %q, want %q", got, want) + } + if got, want := getActor.Origin.Ref, "peer:"+peerID+"/channel:ops"; got != want { + t.Fatalf("GetTask origin ref = %q, want %q", got, want) + } + + records := auditor.snapshot() + if got, want := len(records), 1; got != want { + t.Fatalf("len(task ingress audit records) = %d, want %d", got, want) + } + if got, want := records[0].Action, networkTaskActionEnqueue; got != want { + t.Fatalf("audit action = %q, want %q", got, want) + } + if got, want := records[0].Direction, AuditDirectionRejected; got != want { + t.Fatalf("audit direction = %q, want %q", got, want) + } + if got, want := records[0].Reason, "channel_mismatch"; got != want { + t.Fatalf("audit reason = %q, want %q", got, want) + } +} + +func TestCreateTaskFromPeerUsesServerDerivedIdentityAndAcceptedAudit(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 14, 18, 2, 0, 0, time.UTC) + peerID := "reviewer.sess-ops" + auditor := &taskIngressAuditRecorder{} + var createActor taskpkg.ActorContext + manager := &Manager{ + logger: discardManagerLogger(), + now: func() time.Time { return now }, + peers: newRemotePeerRegistry(t, now, "ops", peerID, []string{networkTaskWriteCapability}), + auditor: auditor, + tasks: fakeNetworkTaskService{ + createTaskFn: func(_ context.Context, spec taskpkg.CreateTask, actor taskpkg.ActorContext) (*taskpkg.Task, error) { + createActor = actor + if got, want := spec.NetworkChannel, "ops"; got != want { + t.Fatalf("CreateTask spec.NetworkChannel = %q, want %q", got, want) + } + return &taskpkg.Task{ + ID: "task-1", + Scope: taskpkg.ScopeGlobal, + Title: spec.Title, + NetworkChannel: spec.NetworkChannel, + CreatedBy: actor.Actor, + Origin: actor.Origin, + }, nil + }, + }, + } + + record, err := manager.CreateTaskFromPeer(context.Background(), TaskIngressContext{ + PeerID: peerID, + Channel: "ops", + RequestID: "req-create-1", + }, taskpkg.CreateTask{ + Scope: taskpkg.ScopeGlobal, + Title: "Peer task", + NetworkChannel: "ops", + }) + if err != nil { + t.Fatalf("CreateTaskFromPeer() error = %v", err) + } + if got, want := createActor.Actor.Kind, taskpkg.ActorKindNetworkPeer; got != want { + t.Fatalf("CreateTask actor kind = %q, want %q", got, want) + } + if got, want := createActor.Origin.Ref, "peer:"+peerID+"/channel:ops"; got != want { + t.Fatalf("CreateTask origin ref = %q, want %q", got, want) + } + if got, want := record.CreatedBy.Ref, peerID; got != want { + t.Fatalf("record.CreatedBy.Ref = %q, want %q", got, want) + } + + records := auditor.snapshot() + if got, want := len(records), 1; got != want { + t.Fatalf("len(task ingress audit records) = %d, want %d", got, want) + } + if got, want := records[0].Direction, AuditDirectionReceived; got != want { + t.Fatalf("audit direction = %q, want %q", got, want) + } + if got, want := records[0].Action, networkTaskActionCreate; got != want { + t.Fatalf("audit action = %q, want %q", got, want) + } +} + +func TestUpdateTaskFromPeerAllowsOnlyStaleChannelRepair(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 14, 18, 5, 0, 0, time.UTC) + peerID := "reviewer.sess-ops" + + t.Run("allows clearing stale channel", func(t *testing.T) { + t.Parallel() + + auditor := &taskIngressAuditRecorder{} + updateCalled := false + manager := &Manager{ + logger: discardManagerLogger(), + now: func() time.Time { return now }, + peers: newRemotePeerRegistry(t, now, "ops", peerID, []string{networkTaskWriteCapability}), + auditor: auditor, + tasks: fakeNetworkTaskService{ + getTaskFn: func(_ context.Context, id string, _ taskpkg.ActorContext) (*taskpkg.TaskView, error) { + return &taskpkg.TaskView{ + Task: taskpkg.Task{ + ID: id, + Scope: taskpkg.ScopeGlobal, + Title: "Stale task", + NetworkChannel: "Finance", + }, + }, nil + }, + updateTaskFn: func(_ context.Context, id string, patch taskpkg.TaskPatch, _ taskpkg.ActorContext) (*taskpkg.Task, error) { + updateCalled = true + if patch.NetworkChannel == nil || *patch.NetworkChannel != "" { + t.Fatalf("update patch network_channel = %#v, want explicit clear", patch.NetworkChannel) + } + return &taskpkg.Task{ + ID: id, + Scope: taskpkg.ScopeGlobal, + Title: "Stale task", + }, nil + }, + }, + } + + clear := "" + record, err := manager.UpdateTaskFromPeer(context.Background(), TaskIngressContext{ + PeerID: peerID, + Channel: "ops", + RequestID: "req-update-clear", + }, "task-1", taskpkg.TaskPatch{NetworkChannel: &clear}) + if err != nil { + t.Fatalf("UpdateTaskFromPeer(clear stale channel) error = %v", err) + } + if !updateCalled { + t.Fatal("UpdateTaskFromPeer(clear stale channel) did not call task service update") + } + if got, want := record.ID, "task-1"; got != want { + t.Fatalf("updated record id = %q, want %q", got, want) + } + + records := auditor.snapshot() + if got, want := len(records), 1; got != want { + t.Fatalf("len(task ingress audit records) = %d, want %d", got, want) + } + if got, want := records[0].Direction, AuditDirectionReceived; got != want { + t.Fatalf("audit direction = %q, want %q", got, want) + } + }) + + t.Run("rejects unrelated writes while stale channel remains", func(t *testing.T) { + t.Parallel() + + auditor := &taskIngressAuditRecorder{} + updateCalled := false + manager := &Manager{ + logger: discardManagerLogger(), + now: func() time.Time { return now }, + peers: newRemotePeerRegistry(t, now, "ops", peerID, []string{networkTaskWriteCapability}), + auditor: auditor, + tasks: fakeNetworkTaskService{ + getTaskFn: func(_ context.Context, id string, _ taskpkg.ActorContext) (*taskpkg.TaskView, error) { + return &taskpkg.TaskView{ + Task: taskpkg.Task{ + ID: id, + Scope: taskpkg.ScopeGlobal, + Title: "Stale task", + NetworkChannel: "Finance", + }, + }, nil + }, + updateTaskFn: func(context.Context, string, taskpkg.TaskPatch, taskpkg.ActorContext) (*taskpkg.Task, error) { + updateCalled = true + return nil, nil + }, + }, + } + + title := "Renamed" + _, err := manager.UpdateTaskFromPeer(context.Background(), TaskIngressContext{ + PeerID: peerID, + Channel: "ops", + RequestID: "req-update-title", + }, "task-1", taskpkg.TaskPatch{Title: &title}) + if !errors.Is(err, ErrTaskChannelStale) { + t.Fatalf("UpdateTaskFromPeer(unrelated stale update) error = %v, want %v", err, ErrTaskChannelStale) + } + if updateCalled { + t.Fatal("UpdateTaskFromPeer(unrelated stale update) called task service update") + } + + records := auditor.snapshot() + if got, want := len(records), 1; got != want { + t.Fatalf("len(task ingress audit records) = %d, want %d", got, want) + } + if got, want := records[0].Direction, AuditDirectionRejected; got != want { + t.Fatalf("audit direction = %q, want %q", got, want) + } + if got, want := records[0].Reason, "stale_channel"; got != want { + t.Fatalf("audit reason = %q, want %q", got, want) + } + }) +} + +func TestCancelTaskFromPeerRejectsPeerWithoutTaskWriteCapability(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 14, 18, 6, 0, 0, time.UTC) + peerID := "reviewer.sess-ops" + auditor := &taskIngressAuditRecorder{} + cancelCalled := false + manager := &Manager{ + logger: discardManagerLogger(), + now: func() time.Time { return now }, + peers: newRemotePeerRegistry(t, now, "ops", peerID, []string{"task.read"}), + auditor: auditor, + tasks: fakeNetworkTaskService{ + cancelTaskFn: func(context.Context, string, taskpkg.CancelTask, taskpkg.ActorContext) (*taskpkg.Task, error) { + cancelCalled = true + return nil, nil + }, + }, + } + + _, err := manager.CancelTaskFromPeer(context.Background(), TaskIngressContext{ + PeerID: peerID, + Channel: "ops", + RequestID: "req-cancel-1", + }, "task-1", taskpkg.CancelTask{}) + if !errors.Is(err, ErrTaskIngressCapabilityDenied) { + t.Fatalf("CancelTaskFromPeer() error = %v, want %v", err, ErrTaskIngressCapabilityDenied) + } + if cancelCalled { + t.Fatal("CancelTaskFromPeer() called task service cancel without task.write capability") + } + + records := auditor.snapshot() + if got, want := len(records), 1; got != want { + t.Fatalf("len(task ingress audit records) = %d, want %d", got, want) + } + if got, want := records[0].Reason, "capability_denied"; got != want { + t.Fatalf("audit reason = %q, want %q", got, want) + } +} + +func TestTaskIngressHelpersCoverValidationAndReasonMapping(t *testing.T) { + t.Parallel() + + t.Run("validates ingress context fields", func(t *testing.T) { + t.Parallel() + + if err := (TaskIngressContext{}).Validate(); err == nil { + t.Fatal("TaskIngressContext{}.Validate() error = nil, want non-nil") + } + if err := (TaskIngressContext{PeerID: "bad peer", Channel: "ops", RequestID: "req-1"}).Validate(); err == nil { + t.Fatal("TaskIngressContext(invalid peer).Validate() error = nil, want non-nil") + } + }) + + t.Run("covers reason mapping", func(t *testing.T) { + t.Parallel() + + testCases := []struct { + err error + want string + }{ + {err: ErrTaskChannelMismatch, want: "channel_mismatch"}, + {err: ErrTaskChannelStale, want: "stale_channel"}, + {err: ErrTaskIngressCapabilityDenied, want: "capability_denied"}, + {err: ErrTaskIngressPeerNotFound, want: "peer_not_found"}, + {err: ErrTaskIngressUnavailable, want: "task_ingress_unavailable"}, + {err: taskpkg.ErrTaskNotFound, want: "task_not_found"}, + {err: taskpkg.ErrValidation, want: "validation_failed"}, + {err: taskpkg.ErrPermissionDenied, want: "permission_denied"}, + {err: taskpkg.ErrStaleNetworkChannel, want: "stale_channel"}, + {err: ErrMissingField, want: "invalid_request"}, + {err: errors.New("boom"), want: "task_ingress_failed"}, + } + + for _, tc := range testCases { + if got := taskIngressReason(tc.err); got != tc.want { + t.Fatalf("taskIngressReason(%v) = %q, want %q", tc.err, got, tc.want) + } + } + }) + + t.Run("applies manager task service option", func(t *testing.T) { + t.Parallel() + + opts := managerOptions{} + service := fakeNetworkTaskService{} + WithManagerTaskService(service)(&opts) + if opts.tasks == nil { + t.Fatal("WithManagerTaskService() did not assign opts.tasks") + } + }) +} + +func newRemotePeerRegistry(t *testing.T, now time.Time, channel string, peerID string, capabilities []string) *PeerRegistry { + t.Helper() + + registry, err := NewPeerRegistry(10*time.Second, WithPeerRegistryClock(func() time.Time { return now })) + if err != nil { + t.Fatalf("NewPeerRegistry() error = %v", err) + } + + card, err := DefaultPeerCard(peerID) + if err != nil { + t.Fatalf("DefaultPeerCard(%q) error = %v", peerID, err) + } + card.Capabilities = append([]string(nil), capabilities...) + if _, stored, err := registry.RefreshRemote(channel, card, now); err != nil { + t.Fatalf("RefreshRemote(%q, %q) error = %v", channel, peerID, err) + } else if !stored { + t.Fatalf("RefreshRemote(%q, %q) stored = false, want true", channel, peerID) + } + + return registry +} diff --git a/internal/network/validate.go b/internal/network/validate.go index 1b4d7399c..9e7125f01 100644 --- a/internal/network/validate.go +++ b/internal/network/validate.go @@ -10,6 +10,8 @@ import ( "regexp" "strings" "time" + + "github.com/pedronauck/agh/internal/network/rules" ) var ( @@ -30,8 +32,7 @@ var ( ) var ( - channelPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`) - peerIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`) + peerIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`) ) // DefaultMaxReplayAge is the RFC-recommended maximum receiver replay age when @@ -144,7 +145,7 @@ func ValidateEnvelope(env Envelope, opts ValidateOptions) error { // ValidateChannel reports whether the channel matches the RFC grammar. func ValidateChannel(channel string) error { - if !channelPattern.MatchString(channel) { + if !rules.ValidChannel(channel) { return fmt.Errorf("%w: channel=%q", ErrInvalidField, channel) } return nil diff --git a/internal/observe/health.go b/internal/observe/health.go index 9457434bc..1273a4e43 100644 --- a/internal/observe/health.go +++ b/internal/observe/health.go @@ -21,6 +21,7 @@ type Health struct { GlobalDBSizeBytes int64 `json:"global_db_size_bytes"` SessionDBSizeBytes int64 `json:"session_db_size_bytes"` Bridges BridgeAggregateHealth `json:"bridges"` + Tasks TaskHealth `json:"tasks"` Version string `json:"version"` } @@ -45,6 +46,10 @@ func (o *Observer) Health(ctx context.Context) (Health, error) { if err != nil { return Health{}, err } + taskHealth, err := o.collectTaskHealth(ctx) + if err != nil { + return Health{}, fmt.Errorf("observe: collect task health: %w", err) + } uptimeSeconds := int64(o.now().Sub(o.startedAt).Seconds()) if uptimeSeconds < 0 { @@ -59,6 +64,7 @@ func (o *Observer) Health(ctx context.Context) (Health, error) { GlobalDBSizeBytes: globalDBSize, SessionDBSizeBytes: sessionDBSize, Bridges: bridgeHealth, + Tasks: taskHealth, Version: o.versionSource().Version, }, nil } diff --git a/internal/observe/observer.go b/internal/observe/observer.go index 16008b596..3135cb7b5 100644 --- a/internal/observe/observer.go +++ b/internal/observe/observer.go @@ -19,6 +19,7 @@ import ( "github.com/pedronauck/agh/internal/store" "github.com/pedronauck/agh/internal/store/globaldb" "github.com/pedronauck/agh/internal/store/sessiondb" + taskpkg "github.com/pedronauck/agh/internal/task" "github.com/pedronauck/agh/internal/version" workspacepkg "github.com/pedronauck/agh/internal/workspace" ) @@ -35,6 +36,10 @@ type Registry interface { ListTokenStats(ctx context.Context, query store.TokenStatsQuery) ([]store.TokenStats, error) WritePermissionLog(ctx context.Context, entry store.PermissionLogEntry) error ListPermissionLog(ctx context.Context, query store.PermissionLogQuery) ([]store.PermissionLogEntry, error) + ListNetworkAudit(ctx context.Context, query store.NetworkAuditQuery) ([]store.NetworkAuditEntry, error) + ListTasks(ctx context.Context, query taskpkg.TaskQuery) ([]taskpkg.TaskSummary, error) + ListTaskRuns(ctx context.Context, query taskpkg.TaskRunQuery) ([]taskpkg.TaskRun, error) + ListTaskEvents(ctx context.Context, query taskpkg.TaskEventQuery) ([]taskpkg.TaskEvent, error) Path() string Close(ctx context.Context) error } @@ -75,6 +80,13 @@ type observedSession struct { permissionMode string } +// TaskHealthConfig controls task-run stuck detection in the read-side health view. +type TaskHealthConfig struct { + ClaimedStuckAfter time.Duration + StartingStuckAfter time.Duration + RunningStuckAfter time.Duration +} + // Observer implements session.Notifier and exposes query/health helpers for global observability. type Observer struct { mu sync.RWMutex @@ -93,6 +105,7 @@ type Observer struct { bridgeState map[string]observedBridgeState hookCatalogSource HookCatalogSource openHookStore HookStoreOpener + taskHealthConfig TaskHealthConfig } var _ session.Notifier = (*Observer)(nil) @@ -175,6 +188,14 @@ func WithHookStoreOpener(opener HookStoreOpener) Option { } } +// WithTaskHealthConfig overrides the task-run stuck thresholds used by the +// observer health view. +func WithTaskHealthConfig(cfg TaskHealthConfig) Option { + return func(observer *Observer) { + observer.taskHealthConfig = cfg + } +} + // New constructs an Observer and opens the global AGH database when needed. func New(ctx context.Context, opts ...Option) (*Observer, error) { if ctx == nil { @@ -195,6 +216,11 @@ func New(ctx context.Context, opts ...Option) (*Observer, error) { versionSource: version.Current, sessions: make(map[string]observedSession), bridgeState: make(map[string]observedBridgeState), + taskHealthConfig: TaskHealthConfig{ + ClaimedStuckAfter: 5 * time.Minute, + StartingStuckAfter: 5 * time.Minute, + RunningStuckAfter: 30 * time.Minute, + }, } for _, opt := range opts { diff --git a/internal/observe/tasks.go b/internal/observe/tasks.go new file mode 100644 index 000000000..a051badf1 --- /dev/null +++ b/internal/observe/tasks.go @@ -0,0 +1,899 @@ +package observe + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/pedronauck/agh/internal/session" + "github.com/pedronauck/agh/internal/store" + taskpkg "github.com/pedronauck/agh/internal/task" +) + +const ( + taskIngressAuditEnqueueAction = "task.run.enqueue" + taskIngressChannelMismatch = "channel_mismatch" + taskEventCancelled = "task.cancelled" + taskEventRunEnqueued = "task.run_enqueued" + taskEventRunForceStopped = "task.run_force_stopped" + taskEventRunRecovered = "task.run_recovered" +) + +// TaskSummaryQuery filters the current task summary view. +type TaskSummaryQuery struct { + Scope taskpkg.Scope `json:"scope,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + OwnerKind taskpkg.OwnerKind `json:"owner_kind,omitempty"` + OwnerRef string `json:"owner_ref,omitempty"` + NetworkChannel string `json:"network_channel,omitempty"` + OriginKind taskpkg.OriginKind `json:"origin_kind,omitempty"` +} + +// Validate ensures the summary query uses supported filters. +func (q TaskSummaryQuery) Validate() error { + if q.Scope.Normalize() != "" { + if err := q.Scope.Validate("task_summary_query.scope"); err != nil { + return err + } + } + if q.OwnerKind.Normalize() != "" { + if err := q.OwnerKind.Validate("task_summary_query.owner_kind"); err != nil { + return err + } + } + if q.OriginKind.Normalize() != "" { + if err := q.OriginKind.Validate("task_summary_query.origin_kind"); err != nil { + return err + } + } + return nil +} + +// TaskMetricsQuery filters audit-derived metrics and current queue metrics. +type TaskMetricsQuery struct { + Since time.Time `json:"since,omitempty"` + NetworkChannel string `json:"network_channel,omitempty"` + OriginKind taskpkg.OriginKind `json:"origin_kind,omitempty"` +} + +// Validate ensures the metrics query uses supported filters. +func (q TaskMetricsQuery) Validate() error { + if q.OriginKind.Normalize() != "" { + if err := q.OriginKind.Validate("task_metrics_query.origin_kind"); err != nil { + return err + } + } + return nil +} + +// TaskStatusTotal reports one current task-count bucket. +type TaskStatusTotal struct { + Scope taskpkg.Scope `json:"scope"` + Status taskpkg.TaskStatus `json:"status"` + NetworkChannel string `json:"network_channel,omitempty"` + Count int `json:"count"` +} + +// TaskOriginTotal reports one current task-origin bucket. +type TaskOriginTotal struct { + OriginKind taskpkg.OriginKind `json:"origin_kind"` + NetworkChannel string `json:"network_channel,omitempty"` + Count int `json:"count"` +} + +// TaskRunTotal reports one current task-run bucket. +type TaskRunTotal struct { + Status taskpkg.TaskRunStatus `json:"status"` + OriginKind taskpkg.OriginKind `json:"origin_kind"` + NetworkChannel string `json:"network_channel,omitempty"` + Count int `json:"count"` +} + +// TaskOwnerTotal reports one current ownership bucket. +type TaskOwnerTotal struct { + OwnerKind taskpkg.OwnerKind `json:"owner_kind"` + OwnerRef string `json:"owner_ref"` + Count int `json:"count"` +} + +// TaskQueueDepth reports queued work by channel. +type TaskQueueDepth struct { + NetworkChannel string `json:"network_channel,omitempty"` + Count int `json:"count"` + OldestQueuedAt time.Time `json:"oldest_queued_at,omitempty"` + OldestQueueAgeMilli int64 `json:"oldest_queue_age_ms"` +} + +// TaskSummary exposes the current read-side task summary buckets. +type TaskSummary struct { + TotalTasks int `json:"total_tasks"` + TotalRuns int `json:"total_runs"` + TaskTotals []TaskStatusTotal `json:"task_totals,omitempty"` + TaskOrigins []TaskOriginTotal `json:"task_origins,omitempty"` + RunTotals []TaskRunTotal `json:"run_totals,omitempty"` + OwnerTotals []TaskOwnerTotal `json:"owner_totals,omitempty"` + QueueDepth []TaskQueueDepth `json:"queue_depth,omitempty"` +} + +// LatencyMetric summarizes one task-run latency family in milliseconds. +type LatencyMetric struct { + Samples int `json:"samples"` + AverageMillis int64 `json:"average_ms"` + MaximumMillis int64 `json:"maximum_ms"` +} + +// TaskCancelRequestTotal reports cancellation requests grouped by origin. +type TaskCancelRequestTotal struct { + OriginKind taskpkg.OriginKind `json:"origin_kind"` + Count int `json:"count"` +} + +// TaskRecoveryTotals reports boot-recovery outcomes grouped by manager action. +type TaskRecoveryTotals struct { + Requeued int `json:"requeued"` + MarkedRunning int `json:"marked_running"` + Failed int `json:"failed"` +} + +// TaskMetrics exposes current counters and latency summaries for the task domain. +type TaskMetrics struct { + TasksTotal []TaskStatusTotal `json:"tasks_total,omitempty"` + TaskRunsTotal []TaskRunTotal `json:"task_runs_total,omitempty"` + TaskQueueDepth []TaskQueueDepth `json:"task_queue_depth,omitempty"` + TaskCancelRequestsTotal []TaskCancelRequestTotal `json:"task_cancel_requests_total,omitempty"` + TaskForcedStopsTotal int `json:"task_forced_stops_total"` + TaskClaimLatencyMillis LatencyMetric `json:"task_claim_latency_ms"` + TaskStartLatencyMillis LatencyMetric `json:"task_start_latency_ms"` + DuplicateIngressTotal int `json:"duplicate_ingress_total"` + ChannelMismatchTotal int `json:"channel_mismatch_total"` + RecoveryTotals TaskRecoveryTotals `json:"recovery_totals"` +} + +// StuckTaskRun reports one run that exceeded the configured claimed/starting/running threshold. +type StuckTaskRun struct { + TaskID string `json:"task_id"` + RunID string `json:"run_id"` + Status taskpkg.TaskRunStatus `json:"status"` + OriginKind taskpkg.OriginKind `json:"origin_kind"` + NetworkChannel string `json:"network_channel,omitempty"` + SessionID string `json:"session_id,omitempty"` + AgeMillis int64 `json:"age_ms"` +} + +// TaskHealth exposes the current operational task-health view. +type TaskHealth struct { + Status string `json:"status"` + QueueDepthTotal int `json:"queue_depth_total"` + OldestQueuedAt time.Time `json:"oldest_queued_at,omitempty"` + OldestQueueAgeMilli int64 `json:"oldest_queue_age_ms"` + QueueDepth []TaskQueueDepth `json:"queue_depth,omitempty"` + StuckRuns []StuckTaskRun `json:"stuck_runs,omitempty"` + ActiveOrphanRuns int `json:"active_orphan_runs"` + TaskTotals []TaskStatusTotal `json:"task_totals,omitempty"` + RunTotals []TaskRunTotal `json:"run_totals,omitempty"` + OwnerTotals []TaskOwnerTotal `json:"owner_totals,omitempty"` + ForcedStopsSinceStart int `json:"forced_stops_since_start"` + DuplicateIngressSinceStart int `json:"duplicate_ingress_since_start"` + ChannelMismatchSinceStart int `json:"channel_mismatch_since_start"` + RecoverySinceStart TaskRecoveryTotals `json:"recovery_since_start"` +} + +type taskSnapshot struct { + tasks []taskpkg.TaskSummary + runs []taskpkg.TaskRun + events []taskpkg.TaskEvent + audits []store.NetworkAuditEntry + tasksByID map[string]taskpkg.TaskSummary + runsByID map[string]taskpkg.TaskRun +} + +type taskRecoveryPayload struct { + Action taskpkg.RunBootRecoveryAction `json:"action"` +} + +// QueryTaskSummary returns the current task summary buckets filtered by the supplied view. +func (o *Observer) QueryTaskSummary(ctx context.Context, query TaskSummaryQuery) (TaskSummary, error) { + snapshot, err := o.loadTaskSnapshot(ctx, query) + if err != nil { + return TaskSummary{}, err + } + return taskSummaryFromSnapshot(snapshot, o.now), nil +} + +// QueryTaskMetrics returns task-domain counters and latency summaries derived from durable state and audit rows. +func (o *Observer) QueryTaskMetrics(ctx context.Context, query TaskMetricsQuery) (TaskMetrics, error) { + if ctx == nil { + return TaskMetrics{}, errors.New("observe: task metrics context is required") + } + if err := query.Validate(); err != nil { + return TaskMetrics{}, err + } + + snapshot, err := o.loadTaskSnapshot(ctx, TaskSummaryQuery{NetworkChannel: query.NetworkChannel}) + if err != nil { + return TaskMetrics{}, err + } + return taskMetricsFromSnapshot(snapshot, query, o.now), nil +} + +func (o *Observer) collectTaskHealth(ctx context.Context) (TaskHealth, error) { + if ctx == nil { + return TaskHealth{}, errors.New("observe: task health context is required") + } + + snapshot, err := o.loadTaskSnapshot(ctx, TaskSummaryQuery{}) + if err != nil { + return TaskHealth{}, err + } + summary := taskSummaryFromSnapshot(snapshot, o.now) + metrics := taskMetricsFromSnapshot(snapshot, TaskMetricsQuery{Since: o.startedAt}, o.now) + + stuckRuns := findStuckRuns(snapshot.runs, o.now(), o.taskHealthConfig) + sortStuckRuns(stuckRuns) + activeOrphans, err := o.countActiveOrphanRuns(ctx, snapshot.runs) + if err != nil { + return TaskHealth{}, err + } + + queueDepthTotal := 0 + var oldestQueuedAt time.Time + var oldestQueuedAge int64 + for _, item := range summary.QueueDepth { + queueDepthTotal += item.Count + if item.OldestQueuedAt.IsZero() { + continue + } + if oldestQueuedAt.IsZero() || item.OldestQueuedAt.Before(oldestQueuedAt) { + oldestQueuedAt = item.OldestQueuedAt + oldestQueuedAge = item.OldestQueueAgeMilli + } + } + + status := "ok" + if len(stuckRuns) > 0 || activeOrphans > 0 { + status = "warn" + } + + return TaskHealth{ + Status: status, + QueueDepthTotal: queueDepthTotal, + OldestQueuedAt: oldestQueuedAt, + OldestQueueAgeMilli: oldestQueuedAge, + QueueDepth: summary.QueueDepth, + StuckRuns: stuckRuns, + ActiveOrphanRuns: activeOrphans, + TaskTotals: summary.TaskTotals, + RunTotals: summary.RunTotals, + OwnerTotals: summary.OwnerTotals, + ForcedStopsSinceStart: metrics.TaskForcedStopsTotal, + DuplicateIngressSinceStart: metrics.DuplicateIngressTotal, + ChannelMismatchSinceStart: metrics.ChannelMismatchTotal, + RecoverySinceStart: metrics.RecoveryTotals, + }, nil +} + +func taskSummaryFromSnapshot(snapshot taskSnapshot, now func() time.Time) TaskSummary { + return TaskSummary{ + TotalTasks: len(snapshot.tasks), + TotalRuns: len(snapshot.runs), + TaskTotals: summarizeTasks(snapshot.tasks), + TaskOrigins: summarizeTaskOrigins(snapshot.tasks), + RunTotals: summarizeRuns(snapshot.runs), + OwnerTotals: summarizeOwners(snapshot.tasks), + QueueDepth: summarizeQueueDepth(snapshot.runs, now), + } +} + +func taskMetricsFromSnapshot(snapshot taskSnapshot, query TaskMetricsQuery, now func() time.Time) TaskMetrics { + runs := filterRunsByOrigin(snapshot.runs, query.OriginKind) + events := filterTaskEvents(snapshot.events, snapshot.tasksByID, snapshot.runsByID, query) + audits := filterTaskIngressAudits(snapshot.audits, query) + networkEnqueueEvents := filterNetworkEnqueueEvents(events) + + duplicateIngress := len(filterAcceptedEnqueueAudits(audits)) - len(networkEnqueueEvents) + if duplicateIngress < 0 { + duplicateIngress = 0 + } + + return TaskMetrics{ + TasksTotal: summarizeTasks(filterTasksByOrigin(snapshot.tasks, query.OriginKind)), + TaskRunsTotal: summarizeRuns(runs), + TaskQueueDepth: summarizeQueueDepth(runs, now), + TaskCancelRequestsTotal: summarizeCancelRequests(events), + TaskForcedStopsTotal: countEventsByType(events, taskEventRunForceStopped), + TaskClaimLatencyMillis: summarizeClaimLatency(runs), + TaskStartLatencyMillis: summarizeStartLatency(runs), + DuplicateIngressTotal: duplicateIngress, + ChannelMismatchTotal: countChannelMismatchAudits(audits), + RecoveryTotals: summarizeRecovery(events), + } +} + +func (o *Observer) loadTaskSnapshot(ctx context.Context, query TaskSummaryQuery) (taskSnapshot, error) { + if ctx == nil { + return taskSnapshot{}, errors.New("observe: task summary context is required") + } + if err := query.Validate(); err != nil { + return taskSnapshot{}, err + } + + tasks, err := o.registry.ListTasks(ctx, taskpkg.TaskQuery{ + Scope: query.Scope, + WorkspaceID: strings.TrimSpace(query.WorkspaceID), + OwnerKind: query.OwnerKind.Normalize(), + OwnerRef: strings.TrimSpace(query.OwnerRef), + NetworkChannel: strings.TrimSpace(query.NetworkChannel), + }) + if err != nil { + return taskSnapshot{}, fmt.Errorf("observe: list tasks for summary: %w", err) + } + tasks = filterTasksByOrigin(tasks, query.OriginKind) + + tasksByID := make(map[string]taskpkg.TaskSummary, len(tasks)) + taskIDs := make(map[string]struct{}, len(tasks)) + for _, item := range tasks { + taskID := strings.TrimSpace(item.ID) + if taskID == "" { + continue + } + tasksByID[taskID] = item + taskIDs[taskID] = struct{}{} + } + + runs, err := o.registry.ListTaskRuns(ctx, taskpkg.TaskRunQuery{}) + if err != nil { + return taskSnapshot{}, fmt.Errorf("observe: list task runs for summary: %w", err) + } + runs = filterRuns(runs, taskIDs, query) + + runsByID := make(map[string]taskpkg.TaskRun, len(runs)) + for _, item := range runs { + runID := strings.TrimSpace(item.ID) + if runID == "" { + continue + } + runsByID[runID] = item + } + + events, err := o.registry.ListTaskEvents(ctx, taskpkg.TaskEventQuery{}) + if err != nil { + return taskSnapshot{}, fmt.Errorf("observe: list task events for summary: %w", err) + } + events = filterEventsForTasks(events, taskIDs) + + audits, err := o.registry.ListNetworkAudit(ctx, store.NetworkAuditQuery{Channel: strings.TrimSpace(query.NetworkChannel)}) + if err != nil { + return taskSnapshot{}, fmt.Errorf("observe: list network audit for summary: %w", err) + } + + return taskSnapshot{ + tasks: tasks, + runs: runs, + events: events, + audits: audits, + tasksByID: tasksByID, + runsByID: runsByID, + }, nil +} + +func summarizeTasks(tasks []taskpkg.TaskSummary) []TaskStatusTotal { + counts := make(map[string]TaskStatusTotal) + for _, item := range tasks { + key := string(item.Scope.Normalize()) + "\x00" + string(item.Status.Normalize()) + "\x00" + strings.TrimSpace(item.NetworkChannel) + current := counts[key] + current.Scope = item.Scope.Normalize() + current.Status = item.Status.Normalize() + current.NetworkChannel = strings.TrimSpace(item.NetworkChannel) + current.Count++ + counts[key] = current + } + rows := make([]TaskStatusTotal, 0, len(counts)) + for _, item := range counts { + rows = append(rows, item) + } + slices.SortFunc(rows, func(left, right TaskStatusTotal) int { + if cmp := strings.Compare(string(left.Scope), string(right.Scope)); cmp != 0 { + return cmp + } + if cmp := strings.Compare(string(left.Status), string(right.Status)); cmp != 0 { + return cmp + } + return strings.Compare(left.NetworkChannel, right.NetworkChannel) + }) + return rows +} + +func summarizeTaskOrigins(tasks []taskpkg.TaskSummary) []TaskOriginTotal { + counts := make(map[string]TaskOriginTotal) + for _, item := range tasks { + key := string(item.Origin.Kind.Normalize()) + "\x00" + strings.TrimSpace(item.NetworkChannel) + current := counts[key] + current.OriginKind = item.Origin.Kind.Normalize() + current.NetworkChannel = strings.TrimSpace(item.NetworkChannel) + current.Count++ + counts[key] = current + } + rows := make([]TaskOriginTotal, 0, len(counts)) + for _, item := range counts { + rows = append(rows, item) + } + slices.SortFunc(rows, func(left, right TaskOriginTotal) int { + if cmp := strings.Compare(string(left.OriginKind), string(right.OriginKind)); cmp != 0 { + return cmp + } + return strings.Compare(left.NetworkChannel, right.NetworkChannel) + }) + return rows +} + +func summarizeRuns(runs []taskpkg.TaskRun) []TaskRunTotal { + counts := make(map[string]TaskRunTotal) + for _, item := range runs { + channel := strings.TrimSpace(item.NetworkChannel) + key := string(item.Status.Normalize()) + "\x00" + string(item.Origin.Kind.Normalize()) + "\x00" + channel + current := counts[key] + current.Status = item.Status.Normalize() + current.OriginKind = item.Origin.Kind.Normalize() + current.NetworkChannel = channel + current.Count++ + counts[key] = current + } + rows := make([]TaskRunTotal, 0, len(counts)) + for _, item := range counts { + rows = append(rows, item) + } + slices.SortFunc(rows, func(left, right TaskRunTotal) int { + if cmp := strings.Compare(string(left.Status), string(right.Status)); cmp != 0 { + return cmp + } + if cmp := strings.Compare(string(left.OriginKind), string(right.OriginKind)); cmp != 0 { + return cmp + } + return strings.Compare(left.NetworkChannel, right.NetworkChannel) + }) + return rows +} + +func summarizeOwners(tasks []taskpkg.TaskSummary) []TaskOwnerTotal { + counts := make(map[string]TaskOwnerTotal) + for _, item := range tasks { + if item.Owner == nil { + continue + } + key := string(item.Owner.Kind.Normalize()) + "\x00" + strings.TrimSpace(item.Owner.Ref) + current := counts[key] + current.OwnerKind = item.Owner.Kind.Normalize() + current.OwnerRef = strings.TrimSpace(item.Owner.Ref) + current.Count++ + counts[key] = current + } + rows := make([]TaskOwnerTotal, 0, len(counts)) + for _, item := range counts { + rows = append(rows, item) + } + slices.SortFunc(rows, func(left, right TaskOwnerTotal) int { + if cmp := strings.Compare(string(left.OwnerKind), string(right.OwnerKind)); cmp != 0 { + return cmp + } + return strings.Compare(left.OwnerRef, right.OwnerRef) + }) + return rows +} + +func summarizeQueueDepth(runs []taskpkg.TaskRun, now func() time.Time) []TaskQueueDepth { + counts := make(map[string]TaskQueueDepth) + currentTime := time.Now().UTC() + if now != nil { + currentTime = now().UTC() + } + for _, item := range runs { + if item.Status.Normalize() != taskpkg.TaskRunStatusQueued { + continue + } + channel := strings.TrimSpace(item.NetworkChannel) + current := counts[channel] + current.NetworkChannel = channel + current.Count++ + if current.OldestQueuedAt.IsZero() || item.QueuedAt.Before(current.OldestQueuedAt) { + current.OldestQueuedAt = item.QueuedAt + age := currentTime.Sub(item.QueuedAt) + if age < 0 { + age = 0 + } + current.OldestQueueAgeMilli = age.Milliseconds() + } + counts[channel] = current + } + rows := make([]TaskQueueDepth, 0, len(counts)) + for _, item := range counts { + rows = append(rows, item) + } + slices.SortFunc(rows, func(left, right TaskQueueDepth) int { + return strings.Compare(left.NetworkChannel, right.NetworkChannel) + }) + return rows +} + +func summarizeCancelRequests(events []taskpkg.TaskEvent) []TaskCancelRequestTotal { + counts := make(map[string]TaskCancelRequestTotal) + for _, item := range events { + if item.EventType != taskEventCancelled { + continue + } + key := string(item.Origin.Kind.Normalize()) + current := counts[key] + current.OriginKind = item.Origin.Kind.Normalize() + current.Count++ + counts[key] = current + } + rows := make([]TaskCancelRequestTotal, 0, len(counts)) + for _, item := range counts { + rows = append(rows, item) + } + slices.SortFunc(rows, func(left, right TaskCancelRequestTotal) int { + return strings.Compare(string(left.OriginKind), string(right.OriginKind)) + }) + return rows +} + +func summarizeClaimLatency(runs []taskpkg.TaskRun) LatencyMetric { + return summarizeLatency(runs, func(run taskpkg.TaskRun) (time.Duration, bool) { + if run.ClaimedAt.IsZero() { + return 0, false + } + duration := run.ClaimedAt.Sub(run.QueuedAt) + if duration < 0 { + duration = 0 + } + return duration, true + }) +} + +func summarizeStartLatency(runs []taskpkg.TaskRun) LatencyMetric { + return summarizeLatency(runs, func(run taskpkg.TaskRun) (time.Duration, bool) { + if run.StartedAt.IsZero() { + return 0, false + } + base := run.ClaimedAt + if base.IsZero() { + base = run.QueuedAt + } + duration := run.StartedAt.Sub(base) + if duration < 0 { + duration = 0 + } + return duration, true + }) +} + +func summarizeLatency(runs []taskpkg.TaskRun, measure func(taskpkg.TaskRun) (time.Duration, bool)) LatencyMetric { + var total time.Duration + var max time.Duration + samples := 0 + for _, item := range runs { + duration, ok := measure(item) + if !ok { + continue + } + samples++ + total += duration + if duration > max { + max = duration + } + } + if samples == 0 { + return LatencyMetric{} + } + return LatencyMetric{ + Samples: samples, + AverageMillis: (total / time.Duration(samples)).Milliseconds(), + MaximumMillis: max.Milliseconds(), + } +} + +func summarizeRecovery(events []taskpkg.TaskEvent) TaskRecoveryTotals { + totals := TaskRecoveryTotals{} + for _, item := range events { + if item.EventType != taskEventRunRecovered { + continue + } + var payload taskRecoveryPayload + if len(item.Payload) > 0 { + if err := json.Unmarshal(item.Payload, &payload); err != nil { + continue + } + } + switch payload.Action.Normalize() { + case taskpkg.RunBootRecoveryRequeue: + totals.Requeued++ + case taskpkg.RunBootRecoveryMarkRunning: + totals.MarkedRunning++ + case taskpkg.RunBootRecoveryFail: + totals.Failed++ + } + } + return totals +} + +func countEventsByType(events []taskpkg.TaskEvent, eventType string) int { + count := 0 + for _, item := range events { + if item.EventType == eventType { + count++ + } + } + return count +} + +func findStuckRuns(runs []taskpkg.TaskRun, now time.Time, cfg TaskHealthConfig) []StuckTaskRun { + stuck := make([]StuckTaskRun, 0) + for _, item := range runs { + threshold, age, ok := runStuckAge(item, now, cfg) + if !ok || threshold <= 0 || age < threshold { + continue + } + stuck = append(stuck, StuckTaskRun{ + TaskID: strings.TrimSpace(item.TaskID), + RunID: strings.TrimSpace(item.ID), + Status: item.Status.Normalize(), + OriginKind: item.Origin.Kind.Normalize(), + NetworkChannel: strings.TrimSpace(item.NetworkChannel), + SessionID: strings.TrimSpace(item.SessionID), + AgeMillis: age.Milliseconds(), + }) + } + return stuck +} + +func runStuckAge(run taskpkg.TaskRun, now time.Time, cfg TaskHealthConfig) (time.Duration, time.Duration, bool) { + switch run.Status.Normalize() { + case taskpkg.TaskRunStatusClaimed: + base := run.ClaimedAt + if base.IsZero() { + base = run.QueuedAt + } + return cfg.ClaimedStuckAfter, safeSince(now, base), true + case taskpkg.TaskRunStatusStarting: + base := run.ClaimedAt + if base.IsZero() { + base = run.QueuedAt + } + return cfg.StartingStuckAfter, safeSince(now, base), true + case taskpkg.TaskRunStatusRunning: + base := run.StartedAt + if base.IsZero() { + base = run.ClaimedAt + } + if base.IsZero() { + base = run.QueuedAt + } + return cfg.RunningStuckAfter, safeSince(now, base), true + default: + return 0, 0, false + } +} + +func safeSince(now time.Time, started time.Time) time.Duration { + if started.IsZero() { + return 0 + } + duration := now.Sub(started) + if duration < 0 { + return 0 + } + return duration +} + +func sortStuckRuns(runs []StuckTaskRun) { + slices.SortFunc(runs, func(left, right StuckTaskRun) int { + switch { + case left.AgeMillis > right.AgeMillis: + return -1 + case left.AgeMillis < right.AgeMillis: + return 1 + default: + return strings.Compare(left.RunID, right.RunID) + } + }) +} + +func (o *Observer) countActiveOrphanRuns(ctx context.Context, runs []taskpkg.TaskRun) (int, error) { + liveSessions, err := o.liveSessionIDs(ctx) + if err != nil { + return 0, err + } + count := 0 + for _, item := range runs { + status := item.Status.Normalize() + if status != taskpkg.TaskRunStatusStarting && status != taskpkg.TaskRunStatusRunning { + continue + } + sessionID := strings.TrimSpace(item.SessionID) + if sessionID == "" { + count++ + continue + } + if _, ok := liveSessions[sessionID]; !ok { + count++ + } + } + return count, nil +} + +func (o *Observer) liveSessionIDs(ctx context.Context) (map[string]struct{}, error) { + live := make(map[string]struct{}) + if o.sessionSource != nil { + for _, info := range o.sessionSource.List() { + if info == nil || !isLiveSessionState(string(info.State)) { + continue + } + live[strings.TrimSpace(info.ID)] = struct{}{} + } + return live, nil + } + + sessions, err := o.registry.ListSessions(ctx, store.SessionListQuery{}) + if err != nil { + return nil, fmt.Errorf("observe: list sessions for task health: %w", err) + } + for _, info := range sessions { + if !isLiveSessionState(info.State) { + continue + } + live[strings.TrimSpace(info.ID)] = struct{}{} + } + return live, nil +} + +func isLiveSessionState(state string) bool { + normalized := strings.TrimSpace(state) + return normalized != "" && normalized != string(session.StateStopped) && normalized != "orphaned" +} + +func filterTasksByOrigin(tasks []taskpkg.TaskSummary, origin taskpkg.OriginKind) []taskpkg.TaskSummary { + if origin.Normalize() == "" { + return tasks + } + filtered := make([]taskpkg.TaskSummary, 0, len(tasks)) + for _, item := range tasks { + if item.Origin.Kind.Normalize() == origin.Normalize() { + filtered = append(filtered, item) + } + } + return filtered +} + +func filterRuns(runs []taskpkg.TaskRun, taskIDs map[string]struct{}, query TaskSummaryQuery) []taskpkg.TaskRun { + filtered := make([]taskpkg.TaskRun, 0, len(runs)) + for _, item := range runs { + if _, ok := taskIDs[strings.TrimSpace(item.TaskID)]; !ok { + continue + } + if query.OriginKind.Normalize() != "" && item.Origin.Kind.Normalize() != query.OriginKind.Normalize() { + continue + } + if channel := strings.TrimSpace(query.NetworkChannel); channel != "" && strings.TrimSpace(item.NetworkChannel) != channel { + continue + } + filtered = append(filtered, item) + } + return filtered +} + +func filterRunsByOrigin(runs []taskpkg.TaskRun, origin taskpkg.OriginKind) []taskpkg.TaskRun { + if origin.Normalize() == "" { + return runs + } + filtered := make([]taskpkg.TaskRun, 0, len(runs)) + for _, item := range runs { + if item.Origin.Kind.Normalize() == origin.Normalize() { + filtered = append(filtered, item) + } + } + return filtered +} + +func filterEventsForTasks(events []taskpkg.TaskEvent, taskIDs map[string]struct{}) []taskpkg.TaskEvent { + filtered := make([]taskpkg.TaskEvent, 0, len(events)) + for _, item := range events { + if _, ok := taskIDs[strings.TrimSpace(item.TaskID)]; !ok { + continue + } + filtered = append(filtered, item) + } + return filtered +} + +func filterTaskEvents( + events []taskpkg.TaskEvent, + tasksByID map[string]taskpkg.TaskSummary, + runsByID map[string]taskpkg.TaskRun, + query TaskMetricsQuery, +) []taskpkg.TaskEvent { + filtered := make([]taskpkg.TaskEvent, 0, len(events)) + for _, item := range events { + if !query.Since.IsZero() && item.Timestamp.Before(query.Since) { + continue + } + if query.OriginKind.Normalize() != "" && item.Origin.Kind.Normalize() != query.OriginKind.Normalize() { + continue + } + if channel := strings.TrimSpace(query.NetworkChannel); channel != "" && eventChannel(item, tasksByID, runsByID) != channel { + continue + } + filtered = append(filtered, item) + } + return filtered +} + +func eventChannel(event taskpkg.TaskEvent, tasksByID map[string]taskpkg.TaskSummary, runsByID map[string]taskpkg.TaskRun) string { + if run, ok := runsByID[strings.TrimSpace(event.RunID)]; ok { + return strings.TrimSpace(run.NetworkChannel) + } + if taskItem, ok := tasksByID[strings.TrimSpace(event.TaskID)]; ok { + return strings.TrimSpace(taskItem.NetworkChannel) + } + return "" +} + +func filterTaskIngressAudits(audits []store.NetworkAuditEntry, query TaskMetricsQuery) []store.NetworkAuditEntry { + filtered := make([]store.NetworkAuditEntry, 0, len(audits)) + for _, item := range audits { + if !isTaskIngressAudit(item) { + continue + } + if !query.Since.IsZero() && item.Timestamp.Before(query.Since) { + continue + } + if channel := strings.TrimSpace(query.NetworkChannel); channel != "" && strings.TrimSpace(item.Channel) != channel { + continue + } + filtered = append(filtered, item) + } + return filtered +} + +func isTaskIngressAudit(entry store.NetworkAuditEntry) bool { + return strings.HasPrefix(strings.TrimSpace(entry.Kind), "task.") +} + +func filterNetworkEnqueueEvents(events []taskpkg.TaskEvent) []taskpkg.TaskEvent { + filtered := make([]taskpkg.TaskEvent, 0, len(events)) + for _, item := range events { + if item.EventType == taskEventRunEnqueued && item.Origin.Kind.Normalize() == taskpkg.OriginKindNetwork { + filtered = append(filtered, item) + } + } + return filtered +} + +func filterAcceptedEnqueueAudits(audits []store.NetworkAuditEntry) []store.NetworkAuditEntry { + filtered := make([]store.NetworkAuditEntry, 0, len(audits)) + for _, item := range audits { + if strings.TrimSpace(item.Kind) != taskIngressAuditEnqueueAction { + continue + } + if strings.TrimSpace(item.Direction) != "received" { + continue + } + filtered = append(filtered, item) + } + return filtered +} + +func countChannelMismatchAudits(audits []store.NetworkAuditEntry) int { + count := 0 + for _, item := range audits { + if strings.TrimSpace(item.Direction) != "rejected" { + continue + } + if strings.TrimSpace(item.Reason) == taskIngressChannelMismatch { + count++ + } + } + return count +} diff --git a/internal/observe/tasks_health_optimization_test.go b/internal/observe/tasks_health_optimization_test.go new file mode 100644 index 000000000..ddbed63ca --- /dev/null +++ b/internal/observe/tasks_health_optimization_test.go @@ -0,0 +1,96 @@ +package observe + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/pedronauck/agh/internal/store" + taskpkg "github.com/pedronauck/agh/internal/task" + "github.com/pedronauck/agh/internal/testutil" +) + +type countingTaskRegistry struct { + Registry + + mu sync.Mutex + listTasksCalls int + listRunsCalls int + listEventCalls int + listAuditCalls int +} + +func (r *countingTaskRegistry) ListTasks(ctx context.Context, query taskpkg.TaskQuery) ([]taskpkg.TaskSummary, error) { + r.mu.Lock() + r.listTasksCalls++ + r.mu.Unlock() + return r.Registry.ListTasks(ctx, query) +} + +func (r *countingTaskRegistry) ListTaskRuns(ctx context.Context, query taskpkg.TaskRunQuery) ([]taskpkg.TaskRun, error) { + r.mu.Lock() + r.listRunsCalls++ + r.mu.Unlock() + return r.Registry.ListTaskRuns(ctx, query) +} + +func (r *countingTaskRegistry) ListTaskEvents(ctx context.Context, query taskpkg.TaskEventQuery) ([]taskpkg.TaskEvent, error) { + r.mu.Lock() + r.listEventCalls++ + r.mu.Unlock() + return r.Registry.ListTaskEvents(ctx, query) +} + +func (r *countingTaskRegistry) ListNetworkAudit(ctx context.Context, query store.NetworkAuditQuery) ([]store.NetworkAuditEntry, error) { + r.mu.Lock() + r.listAuditCalls++ + r.mu.Unlock() + return r.Registry.ListNetworkAudit(ctx, query) +} + +func TestHealthLoadsTaskDataOncePerSnapshot(t *testing.T) { + t.Parallel() + + h := newHarness(t) + wrapped := &countingTaskRegistry{Registry: h.registry} + h.observer.registry = wrapped + + createObserveTask(t, h, taskpkg.Task{ + ID: "task-health-once", + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: h.workspaceID, + Title: "Health once", + Status: taskpkg.TaskStatusInProgress, + CreatedBy: taskActor(taskpkg.ActorKindHuman, "user"), + Origin: taskOrigin(taskpkg.OriginKindCLI, "agh task"), + CreatedAt: h.now, + UpdatedAt: h.now, + }) + createObserveRun(t, h, taskpkg.TaskRun{ + ID: "run-health-once", + TaskID: "task-health-once", + Status: taskpkg.TaskRunStatusClaimed, + Attempt: 1, + Origin: taskOrigin(taskpkg.OriginKindCLI, "agh task"), + QueuedAt: h.now.Add(-10 * time.Minute), + ClaimedAt: h.now.Add(-6 * time.Minute), + }) + + if _, err := h.observer.Health(testutil.Context(t)); err != nil { + t.Fatalf("Health() error = %v", err) + } + + if got, want := wrapped.listTasksCalls, 1; got != want { + t.Fatalf("ListTasks calls = %d, want %d", got, want) + } + if got, want := wrapped.listRunsCalls, 1; got != want { + t.Fatalf("ListTaskRuns calls = %d, want %d", got, want) + } + if got, want := wrapped.listEventCalls, 1; got != want { + t.Fatalf("ListTaskEvents calls = %d, want %d", got, want) + } + if got, want := wrapped.listAuditCalls, 1; got != want { + t.Fatalf("ListNetworkAudit calls = %d, want %d", got, want) + } +} diff --git a/internal/observe/tasks_integration_test.go b/internal/observe/tasks_integration_test.go new file mode 100644 index 000000000..3099b3c78 --- /dev/null +++ b/internal/observe/tasks_integration_test.go @@ -0,0 +1,271 @@ +//go:build integration + +package observe + +import ( + "context" + "strconv" + "testing" + "time" + + taskpkg "github.com/pedronauck/agh/internal/task" + "github.com/pedronauck/agh/internal/testutil" +) + +type observeTaskClock struct { + current time.Time +} + +func (c *observeTaskClock) Now() time.Time { + return c.current +} + +func (c *observeTaskClock) Advance(duration time.Duration) { + c.current = c.current.Add(duration) +} + +type observeSessionExecutor struct { + nextSessionID string + startCalls []taskpkg.StartTaskSession + attachCalls []string + requestStopCalls []taskStopCall + forceStopCalls []taskStopCall +} + +type taskStopCall struct { + SessionID string + Reason taskpkg.StopReason +} + +func (e *observeSessionExecutor) StartTaskSession(_ context.Context, spec taskpkg.StartTaskSession) (*taskpkg.SessionRef, error) { + e.startCalls = append(e.startCalls, spec) + if e.nextSessionID == "" { + e.nextSessionID = "sess-observe-1" + } + return &taskpkg.SessionRef{SessionID: e.nextSessionID, StartedAt: spec.Run.StartedAt}, nil +} + +func (e *observeSessionExecutor) AttachTaskSession(_ context.Context, _ string, sessionID string) (*taskpkg.SessionRef, error) { + e.attachCalls = append(e.attachCalls, sessionID) + return &taskpkg.SessionRef{SessionID: sessionID}, nil +} + +func (e *observeSessionExecutor) RequestTaskStop(_ context.Context, sessionID string, reason taskpkg.StopReason) error { + e.requestStopCalls = append(e.requestStopCalls, taskStopCall{SessionID: sessionID, Reason: reason}) + return nil +} + +func (e *observeSessionExecutor) ForceTaskStop(_ context.Context, sessionID string, reason taskpkg.StopReason) error { + e.forceStopCalls = append(e.forceStopCalls, taskStopCall{SessionID: sessionID, Reason: reason}) + return nil +} + +func TestObserveTaskLifecycleSummaryAndMetrics(t *testing.T) { + t.Parallel() + + h := newHarness(t) + clock := &observeTaskClock{current: h.now.Add(30 * time.Minute)} + executor := &observeSessionExecutor{nextSessionID: "sess-observe-lifecycle"} + manager := newObserveTaskManager(t, h, executor, clock) + + networkActor, err := taskpkg.DeriveNetworkPeerActorContext("peer-build", "peer:peer-build/channel:engineering") + if err != nil { + t.Fatalf("DeriveNetworkPeerActorContext() error = %v", err) + } + daemonActor, err := taskpkg.DeriveDaemonActorContext("scheduler", "daemon.scheduler") + if err != nil { + t.Fatalf("DeriveDaemonActorContext() error = %v", err) + } + + created, err := manager.CreateTask(testutil.Context(t), taskpkg.CreateTask{ + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: h.workspaceID, + Title: "Implement observe lifecycle coverage", + NetworkChannel: "engineering", + }, networkActor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + clock.Advance(2 * time.Minute) + run, err := manager.EnqueueRun(testutil.Context(t), taskpkg.EnqueueRun{ + TaskID: created.ID, + IdempotencyKey: "idem-observe-1", + }, networkActor) + if err != nil { + t.Fatalf("EnqueueRun() error = %v", err) + } + + clock.Advance(2 * time.Minute) + if _, err := manager.ClaimRun(testutil.Context(t), run.ID, taskpkg.ClaimRun{IdempotencyKey: "claim-observe-1"}, daemonActor); err != nil { + t.Fatalf("ClaimRun() error = %v", err) + } + + clock.Advance(3 * time.Minute) + if _, err := manager.StartRun(testutil.Context(t), run.ID, taskpkg.StartRun{IdempotencyKey: "start-observe-1"}, daemonActor); err != nil { + t.Fatalf("StartRun() error = %v", err) + } + + clock.Advance(4 * time.Minute) + if _, err := manager.CompleteRun(testutil.Context(t), run.ID, taskpkg.RunResult{}, daemonActor); err != nil { + t.Fatalf("CompleteRun() error = %v", err) + } + + summary, err := h.observer.QueryTaskSummary(testutil.Context(t), TaskSummaryQuery{}) + if err != nil { + t.Fatalf("QueryTaskSummary() error = %v", err) + } + if !containsTaskTotal(summary.TaskTotals, taskpkg.ScopeWorkspace, taskpkg.TaskStatusCompleted, "engineering", 1) { + t.Fatalf("summary.TaskTotals = %#v, want workspace/completed/engineering count 1", summary.TaskTotals) + } + if !containsTaskOriginTotal(summary.TaskOrigins, taskpkg.OriginKindNetwork, "engineering", 1) { + t.Fatalf("summary.TaskOrigins = %#v, want network/engineering count 1", summary.TaskOrigins) + } + if !containsRunTotal(summary.RunTotals, taskpkg.TaskRunStatusCompleted, taskpkg.OriginKindNetwork, "engineering", 1) { + t.Fatalf("summary.RunTotals = %#v, want completed/network/engineering count 1", summary.RunTotals) + } + + metrics, err := h.observer.QueryTaskMetrics(testutil.Context(t), TaskMetricsQuery{}) + if err != nil { + t.Fatalf("QueryTaskMetrics() error = %v", err) + } + if !containsRunTotal(metrics.TaskRunsTotal, taskpkg.TaskRunStatusCompleted, taskpkg.OriginKindNetwork, "engineering", 1) { + t.Fatalf("metrics.TaskRunsTotal = %#v, want completed/network/engineering count 1", metrics.TaskRunsTotal) + } + if got, want := metrics.TaskClaimLatencyMillis.Samples, 1; got != want { + t.Fatalf("metrics.TaskClaimLatencyMillis.Samples = %d, want %d", got, want) + } + if got, want := metrics.TaskClaimLatencyMillis.AverageMillis, int64((2 * time.Minute).Milliseconds()); got != want { + t.Fatalf("metrics.TaskClaimLatencyMillis.AverageMillis = %d, want %d", got, want) + } + if got, want := metrics.TaskStartLatencyMillis.AverageMillis, int64((3 * time.Minute).Milliseconds()); got != want { + t.Fatalf("metrics.TaskStartLatencyMillis.AverageMillis = %d, want %d", got, want) + } +} + +func TestObserveHealthReflectsRecoveryAndForcedStopOutcomes(t *testing.T) { + t.Parallel() + + h := newHarness(t) + clock := &observeTaskClock{current: h.now.Add(45 * time.Minute)} + executor := &observeSessionExecutor{nextSessionID: "sess-observe-cancel"} + manager := newObserveTaskManager(t, h, executor, clock) + + humanActor, err := taskpkg.DeriveHumanActorContext("user-ops", taskpkg.OriginKindCLI, "agh task") + if err != nil { + t.Fatalf("DeriveHumanActorContext() error = %v", err) + } + daemonActor, err := taskpkg.DeriveDaemonActorContext("scheduler", "daemon.scheduler") + if err != nil { + t.Fatalf("DeriveDaemonActorContext() error = %v", err) + } + recoveryActor, err := taskpkg.DeriveDaemonActorContext("boot-recovery", "daemon.boot") + if err != nil { + t.Fatalf("DeriveDaemonActorContext(boot) error = %v", err) + } + + cancelTask, err := manager.CreateTask(testutil.Context(t), taskpkg.CreateTask{ + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: h.workspaceID, + Title: "Cancel running work", + }, humanActor) + if err != nil { + t.Fatalf("CreateTask(cancelTask) error = %v", err) + } + recoveryTask, err := manager.CreateTask(testutil.Context(t), taskpkg.CreateTask{ + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: h.workspaceID, + Title: "Recover orphaned run", + }, humanActor) + if err != nil { + t.Fatalf("CreateTask(recoveryTask) error = %v", err) + } + + clock.Advance(time.Minute) + runningRun, err := manager.EnqueueRun(testutil.Context(t), taskpkg.EnqueueRun{TaskID: cancelTask.ID}, humanActor) + if err != nil { + t.Fatalf("EnqueueRun(running) error = %v", err) + } + clock.Advance(time.Minute) + if _, err := manager.ClaimRun(testutil.Context(t), runningRun.ID, taskpkg.ClaimRun{IdempotencyKey: "claim-cancel-1"}, daemonActor); err != nil { + t.Fatalf("ClaimRun(running) error = %v", err) + } + clock.Advance(time.Minute) + if _, err := manager.StartRun(testutil.Context(t), runningRun.ID, taskpkg.StartRun{IdempotencyKey: "start-cancel-1"}, daemonActor); err != nil { + t.Fatalf("StartRun(running) error = %v", err) + } + clock.Advance(time.Minute) + if _, err := manager.CancelTask(testutil.Context(t), cancelTask.ID, taskpkg.CancelTask{Reason: "shutdown"}, humanActor); err != nil { + t.Fatalf("CancelTask() error = %v", err) + } + + executor.nextSessionID = "sess-observe-attach" + clock.Advance(time.Minute) + recoveredRun, err := manager.EnqueueRun(testutil.Context(t), taskpkg.EnqueueRun{TaskID: recoveryTask.ID}, humanActor) + if err != nil { + t.Fatalf("EnqueueRun(recovery) error = %v", err) + } + clock.Advance(time.Minute) + if _, err := manager.ClaimRun(testutil.Context(t), recoveredRun.ID, taskpkg.ClaimRun{IdempotencyKey: "claim-recovery-1"}, daemonActor); err != nil { + t.Fatalf("ClaimRun(recovery) error = %v", err) + } + clock.Advance(time.Minute) + if _, err := manager.AttachRunSession(testutil.Context(t), recoveredRun.ID, "sess-missing-on-boot", daemonActor); err != nil { + t.Fatalf("AttachRunSession() error = %v", err) + } + clock.Advance(time.Minute) + if _, err := manager.RecoverRunOnBoot(testutil.Context(t), recoveredRun.ID, taskpkg.RunBootRecovery{ + Action: taskpkg.RunBootRecoveryFail, + Reason: "orphaned_on_boot", + SessionState: "missing", + }, recoveryActor); err != nil { + t.Fatalf("RecoverRunOnBoot() error = %v", err) + } + + health, err := h.observer.Health(testutil.Context(t)) + if err != nil { + t.Fatalf("Health() error = %v", err) + } + + if got, want := len(executor.forceStopCalls), 1; got != want { + t.Fatalf("len(forceStopCalls) = %d, want %d", got, want) + } + if got, want := health.Tasks.ForcedStopsSinceStart, 1; got != want { + t.Fatalf("health.Tasks.ForcedStopsSinceStart = %d, want %d", got, want) + } + if got, want := health.Tasks.RecoverySinceStart.Failed, 1; got != want { + t.Fatalf("health.Tasks.RecoverySinceStart.Failed = %d, want %d", got, want) + } + if !containsTaskTotal(health.Tasks.TaskTotals, taskpkg.ScopeWorkspace, taskpkg.TaskStatusCancelled, "", 1) { + t.Fatalf("health.Tasks.TaskTotals = %#v, want cancelled task count 1", health.Tasks.TaskTotals) + } + if !containsTaskTotal(health.Tasks.TaskTotals, taskpkg.ScopeWorkspace, taskpkg.TaskStatusFailed, "", 1) { + t.Fatalf("health.Tasks.TaskTotals = %#v, want failed task count 1", health.Tasks.TaskTotals) + } + if !containsRunTotal(health.Tasks.RunTotals, taskpkg.TaskRunStatusCancelled, taskpkg.OriginKindCLI, "", 1) { + t.Fatalf("health.Tasks.RunTotals = %#v, want cancelled/cli run count 1", health.Tasks.RunTotals) + } + if !containsRunTotal(health.Tasks.RunTotals, taskpkg.TaskRunStatusFailed, taskpkg.OriginKindCLI, "", 1) { + t.Fatalf("health.Tasks.RunTotals = %#v, want failed/cli run count 1", health.Tasks.RunTotals) + } +} + +func newObserveTaskManager(t *testing.T, h *harness, executor *observeSessionExecutor, clock *observeTaskClock) *taskpkg.TaskManager { + t.Helper() + + sequence := 0 + manager, err := taskpkg.NewManager( + taskpkg.WithStore(h.registry), + taskpkg.WithSessionExecutor(executor), + taskpkg.WithManagerNow(clock.Now), + taskpkg.WithIDGenerator(func(prefix string) string { + sequence++ + return prefix + "-observe-" + strconv.FormatInt(clock.current.UnixNano(), 10) + "-" + strconv.Itoa(sequence) + }), + taskpkg.WithCancelGracePeriod(0), + ) + if err != nil { + t.Fatalf("task.NewManager() error = %v", err) + } + return manager +} diff --git a/internal/observe/tasks_test.go b/internal/observe/tasks_test.go new file mode 100644 index 000000000..578ce3761 --- /dev/null +++ b/internal/observe/tasks_test.go @@ -0,0 +1,525 @@ +package observe + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/pedronauck/agh/internal/session" + "github.com/pedronauck/agh/internal/store" + taskpkg "github.com/pedronauck/agh/internal/task" + "github.com/pedronauck/agh/internal/testutil" +) + +func TestQueryTaskSummaryAggregatesByScopeOriginChannelAndOwner(t *testing.T) { + t.Parallel() + + h := newHarness(t) + + createObserveTask(t, h, taskpkg.Task{ + ID: "task-global-ready", + Scope: taskpkg.ScopeGlobal, + Title: "Global ready", + Status: taskpkg.TaskStatusReady, + CreatedBy: taskActor(taskpkg.ActorKindHuman, "user-1"), + Origin: taskOrigin(taskpkg.OriginKindCLI, "agh task create"), + CreatedAt: h.now.Add(time.Minute), + UpdatedAt: h.now.Add(time.Minute), + }) + createObserveTask(t, h, taskpkg.Task{ + ID: "task-workspace-blocked", + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: h.workspaceID, + Title: "Workspace blocked", + Status: taskpkg.TaskStatusBlocked, + Owner: taskOwner(taskpkg.OwnerKindHuman, "alice"), + NetworkChannel: "ops", + CreatedBy: taskActor(taskpkg.ActorKindNetworkPeer, "peer-ops"), + Origin: taskOrigin(taskpkg.OriginKindNetwork, "peer:peer-ops/channel:ops"), + CreatedAt: h.now.Add(2 * time.Minute), + UpdatedAt: h.now.Add(2 * time.Minute), + }) + createObserveTask(t, h, taskpkg.Task{ + ID: "task-workspace-completed", + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: h.workspaceID, + Title: "Workspace completed", + Status: taskpkg.TaskStatusCompleted, + Owner: taskOwner(taskpkg.OwnerKindPool, "backlog"), + NetworkChannel: "eng", + CreatedBy: taskActor(taskpkg.ActorKindAutomation, "rule-1"), + Origin: taskOrigin(taskpkg.OriginKindAutomation, "run:rule-1"), + CreatedAt: h.now.Add(3 * time.Minute), + UpdatedAt: h.now.Add(3 * time.Minute), + ClosedAt: h.now.Add(4 * time.Minute), + }) + + createObserveRun(t, h, taskpkg.TaskRun{ + ID: "run-global-queued", + TaskID: "task-global-ready", + Status: taskpkg.TaskRunStatusQueued, + Attempt: 1, + Origin: taskOrigin(taskpkg.OriginKindCLI, "agh task run"), + QueuedAt: h.now.Add(10 * time.Minute), + }) + createObserveRun(t, h, taskpkg.TaskRun{ + ID: "run-workspace-running", + TaskID: "task-workspace-blocked", + Status: taskpkg.TaskRunStatusRunning, + Attempt: 1, + ClaimedBy: taskActorPtr(taskpkg.ActorKindDaemon, "scheduler"), + SessionID: "sess-ops-live", + Origin: taskOrigin(taskpkg.OriginKindNetwork, "peer:peer-ops/channel:ops"), + NetworkChannel: "ops", + QueuedAt: h.now.Add(11 * time.Minute), + ClaimedAt: h.now.Add(12 * time.Minute), + StartedAt: h.now.Add(13 * time.Minute), + }) + createObserveRun(t, h, taskpkg.TaskRun{ + ID: "run-workspace-completed", + TaskID: "task-workspace-completed", + Status: taskpkg.TaskRunStatusCompleted, + Attempt: 1, + ClaimedBy: taskActorPtr(taskpkg.ActorKindDaemon, "scheduler"), + SessionID: "sess-eng-done", + Origin: taskOrigin(taskpkg.OriginKindAutomation, "run:rule-1"), + NetworkChannel: "eng", + QueuedAt: h.now.Add(14 * time.Minute), + ClaimedAt: h.now.Add(15 * time.Minute), + StartedAt: h.now.Add(16 * time.Minute), + EndedAt: h.now.Add(18 * time.Minute), + }) + + summary, err := h.observer.QueryTaskSummary(testutil.Context(t), TaskSummaryQuery{}) + if err != nil { + t.Fatalf("QueryTaskSummary() error = %v", err) + } + + if got, want := summary.TotalTasks, 3; got != want { + t.Fatalf("summary.TotalTasks = %d, want %d", got, want) + } + if got, want := summary.TotalRuns, 3; got != want { + t.Fatalf("summary.TotalRuns = %d, want %d", got, want) + } + if !containsTaskTotal(summary.TaskTotals, taskpkg.ScopeGlobal, taskpkg.TaskStatusReady, "", 1) { + t.Fatalf("summary.TaskTotals = %#v, want global/ready/unbound count 1", summary.TaskTotals) + } + if !containsTaskTotal(summary.TaskTotals, taskpkg.ScopeWorkspace, taskpkg.TaskStatusBlocked, "ops", 1) { + t.Fatalf("summary.TaskTotals = %#v, want workspace/blocked/ops count 1", summary.TaskTotals) + } + if !containsTaskOriginTotal(summary.TaskOrigins, taskpkg.OriginKindNetwork, "ops", 1) { + t.Fatalf("summary.TaskOrigins = %#v, want network/ops count 1", summary.TaskOrigins) + } + if !containsRunTotal(summary.RunTotals, taskpkg.TaskRunStatusCompleted, taskpkg.OriginKindAutomation, "eng", 1) { + t.Fatalf("summary.RunTotals = %#v, want completed/automation/eng count 1", summary.RunTotals) + } + if !containsOwnerTotal(summary.OwnerTotals, taskpkg.OwnerKindHuman, "alice", 1) { + t.Fatalf("summary.OwnerTotals = %#v, want human/alice count 1", summary.OwnerTotals) + } + if !containsQueueDepth(summary.QueueDepth, "", 1) { + t.Fatalf("summary.QueueDepth = %#v, want unbound queue depth 1", summary.QueueDepth) + } + + filtered, err := h.observer.QueryTaskSummary(testutil.Context(t), TaskSummaryQuery{ + OwnerKind: taskpkg.OwnerKindHuman, + OwnerRef: "alice", + NetworkChannel: "ops", + }) + if err != nil { + t.Fatalf("QueryTaskSummary(filtered) error = %v", err) + } + if got, want := filtered.TotalTasks, 1; got != want { + t.Fatalf("filtered.TotalTasks = %d, want %d", got, want) + } + if got, want := filtered.TotalRuns, 1; got != want { + t.Fatalf("filtered.TotalRuns = %d, want %d", got, want) + } + if !containsRunTotal(filtered.RunTotals, taskpkg.TaskRunStatusRunning, taskpkg.OriginKindNetwork, "ops", 1) { + t.Fatalf("filtered.RunTotals = %#v, want running/network/ops count 1", filtered.RunTotals) + } +} + +func TestTaskHealthFlagsStuckRunsByConfiguredThresholds(t *testing.T) { + t.Parallel() + + h := newHarness(t) + h.observer.taskHealthConfig = TaskHealthConfig{ + ClaimedStuckAfter: 5 * time.Minute, + StartingStuckAfter: 10 * time.Minute, + RunningStuckAfter: 15 * time.Minute, + } + + liveStartedAt := h.observer.now().Add(-5 * time.Minute) + h.source.sessions = []*session.SessionInfo{ + { + ID: "sess-live-running", + Name: "LIVE", + AgentName: "coder", + WorkspaceID: h.workspaceID, + Workspace: h.workspace, + State: session.StateActive, + ACPSessionID: "acp-live-running", + CreatedAt: liveStartedAt, + UpdatedAt: liveStartedAt, + }, + { + ID: "sess-live-starting", + Name: "LIVE2", + AgentName: "coder", + WorkspaceID: h.workspaceID, + Workspace: h.workspace, + State: session.StateActive, + ACPSessionID: "acp-live-starting", + CreatedAt: liveStartedAt, + UpdatedAt: liveStartedAt, + }, + } + + taskIDs := []string{"task-claimed", "task-starting-recent", "task-starting-stale", "task-running-stale"} + for _, id := range taskIDs { + createObserveTask(t, h, taskpkg.Task{ + ID: id, + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: h.workspaceID, + Title: id, + Status: taskpkg.TaskStatusInProgress, + CreatedBy: taskActor(taskpkg.ActorKindHuman, "user"), + Origin: taskOrigin(taskpkg.OriginKindCLI, "agh task"), + CreatedAt: h.now, + UpdatedAt: h.now, + }) + } + + now := h.observer.now() + createObserveRun(t, h, taskpkg.TaskRun{ + ID: "run-claimed-stale", + TaskID: "task-claimed", + Status: taskpkg.TaskRunStatusClaimed, + Attempt: 1, + Origin: taskOrigin(taskpkg.OriginKindCLI, "agh task"), + QueuedAt: now.Add(-40 * time.Minute), + ClaimedAt: now.Add(-20 * time.Minute), + }) + createObserveRun(t, h, taskpkg.TaskRun{ + ID: "run-starting-fresh", + TaskID: "task-starting-recent", + Status: taskpkg.TaskRunStatusStarting, + Attempt: 1, + SessionID: "sess-live-starting", + Origin: taskOrigin(taskpkg.OriginKindCLI, "agh task"), + QueuedAt: now.Add(-15 * time.Minute), + ClaimedAt: now.Add(-4 * time.Minute), + NetworkChannel: "ops", + }) + createObserveRun(t, h, taskpkg.TaskRun{ + ID: "run-starting-stale", + TaskID: "task-starting-stale", + Status: taskpkg.TaskRunStatusStarting, + Attempt: 1, + SessionID: "sess-live-starting", + Origin: taskOrigin(taskpkg.OriginKindNetwork, "peer:peer-1/channel:ops"), + QueuedAt: now.Add(-25 * time.Minute), + ClaimedAt: now.Add(-12 * time.Minute), + NetworkChannel: "ops", + }) + createObserveRun(t, h, taskpkg.TaskRun{ + ID: "run-running-stale", + TaskID: "task-running-stale", + Status: taskpkg.TaskRunStatusRunning, + Attempt: 1, + SessionID: "sess-live-running", + Origin: taskOrigin(taskpkg.OriginKindAutomation, "run:auto-1"), + QueuedAt: now.Add(-30 * time.Minute), + ClaimedAt: now.Add(-28 * time.Minute), + StartedAt: now.Add(-20 * time.Minute), + NetworkChannel: "eng", + }) + + health, err := h.observer.Health(testutil.Context(t)) + if err != nil { + t.Fatalf("Health() error = %v", err) + } + + if got, want := len(health.Tasks.StuckRuns), 3; got != want { + t.Fatalf("len(health.Tasks.StuckRuns) = %d, want %d", got, want) + } + if !containsStuckRun(health.Tasks.StuckRuns, "run-claimed-stale", taskpkg.TaskRunStatusClaimed) { + t.Fatalf("health.Tasks.StuckRuns = %#v, want claimed stale run", health.Tasks.StuckRuns) + } + if !containsStuckRun(health.Tasks.StuckRuns, "run-starting-stale", taskpkg.TaskRunStatusStarting) { + t.Fatalf("health.Tasks.StuckRuns = %#v, want starting stale run", health.Tasks.StuckRuns) + } + if !containsStuckRun(health.Tasks.StuckRuns, "run-running-stale", taskpkg.TaskRunStatusRunning) { + t.Fatalf("health.Tasks.StuckRuns = %#v, want running stale run", health.Tasks.StuckRuns) + } + if containsStuckRun(health.Tasks.StuckRuns, "run-starting-fresh", taskpkg.TaskRunStatusStarting) { + t.Fatalf("health.Tasks.StuckRuns = %#v, fresh starting run should not be flagged", health.Tasks.StuckRuns) + } + if got, want := health.Tasks.ActiveOrphanRuns, 0; got != want { + t.Fatalf("health.Tasks.ActiveOrphanRuns = %d, want %d", got, want) + } + if got, want := health.Tasks.Status, "warn"; got != want { + t.Fatalf("health.Tasks.Status = %q, want %q", got, want) + } +} + +func TestQueryTaskMetricsCountsDuplicateIngressAndChannelMismatch(t *testing.T) { + t.Parallel() + + h := newHarness(t) + + createObserveTask(t, h, taskpkg.Task{ + ID: "task-net", + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: h.workspaceID, + Title: "Network task", + Status: taskpkg.TaskStatusReady, + NetworkChannel: "ops", + CreatedBy: taskActor(taskpkg.ActorKindNetworkPeer, "peer-ops"), + Origin: taskOrigin(taskpkg.OriginKindNetwork, "peer:peer-ops/channel:ops"), + CreatedAt: h.now, + UpdatedAt: h.now, + }) + createObserveRun(t, h, taskpkg.TaskRun{ + ID: "run-net", + TaskID: "task-net", + Status: taskpkg.TaskRunStatusQueued, + Attempt: 1, + Origin: taskOrigin(taskpkg.OriginKindNetwork, "peer:peer-ops/channel:ops"), + NetworkChannel: "ops", + IdempotencyKey: "idem-1", + QueuedAt: h.now.Add(time.Minute), + }) + createObserveEvent(t, h, taskpkg.TaskEvent{ + ID: "evt-run-enqueued", + TaskID: "task-net", + RunID: "run-net", + EventType: taskEventRunEnqueued, + Actor: taskActor(taskpkg.ActorKindNetworkPeer, "peer-ops"), + Origin: taskOrigin(taskpkg.OriginKindNetwork, "peer:peer-ops/channel:ops"), + Timestamp: h.now.Add(2 * time.Minute), + Payload: mustJSON(t, map[string]any{"network_channel": "ops", "idempotency_key": "idem-1"}), + }) + createObserveAudit(t, h, store.NetworkAuditEntry{ + ID: "naud-accepted-1", + SessionID: "netpeer:peer-ops", + Direction: "received", + Kind: taskIngressAuditEnqueueAction, + Channel: "ops", + PeerFrom: "peer-ops", + MessageID: "req-1", + Size: 32, + Timestamp: h.now.Add(2 * time.Minute), + }) + createObserveAudit(t, h, store.NetworkAuditEntry{ + ID: "naud-accepted-2", + SessionID: "netpeer:peer-ops", + Direction: "received", + Kind: taskIngressAuditEnqueueAction, + Channel: "ops", + PeerFrom: "peer-ops", + MessageID: "req-2", + Size: 32, + Timestamp: h.now.Add(3 * time.Minute), + }) + createObserveAudit(t, h, store.NetworkAuditEntry{ + ID: "naud-rejected-mismatch", + SessionID: "netpeer:peer-ops", + Direction: "rejected", + Kind: taskIngressAuditEnqueueAction, + Channel: "ops", + PeerFrom: "peer-ops", + MessageID: "req-3", + Reason: taskIngressChannelMismatch, + Size: 32, + Timestamp: h.now.Add(4 * time.Minute), + }) + createObserveAudit(t, h, store.NetworkAuditEntry{ + ID: "naud-rejected-stale", + SessionID: "netpeer:peer-ops", + Direction: "rejected", + Kind: taskIngressAuditEnqueueAction, + Channel: "ops", + PeerFrom: "peer-ops", + MessageID: "req-4", + Reason: "stale_channel", + Size: 32, + Timestamp: h.now.Add(5 * time.Minute), + }) + + metrics, err := h.observer.QueryTaskMetrics(testutil.Context(t), TaskMetricsQuery{ + Since: h.now, + NetworkChannel: "ops", + }) + if err != nil { + t.Fatalf("QueryTaskMetrics() error = %v", err) + } + + if got, want := metrics.DuplicateIngressTotal, 1; got != want { + t.Fatalf("metrics.DuplicateIngressTotal = %d, want %d", got, want) + } + if got, want := metrics.ChannelMismatchTotal, 1; got != want { + t.Fatalf("metrics.ChannelMismatchTotal = %d, want %d", got, want) + } + if !containsRunTotal(metrics.TaskRunsTotal, taskpkg.TaskRunStatusQueued, taskpkg.OriginKindNetwork, "ops", 1) { + t.Fatalf("metrics.TaskRunsTotal = %#v, want queued/network/ops count 1", metrics.TaskRunsTotal) + } + if !containsQueueDepth(metrics.TaskQueueDepth, "ops", 1) { + t.Fatalf("metrics.TaskQueueDepth = %#v, want ops queue depth 1", metrics.TaskQueueDepth) + } +} + +func TestTaskObserveQueryValidationAndConfigOption(t *testing.T) { + t.Parallel() + + cfg := TaskHealthConfig{ + ClaimedStuckAfter: time.Minute, + StartingStuckAfter: 2 * time.Minute, + RunningStuckAfter: 3 * time.Minute, + } + + observer := &Observer{} + WithTaskHealthConfig(cfg)(observer) + + if observer.taskHealthConfig != cfg { + t.Fatalf("observer.taskHealthConfig = %#v, want %#v", observer.taskHealthConfig, cfg) + } + + if err := (TaskSummaryQuery{Scope: taskpkg.Scope("bogus")}).Validate(); !errors.Is(err, taskpkg.ErrValidation) || !strings.Contains(err.Error(), "scope") { + t.Fatalf("TaskSummaryQuery.Validate(invalid scope) error = %v, want ErrValidation mentioning scope", err) + } + if err := (TaskSummaryQuery{OwnerKind: taskpkg.OwnerKind("bogus")}).Validate(); !errors.Is(err, taskpkg.ErrValidation) || !strings.Contains(err.Error(), "owner_kind") { + t.Fatalf("TaskSummaryQuery.Validate(invalid owner kind) error = %v, want ErrValidation mentioning owner_kind", err) + } + if err := (TaskMetricsQuery{OriginKind: taskpkg.OriginKind("bogus")}).Validate(); !errors.Is(err, taskpkg.ErrValidation) || !strings.Contains(err.Error(), "origin_kind") { + t.Fatalf("TaskMetricsQuery.Validate(invalid origin kind) error = %v, want ErrValidation mentioning origin_kind", err) + } +} + +func TestObserverHealthWrapsTaskHealthErrors(t *testing.T) { + t.Parallel() + + h := newHarness(t) + h.observer.bridgeSource = nil + if err := h.registry.Close(testutil.Context(t)); err != nil { + t.Fatalf("registry.Close() error = %v", err) + } + + _, err := h.observer.Health(testutil.Context(t)) + if err == nil { + t.Fatal("Health() error = nil, want wrapped task health failure") + } + if !strings.Contains(err.Error(), "observe: collect task health") { + t.Fatalf("Health() error = %v, want collect task health context", err) + } +} + +func createObserveTask(t *testing.T, h *harness, record taskpkg.Task) { + t.Helper() + if err := h.registry.CreateTask(testutil.Context(t), record); err != nil { + t.Fatalf("CreateTask(%q) error = %v", record.ID, err) + } +} + +func createObserveRun(t *testing.T, h *harness, run taskpkg.TaskRun) { + t.Helper() + if err := h.registry.CreateTaskRun(testutil.Context(t), run); err != nil { + t.Fatalf("CreateTaskRun(%q) error = %v", run.ID, err) + } +} + +func createObserveEvent(t *testing.T, h *harness, event taskpkg.TaskEvent) { + t.Helper() + if err := h.registry.CreateTaskEvent(testutil.Context(t), event); err != nil { + t.Fatalf("CreateTaskEvent(%q) error = %v", event.ID, err) + } +} + +func createObserveAudit(t *testing.T, h *harness, entry store.NetworkAuditEntry) { + t.Helper() + if err := h.registry.WriteNetworkAudit(testutil.Context(t), entry); err != nil { + t.Fatalf("WriteNetworkAudit(%q) error = %v", entry.ID, err) + } +} + +func taskActor(kind taskpkg.ActorKind, ref string) taskpkg.ActorIdentity { + return taskpkg.ActorIdentity{Kind: kind, Ref: ref} +} + +func taskActorPtr(kind taskpkg.ActorKind, ref string) *taskpkg.ActorIdentity { + actor := taskActor(kind, ref) + return &actor +} + +func taskOrigin(kind taskpkg.OriginKind, ref string) taskpkg.Origin { + return taskpkg.Origin{Kind: kind, Ref: ref} +} + +func taskOwner(kind taskpkg.OwnerKind, ref string) *taskpkg.Ownership { + return &taskpkg.Ownership{Kind: kind, Ref: ref} +} + +func mustJSON(t *testing.T, value any) json.RawMessage { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + return raw +} + +func containsTaskTotal(rows []TaskStatusTotal, scope taskpkg.Scope, status taskpkg.TaskStatus, channel string, count int) bool { + for _, item := range rows { + if item.Scope == scope && item.Status == status && item.NetworkChannel == channel && item.Count == count { + return true + } + } + return false +} + +func containsTaskOriginTotal(rows []TaskOriginTotal, origin taskpkg.OriginKind, channel string, count int) bool { + for _, item := range rows { + if item.OriginKind == origin && item.NetworkChannel == channel && item.Count == count { + return true + } + } + return false +} + +func containsRunTotal(rows []TaskRunTotal, status taskpkg.TaskRunStatus, origin taskpkg.OriginKind, channel string, count int) bool { + for _, item := range rows { + if item.Status == status && item.OriginKind == origin && item.NetworkChannel == channel && item.Count == count { + return true + } + } + return false +} + +func containsOwnerTotal(rows []TaskOwnerTotal, ownerKind taskpkg.OwnerKind, ownerRef string, count int) bool { + for _, item := range rows { + if item.OwnerKind == ownerKind && item.OwnerRef == ownerRef && item.Count == count { + return true + } + } + return false +} + +func containsQueueDepth(rows []TaskQueueDepth, channel string, count int) bool { + for _, item := range rows { + if item.NetworkChannel == channel && item.Count == count { + return true + } + } + return false +} + +func containsStuckRun(rows []StuckTaskRun, runID string, status taskpkg.TaskRunStatus) bool { + for _, item := range rows { + if item.RunID == runID && item.Status == status { + return true + } + } + return false +} diff --git a/internal/session/manager.go b/internal/session/manager.go index 102ade0a1..2c3c8503b 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -427,6 +427,22 @@ func (m *Manager) remove(id string) { delete(m.finalizing, id) } +func (m *Manager) removeActive(id string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.sessions, id) + delete(m.pending, id) +} + +func (m *Manager) finishFinalization(id string) { + m.mu.Lock() + defer m.mu.Unlock() + if done, ok := m.finalizing[id]; ok { + close(done) + } + delete(m.finalizing, id) +} + func (m *Manager) claimFinalization(session *Session) (bool, <-chan struct{}) { if session == nil { return false, nil @@ -448,6 +464,39 @@ func (m *Manager) claimFinalization(session *Session) (bool, <-chan struct{}) { return true, done } +// WaitForFinalizations blocks until all in-flight finalization routines finish. +func (m *Manager) WaitForFinalizations(ctx context.Context) error { + if m == nil { + return nil + } + if ctx == nil { + return errors.New("session: wait for finalizations context is required") + } + + for { + m.mu.RLock() + pending := make([]<-chan struct{}, 0, len(m.finalizing)) + for _, done := range m.finalizing { + if done != nil { + pending = append(pending, done) + } + } + m.mu.RUnlock() + + if len(pending) == 0 { + return nil + } + + for _, done := range pending { + select { + case <-done: + case <-ctx.Done(): + return ctx.Err() + } + } + } +} + type maxSessionsReachedError struct { active int limit int diff --git a/internal/session/manager_hooks_test.go b/internal/session/manager_hooks_test.go index 1645686ad..e7525a775 100644 --- a/internal/session/manager_hooks_test.go +++ b/internal/session/manager_hooks_test.go @@ -426,6 +426,52 @@ func TestStopWithCauseLifecycle(t *testing.T) { t.Fatalf("cleanup Stop() error = %v", err) } }) + + t.Run("ShouldWaitForPostStopDispatchWhenWatcherFinalizesFirst", func(t *testing.T) { + t.Parallel() + + postStopStarted := make(chan struct{}) + releasePostStop := make(chan struct{}) + dispatcher := &spyHookDispatcher{ + dispatchSessionPostStopFn: func(_ context.Context, payload hookspkg.SessionPostStopPayload) (hookspkg.SessionPostStopPayload, error) { + close(postStopStarted) + <-releasePostStop + return payload, nil + }, + } + h := newHarness(t, WithHookSet(fullHookSet(dispatcher))) + session := createSession(t, h) + + h.driver.stopHook = func(proc *fakeProcess) error { + proc.exit() + select { + case <-postStopStarted: + return nil + case <-time.After(time.Second): + return errors.New("test: watcher did not reach session.post_stop") + } + } + + stopDone := make(chan error, 1) + go func() { + stopDone <- h.manager.StopWithCause(testutil.Context(t), session.ID, CauseShutdown, "daemon shutdown") + }() + + select { + case err := <-stopDone: + t.Fatalf("StopWithCause() returned before session.post_stop completed: %v", err) + case <-time.After(50 * time.Millisecond): + } + + close(releasePostStop) + + if err := <-stopDone; err != nil { + t.Fatalf("StopWithCause() error = %v", err) + } + if got := h.notifier.stoppedCount(); got != 1 { + t.Fatalf("stopped notifications = %d, want 1", got) + } + }) } func TestCreateUsesPatchedPrompt(t *testing.T) { diff --git a/internal/session/manager_lifecycle.go b/internal/session/manager_lifecycle.go index 5ac446418..7dc8d80f1 100644 --- a/internal/session/manager_lifecycle.go +++ b/internal/session/manager_lifecycle.go @@ -162,6 +162,7 @@ func (m *Manager) finalizeStopped(ctx context.Context, session *Session, waitErr if done == nil { return nil } + defer m.finishFinalization(session.ID) var errs []error state := session.Info().State @@ -250,7 +251,7 @@ func (m *Manager) finalizeStopped(ctx context.Context, session *Session, waitErr } } - m.remove(session.ID) + m.removeActive(session.ID) m.dispatchSessionPostStop(ctx, session) if m.notifier != nil { m.notifier.OnSessionStopped(ctx, session) diff --git a/internal/session/stop_reason.go b/internal/session/stop_reason.go index c7314a05b..b5726f365 100644 --- a/internal/session/stop_reason.go +++ b/internal/session/stop_reason.go @@ -49,42 +49,59 @@ func classifyStopReason(cause StopCause, waitErr error, detail string) (store.St } } -// StopWithCause stops a session while preserving the explicit stop initiator. -func (m *Manager) StopWithCause(ctx context.Context, id string, cause StopCause, detail string) error { +// RequestStopWithCause marks a session as stopping and sends the cooperative ACP +// cancel signal without forcing process termination. +func (m *Manager) RequestStopWithCause(ctx context.Context, id string, cause StopCause, detail string) error { + if m == nil { + return errors.New("session: manager is required") + } if ctx == nil { - return errors.New("session: stop context is required") + return errors.New("session: request stop context is required") } if cause == CauseNone { cause = CauseUserRequested } - session, err := m.lookup(id) + session, proc, alreadyStopped, err := m.prepareStopWithCause(ctx, id, cause, detail) if err != nil { return err } - if err := m.dispatchSessionPreStop(ctx, session); err != nil { - return err + if alreadyStopped { + return nil + } + if proc == nil { + return m.finalizeStopped(ctx, session, nil) } - writeMeta, promptSetupDone, err := session.prepareStop(m.now(), cause, detail) - if err != nil { - return err + cancelErr := m.driver.Cancel(ctx, proc) + if cancelErr != nil && !isProcessDone(proc) { + return fmt.Errorf("session: request cooperative stop for %q: %w", id, cancelErr) } - if writeMeta { - if err := m.writeMeta(session); err != nil { - return err - } + if isProcessDone(proc) { + return errors.Join(cancelErr, m.finalizeStopped(ctx, session, nil)) } - if err := waitForPromptSetup(ctx, session, promptSetupDone); err != nil { - return err + return cancelErr +} + +// StopWithCause stops a session while preserving the explicit stop initiator. +func (m *Manager) StopWithCause(ctx context.Context, id string, cause StopCause, detail string) error { + if m == nil { + return errors.New("session: manager is required") + } + if ctx == nil { + return errors.New("session: stop context is required") + } + if cause == CauseNone { + cause = CauseUserRequested } - state := session.Info().State - if state == StateStopped { + session, proc, alreadyStopped, err := m.prepareStopWithCause(ctx, id, cause, detail) + if err != nil { + return err + } + if alreadyStopped { return nil } - - proc := session.processHandle() if proc == nil { return m.finalizeStopped(ctx, session, nil) } @@ -103,3 +120,33 @@ func (m *Manager) StopWithCause(ctx context.Context, id string, cause StopCause, return errors.Join(stopErr, m.finalizeStopped(ctx, session, nil)) } + +func (m *Manager) prepareStopWithCause(ctx context.Context, id string, cause StopCause, detail string) (*Session, *AgentProcess, bool, error) { + session, err := m.lookup(id) + if err != nil { + return nil, nil, false, err + } + if session.Info().State == StateActive { + if err := m.dispatchSessionPreStop(ctx, session); err != nil { + return nil, nil, false, err + } + } + + writeMeta, promptSetupDone, err := session.prepareStop(m.now(), cause, detail) + if err != nil { + return nil, nil, false, err + } + if writeMeta { + if err := m.writeMeta(session); err != nil { + return nil, nil, false, err + } + } + if err := waitForPromptSetup(ctx, session, promptSetupDone); err != nil { + return nil, nil, false, err + } + + if session.Info().State == StateStopped { + return session, nil, true, nil + } + return session, session.processHandle(), false, nil +} diff --git a/internal/session/stop_reason_test.go b/internal/session/stop_reason_test.go index b88ef90c5..6f6b34325 100644 --- a/internal/session/stop_reason_test.go +++ b/internal/session/stop_reason_test.go @@ -2,9 +2,11 @@ package session import ( "errors" + "strings" "testing" "github.com/pedronauck/agh/internal/store" + "github.com/pedronauck/agh/internal/testutil" ) func TestClassifyStopReason(t *testing.T) { @@ -117,3 +119,27 @@ func TestClassifyStopReason(t *testing.T) { }) } } + +func TestStopMethodsRejectNilManager(t *testing.T) { + t.Parallel() + + t.Run("Should reject request stop on nil manager", func(t *testing.T) { + t.Parallel() + + var nilManager *Manager + err := nilManager.RequestStopWithCause(testutil.Context(t), "sess-1", CauseUserRequested, "") + if err == nil || !strings.Contains(err.Error(), "manager is required") { + t.Fatalf("RequestStopWithCause(nil manager) error = %v, want manager is required", err) + } + }) + + t.Run("Should reject forced stop on nil manager", func(t *testing.T) { + t.Parallel() + + var nilManager *Manager + err := nilManager.StopWithCause(testutil.Context(t), "sess-1", CauseUserRequested, "") + if err == nil || !strings.Contains(err.Error(), "manager is required") { + t.Fatalf("StopWithCause(nil manager) error = %v, want manager is required", err) + } + }) +} diff --git a/internal/store/globaldb/global_db.go b/internal/store/globaldb/global_db.go index a4f787114..57c083400 100644 --- a/internal/store/globaldb/global_db.go +++ b/internal/store/globaldb/global_db.go @@ -123,6 +123,7 @@ var globalSchemaStatements = []string{ workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, prompt TEXT NOT NULL, schedule TEXT, + task TEXT, enabled BOOLEAN NOT NULL DEFAULT 1, retry TEXT NOT NULL, fire_limit TEXT NOT NULL, @@ -161,6 +162,8 @@ var globalSchemaStatements = []string{ job_id TEXT, trigger_id TEXT, session_id TEXT, + task_id TEXT, + task_run_id TEXT, status TEXT NOT NULL, attempt INTEGER NOT NULL DEFAULT 1, started_at TEXT, @@ -199,6 +202,105 @@ var globalSchemaStatements = []string{ `CREATE INDEX IF NOT EXISTS idx_automation_runs_trigger ON automation_runs(trigger_id);`, `CREATE INDEX IF NOT EXISTS idx_automation_runs_status ON automation_runs(status);`, `CREATE INDEX IF NOT EXISTS idx_automation_runs_started ON automation_runs(started_at);`, + `CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + identifier TEXT, + scope TEXT NOT NULL CHECK (scope IN ('global', 'workspace')), + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + parent_task_id TEXT REFERENCES tasks(id), + network_channel TEXT, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL CHECK (status IN ('pending', 'blocked', 'ready', 'in_progress', 'completed', 'failed', 'cancelled')), + owner_kind TEXT CHECK (owner_kind IS NULL OR owner_kind IN ('human', 'agent_session', 'automation', 'extension', 'network_peer', 'pool')), + owner_ref TEXT, + created_by_kind TEXT NOT NULL CHECK (created_by_kind IN ('human', 'agent_session', 'automation', 'extension', 'network_peer', 'daemon')), + created_by_ref TEXT NOT NULL, + origin_kind TEXT NOT NULL CHECK (origin_kind IN ('cli', 'web', 'uds', 'http', 'automation', 'extension', 'network', 'agent_session', 'daemon')), + origin_ref TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + closed_at TEXT, + metadata_json TEXT, + CHECK ( + (scope = 'global' AND workspace_id IS NULL) OR + (scope = 'workspace' AND workspace_id IS NOT NULL) + ), + CHECK ( + (owner_kind IS NULL AND owner_ref IS NULL) OR + (owner_kind IS NOT NULL AND owner_ref IS NOT NULL) + ), + CHECK (parent_task_id IS NULL OR parent_task_id <> id) + );`, + `CREATE INDEX IF NOT EXISTS idx_tasks_scope ON tasks(scope);`, + `CREATE INDEX IF NOT EXISTS idx_tasks_workspace ON tasks(workspace_id);`, + `CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);`, + `CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id);`, + `CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner_kind, owner_ref);`, + `CREATE INDEX IF NOT EXISTS idx_tasks_channel ON tasks(network_channel);`, + `CREATE TABLE IF NOT EXISTS task_runs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ('queued', 'claimed', 'starting', 'running', 'completed', 'failed', 'cancelled')), + attempt INTEGER NOT NULL CHECK (attempt > 0), + claimed_by_kind TEXT CHECK (claimed_by_kind IS NULL OR claimed_by_kind IN ('human', 'agent_session', 'automation', 'extension', 'network_peer', 'daemon')), + claimed_by_ref TEXT, + session_id TEXT, + origin_kind TEXT NOT NULL CHECK (origin_kind IN ('cli', 'web', 'uds', 'http', 'automation', 'extension', 'network', 'agent_session', 'daemon')), + origin_ref TEXT NOT NULL, + idempotency_key TEXT, + network_channel TEXT, + queued_at TEXT NOT NULL, + claimed_at TEXT, + started_at TEXT, + ended_at TEXT, + error TEXT, + result_json TEXT, + CHECK ( + (claimed_by_kind IS NULL AND claimed_by_ref IS NULL) OR + (claimed_by_kind IS NOT NULL AND claimed_by_ref IS NOT NULL) + ), + CHECK (status <> 'queued' OR session_id IS NULL) + );`, + `CREATE INDEX IF NOT EXISTS idx_task_runs_task ON task_runs(task_id, queued_at DESC, id DESC);`, + `CREATE INDEX IF NOT EXISTS idx_task_runs_task_status ON task_runs(task_id, status, queued_at DESC, id DESC);`, + `CREATE INDEX IF NOT EXISTS idx_task_runs_status ON task_runs(status);`, + `CREATE INDEX IF NOT EXISTS idx_task_runs_session ON task_runs(session_id);`, + `CREATE INDEX IF NOT EXISTS idx_task_runs_channel ON task_runs(network_channel);`, + `CREATE TABLE IF NOT EXISTS task_dependencies ( + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + depends_on_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('blocks')), + created_at TEXT NOT NULL, + PRIMARY KEY (task_id, depends_on_task_id, kind), + CHECK (task_id <> depends_on_task_id) + );`, + `CREATE INDEX IF NOT EXISTS idx_task_dependencies_task ON task_dependencies(task_id, created_at ASC, depends_on_task_id ASC);`, + `CREATE INDEX IF NOT EXISTS idx_task_dependencies_depends_on ON task_dependencies(depends_on_task_id, task_id ASC);`, + `CREATE TABLE IF NOT EXISTS task_events ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL, + event_type TEXT NOT NULL, + actor_kind TEXT NOT NULL CHECK (actor_kind IN ('human', 'agent_session', 'automation', 'extension', 'network_peer', 'daemon')), + actor_ref TEXT NOT NULL, + origin_kind TEXT NOT NULL CHECK (origin_kind IN ('cli', 'web', 'uds', 'http', 'automation', 'extension', 'network', 'agent_session', 'daemon')), + origin_ref TEXT NOT NULL, + payload_json TEXT, + timestamp TEXT NOT NULL + );`, + `CREATE INDEX IF NOT EXISTS idx_task_events_task ON task_events(task_id, timestamp DESC, id DESC);`, + `CREATE INDEX IF NOT EXISTS idx_task_events_run ON task_events(run_id, timestamp DESC, id DESC);`, + `CREATE INDEX IF NOT EXISTS idx_task_events_type ON task_events(event_type, timestamp DESC, id DESC);`, + `CREATE TABLE IF NOT EXISTS task_run_idempotency ( + idempotency_key TEXT NOT NULL, + origin_kind TEXT NOT NULL CHECK (origin_kind IN ('cli', 'web', 'uds', 'http', 'automation', 'extension', 'network', 'agent_session', 'daemon')), + origin_ref TEXT NOT NULL, + run_id TEXT NOT NULL REFERENCES task_runs(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + PRIMARY KEY (idempotency_key, origin_kind, origin_ref) + );`, + `CREATE INDEX IF NOT EXISTS idx_task_run_idempotency_run ON task_run_idempotency(run_id);`, `CREATE TABLE IF NOT EXISTS bridge_instances ( id TEXT PRIMARY KEY, scope TEXT NOT NULL, diff --git a/internal/store/globaldb/global_db_automation.go b/internal/store/globaldb/global_db_automation.go index 3723bffcb..e3399ac19 100644 --- a/internal/store/globaldb/global_db_automation.go +++ b/internal/store/globaldb/global_db_automation.go @@ -11,6 +11,7 @@ import ( automation "github.com/pedronauck/agh/internal/automation/model" "github.com/pedronauck/agh/internal/store" + taskpkg "github.com/pedronauck/agh/internal/task" aghworkspace "github.com/pedronauck/agh/internal/workspace" ) @@ -42,7 +43,7 @@ func (g *GlobalDB) UpdateJob(ctx context.Context, job automation.Job) (automatio return automation.Job{}, err } - scheduleJSON, retryJSON, fireLimitJSON, err := encodeJobRecord(normalized) + scheduleJSON, taskJSON, retryJSON, fireLimitJSON, err := encodeJobRecord(normalized) if err != nil { return automation.Job{}, err } @@ -50,7 +51,7 @@ func (g *GlobalDB) UpdateJob(ctx context.Context, job automation.Job) (automatio result, err := g.db.ExecContext( ctx, `UPDATE automation_jobs - SET scope = ?, name = ?, agent_name = ?, workspace_id = ?, prompt = ?, schedule = ?, enabled = ?, retry = ?, fire_limit = ?, source = ?, updated_at = ? + SET scope = ?, name = ?, agent_name = ?, workspace_id = ?, prompt = ?, schedule = ?, task = ?, enabled = ?, retry = ?, fire_limit = ?, source = ?, updated_at = ? WHERE id = ?`, normalized.Scope, normalized.Name, @@ -58,6 +59,7 @@ func (g *GlobalDB) UpdateJob(ctx context.Context, job automation.Job) (automatio store.NullableString(normalized.WorkspaceID), normalized.Prompt, scheduleJSON, + taskJSON, normalized.Enabled, retryJSON, fireLimitJSON, @@ -108,7 +110,7 @@ func (g *GlobalDB) GetJob(ctx context.Context, id string) (automation.Job, error return g.getJobByQuery( ctx, - `SELECT id, scope, name, agent_name, workspace_id, prompt, schedule, enabled, retry, fire_limit, source, created_at, updated_at + `SELECT id, scope, name, agent_name, workspace_id, prompt, schedule, task, enabled, retry, fire_limit, source, created_at, updated_at FROM automation_jobs WHERE id = ?`, trimmedID, @@ -124,7 +126,7 @@ func (g *GlobalDB) ListJobs(ctx context.Context, query automation.JobListQuery) return nil, err } - sqlQuery := `SELECT id, scope, name, agent_name, workspace_id, prompt, schedule, enabled, retry, fire_limit, source, created_at, updated_at FROM automation_jobs` + sqlQuery := `SELECT id, scope, name, agent_name, workspace_id, prompt, schedule, task, enabled, retry, fire_limit, source, created_at, updated_at FROM automation_jobs` where, args := store.BuildClauses( store.StringClause("scope", string(query.Scope)), store.StringClause("workspace_id", query.WorkspaceID), @@ -337,12 +339,14 @@ func (g *GlobalDB) CreateRun(ctx context.Context, run automation.Run) (automatio if _, err := g.db.ExecContext( ctx, - `INSERT INTO automation_runs (id, job_id, trigger_id, session_id, status, attempt, started_at, ended_at, error) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO automation_runs (id, job_id, trigger_id, session_id, task_id, task_run_id, status, attempt, started_at, ended_at, error) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, normalized.ID, store.NullableString(normalized.JobID), store.NullableString(normalized.TriggerID), store.NullableString(normalized.SessionID), + store.NullableString(normalized.TaskID), + store.NullableString(normalized.TaskRunID), normalized.Status, normalized.Attempt, nullableAutomationTimestamp(normalized.StartedAt), @@ -369,11 +373,13 @@ func (g *GlobalDB) UpdateRun(ctx context.Context, run automation.Run) (automatio result, err := g.db.ExecContext( ctx, `UPDATE automation_runs - SET job_id = ?, trigger_id = ?, session_id = ?, status = ?, attempt = ?, started_at = ?, ended_at = ?, error = ? + SET job_id = ?, trigger_id = ?, session_id = ?, task_id = ?, task_run_id = ?, status = ?, attempt = ?, started_at = ?, ended_at = ?, error = ? WHERE id = ?`, store.NullableString(normalized.JobID), store.NullableString(normalized.TriggerID), store.NullableString(normalized.SessionID), + store.NullableString(normalized.TaskID), + store.NullableString(normalized.TaskRunID), normalized.Status, normalized.Attempt, nullableAutomationTimestamp(normalized.StartedAt), @@ -424,7 +430,7 @@ func (g *GlobalDB) GetRun(ctx context.Context, id string) (automation.Run, error row := g.db.QueryRowContext( ctx, - `SELECT id, job_id, trigger_id, session_id, status, attempt, started_at, ended_at, error + `SELECT id, job_id, trigger_id, session_id, task_id, task_run_id, status, attempt, started_at, ended_at, error FROM automation_runs WHERE id = ?`, trimmedID, @@ -449,7 +455,7 @@ func (g *GlobalDB) ListRuns(ctx context.Context, query automation.RunQuery) ([]a return nil, err } - sqlQuery := `SELECT id, job_id, trigger_id, session_id, status, attempt, started_at, ended_at, error FROM automation_runs` + sqlQuery := `SELECT id, job_id, trigger_id, session_id, task_id, task_run_id, status, attempt, started_at, ended_at, error FROM automation_runs` where, args := buildAutomationRunClauses(query) sqlQuery = store.AppendWhere(sqlQuery, where) sqlQuery += " ORDER BY started_at DESC, id DESC" @@ -810,7 +816,7 @@ func (g *GlobalDB) DeleteTriggerWebhookSecret(ctx context.Context, triggerID str } func (g *GlobalDB) insertJob(ctx context.Context, exec sqlExecutor, job automation.Job) error { - scheduleJSON, retryJSON, fireLimitJSON, err := encodeJobRecord(job) + scheduleJSON, taskJSON, retryJSON, fireLimitJSON, err := encodeJobRecord(job) if err != nil { return err } @@ -818,8 +824,8 @@ func (g *GlobalDB) insertJob(ctx context.Context, exec sqlExecutor, job automati if _, err := exec.ExecContext( ctx, `INSERT INTO automation_jobs ( - id, scope, name, agent_name, workspace_id, prompt, schedule, enabled, retry, fire_limit, source, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, scope, name, agent_name, workspace_id, prompt, schedule, task, enabled, retry, fire_limit, source, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, job.ID, job.Scope, job.Name, @@ -827,6 +833,7 @@ func (g *GlobalDB) insertJob(ctx context.Context, exec sqlExecutor, job automati store.NullableString(job.WorkspaceID), job.Prompt, scheduleJSON, + taskJSON, job.Enabled, retryJSON, fireLimitJSON, @@ -1054,6 +1061,7 @@ func scanAutomationJob(scanner rowScanner) (automation.Job, error) { scope string workspaceID sql.NullString scheduleRaw sql.NullString + taskRaw sql.NullString retryRaw string fireLimit string source string @@ -1068,6 +1076,7 @@ func scanAutomationJob(scanner rowScanner) (automation.Job, error) { &workspaceID, &job.Prompt, &scheduleRaw, + &taskRaw, &job.Enabled, &retryRaw, &fireLimit, @@ -1085,6 +1094,9 @@ func scanAutomationJob(scanner rowScanner) (automation.Job, error) { if err := decodeAutomationSchedule(scheduleRaw, &job.Schedule); err != nil { return automation.Job{}, err } + if err := decodeAutomationTaskConfig(taskRaw, &job.Task); err != nil { + return automation.Job{}, err + } if err := decodeAutomationJSON(retryRaw, &job.Retry, "job.retry"); err != nil { return automation.Job{}, err } @@ -1177,6 +1189,8 @@ func scanAutomationRun(scanner rowScanner) (automation.Run, error) { jobID sql.NullString triggerID sql.NullString sessionID sql.NullString + taskID sql.NullString + taskRunID sql.NullString status string startedAt sql.NullString endedAt sql.NullString @@ -1187,6 +1201,8 @@ func scanAutomationRun(scanner rowScanner) (automation.Run, error) { &jobID, &triggerID, &sessionID, + &taskID, + &taskRunID, &status, &run.Attempt, &startedAt, @@ -1199,6 +1215,8 @@ func scanAutomationRun(scanner rowScanner) (automation.Run, error) { run.JobID = automationNullStringValue(jobID) run.TriggerID = automationNullStringValue(triggerID) run.SessionID = automationNullStringValue(sessionID) + run.TaskID = automationNullStringValue(taskID) + run.TaskRunID = automationNullStringValue(taskRunID) run.Status = automation.RunStatus(strings.TrimSpace(status)) if startedAt.Valid { value, err := store.ParseTimestamp(startedAt.String) @@ -1253,21 +1271,25 @@ func scanTriggerEnabledOverlay(scanner rowScanner) (automation.TriggerEnabledOve return overlay, nil } -func encodeJobRecord(job automation.Job) (string, string, string, error) { +func encodeJobRecord(job automation.Job) (string, any, string, string, error) { scheduleJSON, err := encodeAutomationJSON(job.Schedule, "job.schedule") if err != nil { - return "", "", "", err + return "", nil, "", "", err + } + taskJSON, err := encodeOptionalAutomationJSON(job.Task, job.Task == nil, "job.task") + if err != nil { + return "", nil, "", "", err } retryJSON, err := encodeAutomationJSON(job.Retry, "job.retry") if err != nil { - return "", "", "", err + return "", nil, "", "", err } fireLimitJSON, err := encodeAutomationJSON(job.FireLimit, "job.fire_limit") if err != nil { - return "", "", "", err + return "", nil, "", "", err } - return scheduleJSON, retryJSON, fireLimitJSON, nil + return scheduleJSON, taskJSON, retryJSON, fireLimitJSON, nil } func encodeTriggerRecord(trigger automation.Trigger) (any, string, string, error) { @@ -1348,11 +1370,19 @@ func validateAutomationRunRecord(run automation.Run) error { } jobID := strings.TrimSpace(run.JobID) triggerID := strings.TrimSpace(run.TriggerID) + taskID := strings.TrimSpace(run.TaskID) + taskRunID := strings.TrimSpace(run.TaskRunID) switch { case jobID == "" && triggerID == "": return errors.New("store: automation run job_id or trigger_id is required") case jobID != "" && triggerID != "": return errors.New("store: automation run must reference either a job or a trigger, not both") + case taskRunID != "" && taskID == "": + return errors.New("store: automation run task_id is required when task_run_id is set") + case run.Status == automation.RunDelegated && taskID == "": + return errors.New("store: automation run task_id is required when status is delegated") + case run.Status == automation.RunDelegated && taskRunID == "": + return errors.New("store: automation run task_run_id is required when status is delegated") default: return nil } @@ -1442,6 +1472,19 @@ func normalizeAutomationJob(job automation.Job) automation.Job { schedule.Time = strings.TrimSpace(schedule.Time) job.Schedule = &schedule } + if job.Task != nil { + taskConfig := *job.Task + taskConfig.Title = strings.TrimSpace(taskConfig.Title) + taskConfig.Description = strings.TrimSpace(taskConfig.Description) + taskConfig.NetworkChannel = strings.TrimSpace(taskConfig.NetworkChannel) + if taskConfig.Owner != nil { + owner := *taskConfig.Owner + owner.Kind = taskpkg.OwnerKind(strings.TrimSpace(string(owner.Kind))) + owner.Ref = strings.TrimSpace(owner.Ref) + taskConfig.Owner = &owner + } + job.Task = &taskConfig + } return job } @@ -1473,6 +1516,8 @@ func normalizeAutomationRun(run automation.Run) automation.Run { run.JobID = strings.TrimSpace(run.JobID) run.TriggerID = strings.TrimSpace(run.TriggerID) run.SessionID = strings.TrimSpace(run.SessionID) + run.TaskID = strings.TrimSpace(run.TaskID) + run.TaskRunID = strings.TrimSpace(run.TaskRunID) run.Status = automation.RunStatus(strings.TrimSpace(string(run.Status))) return run } @@ -1542,6 +1587,20 @@ func decodeAutomationSchedule(raw sql.NullString, target **automation.ScheduleSp return nil } +func decodeAutomationTaskConfig(raw sql.NullString, target **automation.JobTaskConfig) error { + if !raw.Valid || strings.TrimSpace(raw.String) == "" { + *target = nil + return nil + } + + var taskConfig automation.JobTaskConfig + if err := json.Unmarshal([]byte(raw.String), &taskConfig); err != nil { + return fmt.Errorf("store: decode job.task: %w", err) + } + *target = &taskConfig + return nil +} + func decodeAutomationFilter(raw sql.NullString, target *map[string]string) error { if !raw.Valid || strings.TrimSpace(raw.String) == "" { *target = nil diff --git a/internal/store/globaldb/global_db_automation_test.go b/internal/store/globaldb/global_db_automation_test.go index e4d043353..93359b24d 100644 --- a/internal/store/globaldb/global_db_automation_test.go +++ b/internal/store/globaldb/global_db_automation_test.go @@ -44,6 +44,7 @@ func TestOpenGlobalDBCreatesAutomationSchemaAndIndexes(t *testing.T) { "workspace_id", "prompt", "schedule", + "task", "enabled", "retry", "fire_limit", @@ -74,6 +75,8 @@ func TestOpenGlobalDBCreatesAutomationSchemaAndIndexes(t *testing.T) { "job_id", "trigger_id", "session_id", + "task_id", + "task_run_id", "status", "attempt", "started_at", @@ -602,6 +605,14 @@ func TestAutomationStoreHelperBranches(t *testing.T) { }); err == nil { t.Fatal("validateAutomationRunRecord(both job and trigger) error = nil, want non-nil") } + if err := validateAutomationRunRecord(Run{ + JobID: "job-1", + Status: automation.RunDelegated, + Attempt: 1, + TaskID: "task-1", + }); err == nil { + t.Fatal("validateAutomationRunRecord(delegated without task run) error = nil, want non-nil") + } var schedule *automation.ScheduleSpec if err := decodeAutomationSchedule(sql.NullString{Valid: true, String: `{"mode":"cron","expr":"0 * * * *"}`}, &schedule); err != nil { @@ -614,6 +625,17 @@ func TestAutomationStoreHelperBranches(t *testing.T) { t.Fatal("decodeAutomationSchedule(invalid) error = nil, want non-nil") } + var taskConfig *automation.JobTaskConfig + if err := decodeAutomationTaskConfig(sql.NullString{Valid: true, String: `{"title":"Review findings","network_channel":"ops-automation"}`}, &taskConfig); err != nil { + t.Fatalf("decodeAutomationTaskConfig(valid) error = %v", err) + } + if taskConfig == nil || taskConfig.Title != "Review findings" { + t.Fatalf("decodeAutomationTaskConfig(valid) = %#v, want populated task config", taskConfig) + } + if err := decodeAutomationTaskConfig(sql.NullString{Valid: true, String: `{`}, &taskConfig); err == nil { + t.Fatal("decodeAutomationTaskConfig(invalid) error = nil, want non-nil") + } + var filter map[string]string if err := decodeAutomationFilter(sql.NullString{Valid: true, String: `{"data.kind":"ready"}`}, &filter); err != nil { t.Fatalf("decodeAutomationFilter(valid) error = %v", err) diff --git a/internal/store/globaldb/global_db_task.go b/internal/store/globaldb/global_db_task.go new file mode 100644 index 000000000..165e7ff0a --- /dev/null +++ b/internal/store/globaldb/global_db_task.go @@ -0,0 +1,959 @@ +package globaldb + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/pedronauck/agh/internal/store" + taskpkg "github.com/pedronauck/agh/internal/task" + aghworkspace "github.com/pedronauck/agh/internal/workspace" +) + +var _ taskpkg.TaskStore = (*GlobalDB)(nil) +var _ taskpkg.RunStore = (*GlobalDB)(nil) + +// CreateTask inserts one durable task record. +func (g *GlobalDB) CreateTask(ctx context.Context, record taskpkg.Task) error { + if err := g.checkReady(ctx, "create task"); err != nil { + return err + } + + normalized, err := g.normalizeTaskForCreate(record) + if err != nil { + return err + } + if err := g.ensureTaskCreateReferences(ctx, normalized); err != nil { + return err + } + + _, err = g.db.ExecContext( + ctx, + `INSERT INTO tasks ( + id, identifier, scope, workspace_id, parent_task_id, network_channel, title, description, status, + owner_kind, owner_ref, created_by_kind, created_by_ref, origin_kind, origin_ref, created_at, updated_at, closed_at, metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + normalized.ID, + store.NullableString(normalized.Identifier), + string(normalized.Scope), + store.NullableString(normalized.WorkspaceID), + store.NullableString(normalized.ParentTaskID), + store.NullableString(normalized.NetworkChannel), + normalized.Title, + store.NullableString(normalized.Description), + string(normalized.Status), + taskOwnerKindValue(normalized.Owner), + taskOwnerRefValue(normalized.Owner), + string(normalized.CreatedBy.Kind), + normalized.CreatedBy.Ref, + string(normalized.Origin.Kind), + normalized.Origin.Ref, + store.FormatTimestamp(normalized.CreatedAt), + store.FormatTimestamp(normalized.UpdatedAt), + nullableTaskTimestamp(normalized.ClosedAt), + nullableTaskJSON(normalized.Metadata), + ) + if err != nil { + return fmt.Errorf("store: create task %q: %w", normalized.ID, err) + } + + return nil +} + +// UpdateTask replaces the persisted canonical task record. +func (g *GlobalDB) UpdateTask(ctx context.Context, record taskpkg.Task) error { + if err := g.checkReady(ctx, "update task"); err != nil { + return err + } + + normalized, err := g.normalizeTaskForUpdate(record) + if err != nil { + return err + } + + current, err := g.GetTask(ctx, normalized.ID) + if err != nil { + return err + } + if err := taskpkg.ValidateImmutableTaskFields(current, normalized); err != nil { + return err + } + + normalized.CreatedAt = current.CreatedAt + result, err := g.db.ExecContext( + ctx, + `UPDATE tasks + SET identifier = ?, scope = ?, workspace_id = ?, parent_task_id = ?, network_channel = ?, title = ?, description = ?, status = ?, + owner_kind = ?, owner_ref = ?, created_by_kind = ?, created_by_ref = ?, origin_kind = ?, origin_ref = ?, created_at = ?, + updated_at = ?, closed_at = ?, metadata_json = ? + WHERE id = ?`, + store.NullableString(normalized.Identifier), + string(normalized.Scope), + store.NullableString(normalized.WorkspaceID), + store.NullableString(normalized.ParentTaskID), + store.NullableString(normalized.NetworkChannel), + normalized.Title, + store.NullableString(normalized.Description), + string(normalized.Status), + taskOwnerKindValue(normalized.Owner), + taskOwnerRefValue(normalized.Owner), + string(normalized.CreatedBy.Kind), + normalized.CreatedBy.Ref, + string(normalized.Origin.Kind), + normalized.Origin.Ref, + store.FormatTimestamp(normalized.CreatedAt), + store.FormatTimestamp(normalized.UpdatedAt), + nullableTaskTimestamp(normalized.ClosedAt), + nullableTaskJSON(normalized.Metadata), + normalized.ID, + ) + if err != nil { + return fmt.Errorf("store: update task %q: %w", normalized.ID, err) + } + + return requireRowsAffected(result, taskpkg.ErrTaskNotFound, normalized.ID, "task") +} + +// GetTask returns one persisted task by primary key. +func (g *GlobalDB) GetTask(ctx context.Context, id string) (taskpkg.Task, error) { + if err := g.checkReady(ctx, "get task"); err != nil { + return taskpkg.Task{}, err + } + + trimmedID, err := requireTaskValue(id, "task id") + if err != nil { + return taskpkg.Task{}, err + } + + row := g.db.QueryRowContext( + ctx, + `SELECT + id, identifier, scope, workspace_id, parent_task_id, network_channel, title, description, status, + owner_kind, owner_ref, created_by_kind, created_by_ref, origin_kind, origin_ref, created_at, updated_at, closed_at, metadata_json + FROM tasks + WHERE id = ?`, + trimmedID, + ) + + record, err := scanTaskRecord(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return taskpkg.Task{}, taskpkg.ErrTaskNotFound + } + return taskpkg.Task{}, err + } + return record, nil +} + +// ListTasks returns durable task summaries that match the supplied filters. +func (g *GlobalDB) ListTasks(ctx context.Context, query taskpkg.TaskQuery) ([]taskpkg.TaskSummary, error) { + if err := g.checkReady(ctx, "list tasks"); err != nil { + return nil, err + } + if err := query.Validate("task_query"); err != nil { + return nil, err + } + + normalized := normalizeTaskQuery(query) + sqlQuery := `SELECT + id, identifier, scope, workspace_id, parent_task_id, network_channel, title, description, status, + owner_kind, owner_ref, created_by_kind, created_by_ref, origin_kind, origin_ref, created_at, updated_at, closed_at, metadata_json + FROM tasks` + where, args := store.BuildClauses( + store.StringClause("scope", string(normalized.Scope)), + store.StringClause("workspace_id", normalized.WorkspaceID), + store.StringClause("status", string(normalized.Status)), + store.StringClause("owner_kind", string(normalized.OwnerKind)), + store.StringClause("owner_ref", normalized.OwnerRef), + store.StringClause("parent_task_id", normalized.ParentTaskID), + store.StringClause("network_channel", normalized.NetworkChannel), + ) + sqlQuery = store.AppendWhere(sqlQuery, where) + sqlQuery += " ORDER BY updated_at DESC, created_at DESC, id DESC" + sqlQuery, args = store.AppendLimit(sqlQuery, args, normalized.Limit) + + rows, err := g.db.QueryContext(ctx, sqlQuery, args...) + if err != nil { + return nil, fmt.Errorf("store: query tasks: %w", err) + } + defer func() { + // Close errors are not actionable here once Next/Err have reported the read outcome. + _ = rows.Close() + }() + + summaries := make([]taskpkg.TaskSummary, 0) + for rows.Next() { + record, scanErr := scanTaskRecord(rows) + if scanErr != nil { + return nil, scanErr + } + summaries = append(summaries, taskSummaryFromRecord(record)) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate tasks: %w", err) + } + + return summaries, nil +} + +// CountDirectChildren reports how many persisted tasks reference the supplied parent id. +func (g *GlobalDB) CountDirectChildren(ctx context.Context, parentTaskID string) (int, error) { + if err := g.checkReady(ctx, "count task children"); err != nil { + return 0, err + } + + trimmedID, err := requireTaskValue(parentTaskID, "parent task id") + if err != nil { + return 0, err + } + + var count int + if err := g.db.QueryRowContext( + ctx, + `SELECT COUNT(1) FROM tasks WHERE parent_task_id = ?`, + trimmedID, + ).Scan(&count); err != nil { + return 0, fmt.Errorf("store: count direct children for task %q: %w", trimmedID, err) + } + + return count, nil +} + +// CreateTaskRun inserts one durable task-run record. +func (g *GlobalDB) CreateTaskRun(ctx context.Context, run taskpkg.TaskRun) error { + if err := g.checkReady(ctx, "create task run"); err != nil { + return err + } + + normalized, err := g.normalizeTaskRunForCreate(run) + if err != nil { + return err + } + if err := g.ensureTaskExists(ctx, normalized.TaskID); err != nil { + return err + } + + _, err = g.db.ExecContext( + ctx, + `INSERT INTO task_runs ( + id, task_id, status, attempt, claimed_by_kind, claimed_by_ref, session_id, origin_kind, origin_ref, + idempotency_key, network_channel, queued_at, claimed_at, started_at, ended_at, error, result_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + normalized.ID, + normalized.TaskID, + string(normalized.Status), + normalized.Attempt, + taskActorKindValue(normalized.ClaimedBy), + taskActorRefValue(normalized.ClaimedBy), + store.NullableString(normalized.SessionID), + string(normalized.Origin.Kind), + normalized.Origin.Ref, + store.NullableString(normalized.IdempotencyKey), + store.NullableString(normalized.NetworkChannel), + store.FormatTimestamp(normalized.QueuedAt), + nullableTaskTimestamp(normalized.ClaimedAt), + nullableTaskTimestamp(normalized.StartedAt), + nullableTaskTimestamp(normalized.EndedAt), + store.NullableString(normalized.Error), + nullableTaskJSON(normalized.Result), + ) + if err != nil { + return fmt.Errorf("store: create task run %q: %w", normalized.ID, err) + } + + return nil +} + +// UpdateTaskRun replaces the persisted canonical task-run record. +func (g *GlobalDB) UpdateTaskRun(ctx context.Context, run taskpkg.TaskRun) error { + if err := g.checkReady(ctx, "update task run"); err != nil { + return err + } + + normalized, err := g.normalizeTaskRunForUpdate(run) + if err != nil { + return err + } + + current, err := g.GetTaskRun(ctx, normalized.ID) + if err != nil { + return err + } + if strings.TrimSpace(current.SessionID) != "" && strings.TrimSpace(normalized.SessionID) != strings.TrimSpace(current.SessionID) { + return taskpkg.ErrSessionAlreadyBound + } + if normalized.QueuedAt.IsZero() { + normalized.QueuedAt = current.QueuedAt + } + if err := g.ensureTaskExists(ctx, normalized.TaskID); err != nil { + return err + } + + result, err := g.db.ExecContext( + ctx, + `UPDATE task_runs + SET task_id = ?, status = ?, attempt = ?, claimed_by_kind = ?, claimed_by_ref = ?, session_id = ?, origin_kind = ?, origin_ref = ?, + idempotency_key = ?, network_channel = ?, queued_at = ?, claimed_at = ?, started_at = ?, ended_at = ?, error = ?, result_json = ? + WHERE id = ?`, + normalized.TaskID, + string(normalized.Status), + normalized.Attempt, + taskActorKindValue(normalized.ClaimedBy), + taskActorRefValue(normalized.ClaimedBy), + store.NullableString(normalized.SessionID), + string(normalized.Origin.Kind), + normalized.Origin.Ref, + store.NullableString(normalized.IdempotencyKey), + store.NullableString(normalized.NetworkChannel), + store.FormatTimestamp(normalized.QueuedAt), + nullableTaskTimestamp(normalized.ClaimedAt), + nullableTaskTimestamp(normalized.StartedAt), + nullableTaskTimestamp(normalized.EndedAt), + store.NullableString(normalized.Error), + nullableTaskJSON(normalized.Result), + normalized.ID, + ) + if err != nil { + return fmt.Errorf("store: update task run %q: %w", normalized.ID, err) + } + + return requireRowsAffected(result, taskpkg.ErrTaskRunNotFound, normalized.ID, "task run") +} + +// GetTaskRun returns one persisted task run by primary key. +func (g *GlobalDB) GetTaskRun(ctx context.Context, id string) (taskpkg.TaskRun, error) { + if err := g.checkReady(ctx, "get task run"); err != nil { + return taskpkg.TaskRun{}, err + } + + trimmedID, err := requireTaskValue(id, "task run id") + if err != nil { + return taskpkg.TaskRun{}, err + } + + row := g.db.QueryRowContext( + ctx, + `SELECT + id, task_id, status, attempt, claimed_by_kind, claimed_by_ref, session_id, origin_kind, origin_ref, + idempotency_key, network_channel, queued_at, claimed_at, started_at, ended_at, error, result_json + FROM task_runs + WHERE id = ?`, + trimmedID, + ) + + run, err := scanTaskRunRecord(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return taskpkg.TaskRun{}, taskpkg.ErrTaskRunNotFound + } + return taskpkg.TaskRun{}, err + } + return run, nil +} + +// ListTaskRuns returns persisted runs that match the supplied filters. +func (g *GlobalDB) ListTaskRuns(ctx context.Context, query taskpkg.TaskRunQuery) ([]taskpkg.TaskRun, error) { + if err := g.checkReady(ctx, "list task runs"); err != nil { + return nil, err + } + if err := query.Validate("task_run_query"); err != nil { + return nil, err + } + + normalized := normalizeTaskRunQuery(query) + sqlQuery := `SELECT + id, task_id, status, attempt, claimed_by_kind, claimed_by_ref, session_id, origin_kind, origin_ref, + idempotency_key, network_channel, queued_at, claimed_at, started_at, ended_at, error, result_json + FROM task_runs` + where, args := store.BuildClauses( + store.StringClause("task_id", normalized.TaskID), + store.StringClause("status", string(normalized.Status)), + store.StringClause("session_id", normalized.SessionID), + ) + sqlQuery = store.AppendWhere(sqlQuery, where) + sqlQuery += " ORDER BY queued_at DESC, id DESC" + sqlQuery, args = store.AppendLimit(sqlQuery, args, normalized.Limit) + + rows, err := g.db.QueryContext(ctx, sqlQuery, args...) + if err != nil { + return nil, fmt.Errorf("store: query task runs: %w", err) + } + defer func() { + // Close errors are not actionable here once Next/Err have reported the read outcome. + _ = rows.Close() + }() + + runs := make([]taskpkg.TaskRun, 0) + for rows.Next() { + run, scanErr := scanTaskRunRecord(rows) + if scanErr != nil { + return nil, scanErr + } + runs = append(runs, run) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate task runs: %w", err) + } + + return runs, nil +} + +// ListTaskRunsByStatus returns persisted runs that match any of the supplied statuses. +func (g *GlobalDB) ListTaskRunsByStatus(ctx context.Context, statuses []taskpkg.TaskRunStatus) ([]taskpkg.TaskRun, error) { + if err := g.checkReady(ctx, "list task runs by status"); err != nil { + return nil, err + } + if len(statuses) == 0 { + return []taskpkg.TaskRun{}, nil + } + + placeholders := make([]string, 0, len(statuses)) + args := make([]any, 0, len(statuses)) + for _, status := range statuses { + normalized := status.Normalize() + if err := normalized.Validate("task_run_statuses"); err != nil { + return nil, err + } + placeholders = append(placeholders, "?") + args = append(args, string(normalized)) + } + + rows, err := g.db.QueryContext( + ctx, + fmt.Sprintf( + `SELECT + id, task_id, status, attempt, claimed_by_kind, claimed_by_ref, session_id, origin_kind, origin_ref, + idempotency_key, network_channel, queued_at, claimed_at, started_at, ended_at, error, result_json + FROM task_runs + WHERE status IN (%s) + ORDER BY queued_at ASC, id ASC`, + strings.Join(placeholders, ", "), + ), + args..., + ) + if err != nil { + return nil, fmt.Errorf("store: query task runs by status: %w", err) + } + defer func() { + // Close errors are not actionable here once Next/Err have reported the read outcome. + _ = rows.Close() + }() + + runs := make([]taskpkg.TaskRun, 0) + for rows.Next() { + run, scanErr := scanTaskRunRecord(rows) + if scanErr != nil { + return nil, scanErr + } + runs = append(runs, run) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate task runs by status: %w", err) + } + + return runs, nil +} + +// CountActiveSessionBindings reports how many non-terminal runs are bound to one session. +func (g *GlobalDB) CountActiveSessionBindings(ctx context.Context, sessionID string) (int, error) { + if err := g.checkReady(ctx, "count active task-run session bindings"); err != nil { + return 0, err + } + + trimmedSessionID, err := requireTaskValue(sessionID, "task run session id") + if err != nil { + return 0, err + } + + var count int + if err := g.db.QueryRowContext( + ctx, + `SELECT COUNT(1) + FROM task_runs + WHERE session_id = ? + AND status IN (?, ?, ?)`, + trimmedSessionID, + string(taskpkg.TaskRunStatusClaimed), + string(taskpkg.TaskRunStatusStarting), + string(taskpkg.TaskRunStatusRunning), + ).Scan(&count); err != nil { + return 0, fmt.Errorf("store: count active task-run session bindings for %q: %w", trimmedSessionID, err) + } + + return count, nil +} + +func (g *GlobalDB) normalizeTaskForCreate(record taskpkg.Task) (taskpkg.Task, error) { + normalized := normalizeTaskRecord(record) + if normalized.CreatedAt.IsZero() { + normalized.CreatedAt = g.now() + } + if normalized.UpdatedAt.IsZero() { + normalized.UpdatedAt = normalized.CreatedAt + } + if err := normalized.Validate(); err != nil { + return taskpkg.Task{}, err + } + return normalized, nil +} + +func (g *GlobalDB) normalizeTaskForUpdate(record taskpkg.Task) (taskpkg.Task, error) { + normalized := normalizeTaskRecord(record) + if normalized.UpdatedAt.IsZero() { + normalized.UpdatedAt = g.now() + } + if err := normalized.Validate(); err != nil { + return taskpkg.Task{}, err + } + return normalized, nil +} + +func (g *GlobalDB) normalizeTaskRunForCreate(run taskpkg.TaskRun) (taskpkg.TaskRun, error) { + normalized := normalizeTaskRunRecord(run) + if normalized.Attempt == 0 { + normalized.Attempt = 1 + } + if normalized.QueuedAt.IsZero() { + normalized.QueuedAt = g.now() + } + if err := normalized.Validate(); err != nil { + return taskpkg.TaskRun{}, err + } + return normalized, nil +} + +func (g *GlobalDB) normalizeTaskRunForUpdate(run taskpkg.TaskRun) (taskpkg.TaskRun, error) { + normalized := normalizeTaskRunRecord(run) + if err := normalized.Validate(); err != nil { + return taskpkg.TaskRun{}, err + } + return normalized, nil +} + +func (g *GlobalDB) ensureTaskCreateReferences(ctx context.Context, record taskpkg.Task) error { + if err := taskpkg.ValidateScopeBinding(record.Scope, record.WorkspaceID, "task", "workspace_id"); err != nil { + return err + } + if record.Scope == taskpkg.ScopeWorkspace { + if err := g.ensureWorkspaceExists(ctx, record.WorkspaceID); err != nil { + return err + } + } + if strings.TrimSpace(record.ParentTaskID) != "" { + if err := g.ensureTaskExists(ctx, record.ParentTaskID); err != nil { + return err + } + } + return nil +} + +func (g *GlobalDB) ensureWorkspaceExists(ctx context.Context, workspaceID string) error { + trimmedID := strings.TrimSpace(workspaceID) + if trimmedID == "" { + return nil + } + + var exists int + if err := g.db.QueryRowContext(ctx, `SELECT 1 FROM workspaces WHERE id = ?`, trimmedID).Scan(&exists); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return aghworkspace.ErrWorkspaceNotFound + } + return fmt.Errorf("store: lookup workspace %q: %w", trimmedID, err) + } + return nil +} + +func (g *GlobalDB) ensureTaskExists(ctx context.Context, taskID string) error { + trimmedID := strings.TrimSpace(taskID) + if trimmedID == "" { + return taskpkg.ErrTaskNotFound + } + + var exists int + if err := g.db.QueryRowContext(ctx, `SELECT 1 FROM tasks WHERE id = ?`, trimmedID).Scan(&exists); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return taskpkg.ErrTaskNotFound + } + return fmt.Errorf("store: lookup task %q: %w", trimmedID, err) + } + return nil +} + +func scanTaskRecord(scanner rowScanner) (taskpkg.Task, error) { + var ( + record taskpkg.Task + identifier sql.NullString + scope string + workspaceID sql.NullString + parentTaskID sql.NullString + networkChannel sql.NullString + description sql.NullString + status string + ownerKind sql.NullString + ownerRef sql.NullString + createdByKind string + originKind string + createdAtRaw string + updatedAtRaw string + closedAtRaw sql.NullString + metadataJSON sql.NullString + ) + if err := scanner.Scan( + &record.ID, + &identifier, + &scope, + &workspaceID, + &parentTaskID, + &networkChannel, + &record.Title, + &description, + &status, + &ownerKind, + &ownerRef, + &createdByKind, + &record.CreatedBy.Ref, + &originKind, + &record.Origin.Ref, + &createdAtRaw, + &updatedAtRaw, + &closedAtRaw, + &metadataJSON, + ); err != nil { + return taskpkg.Task{}, fmt.Errorf("store: scan task: %w", err) + } + + record.Identifier = taskNullStringValue(identifier) + record.Scope = taskpkg.Scope(strings.TrimSpace(scope)) + record.WorkspaceID = taskNullStringValue(workspaceID) + record.ParentTaskID = taskNullStringValue(parentTaskID) + record.NetworkChannel = taskNullStringValue(networkChannel) + record.Description = taskNullStringValue(description) + record.Status = taskpkg.TaskStatus(strings.TrimSpace(status)) + record.CreatedBy.Kind = taskpkg.ActorKind(strings.TrimSpace(createdByKind)) + record.Origin.Kind = taskpkg.OriginKind(strings.TrimSpace(originKind)) + if ownerKind.Valid || ownerRef.Valid { + record.Owner = &taskpkg.Ownership{ + Kind: taskpkg.OwnerKind(strings.TrimSpace(ownerKind.String)), + Ref: strings.TrimSpace(ownerRef.String), + } + } + + createdAt, err := store.ParseTimestamp(createdAtRaw) + if err != nil { + return taskpkg.Task{}, err + } + updatedAt, err := store.ParseTimestamp(updatedAtRaw) + if err != nil { + return taskpkg.Task{}, err + } + record.CreatedAt = createdAt + record.UpdatedAt = updatedAt + if closedAtRaw.Valid { + closedAt, err := store.ParseTimestamp(closedAtRaw.String) + if err != nil { + return taskpkg.Task{}, err + } + record.ClosedAt = closedAt + } + + metadata, err := decodeTaskJSON(metadataJSON, "task.metadata_json") + if err != nil { + return taskpkg.Task{}, err + } + record.Metadata = metadata + record = normalizeTaskRecord(record) + if err := record.Validate(); err != nil { + return taskpkg.Task{}, err + } + + return record, nil +} + +func scanTaskRunRecord(scanner rowScanner) (taskpkg.TaskRun, error) { + var ( + run taskpkg.TaskRun + status string + claimedByKind sql.NullString + claimedByRef sql.NullString + sessionID sql.NullString + originKind string + idempotencyKey sql.NullString + networkChannel sql.NullString + queuedAtRaw string + claimedAtRaw sql.NullString + startedAtRaw sql.NullString + endedAtRaw sql.NullString + runErr sql.NullString + resultJSON sql.NullString + ) + if err := scanner.Scan( + &run.ID, + &run.TaskID, + &status, + &run.Attempt, + &claimedByKind, + &claimedByRef, + &sessionID, + &originKind, + &run.Origin.Ref, + &idempotencyKey, + &networkChannel, + &queuedAtRaw, + &claimedAtRaw, + &startedAtRaw, + &endedAtRaw, + &runErr, + &resultJSON, + ); err != nil { + return taskpkg.TaskRun{}, fmt.Errorf("store: scan task run: %w", err) + } + + run.Status = taskpkg.TaskRunStatus(strings.TrimSpace(status)) + if claimedByKind.Valid || claimedByRef.Valid { + run.ClaimedBy = &taskpkg.ActorIdentity{ + Kind: taskpkg.ActorKind(strings.TrimSpace(claimedByKind.String)), + Ref: strings.TrimSpace(claimedByRef.String), + } + } + run.SessionID = taskNullStringValue(sessionID) + run.Origin.Kind = taskpkg.OriginKind(strings.TrimSpace(originKind)) + run.IdempotencyKey = taskNullStringValue(idempotencyKey) + run.NetworkChannel = taskNullStringValue(networkChannel) + run.Error = taskNullStringValue(runErr) + + queuedAt, err := store.ParseTimestamp(queuedAtRaw) + if err != nil { + return taskpkg.TaskRun{}, err + } + run.QueuedAt = queuedAt + if claimedAtRaw.Valid { + claimedAt, err := store.ParseTimestamp(claimedAtRaw.String) + if err != nil { + return taskpkg.TaskRun{}, err + } + run.ClaimedAt = claimedAt + } + if startedAtRaw.Valid { + startedAt, err := store.ParseTimestamp(startedAtRaw.String) + if err != nil { + return taskpkg.TaskRun{}, err + } + run.StartedAt = startedAt + } + if endedAtRaw.Valid { + endedAt, err := store.ParseTimestamp(endedAtRaw.String) + if err != nil { + return taskpkg.TaskRun{}, err + } + run.EndedAt = endedAt + } + + result, err := decodeTaskJSON(resultJSON, "task_run.result_json") + if err != nil { + return taskpkg.TaskRun{}, err + } + run.Result = result + run = normalizeTaskRunRecord(run) + if err := run.Validate(); err != nil { + return taskpkg.TaskRun{}, err + } + + return run, nil +} + +func normalizeTaskRecord(record taskpkg.Task) taskpkg.Task { + normalized := record + normalized.ID = strings.TrimSpace(normalized.ID) + normalized.Identifier = strings.TrimSpace(normalized.Identifier) + normalized.Scope = normalized.Scope.Normalize() + normalized.WorkspaceID = strings.TrimSpace(normalized.WorkspaceID) + normalized.ParentTaskID = strings.TrimSpace(normalized.ParentTaskID) + normalized.NetworkChannel = strings.TrimSpace(normalized.NetworkChannel) + normalized.Title = strings.TrimSpace(normalized.Title) + normalized.Description = strings.TrimSpace(normalized.Description) + normalized.Status = normalized.Status.Normalize() + normalized.CreatedBy.Kind = normalized.CreatedBy.Kind.Normalize() + normalized.CreatedBy.Ref = strings.TrimSpace(normalized.CreatedBy.Ref) + normalized.Origin.Kind = normalized.Origin.Kind.Normalize() + normalized.Origin.Ref = strings.TrimSpace(normalized.Origin.Ref) + if normalized.Owner != nil { + owner := *normalized.Owner + owner.Kind = owner.Kind.Normalize() + owner.Ref = strings.TrimSpace(owner.Ref) + if owner.IsZero() { + normalized.Owner = nil + } else { + normalized.Owner = &owner + } + } + normalized.Metadata = normalizeTaskJSON(normalized.Metadata) + if !normalized.CreatedAt.IsZero() { + normalized.CreatedAt = normalized.CreatedAt.UTC() + } + if !normalized.UpdatedAt.IsZero() { + normalized.UpdatedAt = normalized.UpdatedAt.UTC() + } + if !normalized.ClosedAt.IsZero() { + normalized.ClosedAt = normalized.ClosedAt.UTC() + } + return normalized +} + +func normalizeTaskRunRecord(run taskpkg.TaskRun) taskpkg.TaskRun { + normalized := run + normalized.ID = strings.TrimSpace(normalized.ID) + normalized.TaskID = strings.TrimSpace(normalized.TaskID) + normalized.Status = normalized.Status.Normalize() + if normalized.ClaimedBy != nil { + claimedBy := *normalized.ClaimedBy + claimedBy.Kind = claimedBy.Kind.Normalize() + claimedBy.Ref = strings.TrimSpace(claimedBy.Ref) + normalized.ClaimedBy = &claimedBy + } + normalized.SessionID = strings.TrimSpace(normalized.SessionID) + normalized.Origin.Kind = normalized.Origin.Kind.Normalize() + normalized.Origin.Ref = strings.TrimSpace(normalized.Origin.Ref) + normalized.IdempotencyKey = strings.TrimSpace(normalized.IdempotencyKey) + normalized.NetworkChannel = strings.TrimSpace(normalized.NetworkChannel) + normalized.Error = strings.TrimSpace(normalized.Error) + normalized.Result = normalizeTaskJSON(normalized.Result) + if !normalized.QueuedAt.IsZero() { + normalized.QueuedAt = normalized.QueuedAt.UTC() + } + if !normalized.ClaimedAt.IsZero() { + normalized.ClaimedAt = normalized.ClaimedAt.UTC() + } + if !normalized.StartedAt.IsZero() { + normalized.StartedAt = normalized.StartedAt.UTC() + } + if !normalized.EndedAt.IsZero() { + normalized.EndedAt = normalized.EndedAt.UTC() + } + return normalized +} + +func normalizeTaskQuery(query taskpkg.TaskQuery) taskpkg.TaskQuery { + normalized := query + normalized.Scope = normalized.Scope.Normalize() + normalized.WorkspaceID = strings.TrimSpace(normalized.WorkspaceID) + normalized.Status = normalized.Status.Normalize() + normalized.OwnerKind = normalized.OwnerKind.Normalize() + normalized.OwnerRef = strings.TrimSpace(normalized.OwnerRef) + normalized.ParentTaskID = strings.TrimSpace(normalized.ParentTaskID) + normalized.NetworkChannel = strings.TrimSpace(normalized.NetworkChannel) + return normalized +} + +func normalizeTaskRunQuery(query taskpkg.TaskRunQuery) taskpkg.TaskRunQuery { + normalized := query + normalized.TaskID = strings.TrimSpace(normalized.TaskID) + normalized.Status = normalized.Status.Normalize() + normalized.SessionID = strings.TrimSpace(normalized.SessionID) + return normalized +} + +func taskSummaryFromRecord(record taskpkg.Task) taskpkg.TaskSummary { + return taskpkg.TaskSummary{ + ID: record.ID, + Identifier: record.Identifier, + Scope: record.Scope, + WorkspaceID: record.WorkspaceID, + ParentTaskID: record.ParentTaskID, + NetworkChannel: record.NetworkChannel, + Title: record.Title, + Status: record.Status, + Owner: record.Owner, + CreatedBy: record.CreatedBy, + Origin: record.Origin, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + ClosedAt: record.ClosedAt, + } +} + +func requireTaskValue(value string, label string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", fmt.Errorf("store: %s is required", label) + } + return trimmed, nil +} + +func taskOwnerKindValue(owner *taskpkg.Ownership) any { + if owner == nil { + return nil + } + return string(owner.Kind) +} + +func taskOwnerRefValue(owner *taskpkg.Ownership) any { + if owner == nil { + return nil + } + return owner.Ref +} + +func taskActorKindValue(actor *taskpkg.ActorIdentity) any { + if actor == nil { + return nil + } + return string(actor.Kind) +} + +func taskActorRefValue(actor *taskpkg.ActorIdentity) any { + if actor == nil { + return nil + } + return actor.Ref +} + +func nullableTaskTimestamp(value time.Time) any { + if value.IsZero() { + return nil + } + return store.FormatTimestamp(value) +} + +func normalizeTaskJSON(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return nil + } + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" { + return nil + } + return json.RawMessage(trimmed) +} + +func nullableTaskJSON(raw json.RawMessage) any { + if len(raw) == 0 { + return nil + } + return string(raw) +} + +func decodeTaskJSON(raw sql.NullString, label string) (json.RawMessage, error) { + if !raw.Valid { + return nil, nil + } + trimmed := strings.TrimSpace(raw.String) + if trimmed == "" { + return nil, nil + } + value := json.RawMessage(trimmed) + if !json.Valid(value) { + return nil, fmt.Errorf("store: decode %s: invalid JSON", label) + } + return value, nil +} + +func taskNullStringValue(value sql.NullString) string { + if !value.Valid { + return "" + } + return strings.TrimSpace(value.String) +} diff --git a/internal/store/globaldb/global_db_task_aux.go b/internal/store/globaldb/global_db_task_aux.go new file mode 100644 index 000000000..def4062e7 --- /dev/null +++ b/internal/store/globaldb/global_db_task_aux.go @@ -0,0 +1,812 @@ +package globaldb + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/pedronauck/agh/internal/store" + taskpkg "github.com/pedronauck/agh/internal/task" +) + +var _ taskpkg.DependencyStore = (*GlobalDB)(nil) +var _ taskpkg.EventStore = (*GlobalDB)(nil) +var _ taskpkg.IdempotencyStore = (*GlobalDB)(nil) + +type taskSQLExecutor interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +// CreateDependency inserts one durable task-dependency edge under a single SQLite write lock. +func (g *GlobalDB) CreateDependency(ctx context.Context, dependency taskpkg.TaskDependency) error { + if err := g.checkReady(ctx, "create task dependency"); err != nil { + return err + } + + normalized, err := g.normalizeTaskDependencyForCreate(dependency) + if err != nil { + return err + } + + return g.withTaskImmediateTransaction(ctx, "create task dependency", func(exec taskSQLExecutor) error { + if err := g.ensureTaskExistsWithExecutor(ctx, exec, normalized.TaskID); err != nil { + return err + } + if err := g.ensureTaskExistsWithExecutor(ctx, exec, normalized.DependsOnTaskID); err != nil { + return err + } + + exists, err := g.taskDependencyExists(ctx, exec, normalized) + if err != nil { + return err + } + if exists { + return fmt.Errorf( + "%w: dependency edge %q -> %q already exists", + taskpkg.ErrValidation, + normalized.TaskID, + normalized.DependsOnTaskID, + ) + } + + count, err := g.countDependenciesWithExecutor(ctx, exec, normalized.TaskID) + if err != nil { + return err + } + if err := taskpkg.ValidateDependencyCount(count + 1); err != nil { + return err + } + + hasPath, err := g.hasDependencyPathWithExecutor(ctx, exec, normalized.DependsOnTaskID, normalized.TaskID) + if err != nil { + return err + } + if hasPath { + return fmt.Errorf( + "%w: adding dependency %q -> %q would create a cycle", + taskpkg.ErrCycleDetected, + normalized.TaskID, + normalized.DependsOnTaskID, + ) + } + + if _, err := exec.ExecContext( + ctx, + `INSERT INTO task_dependencies (task_id, depends_on_task_id, kind, created_at) + VALUES (?, ?, ?, ?)`, + normalized.TaskID, + normalized.DependsOnTaskID, + string(normalized.Kind), + store.FormatTimestamp(normalized.CreatedAt), + ); err != nil { + return fmt.Errorf( + "store: create task dependency %q -> %q: %w", + normalized.TaskID, + normalized.DependsOnTaskID, + err, + ) + } + + return nil + }) +} + +// DeleteDependency removes one persisted dependency edge. +func (g *GlobalDB) DeleteDependency(ctx context.Context, taskID string, dependsOnID string) error { + if err := g.checkReady(ctx, "delete task dependency"); err != nil { + return err + } + + trimmedTaskID, err := requireTaskValue(taskID, "task dependency task id") + if err != nil { + return err + } + trimmedDependsOnID, err := requireTaskValue(dependsOnID, "task dependency depends_on_task_id") + if err != nil { + return err + } + + result, err := g.db.ExecContext( + ctx, + `DELETE FROM task_dependencies WHERE task_id = ? AND depends_on_task_id = ?`, + trimmedTaskID, + trimmedDependsOnID, + ) + if err != nil { + return fmt.Errorf( + "store: delete task dependency %q -> %q: %w", + trimmedTaskID, + trimmedDependsOnID, + err, + ) + } + + return requireRowsAffected(result, taskpkg.ErrTaskDependencyNotFound, trimmedTaskID+"->"+trimmedDependsOnID, "task dependency") +} + +// ListDependencies returns the persisted dependency edges for one task. +func (g *GlobalDB) ListDependencies(ctx context.Context, taskID string) ([]taskpkg.TaskDependency, error) { + if err := g.checkReady(ctx, "list task dependencies"); err != nil { + return nil, err + } + + trimmedTaskID, err := requireTaskValue(taskID, "task dependency task id") + if err != nil { + return nil, err + } + + rows, err := g.db.QueryContext( + ctx, + `SELECT task_id, depends_on_task_id, kind, created_at + FROM task_dependencies + WHERE task_id = ? + ORDER BY created_at ASC, depends_on_task_id ASC`, + trimmedTaskID, + ) + if err != nil { + return nil, fmt.Errorf("store: query task dependencies for %q: %w", trimmedTaskID, err) + } + defer func() { + _ = rows.Close() + }() + + dependencies := make([]taskpkg.TaskDependency, 0) + for rows.Next() { + record, scanErr := scanTaskDependencyRecord(rows) + if scanErr != nil { + return nil, scanErr + } + dependencies = append(dependencies, record) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate task dependencies for %q: %w", trimmedTaskID, err) + } + + return dependencies, nil +} + +// ListDependents returns persisted dependency edges that point at one task. +func (g *GlobalDB) ListDependents(ctx context.Context, dependsOnTaskID string) ([]taskpkg.TaskDependency, error) { + if err := g.checkReady(ctx, "list task dependents"); err != nil { + return nil, err + } + + trimmedDependsOnID, err := requireTaskValue(dependsOnTaskID, "task dependent depends_on_task_id") + if err != nil { + return nil, err + } + + rows, err := g.db.QueryContext( + ctx, + `SELECT task_id, depends_on_task_id, kind, created_at + FROM task_dependencies + WHERE depends_on_task_id = ? + ORDER BY created_at ASC, task_id ASC`, + trimmedDependsOnID, + ) + if err != nil { + return nil, fmt.Errorf("store: query task dependents for %q: %w", trimmedDependsOnID, err) + } + defer func() { + _ = rows.Close() + }() + + dependents := make([]taskpkg.TaskDependency, 0) + for rows.Next() { + record, scanErr := scanTaskDependencyRecord(rows) + if scanErr != nil { + return nil, scanErr + } + dependents = append(dependents, record) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate task dependents for %q: %w", trimmedDependsOnID, err) + } + + return dependents, nil +} + +// CountDependencies reports how many dependency edges are stored for one task. +func (g *GlobalDB) CountDependencies(ctx context.Context, taskID string) (int, error) { + if err := g.checkReady(ctx, "count task dependencies"); err != nil { + return 0, err + } + + trimmedTaskID, err := requireTaskValue(taskID, "task dependency task id") + if err != nil { + return 0, err + } + + return g.countDependenciesWithExecutor(ctx, g.db, trimmedTaskID) +} + +// HasDependencyPath reports whether the dependency graph already contains a path from one task to another. +func (g *GlobalDB) HasDependencyPath(ctx context.Context, fromTaskID string, toTaskID string) (bool, error) { + if err := g.checkReady(ctx, "check task dependency path"); err != nil { + return false, err + } + + trimmedFromTaskID, err := requireTaskValue(fromTaskID, "task dependency path from_task_id") + if err != nil { + return false, err + } + trimmedToTaskID, err := requireTaskValue(toTaskID, "task dependency path to_task_id") + if err != nil { + return false, err + } + + return g.hasDependencyPathWithExecutor(ctx, g.db, trimmedFromTaskID, trimmedToTaskID) +} + +// CreateTaskEvent inserts one immutable task audit event. +func (g *GlobalDB) CreateTaskEvent(ctx context.Context, event taskpkg.TaskEvent) error { + if err := g.checkReady(ctx, "create task event"); err != nil { + return err + } + + normalized, err := g.normalizeTaskEventForCreate(event) + if err != nil { + return err + } + if err := g.ensureTaskExists(ctx, normalized.TaskID); err != nil { + return err + } + if strings.TrimSpace(normalized.RunID) != "" { + run, err := g.getTaskRunWithExecutor(ctx, g.db, normalized.RunID) + if err != nil { + return err + } + if strings.TrimSpace(run.TaskID) != normalized.TaskID { + return fmt.Errorf( + "%w: task_event.run_id %q does not belong to task %q", + taskpkg.ErrValidation, + normalized.RunID, + normalized.TaskID, + ) + } + } + + if _, err := g.db.ExecContext( + ctx, + `INSERT INTO task_events ( + id, task_id, run_id, event_type, actor_kind, actor_ref, origin_kind, origin_ref, payload_json, timestamp + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + normalized.ID, + normalized.TaskID, + store.NullableString(normalized.RunID), + normalized.EventType, + string(normalized.Actor.Kind), + normalized.Actor.Ref, + string(normalized.Origin.Kind), + normalized.Origin.Ref, + nullableTaskJSON(normalized.Payload), + store.FormatTimestamp(normalized.Timestamp), + ); err != nil { + return fmt.Errorf("store: create task event %q: %w", normalized.ID, err) + } + + return nil +} + +// ListTaskEvents returns persisted audit events that match the supplied filters. +func (g *GlobalDB) ListTaskEvents(ctx context.Context, query taskpkg.TaskEventQuery) ([]taskpkg.TaskEvent, error) { + if err := g.checkReady(ctx, "list task events"); err != nil { + return nil, err + } + if err := query.Validate("task_event_query"); err != nil { + return nil, err + } + + normalized := normalizeTaskEventQuery(query) + sqlQuery := `SELECT + id, task_id, run_id, event_type, actor_kind, actor_ref, origin_kind, origin_ref, payload_json, timestamp + FROM task_events` + where, args := store.BuildClauses( + store.StringClause("task_id", normalized.TaskID), + store.StringClause("run_id", normalized.RunID), + store.StringClause("event_type", normalized.EventType), + ) + sqlQuery = store.AppendWhere(sqlQuery, where) + sqlQuery += " ORDER BY timestamp DESC, id DESC" + sqlQuery, args = store.AppendLimit(sqlQuery, args, normalized.Limit) + + rows, err := g.db.QueryContext(ctx, sqlQuery, args...) + if err != nil { + return nil, fmt.Errorf("store: query task events: %w", err) + } + defer func() { + _ = rows.Close() + }() + + events := make([]taskpkg.TaskEvent, 0) + for rows.Next() { + event, scanErr := scanTaskEventRecord(rows) + if scanErr != nil { + return nil, scanErr + } + events = append(events, event) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate task events: %w", err) + } + + return events, nil +} + +// GetTaskRunByIdempotencyKey returns the original persisted run bound to one origin-scoped idempotency key. +func (g *GlobalDB) GetTaskRunByIdempotencyKey(ctx context.Context, key string, origin taskpkg.Origin) (taskpkg.TaskRun, error) { + if err := g.checkReady(ctx, "get task run by idempotency key"); err != nil { + return taskpkg.TaskRun{}, err + } + + trimmedKey, normalizedOrigin, err := normalizeTaskRunIdempotencyLookup(key, origin) + if err != nil { + return taskpkg.TaskRun{}, err + } + + row := g.db.QueryRowContext( + ctx, + `SELECT + tr.id, tr.task_id, tr.status, tr.attempt, tr.claimed_by_kind, tr.claimed_by_ref, tr.session_id, + tr.origin_kind, tr.origin_ref, tr.idempotency_key, tr.network_channel, tr.queued_at, tr.claimed_at, + tr.started_at, tr.ended_at, tr.error, tr.result_json + FROM task_run_idempotency tri + JOIN task_runs tr ON tr.id = tri.run_id + WHERE tri.idempotency_key = ? AND tri.origin_kind = ? AND tri.origin_ref = ?`, + trimmedKey, + string(normalizedOrigin.Kind), + normalizedOrigin.Ref, + ) + + run, err := scanTaskRunRecord(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return taskpkg.TaskRun{}, taskpkg.ErrTaskRunIdempotencyNotFound + } + return taskpkg.TaskRun{}, err + } + return run, nil +} + +// SaveTaskRunIdempotency inserts one origin-scoped idempotency binding for a persisted run. +func (g *GlobalDB) SaveTaskRunIdempotency(ctx context.Context, record taskpkg.TaskRunIdempotency) error { + if err := g.checkReady(ctx, "save task run idempotency"); err != nil { + return err + } + + normalized, err := g.normalizeTaskRunIdempotencyForCreate(record) + if err != nil { + return err + } + + run, err := g.getTaskRunWithExecutor(ctx, g.db, normalized.RunID) + if err != nil { + return err + } + if !taskOriginsEqual(run.Origin, normalized.Origin) { + return fmt.Errorf( + "%w: task_run_idempotency origin %q/%q does not match run origin %q/%q", + taskpkg.ErrValidation, + normalized.Origin.Kind, + normalized.Origin.Ref, + run.Origin.Kind, + run.Origin.Ref, + ) + } + + result, err := g.db.ExecContext( + ctx, + `INSERT INTO task_run_idempotency ( + idempotency_key, origin_kind, origin_ref, run_id, created_at + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(idempotency_key, origin_kind, origin_ref) DO NOTHING`, + normalized.IdempotencyKey, + string(normalized.Origin.Kind), + normalized.Origin.Ref, + normalized.RunID, + store.FormatTimestamp(normalized.CreatedAt), + ) + if err != nil { + return fmt.Errorf("store: save task run idempotency %q: %w", normalized.IdempotencyKey, err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("store: rows affected for task run idempotency %q: %w", normalized.IdempotencyKey, err) + } + if rowsAffected > 0 { + return nil + } + + current, err := getTaskRunIdempotencyRecord(ctx, g.db, normalized.IdempotencyKey, normalized.Origin) + if err != nil { + return err + } + if current.RunID != normalized.RunID { + return fmt.Errorf( + "%w: idempotency key %q is already bound to run %q", + taskpkg.ErrValidation, + normalized.IdempotencyKey, + current.RunID, + ) + } + + return nil +} + +func (g *GlobalDB) normalizeTaskDependencyForCreate(record taskpkg.TaskDependency) (taskpkg.TaskDependency, error) { + normalized := normalizeTaskDependencyRecord(record) + if normalized.CreatedAt.IsZero() { + normalized.CreatedAt = g.now() + } + if err := normalized.Validate(); err != nil { + return taskpkg.TaskDependency{}, err + } + return normalized, nil +} + +func (g *GlobalDB) normalizeTaskEventForCreate(record taskpkg.TaskEvent) (taskpkg.TaskEvent, error) { + normalized := normalizeTaskEventRecord(record) + if normalized.Timestamp.IsZero() { + normalized.Timestamp = g.now() + } + if err := normalized.Validate(); err != nil { + return taskpkg.TaskEvent{}, err + } + return normalized, nil +} + +func (g *GlobalDB) normalizeTaskRunIdempotencyForCreate(record taskpkg.TaskRunIdempotency) (taskpkg.TaskRunIdempotency, error) { + normalized := normalizeTaskRunIdempotencyRecord(record) + if normalized.CreatedAt.IsZero() { + normalized.CreatedAt = g.now() + } + if err := normalized.Validate(); err != nil { + return taskpkg.TaskRunIdempotency{}, err + } + return normalized, nil +} + +func (g *GlobalDB) withTaskImmediateTransaction(ctx context.Context, action string, run func(exec taskSQLExecutor) error) error { + conn, err := g.db.Conn(ctx) + if err != nil { + return fmt.Errorf("store: open connection for %s: %w", action, err) + } + defer func() { + _ = conn.Close() + }() + + rollbackCtx := context.WithoutCancel(ctx) + if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil { + return fmt.Errorf("store: begin immediate %s transaction: %w", action, err) + } + + finished := false + defer func() { + if !finished { + _, _ = conn.ExecContext(rollbackCtx, "ROLLBACK") + } + }() + + if err := run(conn); err != nil { + if _, rollbackErr := conn.ExecContext(rollbackCtx, "ROLLBACK"); rollbackErr != nil { + return errors.Join(err, fmt.Errorf("store: rollback %s transaction: %w", action, rollbackErr)) + } + finished = true + return err + } + + if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil { + if _, rollbackErr := conn.ExecContext(rollbackCtx, "ROLLBACK"); rollbackErr != nil { + return errors.Join( + fmt.Errorf("store: commit %s transaction: %w", action, err), + fmt.Errorf("store: rollback %s transaction: %w", action, rollbackErr), + ) + } + finished = true + return fmt.Errorf("store: commit %s transaction: %w", action, err) + } + + finished = true + return nil +} + +func (g *GlobalDB) ensureTaskExistsWithExecutor(ctx context.Context, exec taskSQLExecutor, taskID string) error { + trimmedID, err := requireTaskValue(taskID, "task id") + if err != nil { + return err + } + + var exists int + if err := exec.QueryRowContext(ctx, `SELECT 1 FROM tasks WHERE id = ?`, trimmedID).Scan(&exists); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return taskpkg.ErrTaskNotFound + } + return fmt.Errorf("store: lookup task %q: %w", trimmedID, err) + } + + return nil +} + +func (g *GlobalDB) getTaskRunWithExecutor(ctx context.Context, exec taskSQLExecutor, runID string) (taskpkg.TaskRun, error) { + trimmedRunID, err := requireTaskValue(runID, "task run id") + if err != nil { + return taskpkg.TaskRun{}, err + } + + row := exec.QueryRowContext( + ctx, + `SELECT + id, task_id, status, attempt, claimed_by_kind, claimed_by_ref, session_id, origin_kind, origin_ref, + idempotency_key, network_channel, queued_at, claimed_at, started_at, ended_at, error, result_json + FROM task_runs + WHERE id = ?`, + trimmedRunID, + ) + + run, err := scanTaskRunRecord(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return taskpkg.TaskRun{}, taskpkg.ErrTaskRunNotFound + } + return taskpkg.TaskRun{}, err + } + return run, nil +} + +func (g *GlobalDB) countDependenciesWithExecutor(ctx context.Context, exec taskSQLExecutor, taskID string) (int, error) { + var count int + if err := exec.QueryRowContext( + ctx, + `SELECT COUNT(1) FROM task_dependencies WHERE task_id = ?`, + taskID, + ).Scan(&count); err != nil { + return 0, fmt.Errorf("store: count task dependencies for %q: %w", taskID, err) + } + return count, nil +} + +func (g *GlobalDB) taskDependencyExists(ctx context.Context, exec taskSQLExecutor, dependency taskpkg.TaskDependency) (bool, error) { + var exists int + if err := exec.QueryRowContext( + ctx, + `SELECT 1 + FROM task_dependencies + WHERE task_id = ? AND depends_on_task_id = ? AND kind = ?`, + dependency.TaskID, + dependency.DependsOnTaskID, + string(dependency.Kind), + ).Scan(&exists); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, fmt.Errorf( + "store: lookup task dependency %q -> %q: %w", + dependency.TaskID, + dependency.DependsOnTaskID, + err, + ) + } + return true, nil +} + +func (g *GlobalDB) hasDependencyPathWithExecutor(ctx context.Context, exec taskSQLExecutor, fromTaskID string, toTaskID string) (bool, error) { + var exists int + if err := exec.QueryRowContext( + ctx, + `WITH RECURSIVE dependency_path(node_id) AS ( + SELECT depends_on_task_id + FROM task_dependencies + WHERE task_id = ? + UNION + SELECT td.depends_on_task_id + FROM task_dependencies td + JOIN dependency_path path ON td.task_id = path.node_id + ) + SELECT EXISTS(SELECT 1 FROM dependency_path WHERE node_id = ?)`, + fromTaskID, + toTaskID, + ).Scan(&exists); err != nil { + return false, fmt.Errorf("store: query task dependency path %q -> %q: %w", fromTaskID, toTaskID, err) + } + return exists == 1, nil +} + +func getTaskRunIdempotencyRecord(ctx context.Context, exec taskSQLExecutor, key string, origin taskpkg.Origin) (taskpkg.TaskRunIdempotency, error) { + row := exec.QueryRowContext( + ctx, + `SELECT idempotency_key, run_id, origin_kind, origin_ref, created_at + FROM task_run_idempotency + WHERE idempotency_key = ? AND origin_kind = ? AND origin_ref = ?`, + key, + string(origin.Kind), + origin.Ref, + ) + + record, err := scanTaskRunIdempotencyRecord(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return taskpkg.TaskRunIdempotency{}, taskpkg.ErrTaskRunIdempotencyNotFound + } + return taskpkg.TaskRunIdempotency{}, err + } + return record, nil +} + +func normalizeTaskDependencyRecord(record taskpkg.TaskDependency) taskpkg.TaskDependency { + normalized := record + normalized.TaskID = strings.TrimSpace(normalized.TaskID) + normalized.DependsOnTaskID = strings.TrimSpace(normalized.DependsOnTaskID) + normalized.Kind = normalized.Kind.Normalize() + if !normalized.CreatedAt.IsZero() { + normalized.CreatedAt = normalized.CreatedAt.UTC() + } + return normalized +} + +func normalizeTaskEventRecord(record taskpkg.TaskEvent) taskpkg.TaskEvent { + normalized := record + normalized.ID = strings.TrimSpace(normalized.ID) + normalized.TaskID = strings.TrimSpace(normalized.TaskID) + normalized.RunID = strings.TrimSpace(normalized.RunID) + normalized.EventType = strings.TrimSpace(normalized.EventType) + normalized.Actor.Kind = normalized.Actor.Kind.Normalize() + normalized.Actor.Ref = strings.TrimSpace(normalized.Actor.Ref) + normalized.Origin.Kind = normalized.Origin.Kind.Normalize() + normalized.Origin.Ref = strings.TrimSpace(normalized.Origin.Ref) + normalized.Payload = normalizeTaskJSON(normalized.Payload) + if !normalized.Timestamp.IsZero() { + normalized.Timestamp = normalized.Timestamp.UTC() + } + return normalized +} + +func normalizeTaskEventQuery(query taskpkg.TaskEventQuery) taskpkg.TaskEventQuery { + normalized := query + normalized.TaskID = strings.TrimSpace(normalized.TaskID) + normalized.RunID = strings.TrimSpace(normalized.RunID) + normalized.EventType = strings.TrimSpace(normalized.EventType) + return normalized +} + +func normalizeTaskRunIdempotencyLookup(key string, origin taskpkg.Origin) (string, taskpkg.Origin, error) { + trimmedKey, err := requireTaskValue(key, "task run idempotency key") + if err != nil { + return "", taskpkg.Origin{}, err + } + + normalizedOrigin := taskpkg.Origin{ + Kind: origin.Kind.Normalize(), + Ref: strings.TrimSpace(origin.Ref), + } + if err := normalizedOrigin.Validate("task_run_idempotency.origin"); err != nil { + return "", taskpkg.Origin{}, err + } + + return trimmedKey, normalizedOrigin, nil +} + +func normalizeTaskRunIdempotencyRecord(record taskpkg.TaskRunIdempotency) taskpkg.TaskRunIdempotency { + normalized := record + normalized.IdempotencyKey = strings.TrimSpace(normalized.IdempotencyKey) + normalized.RunID = strings.TrimSpace(normalized.RunID) + normalized.Origin.Kind = normalized.Origin.Kind.Normalize() + normalized.Origin.Ref = strings.TrimSpace(normalized.Origin.Ref) + if !normalized.CreatedAt.IsZero() { + normalized.CreatedAt = normalized.CreatedAt.UTC() + } + return normalized +} + +func scanTaskDependencyRecord(scanner rowScanner) (taskpkg.TaskDependency, error) { + var ( + record taskpkg.TaskDependency + kind string + createdAtRaw string + ) + if err := scanner.Scan( + &record.TaskID, + &record.DependsOnTaskID, + &kind, + &createdAtRaw, + ); err != nil { + return taskpkg.TaskDependency{}, fmt.Errorf("store: scan task dependency: %w", err) + } + + record.Kind = taskpkg.DependencyKind(strings.TrimSpace(kind)) + createdAt, err := store.ParseTimestamp(createdAtRaw) + if err != nil { + return taskpkg.TaskDependency{}, err + } + record.CreatedAt = createdAt + record = normalizeTaskDependencyRecord(record) + if err := record.Validate(); err != nil { + return taskpkg.TaskDependency{}, err + } + + return record, nil +} + +func scanTaskEventRecord(scanner rowScanner) (taskpkg.TaskEvent, error) { + var ( + record taskpkg.TaskEvent + runID sql.NullString + actorKind string + originKind string + payloadJSON sql.NullString + timestamp string + ) + if err := scanner.Scan( + &record.ID, + &record.TaskID, + &runID, + &record.EventType, + &actorKind, + &record.Actor.Ref, + &originKind, + &record.Origin.Ref, + &payloadJSON, + ×tamp, + ); err != nil { + return taskpkg.TaskEvent{}, fmt.Errorf("store: scan task event: %w", err) + } + + record.RunID = taskNullStringValue(runID) + record.Actor.Kind = taskpkg.ActorKind(strings.TrimSpace(actorKind)) + record.Origin.Kind = taskpkg.OriginKind(strings.TrimSpace(originKind)) + payload, err := decodeTaskJSON(payloadJSON, "task_event.payload_json") + if err != nil { + return taskpkg.TaskEvent{}, err + } + record.Payload = payload + + parsedTimestamp, err := store.ParseTimestamp(timestamp) + if err != nil { + return taskpkg.TaskEvent{}, err + } + record.Timestamp = parsedTimestamp + record = normalizeTaskEventRecord(record) + if err := record.Validate(); err != nil { + return taskpkg.TaskEvent{}, err + } + + return record, nil +} + +func scanTaskRunIdempotencyRecord(scanner rowScanner) (taskpkg.TaskRunIdempotency, error) { + var ( + record taskpkg.TaskRunIdempotency + originKind string + createdAtRaw string + ) + if err := scanner.Scan( + &record.IdempotencyKey, + &record.RunID, + &originKind, + &record.Origin.Ref, + &createdAtRaw, + ); err != nil { + return taskpkg.TaskRunIdempotency{}, fmt.Errorf("store: scan task run idempotency: %w", err) + } + + record.Origin.Kind = taskpkg.OriginKind(strings.TrimSpace(originKind)) + createdAt, err := store.ParseTimestamp(createdAtRaw) + if err != nil { + return taskpkg.TaskRunIdempotency{}, err + } + record.CreatedAt = createdAt + record = normalizeTaskRunIdempotencyRecord(record) + if err := record.Validate(); err != nil { + return taskpkg.TaskRunIdempotency{}, err + } + + return record, nil +} + +func taskOriginsEqual(left taskpkg.Origin, right taskpkg.Origin) bool { + return left.Kind.Normalize() == right.Kind.Normalize() && strings.TrimSpace(left.Ref) == strings.TrimSpace(right.Ref) +} diff --git a/internal/store/globaldb/global_db_task_graph_audit_integration_test.go b/internal/store/globaldb/global_db_task_graph_audit_integration_test.go new file mode 100644 index 000000000..4c16154cb --- /dev/null +++ b/internal/store/globaldb/global_db_task_graph_audit_integration_test.go @@ -0,0 +1,121 @@ +//go:build integration + +package globaldb + +import ( + "errors" + "path/filepath" + "testing" + "time" + + taskpkg "github.com/pedronauck/agh/internal/task" + "github.com/pedronauck/agh/internal/testutil" +) + +func TestGlobalDBCreateDependencyCycleFailsTransactionally(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t) + dbPath := filepath.Join(t.TempDir(), GlobalDatabaseName) + + globalDB, err := OpenGlobalDB(ctx, dbPath) + if err != nil { + t.Fatalf("OpenGlobalDB() error = %v", err) + } + t.Cleanup(func() { + if err := globalDB.Close(ctx); err != nil { + t.Fatalf("Close() error = %v", err) + } + }) + + taskA := taskRecordForTest("task-cycle-a") + taskB := taskRecordForTest("task-cycle-b") + taskC := taskRecordForTest("task-cycle-c") + for _, record := range []taskpkg.Task{taskA, taskB, taskC} { + if err := globalDB.CreateTask(ctx, record); err != nil { + t.Fatalf("CreateTask(%q) error = %v", record.ID, err) + } + } + + for _, dependency := range []taskpkg.TaskDependency{ + taskDependencyForTest(taskA.ID, taskB.ID), + taskDependencyForTest(taskB.ID, taskC.ID), + } { + if err := globalDB.CreateDependency(ctx, dependency); err != nil { + t.Fatalf("CreateDependency(%q -> %q) error = %v", dependency.TaskID, dependency.DependsOnTaskID, err) + } + } + + err = globalDB.CreateDependency(ctx, taskDependencyForTest(taskC.ID, taskA.ID)) + if !errors.Is(err, taskpkg.ErrCycleDetected) { + t.Fatalf("CreateDependency(cycle) error = %v, want ErrCycleDetected", err) + } + + dependencies, err := globalDB.ListDependencies(ctx, taskC.ID) + if err != nil { + t.Fatalf("ListDependencies(taskC) error = %v", err) + } + if got := len(dependencies); got != 0 { + t.Fatalf("len(ListDependencies(taskC)) = %d, want 0", got) + } +} + +func TestGlobalDBTaskRunIdempotencyDeduplicatesDuplicateWrites(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t) + dbPath := filepath.Join(t.TempDir(), GlobalDatabaseName) + + first, err := OpenGlobalDB(ctx, dbPath) + if err != nil { + t.Fatalf("OpenGlobalDB(first) error = %v", err) + } + + taskRecord := taskRecordForTest("task-idempotency-integration") + if err := first.CreateTask(ctx, taskRecord); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + runOne := taskRunForTest("run-idempotency-integration-one", taskRecord.ID) + runOne.Origin = taskpkg.Origin{Kind: taskpkg.OriginKindAutomation, Ref: "rule:nightly"} + runOne.IdempotencyKey = "idem-duplicate" + runTwo := taskRunForTest("run-idempotency-integration-two", taskRecord.ID) + runTwo.QueuedAt = runTwo.QueuedAt.Add(time.Minute) + runTwo.Origin = taskpkg.Origin{Kind: taskpkg.OriginKindAutomation, Ref: "rule:nightly"} + runTwo.IdempotencyKey = "idem-duplicate" + for _, run := range []taskpkg.TaskRun{runOne, runTwo} { + if err := first.CreateTaskRun(ctx, run); err != nil { + t.Fatalf("CreateTaskRun(%q) error = %v", run.ID, err) + } + } + + recordOne := taskRunIdempotencyForTest("idem-duplicate", runOne.ID, runOne.Origin) + recordTwo := taskRunIdempotencyForTest("idem-duplicate", runTwo.ID, runTwo.Origin) + if err := first.SaveTaskRunIdempotency(ctx, recordOne); err != nil { + t.Fatalf("SaveTaskRunIdempotency(first) error = %v", err) + } + err = first.SaveTaskRunIdempotency(ctx, recordTwo) + if !errors.Is(err, taskpkg.ErrValidation) { + t.Fatalf("SaveTaskRunIdempotency(duplicate) error = %v, want ErrValidation", err) + } + + if err := first.Close(ctx); err != nil { + t.Fatalf("Close(first) error = %v", err) + } + + second, err := OpenGlobalDB(ctx, dbPath) + if err != nil { + t.Fatalf("OpenGlobalDB(second) error = %v", err) + } + t.Cleanup(func() { + if err := second.Close(ctx); err != nil { + t.Fatalf("Close(second) error = %v", err) + } + }) + + gotRun, err := second.GetTaskRunByIdempotencyKey(ctx, recordOne.IdempotencyKey, recordOne.Origin) + if err != nil { + t.Fatalf("GetTaskRunByIdempotencyKey() error = %v", err) + } + assertTaskRunEqual(t, gotRun, runOne) +} diff --git a/internal/store/globaldb/global_db_task_graph_audit_test.go b/internal/store/globaldb/global_db_task_graph_audit_test.go new file mode 100644 index 000000000..e28ea7f9d --- /dev/null +++ b/internal/store/globaldb/global_db_task_graph_audit_test.go @@ -0,0 +1,421 @@ +package globaldb + +import ( + "encoding/json" + "errors" + "strconv" + "strings" + "testing" + "time" + + taskpkg "github.com/pedronauck/agh/internal/task" + "github.com/pedronauck/agh/internal/testutil" +) + +func TestGlobalDBTaskDependencyRoundTripAndDelete(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + rootTask := taskRecordForTest("task-dependency-root") + middleTask := taskRecordForTest("task-dependency-middle") + leafTask := taskRecordForTest("task-dependency-leaf") + + for _, record := range []taskpkg.Task{rootTask, middleTask, leafTask} { + if err := globalDB.CreateTask(testutil.Context(t), record); err != nil { + t.Fatalf("CreateTask(%q) error = %v", record.ID, err) + } + } + + rootDependsOnMiddle := taskDependencyForTest(rootTask.ID, middleTask.ID) + middleDependsOnLeaf := taskDependencyForTest(middleTask.ID, leafTask.ID) + for _, dependency := range []taskpkg.TaskDependency{rootDependsOnMiddle, middleDependsOnLeaf} { + if err := globalDB.CreateDependency(testutil.Context(t), dependency); err != nil { + t.Fatalf("CreateDependency(%q -> %q) error = %v", dependency.TaskID, dependency.DependsOnTaskID, err) + } + } + + dependencies, err := globalDB.ListDependencies(testutil.Context(t), rootTask.ID) + if err != nil { + t.Fatalf("ListDependencies() error = %v", err) + } + if got, want := len(dependencies), 1; got != want { + t.Fatalf("len(ListDependencies()) = %d, want %d", got, want) + } + assertTaskDependencyEqual(t, dependencies[0], rootDependsOnMiddle) + + count, err := globalDB.CountDependencies(testutil.Context(t), rootTask.ID) + if err != nil { + t.Fatalf("CountDependencies() error = %v", err) + } + if got, want := count, 1; got != want { + t.Fatalf("CountDependencies() = %d, want %d", got, want) + } + + hasPath, err := globalDB.HasDependencyPath(testutil.Context(t), rootTask.ID, leafTask.ID) + if err != nil { + t.Fatalf("HasDependencyPath(root, leaf) error = %v", err) + } + if !hasPath { + t.Fatal("HasDependencyPath(root, leaf) = false, want true") + } + + if err := globalDB.DeleteDependency(testutil.Context(t), rootTask.ID, middleTask.ID); err != nil { + t.Fatalf("DeleteDependency() error = %v", err) + } + + dependencies, err = globalDB.ListDependencies(testutil.Context(t), rootTask.ID) + if err != nil { + t.Fatalf("ListDependencies(after delete) error = %v", err) + } + if got := len(dependencies); got != 0 { + t.Fatalf("len(ListDependencies(after delete)) = %d, want 0", got) + } + + hasPath, err = globalDB.HasDependencyPath(testutil.Context(t), rootTask.ID, leafTask.ID) + if err != nil { + t.Fatalf("HasDependencyPath(after delete) error = %v", err) + } + if hasPath { + t.Fatal("HasDependencyPath(after delete) = true, want false") + } +} + +func TestGlobalDBCreateDependencyRejectsInvalidEdges(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + rootTask := taskRecordForTest("task-dependency-guard-root") + if err := globalDB.CreateTask(testutil.Context(t), rootTask); err != nil { + t.Fatalf("CreateTask(root) error = %v", err) + } + + t.Run("self dependency", func(t *testing.T) { + t.Parallel() + + err := globalDB.CreateDependency(testutil.Context(t), taskDependencyForTest(rootTask.ID, rootTask.ID)) + if !errors.Is(err, taskpkg.ErrValidation) { + t.Fatalf("CreateDependency(self) error = %v, want ErrValidation", err) + } + }) + + t.Run("duplicate dependency", func(t *testing.T) { + t.Parallel() + + dependencyTask := taskRecordForTest("task-dependency-guard-duplicate") + if err := globalDB.CreateTask(testutil.Context(t), dependencyTask); err != nil { + t.Fatalf("CreateTask(duplicate target) error = %v", err) + } + + dependency := taskDependencyForTest(rootTask.ID, dependencyTask.ID) + if err := globalDB.CreateDependency(testutil.Context(t), dependency); err != nil { + t.Fatalf("CreateDependency(first) error = %v", err) + } + + err := globalDB.CreateDependency(testutil.Context(t), dependency) + if !errors.Is(err, taskpkg.ErrValidation) { + t.Fatalf("CreateDependency(duplicate) error = %v, want ErrValidation", err) + } + }) + + t.Run("dependency limit", func(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + limitRoot := taskRecordForTest("task-dependency-limit-root") + if err := globalDB.CreateTask(testutil.Context(t), limitRoot); err != nil { + t.Fatalf("CreateTask(limit root) error = %v", err) + } + + for idx := 0; idx < taskpkg.MaxDependencyCount; idx++ { + dependencyTask := taskRecordForTest("task-dependency-limit-" + strconv.Itoa(idx)) + dependencyTask.ID = "task-dependency-limit-" + strconv.Itoa(idx) + dependencyTask.Identifier = "identifier-task-dependency-limit-" + strconv.Itoa(idx) + dependencyTask.Title = "Task dependency limit " + strconv.Itoa(idx) + if err := globalDB.CreateTask(testutil.Context(t), dependencyTask); err != nil { + t.Fatalf("CreateTask(limit target %d) error = %v", idx, err) + } + if err := globalDB.CreateDependency(testutil.Context(t), taskDependencyForTest(limitRoot.ID, dependencyTask.ID)); err != nil { + t.Fatalf("CreateDependency(limit %d) error = %v", idx, err) + } + } + + overflowTask := taskRecordForTest("task-dependency-limit-overflow") + if err := globalDB.CreateTask(testutil.Context(t), overflowTask); err != nil { + t.Fatalf("CreateTask(limit overflow) error = %v", err) + } + + err := globalDB.CreateDependency(testutil.Context(t), taskDependencyForTest(limitRoot.ID, overflowTask.ID)) + if !errors.Is(err, taskpkg.ErrGraphLimitExceeded) { + t.Fatalf("CreateDependency(limit overflow) error = %v, want ErrGraphLimitExceeded", err) + } + }) +} + +func TestGlobalDBTaskEventRoundTripRejectsOversizePayloadAndPreservesOrigin(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + taskRecord := taskRecordForTest("task-event-roundtrip") + if err := globalDB.CreateTask(testutil.Context(t), taskRecord); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + run := taskRunForTest("run-event-roundtrip", taskRecord.ID) + run.Status = taskpkg.TaskRunStatusRunning + run.SessionID = "sess-task-event-roundtrip" + run.ClaimedBy = actorForTest(taskpkg.ActorKindDaemon, "scheduler") + run.ClaimedAt = run.QueuedAt.Add(10 * time.Second) + run.StartedAt = run.QueuedAt.Add(20 * time.Second) + if err := globalDB.CreateTaskRun(testutil.Context(t), run); err != nil { + t.Fatalf("CreateTaskRun() error = %v", err) + } + + event := taskEventForTest("evt-roundtrip", taskRecord.ID, run.ID) + if err := globalDB.CreateTaskEvent(testutil.Context(t), event); err != nil { + t.Fatalf("CreateTaskEvent() error = %v", err) + } + + events, err := globalDB.ListTaskEvents(testutil.Context(t), taskpkg.TaskEventQuery{TaskID: taskRecord.ID}) + if err != nil { + t.Fatalf("ListTaskEvents() error = %v", err) + } + if got, want := len(events), 1; got != want { + t.Fatalf("len(ListTaskEvents()) = %d, want %d", got, want) + } + assertTaskEventEqual(t, events[0], event) + + oversize := taskEventForTest("evt-oversize", taskRecord.ID, run.ID) + oversize.Payload = taskJSONBlob(taskpkg.MaxPayloadBytes + 1) + err = globalDB.CreateTaskEvent(testutil.Context(t), oversize) + if !errors.Is(err, taskpkg.ErrPayloadTooLarge) { + t.Fatalf("CreateTaskEvent(oversize) error = %v, want ErrPayloadTooLarge", err) + } +} + +func TestGlobalDBTaskEventRejectsRunTaskMismatch(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + firstTask := taskRecordForTest("task-event-first") + secondTask := taskRecordForTest("task-event-second") + for _, record := range []taskpkg.Task{firstTask, secondTask} { + if err := globalDB.CreateTask(testutil.Context(t), record); err != nil { + t.Fatalf("CreateTask(%q) error = %v", record.ID, err) + } + } + + run := taskRunForTest("run-event-mismatch", firstTask.ID) + if err := globalDB.CreateTaskRun(testutil.Context(t), run); err != nil { + t.Fatalf("CreateTaskRun() error = %v", err) + } + + event := taskEventForTest("evt-mismatch", secondTask.ID, run.ID) + err := globalDB.CreateTaskEvent(testutil.Context(t), event) + if !errors.Is(err, taskpkg.ErrValidation) { + t.Fatalf("CreateTaskEvent(run mismatch) error = %v, want ErrValidation", err) + } +} + +func TestGlobalDBTaskRunIdempotencyLookupUsesOriginScope(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + taskRecord := taskRecordForTest("task-idempotency-scope") + if err := globalDB.CreateTask(testutil.Context(t), taskRecord); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + runA := taskRunForTest("run-idempotency-a", taskRecord.ID) + runA.Origin = taskpkg.Origin{Kind: taskpkg.OriginKindAutomation, Ref: "rule:nightly"} + runA.IdempotencyKey = "idem-shared" + runB := taskRunForTest("run-idempotency-b", taskRecord.ID) + runB.QueuedAt = runB.QueuedAt.Add(time.Minute) + runB.Origin = taskpkg.Origin{Kind: taskpkg.OriginKindNetwork, Ref: "peer:finance"} + runB.IdempotencyKey = "idem-shared" + for _, run := range []taskpkg.TaskRun{runA, runB} { + if err := globalDB.CreateTaskRun(testutil.Context(t), run); err != nil { + t.Fatalf("CreateTaskRun(%q) error = %v", run.ID, err) + } + } + + recordA := taskRunIdempotencyForTest("idem-shared", runA.ID, runA.Origin) + recordB := taskRunIdempotencyForTest("idem-shared", runB.ID, runB.Origin) + if err := globalDB.SaveTaskRunIdempotency(testutil.Context(t), recordA); err != nil { + t.Fatalf("SaveTaskRunIdempotency(recordA) error = %v", err) + } + if err := globalDB.SaveTaskRunIdempotency(testutil.Context(t), recordA); err != nil { + t.Fatalf("SaveTaskRunIdempotency(recordA duplicate same run) error = %v", err) + } + if err := globalDB.SaveTaskRunIdempotency(testutil.Context(t), recordB); err != nil { + t.Fatalf("SaveTaskRunIdempotency(recordB) error = %v", err) + } + + gotA, err := globalDB.GetTaskRunByIdempotencyKey(testutil.Context(t), recordA.IdempotencyKey, recordA.Origin) + if err != nil { + t.Fatalf("GetTaskRunByIdempotencyKey(recordA) error = %v", err) + } + assertTaskRunEqual(t, gotA, runA) + + gotB, err := globalDB.GetTaskRunByIdempotencyKey(testutil.Context(t), recordB.IdempotencyKey, recordB.Origin) + if err != nil { + t.Fatalf("GetTaskRunByIdempotencyKey(recordB) error = %v", err) + } + assertTaskRunEqual(t, gotB, runB) + + _, err = globalDB.GetTaskRunByIdempotencyKey( + testutil.Context(t), + recordA.IdempotencyKey, + taskpkg.Origin{Kind: taskpkg.OriginKindHTTP, Ref: "http"}, + ) + if !errors.Is(err, taskpkg.ErrTaskRunIdempotencyNotFound) { + t.Fatalf("GetTaskRunByIdempotencyKey(missing origin scope) error = %v, want ErrTaskRunIdempotencyNotFound", err) + } +} + +func TestGlobalDBTaskRunIdempotencyRejectsOriginMismatch(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + taskRecord := taskRecordForTest("task-idempotency-mismatch") + if err := globalDB.CreateTask(testutil.Context(t), taskRecord); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + run := taskRunForTest("run-idempotency-mismatch", taskRecord.ID) + run.Origin = taskpkg.Origin{Kind: taskpkg.OriginKindAutomation, Ref: "rule:nightly"} + if err := globalDB.CreateTaskRun(testutil.Context(t), run); err != nil { + t.Fatalf("CreateTaskRun() error = %v", err) + } + + record := taskRunIdempotencyForTest("idem-mismatch", run.ID, taskpkg.Origin{Kind: taskpkg.OriginKindNetwork, Ref: "peer:other"}) + err := globalDB.SaveTaskRunIdempotency(testutil.Context(t), record) + if !errors.Is(err, taskpkg.ErrValidation) { + t.Fatalf("SaveTaskRunIdempotency(origin mismatch) error = %v, want ErrValidation", err) + } +} + +func TestGlobalDBTaskDependencyAndAuditErrorPaths(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + rootTask := taskRecordForTest("task-error-root") + if err := globalDB.CreateTask(testutil.Context(t), rootTask); err != nil { + t.Fatalf("CreateTask(root) error = %v", err) + } + + err := globalDB.DeleteDependency(testutil.Context(t), rootTask.ID, "missing-dependency") + if !errors.Is(err, taskpkg.ErrTaskDependencyNotFound) { + t.Fatalf("DeleteDependency(missing) error = %v, want ErrTaskDependencyNotFound", err) + } + + err = globalDB.CreateDependency(testutil.Context(t), taskDependencyForTest(rootTask.ID, "missing-task")) + if !errors.Is(err, taskpkg.ErrTaskNotFound) { + t.Fatalf("CreateDependency(missing target) error = %v, want ErrTaskNotFound", err) + } + + if _, err := globalDB.ListDependencies(testutil.Context(t), " "); err == nil { + t.Fatal("ListDependencies(empty) error = nil, want non-nil") + } + if _, err := globalDB.CountDependencies(testutil.Context(t), " "); err == nil { + t.Fatal("CountDependencies(empty) error = nil, want non-nil") + } + if _, err := globalDB.HasDependencyPath(testutil.Context(t), "", rootTask.ID); err == nil { + t.Fatal("HasDependencyPath(empty from) error = nil, want non-nil") + } + + event := taskEventForTest("evt-missing-run", rootTask.ID, "missing-run") + err = globalDB.CreateTaskEvent(testutil.Context(t), event) + if !errors.Is(err, taskpkg.ErrTaskRunNotFound) { + t.Fatalf("CreateTaskEvent(missing run) error = %v, want ErrTaskRunNotFound", err) + } +} + +func TestGlobalDBTaskRunIdempotencyErrorPaths(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + + if _, err := globalDB.GetTaskRunByIdempotencyKey( + testutil.Context(t), + "idem-missing", + taskpkg.Origin{Kind: taskpkg.OriginKindHTTP}, + ); !errors.Is(err, taskpkg.ErrValidation) { + t.Fatalf("GetTaskRunByIdempotencyKey(invalid origin) error = %v, want ErrValidation", err) + } + + record := taskRunIdempotencyForTest("idem-missing-run", "missing-run", taskpkg.Origin{Kind: taskpkg.OriginKindAutomation, Ref: "rule:nightly"}) + err := globalDB.SaveTaskRunIdempotency(testutil.Context(t), record) + if !errors.Is(err, taskpkg.ErrTaskRunNotFound) { + t.Fatalf("SaveTaskRunIdempotency(missing run) error = %v, want ErrTaskRunNotFound", err) + } +} + +func taskDependencyForTest(taskID string, dependsOnTaskID string) taskpkg.TaskDependency { + return taskpkg.TaskDependency{ + TaskID: taskID, + DependsOnTaskID: dependsOnTaskID, + Kind: taskpkg.DependencyKindBlocks, + CreatedAt: time.Date(2026, 4, 14, 14, 0, 0, 0, time.UTC), + } +} + +func taskEventForTest(id string, taskID string, runID string) taskpkg.TaskEvent { + return taskpkg.TaskEvent{ + ID: id, + TaskID: taskID, + RunID: runID, + EventType: "task.run_started", + Actor: taskpkg.ActorIdentity{ + Kind: taskpkg.ActorKindDaemon, + Ref: "scheduler", + }, + Origin: taskpkg.Origin{ + Kind: taskpkg.OriginKindAutomation, + Ref: "rule:nightly", + }, + Payload: json.RawMessage(`{"forced_stop":false}`), + Timestamp: time.Date(2026, 4, 14, 14, 30, 0, 0, time.UTC), + } +} + +func taskRunIdempotencyForTest(key string, runID string, origin taskpkg.Origin) taskpkg.TaskRunIdempotency { + return taskpkg.TaskRunIdempotency{ + IdempotencyKey: key, + RunID: runID, + Origin: origin, + CreatedAt: time.Date(2026, 4, 14, 15, 0, 0, 0, time.UTC), + } +} + +func taskJSONBlob(targetSize int) json.RawMessage { + if targetSize <= 2 { + return json.RawMessage(`""`) + } + return json.RawMessage(`"` + strings.Repeat("a", targetSize-2) + `"`) +} + +func assertTaskDependencyEqual(t *testing.T, got taskpkg.TaskDependency, want taskpkg.TaskDependency) { + t.Helper() + + if got.TaskID != want.TaskID || + got.DependsOnTaskID != want.DependsOnTaskID || + got.Kind != want.Kind || + !got.CreatedAt.Equal(want.CreatedAt) { + t.Fatalf("task dependency = %#v, want %#v", got, want) + } +} + +func assertTaskEventEqual(t *testing.T, got taskpkg.TaskEvent, want taskpkg.TaskEvent) { + t.Helper() + + if got.ID != want.ID || + got.TaskID != want.TaskID || + got.RunID != want.RunID || + got.EventType != want.EventType || + got.Actor != want.Actor || + got.Origin != want.Origin || + string(got.Payload) != string(want.Payload) || + !got.Timestamp.Equal(want.Timestamp) { + t.Fatalf("task event = %#v, want %#v", got, want) + } +} diff --git a/internal/store/globaldb/global_db_task_integration_test.go b/internal/store/globaldb/global_db_task_integration_test.go new file mode 100644 index 000000000..b10460119 --- /dev/null +++ b/internal/store/globaldb/global_db_task_integration_test.go @@ -0,0 +1,154 @@ +//go:build integration + +package globaldb + +import ( + "path/filepath" + "testing" + "time" + + taskpkg "github.com/pedronauck/agh/internal/task" + "github.com/pedronauck/agh/internal/testutil" +) + +func TestGlobalDBTaskPersistenceSurvivesReopenWithGlobalAndWorkspaceTasks(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t) + dbPath := filepath.Join(t.TempDir(), GlobalDatabaseName) + + first, err := OpenGlobalDB(ctx, dbPath) + if err != nil { + t.Fatalf("OpenGlobalDB(first) error = %v", err) + } + + workspaceID := registerWorkspaceForGlobalTests(t, first, "task-integration-workspace", filepath.Join(t.TempDir(), "workspace")) + globalTask := taskRecordForTest("task-integration-global") + workspaceTask := taskRecordForTest("task-integration-workspace-child") + workspaceTask.Scope = taskpkg.ScopeWorkspace + workspaceTask.WorkspaceID = workspaceID + workspaceTask.ParentTaskID = globalTask.ID + workspaceTask.Owner = ownershipForTest(taskpkg.OwnerKindPool, "backlog") + workspaceTask.NetworkChannel = "engineering" + + if err := first.CreateTask(ctx, globalTask); err != nil { + t.Fatalf("CreateTask(global) error = %v", err) + } + if err := first.CreateTask(ctx, workspaceTask); err != nil { + t.Fatalf("CreateTask(workspace) error = %v", err) + } + + if err := first.Close(ctx); err != nil { + t.Fatalf("Close(first) error = %v", err) + } + + second, err := OpenGlobalDB(ctx, dbPath) + if err != nil { + t.Fatalf("OpenGlobalDB(second) error = %v", err) + } + t.Cleanup(func() { + if err := second.Close(ctx); err != nil { + t.Fatalf("Close(second) error = %v", err) + } + }) + + globalTasks, err := second.ListTasks(ctx, taskpkg.TaskQuery{Scope: taskpkg.ScopeGlobal}) + if err != nil { + t.Fatalf("ListTasks(global) error = %v", err) + } + if got, want := len(globalTasks), 1; got != want { + t.Fatalf("len(ListTasks(global)) = %d, want %d", got, want) + } + assertTaskSummaryMatchesTask(t, globalTasks[0], globalTask) + + workspaceTasks, err := second.ListTasks(ctx, taskpkg.TaskQuery{WorkspaceID: workspaceID}) + if err != nil { + t.Fatalf("ListTasks(workspace) error = %v", err) + } + if got, want := len(workspaceTasks), 1; got != want { + t.Fatalf("len(ListTasks(workspace)) = %d, want %d", got, want) + } + assertTaskSummaryMatchesTask(t, workspaceTasks[0], workspaceTask) + + reloadedTask, err := second.GetTask(ctx, workspaceTask.ID) + if err != nil { + t.Fatalf("GetTask(workspace) error = %v", err) + } + assertTaskEqual(t, reloadedTask, workspaceTask) +} + +func TestGlobalDBTaskRunSessionAttachmentSurvivesReopen(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t) + dbPath := filepath.Join(t.TempDir(), GlobalDatabaseName) + + first, err := OpenGlobalDB(ctx, dbPath) + if err != nil { + t.Fatalf("OpenGlobalDB(first) error = %v", err) + } + + taskRecord := taskRecordForTest("task-integration-run") + if err := first.CreateTask(ctx, taskRecord); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + run := taskRunForTest("run-integration", taskRecord.ID) + if err := first.CreateTaskRun(ctx, run); err != nil { + t.Fatalf("CreateTaskRun(queued) error = %v", err) + } + + if err := first.Close(ctx); err != nil { + t.Fatalf("Close(first) error = %v", err) + } + + second, err := OpenGlobalDB(ctx, dbPath) + if err != nil { + t.Fatalf("OpenGlobalDB(second) error = %v", err) + } + + storedQueued, err := second.GetTaskRun(ctx, run.ID) + if err != nil { + t.Fatalf("GetTaskRun(queued) error = %v", err) + } + if storedQueued.SessionID != "" { + t.Fatalf("GetTaskRun(queued).SessionID = %q, want empty", storedQueued.SessionID) + } + + storedQueued.Status = taskpkg.TaskRunStatusRunning + storedQueued.SessionID = "sess-persisted" + storedQueued.StartedAt = storedQueued.QueuedAt.Add(45 * time.Second) + storedQueued.ClaimedAt = storedQueued.QueuedAt.Add(15 * time.Second) + storedQueued.ClaimedBy = actorForTest(taskpkg.ActorKindDaemon, "scheduler") + if err := second.UpdateTaskRun(ctx, storedQueued); err != nil { + t.Fatalf("UpdateTaskRun(attached) error = %v", err) + } + + if err := second.Close(ctx); err != nil { + t.Fatalf("Close(second) error = %v", err) + } + + third, err := OpenGlobalDB(ctx, dbPath) + if err != nil { + t.Fatalf("OpenGlobalDB(third) error = %v", err) + } + t.Cleanup(func() { + if err := third.Close(ctx); err != nil { + t.Fatalf("Close(third) error = %v", err) + } + }) + + reloadedRun, err := third.GetTaskRun(ctx, run.ID) + if err != nil { + t.Fatalf("GetTaskRun(attached) error = %v", err) + } + assertTaskRunEqual(t, reloadedRun, storedQueued) + + runs, err := third.ListTaskRuns(ctx, taskpkg.TaskRunQuery{TaskID: taskRecord.ID}) + if err != nil { + t.Fatalf("ListTaskRuns() error = %v", err) + } + if got, want := len(runs), 1; got != want { + t.Fatalf("len(ListTaskRuns()) = %d, want %d", got, want) + } +} diff --git a/internal/store/globaldb/global_db_task_test.go b/internal/store/globaldb/global_db_task_test.go new file mode 100644 index 000000000..d45a26cdf --- /dev/null +++ b/internal/store/globaldb/global_db_task_test.go @@ -0,0 +1,718 @@ +package globaldb + +import ( + "database/sql" + "encoding/json" + "errors" + "path/filepath" + "sort" + "testing" + "time" + + taskpkg "github.com/pedronauck/agh/internal/task" + "github.com/pedronauck/agh/internal/testutil" + aghworkspace "github.com/pedronauck/agh/internal/workspace" +) + +func TestOpenGlobalDBCreatesTaskSchemaAndIndexes(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + + assertTablesPresent(t, globalDB.db, "tasks", "task_runs", "task_dependencies", "task_events", "task_run_idempotency") + assertTableColumns(t, globalDB.db, "tasks", []string{ + "id", + "identifier", + "scope", + "workspace_id", + "parent_task_id", + "network_channel", + "title", + "description", + "status", + "owner_kind", + "owner_ref", + "created_by_kind", + "created_by_ref", + "origin_kind", + "origin_ref", + "created_at", + "updated_at", + "closed_at", + "metadata_json", + }) + assertTableColumns(t, globalDB.db, "task_runs", []string{ + "id", + "task_id", + "status", + "attempt", + "claimed_by_kind", + "claimed_by_ref", + "session_id", + "origin_kind", + "origin_ref", + "idempotency_key", + "network_channel", + "queued_at", + "claimed_at", + "started_at", + "ended_at", + "error", + "result_json", + }) + assertTableColumns(t, globalDB.db, "task_dependencies", []string{ + "task_id", + "depends_on_task_id", + "kind", + "created_at", + }) + assertTableColumns(t, globalDB.db, "task_events", []string{ + "id", + "task_id", + "run_id", + "event_type", + "actor_kind", + "actor_ref", + "origin_kind", + "origin_ref", + "payload_json", + "timestamp", + }) + assertTableColumns(t, globalDB.db, "task_run_idempotency", []string{ + "idempotency_key", + "origin_kind", + "origin_ref", + "run_id", + "created_at", + }) + assertIndexesPresent(t, globalDB.db, "tasks", + "idx_tasks_scope", + "idx_tasks_workspace", + "idx_tasks_status", + "idx_tasks_parent", + "idx_tasks_owner", + "idx_tasks_channel", + ) + assertIndexesPresent(t, globalDB.db, "task_runs", + "idx_task_runs_task", + "idx_task_runs_task_status", + "idx_task_runs_status", + "idx_task_runs_session", + "idx_task_runs_channel", + ) + assertIndexesPresent(t, globalDB.db, "task_dependencies", + "idx_task_dependencies_task", + "idx_task_dependencies_depends_on", + ) + assertIndexesPresent(t, globalDB.db, "task_events", + "idx_task_events_task", + "idx_task_events_run", + "idx_task_events_type", + ) + assertIndexesPresent(t, globalDB.db, "task_run_idempotency", + "idx_task_run_idempotency_run", + ) +} + +func TestGlobalDBTaskRoundTripPreservesNullableFields(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + workspaceID := registerWorkspaceForGlobalTests(t, globalDB, "task-roundtrip-workspace", filepath.Join(t.TempDir(), "workspace")) + + parent := taskRecordForTest("task-parent") + parent.Metadata = json.RawMessage(`{"kind":"global"}`) + if err := globalDB.CreateTask(testutil.Context(t), parent); err != nil { + t.Fatalf("CreateTask(parent) error = %v", err) + } + + child := taskRecordForTest("task-child") + child.Scope = taskpkg.ScopeWorkspace + child.WorkspaceID = workspaceID + child.ParentTaskID = parent.ID + child.NetworkChannel = "finance" + child.Owner = ownershipForTest(taskpkg.OwnerKindHuman, "alice") + child.Metadata = json.RawMessage(`{"kind":"workspace"}`) + if err := globalDB.CreateTask(testutil.Context(t), child); err != nil { + t.Fatalf("CreateTask(child) error = %v", err) + } + + gotParent, err := globalDB.GetTask(testutil.Context(t), parent.ID) + if err != nil { + t.Fatalf("GetTask(parent) error = %v", err) + } + assertTaskEqual(t, gotParent, parent) + if gotParent.WorkspaceID != "" { + t.Fatalf("GetTask(parent).WorkspaceID = %q, want empty", gotParent.WorkspaceID) + } + if gotParent.ParentTaskID != "" { + t.Fatalf("GetTask(parent).ParentTaskID = %q, want empty", gotParent.ParentTaskID) + } + if gotParent.Owner != nil { + t.Fatalf("GetTask(parent).Owner = %#v, want nil", gotParent.Owner) + } + if gotParent.NetworkChannel != "" { + t.Fatalf("GetTask(parent).NetworkChannel = %q, want empty", gotParent.NetworkChannel) + } + + gotChild, err := globalDB.GetTask(testutil.Context(t), child.ID) + if err != nil { + t.Fatalf("GetTask(child) error = %v", err) + } + assertTaskEqual(t, gotChild, child) + + summaries, err := globalDB.ListTasks(testutil.Context(t), taskpkg.TaskQuery{ParentTaskID: parent.ID}) + if err != nil { + t.Fatalf("ListTasks(parent filter) error = %v", err) + } + if got, want := len(summaries), 1; got != want { + t.Fatalf("len(ListTasks(parent filter)) = %d, want %d", got, want) + } + assertTaskSummaryMatchesTask(t, summaries[0], child) + + children, err := globalDB.CountDirectChildren(testutil.Context(t), parent.ID) + if err != nil { + t.Fatalf("CountDirectChildren() error = %v", err) + } + if got, want := children, 1; got != want { + t.Fatalf("CountDirectChildren() = %d, want %d", got, want) + } +} + +func TestGlobalDBCreateAndUpdateTaskRejectInvalidScopeBindings(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + workspaceID := registerWorkspaceForGlobalTests(t, globalDB, "invalid-scope-workspace", filepath.Join(t.TempDir(), "workspace")) + + t.Run("create rejects global task with workspace", func(t *testing.T) { + t.Parallel() + + record := taskRecordForTest("task-invalid-create-global") + record.WorkspaceID = workspaceID + + err := globalDB.CreateTask(testutil.Context(t), record) + if !errors.Is(err, taskpkg.ErrInvalidScopeBinding) { + t.Fatalf("CreateTask(global with workspace) error = %v, want ErrInvalidScopeBinding", err) + } + }) + + t.Run("create rejects workspace task without workspace", func(t *testing.T) { + t.Parallel() + + record := taskRecordForTest("task-invalid-create-workspace") + record.Scope = taskpkg.ScopeWorkspace + + err := globalDB.CreateTask(testutil.Context(t), record) + if !errors.Is(err, taskpkg.ErrInvalidScopeBinding) { + t.Fatalf("CreateTask(workspace without workspace_id) error = %v, want ErrInvalidScopeBinding", err) + } + }) + + t.Run("update rejects global task with workspace", func(t *testing.T) { + t.Parallel() + + record := taskRecordForTest("task-invalid-update-global") + if err := globalDB.CreateTask(testutil.Context(t), record); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + record.WorkspaceID = workspaceID + record.UpdatedAt = record.UpdatedAt.Add(time.Minute) + err := globalDB.UpdateTask(testutil.Context(t), record) + if !errors.Is(err, taskpkg.ErrInvalidScopeBinding) { + t.Fatalf("UpdateTask(global with workspace) error = %v, want ErrInvalidScopeBinding", err) + } + }) + + t.Run("update rejects workspace task without workspace", func(t *testing.T) { + t.Parallel() + + record := taskRecordForTest("task-invalid-update-workspace") + record.Scope = taskpkg.ScopeWorkspace + record.WorkspaceID = workspaceID + if err := globalDB.CreateTask(testutil.Context(t), record); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + record.WorkspaceID = "" + record.UpdatedAt = record.UpdatedAt.Add(time.Minute) + err := globalDB.UpdateTask(testutil.Context(t), record) + if !errors.Is(err, taskpkg.ErrInvalidScopeBinding) { + t.Fatalf("UpdateTask(workspace without workspace_id) error = %v, want ErrInvalidScopeBinding", err) + } + }) +} + +func TestGlobalDBListTasksFilters(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + workspaceA := registerWorkspaceForGlobalTests(t, globalDB, "task-filter-a", filepath.Join(t.TempDir(), "workspace-a")) + workspaceB := registerWorkspaceForGlobalTests(t, globalDB, "task-filter-b", filepath.Join(t.TempDir(), "workspace-b")) + + globalTask := taskRecordForTest("task-filter-global") + globalTask.Status = taskpkg.TaskStatusPending + + readyTask := taskRecordForTest("task-filter-ready") + readyTask.CreatedAt = readyTask.CreatedAt.Add(time.Minute) + readyTask.UpdatedAt = readyTask.UpdatedAt.Add(time.Minute) + readyTask.Scope = taskpkg.ScopeWorkspace + readyTask.WorkspaceID = workspaceA + readyTask.Status = taskpkg.TaskStatusReady + readyTask.Owner = ownershipForTest(taskpkg.OwnerKindHuman, "alice") + readyTask.NetworkChannel = "finance" + + childTask := taskRecordForTest("task-filter-child") + childTask.CreatedAt = childTask.CreatedAt.Add(2 * time.Minute) + childTask.UpdatedAt = childTask.UpdatedAt.Add(2 * time.Minute) + childTask.Scope = taskpkg.ScopeWorkspace + childTask.WorkspaceID = workspaceB + childTask.ParentTaskID = globalTask.ID + childTask.Status = taskpkg.TaskStatusBlocked + childTask.Owner = ownershipForTest(taskpkg.OwnerKindPool, "backlog") + childTask.NetworkChannel = "engineering" + + for _, record := range []taskpkg.Task{globalTask, readyTask, childTask} { + if err := globalDB.CreateTask(testutil.Context(t), record); err != nil { + t.Fatalf("CreateTask(%q) error = %v", record.ID, err) + } + } + + for _, tc := range []struct { + name string + query taskpkg.TaskQuery + want []string + }{ + { + name: "scope", + query: taskpkg.TaskQuery{Scope: taskpkg.ScopeGlobal}, + want: []string{globalTask.ID}, + }, + { + name: "workspace", + query: taskpkg.TaskQuery{WorkspaceID: workspaceA}, + want: []string{readyTask.ID}, + }, + { + name: "status", + query: taskpkg.TaskQuery{Status: taskpkg.TaskStatusReady}, + want: []string{readyTask.ID}, + }, + { + name: "parent", + query: taskpkg.TaskQuery{ParentTaskID: globalTask.ID}, + want: []string{childTask.ID}, + }, + { + name: "owner kind", + query: taskpkg.TaskQuery{OwnerKind: taskpkg.OwnerKindHuman}, + want: []string{readyTask.ID}, + }, + { + name: "owner ref", + query: taskpkg.TaskQuery{OwnerRef: "alice"}, + want: []string{readyTask.ID}, + }, + { + name: "channel", + query: taskpkg.TaskQuery{NetworkChannel: "engineering"}, + want: []string{childTask.ID}, + }, + { + name: "limit", + query: taskpkg.TaskQuery{Limit: 2}, + want: []string{childTask.ID, readyTask.ID}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + summaries, err := globalDB.ListTasks(testutil.Context(t), tc.query) + if err != nil { + t.Fatalf("ListTasks(%s) error = %v", tc.name, err) + } + gotIDs := taskSummaryIDs(summaries) + if !testutil.EqualStringSlices(gotIDs, tc.want) { + t.Fatalf("ListTasks(%s) ids = %#v, want %#v", tc.name, gotIDs, tc.want) + } + }) + } +} + +func TestGlobalDBTaskRunRoundTripAndFilters(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + taskRecord := taskRecordForTest("task-run-roundtrip") + if err := globalDB.CreateTask(testutil.Context(t), taskRecord); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + queuedRun := taskRunForTest("run-queued", taskRecord.ID) + if err := globalDB.CreateTaskRun(testutil.Context(t), queuedRun); err != nil { + t.Fatalf("CreateTaskRun() error = %v", err) + } + + storedQueued, err := globalDB.GetTaskRun(testutil.Context(t), queuedRun.ID) + if err != nil { + t.Fatalf("GetTaskRun(queued) error = %v", err) + } + if storedQueued.SessionID != "" { + t.Fatalf("GetTaskRun(queued).SessionID = %q, want empty", storedQueued.SessionID) + } + if storedQueued.ClaimedBy != nil { + t.Fatalf("GetTaskRun(queued).ClaimedBy = %#v, want nil", storedQueued.ClaimedBy) + } + + runningRun := queuedRun + runningRun.Status = taskpkg.TaskRunStatusRunning + runningRun.ClaimedBy = actorForTest(taskpkg.ActorKindDaemon, "scheduler") + runningRun.SessionID = "sess-task-run" + runningRun.NetworkChannel = "finance" + runningRun.ClaimedAt = queuedRun.QueuedAt.Add(30 * time.Second) + runningRun.StartedAt = queuedRun.QueuedAt.Add(time.Minute) + if err := globalDB.UpdateTaskRun(testutil.Context(t), runningRun); err != nil { + t.Fatalf("UpdateTaskRun(running) error = %v", err) + } + + runsByTask, err := globalDB.ListTaskRuns(testutil.Context(t), taskpkg.TaskRunQuery{TaskID: taskRecord.ID}) + if err != nil { + t.Fatalf("ListTaskRuns(task) error = %v", err) + } + if got, want := len(runsByTask), 1; got != want { + t.Fatalf("len(ListTaskRuns(task)) = %d, want %d", got, want) + } + assertTaskRunEqual(t, runsByTask[0], runningRun) + + runsBySession, err := globalDB.ListTaskRuns(testutil.Context(t), taskpkg.TaskRunQuery{SessionID: "sess-task-run"}) + if err != nil { + t.Fatalf("ListTaskRuns(session) error = %v", err) + } + if got, want := len(runsBySession), 1; got != want { + t.Fatalf("len(ListTaskRuns(session)) = %d, want %d", got, want) + } + + runsByStatus, err := globalDB.ListTaskRunsByStatus(testutil.Context(t), []taskpkg.TaskRunStatus{taskpkg.TaskRunStatusRunning}) + if err != nil { + t.Fatalf("ListTaskRunsByStatus() error = %v", err) + } + if got, want := len(runsByStatus), 1; got != want { + t.Fatalf("len(ListTaskRunsByStatus()) = %d, want %d", got, want) + } + + activeBindings, err := globalDB.CountActiveSessionBindings(testutil.Context(t), "sess-task-run") + if err != nil { + t.Fatalf("CountActiveSessionBindings(running) error = %v", err) + } + if got, want := activeBindings, 1; got != want { + t.Fatalf("CountActiveSessionBindings(running) = %d, want %d", got, want) + } + + completedRun := runningRun + completedRun.Status = taskpkg.TaskRunStatusCompleted + completedRun.EndedAt = runningRun.StartedAt.Add(5 * time.Minute) + completedRun.Result = json.RawMessage(`{"ok":true}`) + if err := globalDB.UpdateTaskRun(testutil.Context(t), completedRun); err != nil { + t.Fatalf("UpdateTaskRun(completed) error = %v", err) + } + + storedCompleted, err := globalDB.GetTaskRun(testutil.Context(t), completedRun.ID) + if err != nil { + t.Fatalf("GetTaskRun(completed) error = %v", err) + } + assertTaskRunEqual(t, storedCompleted, completedRun) + + activeBindings, err = globalDB.CountActiveSessionBindings(testutil.Context(t), "sess-task-run") + if err != nil { + t.Fatalf("CountActiveSessionBindings(completed) error = %v", err) + } + if got, want := activeBindings, 0; got != want { + t.Fatalf("CountActiveSessionBindings(completed) = %d, want %d", got, want) + } +} + +func TestGlobalDBUpdateTaskRunRejectsSessionRebinding(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + taskRecord := taskRecordForTest("task-run-rebinding") + if err := globalDB.CreateTask(testutil.Context(t), taskRecord); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + run := taskRunForTest("run-rebinding", taskRecord.ID) + run.Status = taskpkg.TaskRunStatusRunning + run.SessionID = "sess-1" + run.StartedAt = run.QueuedAt.Add(time.Minute) + if err := globalDB.CreateTaskRun(testutil.Context(t), run); err != nil { + t.Fatalf("CreateTaskRun() error = %v", err) + } + + run.SessionID = "sess-2" + err := globalDB.UpdateTaskRun(testutil.Context(t), run) + if !errors.Is(err, taskpkg.ErrSessionAlreadyBound) { + t.Fatalf("UpdateTaskRun(rebind) error = %v, want ErrSessionAlreadyBound", err) + } +} + +func TestGlobalDBTaskAndRunReferenceErrors(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + + _, err := globalDB.GetTask(testutil.Context(t), "missing-task") + if !errors.Is(err, taskpkg.ErrTaskNotFound) { + t.Fatalf("GetTask(missing) error = %v, want ErrTaskNotFound", err) + } + + _, err = globalDB.GetTaskRun(testutil.Context(t), "missing-run") + if !errors.Is(err, taskpkg.ErrTaskRunNotFound) { + t.Fatalf("GetTaskRun(missing) error = %v, want ErrTaskRunNotFound", err) + } + + workspaceTask := taskRecordForTest("task-missing-workspace") + workspaceTask.Scope = taskpkg.ScopeWorkspace + workspaceTask.WorkspaceID = "ws-missing" + err = globalDB.CreateTask(testutil.Context(t), workspaceTask) + if !errors.Is(err, aghworkspace.ErrWorkspaceNotFound) { + t.Fatalf("CreateTask(missing workspace) error = %v, want ErrWorkspaceNotFound", err) + } + + childTask := taskRecordForTest("task-missing-parent") + childTask.ParentTaskID = "missing-parent" + err = globalDB.CreateTask(testutil.Context(t), childTask) + if !errors.Is(err, taskpkg.ErrTaskNotFound) { + t.Fatalf("CreateTask(missing parent) error = %v, want ErrTaskNotFound", err) + } + + run := taskRunForTest("run-missing-task", "missing-task") + err = globalDB.CreateTaskRun(testutil.Context(t), run) + if !errors.Is(err, taskpkg.ErrTaskNotFound) { + t.Fatalf("CreateTaskRun(missing task) error = %v, want ErrTaskNotFound", err) + } +} + +func TestTaskNormalizationDefaultsAndHelpers(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + globalDB.now = func() time.Time { + return time.Date(2026, 4, 14, 15, 0, 0, 0, time.UTC) + } + + record := taskRecordForTest("task-defaults") + record.CreatedAt = time.Time{} + record.UpdatedAt = time.Time{} + record.Owner = ownershipForTest(taskpkg.OwnerKindHuman, " alice ") + normalizedTask, err := globalDB.normalizeTaskForCreate(record) + if err != nil { + t.Fatalf("normalizeTaskForCreate() error = %v", err) + } + if !normalizedTask.CreatedAt.Equal(globalDB.now()) { + t.Fatalf("normalizeTaskForCreate().CreatedAt = %v, want %v", normalizedTask.CreatedAt, globalDB.now()) + } + if !normalizedTask.UpdatedAt.Equal(globalDB.now()) { + t.Fatalf("normalizeTaskForCreate().UpdatedAt = %v, want %v", normalizedTask.UpdatedAt, globalDB.now()) + } + if normalizedTask.Owner == nil || normalizedTask.Owner.Ref != "alice" { + t.Fatalf("normalizeTaskForCreate().Owner = %#v, want trimmed owner", normalizedTask.Owner) + } + + updateRecord := taskRecordForTest("task-update-default") + updateRecord.UpdatedAt = time.Time{} + normalizedUpdate, err := globalDB.normalizeTaskForUpdate(updateRecord) + if err != nil { + t.Fatalf("normalizeTaskForUpdate() error = %v", err) + } + if !normalizedUpdate.UpdatedAt.Equal(globalDB.now()) { + t.Fatalf("normalizeTaskForUpdate().UpdatedAt = %v, want %v", normalizedUpdate.UpdatedAt, globalDB.now()) + } + + run := taskRunForTest("run-defaults", "task-defaults") + run.Attempt = 0 + run.QueuedAt = time.Time{} + normalizedRun, err := globalDB.normalizeTaskRunForCreate(run) + if err != nil { + t.Fatalf("normalizeTaskRunForCreate() error = %v", err) + } + if got, want := normalizedRun.Attempt, 1; got != want { + t.Fatalf("normalizeTaskRunForCreate().Attempt = %d, want %d", got, want) + } + if !normalizedRun.QueuedAt.Equal(globalDB.now()) { + t.Fatalf("normalizeTaskRunForCreate().QueuedAt = %v, want %v", normalizedRun.QueuedAt, globalDB.now()) + } + + runs, err := globalDB.ListTaskRunsByStatus(testutil.Context(t), nil) + if err != nil { + t.Fatalf("ListTaskRunsByStatus(nil) error = %v", err) + } + if got := len(runs); got != 0 { + t.Fatalf("len(ListTaskRunsByStatus(nil)) = %d, want 0", got) + } + + if _, err := requireTaskValue("", "task id"); err == nil { + t.Fatal("requireTaskValue(empty) error = nil, want non-nil") + } + + decoded, err := decodeTaskJSON(sqlNullStringForTest(`{"ok":true}`), "test") + if err != nil { + t.Fatalf("decodeTaskJSON(valid) error = %v", err) + } + if got, want := string(decoded), `{"ok":true}`; got != want { + t.Fatalf("decodeTaskJSON(valid) = %q, want %q", got, want) + } + if _, err := decodeTaskJSON(sqlNullStringForTest(`{"ok":`), "test"); err == nil { + t.Fatal("decodeTaskJSON(invalid) error = nil, want non-nil") + } +} + +func taskRecordForTest(id string) taskpkg.Task { + now := time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC) + return taskpkg.Task{ + ID: id, + Identifier: "identifier-" + id, + Scope: taskpkg.ScopeGlobal, + Title: "Task " + id, + Description: "Description for " + id, + Status: taskpkg.TaskStatusPending, + CreatedBy: taskpkg.ActorIdentity{ + Kind: taskpkg.ActorKindHuman, + Ref: "user:alice", + }, + Origin: taskpkg.Origin{ + Kind: taskpkg.OriginKindCLI, + Ref: "cli", + }, + CreatedAt: now, + UpdatedAt: now, + } +} + +func taskRunForTest(id string, taskID string) taskpkg.TaskRun { + queuedAt := time.Date(2026, 4, 14, 13, 0, 0, 0, time.UTC) + return taskpkg.TaskRun{ + ID: id, + TaskID: taskID, + Status: taskpkg.TaskRunStatusQueued, + Attempt: 1, + Origin: taskpkg.Origin{Kind: taskpkg.OriginKindDaemon, Ref: "scheduler"}, + QueuedAt: queuedAt, + } +} + +func ownershipForTest(kind taskpkg.OwnerKind, ref string) *taskpkg.Ownership { + return &taskpkg.Ownership{Kind: kind, Ref: ref} +} + +func actorForTest(kind taskpkg.ActorKind, ref string) *taskpkg.ActorIdentity { + return &taskpkg.ActorIdentity{Kind: kind, Ref: ref} +} + +func assertTaskEqual(t *testing.T, got taskpkg.Task, want taskpkg.Task) { + t.Helper() + + if got.ID != want.ID || + got.Identifier != want.Identifier || + got.Scope != want.Scope || + got.WorkspaceID != want.WorkspaceID || + got.ParentTaskID != want.ParentTaskID || + got.NetworkChannel != want.NetworkChannel || + got.Title != want.Title || + got.Description != want.Description || + got.Status != want.Status || + got.CreatedBy != want.CreatedBy || + got.Origin != want.Origin || + !got.CreatedAt.Equal(want.CreatedAt) || + !got.UpdatedAt.Equal(want.UpdatedAt) || + !got.ClosedAt.Equal(want.ClosedAt) || + string(got.Metadata) != string(want.Metadata) { + t.Fatalf("task = %#v, want %#v", got, want) + } + assertOwnershipEqual(t, got.Owner, want.Owner) +} + +func assertTaskSummaryMatchesTask(t *testing.T, got taskpkg.TaskSummary, want taskpkg.Task) { + t.Helper() + + if got.ID != want.ID || + got.Identifier != want.Identifier || + got.Scope != want.Scope || + got.WorkspaceID != want.WorkspaceID || + got.ParentTaskID != want.ParentTaskID || + got.NetworkChannel != want.NetworkChannel || + got.Title != want.Title || + got.Status != want.Status || + got.CreatedBy != want.CreatedBy || + got.Origin != want.Origin || + !got.CreatedAt.Equal(want.CreatedAt) || + !got.UpdatedAt.Equal(want.UpdatedAt) || + !got.ClosedAt.Equal(want.ClosedAt) { + t.Fatalf("task summary = %#v, want task %#v", got, want) + } + assertOwnershipEqual(t, got.Owner, want.Owner) +} + +func assertTaskRunEqual(t *testing.T, got taskpkg.TaskRun, want taskpkg.TaskRun) { + t.Helper() + + if got.ID != want.ID || + got.TaskID != want.TaskID || + got.Status != want.Status || + got.Attempt != want.Attempt || + got.SessionID != want.SessionID || + got.Origin != want.Origin || + got.IdempotencyKey != want.IdempotencyKey || + got.NetworkChannel != want.NetworkChannel || + !got.QueuedAt.Equal(want.QueuedAt) || + !got.ClaimedAt.Equal(want.ClaimedAt) || + !got.StartedAt.Equal(want.StartedAt) || + !got.EndedAt.Equal(want.EndedAt) || + got.Error != want.Error || + string(got.Result) != string(want.Result) { + t.Fatalf("task run = %#v, want %#v", got, want) + } + assertActorEqual(t, got.ClaimedBy, want.ClaimedBy) +} + +func assertOwnershipEqual(t *testing.T, got *taskpkg.Ownership, want *taskpkg.Ownership) { + t.Helper() + + switch { + case got == nil && want == nil: + return + case got == nil || want == nil: + t.Fatalf("ownership = %#v, want %#v", got, want) + case *got != *want: + t.Fatalf("ownership = %#v, want %#v", *got, *want) + } +} + +func assertActorEqual(t *testing.T, got *taskpkg.ActorIdentity, want *taskpkg.ActorIdentity) { + t.Helper() + + switch { + case got == nil && want == nil: + return + case got == nil || want == nil: + t.Fatalf("actor = %#v, want %#v", got, want) + case *got != *want: + t.Fatalf("actor = %#v, want %#v", *got, *want) + } +} + +func taskSummaryIDs(summaries []taskpkg.TaskSummary) []string { + ids := make([]string, 0, len(summaries)) + for _, summary := range summaries { + ids = append(ids, summary.ID) + } + sort.Strings(ids) + return ids +} + +func sqlNullStringForTest(value string) sql.NullString { + if value == "" { + return sql.NullString{} + } + return sql.NullString{String: value, Valid: true} +} diff --git a/internal/task/actors.go b/internal/task/actors.go new file mode 100644 index 000000000..adf271b95 --- /dev/null +++ b/internal/task/actors.go @@ -0,0 +1,142 @@ +package task + +import "fmt" + +// FullAccessAuthority returns the v1 broad task-domain authority granted to +// authenticated first-class task surfaces after ingress-level authentication +// and capability checks succeed. +func FullAccessAuthority() Authority { + return Authority{ + Read: true, + Write: true, + CreateGlobal: true, + CreateWorkspace: true, + } +} + +// DeriveHumanActorContext derives one trusted local-human actor context for +// CLI, web, HTTP, or UDS task ingress. +func DeriveHumanActorContext(actorRef string, originKind OriginKind, originRef string) (ActorContext, error) { + switch originKind.Normalize() { + case OriginKindCLI, OriginKindWeb, OriginKindUDS, OriginKindHTTP: + default: + return ActorContext{}, fmt.Errorf( + "%w: human task ingress requires cli, web, uds, or http origin, got %q", + ErrValidation, + originKind, + ) + } + return deriveActorContext(ActorKindHuman, actorRef, originKind, originRef) +} + +// DeriveAgentSessionActorContext derives one trusted agent-session actor +// context. The session ref becomes both the immutable actor ref and origin ref. +func DeriveAgentSessionActorContext(sessionRef string) (ActorContext, error) { + return deriveActorContext(ActorKindAgentSession, sessionRef, OriginKindAgentSession, sessionRef) +} + +// DeriveAutomationLinkedAgentSessionActorContext derives one trusted +// agent-session actor context for work created explicitly by an automation- +// launched session. The session remains the immutable actor while the origin is +// anchored to the automation activation that launched it. +func DeriveAutomationLinkedAgentSessionActorContext(sessionRef string, originRef string) (ActorContext, error) { + if originRef == "" { + originRef = sessionRef + } + return deriveActorContext(ActorKindAgentSession, sessionRef, OriginKindAutomation, originRef) +} + +// DeriveAutomationActorContext derives one trusted automation actor context. +// If originRef is empty, the actor ref is reused as the durable origin ref. +func DeriveAutomationActorContext(actorRef string, originRef string) (ActorContext, error) { + if originRef == "" { + originRef = actorRef + } + return deriveActorContext(ActorKindAutomation, actorRef, OriginKindAutomation, originRef) +} + +// DeriveExtensionActorContext derives one trusted extension actor context. If +// originRef is empty, the actor ref is reused as the durable origin ref. +func DeriveExtensionActorContext(actorRef string, originRef string) (ActorContext, error) { + if originRef == "" { + originRef = actorRef + } + return deriveActorContext(ActorKindExtension, actorRef, OriginKindExtension, originRef) +} + +// DeriveNetworkPeerActorContext derives one trusted network-peer actor +// context. If originRef is empty, the actor ref is reused as the durable origin +// ref so ingress layers may include peer or peer/channel details as needed. +func DeriveNetworkPeerActorContext(actorRef string, originRef string) (ActorContext, error) { + if originRef == "" { + originRef = actorRef + } + return deriveActorContext(ActorKindNetworkPeer, actorRef, OriginKindNetwork, originRef) +} + +// DeriveDaemonActorContext derives one trusted daemon-owned actor context. If +// originRef is empty, the actor ref is reused as the durable origin ref. +func DeriveDaemonActorContext(actorRef string, originRef string) (ActorContext, error) { + if originRef == "" { + originRef = actorRef + } + return deriveActorContext(ActorKindDaemon, actorRef, OriginKindDaemon, originRef) +} + +func deriveActorContext(actorKind ActorKind, actorRef string, originKind OriginKind, originRef string) (ActorContext, error) { + ctx := ActorContext{ + Actor: ActorIdentity{ + Kind: actorKind, + Ref: actorRef, + }, + Origin: Origin{ + Kind: originKind, + Ref: originRef, + }, + Authority: FullAccessAuthority(), + } + if err := ctx.Validate(); err != nil { + return ActorContext{}, err + } + return ctx, nil +} + +func validateActorOriginPair(actor ActorIdentity, origin Origin) error { + switch actor.Kind.Normalize() { + case ActorKindHuman: + switch origin.Kind.Normalize() { + case OriginKindCLI, OriginKindWeb, OriginKindUDS, OriginKindHTTP: + return nil + } + case ActorKindAgentSession: + switch origin.Kind.Normalize() { + case OriginKindAgentSession, OriginKindAutomation: + return nil + } + case ActorKindAutomation: + if origin.Kind.Normalize() == OriginKindAutomation { + return nil + } + case ActorKindExtension: + if origin.Kind.Normalize() == OriginKindExtension { + return nil + } + case ActorKindNetworkPeer: + if origin.Kind.Normalize() == OriginKindNetwork { + return nil + } + case ActorKindDaemon: + if origin.Kind.Normalize() == OriginKindDaemon { + return nil + } + default: + return fmt.Errorf("%w: actor.kind has unsupported value %q", ErrValidation, actor.Kind) + } + + return fmt.Errorf( + "%w: actor.kind %q is not allowed with origin.kind %q", + ErrValidation, + actor.Kind.Normalize(), + origin.Kind.Normalize(), + ) +} diff --git a/internal/task/doc.go b/internal/task/doc.go new file mode 100644 index 000000000..4b1a6d59a --- /dev/null +++ b/internal/task/doc.go @@ -0,0 +1,3 @@ +// Package task defines the canonical task-domain models, interfaces, limits, +// and validation helpers shared by persistence, runtime, and transport layers. +package task diff --git a/internal/task/errors.go b/internal/task/errors.go new file mode 100644 index 000000000..db391d673 --- /dev/null +++ b/internal/task/errors.go @@ -0,0 +1,38 @@ +package task + +import "errors" + +var ( + // ErrTaskNotFound reports that no persisted task matched the lookup. + ErrTaskNotFound = errors.New("task: task not found") + // ErrTaskRunNotFound reports that no persisted task run matched the lookup. + ErrTaskRunNotFound = errors.New("task: task run not found") + // ErrTaskRunIdempotencyNotFound reports that no persisted task-run idempotency record matched the lookup. + ErrTaskRunIdempotencyNotFound = errors.New("task: task run idempotency not found") + // ErrTaskDependencyNotFound reports that no persisted dependency edge matched the lookup. + ErrTaskDependencyNotFound = errors.New("task: task dependency not found") + // ErrTaskEventNotFound reports that no persisted task event matched the lookup. + ErrTaskEventNotFound = errors.New("task: task event not found") + // ErrValidation reports that a task-domain payload or state failed validation. + ErrValidation = errors.New("task: validation failed") + // ErrImmutableField reports that a caller attempted to change an immutable task field. + ErrImmutableField = errors.New("task: immutable field") + // ErrInvalidScopeBinding reports that a scope and workspace binding combination is invalid. + ErrInvalidScopeBinding = errors.New("task: invalid scope binding") + // ErrPayloadTooLarge reports that a JSON payload exceeded the task-domain size guardrails. + ErrPayloadTooLarge = errors.New("task: payload too large") + // ErrGraphLimitExceeded reports that a task hierarchy or dependency operation exceeded a bounded limit. + ErrGraphLimitExceeded = errors.New("task: graph limit exceeded") + // ErrCycleDetected reports that a dependency insert would introduce a cycle. + ErrCycleDetected = errors.New("task: dependency cycle detected") + // ErrInvalidStatusTransition reports that a task or run lifecycle transition is not allowed. + ErrInvalidStatusTransition = errors.New("task: invalid status transition") + // ErrSessionAlreadyBound reports that a run already owns a session binding. + ErrSessionAlreadyBound = errors.New("task: session already bound") + // ErrSessionAttachNotAllowed reports that a run cannot attach an existing session in its current state. + ErrSessionAttachNotAllowed = errors.New("task: session attach not allowed") + // ErrStaleNetworkChannel reports that a stored task or run channel no longer passes the active validator. + ErrStaleNetworkChannel = errors.New("task: stale network channel") + // ErrPermissionDenied reports that the resolved actor context lacks authority for the requested task action. + ErrPermissionDenied = errors.New("task: permission denied") +) diff --git a/internal/task/interfaces.go b/internal/task/interfaces.go new file mode 100644 index 000000000..fbffd7750 --- /dev/null +++ b/internal/task/interfaces.go @@ -0,0 +1,86 @@ +package task + +import ( + "context" +) + +// Manager is the task-domain authority for task and run lifecycle operations. +type Manager interface { + CreateTask(ctx context.Context, spec CreateTask, actor ActorContext) (*Task, error) + CreateChildTask(ctx context.Context, parentTaskID string, spec CreateTask, actor ActorContext) (*Task, error) + UpdateTask(ctx context.Context, id string, patch TaskPatch, actor ActorContext) (*Task, error) + CancelTask(ctx context.Context, id string, req CancelTask, actor ActorContext) (*Task, error) + + AddDependency(ctx context.Context, spec AddDependency, actor ActorContext) error + RemoveDependency(ctx context.Context, taskID string, dependsOnID string, actor ActorContext) error + + EnqueueRun(ctx context.Context, spec EnqueueRun, actor ActorContext) (*TaskRun, error) + ClaimRun(ctx context.Context, runID string, claim ClaimRun, actor ActorContext) (*TaskRun, error) + StartRun(ctx context.Context, runID string, req StartRun, actor ActorContext) (*TaskRun, error) + AttachRunSession(ctx context.Context, runID string, sessionID string, actor ActorContext) (*TaskRun, error) + CompleteRun(ctx context.Context, runID string, result RunResult, actor ActorContext) (*TaskRun, error) + FailRun(ctx context.Context, runID string, failure RunFailure, actor ActorContext) (*TaskRun, error) + CancelRun(ctx context.Context, runID string, req CancelRun, actor ActorContext) (*TaskRun, error) + + GetTask(ctx context.Context, id string, actor ActorContext) (*TaskView, error) + ListTaskRuns(ctx context.Context, taskID string, query TaskRunQuery, actor ActorContext) ([]TaskRun, error) + ListTasks(ctx context.Context, query TaskQuery, actor ActorContext) ([]TaskSummary, error) +} + +// TaskStore is the persistence surface for durable task records. +type TaskStore interface { + CreateTask(ctx context.Context, task Task) error + UpdateTask(ctx context.Context, task Task) error + GetTask(ctx context.Context, id string) (Task, error) + ListTasks(ctx context.Context, query TaskQuery) ([]TaskSummary, error) + CountDirectChildren(ctx context.Context, parentTaskID string) (int, error) +} + +// DependencyStore is the persistence surface for durable dependency edges. +type DependencyStore interface { + CreateDependency(ctx context.Context, dependency TaskDependency) error + DeleteDependency(ctx context.Context, taskID string, dependsOnID string) error + ListDependencies(ctx context.Context, taskID string) ([]TaskDependency, error) + ListDependents(ctx context.Context, dependsOnTaskID string) ([]TaskDependency, error) + CountDependencies(ctx context.Context, taskID string) (int, error) + HasDependencyPath(ctx context.Context, fromTaskID string, toTaskID string) (bool, error) +} + +// RunStore is the persistence surface for durable task-run records. +type RunStore interface { + CreateTaskRun(ctx context.Context, run TaskRun) error + UpdateTaskRun(ctx context.Context, run TaskRun) error + GetTaskRun(ctx context.Context, id string) (TaskRun, error) + ListTaskRuns(ctx context.Context, query TaskRunQuery) ([]TaskRun, error) + ListTaskRunsByStatus(ctx context.Context, statuses []TaskRunStatus) ([]TaskRun, error) + CountActiveSessionBindings(ctx context.Context, sessionID string) (int, error) +} + +// EventStore is the persistence surface for immutable task audit events. +type EventStore interface { + CreateTaskEvent(ctx context.Context, event TaskEvent) error + ListTaskEvents(ctx context.Context, query TaskEventQuery) ([]TaskEvent, error) +} + +// IdempotencyStore is the persistence surface for non-human run idempotency tracking. +type IdempotencyStore interface { + GetTaskRunByIdempotencyKey(ctx context.Context, key string, origin Origin) (TaskRun, error) + SaveTaskRunIdempotency(ctx context.Context, record TaskRunIdempotency) error +} + +// Store composes the task-domain persistence surfaces consumed by the manager. +type Store interface { + TaskStore + DependencyStore + RunStore + EventStore + IdempotencyStore +} + +// SessionExecutor is the injected runtime bridge used to start, attach, and stop task sessions. +type SessionExecutor interface { + StartTaskSession(ctx context.Context, spec StartTaskSession) (*SessionRef, error) + AttachTaskSession(ctx context.Context, runID string, sessionID string) (*SessionRef, error) + RequestTaskStop(ctx context.Context, sessionID string, reason StopReason) error + ForceTaskStop(ctx context.Context, sessionID string, reason StopReason) error +} diff --git a/internal/task/interfaces_integration_test.go b/internal/task/interfaces_integration_test.go new file mode 100644 index 000000000..517f616ca --- /dev/null +++ b/internal/task/interfaces_integration_test.go @@ -0,0 +1,120 @@ +//go:build integration + +package task_test + +import ( + "context" + "testing" + + taskpkg "github.com/pedronauck/agh/internal/task" +) + +type fakeStore struct{} + +func (fakeStore) CreateTask(context.Context, taskpkg.Task) error { return nil } + +func (fakeStore) UpdateTask(context.Context, taskpkg.Task) error { return nil } + +func (fakeStore) GetTask(context.Context, string) (taskpkg.Task, error) { return taskpkg.Task{}, nil } + +func (fakeStore) ListTasks(context.Context, taskpkg.TaskQuery) ([]taskpkg.TaskSummary, error) { + return []taskpkg.TaskSummary{{ID: "task-1", Title: "bootstrap", Scope: taskpkg.ScopeGlobal}}, nil +} + +func (fakeStore) CountDirectChildren(context.Context, string) (int, error) { return 0, nil } + +func (fakeStore) CreateDependency(context.Context, taskpkg.TaskDependency) error { return nil } + +func (fakeStore) DeleteDependency(context.Context, string, string) error { return nil } + +func (fakeStore) ListDependencies(context.Context, string) ([]taskpkg.TaskDependency, error) { + return []taskpkg.TaskDependency{{TaskID: "task-1", DependsOnTaskID: "task-0", Kind: taskpkg.DependencyKindBlocks}}, nil +} + +func (fakeStore) ListDependents(context.Context, string) ([]taskpkg.TaskDependency, error) { + return nil, nil +} + +func (fakeStore) CountDependencies(context.Context, string) (int, error) { return 1, nil } + +func (fakeStore) HasDependencyPath(context.Context, string, string) (bool, error) { return false, nil } + +func (fakeStore) CreateTaskRun(context.Context, taskpkg.TaskRun) error { return nil } + +func (fakeStore) UpdateTaskRun(context.Context, taskpkg.TaskRun) error { return nil } + +func (fakeStore) GetTaskRun(context.Context, string) (taskpkg.TaskRun, error) { + return taskpkg.TaskRun{}, nil +} + +func (fakeStore) ListTaskRuns(context.Context, taskpkg.TaskRunQuery) ([]taskpkg.TaskRun, error) { + return []taskpkg.TaskRun{{ID: "run-1", TaskID: "task-1", Status: taskpkg.TaskRunStatusQueued, Attempt: 1}}, nil +} + +func (fakeStore) ListTaskRunsByStatus(context.Context, []taskpkg.TaskRunStatus) ([]taskpkg.TaskRun, error) { + return []taskpkg.TaskRun{{ID: "run-1", TaskID: "task-1", Status: taskpkg.TaskRunStatusQueued, Attempt: 1}}, nil +} + +func (fakeStore) CountActiveSessionBindings(context.Context, string) (int, error) { return 0, nil } + +func (fakeStore) CreateTaskEvent(context.Context, taskpkg.TaskEvent) error { return nil } + +func (fakeStore) ListTaskEvents(context.Context, taskpkg.TaskEventQuery) ([]taskpkg.TaskEvent, error) { + return []taskpkg.TaskEvent{{ID: "evt-1", TaskID: "task-1", EventType: "task.created"}}, nil +} + +func (fakeStore) GetTaskRunByIdempotencyKey(context.Context, string, taskpkg.Origin) (taskpkg.TaskRun, error) { + return taskpkg.TaskRun{}, nil +} + +func (fakeStore) SaveTaskRunIdempotency(context.Context, taskpkg.TaskRunIdempotency) error { + return nil +} + +type fakeSessionExecutor struct{} + +func (fakeSessionExecutor) StartTaskSession(context.Context, taskpkg.StartTaskSession) (*taskpkg.SessionRef, error) { + return &taskpkg.SessionRef{SessionID: "sess-1"}, nil +} + +func (fakeSessionExecutor) AttachTaskSession(context.Context, string, string) (*taskpkg.SessionRef, error) { + return &taskpkg.SessionRef{SessionID: "sess-1"}, nil +} + +func (fakeSessionExecutor) RequestTaskStop(context.Context, string, taskpkg.StopReason) error { + return nil +} + +func (fakeSessionExecutor) ForceTaskStop(context.Context, string, taskpkg.StopReason) error { + return nil +} + +type fakeCoordinator struct { + store taskpkg.Store + sessions taskpkg.SessionExecutor +} + +func (c fakeCoordinator) compose(ctx context.Context) error { + if _, err := c.store.ListTasks(ctx, taskpkg.TaskQuery{Limit: 1}); err != nil { + return err + } + if err := c.sessions.RequestTaskStop(ctx, "sess-1", taskpkg.StopReasonCancellation); err != nil { + return err + } + return nil +} + +var _ taskpkg.Store = (*fakeStore)(nil) +var _ taskpkg.SessionExecutor = (*fakeSessionExecutor)(nil) + +func TestTaskDomainInterfacesComposeWithoutSessionImport(t *testing.T) { + t.Parallel() + + coordinator := fakeCoordinator{ + store: fakeStore{}, + sessions: fakeSessionExecutor{}, + } + if err := coordinator.compose(context.Background()); err != nil { + t.Fatalf("compose() error = %v", err) + } +} diff --git a/internal/task/limits.go b/internal/task/limits.go new file mode 100644 index 000000000..f38a6b61f --- /dev/null +++ b/internal/task/limits.go @@ -0,0 +1,81 @@ +package task + +const ( + // MaxMetadataBytes caps task metadata payloads at 16 KiB. + MaxMetadataBytes = 16 * 1024 + // MaxPayloadBytes caps persisted event-style JSON payloads at 64 KiB. + MaxPayloadBytes = 64 * 1024 + // MaxResultBytes caps persisted run results at 64 KiB. + MaxResultBytes = 64 * 1024 + // MaxHierarchyDepth caps parent/child nesting at eight levels. + MaxHierarchyDepth = 8 + // MaxDependencyCount caps dependency edges per task at thirty-two. + MaxDependencyCount = 32 + // MaxDirectChildren caps direct child tasks per parent at sixty-four. + MaxDirectChildren = 64 +) + +const ( + // TaskFieldCreatedBy identifies the immutable creator identity field. + TaskFieldCreatedBy = "created_by" + // TaskFieldOrigin identifies the immutable technical ingress field. + TaskFieldOrigin = "origin" + // TaskFieldScope identifies the immutable task scope field. + TaskFieldScope = "scope" + // TaskFieldWorkspaceID identifies the immutable workspace binding field. + TaskFieldWorkspaceID = "workspace_id" + // TaskFieldParentTaskID identifies the immutable parent-task linkage field. + TaskFieldParentTaskID = "parent_task_id" + // TaskFieldTitle identifies the mutable task title field. + TaskFieldTitle = "title" + // TaskFieldDescription identifies the mutable task description field. + TaskFieldDescription = "description" + // TaskFieldMetadata identifies the mutable task metadata field. + TaskFieldMetadata = "metadata" + // TaskFieldNetworkChannel identifies the mutable network channel field. + TaskFieldNetworkChannel = "network_channel" + // TaskFieldOwner identifies the mutable ownership field. + TaskFieldOwner = "owner" +) + +// ImmutableTaskFields returns the canonical immutable task field names. +func ImmutableTaskFields() []string { + return []string{ + TaskFieldCreatedBy, + TaskFieldOrigin, + TaskFieldScope, + TaskFieldWorkspaceID, + TaskFieldParentTaskID, + } +} + +// MutableTaskFields returns the canonical mutable task field names. +func MutableTaskFields() []string { + return []string{ + TaskFieldTitle, + TaskFieldDescription, + TaskFieldMetadata, + TaskFieldNetworkChannel, + TaskFieldOwner, + } +} + +// IsImmutableTaskField reports whether the supplied field name is immutable after task creation. +func IsImmutableTaskField(field string) bool { + switch field { + case TaskFieldCreatedBy, TaskFieldOrigin, TaskFieldScope, TaskFieldWorkspaceID, TaskFieldParentTaskID: + return true + default: + return false + } +} + +// IsMutableTaskField reports whether the supplied field name is directly mutable on a task. +func IsMutableTaskField(field string) bool { + switch field { + case TaskFieldTitle, TaskFieldDescription, TaskFieldMetadata, TaskFieldNetworkChannel, TaskFieldOwner: + return true + default: + return false + } +} diff --git a/internal/task/manager.go b/internal/task/manager.go new file mode 100644 index 000000000..59daace9b --- /dev/null +++ b/internal/task/manager.go @@ -0,0 +1,2075 @@ +package task + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/pedronauck/agh/internal/store" +) + +const ( + taskEventCreated = "task.created" + taskEventUpdated = "task.updated" + taskEventCancelled = "task.cancelled" + taskEventChildCreated = "task.child_created" + taskEventDependencyAdded = "task.dependency_added" + taskEventDependencyRemoved = "task.dependency_removed" + taskEventRunEnqueued = "task.run_enqueued" + taskEventRunClaimed = "task.run_claimed" + taskEventRunStarting = "task.run_starting" + taskEventRunSessionBound = "task.run_session_bound" + taskEventRunStarted = "task.run_started" + taskEventRunCompleted = "task.run_completed" + taskEventRunFailed = "task.run_failed" + taskEventRunCancelled = "task.run_cancelled" + taskEventRunForceStopped = "task.run_force_stopped" + taskEventRunRecovered = "task.run_recovered" + taskEventRunRejected = "task.run_rejected" +) + +// Option customizes TaskManager construction. +type Option func(*managerOptions) + +type managerOptions struct { + store Store + sessions SessionExecutor + channelValidator func(string) error + now func() time.Time + newID func(prefix string) string + cancelGracePeriod time.Duration +} + +// TaskManager centralizes canonical task-domain creation, mutation, read, and +// graph-management rules above the persistence layer. +type TaskManager struct { + store Store + sessions SessionExecutor + channelValidator func(string) error + now func() time.Time + newID func(prefix string) string + cancelGracePeriod time.Duration +} + +var _ Manager = (*TaskManager)(nil) + +// WithStore injects the durable task-domain store consumed by the manager. +func WithStore(store Store) Option { + return func(opts *managerOptions) { + opts.store = store + } +} + +// WithSessionExecutor injects the runtime session bridge used by later +// task-run lifecycle operations. +func WithSessionExecutor(sessions SessionExecutor) Option { + return func(opts *managerOptions) { + opts.sessions = sessions + } +} + +// WithNetworkChannelValidator injects the active channel validator used to +// check task and run bindings without coupling the task package to the network +// runtime implementation. +func WithNetworkChannelValidator(validator func(string) error) Option { + return func(opts *managerOptions) { + opts.channelValidator = validator + } +} + +// WithManagerNow overrides the manager clock for deterministic tests. +func WithManagerNow(now func() time.Time) Option { + return func(opts *managerOptions) { + opts.now = now + } +} + +// WithIDGenerator overrides identifier generation for deterministic tests. +func WithIDGenerator(newID func(prefix string) string) Option { + return func(opts *managerOptions) { + opts.newID = newID + } +} + +// WithCancelGracePeriod overrides the cooperative-stop grace period used before +// requesting forced session termination during task-driven cancellation. +func WithCancelGracePeriod(timeout time.Duration) Option { + return func(opts *managerOptions) { + opts.cancelGracePeriod = timeout + } +} + +// NewManager constructs one task-domain manager with the supplied dependencies. +func NewManager(opts ...Option) (*TaskManager, error) { + options := managerOptions{ + now: func() time.Time { + return time.Now().UTC() + }, + newID: store.NewID, + } + for _, opt := range opts { + if opt != nil { + opt(&options) + } + } + if options.store == nil { + return nil, fmt.Errorf("task: manager store is required") + } + if options.now == nil { + return nil, fmt.Errorf("task: manager clock is required") + } + if options.newID == nil { + return nil, fmt.Errorf("task: manager id generator is required") + } + if options.cancelGracePeriod < 0 { + return nil, fmt.Errorf("task: manager cancel grace period must be zero or positive") + } + + return &TaskManager{ + store: options.store, + sessions: options.sessions, + channelValidator: options.channelValidator, + now: options.now, + newID: options.newID, + cancelGracePeriod: options.cancelGracePeriod, + }, nil +} + +// CreateTask derives one canonical task record from trusted actor context and +// persists the corresponding immutable audit event. +func (m *TaskManager) CreateTask(ctx context.Context, spec CreateTask, actor ActorContext) (*Task, error) { + if err := requireCreateAuthority(actor, spec.Scope); err != nil { + return nil, err + } + + normalizedSpec, err := normalizeCreateTaskSpec(spec) + if err != nil { + return nil, err + } + if err := m.validateParentConstraints(ctx, normalizedSpec); err != nil { + return nil, err + } + if err := m.validateNetworkChannel("create_task.network_channel", normalizedSpec.NetworkChannel); err != nil { + return nil, err + } + + now := m.now().UTC() + record := Task{ + ID: normalizedSpec.ID, + Identifier: normalizedSpec.Identifier, + Scope: normalizedSpec.Scope, + WorkspaceID: normalizedSpec.WorkspaceID, + ParentTaskID: normalizedSpec.ParentTaskID, + NetworkChannel: normalizedSpec.NetworkChannel, + Title: normalizedSpec.Title, + Description: normalizedSpec.Description, + Status: TaskStatusReady, + Owner: cloneOwnership(normalizedSpec.Owner), + CreatedBy: actor.Actor, + Origin: actor.Origin, + CreatedAt: now, + UpdatedAt: now, + Metadata: cloneRawJSON(normalizedSpec.Metadata), + } + if strings.TrimSpace(record.ID) == "" { + record.ID = m.newID("task") + } + if err := record.Validate(); err != nil { + return nil, err + } + if err := m.store.CreateTask(ctx, record); err != nil { + return nil, err + } + if err := m.recordTaskEvent(ctx, record.ID, "", taskEventCreated, actor, createdTaskPayload{ + Scope: record.Scope, + WorkspaceID: record.WorkspaceID, + ParentTaskID: record.ParentTaskID, + Status: record.Status, + NetworkChannel: record.NetworkChannel, + Owner: cloneOwnership(record.Owner), + }); err != nil { + return nil, err + } + + return &record, nil +} + +// CreateChildTask creates one child task beneath the supplied parent and emits +// an additional parent-scoped audit event. +func (m *TaskManager) CreateChildTask(ctx context.Context, parentTaskID string, spec CreateTask, actor ActorContext) (*Task, error) { + trimmedParentID := strings.TrimSpace(parentTaskID) + if trimmedParentID == "" { + return nil, fmt.Errorf("%w: child parent task id is required", ErrValidation) + } + if strings.TrimSpace(spec.ParentTaskID) != "" && strings.TrimSpace(spec.ParentTaskID) != trimmedParentID { + return nil, fmt.Errorf("%w: create_task.parent_task_id must match child parent task id", ErrValidation) + } + + spec.ParentTaskID = trimmedParentID + child, err := m.CreateTask(ctx, spec, actor) + if err != nil { + return nil, err + } + if err := m.recordTaskEvent(ctx, trimmedParentID, "", taskEventChildCreated, actor, childCreatedTaskPayload{ + ChildTaskID: child.ID, + ChildScope: child.Scope, + ChildWorkspaceID: child.WorkspaceID, + }); err != nil { + return nil, err + } + return child, nil +} + +// UpdateTask applies one mutable patch while preserving immutable identity and +// structural fields under manager control. +func (m *TaskManager) UpdateTask(ctx context.Context, id string, patch TaskPatch, actor ActorContext) (*Task, error) { + if err := requireWriteAuthority(actor); err != nil { + return nil, err + } + + trimmedID := strings.TrimSpace(id) + if trimmedID == "" { + return nil, fmt.Errorf("%w: task id is required", ErrValidation) + } + normalizedPatch, err := normalizeTaskPatch(patch) + if err != nil { + return nil, err + } + if normalizedPatch.NetworkChannel != nil { + if err := m.validateNetworkChannel("task_patch.network_channel", *normalizedPatch.NetworkChannel); err != nil { + return nil, err + } + } + + current, err := m.store.GetTask(ctx, trimmedID) + if err != nil { + return nil, err + } + + updated := current + changedFields := make([]string, 0, len(MutableTaskFields())) + + if normalizedPatch.Title != nil && updated.Title != *normalizedPatch.Title { + updated.Title = *normalizedPatch.Title + changedFields = append(changedFields, TaskFieldTitle) + } + if normalizedPatch.Description != nil && updated.Description != *normalizedPatch.Description { + updated.Description = *normalizedPatch.Description + changedFields = append(changedFields, TaskFieldDescription) + } + if normalizedPatch.Metadata != nil && !sameRawJSON(updated.Metadata, *normalizedPatch.Metadata) { + updated.Metadata = cloneRawJSON(*normalizedPatch.Metadata) + changedFields = append(changedFields, TaskFieldMetadata) + } + if normalizedPatch.NetworkChannel != nil && updated.NetworkChannel != *normalizedPatch.NetworkChannel { + updated.NetworkChannel = *normalizedPatch.NetworkChannel + changedFields = append(changedFields, TaskFieldNetworkChannel) + } + if normalizedPatch.Owner != nil && !sameOwnership(updated.Owner, normalizedPatch.Owner) { + updated.Owner = cloneOwnership(normalizedPatch.Owner) + changedFields = append(changedFields, TaskFieldOwner) + } + if normalizedPatch.ClearOwner && updated.Owner != nil { + updated.Owner = nil + changedFields = append(changedFields, TaskFieldOwner) + } + if len(changedFields) == 0 { + return ¤t, nil + } + + dependencies, err := m.store.ListDependencies(ctx, trimmedID) + if err != nil { + return nil, err + } + runs, err := m.store.ListTaskRuns(ctx, TaskRunQuery{TaskID: trimmedID}) + if err != nil { + return nil, err + } + + canonicalStatus, err := m.canonicalTaskStatus(ctx, current, dependencies, runs) + if err != nil { + return nil, err + } + updated.Status = canonicalStatus + updated.UpdatedAt = m.now().UTC() + if err := m.store.UpdateTask(ctx, updated); err != nil { + return nil, err + } + if err := m.recordTaskEvent(ctx, updated.ID, "", taskEventUpdated, actor, updatedTaskPayload{ + ChangedFields: append([]string(nil), changedFields...), + Status: updated.Status, + }); err != nil { + return nil, err + } + + return &updated, nil +} + +// CancelTask propagates manager-owned cancellation through the target task, +// affected runs, and all non-terminal descendants. +func (m *TaskManager) CancelTask(ctx context.Context, id string, req CancelTask, actor ActorContext) (*Task, error) { + if err := requireWriteAuthority(actor); err != nil { + return nil, err + } + + trimmedID := strings.TrimSpace(id) + if trimmedID == "" { + return nil, fmt.Errorf("%w: task id is required", ErrValidation) + } + normalizedReq, err := normalizeCancelTask(req) + if err != nil { + return nil, err + } + + tree, err := m.collectTaskTree(ctx, trimmedID) + if err != nil { + return nil, err + } + if len(tree) == 0 { + return nil, ErrTaskNotFound + } + + root := tree[0] + rootRuns, err := m.store.ListTaskRuns(ctx, TaskRunQuery{TaskID: root.ID}) + if err != nil { + return nil, err + } + rootDeps, err := m.store.ListDependencies(ctx, root.ID) + if err != nil { + return nil, err + } + rootStatus, err := m.canonicalTaskStatus(ctx, root, rootDeps, rootRuns) + if err != nil { + return nil, err + } + if isTerminalTaskStatus(rootStatus) && rootStatus != TaskStatusCancelled && !hasOpenRun(rootRuns) { + return nil, fmt.Errorf("%w: task %q cannot transition from %q to %q", ErrInvalidStatusTransition, root.ID, rootStatus, TaskStatusCancelled) + } + + cancelledRoot := root + for idx, record := range tree { + runs, err := m.store.ListTaskRuns(ctx, TaskRunQuery{TaskID: record.ID}) + if err != nil { + return nil, err + } + dependencies, err := m.store.ListDependencies(ctx, record.ID) + if err != nil { + return nil, err + } + status, err := m.canonicalTaskStatus(ctx, record, dependencies, runs) + if err != nil { + return nil, err + } + record.Status = status + + if idx > 0 && isTerminalTaskStatus(status) { + if record.ID == trimmedID { + cancelledRoot = record + } + continue + } + + propagatedFromTaskID := "" + if idx > 0 { + propagatedFromTaskID = trimmedID + } + + cancelledRunIDs := make([]string, 0) + for _, run := range runs { + if isTerminalRunStatus(run.Status) { + continue + } + cancelledRun, err := m.cancelRunRecord(ctx, record, run, CancelRun(normalizedReq), actor, cancelRunOptions{ + propagatedFromTaskID: propagatedFromTaskID, + reconcileTask: false, + }) + if err != nil { + return nil, err + } + cancelledRunIDs = append(cancelledRunIDs, cancelledRun.ID) + } + + if status.Normalize() == TaskStatusCancelled && len(cancelledRunIDs) == 0 { + if record.ID == trimmedID { + cancelledRoot = record + } + continue + } + + record.Status = TaskStatusCancelled + record.UpdatedAt = m.now().UTC() + record.ClosedAt = record.UpdatedAt + if err := m.store.UpdateTask(ctx, record); err != nil { + return nil, err + } + if err := m.recordTaskEvent(ctx, record.ID, "", taskEventCancelled, actor, cancelledTaskPayload{ + Reason: normalizedReq.Reason, + Metadata: cloneRawJSON(normalizedReq.Metadata), + Status: record.Status, + PropagatedFromTaskID: propagatedFromTaskID, + CancelledRunIDs: append([]string(nil), cancelledRunIDs...), + }); err != nil { + return nil, err + } + if err := m.reconcileDependentTasks(ctx, record.ID, map[string]struct{}{record.ID: {}}); err != nil { + return nil, err + } + + if record.ID == trimmedID { + cancelledRoot = record + } + } + + return &cancelledRoot, nil +} + +// GetTask returns one expanded task view after enforcing read authority. +func (m *TaskManager) GetTask(ctx context.Context, id string, actor ActorContext) (*TaskView, error) { + if err := requireReadAuthority(actor); err != nil { + return nil, err + } + + trimmedID := strings.TrimSpace(id) + if trimmedID == "" { + return nil, fmt.Errorf("%w: task id is required", ErrValidation) + } + + record, err := m.store.GetTask(ctx, trimmedID) + if err != nil { + return nil, err + } + + children, err := m.store.ListTasks(ctx, TaskQuery{ParentTaskID: trimmedID}) + if err != nil { + return nil, err + } + dependencies, err := m.store.ListDependencies(ctx, trimmedID) + if err != nil { + return nil, err + } + runs, err := m.store.ListTaskRuns(ctx, TaskRunQuery{TaskID: trimmedID}) + if err != nil { + return nil, err + } + events, err := m.store.ListTaskEvents(ctx, TaskEventQuery{TaskID: trimmedID}) + if err != nil { + return nil, err + } + + view := &TaskView{ + Task: record, + Children: children, + Dependencies: dependencies, + Runs: runs, + Events: events, + } + view.Task.Status, err = m.canonicalTaskStatus(ctx, record, dependencies, runs) + if err != nil { + return nil, err + } + return view, nil +} + +// ListTaskRuns returns task runs for one task after enforcing read authority and +// task existence. +func (m *TaskManager) ListTaskRuns(ctx context.Context, taskID string, query TaskRunQuery, actor ActorContext) ([]TaskRun, error) { + if err := requireReadAuthority(actor); err != nil { + return nil, err + } + + trimmedID := strings.TrimSpace(taskID) + if trimmedID == "" { + return nil, fmt.Errorf("%w: task id is required", ErrValidation) + } + + if _, err := m.store.GetTask(ctx, trimmedID); err != nil { + return nil, err + } + + normalizedQuery := query + normalizedQuery.TaskID = trimmedID + return m.store.ListTaskRuns(ctx, normalizedQuery) +} + +// ListTasks returns task summaries that satisfy the supplied query filters +// after enforcing read authority. +func (m *TaskManager) ListTasks(ctx context.Context, query TaskQuery, actor ActorContext) ([]TaskSummary, error) { + if err := requireReadAuthority(actor); err != nil { + return nil, err + } + return m.store.ListTasks(ctx, query) +} + +// AddDependency adds one dependency edge through the manager, reconciles the +// task status, and records the canonical audit event. +func (m *TaskManager) AddDependency(ctx context.Context, spec AddDependency, actor ActorContext) error { + if err := requireWriteAuthority(actor); err != nil { + return err + } + + normalizedSpec, err := normalizeAddDependencySpec(spec) + if err != nil { + return err + } + if err := m.store.CreateDependency(ctx, TaskDependency{ + TaskID: normalizedSpec.TaskID, + DependsOnTaskID: normalizedSpec.DependsOnTaskID, + Kind: normalizedSpec.Kind, + }); err != nil { + return err + } + + record, err := m.reconcileTaskCascade(ctx, normalizedSpec.TaskID) + if err != nil { + return err + } + return m.recordTaskEvent(ctx, normalizedSpec.TaskID, "", taskEventDependencyAdded, actor, dependencyTaskPayload{ + DependsOnTaskID: normalizedSpec.DependsOnTaskID, + Kind: normalizedSpec.Kind, + Status: record.Status, + }) +} + +// RemoveDependency deletes one dependency edge through the manager, reconciles +// the task status, and records the canonical audit event. +func (m *TaskManager) RemoveDependency(ctx context.Context, taskID string, dependsOnID string, actor ActorContext) error { + if err := requireWriteAuthority(actor); err != nil { + return err + } + + trimmedTaskID := strings.TrimSpace(taskID) + if trimmedTaskID == "" { + return fmt.Errorf("%w: task id is required", ErrValidation) + } + trimmedDependsOnID := strings.TrimSpace(dependsOnID) + if trimmedDependsOnID == "" { + return fmt.Errorf("%w: depends_on_task_id is required", ErrValidation) + } + + if err := m.store.DeleteDependency(ctx, trimmedTaskID, trimmedDependsOnID); err != nil { + return err + } + + record, err := m.reconcileTaskCascade(ctx, trimmedTaskID) + if err != nil { + return err + } + return m.recordTaskEvent(ctx, trimmedTaskID, "", taskEventDependencyRemoved, actor, dependencyTaskPayload{ + DependsOnTaskID: trimmedDependsOnID, + Kind: DependencyKindBlocks, + Status: record.Status, + }) +} + +// EnqueueRun persists one new queue-first task run under manager authority. +func (m *TaskManager) EnqueueRun(ctx context.Context, spec EnqueueRun, actor ActorContext) (*TaskRun, error) { + if err := requireWriteAuthority(actor); err != nil { + return nil, err + } + + normalizedSpec, err := normalizeEnqueueRunSpec(spec) + if err != nil { + return nil, err + } + if err := requireLifecycleIdempotency(actor, normalizedSpec.IdempotencyKey, "enqueue_run"); err != nil { + return nil, err + } + if err := m.validateNetworkChannel("enqueue_run.network_channel", normalizedSpec.NetworkChannel); err != nil { + return nil, err + } + + taskRecord, err := m.store.GetTask(ctx, normalizedSpec.TaskID) + if err != nil { + return nil, err + } + if taskRecord.Status.Normalize() == TaskStatusCancelled { + return nil, fmt.Errorf("%w: task %q is cancelled", ErrInvalidStatusTransition, taskRecord.ID) + } + + if existing, err := m.lookupIdempotentRun(ctx, normalizedSpec.IdempotencyKey, actor.Origin, normalizedSpec.TaskID); err != nil { + return nil, err + } else if existing != nil { + return existing, nil + } + + existingRuns, err := m.store.ListTaskRuns(ctx, TaskRunQuery{TaskID: normalizedSpec.TaskID}) + if err != nil { + return nil, err + } + run := TaskRun{ + ID: m.newID("run"), + TaskID: normalizedSpec.TaskID, + Status: TaskRunStatusQueued, + Attempt: nextRunAttempt(existingRuns), + Origin: actor.Origin, + IdempotencyKey: normalizedSpec.IdempotencyKey, + NetworkChannel: resolvedRunChannel(normalizedSpec.NetworkChannel, taskRecord.NetworkChannel), + QueuedAt: m.now().UTC(), + } + if err := m.store.CreateTaskRun(ctx, run); err != nil { + return nil, err + } + if err := m.saveRunIdempotency(ctx, run, actor.Origin); err != nil { + return nil, err + } + + reconciledTask, err := m.reconcileTaskCascade(ctx, normalizedSpec.TaskID) + if err != nil { + return nil, err + } + if err := m.recordTaskEvent(ctx, run.TaskID, run.ID, taskEventRunEnqueued, actor, runEnqueuedPayload{ + Attempt: run.Attempt, + Status: run.Status, + TaskStatus: reconciledTask.Status, + NetworkChannel: run.NetworkChannel, + IdempotencyKey: run.IdempotencyKey, + }); err != nil { + return nil, err + } + + return &run, nil +} + +// ClaimRun transitions one queued run into the claimed state. +func (m *TaskManager) ClaimRun(ctx context.Context, runID string, claim ClaimRun, actor ActorContext) (*TaskRun, error) { + if err := requireWriteAuthority(actor); err != nil { + return nil, err + } + + normalizedClaim, err := normalizeClaimRun(claim) + if err != nil { + return nil, err + } + if err := requireLifecycleIdempotency(actor, normalizedClaim.IdempotencyKey, "claim_run"); err != nil { + return nil, err + } + + run, taskRecord, err := m.loadRunWithTask(ctx, runID) + if err != nil { + return nil, err + } + if err := m.ensureTaskExecutable(ctx, taskRecord); err != nil { + return nil, err + } + if err := requireRunTransition(run, TaskRunStatusClaimed); err != nil { + return nil, err + } + + run.Status = TaskRunStatusClaimed + run.ClaimedBy = &ActorIdentity{Kind: actor.Actor.Kind, Ref: actor.Actor.Ref} + run.ClaimedAt = m.now().UTC() + if err := m.store.UpdateTaskRun(ctx, run); err != nil { + return nil, err + } + + reconciledTask, err := m.reconcileTaskCascade(ctx, run.TaskID) + if err != nil { + return nil, err + } + if err := m.recordTaskEvent(ctx, run.TaskID, run.ID, taskEventRunClaimed, actor, runClaimedPayload{ + Status: run.Status, + TaskStatus: reconciledTask.Status, + ClaimedBy: *run.ClaimedBy, + }); err != nil { + return nil, err + } + + return &run, nil +} + +// StartRun transitions one claimed or starting run into active execution. +func (m *TaskManager) StartRun(ctx context.Context, runID string, req StartRun, actor ActorContext) (*TaskRun, error) { + if err := requireWriteAuthority(actor); err != nil { + return nil, err + } + + normalizedReq, err := normalizeStartRun(req) + if err != nil { + return nil, err + } + if err := requireLifecycleIdempotency(actor, normalizedReq.IdempotencyKey, "start_run"); err != nil { + return nil, err + } + + run, taskRecord, err := m.loadRunWithTask(ctx, runID) + if err != nil { + return nil, err + } + if err := m.ensureTaskExecutable(ctx, taskRecord); err != nil { + return nil, err + } + if err := m.validateRunChannelUsable(ctx, taskRecord, run, actor, "start"); err != nil { + return nil, err + } + + switch run.Status.Normalize() { + case TaskRunStatusClaimed: + if err := m.requireSessionExecutor("start run"); err != nil { + return nil, err + } + + run.Status = TaskRunStatusStarting + if err := m.store.UpdateTaskRun(ctx, run); err != nil { + return nil, err + } + + startingTask, err := m.reconcileTaskCascade(ctx, run.TaskID) + if err != nil { + return nil, err + } + if err := m.recordTaskEvent(ctx, run.TaskID, run.ID, taskEventRunStarting, actor, runTransitionPayload{ + Status: run.Status, + TaskStatus: startingTask.Status, + SessionID: run.SessionID, + }); err != nil { + return nil, err + } + + sessionRef, err := m.sessions.StartTaskSession(ctx, StartTaskSession{ + Task: startingTask, + Run: run, + Actor: actor, + }) + if err != nil { + failedRun, failErr := m.failRunRecord(ctx, taskRecord, run, RunFailure{ + Error: fmt.Sprintf("start task session: %v", err), + }, actor) + if failErr != nil { + return nil, errorsJoin(err, failErr) + } + return failedRun, fmt.Errorf("task: start task session for run %q: %w", run.ID, err) + } + if sessionRef == nil { + failedRun, failErr := m.failRunRecord(ctx, taskRecord, run, RunFailure{ + Error: "start task session: nil session reference", + }, actor) + if failErr != nil { + return nil, failErr + } + return failedRun, fmt.Errorf("%w: start_task_session returned nil session reference", ErrValidation) + } + if err := sessionRef.Validate(); err != nil { + failedRun, failErr := m.failRunRecord(ctx, taskRecord, run, RunFailure{ + Error: fmt.Sprintf("start task session: %v", err), + }, actor) + if failErr != nil { + return nil, errorsJoin(err, failErr) + } + return failedRun, err + } + run.SessionID = strings.TrimSpace(sessionRef.SessionID) + case TaskRunStatusStarting: + if strings.TrimSpace(run.SessionID) == "" { + return nil, fmt.Errorf("%w: task run %q cannot transition from %q to %q without a session binding", ErrInvalidStatusTransition, run.ID, run.Status, TaskRunStatusRunning) + } + default: + return nil, requireRunTransition(run, TaskRunStatusRunning) + } + + run.Status = TaskRunStatusRunning + run.StartedAt = m.now().UTC() + if err := m.store.UpdateTaskRun(ctx, run); err != nil { + return nil, err + } + + reconciledTask, err := m.reconcileTaskCascade(ctx, run.TaskID) + if err != nil { + return nil, err + } + if err := m.recordTaskEvent(ctx, run.TaskID, run.ID, taskEventRunStarted, actor, runTransitionPayload{ + Status: run.Status, + TaskStatus: reconciledTask.Status, + SessionID: run.SessionID, + }); err != nil { + return nil, err + } + + return &run, nil +} + +// AttachRunSession binds one existing session to a claimed or starting run. +func (m *TaskManager) AttachRunSession(ctx context.Context, runID string, sessionID string, actor ActorContext) (*TaskRun, error) { + if err := requireWriteAuthority(actor); err != nil { + return nil, err + } + if err := m.requireSessionExecutor("attach run session"); err != nil { + return nil, err + } + + trimmedSessionID := strings.TrimSpace(sessionID) + if trimmedSessionID == "" { + return nil, fmt.Errorf("%w: session id is required", ErrValidation) + } + + run, taskRecord, err := m.loadRunWithTask(ctx, runID) + if err != nil { + return nil, err + } + if err := m.ensureTaskExecutable(ctx, taskRecord); err != nil { + return nil, err + } + if err := m.validateRunChannelUsable(ctx, taskRecord, run, actor, "attach"); err != nil { + return nil, err + } + if strings.TrimSpace(run.SessionID) != "" { + return nil, ErrSessionAlreadyBound + } + + switch run.Status.Normalize() { + case TaskRunStatusClaimed, TaskRunStatusStarting: + default: + return nil, ErrSessionAttachNotAllowed + } + + activeBindings, err := m.store.CountActiveSessionBindings(ctx, trimmedSessionID) + if err != nil { + return nil, err + } + if activeBindings > 0 { + return nil, ErrSessionAlreadyBound + } + + sessionRef, err := m.sessions.AttachTaskSession(ctx, run.ID, trimmedSessionID) + if err != nil { + return nil, err + } + if sessionRef == nil { + return nil, fmt.Errorf("%w: attach_task_session returned nil session reference", ErrValidation) + } + if err := sessionRef.Validate(); err != nil { + return nil, err + } + + run.SessionID = strings.TrimSpace(sessionRef.SessionID) + if run.Status.Normalize() == TaskRunStatusClaimed { + run.Status = TaskRunStatusStarting + } + if err := m.store.UpdateTaskRun(ctx, run); err != nil { + return nil, err + } + + reconciledTask, err := m.reconcileTaskCascade(ctx, run.TaskID) + if err != nil { + return nil, err + } + if err := m.recordTaskEvent(ctx, run.TaskID, run.ID, taskEventRunSessionBound, actor, runTransitionPayload{ + Status: run.Status, + TaskStatus: reconciledTask.Status, + SessionID: run.SessionID, + }); err != nil { + return nil, err + } + + return &run, nil +} + +// CompleteRun marks one running task run as completed and reconciles task state. +func (m *TaskManager) CompleteRun(ctx context.Context, runID string, result RunResult, actor ActorContext) (*TaskRun, error) { + if err := requireWriteAuthority(actor); err != nil { + return nil, err + } + + normalizedResult, err := normalizeRunResult(result) + if err != nil { + return nil, err + } + + run, taskRecord, err := m.loadRunWithTask(ctx, runID) + if err != nil { + return nil, err + } + if err := requireRunTransition(run, TaskRunStatusCompleted); err != nil { + return nil, err + } + + run.Status = TaskRunStatusCompleted + run.Result = cloneRawJSON(normalizedResult.Value) + run.Error = "" + run.EndedAt = m.now().UTC() + if err := m.store.UpdateTaskRun(ctx, run); err != nil { + return nil, err + } + + reconciledTask, err := m.reconcileTaskCascade(ctx, taskRecord.ID) + if err != nil { + return nil, err + } + if err := m.recordTaskEvent(ctx, run.TaskID, run.ID, taskEventRunCompleted, actor, completedRunPayload{ + Status: run.Status, + TaskStatus: reconciledTask.Status, + Result: cloneRawJSON(run.Result), + }); err != nil { + return nil, err + } + + return &run, nil +} + +// FailRun marks one starting or running task run as failed and reconciles task state. +func (m *TaskManager) FailRun(ctx context.Context, runID string, failure RunFailure, actor ActorContext) (*TaskRun, error) { + if err := requireWriteAuthority(actor); err != nil { + return nil, err + } + + normalizedFailure, err := normalizeRunFailure(failure) + if err != nil { + return nil, err + } + + run, taskRecord, err := m.loadRunWithTask(ctx, runID) + if err != nil { + return nil, err + } + return m.failRunRecord(ctx, taskRecord, run, normalizedFailure, actor) +} + +// CancelRun cancels one non-terminal task run under manager authority. +func (m *TaskManager) CancelRun(ctx context.Context, runID string, req CancelRun, actor ActorContext) (*TaskRun, error) { + if err := requireWriteAuthority(actor); err != nil { + return nil, err + } + + normalizedReq, err := normalizeCancelRun(req) + if err != nil { + return nil, err + } + + run, taskRecord, err := m.loadRunWithTask(ctx, runID) + if err != nil { + return nil, err + } + return m.cancelRunRecord(ctx, taskRecord, run, normalizedReq, actor, cancelRunOptions{ + reconcileTask: true, + }) +} + +// RecoverRunOnBoot applies one daemon-owned recovery decision to a non-terminal +// run discovered during startup reconciliation. +func (m *TaskManager) RecoverRunOnBoot(ctx context.Context, runID string, recovery RunBootRecovery, actor ActorContext) (*TaskRun, error) { + if err := requireWriteAuthority(actor); err != nil { + return nil, err + } + + normalizedRecovery, err := normalizeRunBootRecovery(recovery) + if err != nil { + return nil, err + } + + run, taskRecord, err := m.loadRunWithTask(ctx, runID) + if err != nil { + return nil, err + } + + previousStatus := run.Status.Normalize() + previousSessionID := strings.TrimSpace(run.SessionID) + switch normalizedRecovery.Action.Normalize() { + case RunBootRecoveryRequeue: + if previousStatus != TaskRunStatusClaimed { + return nil, fmt.Errorf("%w: task run %q cannot recover from %q via %q", ErrInvalidStatusTransition, run.ID, previousStatus, normalizedRecovery.Action) + } + + run.Status = TaskRunStatusQueued + run.ClaimedBy = nil + run.ClaimedAt = time.Time{} + run.SessionID = "" + run.StartedAt = time.Time{} + run.EndedAt = time.Time{} + run.Error = "" + run.Result = nil + if err := m.store.UpdateTaskRun(ctx, run); err != nil { + return nil, err + } + + reconciledTask, err := m.reconcileTaskCascade(ctx, taskRecord.ID) + if err != nil { + return nil, err + } + if err := m.recordTaskEvent(ctx, run.TaskID, run.ID, taskEventRunRecovered, actor, recoveredRunPayload{ + Action: normalizedRecovery.Action, + PreviousStatus: previousStatus, + Status: run.Status, + TaskStatus: reconciledTask.Status, + Reason: normalizedRecovery.Reason, + SessionID: previousSessionID, + SessionState: normalizedRecovery.SessionState, + }); err != nil { + return nil, err + } + return &run, nil + + case RunBootRecoveryMarkRunning: + switch previousStatus { + case TaskRunStatusClaimed, TaskRunStatusStarting: + case TaskRunStatusRunning: + return &run, nil + default: + return nil, fmt.Errorf("%w: task run %q cannot recover from %q via %q", ErrInvalidStatusTransition, run.ID, previousStatus, normalizedRecovery.Action) + } + if previousSessionID == "" { + return nil, fmt.Errorf("%w: task run %q cannot recover to running without a session binding", ErrInvalidStatusTransition, run.ID) + } + + run.Status = TaskRunStatusRunning + if run.StartedAt.IsZero() { + run.StartedAt = m.now().UTC() + } + if err := m.store.UpdateTaskRun(ctx, run); err != nil { + return nil, err + } + + reconciledTask, err := m.reconcileTaskCascade(ctx, taskRecord.ID) + if err != nil { + return nil, err + } + if err := m.recordTaskEvent(ctx, run.TaskID, run.ID, taskEventRunRecovered, actor, recoveredRunPayload{ + Action: normalizedRecovery.Action, + PreviousStatus: previousStatus, + Status: run.Status, + TaskStatus: reconciledTask.Status, + Reason: normalizedRecovery.Reason, + SessionID: previousSessionID, + SessionState: normalizedRecovery.SessionState, + }); err != nil { + return nil, err + } + return &run, nil + + case RunBootRecoveryFail: + failedRun, err := m.failRunRecord(ctx, taskRecord, run, RunFailure{ + Error: runBootRecoveryError(run, normalizedRecovery), + Metadata: runBootRecoveryMetadata(run, normalizedRecovery), + }, actor) + if err != nil { + return nil, err + } + + reconciledTask, err := m.store.GetTask(ctx, taskRecord.ID) + if err != nil { + return nil, err + } + if err := m.recordTaskEvent(ctx, run.TaskID, run.ID, taskEventRunRecovered, actor, recoveredRunPayload{ + Action: normalizedRecovery.Action, + PreviousStatus: previousStatus, + Status: failedRun.Status, + TaskStatus: reconciledTask.Status, + Reason: normalizedRecovery.Reason, + SessionID: previousSessionID, + SessionState: normalizedRecovery.SessionState, + }); err != nil { + return nil, err + } + return failedRun, nil + + default: + return nil, fmt.Errorf("%w: run boot recovery action %q is not supported", ErrValidation, normalizedRecovery.Action) + } +} + +func requireReadAuthority(actor ActorContext) error { + if err := actor.Validate(); err != nil { + return err + } + if !actor.Authority.Read { + return ErrPermissionDenied + } + return nil +} + +func requireWriteAuthority(actor ActorContext) error { + if err := actor.Validate(); err != nil { + return err + } + if !actor.Authority.Write { + return ErrPermissionDenied + } + return nil +} + +func requireCreateAuthority(actor ActorContext, scope Scope) error { + if err := requireWriteAuthority(actor); err != nil { + return err + } + + switch scope.Normalize() { + case ScopeGlobal: + if !actor.Authority.CreateGlobal { + return ErrPermissionDenied + } + case ScopeWorkspace: + if !actor.Authority.CreateWorkspace { + return ErrPermissionDenied + } + default: + return fmt.Errorf("%w: create_task.scope is required", ErrValidation) + } + return nil +} + +func normalizeCreateTaskSpec(spec CreateTask) (CreateTask, error) { + normalized := spec + normalized.ID = strings.TrimSpace(normalized.ID) + normalized.Identifier = strings.TrimSpace(normalized.Identifier) + normalized.Scope = normalized.Scope.Normalize() + normalized.WorkspaceID = strings.TrimSpace(normalized.WorkspaceID) + normalized.ParentTaskID = strings.TrimSpace(normalized.ParentTaskID) + normalized.NetworkChannel = strings.TrimSpace(normalized.NetworkChannel) + normalized.Title = strings.TrimSpace(normalized.Title) + normalized.Description = strings.TrimSpace(normalized.Description) + if normalized.Owner != nil { + normalized.Owner = normalizeOwnership(normalized.Owner) + } + normalized.Metadata = normalizeRawJSON(normalized.Metadata) + if err := normalized.Validate("create_task"); err != nil { + return CreateTask{}, err + } + return normalized, nil +} + +func normalizeTaskPatch(patch TaskPatch) (TaskPatch, error) { + normalized := patch + if normalized.Title != nil { + title := strings.TrimSpace(*normalized.Title) + normalized.Title = &title + } + if normalized.Description != nil { + description := strings.TrimSpace(*normalized.Description) + normalized.Description = &description + } + if normalized.Metadata != nil { + metadata := normalizeRawJSON(*normalized.Metadata) + normalized.Metadata = &metadata + } + if normalized.NetworkChannel != nil { + channel := strings.TrimSpace(*normalized.NetworkChannel) + normalized.NetworkChannel = &channel + } + if normalized.Owner != nil { + normalized.Owner = normalizeOwnership(normalized.Owner) + } + if err := normalized.Validate("task_patch"); err != nil { + return TaskPatch{}, err + } + return normalized, nil +} + +func normalizeAddDependencySpec(spec AddDependency) (AddDependency, error) { + normalized := spec + normalized.TaskID = strings.TrimSpace(normalized.TaskID) + normalized.DependsOnTaskID = strings.TrimSpace(normalized.DependsOnTaskID) + normalized.Kind = normalized.Kind.Normalize() + if err := normalized.Validate("add_dependency"); err != nil { + return AddDependency{}, err + } + return normalized, nil +} + +func normalizeCancelTask(req CancelTask) (CancelTask, error) { + normalized := req + normalized.Reason = strings.TrimSpace(normalized.Reason) + normalized.Metadata = normalizeRawJSON(normalized.Metadata) + if err := normalized.Validate("cancel_task"); err != nil { + return CancelTask{}, err + } + return normalized, nil +} + +func normalizeEnqueueRunSpec(spec EnqueueRun) (EnqueueRun, error) { + normalized := spec + normalized.TaskID = strings.TrimSpace(normalized.TaskID) + normalized.IdempotencyKey = strings.TrimSpace(normalized.IdempotencyKey) + normalized.NetworkChannel = strings.TrimSpace(normalized.NetworkChannel) + if err := normalized.Validate("enqueue_run"); err != nil { + return EnqueueRun{}, err + } + return normalized, nil +} + +func normalizeClaimRun(claim ClaimRun) (ClaimRun, error) { + normalized := claim + normalized.IdempotencyKey = strings.TrimSpace(normalized.IdempotencyKey) + if err := normalized.Validate("claim_run"); err != nil { + return ClaimRun{}, err + } + return normalized, nil +} + +func normalizeStartRun(req StartRun) (StartRun, error) { + normalized := req + normalized.IdempotencyKey = strings.TrimSpace(normalized.IdempotencyKey) + if err := normalized.Validate("start_run"); err != nil { + return StartRun{}, err + } + return normalized, nil +} + +func normalizeCancelRun(req CancelRun) (CancelRun, error) { + normalized := req + normalized.Reason = strings.TrimSpace(normalized.Reason) + normalized.Metadata = normalizeRawJSON(normalized.Metadata) + if err := normalized.Validate("cancel_run"); err != nil { + return CancelRun{}, err + } + return normalized, nil +} + +func normalizeRunResult(result RunResult) (RunResult, error) { + normalized := result + normalized.Value = normalizeRawJSON(normalized.Value) + if err := normalized.Validate("run_result"); err != nil { + return RunResult{}, err + } + return normalized, nil +} + +func normalizeRunFailure(failure RunFailure) (RunFailure, error) { + normalized := failure + normalized.Error = strings.TrimSpace(normalized.Error) + normalized.Metadata = normalizeRawJSON(normalized.Metadata) + if err := normalized.Validate("run_failure"); err != nil { + return RunFailure{}, err + } + return normalized, nil +} + +func normalizeRunBootRecovery(recovery RunBootRecovery) (RunBootRecovery, error) { + normalized := recovery + normalized.Action = normalized.Action.Normalize() + normalized.Reason = strings.TrimSpace(normalized.Reason) + normalized.SessionState = strings.TrimSpace(normalized.SessionState) + if err := normalized.Validate("run_boot_recovery"); err != nil { + return RunBootRecovery{}, err + } + return normalized, nil +} + +func requireLifecycleIdempotency(actor ActorContext, key string, path string) error { + if actor.Actor.Kind.Normalize() == ActorKindHuman { + return nil + } + if strings.TrimSpace(key) == "" { + return fmt.Errorf("%w: %s.idempotency_key is required for non-human actors", ErrValidation, path) + } + return nil +} + +func (m *TaskManager) validateParentConstraints(ctx context.Context, spec CreateTask) error { + if strings.TrimSpace(spec.ParentTaskID) == "" { + return nil + } + + parent, err := m.store.GetTask(ctx, spec.ParentTaskID) + if err != nil { + return err + } + if err := validateParentChildScope(parent, spec.Scope, spec.WorkspaceID); err != nil { + return err + } + + childCount, err := m.store.CountDirectChildren(ctx, parent.ID) + if err != nil { + return err + } + if err := ValidateDirectChildCount(childCount + 1); err != nil { + return err + } + + parentDepth, err := m.taskDepth(ctx, parent) + if err != nil { + return err + } + return ValidateHierarchyDepth(parentDepth + 1) +} + +func validateParentChildScope(parent Task, childScope Scope, childWorkspaceID string) error { + switch parent.Scope.Normalize() { + case ScopeGlobal: + return nil + case ScopeWorkspace: + if childScope.Normalize() != ScopeWorkspace { + return fmt.Errorf("%w: workspace-scoped parent tasks require workspace-scoped children", ErrValidation) + } + if strings.TrimSpace(parent.WorkspaceID) != strings.TrimSpace(childWorkspaceID) { + return fmt.Errorf("%w: child workspace_id must match workspace-scoped parent", ErrValidation) + } + return nil + default: + return fmt.Errorf("%w: parent task has unsupported scope %q", ErrValidation, parent.Scope) + } +} + +func (m *TaskManager) taskDepth(ctx context.Context, record Task) (int, error) { + depth := 1 + current := record + seen := map[string]struct{}{strings.TrimSpace(record.ID): {}} + + for strings.TrimSpace(current.ParentTaskID) != "" { + parentID := strings.TrimSpace(current.ParentTaskID) + if _, ok := seen[parentID]; ok { + return 0, fmt.Errorf("%w: task hierarchy contains a cycle at %q", ErrValidation, parentID) + } + seen[parentID] = struct{}{} + + parent, err := m.store.GetTask(ctx, parentID) + if err != nil { + return 0, err + } + depth++ + current = parent + } + + return depth, nil +} + +func (m *TaskManager) reconcileTask(ctx context.Context, taskID string) (Task, error) { + record, err := m.store.GetTask(ctx, taskID) + if err != nil { + return Task{}, err + } + dependencies, err := m.store.ListDependencies(ctx, taskID) + if err != nil { + return Task{}, err + } + runs, err := m.store.ListTaskRuns(ctx, TaskRunQuery{TaskID: taskID}) + if err != nil { + return Task{}, err + } + + canonicalStatus, err := m.canonicalTaskStatus(ctx, record, dependencies, runs) + if err != nil { + return Task{}, err + } + if record.Status.Normalize() == canonicalStatus.Normalize() { + return record, nil + } + + record.Status = canonicalStatus + record.UpdatedAt = m.now().UTC() + if isTerminalTaskStatus(record.Status) { + record.ClosedAt = record.UpdatedAt + } else { + record.ClosedAt = time.Time{} + } + if err := m.store.UpdateTask(ctx, record); err != nil { + return Task{}, err + } + return record, nil +} + +func (m *TaskManager) reconcileTaskCascade(ctx context.Context, taskID string) (Task, error) { + previous, err := m.store.GetTask(ctx, taskID) + if err != nil { + return Task{}, err + } + + reconciled, err := m.reconcileTask(ctx, taskID) + if err != nil { + return Task{}, err + } + if previous.Status.Normalize() != reconciled.Status.Normalize() { + if err := m.reconcileDependentTasks(ctx, taskID, map[string]struct{}{taskID: {}}); err != nil { + return Task{}, err + } + } + return reconciled, nil +} + +func (m *TaskManager) canonicalTaskStatus(ctx context.Context, record Task, dependencies []TaskDependency, runs []TaskRun) (TaskStatus, error) { + unresolvedDependencies, err := m.hasUnresolvedDependencies(ctx, dependencies) + if err != nil { + return "", err + } + return taskStatusFromSnapshot(record.Status, unresolvedDependencies, runs), nil +} + +func hasActiveRun(runs []TaskRun) bool { + for _, run := range runs { + switch run.Status.Normalize() { + case TaskRunStatusStarting, TaskRunStatusRunning: + return true + } + } + return false +} + +func hasOpenRun(runs []TaskRun) bool { + for _, run := range runs { + if !isTerminalRunStatus(run.Status) { + return true + } + } + return false +} + +func isTerminalTaskStatus(status TaskStatus) bool { + switch status.Normalize() { + case TaskStatusCompleted, TaskStatusFailed, TaskStatusCancelled: + return true + default: + return false + } +} + +func isTerminalRunStatus(status TaskRunStatus) bool { + switch status.Normalize() { + case TaskRunStatusCompleted, TaskRunStatusFailed, TaskRunStatusCancelled: + return true + default: + return false + } +} + +func taskStatusFromSnapshot(currentStatus TaskStatus, unresolvedDependencies bool, runs []TaskRun) TaskStatus { + status := currentStatus.Normalize() + if status == TaskStatusCancelled { + return status + } + if hasActiveRun(runs) { + return TaskStatusInProgress + } + if hasQueuedOrClaimedRun(runs) { + if unresolvedDependencies { + return TaskStatusBlocked + } + return TaskStatusReady + } + if latest := latestTerminalRun(runs); latest != nil { + switch latest.Status.Normalize() { + case TaskRunStatusCompleted: + return TaskStatusCompleted + case TaskRunStatusFailed: + return TaskStatusFailed + case TaskRunStatusCancelled: + return TaskStatusCancelled + } + } + + if isTerminalTaskStatus(status) { + return status + } + if unresolvedDependencies { + return TaskStatusBlocked + } + return TaskStatusReady +} + +func hasQueuedOrClaimedRun(runs []TaskRun) bool { + for _, run := range runs { + switch run.Status.Normalize() { + case TaskRunStatusQueued, TaskRunStatusClaimed: + return true + } + } + return false +} + +func latestTerminalRun(runs []TaskRun) *TaskRun { + var latest *TaskRun + for idx := range runs { + run := runs[idx] + if !isTerminalRunStatus(run.Status) { + continue + } + if latest == nil || runComesAfter(run, *latest) { + candidate := run + latest = &candidate + } + } + return latest +} + +func runComesAfter(left TaskRun, right TaskRun) bool { + switch { + case left.Attempt != right.Attempt: + return left.Attempt > right.Attempt + case !left.QueuedAt.Equal(right.QueuedAt): + return left.QueuedAt.After(right.QueuedAt) + default: + return left.ID > right.ID + } +} + +func (m *TaskManager) hasUnresolvedDependencies(ctx context.Context, dependencies []TaskDependency) (bool, error) { + for _, dependency := range dependencies { + record, err := m.reconcileTask(ctx, dependency.DependsOnTaskID) + if err != nil { + return false, err + } + if record.Status.Normalize() != TaskStatusCompleted { + return true, nil + } + } + return false, nil +} + +func (m *TaskManager) reconcileDependentTasks(ctx context.Context, taskID string, visited map[string]struct{}) error { + dependents, err := m.store.ListDependents(ctx, taskID) + if err != nil { + return err + } + + for _, dependent := range dependents { + dependentTaskID := strings.TrimSpace(dependent.TaskID) + if _, seen := visited[dependentTaskID]; seen { + continue + } + visited[dependentTaskID] = struct{}{} + + previous, err := m.store.GetTask(ctx, dependentTaskID) + if err != nil { + return err + } + reconciled, err := m.reconcileTask(ctx, dependentTaskID) + if err != nil { + return err + } + if previous.Status.Normalize() != reconciled.Status.Normalize() { + if err := m.reconcileDependentTasks(ctx, dependentTaskID, visited); err != nil { + return err + } + } + } + + return nil +} + +func (m *TaskManager) lookupIdempotentRun(ctx context.Context, key string, origin Origin, taskID string) (*TaskRun, error) { + trimmedKey := strings.TrimSpace(key) + if trimmedKey == "" { + return nil, nil + } + + run, err := m.store.GetTaskRunByIdempotencyKey(ctx, trimmedKey, origin) + if err != nil { + if errors.Is(err, ErrTaskRunIdempotencyNotFound) { + return nil, nil + } + return nil, err + } + if strings.TrimSpace(run.TaskID) != strings.TrimSpace(taskID) { + return nil, fmt.Errorf("%w: idempotency key %q already maps to task %q", ErrValidation, trimmedKey, run.TaskID) + } + return &run, nil +} + +func (m *TaskManager) saveRunIdempotency(ctx context.Context, run TaskRun, origin Origin) error { + if strings.TrimSpace(run.IdempotencyKey) == "" { + return nil + } + return m.store.SaveTaskRunIdempotency(ctx, TaskRunIdempotency{ + IdempotencyKey: run.IdempotencyKey, + RunID: run.ID, + Origin: origin, + CreatedAt: m.now().UTC(), + }) +} + +func (m *TaskManager) loadRunWithTask(ctx context.Context, runID string) (TaskRun, Task, error) { + trimmedRunID := strings.TrimSpace(runID) + if trimmedRunID == "" { + return TaskRun{}, Task{}, fmt.Errorf("%w: task run id is required", ErrValidation) + } + + run, err := m.store.GetTaskRun(ctx, trimmedRunID) + if err != nil { + return TaskRun{}, Task{}, err + } + taskRecord, err := m.store.GetTask(ctx, run.TaskID) + if err != nil { + return TaskRun{}, Task{}, err + } + return run, taskRecord, nil +} + +func (m *TaskManager) ensureTaskExecutable(ctx context.Context, record Task) error { + dependencies, err := m.store.ListDependencies(ctx, record.ID) + if err != nil { + return err + } + runs, err := m.store.ListTaskRuns(ctx, TaskRunQuery{TaskID: record.ID}) + if err != nil { + return err + } + status, err := m.canonicalTaskStatus(ctx, record, dependencies, runs) + if err != nil { + return err + } + + switch status.Normalize() { + case TaskStatusBlocked: + return fmt.Errorf("%w: task %q is blocked", ErrInvalidStatusTransition, record.ID) + case TaskStatusCancelled: + return fmt.Errorf("%w: task %q is cancelled", ErrInvalidStatusTransition, record.ID) + default: + return nil + } +} + +func (m *TaskManager) requireSessionExecutor(action string) error { + if m.sessions == nil { + return fmt.Errorf("%w: session executor is required to %s", ErrValidation, action) + } + return nil +} + +func (m *TaskManager) collectTaskTree(ctx context.Context, rootTaskID string) ([]Task, error) { + root, err := m.store.GetTask(ctx, rootTaskID) + if err != nil { + return nil, err + } + + tree := []Task{root} + queue := []Task{root} + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + + children, err := m.store.ListTasks(ctx, TaskQuery{ParentTaskID: current.ID}) + if err != nil { + return nil, err + } + for _, child := range children { + record, err := m.store.GetTask(ctx, child.ID) + if err != nil { + return nil, err + } + tree = append(tree, record) + queue = append(queue, record) + } + } + + return tree, nil +} + +type cancelRunOptions struct { + propagatedFromTaskID string + reconcileTask bool +} + +func (m *TaskManager) failRunRecord(ctx context.Context, taskRecord Task, run TaskRun, failure RunFailure, actor ActorContext) (*TaskRun, error) { + switch run.Status.Normalize() { + case TaskRunStatusStarting, TaskRunStatusRunning: + default: + return nil, requireRunTransition(run, TaskRunStatusFailed) + } + + run.Status = TaskRunStatusFailed + run.Error = failure.Error + run.Result = nil + run.EndedAt = m.now().UTC() + if err := m.store.UpdateTaskRun(ctx, run); err != nil { + return nil, err + } + + reconciledTask, err := m.reconcileTaskCascade(ctx, taskRecord.ID) + if err != nil { + return nil, err + } + if err := m.recordTaskEvent(ctx, run.TaskID, run.ID, taskEventRunFailed, actor, failedRunPayload{ + Status: run.Status, + TaskStatus: reconciledTask.Status, + Error: run.Error, + Metadata: cloneRawJSON(failure.Metadata), + }); err != nil { + return nil, err + } + + return &run, nil +} + +func (m *TaskManager) cancelRunRecord(ctx context.Context, taskRecord Task, run TaskRun, req CancelRun, actor ActorContext, opts cancelRunOptions) (*TaskRun, error) { + status := run.Status.Normalize() + switch status { + case TaskRunStatusQueued, TaskRunStatusClaimed, TaskRunStatusStarting, TaskRunStatusRunning: + default: + return nil, requireRunTransition(run, TaskRunStatusCancelled) + } + + sessionID := strings.TrimSpace(run.SessionID) + activeSession := (status == TaskRunStatusStarting || status == TaskRunStatusRunning) && sessionID != "" + if activeSession { + if err := m.requireSessionExecutor("cancel active run"); err != nil { + return nil, err + } + } + + run.Status = TaskRunStatusCancelled + run.Result = nil + run.Error = "" + run.EndedAt = m.now().UTC() + if err := m.store.UpdateTaskRun(ctx, run); err != nil { + return nil, err + } + + cooperativeStopRequested := false + if activeSession { + if err := m.sessions.RequestTaskStop(ctx, sessionID, StopReasonCancellation); err != nil { + return nil, fmt.Errorf("task: request stop for session %q: %w", sessionID, err) + } + cooperativeStopRequested = true + } + + reconciledTask := taskRecord + if opts.reconcileTask { + var err error + reconciledTask, err = m.reconcileTaskCascade(ctx, taskRecord.ID) + if err != nil { + return nil, err + } + } + if err := m.recordTaskEvent(ctx, run.TaskID, run.ID, taskEventRunCancelled, actor, cancelledRunPayload{ + Status: run.Status, + TaskStatus: reconciledTask.Status, + Reason: req.Reason, + Metadata: cloneRawJSON(req.Metadata), + SessionID: sessionID, + PropagatedFromTaskID: opts.propagatedFromTaskID, + CooperativeStopRequested: cooperativeStopRequested, + }); err != nil { + return nil, err + } + + if activeSession { + if err := m.waitAndForceStopRun(ctx, sessionID); err != nil { + return nil, err + } + if err := m.recordTaskEvent(ctx, run.TaskID, run.ID, taskEventRunForceStopped, actor, forceStoppedRunPayload{ + SessionID: sessionID, + GraceTimeoutMillis: m.cancelGracePeriod.Milliseconds(), + PropagatedFromTaskID: opts.propagatedFromTaskID, + }); err != nil { + return nil, err + } + } + + return &run, nil +} + +func (m *TaskManager) waitAndForceStopRun(ctx context.Context, sessionID string) error { + if m.cancelGracePeriod > 0 { + timer := time.NewTimer(m.cancelGracePeriod) + defer timer.Stop() + + select { + case <-timer.C: + case <-ctx.Done(): + return fmt.Errorf("task: wait for force-stop grace period on session %q: %w", sessionID, ctx.Err()) + } + } + if err := m.sessions.ForceTaskStop(ctx, sessionID, StopReasonCancellation); err != nil { + return fmt.Errorf("task: force stop session %q: %w", sessionID, err) + } + return nil +} + +func requireRunTransition(run TaskRun, next TaskRunStatus) error { + current := run.Status.Normalize() + target := next.Normalize() + if allowsRunTransition(current, target) { + return nil + } + return fmt.Errorf("%w: task run %q cannot transition from %q to %q", ErrInvalidStatusTransition, run.ID, current, target) +} + +func allowsRunTransition(current TaskRunStatus, next TaskRunStatus) bool { + switch current.Normalize() { + case TaskRunStatusQueued: + return next.Normalize() == TaskRunStatusClaimed || next.Normalize() == TaskRunStatusCancelled + case TaskRunStatusClaimed: + switch next.Normalize() { + case TaskRunStatusStarting, TaskRunStatusCancelled: + return true + } + case TaskRunStatusStarting: + switch next.Normalize() { + case TaskRunStatusRunning, TaskRunStatusFailed, TaskRunStatusCancelled: + return true + } + case TaskRunStatusRunning: + switch next.Normalize() { + case TaskRunStatusCompleted, TaskRunStatusFailed, TaskRunStatusCancelled: + return true + } + } + return false +} + +func nextRunAttempt(runs []TaskRun) int { + maxAttempt := 0 + for _, run := range runs { + if run.Attempt > maxAttempt { + maxAttempt = run.Attempt + } + } + return maxAttempt + 1 +} + +func (m *TaskManager) validateNetworkChannel(path string, channel string) error { + if m == nil || m.channelValidator == nil { + return nil + } + + trimmed := strings.TrimSpace(channel) + if trimmed == "" { + return nil + } + if err := m.channelValidator(trimmed); err != nil { + return fmt.Errorf("%w: %s: %w", ErrValidation, path, err) + } + return nil +} + +func (m *TaskManager) validateRunChannelUsable(ctx context.Context, taskRecord Task, run TaskRun, actor ActorContext, operation string) error { + channel := resolvedRunChannel(run.NetworkChannel, taskRecord.NetworkChannel) + if strings.TrimSpace(channel) == "" { + return nil + } + if err := m.validateNetworkChannel("task_run.network_channel", channel); err == nil { + return nil + } + + rejectedErr := fmt.Errorf( + "%w: task %q run %q channel %q is no longer valid", + ErrStaleNetworkChannel, + taskRecord.ID, + run.ID, + strings.TrimSpace(channel), + ) + if recordErr := m.recordTaskEvent(ctx, taskRecord.ID, run.ID, taskEventRunRejected, actor, rejectedRunPayload{ + Operation: strings.TrimSpace(operation), + Reason: "stale_network_channel", + NetworkChannel: strings.TrimSpace(channel), + }); recordErr != nil { + return errorsJoin(rejectedErr, recordErr) + } + return rejectedErr +} + +func resolvedRunChannel(requested string, taskChannel string) string { + if strings.TrimSpace(requested) != "" { + return strings.TrimSpace(requested) + } + return strings.TrimSpace(taskChannel) +} + +func errorsJoin(errs ...error) error { + return errors.Join(errs...) +} + +func runBootRecoveryError(run TaskRun, recovery RunBootRecovery) string { + sessionID := strings.TrimSpace(run.SessionID) + switch { + case sessionID != "" && recovery.SessionState != "": + return fmt.Sprintf("orphaned on boot: session %q is %s", sessionID, recovery.SessionState) + case sessionID != "": + return fmt.Sprintf("orphaned on boot: session %q is not live", sessionID) + default: + return "orphaned on boot: run has no live session" + } +} + +func runBootRecoveryMetadata(run TaskRun, recovery RunBootRecovery) json.RawMessage { + payload, err := marshalTaskEventPayload(map[string]string{ + "reason": normalizedBootRecoveryReason(recovery.Reason), + "previous_status": string(run.Status.Normalize()), + "session_id": strings.TrimSpace(run.SessionID), + "session_state": strings.TrimSpace(recovery.SessionState), + }) + if err != nil { + return nil + } + return payload +} + +func normalizedBootRecoveryReason(reason string) string { + trimmed := strings.TrimSpace(reason) + if trimmed == "" { + return "orphaned_on_boot" + } + return trimmed +} + +func (m *TaskManager) recordTaskEvent(ctx context.Context, taskID string, runID string, eventType string, actor ActorContext, payload any) error { + rawPayload, err := marshalTaskEventPayload(payload) + if err != nil { + return err + } + return m.store.CreateTaskEvent(ctx, TaskEvent{ + ID: m.newID("evt"), + TaskID: strings.TrimSpace(taskID), + RunID: strings.TrimSpace(runID), + EventType: strings.TrimSpace(eventType), + Actor: actor.Actor, + Origin: actor.Origin, + Payload: rawPayload, + Timestamp: m.now().UTC(), + }) +} + +func marshalTaskEventPayload(payload any) (json.RawMessage, error) { + if payload == nil { + return nil, nil + } + raw, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("task: marshal task event payload: %w", err) + } + return json.RawMessage(raw), nil +} + +func normalizeOwnership(owner *Ownership) *Ownership { + if owner == nil { + return nil + } + normalized := *owner + normalized.Kind = normalized.Kind.Normalize() + normalized.Ref = strings.TrimSpace(normalized.Ref) + if normalized.IsZero() { + return nil + } + return &normalized +} + +func cloneOwnership(owner *Ownership) *Ownership { + if owner == nil { + return nil + } + cloned := *owner + return &cloned +} + +func sameOwnership(left *Ownership, right *Ownership) bool { + switch { + case left == nil && right == nil: + return true + case left == nil || right == nil: + return false + default: + return left.Kind.Normalize() == right.Kind.Normalize() && strings.TrimSpace(left.Ref) == strings.TrimSpace(right.Ref) + } +} + +func normalizeRawJSON(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return nil + } + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" { + return nil + } + return json.RawMessage(trimmed) +} + +func cloneRawJSON(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return nil + } + cloned := make(json.RawMessage, len(raw)) + copy(cloned, raw) + return cloned +} + +func sameRawJSON(left json.RawMessage, right json.RawMessage) bool { + return string(normalizeRawJSON(left)) == string(normalizeRawJSON(right)) +} + +type createdTaskPayload struct { + Scope Scope `json:"scope"` + WorkspaceID string `json:"workspace_id,omitempty"` + ParentTaskID string `json:"parent_task_id,omitempty"` + Status TaskStatus `json:"status"` + NetworkChannel string `json:"network_channel,omitempty"` + Owner *Ownership `json:"owner,omitempty"` +} + +type updatedTaskPayload struct { + ChangedFields []string `json:"changed_fields"` + Status TaskStatus `json:"status"` +} + +type childCreatedTaskPayload struct { + ChildTaskID string `json:"child_task_id"` + ChildScope Scope `json:"child_scope"` + ChildWorkspaceID string `json:"child_workspace_id,omitempty"` +} + +type dependencyTaskPayload struct { + DependsOnTaskID string `json:"depends_on_task_id"` + Kind DependencyKind `json:"kind"` + Status TaskStatus `json:"status"` +} + +type cancelledTaskPayload struct { + Reason string `json:"reason,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + Status TaskStatus `json:"status"` + PropagatedFromTaskID string `json:"propagated_from_task_id,omitempty"` + CancelledRunIDs []string `json:"cancelled_run_ids,omitempty"` +} + +type runEnqueuedPayload struct { + Attempt int `json:"attempt"` + Status TaskRunStatus `json:"status"` + TaskStatus TaskStatus `json:"task_status"` + NetworkChannel string `json:"network_channel,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +type runClaimedPayload struct { + Status TaskRunStatus `json:"status"` + TaskStatus TaskStatus `json:"task_status"` + ClaimedBy ActorIdentity `json:"claimed_by"` +} + +type runTransitionPayload struct { + Status TaskRunStatus `json:"status"` + TaskStatus TaskStatus `json:"task_status"` + SessionID string `json:"session_id,omitempty"` +} + +type completedRunPayload struct { + Status TaskRunStatus `json:"status"` + TaskStatus TaskStatus `json:"task_status"` + Result json.RawMessage `json:"result,omitempty"` +} + +type failedRunPayload struct { + Status TaskRunStatus `json:"status"` + TaskStatus TaskStatus `json:"task_status"` + Error string `json:"error"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +type cancelledRunPayload struct { + Status TaskRunStatus `json:"status"` + TaskStatus TaskStatus `json:"task_status,omitempty"` + Reason string `json:"reason,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + SessionID string `json:"session_id,omitempty"` + PropagatedFromTaskID string `json:"propagated_from_task_id,omitempty"` + CooperativeStopRequested bool `json:"cooperative_stop_requested,omitempty"` +} + +type forceStoppedRunPayload struct { + SessionID string `json:"session_id"` + GraceTimeoutMillis int64 `json:"grace_timeout_ms"` + PropagatedFromTaskID string `json:"propagated_from_task_id,omitempty"` +} + +type rejectedRunPayload struct { + Operation string `json:"operation"` + Reason string `json:"reason"` + NetworkChannel string `json:"network_channel,omitempty"` +} + +type recoveredRunPayload struct { + Action RunBootRecoveryAction `json:"action"` + PreviousStatus TaskRunStatus `json:"previous_status"` + Status TaskRunStatus `json:"status"` + TaskStatus TaskStatus `json:"task_status"` + Reason string `json:"reason,omitempty"` + SessionID string `json:"session_id,omitempty"` + SessionState string `json:"session_state,omitempty"` +} diff --git a/internal/task/manager_integration_test.go b/internal/task/manager_integration_test.go new file mode 100644 index 000000000..08994c814 --- /dev/null +++ b/internal/task/manager_integration_test.go @@ -0,0 +1,557 @@ +//go:build integration + +package task_test + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/pedronauck/agh/internal/store/globaldb" + taskpkg "github.com/pedronauck/agh/internal/task" + "github.com/pedronauck/agh/internal/testutil" + aghworkspace "github.com/pedronauck/agh/internal/workspace" +) + +type integrationStopCall struct { + SessionID string + Reason taskpkg.StopReason +} + +type integrationSessionExecutor struct { + startCalls []taskpkg.StartTaskSession + requestStopCalls []integrationStopCall + forceStopCalls []integrationStopCall +} + +func (e *integrationSessionExecutor) StartTaskSession(_ context.Context, spec taskpkg.StartTaskSession) (*taskpkg.SessionRef, error) { + e.startCalls = append(e.startCalls, spec) + return &taskpkg.SessionRef{SessionID: "sess-int-" + strconv.Itoa(len(e.startCalls))}, nil +} + +func (e *integrationSessionExecutor) AttachTaskSession(_ context.Context, runID string, sessionID string) (*taskpkg.SessionRef, error) { + return &taskpkg.SessionRef{SessionID: sessionID}, nil +} + +func (e *integrationSessionExecutor) RequestTaskStop(_ context.Context, sessionID string, reason taskpkg.StopReason) error { + e.requestStopCalls = append(e.requestStopCalls, integrationStopCall{SessionID: sessionID, Reason: reason}) + return nil +} + +func (e *integrationSessionExecutor) ForceTaskStop(_ context.Context, sessionID string, reason taskpkg.StopReason) error { + e.forceStopCalls = append(e.forceStopCalls, integrationStopCall{SessionID: sessionID, Reason: reason}) + return nil +} + +func TestTaskManagerCreateTaskPersistsAgentSessionIdentity(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t) + db := openTaskManagerGlobalDB(t) + manager := newTaskManagerIntegration(t, db) + + actor, err := taskpkg.DeriveAgentSessionActorContext("sess-agent-1") + if err != nil { + t.Fatalf("DeriveAgentSessionActorContext() error = %v", err) + } + + created, err := manager.CreateTask(ctx, taskpkg.CreateTask{ + Scope: taskpkg.ScopeGlobal, + Title: "Investigate task manager", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + stored, err := db.GetTask(ctx, created.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if got, want := stored.CreatedBy.Kind, taskpkg.ActorKindAgentSession; got != want { + t.Fatalf("stored.CreatedBy.Kind = %q, want %q", got, want) + } + if got, want := stored.CreatedBy.Ref, "sess-agent-1"; got != want { + t.Fatalf("stored.CreatedBy.Ref = %q, want %q", got, want) + } + if got, want := stored.Origin.Kind, taskpkg.OriginKindAgentSession; got != want { + t.Fatalf("stored.Origin.Kind = %q, want %q", got, want) + } + if got, want := stored.Origin.Ref, "sess-agent-1"; got != want { + t.Fatalf("stored.Origin.Ref = %q, want %q", got, want) + } + if stored.Owner != nil { + t.Fatalf("stored.Owner = %#v, want nil", stored.Owner) + } + if got, want := stored.Status, taskpkg.TaskStatusReady; got != want { + t.Fatalf("stored.Status = %q, want %q", got, want) + } + + events, err := db.ListTaskEvents(ctx, taskpkg.TaskEventQuery{TaskID: stored.ID}) + if err != nil { + t.Fatalf("ListTaskEvents() error = %v", err) + } + if len(events) != 1 { + t.Fatalf("len(events) = %d, want 1", len(events)) + } + if got, want := events[0].EventType, "task.created"; got != want { + t.Fatalf("events[0].EventType = %q, want %q", got, want) + } + if got, want := events[0].Actor.Kind, taskpkg.ActorKindAgentSession; got != want { + t.Fatalf("events[0].Actor.Kind = %q, want %q", got, want) + } +} + +func TestTaskManagerCreateTaskPersistsAutomationLinkedAgentOrigin(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t) + db := openTaskManagerGlobalDB(t) + manager := newTaskManagerIntegration(t, db) + + actor, err := taskpkg.DeriveAutomationLinkedAgentSessionActorContext("sess-agent-2", "run:run-2") + if err != nil { + t.Fatalf("DeriveAutomationLinkedAgentSessionActorContext() error = %v", err) + } + + created, err := manager.CreateTask(ctx, taskpkg.CreateTask{ + Scope: taskpkg.ScopeGlobal, + Title: "Investigate automation-linked task creation", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + stored, err := db.GetTask(ctx, created.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if got, want := stored.CreatedBy.Kind, taskpkg.ActorKindAgentSession; got != want { + t.Fatalf("stored.CreatedBy.Kind = %q, want %q", got, want) + } + if got, want := stored.CreatedBy.Ref, "sess-agent-2"; got != want { + t.Fatalf("stored.CreatedBy.Ref = %q, want %q", got, want) + } + if got, want := stored.Origin.Kind, taskpkg.OriginKindAutomation; got != want { + t.Fatalf("stored.Origin.Kind = %q, want %q", got, want) + } + if got, want := stored.Origin.Ref, "run:run-2"; got != want { + t.Fatalf("stored.Origin.Ref = %q, want %q", got, want) + } +} + +func TestTaskManagerChildAndDependencyFlowsPersistAudit(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t) + db := openTaskManagerGlobalDB(t) + manager := newTaskManagerIntegration(t, db) + workspaceID := registerTaskManagerWorkspace(t, db, "task-manager-integration", filepath.Join(t.TempDir(), "workspace")) + + actor, err := taskpkg.DeriveHumanActorContext("user-1", taskpkg.OriginKindCLI, "agh task create") + if err != nil { + t.Fatalf("DeriveHumanActorContext() error = %v", err) + } + + parent, err := manager.CreateTask(ctx, taskpkg.CreateTask{ + Scope: taskpkg.ScopeGlobal, + Title: "Coordinator", + }, actor) + if err != nil { + t.Fatalf("CreateTask(parent) error = %v", err) + } + child, err := manager.CreateChildTask(ctx, parent.ID, taskpkg.CreateTask{ + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: workspaceID, + Title: "Workspace child", + Owner: &taskpkg.Ownership{Kind: taskpkg.OwnerKindPool, Ref: "triage"}, + }, actor) + if err != nil { + t.Fatalf("CreateChildTask() error = %v", err) + } + blocker, err := manager.CreateTask(ctx, taskpkg.CreateTask{ + Scope: taskpkg.ScopeWorkspace, + WorkspaceID: workspaceID, + Title: "Blocking task", + NetworkChannel: "ops", + }, actor) + if err != nil { + t.Fatalf("CreateTask(blocker) error = %v", err) + } + + if err := manager.AddDependency(ctx, taskpkg.AddDependency{ + TaskID: child.ID, + DependsOnTaskID: blocker.ID, + Kind: taskpkg.DependencyKindBlocks, + }, actor); err != nil { + t.Fatalf("AddDependency() error = %v", err) + } + + storedChild, err := db.GetTask(ctx, child.ID) + if err != nil { + t.Fatalf("GetTask(child) error = %v", err) + } + if got, want := storedChild.ParentTaskID, parent.ID; got != want { + t.Fatalf("storedChild.ParentTaskID = %q, want %q", got, want) + } + if got, want := storedChild.Status, taskpkg.TaskStatusBlocked; got != want { + t.Fatalf("storedChild.Status = %q, want %q", got, want) + } + + dependencies, err := db.ListDependencies(ctx, child.ID) + if err != nil { + t.Fatalf("ListDependencies(child) error = %v", err) + } + if len(dependencies) != 1 { + t.Fatalf("len(dependencies) = %d, want 1", len(dependencies)) + } + if got, want := dependencies[0].DependsOnTaskID, blocker.ID; got != want { + t.Fatalf("dependencies[0].DependsOnTaskID = %q, want %q", got, want) + } + + childEvents, err := db.ListTaskEvents(ctx, taskpkg.TaskEventQuery{TaskID: child.ID}) + if err != nil { + t.Fatalf("ListTaskEvents(child) error = %v", err) + } + if !testutil.EqualStringSlices(sortedEventTypes(childEvents), []string{"task.created", "task.dependency_added"}) { + t.Fatalf("child event types = %#v, want task.created + task.dependency_added", sortedEventTypes(childEvents)) + } + + parentEvents, err := db.ListTaskEvents(ctx, taskpkg.TaskEventQuery{TaskID: parent.ID}) + if err != nil { + t.Fatalf("ListTaskEvents(parent) error = %v", err) + } + if !containsEventType(parentEvents, "task.child_created") { + t.Fatalf("parent events = %#v, want task.child_created", sortedEventTypes(parentEvents)) + } + + view, err := manager.GetTask(ctx, child.ID, actor) + if err != nil { + t.Fatalf("GetTask(view) error = %v", err) + } + if got, want := len(view.Dependencies), 1; got != want { + t.Fatalf("len(view.Dependencies) = %d, want %d", got, want) + } + if got, want := view.Task.Status, taskpkg.TaskStatusBlocked; got != want { + t.Fatalf("view.Task.Status = %q, want %q", got, want) + } +} + +func TestTaskManagerRunLifecyclePersistsAndReconcilesAgainstStorage(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t) + db := openTaskManagerGlobalDB(t) + executor := &integrationSessionExecutor{} + manager := newTaskManagerIntegration(t, db, taskpkg.WithSessionExecutor(executor)) + actor, err := taskpkg.DeriveHumanActorContext("user-1", taskpkg.OriginKindCLI, "agh task run") + if err != nil { + t.Fatalf("DeriveHumanActorContext() error = %v", err) + } + + taskRecord, err := manager.CreateTask(ctx, taskpkg.CreateTask{ + Scope: taskpkg.ScopeGlobal, + Title: "Lifecycle integration", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + run, err := manager.EnqueueRun(ctx, taskpkg.EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun() error = %v", err) + } + storedRun, err := db.GetTaskRun(ctx, run.ID) + if err != nil { + t.Fatalf("GetTaskRun(queued) error = %v", err) + } + if got, want := storedRun.Status, taskpkg.TaskRunStatusQueued; got != want { + t.Fatalf("queued run status = %q, want %q", got, want) + } + + run, err = manager.ClaimRun(ctx, run.ID, taskpkg.ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun() error = %v", err) + } + if got, want := run.Status, taskpkg.TaskRunStatusClaimed; got != want { + t.Fatalf("claimed run status = %q, want %q", got, want) + } + storedTask, err := db.GetTask(ctx, taskRecord.ID) + if err != nil { + t.Fatalf("GetTask(claimed) error = %v", err) + } + if got, want := storedTask.Status, taskpkg.TaskStatusReady; got != want { + t.Fatalf("task status after claim = %q, want %q", got, want) + } + + run, err = manager.StartRun(ctx, run.ID, taskpkg.StartRun{}, actor) + if err != nil { + t.Fatalf("StartRun() error = %v", err) + } + if got, want := run.Status, taskpkg.TaskRunStatusRunning; got != want { + t.Fatalf("running run status = %q, want %q", got, want) + } + if got := run.SessionID; got == "" { + t.Fatal("run.SessionID = empty, want dedicated session id") + } + storedTask, err = db.GetTask(ctx, taskRecord.ID) + if err != nil { + t.Fatalf("GetTask(running) error = %v", err) + } + if got, want := storedTask.Status, taskpkg.TaskStatusInProgress; got != want { + t.Fatalf("task status after start = %q, want %q", got, want) + } + + run, err = manager.CompleteRun(ctx, run.ID, taskpkg.RunResult{ + Value: json.RawMessage(`{"result":"ok"}`), + }, actor) + if err != nil { + t.Fatalf("CompleteRun() error = %v", err) + } + if got, want := run.Status, taskpkg.TaskRunStatusCompleted; got != want { + t.Fatalf("completed run status = %q, want %q", got, want) + } + storedTask, err = db.GetTask(ctx, taskRecord.ID) + if err != nil { + t.Fatalf("GetTask(completed) error = %v", err) + } + if got, want := storedTask.Status, taskpkg.TaskStatusCompleted; got != want { + t.Fatalf("task status after complete = %q, want %q", got, want) + } + + events, err := db.ListTaskEvents(ctx, taskpkg.TaskEventQuery{TaskID: taskRecord.ID}) + if err != nil { + t.Fatalf("ListTaskEvents() error = %v", err) + } + wantTypes := []string{ + "task.created", + "task.run_claimed", + "task.run_completed", + "task.run_enqueued", + "task.run_started", + "task.run_starting", + } + if !testutil.EqualStringSlices(sortedEventTypes(events), wantTypes) { + t.Fatalf("event types = %#v, want %#v", sortedEventTypes(events), wantTypes) + } +} + +func TestTaskManagerCancelTaskTreePersistsCancellationAudit(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t) + db := openTaskManagerGlobalDB(t) + executor := &integrationSessionExecutor{} + manager := newTaskManagerIntegration( + t, + db, + taskpkg.WithSessionExecutor(executor), + taskpkg.WithCancelGracePeriod(0), + ) + actor, err := taskpkg.DeriveHumanActorContext("user-1", taskpkg.OriginKindCLI, "agh task cancel") + if err != nil { + t.Fatalf("DeriveHumanActorContext() error = %v", err) + } + + parent, err := manager.CreateTask(ctx, taskpkg.CreateTask{ + Scope: taskpkg.ScopeGlobal, + Title: "Cancellation parent", + }, actor) + if err != nil { + t.Fatalf("CreateTask(parent) error = %v", err) + } + queuedChild, err := manager.CreateChildTask(ctx, parent.ID, taskpkg.CreateTask{ + Scope: taskpkg.ScopeGlobal, + Title: "Queued child", + }, actor) + if err != nil { + t.Fatalf("CreateChildTask(queued child) error = %v", err) + } + activeChild, err := manager.CreateChildTask(ctx, parent.ID, taskpkg.CreateTask{ + Scope: taskpkg.ScopeGlobal, + Title: "Active child", + }, actor) + if err != nil { + t.Fatalf("CreateChildTask(active child) error = %v", err) + } + + queuedRun, err := manager.EnqueueRun(ctx, taskpkg.EnqueueRun{TaskID: queuedChild.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun(queued child) error = %v", err) + } + activeRun, err := manager.EnqueueRun(ctx, taskpkg.EnqueueRun{TaskID: activeChild.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun(active child) error = %v", err) + } + activeRun, err = manager.ClaimRun(ctx, activeRun.ID, taskpkg.ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun(active child) error = %v", err) + } + activeRun, err = manager.StartRun(ctx, activeRun.ID, taskpkg.StartRun{}, actor) + if err != nil { + t.Fatalf("StartRun(active child) error = %v", err) + } + + cancelledParent, err := manager.CancelTask(ctx, parent.ID, taskpkg.CancelTask{ + Reason: "stop tree", + }, actor) + if err != nil { + t.Fatalf("CancelTask() error = %v", err) + } + if got, want := cancelledParent.Status, taskpkg.TaskStatusCancelled; got != want { + t.Fatalf("cancelled parent status = %q, want %q", got, want) + } + + for _, taskID := range []string{parent.ID, queuedChild.ID, activeChild.ID} { + record, err := db.GetTask(ctx, taskID) + if err != nil { + t.Fatalf("GetTask(%q) error = %v", taskID, err) + } + if got, want := record.Status, taskpkg.TaskStatusCancelled; got != want { + t.Fatalf("task %q status = %q, want %q", taskID, got, want) + } + } + + storedQueuedRun, err := db.GetTaskRun(ctx, queuedRun.ID) + if err != nil { + t.Fatalf("GetTaskRun(queued) error = %v", err) + } + if got, want := storedQueuedRun.Status, taskpkg.TaskRunStatusCancelled; got != want { + t.Fatalf("queued child run status = %q, want %q", got, want) + } + storedActiveRun, err := db.GetTaskRun(ctx, activeRun.ID) + if err != nil { + t.Fatalf("GetTaskRun(active) error = %v", err) + } + if got, want := storedActiveRun.Status, taskpkg.TaskRunStatusCancelled; got != want { + t.Fatalf("active child run status = %q, want %q", got, want) + } + + if len(executor.requestStopCalls) != 1 { + t.Fatalf("len(requestStopCalls) = %d, want 1", len(executor.requestStopCalls)) + } + if got, want := executor.requestStopCalls[0].SessionID, activeRun.SessionID; got != want { + t.Fatalf("requestStopCalls[0].SessionID = %q, want %q", got, want) + } + if len(executor.forceStopCalls) != 1 { + t.Fatalf("len(forceStopCalls) = %d, want 1", len(executor.forceStopCalls)) + } + + parentEvents, err := db.ListTaskEvents(ctx, taskpkg.TaskEventQuery{TaskID: parent.ID}) + if err != nil { + t.Fatalf("ListTaskEvents(parent) error = %v", err) + } + if !containsEventType(parentEvents, "task.cancelled") { + t.Fatalf("parent event types = %#v, want task.cancelled", sortedEventTypes(parentEvents)) + } + + activeChildEvents, err := db.ListTaskEvents(ctx, taskpkg.TaskEventQuery{TaskID: activeChild.ID}) + if err != nil { + t.Fatalf("ListTaskEvents(active child) error = %v", err) + } + if !containsEventType(activeChildEvents, "task.run_cancelled") { + t.Fatalf("active child event types = %#v, want task.run_cancelled", sortedEventTypes(activeChildEvents)) + } + if !containsEventType(activeChildEvents, "task.run_force_stopped") { + t.Fatalf("active child event types = %#v, want task.run_force_stopped", sortedEventTypes(activeChildEvents)) + } +} + +func openTaskManagerGlobalDB(t *testing.T) *globaldb.GlobalDB { + t.Helper() + + ctx := testutil.Context(t) + dbPath := filepath.Join(t.TempDir(), "agh.db") + db, err := globaldb.OpenGlobalDB(ctx, dbPath) + if err != nil { + t.Fatalf("OpenGlobalDB() error = %v", err) + } + t.Cleanup(func() { + if err := db.Close(ctx); err != nil { + t.Fatalf("GlobalDB.Close() error = %v", err) + } + }) + return db +} + +func newTaskManagerIntegration(t *testing.T, store taskpkg.Store, extraOpts ...taskpkg.Option) *taskpkg.TaskManager { + t.Helper() + + options := []taskpkg.Option{taskpkg.WithStore(store)} + options = append(options, extraOpts...) + manager, err := taskpkg.NewManager(options...) + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + return manager +} + +func registerTaskManagerWorkspace(t *testing.T, db *globaldb.GlobalDB, name string, rootDir string) string { + t.Helper() + + if err := os.MkdirAll(rootDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%q) error = %v", rootDir, err) + } + + workspace := aghworkspace.Workspace{ + ID: "ws-" + strings.ReplaceAll(name, " ", "-"), + RootDir: rootDir, + Name: name, + CreatedAt: time.Date(2026, 4, 14, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 14, 10, 0, 0, 0, time.UTC), + } + if err := db.InsertWorkspace(testutil.Context(t), workspace); err != nil { + t.Fatalf("InsertWorkspace() error = %v", err) + } + return workspace.ID +} + +func sortedEventTypes(events []taskpkg.TaskEvent) []string { + types := make([]string, 0, len(events)) + for _, event := range events { + types = append(types, event.EventType) + } + sort.Strings(types) + return types +} + +func containsEventType(events []taskpkg.TaskEvent, want string) bool { + for _, event := range events { + if event.EventType == want { + return true + } + } + return false +} + +func TestTaskManagerGetTaskRequiresReadAuthorityIntegration(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t) + db := openTaskManagerGlobalDB(t) + manager := newTaskManagerIntegration(t, db) + actor, err := taskpkg.DeriveHumanActorContext("user-1", taskpkg.OriginKindCLI, "agh task create") + if err != nil { + t.Fatalf("DeriveHumanActorContext() error = %v", err) + } + taskRecord, err := manager.CreateTask(ctx, taskpkg.CreateTask{ + Scope: taskpkg.ScopeGlobal, + Title: "Read auth check", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + denied := actor + denied.Authority.Read = false + _, err = manager.GetTask(ctx, taskRecord.ID, denied) + if !errors.Is(err, taskpkg.ErrPermissionDenied) { + t.Fatalf("GetTask(no read) error = %v, want %v", err, taskpkg.ErrPermissionDenied) + } +} diff --git a/internal/task/manager_test.go b/internal/task/manager_test.go new file mode 100644 index 000000000..1f83fe7f0 --- /dev/null +++ b/internal/task/manager_test.go @@ -0,0 +1,2414 @@ +package task + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "testing" + "time" +) + +type inMemoryManagerStore struct { + tasks map[string]Task + dependencies map[string]map[string]TaskDependency + runs map[string]TaskRun + events []TaskEvent + idempotencyByKey map[string]TaskRunIdempotency +} + +type testSessionExecutor struct{} + +func (testSessionExecutor) StartTaskSession(context.Context, StartTaskSession) (*SessionRef, error) { + return &SessionRef{SessionID: "sess-test"}, nil +} + +func (testSessionExecutor) AttachTaskSession(context.Context, string, string) (*SessionRef, error) { + return &SessionRef{SessionID: "sess-test"}, nil +} + +func (testSessionExecutor) RequestTaskStop(context.Context, string, StopReason) error { + return nil +} + +func (testSessionExecutor) ForceTaskStop(context.Context, string, StopReason) error { + return nil +} + +type sessionStopCall struct { + SessionID string + Reason StopReason +} + +type attachSessionCall struct { + RunID string + SessionID string +} + +type recordingSessionExecutor struct { + startCalls []StartTaskSession + attachCalls []attachSessionCall + requestStopCalls []sessionStopCall + forceStopCalls []sessionStopCall + startRef *SessionRef + returnNilStart bool + startErr error + attachErr error + requestStopErr error + forceStopErr error +} + +func (e *recordingSessionExecutor) StartTaskSession(_ context.Context, spec StartTaskSession) (*SessionRef, error) { + e.startCalls = append(e.startCalls, spec) + if e.startErr != nil { + return nil, e.startErr + } + if e.returnNilStart { + return nil, nil + } + if e.startRef != nil { + ref := *e.startRef + return &ref, nil + } + return &SessionRef{SessionID: "sess-start-" + strconv.Itoa(len(e.startCalls))}, nil +} + +func (e *recordingSessionExecutor) AttachTaskSession(_ context.Context, runID string, sessionID string) (*SessionRef, error) { + e.attachCalls = append(e.attachCalls, attachSessionCall{RunID: runID, SessionID: sessionID}) + if e.attachErr != nil { + return nil, e.attachErr + } + return &SessionRef{SessionID: sessionID}, nil +} + +func (e *recordingSessionExecutor) RequestTaskStop(_ context.Context, sessionID string, reason StopReason) error { + e.requestStopCalls = append(e.requestStopCalls, sessionStopCall{SessionID: sessionID, Reason: reason}) + return e.requestStopErr +} + +func (e *recordingSessionExecutor) ForceTaskStop(_ context.Context, sessionID string, reason StopReason) error { + e.forceStopCalls = append(e.forceStopCalls, sessionStopCall{SessionID: sessionID, Reason: reason}) + return e.forceStopErr +} + +func newInMemoryManagerStore() *inMemoryManagerStore { + return &inMemoryManagerStore{ + tasks: make(map[string]Task), + dependencies: make(map[string]map[string]TaskDependency), + runs: make(map[string]TaskRun), + events: make([]TaskEvent, 0), + idempotencyByKey: make(map[string]TaskRunIdempotency), + } +} + +func (s *inMemoryManagerStore) CreateTask(_ context.Context, taskRecord Task) error { + if _, exists := s.tasks[taskRecord.ID]; exists { + return fmtTestError("%w: duplicate task %q", ErrValidation, taskRecord.ID) + } + s.tasks[taskRecord.ID] = cloneTask(taskRecord) + return nil +} + +func (s *inMemoryManagerStore) UpdateTask(_ context.Context, taskRecord Task) error { + if _, exists := s.tasks[taskRecord.ID]; !exists { + return ErrTaskNotFound + } + s.tasks[taskRecord.ID] = cloneTask(taskRecord) + return nil +} + +func (s *inMemoryManagerStore) GetTask(_ context.Context, id string) (Task, error) { + record, ok := s.tasks[strings.TrimSpace(id)] + if !ok { + return Task{}, ErrTaskNotFound + } + return cloneTask(record), nil +} + +func (s *inMemoryManagerStore) ListTasks(_ context.Context, query TaskQuery) ([]TaskSummary, error) { + if err := query.Validate("task_query"); err != nil { + return nil, err + } + + normalized := query + normalized.Scope = normalized.Scope.Normalize() + normalized.WorkspaceID = strings.TrimSpace(normalized.WorkspaceID) + normalized.Status = normalized.Status.Normalize() + normalized.OwnerKind = normalized.OwnerKind.Normalize() + normalized.OwnerRef = strings.TrimSpace(normalized.OwnerRef) + normalized.ParentTaskID = strings.TrimSpace(normalized.ParentTaskID) + normalized.NetworkChannel = strings.TrimSpace(normalized.NetworkChannel) + + summaries := make([]TaskSummary, 0) + for _, record := range s.tasks { + if normalized.Scope.Normalize() != "" && record.Scope != normalized.Scope { + continue + } + if normalized.WorkspaceID != "" && record.WorkspaceID != normalized.WorkspaceID { + continue + } + if normalized.Status.Normalize() != "" && record.Status != normalized.Status { + continue + } + if normalized.OwnerKind.Normalize() != "" { + if record.Owner == nil || record.Owner.Kind != normalized.OwnerKind { + continue + } + } + if normalized.OwnerRef != "" { + if record.Owner == nil || record.Owner.Ref != normalized.OwnerRef { + continue + } + } + if normalized.ParentTaskID != "" && record.ParentTaskID != normalized.ParentTaskID { + continue + } + if normalized.NetworkChannel != "" && record.NetworkChannel != normalized.NetworkChannel { + continue + } + summaries = append(summaries, TaskSummary{ + ID: record.ID, + Identifier: record.Identifier, + Scope: record.Scope, + WorkspaceID: record.WorkspaceID, + ParentTaskID: record.ParentTaskID, + NetworkChannel: record.NetworkChannel, + Title: record.Title, + Status: record.Status, + Owner: cloneOwnership(record.Owner), + CreatedBy: record.CreatedBy, + Origin: record.Origin, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + ClosedAt: record.ClosedAt, + }) + } + + sort.Slice(summaries, func(i int, j int) bool { + return summaries[i].ID < summaries[j].ID + }) + if normalized.Limit > 0 && len(summaries) > normalized.Limit { + return append([]TaskSummary(nil), summaries[:normalized.Limit]...), nil + } + return append([]TaskSummary(nil), summaries...), nil +} + +func (s *inMemoryManagerStore) CountDirectChildren(_ context.Context, parentTaskID string) (int, error) { + count := 0 + for _, record := range s.tasks { + if record.ParentTaskID == strings.TrimSpace(parentTaskID) { + count++ + } + } + return count, nil +} + +func (s *inMemoryManagerStore) CreateDependency(_ context.Context, dependency TaskDependency) error { + if _, ok := s.tasks[dependency.TaskID]; !ok { + return ErrTaskNotFound + } + if _, ok := s.tasks[dependency.DependsOnTaskID]; !ok { + return ErrTaskNotFound + } + if s.dependencies[dependency.TaskID] == nil { + s.dependencies[dependency.TaskID] = make(map[string]TaskDependency) + } + s.dependencies[dependency.TaskID][dependency.DependsOnTaskID] = dependency + return nil +} + +func (s *inMemoryManagerStore) DeleteDependency(_ context.Context, taskID string, dependsOnID string) error { + taskDeps := s.dependencies[strings.TrimSpace(taskID)] + if taskDeps == nil { + return ErrTaskDependencyNotFound + } + if _, ok := taskDeps[strings.TrimSpace(dependsOnID)]; !ok { + return ErrTaskDependencyNotFound + } + delete(taskDeps, strings.TrimSpace(dependsOnID)) + return nil +} + +func (s *inMemoryManagerStore) ListDependencies(_ context.Context, taskID string) ([]TaskDependency, error) { + taskDeps := s.dependencies[strings.TrimSpace(taskID)] + if len(taskDeps) == 0 { + return nil, nil + } + + dependencies := make([]TaskDependency, 0, len(taskDeps)) + for _, dependency := range taskDeps { + dependencies = append(dependencies, dependency) + } + sort.Slice(dependencies, func(i int, j int) bool { + return dependencies[i].DependsOnTaskID < dependencies[j].DependsOnTaskID + }) + return dependencies, nil +} + +func (s *inMemoryManagerStore) ListDependents(_ context.Context, dependsOnTaskID string) ([]TaskDependency, error) { + dependents := make([]TaskDependency, 0) + for _, taskDeps := range s.dependencies { + if dependency, ok := taskDeps[strings.TrimSpace(dependsOnTaskID)]; ok { + dependents = append(dependents, dependency) + } + } + sort.Slice(dependents, func(i int, j int) bool { + return dependents[i].TaskID < dependents[j].TaskID + }) + return dependents, nil +} + +func (s *inMemoryManagerStore) CountDependencies(_ context.Context, taskID string) (int, error) { + return len(s.dependencies[strings.TrimSpace(taskID)]), nil +} + +func (s *inMemoryManagerStore) HasDependencyPath(_ context.Context, fromTaskID string, toTaskID string) (bool, error) { + visited := make(map[string]struct{}) + var walk func(string) bool + walk = func(current string) bool { + if current == strings.TrimSpace(toTaskID) { + return true + } + if _, seen := visited[current]; seen { + return false + } + visited[current] = struct{}{} + for _, dependency := range s.dependencies[current] { + if walk(dependency.DependsOnTaskID) { + return true + } + } + return false + } + return walk(strings.TrimSpace(fromTaskID)), nil +} + +func (s *inMemoryManagerStore) CreateTaskRun(_ context.Context, run TaskRun) error { + s.runs[run.ID] = cloneTaskRun(run) + return nil +} + +func (s *inMemoryManagerStore) UpdateTaskRun(_ context.Context, run TaskRun) error { + s.runs[run.ID] = cloneTaskRun(run) + return nil +} + +func (s *inMemoryManagerStore) GetTaskRun(_ context.Context, id string) (TaskRun, error) { + run, ok := s.runs[strings.TrimSpace(id)] + if !ok { + return TaskRun{}, ErrTaskRunNotFound + } + return cloneTaskRun(run), nil +} + +func (s *inMemoryManagerStore) ListTaskRuns(_ context.Context, query TaskRunQuery) ([]TaskRun, error) { + if err := query.Validate("task_run_query"); err != nil { + return nil, err + } + + normalized := query + normalized.TaskID = strings.TrimSpace(normalized.TaskID) + normalized.Status = normalized.Status.Normalize() + normalized.SessionID = strings.TrimSpace(normalized.SessionID) + + runs := make([]TaskRun, 0) + for _, run := range s.runs { + if normalized.TaskID != "" && run.TaskID != normalized.TaskID { + continue + } + if normalized.Status.Normalize() != "" && run.Status != normalized.Status { + continue + } + if normalized.SessionID != "" && run.SessionID != normalized.SessionID { + continue + } + runs = append(runs, cloneTaskRun(run)) + } + sort.Slice(runs, func(i int, j int) bool { + return runs[i].ID < runs[j].ID + }) + if normalized.Limit > 0 && len(runs) > normalized.Limit { + return append([]TaskRun(nil), runs[:normalized.Limit]...), nil + } + return append([]TaskRun(nil), runs...), nil +} + +func (s *inMemoryManagerStore) ListTaskRunsByStatus(_ context.Context, statuses []TaskRunStatus) ([]TaskRun, error) { + allowed := make(map[TaskRunStatus]struct{}, len(statuses)) + for _, status := range statuses { + allowed[status.Normalize()] = struct{}{} + } + runs := make([]TaskRun, 0) + for _, run := range s.runs { + if _, ok := allowed[run.Status.Normalize()]; ok { + runs = append(runs, cloneTaskRun(run)) + } + } + return runs, nil +} + +func (s *inMemoryManagerStore) CountActiveSessionBindings(_ context.Context, sessionID string) (int, error) { + count := 0 + for _, run := range s.runs { + if run.SessionID == strings.TrimSpace(sessionID) && run.EndedAt.IsZero() { + count++ + } + } + return count, nil +} + +func (s *inMemoryManagerStore) CreateTaskEvent(_ context.Context, event TaskEvent) error { + if _, ok := s.tasks[event.TaskID]; !ok { + return ErrTaskNotFound + } + s.events = append(s.events, event) + sort.Slice(s.events, func(i int, j int) bool { + if s.events[i].Timestamp.Equal(s.events[j].Timestamp) { + return s.events[i].ID > s.events[j].ID + } + return s.events[i].Timestamp.After(s.events[j].Timestamp) + }) + return nil +} + +func (s *inMemoryManagerStore) ListTaskEvents(_ context.Context, query TaskEventQuery) ([]TaskEvent, error) { + if err := query.Validate("task_event_query"); err != nil { + return nil, err + } + events := make([]TaskEvent, 0) + for _, event := range s.events { + if query.TaskID != "" && event.TaskID != strings.TrimSpace(query.TaskID) { + continue + } + if query.RunID != "" && event.RunID != strings.TrimSpace(query.RunID) { + continue + } + if query.EventType != "" && event.EventType != strings.TrimSpace(query.EventType) { + continue + } + events = append(events, event) + } + if query.Limit > 0 && len(events) > query.Limit { + return append([]TaskEvent(nil), events[:query.Limit]...), nil + } + return append([]TaskEvent(nil), events...), nil +} + +func (s *inMemoryManagerStore) GetTaskRunByIdempotencyKey(_ context.Context, key string, origin Origin) (TaskRun, error) { + record, ok := s.idempotencyByKey[idempotencyKey(origin, key)] + if !ok { + return TaskRun{}, ErrTaskRunIdempotencyNotFound + } + return s.GetTaskRun(context.Background(), record.RunID) +} + +func (s *inMemoryManagerStore) SaveTaskRunIdempotency(_ context.Context, record TaskRunIdempotency) error { + s.idempotencyByKey[idempotencyKey(record.Origin, record.IdempotencyKey)] = record + return nil +} + +func TestDeriveActorContextsForSupportedSurfaces(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + derive func() (ActorContext, error) + want ActorContext + wantErr error + }{ + { + name: "human cli", + derive: func() (ActorContext, error) { + return DeriveHumanActorContext("user-1", OriginKindCLI, "agh task create") + }, + want: ActorContext{ + Actor: ActorIdentity{Kind: ActorKindHuman, Ref: "user-1"}, + Origin: Origin{Kind: OriginKindCLI, Ref: "agh task create"}, + Authority: FullAccessAuthority(), + }, + }, + { + name: "agent session", + derive: func() (ActorContext, error) { + return DeriveAgentSessionActorContext("sess-1") + }, + want: ActorContext{ + Actor: ActorIdentity{Kind: ActorKindAgentSession, Ref: "sess-1"}, + Origin: Origin{Kind: OriginKindAgentSession, Ref: "sess-1"}, + Authority: FullAccessAuthority(), + }, + }, + { + name: "automation-linked agent session", + derive: func() (ActorContext, error) { + return DeriveAutomationLinkedAgentSessionActorContext("sess-1", "run:run-1") + }, + want: ActorContext{ + Actor: ActorIdentity{Kind: ActorKindAgentSession, Ref: "sess-1"}, + Origin: Origin{Kind: OriginKindAutomation, Ref: "run:run-1"}, + Authority: FullAccessAuthority(), + }, + }, + { + name: "automation", + derive: func() (ActorContext, error) { + return DeriveAutomationActorContext("rule:nightly", "") + }, + want: ActorContext{ + Actor: ActorIdentity{Kind: ActorKindAutomation, Ref: "rule:nightly"}, + Origin: Origin{Kind: OriginKindAutomation, Ref: "rule:nightly"}, + Authority: FullAccessAuthority(), + }, + }, + { + name: "extension", + derive: func() (ActorContext, error) { + return DeriveExtensionActorContext("ext.telegram", "cap.task.write") + }, + want: ActorContext{ + Actor: ActorIdentity{Kind: ActorKindExtension, Ref: "ext.telegram"}, + Origin: Origin{Kind: OriginKindExtension, Ref: "cap.task.write"}, + Authority: FullAccessAuthority(), + }, + }, + { + name: "network peer", + derive: func() (ActorContext, error) { + return DeriveNetworkPeerActorContext("peer:finance", "peer:finance/ops") + }, + want: ActorContext{ + Actor: ActorIdentity{Kind: ActorKindNetworkPeer, Ref: "peer:finance"}, + Origin: Origin{Kind: OriginKindNetwork, Ref: "peer:finance/ops"}, + Authority: FullAccessAuthority(), + }, + }, + { + name: "human invalid origin", + derive: func() (ActorContext, error) { + return DeriveHumanActorContext("user-1", OriginKindAutomation, "rule:nightly") + }, + wantErr: ErrValidation, + }, + { + name: "manual actor origin mismatch rejected", + derive: func() (ActorContext, error) { + ctx := validActorContext() + ctx.Actor.Kind = ActorKindHuman + ctx.Origin.Kind = OriginKindAutomation + return ctx, ctx.Validate() + }, + wantErr: ErrValidation, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := tt.derive() + if tt.wantErr == nil { + if err != nil { + t.Fatalf("derive() error = %v", err) + } + if got != tt.want { + t.Fatalf("derive() = %#v, want %#v", got, tt.want) + } + return + } + if err == nil { + t.Fatal("derive() error = nil, want non-nil") + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("derive() error = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestManagerCreateTaskUsesTrustedActorContext(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + manager := newTaskManagerForTest(t, store) + actor, err := DeriveAgentSessionActorContext("sess-123") + if err != nil { + t.Fatalf("DeriveAgentSessionActorContext() error = %v", err) + } + + created, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Investigate task manager", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + if got, want := created.CreatedBy, actor.Actor; got != want { + t.Fatalf("created.CreatedBy = %#v, want %#v", got, want) + } + if got, want := created.Origin, actor.Origin; got != want { + t.Fatalf("created.Origin = %#v, want %#v", got, want) + } + if created.Owner != nil { + t.Fatalf("created.Owner = %#v, want nil", created.Owner) + } + if got, want := created.Status, TaskStatusReady; got != want { + t.Fatalf("created.Status = %q, want %q", got, want) + } + + events, err := store.ListTaskEvents(context.Background(), TaskEventQuery{TaskID: created.ID}) + if err != nil { + t.Fatalf("ListTaskEvents() error = %v", err) + } + if len(events) != 1 { + t.Fatalf("len(events) = %d, want 1", len(events)) + } + if got, want := events[0].EventType, taskEventCreated; got != want { + t.Fatalf("events[0].EventType = %q, want %q", got, want) + } + if got, want := events[0].Actor, actor.Actor; got != want { + t.Fatalf("events[0].Actor = %#v, want %#v", got, want) + } + if got, want := events[0].Origin, actor.Origin; got != want { + t.Fatalf("events[0].Origin = %#v, want %#v", got, want) + } +} + +func TestManagerCreateTaskEnforcesScopeAuthority(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec CreateTask + actor ActorContext + }{ + { + name: "global create denied without global authority", + spec: CreateTask{Scope: ScopeGlobal, Title: "Global task"}, + actor: ActorContext{ + Actor: ActorIdentity{Kind: ActorKindHuman, Ref: "user-1"}, + Origin: Origin{Kind: OriginKindCLI, Ref: "agh task create"}, + Authority: Authority{Read: true, Write: true, CreateWorkspace: true}, + }, + }, + { + name: "workspace create denied without workspace authority", + spec: CreateTask{Scope: ScopeWorkspace, WorkspaceID: "ws-1", Title: "Workspace task"}, + actor: ActorContext{ + Actor: ActorIdentity{Kind: ActorKindHuman, Ref: "user-1"}, + Origin: Origin{Kind: OriginKindCLI, Ref: "agh task create"}, + Authority: Authority{Read: true, Write: true, CreateGlobal: true}, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + manager := newTaskManagerForTest(t, newInMemoryManagerStore()) + _, err := manager.CreateTask(context.Background(), tt.spec, tt.actor) + if !errors.Is(err, ErrPermissionDenied) { + t.Fatalf("CreateTask() error = %v, want %v", err, ErrPermissionDenied) + } + }) + } +} + +func TestManagerUpdateTaskAllowsMutableOwnershipAndChannelFields(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + manager := newTaskManagerForTest(t, store) + actor := validActorContext() + + created, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeWorkspace, + WorkspaceID: "ws-1", + Title: "Queue task", + Description: "Unassigned", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + title := "Claimed task" + description := "Assigned to triage" + channel := "ops" + metadata := json.RawMessage(`{"priority":"high"}`) + updated, err := manager.UpdateTask(context.Background(), created.ID, TaskPatch{ + Title: &title, + Description: &description, + NetworkChannel: &channel, + Owner: &Ownership{Kind: OwnerKindPool, Ref: "triage"}, + Metadata: &metadata, + }, actor) + if err != nil { + t.Fatalf("UpdateTask(assign) error = %v", err) + } + + if got, want := updated.Title, title; got != want { + t.Fatalf("updated.Title = %q, want %q", got, want) + } + if got, want := updated.Description, description; got != want { + t.Fatalf("updated.Description = %q, want %q", got, want) + } + if got, want := updated.NetworkChannel, channel; got != want { + t.Fatalf("updated.NetworkChannel = %q, want %q", got, want) + } + if updated.Owner == nil || updated.Owner.Kind != OwnerKindPool || updated.Owner.Ref != "triage" { + t.Fatalf("updated.Owner = %#v, want pool/triage", updated.Owner) + } + if got, want := updated.Scope, created.Scope; got != want { + t.Fatalf("updated.Scope = %q, want %q", got, want) + } + if got, want := updated.WorkspaceID, created.WorkspaceID; got != want { + t.Fatalf("updated.WorkspaceID = %q, want %q", got, want) + } + if got, want := updated.ParentTaskID, created.ParentTaskID; got != want { + t.Fatalf("updated.ParentTaskID = %q, want %q", got, want) + } + if got, want := updated.CreatedBy, created.CreatedBy; got != want { + t.Fatalf("updated.CreatedBy = %#v, want %#v", got, want) + } + if got, want := updated.Origin, created.Origin; got != want { + t.Fatalf("updated.Origin = %#v, want %#v", got, want) + } + + clearChannel := "" + cleared, err := manager.UpdateTask(context.Background(), created.ID, TaskPatch{ + NetworkChannel: &clearChannel, + ClearOwner: true, + }, actor) + if err != nil { + t.Fatalf("UpdateTask(clear) error = %v", err) + } + if cleared.Owner != nil { + t.Fatalf("cleared.Owner = %#v, want nil", cleared.Owner) + } + if got := cleared.NetworkChannel; got != "" { + t.Fatalf("cleared.NetworkChannel = %q, want empty", got) + } +} + +func TestManagerUpdateTaskPreservesCanonicalBlockedStatus(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + manager := newTaskManagerForTest(t, store) + actor := validActorContext() + + taskA, err := manager.CreateTask(context.Background(), CreateTask{Scope: ScopeGlobal, Title: "task A"}, actor) + if err != nil { + t.Fatalf("CreateTask(taskA) error = %v", err) + } + taskB, err := manager.CreateTask(context.Background(), CreateTask{Scope: ScopeGlobal, Title: "task B"}, actor) + if err != nil { + t.Fatalf("CreateTask(taskB) error = %v", err) + } + if err := manager.AddDependency(context.Background(), AddDependency{ + TaskID: taskA.ID, + DependsOnTaskID: taskB.ID, + Kind: DependencyKindBlocks, + }, actor); err != nil { + t.Fatalf("AddDependency() error = %v", err) + } + + title := "task A renamed" + updated, err := manager.UpdateTask(context.Background(), taskA.ID, TaskPatch{ + Title: &title, + }, actor) + if err != nil { + t.Fatalf("UpdateTask() error = %v", err) + } + if got, want := updated.Status, TaskStatusBlocked; got != want { + t.Fatalf("updated.Status = %q, want %q", got, want) + } +} + +func TestManagerCreateChildTaskEnforcesParentRulesAndEmitsAudit(t *testing.T) { + t.Parallel() + + t.Run("global parent allows workspace child and emits parent event", func(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + manager := newTaskManagerForTest(t, store) + actor := validActorContext() + + parent, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Coordinator", + }, actor) + if err != nil { + t.Fatalf("CreateTask(parent) error = %v", err) + } + + child, err := manager.CreateChildTask(context.Background(), parent.ID, CreateTask{ + Scope: ScopeWorkspace, + WorkspaceID: "ws-1", + Title: "Workspace child", + }, actor) + if err != nil { + t.Fatalf("CreateChildTask() error = %v", err) + } + if got, want := child.ParentTaskID, parent.ID; got != want { + t.Fatalf("child.ParentTaskID = %q, want %q", got, want) + } + + events, err := store.ListTaskEvents(context.Background(), TaskEventQuery{TaskID: parent.ID}) + if err != nil { + t.Fatalf("ListTaskEvents(parent) error = %v", err) + } + if len(events) != 2 { + t.Fatalf("len(parent events) = %d, want 2", len(events)) + } + if got, want := events[0].EventType, taskEventChildCreated; got != want { + t.Fatalf("parent event type = %q, want %q", got, want) + } + }) + + t.Run("workspace parent rejects cross scope or cross workspace children", func(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + manager := newTaskManagerForTest(t, store) + actor := validActorContext() + + parent, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeWorkspace, + WorkspaceID: "ws-parent", + Title: "Workspace parent", + }, actor) + if err != nil { + t.Fatalf("CreateTask(parent) error = %v", err) + } + + _, err = manager.CreateChildTask(context.Background(), parent.ID, CreateTask{ + Scope: ScopeGlobal, + Title: "Invalid global child", + }, actor) + if !errors.Is(err, ErrValidation) { + t.Fatalf("CreateChildTask(global child) error = %v, want %v", err, ErrValidation) + } + + _, err = manager.CreateChildTask(context.Background(), parent.ID, CreateTask{ + Scope: ScopeWorkspace, + WorkspaceID: "ws-other", + Title: "Wrong workspace child", + }, actor) + if !errors.Is(err, ErrValidation) { + t.Fatalf("CreateChildTask(other workspace child) error = %v, want %v", err, ErrValidation) + } + }) +} + +func TestManagerAddAndRemoveDependencyReconcileStatusAndEvents(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + manager := newTaskManagerForTest(t, store) + actor := validActorContext() + + taskA, err := manager.CreateTask(context.Background(), CreateTask{Scope: ScopeGlobal, Title: "task A"}, actor) + if err != nil { + t.Fatalf("CreateTask(taskA) error = %v", err) + } + taskB, err := manager.CreateTask(context.Background(), CreateTask{Scope: ScopeGlobal, Title: "task B"}, actor) + if err != nil { + t.Fatalf("CreateTask(taskB) error = %v", err) + } + + if err := manager.AddDependency(context.Background(), AddDependency{ + TaskID: taskA.ID, + DependsOnTaskID: taskB.ID, + Kind: DependencyKindBlocks, + }, actor); err != nil { + t.Fatalf("AddDependency() error = %v", err) + } + + blocked, err := store.GetTask(context.Background(), taskA.ID) + if err != nil { + t.Fatalf("GetTask(blocked) error = %v", err) + } + if got, want := blocked.Status, TaskStatusBlocked; got != want { + t.Fatalf("blocked.Status = %q, want %q", got, want) + } + + if err := manager.RemoveDependency(context.Background(), taskA.ID, taskB.ID, actor); err != nil { + t.Fatalf("RemoveDependency() error = %v", err) + } + + ready, err := store.GetTask(context.Background(), taskA.ID) + if err != nil { + t.Fatalf("GetTask(ready) error = %v", err) + } + if got, want := ready.Status, TaskStatusReady; got != want { + t.Fatalf("ready.Status = %q, want %q", got, want) + } + + events, err := store.ListTaskEvents(context.Background(), TaskEventQuery{TaskID: taskA.ID}) + if err != nil { + t.Fatalf("ListTaskEvents(taskA) error = %v", err) + } + if len(events) != 3 { + t.Fatalf("len(taskA events) = %d, want 3", len(events)) + } + if got, want := events[0].EventType, taskEventDependencyRemoved; got != want { + t.Fatalf("events[0].EventType = %q, want %q", got, want) + } + if got, want := events[1].EventType, taskEventDependencyAdded; got != want { + t.Fatalf("events[1].EventType = %q, want %q", got, want) + } +} + +func TestManagerGetAndListTasksRequireReadAuthorityAndBuildView(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + manager := newTaskManagerForTest(t, store) + actor := validActorContext() + + parent, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Parent task", + }, actor) + if err != nil { + t.Fatalf("CreateTask(parent) error = %v", err) + } + child, err := manager.CreateChildTask(context.Background(), parent.ID, CreateTask{ + Scope: ScopeGlobal, + Title: "Child task", + }, actor) + if err != nil { + t.Fatalf("CreateChildTask() error = %v", err) + } + dependency, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Dependency", + }, actor) + if err != nil { + t.Fatalf("CreateTask(dependency) error = %v", err) + } + if err := manager.AddDependency(context.Background(), AddDependency{ + TaskID: child.ID, + DependsOnTaskID: dependency.ID, + Kind: DependencyKindBlocks, + }, actor); err != nil { + t.Fatalf("AddDependency() error = %v", err) + } + + store.runs["run-active"] = TaskRun{ + ID: "run-active", + TaskID: child.ID, + Status: TaskRunStatusRunning, + Attempt: 1, + Origin: Origin{Kind: OriginKindAutomation, Ref: "rule:nightly"}, + QueuedAt: time.Date(2026, 4, 14, 13, 0, 0, 0, time.UTC), + } + + view, err := manager.GetTask(context.Background(), child.ID, actor) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if got, want := view.Task.Status, TaskStatusInProgress; got != want { + t.Fatalf("view.Task.Status = %q, want %q", got, want) + } + if len(view.Dependencies) != 1 { + t.Fatalf("len(view.Dependencies) = %d, want 1", len(view.Dependencies)) + } + if len(view.Runs) != 1 { + t.Fatalf("len(view.Runs) = %d, want 1", len(view.Runs)) + } + if len(view.Events) < 2 { + t.Fatalf("len(view.Events) = %d, want at least 2", len(view.Events)) + } + + summaries, err := manager.ListTasks(context.Background(), TaskQuery{ParentTaskID: parent.ID}, actor) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + if len(summaries) != 1 || summaries[0].ID != child.ID { + t.Fatalf("ListTasks(parent filter) = %#v, want only child %q", summaries, child.ID) + } + runs, err := manager.ListTaskRuns(context.Background(), child.ID, TaskRunQuery{}, actor) + if err != nil { + t.Fatalf("ListTaskRuns() error = %v", err) + } + if len(runs) != 1 || runs[0].ID != "run-active" { + t.Fatalf("ListTaskRuns() = %#v, want only run-active", runs) + } + + noRead := actor + noRead.Authority.Read = false + if _, err := manager.GetTask(context.Background(), child.ID, noRead); !errors.Is(err, ErrPermissionDenied) { + t.Fatalf("GetTask(no read) error = %v, want %v", err, ErrPermissionDenied) + } + if _, err := manager.ListTasks(context.Background(), TaskQuery{}, noRead); !errors.Is(err, ErrPermissionDenied) { + t.Fatalf("ListTasks(no read) error = %v, want %v", err, ErrPermissionDenied) + } + if _, err := manager.ListTaskRuns(context.Background(), child.ID, TaskRunQuery{}, noRead); !errors.Is(err, ErrPermissionDenied) { + t.Fatalf("ListTaskRuns(no read) error = %v, want %v", err, ErrPermissionDenied) + } +} + +func TestManagerRunLifecycleRejectsInvalidTransitions(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + executor := &recordingSessionExecutor{} + manager := newTaskManagerForTestWithOptions(t, store, WithSessionExecutor(executor)) + actor := validActorContext() + + taskRecord, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Lifecycle transitions", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + queuedRun, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun() error = %v", err) + } + + if _, err := manager.CompleteRun(context.Background(), queuedRun.ID, RunResult{ + Value: json.RawMessage(`{"ok":true}`), + }, actor); !errors.Is(err, ErrInvalidStatusTransition) { + t.Fatalf("CompleteRun(queued) error = %v, want %v", err, ErrInvalidStatusTransition) + } + + claimedRun, err := manager.ClaimRun(context.Background(), queuedRun.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun() error = %v", err) + } + runningRun, err := manager.StartRun(context.Background(), claimedRun.ID, StartRun{}, actor) + if err != nil { + t.Fatalf("StartRun() error = %v", err) + } + + if _, err := manager.ClaimRun(context.Background(), runningRun.ID, ClaimRun{}, actor); !errors.Is(err, ErrInvalidStatusTransition) { + t.Fatalf("ClaimRun(running) error = %v, want %v", err, ErrInvalidStatusTransition) + } +} + +func TestManagerTaskReconciliationAcrossDependenciesAndRuns(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + executor := &recordingSessionExecutor{} + manager := newTaskManagerForTestWithOptions(t, store, WithSessionExecutor(executor)) + actor := validActorContext() + + blocker, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Blocking task", + }, actor) + if err != nil { + t.Fatalf("CreateTask(blocker) error = %v", err) + } + target, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Target task", + }, actor) + if err != nil { + t.Fatalf("CreateTask(target) error = %v", err) + } + + if err := manager.AddDependency(context.Background(), AddDependency{ + TaskID: target.ID, + DependsOnTaskID: blocker.ID, + Kind: DependencyKindBlocks, + }, actor); err != nil { + t.Fatalf("AddDependency() error = %v", err) + } + if got, want := store.tasks[target.ID].Status, TaskStatusBlocked; got != want { + t.Fatalf("target.Status after blocker add = %q, want %q", got, want) + } + + blockerRun, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: blocker.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun(blocker) error = %v", err) + } + blockerRun, err = manager.ClaimRun(context.Background(), blockerRun.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun(blocker) error = %v", err) + } + blockerRun, err = manager.StartRun(context.Background(), blockerRun.ID, StartRun{}, actor) + if err != nil { + t.Fatalf("StartRun(blocker) error = %v", err) + } + if _, err := manager.CompleteRun(context.Background(), blockerRun.ID, RunResult{ + Value: json.RawMessage(`{"state":"done"}`), + }, actor); err != nil { + t.Fatalf("CompleteRun(blocker) error = %v", err) + } + if got, want := store.tasks[blocker.ID].Status, TaskStatusCompleted; got != want { + t.Fatalf("blocker.Status after complete = %q, want %q", got, want) + } + if got, want := store.tasks[target.ID].Status, TaskStatusReady; got != want { + t.Fatalf("target.Status after blocker complete = %q, want %q", got, want) + } + + targetRun, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: target.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun(target) error = %v", err) + } + targetRun, err = manager.ClaimRun(context.Background(), targetRun.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun(target) error = %v", err) + } + targetRun, err = manager.StartRun(context.Background(), targetRun.ID, StartRun{}, actor) + if err != nil { + t.Fatalf("StartRun(target) error = %v", err) + } + if got, want := store.tasks[target.ID].Status, TaskStatusInProgress; got != want { + t.Fatalf("target.Status after start = %q, want %q", got, want) + } + if _, err := manager.CompleteRun(context.Background(), targetRun.ID, RunResult{ + Value: json.RawMessage(`{"state":"done"}`), + }, actor); err != nil { + t.Fatalf("CompleteRun(target) error = %v", err) + } + if got, want := store.tasks[target.ID].Status, TaskStatusCompleted; got != want { + t.Fatalf("target.Status after complete = %q, want %q", got, want) + } + + failedTask, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Failure task", + }, actor) + if err != nil { + t.Fatalf("CreateTask(failedTask) error = %v", err) + } + failedRun, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: failedTask.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun(failedTask) error = %v", err) + } + failedRun, err = manager.ClaimRun(context.Background(), failedRun.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun(failedTask) error = %v", err) + } + failedRun, err = manager.StartRun(context.Background(), failedRun.ID, StartRun{}, actor) + if err != nil { + t.Fatalf("StartRun(failedTask) error = %v", err) + } + if _, err := manager.FailRun(context.Background(), failedRun.ID, RunFailure{ + Error: "boom", + }, actor); err != nil { + t.Fatalf("FailRun() error = %v", err) + } + if got, want := store.tasks[failedTask.ID].Status, TaskStatusFailed; got != want { + t.Fatalf("failedTask.Status = %q, want %q", got, want) + } + + cancelledTask, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Cancelled task", + }, actor) + if err != nil { + t.Fatalf("CreateTask(cancelledTask) error = %v", err) + } + cancelledRun, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: cancelledTask.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun(cancelledTask) error = %v", err) + } + cancelledRun, err = manager.ClaimRun(context.Background(), cancelledRun.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun(cancelledTask) error = %v", err) + } + cancelledRun, err = manager.StartRun(context.Background(), cancelledRun.ID, StartRun{}, actor) + if err != nil { + t.Fatalf("StartRun(cancelledTask) error = %v", err) + } + if _, err := manager.CancelRun(context.Background(), cancelledRun.ID, CancelRun{ + Reason: "stop", + }, actor); err != nil { + t.Fatalf("CancelRun() error = %v", err) + } + if got, want := store.tasks[cancelledTask.ID].Status, TaskStatusCancelled; got != want { + t.Fatalf("cancelledTask.Status = %q, want %q", got, want) + } +} + +func TestManagerCancelTaskPropagatesAcrossTree(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + executor := &recordingSessionExecutor{} + manager := newTaskManagerForTestWithOptions( + t, + store, + WithSessionExecutor(executor), + WithCancelGracePeriod(0), + ) + actor := validActorContext() + + parent, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Parent task", + }, actor) + if err != nil { + t.Fatalf("CreateTask(parent) error = %v", err) + } + queuedChild, err := manager.CreateChildTask(context.Background(), parent.ID, CreateTask{ + Scope: ScopeGlobal, + Title: "Queued child", + }, actor) + if err != nil { + t.Fatalf("CreateChildTask(queued child) error = %v", err) + } + activeChild, err := manager.CreateChildTask(context.Background(), parent.ID, CreateTask{ + Scope: ScopeGlobal, + Title: "Active child", + }, actor) + if err != nil { + t.Fatalf("CreateChildTask(active child) error = %v", err) + } + + queuedRun, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: queuedChild.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun(queued child) error = %v", err) + } + activeRun, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: activeChild.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun(active child) error = %v", err) + } + activeRun, err = manager.ClaimRun(context.Background(), activeRun.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun(active child) error = %v", err) + } + activeRun, err = manager.StartRun(context.Background(), activeRun.ID, StartRun{}, actor) + if err != nil { + t.Fatalf("StartRun(active child) error = %v", err) + } + + cancelledParent, err := manager.CancelTask(context.Background(), parent.ID, CancelTask{ + Reason: "parent requested stop", + }, actor) + if err != nil { + t.Fatalf("CancelTask() error = %v", err) + } + if got, want := cancelledParent.Status, TaskStatusCancelled; got != want { + t.Fatalf("cancelledParent.Status = %q, want %q", got, want) + } + if got, want := store.tasks[parent.ID].Status, TaskStatusCancelled; got != want { + t.Fatalf("parent.Status = %q, want %q", got, want) + } + if got, want := store.tasks[queuedChild.ID].Status, TaskStatusCancelled; got != want { + t.Fatalf("queuedChild.Status = %q, want %q", got, want) + } + if got, want := store.tasks[activeChild.ID].Status, TaskStatusCancelled; got != want { + t.Fatalf("activeChild.Status = %q, want %q", got, want) + } + if got, want := store.runs[queuedRun.ID].Status, TaskRunStatusCancelled; got != want { + t.Fatalf("queuedRun.Status = %q, want %q", got, want) + } + if got, want := store.runs[activeRun.ID].Status, TaskRunStatusCancelled; got != want { + t.Fatalf("activeRun.Status = %q, want %q", got, want) + } + if len(executor.requestStopCalls) != 1 { + t.Fatalf("len(requestStopCalls) = %d, want 1", len(executor.requestStopCalls)) + } + if got, want := executor.requestStopCalls[0].SessionID, activeRun.SessionID; got != want { + t.Fatalf("requestStopCalls[0].SessionID = %q, want %q", got, want) + } + if len(executor.forceStopCalls) != 1 { + t.Fatalf("len(forceStopCalls) = %d, want 1", len(executor.forceStopCalls)) + } + if got, want := executor.forceStopCalls[0].Reason, StopReasonCancellation; got != want { + t.Fatalf("forceStopCalls[0].Reason = %q, want %q", got, want) + } + + parentEvents, err := store.ListTaskEvents(context.Background(), TaskEventQuery{TaskID: parent.ID}) + if err != nil { + t.Fatalf("ListTaskEvents(parent) error = %v", err) + } + if !containsEventType(parentEvents, taskEventCancelled) { + t.Fatalf("parent events = %#v, want %q", sortedEventTypes(parentEvents), taskEventCancelled) + } + + activeChildEvents, err := store.ListTaskEvents(context.Background(), TaskEventQuery{TaskID: activeChild.ID}) + if err != nil { + t.Fatalf("ListTaskEvents(active child) error = %v", err) + } + if !containsEventType(activeChildEvents, taskEventRunCancelled) { + t.Fatalf("active child events = %#v, want %q", sortedEventTypes(activeChildEvents), taskEventRunCancelled) + } +} + +func TestManagerAttachRunSessionAndRetryLatestRunOutcome(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + executor := &recordingSessionExecutor{} + manager := newTaskManagerForTestWithOptions(t, store, WithSessionExecutor(executor)) + actor := validActorContext() + + taskRecord, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Attach and retry", + NetworkChannel: "ops", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + firstRun, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun(first) error = %v", err) + } + if got, want := firstRun.NetworkChannel, "ops"; got != want { + t.Fatalf("firstRun.NetworkChannel = %q, want %q", got, want) + } + firstRun, err = manager.ClaimRun(context.Background(), firstRun.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun(first) error = %v", err) + } + firstRun, err = manager.StartRun(context.Background(), firstRun.ID, StartRun{}, actor) + if err != nil { + t.Fatalf("StartRun(first) error = %v", err) + } + if _, err := manager.CompleteRun(context.Background(), firstRun.ID, RunResult{ + Value: json.RawMessage(`{"result":"ok"}`), + }, actor); err != nil { + t.Fatalf("CompleteRun(first) error = %v", err) + } + if got, want := store.tasks[taskRecord.ID].Status, TaskStatusCompleted; got != want { + t.Fatalf("task.Status after first completion = %q, want %q", got, want) + } + + retryRun, err := manager.EnqueueRun(context.Background(), EnqueueRun{ + TaskID: taskRecord.ID, + NetworkChannel: "custom", + }, actor) + if err != nil { + t.Fatalf("EnqueueRun(retry) error = %v", err) + } + if got, want := retryRun.Attempt, 2; got != want { + t.Fatalf("retryRun.Attempt = %d, want %d", got, want) + } + if got, want := retryRun.NetworkChannel, "custom"; got != want { + t.Fatalf("retryRun.NetworkChannel = %q, want %q", got, want) + } + if got, want := store.tasks[taskRecord.ID].Status, TaskStatusReady; got != want { + t.Fatalf("task.Status after retry enqueue = %q, want %q", got, want) + } + + retryRun, err = manager.ClaimRun(context.Background(), retryRun.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun(retry) error = %v", err) + } + retryRun, err = manager.AttachRunSession(context.Background(), retryRun.ID, "sess-resume", actor) + if err != nil { + t.Fatalf("AttachRunSession() error = %v", err) + } + if got, want := retryRun.Status, TaskRunStatusStarting; got != want { + t.Fatalf("retryRun.Status after attach = %q, want %q", got, want) + } + if got, want := retryRun.SessionID, "sess-resume"; got != want { + t.Fatalf("retryRun.SessionID after attach = %q, want %q", got, want) + } + if got, want := store.tasks[taskRecord.ID].Status, TaskStatusInProgress; got != want { + t.Fatalf("task.Status after attach = %q, want %q", got, want) + } + + retryRun, err = manager.StartRun(context.Background(), retryRun.ID, StartRun{}, actor) + if err != nil { + t.Fatalf("StartRun(retry) error = %v", err) + } + if got, want := len(executor.attachCalls), 1; got != want { + t.Fatalf("len(attachCalls) = %d, want %d", got, want) + } + if got, want := len(executor.startCalls), 1; got != want { + t.Fatalf("len(startCalls) = %d, want %d", got, want) + } + if _, err := manager.AttachRunSession(context.Background(), retryRun.ID, "sess-other", actor); !errors.Is(err, ErrSessionAlreadyBound) { + t.Fatalf("AttachRunSession(running) error = %v, want %v", err, ErrSessionAlreadyBound) + } + + if _, err := manager.FailRun(context.Background(), retryRun.ID, RunFailure{ + Error: "resume failed", + }, actor); err != nil { + t.Fatalf("FailRun(retry) error = %v", err) + } + if got, want := store.tasks[taskRecord.ID].Status, TaskStatusFailed; got != want { + t.Fatalf("task.Status after retry failure = %q, want %q", got, want) + } +} + +func TestManagerNonHumanIdempotencyAndExecutionGuards(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + manager := newTaskManagerForTest(t, store) + automationActor, err := DeriveAutomationActorContext("rule:nightly", "") + if err != nil { + t.Fatalf("DeriveAutomationActorContext() error = %v", err) + } + + taskOne, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Idempotent task one", + }, automationActor) + if err != nil { + t.Fatalf("CreateTask(taskOne) error = %v", err) + } + taskTwo, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Idempotent task two", + }, automationActor) + if err != nil { + t.Fatalf("CreateTask(taskTwo) error = %v", err) + } + + if _, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: taskOne.ID}, automationActor); !errors.Is(err, ErrValidation) { + t.Fatalf("EnqueueRun(no idempotency) error = %v, want %v", err, ErrValidation) + } + + runOne, err := manager.EnqueueRun(context.Background(), EnqueueRun{ + TaskID: taskOne.ID, + IdempotencyKey: "idem-1", + }, automationActor) + if err != nil { + t.Fatalf("EnqueueRun(taskOne) error = %v", err) + } + runAgain, err := manager.EnqueueRun(context.Background(), EnqueueRun{ + TaskID: taskOne.ID, + IdempotencyKey: "idem-1", + }, automationActor) + if err != nil { + t.Fatalf("EnqueueRun(taskOne duplicate) error = %v", err) + } + if got, want := runAgain.ID, runOne.ID; got != want { + t.Fatalf("duplicate enqueue run id = %q, want %q", got, want) + } + if got, want := len(store.runs), 1; got != want { + t.Fatalf("len(store.runs) = %d, want %d", got, want) + } + + if _, err := manager.EnqueueRun(context.Background(), EnqueueRun{ + TaskID: taskTwo.ID, + IdempotencyKey: "idem-1", + }, automationActor); !errors.Is(err, ErrValidation) { + t.Fatalf("EnqueueRun(taskTwo duplicate key) error = %v, want %v", err, ErrValidation) + } + + if _, err := manager.ClaimRun(context.Background(), runOne.ID, ClaimRun{}, automationActor); !errors.Is(err, ErrValidation) { + t.Fatalf("ClaimRun(no idempotency) error = %v, want %v", err, ErrValidation) + } + claimedRun, err := manager.ClaimRun(context.Background(), runOne.ID, ClaimRun{ + IdempotencyKey: "claim-idem", + }, automationActor) + if err != nil { + t.Fatalf("ClaimRun(with idempotency) error = %v", err) + } + if _, err := manager.StartRun(context.Background(), claimedRun.ID, StartRun{}, automationActor); !errors.Is(err, ErrValidation) { + t.Fatalf("StartRun(no idempotency) error = %v, want %v", err, ErrValidation) + } + if _, err := manager.StartRun(context.Background(), claimedRun.ID, StartRun{ + IdempotencyKey: "start-idem", + }, automationActor); !errors.Is(err, ErrValidation) { + t.Fatalf("StartRun(no session executor) error = %v, want %v", err, ErrValidation) + } +} + +func TestManagerNetworkPeerEnqueueRunUsesOriginScopedIdempotency(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + manager := newTaskManagerForTest(t, store) + actor, err := DeriveNetworkPeerActorContext("peer.ops-review", "peer:peer.ops-review/channel:ops") + if err != nil { + t.Fatalf("DeriveNetworkPeerActorContext() error = %v", err) + } + + taskRecord, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Peer-originated task", + NetworkChannel: "ops", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + firstRun, err := manager.EnqueueRun(context.Background(), EnqueueRun{ + TaskID: taskRecord.ID, + IdempotencyKey: "delivery-1", + }, actor) + if err != nil { + t.Fatalf("EnqueueRun(first) error = %v", err) + } + secondRun, err := manager.EnqueueRun(context.Background(), EnqueueRun{ + TaskID: taskRecord.ID, + IdempotencyKey: "delivery-1", + }, actor) + if err != nil { + t.Fatalf("EnqueueRun(duplicate) error = %v", err) + } + + if got, want := secondRun.ID, firstRun.ID; got != want { + t.Fatalf("duplicate enqueue run id = %q, want %q", got, want) + } + if got, want := len(store.runs), 1; got != want { + t.Fatalf("len(store.runs) = %d, want %d", got, want) + } + if got, want := len(store.idempotencyByKey), 1; got != want { + t.Fatalf("len(store.idempotencyByKey) = %d, want %d", got, want) + } +} + +func TestManagerStartRunRejectsStaleRunChannelWithoutMutation(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + bootstrap := newTaskManagerForTest(t, store) + actor, err := DeriveHumanActorContext("user-1", OriginKindCLI, "agh task run start") + if err != nil { + t.Fatalf("DeriveHumanActorContext() error = %v", err) + } + + taskRecord, err := bootstrap.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Stale run snapshot task", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + run, err := bootstrap.EnqueueRun(context.Background(), EnqueueRun{ + TaskID: taskRecord.ID, + NetworkChannel: "legacy", + }, actor) + if err != nil { + t.Fatalf("EnqueueRun() error = %v", err) + } + run, err = bootstrap.ClaimRun(context.Background(), run.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun() error = %v", err) + } + + executor := &recordingSessionExecutor{} + manager := newTaskManagerForTestWithOptions( + t, + store, + WithSessionExecutor(executor), + WithNetworkChannelValidator(func(channel string) error { + if channel == "legacy" { + return fmt.Errorf("channel retired") + } + return nil + }), + ) + + started, err := manager.StartRun(context.Background(), run.ID, StartRun{}, actor) + if !errors.Is(err, ErrStaleNetworkChannel) { + t.Fatalf("StartRun() error = %v, want %v", err, ErrStaleNetworkChannel) + } + if started != nil { + t.Fatalf("StartRun() run = %#v, want nil on stale-channel rejection", started) + } + if got := len(executor.startCalls); got != 0 { + t.Fatalf("len(executor.startCalls) = %d, want 0", got) + } + + storedRun, err := store.GetTaskRun(context.Background(), run.ID) + if err != nil { + t.Fatalf("GetTaskRun() error = %v", err) + } + if got, want := storedRun.Status, TaskRunStatusClaimed; got != want { + t.Fatalf("storedRun.Status = %q, want %q", got, want) + } + if !storedRun.StartedAt.IsZero() { + t.Fatalf("storedRun.StartedAt = %s, want zero", storedRun.StartedAt) + } + if got, want := storedRun.NetworkChannel, "legacy"; got != want { + t.Fatalf("storedRun.NetworkChannel = %q, want %q", got, want) + } + + events, err := store.ListTaskEvents(context.Background(), TaskEventQuery{TaskID: taskRecord.ID}) + if err != nil { + t.Fatalf("ListTaskEvents() error = %v", err) + } + rejectedEvents := make([]TaskEvent, 0) + for _, event := range events { + if event.EventType == taskEventRunRejected { + rejectedEvents = append(rejectedEvents, event) + } + } + if got, want := len(rejectedEvents), 1; got != want { + t.Fatalf("len(rejectedEvents) = %d, want %d; event types=%v", got, want, sortedEventTypes(events)) + } + + var payload rejectedRunPayload + if err := json.Unmarshal(rejectedEvents[0].Payload, &payload); err != nil { + t.Fatalf("json.Unmarshal(rejected payload) error = %v", err) + } + if got, want := payload.Operation, "start"; got != want { + t.Fatalf("payload.Operation = %q, want %q", got, want) + } + if got, want := payload.Reason, "stale_network_channel"; got != want { + t.Fatalf("payload.Reason = %q, want %q", got, want) + } + if got, want := payload.NetworkChannel, "legacy"; got != want { + t.Fatalf("payload.NetworkChannel = %q, want %q", got, want) + } +} + +func TestManagerBlockedExecutionAndFailureGuardrails(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + executor := &recordingSessionExecutor{startErr: errors.New("boot failed")} + manager := newTaskManagerForTestWithOptions( + t, + store, + WithSessionExecutor(executor), + WithCancelGracePeriod(time.Millisecond), + ) + actor := validActorContext() + + blocker, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Blocker", + }, actor) + if err != nil { + t.Fatalf("CreateTask(blocker) error = %v", err) + } + target, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Target", + }, actor) + if err != nil { + t.Fatalf("CreateTask(target) error = %v", err) + } + if err := manager.AddDependency(context.Background(), AddDependency{ + TaskID: target.ID, + DependsOnTaskID: blocker.ID, + Kind: DependencyKindBlocks, + }, actor); err != nil { + t.Fatalf("AddDependency() error = %v", err) + } + + blockedRun, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: target.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun(blocked target) error = %v", err) + } + if _, err := manager.ClaimRun(context.Background(), blockedRun.ID, ClaimRun{}, actor); !errors.Is(err, ErrInvalidStatusTransition) { + t.Fatalf("ClaimRun(blocked target) error = %v, want %v", err, ErrInvalidStatusTransition) + } + + failingTask, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Failing start task", + }, actor) + if err != nil { + t.Fatalf("CreateTask(failingTask) error = %v", err) + } + failingRun, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: failingTask.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun(failingTask) error = %v", err) + } + failingRun, err = manager.ClaimRun(context.Background(), failingRun.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun(failingTask) error = %v", err) + } + failedRun, err := manager.StartRun(context.Background(), failingRun.ID, StartRun{}, actor) + if err == nil { + t.Fatal("StartRun(failingTask) error = nil, want non-nil") + } + if failedRun == nil || failedRun.Status != TaskRunStatusFailed { + t.Fatalf("failedRun = %#v, want failed status", failedRun) + } + if got, want := store.tasks[failingTask.ID].Status, TaskStatusFailed; got != want { + t.Fatalf("failingTask.Status = %q, want %q", got, want) + } + + completedTask, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Completed task", + }, actor) + if err != nil { + t.Fatalf("CreateTask(completedTask) error = %v", err) + } + completedRun, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: completedTask.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun(completedTask) error = %v", err) + } + completedRun, err = manager.ClaimRun(context.Background(), completedRun.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun(completedTask) error = %v", err) + } + executor.startErr = nil + completedRun, err = manager.StartRun(context.Background(), completedRun.ID, StartRun{}, actor) + if err != nil { + t.Fatalf("StartRun(completedTask) error = %v", err) + } + if _, err := manager.CompleteRun(context.Background(), completedRun.ID, RunResult{ + Value: json.RawMessage(`{"ok":true}`), + }, actor); err != nil { + t.Fatalf("CompleteRun(completedTask) error = %v", err) + } + if _, err := manager.CancelTask(context.Background(), completedTask.ID, CancelTask{ + Reason: "too late", + }, actor); !errors.Is(err, ErrInvalidStatusTransition) { + t.Fatalf("CancelTask(completedTask) error = %v, want %v", err, ErrInvalidStatusTransition) + } +} + +func TestManagerHelperCoverage(t *testing.T) { + t.Parallel() + + if !hasOpenRun([]TaskRun{{Status: TaskRunStatusQueued}}) { + t.Fatal("hasOpenRun(queued) = false, want true") + } + if hasOpenRun([]TaskRun{{Status: TaskRunStatusCompleted}}) { + t.Fatal("hasOpenRun(completed) = true, want false") + } + if !runComesAfter( + TaskRun{ID: "run-2", Attempt: 2, QueuedAt: time.Date(2026, 4, 14, 16, 0, 0, 0, time.UTC)}, + TaskRun{ID: "run-1", Attempt: 1, QueuedAt: time.Date(2026, 4, 14, 15, 0, 0, 0, time.UTC)}, + ) { + t.Fatal("runComesAfter(later, earlier) = false, want true") + } + if allowsRunTransition(TaskRunStatusCompleted, TaskRunStatusRunning) { + t.Fatal("allowsRunTransition(completed, running) = true, want false") + } + + joined := errorsJoin(nil, ErrValidation) + if !errors.Is(joined, ErrValidation) { + t.Fatalf("errorsJoin(nil, ErrValidation) = %v, want ErrValidation", joined) + } + + executor := &recordingSessionExecutor{} + manager := newTaskManagerForTestWithOptions( + t, + newInMemoryManagerStore(), + WithSessionExecutor(executor), + WithCancelGracePeriod(time.Millisecond), + ) + + if err := manager.waitAndForceStopRun(context.Background(), "sess-helper"); err != nil { + t.Fatalf("waitAndForceStopRun() error = %v", err) + } + if len(executor.forceStopCalls) != 1 { + t.Fatalf("len(forceStopCalls) = %d, want 1", len(executor.forceStopCalls)) + } + + cancelledCtx, cancel := context.WithCancel(context.Background()) + cancel() + if err := manager.waitAndForceStopRun(cancelledCtx, "sess-cancelled"); err == nil { + t.Fatal("waitAndForceStopRun(cancelled) error = nil, want non-nil") + } +} + +func TestManagerStartRunAndAttachErrorBranches(t *testing.T) { + t.Parallel() + + t.Run("start run fails closed when executor returns nil session ref", func(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + executor := &recordingSessionExecutor{returnNilStart: true} + manager := newTaskManagerForTestWithOptions(t, store, WithSessionExecutor(executor)) + actor := validActorContext() + + taskRecord, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Nil session ref task", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + run, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun() error = %v", err) + } + run, err = manager.ClaimRun(context.Background(), run.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun() error = %v", err) + } + + failedRun, err := manager.StartRun(context.Background(), run.ID, StartRun{}, actor) + if err == nil { + t.Fatal("StartRun() error = nil, want non-nil") + } + if failedRun == nil || failedRun.Status != TaskRunStatusFailed { + t.Fatalf("failedRun = %#v, want failed run", failedRun) + } + }) + + t.Run("attach run rejects active session reuse", func(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + executor := &recordingSessionExecutor{} + manager := newTaskManagerForTestWithOptions(t, store, WithSessionExecutor(executor)) + actor := validActorContext() + + taskRecord, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Shared session guard", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + runOne, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun(runOne) error = %v", err) + } + runOne, err = manager.ClaimRun(context.Background(), runOne.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun(runOne) error = %v", err) + } + if _, err := manager.AttachRunSession(context.Background(), runOne.ID, "sess-shared", actor); err != nil { + t.Fatalf("AttachRunSession(runOne) error = %v", err) + } + + runTwo, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun(runTwo) error = %v", err) + } + runTwo, err = manager.ClaimRun(context.Background(), runTwo.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun(runTwo) error = %v", err) + } + if _, err := manager.AttachRunSession(context.Background(), runTwo.ID, "sess-shared", actor); !errors.Is(err, ErrSessionAlreadyBound) { + t.Fatalf("AttachRunSession(runTwo shared session) error = %v, want %v", err, ErrSessionAlreadyBound) + } + }) + + t.Run("attach run validates executor state and session id", func(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + managerWithoutExecutor := newTaskManagerForTest(t, store) + actor := validActorContext() + + taskRecord, err := managerWithoutExecutor.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Attach validation task", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + run, err := managerWithoutExecutor.EnqueueRun(context.Background(), EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun() error = %v", err) + } + run, err = managerWithoutExecutor.ClaimRun(context.Background(), run.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun() error = %v", err) + } + if _, err := managerWithoutExecutor.AttachRunSession(context.Background(), run.ID, "sess-1", actor); !errors.Is(err, ErrValidation) { + t.Fatalf("AttachRunSession(no executor) error = %v, want %v", err, ErrValidation) + } + + managerWithExecutor := newTaskManagerForTestWithOptions(t, newInMemoryManagerStore(), WithSessionExecutor(&recordingSessionExecutor{})) + taskRecord, err = managerWithExecutor.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Attach session id validation", + }, actor) + if err != nil { + t.Fatalf("CreateTask(with executor) error = %v", err) + } + run, err = managerWithExecutor.EnqueueRun(context.Background(), EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun(with executor) error = %v", err) + } + if _, err := managerWithExecutor.AttachRunSession(context.Background(), run.ID, "", actor); !errors.Is(err, ErrValidation) { + t.Fatalf("AttachRunSession(empty session id) error = %v, want %v", err, ErrValidation) + } + if _, err := managerWithExecutor.AttachRunSession(context.Background(), run.ID, "sess-2", actor); !errors.Is(err, ErrSessionAttachNotAllowed) { + t.Fatalf("AttachRunSession(queued run) error = %v, want %v", err, ErrSessionAttachNotAllowed) + } + }) +} + +func TestManagerRecoverRunOnBoot(t *testing.T) { + t.Parallel() + + daemonActor, err := DeriveDaemonActorContext("boot-recovery", "daemon.boot") + if err != nil { + t.Fatalf("DeriveDaemonActorContext() error = %v", err) + } + + t.Run("claimed run requeues and records recovery event", func(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + manager := newTaskManagerForTest(t, store) + actor := validActorContext() + + taskRecord, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Claimed recovery", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + run, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun() error = %v", err) + } + run, err = manager.ClaimRun(context.Background(), run.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun() error = %v", err) + } + + recovered, err := manager.RecoverRunOnBoot(context.Background(), run.ID, RunBootRecovery{ + Action: RunBootRecoveryRequeue, + Reason: "orphaned_on_boot", + SessionState: "missing", + }, daemonActor) + if err != nil { + t.Fatalf("RecoverRunOnBoot(requeue) error = %v", err) + } + if got, want := recovered.Status, TaskRunStatusQueued; got != want { + t.Fatalf("recovered.Status = %q, want %q", got, want) + } + if recovered.ClaimedBy != nil { + t.Fatalf("recovered.ClaimedBy = %#v, want nil", recovered.ClaimedBy) + } + if !store.runs[run.ID].ClaimedAt.IsZero() { + t.Fatalf("store.runs[%q].ClaimedAt = %v, want zero", run.ID, store.runs[run.ID].ClaimedAt) + } + + events, err := store.ListTaskEvents(context.Background(), TaskEventQuery{TaskID: taskRecord.ID}) + if err != nil { + t.Fatalf("ListTaskEvents() error = %v", err) + } + if !containsEventType(events, taskEventRunRecovered) { + t.Fatalf("events = %#v, want %q", sortedEventTypes(events), taskEventRunRecovered) + } + }) + + t.Run("starting run is promoted to running when session is live", func(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + executor := &recordingSessionExecutor{} + manager := newTaskManagerForTestWithOptions(t, store, WithSessionExecutor(executor)) + actor := validActorContext() + + taskRecord, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Starting recovery", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + run, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun() error = %v", err) + } + run, err = manager.ClaimRun(context.Background(), run.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun() error = %v", err) + } + run, err = manager.AttachRunSession(context.Background(), run.ID, "sess-live", actor) + if err != nil { + t.Fatalf("AttachRunSession() error = %v", err) + } + + recovered, err := manager.RecoverRunOnBoot(context.Background(), run.ID, RunBootRecovery{ + Action: RunBootRecoveryMarkRunning, + Reason: "orphaned_on_boot", + SessionState: "active", + }, daemonActor) + if err != nil { + t.Fatalf("RecoverRunOnBoot(mark running) error = %v", err) + } + if got, want := recovered.Status, TaskRunStatusRunning; got != want { + t.Fatalf("recovered.Status = %q, want %q", got, want) + } + if recovered.StartedAt.IsZero() { + t.Fatal("recovered.StartedAt = zero, want recovery timestamp") + } + }) + + t.Run("running run fails closed when the attached session is not live", func(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + executor := &recordingSessionExecutor{} + manager := newTaskManagerForTestWithOptions(t, store, WithSessionExecutor(executor)) + actor := validActorContext() + + taskRecord, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Running recovery", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + run, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun() error = %v", err) + } + run, err = manager.ClaimRun(context.Background(), run.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun() error = %v", err) + } + run, err = manager.StartRun(context.Background(), run.ID, StartRun{}, actor) + if err != nil { + t.Fatalf("StartRun() error = %v", err) + } + + recovered, err := manager.RecoverRunOnBoot(context.Background(), run.ID, RunBootRecovery{ + Action: RunBootRecoveryFail, + Reason: "orphaned_on_boot", + SessionState: "missing", + }, daemonActor) + if err != nil { + t.Fatalf("RecoverRunOnBoot(fail) error = %v", err) + } + if got, want := recovered.Status, TaskRunStatusFailed; got != want { + t.Fatalf("recovered.Status = %q, want %q", got, want) + } + if !strings.Contains(recovered.Error, "orphaned on boot") { + t.Fatalf("recovered.Error = %q, want orphaned-on-boot detail", recovered.Error) + } + + events, err := store.ListTaskEvents(context.Background(), TaskEventQuery{TaskID: taskRecord.ID}) + if err != nil { + t.Fatalf("ListTaskEvents() error = %v", err) + } + if !containsEventType(events, taskEventRunFailed) || !containsEventType(events, taskEventRunRecovered) { + t.Fatalf("events = %#v, want %q and %q", sortedEventTypes(events), taskEventRunFailed, taskEventRunRecovered) + } + }) + + t.Run("claimed run cannot recover to running without a session binding", func(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + manager := newTaskManagerForTest(t, store) + actor := validActorContext() + + taskRecord, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Claimed without session", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + run, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun() error = %v", err) + } + run, err = manager.ClaimRun(context.Background(), run.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun() error = %v", err) + } + + if _, err := manager.RecoverRunOnBoot(context.Background(), run.ID, RunBootRecovery{ + Action: RunBootRecoveryMarkRunning, + Reason: "orphaned_on_boot", + SessionState: "missing", + }, daemonActor); !errors.Is(err, ErrInvalidStatusTransition) { + t.Fatalf("RecoverRunOnBoot(mark running without session) error = %v, want %v", err, ErrInvalidStatusTransition) + } + }) + + t.Run("running run remains unchanged when recovery confirms it is still live", func(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + executor := &recordingSessionExecutor{} + manager := newTaskManagerForTestWithOptions(t, store, WithSessionExecutor(executor)) + actor := validActorContext() + + taskRecord, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Running still live", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + run, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun() error = %v", err) + } + run, err = manager.ClaimRun(context.Background(), run.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun() error = %v", err) + } + run, err = manager.StartRun(context.Background(), run.ID, StartRun{}, actor) + if err != nil { + t.Fatalf("StartRun() error = %v", err) + } + + eventsBefore, err := store.ListTaskEvents(context.Background(), TaskEventQuery{TaskID: taskRecord.ID}) + if err != nil { + t.Fatalf("ListTaskEvents(before) error = %v", err) + } + + recovered, err := manager.RecoverRunOnBoot(context.Background(), run.ID, RunBootRecovery{ + Action: RunBootRecoveryMarkRunning, + Reason: "orphaned_on_boot", + SessionState: "active", + }, daemonActor) + if err != nil { + t.Fatalf("RecoverRunOnBoot(mark running while already running) error = %v", err) + } + if got, want := recovered.Status, TaskRunStatusRunning; got != want { + t.Fatalf("recovered.Status = %q, want %q", got, want) + } + + eventsAfter, err := store.ListTaskEvents(context.Background(), TaskEventQuery{TaskID: taskRecord.ID}) + if err != nil { + t.Fatalf("ListTaskEvents(after) error = %v", err) + } + if got, want := len(eventsAfter), len(eventsBefore); got != want { + t.Fatalf("event count after noop recovery = %d, want %d", got, want) + } + }) + + t.Run("starting run cannot be requeued on boot", func(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + executor := &recordingSessionExecutor{} + manager := newTaskManagerForTestWithOptions(t, store, WithSessionExecutor(executor)) + actor := validActorContext() + + taskRecord, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Starting cannot requeue", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + run, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun() error = %v", err) + } + run, err = manager.ClaimRun(context.Background(), run.ID, ClaimRun{}, actor) + if err != nil { + t.Fatalf("ClaimRun() error = %v", err) + } + run, err = manager.AttachRunSession(context.Background(), run.ID, "sess-bound", actor) + if err != nil { + t.Fatalf("AttachRunSession() error = %v", err) + } + + if _, err := manager.RecoverRunOnBoot(context.Background(), run.ID, RunBootRecovery{ + Action: RunBootRecoveryRequeue, + Reason: "orphaned_on_boot", + SessionState: "missing", + }, daemonActor); !errors.Is(err, ErrInvalidStatusTransition) { + t.Fatalf("RecoverRunOnBoot(requeue starting) error = %v, want %v", err, ErrInvalidStatusTransition) + } + }) +} + +func TestManagerGetTaskAndFailRunGuardrails(t *testing.T) { + t.Parallel() + + manager := newTaskManagerForTest(t, newInMemoryManagerStore()) + actor := validActorContext() + + if _, err := manager.GetTask(context.Background(), "", actor); !errors.Is(err, ErrValidation) { + t.Fatalf("GetTask(empty id) error = %v, want %v", err, ErrValidation) + } + if _, err := manager.GetTask(context.Background(), "missing-task", actor); !errors.Is(err, ErrTaskNotFound) { + t.Fatalf("GetTask(missing) error = %v, want %v", err, ErrTaskNotFound) + } + + taskRecord, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Queued fail guard", + }, actor) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + run, err := manager.EnqueueRun(context.Background(), EnqueueRun{TaskID: taskRecord.ID}, actor) + if err != nil { + t.Fatalf("EnqueueRun() error = %v", err) + } + if _, err := manager.FailRun(context.Background(), run.ID, RunFailure{ + Error: "cannot fail queued run", + }, actor); !errors.Is(err, ErrInvalidStatusTransition) { + t.Fatalf("FailRun(queued) error = %v, want %v", err, ErrInvalidStatusTransition) + } +} + +func TestRunBootRecoveryHelpersAndWriteAuthority(t *testing.T) { + t.Parallel() + + t.Run("formats recovery errors for bound and missing sessions", func(t *testing.T) { + t.Parallel() + + if got, want := runBootRecoveryError(TaskRun{ + ID: "run-active", + SessionID: "sess-active", + }, RunBootRecovery{ + SessionState: "stopped", + }), `orphaned on boot: session "sess-active" is stopped`; got != want { + t.Fatalf("runBootRecoveryError(bound+state) = %q, want %q", got, want) + } + if got, want := runBootRecoveryError(TaskRun{ + ID: "run-bound", + SessionID: "sess-bound", + }, RunBootRecovery{}), `orphaned on boot: session "sess-bound" is not live`; got != want { + t.Fatalf("runBootRecoveryError(bound) = %q, want %q", got, want) + } + if got, want := runBootRecoveryError(TaskRun{ID: "run-missing"}, RunBootRecovery{}), "orphaned on boot: run has no live session"; got != want { + t.Fatalf("runBootRecoveryError(missing) = %q, want %q", got, want) + } + }) + + t.Run("normalizes recovery metadata reason", func(t *testing.T) { + t.Parallel() + + metadata := runBootRecoveryMetadata(TaskRun{ + ID: "run-meta", + Status: TaskRunStatusStarting, + SessionID: "sess-meta", + }, RunBootRecovery{ + Reason: " ", + SessionState: "missing", + }) + if metadata == nil { + t.Fatal("runBootRecoveryMetadata() = nil, want payload") + } + + var payload map[string]string + if err := json.Unmarshal(metadata, &payload); err != nil { + t.Fatalf("json.Unmarshal(metadata) error = %v", err) + } + if got, want := payload["reason"], "orphaned_on_boot"; got != want { + t.Fatalf("payload[reason] = %q, want %q", got, want) + } + if got, want := payload["previous_status"], string(TaskRunStatusStarting); got != want { + t.Fatalf("payload[previous_status] = %q, want %q", got, want) + } + }) + + t.Run("write authority rejects read-only actors", func(t *testing.T) { + t.Parallel() + + actor := validActorContext() + actor.Authority.Write = false + actor.Authority.CreateGlobal = false + actor.Authority.CreateWorkspace = false + if err := requireWriteAuthority(actor); !errors.Is(err, ErrPermissionDenied) { + t.Fatalf("requireWriteAuthority(read-only) error = %v, want %v", err, ErrPermissionDenied) + } + }) +} + +func TestManagerAdditionalBranchCoverage(t *testing.T) { + t.Parallel() + + t.Run("constructor validates required dependencies", func(t *testing.T) { + t.Parallel() + + if _, err := NewManager(); err == nil { + t.Fatal("NewManager() error = nil, want non-nil") + } + if _, err := NewManager(WithStore(newInMemoryManagerStore()), WithManagerNow(nil)); err == nil { + t.Fatal("NewManager(nil clock) error = nil, want non-nil") + } + if _, err := NewManager(WithStore(newInMemoryManagerStore()), WithIDGenerator(nil)); err == nil { + t.Fatal("NewManager(nil generator) error = nil, want non-nil") + } + + manager, err := NewManager( + WithStore(newInMemoryManagerStore()), + WithSessionExecutor(testSessionExecutor{}), + ) + if err != nil { + t.Fatalf("NewManager(with session executor) error = %v", err) + } + if manager.sessions == nil { + t.Fatal("manager.sessions = nil, want non-nil") + } + }) + + t.Run("surface helpers default origin refs", func(t *testing.T) { + t.Parallel() + + extension, err := DeriveExtensionActorContext("ext-1", "") + if err != nil { + t.Fatalf("DeriveExtensionActorContext() error = %v", err) + } + if got, want := extension.Origin.Ref, "ext-1"; got != want { + t.Fatalf("extension.Origin.Ref = %q, want %q", got, want) + } + + network, err := DeriveNetworkPeerActorContext("peer-1", "") + if err != nil { + t.Fatalf("DeriveNetworkPeerActorContext() error = %v", err) + } + if got, want := network.Origin.Ref, "peer-1"; got != want { + t.Fatalf("network.Origin.Ref = %q, want %q", got, want) + } + + daemon, err := DeriveDaemonActorContext("scheduler", "") + if err != nil { + t.Fatalf("DeriveDaemonActorContext() error = %v", err) + } + if got, want := daemon.Origin.Ref, "scheduler"; got != want { + t.Fatalf("daemon.Origin.Ref = %q, want %q", got, want) + } + }) + + t.Run("task depth detects cycles", func(t *testing.T) { + t.Parallel() + + store := newInMemoryManagerStore() + manager := newTaskManagerForTest(t, store) + taskA := validTask() + taskA.ID = "task-a" + taskA.Status = TaskStatusReady + taskA.ParentTaskID = "task-b" + taskB := validTask() + taskB.ID = "task-b" + taskB.Status = TaskStatusReady + taskB.ParentTaskID = "task-a" + store.tasks[taskA.ID] = taskA + store.tasks[taskB.ID] = taskB + + _, err := manager.taskDepth(context.Background(), taskA) + if !errors.Is(err, ErrValidation) { + t.Fatalf("taskDepth(cycle) error = %v, want %v", err, ErrValidation) + } + }) + + t.Run("create child rejects mismatched parent field", func(t *testing.T) { + t.Parallel() + + manager := newTaskManagerForTest(t, newInMemoryManagerStore()) + actor := validActorContext() + parent, err := manager.CreateTask(context.Background(), CreateTask{ + Scope: ScopeGlobal, + Title: "Parent", + }, actor) + if err != nil { + t.Fatalf("CreateTask(parent) error = %v", err) + } + + _, err = manager.CreateChildTask(context.Background(), parent.ID, CreateTask{ + Scope: ScopeGlobal, + ParentTaskID: "different-parent", + Title: "Child", + }, actor) + if !errors.Is(err, ErrValidation) { + t.Fatalf("CreateChildTask(mismatch) error = %v, want %v", err, ErrValidation) + } + }) + + t.Run("remove dependency validates ids and nil payload marshals cleanly", func(t *testing.T) { + t.Parallel() + + manager := newTaskManagerForTest(t, newInMemoryManagerStore()) + actor := validActorContext() + if err := manager.RemoveDependency(context.Background(), "", "task-b", actor); !errors.Is(err, ErrValidation) { + t.Fatalf("RemoveDependency(empty task) error = %v, want %v", err, ErrValidation) + } + if err := manager.RemoveDependency(context.Background(), "task-a", "", actor); !errors.Is(err, ErrValidation) { + t.Fatalf("RemoveDependency(empty depends_on) error = %v, want %v", err, ErrValidation) + } + + raw, err := marshalTaskEventPayload(nil) + if err != nil { + t.Fatalf("marshalTaskEventPayload(nil) error = %v", err) + } + if raw != nil { + t.Fatalf("marshalTaskEventPayload(nil) = %q, want nil", string(raw)) + } + }) + + t.Run("ownership helpers cover nil and zero values", func(t *testing.T) { + t.Parallel() + + if got := normalizeOwnership(nil); got != nil { + t.Fatalf("normalizeOwnership(nil) = %#v, want nil", got) + } + if got := normalizeOwnership(&Ownership{}); got != nil { + t.Fatalf("normalizeOwnership(zero) = %#v, want nil", got) + } + if sameOwnership(nil, &Ownership{Kind: OwnerKindPool, Ref: "triage"}) { + t.Fatal("sameOwnership(nil, owner) = true, want false") + } + if !isTerminalTaskStatus(TaskStatusFailed) { + t.Fatal("isTerminalTaskStatus(failed) = false, want true") + } + }) +} + +func newTaskManagerForTest(t *testing.T, store Store) *TaskManager { + t.Helper() + return newTaskManagerForTestWithOptions(t, store) +} + +func newTaskManagerForTestWithOptions(t *testing.T, store Store, extraOpts ...Option) *TaskManager { + t.Helper() + + now := time.Date(2026, 4, 14, 15, 0, 0, 0, time.UTC) + counter := 0 + options := []Option{ + WithStore(store), + WithManagerNow(func() time.Time { return now }), + WithIDGenerator(func(prefix string) string { + counter++ + return prefix + "-test-" + strconv.Itoa(counter) + }), + } + options = append(options, extraOpts...) + manager, err := NewManager(options...) + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + return manager +} + +func cloneTask(record Task) Task { + cloned := record + cloned.Owner = cloneOwnership(record.Owner) + cloned.Metadata = cloneRawJSON(record.Metadata) + return cloned +} + +func cloneTaskRun(record TaskRun) TaskRun { + cloned := record + if record.ClaimedBy != nil { + claimedBy := *record.ClaimedBy + cloned.ClaimedBy = &claimedBy + } + cloned.Result = cloneRawJSON(record.Result) + return cloned +} + +func sortedEventTypes(events []TaskEvent) []string { + types := make([]string, 0, len(events)) + for _, event := range events { + types = append(types, event.EventType) + } + sort.Strings(types) + return types +} + +func containsEventType(events []TaskEvent, want string) bool { + for _, event := range events { + if event.EventType == want { + return true + } + } + return false +} + +func idempotencyKey(origin Origin, key string) string { + return string(origin.Kind.Normalize()) + "|" + strings.TrimSpace(origin.Ref) + "|" + strings.TrimSpace(key) +} + +func fmtTestError(format string, args ...any) error { + return fmt.Errorf(format, args...) +} diff --git a/internal/task/types.go b/internal/task/types.go new file mode 100644 index 000000000..d1dd9743e --- /dev/null +++ b/internal/task/types.go @@ -0,0 +1,397 @@ +package task + +import ( + "encoding/json" + "time" +) + +// Scope identifies whether a task is daemon-global or workspace-scoped. +type Scope string + +const ( + // ScopeGlobal identifies a daemon-wide task with no workspace binding. + ScopeGlobal Scope = "global" + // ScopeWorkspace identifies a task bound to one workspace. + ScopeWorkspace Scope = "workspace" +) + +// TaskStatus identifies the canonical lifecycle state of a task. +type TaskStatus string + +const ( + // TaskStatusPending reports a task that exists but has not yet been reconciled into ready work. + TaskStatusPending TaskStatus = "pending" + // TaskStatusBlocked reports a task with unresolved dependencies. + TaskStatusBlocked TaskStatus = "blocked" + // TaskStatusReady reports a task that may execute because dependencies are satisfied. + TaskStatusReady TaskStatus = "ready" + // TaskStatusInProgress reports a task with an active starting or running run. + TaskStatusInProgress TaskStatus = "in_progress" + // TaskStatusCompleted reports a task that finished successfully. + TaskStatusCompleted TaskStatus = "completed" + // TaskStatusFailed reports a task that ended unsuccessfully. + TaskStatusFailed TaskStatus = "failed" + // TaskStatusCancelled reports a task that was cancelled before successful completion. + TaskStatusCancelled TaskStatus = "cancelled" +) + +// TaskRunStatus identifies the canonical lifecycle state of a task run. +type TaskRunStatus string + +const ( + // TaskRunStatusQueued reports a run that has been accepted but not yet claimed. + TaskRunStatusQueued TaskRunStatus = "queued" + // TaskRunStatusClaimed reports a run that has been claimed for execution. + TaskRunStatusClaimed TaskRunStatus = "claimed" + // TaskRunStatusStarting reports a run that is starting its execution session. + TaskRunStatusStarting TaskRunStatus = "starting" + // TaskRunStatusRunning reports a run that is actively executing. + TaskRunStatusRunning TaskRunStatus = "running" + // TaskRunStatusCompleted reports a run that finished successfully. + TaskRunStatusCompleted TaskRunStatus = "completed" + // TaskRunStatusFailed reports a run that finished with an error. + TaskRunStatusFailed TaskRunStatus = "failed" + // TaskRunStatusCancelled reports a run that was cancelled. + TaskRunStatusCancelled TaskRunStatus = "cancelled" +) + +// ActorKind identifies the authenticated principal class behind task writes. +type ActorKind string + +const ( + // ActorKindHuman identifies a human principal writing through CLI, web, HTTP, or UDS surfaces. + ActorKindHuman ActorKind = "human" + // ActorKindAgentSession identifies an AGH agent session principal. + ActorKindAgentSession ActorKind = "agent_session" + // ActorKindAutomation identifies daemon-owned automation flows. + ActorKindAutomation ActorKind = "automation" + // ActorKindExtension identifies an authenticated extension runtime principal. + ActorKindExtension ActorKind = "extension" + // ActorKindNetworkPeer identifies an authenticated network peer principal. + ActorKindNetworkPeer ActorKind = "network_peer" + // ActorKindDaemon identifies daemon-owned system work. + ActorKindDaemon ActorKind = "daemon" +) + +// OwnerKind identifies who currently owns a task operationally. +type OwnerKind string + +const ( + // OwnerKindHuman identifies a human owner. + OwnerKindHuman OwnerKind = "human" + // OwnerKindAgentSession identifies an agent-session owner. + OwnerKindAgentSession OwnerKind = "agent_session" + // OwnerKindAutomation identifies an automation owner. + OwnerKindAutomation OwnerKind = "automation" + // OwnerKindExtension identifies an extension owner. + OwnerKindExtension OwnerKind = "extension" + // OwnerKindNetworkPeer identifies a network-peer owner. + OwnerKindNetworkPeer OwnerKind = "network_peer" + // OwnerKindPool identifies pooled ownership without a dedicated assignee. + OwnerKindPool OwnerKind = "pool" +) + +// OriginKind identifies the technical ingress surface that produced a task-domain write. +type OriginKind string + +const ( + // OriginKindCLI identifies CLI ingress. + OriginKindCLI OriginKind = "cli" + // OriginKindWeb identifies web UI ingress. + OriginKindWeb OriginKind = "web" + // OriginKindUDS identifies local UDS ingress. + OriginKindUDS OriginKind = "uds" + // OriginKindHTTP identifies HTTP ingress. + OriginKindHTTP OriginKind = "http" + // OriginKindAutomation identifies automation ingress. + OriginKindAutomation OriginKind = "automation" + // OriginKindExtension identifies extension ingress. + OriginKindExtension OriginKind = "extension" + // OriginKindNetwork identifies network ingress. + OriginKindNetwork OriginKind = "network" + // OriginKindAgentSession identifies session tool-call ingress. + OriginKindAgentSession OriginKind = "agent_session" + // OriginKindDaemon identifies daemon-owned internal ingress. + OriginKindDaemon OriginKind = "daemon" +) + +// DependencyKind identifies the semantic meaning of one dependency edge. +type DependencyKind string + +const ( + // DependencyKindBlocks identifies a dependency that must resolve before the task may proceed. + DependencyKindBlocks DependencyKind = "blocks" +) + +// StopReason identifies why the task domain asked the session bridge to stop a session. +type StopReason string + +const ( + // StopReasonCancellation identifies explicit task or run cancellation. + StopReasonCancellation StopReason = "cancellation" + // StopReasonShutdown identifies daemon shutdown or boot recovery stop requests. + StopReasonShutdown StopReason = "shutdown" + // StopReasonOrphanedRun identifies orphaned-run recovery handling. + StopReasonOrphanedRun StopReason = "orphaned_run" +) + +// RunBootRecoveryAction identifies the manager-owned recovery action applied to +// a non-terminal run during daemon startup reconciliation. +type RunBootRecoveryAction string + +const ( + // RunBootRecoveryRequeue resets one claimed run back to the durable queue. + RunBootRecoveryRequeue RunBootRecoveryAction = "requeue" + // RunBootRecoveryMarkRunning promotes one live attached run into running. + RunBootRecoveryMarkRunning RunBootRecoveryAction = "mark_running" + // RunBootRecoveryFail marks one orphaned attached run as failed. + RunBootRecoveryFail RunBootRecoveryAction = "fail" +) + +// ActorIdentity is the immutable server-derived actor identity attached to task and run writes. +type ActorIdentity struct { + Kind ActorKind `json:"kind"` + Ref string `json:"ref"` +} + +// Ownership is the optional mutable operational assignee attached to a task. +type Ownership struct { + Kind OwnerKind `json:"kind"` + Ref string `json:"ref"` +} + +// Origin is the immutable technical ingress context attached to task and run writes. +type Origin struct { + Kind OriginKind `json:"kind"` + Ref string `json:"ref"` +} + +// Authority captures the task-domain permissions resolved for one authenticated principal. +type Authority struct { + Read bool `json:"read"` + Write bool `json:"write"` + CreateGlobal bool `json:"create_global"` + CreateWorkspace bool `json:"create_workspace"` +} + +// ActorContext carries the authenticated principal, ingress origin, and resolved task authority. +type ActorContext struct { + Actor ActorIdentity `json:"actor"` + Origin Origin `json:"origin"` + Authority Authority `json:"authority"` +} + +// Task is the durable coordination record owned by the task domain. +type Task struct { + ID string `json:"id"` + Identifier string `json:"identifier,omitempty"` + Scope Scope `json:"scope"` + WorkspaceID string `json:"workspace_id,omitempty"` + ParentTaskID string `json:"parent_task_id,omitempty"` + NetworkChannel string `json:"network_channel,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Status TaskStatus `json:"status"` + Owner *Ownership `json:"owner,omitempty"` + CreatedBy ActorIdentity `json:"created_by"` + Origin Origin `json:"origin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ClosedAt time.Time `json:"closed_at,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +// TaskDependency is the durable edge record connecting one task to a blocking dependency. +type TaskDependency struct { + TaskID string `json:"task_id"` + DependsOnTaskID string `json:"depends_on_task_id"` + Kind DependencyKind `json:"kind"` + CreatedAt time.Time `json:"created_at"` +} + +// TaskRun is the durable execution record for one task attempt. +type TaskRun struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Status TaskRunStatus `json:"status"` + Attempt int `json:"attempt"` + ClaimedBy *ActorIdentity `json:"claimed_by,omitempty"` + SessionID string `json:"session_id,omitempty"` + Origin Origin `json:"origin"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + NetworkChannel string `json:"network_channel,omitempty"` + QueuedAt time.Time `json:"queued_at"` + ClaimedAt time.Time `json:"claimed_at,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + EndedAt time.Time `json:"ended_at,omitempty"` + Error string `json:"error,omitempty"` + Result json.RawMessage `json:"result,omitempty"` +} + +// TaskEvent is the immutable audit record emitted for task-domain actions. +type TaskEvent struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + RunID string `json:"run_id,omitempty"` + EventType string `json:"event_type"` + Actor ActorIdentity `json:"actor"` + Origin Origin `json:"origin"` + Payload json.RawMessage `json:"payload,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// TaskRunIdempotency is the durable deduplication record for non-human run ingress. +type TaskRunIdempotency struct { + IdempotencyKey string `json:"idempotency_key"` + RunID string `json:"run_id"` + Origin Origin `json:"origin"` + CreatedAt time.Time `json:"created_at"` +} + +// TaskSummary is the lightweight read model returned from list-oriented task queries. +type TaskSummary struct { + ID string `json:"id"` + Identifier string `json:"identifier,omitempty"` + Scope Scope `json:"scope"` + WorkspaceID string `json:"workspace_id,omitempty"` + ParentTaskID string `json:"parent_task_id,omitempty"` + NetworkChannel string `json:"network_channel,omitempty"` + Title string `json:"title"` + Status TaskStatus `json:"status"` + Owner *Ownership `json:"owner,omitempty"` + CreatedBy ActorIdentity `json:"created_by"` + Origin Origin `json:"origin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ClosedAt time.Time `json:"closed_at,omitempty"` +} + +// TaskView is the expanded read model returned from single-task lookups. +type TaskView struct { + Task Task `json:"task"` + Children []TaskSummary `json:"children,omitempty"` + Dependencies []TaskDependency `json:"dependencies,omitempty"` + Runs []TaskRun `json:"runs,omitempty"` + Events []TaskEvent `json:"events,omitempty"` +} + +// CreateTask captures the mutable inputs accepted when creating a new task. +type CreateTask struct { + ID string `json:"id,omitempty"` + Identifier string `json:"identifier,omitempty"` + Scope Scope `json:"scope"` + WorkspaceID string `json:"workspace_id,omitempty"` + ParentTaskID string `json:"parent_task_id,omitempty"` + NetworkChannel string `json:"network_channel,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Owner *Ownership `json:"owner,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +// TaskPatch captures the mutable task fields accepted by update operations. +type TaskPatch struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Metadata *json.RawMessage `json:"metadata,omitempty"` + NetworkChannel *string `json:"network_channel,omitempty"` + Owner *Ownership `json:"owner,omitempty"` + ClearOwner bool `json:"clear_owner,omitempty"` +} + +// CancelTask captures the task-level cancellation request payload. +type CancelTask struct { + Reason string `json:"reason,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +// AddDependency captures one dependency-edge creation request. +type AddDependency struct { + TaskID string `json:"task_id"` + DependsOnTaskID string `json:"depends_on_task_id"` + Kind DependencyKind `json:"kind"` +} + +// EnqueueRun captures the mutable inputs accepted when queuing a task run. +type EnqueueRun struct { + TaskID string `json:"task_id"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + NetworkChannel string `json:"network_channel,omitempty"` +} + +// ClaimRun captures one run-claim request. +type ClaimRun struct { + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +// StartRun captures one run-start request. +type StartRun struct { + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +// CancelRun captures one run-cancellation request. +type CancelRun struct { + Reason string `json:"reason,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +// RunResult captures the durable JSON result returned by a completed run. +type RunResult struct { + Value json.RawMessage `json:"value,omitempty"` +} + +// RunFailure captures the durable failure payload returned by a failed run. +type RunFailure struct { + Error string `json:"error"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +// TaskQuery captures the supported list filters for task reads. +type TaskQuery struct { + Scope Scope `json:"scope,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + Status TaskStatus `json:"status,omitempty"` + OwnerKind OwnerKind `json:"owner_kind,omitempty"` + OwnerRef string `json:"owner_ref,omitempty"` + ParentTaskID string `json:"parent_task_id,omitempty"` + NetworkChannel string `json:"network_channel,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// TaskRunQuery captures the supported list filters for task-run reads. +type TaskRunQuery struct { + TaskID string `json:"task_id,omitempty"` + Status TaskRunStatus `json:"status,omitempty"` + SessionID string `json:"session_id,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// TaskEventQuery captures the supported list filters for task-event reads. +type TaskEventQuery struct { + TaskID string `json:"task_id,omitempty"` + RunID string `json:"run_id,omitempty"` + EventType string `json:"event_type,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// StartTaskSession captures the task and run context needed to allocate a dedicated session. +type StartTaskSession struct { + Task Task `json:"task"` + Run TaskRun `json:"run"` + Actor ActorContext `json:"actor"` +} + +// SessionRef is the task-domain view of a runtime session binding. +type SessionRef struct { + SessionID string `json:"session_id"` + WorkspaceID string `json:"workspace_id,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` +} + +// RunBootRecovery captures one daemon-owned recovery decision for an in-flight +// run discovered during boot reconciliation. +type RunBootRecovery struct { + Action RunBootRecoveryAction `json:"action"` + Reason string `json:"reason,omitempty"` + SessionState string `json:"session_state,omitempty"` +} diff --git a/internal/task/validate.go b/internal/task/validate.go new file mode 100644 index 000000000..ddb8f1c04 --- /dev/null +++ b/internal/task/validate.go @@ -0,0 +1,713 @@ +package task + +import ( + "encoding/json" + "fmt" + "strings" +) + +// Normalize returns the normalized representation of the scope. +func (s Scope) Normalize() Scope { + return Scope(strings.ToLower(strings.TrimSpace(string(s)))) +} + +// Validate reports whether the scope is one of the supported task scope values. +func (s Scope) Validate(path string) error { + switch s.Normalize() { + case ScopeGlobal, ScopeWorkspace: + return nil + case "": + return fmt.Errorf("%w: %s is required", ErrValidation, path) + default: + return fmt.Errorf("%w: %s must be %q or %q: %q", ErrValidation, path, ScopeGlobal, ScopeWorkspace, s) + } +} + +// Normalize returns the normalized representation of the task status. +func (s TaskStatus) Normalize() TaskStatus { + return TaskStatus(strings.ToLower(strings.TrimSpace(string(s)))) +} + +// Validate reports whether the task status is one of the supported lifecycle states. +func (s TaskStatus) Validate(path string) error { + switch s.Normalize() { + case TaskStatusPending, + TaskStatusBlocked, + TaskStatusReady, + TaskStatusInProgress, + TaskStatusCompleted, + TaskStatusFailed, + TaskStatusCancelled: + return nil + case "": + return fmt.Errorf("%w: %s is required", ErrValidation, path) + default: + return fmt.Errorf( + "%w: %s must be one of %q, %q, %q, %q, %q, %q, or %q: %q", + ErrValidation, + path, + TaskStatusPending, + TaskStatusBlocked, + TaskStatusReady, + TaskStatusInProgress, + TaskStatusCompleted, + TaskStatusFailed, + TaskStatusCancelled, + s, + ) + } +} + +// Normalize returns the normalized representation of the task-run status. +func (s TaskRunStatus) Normalize() TaskRunStatus { + return TaskRunStatus(strings.ToLower(strings.TrimSpace(string(s)))) +} + +// Validate reports whether the task-run status is one of the supported lifecycle states. +func (s TaskRunStatus) Validate(path string) error { + switch s.Normalize() { + case TaskRunStatusQueued, + TaskRunStatusClaimed, + TaskRunStatusStarting, + TaskRunStatusRunning, + TaskRunStatusCompleted, + TaskRunStatusFailed, + TaskRunStatusCancelled: + return nil + case "": + return fmt.Errorf("%w: %s is required", ErrValidation, path) + default: + return fmt.Errorf( + "%w: %s must be one of %q, %q, %q, %q, %q, %q, or %q: %q", + ErrValidation, + path, + TaskRunStatusQueued, + TaskRunStatusClaimed, + TaskRunStatusStarting, + TaskRunStatusRunning, + TaskRunStatusCompleted, + TaskRunStatusFailed, + TaskRunStatusCancelled, + s, + ) + } +} + +// Normalize returns the normalized representation of the actor kind. +func (k ActorKind) Normalize() ActorKind { + return ActorKind(strings.ToLower(strings.TrimSpace(string(k)))) +} + +// Validate reports whether the actor kind is supported. +func (k ActorKind) Validate(path string) error { + switch k.Normalize() { + case ActorKindHuman, + ActorKindAgentSession, + ActorKindAutomation, + ActorKindExtension, + ActorKindNetworkPeer, + ActorKindDaemon: + return nil + case "": + return fmt.Errorf("%w: %s is required", ErrValidation, path) + default: + return fmt.Errorf("%w: %s has unsupported value %q", ErrValidation, path, k) + } +} + +// Normalize returns the normalized representation of the owner kind. +func (k OwnerKind) Normalize() OwnerKind { + return OwnerKind(strings.ToLower(strings.TrimSpace(string(k)))) +} + +// Validate reports whether the owner kind is supported. +func (k OwnerKind) Validate(path string) error { + switch k.Normalize() { + case OwnerKindHuman, + OwnerKindAgentSession, + OwnerKindAutomation, + OwnerKindExtension, + OwnerKindNetworkPeer, + OwnerKindPool: + return nil + case "": + return fmt.Errorf("%w: %s is required", ErrValidation, path) + default: + return fmt.Errorf("%w: %s has unsupported value %q", ErrValidation, path, k) + } +} + +// Normalize returns the normalized representation of the origin kind. +func (k OriginKind) Normalize() OriginKind { + return OriginKind(strings.ToLower(strings.TrimSpace(string(k)))) +} + +// Validate reports whether the origin kind is supported. +func (k OriginKind) Validate(path string) error { + switch k.Normalize() { + case OriginKindCLI, + OriginKindWeb, + OriginKindUDS, + OriginKindHTTP, + OriginKindAutomation, + OriginKindExtension, + OriginKindNetwork, + OriginKindAgentSession, + OriginKindDaemon: + return nil + case "": + return fmt.Errorf("%w: %s is required", ErrValidation, path) + default: + return fmt.Errorf("%w: %s has unsupported value %q", ErrValidation, path, k) + } +} + +// Normalize returns the normalized representation of the dependency kind. +func (k DependencyKind) Normalize() DependencyKind { + return DependencyKind(strings.ToLower(strings.TrimSpace(string(k)))) +} + +// Validate reports whether the dependency kind is supported. +func (k DependencyKind) Validate(path string) error { + switch k.Normalize() { + case DependencyKindBlocks: + return nil + case "": + return fmt.Errorf("%w: %s is required", ErrValidation, path) + default: + return fmt.Errorf("%w: %s has unsupported value %q", ErrValidation, path, k) + } +} + +// Normalize returns the normalized representation of the session stop reason. +func (r StopReason) Normalize() StopReason { + return StopReason(strings.ToLower(strings.TrimSpace(string(r)))) +} + +// Validate reports whether the stop reason is supported. +func (r StopReason) Validate(path string) error { + switch r.Normalize() { + case StopReasonCancellation, StopReasonShutdown, StopReasonOrphanedRun: + return nil + case "": + return fmt.Errorf("%w: %s is required", ErrValidation, path) + default: + return fmt.Errorf("%w: %s has unsupported value %q", ErrValidation, path, r) + } +} + +// Normalize returns the normalized representation of the boot-recovery action. +func (a RunBootRecoveryAction) Normalize() RunBootRecoveryAction { + return RunBootRecoveryAction(strings.ToLower(strings.TrimSpace(string(a)))) +} + +// Validate reports whether the boot-recovery action is supported. +func (a RunBootRecoveryAction) Validate(path string) error { + switch a.Normalize() { + case RunBootRecoveryRequeue, RunBootRecoveryMarkRunning, RunBootRecoveryFail: + return nil + case "": + return fmt.Errorf("%w: %s is required", ErrValidation, path) + default: + return fmt.Errorf("%w: %s has unsupported value %q", ErrValidation, path, a) + } +} + +// Validate reports whether the actor identity contains a supported kind and non-empty reference. +func (a ActorIdentity) Validate(path string) error { + if err := a.Kind.Validate(nestedPath(path, "kind")); err != nil { + return err + } + if strings.TrimSpace(a.Ref) == "" { + return fmt.Errorf("%w: %s is required", ErrValidation, nestedPath(path, "ref")) + } + return nil +} + +// Validate reports whether the ownership value contains a supported kind and non-empty reference. +func (o Ownership) Validate(path string) error { + if err := o.Kind.Validate(nestedPath(path, "kind")); err != nil { + return err + } + if strings.TrimSpace(o.Ref) == "" { + return fmt.Errorf("%w: %s is required", ErrValidation, nestedPath(path, "ref")) + } + return nil +} + +// IsZero reports whether the ownership value is empty. +func (o Ownership) IsZero() bool { + return o.Kind.Normalize() == "" && strings.TrimSpace(o.Ref) == "" +} + +// Validate reports whether the origin contains a supported kind and non-empty reference. +func (o Origin) Validate(path string) error { + if err := o.Kind.Validate(nestedPath(path, "kind")); err != nil { + return err + } + if strings.TrimSpace(o.Ref) == "" { + return fmt.Errorf("%w: %s is required", ErrValidation, nestedPath(path, "ref")) + } + return nil +} + +// Validate reports whether the authority flags are internally consistent. +func (a Authority) Validate(path string) error { + if !a.Write && (a.CreateGlobal || a.CreateWorkspace) { + return fmt.Errorf("%w: %s create permissions require write permission", ErrValidation, path) + } + return nil +} + +// Validate reports whether the actor context contains a valid principal, origin, and authority envelope. +func (a ActorContext) Validate() error { + if err := a.Actor.Validate("actor"); err != nil { + return err + } + if err := a.Origin.Validate("origin"); err != nil { + return err + } + if err := validateActorOriginPair(a.Actor, a.Origin); err != nil { + return err + } + if err := a.Authority.Validate("authority"); err != nil { + return err + } + return nil +} + +// Validate reports whether the task record contains the canonical persisted shape. +func (t Task) Validate() error { + if strings.TrimSpace(t.ID) == "" { + return fmt.Errorf("%w: task.id is required", ErrValidation) + } + if err := ValidateScopeBinding(t.Scope, t.WorkspaceID, "task", "workspace_id"); err != nil { + return err + } + if strings.TrimSpace(t.ParentTaskID) != "" && strings.TrimSpace(t.ParentTaskID) == strings.TrimSpace(t.ID) { + return fmt.Errorf("%w: task.parent_task_id cannot equal task.id", ErrValidation) + } + if strings.TrimSpace(t.Title) == "" { + return fmt.Errorf("%w: task.title is required", ErrValidation) + } + if err := t.Status.Validate("task.status"); err != nil { + return err + } + if err := t.CreatedBy.Validate("task.created_by"); err != nil { + return err + } + if err := t.Origin.Validate("task.origin"); err != nil { + return err + } + if t.Owner != nil { + if err := t.Owner.Validate("task.owner"); err != nil { + return err + } + } + if err := ValidateMetadataSize(t.Metadata, "task.metadata"); err != nil { + return err + } + return nil +} + +// Validate reports whether the dependency edge contains the canonical persisted shape. +func (d TaskDependency) Validate() error { + if strings.TrimSpace(d.TaskID) == "" { + return fmt.Errorf("%w: task_dependency.task_id is required", ErrValidation) + } + if strings.TrimSpace(d.DependsOnTaskID) == "" { + return fmt.Errorf("%w: task_dependency.depends_on_task_id is required", ErrValidation) + } + if strings.TrimSpace(d.TaskID) == strings.TrimSpace(d.DependsOnTaskID) { + return fmt.Errorf("%w: task_dependency cannot depend on itself", ErrValidation) + } + if err := d.Kind.Validate("task_dependency.kind"); err != nil { + return err + } + return nil +} + +// Validate reports whether the task-run record contains the canonical persisted shape. +func (r TaskRun) Validate() error { + if strings.TrimSpace(r.ID) == "" { + return fmt.Errorf("%w: task_run.id is required", ErrValidation) + } + if strings.TrimSpace(r.TaskID) == "" { + return fmt.Errorf("%w: task_run.task_id is required", ErrValidation) + } + if err := r.Status.Validate("task_run.status"); err != nil { + return err + } + if r.Attempt <= 0 { + return fmt.Errorf("%w: task_run.attempt must be positive: %d", ErrValidation, r.Attempt) + } + if r.ClaimedBy != nil { + if err := r.ClaimedBy.Validate("task_run.claimed_by"); err != nil { + return err + } + } + if err := r.Origin.Validate("task_run.origin"); err != nil { + return err + } + if r.Status.Normalize() == TaskRunStatusQueued && strings.TrimSpace(r.SessionID) != "" { + return fmt.Errorf("%w: task_run.session_id must be empty while status is %q", ErrValidation, TaskRunStatusQueued) + } + if err := ValidateResultSize(r.Result, "task_run.result"); err != nil { + return err + } + return nil +} + +// Validate reports whether the boot-recovery request contains one supported +// recovery action. +func (r RunBootRecovery) Validate(path string) error { + if err := r.Action.Validate(nestedPath(path, "action")); err != nil { + return err + } + return nil +} + +// Validate reports whether the audit event contains the canonical persisted shape. +func (e TaskEvent) Validate() error { + if strings.TrimSpace(e.ID) == "" { + return fmt.Errorf("%w: task_event.id is required", ErrValidation) + } + if strings.TrimSpace(e.TaskID) == "" { + return fmt.Errorf("%w: task_event.task_id is required", ErrValidation) + } + if strings.TrimSpace(e.EventType) == "" { + return fmt.Errorf("%w: task_event.event_type is required", ErrValidation) + } + if err := e.Actor.Validate("task_event.actor"); err != nil { + return err + } + if err := e.Origin.Validate("task_event.origin"); err != nil { + return err + } + if err := ValidatePayloadSize(e.Payload, "task_event.payload"); err != nil { + return err + } + return nil +} + +// Validate reports whether the persisted idempotency record contains the canonical shape. +func (r TaskRunIdempotency) Validate() error { + if strings.TrimSpace(r.IdempotencyKey) == "" { + return fmt.Errorf("%w: task_run_idempotency.idempotency_key is required", ErrValidation) + } + if strings.TrimSpace(r.RunID) == "" { + return fmt.Errorf("%w: task_run_idempotency.run_id is required", ErrValidation) + } + if err := r.Origin.Validate("task_run_idempotency.origin"); err != nil { + return err + } + return nil +} + +// Validate reports whether the create-task request is internally consistent. +func (r CreateTask) Validate(path string) error { + if err := ValidateScopeBinding(r.Scope, r.WorkspaceID, path, "workspace_id"); err != nil { + return err + } + if strings.TrimSpace(r.Title) == "" { + return fmt.Errorf("%w: %s is required", ErrValidation, nestedPath(path, "title")) + } + if strings.TrimSpace(r.ParentTaskID) != "" && strings.TrimSpace(r.ID) != "" && strings.TrimSpace(r.ParentTaskID) == strings.TrimSpace(r.ID) { + return fmt.Errorf("%w: %s cannot equal %s", ErrValidation, nestedPath(path, "parent_task_id"), nestedPath(path, "id")) + } + if r.Owner != nil { + if err := r.Owner.Validate(nestedPath(path, "owner")); err != nil { + return err + } + } + if err := ValidateMetadataSize(r.Metadata, nestedPath(path, "metadata")); err != nil { + return err + } + return nil +} + +// Validate reports whether the task patch contains at least one mutable field and valid values. +func (p TaskPatch) Validate(path string) error { + if p.Title == nil && p.Description == nil && p.Metadata == nil && p.NetworkChannel == nil && p.Owner == nil && !p.ClearOwner { + return fmt.Errorf("%w: %s requires at least one mutable field", ErrValidation, path) + } + if p.Title != nil && strings.TrimSpace(*p.Title) == "" { + return fmt.Errorf("%w: %s is required when provided", ErrValidation, nestedPath(path, "title")) + } + if p.Owner != nil && p.ClearOwner { + return fmt.Errorf("%w: %s.owner and %s.clear_owner cannot both be set", ErrValidation, path, path) + } + if p.Owner != nil { + if err := p.Owner.Validate(nestedPath(path, "owner")); err != nil { + return err + } + } + if p.Metadata != nil { + if err := ValidateMetadataSize(*p.Metadata, nestedPath(path, "metadata")); err != nil { + return err + } + } + return nil +} + +// Validate reports whether the task-cancellation request is internally consistent. +func (r CancelTask) Validate(path string) error { + return ValidatePayloadSize(r.Metadata, nestedPath(path, "metadata")) +} + +// Validate reports whether the dependency-create request is internally consistent. +func (r AddDependency) Validate(path string) error { + dependency := TaskDependency{ + TaskID: r.TaskID, + DependsOnTaskID: r.DependsOnTaskID, + Kind: r.Kind, + } + if err := dependency.Validate(); err != nil { + return fmt.Errorf("%w: %s", err, path) + } + return nil +} + +// Validate reports whether the enqueue-run request is internally consistent. +func (r EnqueueRun) Validate(path string) error { + if strings.TrimSpace(r.TaskID) == "" { + return fmt.Errorf("%w: %s is required", ErrValidation, nestedPath(path, "task_id")) + } + return nil +} + +// Validate reports whether the claim-run request is internally consistent. +func (r ClaimRun) Validate(path string) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("%w: claim_run path is required", ErrValidation) + } + return nil +} + +// Validate reports whether the start-run request is internally consistent. +func (r StartRun) Validate(path string) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("%w: start_run path is required", ErrValidation) + } + return nil +} + +// Validate reports whether the cancel-run request is internally consistent. +func (r CancelRun) Validate(path string) error { + return ValidatePayloadSize(r.Metadata, nestedPath(path, "metadata")) +} + +// Validate reports whether the run result respects the shared result-size guardrail. +func (r RunResult) Validate(path string) error { + return ValidateResultSize(r.Value, nestedPath(path, "value")) +} + +// Validate reports whether the run failure contains a message and bounded metadata. +func (r RunFailure) Validate(path string) error { + if strings.TrimSpace(r.Error) == "" { + return fmt.Errorf("%w: %s is required", ErrValidation, nestedPath(path, "error")) + } + return ValidatePayloadSize(r.Metadata, nestedPath(path, "metadata")) +} + +// Validate reports whether the task-query filters are internally consistent. +func (q TaskQuery) Validate(path string) error { + if q.Scope.Normalize() != "" { + if err := ValidateScopeBinding(q.Scope, q.WorkspaceID, path, "workspace_id"); err != nil { + return err + } + } + if q.Status.Normalize() != "" { + if err := q.Status.Validate(nestedPath(path, "status")); err != nil { + return err + } + } + if q.OwnerKind.Normalize() != "" { + if err := q.OwnerKind.Validate(nestedPath(path, "owner_kind")); err != nil { + return err + } + } + if q.Limit < 0 { + return fmt.Errorf("%w: %s must be zero or positive: %d", ErrValidation, nestedPath(path, "limit"), q.Limit) + } + return nil +} + +// Validate reports whether the task-run query filters are internally consistent. +func (q TaskRunQuery) Validate(path string) error { + if q.Status.Normalize() != "" { + if err := q.Status.Validate(nestedPath(path, "status")); err != nil { + return err + } + } + if q.Limit < 0 { + return fmt.Errorf("%w: %s must be zero or positive: %d", ErrValidation, nestedPath(path, "limit"), q.Limit) + } + return nil +} + +// Validate reports whether the task-event query filters are internally consistent. +func (q TaskEventQuery) Validate(path string) error { + if q.Limit < 0 { + return fmt.Errorf("%w: %s must be zero or positive: %d", ErrValidation, nestedPath(path, "limit"), q.Limit) + } + return nil +} + +// Validate reports whether the session-start request contains the task and run context required by the bridge. +func (r StartTaskSession) Validate() error { + if err := r.Task.Validate(); err != nil { + return err + } + if err := r.Run.Validate(); err != nil { + return err + } + if err := r.Actor.Validate(); err != nil { + return err + } + if strings.TrimSpace(r.Run.TaskID) != strings.TrimSpace(r.Task.ID) { + return fmt.Errorf("%w: start_task_session.run.task_id must match start_task_session.task.id", ErrValidation) + } + return nil +} + +// Validate reports whether the session reference returned by the bridge is usable. +func (r SessionRef) Validate() error { + if strings.TrimSpace(r.SessionID) == "" { + return fmt.Errorf("%w: session_ref.session_id is required", ErrValidation) + } + return nil +} + +// ValidateScopeBinding enforces the canonical scope/workspace invariant shared by task-domain records. +func ValidateScopeBinding(scope Scope, workspaceBinding string, path string, workspaceField string) error { + scopePath := nestedPath(path, "scope") + if err := scope.Validate(scopePath); err != nil { + return err + } + + workspacePath := nestedPath(path, workspaceField) + switch scope.Normalize() { + case ScopeGlobal: + if strings.TrimSpace(workspaceBinding) != "" { + return fmt.Errorf( + "%w: %s must be empty when %s is %q", + ErrInvalidScopeBinding, + workspacePath, + scopePath, + ScopeGlobal, + ) + } + case ScopeWorkspace: + if strings.TrimSpace(workspaceBinding) == "" { + return fmt.Errorf( + "%w: %s is required when %s is %q", + ErrInvalidScopeBinding, + workspacePath, + scopePath, + ScopeWorkspace, + ) + } + } + + return nil +} + +// ValidateImmutableTaskFields reports whether an update attempted to change immutable task fields. +func ValidateImmutableTaskFields(current Task, next Task) error { + if !sameActorIdentity(current.CreatedBy, next.CreatedBy) { + return fmt.Errorf("%w: %s", ErrImmutableField, TaskFieldCreatedBy) + } + if !sameOrigin(current.Origin, next.Origin) { + return fmt.Errorf("%w: %s", ErrImmutableField, TaskFieldOrigin) + } + if current.Scope.Normalize() != next.Scope.Normalize() { + return fmt.Errorf("%w: %s", ErrImmutableField, TaskFieldScope) + } + if strings.TrimSpace(current.WorkspaceID) != strings.TrimSpace(next.WorkspaceID) { + return fmt.Errorf("%w: %s", ErrImmutableField, TaskFieldWorkspaceID) + } + if strings.TrimSpace(current.ParentTaskID) != strings.TrimSpace(next.ParentTaskID) { + return fmt.Errorf("%w: %s", ErrImmutableField, TaskFieldParentTaskID) + } + return nil +} + +// ValidateMetadataSize reports whether metadata JSON respects the shared 16 KiB guardrail. +func ValidateMetadataSize(payload json.RawMessage, path string) error { + return validateJSONSize(payload, MaxMetadataBytes, path) +} + +// ValidatePayloadSize reports whether a persisted JSON payload respects the shared 64 KiB guardrail. +func ValidatePayloadSize(payload json.RawMessage, path string) error { + return validateJSONSize(payload, MaxPayloadBytes, path) +} + +// ValidateResultSize reports whether a persisted run result respects the shared 64 KiB guardrail. +func ValidateResultSize(payload json.RawMessage, path string) error { + return validateJSONSize(payload, MaxResultBytes, path) +} + +// ValidateHierarchyDepth reports whether the supplied task depth stays within the bounded hierarchy limit. +func ValidateHierarchyDepth(depth int) error { + return validateBoundedCount(depth, MaxHierarchyDepth, "hierarchy depth") +} + +// ValidateDependencyCount reports whether the supplied dependency count stays within the bounded edge limit. +func ValidateDependencyCount(count int) error { + return validateBoundedCount(count, MaxDependencyCount, "dependency count") +} + +// ValidateDirectChildCount reports whether the supplied direct-child count stays within the bounded fan-out limit. +func ValidateDirectChildCount(count int) error { + return validateBoundedCount(count, MaxDirectChildren, "direct child count") +} + +func validateJSONSize(payload json.RawMessage, maxBytes int, path string) error { + if len(payload) == 0 { + return nil + } + + trimmed := bytesTrimSpace(payload) + if !json.Valid(trimmed) { + return fmt.Errorf("%w: %s must contain valid JSON", ErrValidation, path) + } + if len(trimmed) > maxBytes { + return fmt.Errorf("%w: %s exceeds %d bytes", ErrPayloadTooLarge, path, maxBytes) + } + return nil +} + +func validateBoundedCount(count int, maxCount int, label string) error { + if count < 0 { + return fmt.Errorf("%w: %s cannot be negative: %d", ErrValidation, label, count) + } + if count > maxCount { + return fmt.Errorf("%w: %s exceeds %d: %d", ErrGraphLimitExceeded, label, maxCount, count) + } + return nil +} + +func nestedPath(path string, field string) string { + trimmedPath := strings.TrimSpace(path) + trimmedField := strings.TrimSpace(field) + if trimmedPath == "" { + return trimmedField + } + if trimmedField == "" { + return trimmedPath + } + return trimmedPath + "." + trimmedField +} + +func sameActorIdentity(left ActorIdentity, right ActorIdentity) bool { + return left.Kind.Normalize() == right.Kind.Normalize() && strings.TrimSpace(left.Ref) == strings.TrimSpace(right.Ref) +} + +func sameOrigin(left Origin, right Origin) bool { + return left.Kind.Normalize() == right.Kind.Normalize() && strings.TrimSpace(left.Ref) == strings.TrimSpace(right.Ref) +} + +func bytesTrimSpace(payload []byte) []byte { + return []byte(strings.TrimSpace(string(payload))) +} diff --git a/internal/task/validate_test.go b/internal/task/validate_test.go new file mode 100644 index 000000000..dfa903b9b --- /dev/null +++ b/internal/task/validate_test.go @@ -0,0 +1,865 @@ +package task + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" +) + +func TestValidateScopeBinding(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scope Scope + workspaceID string + wantErr error + }{ + {name: "global without workspace", scope: ScopeGlobal}, + {name: "workspace with workspace", scope: ScopeWorkspace, workspaceID: "ws-1"}, + {name: "global with workspace", scope: ScopeGlobal, workspaceID: "ws-1", wantErr: ErrInvalidScopeBinding}, + {name: "workspace without workspace", scope: ScopeWorkspace, wantErr: ErrInvalidScopeBinding}, + {name: "unsupported scope", scope: Scope("tenant"), wantErr: ErrValidation}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateScopeBinding(tt.scope, tt.workspaceID, "task", "workspace_id") + if tt.wantErr == nil { + if err != nil { + t.Fatalf("ValidateScopeBinding() error = %v", err) + } + return + } + if err == nil { + t.Fatal("ValidateScopeBinding() error = nil, want non-nil") + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("ValidateScopeBinding() error = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateImmutableTaskFields(t *testing.T) { + t.Parallel() + + current := validTask() + tests := []struct { + name string + mutate func(*Task) + wantField string + expectError bool + }{ + { + name: "created by immutable", + mutate: func(next *Task) { + next.CreatedBy.Ref = "human-2" + }, + wantField: TaskFieldCreatedBy, + expectError: true, + }, + { + name: "origin immutable", + mutate: func(next *Task) { + next.Origin.Ref = "http:api" + }, + wantField: TaskFieldOrigin, + expectError: true, + }, + { + name: "scope immutable", + mutate: func(next *Task) { + next.Scope = ScopeWorkspace + next.WorkspaceID = "ws-1" + }, + wantField: TaskFieldScope, + expectError: true, + }, + { + name: "workspace id immutable", + mutate: func(next *Task) { + next.WorkspaceID = "ws-2" + }, + wantField: TaskFieldWorkspaceID, + expectError: true, + }, + { + name: "parent task id immutable", + mutate: func(next *Task) { + next.ParentTaskID = "task-2" + }, + wantField: TaskFieldParentTaskID, + expectError: true, + }, + { + name: "mutable fields allowed", + mutate: func(next *Task) { + next.Title = "Updated" + next.Description = "changed" + next.NetworkChannel = "network:alpha" + next.Owner = &Ownership{Kind: OwnerKindPool, Ref: "triage"} + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + next := current + tt.mutate(&next) + + err := ValidateImmutableTaskFields(current, next) + if !tt.expectError { + if err != nil { + t.Fatalf("ValidateImmutableTaskFields() error = %v", err) + } + return + } + if err == nil { + t.Fatal("ValidateImmutableTaskFields() error = nil, want non-nil") + } + if !errors.Is(err, ErrImmutableField) { + t.Fatalf("ValidateImmutableTaskFields() error = %v, want ErrImmutableField", err) + } + if !strings.Contains(err.Error(), tt.wantField) { + t.Fatalf("ValidateImmutableTaskFields() error = %q, want field %q", err.Error(), tt.wantField) + } + }) + } +} + +func TestPayloadSizeGuards(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + run func() error + wantErr error + }{ + { + name: "metadata within limit", + run: func() error { + return ValidateMetadataSize(jsonBlob(MaxMetadataBytes-8), "task.metadata") + }, + }, + { + name: "metadata over limit", + run: func() error { + return ValidateMetadataSize(jsonBlob(MaxMetadataBytes+1), "task.metadata") + }, + wantErr: ErrPayloadTooLarge, + }, + { + name: "payload over limit", + run: func() error { + return ValidatePayloadSize(jsonBlob(MaxPayloadBytes+1), "task_event.payload") + }, + wantErr: ErrPayloadTooLarge, + }, + { + name: "result over limit", + run: func() error { + return ValidateResultSize(jsonBlob(MaxResultBytes+1), "task_run.result") + }, + wantErr: ErrPayloadTooLarge, + }, + { + name: "invalid json", + run: func() error { + return ValidatePayloadSize(json.RawMessage(`{`), "task_event.payload") + }, + wantErr: ErrValidation, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.run() + if tt.wantErr == nil { + if err != nil { + t.Fatalf("payload guard error = %v", err) + } + return + } + if err == nil { + t.Fatal("payload guard error = nil, want non-nil") + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("payload guard error = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestGraphLimitGuards(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + run func() error + wantErr error + }{ + { + name: "depth at limit", + run: func() error { + return ValidateHierarchyDepth(MaxHierarchyDepth) + }, + }, + { + name: "depth over limit", + run: func() error { + return ValidateHierarchyDepth(MaxHierarchyDepth + 1) + }, + wantErr: ErrGraphLimitExceeded, + }, + { + name: "dependency count over limit", + run: func() error { + return ValidateDependencyCount(MaxDependencyCount + 1) + }, + wantErr: ErrGraphLimitExceeded, + }, + { + name: "direct child count over limit", + run: func() error { + return ValidateDirectChildCount(MaxDirectChildren + 1) + }, + wantErr: ErrGraphLimitExceeded, + }, + { + name: "negative count rejected", + run: func() error { + return ValidateDependencyCount(-1) + }, + wantErr: ErrValidation, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.run() + if tt.wantErr == nil { + if err != nil { + t.Fatalf("graph limit error = %v", err) + } + return + } + if err == nil { + t.Fatal("graph limit error = nil, want non-nil") + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("graph limit error = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestTaskFieldMutabilityHelpers(t *testing.T) { + t.Parallel() + + for _, field := range ImmutableTaskFields() { + if !IsImmutableTaskField(field) { + t.Fatalf("IsImmutableTaskField(%q) = false, want true", field) + } + } + for _, field := range MutableTaskFields() { + if !IsMutableTaskField(field) { + t.Fatalf("IsMutableTaskField(%q) = false, want true", field) + } + } + if IsImmutableTaskField("title") { + t.Fatal("IsImmutableTaskField(\"title\") = true, want false") + } + if IsMutableTaskField("scope") { + t.Fatal("IsMutableTaskField(\"scope\") = true, want false") + } +} + +func TestDomainValidationHelpers(t *testing.T) { + t.Parallel() + + t.Run("task valid", func(t *testing.T) { + t.Parallel() + if err := validTask().Validate(); err != nil { + t.Fatalf("Task.Validate() error = %v", err) + } + }) + + t.Run("task invalid owner", func(t *testing.T) { + t.Parallel() + taskRecord := validTask() + taskRecord.Owner = &Ownership{Kind: OwnerKindHuman} + err := taskRecord.Validate() + if err == nil || !errors.Is(err, ErrValidation) { + t.Fatalf("Task.Validate() error = %v, want ErrValidation", err) + } + }) + + t.Run("task dependency self dependency", func(t *testing.T) { + t.Parallel() + err := (TaskDependency{ + TaskID: "task-1", + DependsOnTaskID: "task-1", + Kind: DependencyKindBlocks, + }).Validate() + if err == nil || !errors.Is(err, ErrValidation) { + t.Fatalf("TaskDependency.Validate() error = %v, want ErrValidation", err) + } + }) + + t.Run("task run queued session invalid", func(t *testing.T) { + t.Parallel() + run := validRun() + run.SessionID = "sess-1" + err := run.Validate() + if err == nil || !errors.Is(err, ErrValidation) { + t.Fatalf("TaskRun.Validate() error = %v, want ErrValidation", err) + } + }) + + t.Run("task event invalid payload", func(t *testing.T) { + t.Parallel() + event := validEvent() + event.Payload = json.RawMessage(`{`) + err := event.Validate() + if err == nil || !errors.Is(err, ErrValidation) { + t.Fatalf("TaskEvent.Validate() error = %v, want ErrValidation", err) + } + }) + + t.Run("task patch requires mutable field", func(t *testing.T) { + t.Parallel() + err := (TaskPatch{}).Validate("patch") + if err == nil || !errors.Is(err, ErrValidation) { + t.Fatalf("TaskPatch.Validate() error = %v, want ErrValidation", err) + } + }) + + t.Run("run failure requires error", func(t *testing.T) { + t.Parallel() + err := (RunFailure{}).Validate("failure") + if err == nil || !errors.Is(err, ErrValidation) { + t.Fatalf("RunFailure.Validate() error = %v, want ErrValidation", err) + } + }) + + t.Run("start task session requires matching task and run", func(t *testing.T) { + t.Parallel() + req := StartTaskSession{ + Task: validTask(), + Run: validRun(), + Actor: validActorContext(), + } + req.Run.TaskID = "task-2" + err := req.Validate() + if err == nil || !errors.Is(err, ErrValidation) { + t.Fatalf("StartTaskSession.Validate() error = %v, want ErrValidation", err) + } + }) + + t.Run("task query validates filters", func(t *testing.T) { + t.Parallel() + err := (TaskQuery{ + Scope: ScopeWorkspace, + WorkspaceID: "ws-1", + Status: TaskStatusReady, + OwnerKind: OwnerKindPool, + Limit: 10, + }).Validate("query") + if err != nil { + t.Fatalf("TaskQuery.Validate() error = %v", err) + } + }) + + t.Run("start task session valid", func(t *testing.T) { + t.Parallel() + req := StartTaskSession{ + Task: validTask(), + Run: validRun(), + Actor: validActorContext(), + } + if err := req.Validate(); err != nil { + t.Fatalf("StartTaskSession.Validate() error = %v", err) + } + }) +} + +func validTask() Task { + now := time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC) + return Task{ + ID: "task-1", + Identifier: "TASK-1", + Scope: ScopeGlobal, + Title: "Bootstrap internal/task", + Description: "Create the task domain", + Status: TaskStatusReady, + Owner: &Ownership{Kind: OwnerKindHuman, Ref: "user-1"}, + CreatedBy: ActorIdentity{Kind: ActorKindHuman, Ref: "user-1"}, + Origin: Origin{Kind: OriginKindCLI, Ref: "agh task create"}, + CreatedAt: now, + UpdatedAt: now, + Metadata: json.RawMessage(`{"priority":"high"}`), + ClosedAt: time.Time{}, + ParentTaskID: "", + } +} + +func validRun() TaskRun { + now := time.Date(2026, 4, 14, 12, 30, 0, 0, time.UTC) + return TaskRun{ + ID: "run-1", + TaskID: "task-1", + Status: TaskRunStatusQueued, + Attempt: 1, + Origin: Origin{Kind: OriginKindCLI, Ref: "agh task run enqueue"}, + QueuedAt: now, + Result: json.RawMessage(`{"ok":true}`), + } +} + +func validEvent() TaskEvent { + now := time.Date(2026, 4, 14, 13, 0, 0, 0, time.UTC) + return TaskEvent{ + ID: "evt-1", + TaskID: "task-1", + EventType: "task.created", + Actor: ActorIdentity{Kind: ActorKindHuman, Ref: "user-1"}, + Origin: Origin{Kind: OriginKindCLI, Ref: "agh task create"}, + Payload: json.RawMessage(`{"source":"cli"}`), + Timestamp: now, + } +} + +func validTaskRunIdempotency() TaskRunIdempotency { + now := time.Date(2026, 4, 14, 13, 30, 0, 0, time.UTC) + return TaskRunIdempotency{ + IdempotencyKey: "idem-1", + RunID: "run-1", + Origin: Origin{Kind: OriginKindAutomation, Ref: "rule:nightly"}, + CreatedAt: now, + } +} + +func validActorContext() ActorContext { + return ActorContext{ + Actor: ActorIdentity{Kind: ActorKindHuman, Ref: "user-1"}, + Origin: Origin{Kind: OriginKindCLI, Ref: "agh task run start"}, + Authority: Authority{ + Read: true, + Write: true, + CreateGlobal: true, + CreateWorkspace: true, + }, + } +} + +func jsonBlob(targetSize int) json.RawMessage { + if targetSize <= 2 { + return json.RawMessage(`""`) + } + return json.RawMessage(`"` + strings.Repeat("a", targetSize-2) + `"`) +} + +func TestEnumAndIdentityValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + run func() error + wantErr error + }{ + {name: "task status valid", run: func() error { return TaskStatusReady.Validate("status") }}, + {name: "task status invalid", run: func() error { return TaskStatus("waiting").Validate("status") }, wantErr: ErrValidation}, + {name: "task run status valid", run: func() error { return TaskRunStatusRunning.Validate("run.status") }}, + {name: "task run status invalid", run: func() error { return TaskRunStatus("paused").Validate("run.status") }, wantErr: ErrValidation}, + {name: "actor kind valid", run: func() error { return ActorKindHuman.Validate("actor.kind") }}, + {name: "actor kind invalid", run: func() error { return ActorKind("bot").Validate("actor.kind") }, wantErr: ErrValidation}, + {name: "owner kind valid", run: func() error { return OwnerKindPool.Validate("owner.kind") }}, + {name: "owner kind invalid", run: func() error { return OwnerKind("queue").Validate("owner.kind") }, wantErr: ErrValidation}, + {name: "origin kind valid", run: func() error { return OriginKindCLI.Validate("origin.kind") }}, + {name: "origin kind invalid", run: func() error { return OriginKind("mqtt").Validate("origin.kind") }, wantErr: ErrValidation}, + {name: "dependency kind valid", run: func() error { return DependencyKindBlocks.Validate("dependency.kind") }}, + {name: "dependency kind invalid", run: func() error { return DependencyKind("soft").Validate("dependency.kind") }, wantErr: ErrValidation}, + {name: "stop reason valid", run: func() error { return StopReasonCancellation.Validate("stop.reason") }}, + {name: "stop reason invalid", run: func() error { return StopReason("later").Validate("stop.reason") }, wantErr: ErrValidation}, + {name: "run boot recovery action valid", run: func() error { return RunBootRecoveryMarkRunning.Validate("recovery.action") }}, + {name: "run boot recovery action invalid", run: func() error { return RunBootRecoveryAction("resume").Validate("recovery.action") }, wantErr: ErrValidation}, + {name: "actor identity valid", run: func() error { return validTask().CreatedBy.Validate("actor") }}, + {name: "actor identity invalid", run: func() error { return ActorIdentity{Kind: ActorKindHuman}.Validate("actor") }, wantErr: ErrValidation}, + {name: "origin valid", run: func() error { return validTask().Origin.Validate("origin") }}, + {name: "origin invalid", run: func() error { return Origin{Kind: OriginKindCLI}.Validate("origin") }, wantErr: ErrValidation}, + {name: "authority valid", run: func() error { return validActorContext().Authority.Validate("authority") }}, + {name: "authority invalid", run: func() error { + return Authority{CreateGlobal: true}.Validate("authority") + }, wantErr: ErrValidation}, + {name: "actor context valid", run: func() error { return validActorContext().Validate() }}, + {name: "actor context invalid", run: func() error { + ctx := validActorContext() + ctx.Actor.Ref = "" + return ctx.Validate() + }, wantErr: ErrValidation}, + {name: "run boot recovery valid", run: func() error { + return RunBootRecovery{Action: RunBootRecoveryFail}.Validate("recovery") + }}, + {name: "run boot recovery invalid", run: func() error { + return RunBootRecovery{}.Validate("recovery") + }, wantErr: ErrValidation}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.run() + if tt.wantErr == nil { + if err != nil { + t.Fatalf("validation error = %v", err) + } + return + } + if err == nil { + t.Fatal("validation error = nil, want non-nil") + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("validation error = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestRequestAndQueryValidation(t *testing.T) { + t.Parallel() + + title := "Updated title" + channel := "network:alpha" + metadata := json.RawMessage(`{"priority":"medium"}`) + + tests := []struct { + name string + run func() error + wantErr error + }{ + { + name: "create task valid", + run: func() error { + return CreateTask{ + Scope: ScopeWorkspace, + Title: "Create task", + Owner: &Ownership{Kind: OwnerKindPool, Ref: "triage"}, + Metadata: json.RawMessage(`{"kind":"bootstrap"}`), + WorkspaceID: "ws-1", + }.Validate("create") + }, + }, + { + name: "create task invalid parent self", + run: func() error { + return CreateTask{ + ID: "task-1", + Scope: ScopeGlobal, + Title: "Create task", + ParentTaskID: "task-1", + }.Validate("create") + }, + wantErr: ErrValidation, + }, + { + name: "task patch valid", + run: func() error { + return TaskPatch{ + Title: &title, + NetworkChannel: &channel, + Metadata: &metadata, + }.Validate("patch") + }, + }, + { + name: "task patch owner conflict", + run: func() error { + return TaskPatch{ + Owner: &Ownership{Kind: OwnerKindPool, Ref: "triage"}, + ClearOwner: true, + }.Validate("patch") + }, + wantErr: ErrValidation, + }, + { + name: "cancel task metadata valid", + run: func() error { + return CancelTask{Metadata: json.RawMessage(`{"reason":"user"}`)}.Validate("cancel") + }, + }, + { + name: "add dependency valid", + run: func() error { + return AddDependency{ + TaskID: "task-1", + DependsOnTaskID: "task-0", + Kind: DependencyKindBlocks, + }.Validate("dependency") + }, + }, + { + name: "add dependency invalid", + run: func() error { + return AddDependency{ + TaskID: "task-1", + DependsOnTaskID: "task-1", + Kind: DependencyKindBlocks, + }.Validate("dependency") + }, + wantErr: ErrValidation, + }, + { + name: "enqueue run valid", + run: func() error { + return EnqueueRun{TaskID: "task-1"}.Validate("enqueue") + }, + }, + { + name: "enqueue run invalid", + run: func() error { + return EnqueueRun{}.Validate("enqueue") + }, + wantErr: ErrValidation, + }, + { + name: "claim run valid", + run: func() error { + return ClaimRun{}.Validate("claim") + }, + }, + { + name: "claim run invalid path", + run: func() error { + return ClaimRun{}.Validate(" ") + }, + wantErr: ErrValidation, + }, + { + name: "start run valid", + run: func() error { + return StartRun{}.Validate("start") + }, + }, + { + name: "start run invalid path", + run: func() error { + return StartRun{}.Validate("") + }, + wantErr: ErrValidation, + }, + { + name: "cancel run metadata valid", + run: func() error { + return CancelRun{Metadata: json.RawMessage(`{"reason":"user"}`)}.Validate("cancel") + }, + }, + { + name: "run result valid", + run: func() error { + return RunResult{Value: json.RawMessage(`{"ok":true}`)}.Validate("result") + }, + }, + { + name: "task run query valid", + run: func() error { + return TaskRunQuery{Status: TaskRunStatusRunning, Limit: 2}.Validate("runs") + }, + }, + { + name: "task run query invalid", + run: func() error { + return TaskRunQuery{Limit: -1}.Validate("runs") + }, + wantErr: ErrValidation, + }, + { + name: "task event query valid", + run: func() error { + return TaskEventQuery{Limit: 1}.Validate("events") + }, + }, + { + name: "task event query invalid", + run: func() error { + return TaskEventQuery{Limit: -1}.Validate("events") + }, + wantErr: ErrValidation, + }, + { + name: "task run idempotency valid", + run: func() error { + return validTaskRunIdempotency().Validate() + }, + }, + { + name: "task run idempotency invalid", + run: func() error { + record := validTaskRunIdempotency() + record.Origin.Ref = "" + return record.Validate() + }, + wantErr: ErrValidation, + }, + { + name: "session ref valid", + run: func() error { + return SessionRef{SessionID: "sess-1"}.Validate() + }, + }, + { + name: "session ref invalid", + run: func() error { + return SessionRef{}.Validate() + }, + wantErr: ErrValidation, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.run() + if tt.wantErr == nil { + if err != nil { + t.Fatalf("validation error = %v", err) + } + return + } + if err == nil { + t.Fatal("validation error = nil, want non-nil") + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("validation error = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestOwnershipIsZero(t *testing.T) { + t.Parallel() + + if !(Ownership{}).IsZero() { + t.Fatal("Ownership{}.IsZero() = false, want true") + } + if (Ownership{Kind: OwnerKindPool, Ref: "triage"}).IsZero() { + t.Fatal("Ownership{pool}.IsZero() = true, want false") + } +} + +func TestAdditionalBranchCoverage(t *testing.T) { + t.Parallel() + + t.Run("task missing id", func(t *testing.T) { + t.Parallel() + taskRecord := validTask() + taskRecord.ID = "" + err := taskRecord.Validate() + if err == nil || !errors.Is(err, ErrValidation) { + t.Fatalf("Task.Validate() error = %v, want ErrValidation", err) + } + }) + + t.Run("task parent self", func(t *testing.T) { + t.Parallel() + taskRecord := validTask() + taskRecord.ParentTaskID = taskRecord.ID + err := taskRecord.Validate() + if err == nil || !errors.Is(err, ErrValidation) { + t.Fatalf("Task.Validate() error = %v, want ErrValidation", err) + } + }) + + t.Run("task missing title", func(t *testing.T) { + t.Parallel() + taskRecord := validTask() + taskRecord.Title = "" + err := taskRecord.Validate() + if err == nil || !errors.Is(err, ErrValidation) { + t.Fatalf("Task.Validate() error = %v, want ErrValidation", err) + } + }) + + t.Run("task run missing claimed by ref", func(t *testing.T) { + t.Parallel() + run := validRun() + run.Status = TaskRunStatusClaimed + run.ClaimedBy = &ActorIdentity{Kind: ActorKindHuman} + err := run.Validate() + if err == nil || !errors.Is(err, ErrValidation) { + t.Fatalf("TaskRun.Validate() error = %v, want ErrValidation", err) + } + }) + + t.Run("task run invalid attempt", func(t *testing.T) { + t.Parallel() + run := validRun() + run.Attempt = 0 + err := run.Validate() + if err == nil || !errors.Is(err, ErrValidation) { + t.Fatalf("TaskRun.Validate() error = %v, want ErrValidation", err) + } + }) + + t.Run("task event missing event type", func(t *testing.T) { + t.Parallel() + event := validEvent() + event.EventType = "" + err := event.Validate() + if err == nil || !errors.Is(err, ErrValidation) { + t.Fatalf("TaskEvent.Validate() error = %v, want ErrValidation", err) + } + }) + + t.Run("task event missing origin", func(t *testing.T) { + t.Parallel() + event := validEvent() + event.Origin.Ref = "" + err := event.Validate() + if err == nil || !errors.Is(err, ErrValidation) { + t.Fatalf("TaskEvent.Validate() error = %v, want ErrValidation", err) + } + }) + + t.Run("start task session invalid actor", func(t *testing.T) { + t.Parallel() + req := StartTaskSession{ + Task: validTask(), + Run: validRun(), + Actor: validActorContext(), + } + req.Actor.Authority = Authority{CreateGlobal: true} + err := req.Validate() + if err == nil || !errors.Is(err, ErrValidation) { + t.Fatalf("StartTaskSession.Validate() error = %v, want ErrValidation", err) + } + }) + + t.Run("nested path helper empty path", func(t *testing.T) { + t.Parallel() + if got := nestedPath("", "field"); got != "field" { + t.Fatalf("nestedPath('', 'field') = %q, want field", got) + } + if got := nestedPath("root", ""); got != "root" { + t.Fatalf("nestedPath('root', '') = %q, want root", got) + } + }) +} diff --git a/openapi/agh.json b/openapi/agh.json index 94c043c9c..547194f6c 100644 --- a/openapi/agh.json +++ b/openapi/agh.json @@ -367,6 +367,42 @@ "enum": ["config", "dynamic"], "type": "string" }, + "task": { + "nullable": true, + "properties": { + "description": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "title": { + "type": "string" + } + }, + "type": "object" + }, "updated_at": { "format": "date-time", "type": "string" @@ -529,6 +565,42 @@ "enum": ["global", "workspace"], "type": "string" }, + "task": { + "nullable": true, + "properties": { + "description": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "title": { + "type": "string" + } + }, + "type": "object" + }, "workspace_id": { "type": "string" } @@ -633,6 +705,42 @@ "enum": ["config", "dynamic"], "type": "string" }, + "task": { + "nullable": true, + "properties": { + "description": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "title": { + "type": "string" + } + }, + "type": "object" + }, "updated_at": { "format": "date-time", "type": "string" @@ -932,6 +1040,42 @@ "enum": ["config", "dynamic"], "type": "string" }, + "task": { + "nullable": true, + "properties": { + "description": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "title": { + "type": "string" + } + }, + "type": "object" + }, "updated_at": { "format": "date-time", "type": "string" @@ -1103,6 +1247,42 @@ "required": ["mode"], "type": "object" }, + "task": { + "nullable": true, + "properties": { + "description": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "title": { + "type": "string" + } + }, + "type": "object" + }, "workspace_id": { "nullable": true, "type": "string" @@ -1207,6 +1387,42 @@ "enum": ["config", "dynamic"], "type": "string" }, + "task": { + "nullable": true, + "properties": { + "description": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "title": { + "type": "string" + } + }, + "type": "object" + }, "updated_at": { "format": "date-time", "type": "string" @@ -1345,7 +1561,14 @@ "in": "query", "name": "status", "schema": { - "enum": ["scheduled", "running", "completed", "failed", "cancelled"], + "enum": [ + "scheduled", + "running", + "delegated", + "completed", + "failed", + "cancelled" + ], "type": "string" } }, @@ -1415,12 +1638,19 @@ "enum": [ "scheduled", "running", + "delegated", "completed", "failed", "cancelled" ], "type": "string" }, + "task_id": { + "type": "string" + }, + "task_run_id": { + "type": "string" + }, "trigger_id": { "type": "string" } @@ -1562,12 +1792,19 @@ "enum": [ "scheduled", "running", + "delegated", "completed", "failed", "cancelled" ], "type": "string" }, + "task_id": { + "type": "string" + }, + "task_run_id": { + "type": "string" + }, "trigger_id": { "type": "string" } @@ -1681,7 +1918,14 @@ "in": "query", "name": "status", "schema": { - "enum": ["scheduled", "running", "completed", "failed", "cancelled"], + "enum": [ + "scheduled", + "running", + "delegated", + "completed", + "failed", + "cancelled" + ], "type": "string" } }, @@ -1751,12 +1995,19 @@ "enum": [ "scheduled", "running", + "delegated", "completed", "failed", "cancelled" ], "type": "string" }, + "task_id": { + "type": "string" + }, + "task_run_id": { + "type": "string" + }, "trigger_id": { "type": "string" } @@ -1882,12 +2133,19 @@ "enum": [ "scheduled", "running", + "delegated", "completed", "failed", "cancelled" ], "type": "string" }, + "task_id": { + "type": "string" + }, + "task_run_id": { + "type": "string" + }, "trigger_id": { "type": "string" } @@ -3044,7 +3302,14 @@ "in": "query", "name": "status", "schema": { - "enum": ["scheduled", "running", "completed", "failed", "cancelled"], + "enum": [ + "scheduled", + "running", + "delegated", + "completed", + "failed", + "cancelled" + ], "type": "string" } }, @@ -3114,12 +3379,19 @@ "enum": [ "scheduled", "running", + "delegated", "completed", "failed", "cancelled" ], "type": "string" }, + "task_id": { + "type": "string" + }, + "task_run_id": { + "type": "string" + }, "trigger_id": { "type": "string" } @@ -11520,32 +11792,14 @@ "x-agh-transports": ["http", "uds"] } }, - "/api/webhooks/global/{endpoint}": { + "/api/task-runs/{id}/attach-session": { "post": { - "operationId": "deliverGlobalWebhook", + "operationId": "attachTaskRunSession", "parameters": [ { - "description": "Webhook endpoint slug and id", + "description": "Task run id", "in": "path", - "name": "endpoint", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Signed webhook timestamp", - "in": "header", - "name": "X-AGH-Webhook-Timestamp", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Signed webhook HMAC signature", - "in": "header", - "name": "X-AGH-Webhook-Signature", + "name": "id", "required": true, "schema": { "type": "string" @@ -11556,7 +11810,12 @@ "content": { "application/json": { "schema": { - "additionalProperties": {}, + "properties": { + "session_id": { + "type": "string" + } + }, + "required": ["session_id"], "type": "object" } } @@ -11570,70 +11829,5297 @@ "application/json": { "schema": { "properties": { - "result": { + "run": { "properties": { - "matched": { + "attempt": { "type": "integer" }, - "runs": { - "items": { - "properties": { - "attempt": { - "type": "integer" - }, - "ended_at": { - "format": "date-time", - "nullable": true, - "type": "string" - }, - "error": { - "type": "string" - }, - "id": { - "type": "string" - }, - "job_id": { - "type": "string" - }, - "session_id": { - "type": "string" - }, - "started_at": { - "format": "date-time", - "nullable": true, - "type": "string" - }, - "status": { - "enum": [ - "scheduled", - "running", - "completed", - "failed", - "cancelled" - ], - "type": "string" - }, - "trigger_id": { - "type": "string" - } + "claimed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "claimed_by": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" }, - "required": ["attempt", "id", "status"], - "type": "object" + "ref": { + "type": "string" + } }, - "type": "array" - } - }, - "required": ["matched"], - "type": "object" - } - }, - "required": ["result"], - "type": "object" - } - } - }, - "description": "OK" - }, + "required": ["kind", "ref"], + "type": "object" + }, + "ended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "idempotency_key": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "queued_at": { + "format": "date-time", + "type": "string" + }, + "result": {}, + "session_id": { + "type": "string" + }, + "started_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "status": { + "enum": [ + "queued", + "claimed", + "starting", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "attempt", + "id", + "origin", + "queued_at", + "status", + "task_id" + ], + "type": "object" + } + }, + "required": ["run"], + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid attach-session request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task run or session not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Attach-session conflict" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "Attach an existing session to one task run", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + } + }, + "/api/task-runs/{id}/cancel": { + "post": { + "operationId": "cancelTaskRun", + "parameters": [ + { + "description": "Task run id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "metadata": {}, + "reason": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "JSON request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "run": { + "properties": { + "attempt": { + "type": "integer" + }, + "claimed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "claimed_by": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "ended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "idempotency_key": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "queued_at": { + "format": "date-time", + "type": "string" + }, + "result": {}, + "session_id": { + "type": "string" + }, + "started_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "status": { + "enum": [ + "queued", + "claimed", + "starting", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "attempt", + "id", + "origin", + "queued_at", + "status", + "task_id" + ], + "type": "object" + } + }, + "required": ["run"], + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid task-run cancel request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task run not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task-run cancel conflict" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "Cancel one task run", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + } + }, + "/api/task-runs/{id}/claim": { + "post": { + "operationId": "claimTaskRun", + "parameters": [ + { + "description": "Task run id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "idempotency_key": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "JSON request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "run": { + "properties": { + "attempt": { + "type": "integer" + }, + "claimed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "claimed_by": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "ended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "idempotency_key": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "queued_at": { + "format": "date-time", + "type": "string" + }, + "result": {}, + "session_id": { + "type": "string" + }, + "started_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "status": { + "enum": [ + "queued", + "claimed", + "starting", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "attempt", + "id", + "origin", + "queued_at", + "status", + "task_id" + ], + "type": "object" + } + }, + "required": ["run"], + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid task-run claim request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task run not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task-run claim conflict" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "Claim one queued task run", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + } + }, + "/api/task-runs/{id}/complete": { + "post": { + "operationId": "completeTaskRun", + "parameters": [ + { + "description": "Task run id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": {} + }, + "type": "object" + } + } + }, + "description": "JSON request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "run": { + "properties": { + "attempt": { + "type": "integer" + }, + "claimed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "claimed_by": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "ended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "idempotency_key": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "queued_at": { + "format": "date-time", + "type": "string" + }, + "result": {}, + "session_id": { + "type": "string" + }, + "started_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "status": { + "enum": [ + "queued", + "claimed", + "starting", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "attempt", + "id", + "origin", + "queued_at", + "status", + "task_id" + ], + "type": "object" + } + }, + "required": ["run"], + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid task-run completion request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task run not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task-run completion conflict" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "Complete one running task run", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + } + }, + "/api/task-runs/{id}/fail": { + "post": { + "operationId": "failTaskRun", + "parameters": [ + { + "description": "Task run id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + }, + "metadata": {} + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "JSON request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "run": { + "properties": { + "attempt": { + "type": "integer" + }, + "claimed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "claimed_by": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "ended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "idempotency_key": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "queued_at": { + "format": "date-time", + "type": "string" + }, + "result": {}, + "session_id": { + "type": "string" + }, + "started_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "status": { + "enum": [ + "queued", + "claimed", + "starting", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "attempt", + "id", + "origin", + "queued_at", + "status", + "task_id" + ], + "type": "object" + } + }, + "required": ["run"], + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid task-run failure request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task run not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task-run failure conflict" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "Fail one task run", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + } + }, + "/api/task-runs/{id}/start": { + "post": { + "operationId": "startTaskRun", + "parameters": [ + { + "description": "Task run id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "idempotency_key": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "JSON request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "run": { + "properties": { + "attempt": { + "type": "integer" + }, + "claimed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "claimed_by": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "ended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "idempotency_key": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "queued_at": { + "format": "date-time", + "type": "string" + }, + "result": {}, + "session_id": { + "type": "string" + }, + "started_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "status": { + "enum": [ + "queued", + "claimed", + "starting", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "attempt", + "id", + "origin", + "queued_at", + "status", + "task_id" + ], + "type": "object" + } + }, + "required": ["run"], + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid task-run start request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task run not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task-run start conflict" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "Start one claimed task run", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + } + }, + "/api/tasks": { + "get": { + "operationId": "listTasks", + "parameters": [ + { + "description": "Filter by task scope", + "in": "query", + "name": "scope", + "schema": { + "enum": ["global", "workspace"], + "type": "string" + } + }, + { + "description": "Filter by workspace path, name, or ID", + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by task status", + "in": "query", + "name": "status", + "schema": { + "enum": [ + "pending", + "blocked", + "ready", + "in_progress", + "completed", + "failed", + "cancelled" + ], + "type": "string" + } + }, + { + "description": "Filter by owner kind", + "in": "query", + "name": "owner_kind", + "schema": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + } + }, + { + "description": "Filter by owner reference", + "in": "query", + "name": "owner_ref", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by parent task ID", + "in": "query", + "name": "parent_task_id", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by network channel", + "in": "query", + "name": "network_channel", + "schema": { + "type": "string" + } + }, + { + "description": "Maximum number of records to return", + "in": "query", + "name": "limit", + "schema": { + "format": "int32", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "tasks": { + "items": { + "properties": { + "closed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "created_by": { + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "id": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "parent_task_id": { + "type": "string" + }, + "scope": { + "enum": ["global", "workspace"], + "type": "string" + }, + "status": { + "enum": [ + "pending", + "blocked", + "ready", + "in_progress", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "created_at", + "created_by", + "id", + "origin", + "scope", + "status", + "title", + "updated_at" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": ["tasks"], + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid task filter" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Workspace not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "List tasks", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + }, + "post": { + "operationId": "createTask", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "metadata": {}, + "network_channel": { + "type": "string" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "scope": { + "enum": ["global", "workspace"], + "type": "string" + }, + "title": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "required": ["scope", "title"], + "type": "object" + } + } + }, + "description": "JSON request body", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "task": { + "properties": { + "closed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "created_by": { + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "metadata": {}, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "parent_task_id": { + "type": "string" + }, + "scope": { + "enum": ["global", "workspace"], + "type": "string" + }, + "status": { + "enum": [ + "pending", + "blocked", + "ready", + "in_progress", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "created_at", + "created_by", + "id", + "origin", + "scope", + "status", + "title", + "updated_at" + ], + "type": "object" + } + }, + "required": ["task"], + "type": "object" + } + } + }, + "description": "Created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid task request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Workspace not found" + }, + "413": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Payload too large" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "Create a task", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + } + }, + "/api/tasks/{id}": { + "get": { + "operationId": "getTask", + "parameters": [ + { + "description": "Task id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "task": { + "properties": { + "children": { + "items": { + "properties": { + "closed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "created_by": { + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "id": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "parent_task_id": { + "type": "string" + }, + "scope": { + "enum": ["global", "workspace"], + "type": "string" + }, + "status": { + "enum": [ + "pending", + "blocked", + "ready", + "in_progress", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "created_at", + "created_by", + "id", + "origin", + "scope", + "status", + "title", + "updated_at" + ], + "type": "object" + }, + "type": "array" + }, + "dependencies": { + "items": { + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "depends_on_task_id": { + "type": "string" + }, + "kind": { + "enum": ["blocks"], + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "created_at", + "depends_on_task_id", + "kind", + "task_id" + ], + "type": "object" + }, + "type": "array" + }, + "events": { + "items": { + "properties": { + "actor": { + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "event_type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "payload": {}, + "run_id": { + "type": "string" + }, + "task_id": { + "type": "string" + }, + "timestamp": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "actor", + "event_type", + "id", + "origin", + "task_id", + "timestamp" + ], + "type": "object" + }, + "type": "array" + }, + "runs": { + "items": { + "properties": { + "attempt": { + "type": "integer" + }, + "claimed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "claimed_by": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "ended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "idempotency_key": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "queued_at": { + "format": "date-time", + "type": "string" + }, + "result": {}, + "session_id": { + "type": "string" + }, + "started_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "status": { + "enum": [ + "queued", + "claimed", + "starting", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "attempt", + "id", + "origin", + "queued_at", + "status", + "task_id" + ], + "type": "object" + }, + "type": "array" + }, + "task": { + "properties": { + "closed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "created_by": { + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "metadata": {}, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "parent_task_id": { + "type": "string" + }, + "scope": { + "enum": ["global", "workspace"], + "type": "string" + }, + "status": { + "enum": [ + "pending", + "blocked", + "ready", + "in_progress", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "created_at", + "created_by", + "id", + "origin", + "scope", + "status", + "title", + "updated_at" + ], + "type": "object" + } + }, + "required": ["task"], + "type": "object" + } + }, + "required": ["task"], + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid task id" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "Get one task with detail", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + }, + "patch": { + "operationId": "updateTask", + "parameters": [ + { + "description": "Task id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "clear_owner": { + "type": "boolean" + }, + "description": { + "nullable": true, + "type": "string" + }, + "metadata": {}, + "network_channel": { + "nullable": true, + "type": "string" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "title": { + "nullable": true, + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "JSON request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "task": { + "properties": { + "closed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "created_by": { + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "metadata": {}, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "parent_task_id": { + "type": "string" + }, + "scope": { + "enum": ["global", "workspace"], + "type": "string" + }, + "status": { + "enum": [ + "pending", + "blocked", + "ready", + "in_progress", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "created_at", + "created_by", + "id", + "origin", + "scope", + "status", + "title", + "updated_at" + ], + "type": "object" + } + }, + "required": ["task"], + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid task update" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task update conflict" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "Update one task", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + } + }, + "/api/tasks/{id}/cancel": { + "post": { + "operationId": "cancelTask", + "parameters": [ + { + "description": "Task id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "metadata": {}, + "reason": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "JSON request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "task": { + "properties": { + "closed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "created_by": { + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "metadata": {}, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "parent_task_id": { + "type": "string" + }, + "scope": { + "enum": ["global", "workspace"], + "type": "string" + }, + "status": { + "enum": [ + "pending", + "blocked", + "ready", + "in_progress", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "created_at", + "created_by", + "id", + "origin", + "scope", + "status", + "title", + "updated_at" + ], + "type": "object" + } + }, + "required": ["task"], + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid task cancel request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task cancel conflict" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "Cancel one task tree", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + } + }, + "/api/tasks/{id}/children": { + "post": { + "operationId": "createChildTask", + "parameters": [ + { + "description": "Parent task id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "metadata": {}, + "network_channel": { + "type": "string" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "scope": { + "enum": ["global", "workspace"], + "type": "string" + }, + "title": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "required": ["scope", "title"], + "type": "object" + } + } + }, + "description": "JSON request body", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "task": { + "properties": { + "closed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "created_by": { + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "metadata": {}, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "parent_task_id": { + "type": "string" + }, + "scope": { + "enum": ["global", "workspace"], + "type": "string" + }, + "status": { + "enum": [ + "pending", + "blocked", + "ready", + "in_progress", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "created_at", + "created_by", + "id", + "origin", + "scope", + "status", + "title", + "updated_at" + ], + "type": "object" + } + }, + "required": ["task"], + "type": "object" + } + } + }, + "description": "Created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid child task request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task or workspace not found" + }, + "413": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Payload too large" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "Create one child task", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + } + }, + "/api/tasks/{id}/dependencies": { + "post": { + "operationId": "addTaskDependency", + "parameters": [ + { + "description": "Task id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "depends_on_task_id": { + "type": "string" + }, + "kind": { + "enum": ["blocks"], + "type": "string" + } + }, + "required": ["depends_on_task_id"], + "type": "object" + } + } + }, + "description": "JSON request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "task": { + "properties": { + "children": { + "items": { + "properties": { + "closed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "created_by": { + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "id": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "parent_task_id": { + "type": "string" + }, + "scope": { + "enum": ["global", "workspace"], + "type": "string" + }, + "status": { + "enum": [ + "pending", + "blocked", + "ready", + "in_progress", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "created_at", + "created_by", + "id", + "origin", + "scope", + "status", + "title", + "updated_at" + ], + "type": "object" + }, + "type": "array" + }, + "dependencies": { + "items": { + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "depends_on_task_id": { + "type": "string" + }, + "kind": { + "enum": ["blocks"], + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "created_at", + "depends_on_task_id", + "kind", + "task_id" + ], + "type": "object" + }, + "type": "array" + }, + "events": { + "items": { + "properties": { + "actor": { + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "event_type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "payload": {}, + "run_id": { + "type": "string" + }, + "task_id": { + "type": "string" + }, + "timestamp": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "actor", + "event_type", + "id", + "origin", + "task_id", + "timestamp" + ], + "type": "object" + }, + "type": "array" + }, + "runs": { + "items": { + "properties": { + "attempt": { + "type": "integer" + }, + "claimed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "claimed_by": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "ended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "idempotency_key": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "queued_at": { + "format": "date-time", + "type": "string" + }, + "result": {}, + "session_id": { + "type": "string" + }, + "started_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "status": { + "enum": [ + "queued", + "claimed", + "starting", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "attempt", + "id", + "origin", + "queued_at", + "status", + "task_id" + ], + "type": "object" + }, + "type": "array" + }, + "task": { + "properties": { + "closed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "created_by": { + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "metadata": {}, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "parent_task_id": { + "type": "string" + }, + "scope": { + "enum": ["global", "workspace"], + "type": "string" + }, + "status": { + "enum": [ + "pending", + "blocked", + "ready", + "in_progress", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "created_at", + "created_by", + "id", + "origin", + "scope", + "status", + "title", + "updated_at" + ], + "type": "object" + } + }, + "required": ["task"], + "type": "object" + } + }, + "required": ["task"], + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid dependency request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Dependency conflict" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "Add one task dependency", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + } + }, + "/api/tasks/{id}/dependencies/{depends_on_id}": { + "delete": { + "operationId": "removeTaskDependency", + "parameters": [ + { + "description": "Task id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Dependency task id", + "in": "path", + "name": "depends_on_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "task": { + "properties": { + "children": { + "items": { + "properties": { + "closed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "created_by": { + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "id": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "parent_task_id": { + "type": "string" + }, + "scope": { + "enum": ["global", "workspace"], + "type": "string" + }, + "status": { + "enum": [ + "pending", + "blocked", + "ready", + "in_progress", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "created_at", + "created_by", + "id", + "origin", + "scope", + "status", + "title", + "updated_at" + ], + "type": "object" + }, + "type": "array" + }, + "dependencies": { + "items": { + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "depends_on_task_id": { + "type": "string" + }, + "kind": { + "enum": ["blocks"], + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "created_at", + "depends_on_task_id", + "kind", + "task_id" + ], + "type": "object" + }, + "type": "array" + }, + "events": { + "items": { + "properties": { + "actor": { + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "event_type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "payload": {}, + "run_id": { + "type": "string" + }, + "task_id": { + "type": "string" + }, + "timestamp": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "actor", + "event_type", + "id", + "origin", + "task_id", + "timestamp" + ], + "type": "object" + }, + "type": "array" + }, + "runs": { + "items": { + "properties": { + "attempt": { + "type": "integer" + }, + "claimed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "claimed_by": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "ended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "idempotency_key": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "queued_at": { + "format": "date-time", + "type": "string" + }, + "result": {}, + "session_id": { + "type": "string" + }, + "started_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "status": { + "enum": [ + "queued", + "claimed", + "starting", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "attempt", + "id", + "origin", + "queued_at", + "status", + "task_id" + ], + "type": "object" + }, + "type": "array" + }, + "task": { + "properties": { + "closed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "created_by": { + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "metadata": {}, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "owner": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "pool" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "parent_task_id": { + "type": "string" + }, + "scope": { + "enum": ["global", "workspace"], + "type": "string" + }, + "status": { + "enum": [ + "pending", + "blocked", + "ready", + "in_progress", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "created_at", + "created_by", + "id", + "origin", + "scope", + "status", + "title", + "updated_at" + ], + "type": "object" + } + }, + "required": ["task"], + "type": "object" + } + }, + "required": ["task"], + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid dependency request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task or dependency not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "Remove one task dependency", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + } + }, + "/api/tasks/{id}/runs": { + "get": { + "operationId": "listTaskRuns", + "parameters": [ + { + "description": "Task id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Filter by run status", + "in": "query", + "name": "status", + "schema": { + "enum": [ + "queued", + "claimed", + "starting", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + } + }, + { + "description": "Filter by attached session id", + "in": "query", + "name": "session_id", + "schema": { + "type": "string" + } + }, + { + "description": "Maximum number of records to return", + "in": "query", + "name": "limit", + "schema": { + "format": "int32", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "runs": { + "items": { + "properties": { + "attempt": { + "type": "integer" + }, + "claimed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "claimed_by": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "ended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "idempotency_key": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "queued_at": { + "format": "date-time", + "type": "string" + }, + "result": {}, + "session_id": { + "type": "string" + }, + "started_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "status": { + "enum": [ + "queued", + "claimed", + "starting", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "attempt", + "id", + "origin", + "queued_at", + "status", + "task_id" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": ["runs"], + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid task-run filter" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "List runs for one task", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + }, + "post": { + "operationId": "enqueueTaskRun", + "parameters": [ + { + "description": "Task id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "idempotency_key": { + "type": "string" + }, + "network_channel": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "JSON request body", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "run": { + "properties": { + "attempt": { + "type": "integer" + }, + "claimed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "claimed_by": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "ended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "idempotency_key": { + "type": "string" + }, + "network_channel": { + "type": "string" + }, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["kind", "ref"], + "type": "object" + }, + "queued_at": { + "format": "date-time", + "type": "string" + }, + "result": {}, + "session_id": { + "type": "string" + }, + "started_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "status": { + "enum": [ + "queued", + "claimed", + "starting", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "attempt", + "id", + "origin", + "queued_at", + "status", + "task_id" + ], + "type": "object" + } + }, + "required": ["run"], + "type": "object" + } + } + }, + "description": "Created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Invalid task-run enqueue request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task-run enqueue conflict" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "type": "object" + } + } + }, + "description": "Task service is not configured" + }, + "default": { + "description": "" + } + }, + "summary": "Enqueue one task run", + "tags": ["tasks"], + "x-agh-transports": ["http", "uds"] + } + }, + "/api/webhooks/global/{endpoint}": { + "post": { + "operationId": "deliverGlobalWebhook", + "parameters": [ + { + "description": "Webhook endpoint slug and id", + "in": "path", + "name": "endpoint", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Signed webhook timestamp", + "in": "header", + "name": "X-AGH-Webhook-Timestamp", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Signed webhook HMAC signature", + "in": "header", + "name": "X-AGH-Webhook-Signature", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": {}, + "type": "object" + } + } + }, + "description": "JSON request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "properties": { + "matched": { + "type": "integer" + }, + "runs": { + "items": { + "properties": { + "attempt": { + "type": "integer" + }, + "ended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "job_id": { + "type": "string" + }, + "session_id": { + "type": "string" + }, + "started_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "status": { + "enum": [ + "scheduled", + "running", + "delegated", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "task_id": { + "type": "string" + }, + "task_run_id": { + "type": "string" + }, + "trigger_id": { + "type": "string" + } + }, + "required": ["attempt", "id", "status"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["matched"], + "type": "object" + } + }, + "required": ["result"], + "type": "object" + } + } + }, + "description": "OK" + }, "400": { "content": { "application/json": { @@ -11819,12 +17305,19 @@ "enum": [ "scheduled", "running", + "delegated", "completed", "failed", "cancelled" ], "type": "string" }, + "task_id": { + "type": "string" + }, + "task_run_id": { + "type": "string" + }, "trigger_id": { "type": "string" } @@ -12811,6 +18304,9 @@ { "name": "skills" }, + { + "name": "tasks" + }, { "name": "workspaces" } diff --git a/sdk/examples/telegram-reference/extension.toml b/sdk/examples/telegram-reference/extension.toml index 9b8527025..147c9f509 100644 --- a/sdk/examples/telegram-reference/extension.toml +++ b/sdk/examples/telegram-reference/extension.toml @@ -12,11 +12,7 @@ platform = "telegram" display_name = "Telegram" [actions] -requires = [ - "bridges/messages/ingest", - "bridges/instances/get", - "bridges/instances/report_state", -] +requires = ["bridges/messages/ingest", "bridges/instances/get", "bridges/instances/report_state"] [subprocess] command = "./bin/telegram-reference" diff --git a/sdk/typescript/src/generated/contracts.ts b/sdk/typescript/src/generated/contracts.ts index f67d82138..327ae7b6f 100644 --- a/sdk/typescript/src/generated/contracts.ts +++ b/sdk/typescript/src/generated/contracts.ts @@ -31,7 +31,20 @@ export type HostAPIMethod = | "sessions/prompt" | "sessions/status" | "sessions/stop" - | "skills/list"; + | "skills/list" + | "tasks" + | "tasks/cancel" + | "tasks/create" + | "tasks/get" + | "tasks/runs" + | "tasks/runs/attach_session" + | "tasks/runs/cancel" + | "tasks/runs/claim" + | "tasks/runs/complete" + | "tasks/runs/enqueue" + | "tasks/runs/fail" + | "tasks/runs/start" + | "tasks/update"; export interface AcceptedCapabilities { provides: string[]; @@ -222,6 +235,20 @@ export interface ScheduleSpec { time?: string; } +export type OwnerKind = string; + +export interface Ownership { + kind: OwnerKind; + ref: string; +} + +export interface JobTaskConfig { + title?: string; + description?: string; + owner?: Ownership; + network_channel?: string; +} + export type RetryStrategy = string; export interface RetryConfig { @@ -242,6 +269,7 @@ export interface AutomationJobCreateParams { workspace_id?: string; prompt: string; schedule: ScheduleSpec; + task?: JobTaskConfig; enabled?: boolean; retry?: RetryConfig; fire_limit?: FireLimitConfig; @@ -293,6 +321,7 @@ export interface AutomationJobUpdateParams { workspace_id?: string; prompt?: string; schedule?: ScheduleSpec; + task?: JobTaskConfig; enabled?: boolean; retry?: RetryConfig; fire_limit?: FireLimitConfig; @@ -868,6 +897,7 @@ export interface Job { workspace_id?: string; prompt: string; schedule?: ScheduleSpec; + task?: JobTaskConfig; enabled: boolean; retry: RetryConfig; fire_limit: FireLimitConfig; @@ -1048,6 +1078,74 @@ export interface BridgeAggregateHealth { status_counts: BridgeStatusCounts; } +export interface TaskQueueDepth { + network_channel?: string; + count: number; + oldest_queued_at?: ISODateTime; + oldest_queue_age_ms: number; +} + +export type TaskRunStatus = string; + +export type OriginKind = string; + +export interface StuckTaskRun { + task_id: string; + run_id: string; + status: TaskRunStatus; + origin_kind: OriginKind; + network_channel?: string; + session_id?: string; + age_ms: number; +} + +export type Scope = string; + +export type TaskStatus = string; + +export interface TaskStatusTotal { + scope: Scope; + status: TaskStatus; + network_channel?: string; + count: number; +} + +export interface TaskRunTotal { + status: TaskRunStatus; + origin_kind: OriginKind; + network_channel?: string; + count: number; +} + +export interface TaskOwnerTotal { + owner_kind: OwnerKind; + owner_ref: string; + count: number; +} + +export interface TaskRecoveryTotals { + requeued: number; + marked_running: number; + failed: number; +} + +export interface TaskHealth { + status: string; + queue_depth_total: number; + oldest_queued_at?: ISODateTime; + oldest_queue_age_ms: number; + queue_depth?: TaskQueueDepth[]; + stuck_runs?: StuckTaskRun[]; + active_orphan_runs: number; + task_totals?: TaskStatusTotal[]; + run_totals?: TaskRunTotal[]; + owner_totals?: TaskOwnerTotal[]; + forced_stops_since_start: number; + duplicate_ingress_since_start: number; + channel_mismatch_since_start: number; + recovery_since_start: TaskRecoveryTotals; +} + export interface ObserveHealth { status: string; uptime_seconds: number; @@ -1056,6 +1154,7 @@ export interface ObserveHealth { global_db_size_bytes: number; session_db_size_bytes: number; bridges: BridgeAggregateHealth; + tasks: TaskHealth; version: string; } @@ -1221,6 +1320,8 @@ export interface Run { job_id?: string; trigger_id?: string; session_id?: string; + task_id?: string; + task_run_id?: string; status: RunStatus; attempt: number; started_at?: ISODateTime; @@ -1505,6 +1606,188 @@ export interface SkillsListParams { workspace?: string; } +export type ActorKind = string; + +export interface ActorIdentity { + kind: ActorKind; + ref: string; +} + +export interface Origin { + kind: OriginKind; + ref: string; +} + +export interface Task { + id: string; + identifier?: string; + scope: Scope; + workspace_id?: string; + parent_task_id?: string; + network_channel?: string; + title: string; + description?: string; + status: TaskStatus; + owner?: Ownership; + created_by: ActorIdentity; + origin: Origin; + created_at: ISODateTime; + updated_at: ISODateTime; + closed_at?: ISODateTime; + metadata?: JSONValue; +} + +export interface TaskCancelParams { + id: string; + reason?: string; + metadata?: JSONValue; +} + +export interface TaskCreateParams { + id?: string; + identifier?: string; + scope: Scope; + workspace?: string; + network_channel?: string; + title: string; + description?: string; + owner?: Ownership; + metadata?: JSONValue; +} + +export interface TaskSummary { + id: string; + identifier?: string; + scope: Scope; + workspace_id?: string; + parent_task_id?: string; + network_channel?: string; + title: string; + status: TaskStatus; + owner?: Ownership; + created_by: ActorIdentity; + origin: Origin; + created_at: ISODateTime; + updated_at: ISODateTime; + closed_at?: ISODateTime; +} + +export type DependencyKind = string; + +export interface TaskDependencyPayload { + task_id: string; + depends_on_task_id: string; + kind: DependencyKind; + created_at: ISODateTime; +} + +export interface TaskRun { + id: string; + task_id: string; + status: TaskRunStatus; + attempt: number; + claimed_by?: ActorIdentity; + session_id?: string; + origin: Origin; + idempotency_key?: string; + network_channel?: string; + queued_at: ISODateTime; + claimed_at?: ISODateTime; + started_at?: ISODateTime; + ended_at?: ISODateTime; + error?: string; + result?: JSONValue; +} + +export interface TaskEventPayload { + id: string; + task_id: string; + run_id?: string; + event_type: string; + actor: ActorIdentity; + origin: Origin; + payload?: JSONValue; + timestamp: ISODateTime; +} + +export interface TaskDetail { + task: Task; + children?: TaskSummary[]; + dependencies?: TaskDependencyPayload[]; + runs?: TaskRun[]; + events?: TaskEventPayload[]; +} + +export interface TaskRunAttachSessionParams { + id: string; + session_id: string; +} + +export interface TaskRunCancelParams { + id: string; + reason?: string; + metadata?: JSONValue; +} + +export interface TaskRunClaimParams { + id: string; + idempotency_key?: string; +} + +export interface TaskRunCompleteParams { + id: string; + result?: JSONValue; +} + +export interface TaskRunEnqueueParams { + task_id: string; + idempotency_key?: string; + network_channel?: string; +} + +export interface TaskRunFailParams { + id: string; + error: string; + metadata?: JSONValue; +} + +export interface TaskRunStartParams { + id: string; + idempotency_key?: string; +} + +export interface TaskRunsParams { + id: string; + status?: TaskRunStatus; + session_id?: string; + limit?: number; +} + +export interface TaskTargetParams { + id: string; +} + +export interface TaskUpdateParams { + id: string; + title?: string; + description?: string; + metadata?: JSONValue; + network_channel?: string; + owner?: Ownership; + clear_owner?: boolean; +} + +export interface TasksParams { + scope?: Scope; + workspace?: string; + status?: TaskStatus; + owner_kind?: OwnerKind; + owner_ref?: string; + parent_task_id?: string; + network_channel?: string; + limit?: number; +} + export type ToolSource = "builtin" | "mcp" | "extension" | "dynamic"; export interface Tool { @@ -1895,6 +2178,58 @@ export interface HostAPIMethodMap { params: AutomationRunsParams | undefined; result: Run[]; }; + tasks: { + params: TasksParams | undefined; + result: TaskSummary[]; + }; + "tasks/get": { + params: TaskTargetParams; + result: TaskDetail; + }; + "tasks/create": { + params: TaskCreateParams; + result: Task; + }; + "tasks/update": { + params: TaskUpdateParams; + result: Task; + }; + "tasks/cancel": { + params: TaskCancelParams; + result: Task; + }; + "tasks/runs": { + params: TaskRunsParams; + result: TaskRun[]; + }; + "tasks/runs/enqueue": { + params: TaskRunEnqueueParams; + result: TaskRun; + }; + "tasks/runs/claim": { + params: TaskRunClaimParams; + result: TaskRun; + }; + "tasks/runs/start": { + params: TaskRunStartParams; + result: TaskRun; + }; + "tasks/runs/attach_session": { + params: TaskRunAttachSessionParams; + result: TaskRun; + }; + "tasks/runs/complete": { + params: TaskRunCompleteParams; + result: TaskRun; + }; + "tasks/runs/fail": { + params: TaskRunFailParams; + result: TaskRun; + }; + "tasks/runs/cancel": { + params: TaskRunCancelParams; + result: TaskRun; + }; "bridges/messages/ingest": { params: InboundMessageEnvelope; result: BridgesMessagesIngestResult; diff --git a/web/src/generated/agh-openapi.d.ts b/web/src/generated/agh-openapi.d.ts index df8f0f45a..8eabcc188 100644 --- a/web/src/generated/agh-openapi.d.ts +++ b/web/src/generated/agh-openapi.d.ts @@ -902,6 +902,230 @@ export interface paths { patch?: never; trace?: never; }; + "/api/task-runs/{id}/attach-session": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Attach an existing session to one task run */ + post: operations["attachTaskRunSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/task-runs/{id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Cancel one task run */ + post: operations["cancelTaskRun"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/task-runs/{id}/claim": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Claim one queued task run */ + post: operations["claimTaskRun"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/task-runs/{id}/complete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Complete one running task run */ + post: operations["completeTaskRun"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/task-runs/{id}/fail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Fail one task run */ + post: operations["failTaskRun"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/task-runs/{id}/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Start one claimed task run */ + post: operations["startTaskRun"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/tasks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List tasks */ + get: operations["listTasks"]; + put?: never; + /** Create a task */ + post: operations["createTask"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/tasks/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one task with detail */ + get: operations["getTask"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update one task */ + patch: operations["updateTask"]; + trace?: never; + }; + "/api/tasks/{id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Cancel one task tree */ + post: operations["cancelTask"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/tasks/{id}/children": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create one child task */ + post: operations["createChildTask"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/tasks/{id}/dependencies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Add one task dependency */ + post: operations["addTaskDependency"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/tasks/{id}/dependencies/{depends_on_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove one task dependency */ + delete: operations["removeTaskDependency"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/tasks/{id}/runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List runs for one task */ + get: operations["listTaskRuns"]; + put?: never; + /** Enqueue one task run */ + post: operations["enqueueTaskRun"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/webhooks/global/{endpoint}": { parameters: { query?: never; @@ -1182,6 +1406,22 @@ export interface operations { scope: "global" | "workspace"; /** @enum {string} */ source: "config" | "dynamic"; + task?: { + description?: string; + network_channel?: string; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + title?: string; + } | null; /** Format: date-time */ updated_at: string; workspace_id?: string; @@ -1264,6 +1504,22 @@ export interface operations { }; /** @enum {string} */ scope: "global" | "workspace"; + task?: { + description?: string; + network_channel?: string; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + title?: string; + } | null; workspace_id?: string; }; }; @@ -1307,6 +1563,22 @@ export interface operations { scope: "global" | "workspace"; /** @enum {string} */ source: "config" | "dynamic"; + task?: { + description?: string; + network_channel?: string; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + title?: string; + } | null; /** Format: date-time */ updated_at: string; workspace_id?: string; @@ -1416,6 +1688,22 @@ export interface operations { scope: "global" | "workspace"; /** @enum {string} */ source: "config" | "dynamic"; + task?: { + description?: string; + network_channel?: string; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + title?: string; + } | null; /** Format: date-time */ updated_at: string; workspace_id?: string; @@ -1570,6 +1858,22 @@ export interface operations { mode: "cron" | "every" | "at"; time?: string; } | null; + task?: { + description?: string; + network_channel?: string; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + title?: string; + } | null; workspace_id?: string | null; }; }; @@ -1613,6 +1917,22 @@ export interface operations { scope: "global" | "workspace"; /** @enum {string} */ source: "config" | "dynamic"; + task?: { + description?: string; + network_channel?: string; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + title?: string; + } | null; /** Format: date-time */ updated_at: string; workspace_id?: string; @@ -1687,7 +2007,7 @@ export interface operations { parameters: { query?: { /** @description Filter by run status */ - status?: "scheduled" | "running" | "completed" | "failed" | "cancelled"; + status?: "scheduled" | "running" | "delegated" | "completed" | "failed" | "cancelled"; /** @description Only runs started since this timestamp */ since?: string; /** @description Only runs started before this timestamp */ @@ -1722,7 +2042,9 @@ export interface operations { /** Format: date-time */ started_at?: string | null; /** @enum {string} */ - status: "scheduled" | "running" | "completed" | "failed" | "cancelled"; + status: "scheduled" | "running" | "delegated" | "completed" | "failed" | "cancelled"; + task_id?: string; + task_run_id?: string; trigger_id?: string; }[]; }; @@ -1810,7 +2132,9 @@ export interface operations { /** Format: date-time */ started_at?: string | null; /** @enum {string} */ - status: "scheduled" | "running" | "completed" | "failed" | "cancelled"; + status: "scheduled" | "running" | "delegated" | "completed" | "failed" | "cancelled"; + task_id?: string; + task_run_id?: string; trigger_id?: string; }; }; @@ -1876,7 +2200,7 @@ export interface operations { /** @description Filter by automation trigger id */ trigger_id?: string; /** @description Filter by run status */ - status?: "scheduled" | "running" | "completed" | "failed" | "cancelled"; + status?: "scheduled" | "running" | "delegated" | "completed" | "failed" | "cancelled"; /** @description Only runs started since this timestamp */ since?: string; /** @description Only runs started before this timestamp */ @@ -1908,7 +2232,9 @@ export interface operations { /** Format: date-time */ started_at?: string | null; /** @enum {string} */ - status: "scheduled" | "running" | "completed" | "failed" | "cancelled"; + status: "scheduled" | "running" | "delegated" | "completed" | "failed" | "cancelled"; + task_id?: string; + task_run_id?: string; trigger_id?: string; }[]; }; @@ -1985,7 +2311,9 @@ export interface operations { /** Format: date-time */ started_at?: string | null; /** @enum {string} */ - status: "scheduled" | "running" | "completed" | "failed" | "cancelled"; + status: "scheduled" | "running" | "delegated" | "completed" | "failed" | "cancelled"; + task_id?: string; + task_run_id?: string; trigger_id?: string; }; }; @@ -2583,7 +2911,7 @@ export interface operations { parameters: { query?: { /** @description Filter by run status */ - status?: "scheduled" | "running" | "completed" | "failed" | "cancelled"; + status?: "scheduled" | "running" | "delegated" | "completed" | "failed" | "cancelled"; /** @description Only runs started since this timestamp */ since?: string; /** @description Only runs started before this timestamp */ @@ -2618,7 +2946,9 @@ export interface operations { /** Format: date-time */ started_at?: string | null; /** @enum {string} */ - status: "scheduled" | "running" | "completed" | "failed" | "cancelled"; + status: "scheduled" | "running" | "delegated" | "completed" | "failed" | "cancelled"; + task_id?: string; + task_run_id?: string; trigger_id?: string; }[]; }; @@ -7104,18 +7434,13 @@ export interface operations { }; }; }; - deliverGlobalWebhook: { + attachTaskRunSession: { parameters: { query?: never; - header: { - /** @description Signed webhook timestamp */ - "X-AGH-Webhook-Timestamp": string; - /** @description Signed webhook HMAC signature */ - "X-AGH-Webhook-Signature": string; - }; + header?: never; path: { - /** @description Webhook endpoint slug and id */ - endpoint: string; + /** @description Task run id */ + id: string; }; cookie?: never; }; @@ -7123,7 +7448,7 @@ export interface operations { requestBody: { content: { "application/json": { - [key: string]: unknown; + session_id: string; }; }; }; @@ -7135,27 +7460,62 @@ export interface operations { }; content: { "application/json": { - result: { - matched: number; - runs?: { - attempt: number; - /** Format: date-time */ - ended_at?: string | null; - error?: string; - id: string; - job_id?: string; - session_id?: string; - /** Format: date-time */ - started_at?: string | null; + run: { + attempt: number; + /** Format: date-time */ + claimed_at?: string | null; + claimed_by?: { /** @enum {string} */ - status: "scheduled" | "running" | "completed" | "failed" | "cancelled"; - trigger_id?: string; - }[]; + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + } | null; + /** Format: date-time */ + ended_at?: string | null; + error?: string; + id: string; + idempotency_key?: string; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + /** Format: date-time */ + queued_at: string; + result?: unknown; + session_id?: string; + /** Format: date-time */ + started_at?: string | null; + /** @enum {string} */ + status: + | "queued" + | "claimed" + | "starting" + | "running" + | "completed" + | "failed" + | "cancelled"; + task_id: string; }; }; }; }; - /** @description Invalid webhook request */ + /** @description Invalid attach-session request */ 400: { headers: { [name: string]: unknown; @@ -7166,8 +7526,8 @@ export interface operations { }; }; }; - /** @description Webhook authentication failed */ - 401: { + /** @description Task run or session not found */ + 404: { headers: { [name: string]: unknown; }; @@ -7177,8 +7537,8 @@ export interface operations { }; }; }; - /** @description Webhook trigger not found */ - 404: { + /** @description Attach-session conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -7199,7 +7559,7 @@ export interface operations { }; }; }; - /** @description Automation manager is not configured */ + /** @description Task service is not configured */ 503: { headers: { [name: string]: unknown; @@ -7218,20 +7578,2868 @@ export interface operations { }; }; }; - deliverWorkspaceWebhook: { + cancelTaskRun: { parameters: { query?: never; - header: { - /** @description Signed webhook timestamp */ - "X-AGH-Webhook-Timestamp": string; - /** @description Signed webhook HMAC signature */ - "X-AGH-Webhook-Signature": string; - }; + header?: never; path: { - /** @description Workspace id */ - workspace_id: string; - /** @description Webhook endpoint slug and id */ - endpoint: string; + /** @description Task run id */ + id: string; + }; + cookie?: never; + }; + /** @description JSON request body */ + requestBody: { + content: { + "application/json": { + metadata?: unknown; + reason?: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + run: { + attempt: number; + /** Format: date-time */ + claimed_at?: string | null; + claimed_by?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + } | null; + /** Format: date-time */ + ended_at?: string | null; + error?: string; + id: string; + idempotency_key?: string; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + /** Format: date-time */ + queued_at: string; + result?: unknown; + session_id?: string; + /** Format: date-time */ + started_at?: string | null; + /** @enum {string} */ + status: + | "queued" + | "claimed" + | "starting" + | "running" + | "completed" + | "failed" + | "cancelled"; + task_id: string; + }; + }; + }; + }; + /** @description Invalid task-run cancel request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task run not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task-run cancel conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task service is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + claimTaskRun: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task run id */ + id: string; + }; + cookie?: never; + }; + /** @description JSON request body */ + requestBody: { + content: { + "application/json": { + idempotency_key?: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + run: { + attempt: number; + /** Format: date-time */ + claimed_at?: string | null; + claimed_by?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + } | null; + /** Format: date-time */ + ended_at?: string | null; + error?: string; + id: string; + idempotency_key?: string; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + /** Format: date-time */ + queued_at: string; + result?: unknown; + session_id?: string; + /** Format: date-time */ + started_at?: string | null; + /** @enum {string} */ + status: + | "queued" + | "claimed" + | "starting" + | "running" + | "completed" + | "failed" + | "cancelled"; + task_id: string; + }; + }; + }; + }; + /** @description Invalid task-run claim request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task run not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task-run claim conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task service is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + completeTaskRun: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task run id */ + id: string; + }; + cookie?: never; + }; + /** @description JSON request body */ + requestBody: { + content: { + "application/json": { + result?: unknown; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + run: { + attempt: number; + /** Format: date-time */ + claimed_at?: string | null; + claimed_by?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + } | null; + /** Format: date-time */ + ended_at?: string | null; + error?: string; + id: string; + idempotency_key?: string; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + /** Format: date-time */ + queued_at: string; + result?: unknown; + session_id?: string; + /** Format: date-time */ + started_at?: string | null; + /** @enum {string} */ + status: + | "queued" + | "claimed" + | "starting" + | "running" + | "completed" + | "failed" + | "cancelled"; + task_id: string; + }; + }; + }; + }; + /** @description Invalid task-run completion request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task run not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task-run completion conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task service is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + failTaskRun: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task run id */ + id: string; + }; + cookie?: never; + }; + /** @description JSON request body */ + requestBody: { + content: { + "application/json": { + error: string; + metadata?: unknown; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + run: { + attempt: number; + /** Format: date-time */ + claimed_at?: string | null; + claimed_by?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + } | null; + /** Format: date-time */ + ended_at?: string | null; + error?: string; + id: string; + idempotency_key?: string; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + /** Format: date-time */ + queued_at: string; + result?: unknown; + session_id?: string; + /** Format: date-time */ + started_at?: string | null; + /** @enum {string} */ + status: + | "queued" + | "claimed" + | "starting" + | "running" + | "completed" + | "failed" + | "cancelled"; + task_id: string; + }; + }; + }; + }; + /** @description Invalid task-run failure request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task run not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task-run failure conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task service is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + startTaskRun: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task run id */ + id: string; + }; + cookie?: never; + }; + /** @description JSON request body */ + requestBody: { + content: { + "application/json": { + idempotency_key?: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + run: { + attempt: number; + /** Format: date-time */ + claimed_at?: string | null; + claimed_by?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + } | null; + /** Format: date-time */ + ended_at?: string | null; + error?: string; + id: string; + idempotency_key?: string; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + /** Format: date-time */ + queued_at: string; + result?: unknown; + session_id?: string; + /** Format: date-time */ + started_at?: string | null; + /** @enum {string} */ + status: + | "queued" + | "claimed" + | "starting" + | "running" + | "completed" + | "failed" + | "cancelled"; + task_id: string; + }; + }; + }; + }; + /** @description Invalid task-run start request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task run not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task-run start conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task service is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listTasks: { + parameters: { + query?: { + /** @description Filter by task scope */ + scope?: "global" | "workspace"; + /** @description Filter by workspace path, name, or ID */ + workspace?: string; + /** @description Filter by task status */ + status?: + | "pending" + | "blocked" + | "ready" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + /** @description Filter by owner kind */ + owner_kind?: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + /** @description Filter by owner reference */ + owner_ref?: string; + /** @description Filter by parent task ID */ + parent_task_id?: string; + /** @description Filter by network channel */ + network_channel?: string; + /** @description Maximum number of records to return */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + tasks: { + /** Format: date-time */ + closed_at?: string | null; + /** Format: date-time */ + created_at: string; + created_by: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + }; + id: string; + identifier?: string; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + parent_task_id?: string; + /** @enum {string} */ + scope: "global" | "workspace"; + /** @enum {string} */ + status: + | "pending" + | "blocked" + | "ready" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + title: string; + /** Format: date-time */ + updated_at: string; + workspace_id?: string; + }[]; + }; + }; + }; + /** @description Invalid task filter */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Workspace not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task service is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + createTask: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description JSON request body */ + requestBody: { + content: { + "application/json": { + description?: string; + id?: string; + identifier?: string; + metadata?: unknown; + network_channel?: string; + owner?: { + /** @enum {string} */ + kind: "human" | "agent_session" | "automation" | "extension" | "network_peer" | "pool"; + ref: string; + } | null; + /** @enum {string} */ + scope: "global" | "workspace"; + title: string; + workspace?: string; + }; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + task: { + /** Format: date-time */ + closed_at?: string | null; + /** Format: date-time */ + created_at: string; + created_by: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + }; + description?: string; + id: string; + identifier?: string; + metadata?: unknown; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + parent_task_id?: string; + /** @enum {string} */ + scope: "global" | "workspace"; + /** @enum {string} */ + status: + | "pending" + | "blocked" + | "ready" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + title: string; + /** Format: date-time */ + updated_at: string; + workspace_id?: string; + }; + }; + }; + }; + /** @description Invalid task request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Workspace not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Payload too large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task service is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getTask: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task id */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + task: { + children?: { + /** Format: date-time */ + closed_at?: string | null; + /** Format: date-time */ + created_at: string; + created_by: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + }; + id: string; + identifier?: string; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + parent_task_id?: string; + /** @enum {string} */ + scope: "global" | "workspace"; + /** @enum {string} */ + status: + | "pending" + | "blocked" + | "ready" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + title: string; + /** Format: date-time */ + updated_at: string; + workspace_id?: string; + }[]; + dependencies?: { + /** Format: date-time */ + created_at: string; + depends_on_task_id: string; + /** @enum {string} */ + kind: "blocks"; + task_id: string; + }[]; + events?: { + actor: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + }; + event_type: string; + id: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + payload?: unknown; + run_id?: string; + task_id: string; + /** Format: date-time */ + timestamp: string; + }[]; + runs?: { + attempt: number; + /** Format: date-time */ + claimed_at?: string | null; + claimed_by?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + } | null; + /** Format: date-time */ + ended_at?: string | null; + error?: string; + id: string; + idempotency_key?: string; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + /** Format: date-time */ + queued_at: string; + result?: unknown; + session_id?: string; + /** Format: date-time */ + started_at?: string | null; + /** @enum {string} */ + status: + | "queued" + | "claimed" + | "starting" + | "running" + | "completed" + | "failed" + | "cancelled"; + task_id: string; + }[]; + task: { + /** Format: date-time */ + closed_at?: string | null; + /** Format: date-time */ + created_at: string; + created_by: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + }; + description?: string; + id: string; + identifier?: string; + metadata?: unknown; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + parent_task_id?: string; + /** @enum {string} */ + scope: "global" | "workspace"; + /** @enum {string} */ + status: + | "pending" + | "blocked" + | "ready" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + title: string; + /** Format: date-time */ + updated_at: string; + workspace_id?: string; + }; + }; + }; + }; + }; + /** @description Invalid task id */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task service is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateTask: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task id */ + id: string; + }; + cookie?: never; + }; + /** @description JSON request body */ + requestBody: { + content: { + "application/json": { + clear_owner?: boolean; + description?: string | null; + metadata?: unknown; + network_channel?: string | null; + owner?: { + /** @enum {string} */ + kind: "human" | "agent_session" | "automation" | "extension" | "network_peer" | "pool"; + ref: string; + } | null; + title?: string | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + task: { + /** Format: date-time */ + closed_at?: string | null; + /** Format: date-time */ + created_at: string; + created_by: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + }; + description?: string; + id: string; + identifier?: string; + metadata?: unknown; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + parent_task_id?: string; + /** @enum {string} */ + scope: "global" | "workspace"; + /** @enum {string} */ + status: + | "pending" + | "blocked" + | "ready" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + title: string; + /** Format: date-time */ + updated_at: string; + workspace_id?: string; + }; + }; + }; + }; + /** @description Invalid task update */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task update conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task service is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + cancelTask: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task id */ + id: string; + }; + cookie?: never; + }; + /** @description JSON request body */ + requestBody: { + content: { + "application/json": { + metadata?: unknown; + reason?: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + task: { + /** Format: date-time */ + closed_at?: string | null; + /** Format: date-time */ + created_at: string; + created_by: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + }; + description?: string; + id: string; + identifier?: string; + metadata?: unknown; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + parent_task_id?: string; + /** @enum {string} */ + scope: "global" | "workspace"; + /** @enum {string} */ + status: + | "pending" + | "blocked" + | "ready" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + title: string; + /** Format: date-time */ + updated_at: string; + workspace_id?: string; + }; + }; + }; + }; + /** @description Invalid task cancel request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task cancel conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task service is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + createChildTask: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Parent task id */ + id: string; + }; + cookie?: never; + }; + /** @description JSON request body */ + requestBody: { + content: { + "application/json": { + description?: string; + id?: string; + identifier?: string; + metadata?: unknown; + network_channel?: string; + owner?: { + /** @enum {string} */ + kind: "human" | "agent_session" | "automation" | "extension" | "network_peer" | "pool"; + ref: string; + } | null; + /** @enum {string} */ + scope: "global" | "workspace"; + title: string; + workspace?: string; + }; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + task: { + /** Format: date-time */ + closed_at?: string | null; + /** Format: date-time */ + created_at: string; + created_by: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + }; + description?: string; + id: string; + identifier?: string; + metadata?: unknown; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + parent_task_id?: string; + /** @enum {string} */ + scope: "global" | "workspace"; + /** @enum {string} */ + status: + | "pending" + | "blocked" + | "ready" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + title: string; + /** Format: date-time */ + updated_at: string; + workspace_id?: string; + }; + }; + }; + }; + /** @description Invalid child task request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task or workspace not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Payload too large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task service is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + addTaskDependency: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task id */ + id: string; + }; + cookie?: never; + }; + /** @description JSON request body */ + requestBody: { + content: { + "application/json": { + depends_on_task_id: string; + /** @enum {string} */ + kind?: "blocks"; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + task: { + children?: { + /** Format: date-time */ + closed_at?: string | null; + /** Format: date-time */ + created_at: string; + created_by: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + }; + id: string; + identifier?: string; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + parent_task_id?: string; + /** @enum {string} */ + scope: "global" | "workspace"; + /** @enum {string} */ + status: + | "pending" + | "blocked" + | "ready" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + title: string; + /** Format: date-time */ + updated_at: string; + workspace_id?: string; + }[]; + dependencies?: { + /** Format: date-time */ + created_at: string; + depends_on_task_id: string; + /** @enum {string} */ + kind: "blocks"; + task_id: string; + }[]; + events?: { + actor: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + }; + event_type: string; + id: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + payload?: unknown; + run_id?: string; + task_id: string; + /** Format: date-time */ + timestamp: string; + }[]; + runs?: { + attempt: number; + /** Format: date-time */ + claimed_at?: string | null; + claimed_by?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + } | null; + /** Format: date-time */ + ended_at?: string | null; + error?: string; + id: string; + idempotency_key?: string; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + /** Format: date-time */ + queued_at: string; + result?: unknown; + session_id?: string; + /** Format: date-time */ + started_at?: string | null; + /** @enum {string} */ + status: + | "queued" + | "claimed" + | "starting" + | "running" + | "completed" + | "failed" + | "cancelled"; + task_id: string; + }[]; + task: { + /** Format: date-time */ + closed_at?: string | null; + /** Format: date-time */ + created_at: string; + created_by: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + }; + description?: string; + id: string; + identifier?: string; + metadata?: unknown; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + parent_task_id?: string; + /** @enum {string} */ + scope: "global" | "workspace"; + /** @enum {string} */ + status: + | "pending" + | "blocked" + | "ready" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + title: string; + /** Format: date-time */ + updated_at: string; + workspace_id?: string; + }; + }; + }; + }; + }; + /** @description Invalid dependency request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Dependency conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task service is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + removeTaskDependency: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task id */ + id: string; + /** @description Dependency task id */ + depends_on_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + task: { + children?: { + /** Format: date-time */ + closed_at?: string | null; + /** Format: date-time */ + created_at: string; + created_by: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + }; + id: string; + identifier?: string; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + parent_task_id?: string; + /** @enum {string} */ + scope: "global" | "workspace"; + /** @enum {string} */ + status: + | "pending" + | "blocked" + | "ready" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + title: string; + /** Format: date-time */ + updated_at: string; + workspace_id?: string; + }[]; + dependencies?: { + /** Format: date-time */ + created_at: string; + depends_on_task_id: string; + /** @enum {string} */ + kind: "blocks"; + task_id: string; + }[]; + events?: { + actor: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + }; + event_type: string; + id: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + payload?: unknown; + run_id?: string; + task_id: string; + /** Format: date-time */ + timestamp: string; + }[]; + runs?: { + attempt: number; + /** Format: date-time */ + claimed_at?: string | null; + claimed_by?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + } | null; + /** Format: date-time */ + ended_at?: string | null; + error?: string; + id: string; + idempotency_key?: string; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + /** Format: date-time */ + queued_at: string; + result?: unknown; + session_id?: string; + /** Format: date-time */ + started_at?: string | null; + /** @enum {string} */ + status: + | "queued" + | "claimed" + | "starting" + | "running" + | "completed" + | "failed" + | "cancelled"; + task_id: string; + }[]; + task: { + /** Format: date-time */ + closed_at?: string | null; + /** Format: date-time */ + created_at: string; + created_by: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + }; + description?: string; + id: string; + identifier?: string; + metadata?: unknown; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + owner?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "pool"; + ref: string; + } | null; + parent_task_id?: string; + /** @enum {string} */ + scope: "global" | "workspace"; + /** @enum {string} */ + status: + | "pending" + | "blocked" + | "ready" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + title: string; + /** Format: date-time */ + updated_at: string; + workspace_id?: string; + }; + }; + }; + }; + }; + /** @description Invalid dependency request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task or dependency not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task service is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listTaskRuns: { + parameters: { + query?: { + /** @description Filter by run status */ + status?: + | "queued" + | "claimed" + | "starting" + | "running" + | "completed" + | "failed" + | "cancelled"; + /** @description Filter by attached session id */ + session_id?: string; + /** @description Maximum number of records to return */ + limit?: number; + }; + header?: never; + path: { + /** @description Task id */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + runs: { + attempt: number; + /** Format: date-time */ + claimed_at?: string | null; + claimed_by?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + } | null; + /** Format: date-time */ + ended_at?: string | null; + error?: string; + id: string; + idempotency_key?: string; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + /** Format: date-time */ + queued_at: string; + result?: unknown; + session_id?: string; + /** Format: date-time */ + started_at?: string | null; + /** @enum {string} */ + status: + | "queued" + | "claimed" + | "starting" + | "running" + | "completed" + | "failed" + | "cancelled"; + task_id: string; + }[]; + }; + }; + }; + /** @description Invalid task-run filter */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task service is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + enqueueTaskRun: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task id */ + id: string; + }; + cookie?: never; + }; + /** @description JSON request body */ + requestBody: { + content: { + "application/json": { + idempotency_key?: string; + network_channel?: string; + }; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + run: { + attempt: number; + /** Format: date-time */ + claimed_at?: string | null; + claimed_by?: { + /** @enum {string} */ + kind: + | "human" + | "agent_session" + | "automation" + | "extension" + | "network_peer" + | "daemon"; + ref: string; + } | null; + /** Format: date-time */ + ended_at?: string | null; + error?: string; + id: string; + idempotency_key?: string; + network_channel?: string; + origin: { + /** @enum {string} */ + kind: + | "cli" + | "web" + | "uds" + | "http" + | "automation" + | "extension" + | "network" + | "agent_session" + | "daemon"; + ref: string; + }; + /** Format: date-time */ + queued_at: string; + result?: unknown; + session_id?: string; + /** Format: date-time */ + started_at?: string | null; + /** @enum {string} */ + status: + | "queued" + | "claimed" + | "starting" + | "running" + | "completed" + | "failed" + | "cancelled"; + task_id: string; + }; + }; + }; + }; + /** @description Invalid task-run enqueue request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task-run enqueue conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Task service is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deliverGlobalWebhook: { + parameters: { + query?: never; + header: { + /** @description Signed webhook timestamp */ + "X-AGH-Webhook-Timestamp": string; + /** @description Signed webhook HMAC signature */ + "X-AGH-Webhook-Signature": string; + }; + path: { + /** @description Webhook endpoint slug and id */ + endpoint: string; + }; + cookie?: never; + }; + /** @description JSON request body */ + requestBody: { + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + result: { + matched: number; + runs?: { + attempt: number; + /** Format: date-time */ + ended_at?: string | null; + error?: string; + id: string; + job_id?: string; + session_id?: string; + /** Format: date-time */ + started_at?: string | null; + /** @enum {string} */ + status: + | "scheduled" + | "running" + | "delegated" + | "completed" + | "failed" + | "cancelled"; + task_id?: string; + task_run_id?: string; + trigger_id?: string; + }[]; + }; + }; + }; + }; + /** @description Invalid webhook request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Webhook authentication failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Webhook trigger not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Automation manager is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deliverWorkspaceWebhook: { + parameters: { + query?: never; + header: { + /** @description Signed webhook timestamp */ + "X-AGH-Webhook-Timestamp": string; + /** @description Signed webhook HMAC signature */ + "X-AGH-Webhook-Signature": string; + }; + path: { + /** @description Workspace id */ + workspace_id: string; + /** @description Webhook endpoint slug and id */ + endpoint: string; }; cookie?: never; }; @@ -7264,7 +10472,15 @@ export interface operations { /** Format: date-time */ started_at?: string | null; /** @enum {string} */ - status: "scheduled" | "running" | "completed" | "failed" | "cancelled"; + status: + | "scheduled" + | "running" + | "delegated" + | "completed" + | "failed" + | "cancelled"; + task_id?: string; + task_run_id?: string; trigger_id?: string; }[]; };