From 9eeb7faf745be37fecd2eeee6cc7f4e9b31c3a02 Mon Sep 17 00:00:00 2001 From: Maxi Gimenez Date: Fri, 18 Sep 2026 16:47:27 +0100 Subject: [PATCH] feat: route Linear and GitHub issues by agent assignment --- README.md | 5 + docs/api.md | 24 +- docs/dashboard.md | 12 +- docs/deploy-cloud.md | 4 + docs/getting-started.md | 9 +- docs/plans/assigned-ticket-pr-feedback.md | 602 ++++++++++++++++++ docs/releasing.md | 12 + docs/routes.md | 70 +- packages/cli/README.md | 11 +- packages/cli/src/commands/agents.ts | 5 + packages/cli/src/commands/init.ts | 21 +- packages/cli/src/commands/preflight.ts | 11 + .../migrations/008_linear_agent_identity.sql | 2 + packages/cloud-api/src/routes/runner.ts | 6 +- packages/cloud-api/src/routes/user.ts | 67 +- .../cloud-api/test/assignment-routes.test.ts | 108 ++++ packages/cloud-dashboard/src/api/types.ts | 18 +- .../src/components/RouteForm.tsx | 31 +- .../cloud-dashboard/src/lib/routeDraft.ts | 23 +- .../cloud-dashboard/src/screens/Agents.tsx | 7 + .../src/screens/ProjectNew.tsx | 29 +- .../cloud-dashboard/src/screens/RouteEdit.tsx | 13 +- .../cloud-dashboard/src/screens/RouteNew.tsx | 1 + .../cloud-dashboard/test/routeDraft.test.ts | 13 + .../cloud-dashboard/test/screens.test.tsx | 63 ++ packages/common/src/github.ts | 47 +- packages/common/src/index.ts | 17 + packages/common/src/route-catalog.ts | 57 ++ packages/common/src/route-validation.ts | 36 +- packages/common/test/github.test.ts | 17 + .../orchestrator/src/config-validation.ts | 18 + packages/orchestrator/src/github/service.ts | 3 + packages/orchestrator/src/hermes/discovery.ts | 2 + packages/orchestrator/src/linear/service.ts | 48 +- packages/orchestrator/src/prompts/render.ts | 2 +- .../orchestrator/src/routing/rule-engine.ts | 45 +- .../orchestrator/test/config-loader.test.ts | 42 ++ .../test/hermes/discovery.test.ts | 12 +- .../orchestrator/test/linear/service.test.ts | 117 ++++ .../test/routing/assignment-pipeline.test.ts | 151 +++++ .../test/routing/route-catalog.test.ts | 65 +- 41 files changed, 1776 insertions(+), 70 deletions(-) create mode 100644 docs/plans/assigned-ticket-pr-feedback.md create mode 100644 packages/cloud-api/src/migrations/008_linear_agent_identity.sql create mode 100644 packages/cloud-api/test/assignment-routes.test.ts create mode 100644 packages/orchestrator/test/linear/service.test.ts create mode 100644 packages/orchestrator/test/routing/assignment-pipeline.test.ts diff --git a/README.md b/README.md index 889bc63..8e504ce 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,11 @@ needs a release. Ready-made routes for every supported case, including multi-round pull request review, are in **[docs/routes.md](./docs/routes.md)** and served from `GET /v1/route-templates`. +Assignment templates can start an implementation when a Linear or GitHub issue is +assigned to a configured bot. See [setup and current scope](./docs/routes.md#implement-an-assigned-issue). +The [six-release plan](./docs/plans/assigned-ticket-pr-feedback.md) covers durable work, +verified PR evidence, and continuation in the same Hermes session. + ## How it fits together ``` diff --git a/docs/api.md b/docs/api.md index 38c3e72..8dac2ec 100644 --- a/docs/api.md +++ b/docs/api.md @@ -123,7 +123,8 @@ DELETE /v1/projects/:id ```jsonc // Linear -{ "id": "taplands", "provider": "linear", "filters": { "team": "ENG" } } +{ "id": "taplands", "provider": "linear", + "filters": { "team": "ENG", "repository": "acme/www" } } // GitHub { "id": "www", "provider": "github", @@ -189,14 +190,18 @@ DELETE /v1/routes/:id "target": { "agentRef": { "profile": "product" } - // or, for either pull request trigger — matches the account being + // or, for PR triggers and ticket triggers with provider "github": + // matches the account being // assigned to the item or asked to review it: // "agentRef": { "githubLogin": "acme-reviewer-bot" } + // or, for ticket triggers with provider "linear": + // "agentRef": { "linearUserId": "user-uuid", "linearWorkspaceId": "org-uuid" } }, "execution": { "prompt": "Review {{ticket.ref}}: {{ticket.title}}\n\n{{ticket.body}}", "requireApproval": false, // uses Hermes' own approval gate + "requireRepository": false, // assigned implementation templates set true "modelOverride": null, // null = the profile's own model "timeoutSeconds": 1800, "approvalTimeoutSeconds": 3600 // optional; how long a gate may wait @@ -398,11 +403,19 @@ PUT /v1/routes/{id} ``` `PUT` replaces the route entirely rather than merging fields. A route is a -single decision, and its parts constrain each other — a `githubLogin` target is -valid on a pull request trigger and rejected on a ticket one — so a partial +single decision, and its parts constrain each other — a Linear identity target needs +both user and workspace IDs and a Linear ticket trigger — so a partial update could walk a route through states the validator rejects as a whole. The body is validated as the complete rule it will become. +With `execution.requireRepository: true`, enabled routes also require a registered +project of the matching provider, a valid `owner/repo` repository, and exactly one +enabled agent resolving the target in the organization's inventory. Linear projects +use `filters.repository`; GitHub projects use `filters.owner` and `filters.repo`. +Invalid or ambiguous setup returns 400 on both creation and replacement. Disabled +drafts skip this contextual check. Optional `filters.repository`, when supplied on a +project, must be an `owner/repo` string. + The id in the path wins over any id in the body, so a copy-pasted definition cannot rewrite a different route. `PUT` to an id that does not exist is a `404`, not an upsert. @@ -493,7 +506,8 @@ than deferred — nothing will retry it, so the caller is owed the reason. ## Agents and runners ```http -GET /v1/agents Hermes profiles, as discovered by the runner (incl. avatar_url) +GET /v1/agents Hermes profiles, as discovered by the runner (incl. avatar_url, + linear_user_id and linear_workspace_id) GET /v1/runners registered runners, with health and a `stale` flag ``` diff --git a/docs/dashboard.md b/docs/dashboard.md index 7355ca7..d7d36a9 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -194,12 +194,22 @@ asks only for what the template declares, and asks for it with the right control | `` | a dropdown of registered projects | | `` | a dropdown of discovered agents | | `` | filled from the selected agent | +| ``, `` | editable fields filled from the selected agent | | anything else | a text field | Free text where the API already knows the answer produces a route that is structurally valid and can never match — a typo'd project id fires nothing, silently, forever. Selecting the agent is also what fills a `githubLogin` target, so the two cannot drift -apart the way two independent fields would. +apart the way two independent fields would. Selecting a Linear agent also fills its +user and workspace IDs from runner inventory. Configure those IDs in the runner; +the Agents table displays them. Editing a route preserves its identity target. + +The assigned implementation templates limit project choices to the matching provider +and explain that every currently assigned open issue is eligible. For a Linear +project, **Add project** includes an **Implementation repository** field (`owner/repo`). +The API rejects enabling an implementation route with missing repository context or +an unknown/ambiguous agent. See [assigned issue setup](./routes.md#implement-an-assigned-issue) +for the single-runner limitation and the current release's behavior. Under the prompt is the list of variables the runner substitutes at dispatch. Clicking one inserts it at the cursor. This matters more than it looks: the runner deliberately diff --git a/docs/deploy-cloud.md b/docs/deploy-cloud.md index fa6c49c..131b178 100644 --- a/docs/deploy-cloud.md +++ b/docs/deploy-cloud.md @@ -238,6 +238,10 @@ docker build --platform linux/amd64 -t sentinel0-cloud . ## Adding a migration +Assignment routing adds `008_linear_agent_identity.sql`. Apply it with the API deploy +before the updated runner/dashboard; it adds nullable agent identity columns and +does not rewrite existing routes. See [release order and rollback](./releasing.md#after-a-deploy). + Add `packages/cloud-api/src/migrations/002_whatever.sql`. Nothing else — the migrator picks up any `.sql` file it has not already applied and records it in `schema_migrations`. diff --git a/docs/getting-started.md b/docs/getting-started.md index 5f9961a..ccaa0da 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -107,17 +107,24 @@ Neither is recoverable. Store them now. ## 4. Configure what should happen -Three shapes of route cover most of what you will want: +These routes cover the common starting points: | You want | `trigger.type` | `match` | |---|---|---| | a ticket gets a label | `ticket` | `labels` or `labelsAdded` | +| an issue is assigned to the implementer | `ticket` | target by GitHub login or scoped Linear identity | | a PR is assigned to someone | `pr_event` | `assignees` or `assigneesAdded` | | an agent is asked to review a PR | `pr_review_requested` | target by `githubLogin` | Routes fire **once per item** by default and mark their work with `sentinel0:` labels, so an agent's own commits and comments cannot retrigger the route that started them. +For assigned implementation, follow [the assignment setup](./routes.md#implement-an-assigned-issue): +declare the bot identity in the Hermes profile configuration and bind Linear projects +to an implementation repository. This starts the initial run and source summary; +automatic PR feedback continuation is a later release. `preflight` checks identity +configuration shape, not whether the configured bot actually owns the account. + **[routes.md](./routes.md) is the full reference** — every trigger, every match clause, the loop guard, and a ready-made route for each supported case. You can also fetch those directly: diff --git a/docs/plans/assigned-ticket-pr-feedback.md b/docs/plans/assigned-ticket-pr-feedback.md new file mode 100644 index 0000000..c8350bd --- /dev/null +++ b/docs/plans/assigned-ticket-pr-feedback.md @@ -0,0 +1,602 @@ +# Assigned ticket → PR → feedback continuation + +Status: six sequential, releasable PRs. Iteration 1 implementation and automated +validation are complete on `feat/assignment-routing`; live acceptance is pending +disposable GitHub, Linear, and Hermes targets. Iterations 2–6 remain planned. +The sections below describe the target design, not a claim that all behavior exists. + +Iteration 1 validation: full build, package typechecks, lint and 627 tests passed; +the final compatibility regression and discovery checks passed (24 focused tests, +including one additional test). CLI package dry-run passed. Live tracker/Hermes +acceptance and the Node 22/24 CI matrix must pass before release. + +## Outcome + +Assign a Linear or GitHub issue to a configured Hermes bot. Sentinel0 starts that +agent, Hermes implements the task and opens a PR, and the originating issue receives +the PR link and validation evidence. Feedback from people or other bots starts a new +turn in the same Hermes session. The agent fixes valid critical findings or explains +why it disagrees, pushes to the existing branch, and reports the result to the issue. + +The workflow remains observable and recoverable across restarts, busy agents, duplicate +observations, and temporary failures. This is a product capability with automated +acceptance tests and a live acceptance exercise before rollout. + +## Boundaries and decisions + +- Hermes continues to own repository access, worktrees, implementation, commits, PR + creation, and substantive replies to PR feedback. +- Sentinel0 owns assignment routing, workflow ownership, event delivery, session + selection, artifact references, lifecycle, and reliable reporting to the source issue. +- Both issue providers enter the same workflow. PRs and their feedback live on GitHub. +- Use polling initially, with pagination and durable event tracking. No new inbound + runner endpoint is required. Webhooks can feed the same event inbox later. +- A new workflow is opt-in per project. Existing routes retain their current behavior; + a workflow-owned issue/PR must not also start an ordinary implementation route. +- Current assignment is the intake condition. An assigned issue observed after downtime + must be eligible even if Sentinel0 never observed the assignment transition. +- One workflow retains one owning runner/profile and one Hermes session. Never pick + the profile's most recent session: unrelated tickets must remain isolated. +- The initial implementation supports one repository and one active PR per workflow. + Reject ambiguous repository selection or multiple candidate PRs visibly. Multi-PR + work is a later extension, not something inferred from free-text summaries. +- Automatic PR merging is outside this change. Observe merge/closure and conclude the + workflow; leave merge policy to the team. +- The agent policy defines criticality. Every delivered finding receives a recorded + disposition, including noncritical findings and disagreements, so silence is not + confused with a lost event. + +## Current implementation to extend + +| Area | Existing foundation | Required extension | +|---|---|---| +| Identity and intake | GitHub assignees; profile and GitHub login targets | Linear user IDs, repository binding, assignment intake for both providers | +| Dispatch | Route matching, run records, one active local run per profile | Durable workflow ownership and queued turns | +| Hermes | Adapter accepts `sessionId`; runs store `hermesSessionId` | Select and persist the workflow session; safe creation retries | +| PR monitoring | Open PR metadata and requested reviewers | Reviews, inline comments, conversation comments, PR lifecycle and check evidence | +| Reporting | Short agent summary posted to trigger item | Structured PR/evidence references and durable source-issue report delivery | +| Recovery | Attach to a live Hermes run; cloud mirror outbox | Recover pending turns, submission ambiguity, reports, and workflow state | +| UI | Run lists, approvals, routes, integration setup | Workflow history, pending feedback, evidence, blocked reasons and recovery actions | + +Relevant source: `packages/common/src/index.ts`, `route-validation.ts`, +`route-catalog.ts`; `packages/orchestrator/src/linear/service.ts`, +`github/service.ts`, `routing/dispatcher.ts`, `hermes/adapter.ts`, `database.ts`, +`index.ts`; and the cloud API, migrations, dashboard and CLI packages. + +## Workflow and persistence model + +```mermaid +flowchart LR + A[Issue assigned] --> B[Queued implementation] + B --> C[Hermes turn] + C --> D[PR linked; awaiting review] + D --> E[New feedback persisted] + E --> F[Queued continuation] + F --> G[Same profile and session] + G --> D + D --> H[PR merged or closed] + C --> R[Report delivery] + G --> R + H --> R +``` + +Keep work state separate from run state. A completed implementation run can leave the +work awaiting review. An unanswered Hermes approval occupies the profile but does not +end the workflow. A report delivery failure does not erase a successful code change. + +Proposed records, with shared versioned types and additive SQLite/Postgres migrations: + +- **Work:** stable ID, organization, source provider and immutable issue ID, tracker + scope (Linear workspace or GitHub repository), source project/ref/URL, repository, + assigned identity, owner runner/profile, Hermes instance/session, lifecycle state, + blocked reason, current PR/head SHA, and version/timestamps. +- **Artifacts:** work ID, type, URL, repository/PR identity or commit SHA, reporting + turn, evidence source, observation time, and validation status. Store references + and bounded summaries; large artifacts stay with their existing host. +- **Feedback inbox:** provider event ID and revision, PR identity, author identity, + event kind, parent review/thread, file/line and commit context, body, timestamps, + processing state, batch/turn assignment, disposition and response reference. +- **Turns:** work ID, purpose, immutable prompt snapshot, feedback batch, run ID, + attempt, submission key, Hermes run/session IDs, and execution state. +- **Report outbox:** logical report key, destination provider/issue, payload version, + remote comment ID, delivery state, retry schedule and last error. + +Cloud atomically arbitrates initial work ownership using the organization, provider, +tracker scope and immutable issue ID. It must not depend on a route or project ID: +overlapping project filters must not create two owners. The owning runner keeps the +durable execution inbox/turn/outbox in SQLite and mirrors versioned state to the cloud. + +The owning runner may continue already-owned work during a cloud outage. Newly +observed work waits until its ownership claim can be confirmed. Staleness alone never +transfers ownership to another runner. Transfers require the previous owner to be +quiescent and the destination to have a verified usable session; otherwise show a +blocked transfer. Enforce one registered owner per configured Hermes instance/profile. + +## Release iterations: one PR per iteration + +These are sequential releases: each depends only on previously merged and released +iterations. Each PR includes the implementation, migrations, usable CLI/dashboard +surface, tests, documentation and release notes for its capability. There is no +separate final testing or documentation PR, and no requirement to ship all six at once. +PR titles below are proposed titles, not already-created pull requests. + +| Iteration | Proposed PR title | What the released version lets an operator do | +|---|---|---| +| 1 | `feat: route Linear and GitHub issues by agent assignment` | Assign either kind of issue and get an initial agent run and its existing summary | +| 2 | `feat: persist assigned work ownership and queued turns` | Keep assigned work through contention/restarts and see who owns it | +| 3 | `feat: link implementation PRs and reliably report evidence` | See the verified PR/evidence on the source issue, with retryable reporting | +| 4 | `feat: continue tracked work in its original Hermes session` | Manually ask for another turn on the same ticket, PR and session | +| 5 | `feat: address PR feedback automatically in the owning session` | Have human/bot review feedback evaluated and addressed automatically | +| 6 | `feat: complete workflow lifecycle and recovery controls` | Follow checks through completion and manage merge/closure, reopening and handoff | + +The assignment → PR → automatic review-feedback loop is available after iteration 5. +Iteration 6 completes ongoing evidence updates and lifecycle/recovery operations. +Basic safeguards and recovery required by an iteration ship in that iteration. + +### Iteration 1 — Assignment works on both providers + +**Ship:** Linear user identity on profiles, Linear assignee collection, paginated issue +intake, explicit Linear-project repository context, and validated assignment route +templates for both providers. Reuse the existing dispatch and summary-comment path. +Expose setup/preflight, route creation and agent identity display in the same PR. + +Use current assignment plus existing once-only guards for the initial implementation +run. An assignment observed after downtime is eligible. For this first release, +document one designated runner per watched project/profile: distributed ownership +arrives in iteration 2. Assignment starts an ordinary run; it does not claim to +establish session continuation or verified PR tracking yet. + +**Main changes:** common types/config/validation/catalog; Linear and GitHub collectors; +CLI initialization/preflight; cloud inventory and project configuration; dashboard +setup; relevant setup, routes, API and CLI docs. + +**Release gate:** Linear and GitHub assignment tests select the correct profile and +repository, ignore unmapped identities, work beyond page one and after downtime, and +do not repeat on unchanged observations. Existing label/reviewer routes still pass. +Exercise one initial live run per provider, including the existing summary comment. + +**Rollback:** disable the new assignment routes; existing routes and configuration +remain readable. Identity and repository fields are optional for older configurations. + +### Iteration 2 — Assigned work is durable and has one owner + +**Ship:** opt-in tracked assignment mode with work records, cloud ownership arbitration, +a persistent local turn queue, run/work links and minimal work list/detail views. +Busy agents and pending approvals leave work queued. Persist the first Hermes session +and an immutable turn submission key, and recover accepted submissions after restarts. +An initial turn can finish with the work idle, ready for a later continuation. + +Enforce project-independent source identity, organization isolation, runner addressing +and one owner per Hermes instance/profile. New claims wait through cloud outages; +confirmed owners can continue locally. Prevent legacy routes from starting parallel +implementation runs for tracked items. Disabling/reassigning the agent pauses the work +visibly; automatic transfer is unavailable until the later recovery controls. + +**Main changes:** common work/turn contracts; SQLite and Postgres migrations; cloud +claim/mirror APIs; dispatcher/queue/recovery; Hermes submission handling; minimal +dashboard/CLI ownership and queue diagnostics. Introduce real-Postgres and file-backed +SQLite integration tests in this PR. + +**Release gate:** two runners/overlapping projects produce one work owner and initial +run; two issues sharing a profile execute serially; queued work and session identity +survive a real process restart; lost submission responses reconcile without another +run; wrong-organization/runner calls fail. Verify the deployed Hermes idempotency +contract before enabling automatic submission retries. + +**Rollback:** stop new tracked intake and pause its pending turns; keep claims and +queue records. Reconcile live turns before downgrading. Do not convert tracked items +back to ordinary routes automatically, which could restart their work. + +### Iteration 3 — The PR and evidence reach the original issue reliably + +**Ship:** validated structured agent results, verified ticket/work → PR/branch/head +links, initial evidence collection, and a persistent report outbox. Hermes opens the +PR and returns its identifiers; Sentinel0 verifies it and posts the report to the +correct Linear or GitHub issue. Show artifacts, check status and delivery failures +in work detail, with report-only retry available in both API and operator tooling. + +Add deterministic PR correlation and recovery when PR creation precedes a crash. +Record code-host checks against the exact head SHA and distinguish them from +agent-reported test results. Pending checks remain pending; continuous report refresh +ships in iteration 6. Add basic linked-PR closure detection now so later continuations +can refuse work on closed/merged PRs. Incomplete/malformed results remain visible and +recoverable without blindly repeating the implementation. + +**Main changes:** result contract/parser; artifact storage/APIs; GitHub PR/check lookup; +cross-provider report addressing; durable tracker outbox; workflow detail UI and docs. + +**Release gate:** both providers receive the verified PR link and evidence under the +configured reporting identity; unrelated/multiple candidate PRs are rejected; a crash +after PR creation does not create another PR; report retry survives restart/outage; +a lost comment response is reconciled before creating another comment. Retrying a +report never starts Hermes, and evidence from an old SHA is not marked current. + +**Rollback:** stop new tracked intake/turns while preserving artifact bindings and +pending reports. Keep report delivery available or paused for deliberate retry. Older +ordinary runs keep their summary-only output contract. + +### Iteration 4 — Manual continuation proves session reuse + +**Ship:** a “Continue work” action in the work detail view and a CLI/API equivalent. +An operator supplies feedback; Sentinel0 queues a turn on that work's original runner, +profile, session and PR, then reports the outcome through iteration 3's outbox. + +Pass the saved session explicitly and verify repository/branch/head context before +work resumes. Missing sessions block visibly; never select the profile's latest +session or silently start a fresh conversation. Manual continuation uses the durable +queue, existing approval/cancellation flow and stable submission keys. Refuse closed +PRs, paused work and ambiguous ownership. Automatic review ingestion remains disabled. + +**Main changes:** continuation command/API; dispatcher session selection and context; +dashboard/CLI action; session-aware fake Hermes; command addressing and diagnostics. + +**Release gate:** a live second turn recalls first-turn context, produces a new run ID +with the same session ID, updates the same PR and reports back to the original issue. +Two tickets cannot share context accidentally. Cover busy/approval, restart, missing +session, duplicate command delivery, changed head and closed-PR cases. + +**Rollback:** disable continuation commands and pause unstarted continuation turns; +initial tracked work and report delivery still function. Preserve the session mapping. + +### Iteration 5 — Automatic feedback from humans and bots + +**Ship:** opt-in feedback monitoring on linked PRs, a durable feedback inbox, bounded +batching, and automatic continuation through iteration 4. Collect human reviews, +inline comments/replies and conversation comments, plus the actual surfaces used by +`baz-reviewer`. Apply the agent's versioned criticality policy, record each finding's +disposition and return evidence-backed replies or code changes on the existing PR. + +Pagination, edited feedback, self-event suppression, pending feedback while busy, +current-head/thread revalidation, partial-side-effect recovery, bounded retries and +rate-limit handling are release requirements here. A merged/closed PR or reassigned +issue stops new automatic continuations. Include enable/disable/pause controls, +pending-feedback and disposition visibility, and actionable blocked reasons now. + +**Main changes:** GitHub feedback collectors; durable inbox/cursors; batch scheduler; +continuation prompt/policy/result contracts; PR context checks; cloud/dashboard +feedback history and controls; provider-neutral source reports. + +**Release gate:** the full automatic flow passes for a Linear-origin issue and a +GitHub-origin issue. A human and real `baz-reviewer` exercise fixed critical findings, +evidenced disagreement and noncritical dispositions. Confirm the same profile/session, +one PR, no self-trigger loop, no lost feedback while busy, no repeat of unchanged +feedback, and successful recovery through process/tracker outages. Prove the +full-path test in CI; do not defer it to iteration 6. + +**Rollback:** disable automatic feedback dispatch but retain its inbox. Initial work, +manual continuation and reporting remain usable; unprocessed feedback stays visible. + +### Iteration 6 — Lifecycle completion and operational recovery + +**Ship:** ongoing check/evidence refresh for the relevant commits, final merge/closure +reports, explicit reopen/resume behavior and recovery/handoff controls. Add optional +mapping of verified PR lifecycle to Linear/GitHub issue state; leave automatic issue +closure disabled by default. Complete work history and queue/report diagnostics. + +Basic closure stopping, safe blocking and report/turn recovery already shipped in +earlier iterations. This release makes the remaining lifecycle operations usable: +resume deliberately after reopening, recover a lost session with an explicit new +session decision and preserved artifact context, or transfer only after the old +owner is quiescent and the destination can use the intended session. Never imply +that Hermes sessions are automatically portable across profiles or machines. + +**Main changes:** lifecycle reconciliation and evidence refresh; source-state mapping; +recovery/transfer commands and ownership checks; workflow timeline and diagnostics; +complete operational docs and the expanded acceptance suite. + +**Release gate:** merge/close/reopen, late check completion, unassignment/reassignment, +profile removal, blocked transfer, approved session replacement, long-lived work and +mixed-version deployment cases pass. Run the complete live acceptance matrix and +verify each release's rollback retains enough state to resume safely. + +**Rollback:** disable lifecycle state writes and new recovery actions; retain their +audit history and bindings. Evidence reporting and earlier capabilities continue on +the compatible cloud schema. Never restore a previous owner blindly. + +### Definition of releasable for every PR + +1. The advertised capability works from configuration to visible result on the + currently released dependencies. No operator-facing button points at future work. +2. Tests for normal behavior, failure/recovery and relevant regressions pass; necessary + Postgres, restart and live contract checks belong to that PR's release gate. +3. CLI/dashboard/API configuration and docs ship together. Release notes state which + stages of the full workflow are available and any supported operating limits. +4. Schema changes are additive and old clients remain supported. If a runner cannot + support a new mode, registration/config validation prevents it from consuming that + work. Test the mixed-version window, not just the final all-upgraded deployment. +5. Deploy compatible cloud migrations/API first, then runner/CLI and dashboard; enable + the new capability on a test project after its checks pass. This ordering happens + within each release, without depending on a later PR. +6. The PR records rollout and rollback actions, retained state and active-run handling. + Reconcile active work before downgrade; disabling a feature must not delete work. + +## Technical work packages + +These packages describe implementation detail, not additional PRs. The iteration +boundaries above take precedence. Allocate them as follows: + +| Work package | Release allocation | +|---|---| +| A: external contracts | Credentials/intake in 1; idempotency in 2; evidence in 3; session in 4; real bot feedback in 5 | +| B: identity and intake | 1 | +| C: ownership and queue | 2; usable transfer/reopen recovery in 6 | +| D: Hermes and artifacts | Submission/session persistence in 2; PR/evidence in 3; session continuation in 4 | +| E: PR observations | PR identity/check lookup and closure checks in 3; feedback in 5; full lifecycle refresh in 6 | +| F: feedback continuation | Manual turn infrastructure in 4; automatic policy-driven turns in 5 | +| G: source reporting | Durable reports in 3; per-turn use in 4–5; ongoing/final lifecycle updates in 6 | +| H: operator surfaces/docs | Included with each owning iteration | +| I: validation/rollout | Included with each owning iteration; complete matrix in 6 | + +### A. Establish the executable contracts + +- Verify the deployed Hermes version can continue an existing session through + `POST /v1/runs`, including after a gateway restart. Capture scrubbed request/response + fixtures for session IDs, run IDs, terminal output and approval states. +- Verify support for run-creation idempotency and its retention window. Require a + compatible version for automatic ambiguous-submission recovery; do not silently + retry a possibly accepted request against a server without that support. +- Record the actual output surfaces used by `baz-reviewer`: formal reviews, inline + comments, regular PR comments, edits, or check annotations. Implement the surfaces + it actually uses as well as normal human reviews; do not assume all bots submit + `CHANGES_REQUESTED` reviews. +- Verify bot access to the selected repository and Linear workspace. Separate the + Hermes execution identity from the credential Sentinel0 uses for tracker reporting. + Use a bot-owned reporting credential when reports must appear as that bot. +- Decide the initial profile policy text for criticality, evidence and disagreements; + record its version with each turn. Do not silently rewrite the installed `SOUL.md`. +- Restore or locate the `add-tests` and `update-docs` skills referenced by `AGENTS.md` + before implementation. Their files were absent when this plan was written. + +Gate: a real two-turn Hermes experiment recalls a fact from turn one in turn two, +keeps the same session, and produces distinct run IDs. Provider fixtures match live +payloads. Missing capability is a visible deployment prerequisite. + +### B. Add identities and provider-neutral assignment intake + +- Add optional Linear workspace/user identity to profile configuration, discovery, + cloud inventory, CLI initialization/preflight, and the agent display. Match immutable + Linear user IDs; display names/emails are labels, not identity keys. +- Fetch Linear assignees and normalize them into trigger data. Extend filters, + validation and prompt context where needed. Keep GitHub login matching intact. +- Add explicit source-project → GitHub repository binding for Linear work; derive + it from the issue repository for GitHub work. Keep the source and repository + credential scopes distinct when selecting clients. +- Add an assignment workflow template/configuration surface. Reject unknown or + ambiguous agent mappings and missing repository bindings before enabling it. +- Paginate intake queries; do not silently stop at 100 items. On activation, show + which current assignments are eligible. Re-observation must not start duplicate work. + +Gate: assigning either provider's issue selects the intended profile and repository; +assigning a human or an unmapped user starts nothing and has an explainable result. +Already assigned issues are picked up after downtime and beyond the first page. + +### C. Persist work ownership and a durable turn queue + +- Implement the work/turn schema and atomic initial cloud claim, plus local + transactions that enqueue work before advancing collection progress. +- Connect workflow dispatch to existing runs with `workId` and turn purpose; preserve + existing run APIs for ordinary routes. Add organization-scoped ownership APIs and + runner-addressed commands; ensure polls, acknowledgements and writes check ownership. +- Queue work when its profile is busy or awaiting approval. A busy result must not + consume an assignment or feedback event. Serialize turns per work/profile and + maintain the existing global concurrency limit and fair scheduling between tickets. +- Freeze each submitted turn's prompt and feedback batch. Later feedback becomes a + later pending batch; never mutate an already submitted turn. +- Keep workflow deduplication independent of the current 30-day dispatch-ledger + pruning. Active work and its feedback must survive long-running PR review cycles. +- Define removal/reassignment behavior: cancel unstarted work, request a safe stop of + active work, retain history, and require a deliberate transfer for a different + agent. Route edits/deletion must not silently move an existing workflow's owner. +- Exclude workflow-owned items from overlapping legacy implementation routes. Audit + project scoping of lapsed-claim cleanup and observation-before-dispatch behavior + so the new path cannot rearm another project's work or lose a busy transition. + +Gate: two runners or overlapping projects observe one issue and only one owns it. +Pending work survives a process restart, cloud outage, profile contention and pruning. +Unrelated organizations and sessions cannot claim or continue it. + +### D. Bind the Hermes session and PR artifacts + +- Persist the session as soon as Hermes returns it; send it explicitly on every + subsequent turn. Never substitute a new session when continuation fails. Mark the + work blocked, with a deliberate recovery option for a deleted/expired session. +- Give each turn a stable submission key and immutable request payload. Reconcile a + lost create response through Hermes idempotency within its verified retention + window. After that window, require reconciliation instead of a blind new submission. +- Extend the output contract with a versioned, validated result alongside the readable + summary: work ID, repository, branch, PR URL/number, head SHA, evidence references, + and per-feedback dispositions/response references. Parse this outside free-text + summary extraction; malformed output must not be treated as successful registration. +- Include the work ID and source issue link in the PR metadata. Verify the returned + PR through GitHub and bind it to the work. Recover a PR created before a crash by + looking up that correlation marker; multiple matches require reconciliation. +- Capture test commands/results as agent-reported evidence and fetch GitHub check + status for the relevant head SHA as independently observed evidence. Keep those + origins visible. Never claim checks passed when pending or for an older commit. + +Gate: implementation produces a verified PR link, source issue link, evidence and +session binding. Two tickets handled by the same profile retain separate sessions. +A crash after PR creation cannot silently create a second PR. + +### E. Ingest PR feedback and lifecycle changes + +- Poll linked PRs for submitted/edited/dismissed reviews, inline comments and replies, + conversation comments, thread resolution and head changes. Add bot check output if + the contract checks show the selected reviewer uses it. +- Normalize feedback with source IDs, revision, author, review/thread and commit + context. Group a review and its inline comments without delivering the same + finding twice. Bot-authored feedback is eligible; the worker's own acknowledgements + and report comments are excluded using identity and recorded output IDs. +- Use durable per-stream progress and an overlap window where appropriate; dedupe by + source ID/revision, not PR `updated_at`. Persist inbox entries and cursor movement + atomically. Do not advance progress past an unpersisted page after a partial failure. +- Debounce nearby feedback into bounded batches, with a maximum delay so a busy PR + cannot postpone processing forever. Preserve pending work during rate limits and + honor provider retry guidance. +- Bootstrap feedback collection when the PR is bound, including feedback that arrived + before Sentinel0 registered the PR. Recheck current thread/head state before execution. +- Fetch linked PRs by identity after they leave the open-PR list. Merge closes the + work as merged; closure without merge records that outcome and cancels unstarted + continuations. Reopening requires an explicit resume policy, retaining the same work. + +Gate: human and bot feedback of each supported kind produces one durable item per +revision; edits are seen, resolved/outdated findings are not blindly acted on, and +feedback arriving during an active turn is processed afterward. + +### F. Continue work under the agent's feedback policy + +- Resolve feedback to its existing work owner, and create a new run in the bound + session with the source issue, verified PR/branch/head, and pending feedback batch. +- Ask the agent to assess each finding against the current code. Record dispositions + such as fixed, rejected with evidence, noncritical, already resolved or needs a + human decision. Criticality is policy-driven; reviewer wording alone is not proof. +- For accepted critical findings, instruct Hermes to change the existing branch, + validate, push, and reply to the relevant review thread. For disagreement, require + a concrete explanation and evidence. Preserve the same PR throughout the cycle. +- Compare the head before acting/pushing; reconcile concurrent human changes using + Hermes' repository workflow. Report conflicts rather than overwriting someone + else's work. A reused conversation does not prove its old worktree is still current. +- Preserve existing approval/cancel semantics. Do not treat an approval from a prior + run as blanket permission for future turns; honor what the deployed Hermes policy + actually requires. +- Mark only explicitly dispositioned feedback as handled. On failure, retain the + batch and reconcile partial pushes/replies before retrying. Include bounded retry + and repeated-disagreement handling, ending visibly blocked when progress needs help. + +Gate: a valid critical finding causes a commit and reply; an invalid finding produces +an evidence-backed disagreement; noncritical feedback produces no unnecessary code +change. All use the same profile/session, and none of the replies triggers a loop. + +### G. Deliver complete evidence to the source issue + +- Add a durable tracker-report outbox, separate from the cloud mirror. Enqueue the + report with the local turn outcome in one transaction. Report delivery and execution + status remain separate and independently retryable. +- Address the original issue using its stored provider and immutable identity. A + GitHub PR feedback turn on Linear-origin work must still report to Linear. +- Publish the PR link, relevant commit, check/test evidence, feedback dispositions, + unresolved questions and next state. Use the same report shape for GitHub issues. +- Store remote comment IDs and deterministic report markers. Reconcile an ambiguous + POST before retrying; use provider idempotency if available. Document the residual + ambiguity when a provider cannot support atomic idempotent creation. +- Use one logical report per turn and update it when check evidence settles for that + turn's head SHA. Refreshing evidence must not start another coding turn. +- Retry transient failures with backoff; show permanent credential/permission errors + and allow report-only retry without rerunning Hermes. Announce merge/closure to the + source issue; do not equate an opened PR with a completed issue. +- Keep automatic source-issue closing/state changes opt-in. Default to reporting the + verified PR lifecycle without overriding the team's tracker workflow. + +Gate: reports arrive on the correct issue, under the configured reporting identity, +with current evidence. An outage or restart does not lose a report or rerun the code. + +### H. Make the workflow operable and document it + +- Add cloud APIs and dashboard views for work, linked runs/PR, session identity, + pending feedback, dispositions, evidence and report delivery errors. +- Extend CLI diagnostics to explain assignment routing, ownership, queued turns, + missing sessions, and delivery failures. Provide deliberate pause/resume, retry-turn + and retry-report actions with distinct behavior and runner-addressed commands. +- Validate identity/repository configuration in setup and expose the distinction + between agent credentials and Sentinel0 reporting credentials. Do not require + operators to understand internal event IDs to run the workflow. +- Update `README.md`, `docs/getting-started.md`, `docs/routes.md`, `docs/api.md`, + `docs/dashboard.md`, `docs/deploy-cloud.md`, `docs/releasing.md`, and the CLI README + where affected. Correct contradictory setup/routing guidance in those paths and + `CONTRIBUTING.md`; update architecture notes in `CLAUDE.md`. +- Add a worked Linear example, equivalent GitHub example, bot review-policy example, + required credentials/capabilities, lifecycle semantics, offline behavior and recovery. + +Gate: an operator can configure and follow both workflows from documented setup, +see why work is waiting, and retry reporting without repeating implementation. + +### I. Prove each release and roll it out gradually + +- Extend existing suites near changed behavior. Add a focused Linear collector suite + and workflow integration suites; reuse the fake Hermes server and dashboard fixtures. +- Make fake Hermes session-aware and idempotency-aware, including lost responses, + approvals, deleted sessions and restarts. Add verified contract fixtures so the + fake cannot merely repeat assumptions from the client. +- Add a CI job with disposable real Postgres for migrations, ownership races, + organization isolation, addressed commands and mirror/outbox behavior. Existing + SQL-string mocks alone cannot prove these properties. Use file-backed SQLite for + process-restart tests; a second object using one in-memory connection is insufficient. +- Add deterministic full-path tests: provider observation → durable work/turn → + Hermes → PR registration → feedback → same-session continuation → source report. + Use controllable fake tracker servers and the real internal APIs/databases. +- Run a live acceptance exercise on disposable Linear and GitHub issues, a test + repository and the deployed Hermes profile. Include one human review and a real + `baz-reviewer` review, then compare every external artifact with the stored history. +- For each iteration, ship additive migrations and backward-compatible cloud APIs + first, then compatible runners and dashboard/CLI. Enable one test project, then + one production project. Monitor queue age, duplicate submissions, session + continuity and report delivery. +- Rollback disables new workflow intake and pauses unstarted continuations without + deleting ownership, feedback or reports. Reconcile active Hermes turns before + downgrading their runner; keep the additive schema for recovery. + +Gate: each iteration's relevant acceptance cases pass before its release; the whole +matrix passes for the completed capability. Record versions, commands, observed +artifacts and failures/fixes. Do not describe a mocked integration pass as proof of +live behavior. + +## Required acceptance matrix + +| Scenario | Expected proof | +|---|---| +| Linear assignment | Correct user/profile/repository; one work and initial turn | +| GitHub issue assignment | Same workflow behavior and report format as Linear | +| Already assigned while runner offline | Picked up after recovery without needing reassignment | +| More than one page of issues/comments | Eligible items and feedback beyond page one are processed | +| Duplicate observations; overlapping projects/two runners | One owner, one submitted turn, no duplicate PR | +| Two tickets on one profile | Serialized execution; distinct sessions and PR bindings | +| PR created before process crash | Existing PR recovered; no second PR opened | +| Human requested changes; bot review/comment | Correct feedback batch reaches original profile/session | +| Critical, invalid and noncritical findings | Fix, evidenced disagreement and recorded deferral respectively | +| Edited comment; review plus inline comments | New revision handled without repeating unchanged findings | +| Feedback during a busy turn/approval | Remains durable and runs after profile becomes available | +| Own push/reply/report; unchanged feedback | No extra coding turn | +| Resolved thread, stale finding, concurrent head change | Revalidation and reconciliation before edits/push | +| Lost Hermes create response; expired submission key | Safe replay within contract; visible reconciliation otherwise | +| Missing session; moved/disabled profile | Blocked visibly; no fresh-session or wrong-agent substitution | +| Process/gateway restart mid-turn | Attach/reconcile existing run; preserve session and pending feedback | +| Tracker/cloud outage or rate limit | Durable recovery; ownership preserved; no silent queue loss | +| Report accepted but response lost | Reconcile remote report marker/ID before another creation | +| CI pending/fails/new head | Accurate status tied to SHA; no stale success claim | +| Approval denied/cancel/unassignment/reassignment | Defined stop/transfer behavior with retained history | +| PR merged, closed, reopened | Correct lifecycle and reports; no accidental continuation after closure | +| Cross-org or wrong-runner request | Cannot read, claim, mutate or continue another owner's work | +| Existing routes; mixed cloud/runner versions | Existing routes work; unsupported new workflow rejected visibly | + +## Checks and definition of done + +Use the existing CI matrix (Node 22.12 and 24) and build before tests so the compiled +package checks run. For implementation changes, run the affected suites first, then: + +```bash +pnpm lint +pnpm build +NODE_OPTIONS=--experimental-sqlite SENTINEL0_DB_PATH=memory pnpm test +``` + +Run the existing per-package `tsc --noEmit` checks as CI does. Add the real-Postgres +and persistent-restart suites as explicit required jobs with isolated databases; +they must not inherit the `memory` override above. Verify the CLI tarball through +the existing clean-install checks when config/shared exports change. Build affected +Docker images before deployment. + +Done means both source-provider flows pass the acceptance matrix, the live two-round +review exercise uses one session and PR per work item, failures are visible and +recoverable, existing routes remain covered, and the setup/operation docs match the +shipped behavior. A successful PR-opening demonstration alone does not satisfy this. + +## References + +- [Hermes Runs API](https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server#post-v1runs): + existing-session transcript loading and run-creation idempotency. Verify the deployed + version before releasing the dependent capability rather than assuming it matches + the current documentation. +- [Linear filtering](https://linear.app/developers/filtering): issue assignee relations. +- [GitHub webhook event taxonomy](https://docs.github.com/en/webhooks/webhook-events-and-payloads#pull_request_review_comment): + reviews, inline review comments, threads and issue/PR conversation comments are + distinct surfaces even when collected through polling. diff --git a/docs/releasing.md b/docs/releasing.md index f9b0e4a..0cd98a2 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -100,6 +100,18 @@ lose an afternoon. ### After a deploy +For the assignment-routing release, deploy the cloud API with migration +`008_linear_agent_identity.sql` before publishing runner inventory with Linear IDs +or deploying the matching dashboard. Update the designated runner before enabling +the new assignment templates. Old profile configurations remain valid because the +two identity fields and repository binding are optional for existing routes. + +Before release, exercise one disposable Linear assignment and one GitHub assignment +through Hermes and verify the source summary, repository context, and no second run +after another poll. Automated fixtures do not replace this live acceptance gate. +Disable the new routes to roll back intake; disabling does not cancel already active +runs. Let them finish or cancel them explicitly. Keep the additive columns in place. + The runner reloads projects and routes on its own each cycle, so nothing needs restarting on the Mac Mini — *unless* the deploy added an endpoint the runner needs, in which case the runner has to be updated too. Its fallback is deliberately quiet: diff --git a/docs/routes.md b/docs/routes.md index 20f65c0..3668eb4 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -114,8 +114,66 @@ cannot be found by a route that names it, and `sentinel0 explain` marks such a r `githubLogin` fires only when that account is **named on the item** — assigned to it, or asked to review it — which is what makes "act when *this* agent is asked" address -one agent rather than all of them. It applies to pull request triggers only; the API -rejects it on a `ticket` route, which could never fire. +one agent rather than all of them. It applies to pull request triggers and to +`ticket` triggers explicitly pinned to `provider: "github"`. + +Linear assignment targets use immutable IDs scoped to the workspace: + +```jsonc +"target": { "agentRef": { + "linearUserId": "LINEAR_USER_UUID", + "linearWorkspaceId": "LINEAR_ORGANIZATION_UUID" +} } +``` + +Declare both IDs on `hermes.profiles[]` in the runner configuration. The route must +use `type: "ticket"` and `provider: "linear"`. The collector reads the organization +ID from the actual Linear connection; a credential for another workspace cannot +match that target. An identity target must resolve exactly one enabled profile. Ambiguous or missing +agents are reported by `sentinel0 explain` and are never chosen arbitrarily. + +### Implement an assigned issue + +1. Configure the implementer's Hermes profile with `githubLogin`, or with both + `linearUserId` and `linearWorkspaceId`. `sentinel0 init` asks for these; restart + the runner after changing local configuration and check `sentinel0 agents`. +2. Give the Hermes profile working repository access and its own GitHub credentials. +3. For Linear, bind the watched project to one implementation repository: + `{"team":"ENG","repository":"acme/api"}` in its `filters`. The dashboard's + **Add project → Linear → Implementation repository** sets this field. GitHub + projects use their existing `filters.owner` and `filters.repo`. +4. Choose `linear-assigned-implementation` or `github-assigned-implementation` from + the route catalog. Select the project and agent, review the prompt and filters, + and enable the route when ready. All currently assigned open issues are eligible, + including assignments made before the route existed or while the runner was down. +5. Use `sentinel0 explain --ref ENG-123` (or `acme/api#123`) to inspect matching + without dispatching. An enabled runner may start eligible work on its next poll; + stop it while reviewing an enabled route if you need a preview first. + +Both collectors paginate, so assignments beyond the first page remain eligible. +Linear completed/canceled state types and closed GitHub issues are skipped even +when included in project filters. `execution.requireRepository: true` prevents +starting without an explicit `owner/repo`; `{{repo.slug}}` renders that repository. +The API refuses to enable these routes without a valid project repository and +exactly one enabled matching agent in the published inventory. Disabled drafts can +be saved before setup is complete. + +This release starts the initial implementation run and posts the existing agent +summary to the source issue. Hermes is asked to open a PR and include the link and +checks in its summary. Sentinel0 does not yet verify those artifacts or resume the +session for PR feedback. Use one designated runner per watched project/profile; +distributed ownership and queued continuation are later iterations in the +[workflow plan](./plans/assigned-ticket-pr-feedback.md). + +The once-only ledger suppresses another implementation when the issue changes, +is unassigned/reassigned, or is closed/reopened. Do not enable overlapping ordinary +implementation routes for the same work. Summary delivery currently uses the +existing best-effort outcome path; durable delivery is planned in iteration 3. + +Source comments use the Linear/GitHub integration credential configured in Sentinel0, +including any project override. Configure that credential for the bot account if +comments should appear as the bot. The assignment identity alone does not change +the commenting account, and Sentinel0 does not receive Hermes' credentials. ### Which GitHub account is which @@ -197,8 +255,9 @@ even for a route with markers off. Starting a second agent on in-flight work is wanted. A `once` route also declines anything carrying `sentinel0:done` or `sentinel0:failed`. -**Removing that label by hand re-arms the route**, which is also how you retry -something that failed. +Removing the label alone does not clear the runner's once-only dedupe ledger. +Use an explicit manual run for a deliberate retry; do not expect reassignment or +marker removal to restart a once-only implementation. ### Choosing @@ -363,7 +422,8 @@ to witness the request appear, and that moment has passed. Switch the route to `while-matched` + `match.reviewers`, which acts on the state instead. **It fired once and never again.** That is `refire: "once"`. The item now carries -`sentinel0:done` — remove it to re-arm, or switch to `per-change` with a transition +`sentinel0:done`, and the runner retains its once-only claim. Use an explicit manual +run for a deliberate retry, or design a separate repeating route with a transition clause. **A transition route never fires.** It needs a prior observation, unless the item was diff --git a/packages/cli/README.md b/packages/cli/README.md index 4e3fc7d..77ec5bf 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -29,7 +29,7 @@ On the machine that runs Hermes: ```bash sentinel0 init # cloud key, Hermes profiles — each key is probed as you enter it -sentinel0 preflight # Node, Hermes, cloud, gh auth +sentinel0 preflight # Node, Hermes, cloud, tracker credentials sentinel0 start sentinel0 runner install # survive reboots (launchd) ``` @@ -78,6 +78,12 @@ route, rewording what an agent is asked to do never needs one either. Ready-made routes for every supported case, including multi-round pull request review, ship with the control plane and are served from `GET /v1/route-templates`. +For assigned implementation, `init` accepts a profile's GitHub login or its Linear +user and workspace IDs; `agents` shows those declarations. Linear projects also need +an implementation repository. See the +[assignment setup](https://github.com/maxigimenez/sentinel0/blob/main/docs/routes.md#implement-an-assigned-issue) +for route templates, initial-run behavior, and release limitations. + ## State Everything this CLI keeps lives under `~/.sentinel0` — cloud credentials and Hermes keys @@ -91,7 +97,8 @@ in `config.json`, the last known good routes in `routes.json`, run history in - **Node.js >= 22.5** — the CLI re-executes itself under a compatible interpreter if the active one cannot load `node:sqlite` - A **sentinel0 control plane** to point at, and a `snt_usr_` key for it -- `gh`, authenticated, if any project pulls from GitHub +- A GitHub integration token if any project pulls from GitHub. Hermes profiles need + their own repository credentials to open PRs; Sentinel0 does not pass its token on. ## Documentation diff --git a/packages/cli/src/commands/agents.ts b/packages/cli/src/commands/agents.ts index 9a703a9..ad5bc4e 100644 --- a/packages/cli/src/commands/agents.ts +++ b/packages/cli/src/commands/agents.ts @@ -26,6 +26,11 @@ export async function runAgents(context: CliContext): Promise { if (agent.githubLogin) { console.log(chalk.dim(` github ${agent.githubLogin}`)) } + if (agent.linearUserId) { + console.log( + chalk.dim(` linear ${agent.linearUserId} (workspace ${agent.linearWorkspaceId})`) + ) + } if (agent.toolsets.length) { console.log(chalk.dim(` toolsets ${agent.toolsets.join(', ')}`)) } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 0d9707e..647d1c2 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -160,10 +160,28 @@ export async function runInit(context: CliContext): Promise { await p.text({ message: `GitHub login for "${name}" (optional)`, initialValue: remembered?.githubLogin ?? '', - placeholder: 'Needed only for PR-review routes', + placeholder: 'Used for assigned GitHub issues and PR-review routes', }) ).trim() + const linearUserId = assertNotCancel( + await p.text({ + message: `Linear user ID for "${name}" (optional)`, + initialValue: remembered?.linearUserId ?? '', + placeholder: 'Immutable user UUID, not a display name', + }) + ).trim() + const linearWorkspaceId = linearUserId + ? assertNotCancel( + await p.text({ + message: `Linear workspace ID for "${name}"`, + initialValue: remembered?.linearWorkspaceId ?? '', + validate: (value) => + !value?.trim() ? 'The Linear organization ID is required.' : undefined, + }) + ).trim() + : '' + const avatarUrl = assertNotCancel( await p.text({ message: `Avatar image URL for "${name}" (optional)`, @@ -178,6 +196,7 @@ export async function runInit(context: CliContext): Promise { enabled: true, ...(role ? { role } : {}), ...(githubLogin ? { githubLogin } : {}), + ...(linearUserId ? { linearUserId, linearWorkspaceId } : {}), ...(avatarUrl ? { avatarUrl } : {}), } } diff --git a/packages/cli/src/commands/preflight.ts b/packages/cli/src/commands/preflight.ts index c7594d2..659f995 100644 --- a/packages/cli/src/commands/preflight.ts +++ b/packages/cli/src/commands/preflight.ts @@ -36,6 +36,17 @@ export async function runPreflight(context: CliContext): Promise { if (config.hermes) { for (const profile of config.hermes.profiles.filter((entry) => entry.enabled)) { + if (profile.linearUserId || profile.linearWorkspaceId) { + checks.push({ + name: `Linear identity for "${profile.name}"`, + ok: Boolean(profile.linearUserId && profile.linearWorkspaceId), + required: true, + detail: + profile.linearUserId && profile.linearWorkspaceId + ? `${profile.linearUserId} in ${profile.linearWorkspaceId}` + : 'Set both linearUserId and linearWorkspaceId.', + }) + } const prefix = profile.name === 'default' ? '' : `/p/${profile.name}` let detail = '' let ok = false diff --git a/packages/cloud-api/src/migrations/008_linear_agent_identity.sql b/packages/cloud-api/src/migrations/008_linear_agent_identity.sql new file mode 100644 index 0000000..6363baf --- /dev/null +++ b/packages/cloud-api/src/migrations/008_linear_agent_identity.sql @@ -0,0 +1,2 @@ +ALTER TABLE agents ADD COLUMN IF NOT EXISTS linear_user_id TEXT; +ALTER TABLE agents ADD COLUMN IF NOT EXISTS linear_workspace_id TEXT; diff --git a/packages/cloud-api/src/routes/runner.ts b/packages/cloud-api/src/routes/runner.ts index 7748adf..d04613e 100644 --- a/packages/cloud-api/src/routes/runner.ts +++ b/packages/cloud-api/src/routes/runner.ts @@ -180,8 +180,8 @@ export function registerRunnerRoutes(app: FastifyInstance, db: Database): void { await client.query( `INSERT INTO agents (id, org_id, runner_id, profile, display_name, role, model, provider, toolsets, skills, github_login, avatar_url, - enabled, synced_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13, now())`, + enabled, linear_user_id, linear_workspace_id, synced_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15, now())`, [ newId('agt'), orgId, @@ -196,6 +196,8 @@ export function registerRunnerRoutes(app: FastifyInstance, db: Database): void { agent.githubLogin ?? null, agent.avatarUrl ?? null, agent.enabled, + agent.linearUserId ?? null, + agent.linearWorkspaceId ?? null, ] ) } diff --git a/packages/cloud-api/src/routes/user.ts b/packages/cloud-api/src/routes/user.ts index 9dbb1c6..7559e70 100644 --- a/packages/cloud-api/src/routes/user.ts +++ b/packages/cloud-api/src/routes/user.ts @@ -8,6 +8,7 @@ import { PROMPT_VARIABLES, ROUTE_CATALOG, validateRoutingRule, + isRepositorySlug, type RoutingRule, } from '@sentinel0/common' import { isIntegrationProvider, INTEGRATION_PROVIDER } from '@sentinel0/common' @@ -39,6 +40,52 @@ const MAX_PROMPT_LENGTH = 20_000 * UI from the start: list endpoints are paged, mutations return the stored * object, and nothing here requires knowing how the runner works. */ +async function validateAssignmentContext( + db: Database, + orgId: string, + route: RoutingRule +): Promise { + if (!route.execution.requireRepository || route.enabled === false) { + return undefined + } + const { rows: projects } = await db.query<{ provider: string; filters: Record }>( + 'SELECT provider, filters FROM projects WHERE org_id = $1 AND id = $2', + [orgId, route.trigger.projectId] + ) + const project = projects[0] + if (!project || (route.trigger.provider && project.provider !== route.trigger.provider)) { + return 'Choose an existing project with the same provider as the assignment route.' + } + const filters = project.filters ?? {} + const repository = + project.provider === 'github' + ? typeof filters.owner === 'string' && typeof filters.repo === 'string' + ? `${filters.owner}/${filters.repo}` + : undefined + : filters.repository + if (!isRepositorySlug(repository)) { + return 'Configure the project repository before enabling an assignment implementation.' + } + const ref = route.target.agentRef + const { rows: agents } = await db.query( + `SELECT id FROM agents WHERE org_id = $1 AND enabled = true AND + (($2::text IS NOT NULL AND profile = $2) OR + ($3::text IS NOT NULL AND lower(github_login) = lower($3)) OR + ($4::text IS NOT NULL AND linear_user_id = $4 AND linear_workspace_id = $5))`, + [ + orgId, + ref.profile ?? null, + ref.githubLogin ?? null, + ref.linearUserId ?? null, + ref.linearWorkspaceId ?? null, + ] + ) + if (agents.length !== 1) { + return 'The assignment target must identify exactly one enabled agent. Check the runner inventory.' + } + return undefined +} + export function registerUserRoutes(app: FastifyInstance, db: Database): void { app.addHook('onRequest', async (request, reply) => { if (!request.url.startsWith('/v1/') || request.url.startsWith('/v1/runner/')) { @@ -113,7 +160,7 @@ export function registerUserRoutes(app: FastifyInstance, db: Database): void { const { orgId } = authOf(request) const { rows } = await db.query( `SELECT a.id, a.profile, a.display_name, a.role, a.model, a.provider, - a.toolsets, a.skills, a.github_login, a.avatar_url, a.enabled, a.synced_at, + a.toolsets, a.skills, a.github_login, a.linear_user_id, a.linear_workspace_id, a.avatar_url, a.enabled, a.synced_at, a.status, a.current_run_id, a.status_at, r.name AS runner, (r.last_seen_at < now() - interval '90 seconds') AS runner_stale FROM agents a JOIN runners r ON r.id = a.runner_id @@ -144,6 +191,16 @@ export function registerUserRoutes(app: FastifyInstance, db: Database): void { if (body.provider !== 'github' && body.provider !== 'linear') { return reply.code(400).send({ error: 'provider must be "github" or "linear".' }) } + if ( + body.filters !== undefined && + (!body.filters || typeof body.filters !== 'object' || Array.isArray(body.filters)) + ) { + return reply.code(400).send({ error: 'filters must be an object.' }) + } + const filters = (body.filters ?? {}) as Record + if (filters.repository !== undefined && !isRepositorySlug(filters.repository)) { + return reply.code(400).send({ error: 'filters.repository must be a GitHub owner/repo.' }) + } await db.query( `INSERT INTO projects (id, org_id, provider, filters) VALUES ($1,$2,$3,$4) @@ -179,6 +236,10 @@ export function registerUserRoutes(app: FastifyInstance, db: Database): void { if (problem) { return reply.code(400).send({ error: problem }) } + const assignmentProblem = await validateAssignmentContext(db, orgId, route) + if (assignmentProblem) { + return reply.code(400).send({ error: assignmentProblem }) + } const id = route.id || newId('rt') // Stored explicitly rather than left to the runner's default, so what a @@ -239,6 +300,10 @@ export function registerUserRoutes(app: FastifyInstance, db: Database): void { if (problem) { return reply.code(400).send({ error: problem }) } + const assignmentProblem = await validateAssignmentContext(db, orgId, route) + if (assignmentProblem) { + return reply.code(400).send({ error: assignmentProblem }) + } const stored: RoutingRule = { ...route, guard: { ...DEFAULT_ROUTE_GUARD, ...route.guard } } const { rowCount } = await db.query( diff --git a/packages/cloud-api/test/assignment-routes.test.ts b/packages/cloud-api/test/assignment-routes.test.ts new file mode 100644 index 0000000..84301f9 --- /dev/null +++ b/packages/cloud-api/test/assignment-routes.test.ts @@ -0,0 +1,108 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { fillRouteTemplate, findRouteTemplate } from '@sentinel0/common' +import { buildApp } from '../src/app.js' +import type { Database } from '../src/db.js' + +beforeAll(() => { + process.env.LOG_LEVEL = 'silent' +}) + +function database(options: { repository?: string; provider?: string; agents?: number } = {}) { + const query = vi.fn(async (sql: string, params: unknown[] = []) => { + if (sql.includes('FROM api_keys')) { + return { rows: [{ id: 'key', org_id: 'org', scope: 'user', revoked_at: null }], rowCount: 1 } + } + if (sql.includes('FROM projects')) { + expect(params).toEqual(['org', 'eng']) + return { + rows: [ + { provider: options.provider ?? 'linear', filters: { repository: options.repository } }, + ], + rowCount: 1, + } + } + if (sql.includes('FROM agents')) { + expect(params).toEqual(['org', null, null, 'user-id', 'workspace-id']) + return { + rows: Array.from({ length: options.agents ?? 1 }, (_, id) => ({ id })), + rowCount: options.agents ?? 1, + } + } + return { rows: [], rowCount: 1 } + }) + return { db: { query } as unknown as Database, query } +} + +const route = () => + fillRouteTemplate(findRouteTemplate('linear-assigned-implementation')!, { + '': 'eng', + '': 'user-id', + '': 'workspace-id', + }) + +describe('assignment route configuration', () => { + it.each(['POST', 'PUT'] as const)( + 'accepts a complete %s configuration scoped to its organization', + async (method) => { + const { db } = database({ repository: 'acme/platform' }) + const app = await buildApp(db) + try { + const response = await app.inject({ + method, + url: method === 'POST' ? '/v1/routes' : '/v1/routes/rt_1', + headers: { authorization: 'Bearer snt_usr_test' }, + payload: route(), + }) + expect(response.statusCode).toBe(method === 'POST' ? 201 : 200) + expect(response.json().route.target.agentRef).toEqual({ + linearUserId: 'user-id', + linearWorkspaceId: 'workspace-id', + }) + } finally { + await app.close() + } + } + ) + + it.each([ + [{}, 'repository'], + [{ repository: 'acme/platform', agents: 0 }, 'exactly one'], + [{ repository: 'acme/platform', agents: 2 }, 'exactly one'], + [{ repository: 'acme/platform', provider: 'github' }, 'same provider'], + ] as const)('refuses an unusable enabled route: %j', async (options, reason) => { + const { db, query } = database(options) + const app = await buildApp(db) + try { + const response = await app.inject({ + method: 'POST', + url: '/v1/routes', + headers: { authorization: 'Bearer snt_usr_test' }, + payload: route(), + }) + expect(response.statusCode).toBe(400) + expect(response.json().error).toContain(reason) + expect(query.mock.calls.some(([sql]) => sql.includes('INSERT INTO routes'))).toBe(false) + } finally { + await app.close() + } + }) + + it('allows a disabled draft to be saved before its prerequisites are ready', async () => { + const { db } = database() + const app = await buildApp(db) + try { + expect( + ( + await app.inject({ + method: 'POST', + url: '/v1/routes', + headers: { authorization: 'Bearer snt_usr_test' }, + payload: { ...route(), enabled: false }, + }) + ).statusCode + ).toBe(201) + } finally { + await app.close() + } + }) +}) diff --git a/packages/cloud-dashboard/src/api/types.ts b/packages/cloud-dashboard/src/api/types.ts index 30d949c..a58abeb 100644 --- a/packages/cloud-dashboard/src/api/types.ts +++ b/packages/cloud-dashboard/src/api/types.ts @@ -57,6 +57,8 @@ export interface Agent { toolsets: string[] | null skills: string[] | null github_login: string | null + linear_user_id?: string | null + linear_workspace_id?: string | null avatar_url: string | null enabled: boolean synced_at: string | null @@ -178,8 +180,20 @@ export interface RoutingRule { enabled: boolean trigger: { type: string; provider?: string; projectId?: string } match?: Record - target: { agentRef?: { profile?: string; githubLogin?: string } } - execution: { prompt: string; requireApproval?: boolean; timeoutSeconds?: number } + target: { + agentRef?: { + profile?: string + githubLogin?: string + linearUserId?: string + linearWorkspaceId?: string + } + } + execution: { + prompt: string + requireApproval?: boolean + requireRepository?: boolean + timeoutSeconds?: number + } outcome?: Record guard?: { refire?: string; markers?: boolean } } diff --git a/packages/cloud-dashboard/src/components/RouteForm.tsx b/packages/cloud-dashboard/src/components/RouteForm.tsx index fb4e188..42a4191 100644 --- a/packages/cloud-dashboard/src/components/RouteForm.tsx +++ b/packages/cloud-dashboard/src/components/RouteForm.tsx @@ -58,6 +58,7 @@ export function RouteForm({ variables, extraTokens, preserved, + requiresRepository = false, error, saving, onSubmit, @@ -73,6 +74,7 @@ export function RouteForm({ extraTokens: string[] /** The parts of the rule this form does not edit, shown so they are not a surprise. */ preserved: Partial + requiresRepository?: boolean error?: string saving: boolean onSubmit: () => void @@ -87,10 +89,14 @@ export function RouteForm({ [extraTokens] ) - const projectOptions = projects.map((project) => ({ - value: project.id, - label: `${project.id} · ${project.provider}`, - })) + const projectOptions = projects + .filter( + (project) => !preserved.trigger?.provider || project.provider === preserved.trigger.provider + ) + .map((project) => ({ + value: project.id, + label: `${project.id} · ${project.provider}`, + })) const agentOptions = agents.map((agent) => ({ value: agent.profile, label: agent.display_name ? `${agent.profile} · ${agent.display_name}` : agent.profile, @@ -100,7 +106,15 @@ export function RouteForm({ // drift apart the way two free-text fields would. const chooseAgent = (profile: string): void => { const agent = agents.find((entry) => entry.profile === profile) - set({ agentProfile: profile, agentGithubLogin: agent?.github_login ?? '' }) + set({ + agentProfile: profile, + agentGithubLogin: agent?.github_login ?? '', + extras: { + ...draft.extras, + '': agent?.linear_user_id ?? '', + '': agent?.linear_workspace_id ?? '', + }, + }) } const selectedAgent = agents.find((entry) => entry.profile === draft.agentProfile) @@ -108,6 +122,13 @@ export function RouteForm({ return ( <>
+ {requiresRepository ? ( + + Every currently assigned open issue in this project's filters is eligible, including + assignments made while the runner was offline. Check the filters before enabling. This + release starts the initial run; use one runner per project/profile. + + ) : null}
': route.target.agentRef.linearUserId, + '': route.target.agentRef.linearWorkspaceId ?? '', + } + : {}, } } @@ -50,11 +55,12 @@ export function toDraft(route: RoutingRule): RouteDraft { * stops a route re-firing on its own work. * * The target keeps only the key it already had. A rule addresses an agent by - * profile *or* by GitHub login, and the API rejects a `githubLogin` target on a - * ticket trigger, so writing both would turn a valid route invalid. + * profile, GitHub login, or a scoped Linear identity. Editing preserves that + * choice so it cannot silently change which agent receives the work. */ export function applyDraft(base: Partial, draft: RouteDraft): Omit { const byLogin = Boolean(base.target?.agentRef?.githubLogin) + const byLinear = Boolean(base.target?.agentRef?.linearUserId) const priority = Number.parseInt(draft.priority, 10) const timeout = Number.parseInt(draft.timeoutSeconds, 10) @@ -70,7 +76,14 @@ export function applyDraft(base: Partial, draft: RouteDraft): Omit< }, target: { ...base.target, - agentRef: byLogin ? { githubLogin: draft.agentGithubLogin } : { profile: draft.agentProfile }, + agentRef: byLinear + ? { + linearUserId: draft.extras[''], + linearWorkspaceId: draft.extras[''], + } + : byLogin + ? { githubLogin: draft.agentGithubLogin } + : { profile: draft.agentProfile }, }, execution: { ...base.execution, @@ -119,7 +132,7 @@ export function draftProblems(draft: RouteDraft, extraTokens: string[]): string[ if (!draft.agentGithubLogin.trim()) { problems.push('This route matches on a GitHub identity, so it needs one.') } - } else if (!draft.agentProfile) { + } else if (!extraTokens.includes('') && !draft.agentProfile) { problems.push('Pick the agent this route starts.') } for (const token of extraTokens) { diff --git a/packages/cloud-dashboard/src/screens/Agents.tsx b/packages/cloud-dashboard/src/screens/Agents.tsx index 367ac33..b94c8b1 100644 --- a/packages/cloud-dashboard/src/screens/Agents.tsx +++ b/packages/cloud-dashboard/src/screens/Agents.tsx @@ -84,6 +84,7 @@ export function Agents(): ReactNode { Agent Model GitHub + Linear Runner Doing @@ -118,6 +119,12 @@ export function Agents(): ReactNode { secondary={agent.github_login ? 'own account' : 'no PR routing'} /> + + + ([]) const [team, setTeam] = useState('') + const [implementationRepo, setImplementationRepo] = useState('') const [raw, setRaw] = useState('') const [showRaw, setShowRaw] = useState(false) const [saving, setSaving] = useState(false) @@ -81,7 +82,10 @@ export function ProjectNew(): ReactNode { return JSON.parse(raw) as Record } if (provider === 'linear') { - return team ? { team } : {} + return { + ...(team ? { team } : {}), + ...(implementationRepo.trim() ? { repository: implementationRepo.trim() } : {}), + } } const [owner, name] = repo.split('/') return { @@ -193,13 +197,22 @@ export function ProjectNew(): ReactNode { ) ) : ( - setTeam(event.target.value)} - hint="The short key Linear shows on issue identifiers, e.g. ENG." - placeholder="ENG" - /> + <> + setTeam(event.target.value)} + hint="The short key Linear shows on issue identifiers, e.g. ENG." + placeholder="ENG" + /> + setImplementationRepo(event.target.value)} + placeholder="owner/repo" + hint="Required for assigned issue implementation. Hermes uses its own repository credentials." + /> + )}
diff --git a/packages/cloud-dashboard/src/screens/RouteEdit.tsx b/packages/cloud-dashboard/src/screens/RouteEdit.tsx index 43ceb75..7f178c2 100644 --- a/packages/cloud-dashboard/src/screens/RouteEdit.tsx +++ b/packages/cloud-dashboard/src/screens/RouteEdit.tsx @@ -54,10 +54,7 @@ export function RouteEdit(): ReactNode { if (!route.data) { return } - // A route addressed by GitHub login keeps that shape, so the form asks for - // the login rather than a profile. - const byLogin = Boolean(route.data.target?.agentRef?.githubLogin) - const problems = draftProblems(draft, byLogin ? [''] : []) + const problems = draftProblems(draft, identityTokens) if (problems.length > 0) { setError(problems.join(' ')) return @@ -81,6 +78,11 @@ export function RouteEdit(): ReactNode { const loading = route.loading || projects.loading || agents.loading || prompts.loading const failure = route.error ?? projects.error ?? agents.error ?? prompts.error + const identityTokens = route.data?.target?.agentRef?.linearUserId + ? ['', ''] + : route.data?.target?.agentRef?.githubLogin + ? [''] + : [] return ( <> @@ -99,7 +101,8 @@ export function RouteEdit(): ReactNode { projects={projects.data ?? []} agents={agents.data ?? []} variables={prompts.data?.variables ?? []} - extraTokens={route.data.target?.agentRef?.githubLogin ? [''] : []} + extraTokens={identityTokens} + requiresRepository={route.data.execution?.requireRepository} preserved={preservedParts(route.data)} error={error} saving={saving} diff --git a/packages/cloud-dashboard/src/screens/RouteNew.tsx b/packages/cloud-dashboard/src/screens/RouteNew.tsx index 9372063..0b68d4a 100644 --- a/packages/cloud-dashboard/src/screens/RouteNew.tsx +++ b/packages/cloud-dashboard/src/screens/RouteNew.tsx @@ -145,6 +145,7 @@ export function RouteNew(): ReactNode { agents={agents.data ?? []} variables={prompts.data?.variables ?? []} extraTokens={extraTokens} + requiresRepository={template.route.execution?.requireRepository} preserved={preservedParts( fillRouteTemplate(template, placeholderValues(draft)) as never )} diff --git a/packages/cloud-dashboard/test/routeDraft.test.ts b/packages/cloud-dashboard/test/routeDraft.test.ts index b33c2c6..df28f5b 100644 --- a/packages/cloud-dashboard/test/routeDraft.test.ts +++ b/packages/cloud-dashboard/test/routeDraft.test.ts @@ -45,6 +45,19 @@ describe('toDraft', () => { }) describe('applyDraft', () => { + it('preserves and edits a workspace-scoped Linear identity without replacing it with a profile', () => { + const base = { + ...stored, + target: { agentRef: { linearUserId: 'u1', linearWorkspaceId: 'w1' } }, + } + const draft = toDraft(base) + const saved = applyDraft(base, { + ...draft, + extras: { '': 'u2', '': 'w2' }, + }) + expect(saved.target.agentRef).toEqual({ linearUserId: 'u2', linearWorkspaceId: 'w2' }) + expect(draft.extras).toEqual({ '': 'u1', '': 'w1' }) + }) /** * The reason this function exists. The form edits seven fields; a route also * carries the match clauses and the guard that stops it re-firing on the diff --git a/packages/cloud-dashboard/test/screens.test.tsx b/packages/cloud-dashboard/test/screens.test.tsx index a31f163..e095d3b 100644 --- a/packages/cloud-dashboard/test/screens.test.tsx +++ b/packages/cloud-dashboard/test/screens.test.tsx @@ -78,6 +78,69 @@ async function renderAt(path: string) { return render() } +describe('assigned issue setup', () => { + it('saves a scoped Linear route without converting it to a profile target', async () => { + const route = { + ...routesFixture.routes[0], + trigger: { type: 'ticket', provider: 'linear', projectId: 'ENG' }, + target: { agentRef: { linearUserId: 'user-bot', linearWorkspaceId: 'workspace' } }, + execution: { ...routesFixture.routes[0].execution, requireRepository: true }, + } + const updates: unknown[] = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const path = new URL(String(input)).pathname + if (path === `/v1/routes/${route.id}`) { + if (init?.method === 'PUT') { + updates.push(JSON.parse(String(init.body))) + } + return Response.json({ route }) + } + return Response.json(ROUTES[path] ?? {}) + }) + ) + await renderAt(`/routes/${route.id}/edit`) + expect(await screen.findByText('Assignment intake')).toBeTruthy() + expect((screen.getByLabelText('Linear user id') as HTMLInputElement).value).toBe('user-bot') + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /save changes/i })) + await waitFor(() => expect(updates).toHaveLength(1)) + expect(updates[0]).toMatchObject({ + target: route.target, + execution: { requireRepository: true }, + }) + }) + + it('includes the implementation repository when creating a Linear project', async () => { + const posts: unknown[] = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const path = new URL(String(input)).pathname + if (path === '/v1/projects' && init?.method === 'POST') { + const project = JSON.parse(String(init.body)) + posts.push(project) + return Response.json({ project }) + } + return Response.json(ROUTES[path] ?? {}) + }) + ) + await renderAt('/projects/new') + const user = userEvent.setup() + await user.click(await screen.findByLabelText('Provider')) + await user.click(await screen.findByRole('option', { name: 'Linear' })) + await user.type(screen.getByLabelText(/team/i), 'ENG') + await user.type(screen.getByLabelText(/implementation repository/i), 'acme/api') + await user.click(screen.getByRole('button', { name: /save project/i })) + await waitFor(() => expect(posts).toHaveLength(1)) + expect(posts[0]).toMatchObject({ + provider: 'linear', + filters: { team: 'ENG', repository: 'acme/api' }, + }) + }) +}) + describe('signed-in shell', () => { it('restores the stored key and shows the organization', async () => { await renderAt('/') diff --git a/packages/common/src/github.ts b/packages/common/src/github.ts index cf75812..1b1a849 100644 --- a/packages/common/src/github.ts +++ b/packages/common/src/github.ts @@ -216,24 +216,45 @@ export class GitHubRestClient { repo: string, filters: { state?: string; labels?: string[] } = {} ): Promise { - const issues = - (await this.request('GET', `/repos/${owner}/${repo}/issues`, { - query: { - state: filters.state ?? 'open', - per_page: PAGE_SIZE, - labels: filters.labels?.length ? filters.labels.join(',') : undefined, - }, - })) ?? [] + const issues = await this.pages(`/repos/${owner}/${repo}/issues`, { + state: filters.state ?? 'open', + labels: filters.labels?.length ? filters.labels.join(',') : undefined, + }) return issues.filter((issue) => issue.pull_request === undefined) } + private async pages( + path: string, + query: RequestOptions['query'] + ): Promise { + const items = new Map() + for (let page = 1; ; page++) { + const batch = + (await this.request('GET', path, { + query: { + ...query, + per_page: PAGE_SIZE, + page, + sort: 'created', + direction: 'asc', + }, + })) ?? [] + const previousSize = items.size + for (const item of batch) { + items.set(item.number, item) + } + if (batch.length < PAGE_SIZE) { + return [...items.values()] + } + if (items.size === previousSize) { + throw new Error(`GitHub pagination did not advance for ${path}.`) + } + } + } + async pullRequests(owner: string, repo: string): Promise { - return ( - (await this.request('GET', `/repos/${owner}/${repo}/pulls`, { - query: { state: 'open', per_page: PAGE_SIZE }, - })) ?? [] - ) + return this.pages(`/repos/${owner}/${repo}/pulls`, { state: 'open' }) } async createComment(owner: string, repo: string, number: number, body: string): Promise { diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index cab7c38..5b75456 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -125,6 +125,8 @@ export interface AgentDescriptor { toolsets: string[] skills: string[] githubLogin?: string + linearUserId?: string + linearWorkspaceId?: string avatarUrl?: string enabled: boolean discoveredAt: number @@ -170,6 +172,9 @@ export interface TriggerEvent { labels: string[] state?: string assignees?: string[] + linearWorkspaceId?: string + repository?: string + isClosed?: boolean prNumber?: number requestedReviewers?: string[] isDraft?: boolean @@ -248,6 +253,8 @@ export interface RouteTarget { agentRef: { profile?: string githubLogin?: string + linearUserId?: string + linearWorkspaceId?: string } } @@ -263,6 +270,8 @@ export interface RouteExecution { */ prompt: string requireApproval: boolean + /** Assignment implementations must have an explicit repository. */ + requireRepository?: boolean modelOverride?: string | null timeoutSeconds: number /** @@ -454,6 +463,8 @@ export interface HermesProfileConfig { name: string apiKey: string githubLogin?: string + linearUserId?: string + linearWorkspaceId?: string role?: string /** Shown beside this agent's Slack notifications. */ avatarUrl?: string @@ -481,6 +492,8 @@ export interface ProjectConfig { project?: string owner?: string repo?: string + /** GitHub owner/repo used when implementing a Linear issue. */ + repository?: string } } @@ -508,6 +521,10 @@ export interface AppConfig { } export const CONFIG_VERSION = 2 + +export function isRepositorySlug(value: unknown): value is string { + return typeof value === 'string' && /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(value) +} export const DEFAULT_API_PORT = 9371 export const DEFAULT_CONCURRENCY = 2 diff --git a/packages/common/src/route-catalog.ts b/packages/common/src/route-catalog.ts index 4e793c8..071825b 100644 --- a/packages/common/src/route-catalog.ts +++ b/packages/common/src/route-catalog.ts @@ -50,6 +50,8 @@ const GITHUB_LOGIN: RouteTemplatePlaceholder = { } export const ROUTE_CATALOG: RouteTemplate[] = [ + assignmentTemplate('linear'), + assignmentTemplate('github'), // ── Tickets ─────────────────────────────────────────────── { id: 'ticket-label-analysis', @@ -365,6 +367,61 @@ export const ROUTE_CATALOG: RouteTemplate[] = [ }, ] +function assignmentTemplate(provider: 'linear' | 'github'): RouteTemplate { + return { + id: `${provider}-assigned-implementation`, + name: `Implement an assigned ${provider === 'linear' ? 'Linear' : 'GitHub'} issue`, + summary: 'Assign an issue to the agent; it implements it and reports back.', + description: + 'Starts once while the issue is assigned, including assignments made while the runner was offline. Requires repository context. This starts an initial run; automatic PR feedback continuation is not yet supported. Use one runner per project/profile.', + placeholders: + provider === 'github' + ? [PROJECT, GITHUB_LOGIN] + : [ + PROJECT, + { + token: '', + label: 'Linear user ID', + hint: 'The immutable user ID declared on the Hermes profile.', + }, + { + token: '', + label: 'Linear workspace ID', + hint: 'The organization ID returned by Linear.', + }, + ], + route: { + name: `Implement assigned ${provider} issue`, + priority: 100, + enabled: true, + guard: { refire: 'once', markers: true }, + trigger: { type: 'ticket', provider, projectId: '' }, + match: {}, + target: { + agentRef: + provider === 'github' + ? { githubLogin: '' } + : { linearUserId: '', linearWorkspaceId: '' }, + }, + execution: { + requireApproval: false, + requireRepository: true, + timeoutSeconds: 1800, + prompt: [ + 'Implement the issue assigned to you, following your profile instructions.', + 'Issue: {{ticket.ref}} — {{ticket.title}}', + 'Source: {{ticket.url}}', + 'Repository: {{repo.slug}}', + '{{ticket.body}}', + 'Use your own repository tools and credentials. Validate your changes and open a PR linking the source issue.', + 'Report the PR URL and validation result in your final summary. Do not claim unrun or pending checks passed.', + ].join('\n'), + }, + outcome: { postComment: { target: 'ticket' } }, + }, + } +} + export function findRouteTemplate(id: string): RouteTemplate | undefined { return ROUTE_CATALOG.find((template) => template.id === id) } diff --git a/packages/common/src/route-validation.ts b/packages/common/src/route-validation.ts index 9eb6667..8a90c55 100644 --- a/packages/common/src/route-validation.ts +++ b/packages/common/src/route-validation.ts @@ -41,8 +41,31 @@ export function validateRoutingRule(route: RoutingRule): string | undefined { if (!route.target?.agentRef) { return 'target.agentRef is required.' } - if (!route.target.agentRef.profile && !route.target.agentRef.githubLogin) { - return 'target.agentRef needs either a profile or a githubLogin.' + if ( + !route.target.agentRef.profile && + !route.target.agentRef.githubLogin && + !route.target.agentRef.linearUserId + ) { + return 'target.agentRef needs a profile, githubLogin or linearUserId.' + } + const ref = route.target.agentRef + for (const field of ['profile', 'githubLogin', 'linearUserId', 'linearWorkspaceId'] as const) { + if (ref[field] !== undefined && (typeof ref[field] !== 'string' || !ref[field]?.trim())) { + return `target.agentRef.${field} must be a non-empty string.` + } + } + if (ref.linearUserId || ref.linearWorkspaceId) { + if ( + !ref.linearUserId?.trim() || + !ref.linearWorkspaceId?.trim() || + ref.profile || + ref.githubLogin + ) { + return 'A Linear identity needs linearUserId and linearWorkspaceId, without another target.' + } + if (route.trigger.type !== TRIGGER_TYPE.TICKET || route.trigger.provider !== 'linear') { + return 'A Linear identity requires a ticket trigger with provider linear.' + } } if (!route.execution?.prompt?.trim()) { @@ -81,10 +104,19 @@ export function validateRoutingRule(route: RoutingRule): string | undefined { // us who was asked -- otherwise the route would silently never fire. if (route.target.agentRef.githubLogin) { const supported: TriggerType[] = [TRIGGER_TYPE.PR_REVIEW_REQUESTED, TRIGGER_TYPE.PR_EVENT] + if (route.trigger.provider === 'github') { + supported.push(TRIGGER_TYPE.TICKET) + } if (!supported.includes(route.trigger.type)) { return `target.agentRef.githubLogin only applies to ${supported.join(' or ')} triggers.` } } + if ( + route.execution.requireRepository !== undefined && + typeof route.execution.requireRepository !== 'boolean' + ) { + return 'execution.requireRepository must be a boolean.' + } const prOnly = route.match?.isDraft !== undefined || route.match?.baseBranch !== undefined if (prOnly && route.trigger.type === TRIGGER_TYPE.TICKET) { diff --git a/packages/common/test/github.test.ts b/packages/common/test/github.test.ts index a4eb784..b3fcfca 100644 --- a/packages/common/test/github.test.ts +++ b/packages/common/test/github.test.ts @@ -49,6 +49,23 @@ describe('authentication', () => { }) describe('issues', () => { + it('paginates before removing PR rows so an issue beyond a full PR page is collected', async () => { + const { api, calls } = client((url) => ({ + body: + url.searchParams.get('page') === '1' + ? Array.from({ length: 100 }, (_, n) => ({ number: n + 1, pull_request: { url: 'pr' } })) + : [{ number: 101, title: 'Assigned issue', assignees: [{ login: 'bot' }] }], + })) + expect((await api.issues('acme', 'platform')).map((item) => item.number)).toEqual([101]) + expect(calls.map((call) => call.url.searchParams.get('page'))).toEqual(['1', '2']) + }) + + it('fails rather than looping on a repeated full page', async () => { + const { api } = client(() => ({ + body: Array.from({ length: 100 }, (_, n) => ({ number: n + 1 })), + })) + await expect(api.issues('acme', 'platform')).rejects.toThrow('pagination did not advance') + }) /* * The single most important line in this file. * diff --git a/packages/orchestrator/src/config-validation.ts b/packages/orchestrator/src/config-validation.ts index 33c7d45..7fb8568 100644 --- a/packages/orchestrator/src/config-validation.ts +++ b/packages/orchestrator/src/config-validation.ts @@ -1,5 +1,6 @@ import { TICKET_PROVIDER, + isRepositorySlug, type CloudConfig, type HermesConfig, type HermesProfileConfig, @@ -55,10 +56,24 @@ export function validateHermesProfile(raw: unknown, index: number): HermesProfil throw new Error(`hermes.profiles[${index}].enabled ("${name}") must be a boolean.`) } + const linearUserId = assertOptionalString( + raw.linearUserId, + `hermes.profiles[${index}].linearUserId` + ) + const linearWorkspaceId = assertOptionalString( + raw.linearWorkspaceId, + `hermes.profiles[${index}].linearWorkspaceId` + ) + if (Boolean(linearUserId) !== Boolean(linearWorkspaceId)) { + throw new Error(`Profile "${name}" needs both linearUserId and linearWorkspaceId.`) + } + return { name, apiKey, githubLogin: assertOptionalString(raw.githubLogin, `hermes.profiles[${index}].githubLogin`), + linearUserId, + linearWorkspaceId, role: assertOptionalString(raw.role, `hermes.profiles[${index}].role`), avatarUrl: assertOptionalString(raw.avatarUrl, `hermes.profiles[${index}].avatarUrl`), enabled: raw.enabled !== false, @@ -111,6 +126,9 @@ export function validateProject(raw: unknown): ProjectConfig { const filtersRaw = raw.filters assertObject(filtersRaw, `project.filters for "${id}"`) const filters = filtersRaw as ProjectConfig['filters'] + if (filters.repository !== undefined && !isRepositorySlug(filters.repository)) { + throw new Error(`project.filters.repository for "${id}" must be a GitHub owner/repo.`) + } if (provider === TICKET_PROVIDER.GITHUB) { assertNonEmptyString(filters.owner, `project.filters.owner for "${id}"`) diff --git a/packages/orchestrator/src/github/service.ts b/packages/orchestrator/src/github/service.ts index 60befd2..ccd05eb 100644 --- a/packages/orchestrator/src/github/service.ts +++ b/packages/orchestrator/src/github/service.ts @@ -71,6 +71,8 @@ export class GitHubService implements TriggerSource, TrackerWriter { state: issue.state, labels: issue.labels?.map((label) => label.name) ?? [], assignees: logins(issue.assignees), + repository: `${owner}/${repo}`, + isClosed: issue.state === 'closed', })) } @@ -101,6 +103,7 @@ export class GitHubService implements TriggerSource, TrackerWriter { state: pull.state, labels: pull.labels?.map((label) => label.name) ?? [], assignees: logins(pull.assignees), + repository: `${owner}/${repo}`, prNumber: pull.number, requestedReviewers: reviewers, isDraft: pull.draft, diff --git a/packages/orchestrator/src/hermes/discovery.ts b/packages/orchestrator/src/hermes/discovery.ts index 72a4bb0..fb893de 100644 --- a/packages/orchestrator/src/hermes/discovery.ts +++ b/packages/orchestrator/src/hermes/discovery.ts @@ -41,6 +41,8 @@ export async function discoverProfile( toolsets: toolsets.filter((entry) => entry.enabled !== false).map((entry) => entry.name), skills: skills.map((entry) => entry.name), githubLogin: profile.githubLogin, + linearUserId: profile.linearUserId, + linearWorkspaceId: profile.linearWorkspaceId, avatarUrl: profile.avatarUrl, enabled: profile.enabled, discoveredAt: now, diff --git a/packages/orchestrator/src/linear/service.ts b/packages/orchestrator/src/linear/service.ts index 66358eb..8193625 100644 --- a/packages/orchestrator/src/linear/service.ts +++ b/packages/orchestrator/src/linear/service.ts @@ -20,13 +20,16 @@ interface IssueNode { url?: string createdAt?: string updatedAt?: string - state?: { name?: string } + state?: { name?: string; type?: string } + assignee?: { id: string } | null labels?: { nodes: Array<{ name: string }> } } const ISSUES_QUERY = ` - query Issues($filter: IssueFilter) { - issues(filter: $filter, first: 100) { + query Issues($filter: IssueFilter, $after: String) { + organization { id } + issues(filter: $filter, first: 100, after: $after) { + pageInfo { hasNextPage endCursor } nodes { id identifier @@ -35,7 +38,8 @@ const ISSUES_QUERY = ` url createdAt updatedAt - state { name } + state { name type } + assignee { id } labels { nodes { name } } } } @@ -102,9 +106,37 @@ export class LinearService implements TriggerSource, TrackerWriter { filter.project = { name: { eq: projectName } } } - const data = await this.request<{ issues: { nodes: IssueNode[] } }>(ISSUES_QUERY, { filter }) + const nodes = new Map() + const cursors = new Set() + let after: string | undefined + let workspaceId: string | undefined + do { + const data = await this.request<{ + organization: { id: string } + issues: { nodes: IssueNode[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } } + }>(ISSUES_QUERY, { filter, after }) + if (!data.organization?.id || (workspaceId && workspaceId !== data.organization.id)) { + throw new Error('Linear workspace changed or was missing during issue collection.') + } + workspaceId = data.organization.id + for (const issue of data.issues.nodes) { + nodes.set(issue.id, issue) + } + const page = data.issues.pageInfo + if (!page) { + throw new Error('Linear issue response is missing pageInfo.') + } + if (!page.hasNextPage) { + break + } + if (!page.endCursor || cursors.has(page.endCursor)) { + throw new Error('Linear issue pagination did not advance.') + } + cursors.add(page.endCursor) + after = page.endCursor + } while (after) - return data.issues.nodes.map((issue) => ({ + return [...nodes.values()].map((issue) => ({ type: TRIGGER_TYPE.TICKET, projectId: project.id, provider: TICKET_PROVIDER.LINEAR, @@ -118,6 +150,10 @@ export class LinearService implements TriggerSource, TrackerWriter { url: issue.url, state: issue.state?.name, labels: issue.labels?.nodes.map((label) => label.name) ?? [], + assignees: issue.assignee ? [issue.assignee.id] : [], + linearWorkspaceId: workspaceId, + repository: project.filters.repository, + isClosed: issue.state?.type === 'completed' || issue.state?.type === 'canceled', })) } diff --git a/packages/orchestrator/src/prompts/render.ts b/packages/orchestrator/src/prompts/render.ts index 9114cec..6872737 100644 --- a/packages/orchestrator/src/prompts/render.ts +++ b/packages/orchestrator/src/prompts/render.ts @@ -31,7 +31,7 @@ function resolveVariable(name: string, context: PromptContext): string | undefin return event.projectId case 'repo.slug': // GitHub refs are `owner/repo#number`; anything else has no repo to name. - return event.ref.match(/^([^/]+\/[^#]+)#\d+$/)?.[1] ?? '' + return event.repository ?? event.ref.match(/^([^/]+\/[^#]+)#\d+$/)?.[1] ?? '' case 'agent.profile': return context.agentProfile case 'agent.role': diff --git a/packages/orchestrator/src/routing/rule-engine.ts b/packages/orchestrator/src/routing/rule-engine.ts index 09b5cda..9ac14d6 100644 --- a/packages/orchestrator/src/routing/rule-engine.ts +++ b/packages/orchestrator/src/routing/rule-engine.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto' import { DEFAULT_ROUTE_GUARD, SENTINEL0_LABEL, + isRepositorySlug, type AgentDescriptor, type RouteGuard, type RouteTarget, @@ -23,8 +24,8 @@ export function resolveAgent( agents: readonly AgentDescriptor[], target: RouteTarget ): AgentDescriptor | undefined { - const { profile, githubLogin } = target.agentRef - return agents.find((agent) => { + const { profile, githubLogin, linearUserId, linearWorkspaceId } = target.agentRef + const candidates = agents.filter((agent) => { if (!agent.enabled) { return false } @@ -34,8 +35,12 @@ export function resolveAgent( if (githubLogin) { return agent.githubLogin?.toLowerCase() === githubLogin.toLowerCase() } + if (linearUserId) { + return agent.linearUserId === linearUserId && agent.linearWorkspaceId === linearWorkspaceId + } return false }) + return candidates.length === 1 ? candidates[0] : undefined } /** @@ -52,7 +57,24 @@ export function explainTarget( return undefined } - const { profile, githubLogin } = target.agentRef + const { profile, githubLogin, linearUserId, linearWorkspaceId } = target.agentRef + const matches = agents.filter( + (agent) => + agent.enabled && + (profile + ? agent.profile === profile + : githubLogin + ? agent.githubLogin?.toLowerCase() === githubLogin.toLowerCase() + : linearUserId && + agent.linearUserId === linearUserId && + agent.linearWorkspaceId === linearWorkspaceId) + ) + if (matches.length > 1) { + return 'multiple enabled agents have this identity; configure one owner' + } + if (linearUserId) { + return `no enabled agent has Linear user "${linearUserId}" in workspace "${linearWorkspaceId}"` + } if (!profile && !githubLogin) { return 'target.agentRef names neither a profile nor a githubLogin' } @@ -146,6 +168,23 @@ export function explainRule(rule: RoutingRule, event: TriggerEvent): string | un if (!rule.enabled) { return 'the route is disabled' } + if (rule.execution.requireRepository) { + if (!isRepositorySlug(event.repository)) { + return 'an assignment implementation needs a configured repository' + } + if (event.isClosed || event.state === 'closed') { + return 'the assigned issue is closed' + } + } + const { linearUserId, linearWorkspaceId } = rule.target.agentRef + if ( + linearUserId && + (event.provider !== 'linear' || + event.linearWorkspaceId !== linearWorkspaceId || + !event.assignees?.includes(linearUserId)) + ) { + return 'the Linear workspace or assigned user does not match the target agent' + } // Never start a second agent on something a run is already working on. This // is unconditional: an agent acting on an item changes it, and without this diff --git a/packages/orchestrator/test/config-loader.test.ts b/packages/orchestrator/test/config-loader.test.ts index a3e3a77..b129273 100644 --- a/packages/orchestrator/test/config-loader.test.ts +++ b/packages/orchestrator/test/config-loader.test.ts @@ -92,6 +92,33 @@ describe('readConfigStore', () => { }) describe('loadConfig', () => { + it('round-trips the scoped Linear bot identity and repository', async () => { + await writeRaw( + validConfig({ + hermes: { + baseUrl: 'http://localhost:8642', + profiles: [{ name: 'coder', apiKey: 'k', linearUserId: 'u', linearWorkspaceId: 'w' }], + }, + projects: [ + { id: 'eng', provider: 'linear', filters: { team: 'ENG', repository: 'acme/platform' } }, + ], + }) + ) + const config = await loadConfig() + expect(config.hermes?.profiles[0]).toMatchObject({ linearUserId: 'u', linearWorkspaceId: 'w' }) + expect(config.projects[0].filters.repository).toBe('acme/platform') + }) + it('rejects an unscoped Linear identity', async () => { + await writeRaw( + validConfig({ + hermes: { + baseUrl: 'http://localhost:8642', + profiles: [{ name: 'coder', apiKey: 'k', linearUserId: 'u' }], + }, + }) + ) + await expect(loadConfig()).rejects.toThrow('both linearUserId and linearWorkspaceId') + }) it('loads projects and hermes profiles', async () => { await writeRaw(validConfig()) const config = await loadConfig() @@ -103,6 +130,21 @@ describe('loadConfig', () => { expect(config.cloud).toBeNull() }) + it('keeps profile-targeted legacy configurations valid when profiles share a GitHub account', async () => { + await writeRaw( + validConfig({ + hermes: { + baseUrl: 'http://h', + profiles: [ + { name: 'product', apiKey: 'p', githubLogin: 'bot' }, + { name: 'reviewer', apiKey: 'r', githubLogin: 'bot' }, + ], + }, + }) + ) + expect((await loadConfig()).hermes?.profiles).toHaveLength(2) + }) + it('normalizes a trailing slash off the hermes base url', async () => { await writeRaw( validConfig({ diff --git a/packages/orchestrator/test/hermes/discovery.test.ts b/packages/orchestrator/test/hermes/discovery.test.ts index d72a9ae..4098dbd 100644 --- a/packages/orchestrator/test/hermes/discovery.test.ts +++ b/packages/orchestrator/test/hermes/discovery.test.ts @@ -25,7 +25,15 @@ describe('discoverAgents', () => { const { agents, failures } = await discoverAgents( config(server.baseUrl, { profiles: [ - { name: 'default', apiKey: 'k', enabled: true, role: 'product', githubLogin: 'bot' }, + { + name: 'default', + apiKey: 'k', + enabled: true, + role: 'product', + githubLogin: 'bot', + linearUserId: 'u', + linearWorkspaceId: 'w', + }, ], }), 123 @@ -42,6 +50,8 @@ describe('discoverAgents', () => { toolsets: ['core'], skills: ['github-pr-workflow'], githubLogin: 'bot', + linearUserId: 'u', + linearWorkspaceId: 'w', enabled: true, discoveredAt: 123, }, diff --git a/packages/orchestrator/test/linear/service.test.ts b/packages/orchestrator/test/linear/service.test.ts new file mode 100644 index 0000000..798777f --- /dev/null +++ b/packages/orchestrator/test/linear/service.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProjectConfig } from '@sentinel0/common' +import { LinearService } from '../../src/linear/service.js' + +const project: ProjectConfig = { + id: 'eng', + provider: 'linear', + filters: { team: 'ENG', repository: 'acme/platform' }, +} +const issue = (id: string, assignee: string | null = 'bot-id') => ({ + id, + identifier: `ENG-${id}`, + title: 'Implement export', + description: 'CSV', + updatedAt: 'r1', + state: { name: 'Todo', type: 'unstarted' }, + assignee: assignee ? { id: assignee } : null, + labels: { nodes: [] }, +}) +const page = (nodes: unknown[], next: string | null = null) => ({ + data: { + organization: { id: 'workspace-id' }, + issues: { nodes, pageInfo: { hasNextPage: Boolean(next), endCursor: next } }, + }, +}) + +afterEach(() => vi.unstubAllGlobals()) + +describe('Linear assignment collection', () => { + it('collects every page and preserves immutable identity and repository context', async () => { + const requests: Array<{ query: string; variables: { after?: string; filter: unknown } }> = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (_url, init) => { + const body = JSON.parse(init.body) + requests.push(body) + return Response.json( + body.variables.after + ? page([issue('101', null)]) + : page( + Array.from({ length: 100 }, (_, n) => issue(String(n))), + 'cursor-1' + ) + ) + }) + ) + const events = await new LinearService(async () => 'key').collect(project) + expect(events).toHaveLength(101) + expect(events[0]).toMatchObject({ + assignees: ['bot-id'], + linearWorkspaceId: 'workspace-id', + repository: 'acme/platform', + isClosed: false, + }) + expect(events.at(-1)?.assignees).toEqual([]) + expect(requests[0].query).toContain('assignee { id }') + expect(requests[1].variables).toMatchObject({ + after: 'cursor-1', + filter: { team: { key: { eq: 'ENG' } } }, + }) + }) + + it('marks terminal Linear states regardless of their display names', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json( + page([ + { ...issue('1'), state: { name: 'Shipped', type: 'completed' } }, + { ...issue('2'), state: { name: 'Wont fix', type: 'canceled' } }, + ]) + ) + ) + ) + expect( + (await new LinearService(async () => 'key').collect(project)).map((event) => event.isClosed) + ).toEqual([true, true]) + }) + + it('rejects a repeated cursor instead of returning an incomplete collection', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json(page([issue('1')], 'stuck'))) + ) + await expect(new LinearService(async () => 'key').collect(project)).rejects.toThrow( + 'pagination did not advance' + ) + }) + + it('fails the cycle when a later page fails', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(Response.json(page([issue('1')], 'next'))) + .mockResolvedValueOnce(Response.json({ errors: [{ message: 'rate limited' }] })) + ) + await expect(new LinearService(async () => 'key').collect(project)).rejects.toThrow( + 'rate limited' + ) + }) + + it('refuses to combine pages from different workspaces after credential rotation', async () => { + const next = page([issue('2')]) + next.data.organization.id = 'other-workspace' + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(Response.json(page([issue('1')], 'next'))) + .mockResolvedValueOnce(Response.json(next)) + ) + await expect(new LinearService(async () => 'key').collect(project)).rejects.toThrow( + 'workspace changed' + ) + }) +}) diff --git a/packages/orchestrator/test/routing/assignment-pipeline.test.ts b/packages/orchestrator/test/routing/assignment-pipeline.test.ts new file mode 100644 index 0000000..62c8b44 --- /dev/null +++ b/packages/orchestrator/test/routing/assignment-pipeline.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + fillRouteTemplate, + findRouteTemplate, + type Logger, + type ProjectConfig, +} from '@sentinel0/common' +import type { GitHubRestClient } from '@sentinel0/common/github' +import { LinearService } from '../../src/linear/service.js' +import { GitHubService } from '../../src/github/service.js' +import { openDatabase } from '../../src/database.js' +import { HermesClient } from '../../src/hermes/client.js' +import { HermesAdapter } from '../../src/hermes/adapter.js' +import { Dispatcher } from '../../src/routing/dispatcher.js' +import { RunLifecycle } from '../../src/routing/run-lifecycle.js' +import { observeCycle } from '../../src/triggers/history.js' +import { startFakeHermes } from '../hermes/fake-hermes-server.js' + +afterEach(() => vi.unstubAllGlobals()) + +describe('assignment through collection, history, dispatch and reporting', () => { + it.each(['linear', 'github'] as const)( + '%s starts once on first observation, uses the right repo and reports to the issue', + async (provider) => { + const hermes = await startFakeHermes({ + statuses: ['completed'], + output: 'SENTINEL0_SUMMARY: PR https://github.com/acme/platform/pull/7; tests passed.', + }) + const db = openDatabase('memory') + const logger: Logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + event: vi.fn(), + } + const postComment = vi.fn(async () => undefined) + const project: ProjectConfig = { + id: 'eng', + provider, + filters: { team: 'ENG', repository: 'acme/platform', owner: 'acme', repo: 'platform' }, + } + const originalFetch = globalThis.fetch + vi.stubGlobal( + 'fetch', + vi.fn(async (input, init) => { + if (String(input) !== 'https://linear.test/graphql') { + return originalFetch(input, init) + } + return Response.json({ + data: { + organization: { id: 'w' }, + issues: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { + id: 'issue-id', + identifier: 'ENG-1', + title: 'Export', + description: 'CSV', + assignee: { id: 'u' }, + state: { name: 'Todo', type: 'unstarted' }, + updatedAt: 'r', + }, + ], + }, + }, + }) + }) + ) + const source = + provider === 'linear' + ? new LinearService(async () => 'key', 'https://linear.test/graphql') + : new GitHubService({ + issues: async () => [ + { + number: 1, + title: 'Export', + body: 'CSV', + assignees: [{ login: 'bot' }], + labels: [], + state: 'open', + updated_at: 'r', + }, + ], + pullRequests: async () => [], + } as unknown as GitHubRestClient) + const route = { + ...fillRouteTemplate(findRouteTemplate(`${provider}-assigned-implementation`)!, { + '': 'eng', + '': 'bot', + '': 'u', + '': 'w', + }), + id: 'assignment', + } + const dispatcher = new Dispatcher({ + db, + logger, + lifecycle: new RunLifecycle(db, logger), + outcomes: { postComment, updateLabels: async () => undefined }, + agents: [ + { + profile: 'coder', + enabled: true, + githubLogin: 'bot', + linearUserId: 'u', + linearWorkspaceId: 'w', + toolsets: [], + skills: [], + discoveredAt: 0, + }, + ], + adapters: new Map([ + [ + 'coder', + new HermesAdapter( + new HermesClient({ baseUrl: hermes.baseUrl, profile: 'coder', apiKey: 'k' }), + logger, + { pollIntervalMs: 5 } + ), + ], + ]), + newRunId: () => 'run_assignment', + }) + try { + const [event] = observeCycle(db, project.id, await source.collect(project)) + expect(event.changes).toBeUndefined() + expect(await dispatcher.dispatch(event, [route])).toMatchObject({ status: 'completed' }) + expect( + await dispatcher.dispatch({ ...event, revision: 'agent-changed-the-item' }, [route]) + ).toMatchObject({ reason: 'duplicate' }) + expect(hermes.createdBodies).toHaveLength(1) + expect(hermes.createdBodies[0]).toMatchObject({ + input: expect.stringContaining('Repository: acme/platform'), + }) + expect(postComment).toHaveBeenCalledWith( + 'ticket', + expect.objectContaining({ + provider, + ref: provider === 'linear' ? 'ENG-1' : 'acme/platform#1', + }), + expect.stringContaining('/pull/7') + ) + } finally { + await hermes.close() + db.close() + } + } + ) +}) diff --git a/packages/orchestrator/test/routing/route-catalog.test.ts b/packages/orchestrator/test/routing/route-catalog.test.ts index a095a30..ce6e9a3 100644 --- a/packages/orchestrator/test/routing/route-catalog.test.ts +++ b/packages/orchestrator/test/routing/route-catalog.test.ts @@ -11,7 +11,13 @@ import { type TriggerEvent, } from '@sentinel0/common' import { renderPromptText } from '../../src/prompts/render.js' -import { dedupeKey, guardOf, matchesRule } from '../../src/routing/rule-engine.js' +import { + dedupeKey, + guardOf, + matchesRule, + resolveAgent, + explainTarget, +} from '../../src/routing/rule-engine.js' /** The values a user would type into the dashboard. */ const ANSWERS: Record = { @@ -19,6 +25,8 @@ const ANSWERS: Record = { '': 'reviewer', '': 'acme-reviewer', '