diff --git a/.agents/skills/codetwo-develop/references/workflow.md b/.agents/skills/codetwo-develop/references/workflow.md index 2616f6ad..5ac332d2 100644 --- a/.agents/skills/codetwo-develop/references/workflow.md +++ b/.agents/skills/codetwo-develop/references/workflow.md @@ -8,7 +8,8 @@ change; ordinary edits need only affected-area rules, code, and tests. The [devf A user request, Issue, or confirmed Incident starts a change. Record the outcome, constraints, owner, observable acceptance, and smallest plan in -`docs/sdlc/changes/-/change.md`, using the [single-file template](../templates/change.md). +`docs/sdlc/changes/-/`, using four templates: [Intent](../templates/intent.md), +[Spec](../templates/spec.md), [Plan](../templates/plan.md), and [Verification](../templates/verification.md). A mechanical follow-up may update its existing record with the new scope and evidence. Read-only work needs no change record or repository checks. @@ -23,22 +24,32 @@ this repository's development state. ./script/devflow status ``` -`new` creates one draft containing Intent, Acceptance criteria, Plan, Verification, and Review and -release. Edit it directly. Do not create a file or run an approval command for every phase. -Historical schema-3 stage bundles remain valid and auditable; new work uses schema 4. Do not mix -formats inside one bundle, bulk rewrite history, or create a parallel specs/plans registry. +`new` creates all four draft/pending files together. Preparing a draft does not grant execution +permission. Each fact has one home: + +| File | Owns | +| --- | --- | +| `intent.md` | Request, outcome, constraints, source, risk, and existing implementation authorization | +| `spec.md` | Design decisions, unique `AC-N` criteria, and additional high/critical design approval | +| `plan.md` | Exact scope, implementation ownership, risk-selected checks, and rollback | +| `verification.md` | One result per criterion, checked revision, verifier, residual risk, review and release | + +Do not copy authorization fields into Spec or Plan. Their `accepted` state records readiness under +the Intent authorization, not a new permission request. Historical schema-3 stage bundles and +schema-4 single records remain valid and auditable; new work uses schema 5. Do not mix formats +inside one bundle, bulk rewrite history, or create a parallel specs/plans registry. ### Authorization and design A direct implementation request authorizes its bounded local work. Record the named requester, -date, original decision or session quote, and constraints as `approved_by`, `approved_at`, and +date, original decision or session quote, and constraints in Intent as `approved_by`, `approved_at`, and `approval_source`. For low/medium work, the owner may elaborate the local design and plan within that authorization without additional approval rounds. Material scope changes or unresolved security, data, or major design decisions require the corresponding human decision. -High/critical records additionally require `design_approved_by`, `design_approved_at`, and -`design_approval_source`; both intent and design approvers must differ from the implementation -owner. Reuse an existing explicit decision for the same pending scope; never invent approval or +High/critical Specs additionally require `design_approved_by`, `design_approved_at`, and +`design_approval_source`; both intent and design approvers must differ from the Intent and Plan +implementation owners. Reuse an existing explicit decision for the same pending scope; never invent approval or infer it from silence. Names and dates are auditable claims, not authentication. | Risk | Design and verification | @@ -48,7 +59,7 @@ infer it from silence. Names and dates are auditable claims, not authentication. | High | Security, migrations, persistence/protocol boundaries, release controls, major architecture; independent design decision, integration/rollback proof and independent verification | | Critical | Destructive operations, credentials, possible private-data exposure; high-risk requirements plus explicit authorization for each affected operation | -The `scope` field lists exact repository paths or directory prefixes, comma-separated. No globs, +The Plan `scope` field lists exact repository paths or directory prefixes, comma-separated. No globs, traversal, or root-wide scope. Changed paths, deletions, and both rename endpoints must be covered by a record added or updated in the same diff. Historical approval alone cannot cover new work. @@ -60,21 +71,27 @@ Read existing owner code and consumers before introducing another abstraction. P worktree state. Use the [instance preflight](../../codetwo-operations/references/desktop-instances.md) before launching Core; one data directory has one live owner. -Status is stored once: +Each file stores only its own stage status. Drafts may coexist; accepted stages require accepted +predecessors. Accept Intent after capturing the existing request, then complete and accept Spec and +Plan within that authorization. High/critical Spec acceptance additionally requires the recorded +independent design decision. No stage-specific approval command or repeated confirmation is needed. + +Intent, Spec, and Plan use `draft`, `in-review`, `accepted`, or `rejected`. Verification uses: | Status | Fact and next trigger | | --- | --- | -| `draft` | Proposal; resolve acceptance and authorization before implementation | -| `accepted` | Authorized scope and design ready; start implementation | -| `in-progress` | Authorized implementation or verification running; inspect results and fix | +| `pending` | Evidence is not collected yet; drafts may still be prepared | +| `in-progress` | Accepted Plan is being implemented or verified | | `failed` | Actual FAIL evidence; correct and reverify | | `blocked` | Missing decision, dependency, or evidence; `next_trigger` names what must change | -| `passed` | Every AC checked and backed by current PASS evidence; human review next | -| `rejected` / `superseded` | Record the decision and successor in `next_trigger`; do not execute | +| `passed` | Every Spec AC checked and backed by current PASS evidence; human review next | -Use `next_trigger` to identify the next owner/action or blocker. Merge, release, recovery, and -no-release closure are recorded with links in Review and release, after the real events occur; -`passed` alone only means verified work. Draft PRs may hold proposals and honest failures. +Rejected stages also name a concrete `next_trigger`. New relevant changes invalidate affected +acceptance/evidence: update the owning files and return Verification to `in-progress`, or `pending` +while a revised predecessor is being decided. Keep old results clearly labeled as prior evidence. +Merge, release, recovery, and no-release closure live in Verification's Review and release section, +after the actual events occur; `passed` alone only means verified work. Draft PRs may hold proposals +and honest failures. ## Test @@ -85,7 +102,7 @@ or repeat only after relevant changes, failures, or unresolved concerns. | --- | --- | | Repository files | `bun script/verify/sdlc.ts --worktree` once before handoff; includes structure and scope | | Documentation, linked assets, catalog | `bun script/verify/docs.ts` plus `git diff --check` | -| Authorization/routing rules, lifecycle behavior, template structure, or checkers | `bun test script/verify/checks.test.ts script/devflow.test.ts`; this is also the active lifecycle Eval | +| Authorization/routing rules, lifecycle behavior, template structure, or checkers | `bun test script/verify/checks.test.ts script/verify/four-stage.test.ts script/devflow.test.ts`; this is also the active lifecycle Eval | | Rust | Affected crate tests and applicable format/check commands in its existing workflow | | Desktop | Affected tests/types/build from `apps/desktop/package.json`; actual window rendering for layout/interaction | | Security, data, protocol, packaging | Applicable contract, integration, migration, package, or runtime checks; recovery proof | @@ -95,7 +112,7 @@ UI acceptance uses the actual rendered app in affected light/dark/narrow states. fixture image does not prove product behavior. Lifecycle-only work does not start Core or rerun unrelated renderer, Rust, or packaging suites. -Acceptance uses unique `AC-N` checkboxes. Verification maps each id to exactly one current result: +Spec acceptance uses unique `AC-N` checkboxes. Verification maps each id to exactly one current result: ```text - AC-1: PASS — `relevant-command` passed in the named environment. @@ -105,7 +122,7 @@ Residual risk: concrete unchecked boundary or remaining limitation. `passed` requires checked criteria, PASS evidence citing a command or linked artifact, verifier, date, mode, and `revision` (commit or explicitly described worktree baseline). High/critical -verification must be independent of the owner. Failed results retain every criterion mapping and +verification must be independent of the Intent and Plan implementation owners. Failed results retain every criterion mapping and at least one FAIL; record prior attempts separately. Skips state their reason. New relevant changes invalidate the affected evidence: return to `in-progress` and recheck before `passed`. @@ -117,7 +134,7 @@ Ready PRs require authorized scope and passing verification. The SDLC workflow r branch and metadata Gate when code, PR text, or draft status changes: ```sh -PR_BODY='Change: docs/sdlc/changes/-/change.md' PR_IS_DRAFT=false \ +PR_BODY='Change: docs/sdlc/changes/-/intent.md' PR_IS_DRAFT=false \ PR_BASE_SHA= ./script/devflow check-pr ``` diff --git a/.agents/skills/codetwo-develop/templates/change.md b/.agents/skills/codetwo-develop/templates/change.md deleted file mode 100644 index ec511efc..00000000 --- a/.agents/skills/codetwo-develop/templates/change.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -id: {{ID}} -schema: 4 -status: draft -owner: unassigned -created: {{DATE}} -source: {{SOURCE}} -risk: {{RISK}} -scope: pending -approved_by: "" -approved_at: "" -approval_source: "" -next_trigger: Complete acceptance and record the existing request authorization. -revision: "" -verification_mode: owner -verified_by: "" -verified_at: "" -release_target: none ---- - -# {{TITLE}} - -## Intent - -[fill] Problem, outcome, constraints, and non-goals. Link the request or quote the dated session decision. - -## Acceptance criteria - -- [ ] AC-1: [fill] Observable outcome and relevant failure path. - -## Plan - -[fill] Smallest implementation and checks; link existing design rather than copying it. -For high/critical risk, record independent design_approved_by, design_approved_at, and design_approval_source in frontmatter before execution. - -## Verification - -- AC-1: BLOCKED — Acceptance has not been checked. - -Verdict: pending. -Residual risk: pending. - -## Review and release - -Approval: pending. -Rollback: [fill] Revert or recovery path. -Release: No release requested; merge and external actions require their own authorization. -Feedback: Link an Incident and regression Eval when a real failure occurs. diff --git a/.agents/skills/codetwo-develop/templates/intent.md b/.agents/skills/codetwo-develop/templates/intent.md new file mode 100644 index 00000000..906cd6ec --- /dev/null +++ b/.agents/skills/codetwo-develop/templates/intent.md @@ -0,0 +1,20 @@ +--- +id: {{ID}} +schema: 5 +stage: intent +status: draft +owner: unassigned +created: {{DATE}} +source: {{SOURCE}} +risk: {{RISK}} +approved_by: "" +approved_at: "" +approval_source: "" +next_trigger: Complete the request and record its existing authorization. +--- + +# Intent: {{TITLE}} + +## Intent + +[fill] Problem, outcome, constraints, non-goals, and original request. Record existing implementation authorization above; ordinary local work does not need another approval round. diff --git a/.agents/skills/codetwo-develop/templates/plan.md b/.agents/skills/codetwo-develop/templates/plan.md new file mode 100644 index 00000000..6e7eef3c --- /dev/null +++ b/.agents/skills/codetwo-develop/templates/plan.md @@ -0,0 +1,18 @@ +--- +id: {{ID}} +schema: 5 +stage: plan +status: draft +owner: unassigned +created: {{DATE}} +based_on: spec.md +scope: pending +--- + +# Plan: {{TITLE}} + +## Plan + +[fill] Smallest implementation, exact files and ownership, and checks selected by risk and affected behavior. Explain which acceptance items need rendered UI, integration, package, or independent evidence and why other checks do not apply. Accept this Plan under the Intent authorization; no separate approval command is required. + +Rollback: [fill] Revert or recovery path. diff --git a/.agents/skills/codetwo-develop/templates/spec.md b/.agents/skills/codetwo-develop/templates/spec.md new file mode 100644 index 00000000..1387e768 --- /dev/null +++ b/.agents/skills/codetwo-develop/templates/spec.md @@ -0,0 +1,19 @@ +--- +id: {{ID}} +schema: 5 +stage: spec +status: draft +owner: unassigned +created: {{DATE}} +based_on: intent.md +--- + +# Spec: {{TITLE}} + +## Design + +[fill] Behavior, boundaries, and material decisions under the authorized Intent. For high/critical risk, record design_approved_by, design_approved_at, and design_approval_source here in frontmatter before accepting this Spec. + +## Acceptance criteria + +- [ ] AC-1: [fill] Observable outcome and relevant failure path. diff --git a/.agents/skills/codetwo-develop/templates/verification.md b/.agents/skills/codetwo-develop/templates/verification.md new file mode 100644 index 00000000..f2936060 --- /dev/null +++ b/.agents/skills/codetwo-develop/templates/verification.md @@ -0,0 +1,30 @@ +--- +id: {{ID}} +schema: 5 +stage: verification +status: pending +owner: unassigned +created: {{DATE}} +based_on: plan.md +revision: "" +verification_mode: owner +verified_by: "" +verified_at: "" +release_target: none +--- + +# Verification: {{TITLE}} + +## Verification + +- AC-1: BLOCKED — Acceptance has not been checked. + +Verdict: pending. +Residual risk: pending. + +## Review and release + +Approval: pending. +Rollback: See plan.md. +Release: No release requested; merge and external actions require their own authorization. +Feedback: Link an Incident and regression Eval when a real failure occurs. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0ce1194a..408ee982 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,9 +4,9 @@ Describe the problem and observable result. ## Change record -- Change: +- Change: -The linked record owns scope, authorization, acceptance, evidence, and residual risk. +The linked change directory owns scope, authorization, acceptance, evidence, and residual risk. Reuse it; do not copy its stage status or approval fields here. ## Review and release diff --git a/.github/workflows/sdlc.yml b/.github/workflows/sdlc.yml index cffbc2e9..de1185b4 100644 --- a/.github/workflows/sdlc.yml +++ b/.github/workflows/sdlc.yml @@ -24,7 +24,7 @@ jobs: with: bun-version: 1.3.10 - name: Test lifecycle and devflow Gates - run: bun test script/verify/checks.test.ts script/devflow.test.ts + run: bun test script/verify/checks.test.ts script/verify/four-stage.test.ts script/devflow.test.ts - name: Validate documentation run: bun script/verify/docs.ts - name: Validate PR records, changed scope, and review readiness diff --git a/AGENTS.md b/AGENTS.md index 6827cb28..0c33de0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,9 @@ checkout. - [Workflow](.agents/skills/codetwo-develop/references/workflow.md) owns lifecycle, authorization, risk-based checks, and release boundaries. Use codetwo-develop references as needed for daily work. The external ai-native-sdlc Skill is for lifecycle setup, audit, or improvement, not ordinary implementation. -- New changes use one `docs/sdlc/changes/-/change.md` record, created with - `./script/devflow new`. Link existing requests and design decisions; retain historical stage bundles. +- New changes use `intent.md`, `spec.md`, `plan.md`, and `verification.md` under + `docs/sdlc/changes/-/`, created together with `./script/devflow new`. + Link existing requests and design decisions; retain historical records. - A direct implementation request authorizes bounded local work. Record its source, requester, constraints, and acceptance; elaborate reversible local design and plan without stepwise approval. High/critical design still needs an independent human decision. User instructions take precedence @@ -21,7 +22,7 @@ checkout. - Finish implementation, applicable checks, inspection, and fixes. Before repository-file handoff, run `bun script/verify/sdlc.ts --worktree`; run `bun script/verify/docs.ts` when documentation or links change. Authorization, routing, lifecycle behavior, template structure, or checker changes - also run `bun test script/verify/checks.test.ts script/devflow.test.ts` (the active lifecycle Eval). + also run `bun test script/verify/checks.test.ts script/verify/four-stage.test.ts script/devflow.test.ts` (the active lifecycle Eval). Pure wording edits need only applicable documentation and scope checks. Reuse valid evidence. - UI changes need actual rendering. A passing local check does not prove remote CI, approval, publication, or production recovery. Record actual evidence, skipped checks, and residual risk. diff --git a/Cargo.lock b/Cargo.lock index 2ba71806..d336ec13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "ahash" version = "0.8.12" @@ -53,6 +64,112 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.91" @@ -175,6 +292,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -184,6 +310,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -229,6 +368,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.3.0" @@ -285,6 +433,16 @@ dependencies = [ "phf", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "codetwo-core" version = "0.0.0" @@ -327,6 +485,7 @@ dependencies = [ "codetwo-plugins", "codetwo-server", "futures-util", + "keyring", "reqwest", "serde", "serde_json", @@ -425,12 +584,41 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "constant_time_eq" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -464,6 +652,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crossterm" version = "0.28.1" @@ -545,6 +739,36 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "openssl", + "sha2", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -553,6 +777,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -588,6 +813,33 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -613,6 +865,26 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -679,6 +951,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -718,6 +1005,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.33" @@ -845,6 +1145,36 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.2" @@ -1162,6 +1492,16 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instability" version = "0.3.12" @@ -1228,6 +1568,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "openssl", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "kqueue" version = "1.2.1" @@ -1271,6 +1628,16 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libghostty-vt" version = "0.2.1" @@ -1366,6 +1733,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1424,10 +1800,23 @@ dependencies = [ "bitflags 1.3.2", "cfg-if", "libc", - "memoffset", + "memoffset 0.6.5", "pin-utils", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset 0.9.1", +] + [[package]] name = "notify" version = "8.2.0" @@ -1455,6 +1844,69 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1681,6 +2133,69 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1752,6 +2267,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -1784,6 +2310,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "portable-pty" version = "0.8.1" @@ -1797,7 +2337,7 @@ dependencies = [ "lazy_static", "libc", "log", - "nix", + "nix 0.25.1", "serial", "shared_library", "shell-words", @@ -1823,6 +2363,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -2214,6 +2763,61 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.7", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.229" @@ -2268,6 +2872,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2752,8 +3367,8 @@ checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", ] [[package]] @@ -2765,6 +3380,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.20.2" @@ -2774,8 +3398,29 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", - "winnow", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -2914,6 +3559,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicase" version = "2.9.0" @@ -3423,6 +4079,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" @@ -3438,6 +4103,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xmlwriter" version = "0.1.0" @@ -3467,6 +4142,62 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.7", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.55" @@ -3513,6 +4244,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" @@ -3567,3 +4312,40 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock index 2db465a5..a2dce496 100644 --- a/apps/desktop/bun.lock +++ b/apps/desktop/bun.lock @@ -24,6 +24,7 @@ "cn": "^0.2.4", "monaco-editor": "^0.56.0", "react": "^18.3.1", + "react-compiler-runtime": "1.0.0", "react-dom": "^18.3.1", "react-markdown": "8.0.7", "remark-gfm": "3.0.1", @@ -2131,6 +2132,8 @@ "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "react-compiler-runtime": ["react-compiler-runtime@1.0.0", "", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" } }, "sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w=="], + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], "react-icons": ["react-icons@5.7.0", "", { "peerDependencies": { "react": "*" } }, "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw=="], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7ff181a3..d99db3ad 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -49,6 +49,7 @@ "cn": "^0.2.4", "monaco-editor": "^0.56.0", "react": "^18.3.1", + "react-compiler-runtime": "1.0.0", "react-dom": "^18.3.1", "react-markdown": "8.0.7", "remark-gfm": "3.0.1", diff --git a/apps/desktop/src-host/Cargo.toml b/apps/desktop/src-host/Cargo.toml index 5355a35a..81603e6a 100644 --- a/apps/desktop/src-host/Cargo.toml +++ b/apps/desktop/src-host/Cargo.toml @@ -20,6 +20,7 @@ futures-util = "0.3" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } tempfile = "3" url = "2" +keyring = { version = "=3.6.3", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust", "vendored"] } [lib] name = "codetwo_desktop_host" diff --git a/apps/desktop/src-host/src/host_events.rs b/apps/desktop/src-host/src/host_events.rs index 0533c416..f467ebdd 100644 --- a/apps/desktop/src-host/src/host_events.rs +++ b/apps/desktop/src-host/src/host_events.rs @@ -3,7 +3,7 @@ use codetwo_kernel::{ async_trait, CommandRealm, Context, Injection, Plugin, PluginError, PluginResult, Service, }; -use codetwo_plugins::events::{ConnectorEvent, PluginsChanged}; +use codetwo_plugins::events::{ConnectorEvent, PluginRuntimeChanged, PluginsChanged}; use codetwo_plugins::{EventBus, TerminalEvent, TerminalOutputEvent}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -161,6 +161,12 @@ impl Plugin for HostEventsPlugin { None }); + let host = self.host.clone(); + ctx.on::(move |_| { + let _ = host.emit("plugins-changed", ()); + None + }); + let host = self.host.clone(); ctx.on::(move |event| { let _ = host.emit("plugin-connector-event", (*event).clone()); diff --git a/apps/desktop/src-host/src/issue_delivery.rs b/apps/desktop/src-host/src/issue_delivery.rs new file mode 100644 index 00000000..12dc3707 --- /dev/null +++ b/apps/desktop/src-host/src/issue_delivery.rs @@ -0,0 +1,1421 @@ +//! Host-owned issue delivery: plugin I/O is short-lived; Core retains tasks and workspaces. +use codetwo_core::{ + engine::{Engine, ParallelTaskCreation}, + event::{Event, Op}, + issue_delivery::{ + DeliveryPermissions, IssueDelivery, PendingFeedback, TrackedIssue, ValidationResult, + VerificationReceipt, + }, + permission::ExecutionPolicy, + provider::ProviderId, + session::SessionRunState, + skill::DocBlock, + store::Store, + task::TaskId, + worktree::WorktreeBaseline, +}; +use codetwo_kernel::{ + async_trait, Context, Injection, Plugin, PluginError, PluginResult, WeakContext, +}; +use codetwo_plugins::{events::EngineEvent, EngineService, Paths, PluginHub, StoreService}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; +use tokio::{io::AsyncReadExt, process::Command, sync::Mutex}; + +fn error(message: impl ToString) -> PluginError { + PluginError::new(message.to_string()) +} +fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} +fn decode(value: Value) -> Result { + serde_json::from_value(value).map_err(error) +} +fn encode(value: impl serde::Serialize) -> Result { + serde_json::to_value(value).map_err(error) +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct Connector { + plugin_id: String, + connector_id: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ConnectorRequest { + connector: Connector, + operation: String, + #[serde(default)] + input: Value, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ConnectRequest { + connector: Connector, + token: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct StartRequest { + connector: Connector, + issue_id: String, + repository: String, + provider: String, + #[serde(default)] + model: Option, + base_branch: String, + acceptance: String, + validation_commands: Vec, + permissions: DeliveryPermissions, + #[serde(default)] + done_state_id: Option, + #[serde(default)] + new_attempt: bool, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RunRequest { + id: String, + #[serde(default)] + message: String, + #[serde(default)] + accept_issue_update: bool, +} + +pub struct IssueDeliveryPlugin; +struct Controller { + store: Arc, + engine: Arc, + hub: Arc, + context: WeakContext, + credential_namespace: String, + credentials: Arc, + gate: Mutex<()>, + cancellations: std::sync::Mutex>, + run_gates: std::sync::Mutex>>>, +} + +#[async_trait] +impl Plugin for IssueDeliveryPlugin { + fn name(&self) -> &str { + "issue-delivery" + } + fn description(&self) -> Option<&str> { + Some("Issue development, verified PR delivery, and recoverable tracker synchronization.") + } + fn inject(&self) -> Injection { + Injection::required(["store", "engine", "plugin-hub", "paths"]) + } + async fn apply(&self, ctx: Context, _: Value) -> PluginResult { + let paths = ctx.expect::()?; + let store = ctx.expect::()?.0.clone(); + store.init_issue_delivery().map_err(error)?; + let controller = Arc::new(Controller { + store, + engine: ctx.expect::()?.0.clone(), + hub: ctx.expect::()?, + context: ctx.weak(), + credentials: Arc::new(SystemCredentials), + credential_namespace: format!( + "dev.codetwo.issues.{}", + blake3::hash(paths.data_dir.to_string_lossy().as_bytes()).to_hex() + ), + gate: Mutex::new(()), + cancellations: std::sync::Mutex::new(Default::default()), + run_gates: std::sync::Mutex::new(Default::default()), + }); + let c = controller.clone(); + ctx.command("issue_delivery.connect", move |args| { + let c = c.clone(); + async move { + let args: ConnectRequest = decode(args)?; + if args.token.trim().is_empty() || args.token.len() > 8192 { + return Err(error("Enter a valid API key.")); + } + let _guard = c.gate.lock().await; + c.check_connector(&args.connector)?; + let result = c + .invoke( + &args.connector, + "connection.info", + json!({}), + Some(args.token.trim().into()), + ) + .await?; + c.credentials + .access( + &c.credential_namespace, + &args.connector, + CredentialOp::Set(args.token.trim().into()), + ) + .await?; + Ok(result) + } + })?; + let c = controller.clone(); + ctx.command("issue_delivery.disconnect", move |args| { + let c = c.clone(); + async move { + let connector: Connector = decode(args)?; + let _guard = c.gate.lock().await; + c.credentials + .access(&c.credential_namespace, &connector, CredentialOp::Delete) + .await?; + Ok(Value::Null) + } + })?; + let c = controller.clone(); + ctx.command("issue_delivery.read", move |args| { + let c = c.clone(); + async move { + let args: ConnectorRequest = decode(args)?; + if !matches!( + args.operation.as_str(), + "connection.info" | "issues.list" | "issues.get" + ) { + return Err(error("Unsupported read operation.")); + } + if args.operation == "issues.get" { + c.read_issue(&args.connector, args.input).await + } else { + c.invoke(&args.connector, &args.operation, args.input, None) + .await + } + } + })?; + ctx.command("issue_delivery.repository", move |args| async move { + #[derive(Deserialize)] struct RepositoryArgs { path: String } + let args:RepositoryArgs=decode(args)?; + let path=std::fs::canonicalize(args.path).map_err(|_|error("Repository not found."))?; + let branch=git(&path,&["symbolic-ref","--short","HEAD"]).await?.trim().to_owned(); + let validation = if path.join("Cargo.toml").is_file() { "cargo test" } + else if path.join("bun.lock").is_file() || path.join("bun.lockb").is_file() { "bun test" } + else if path.join("pnpm-lock.yaml").is_file() { "pnpm test" } + else if path.join("package.json").is_file() { "npm test" } else { "" }; + let common=git(&path,&["rev-parse","--path-format=absolute","--git-common-dir"]).await?; + let identity=std::fs::canonicalize(common.trim()).map_err(error)?.to_string_lossy().into_owned(); + Ok(json!({"base_branch":branch,"validation_command":validation,"repository_identity":identity})) + })?; + let c = controller.clone(); + ctx.command("issue_delivery.start", move |args| { + let c = c.clone(); + async move { + let args: StartRequest = decode(args)?; + let _guard = c.gate.lock().await; + encode(c.start(args).await?) + } + })?; + let c = controller.clone(); + ctx.command("issue_delivery.list", move |_| { + let c = c.clone(); + async move { encode(c.store.issue_deliveries().map_err(error)?) } + })?; + for operation in ["continue", "cancel", "refresh", "publish", "retry_creation"] { + let c = controller.clone(); + ctx.command(format!("issue_delivery.{operation}"), move |args| { let c = c.clone(); async move { + let args: RunRequest = decode(args)?; + if operation == "cancel" { + let existing=c.store.issue_delivery(&args.id).map_err(error)?.ok_or_else(||error("Development task not found."))?; + if matches!(existing.stage.as_str(),"merged"|"closed") {return Err(error("This attempt is already finished."));} + c.cancellations.lock().unwrap().insert(args.id.clone()); + } + let gate=c.run_gate(&args.id); + let _guard = gate.lock().await; + let mut run = c.store.issue_delivery(&args.id).map_err(error)?.ok_or_else(|| error("Development task not found."))?; + match operation { + "cancel" => { + c.cancellations.lock().unwrap().remove(&run.id); + if matches!(run.stage.as_str(),"merged"|"closed") { return Err(error("This attempt is already finished.")); } + if let Some(session) = &run.session_id { c.engine.submit(Op::Cancel { session: session.clone() }).await.map_err(error)?; } + run.stage = "cancelled".into(); run.error = None; run.sync_pending=run.permissions.writeback; + c.cancellations.lock().unwrap().remove(&run.id); + } + "continue" => { + if run.terminal() { return Err(error("This attempt is finished. Start a new attempt explicitly.")); } + if args.accept_issue_update { + if let Some(issue) = run.pending_issue.take() { run.issue = issue; } + } else if run.pending_issue.is_some() { return Err(error("Review and accept the changed issue before continuing.")); } + if args.message.trim().is_empty() { return Err(error("Describe what to continue or clarify.")); } + if args.message.len() > 24_000 { return Err(error("Continuation is too long.")); } + let session = run.session_id.clone().ok_or_else(|| error("The task has no session yet. Refresh its creation status."))?; + c.prompt(&session, &format!("Continue the existing issue development task. Keep its repository and authorization boundaries.\n\nUser clarification:\n{}\n\nCurrent task context:\n{}", args.message, run.prompt()), format!("issue-continue:{}:{}", run.id, uuid::Uuid::new_v4())).await?; + run.stage = "developing".into(); run.error = None; run.verification = None; + } + "retry_creation" => { + if run.terminal() {return Err(error("This attempt is finished."));} + let leases=c.store.list_task_session_leases(&TaskId::new(run.task_id.clone())).map_err(error)?; + if let Some(lease)=leases.first() {run.session_id=Some(lease.session_id.clone());} + else { + if c.store.get_task(&TaskId::new(run.task_id.clone())).map_err(error)?.is_some() {return Err(error("The task exists but its session needs recovery. No duplicate was created."));} + if git(Path::new(&run.repository),&["rev-parse","HEAD"]).await?.trim()!=run.base_sha {return Err(error("The original base changed. Restore it or cancel and start a new attempt."));} + c.create_task(&run).await?; + } + run.stage="creating".into(); run.error=None; + } + "publish" => { c.reconcile(&mut run, true).await; } + _ => { c.reconcile(&mut run, true).await; } + } + c.save(&mut run)?; + encode(run) + }})?; + } + // This is an application-owned task observer, not a remote scheduler. Credentials and + // bundle policy are checked at every external read/write; disabling a bundle keeps tasks. + let c = controller.clone(); + ctx.spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + loop { + interval.tick().await; + c.tick().await; + } + }); + let weak = Arc::downgrade(&controller); + let context = ctx.weak(); + ctx.on::(move |event| { + if matches!( + &event.0, + Event::SessionCreated { .. } | Event::TurnEnded { .. } + ) { + if let (Some(c), Some(ctx)) = (weak.upgrade(), context.upgrade()) { + ctx.spawn(async move { + c.tick().await; + }); + } + } + None + }); + Ok(()) + } +} + +enum CredentialOp { + Get, + Set(String), + Delete, +} +#[async_trait] +trait CredentialStore: Send + Sync { + async fn access( + &self, + namespace: &str, + connector: &Connector, + operation: CredentialOp, + ) -> Result, PluginError>; +} +struct SystemCredentials; +#[async_trait] +impl CredentialStore for SystemCredentials { + async fn access( + &self, + namespace: &str, + connector: &Connector, + operation: CredentialOp, + ) -> Result, PluginError> { + credential(namespace, connector, operation).await + } +} + +async fn credential( + namespace: &str, + connector: &Connector, + operation: CredentialOp, +) -> Result, PluginError> { + let namespace = namespace.to_owned(); + let account = format!("{}:{}", connector.plugin_id, connector.connector_id); + tokio::task::spawn_blocking(move || { + static KEYRING_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = KEYRING_LOCK + .lock() + .map_err(|_| error("Credential store is unavailable."))?; + let entry = keyring::Entry::new(&namespace, &account) + .map_err(|_| error("System credential store is unavailable."))?; + match operation { + CredentialOp::Get => entry + .get_password() + .map(Some) + .map_err(|_| error("Reconnect this issue tracker to access its saved credential.")), + CredentialOp::Set(token) => entry + .set_password(&token) + .map(|_| None) + .map_err(|_| error("Could not save the API key in the system credential store.")), + CredentialOp::Delete => match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(None), + Err(_) => Err(error("Could not remove this saved credential.")), + }, + } + }) + .await + .map_err(|_| error("Credential operation failed."))? +} + +impl Controller { + fn run_gate(&self, id: &str) -> Arc> { + self.run_gates + .lock() + .unwrap() + .entry(id.into()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + fn check_connector(&self, connector: &Connector) -> PluginResult { + let plugin = self + .hub + .installed() + .into_iter() + .find(|p| p.id == connector.plugin_id) + .ok_or_else(|| error("Install this issue tracker plugin first."))?; + if !plugin.enabled || !plugin.trusted { + return Err(error("Enable and trust this issue tracker plugin first.")); + } + if !plugin.connector_contributions.iter().any(|c| { + c.id == connector.connector_id && c.capabilities.iter().any(|cap| cap == "issues") + }) { + return Err(error( + "This plugin does not own the selected issue connector.", + )); + } + Ok(()) + } + async fn invoke( + &self, + connector: &Connector, + operation: &str, + input: Value, + token: Option, + ) -> Result { + self.check_connector(connector)?; + let mut input = input.as_object().cloned().unwrap_or_default(); + input.remove("_credential"); + let token = match token { + Some(token) => token, + None => self + .credentials + .access(&self.credential_namespace, connector, CredentialOp::Get) + .await? + .ok_or_else(|| error("Reconnect this issue tracker."))?, + }; + input.insert("_credential".into(), Value::String(token)); + let context = self + .context + .upgrade() + .ok_or_else(|| error("Issue delivery is unavailable."))?; + context.call("plugins.invoke_connector", json!({"plugin_id":connector.plugin_id,"contribution_id":connector.connector_id,"operation":operation,"input":input})).await.map_err(|_| error("Issue tracker request failed. Check the connection, plugin state, and access permissions.")) + } + async fn read_issue(&self, connector: &Connector, input: Value) -> Result { + let mut issue = self.invoke(connector, "issues.get", input, None).await?; + for field in ["comments", "attachments"] { + let more = format!("{field}_more"); + let cursor_key = format!("{field}_cursor"); + let mut cursor = if issue[&more].as_bool() == Some(true) { + issue[&cursor_key].as_str().map(str::to_owned) + } else { + None + }; + if issue[&more].as_bool() == Some(true) && cursor.is_none() { + return Err(error( + "Issue context is incomplete: pagination cursor missing.", + )); + } + for page in 0..10 { + let Some(current) = cursor.take() else { + break; + }; + let next = self + .invoke( + connector, + &format!("issues.{field}"), + json!({"id":issue["id"],"cursor":current}), + None, + ) + .await?; + let values = next["items"] + .as_array() + .ok_or_else(|| error("Incomplete issue page."))? + .clone(); + issue[field] + .as_array_mut() + .ok_or_else(|| error("Incomplete issue context."))? + .extend(values); + cursor = next["cursor"].as_str().map(str::to_owned); + if serde_json::to_vec(&issue).map_err(error)?.len() > 600_000 + || (page == 9 && cursor.is_some()) + { + return Err(error( + "Issue context is too large. Narrow the requirement before starting.", + )); + } + } + issue[&more] = json!(false); + } + Ok(issue) + } + async fn wait_cancel(&self, id: &str) { + loop { + if self.cancellations.lock().unwrap().contains(id) { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + fn save(&self, run: &mut IssueDelivery) -> PluginResult { + run.updated_at = now(); + self.store.save_issue_delivery(run).map_err(error) + } + async fn start(&self, args: StartRequest) -> Result { + if args.acceptance.trim().is_empty() || args.acceptance.len() > 24_000 { + return Err(error("Provide bounded acceptance criteria.")); + } + if args.validation_commands.is_empty() + || args.validation_commands.len() > 8 + || args + .validation_commands + .iter() + .any(|s| s.trim().is_empty() || s.len() > 2000) + { + return Err(error( + "Provide 1–8 validation commands for this repository.", + )); + } + if args.permissions.create_pr && !args.permissions.push { + return Err(error("PR creation also requires push permission.")); + } + if args.provider.trim().is_empty() || args.provider.len() > 256 { + return Err(error("Choose a configured provider.")); + } + let repository = std::fs::canonicalize(&args.repository) + .map_err(|_| error("Choose an existing repository."))?; + let root = git(&repository, &["rev-parse", "--show-toplevel"]).await?; + let repository = PathBuf::from(root.trim()); + let common = git( + &repository, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ) + .await?; + let identity = std::fs::canonicalize(common.trim()) + .map_err(error)? + .to_string_lossy() + .into_owned(); + git( + &repository, + &["check-ref-format", "--branch", &args.base_branch], + ) + .await?; + let current_branch = git(&repository, &["symbolic-ref", "--short", "HEAD"]) + .await? + .trim() + .to_owned(); + if args.base_branch != current_branch { + return Err(error( + "The base must be the source checkout's current branch. Switch it before starting.", + )); + } + let remote = codetwo_core::source_control::inspect(&repository) + .await + .map_err(error)?; + if args.permissions.create_pr + && !remote.as_ref().is_some_and(|r| { + r.host == "github.com" && r.remote_name == "origin" && r.required_cli_available + }) + { + return Err(error( + "PR delivery requires GitHub CLI and a github.com origin remote.", + )); + } + let remote_url = remote.and_then(|r| r.web_url); + let base_sha = git( + &repository, + &[ + "rev-parse", + "--verify", + &format!("refs/heads/{}^{{commit}}", args.base_branch), + ], + ) + .await? + .trim() + .to_string(); + let issue: TrackedIssue = decode( + self.read_issue(&args.connector, json!({"id":args.issue_id})) + .await?, + )?; + if issue.description.len() > 100_000 || issue.id.is_empty() || issue.workspace_id.is_empty() + { + return Err(error("Issue context is missing or too large.")); + } + let id = uuid::Uuid::new_v4().to_string(); + let run = IssueDelivery { + task_id: format!("issue-{id}"), + id: id.clone(), + plugin_id: args.connector.plugin_id, + connector_id: args.connector.connector_id, + repository: repository.to_string_lossy().into_owned(), + repository_identity: identity, + remote_url, + session_id: None, + work_branch: None, + issue, + provider: args.provider, + model: args.model, + base_branch: args.base_branch, + base_sha, + permissions: args.permissions, + validation_commands: args.validation_commands, + acceptance: args.acceptance, + done_state_id: args.done_state_id.filter(|s| !s.is_empty()), + stage: "creating".into(), + error: None, + verification: None, + pull_request: None, + pending_issue: None, + seen_review_ids: vec![], + pending_feedback: None, + synced_milestones: vec![], + sync_error: None, + sync_pending: false, + initial_prompt_sent: false, + created_at: now(), + updated_at: now(), + }; + let (mut run, created) = self + .store + .reserve_issue_delivery(&run, args.new_attempt) + .map_err(error)?; + if !created { + return Ok(run); + } + let result = self.create_task(&run).await; + if let Err(e) = result { + run.stage = "blocked".into(); + run.error = Some(e.to_string()); + self.save(&mut run)?; + } + Ok(run) + } + async fn create_task(&self, run: &IssueDelivery) -> PluginResult { + let provider: ProviderId = serde_json::from_value(json!(run.provider)) + .unwrap_or_else(|_| ProviderId::Custom(run.provider.clone())); + self.engine + .create_parallel_task_session(ParallelTaskCreation { + provider, + cwd: run.repository.clone(), + worktree_base: WorktreeBaseline::Current, + worktree_base_sha: Some(run.base_sha.clone()), + request_id: format!("issue-create:{}", run.id), + model: run.model.clone(), + initial_policy: Some(ExecutionPolicy::default()), + reasoning_effort: None, + task_id: TaskId::new(run.task_id.clone()), + goal: format!("{}: {}", run.issue.identifier, run.issue.title) + .chars() + .take(4096) + .collect(), + }) + .await + .map_err(error) + } + async fn prompt(&self, session: &str, text: &str, request: String) -> PluginResult { + self.engine + .submit(Op::Prompt { + session: session.into(), + doc: vec![DocBlock::Text { text: text.into() }], + request_id: Some(format!("t3-command:{request}")), + }) + .await + .map_err(error) + } + async fn tick(&self) { + use futures_util::{stream, StreamExt}; + let Ok(runs) = self.store.issue_deliveries() else { + return; + }; + stream::iter(runs.into_iter().filter(|r| !r.terminal() || r.sync_pending)) + .for_each_concurrent(4, |mut run| async move { + let gate = self.run_gate(&run.id); + let Ok(_guard) = gate.try_lock() else { + return; + }; + // Re-read after taking the per-run lock; a user may have cancelled or continued it. + let Ok(Some(current)) = self.store.issue_delivery(&run.id) else { + return; + }; + run = current; + self.reconcile(&mut run, false).await; + let _ = self.save(&mut run); + }) + .await; + } + async fn reconcile(&self, run: &mut IssueDelivery, explicit: bool) { + if run.terminal() { + if let Err(e) = self.sync(run).await { + run.sync_error = Some(e.to_string()); + } + return; + } + let id = run.id.clone(); + tokio::select! { + result=self.reconcile_inner(run, explicit) => if let Err(e)=result {run.error=Some(e.to_string());}, + _=self.wait_cancel(&id) => {if !run.terminal() {run.stage="cancelled".into();run.error=None;run.sync_pending=run.permissions.writeback;}} + } + } + async fn reconcile_inner(&self, run: &mut IssueDelivery, explicit: bool) -> PluginResult { + if run.terminal() { + return self.sync(run).await; + } + if run.session_id.is_none() { + let leases = self + .store + .list_task_session_leases(&TaskId::new(run.task_id.clone())) + .map_err(error)?; + run.session_id = leases.first().map(|lease| lease.session_id.clone()); + if run.session_id.is_none() { + if now() - run.created_at > 120_000 { + run.stage = "blocked".into(); + run.error = Some( + "Task creation was interrupted. No duplicate task was created.".into(), + ); + } + return Ok(()); + } + } + if run.terminal() { + return self.sync(run).await; + } + let session_id = run.session_id.as_ref().unwrap().clone(); + let session = self + .store + .get_session(&session_id) + .map_err(error)? + .ok_or_else(|| error("The linked session is unavailable."))?; + if run.work_branch.is_none() { + run.work_branch = Some( + git( + Path::new(&session.cwd), + &["symbolic-ref", "--short", "HEAD"], + ) + .await? + .trim() + .to_owned(), + ); + self.save(run)?; + } + if !run.initial_prompt_sent { + let request = format!("issue-initial:{}", run.id); + if self + .store + .command_receipt("t3-prompt", &request) + .map_err(error)? + .is_some() + { + run.initial_prompt_sent = true; + self.save(run)?; + } else { + run.stage = "developing".into(); + self.save(run)?; + self.prompt(&session_id, &run.prompt(), request).await?; + run.sync_pending = run.permissions.writeback; + return self.sync(run).await; + } + } + if let Some(pending) = run.pending_feedback.clone() { + if self + .store + .command_receipt("t3-prompt", &pending.request_id) + .map_err(error)? + .is_some() + { + run.seen_review_ids.extend(pending.ids); + run.pending_feedback = None; + self.save(run)?; + } else if matches!(session.activity.state, SessionRunState::Idle) { + self.prompt(&session_id, &pending.body, pending.request_id) + .await?; + return Ok(()); + } + } + let connector = Connector { + plugin_id: run.plugin_id.clone(), + connector_id: run.connector_id.clone(), + }; + if self.check_connector(&connector).is_ok() { + match self + .read_issue(&connector, json!({"id":run.issue.id})) + .await + .and_then(decode::) + { + Ok(latest) => { + if latest.workspace_id != run.issue.workspace_id { + return Err(error( + "The issue connector now belongs to another workspace.", + )); + } + run.sync_error = None; + if requirements_changed(&run.issue, &latest) { + run.pending_issue = Some(latest); + } + } + Err(e) => run.sync_error = Some(e.to_string()), + } + } + if matches!( + session.activity.state, + SessionRunState::Running { .. } | SessionRunState::AwaitingInput { .. } + ) { + return self.sync(run).await; + } + if run.pending_issue.is_some() { + run.stage = "blocked".into(); + return Err(error( + "Issue requirements changed. Review the changes before continuing.", + )); + } + if matches!(session.activity.state, SessionRunState::Failed { .. }) { + run.stage = "blocked".into(); + return Err(error( + "The development session needs attention. Open it to continue.", + )); + } + let cwd = Path::new(&session.cwd); + validate_checkout(run, &session).await?; + if let Some(pr) = if (run.permissions.create_pr || run.pull_request.is_some()) + && run.remote_url.is_some() + && codetwo_core::provider::which("gh").is_some() + { + bound_pr(cwd, run).await? + } else { + None + } { + run.pull_request = Some(pr.clone()); + match pr["state"].as_str() { + Some("MERGED") => { + run.stage = "merged".into(); + run.error = None; + run.sync_pending = run.permissions.writeback; + return self.sync(run).await; + } + Some("CLOSED") => { + run.stage = "closed".into(); + run.error = + Some("PR closed without merging; the issue was not completed.".into()); + run.sync_pending = run.permissions.writeback; + return self.sync(run).await; + } + _ => {} + } + let feedback = review_feedback(cwd, &pr).await?; + let fresh = feedback + .into_iter() + .filter(|(id, _)| !run.seen_review_ids.contains(id)) + .collect::>(); + if !fresh.is_empty() { + let pending=PendingFeedback { + ids:fresh.iter().map(|(id,_)|id.clone()).collect(), + body:format!("Continue this issue in the same worktree. Review the following external PR feedback, apply relevant fixes, rerun validation, and commit locally. Preserve repository rules and the original permissions. Do not push, merge, deploy, or post replies.\n\n{}",fresh.iter().map(|(_,body)|body.as_str()).collect::>().join("\n\n")), + request_id:format!("issue-review:{}:{}",run.id,uuid::Uuid::new_v4()), + }; + run.pending_feedback = Some(pending.clone()); + run.stage = "developing".into(); + run.verification = None; + self.save(run)?; + self.prompt(&session_id, &pending.body, pending.request_id) + .await?; + return Ok(()); + } + } + if run.stage != "blocked" || explicit { + self.verify_and_publish(run).await?; + } + self.sync(run).await + } + async fn verify_and_publish(&self, run: &mut IssueDelivery) -> PluginResult { + if run.terminal() { + return Err(error("This delivery attempt is already finished.")); + } + let session = self + .store + .get_session( + run.session_id + .as_deref() + .ok_or_else(|| error("Task creation is not complete."))?, + ) + .map_err(error)? + .ok_or_else(|| error("Session not found."))?; + if !matches!(session.activity.state, SessionRunState::Idle) { + return Err(error("Wait for the current session turn to finish.")); + } + if run.pending_issue.is_some() { + return Err(error("Review the changed issue first.")); + } + let cwd = Path::new(&session.cwd); + validate_checkout(run, &session).await?; + if !git(cwd, &["status", "--porcelain"]) + .await? + .trim() + .is_empty() + { + run.stage = "blocked".into(); + return Err(error( + "Commit the completed local changes before delivery validation.", + )); + } + let head = git(cwd, &["rev-parse", "HEAD"]).await?.trim().to_owned(); + if head == run.base_sha { + run.stage = "blocked".into(); + return Err(error("No committed feature changes are available yet.")); + } + if !run + .verification + .as_ref() + .is_some_and(|v| v.passed && v.head == head) + { + run.stage = "verifying".into(); + self.save(run)?; + let mut results = Vec::new(); + for command in &run.validation_commands { + results.push(validate_command(cwd, command).await?); + } + let passed = results.iter().all(|r| r.exit_code == Some(0)) + && git(cwd, &["rev-parse", "HEAD"]).await?.trim() == head + && git(cwd, &["status", "--porcelain"]) + .await? + .trim() + .is_empty(); + run.verification = Some(VerificationReceipt { + head: head.clone(), + passed, + results, + }); + if !passed { + run.stage = "blocked".into(); + return Err(error( + "Validation failed or changed the checkout. Review the recorded results.", + )); + } + } + run.error = None; + run.stage = "verified".into(); + if !run.permissions.create_pr { + return Ok(()); + } + if !run.permissions.push { + return Err(error("Push permission was not granted.")); + } + let branch = git(cwd, &["symbolic-ref", "--short", "HEAD"]) + .await? + .trim() + .to_owned(); + if branch == run.base_branch { + return Err(error( + "Delivery must use the task's isolated feature branch.", + )); + } + let common = git( + cwd, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ) + .await?; + if std::fs::canonicalize(common.trim()) + .map_err(error)? + .to_string_lossy() + != run.repository_identity + { + return Err(error( + "The checkout no longer belongs to the authorized repository.", + )); + } + let remote = codetwo_core::source_control::inspect(cwd) + .await + .map_err(error)? + .ok_or_else(|| error("The authorized remote is missing."))?; + if remote.remote_name != "origin" || remote.web_url != run.remote_url { + return Err(error("The remote changed since delivery was authorized.")); + } + if git(cwd, &["rev-parse", "HEAD"]).await?.trim() != head + || !git(cwd, &["status", "--porcelain"]) + .await? + .trim() + .is_empty() + { + return Err(error("The checkout changed after validation.")); + } + if run + .pull_request + .as_ref() + .and_then(|pr| pr["headRefOid"].as_str()) + != Some(head.as_str()) + { + git( + cwd, + &[ + "push", + "--", + "origin", + &format!("{head}:refs/heads/{branch}"), + ], + ) + .await?; + } + if let Some(pr) = bound_pr(cwd, run).await? { + run.pull_request = Some(pr); + } else { + let body = format!( + "{}\n\n## Acceptance\n{}\n\n## Verification\n{}\n\nValidated commit: `{}`\n", + run.issue.url, + run.acceptance, + run.validation_commands + .iter() + .map(|s| format!("- `{s}`")) + .collect::>() + .join("\n"), + head + ); + let body_file = tempfile::NamedTempFile::new().map_err(error)?; + std::fs::write(body_file.path(), body).map_err(error)?; + gh( + cwd, + vec![ + "pr".into(), + "create".into(), + "--repo".into(), + run.remote_url + .clone() + .ok_or_else(|| error("Authorized repository is missing."))?, + "--base".into(), + run.base_branch.clone(), + "--head".into(), + branch, + "--title".into(), + format!("{}: {}", run.issue.identifier, run.issue.title), + "--body-file".into(), + body_file.path().to_string_lossy().into_owned(), + ], + ) + .await?; + run.pull_request = bound_pr(cwd, run).await?; + } + run.stage = "review".into(); + run.sync_pending = run.permissions.writeback; + self.save(run) + } + async fn sync(&self, run: &mut IssueDelivery) -> PluginResult { + if !run.permissions.writeback { + return Ok(()); + } + let milestone = match run.stage.as_str() { + "creating" => return Ok(()), + "developing" => "started", + "review" => "review", + "merged" => "merged", + "closed" => "closed", + "cancelled" => "cancelled", + _ => return Ok(()), + }; + if run.synced_milestones.iter().any(|s| s == milestone) { + run.sync_pending = false; + return Ok(()); + } + run.sync_pending = true; + let connector = Connector { + plugin_id: run.plugin_id.clone(), + connector_id: run.connector_id.clone(), + }; + let status = match milestone { + "started" => "Development started in CodeTwo.", + "review" => { + "Implementation and validation completed; pull request is ready for review." + } + "merged" => "The linked pull request has been merged.", + "closed" => { + "The pull request was closed without merging. The issue is not marked complete." + } + _ => "Development was cancelled. The issue is not marked complete.", + }; + let body = format!( + "{}\n\n{}\n\n{}\n\n", + status, + run.pull_request + .as_ref() + .and_then(|pr| pr["url"].as_str()) + .unwrap_or(""), + if milestone == "merged" { + format!( + "Validation: {}", + run.verification + .as_ref() + .filter(|v| run + .pull_request + .as_ref() + .and_then(|p| p["headRefOid"].as_str()) + == Some(v.head.as_str())) + .map(|v| v.head.as_str()) + .unwrap_or("No local validation receipt for the merged revision") + ) + } else { + String::new() + }, + run.id, + milestone + ); + match self.invoke(&connector,"issues.sync",json!({"id":run.issue.id,"workspace_id":run.issue.workspace_id,"key":format!("{}:{milestone}",run.id),"body":body,"state_id":if milestone=="merged" {run.done_state_id.clone()}else{None}}),None).await { + Ok(_) => {run.synced_milestones.push(milestone.into());run.sync_pending=false;run.sync_error=None;} + Err(e) => {run.sync_error=Some(e.to_string());} + } + Ok(()) + } +} + +struct ProcessGroup(Option); +impl Drop for ProcessGroup { + fn drop(&mut self) { + #[cfg(unix)] + if let Some(pid) = self.0 { + unsafe extern "C" { + fn killpg(group: i32, signal: i32) -> i32; + } + unsafe { + killpg(pid as i32, 9); + } + } + } +} + +async fn bounded_output( + mut command: Command, + seconds: u64, +) -> Result<(Option, String), PluginError> { + command + .kill_on_drop(true) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .stdin(std::process::Stdio::null()); + #[cfg(unix)] + command.process_group(0); + let mut child = command + .spawn() + .map_err(|_| error("Could not start the requested command."))?; + let _group = ProcessGroup(child.id()); + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + let read = |stream: Box| async move { + let mut bytes = Vec::new(); + stream + .take(1_000_001) + .read_to_end(&mut bytes) + .await + .map_err(error)?; + if bytes.len() > 1_000_000 { + return Err(error("Command output exceeded its limit.")); + } + Ok::<_, PluginError>(bytes) + }; + let result = tokio::time::timeout(Duration::from_secs(seconds), async { + let (out, err, status) = + tokio::try_join!(read(Box::new(stdout)), read(Box::new(stderr)), async { + child.wait().await.map_err(error) + })?; + Ok::<_, PluginError>(( + status.code(), + String::from_utf8_lossy(&[out, err].concat()).into_owned(), + )) + }) + .await + .map_err(|_| error("Command timed out."))?; + result +} +async fn git(cwd: &Path, args: &[&str]) -> Result { + let mut command = Command::new("git"); + command + .args(args) + .current_dir(cwd) + .env("GIT_TERMINAL_PROMPT", "0"); + let (code, out) = bounded_output(command, 120).await?; + if code != Some(0) { + return Err(error( + "Git operation failed. Check the branch, repository and authentication.", + )); + } + Ok(out) +} +async fn gh(cwd: &Path, args: Vec) -> Result { + let mut command = Command::new("gh"); + command + .args(args) + .current_dir(cwd) + .env("GH_PROMPT_DISABLED", "1") + .env("GH_PAGER", "cat"); + let (code, out) = bounded_output(command, 120).await?; + if code != Some(0) { + return Err(error( + "GitHub operation failed. Check GitHub CLI authentication and repository access.", + )); + } + Ok(out) +} +fn requirements_changed(previous: &TrackedIssue, next: &TrackedIssue) -> bool { + fn comments(issue: &TrackedIssue) -> Vec { + issue + .comments + .iter() + .filter(|comment| { + !comment["body"] + .as_str() + .unwrap_or_default() + .contains(""})); + assert!(!requirements_changed(&old, &next)); + next.comments + .push(json!({"id":"user-1","body":"Also require keyboard support"})); + assert!(requirements_changed(&old, &next)); + let mut attachment = old.clone(); + attachment + .attachments + .push(json!({"id":"design-1","url":"https://example.invalid/new-design"})); + assert!(requirements_changed(&old, &attachment)); +} + +#[test] +fn pr_binding_rejects_other_repository_branch_base_or_replacement_pr() { + let mut run = delivery(); + run.remote_url = Some("https://github.com/test/repo".into()); + run.work_branch = Some("feature".into()); + let pr = json!({"number":7,"url":"https://github.com/test/repo/pull/7","headRefName":"feature","baseRefName":"main","isCrossRepository":false,"state":"OPEN","headRefOid":"head-1"}); + assert!(validate_pr(&run, &pr).is_ok()); + let mut fork_pr = pr.clone(); + fork_pr["isCrossRepository"] = json!(true); + assert!(validate_pr(&run, &fork_pr).is_err()); + for (field, value) in [ + ("url", "https://github.com/other/repo/pull/7"), + ("url", "https://github.com/test/repository/pull/7"), + ("headRefName", "another-feature"), + ("baseRefName", "another-base"), + ] { + let mut changed = pr.clone(); + changed[field] = json!(value); + assert!(validate_pr(&run, &changed).is_err()); + } + run.pull_request = Some(pr.clone()); + let mut replacement = pr; + replacement["number"] = json!(8); + replacement["url"] = json!("https://github.com/test/repo/pull/8"); + assert!(validate_pr(&run, &replacement).is_err()); +} + +#[tokio::test] +async fn checkout_binding_rejects_discard_remote_branch_and_repository_changes() { + let (controller, app, _context, _temp, mut run) = fixture().await; + let mut session = controller + .store + .get_session(run.session_id.as_deref().unwrap()) + .unwrap() + .unwrap(); + assert!(validate_checkout(&run, &session).await.is_ok()); + let original_directory = session.worktree_identity.clone(); + session.worktree_identity = Some( + codetwo_core::worktree::DirectoryIdentity::capture( + Path::new(&session.cwd).parent().unwrap(), + ) + .unwrap(), + ); + assert!(validate_checkout(&run, &session) + .await + .unwrap_err() + .to_string() + .contains("replaced")); + session.worktree_identity = original_directory; + session.worktree_discarded = true; + assert!(validate_checkout(&run, &session).await.is_err()); + session.worktree_discarded = false; + let original_identity = run.repository_identity.clone(); + run.repository_identity = "another-repository".into(); + assert!(validate_checkout(&run, &session).await.is_err()); + run.repository_identity = original_identity; + run.work_branch = Some("other-feature".into()); + assert!(validate_checkout(&run, &session).await.is_err()); + run.work_branch = Some("feature".into()); + git( + Path::new(&session.cwd), + &[ + "remote", + "add", + "origin", + "https://github.com/test/changed.git", + ], + ) + .await + .unwrap(); + assert!(validate_checkout(&run, &session).await.is_err()); + app.stop().await; +} + +fn install_test_connector(controller: &Controller) -> Connector { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../packs/linear"); + let bundle = + codetwo_plugins::bundle::from_local(&source, "test fixture", "test-linear").unwrap(); + let plugin = codetwo_plugins::bundle::install(&controller.hub.dir, bundle).unwrap(); + codetwo_plugins::bundle::set_enabled(&controller.hub.dir, &plugin.id, true).unwrap(); + codetwo_plugins::bundle::set_trusted(&controller.hub.dir, &plugin.id, true).unwrap(); + Connector { + plugin_id: plugin.id, + connector_id: "issues".into(), + } +} + +#[tokio::test] +async fn connector_gate_precedes_credentials_and_replaces_caller_secret() { + let (mut controller, app, context, _temp, _run) = fixture().await; + let connector = install_test_connector(&controller); + let credentials = Arc::new(MemoryCredentials(std::sync::Mutex::new(vec![]))); + controller.credentials = credentials.clone(); + context + .ctx() + .command("plugins.invoke_connector", |args| async move { + assert_eq!(args["input"]["_credential"], "fixture-secret"); + assert_eq!(args["operation"], "issues.get"); + Ok(json!({"id":"issue-1"})) + }) + .unwrap(); + codetwo_plugins::bundle::set_trusted(&controller.hub.dir, &connector.plugin_id, false).unwrap(); + assert!(controller + .invoke(&connector, "issues.get", json!({}), None) + .await + .is_err()); + assert!(credentials.0.lock().unwrap().is_empty()); + codetwo_plugins::bundle::set_trusted(&controller.hub.dir, &connector.plugin_id, true).unwrap(); + codetwo_plugins::bundle::set_enabled(&controller.hub.dir, &connector.plugin_id, false).unwrap(); + assert!(controller + .invoke(&connector, "issues.get", json!({}), None) + .await + .is_err()); + assert!(credentials.0.lock().unwrap().is_empty()); + codetwo_plugins::bundle::set_enabled(&controller.hub.dir, &connector.plugin_id, true).unwrap(); + let wrong = Connector { + plugin_id: connector.plugin_id.clone(), + connector_id: "not-owned".into(), + }; + assert!(controller + .invoke(&wrong, "issues.get", json!({}), None) + .await + .is_err()); + assert!(credentials.0.lock().unwrap().is_empty()); + let result = controller + .invoke( + &connector, + "issues.get", + json!({"id":"issue-1","_credential":"caller-supplied-secret"}), + None, + ) + .await + .unwrap(); + assert_eq!(result["id"], "issue-1"); + assert_eq!(credentials.0.lock().unwrap().len(), 1); + app.stop().await; +} + +#[tokio::test] +async fn tracker_writeback_requires_permission_and_retries_stable_milestone() { + let (mut controller, app, context, _temp, mut run) = fixture().await; + let connector = install_test_connector(&controller); + controller.credentials = Arc::new(MemoryCredentials(std::sync::Mutex::new(vec![]))); + run.plugin_id = connector.plugin_id; + run.stage = "merged".into(); + run.done_state_id = Some("done-1".into()); + run.pull_request = + Some(json!({"url":"https://github.com/test/repo/pull/7","headRefOid":"head-1"})); + let calls = Arc::new(std::sync::Mutex::new(Vec::::new())); + let observed = calls.clone(); + context + .ctx() + .command("plugins.invoke_connector", move |args| { + let observed = observed.clone(); + async move { + let mut calls = observed.lock().unwrap(); + calls.push(args.clone()); + if calls.len() == 1 { + Err(error("fixture-secret: remote error must be sanitized")) + } else { + Ok(json!({"comment_url":"https://linear.app/comment/1"})) + } + } + }) + .unwrap(); + controller.sync(&mut run).await.unwrap(); + assert!(calls.lock().unwrap().is_empty()); + run.permissions.writeback = true; + controller.sync(&mut run).await.unwrap(); + assert!(run.sync_pending); + assert!(!run.sync_error.as_ref().unwrap().contains("fixture-secret")); + assert!(run.synced_milestones.is_empty()); + controller.sync(&mut run).await.unwrap(); + assert!(!run.sync_pending); + assert_eq!(run.synced_milestones, vec!["merged"]); + controller.sync(&mut run).await.unwrap(); + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert_eq!( + calls[0], calls[1], + "ambiguous retry must preserve stable key and body" + ); + assert_eq!(calls[0]["input"]["workspace_id"], run.issue.workspace_id); + assert_eq!(calls[0]["input"]["state_id"], "done-1"); + assert!(serde_json::to_string(&run) + .unwrap() + .find("fixture-secret") + .is_none()); + drop(calls); + app.stop().await; +} + +#[tokio::test] +async fn cancelled_and_closed_attempts_never_request_done_state() { + let (mut controller, app, context, _temp, mut run) = fixture().await; + let connector = install_test_connector(&controller); + run.plugin_id = connector.plugin_id; + controller.credentials = Arc::new(MemoryCredentials(std::sync::Mutex::new(vec![]))); + run.permissions.writeback = true; + run.done_state_id = Some("done-1".into()); + run.session_id = None; + let calls = Arc::new(std::sync::Mutex::new(Vec::::new())); + let observed = calls.clone(); + context + .ctx() + .command("plugins.invoke_connector", move |args| { + let observed = observed.clone(); + async move { + observed.lock().unwrap().push(args); + Ok(json!({})) + } + }) + .unwrap(); + for stage in ["cancelled", "closed"] { + run.stage = stage.into(); + controller.reconcile_inner(&mut run, false).await.unwrap(); + assert_eq!(run.stage, stage); + } + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert!(calls.iter().all(|call| call["input"]["state_id"].is_null())); + drop(calls); + app.stop().await; +} + +#[tokio::test] +async fn feedback_is_marked_seen_only_after_durable_prompt_acceptance() { + let (controller, app, _context, _temp, mut run) = fixture().await; + run.pending_feedback = Some(PendingFeedback { + ids: vec!["review-1".into()], + body: "Fix feedback".into(), + request_id: "feedback-request".into(), + }); + controller.save(&mut run).unwrap(); + controller.reconcile_inner(&mut run, false).await.unwrap(); + assert!(run.seen_review_ids.is_empty()); + assert_eq!( + run.pending_feedback.as_ref().unwrap().request_id, + "feedback-request" + ); + for _ in 0..100 { + if controller + .store + .command_receipt("t3-prompt", "feedback-request") + .unwrap() + .is_some() + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(controller + .store + .command_receipt("t3-prompt", "feedback-request") + .unwrap() + .is_some()); + let _ = controller.reconcile_inner(&mut run, false).await; + assert_eq!(run.seen_review_ids, vec!["review-1"]); + assert!(run.pending_feedback.is_none()); + let _ = controller.reconcile_inner(&mut run, false).await; + assert_eq!( + run.seen_review_ids, + vec!["review-1"], + "accepted feedback must not be queued twice" + ); + app.stop().await; +} + +#[tokio::test] +async fn delivery_locks_are_per_task_and_cancellation_interrupts_validation() { + let (controller, app, _context, _temp, mut run) = fixture().await; + let gate = controller.run_gate(&run.id); + let lock = gate.lock().await; + assert!(controller.run_gate(&run.id).try_lock().is_err()); + assert!(controller + .run_gate("independent-delivery") + .try_lock() + .is_ok()); + drop(lock); + git( + Path::new(&run.repository), + &["commit", "--allow-empty", "-m", "feature"], + ) + .await + .unwrap(); + run.validation_commands = vec!["touch validation-started; sleep 10".into()]; + let run_id = run.id.clone(); + let repo = run.repository.clone(); + tokio::join!(controller.reconcile(&mut run, false), async { + for _ in 0..100 { + if Path::new(&repo).join("validation-started").exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + controller.cancellations.lock().unwrap().insert(run_id); + }); + assert_eq!(run.stage, "cancelled"); + assert!(run.verification.is_none()); + app.stop().await; +} + +async fn register_test_commands(controller: &Controller, context: &App, temp: &tempfile::TempDir) { + let ctx = context.ctx(); + ctx.provide(Arc::new(Paths::new(temp.path()))).unwrap(); + ctx.provide(Arc::new(StoreService(controller.store.clone()))) + .unwrap(); + ctx.provide(Arc::new(EngineService(controller.engine.clone()))) + .unwrap(); + ctx.provide(controller.hub.clone()).unwrap(); + // These command tests only exercise local cancellation/recovery branches with writeback=false; + // no connector is installed and no credential method is reachable. + IssueDeliveryPlugin.apply(ctx, Value::Null).await.unwrap(); +} + +#[tokio::test] +async fn cancel_command_is_idempotent_and_cannot_replace_merged_or_closed_outcomes() { + let (controller, app, context, temp, mut run) = fixture().await; + run.stage = "merged".into(); + controller.save(&mut run).unwrap(); + register_test_commands(&controller, &context, &temp).await; + for stage in ["merged", "closed"] { + run.stage = stage.into(); + controller.save(&mut run).unwrap(); + assert!(context + .ctx() + .call("issue_delivery.cancel", json!({"id":run.id})) + .await + .is_err()); + assert_eq!( + controller + .store + .issue_delivery(&run.id) + .unwrap() + .unwrap() + .stage, + stage + ); + } + run.stage = "cancelled".into(); + controller.save(&mut run).unwrap(); + for _ in 0..2 { + let cancelled = context + .ctx() + .call("issue_delivery.cancel", json!({"id":run.id})) + .await + .unwrap(); + assert_eq!(cancelled["stage"], "cancelled"); + } + app.stop().await; +} + +#[tokio::test] +async fn creation_retry_refuses_changed_base_and_recovers_existing_task_session() { + let (controller, app, context, temp, mut run) = fixture().await; + let existing_session = run.session_id.take().unwrap(); + run.stage = "blocked".into(); + controller.save(&mut run).unwrap(); + register_test_commands(&controller, &context, &temp).await; + git( + Path::new(&run.repository), + &["commit", "--allow-empty", "-m", "changed-base"], + ) + .await + .unwrap(); + let rejected = context + .ctx() + .call("issue_delivery.retry_creation", json!({"id":run.id})) + .await + .unwrap_err(); + assert!(rejected.to_string().contains("base changed")); + assert!(controller + .store + .get_task(&TaskId::new(run.task_id.clone())) + .unwrap() + .is_none()); + let task = codetwo_core::Task { + id: TaskId::new(run.task_id.clone()), + status: codetwo_core::TaskStatus::Active, + result_contract: codetwo_core::ResultContract { + goal: "Feature".into(), + required_deliverables: vec![], + completion_conditions: vec![], + boundaries: vec![], + known_risks: vec![], + unresolved_facts: vec![], + }, + provider_configuration: codetwo_core::ProviderConfiguration { + provider: ProviderId::Codex, + model: None, + reasoning_effort: None, + }, + budget: codetwo_core::TaskBudget { + max_cost_microusd: None, + max_tokens: None, + max_duration_seconds: None, + }, + }; + controller.store.create_task(&task, now()).unwrap(); + let existing = context + .ctx() + .call("issue_delivery.retry_creation", json!({"id":run.id})) + .await + .unwrap_err(); + assert!(existing.to_string().contains("task exists")); + controller + .store + .lease_task_session( + &task.id, + &existing_session, + &codetwo_core::AgentId::new("agent-1"), + codetwo_core::AgentRole::Executor, + "fixture", + now(), + ) + .unwrap(); + let recovered = context + .ctx() + .call("issue_delivery.retry_creation", json!({"id":run.id})) + .await + .unwrap(); + assert_eq!(recovered["session_id"], existing_session); + assert_eq!( + controller + .store + .list_task_session_leases(&task.id) + .unwrap() + .len(), + 1 + ); + app.stop().await; +} + +#[tokio::test] +async fn cancellation_signal_cannot_overwrite_merged_while_writeback_is_pending() { + let (mut controller, app, context, _temp, mut run) = fixture().await; + let connector = install_test_connector(&controller); + run.plugin_id = connector.plugin_id; + controller.credentials = Arc::new(MemoryCredentials(std::sync::Mutex::new(vec![]))); + run.stage = "merged".into(); + run.permissions.writeback = true; + run.sync_pending = true; + context + .ctx() + .command("plugins.invoke_connector", |_| async { + tokio::time::sleep(Duration::from_millis(100)).await; + Ok(json!({})) + }) + .unwrap(); + controller + .cancellations + .lock() + .unwrap() + .insert(run.id.clone()); + controller.reconcile(&mut run, false).await; + assert_eq!( + run.stage, "merged", + "cancel must preserve an observed merge even during network synchronization" + ); + app.stop().await; +} + +#[tokio::test] +async fn background_refresh_retries_after_sync_error_and_clears_it_on_recovery() { + let (mut controller, app, context, _temp, mut run) = fixture().await; + let connector = install_test_connector(&controller); + run.plugin_id = connector.plugin_id; + run.stage = "blocked".into(); + run.sync_error = Some("Previous connection or credential failure".into()); + controller.credentials = Arc::new(MemoryCredentials(std::sync::Mutex::new(vec![]))); + let issue = serde_json::to_value(&run.issue).unwrap(); + let calls = Arc::new(std::sync::Mutex::new(0)); + let observed = calls.clone(); + context + .ctx() + .command("plugins.invoke_connector", move |args| { + let observed = observed.clone(); + let issue = issue.clone(); + async move { + assert_eq!(args["operation"], "issues.get"); + let mut calls = observed.lock().unwrap(); + *calls += 1; + if *calls == 1 { + Err(error("Fixture network is still unavailable")) + } else { + Ok(issue) + } + } + }) + .unwrap(); + + controller.reconcile_inner(&mut run, false).await.unwrap(); + assert_eq!( + *calls.lock().unwrap(), + 1, + "background refresh must retry despite an existing sync error" + ); + assert!( + run.sync_error.is_some(), + "an unsuccessful retry must retain the error" + ); + controller.reconcile_inner(&mut run, false).await.unwrap(); + assert_eq!(*calls.lock().unwrap(), 2); + assert!( + run.sync_error.is_none(), + "successful background read must clear the stale error" + ); + assert!(run.pending_issue.is_none()); + assert_eq!( + run.stage, "blocked", + "connection recovery must not implicitly resume a blocked development task" + ); + app.stop().await; +} + +#[cfg(unix)] +#[tokio::test] +async fn local_delivery_without_pr_permission_does_not_invoke_github_cli() { + use std::os::unix::fs::PermissionsExt; + const CHILD: &str = "C2_TEST_LINEAR_GITLAB_CHILD"; + if std::env::var_os(CHILD).is_some() { + let mut failures = Vec::new(); + for remote in [ + "https://gitlab.com/test/repo", + "https://github.com/test/repo", + ] { + let (controller, app, _context, _temp, mut run) = fixture().await; + git( + Path::new(&run.repository), + &["remote", "add", "origin", &format!("{remote}.git")], + ) + .await + .unwrap(); + run.remote_url = Some(remote.into()); + git( + Path::new(&run.repository), + &["commit", "--allow-empty", "-m", "feature"], + ) + .await + .unwrap(); + match controller.reconcile_inner(&mut run, false).await { + Ok(()) => assert_eq!(run.stage, "verified"), + Err(error) => failures.push(format!("{remote}: {error}")), + } + app.stop().await; + } + assert!( + failures.is_empty(), + "local delivery was blocked: {failures:?}" + ); + assert!( + !Path::new(&std::env::var("C2_TEST_GH_MARKER").unwrap()).exists(), + "local delivery must not call gh" + ); + let (controller, app, _context, _temp, mut run) = fixture().await; + git( + Path::new(&run.repository), + &[ + "remote", + "add", + "origin", + "https://github.com/test/repo.git", + ], + ) + .await + .unwrap(); + run.remote_url = Some("https://github.com/test/repo".into()); + let bound = json!({"number":7,"url":"https://github.com/test/repo/pull/7","headRefName":"feature","baseRefName":"main","isCrossRepository":false,"state":"MERGED","headRefOid":run.base_sha}); + run.pull_request = Some(bound.clone()); + let response_file = format!("{}.json", std::env::var("C2_TEST_GH_MARKER").unwrap()); + std::fs::write(response_file, serde_json::to_vec(&bound).unwrap()).unwrap(); + controller.reconcile_inner(&mut run, false).await.unwrap(); + assert_eq!( + run.stage, "merged", + "previously bound PR must still be tracked when creation permission is off" + ); + app.stop().await; + return; + } + let temp = tempfile::tempdir().unwrap(); + let executable = temp.path().join("gh"); + std::fs::write(&executable, "#!/bin/sh\ntouch \"$C2_TEST_GH_MARKER\"\nif [ -f \"$C2_TEST_GH_MARKER.json\" ]; then cat \"$C2_TEST_GH_MARKER.json\"; exit 0; fi\nexit 71\n").unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + let original_path = std::env::var_os("PATH").unwrap(); + let paths = + std::iter::once(temp.path().to_path_buf()).chain(std::env::split_paths(&original_path)); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "issue_delivery::tests::local_delivery_without_pr_permission_does_not_invoke_github_cli", "--nocapture"]) + .env(CHILD, "1").env("PATH", std::env::join_paths(paths).unwrap()) + .env("C2_TEST_GH_MARKER", temp.path().join("gh-invoked")).env("GH_CONFIG_DIR", temp.path()).output().unwrap(); + assert!( + output.status.success(), + "isolated child failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[tokio::test] +async fn installed_linear_bundle_runs_through_real_connector_and_stdio_protocol_offline() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + let pack = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../packs/linear"); + std::fs::copy(pack.join("plugin.js"), source.join("plugin.js")).unwrap(); + let mut manifest: Value = + serde_json::from_slice(&std::fs::read(pack.join("plugin.json")).unwrap()).unwrap(); + manifest["extensions"]["dev.codetwo"]["runtime"]["args"] = + json!(["--require", "./fake-fetch.cjs", "plugin.js"]); + std::fs::write( + source.join("plugin.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(source.join("fake-fetch.cjs"), r#" +global.fetch = async (url, options) => { + if (url !== 'https://api.linear.app/graphql' || options.headers.Authorization !== 'fixture-secret') throw new Error('unexpected request'); + const {query, variables} = JSON.parse(options.body); + if (!query.includes('query Issue(') || variables.id !== 'APP-7') throw new Error('unexpected GraphQL operation'); + return new Response(JSON.stringify({data:{organization:{id:'workspace-1'},issue:{id:'issue-1',identifier:'APP-7',title:'Offline feature',url:'https://linear.app/test/issue/APP-7',description:'Acceptance',updatedAt:'2026-09-08',team:{id:'team-1',name:'Test'},state:{id:'state-1',name:'Todo',type:'unstarted'},comments:{nodes:[],pageInfo:{hasNextPage:false}},attachments:{nodes:[],pageInfo:{hasNextPage:false}},children:{nodes:[]}}}})); +}; +"#).unwrap(); + let data = temp.path().join("data"); + let plugins = Paths::new(&data).plugins(); + let bundle = + codetwo_plugins::bundle::from_local(&source, "offline contract fixture", "linear-offline") + .unwrap(); + let installed = codetwo_plugins::bundle::install(&plugins, bundle).unwrap(); + codetwo_plugins::bundle::set_enabled(&plugins, &installed.id, true).unwrap(); + codetwo_plugins::bundle::set_trusted(&plugins, &installed.id, true).unwrap(); + let app = CoreApp::boot( + AppConfig::bare_in(&data) + .with("paths", PluginEntry::with_config(json!({"data_dir":data}))) + .with("plugin-hub", PluginEntry::default()) + .with("extensions", PluginEntry::default()), + ) + .await + .unwrap(); + let request = json!({"plugin_id":installed.id,"contribution_id":"issues","operation":"issues.get","input":{"id":"APP-7","_credential":"fixture-secret"}}); + let detail = app.call("plugins.invoke_connector", request).await.unwrap(); + assert_eq!(detail["workspace_id"], "workspace-1"); + assert_eq!(detail["identifier"], "APP-7"); + assert_eq!(detail["title"], "Offline feature"); + assert!(!serde_json::to_string(&detail) + .unwrap() + .contains("fixture-secret")); + assert!(app.call("plugins.invoke_connector", json!({"plugin_id":installed.id,"contribution_id":"issues","operation":"message.send","input":{}})).await.is_err()); + app.stop().await; +} diff --git a/apps/desktop/src-host/src/lib.rs b/apps/desktop/src-host/src/lib.rs index 38d67b31..8060a8c5 100644 --- a/apps/desktop/src-host/src/lib.rs +++ b/apps/desktop/src-host/src/lib.rs @@ -10,6 +10,7 @@ mod automation; mod device_sync; mod github; mod host_events; +mod issue_delivery; mod lsp; mod remote; mod scene_mcp; @@ -183,6 +184,7 @@ pub async fn run() -> Result<(), String> { let host = events.clone(); registry.register(move || automation::AutomationPlugin::new(host.clone())); registry.register(|| github::GitHubPlugin); + registry.register(|| issue_delivery::IssueDeliveryPlugin); let host = events.clone(); registry.register(move || lsp::LspPlugin::new(host.clone())); let host = events.clone(); @@ -198,6 +200,7 @@ pub async fn run() -> Result<(), String> { for (name, category, essential, project_scoped) in [ ("automation", PluginCategory::Automation, false, false), ("github", PluginCategory::Integration, false, false), + ("issue-delivery", PluginCategory::Integration, false, false), ("lsp", PluginCategory::DeveloperTools, false, true), ("desktop-events", PluginCategory::Foundation, true, false), ("device-sync", PluginCategory::Integration, false, false), @@ -230,6 +233,7 @@ pub async fn run() -> Result<(), String> { let config = AppConfig::new(&data_dir) .with("automation", PluginEntry::default()) .with("github", PluginEntry::default()) + .with("issue-delivery", PluginEntry::default()) .with("desktop-events", PluginEntry::default()) .with("lsp", PluginEntry::default()) .with("device-sync", PluginEntry::default()) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 43961113..f0e7693e 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,6 +1,8 @@ import { lazy, Suspense, + useCallback, + useMemo, useEffect, useLayoutEffect, useRef, @@ -100,7 +102,6 @@ import { listArchivedSessions, listMemoryReceipts, lspSetRuntimeEnabled, - listPlugins, listProjectScripts, listProjects, listProviders, @@ -110,7 +111,6 @@ import { marketCatalog, marketInstall, planPluginChange, - pluginCatalog, newSession, onBrowserAgentActivity, onBrowserDownloadBlocked, @@ -206,7 +206,6 @@ import type { MemoryAccess, MemoryReceipt, ModelChoice, - PluginInfo, PluginMarketplace, ManagedPluginCatalog, Project, @@ -242,6 +241,7 @@ import { shouldOverlayRailForWorkspace, } from "./dock/Dock"; import type { DockSurface, DockTab } from "./dock/Dock"; +import type { DockerCommandCaller } from "./docker/DockerPage"; import { DocEditor } from "./editor/Editor"; import type { CanvasInsertOptions } from "./editor/Editor"; import { PreviewModal } from "./editor/Preview"; @@ -264,6 +264,12 @@ import type { WorkspaceLoadState } from "./git/state"; import { githubPullRequestReference } from "./github/pullRequests"; import type { PullRequestTaskLinkTarget } from "./github/PullRequestsPage"; import { useLanguage, useT } from "./i18n"; +import { issueDeliveryApi, issueStageLabel } from "./issues/issueDelivery"; +import type { IssueConnector, IssueDelivery } from "./issues/issueDelivery"; +import { + IssueConnectorSettings, + IssueDeliveryDialog, +} from "./issues/IssueDeliveryDialog"; import { IssuesModal } from "./issues/Issues"; import { actionForEvent, @@ -304,6 +310,7 @@ import { applyPluginManagerChange, planPluginManagerChange, } from "./plugins/lifecycle"; +import { usePluginSnapshot } from "./plugins/usePluginSnapshot"; import { ProjectIcon } from "./projects/ProjectIcon"; import { loadProviderRegistry } from "./providers/registry"; import { RemoteModal } from "./remote/Remote"; @@ -854,12 +861,24 @@ export default function App() { >("loading"); const providerRegistryRequestRef = useRef(0); const [skills, setSkills] = useState([]); - const [plugins, setPlugins] = useState([]); - const [managedUserCatalog, setManagedUserCatalog] = - useState(null); - const [managedProjectCatalogs, setManagedProjectCatalogs] = useState< - Record - >({}); + const { snapshot: pluginsSnapshot, refresh: refreshPluginsSnapshot } = + usePluginSnapshot(); + const plugins = useMemo( + () => pluginsSnapshot?.bundles ?? [], + [pluginsSnapshot] + ); + const managedUserCatalog = + pluginsSnapshot?.catalogs.find((item) => item.scope.kind === "user") + ?.catalog ?? null; + const managedProjectCatalogs = useMemo( + () => + Object.fromEntries( + (pluginsSnapshot?.catalogs ?? []).flatMap(({ scope, catalog }) => + scope.kind === "project" ? [[scope.projectPath, catalog]] : [] + ) + ) as Record, + [pluginsSnapshot] + ); const [pluginManagerScope, setPluginManagerScope] = useState({ kind: "user" }); const [sessions, setSessions] = useState([]); @@ -1121,6 +1140,31 @@ export default function App() { const [showPluginManager, setShowPluginManager] = useState(false); const [showDocker, setShowDocker] = useState(false); const [showFeishu, setShowFeishu] = useState(false); + const [issueConnector, setIssueConnector] = useState( + null + ); + const [issueDeliveries, setIssueDeliveries] = useState([]); + useEffect(() => { + let disposed = false; + let generation = 0; + const refresh = async () => { + const request = ++generation; + try { + const values = await issueDeliveryApi("list"); + if (!disposed && request === generation) setIssueDeliveries(values); + } catch { + /* Existing tasks remain visible when the host reconnects. */ + } + }; + void refresh(); + const timer = setInterval(() => { + void refresh(); + }, 5000); + return () => { + disposed = true; + clearInterval(timer); + }; + }, []); const [feishuRailHost, setFeishuRailHost] = useState( null ); @@ -5245,75 +5289,48 @@ export default function App() { void refreshSkills(); }, [refreshSkills]); - const loadManagedCatalog = async (scope: PluginManagerScope) => { - const normalizedScope: PluginManagerScope = - scope.kind === "user" - ? scope - : { - kind: "project", - projectPath: normalizePluginProjectPath(scope.projectPath), - }; - const next = await pluginCatalog(toManagedPluginScope(normalizedScope)); - if (normalizedScope.kind === "user") { - setManagedUserCatalog(next); - } else { - setManagedProjectCatalogs((current) => ({ - ...current, - [normalizedScope.projectPath]: next, - })); - } - return next; - }; - - const refreshManagedCatalogs = async ( - scope: PluginManagerScope = pluginManagerScope - ) => { - const projectPaths = new Set(); - if (scope.kind === "project") - projectPaths.add(normalizePluginProjectPath(scope.projectPath)); - if (activeProject != null && activeProject !== "") - projectPaths.add(normalizePluginProjectPath(activeProject)); - await Promise.all([ - loadManagedCatalog({ kind: "user" }), - ...Array.from( - projectPaths, - async (projectPath) => - await loadManagedCatalog({ kind: "project", projectPath }) - ), - ]); - }; - - // Component policy is runtime state, not merely data for the management page. Keep the user - // graph and the active project's inherited graph warm even while the page is closed. - useEffect(() => { - void refreshManagedCatalogs().catch((error: unknown) => { - console.warn("Could not load plugin catalog", error); - }); - }, [refreshManagedCatalogs]); + const refreshManagedCatalogs = useCallback( + async (scope: PluginManagerScope = pluginManagerScope) => { + const projectPaths = new Set(); + if (scope.kind === "project") + projectPaths.add(normalizePluginProjectPath(scope.projectPath)); + if (activeProject != null && activeProject !== "") + projectPaths.add(normalizePluginProjectPath(activeProject)); + return await refreshPluginsSnapshot([ + { kind: "user" }, + ...Array.from(projectPaths, (projectPath) => ({ + kind: "project" as const, + projectPath, + })), + ]); + }, + [activeProject, pluginManagerScope, refreshPluginsSnapshot] + ); + const loadManagedCatalog = refreshManagedCatalogs; + // Subscribe before the initial read so changes during bootstrap cannot be missed. useEffect(() => { let disposed = false; - let unsubscribe = () => { - /* empty */ + let unsubscribe = () => {}; + const refresh = () => { + if (!disposed) + void refreshManagedCatalogs().catch((error: unknown) => { + console.warn("Could not refresh plugin snapshot", error); + }); }; - const refreshBundles = async () => - await listPlugins() - .then((next) => { - if (!disposed) setPlugins(next); - }) - .catch((error: unknown) => - console.warn("Could not load plugin bundles", error) - ); - void refreshBundles(); - void onPluginsChanged(() => { - void refreshBundles(); - void refreshManagedCatalogs().catch((error: unknown) => { - console.warn("Could not refresh plugin catalog", error); + void onPluginsChanged(refresh) + .then((stop) => { + if (disposed) { + stop(); + return; + } + unsubscribe = stop; + refresh(); + }) + .catch((error: unknown) => { + console.warn("Could not subscribe to plugin changes", error); + refresh(); }); - }).then((stop) => { - if (disposed) stop(); - else unsubscribe = stop; - }); return () => { disposed = true; unsubscribe(); @@ -5425,8 +5442,22 @@ export default function App() { useEffect(() => { if (showDocker && !dockerPlugin) setShowDocker(false); }, [dockerPlugin, showDocker]); - const callDocker = async (name: string, args?: unknown) => - await call(name, args, null); + const callDocker = useCallback( + async (name: string, args?: unknown) => await call(name, args, null), + [] + ); + const issueConnectors: IssueConnector[] = activePluginConnectorContributions( + plugins, + activePluginModel.plugins + ) + .filter((connector) => connector.capabilities.includes("issues")) + .map((connector) => ({ + plugin_id: connector.pluginId, + connector_id: connector.id, + name: + plugins.find((plugin) => plugin.id === connector.pluginId)?.name ?? + connector.provider, + })); const collaborationConnector = activePluginConnectorContributions(plugins, activePluginModel.plugins).find( (connector) => connector.provider === "feishu" @@ -5816,17 +5847,8 @@ export default function App() { setLocalPluginMarketplace(null); marketCatalog() .then(setMarket) - .catch(() => { - /* empty */ - }); - listPlugins() - .then(setPlugins) - .catch(() => { - /* empty */ - }); - void refreshManagedCatalogs(scope).catch(() => { - /* empty */ - }); + .catch(() => {}); + void refreshManagedCatalogs(scope).catch(() => {}); void refreshSkills(); setPluginManagerInitialPluginId(pluginId); setShowAutomations(false); @@ -5846,19 +5868,17 @@ export default function App() { openPluginManagerFor(`bundle:${collaborationConnector.pluginId}`); }; - const refreshPluginManagerData = async ( - scope: PluginManagerScope = pluginManagerScope - ) => { - const [nextMarket, nextPlugins, nextSkills] = await Promise.all([ - marketCatalog(), - listPlugins(), - refreshSkills(), - refreshManagedCatalogs(scope), - ]); - setMarket(nextMarket); - setPlugins(nextPlugins); - setSkills(nextSkills); - }; + const refreshPluginManagerData = useCallback( + async (scope: PluginManagerScope = pluginManagerScope) => { + const [nextMarket] = await Promise.all([ + marketCatalog(), + refreshSkills(), + refreshManagedCatalogs(scope), + ]); + setMarket(nextMarket); + }, + [pluginManagerScope, refreshManagedCatalogs, refreshSkills] + ); const planManagerChange = async ( request: PluginManagerChangeRequest @@ -7988,6 +8008,16 @@ export default function App() { scope={pluginManagerScope} projects={pluginManagerProjects} initialPluginId={pluginManagerInitialPluginId} + pluginDetailsExtensions={issueConnectors.map((connector) => ({ + pluginId: `bundle:${connector.plugin_id}`, + content: ( + setIssueConnector(connector)} + /> + ), + }))} pluginDetailsExtension={ collaborationConnector ? { @@ -8443,6 +8473,28 @@ export default function App() { )}
+ {issueDeliveries + .filter( + (delivery) => + delivery.session_id === activeSession + ) + .map((delivery) => ( + + ))} )} + {issueConnector ? ( + { + setIssueConnector(null); + setShowPluginManager(false); + void selectSession(id); + }} + onClose={() => setIssueConnector(null)} + /> + ) : null} {showIssues && componentEnabled("issues.modal") && ( ({ + name: connector.name, + open: () => { + setShowIssues(false); + setIssueConnector(connector); + }, + }))} cwd={cwd || "."} scenes={scenesSurfaceEnabled ? scenes : []} onInsert={(i) => void insertIssue(i)} diff --git a/apps/desktop/src/bridge.ts b/apps/desktop/src/bridge.ts index 3a298525..4c0cb181 100644 --- a/apps/desktop/src/bridge.ts +++ b/apps/desktop/src/bridge.ts @@ -1476,7 +1476,23 @@ export interface ManagedPluginDependencies { optional: string[]; } +export interface EffectivePluginState { + effective_enabled: boolean; + state: ManagedPluginOverride; + status: PluginStatus | "disabled"; + missing: string[]; + error: string | null; + reason: string | null; +} + +export type PluginProcessObservation = + | { phase: "dormant" | "starting" | "running" } + | { phase: "failed"; error: string }; + export interface ManagedPluginCatalogEntry { + process?: PluginProcessObservation | null; + effective_state?: EffectivePluginState; + effective_components?: Record; id: string; description: string | null; metadata: ManagedPluginMetadata; @@ -1502,6 +1518,7 @@ export type ManagedPluginRecovery = | { kind: "safe_mode"; error: string }; export interface ManagedPluginCatalog { + bundle_states?: Record; graph_revision: number; config_revision: number; recovery: ManagedPluginRecovery; @@ -1597,6 +1614,44 @@ export async function pluginCatalog( ); } +export interface PluginSnapshot { + bundles: PluginInfo[]; + catalogs: { scope: ManagedPluginScope; catalog: ManagedPluginCatalog }[]; +} + +/** One inventory-consistent snapshot; the host always includes the user catalog. */ +export async function pluginSnapshot( + scopes: ManagedPluginScope[] +): Promise { + if (!inDesktop) { + return { + bundles: await listPlugins(), + catalogs: [ + { kind: "user" } as ManagedPluginScope, + ...scopes.filter((scope) => scope.kind !== "user"), + ].map((scope) => ({ scope, catalog: EMPTY_MANAGED_PLUGIN_CATALOG })), + }; + } + const wire = await call<{ + bundles: PluginInfo[]; + catalogs: { + scope: ManagedPluginScopeWire; + catalog: ManagedPluginCatalog; + }[]; + }>( + "plugins.snapshot", + { scopes: scopes.map(managedPluginScopeToWire) }, + null + ); + return { + bundles: wire.bundles, + catalogs: wire.catalogs.map(({ scope, catalog }) => ({ + scope: managedPluginScopeFromWire(scope), + catalog, + })), + }; +} + /** Stage a revision-bound change. The returned id is the only value accepted by apply. */ export async function planPluginChange( request: ManagedPluginChangeRequest diff --git a/apps/desktop/src/i18n/strings.ts b/apps/desktop/src/i18n/strings.ts index dcd3145d..2ee03d5f 100644 --- a/apps/desktop/src/i18n/strings.ts +++ b/apps/desktop/src/i18n/strings.ts @@ -7,6 +7,67 @@ * an English word appearing in the middle of a Chinese sentence at runtime. */ export const en = { + "issueFlow.retryCreation": "Retry task creation", + "issueFlow.connection": "Issue tracker connection", + "issueFlow.apiKey": "API key", + "issueFlow.credentialHint": + "Saved in the system credential store. Never added to prompts or repository files.", + "issueFlow.connect": "Connect", + "issueFlow.disconnect": "Disconnect", + "issueFlow.open": "Issue development", + "issueFlow.working": "Working…", + "issueFlow.search": "Search issues or paste a Linear URL", + "issueFlow.searchButton": "Search", + "issueFlow.team": "Team", + "issueFlow.allTeams": "All teams", + "issueFlow.mine": "Assigned to me", + "issueFlow.more": "Load more", + "issueFlow.recent": "Development history", + "issueFlow.selectIssue": + "Select an issue or resume an existing development task.", + "issueFlow.context": "Description, comments and attachments", + "issueFlow.repository": "Repository", + "issueFlow.provider": "Agent provider", + "issueFlow.base": "Base: current checkout branch", + "issueFlow.acceptance": "Acceptance criteria", + "issueFlow.validation": "Validation commands (one per line)", + "issueFlow.validationHint": + "These commands run in the isolated task worktree after development. Review them before starting.", + "issueFlow.allowPush": "Allow pushing this task branch", + "issueFlow.allowPr": "Allow creating the linked pull request", + "issueFlow.allowWriteback": + "Allow milestone comments and the selected status update", + "issueFlow.doneState": "After the PR is merged", + "issueFlow.nativeState": + "Keep status unchanged / use existing GitHub automation", + "issueFlow.startHint": + "Creates an isolated task and commits locally. External operations follow the permissions above. Merge and release remain governed by repository rules.", + "issueFlow.start": "Start development", + "issueFlow.creating": "Creating task", + "issueFlow.developing": "Developing", + "issueFlow.verifying": "Validating", + "issueFlow.verified": "Validation passed", + "issueFlow.review": "Awaiting review", + "issueFlow.blocked": "Needs attention", + "issueFlow.merged": "PR merged", + "issueFlow.closed": "PR closed without merge", + "issueFlow.cancelled": "Cancelled", + "issueFlow.syncPending": "Sync pending", + "issueFlow.syncError": "Tracker sync needs attention.", + "issueFlow.validationResults": "Validation results", + "issueFlow.pass": "Passed", + "issueFlow.fail": "Failed", + "issueFlow.openTask": "Open task", + "issueFlow.refresh": "Refresh / retry sync", + "issueFlow.cancel": "Cancel development", + "issueFlow.requirementsChanged": + "The issue changed after development started. Review the update before continuing.", + "issueFlow.changedContext": "Updated requirements", + "issueFlow.acceptChanges": "Accept the updated requirements", + "issueFlow.clarification": "Clarification or continuation instructions", + "issueFlow.continue": "Continue in the same task", + "issueFlow.newAttempt": "Start a new attempt", + // app "app.name": "C2", @@ -2862,6 +2923,62 @@ export type StringKey = keyof typeof en; /** Simplified Chinese. Typed against `en`, so an omission won't compile. */ export const zhCN: Record = { + "issueFlow.retryCreation": "重试创建任务", + "issueFlow.connection": "需求平台连接", + "issueFlow.apiKey": "API Key", + "issueFlow.credentialHint": "保存于系统凭据库,不会加入提示词或仓库文件。", + "issueFlow.connect": "连接", + "issueFlow.disconnect": "断开连接", + "issueFlow.open": "Issue 开发", + "issueFlow.working": "处理中…", + "issueFlow.search": "搜索需求或粘贴 Linear 链接", + "issueFlow.searchButton": "搜索", + "issueFlow.team": "团队", + "issueFlow.allTeams": "所有团队", + "issueFlow.mine": "分配给我", + "issueFlow.more": "加载更多", + "issueFlow.recent": "开发记录", + "issueFlow.selectIssue": "选择一个 Issue,或继续已有开发任务。", + "issueFlow.context": "描述、评论与附件", + "issueFlow.repository": "仓库", + "issueFlow.provider": "Agent 提供方", + "issueFlow.base": "基线:当前检出分支", + "issueFlow.acceptance": "验收条件", + "issueFlow.validation": "验证命令(每行一条)", + "issueFlow.validationHint": + "开发结束后,将在独立任务 worktree 中执行这些命令。开始前请检查。", + "issueFlow.allowPush": "允许推送此任务分支", + "issueFlow.allowPr": "允许创建关联 PR", + "issueFlow.allowWriteback": "允许回写里程碑评论和所选状态", + "issueFlow.doneState": "PR 合并后", + "issueFlow.nativeState": "保持状态不变/沿用已有 GitHub 自动化", + "issueFlow.startHint": + "创建独立任务并在本地提交。外部操作遵循以上授权;合并和发布仍遵循仓库规则。", + "issueFlow.start": "开始开发", + "issueFlow.creating": "创建任务中", + "issueFlow.developing": "开发中", + "issueFlow.verifying": "验证中", + "issueFlow.verified": "验证通过", + "issueFlow.review": "待评审", + "issueFlow.blocked": "需要处理", + "issueFlow.merged": "PR 已合并", + "issueFlow.closed": "PR 已关闭,未合并", + "issueFlow.cancelled": "已取消", + "issueFlow.syncPending": "待同步", + "issueFlow.syncError": "平台同步需要处理。", + "issueFlow.validationResults": "验证结果", + "issueFlow.pass": "通过", + "issueFlow.fail": "失败", + "issueFlow.openTask": "打开任务", + "issueFlow.refresh": "刷新/重试同步", + "issueFlow.cancel": "取消开发", + "issueFlow.requirementsChanged": "开发开始后需求发生变化。请检查更新后继续。", + "issueFlow.changedContext": "更新后的需求", + "issueFlow.acceptChanges": "接受更新后的需求", + "issueFlow.clarification": "补充说明或继续开发的要求", + "issueFlow.continue": "在原任务中继续", + "issueFlow.newAttempt": "开始新一轮开发", + "app.name": "C2", "rail.noProject": "未选择项目", "rail.projectsEmpty": diff --git a/apps/desktop/src/issues/IssueDeliveryDialog.tsx b/apps/desktop/src/issues/IssueDeliveryDialog.tsx new file mode 100644 index 00000000..3294d78b --- /dev/null +++ b/apps/desktop/src/issues/IssueDeliveryDialog.tsx @@ -0,0 +1,906 @@ +import { useCallback, useEffect, useId, useRef, useState } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { asJsonObject } from "@/lib/jsonValue"; + +import { useT } from "../i18n"; +import { + connectorIdentity, + issueDeliveryApi, + issueStageLabel, + safeIssueLink, +} from "./issueDelivery"; +import type { + IssueConnector, + IssueDelivery, + IssueDeliveryApi, + TrackerConnection, + TrackedIssue, +} from "./issueDelivery"; + +function Choice({ + label, + value, + options, + onChange, +}: { + label: string; + value: string; + options: { value: string; label: string }[]; + onChange: (value: string) => void; +}) { + return ( + + {label} + + + ); +} + +export function IssueConnectorSettings({ + connector, + onOpen, + api = issueDeliveryApi, +}: { + connector: IssueConnector; + onOpen: () => void; + api?: IssueDeliveryApi; +}) { + const t = useT(); + const tokenId = useId(); + const [token, setToken] = useState(""); + const [connection, setConnection] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + useEffect(() => { + let disposed = false; + void api("read", { + connector: connectorIdentity(connector), + operation: "connection.info", + }) + .then((value) => { + if (!disposed) setConnection(value); + }) + .catch(() => {}); + return () => { + disposed = true; + }; + }, [api, connector]); + async function connect() { + setBusy(true); + setError(null); + try { + setConnection( + await api("connect", { + connector: connectorIdentity(connector), + token, + }) + ); + } catch (reason) { + setError(String(reason)); + } finally { + setToken(""); + setBusy(false); + } + } + async function disconnect() { + setBusy(true); + setError(null); + try { + await api("disconnect", connectorIdentity(connector)); + setConnection(null); + } catch (reason) { + setError(String(reason)); + } finally { + setBusy(false); + } + } + return ( +
+

{t("issueFlow.connection")}

+ {connection ? ( + <> +

+ {connection.workspace.name} · {connection.account.name} +

+
+ + +
+ + ) : ( + <> + + {t("issueFlow.apiKey")} + setToken(e.target.value)} + /> + +

+ {t("issueFlow.credentialHint")} +

+ + + )} + {error != null && error !== "" ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +export function IssueDeliveryDialog({ + connector, + projects, + providers, + repository: initialRepository, + onOpenSession, + onClose, + api = issueDeliveryApi, +}: { + connector: IssueConnector; + projects: { path: string; name: string }[]; + providers: { + id: string; + display_name: string; + available: boolean; + enabled: boolean; + }[]; + repository: string; + onOpenSession: (id: string) => void; + onClose: () => void; + api?: IssueDeliveryApi; +}) { + const t = useT(); + const [connection, setConnection] = useState(null); + const [query, setQuery] = useState(""); + const [team, setTeam] = useState("all"); + const [mine, setMine] = useState(false); + const [items, setItems] = useState([]); + const [cursor, setCursor] = useState(null); + const [selectedRunId, setSelectedRunId] = useState(null); + const [issue, setIssue] = useState(null); + const [runs, setRuns] = useState([]); + const [repository, setRepository] = useState( + initialRepository || projects[0]?.path || "" + ); + const [provider, setProvider] = useState( + providers.find((p) => p.available && p.enabled)?.id ?? "" + ); + const [repositoryIdentity, setRepositoryIdentity] = useState(""); + const [base, setBase] = useState(""); + const [validation, setValidation] = useState(""); + const [acceptance, setAcceptance] = useState(""); + const [newAttemptMode, setNewAttemptMode] = useState(false); + const [doneState, setDoneState] = useState("native"); + const [message, setMessage] = useState(""); + const [acceptUpdate, setAcceptUpdate] = useState(false); + const [permissions, setPermissions] = useState({ + push: true, + create_pr: true, + writeback: true, + }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const searchRequest = useRef(0); + const detailRequest = useRef(0); + const runsRequest = useRef(0); + const preferencesKey = connection + ? `codetwo.issue-delivery.${connection.workspace.id}.${connector.connector_id}` + : null; + const hydratedPreferences = useRef(null); + useEffect(() => { + if ( + preferencesKey === null || + hydratedPreferences.current === preferencesKey + ) + return; + hydratedPreferences.current = preferencesKey; + try { + const preferences = asJsonObject( + JSON.parse(localStorage.getItem(preferencesKey) ?? "null") + ); + if (preferences === null) return; + if ( + !initialRepository && + typeof preferences.repository === "string" && + projects.some((project) => project.path === preferences.repository) + ) + setRepository(preferences.repository); + if ( + typeof preferences.provider === "string" && + providers.some( + (candidate) => + candidate.id === preferences.provider && + candidate.available && + candidate.enabled + ) + ) + setProvider(preferences.provider); + if ( + typeof preferences.team === "string" && + connection?.teams.some( + (candidate) => candidate.id === preferences.team + ) === true + ) + setTeam(preferences.team); + const savedPermissions = asJsonObject(preferences.permissions); + if ( + savedPermissions !== null && + typeof savedPermissions.push === "boolean" && + typeof savedPermissions.create_pr === "boolean" && + typeof savedPermissions.writeback === "boolean" + ) { + setPermissions({ + push: savedPermissions.push, + create_pr: savedPermissions.create_pr, + writeback: savedPermissions.writeback, + }); + } + } catch { + /* Missing or invalid preferences use the visible form defaults. */ + } + }, [connection, initialRepository, preferencesKey, projects, providers]); + const identity = connectorIdentity(connector); + const refresh = useCallback(async () => { + const request = ++runsRequest.current; + const values = await api("list"); + if (request === runsRequest.current) setRuns(values); + }, [api]); + const loadConnection = useCallback(async () => { + setConnection( + await api("read", { + connector: { + plugin_id: connector.plugin_id, + connector_id: connector.connector_id, + }, + operation: "connection.info", + }) + ); + }, [api, connector.plugin_id, connector.connector_id]); + useEffect(() => { + void loadConnection().catch(() => {}); + void refresh().catch((reason: unknown) => setError(String(reason))); + const timer = setInterval(() => { + void refresh().catch(() => {}); + }, 5000); + return () => { + clearInterval(timer); + runsRequest.current++; + searchRequest.current++; + detailRequest.current++; + }; + }, [loadConnection, refresh]); + useEffect(() => { + let disposed = false; + setBase(""); + setRepositoryIdentity(""); + if (repository) + void api<{ + base_branch: string; + validation_command: string; + repository_identity: string; + }>("repository", { path: repository }) + .then((value) => { + if (!disposed) { + setBase(value.base_branch); + setValidation(value.validation_command); + setRepositoryIdentity(value.repository_identity); + } + }) + .catch((reason: unknown) => { + if (!disposed) setError(String(reason)); + }); + return () => { + disposed = true; + }; + }, [api, repository]); + async function selectIssue(id: string) { + const request = ++detailRequest.current; + setError(null); + try { + const value = await api("read", { + connector: identity, + operation: "issues.get", + input: { id }, + }); + if (request === detailRequest.current) { + setIssue(value); + setSelectedRunId(null); + setAcceptance(value.description); + setDoneState("native"); + setNewAttemptMode(false); + } + } catch (reason) { + if (request === detailRequest.current) setError(String(reason)); + } + } + async function search(more = false) { + const request = ++searchRequest.current; + setError(null); + try { + if ( + !more && + (query.startsWith("https://") || + /^[a-zA-Z][a-zA-Z0-9]*-\d+$/.test(query.trim())) + ) { + await selectIssue(query); + return; + } + const result = await api<{ + items: TrackedIssue[]; + cursor: string | null; + }>("read", { + connector: identity, + operation: "issues.list", + input: { + query: query.trim(), + team_id: team === "all" ? undefined : team, + assignee_id: mine ? connection?.account.id : undefined, + cursor: more ? cursor : undefined, + }, + }); + if (request === searchRequest.current) { + setItems((previous) => + more ? [...previous, ...result.items] : result.items + ); + setCursor(result.cursor); + } + } catch (reason) { + if (request === searchRequest.current) setError(String(reason)); + } + } + const matchingRuns = issue + ? runs.filter( + (r) => + r.issue.workspace_id === issue.workspace_id && + r.issue.id === issue.id && + (r.repository_identity === repositoryIdentity || + r.repository === repository) + ) + : []; + const run = + matchingRuns.find((candidate) => candidate.id === selectedRunId) ?? + matchingRuns.find( + (candidate) => + !["merged", "closed", "cancelled"].includes(candidate.stage) + ) ?? + matchingRuns.at(0); + const terminal = + run !== undefined && ["merged", "closed", "cancelled"].includes(run.stage); + const workspaceRuns = runs.filter( + (r) => + r.plugin_id === connector.plugin_id && + r.connector_id === connector.connector_id + ); + async function start(newAttempt = false) { + if (!issue) return; + setBusy(true); + setError(null); + try { + const result = await api("start", { + connector: identity, + issue_id: issue.id, + repository, + provider, + base_branch: base, + acceptance, + validation_commands: validation + .split("\n") + .map((v) => v.trim()) + .filter(Boolean), + permissions, + done_state_id: doneState === "native" ? null : doneState, + new_attempt: newAttempt, + }); + if (preferencesKey !== null) { + try { + localStorage.setItem( + preferencesKey, + JSON.stringify({ repository, provider, team, permissions }) + ); + } catch { + /* The durable task retains the actual authorization. */ + } + } + await refresh(); + setSelectedRunId(result.id); + setNewAttemptMode(false); + if (result.session_id != null && result.session_id !== "") + onOpenSession(result.session_id); + } catch (reason) { + setError(String(reason)); + } finally { + setBusy(false); + } + } + async function action(operation: string) { + if (!run) return; + setBusy(true); + setError(null); + try { + await api(operation, { + id: run.id, + message, + accept_issue_update: acceptUpdate, + }); + await refresh(); + setMessage(""); + setAcceptUpdate(false); + } catch (reason) { + setError(String(reason)); + } finally { + setBusy(false); + } + } + const states = + connection?.teams + .find((candidate) => candidate.id === issue?.team_id) + ?.states.nodes.filter((state) => state.type === "completed") ?? []; + return ( + { + if (!open) onClose(); + }} + > + + + + {connector.name} · {t("issueFlow.open")} + + + {error != null && error !== "" ? ( +

+ {error} +

+ ) : null} + {connection ? null : ( + + void loadConnection().catch((reason: unknown) => + setError(String(reason)) + ) + } + /> + )} +
+ +
+ {issue ? ( + <> +
+ + {issue.identifier} + +

{issue.title}

+
+
+ {t("issueFlow.context")} +

+ {issue.description} +

+ {issue.comments.map((comment) => ( +
+

{comment.user?.name}

+

+ {comment.body} +

+
+ ))} + {issue.attachments.map((attachment) => ( +

+ + {attachment.title} + +

+ ))} +
+ ({ + value: project.path, + label: project.name, + })), + ...(repository && + !projects.some((project) => project.path === repository) + ? [{ value: repository, label: repository }] + : []), + ]} + /> + {run && !newAttemptMode ? ( + <> +
+ {issueStageLabel(run.stage, t)} + {run.sync_pending ? ( + + {t("issueFlow.syncPending")} + + ) : null} +
+ {run.error != null && run.error !== "" ? ( +

+ {run.error} +

+ ) : null} + {run.sync_error != null && run.sync_error !== "" ? ( +

+ {t("issueFlow.syncError")} {run.sync_error} +

+ ) : null} + {run.pull_request ? ( + + PR #{run.pull_request.number} · {run.pull_request.state} + + ) : null} + {run.verification ? ( +
+ + {t("issueFlow.validationResults")} ·{" "} + {run.verification.passed + ? t("issueFlow.pass") + : t("issueFlow.fail")} + + {run.verification.head} + {run.verification.results.map((result, index) => ( +
+

+ {result.command} ·{" "} + {result.exit_code ?? t("issueFlow.fail")} +

+
+                              {result.output}
+                            
+
+ ))} +
+ ) : null} +
+ + + {(run.session_id == null || run.session_id === "") && + !terminal ? ( + + ) : null} + {terminal ? null : ( + + )} +
+ {terminal ? ( + + ) : ( + <> + {run.pending_issue ? ( +
+

{t("issueFlow.requirementsChanged")}

+
+ {t("issueFlow.changedContext")} +

+ {run.pending_issue.description} +

+
+ +
+ ) : null} +