diff --git a/.agents/skills/debug-logs/SKILL.md b/.agents/skills/debug-logs/SKILL.md index 1e3dcfdcfa..9a8365e6c9 100644 --- a/.agents/skills/debug-logs/SKILL.md +++ b/.agents/skills/debug-logs/SKILL.md @@ -1,40 +1,59 @@ --- name: debug-logs -description: Add temporary debug logs to investigate issues. Use when the user needs to trace runtime behavior in the frontend or backend. Debug logs must be stripped before creating a PR. +description: Add temporary debug logs (console.log / structured Warn) to investigate issues. Use whenever the user wants to add logs, log statements, console.logs, trace, instrument, or print runtime behaviour to debug a frontend or backend issue. Triggers include "add debug logs", "add some logs", "log this", "trace this", "instrument", "investigate why", "print", "console.log around". Debug logs must be stripped before creating a PR. --- # Debug Logs Add temporary debug logs to investigate runtime issues. These logs are **never merged** — they must be removed before creating a PR. +**Use this skill any time you are about to add a `console.log`, `logger.Warn("[DEBUG] ...`, or similar transient instrumentation.** Even if the user says just "add some logs", "throw a few logs in there", "trace this", or "instrument X", apply these rules. + ## Rules 1. **All debug logs are temporary.** Strip them before running `/commit` or `/pr`. -2. Use a consistent, searchable prefix so logs can be found and removed easily. -3. Log object fields **inline** — do not log raw objects (they render as `Object` in the browser console and require expanding). +2. Use a consistent, searchable prefix so logs can be found and removed easily (e.g. `[reorder-bug]`, `[WS-DEBUG]`). +3. **Always print every value inline as a string.** Browser DevTools and many terminal viewers collapse arrays/maps/nested objects (`Array(2)`, `{...}`) — the agent must serialise these into the log message itself, not pass them as additional arguments. +4. Prefer **one template literal** per `console.log` call. Use `\n` and indentation to lay out structured data so the user can read it without clicking to expand. ## Frontend (TypeScript) - **Level:** `console.log` — not `console.debug` (hidden by default) or `console.warn` (noisy). -- **Prefix:** `[WS-DEBUG]` (or another `[AREA-DEBUG]` prefix agreed with the user). -- **Format:** Log fields inline as key-value pairs so they are visible without expanding. +- **Prefix:** `[area-bug]` / `[AREA-DEBUG]` agreed with the user. +- **Format:** A single template-literal string. Inline every field. Pre-format arrays/objects with `.map(...).join(...)` or `JSON.stringify(...)` before interpolating. -### ✅ Correct +### ✅ Correct — template literal, every value inlined ```typescript -console.log("[WS-DEBUG] subscribeSession", { - sessionId, - refCount: current + 1, - sentMessage: shouldSend, -}); +console.log( + `[reorder-bug] sidebar:render sort=${sort.key}:${sort.direction} active=${activeId ?? "-"}\n` + + ` inputOrder:\n ${inputs.map((t) => `${t.id}|${t.title}|state=${t.state}`).join("\n ")}\n` + + ` outputOrder:\n ${outputs.map((t) => t.id).join(", ")}`, +); ``` -Console output: -``` -[WS-DEBUG] subscribeSession {sessionId: 'abc', refCount: 2, sentMessage: true} +Renders as readable plain text in the console — no clicking required, copy-pasteable, diff-friendly. + +### ✅ Acceptable — flat object of primitives only + +When every value is a primitive (string/number/bool/null), an object literal is fine: + +```typescript +console.log("[WS-DEBUG] subscribeSession", { sessionId, refCount: current + 1, sent: shouldSend }); ``` -### ❌ Wrong — raw object renders as `Object` +Renders as: `[WS-DEBUG] subscribeSession {sessionId: 'abc', refCount: 2, sent: true}`. + +### ❌ Wrong — array/nested object collapses + +```typescript +console.log("[reorder-bug] render", { tasks: tasks.map(toCompact), groups }); +// Output: [reorder-bug] render {tasks: Array(2), groups: {...}} ← unreadable +``` + +Fix: pre-stringify (`tasks.map(...).join("\n ")`) and embed in a template literal. + +### ❌ Wrong — raw object passed as second arg ```typescript console.log("[WS-DEBUG] subscribeSession", session); @@ -44,17 +63,18 @@ console.log("[WS-DEBUG] subscribeSession", session); ### ❌ Wrong — wrong log level ```typescript -console.warn("[WS-DEBUG] subscribeSession", { sessionId }); // ← use console.log -console.debug("[WS-DEBUG] subscribeSession", { sessionId }); // ← hidden by default +console.warn("[WS-DEBUG] ...", { sessionId }); // ← use console.log +console.debug("[WS-DEBUG] ...", { sessionId }); // ← hidden by default ``` ## Backend (Go) - **Level:** `WARN` — stands out from normal `DEBUG`/`INFO` output without being an error. - **Prefix:** `[DEBUG]` (or another `[AREA-DEBUG]` prefix agreed with the user). -- **Method:** Use the structured logger: `s.logger.Warn("[DEBUG] description", "key", value, ...)`. +- **Method:** Use the structured logger: `s.logger.Warn("[DEBUG] description", "key", value, ...)`. Slog renders each key-value pair inline, so primitives are fine as-is. +- **For slices/maps/structs**, pre-format with `fmt.Sprintf` / `strings.Join` so the value lands on the log line as readable text instead of `[]string{...}`-style verbose output. -### ✅ Correct +### ✅ Correct — primitives as structured fields ```go s.logger.Warn("[DEBUG] handleTaskMoved entering", @@ -65,6 +85,15 @@ s.logger.Warn("[DEBUG] handleTaskMoved entering", ) ``` +### ✅ Correct — pre-format collections inline + +```go +s.logger.Warn("[DEBUG] panel order", + "task_id", taskID, + "panels", strings.Join(panelIDs, ","), +) +``` + ### ❌ Wrong — wrong level ```go @@ -72,11 +101,18 @@ s.logger.Debug("[DEBUG] handleTaskMoved", "task_id", taskID) // ← lost in nois s.logger.Error("[DEBUG] handleTaskMoved", "task_id", taskID) // ← triggers alerts ``` +## Quick Checklist (apply before every debug log you add) + +- [ ] Does the prefix match what's agreed (or already in the file) so all logs are greppable? +- [ ] Is every value a primitive at log time? If not, pre-format with `.map().join()`, `JSON.stringify`, `strings.Join`, or `fmt.Sprintf`. +- [ ] Frontend: `console.log` (not `warn`/`debug`)? Backend: `Warn` (not `Debug`/`Error`)? +- [ ] Is the call site the cheapest possible — one log per event, not per render frame? + ## Workflow 1. **Add debug logs** to the relevant code paths. Do not commit them — keep them as unstaged changes. 2. **Let the user test** the app and report back with console/log output. -3. **Iterate** — add, move, or refine logs as needed based on findings. Still no commits. +3. **Iterate** — add, move, or refine logs as needed based on findings. Still no commits. **If the user reports the values are unreadable (`Array(2)`, `Object`, `{...}`), the previous log violated rule 3; rewrite as a template literal before re-running.** 4. **Fix the issue** once the root cause is identified. 5. **Strip all debug logs** before committing the fix. Only commit the actual fix. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c5938dd52..6b65ad3832 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,20 +1,254 @@ name: release -on: - push: - tags: - - "v*" - workflow_dispatch: +# Maintainer-only release workflow. +# Trigger: GitHub Actions UI → "Run workflow" (workflow_dispatch). +# +# Flow (one click → everything below): +# 1. prepare — bump version, regen CHANGELOG, open + merge release PR, push tag vX.Y.Z +# 2. build-web — build Next.js standalone bundle +# 3. build-cli — build + esbuild-bundle CLI +# 4. build-bundles — assemble bin/ + web/ + cli/ tarballs for 5 platforms +# 5. docker — build & push multi-arch container image to GHCR +# 6. publish-release — create GH release with .tar.gz + .sha256 assets + auto-generated notes +# 7. publish-npm — publish kandev@X.Y.Z + @kdlbs/runtime-*@X.Y.Z to npmjs +# 8. update-homebrew-tap — push updated Formula/kandev.rb to kdlbs/homebrew-kandev +# +# npm publishing uses Trusted Publishers (OIDC) — no NPM_TOKEN required. +# Each package (kandev + 5 @kdlbs/runtime-* packages) must have this workflow +# configured as its trusted publisher on npmjs.com (Settings → Publishing access). +# +# Required secrets: +# HOMEBREW_TAP_DEPLOY_KEY — SSH private key for kdlbs/homebrew-kandev deploy key +on: + workflow_dispatch: + inputs: + bump: + description: "Version bump" + required: true + type: choice + default: minor + options: + - patch + - minor + - major + dry_run: + description: "Dry run (no PR, tag, npm publish, or tap update)" + type: boolean + default: false + +# Default to read-only at the workflow level. Each job opts into the writes +# it actually needs. permissions: - contents: write - packages: write + contents: read jobs: + prepare: + name: Prepare release (bump version, PR, tag) + runs-on: ubuntu-latest + permissions: + contents: write # push release branch + tag + pull-requests: write # create + merge release PR (required by main ruleset) + outputs: + version: ${{ steps.compute.outputs.version }} + tag: ${{ steps.compute.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: pnpm/action-setup@v4 + with: + version: "9" + + - uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Install git-cliff + env: + # Pinned to v2.6.1. To bump, also update the sha512 below — fetch the + # new one from: + # https://github.com/orhun/git-cliff/releases/download//.sha512 + CLIFF_VERSION: "2.6.1" + CLIFF_ASSET: "git-cliff-2.6.1-x86_64-unknown-linux-gnu.tar.gz" + CLIFF_SHA512: "b1e7a6b70fc794accdccd9717eff5a8bf8b507f0308da44d837d935be31b18b19db3758d1a9bb4975fbca7d01ddaae251229a753ae2cd2ecc24845cfc50d067f" + run: | + set -euo pipefail + curl -sSL "https://github.com/orhun/git-cliff/releases/download/v${CLIFF_VERSION}/${CLIFF_ASSET}" -o /tmp/git-cliff.tar.gz + echo "${CLIFF_SHA512} /tmp/git-cliff.tar.gz" | sha512sum -c - + tar -xz -C /tmp -f /tmp/git-cliff.tar.gz + sudo mv "/tmp/git-cliff-${CLIFF_VERSION}/git-cliff" /usr/local/bin/ + rm -rf /tmp/git-cliff.tar.gz "/tmp/git-cliff-${CLIFF_VERSION}" + git-cliff --version + + - name: Configure git identity + run: | + git config user.email "release-bot@kandev" + git config user.name "kandev release bot" + + - name: Compute next version + id: compute + env: + BUMP: ${{ inputs.bump }} + run: | + set -euo pipefail + PKG_VERSION="$(node -p "require('./apps/cli/package.json').version")" + + # Find latest git tag, normalizing legacy two-part tags (vM.m → M.m.0) + LATEST_TAG="$(git tag --list 'v*' | python3 -c " + import sys, re + best = (0, 0, 0) + for t in (l.strip() for l in sys.stdin if l.strip()): + s = t.lstrip('v') + m3 = re.match(r'^(\d+)\.(\d+)\.(\d+)$', s) + m2 = re.match(r'^(\d+)\.(\d+)$', s) + if m3: + v = tuple(int(x) for x in m3.groups()) + elif m2: + v = (int(m2.group(1)), int(m2.group(2)), 0) + else: + continue + if v > best: + best = v + print(f'{best[0]}.{best[1]}.{best[2]}') + ")" + + # Use the higher of package.json version and the latest tag as baseline + BASELINE="$(python3 -c " + a = '$PKG_VERSION'.split('.') + b = '$LATEST_TAG'.split('.') + ai = tuple(int(x) for x in a) + bi = tuple(int(x) for x in b) + print('$PKG_VERSION' if ai >= bi else '$LATEST_TAG') + ")" + + IFS=. read -r MAJ MIN PAT <<<"$BASELINE" + case "$BUMP" in + patch) NEXT="${MAJ}.${MIN}.$((PAT + 1))" ;; + minor) NEXT="${MAJ}.$((MIN + 1)).0" ;; + major) NEXT="$((MAJ + 1)).0.0" ;; + *) echo "Invalid bump '$BUMP'" >&2; exit 1 ;; + esac + + TAG="v${NEXT}" + echo "Current package.json version: $PKG_VERSION" + echo "Latest git tag (normalized): $LATEST_TAG" + echo "Baseline: $BASELINE" + echo "Bump: $BUMP" + echo "Next version: $NEXT" + echo "Next tag: $TAG" + echo "version=$NEXT" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + # Sanity: tag must not already exist + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists locally" >&2 + exit 1 + fi + if git ls-remote --tags origin "refs/tags/$TAG" | grep -q .; then + echo "Tag $TAG already exists on origin" >&2 + exit 1 + fi + + - name: Bump version + generate CHANGELOG (in working tree) + if: ${{ !inputs.dry_run }} + env: + NEXT: ${{ steps.compute.outputs.version }} + TAG: ${{ steps.compute.outputs.tag }} + run: | + set -euo pipefail + # Bump only the main package version. optionalDependencies stay + # pinned to 0.0.0-bootstrap in committed source — publish-npm.sh + # rewrites them to the real version in-flight before publishing, + # once the matching @kdlbs/runtime-* tarballs exist on npm. + (cd apps/cli && npm version --no-git-tag-version "$NEXT") + pnpm -C apps install --no-frozen-lockfile + git-cliff --tag "$TAG" -o CHANGELOG.md + + - name: Create release PR + squash-merge + tag + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEXT: ${{ steps.compute.outputs.version }} + TAG: ${{ steps.compute.outputs.tag }} + run: | + set -euo pipefail + # main is protected by a ruleset that requires PRs, so we go through + # a release branch + auto-merged PR rather than pushing directly. + BRANCH="release/$TAG" + MSG="release: $NEXT" + + git checkout -b "$BRANCH" + git add apps/cli/package.json CHANGELOG.md apps/pnpm-lock.yaml + git commit -m "$MSG" + # If a previous run pushed this branch but failed before the merge + # landed, the remote branch still exists. Delete it so the push + # below is a clean -u origin "$BRANCH" rather than a non-fast-forward + # rejection. Closing the corresponding stale PR (if any) happens + # automatically when GitHub sees the head branch deleted. + git push origin --delete "$BRANCH" 2>/dev/null || true + git push -u origin "$BRANCH" + + gh pr create \ + --title "$MSG" \ + --body "Automated release: \`$NEXT\`" \ + --head "$BRANCH" \ + --base main + # Immediate squash-merge. This relies on main's ruleset requiring + # only "PR required" with no status checks. If status checks are + # added to the ruleset later, this needs to switch to `--auto` + + # polling for merge completion before the tag steps below. + gh pr merge "$BRANCH" --squash --delete-branch --subject "$MSG" --body "" + + # Pull the squashed commit into local main, then tag from it. + git fetch origin main + git checkout main + git reset --hard origin/main + git tag -a "$TAG" -m "release: $NEXT" + + # Tag push: retry once for transient network blips. If both attempts + # fail, main already has the bumped commit but no tag exists. + # Recover by manually pushing the tag from any clone pointing at the + # squashed merge commit: git push origin "$TAG" + git push origin "$TAG" || { + echo "Tag push failed; retrying in 5s..." >&2 + sleep 5 + git push origin "$TAG" || { + echo "ERROR: Tag push failed twice after PR merge succeeded." >&2 + echo " main is at $NEXT but the tag was not created on origin." >&2 + echo " Recover manually: git push origin $TAG" >&2 + exit 1 + } + } + + - name: Dry-run summary + if: ${{ inputs.dry_run }} + env: + NEXT: ${{ steps.compute.outputs.version }} + TAG: ${{ steps.compute.outputs.tag }} + run: | + # In dry-run we still bump + run git-cliff locally so the maintainer + # can verify the computed version + changelog look right, but nothing + # is committed or pushed. + (cd apps/cli && npm version --no-git-tag-version "$NEXT") + pnpm -C apps install --no-frozen-lockfile + git-cliff --tag "$TAG" -o CHANGELOG.md + echo + echo "DRY RUN: Would have created + merged release PR and tagged $TAG." + echo "DRY RUN: apps/cli/package.json bumped to $NEXT (in-runner only)." + echo "DRY RUN: CHANGELOG.md regenerated (in-runner only)." + build-web: + name: Build web standalone + needs: prepare + if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} - uses: pnpm/action-setup@v4 with: version: "9" @@ -25,7 +259,7 @@ jobs: run: pnpm -C apps install --frozen-lockfile - name: Build web (standalone) env: - KANDEV_VERSION: ${{ github.ref_name }} + KANDEV_VERSION: ${{ needs.prepare.outputs.tag }} run: pnpm -C apps --filter @kandev/web build - name: Package web bundle run: bash scripts/release/package-web.sh @@ -35,8 +269,34 @@ jobs: path: dist/web include-hidden-files: true + build-cli: + name: Build & bundle CLI + needs: prepare + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + - uses: pnpm/action-setup@v4 + with: + version: "9" + - uses: actions/setup-node@v6 + with: + node-version: "24" + - name: Install dependencies + run: pnpm -C apps install --frozen-lockfile + - name: Build and bundle CLI + run: bash scripts/release/package-cli.sh + - uses: actions/upload-artifact@v4 + with: + name: cli-bundle + path: dist/kandev/cli + build-bundles: - needs: build-web + name: Build platform bundle (${{ matrix.platform }}) + needs: [prepare, build-web, build-cli] + if: ${{ !inputs.dry_run }} strategy: matrix: include: @@ -69,6 +329,8 @@ jobs: GOARCH: ${{ matrix.goarch }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} - uses: actions/setup-go@v6 with: go-version-file: apps/backend/go.mod @@ -77,6 +339,10 @@ jobs: with: name: web-bundle path: dist/web + - uses: actions/download-artifact@v4 + with: + name: cli-bundle + path: dist/kandev/cli - name: Install cross-compilation tools if: matrix.cc @@ -107,7 +373,7 @@ jobs: - name: Package bundle run: | - rm -rf dist/kandev + rm -rf dist/kandev/bin dist/kandev/web mkdir -p dist/kandev/bin EXT=${{ matrix.goos == 'windows' && '.exe' || '' }} cp apps/backend/bin/kandev${EXT} dist/kandev/bin/ @@ -115,12 +381,17 @@ jobs: cp apps/backend/bin/agentctl-linux-amd64 dist/kandev/bin/ mkdir -p dist/kandev/web cp -R dist/web/. dist/kandev/web/ + # cli/ was already placed by the build-cli job (downloaded above). + # actions/upload-artifact + download-artifact zip-roundtrip strips the + # executable bit, so re-apply it. The Homebrew formula's wrapper at + # bin/kandev does `exec libexec/cli/bin/cli.js`, which fails with + # "Permission denied" if this isn't +x. + chmod +x dist/kandev/cli/bin/cli.js cd dist tar -czf "kandev-${{ matrix.platform }}.tar.gz" kandev shasum -a 256 "kandev-${{ matrix.platform }}.tar.gz" > "kandev-${{ matrix.platform }}.tar.gz.sha256" - - name: Upload bundle artifacts - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v4 with: name: bundle-${{ matrix.platform }} path: | @@ -128,9 +399,17 @@ jobs: dist/kandev-${{ matrix.platform }}.tar.gz.sha256 docker: + name: Build & push container image + needs: prepare + if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest + permissions: + contents: read + packages: write # push to GHCR steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} - name: Docker meta id: meta @@ -138,10 +417,10 @@ jobs: with: images: ghcr.io/kdlbs/kandev tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} + type=raw,value=${{ needs.prepare.outputs.version }} + type=raw,value=${{ needs.prepare.outputs.tag }} type=sha - type=raw,value=latest,enable=${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }} + type=raw,value=latest - uses: docker/setup-buildx-action@v3 @@ -164,11 +443,16 @@ jobs: cache-to: type=gha,mode=max publish-release: - needs: build-bundles + name: Publish GitHub release + needs: [prepare, build-bundles] + if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest + permissions: + contents: write # softprops/action-gh-release creates the release steps: - uses: actions/checkout@v4 with: + ref: ${{ needs.prepare.outputs.tag }} fetch-depth: 0 - name: Generate release notes @@ -179,8 +463,7 @@ jobs: env: OUTPUT: RELEASE_NOTES.md - - name: Download bundle artifacts - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v4 with: pattern: bundle-* path: dist/release-assets @@ -204,10 +487,64 @@ jobs: done ls -lh dist/release-assets - - name: Publish release assets + - name: Publish release uses: softprops/action-gh-release@v2 with: + tag_name: ${{ needs.prepare.outputs.tag }} + name: ${{ needs.prepare.outputs.tag }} body_path: RELEASE_NOTES.md files: | dist/release-assets/kandev-*.tar.gz dist/release-assets/kandev-*.tar.gz.sha256 + + publish-npm: + name: Publish npm packages + needs: [prepare, publish-release] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + permissions: + id-token: write # Required for OIDC trusted publishing + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - uses: pnpm/action-setup@v4 + with: + version: "9" + + - uses: actions/setup-node@v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Install dependencies + run: pnpm -C apps install --frozen-lockfile + + - name: Publish npm packages + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + bash scripts/release/publish-npm.sh \ + "${{ needs.prepare.outputs.version }}" \ + "${{ needs.prepare.outputs.tag }}" + + update-homebrew-tap: + name: Update Homebrew tap + needs: [prepare, publish-release] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - name: Update Homebrew tap formula + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} + run: | + bash scripts/release/update-homebrew-tap.sh \ + "${{ needs.prepare.outputs.version }}" \ + "${{ needs.prepare.outputs.tag }}" diff --git a/AGENTS.md b/AGENTS.md index bfe39f030c..2a43ef2e58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,7 +162,7 @@ Client (WS) → Orchestrator → Lifecycle Manager → ExecutorBackend (containe Client (WS) ← Orchestrator ← Lifecycle Manager ←──── stream updates (WS) ──────── agent subprocess ``` -1. Orchestrator receives `task.start` via WS +1. Orchestrator receives `session.launch` via WS 2. Lifecycle Manager creates executor instance (container or process) 3. agentctl starts inside the instance, agent subprocess is configured and started 4. Agent events stream back via WS through the chain @@ -274,6 +274,36 @@ lib/api/domains/ # API clients Commits to `main` **must** follow [Conventional Commits](https://www.conventionalcommits.org/) (`type: description`). PRs are squash-merged — the PR title becomes the commit, validated by CI. Changelog is auto-generated from these via git-cliff (`cliff.toml`). See `.agents/skills/commit/SKILL.md` for allowed types and examples. +### Release & Versioning Convention + +Kandev uses a **single SemVer** `X.Y.Z` shared across all distribution channels: + +- `apps/cli/package.json` version → `X.Y.Z` +- npm main package: `kandev@X.Y.Z` +- npm runtime packages: `@kdlbs/runtime-{platform}@X.Y.Z` (5 platforms; declared as `optionalDependencies` in main package) +- Git tag: `vX.Y.Z` (three-part; legacy `vM.m` tags normalize to `M.m.0`) +- Homebrew formula: `kdlbs/homebrew-kandev` `Formula/kandev.rb` `version "X.Y.Z"` +- GitHub release: `vX.Y.Z` with platform tarballs `kandev-{platform}.tar.gz` + `.sha256` + +**npm and Homebrew are sibling channels**, not chained. Both consume the same GitHub release artifacts; neither depends on the other. + +**Release flow** (entirely in CI via `.github/workflows/release.yml`, triggered by maintainer from GitHub Actions UI): + +1. Maintainer clicks "Run workflow" → picks `bump` (patch/minor/major) → optional `dry_run`. +2. `prepare` job bumps version + regenerates CHANGELOG, opens release PR, squash-merges, tags `vX.Y.Z`. +3. `build-web` + `build-cli` + `build-bundles` (5 platforms) build the release artifacts. +4. `publish-release` creates the GitHub release with platform tarballs + sha256 + auto-generated notes. +5. `publish-npm` publishes 5 `@kdlbs/runtime-*` packages + main `kandev` package to npmjs. +6. `update-homebrew-tap` pushes updated `Formula/kandev.rb` to `kdlbs/homebrew-kandev` via SSH deploy key. + +There is no local release script — the entire flow runs in GHA. + +**Runtime resolution** (in `apps/cli/src/runtime.ts`): + +1. `KANDEV_BUNDLE_DIR` env var (set by Homebrew wrapper, used by tests). +2. Installed `@kdlbs/runtime-{platform}` npm package via `require.resolve()`. +3. `--runtime-version ` cache fallback (debug only — downloads from GitHub). + ### Code Quality (enforced by linters) Static analysis runs in CI and pre-commit. New code **must** stay within these limits: @@ -332,7 +362,7 @@ Skills use `gh` CLI by default. If a `gh` command fails (not installed, not auth Jira and Linear are the model: per-workspace credentials, a 90s auth-health poller, a settings page with status banner + reconnect CTA, link/import buttons that gate on availability. New integrations should reuse the shared shapes rather than copying either. **Backend** (`apps/backend/internal//`): -- Mirror the package layout: `service.go`, `store.go`, `client.go`, `provider.go`, `handlers.go`, `models.go`, `poller.go`. Expose `Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) (*Service, func() error, error)` (add `eventBus bus.EventBus` only if the integration actually publishes events — Linear does not). +- Mirror the package layout: `service.go`, `store.go`, `client.go`, `provider.go`, `handlers.go`, `models.go`, `poller.go`. Expose `Provide(writer, reader *sqlx.DB, secrets SecretStore, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error)`. Pass `nil` for `eventBus` when the integration doesn't publish events; both Jira and Linear take and use it for issue-watch publishing. - Use `internal/integrations/secretadapter` instead of writing your own upsert wrapper around `secrets.SecretStore`. The adapter satisfies any per-integration `SecretStore` interface shaped as `{Reveal, Set, Delete, Exists}`. - Use `internal/integrations/healthpoll` for the auth-health loop. Implement the `Prober` interface (`ListConfiguredWorkspaces` + `RecordAuthHealth`) on a small adapter and let `healthpoll.New("name", prober, log)` own Start/Stop/ticker. Keep integration-specific loops (JQL polling, webhook reconciliation, etc.) separate, like jira's issue-watch loop. - Wire the service via a per-domain `initService(...)` helper in `cmd/kandev/services.go`, not inline in `provideServices`. @@ -347,7 +377,7 @@ Jira and Linear are the model: per-workspace credentials, a 90s auth-health poll **Where Jira and Linear deliberately diverge:** - Issue model: Jira uses transitions + JQL; Linear uses state IDs + structured filters. Don't merge these schemas — the upstream APIs are genuinely different. -- Event publishing: only Jira accepts `eventBus` and emits `NewJiraIssueEvent` from its issue-watch loop. Linear has no equivalent feature today. +- Watch filter persistence: Jira stores the JQL string verbatim; Linear stores the structured `SearchFilter` as JSON in `filter_json` (Linear has no JQL equivalent). The orchestrator emits `NewJiraIssueEvent` / `NewLinearIssueEvent` respectively and dedups by issue key (Jira) vs identifier (Linear). - Health column extras: Linear's `linear_configs` row carries an `org_slug` captured from successful probes; Jira's row does not. --- @@ -358,4 +388,4 @@ This file is read by AI coding agents (Claude Code via `CLAUDE.md` symlink, Code --- -**Last Updated**: 2026-05-02 +**Last Updated**: 2026-05-03 diff --git a/CHANGELOG.md b/CHANGELOG.md index 90755344f4..fe108bcc13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,159 @@ All notable changes to Kandev. +## 0.41.0 - 2026-05-09 + +### Features + +- allow subtasks to target a sibling repository ([#852](https://github.com/kdlbs/kandev/pull/852)) + +### Bug Fixes + +- preserve dockview state across task and plan-mode switches ([#855](https://github.com/kdlbs/kandev/pull/855)) +- show sent message in chat without waiting for ws broadcast ([#851](https://github.com/kdlbs/kandev/pull/851)) +- rank longest/quickest tasks by active duration ([#849](https://github.com/kdlbs/kandev/pull/849)) +- keep "All Workflows" filter on task nav and workflow.created ([#854](https://github.com/kdlbs/kandev/pull/854)) +- keep command palette selection on first result ([#845](https://github.com/kdlbs/kandev/pull/845)) + +## 0.40.0 - 2026-05-08 + +### Features + +- show grid spinner on agent tab while session is working ([#836](https://github.com/kdlbs/kandev/pull/836)) +- mobile parity (session/terminal/repo pickers + multi-terminal) ([#840](https://github.com/kdlbs/kandev/pull/840)) +- per-task color indicator in sidebar ([#835](https://github.com/kdlbs/kandev/pull/835)) +- rate-limit awareness, poller throttling, and GraphQL batching ([#821](https://github.com/kdlbs/kandev/pull/821)) +- pin tasks and drag-to-reorder in sidebar ([#829](https://github.com/kdlbs/kandev/pull/829)) +- allow renaming quick-chat tabs locally ([#830](https://github.com/kdlbs/kandev/pull/830)) +- multi-question support for ask_user_question_kandev ([#828](https://github.com/kdlbs/kandev/pull/828)) +- allow moving tasks across workflows ([#822](https://github.com/kdlbs/kandev/pull/822)) +- double-click tab to toggle maximize ([#823](https://github.com/kdlbs/kandev/pull/823)) +- cookie-mode integration that triages threads via a utility agent ([#775](https://github.com/kdlbs/kandev/pull/775)) +- add Linear issue watchers ([#805](https://github.com/kdlbs/kandev/pull/805)) + +### Bug Fixes + +- respect "All Workflows" selection with multiple workflows ([#844](https://github.com/kdlbs/kandev/pull/844)) +- stop dockview layout corruption when switching between maximized / sessionless tasks ([#838](https://github.com/kdlbs/kandev/pull/838)) +- hide improve-kandev system workflow from settings UI ([#842](https://github.com/kdlbs/kandev/pull/842)) +- abandon orphan turns on session resume ([#837](https://github.com/kdlbs/kandev/pull/837)) +- source commit pushed status from git remote, not PR commits ([#833](https://github.com/kdlbs/kandev/pull/833)) +- preserve commits in changes panel after refresh ([#834](https://github.com/kdlbs/kandev/pull/834)) + +### Refactoring + +- reframe cross-task message wrapper to authorize action ([#841](https://github.com/kdlbs/kandev/pull/841)) +- drop legacy orchestrator WS handlers superseded by session.launch ([#803](https://github.com/kdlbs/kandev/pull/803)) + +## 0.39.2 - 2026-05-05 + +### Bug Fixes + +- add repository field for npm provenance, patch CLI perms ([#826](https://github.com/kdlbs/kandev/pull/826)) + +## 0.39.1 - 2026-05-05 + +### Features + +- brew install + npm sibling channels via OIDC trusted publishing ([#806](https://github.com/kdlbs/kandev/pull/806)) + +### Bug Fixes + +- restore PR-and-merge pattern ([#824](https://github.com/kdlbs/kandev/pull/824)) + +## 0.39 - 2026-05-04 + +### Features + +- attribute cross-task agent messages with a sender badge ([#819](https://github.com/kdlbs/kandev/pull/819)) +- add 10 ACP agents, set_config_option, auth_required flow ([#807](https://github.com/kdlbs/kandev/pull/807)) +- polish workbench topbars and task-create dialog ([#792](https://github.com/kdlbs/kandev/pull/792)) +- show question icon only for pending input ([#782](https://github.com/kdlbs/kandev/pull/782)) +- add recent task switcher ([#779](https://github.com/kdlbs/kandev/pull/779)) +- collapse commits and PR changes by default in changes panel ([#781](https://github.com/kdlbs/kandev/pull/781)) +- support tasks spanning multiple repositories ([#767](https://github.com/kdlbs/kandev/pull/767)) +- allow reordering sidebar views ([#764](https://github.com/kdlbs/kandev/pull/764)) +- refine workbench chrome ([#761](https://github.com/kdlbs/kandev/pull/761)) +- add get_task_conversation tool ([#756](https://github.com/kdlbs/kandev/pull/756)) +- add improve kandev in-app contribution flow ([#740](https://github.com/kdlbs/kandev/pull/740)) +- refresh + smarter filter for new-task branch selector ([#750](https://github.com/kdlbs/kandev/pull/750)) +- poll JQL queries to auto-create tasks (issue watchers) ([#746](https://github.com/kdlbs/kandev/pull/746)) +- key terminals + dockview layout to TaskEnvironment ([#755](https://github.com/kdlbs/kandev/pull/755)) +- add Linear integration ([#736](https://github.com/kdlbs/kandev/pull/736)) +- show active time and elapsed span per task ([#748](https://github.com/kdlbs/kandev/pull/748)) +- eager-init agent on profile selection ([#747](https://github.com/kdlbs/kandev/pull/747)) +- add message_task_kandev tool ([#745](https://github.com/kdlbs/kandev/pull/745)) +- deferred task move with hand-off prompt + boot/turn-end event split ([#743](https://github.com/kdlbs/kandev/pull/743)) +- mobile terminal key-bar — Ctrl/Shift modify OS-keyboard input + iOS keyboard fixes ([#741](https://github.com/kdlbs/kandev/pull/741)) +- make --port the user-facing port flag ([#737](https://github.com/kdlbs/kandev/pull/737)) +- expose MCP server to external coding agents ([#732](https://github.com/kdlbs/kandev/pull/732)) +- hide Jira buttons when disabled or auth failing ([#725](https://github.com/kdlbs/kandev/pull/725)) +- add GitHub dashboard shortcut to command panel ([#721](https://github.com/kdlbs/kandev/pull/721)) + +### Bug Fixes + +- dedup topbar PR button against auto-shown PR panel ([#812](https://github.com/kdlbs/kandev/pull/812)) +- stabilize task switch flow and session recovery ([#818](https://github.com/kdlbs/kandev/pull/818)) +- suppress spurious task-failed toasts on resume and reconnect ([#814](https://github.com/kdlbs/kandev/pull/814)) +- scope commits/diff to live branch divergence; flatten single-repo ([#816](https://github.com/kdlbs/kandev/pull/816)) +- sanitize multi-repo worktree dirs and polish task-create chip dropdown ([#815](https://github.com/kdlbs/kandev/pull/815)) +- return mcp message_task once dispatched, not after target turn ends ([#817](https://github.com/kdlbs/kandev/pull/817)) +- correct task_environments migration order for older DBs ([#811](https://github.com/kdlbs/kandev/pull/811)) +- retry CLI passthrough launch without resume flag after fast-fail ([#810](https://github.com/kdlbs/kandev/pull/810)) +- close tooltips on popover close and refine branch chip UX ([#808](https://github.com/kdlbs/kandev/pull/808)) +- make executors_running the single source of truth for agent_execution_id ([#799](https://github.com/kdlbs/kandev/pull/799)) +- use shared prompt template with placeholder autocomplete ([#801](https://github.com/kdlbs/kandev/pull/801)) +- tab session bugs — primary star drift + tab close re-creation ([#800](https://github.com/kdlbs/kandev/pull/800)) +- retry session commits fetch when workspace not ready ([#789](https://github.com/kdlbs/kandev/pull/789)) +- focus task name on create dialog open ([#788](https://github.com/kdlbs/kandev/pull/788)) +- dedupe cancel-turn clicks to stop "turn cancelled" cascade ([#784](https://github.com/kdlbs/kandev/pull/784)) +- heal legacy PR watches so single-repo tasks don't dupe PRs ([#785](https://github.com/kdlbs/kandev/pull/785)) +- add border to task name input in create dialog ([#783](https://github.com/kdlbs/kandev/pull/783)) +- prevent inline code from rendering diagrams ([#773](https://github.com/kdlbs/kandev/pull/773)) +- remove settings topbar border ([#774](https://github.com/kdlbs/kandev/pull/774)) +- re-key user-shell RPCs to task_environment_id ([#770](https://github.com/kdlbs/kandev/pull/770)) +- unblock terminals stuck on Connecting and heal task_environments ([#769](https://github.com/kdlbs/kandev/pull/769)) +- prevent orphaned agent subprocesses from concurrent execution creates ([#768](https://github.com/kdlbs/kandev/pull/768)) +- restore readable boxed chat hotkey tooltip ([#765](https://github.com/kdlbs/kandev/pull/765)) +- make mermaid sanitizer quote-aware and detect parens in bracket labels ([#763](https://github.com/kdlbs/kandev/pull/763)) +- refine workbench action button layout ([#762](https://github.com/kdlbs/kandev/pull/762)) +- server-authoritative session ensure for kanban preview ([#760](https://github.com/kdlbs/kandev/pull/760)) +- guide recovery when session profile is deleted ([#752](https://github.com/kdlbs/kandev/pull/752)) +- drop stuck pending permission_request messages ([#723](https://github.com/kdlbs/kandev/pull/723)) +- plan panel misses agent updates emitted before WS connects ([#749](https://github.com/kdlbs/kandev/pull/749)) +- render passthrough terminal in quick chat ([#744](https://github.com/kdlbs/kandev/pull/744)) +- contain kanban card badges within card width ([#739](https://github.com/kdlbs/kandev/pull/739)) +- improve create_task_kandev repository and workspace resolution ([#733](https://github.com/kdlbs/kandev/pull/733)) +- publish agentctl events on workspace restore ([#731](https://github.com/kdlbs/kandev/pull/731)) +- self-recover dockview layout from corrupt persisted state ([#729](https://github.com/kdlbs/kandev/pull/729)) +- populate diff for renamed-and-modified files in git status ([#730](https://github.com/kdlbs/kandev/pull/730)) +- preserve chat scroll position when maximizing panel ([#728](https://github.com/kdlbs/kandev/pull/728)) +- prevent crowding in narrow changes panel and dockview tabs ([#727](https://github.com/kdlbs/kandev/pull/727)) +- drain queued message on workflow transition to non-auto-start step ([#726](https://github.com/kdlbs/kandev/pull/726)) +- apply profile cli_flags to passthrough command ([#722](https://github.com/kdlbs/kandev/pull/722)) +- restore release changelog boundaries ([#716](https://github.com/kdlbs/kandev/pull/716)) + +### Performance + +- speed up stats endpoint aggregation ([#766](https://github.com/kdlbs/kandev/pull/766)) + +### Refactoring + +- move Configuration Chat Agent to utility agents page ([#809](https://github.com/kdlbs/kandev/pull/809)) +- drop redundant VCS split button from task top bar ([#804](https://github.com/kdlbs/kandev/pull/804)) +- drop dead taskPRs loading state and unused removeTaskPR ([#791](https://github.com/kdlbs/kandev/pull/791)) +- move integrations to top-level settings with install-wide configs ([#787](https://github.com/kdlbs/kandev/pull/787)) +- use GetOrEnsureExecution for workspace ops ([#786](https://github.com/kdlbs/kandev/pull/786)) +- extract shared shapes between jira and linear integrations ([#759](https://github.com/kdlbs/kandev/pull/759)) +- route user terminals by environment ([#758](https://github.com/kdlbs/kandev/pull/758)) +- drop unused flat-layout worktrees/ path ([#708](https://github.com/kdlbs/kandev/pull/708)) + +### Documentation + +- add integrations banner to README ([#790](https://github.com/kdlbs/kandev/pull/790)) +- add issue templates ([#754](https://github.com/kdlbs/kandev/pull/754)) +- add star history chart ([#753](https://github.com/kdlbs/kandev/pull/753)) + ## 0.38 - 2026-04-27 ### Features @@ -100,7 +253,7 @@ All notable changes to Kandev. ### Bug Fixes -- release script tags fetching ([986410ab](https://github.com/kdlbs/kandev/commit/986410ab2ebb1cc25e1ad046c9daae18a58cc150)) +- release script tags fetching - show repo name instead of full path in task sidebar ([#652](https://github.com/kdlbs/kandev/pull/652)) - unstick agent session when cancel times out ([#651](https://github.com/kdlbs/kandev/pull/651)) - anchor PR detail panel to session group on auto-open ([#646](https://github.com/kdlbs/kandev/pull/646)) @@ -1156,3 +1309,4 @@ All notable changes to Kandev. - resolve conflicts with main + diff --git a/README.md b/README.md index 9ee11f277a..6d0af035c6 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,35 @@ Each executor uses git worktrees for workspace isolation, preventing concurrent ## Quick Start -### NPX (recommended) +### Homebrew (macOS, Linux) ```bash -npx kandev +brew install kdlbs/kandev/kandev +kandev ``` -This downloads pre-built backend + frontend bundles and starts them locally. The worktrees and sqlite db will be created in `~/.kandev` by default. Should work on macOS, Linux, and Windows (WSL or native). +### NPX + +```bash +npx kandev@latest +``` + +Requires npm 7+. Installs the CLI plus a platform-matched runtime bundle in one step. + +### NPM (global) + +```bash +npm install -g kandev@latest +kandev +``` + +### Updates + +- `brew upgrade kandev` +- `npx kandev@latest` (always uses the latest published version) +- `npm install -g kandev@latest` + +The worktrees and sqlite db live in `~/.kandev` by default. Works on macOS, Linux, and Windows (WSL or native). ### From Source diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index f5c8fdb9a7..fc5ec499ff 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -634,51 +634,86 @@ func (a *messageCreatorAdapter) UpdatePermissionMessage(ctx context.Context, ses return a.svc.UpdatePermissionMessage(ctx, sessionID, pendingID, status) } -// CreateClarificationRequestMessage creates a message for a clarification request. -// This allows clarification requests to appear in the chat as messages. -func (a *messageCreatorAdapter) CreateClarificationRequestMessage(ctx context.Context, taskID, sessionID, pendingID string, question clarification.Question, clarificationContext string) (string, error) { - // Convert question options to interface{} for metadata storage - options := make([]interface{}, len(question.Options)) - for j, opt := range question.Options { - options[j] = map[string]interface{}{ - "option_id": opt.ID, - "label": opt.Label, - "description": opt.Description, +// CreateClarificationRequestMessages emits one chat message per question in a +// clarification request bundle, all sharing the given pending_id. The frontend +// renders them as a stacked group; only the last message sets RequestsInput=true +// so the chat scrolls to the bottom of the group when the bundle arrives. +// Returns the created message IDs in the same order as the input questions. +// +// If any individual CreateMessage call fails, every previously created message +// in the bundle is deleted so we don't leave a half-rendered group dangling in +// the chat. Best-effort: if cleanup itself fails the caller still receives the +// original error and the orphan messages stay (logged at warn-level). +func (a *messageCreatorAdapter) CreateClarificationRequestMessages(ctx context.Context, taskID, sessionID, pendingID string, questions []clarification.Question, clarificationContext string) ([]string, error) { + ids := make([]string, 0, len(questions)) + total := len(questions) + for i, question := range questions { + options := make([]interface{}, len(question.Options)) + for j, opt := range question.Options { + options[j] = map[string]interface{}{ + "option_id": opt.ID, + "label": opt.Label, + "description": opt.Description, + } } - } - questionData := map[string]interface{}{ - "id": question.ID, - "title": question.Title, - "prompt": question.Prompt, - "options": options, - } + questionData := map[string]interface{}{ + "id": question.ID, + "title": question.Title, + "prompt": question.Prompt, + "options": options, + } - metadata := map[string]interface{}{ - "pending_id": pendingID, - "question": questionData, - "context": clarificationContext, - "status": "pending", - } + metadata := map[string]interface{}{ + "pending_id": pendingID, + "question_id": question.ID, + "question": questionData, + "question_index": i, + "question_total": total, + "context": clarificationContext, + "status": "pending", + } - msg, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ - TaskSessionID: sessionID, - TaskID: taskID, - Content: question.Prompt, - AuthorType: "agent", - Type: "clarification_request", - Metadata: metadata, - RequestsInput: true, // This marks the session as waiting for input - }) - if err != nil { - return "", err + // Only the last message marks the session as waiting for input so the + // chat scrolls to the end of the group when the bundle arrives. + requestsInput := i == total-1 + + msg, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ + TaskSessionID: sessionID, + TaskID: taskID, + Content: question.Prompt, + AuthorType: "agent", + Type: "clarification_request", + Metadata: metadata, + RequestsInput: requestsInput, + }) + if err != nil { + a.rollbackPartialBundle(ctx, ids, pendingID) + return nil, err + } + ids = append(ids, msg.ID) } - return msg.ID, nil + return ids, nil } -// UpdateClarificationMessage updates a clarification message's status and response -func (a *messageCreatorAdapter) UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, status string, answer *clarification.Answer) error { - return a.svc.UpdateClarificationMessage(ctx, sessionID, pendingID, status, answer) +// rollbackPartialBundle removes the messages already created when later writes +// in a multi-question bundle fail. Best-effort — failures are logged but do +// not bubble up because the caller already has a more useful error. +func (a *messageCreatorAdapter) rollbackPartialBundle(ctx context.Context, ids []string, pendingID string) { + for _, id := range ids { + if err := a.svc.DeleteMessage(ctx, id); err != nil { + a.logger.Warn("failed to roll back partial clarification bundle message", + zap.String("pending_id", pendingID), + zap.String("message_id", id), + zap.Error(err)) + } + } +} + +// UpdateClarificationMessage updates a clarification message's status and answer +// for a specific (pending_id, question_id) pair within the session. +func (a *messageCreatorAdapter) UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, questionID, status string, answer *clarification.Answer) error { + return a.svc.UpdateClarificationMessageForQuestion(ctx, sessionID, pendingID, questionID, status, answer) } // CreateAgentMessageStreaming creates a new agent message with a pre-generated ID. @@ -747,6 +782,10 @@ func (a *turnServiceAdapter) GetActiveTurn(ctx context.Context, sessionID string return a.svc.GetActiveTurn(ctx, sessionID) } +func (a *turnServiceAdapter) AbandonOpenTurns(ctx context.Context, sessionID string) error { + return a.svc.AbandonOpenTurns(ctx, sessionID) +} + func newTurnServiceAdapter(svc *taskservice.Service) *turnServiceAdapter { return &turnServiceAdapter{svc: svc} } diff --git a/apps/backend/cmd/kandev/e2e_reset.go b/apps/backend/cmd/kandev/e2e_reset.go index 63b4b2a046..3ac0c4a8e5 100644 --- a/apps/backend/cmd/kandev/e2e_reset.go +++ b/apps/backend/cmd/kandev/e2e_reset.go @@ -10,18 +10,29 @@ import ( "github.com/kandev/kandev/internal/common/logger" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + taskservice "github.com/kandev/kandev/internal/task/service" ) -// registerE2EResetRoutes registers the E2E data-reset endpoint. -// The endpoint is available when KANDEV_MOCK_AGENT is "true" or "only" (dev/E2E modes). -func registerE2EResetRoutes(router *gin.Engine, repo *sqliterepo.Repository, log *logger.Logger) { +// registerE2EResetRoutes registers the E2E test-only endpoints. +// The endpoints are available when KANDEV_MOCK_AGENT is "true" or "only" (dev/E2E modes). +func registerE2EResetRoutes(router *gin.Engine, repo *sqliterepo.Repository, taskSvc *taskservice.Service, log *logger.Logger) { mockMode := os.Getenv("KANDEV_MOCK_AGENT") if mockMode != "true" && mockMode != "only" { return } api := router.Group("/api/v1/e2e") - api.DELETE("/reset/:workspaceId", func(c *gin.Context) { + api.DELETE("/reset/:workspaceId", handleE2EReset(repo, log)) + // Hidden-workflow factory: lets E2E tests cover the system-only + // workflow path (e.g. improve-kandev) without depending on the real + // bootstrap endpoint, which clones from GitHub and shells out to gh. + api.POST("/hidden-workflow", handleE2ECreateHiddenWorkflow(taskSvc, log)) + + log.Info("registered E2E endpoints (test-only)") +} + +func handleE2EReset(repo *sqliterepo.Repository, log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { workspaceID := c.Param("workspaceId") // Optional: comma-separated workflow IDs to keep (e.g., the seeded workflow). @@ -50,7 +61,36 @@ func registerE2EResetRoutes(router *gin.Engine, repo *sqliterepo.Repository, log "deleted_tasks": deletedTasks, "deleted_workflows": deletedWorkflows, }) - }) - - log.Info("registered E2E reset endpoint (test-only)") + } +} + +type e2eHiddenWorkflowRequest struct { + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` +} + +func handleE2ECreateHiddenWorkflow(taskSvc *taskservice.Service, log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + var body e2eHiddenWorkflowRequest + if err := c.ShouldBindJSON(&body); err != nil || body.WorkspaceID == "" || body.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id and name are required"}) + return + } + workflow, err := taskSvc.CreateWorkflow(c.Request.Context(), &taskservice.CreateWorkflowRequest{ + WorkspaceID: body.WorkspaceID, + Name: body.Name, + Hidden: true, + }) + if err != nil { + log.Error("e2e: failed to create hidden workflow", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{ + "id": workflow.ID, + "workspace_id": workflow.WorkspaceID, + "name": workflow.Name, + "hidden": workflow.Hidden, + }) + } } diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 4ab3acb05c..71950c56bd 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -49,6 +49,7 @@ import ( prompthandlers "github.com/kandev/kandev/internal/prompts/handlers" "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/slack" spriteshandlers "github.com/kandev/kandev/internal/sprites" taskhandlers "github.com/kandev/kandev/internal/task/handlers" "github.com/kandev/kandev/internal/task/models" @@ -626,6 +627,11 @@ func registerSecondaryRoutes( p.log.Debug("Registered Linear handlers (HTTP + WebSocket)") } + if p.services.Slack != nil { + slack.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Slack, p.log) + p.log.Debug("Registered Slack handlers (HTTP + WebSocket)") + } + docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), dockerTaskTitleProvider(p.taskRepo, p.log), p.log) p.log.Debug("Registered Docker management handlers (HTTP)") @@ -642,7 +648,7 @@ func registerSecondaryRoutes( registerMCPAndDebugRoutes(p, workflowCtrl, clarificationStore, planService) - registerE2EResetRoutes(p.router, p.taskRepo, p.log) + registerE2EResetRoutes(p.router, p.taskRepo, p.taskSvc, p.log) } func dockerTaskTitleProvider(taskRepo *sqliterepo.Repository, log *logger.Logger) docker.TaskTitleProvider { @@ -663,16 +669,44 @@ func dockerTaskTitleProvider(taskRepo *sqliterepo.Repository, log *logger.Logger // registerHealthRoutes sets up the system health endpoint with all health checkers. func registerHealthRoutes(p routeParams) { var githubProvider health.GitHubStatusProvider + var githubRateProvider health.GitHubRateLimitProvider if p.services.GitHub != nil { githubProvider = p.services.GitHub + githubRateProvider = githubRateLimitAdapter{svc: p.services.GitHub} + } + githubChecker := health.NewGitHubChecker(githubProvider) + if githubRateProvider != nil { + githubChecker.WithRateLimitProvider(githubRateProvider) } healthSvc := health.NewService(p.log, - health.NewGitHubChecker(githubProvider), + githubChecker, health.NewAgentChecker(p.agentSettingsController), ) health.RegisterRoutes(p.router, healthSvc, p.log) } +// githubRateLimitAdapter bridges the github.Service's per-resource exhaustion +// snapshot to the structural shape consumed by the health package without +// importing health into github (cycle). +type githubRateLimitAdapter struct { + svc *github.Service +} + +func (a githubRateLimitAdapter) ExhaustedRateLimits() []health.GitHubRateLimitStatus { + if a.svc == nil { + return nil + } + src := a.svc.ExhaustedRateLimits() + if len(src) == 0 { + return nil + } + out := make([]health.GitHubRateLimitStatus, len(src)) + for i, s := range src { + out[i] = health.GitHubRateLimitStatus{Resource: s.Resource, ResetAt: s.ResetAt} + } + return out +} + // registerMCPAndDebugRoutes registers MCP and debug routes and wires the MCP handler. func registerMCPAndDebugRoutes( p routeParams, diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index c1c3461663..6aff72c903 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -32,6 +32,7 @@ import ( // JIRA integration jirapkg "github.com/kandev/kandev/internal/jira" linearpkg "github.com/kandev/kandev/internal/linear" + slackpkg "github.com/kandev/kandev/internal/slack" // Agent infrastructure "github.com/kandev/kandev/internal/agent/hostutility" @@ -358,13 +359,30 @@ func startAgentInfrastructure( addCleanup(func() error { jiraPoller.Stop(); return nil }) } - // Start Linear auth-health poller, mirroring the Jira poller. + // Start Linear poller. Mirrors the Jira shape: auth-health probe plus an + // issue-watch loop that runs configured filters and emits + // NewLinearIssueEvent for the orchestrator to turn into tasks. if services.Linear != nil { + orchestratorSvc.SetLinearService(&linearServiceAdapter{svc: services.Linear}) linearPoller := linearpkg.NewPoller(services.Linear, log) linearPoller.Start(ctx) addCleanup(func() error { linearPoller.Stop(); return nil }) } + // Start Slack auth-health poller and the trigger loop. The trigger + // polls each configured workspace every 30s for new `!kandev …` + // messages from the authenticated user and turns them into Kandev + // tasks via taskSvc. + if services.Slack != nil { + slackPoller := slackpkg.NewPoller(services.Slack, log) + slackPoller.Start(ctx) + addCleanup(func() error { slackPoller.Stop(); return nil }) + + slackTrigger := slackpkg.NewTrigger(services.Slack, log) + slackTrigger.Start(ctx) + addCleanup(func() error { slackTrigger.Stop(); return nil }) + } + return startGatewayAndServe(ctx, cfg, log, eventBus, repos, services, agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, repoCloner, addCleanup, runCleanups) } @@ -440,6 +458,24 @@ func startGatewayAndServe( return nil }) + // Wire the Slack agent runner. Slack triage uses the host-utility + // inference path (single-shot ACP subprocess) with the Kandev MCP + // server attached so the agent can call list_workflows_kandev / + // create_task_kandev / etc. mid-prompt. Both deps land here at the + // same time: hostUtilityMgr just bootstrapped above, services.Utility + // was constructed in provideServices. + if services.Slack != nil && services.Utility != nil { + mcpURL := buildKandevMCPURL(cfg.Server.Port) + slackRunner := slackpkg.NewRunner( + services.Utility, + services.User, + slackHostUtilityAdapter{mgr: hostUtilityMgr}, + []slackpkg.MCPDescriptor{{Name: "kandev", URL: mcpURL}}, + log, + ) + services.Slack.SetRunner(slackRunner) + } + if err := orchestratorSvc.Start(ctx); err != nil { log.Error("Failed to start orchestrator", zap.Error(err)) return false diff --git a/apps/backend/cmd/kandev/orchestrator.go b/apps/backend/cmd/kandev/orchestrator.go index 157a719dfa..0db11f75d7 100644 --- a/apps/backend/cmd/kandev/orchestrator.go +++ b/apps/backend/cmd/kandev/orchestrator.go @@ -17,6 +17,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" jirapkg "github.com/kandev/kandev/internal/jira" + linearpkg "github.com/kandev/kandev/internal/linear" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/secrets" @@ -267,6 +268,24 @@ func (a *jiraServiceAdapter) ReleaseIssueWatchTask(ctx context.Context, watchID, return a.svc.Store().ReleaseIssueWatchTask(ctx, watchID, issueKey) } +// linearServiceAdapter exposes the Linear service's issue-watch dedup methods +// to the orchestrator without leaking the rest of the package surface area. +type linearServiceAdapter struct { + svc *linearpkg.Service +} + +func (a *linearServiceAdapter) ReserveIssueWatchTask(ctx context.Context, watchID, identifier, issueURL string) (bool, error) { + return a.svc.Store().ReserveIssueWatchTask(ctx, watchID, identifier, issueURL) +} + +func (a *linearServiceAdapter) AssignIssueWatchTaskID(ctx context.Context, watchID, identifier, taskID string) error { + return a.svc.Store().AssignIssueWatchTaskID(ctx, watchID, identifier, taskID) +} + +func (a *linearServiceAdapter) ReleaseIssueWatchTask(ctx context.Context, watchID, identifier string) error { + return a.svc.Store().ReleaseIssueWatchTask(ctx, watchID, identifier) +} + // repoLocalPathUpdater adapts the task service's UpdateRepository to the executor.RepoUpdater interface. type repoLocalPathUpdater struct { svc *taskservice.Service diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 0df0e4f4c7..b12381a40d 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -2,12 +2,15 @@ package main import ( "context" + "fmt" "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/discovery" + "github.com/kandev/kandev/internal/agent/hostutility" "github.com/kandev/kandev/internal/agent/registry" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" + agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" @@ -19,6 +22,7 @@ import ( "github.com/kandev/kandev/internal/linear" promptservice "github.com/kandev/kandev/internal/prompts/service" "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/slack" taskmodels "github.com/kandev/kandev/internal/task/models" taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" @@ -86,7 +90,8 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories githubSvc := initGitHubService(dbPool, eventBus, repos.Secrets, log) jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log) - linearSvc := initLinearService(dbPool, repos.Secrets, log) + linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log) + slackSvc := initSlackService(dbPool, repos.Secrets, log) return &Services{ Task: taskSvc, @@ -98,6 +103,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories GitHub: githubSvc, Jira: jiraSvc, Linear: linearSvc, + Slack: slackSvc, // Notification service is initialized after gateway is available. Notification: nil, }, agentSettingsController, nil @@ -238,16 +244,68 @@ func initJiraService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secret } // initLinearService wires up the Linear integration. Failures are non-fatal. -// Linear doesn't publish events today so it doesn't take an eventBus — -// adding one would be speculative until a feature actually needs it. -func initLinearService(dbPool *db.Pool, secretsStore secrets.SecretStore, log *logger.Logger) *linear.Service { - svc, _, err := linear.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), log) +func initLinearService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *linear.Service { + svc, _, err := linear.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log) if err != nil { log.Warn("Linear service initialization failed (non-fatal)", zap.Error(err)) } return svc } +// initSlackService wires up the Slack integration. Failures are non-fatal. +// The agent runner is wired post-construction by main.go once hostutility + +// utility services exist. +func initSlackService(dbPool *db.Pool, secretsStore secrets.SecretStore, log *logger.Logger) *slack.Service { + svc, _, err := slack.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), log) + if err != nil { + log.Warn("Slack service initialization failed (non-fatal)", zap.Error(err)) + } + return svc +} + +// buildKandevMCPURL is the URL passed to the Slack triage agent for the +// Kandev MCP server. The MCP server is mounted on the same port as the rest +// of the backend's HTTP API; this just centralises the path so it stays in +// sync with internal/mcp/server's mount point ("/mcp"). +func buildKandevMCPURL(port int) string { + if port == 0 { + port = portsBackendDefault + } + return fmt.Sprintf("http://localhost:%d/mcp", port) +} + +// portsBackendDefault is the default backend HTTP port. We don't import +// internal/common/ports here to avoid pulling its transitive deps into +// services.go's import graph; the value is duplicated only as a fallback for +// when cfg.Server.Port is left at zero (which shouldn't happen in practice). +const portsBackendDefault = 38429 + +// slackHostUtilityAdapter adapts *hostutility.Manager to slack.HostUtilityRunner. +// The slack package can't import hostutility without a transitive cycle (it +// would need to import agentctl + lifecycle), so we shim through the agentctl +// utility DTO here in the cmd package where both are already imported. +type slackHostUtilityAdapter struct { + mgr *hostutility.Manager +} + +func (a slackHostUtilityAdapter) ExecutePromptWithMCP( + ctx context.Context, + agentType, model, mode, prompt string, + mcpServers []agentctlutil.MCPServerDTO, +) (slack.HostPromptResult, error) { + res, err := a.mgr.ExecutePromptWithMCP(ctx, agentType, model, mode, prompt, mcpServers) + if err != nil { + return slack.HostPromptResult{}, err + } + return slack.HostPromptResult{ + Response: res.Response, + Model: res.Model, + PromptTokens: res.PromptTokens, + ResponseTokens: res.ResponseTokens, + DurationMs: res.DurationMs, + }, nil +} + // workflowProviderAdapter adapts task service to workflow service's WorkflowProvider interface. type workflowProviderAdapter struct { svc *taskservice.Service diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go index fee624d0a0..84098f9a35 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/cmd/kandev/types.go @@ -13,6 +13,7 @@ import ( promptservice "github.com/kandev/kandev/internal/prompts/service" promptstore "github.com/kandev/kandev/internal/prompts/store" "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/slack" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" @@ -47,4 +48,5 @@ type Services struct { GitHub *github.Service Jira *jira.Service Linear *linear.Service + Slack *slack.Service } diff --git a/apps/backend/cmd/mock-agent/scenarios.go b/apps/backend/cmd/mock-agent/scenarios.go index 067a4ec784..3840e24c85 100644 --- a/apps/backend/cmd/mock-agent/scenarios.go +++ b/apps/backend/cmd/mock-agent/scenarios.go @@ -25,9 +25,13 @@ var scenarioRegistry = map[string]func(e *emitter){ "diff-expansion-setup": scenarioDiffExpansionSetup, "diff-update-setup": scenarioDiffUpdateSetup, "diff-update-modify": scenarioDiffUpdateModify, + "diff-update-streaming": scenarioDiffUpdateStreaming, + "multi-file-setup": scenarioMultiFileSetup, + "multi-file-modify": scenarioMultiFileModify, "untracked-file-setup": scenarioUntrackedFileSetup, "untracked-file-modify": scenarioUntrackedFileModify, "clarification": scenarioClarification, + "clarification-multi": scenarioClarificationMulti, "clarification-timeout": scenarioClarificationTimeout, "multi-permission": scenarioMultiPermission, "review-cumulative-setup": scenarioReviewCumulativeSetup, @@ -386,6 +390,114 @@ func scenarioDiffUpdateModify(e *emitter) { e.text("diff-update-modify complete: diff_update_test.txt now has SECOND_MODIFICATION") } +// scenarioDiffUpdateStreaming modifies the file mid-turn, emitting text both +// before and after the write so the agent's turn stays active for several +// seconds. Used to assert that open file editor / diff panels auto-update +// while the agent is still streaming. +func scenarioDiffUpdateStreaming(e *emitter) { + fixedDelay(50) + e.text("diff-update-streaming: starting work") + + fixedDelay(1000) + + filePath := "diff_update_test.txt" + modifiedContent := "line 1: SECOND_MODIFICATION\nline 2: unchanged\nline 3: ALSO_CHANGED\n" + if err := os.WriteFile(filePath, []byte(modifiedContent), 0o644); err != nil { + e.text("diff-update-streaming: write failed: " + err.Error()) + return + } + + fixedDelay(500) + e.text("diff-update-streaming: file written, continuing") + + // Long tail keeps the turn active long enough for polling+sync to fire. + fixedDelay(6000) + e.text("diff-update-streaming complete") +} + +// scenarioMultiFileSetup commits three tracked files, then modifies all three +// so each shows up in git status with FIRST_MODIFICATION. Used to test the +// case where the user has multiple file editors / diff panels open at once. +func scenarioMultiFileSetup(e *emitter) { + fixedDelay(50) + + wd, err := os.Getwd() + if err != nil { + e.text("multi-file-setup: getwd failed: " + err.Error()) + return + } + + runGitCmd := makeGitRunner(wd) + files := []string{"multi_a.txt", "multi_b.txt", "multi_c.txt"} + + // Cleanup any leftovers from a prior run. + for _, f := range files { + _ = runGitCmd("rm", "--force", f) + } + _ = runGitCmd("commit", "-m", "cleanup multi-file fixtures") + + // Commit pristine versions. + for _, f := range files { + original := fmt.Sprintf("%s line 1: original\n%s line 2: unchanged\n%s line 3: original\n", f, f, f) + if err := os.WriteFile(f, []byte(original), 0o644); err != nil { + e.text("multi-file-setup: write failed: " + err.Error()) + return + } + if err := runGitCmd("add", f); err != nil { + e.text("multi-file-setup: git add failed for " + f) + return + } + } + if err := runGitCmd("commit", "-m", "add multi-file fixtures"); err != nil { + e.text("multi-file-setup: git commit failed") + return + } + + // Modify all three so each appears in git status with FIRST_MODIFICATION. + for _, f := range files { + modified := fmt.Sprintf("%s line 1: FIRST_MODIFICATION\n%s line 2: unchanged\n%s line 3: original\n", f, f, f) + if err := os.WriteFile(f, []byte(modified), 0o644); err != nil { + e.text("multi-file-setup: write modified failed: " + err.Error()) + return + } + } + + fixedDelay(100) + e.text("multi-file-setup complete: 3 files have FIRST_MODIFICATION") +} + +// scenarioMultiFileModify modifies all three multi-file fixtures within a +// single turn, with a long trailing delay so the panels must auto-update +// while the agent is still streaming. Reproduces the user's reported case +// where multiple open editor / diff panels go stale on a real edit. +func scenarioMultiFileModify(e *emitter) { + fixedDelay(50) + e.text("multi-file-modify: starting work") + + fixedDelay(800) + + files := []string{"multi_a.txt", "multi_b.txt", "multi_c.txt"} + for i, f := range files { + modified := fmt.Sprintf( + "%s line 1: SECOND_MODIFICATION\n%s line 2: unchanged\n%s line 3: ALSO_CHANGED_%d\n", + f, f, f, i, + ) + if err := os.WriteFile(f, []byte(modified), 0o644); err != nil { + e.text("multi-file-modify: write failed: " + err.Error()) + return + } + // Stagger the writes slightly to mimic a real agent doing one tool + // call per file rather than all writes in a single instant. + fixedDelay(250) + } + + e.text("multi-file-modify: writes done, continuing") + + // Long tail keeps the turn active so we can assert mid-turn updates. + fixedDelay(5000) + e.text("multi-file-modify complete") +} + // scenarioUntrackedFileSetup creates a new untracked file. func scenarioUntrackedFileSetup(e *emitter) { fixedDelay(50) @@ -420,18 +532,75 @@ func scenarioUntrackedFileModify(e *emitter) { e.text("untracked-file-modify complete: untracked_test.txt now has MODIFIED_CONTENT") } -// clarificationQuestionArgs returns the MCP arguments for the clarification question. +// MCP argument keys used by the clarification scenarios. Pulled out so goconst +// stays happy when the same string would otherwise repeat across both helpers. +const ( + clarificationOptionsKey = "options" + clarificationLabelKey = "label" + clarificationDescKey = "description" + clarificationPromptKey = "prompt" + clarificationIDKey = "id" +) + +func mockOption(label, description string) map[string]any { + return map[string]any{clarificationLabelKey: label, clarificationDescKey: description} +} + +// clarificationQuestionArgs returns the MCP arguments for a single-question +// clarification call. Used by the simplest e2e scenario where a one-question +// bundle exercises the same code path as multi-question. func clarificationQuestionArgs() map[string]any { return map[string]any{ - "prompt": "Which database should we use for this project?", - "options": []map[string]any{ - {"label": "PostgreSQL", "description": "Relational database with strong consistency"}, - {"label": "MongoDB", "description": "Document database for flexible schemas"}, - {"label": "SQLite", "description": "Embedded database for simplicity"}, + "questions": []map[string]any{ + { + clarificationIDKey: "db", + clarificationPromptKey: "Which database should we use for this project?", + clarificationOptionsKey: []map[string]any{ + mockOption("PostgreSQL", "Relational database with strong consistency"), + mockOption("MongoDB", "Document database for flexible schemas"), + mockOption("SQLite", "Embedded database for simplicity"), + }, + }, }, } } +// clarificationMultiQuestionArgs returns the MCP arguments for a 3-question +// clarification bundle that exercises the multi-question UI flow. +func clarificationMultiQuestionArgs() map[string]any { + return map[string]any{ + "questions": []map[string]any{ + { + clarificationIDKey: "db", + clarificationPromptKey: "Which database should we use?", + clarificationOptionsKey: []map[string]any{ + mockOption("PostgreSQL", "Relational database with strong consistency"), + mockOption("MongoDB", "Document database for flexible schemas"), + mockOption("SQLite", "Embedded database for simplicity"), + }, + }, + { + clarificationIDKey: "language", + clarificationPromptKey: "Which language should we use?", + clarificationOptionsKey: []map[string]any{ + mockOption("Go", "Strong typing, fast compile"), + mockOption("TypeScript", "Familiar to the team"), + mockOption("Rust", "Best for systems work"), + }, + }, + { + clarificationIDKey: "deploy", + clarificationPromptKey: "How should we deploy?", + clarificationOptionsKey: []map[string]any{ + mockOption("Docker", "Containerized deploy"), + mockOption("Bare metal", "Run directly on hosts"), + }, + }, + }, + "context": "Picking the foundational stack — answer all three so we can move forward.", + } +} + // scenarioClarification: happy path — ask a question via MCP and wait for the answer. func scenarioClarification(e *emitter) { fixedDelay(100) @@ -447,6 +616,22 @@ func scenarioClarification(e *emitter) { e.text(fmt.Sprintf("You answered: %s", result)) } +// scenarioClarificationMulti: stress the multi-question path — 3 questions in +// one bundle, all required, single MCP call. +func scenarioClarificationMulti(e *emitter) { + fixedDelay(100) + e.text("Let me ask you a few questions about the project setup.") + + result, err := callMCPTool("kandev", "ask_user_question_kandev", clarificationMultiQuestionArgs()) + if err != nil { + e.text(fmt.Sprintf("Questions failed: %s", err)) + return + } + + fixedDelay(50) + e.text(fmt.Sprintf("You answered: %s", result)) +} + // scenarioClarificationTimeout: ask a question with a short timeout, then continue. func scenarioClarificationTimeout(e *emitter) { fixedDelay(100) diff --git a/apps/backend/config/prompts/linear-issue-watch-default.md b/apps/backend/config/prompts/linear-issue-watch-default.md new file mode 100644 index 0000000000..451b3a6395 --- /dev/null +++ b/apps/backend/config/prompts/linear-issue-watch-default.md @@ -0,0 +1,22 @@ +You have been assigned a Linear issue to work on. + +**Issue:** {{issue.url}} +**Identifier:** {{issue.identifier}} +**Title:** {{issue.title}} +**Team:** {{issue.team}} +**State:** {{issue.state}} +**Priority:** {{issue.priority}} +**Assignee:** {{issue.assignee}} + +## Description + +{{issue.description}} + +## Instructions + +1. Read the issue description carefully and understand the requirements. +2. Explore the codebase to understand the relevant code and architecture. +3. Implement the changes described in the issue. +4. Write or update tests to cover the changes. +5. Run the test suite to ensure nothing is broken. +6. Commit your changes with a descriptive commit message referencing {{issue.identifier}}. diff --git a/apps/backend/config/workflows/loader.go b/apps/backend/config/workflows/loader.go index 5edc2e83c7..b64f715c6b 100644 --- a/apps/backend/config/workflows/loader.go +++ b/apps/backend/config/workflows/loader.go @@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "strings" + "sync" "time" "github.com/kandev/kandev/internal/workflow/models" @@ -48,6 +49,37 @@ type actionYAML struct { Config map[string]any `yaml:"config,omitempty"` } +// HiddenTemplateIDs returns the set of template IDs marked `hidden: true` +// in their embedded YAML. Hidden templates are system-only flows +// (e.g. improve-kandev) that must not appear in management UI or pickers. +// +// The result is cached for the lifetime of the binary; the embedded YAML is +// static, and ListTemplates (the only caller) is on a hot path served on +// every picker / settings page load. +func HiddenTemplateIDs() (map[string]bool, error) { + hiddenOnce.Do(func() { + templates, err := LoadTemplates() + if err != nil { + hiddenErr = err + return + } + m := make(map[string]bool, len(templates)) + for _, t := range templates { + if t.Hidden { + m[t.ID] = true + } + } + hiddenIDs = m + }) + return hiddenIDs, hiddenErr +} + +var ( + hiddenOnce sync.Once + hiddenIDs map[string]bool + hiddenErr error +) + // LoadTemplates parses all embedded YAML files and returns workflow templates. func LoadTemplates() ([]*models.WorkflowTemplate, error) { entries, err := embeddedFS.ReadDir(".") diff --git a/apps/backend/internal/agent/hostutility/public.go b/apps/backend/internal/agent/hostutility/public.go index f156223f7d..3c38b244eb 100644 --- a/apps/backend/internal/agent/hostutility/public.go +++ b/apps/backend/internal/agent/hostutility/public.go @@ -56,11 +56,24 @@ func (m *Manager) Refresh(ctx context.Context, agentType string) (AgentCapabilit } // ExecutePrompt runs a sessionless utility prompt against the warm instance -// for the given agent type. The caller picks the model (explicit from the -// utility agent record, user default, or probe cache fallback). +// for the given agent type. Convenience wrapper over ExecutePromptWithMCP +// that opts the call out of MCP tool access — most callers (PR title, commit +// message, etc.) want pure text-in/text-out. func (m *Manager) ExecutePrompt( ctx context.Context, agentType, model, mode, prompt string, +) (*PromptResult, error) { + return m.ExecutePromptWithMCP(ctx, agentType, model, mode, prompt, nil) +} + +// ExecutePromptWithMCP runs a sessionless utility prompt with the given MCP +// servers wired into the agent's session. The agent (Claude Code, codex, +// etc.) can call MCP tools mid-prompt and incorporate the results into its +// final reply. Pass nil for mcpServers to disable MCP for this call. +func (m *Manager) ExecutePromptWithMCP( + ctx context.Context, + agentType, model, mode, prompt string, + mcpServers []agentctlutil.MCPServerDTO, ) (*PromptResult, error) { if prompt == "" { return nil, fmt.Errorf("prompt is required") @@ -83,6 +96,7 @@ func (m *Manager) ExecutePrompt( ModelFlag: cfg.ModelFlag.Args(), WorkDir: inst.workDir, }, + MCPServers: mcpServers, } resp, err := inst.client.InferencePrompt(ctx, req) if err != nil { diff --git a/apps/backend/internal/agentctl/client/git.go b/apps/backend/internal/agentctl/client/git.go index 99285313f4..59165ee10d 100644 --- a/apps/backend/internal/agentctl/client/git.go +++ b/apps/backend/internal/agentctl/client/git.go @@ -380,6 +380,10 @@ type GitCommitInfo struct { FilesChanged int `json:"files_changed"` Insertions int `json:"insertions"` Deletions int `json:"deletions"` + // Pushed mirrors process.GitCommitInfo.Pushed — true when the commit is + // reachable from the branch's upstream tracking ref. Sourced from git, so + // it stays correct without an open PR and across multi-repo workspaces. + Pushed bool `json:"pushed"` // RepositoryName tags commits when fetched from a multi-repo subpath. Empty // for single-repo workspaces. Stamped client-side after the per-repo call // so callers can fan out across repos and merge. diff --git a/apps/backend/internal/agentctl/server/api/git_log_merge_base_test.go b/apps/backend/internal/agentctl/server/api/git_log_merge_base_test.go index 21c622b864..5f26028d4d 100644 --- a/apps/backend/internal/agentctl/server/api/git_log_merge_base_test.go +++ b/apps/backend/internal/agentctl/server/api/git_log_merge_base_test.go @@ -34,7 +34,7 @@ func TestComputeMergeBase_PrefersOriginOverStaleLocalBranch(t *testing.T) { advancedMain := strings.TrimSpace(runGitAPI(t, repoDir, "rev-parse", "HEAD")) runGitAPI(t, repoDir, "push", "origin", "main") runGitAPI(t, repoDir, "reset", "--hard", staleLocalMain) - runGitAPI(t, repoDir, "fetch", "origin", "main:refs/remotes/origin/main") + runGitAPI(t, repoDir, "fetch", "origin") // Branch from the (advanced) origin/main and add one commit. runGitAPI(t, repoDir, "checkout", "-b", "feature/x", advancedMain) diff --git a/apps/backend/internal/agentctl/server/process/git_log.go b/apps/backend/internal/agentctl/server/process/git_log.go index e6b7c19a12..7f1e75357d 100644 --- a/apps/backend/internal/agentctl/server/process/git_log.go +++ b/apps/backend/internal/agentctl/server/process/git_log.go @@ -37,6 +37,11 @@ type GitCommitInfo struct { FilesChanged int `json:"files_changed"` Insertions int `json:"insertions"` Deletions int `json:"deletions"` + // Pushed is true when the commit is reachable from the branch's upstream + // tracking ref (i.e. present on the remote). Sourced from git itself — + // not from any PR API — so it stays correct when no PR exists yet, after + // rebases, and across multi-repo workspaces. + Pushed bool `json:"pushed"` // RepositoryName tags commits returned from a multi-repo log fan-out. // Empty for single-repo workspaces. Set by the API layer after the call. RepositoryName string `json:"repository_name,omitempty"` @@ -151,10 +156,60 @@ func (g *GitOperator) GetLog(ctx context.Context, baseCommit string, limit int) }) } + g.markPushedCommits(ctx, result.Commits) + result.Success = true return result, nil } +// markPushedCommits sets Pushed on each commit by looking up which commits in +// HEAD's history are NOT reachable from the upstream tracking ref. A commit is +// pushed iff it is not in that "ahead" set. When the branch has no upstream +// (never been pushed) or the lookup fails, all commits stay Pushed=false — the +// safer default than falsely claiming a commit is on the remote. +func (g *GitOperator) markPushedCommits(ctx context.Context, commits []*GitCommitInfo) { + if len(commits) == 0 { + return + } + upstream := g.getUpstreamRef(ctx) + if upstream == "" { + return + } + // Resolve to a SHA so we can pass it to rev-list as `^`. The caret + // prefix means the arg doesn't match LooksLikeCommitSHA; it falls through + // to the non-branch catch-all in runGitCommand, which passes it unchanged. + // Safe: upstreamSHA is local rev-parse output, not user input. + upstreamSHA, err := g.runGitCommand(ctx, "rev-parse", upstream) + if err != nil { + return + } + upstreamSHA = strings.TrimSpace(upstreamSHA) + if upstreamSHA == "" { + return + } + // Cap the walk to the number of commits we're marking. Without this, a + // branch with many local-only commits would walk unbounded history per + // GetLog call. rev-list walks newest-first the same way GetLog does, so + // the N most recent unpushed SHAs cover the N commits in our result. + output, err := g.runGitCommand(ctx, "rev-list", + fmt.Sprintf("-n%d", len(commits)), "HEAD", "^"+upstreamSHA) + if err != nil { + return + } + unpushed := make(map[string]struct{}) + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + sha := strings.TrimSpace(line) + if sha != "" { + unpushed[sha] = struct{}{} + } + } + for _, c := range commits { + if _, isUnpushed := unpushed[c.CommitSHA]; !isUnpushed { + c.Pushed = true + } + } +} + // GetCumulativeDiff returns the cumulative diff from baseCommit to the working tree // (including uncommitted/unstaged changes). func (g *GitOperator) GetCumulativeDiff(ctx context.Context, baseCommit string) (*CumulativeDiffResult, error) { diff --git a/apps/backend/internal/agentctl/server/process/git_log_test.go b/apps/backend/internal/agentctl/server/process/git_log_test.go index dd46cc7224..d63ee00f3e 100644 --- a/apps/backend/internal/agentctl/server/process/git_log_test.go +++ b/apps/backend/internal/agentctl/server/process/git_log_test.go @@ -79,7 +79,7 @@ func TestGetLog_StaleLocalBranchScenario(t *testing.T) { // Reset local main back to staleLocalMain (simulates "didn't fetch"). runGit(t, repoDir, "reset", "--hard", staleLocalMain) // Now refetch so origin/main is ahead but local main isn't. - runGit(t, repoDir, "fetch", "origin", "main:refs/remotes/origin/main") + runGit(t, repoDir, "fetch", "origin") // Branch from origin/main (the current upstream tip). runGit(t, repoDir, "checkout", "-b", "feature/x", advancedMain) @@ -125,6 +125,87 @@ func TestGetLog_StaleLocalBranchScenario(t *testing.T) { } } +// TestGetLog_PushedReflectsRemoteState verifies that GetLog tags each commit +// with Pushed = true iff it's reachable from the branch's upstream tracking +// ref. This is the fix for the bug where commits showed as "not pushed" when +// they were actually on the remote — sourcing the flag from PR commits broke +// any flow without an open PR. Sourcing it from git's own remote tracking ref +// keeps the answer correct regardless of PR state. +func TestGetLog_PushedReflectsRemoteState(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + log := newTestLogger(t) + ctx := context.Background() + + // setupTestRepo leaves us on main with one initial commit already pushed. + // Branch off and create one pushed + one unpushed commit so we exercise + // both states inside the same log range. + runGit(t, repoDir, "checkout", "-b", "feature/x") + writeFile(t, repoDir, "a.txt", "a\n") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "feat: a") + pushedSHA := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) + runGit(t, repoDir, "push", "-u", "origin", "feature/x") + + writeFile(t, repoDir, "b.txt", "b\n") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "feat: b") + unpushedSHA := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) + + gitOp := NewGitOperator(repoDir, log, nil) + result, err := gitOp.GetLog(ctx, "", 0) + if err != nil { + t.Fatalf("GetLog returned error: %v", err) + } + if !result.Success { + t.Fatalf("GetLog failed: %s", result.Error) + } + + got := make(map[string]bool, len(result.Commits)) + for _, c := range result.Commits { + got[c.CommitSHA] = c.Pushed + } + if pushed, ok := got[pushedSHA]; !ok { + t.Fatalf("pushed commit %s missing from log", pushedSHA) + } else if !pushed { + t.Errorf("expected pushed commit %s to be Pushed=true, got false", pushedSHA) + } + if pushed, ok := got[unpushedSHA]; !ok { + t.Fatalf("unpushed commit %s missing from log", unpushedSHA) + } else if pushed { + t.Errorf("expected local-only commit %s to be Pushed=false, got true", unpushedSHA) + } +} + +// TestGetLog_PushedFalseWhenNoUpstream guards against falsely marking commits +// as pushed on branches that have never been pushed (no upstream configured). +// The "safer default" path must keep Pushed=false rather than guessing. +func TestGetLog_PushedFalseWhenNoUpstream(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + log := newTestLogger(t) + ctx := context.Background() + + // Branch with no upstream — never pushed. + runGit(t, repoDir, "checkout", "-b", "feature/no-upstream") + writeFile(t, repoDir, "a.txt", "a\n") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "feat: a") + + gitOp := NewGitOperator(repoDir, log, nil) + result, err := gitOp.GetLog(ctx, "", 0) + if err != nil { + t.Fatalf("GetLog returned error: %v", err) + } + for _, c := range result.Commits { + if c.Pushed { + t.Errorf("commit %s on branch with no upstream should be Pushed=false, got true", c.CommitSHA) + } + } +} + // TestGetLog_FirstParentSkipsMergedInCommits verifies that GetLog uses // --first-parent so commits brought in via a merge from main do not appear in // the branch's commit list. Without --first-parent, "git log ..HEAD" diff --git a/apps/backend/internal/agentctl/server/utility/acp_executor.go b/apps/backend/internal/agentctl/server/utility/acp_executor.go index 34ad017734..039be01ba2 100644 --- a/apps/backend/internal/agentctl/server/utility/acp_executor.go +++ b/apps/backend/internal/agentctl/server/utility/acp_executor.go @@ -85,7 +85,12 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest) }() // Execute ACP protocol - response, err := e.executeACPSession(ctx, stdin, stdout, workDir, req.Prompt, req.Model, req.Mode) + mcpServers, dropped := toACPMcpServers(req.MCPServers) + for _, name := range dropped { + e.logger.Warn("ACP inference: dropping unsupported MCP server transport", + zap.String("name", name)) + } + response, err := e.executeACPSession(ctx, stdin, stdout, workDir, req.Prompt, req.Model, req.Mode, mcpServers) if err != nil { return &PromptResponse{ Success: false, @@ -104,7 +109,9 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest) // executeACPSession performs the ACP handshake, creates a session, optionally // sets the session model and mode, sends the prompt, and collects the response -// text. +// text. mcpServers, when non-empty, are forwarded to session/new so the agent +// can call MCP tools mid-prompt; an empty slice preserves the legacy "pure +// inference" behaviour. func (e *ACPInferenceExecutor) executeACPSession( ctx context.Context, stdin io.Writer, @@ -113,6 +120,7 @@ func (e *ACPInferenceExecutor) executeACPSession( prompt string, model string, mode string, + mcpServers []acp.McpServer, ) (string, error) { // Collect response text from updates var responseText strings.Builder @@ -149,10 +157,14 @@ func (e *ACPInferenceExecutor) executeACPSession( return "", fmt.Errorf("ACP initialize failed: %w", err) } - // Create new session with empty MCP servers array (required by ACP protocol) + // Create new session. ACP requires McpServers to be a non-nil slice; + // callers without tools pass nil and we substitute an empty array here. + if mcpServers == nil { + mcpServers = []acp.McpServer{} + } sessionResp, err := conn.NewSession(ctx, acp.NewSessionRequest{ Cwd: workDir, - McpServers: []acp.McpServer{}, + McpServers: mcpServers, }) if err != nil { return "", fmt.Errorf("ACP session/new failed: %w", err) @@ -198,6 +210,59 @@ func (e *ACPInferenceExecutor) executeACPSession( return result, nil } +// toACPMcpServers converts the cross-process DTO list into the ACP SDK +// shape. Uses the *Inline variants because the agentcooper fork (vendored +// via go.mod replace) names them that way; the upstream SDK would call them +// McpServerHttp / McpServerSse. Returns nil when there are no entries so +// callers can use the nil-as-empty convention upstream. The second return +// value carries the names of any DTOs we couldn't convert (unsupported +// transport, e.g. stdio) so the caller can surface them in logs rather than +// having them silently disappear from the agent's tool surface. +func toACPMcpServers(in []MCPServerDTO) ([]acp.McpServer, []string) { + if len(in) == 0 { + return nil, nil + } + out := make([]acp.McpServer, 0, len(in)) + var dropped []string + for _, s := range in { + switch strings.ToLower(s.Type) { + case "http": + out = append(out, acp.McpServer{Http: &acp.McpServerHttpInline{ + Name: s.Name, + Type: "http", + Url: s.URL, + Headers: toACPHeaders(s.HeaderKVs), + }}) + case "sse": + out = append(out, acp.McpServer{Sse: &acp.McpServerSseInline{ + Name: s.Name, + Type: "sse", + Url: s.URL, + Headers: toACPHeaders(s.HeaderKVs), + }}) + default: + // Unsupported transport (stdio, or anything else). We don't fail + // the whole inference call on a single bad entry — the agent can + // still run with the entries that did convert — but we surface + // the name so misconfiguration is visible in logs rather than + // silently leaving the agent without tools it expected to have. + dropped = append(dropped, s.Name) + } + } + return out, dropped +} + +func toACPHeaders(in []HTTPHeaderDTO) []acp.HttpHeader { + if len(in) == 0 { + return []acp.HttpHeader{} + } + out := make([]acp.HttpHeader, 0, len(in)) + for _, h := range in { + out = append(out, acp.HttpHeader{Name: h.Name, Value: h.Value}) + } + return out +} + // Probe runs an ephemeral ACP handshake (initialize + session/new) to discover // agent capabilities, auth methods, models, and modes. It does not send a prompt. func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*ProbeResponse, error) { diff --git a/apps/backend/internal/agentctl/server/utility/types.go b/apps/backend/internal/agentctl/server/utility/types.go index 5ec044c427..eb37b64a3c 100644 --- a/apps/backend/internal/agentctl/server/utility/types.go +++ b/apps/backend/internal/agentctl/server/utility/types.go @@ -24,6 +24,32 @@ type PromptRequest struct { // MaxTokens is the maximum tokens for the response (default: 1024). MaxTokens int `json:"max_tokens,omitempty"` + + // MCPServers are the MCP servers to expose to the agent for the duration + // of this single inference call. Forwarded to ACP `session/new` so the + // agent can call tools mid-prompt. Empty (the common case) keeps the + // existing "pure inference, no tools" behaviour for utility-agent + // callers that only need text-in/text-out (PR title generation, commit + // messages, etc.). + MCPServers []MCPServerDTO `json:"mcp_servers,omitempty"` +} + +// MCPServerDTO describes a single MCP server endpoint to wire into the +// agent's session. Mirrors the ACP McpServer shape but kept JSON-flat so it +// crosses the agentctl HTTP boundary cleanly. Today we use HTTP transport +// only (Type="http"); stdio/sse can be added when a caller needs them. +type MCPServerDTO struct { + Name string `json:"name"` + Type string `json:"type"` // "http" | "sse" | "stdio" + URL string `json:"url,omitempty"` // for http/sse + // HeaderKVs is an optional set of HTTP headers (key, value pairs). + HeaderKVs []HTTPHeaderDTO `json:"headers,omitempty"` +} + +// HTTPHeaderDTO is a single HTTP header for HTTP/SSE-transport MCP servers. +type HTTPHeaderDTO struct { + Name string `json:"name"` + Value string `json:"value"` } // ProbeRequest is the request for probing an agent's capabilities. diff --git a/apps/backend/internal/clarification/canceller.go b/apps/backend/internal/clarification/canceller.go index dc6c628360..ecf5199ddf 100644 --- a/apps/backend/internal/clarification/canceller.go +++ b/apps/backend/internal/clarification/canceller.go @@ -49,28 +49,28 @@ func (c *Canceller) CancelSessionAndNotify(ctx context.Context, sessionID string } for _, id := range pendingIDs { - msg, err := c.repo.FindMessageByPendingID(ctx, id) - if err != nil || msg == nil { - c.logger.Debug("message not found for cancelled clarification", + msgs, err := c.repo.FindMessagesByPendingID(ctx, id) + if err != nil || len(msgs) == 0 { + c.logger.Debug("messages not found for cancelled clarification", zap.String("pending_id", id), zap.Error(err)) continue } - if msg.Metadata == nil { - msg.Metadata = map[string]any{} + for _, msg := range msgs { + if msg.Metadata == nil { + msg.Metadata = map[string]any{} + } + msg.Metadata["agent_disconnected"] = true + msg.Metadata["status"] = string(StatusExpired) + if err := c.repo.UpdateMessage(ctx, msg); err != nil { + c.logger.Warn("failed to update message with expired status", + zap.String("pending_id", id), + zap.String("message_id", msg.ID), + zap.Error(err)) + continue + } + c.publishMessageUpdated(ctx, msg) } - msg.Metadata["agent_disconnected"] = true - msg.Metadata["status"] = "expired" - if err := c.repo.UpdateMessage(ctx, msg); err != nil { - c.logger.Warn("failed to update message with expired status", - zap.String("pending_id", id), - zap.String("message_id", msg.ID), - zap.Error(err)) - continue - } - - // Publish message.updated event so the frontend picks up the metadata change - c.publishMessageUpdated(ctx, msg) } return len(pendingIDs) diff --git a/apps/backend/internal/clarification/canceller_test.go b/apps/backend/internal/clarification/canceller_test.go index 774fbb8470..81ec97fa17 100644 --- a/apps/backend/internal/clarification/canceller_test.go +++ b/apps/backend/internal/clarification/canceller_test.go @@ -12,7 +12,7 @@ import ( ) type stubMessageStore struct { - messages map[string]*taskmodels.Message + messages map[string][]*taskmodels.Message updated []*taskmodels.Message } @@ -21,11 +21,19 @@ func (s *stubMessageStore) GetTaskSession(context.Context, string) (*taskmodels. } func (s *stubMessageStore) FindMessageByPendingID(_ context.Context, pendingID string) (*taskmodels.Message, error) { - m, ok := s.messages[pendingID] - if !ok { + msgs, ok := s.messages[pendingID] + if !ok || len(msgs) == 0 { return nil, errors.New("not found") } - return m, nil + return msgs[0], nil +} + +func (s *stubMessageStore) FindMessagesByPendingID(_ context.Context, pendingID string) ([]*taskmodels.Message, error) { + msgs, ok := s.messages[pendingID] + if !ok { + return nil, nil + } + return msgs, nil } func (s *stubMessageStore) UpdateMessage(_ context.Context, m *taskmodels.Message) error { @@ -42,7 +50,7 @@ func (s *stubEventBus) Publish(_ context.Context, _ string, ev *bus.Event) error return nil } -func newTestCanceller(t *testing.T, msgs map[string]*taskmodels.Message) (*Canceller, *stubMessageStore, *stubEventBus) { +func newTestCanceller(t *testing.T, msgs map[string][]*taskmodels.Message) (*Canceller, *stubMessageStore, *stubEventBus) { t.Helper() store := NewStore(time.Minute) repo := &stubMessageStore{messages: msgs} @@ -56,17 +64,17 @@ func newTestCanceller(t *testing.T, msgs map[string]*taskmodels.Message) (*Cance // stays interactive and a user clicking X triggers an unintended new turn via // the event fallback path in the respond handler. func TestCanceller_MarksStatusExpiredOnDisconnect(t *testing.T) { - msgs := map[string]*taskmodels.Message{} + msgs := map[string][]*taskmodels.Message{} c, repo, _ := newTestCanceller(t, msgs) pendingID := c.store.CreateRequest(&Request{SessionID: "s1"}) - msgs[pendingID] = &taskmodels.Message{ + msgs[pendingID] = []*taskmodels.Message{{ ID: "m1", TaskSessionID: "s1", Metadata: map[string]any{ "status": "pending", }, - } + }} cancelled := c.CancelSessionAndNotify(context.Background(), "s1") if cancelled != 1 { @@ -88,7 +96,7 @@ func TestCanceller_MarksStatusExpiredOnDisconnect(t *testing.T) { // TestCanceller_NoMessagesToUpdate confirms that a cancel with no pending // clarifications returns 0 and does not touch the repo. func TestCanceller_NoMessagesToUpdate(t *testing.T) { - c, repo, _ := newTestCanceller(t, map[string]*taskmodels.Message{}) + c, repo, _ := newTestCanceller(t, map[string][]*taskmodels.Message{}) if got := c.CancelSessionAndNotify(context.Background(), "nonexistent"); got != 0 { t.Errorf("expected 0 cancelled, got %d", got) @@ -101,15 +109,15 @@ func TestCanceller_NoMessagesToUpdate(t *testing.T) { // TestCanceller_PublishesMessageUpdatedEvent confirms the canceller publishes // a message.updated event so the frontend refreshes the message in place. func TestCanceller_PublishesMessageUpdatedEvent(t *testing.T) { - msgs := map[string]*taskmodels.Message{} + msgs := map[string][]*taskmodels.Message{} c, _, eventBus := newTestCanceller(t, msgs) pendingID := c.store.CreateRequest(&Request{SessionID: "s1"}) - msgs[pendingID] = &taskmodels.Message{ + msgs[pendingID] = []*taskmodels.Message{{ ID: "m1", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending"}, - } + }} c.CancelSessionAndNotify(context.Background(), "s1") @@ -117,3 +125,26 @@ func TestCanceller_PublishesMessageUpdatedEvent(t *testing.T) { t.Fatalf("expected 1 event, got %d", len(eventBus.events)) } } + +// TestCanceller_MultiQuestion_MarksAllMessagesExpired confirms a multi-question +// bundle has every message marked expired (and emits one update event each) +// so the frontend overlay closes for the whole stack on agent timeout. +func TestCanceller_MultiQuestion_MarksAllMessagesExpired(t *testing.T) { + msgs := map[string][]*taskmodels.Message{} + c, _, eventBus := newTestCanceller(t, msgs) + + pendingID := c.store.CreateRequest(&Request{SessionID: "s1"}) + msgs[pendingID] = []*taskmodels.Message{ + {ID: "m1", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending", "question_id": "q1"}}, + {ID: "m2", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending", "question_id": "q2"}}, + {ID: "m3", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending", "question_id": "q3"}}, + } + + cancelled := c.CancelSessionAndNotify(context.Background(), "s1") + if cancelled != 1 { + t.Fatalf("expected 1 cancelled bundle, got %d", cancelled) + } + if len(eventBus.events) != 3 { + t.Fatalf("expected 3 message.updated events, got %d", len(eventBus.events)) + } +} diff --git a/apps/backend/internal/clarification/handlers.go b/apps/backend/internal/clarification/handlers.go index 3b4c7413da..a2784926f9 100644 --- a/apps/backend/internal/clarification/handlers.go +++ b/apps/backend/internal/clarification/handlers.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "net/http" + "sort" + "strings" "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/common/logger" @@ -16,10 +18,19 @@ import ( "go.uber.org/zap" ) +// Metadata key constants used when constructing event payloads and reading +// per-message clarification metadata. Pulled out so goconst stays happy and +// renames stay safe. +const ( + metaQuestionKey = "question" + metaQuestionIDKey = "question_id" +) + // messageStore is the minimal task repository interface required by clarification handlers. type messageStore interface { GetTaskSession(ctx context.Context, id string) (*taskmodels.TaskSession, error) FindMessageByPendingID(ctx context.Context, pendingID string) (*taskmodels.Message, error) + FindMessagesByPendingID(ctx context.Context, pendingID string) ([]*taskmodels.Message, error) UpdateMessage(ctx context.Context, message *taskmodels.Message) error } @@ -30,10 +41,16 @@ type Broadcaster interface { // MessageCreator interface for creating messages in the database type MessageCreator interface { - // CreateClarificationRequestMessage creates a message for a clarification request - CreateClarificationRequestMessage(ctx context.Context, taskID, sessionID, pendingID string, question Question, clarificationContext string) (string, error) - // UpdateClarificationMessage updates a clarification message's status and response - UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, status string, answer *Answer) error + // CreateClarificationRequestMessages creates one chat message per question in + // a multi-question clarification request, all sharing the given pending_id. + // Only the last message returned should set RequestsInput=true so the chat + // scrolls to the bottom of the group. Returns the created message IDs in the + // same order as the input questions. + CreateClarificationRequestMessages(ctx context.Context, taskID, sessionID, pendingID string, questions []Question, clarificationContext string) ([]string, error) + // UpdateClarificationMessage updates the per-question clarification message's + // status (and stores the matching answer if any) for a (pending_id, question_id) + // pair within the session. + UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, questionID, status string, answer *Answer) error } // EventBus interface for publishing events. @@ -74,11 +91,13 @@ func RegisterRoutes(router *gin.Engine, store *Store, hub Broadcaster, messageCr } // CreateRequestBody is the request body for creating a clarification request. +// A single request may bundle 1..N questions; the bundle is gated on the user +// answering every question (or rejecting the bundle as a whole). type CreateRequestBody struct { - SessionID string `json:"session_id" binding:"required"` - TaskID string `json:"task_id"` - Question Question `json:"question" binding:"required"` - Context string `json:"context"` + SessionID string `json:"session_id" binding:"required"` + TaskID string `json:"task_id"` + Questions []Question `json:"questions" binding:"required,min=1,dive"` + Context string `json:"context"` } // CreateRequestResponse is the response for creating a clarification request. @@ -93,15 +112,9 @@ func (h *Handlers) httpCreateRequest(c *gin.Context) { return } - // Validate question has ID, generate if missing - if body.Question.ID == "" { - body.Question.ID = "q1" - } - // Validate options have IDs - for j := range body.Question.Options { - if body.Question.Options[j].ID == "" { - body.Question.Options[j].ID = generateOptionID(0, j) - } + if errMsg := NormalizeAndValidateQuestions(body.Questions); errMsg != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": errMsg}) + return } // Look up the task ID for this session @@ -121,35 +134,87 @@ func (h *Handlers) httpCreateRequest(c *gin.Context) { req := &Request{ SessionID: sessionID, TaskID: taskID, - Question: body.Question, + Questions: body.Questions, Context: body.Context, } pendingID := h.store.CreateRequest(req) - // Create a message in the database for the clarification request. - // This triggers the session.message.added WebSocket event which the frontend listens to. + // Create one message per question in the database; all share the same + // pending_id and are rendered as a stacked group on the frontend. The + // session.message.added WebSocket event fires per message. On failure we + // also cancel the in-store pending entry so any blocking WaitForResponse + // caller unblocks immediately rather than waiting for the MCP timeout. if h.messageCreator != nil { - _, err := h.messageCreator.CreateClarificationRequestMessage( + _, err := h.messageCreator.CreateClarificationRequestMessages( c.Request.Context(), taskID, sessionID, pendingID, - body.Question, + body.Questions, body.Context, ) if err != nil { - h.logger.Error("failed to create clarification request message", + h.logger.Error("failed to create clarification request messages", zap.String("pending_id", pendingID), zap.String("session_id", sessionID), zap.Error(err)) - // Don't fail the request - the clarification is still created in the store + h.store.CancelRequest(pendingID) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to create clarification messages: " + err.Error(), + }) + return } } c.JSON(http.StatusOK, CreateRequestResponse{PendingID: pendingID}) } +// NormalizeAndValidateQuestions is the single source of truth for clarification +// bundle validation. It mutates `questions` to assign missing IDs (q1, q2, ...) +// and option IDs, and enforces: +// - 1..4 questions per bundle +// - unique question IDs (rejects duplicates) +// - non-empty prompt +// - 2..6 options per question +// +// Both the HTTP handler (httpCreateRequest) and the WebSocket-side MCP handler +// (handleAskUserQuestion) call this so validation never drifts between paths. +// Returns "" on success or an error message describing the first failure. +func NormalizeAndValidateQuestions(questions []Question) string { + if len(questions) == 0 { + return "questions must contain at least 1 question" + } + if len(questions) > 4 { + return "questions must contain at most 4 questions" + } + seen := map[string]bool{} + for i := range questions { + if questions[i].ID == "" { + questions[i].ID = fmt.Sprintf("q%d", i+1) + } + if seen[questions[i].ID] { + return fmt.Sprintf("duplicate question id %q", questions[i].ID) + } + seen[questions[i].ID] = true + if questions[i].Prompt == "" { + return fmt.Sprintf("question %d is missing required 'prompt'", i+1) + } + if len(questions[i].Options) < 2 { + return fmt.Sprintf("question %d must have at least 2 options", i+1) + } + if len(questions[i].Options) > 6 { + return fmt.Sprintf("question %d must have at most 6 options", i+1) + } + for j := range questions[i].Options { + if questions[i].Options[j].ID == "" { + questions[i].Options[j].ID = generateOptionID(i, j) + } + } + } + return "" +} + func (h *Handlers) httpGetRequest(c *gin.Context) { pendingID := c.Param("id") @@ -174,8 +239,12 @@ func (h *Handlers) httpWaitForResponse(c *gin.Context) { } // RespondBody is the request body for responding to a clarification request. +// The frontend posts every answer at once when the user finishes the bundle +// (decision A: per-question commit collected in the hook, batched on the wire). +// Answers must contain exactly one entry per question in the original request, +// or be empty when Rejected=true. type RespondBody struct { - Answers []Answer `json:"answers"` // Keep as array for frontend compatibility, but only first is used + Answers []Answer `json:"answers"` Rejected bool `json:"rejected"` RejectReason string `json:"reject_reason"` } @@ -189,16 +258,21 @@ func (h *Handlers) httpRespond(c *gin.Context) { return } - // Extract the single answer (first one if provided) - var answer *Answer - if len(body.Answers) > 0 { - answer = &body.Answers[0] + // Gate: when not rejecting, the user must have answered every question and + // each answer must reference a question id from the original bundle (no + // duplicates, no fabricated ids). We compare against the in-store request + // first; if the entry is gone (agent already moved on), fall back to the + // persisted messages so we still validate even after the in-memory cleanup. + if !body.Rejected { + if errMsg := h.validateRespondAnswers(c.Request.Context(), pendingID, body.Answers); errMsg != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": errMsg}) + return + } } - // Build the response object resp := &Response{ PendingID: pendingID, - Answer: answer, + Answers: body.Answers, Rejected: body.Rejected, RejectReason: body.RejectReason, } @@ -209,11 +283,13 @@ func (h *Handlers) httpRespond(c *gin.Context) { err := h.store.Respond(pendingID, resp) if err == nil { - // Primary path succeeded — agent will receive the answer directly. - h.updateClarificationMessage(c, pendingID, body.Rejected, answer) - h.publishPrimaryAnsweredEvent(c, pendingID, answer, body.Rejected, body.RejectReason) + // Primary path succeeded — agent will receive the answers directly. + h.applyAnswersToMessages(c, pendingID, body.Rejected, body.Answers) + h.publishPrimaryAnsweredEvent(c, pendingID, body.Answers, body.Rejected, body.RejectReason) h.logger.Info("clarification answered via primary path (same turn)", - zap.String("pending_id", pendingID)) + zap.String("pending_id", pendingID), + zap.Int("answers", len(body.Answers)), + zap.Bool("rejected", body.Rejected)) c.JSON(http.StatusOK, gin.H{"success": true}) return } @@ -246,32 +322,145 @@ func (h *Handlers) httpRespond(c *gin.Context) { zap.String("pending_id", pendingID), zap.String("error", err.Error())) - h.updateClarificationMessage(c, pendingID, body.Rejected, answer) - h.respondViaEventFallback(c, pendingID, answer, body.Rejected, body.RejectReason) + h.applyAnswersToMessages(c, pendingID, body.Rejected, body.Answers) + h.respondViaEventFallback(c, pendingID, body.Answers, body.Rejected, body.RejectReason) c.JSON(http.StatusOK, gin.H{"success": true}) } -// updateClarificationMessage updates the message in the database with status and answer. -func (h *Handlers) updateClarificationMessage(c *gin.Context, pendingID string, rejected bool, answer *Answer) { +// validateRespondAnswers enforces the all-required gate **and** the question-id +// invariant: every answer must target a real question in the bundle, every +// question must have an answer, and no question id may be answered twice. +// Returns "" on success or an error message describing the first failure. +// +// The expected question ids come from the in-store request; if the in-memory +// entry has already been cleaned up (agent timed out before user responded), +// the persisted messages serve as fallback so the late-respond path is +// validated the same way as the primary path. +func (h *Handlers) validateRespondAnswers(ctx context.Context, pendingID string, answers []Answer) string { + expected := h.expectedQuestionIDs(ctx, pendingID) + if len(expected) == 0 { + // Couldn't determine the expected set — fall back to permissive (the + // primary-path Respond will still error sensibly if the bundle is gone). + return "" + } + expectedSet := make(map[string]bool, len(expected)) + for _, id := range expected { + expectedSet[id] = true + } + + if len(answers) != len(expected) { + return fmt.Sprintf("expected %d answers, got %d", len(expected), len(answers)) + } + + seen := make(map[string]bool, len(answers)) + for i, a := range answers { + if a.QuestionID == "" { + return fmt.Sprintf("answer %d is missing question_id", i+1) + } + if !expectedSet[a.QuestionID] { + return fmt.Sprintf("answer %d references unknown question id %q", i+1, a.QuestionID) + } + if seen[a.QuestionID] { + return fmt.Sprintf("answer %d duplicates question id %q", i+1, a.QuestionID) + } + seen[a.QuestionID] = true + } + return "" +} + +// expectedQuestionIDs returns the ordered question ids the user is expected to +// answer for the given pending bundle. Falls back to the persisted messages if +// the in-store request has been cleaned up. +func (h *Handlers) expectedQuestionIDs(ctx context.Context, pendingID string) []string { + if req, ok := h.store.GetRequest(pendingID); ok && req != nil { + ids := make([]string, 0, len(req.Questions)) + for _, q := range req.Questions { + ids = append(ids, q.ID) + } + return ids + } + if h.repo == nil { + return nil + } + msgs, err := h.repo.FindMessagesByPendingID(ctx, pendingID) + if err != nil || len(msgs) == 0 { + return nil + } + ids := make([]string, 0, len(msgs)) + for _, m := range msgs { + if id := stringFromMetadata(m.Metadata, metaQuestionIDKey); id != "" { + ids = append(ids, id) + } + } + return ids +} + +// applyAnswersToMessages flips per-question status (answered/rejected) on every +// message that belongs to the bundle. When rejected, every question is marked +// rejected; when answered, each answer updates the matching question's row. +func (h *Handlers) applyAnswersToMessages(c *gin.Context, pendingID string, rejected bool, answers []Answer) { if h.messageCreator == nil { return } sessionID := h.lookupSessionForPending(c, pendingID) - status := "answered" + if rejected { - status = "rejected" + // Mark every question in the bundle as rejected. Guard h.repo for + // parity with the sibling expectedQuestionIDs path; production wires + // both, but unit tests sometimes pass a nil repo. + if h.repo == nil { + return + } + msgs, err := h.repo.FindMessagesByPendingID(c.Request.Context(), pendingID) + if err != nil || len(msgs) == 0 { + h.logger.Debug("rejected clarification: no messages to update", + zap.String("pending_id", pendingID), + zap.Error(err)) + return + } + for _, msg := range msgs { + questionID := stringFromMetadata(msg.Metadata, "question_id") + if questionID == "" { + continue + } + if err := h.messageCreator.UpdateClarificationMessage(c.Request.Context(), sessionID, pendingID, questionID, "rejected", nil); err != nil { + h.logger.Warn("failed to mark clarification question rejected", + zap.String("pending_id", pendingID), + zap.String("question_id", questionID), + zap.Error(err)) + } + } + return } - if err := h.messageCreator.UpdateClarificationMessage(c.Request.Context(), sessionID, pendingID, status, answer); err != nil { - h.logger.Warn("failed to update clarification message", - zap.String("pending_id", pendingID), - zap.Error(err)) + + for i := range answers { + ans := answers[i] + if ans.QuestionID == "" { + continue + } + if err := h.messageCreator.UpdateClarificationMessage(c.Request.Context(), sessionID, pendingID, ans.QuestionID, "answered", &ans); err != nil { + h.logger.Warn("failed to update clarification question", + zap.String("pending_id", pendingID), + zap.String("question_id", ans.QuestionID), + zap.Error(err)) + } } } +func stringFromMetadata(meta map[string]any, key string) string { + if meta == nil { + return "" + } + if v, ok := meta[key].(string); ok { + return v + } + return "" +} + // respondViaEventFallback publishes a ClarificationAnswered event for the orchestrator // to resume the agent with a new turn. Used when the agent timed out. -func (h *Handlers) respondViaEventFallback(c *gin.Context, pendingID string, answer *Answer, rejected bool, rejectReason string) { +func (h *Handlers) respondViaEventFallback(c *gin.Context, pendingID string, answers []Answer, rejected bool, rejectReason string) { if h.eventBus == nil { return } @@ -291,13 +480,13 @@ func (h *Handlers) respondViaEventFallback(c *gin.Context, pendingID string, ans return } - answerText := buildAnswerText(answer, rejected, rejectReason) + answerText := buildAnswerSummary(clarificationCtx.Questions, answers, rejected, rejectReason) eventData := map[string]any{ "session_id": clarificationCtx.SessionID, "task_id": clarificationCtx.TaskID, "pending_id": pendingID, - "question": clarificationCtx.Question, + metaQuestionKey: clarificationCtx.QuestionSummary, "answer_text": answerText, "rejected": rejected, "reject_reason": rejectReason, @@ -334,7 +523,7 @@ func (h *Handlers) lookupSessionForPending(c *gin.Context, pendingID string) str return msg.TaskSessionID } -func (h *Handlers) publishPrimaryAnsweredEvent(c *gin.Context, pendingID string, answer *Answer, rejected bool, rejectReason string) { +func (h *Handlers) publishPrimaryAnsweredEvent(c *gin.Context, pendingID string, answers []Answer, rejected bool, rejectReason string) { if h.eventBus == nil { return } @@ -353,12 +542,12 @@ func (h *Handlers) publishPrimaryAnsweredEvent(c *gin.Context, pendingID string, return } - answerText := buildAnswerText(answer, rejected, rejectReason) + answerText := buildAnswerSummary(clarificationCtx.Questions, answers, rejected, rejectReason) eventData := map[string]any{ "session_id": clarificationCtx.SessionID, "task_id": clarificationCtx.TaskID, "pending_id": pendingID, - "question": clarificationCtx.Question, + metaQuestionKey: clarificationCtx.QuestionSummary, "answer_text": answerText, "rejected": rejected, "reject_reason": rejectReason, @@ -376,9 +565,10 @@ func (h *Handlers) publishPrimaryAnsweredEvent(c *gin.Context, pendingID string, } type clarificationEventContext struct { - SessionID string - TaskID string - Question string + SessionID string + TaskID string + Questions []Question // Source-of-truth questions used to label answers; falls back to a single synthetic Question when only metadata is available. + QuestionSummary string // Pre-formatted multi-line "Q1: ...\nQ2: ..." used by the orchestrator resume prompt. } func (h *Handlers) resolveClarificationEventContext(ctx context.Context, pendingID string) (clarificationEventContext, error) { @@ -387,57 +577,166 @@ func (h *Handlers) resolveClarificationEventContext(ctx context.Context, pending if req, ok := h.store.GetRequest(pendingID); ok && req != nil { out.SessionID = req.SessionID out.TaskID = req.TaskID - out.Question = req.Question.Prompt + out.Questions = req.Questions + out.QuestionSummary = formatQuestionSummary(req.Questions) + if out.SessionID != "" && out.TaskID != "" && len(out.Questions) > 0 { + return out, nil + } } - if out.SessionID != "" && out.TaskID != "" && out.Question != "" { - return out, nil - } if h.repo == nil { return out, fmt.Errorf("message repository unavailable") } - msg, err := h.repo.FindMessageByPendingID(ctx, pendingID) + msgs, err := h.repo.FindMessagesByPendingID(ctx, pendingID) if err != nil { return out, err } + if len(msgs) == 0 { + return out, fmt.Errorf("no messages for pending_id %s", pendingID) + } + if out.SessionID == "" { - out.SessionID = msg.TaskSessionID + out.SessionID = msgs[0].TaskSessionID } if out.TaskID == "" { - out.TaskID = msg.TaskID + out.TaskID = msgs[0].TaskID } - if out.Question == "" && msg.Metadata != nil { - if qData, ok := msg.Metadata["question"].(map[string]interface{}); ok { - if q, ok := qData["prompt"].(string); ok { - out.Question = q - } - } + if len(out.Questions) == 0 { + out.Questions = questionsFromMessages(msgs) + out.QuestionSummary = formatQuestionSummary(out.Questions) } return out, nil } -// buildAnswerText constructs a human-readable answer text from the response. -func buildAnswerText(answer *Answer, rejected bool, rejectReason string) string { +// questionsFromMessages reconstructs a Question slice from persisted clarification +// messages, ordered by metadata.question_index so the rebuilt summary matches +// the bundle the agent originally sent. Used as a fallback when the in-store +// request has been cleaned up but the persisted metadata still carries the +// original question text. +func questionsFromMessages(msgs []*taskmodels.Message) []Question { + sorted := make([]*taskmodels.Message, len(msgs)) + copy(sorted, msgs) + sort.SliceStable(sorted, func(i, j int) bool { + return questionIndexFromMetadata(sorted[i].Metadata) < questionIndexFromMetadata(sorted[j].Metadata) + }) + out := make([]Question, 0, len(sorted)) + for _, m := range sorted { + out = append(out, questionFromMessageMetadata(m.Metadata)) + } + return out +} + +func questionIndexFromMetadata(meta map[string]any) int { + if meta == nil { + return 0 + } + switch v := meta["question_index"].(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + } + return 0 +} + +func questionFromMessageMetadata(meta map[string]any) Question { + q := Question{ID: stringFromMetadata(meta, metaQuestionIDKey)} + qData, ok := meta[metaQuestionKey].(map[string]any) + if !ok { + return q + } + if v, ok := qData["prompt"].(string); ok { + q.Prompt = v + } + if v, ok := qData["title"].(string); ok { + q.Title = v + } + if q.ID == "" { + if v, ok := qData["id"].(string); ok { + q.ID = v + } + } + return q +} + +func formatQuestionSummary(questions []Question) string { + if len(questions) == 0 { + return "" + } + if len(questions) == 1 { + return questions[0].Prompt + } + parts := make([]string, 0, len(questions)) + for i, q := range questions { + parts = append(parts, fmt.Sprintf("Q%d: %s", i+1, q.Prompt)) + } + return strings.Join(parts, "\n") +} + +// buildAnswerSummary constructs a human-readable summary of the user's response +// across every question in the bundle. Used in the orchestrator resume prompt +// and for chat history rendering. +func buildAnswerSummary(questions []Question, answers []Answer, rejected bool, rejectReason string) string { if rejected { if rejectReason != "" { return fmt.Sprintf("User declined to answer. Reason: %s", rejectReason) } return "User declined to answer." } - if answer == nil { + if len(answers) == 0 { return "User provided no specific answer." } - if answer.CustomText != "" { - return fmt.Sprintf("User answered: %s", answer.CustomText) + if len(questions) <= 1 && len(answers) == 1 { + return formatSingleAnswer(answers[0]) } - if len(answer.SelectedOptions) > 0 { - return fmt.Sprintf("User selected: %v", answer.SelectedOptions) + + answersByID := make(map[string]Answer, len(answers)) + for _, a := range answers { + answersByID[a.QuestionID] = a + } + + parts := make([]string, 0, len(answers)) + for i, q := range questions { + ans, ok := answersByID[q.ID] + if !ok { + continue + } + parts = append(parts, fmt.Sprintf("A%d: %s", i+1, formatAnswerBody(ans))) + } + if len(parts) == 0 { + // No matches by id — fall back to positional formatting so we still + // surface the answers rather than silently dropping them. + for i, a := range answers { + parts = append(parts, fmt.Sprintf("A%d: %s", i+1, formatAnswerBody(a))) + } + } + return strings.Join(parts, "\n") +} + +func formatSingleAnswer(a Answer) string { + if a.CustomText != "" { + return fmt.Sprintf("User answered: %s", a.CustomText) + } + if len(a.SelectedOptions) > 0 { + return fmt.Sprintf("User selected: %v", a.SelectedOptions) } return "User provided no specific answer." } +func formatAnswerBody(a Answer) string { + if a.CustomText != "" { + return a.CustomText + } + if len(a.SelectedOptions) > 0 { + return fmt.Sprintf("%v", a.SelectedOptions) + } + return "(no answer)" +} + func generateOptionID(questionIndex, optionIndex int) string { return fmt.Sprintf("q%d_opt%d", questionIndex+1, optionIndex+1) } diff --git a/apps/backend/internal/clarification/handlers_test.go b/apps/backend/internal/clarification/handlers_test.go index 37301f2cbb..7dee9fb5d9 100644 --- a/apps/backend/internal/clarification/handlers_test.go +++ b/apps/backend/internal/clarification/handlers_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -17,28 +18,36 @@ import ( type stubMessageCreator struct { updates []struct { - pendingID string - status string + pendingID string + questionID string + status string } + created [][]Question } -func (s *stubMessageCreator) CreateClarificationRequestMessage( - _ context.Context, _, _, _ string, _ Question, _ string, -) (string, error) { - return "msg-id", nil +func (s *stubMessageCreator) CreateClarificationRequestMessages( + _ context.Context, _, _, _ string, questions []Question, _ string, +) ([]string, error) { + s.created = append(s.created, questions) + ids := make([]string, len(questions)) + for i := range questions { + ids[i] = "msg-id" + } + return ids, nil } func (s *stubMessageCreator) UpdateClarificationMessage( - _ context.Context, _, pendingID, status string, _ *Answer, + _ context.Context, _, pendingID, questionID, status string, _ *Answer, ) error { s.updates = append(s.updates, struct { - pendingID string - status string - }{pendingID, status}) + pendingID string + questionID string + status string + }{pendingID, questionID, status}) return nil } -func setupTestHandler(t *testing.T, msgs map[string]*taskmodels.Message) (*Handlers, *stubMessageStore, *stubEventBus, *stubMessageCreator) { +func setupTestHandler(t *testing.T, msgs map[string][]*taskmodels.Message) (*Handlers, *stubMessageStore, *stubEventBus, *stubMessageCreator) { t.Helper() gin.SetMode(gin.TestMode) store := NewStore(time.Minute) @@ -56,17 +65,19 @@ func setupTestHandler(t *testing.T, msgs map[string]*taskmodels.Message) (*Handl // to answer" is surprising and wastes a turn. func TestHttpRespond_RejectedAfterTimeout_NoNewTurn(t *testing.T) { // Message exists in DB (agent already moved on; canceller ran). - msgs := map[string]*taskmodels.Message{ - "pending-123": { + msgs := map[string][]*taskmodels.Message{ + "pending-123": {{ ID: "m1", TaskID: "t1", TaskSessionID: "s1", Metadata: map[string]any{ "status": "expired", "agent_disconnected": true, - "question": map[string]interface{}{"prompt": "orig question"}, + "pending_id": "pending-123", + "question_id": "q1", + "question": map[string]interface{}{"id": "q1", "prompt": "orig question"}, }, - }, + }}, } h, _, eventBus, messageCreator := setupTestHandler(t, msgs) @@ -99,17 +110,19 @@ func TestHttpRespond_RejectedAfterTimeout_NoNewTurn(t *testing.T) { // the fallback path and publishes the event so the orchestrator resumes // the agent — the user chose to continue, so a new turn is expected. func TestHttpRespond_AnsweredAfterTimeout_PublishesEvent(t *testing.T) { - msgs := map[string]*taskmodels.Message{ - "pending-456": { + msgs := map[string][]*taskmodels.Message{ + "pending-456": {{ ID: "m2", TaskID: "t1", TaskSessionID: "s1", Metadata: map[string]any{ "status": "expired", "agent_disconnected": true, - "question": map[string]interface{}{"prompt": "orig question"}, + "pending_id": "pending-456", + "question_id": "q1", + "question": map[string]interface{}{"id": "q1", "prompt": "orig question"}, }, - }, + }}, } h, _, eventBus, _ := setupTestHandler(t, msgs) @@ -136,6 +149,199 @@ func TestHttpRespond_AnsweredAfterTimeout_PublishesEvent(t *testing.T) { } } +// TestHttpRespond_DuplicateQuestionID_Rejected400 covers the dedupe gate: +// a payload that names the same question id twice should be rejected even +// when the cardinality matches the bundle size, otherwise the agent could +// receive a phantom answer for the question that was actually skipped. +func TestHttpRespond_DuplicateQuestionID_Rejected400(t *testing.T) { + h, _, _, _ := setupTestHandler(t, map[string][]*taskmodels.Message{}) + pendingID := h.store.CreateRequest(&Request{ + SessionID: "s1", + TaskID: "t1", + Questions: []Question{ + {ID: "q1", Prompt: "First?"}, + {ID: "q2", Prompt: "Second?"}, + }, + }) + body := RespondBody{ + Answers: []Answer{ + {QuestionID: "q1", SelectedOptions: []string{"opt1"}}, + {QuestionID: "q1", SelectedOptions: []string{"opt2"}}, + }, + } + rec := runRespond(t, h, pendingID, body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for duplicate question_id, got %d (body=%s)", rec.Code, rec.Body.String()) + } +} + +// TestHttpRespond_UnknownQuestionID_Rejected400 ensures that fabricated ids +// are rejected even with the right cardinality. +func TestHttpRespond_UnknownQuestionID_Rejected400(t *testing.T) { + h, _, _, _ := setupTestHandler(t, map[string][]*taskmodels.Message{}) + pendingID := h.store.CreateRequest(&Request{ + SessionID: "s1", + TaskID: "t1", + Questions: []Question{ + {ID: "q1", Prompt: "First?"}, + }, + }) + body := RespondBody{ + Answers: []Answer{{QuestionID: "qZZZ", SelectedOptions: []string{"opt1"}}}, + } + rec := runRespond(t, h, pendingID, body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for unknown question_id, got %d (body=%s)", rec.Code, rec.Body.String()) + } +} + +// TestHttpRespond_PartialAnswers_Rejected400 confirms that the handler +// refuses a respond payload that does not contain one answer per question +// in the original bundle. All-required gating is enforced here. +func TestHttpRespond_PartialAnswers_Rejected400(t *testing.T) { + h, _, _, _ := setupTestHandler(t, map[string][]*taskmodels.Message{}) + + pendingID := h.store.CreateRequest(&Request{ + SessionID: "s1", + TaskID: "t1", + Questions: []Question{ + {ID: "q1", Prompt: "First?"}, + {ID: "q2", Prompt: "Second?"}, + {ID: "q3", Prompt: "Third?"}, + }, + }) + + // Only one answer for a 3-question bundle. + body := RespondBody{ + Answers: []Answer{{ + QuestionID: "q1", + SelectedOptions: []string{"opt1"}, + }}, + } + rec := runRespond(t, h, pendingID, body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d (body=%s)", rec.Code, rec.Body.String()) + } +} + +// TestHttpRespond_AllAnswers_PrimaryPath_Success verifies that when every +// question is answered the primary path delivers the full response and +// updates each message exactly once. +func TestHttpRespond_AllAnswers_PrimaryPath_Success(t *testing.T) { + h, _, _, msgCreator := setupTestHandler(t, map[string][]*taskmodels.Message{}) + + pendingID := h.store.CreateRequest(&Request{ + SessionID: "s1", + TaskID: "t1", + Questions: []Question{ + {ID: "q1", Prompt: "First?"}, + {ID: "q2", Prompt: "Second?"}, + }, + }) + + // Drain the response channel so Respond does not block indefinitely. + go func() { + _, _ = h.store.WaitForResponse(context.Background(), pendingID) + }() + + body := RespondBody{ + Answers: []Answer{ + {QuestionID: "q1", SelectedOptions: []string{"opt1"}}, + {QuestionID: "q2", CustomText: "free-form"}, + }, + } + rec := runRespond(t, h, pendingID, body) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body=%s)", rec.Code, rec.Body.String()) + } + + if len(msgCreator.updates) != 2 { + t.Fatalf("expected 2 message updates (one per question), got %d: %+v", + len(msgCreator.updates), msgCreator.updates) + } + for _, u := range msgCreator.updates { + if u.status != "answered" { + t.Errorf("expected status=answered, got %q", u.status) + } + } +} + +// TestValidateAndNormalizeQuestions_AssignsDefaults checks that question and +// option IDs are auto-generated when omitted, in deterministic q1/q1_opt1 form. +func TestValidateAndNormalizeQuestions_AssignsDefaults(t *testing.T) { + qs := []Question{ + {Prompt: "First?", Options: []Option{{Label: "A", Description: "a"}, {Label: "B", Description: "b"}}}, + {Prompt: "Second?", Options: []Option{{Label: "X", Description: "x"}, {Label: "Y", Description: "y"}}}, + } + if err := NormalizeAndValidateQuestions(qs); err != "" { + t.Fatalf("unexpected validation error: %s", err) + } + if qs[0].ID != "q1" || qs[1].ID != "q2" { + t.Errorf("expected q1/q2, got %q/%q", qs[0].ID, qs[1].ID) + } + if qs[0].Options[0].ID != "q1_opt1" || qs[1].Options[1].ID != "q2_opt2" { + t.Errorf("unexpected option IDs: %+v / %+v", qs[0].Options, qs[1].Options) + } +} + +// TestValidateAndNormalizeQuestions_RejectsInvalid covers the edge cases that +// guard against malformed payloads (no questions, too many, bad option counts). +func TestValidateAndNormalizeQuestions_RejectsInvalid(t *testing.T) { + cases := []struct { + name string + input []Question + }{ + {"no questions", nil}, + {"too many", []Question{{}, {}, {}, {}, {}}}, + {"missing prompt", []Question{{Options: []Option{{Label: "A", Description: "a"}, {Label: "B", Description: "b"}}}}}, + {"single option", []Question{{Prompt: "?", Options: []Option{{Label: "A", Description: "a"}}}}}, + {"too many options", []Question{{Prompt: "?", Options: []Option{ + {Label: "1", Description: "1"}, {Label: "2", Description: "2"}, {Label: "3", Description: "3"}, + {Label: "4", Description: "4"}, {Label: "5", Description: "5"}, {Label: "6", Description: "6"}, + {Label: "7", Description: "7"}, + }}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if msg := NormalizeAndValidateQuestions(tc.input); msg == "" { + t.Fatalf("expected validation error, got nil for %+v", tc.input) + } + }) + } +} + +// TestBuildAnswerSummary_SingleQuestion preserves the original "User selected:" +// text shape so existing prompts in the orchestrator stay readable. +func TestBuildAnswerSummary_SingleQuestion(t *testing.T) { + got := buildAnswerSummary( + []Question{{ID: "q1", Prompt: "Which?"}}, + []Answer{{QuestionID: "q1", SelectedOptions: []string{"opt1"}}}, + false, "", + ) + if got != "User selected: [opt1]" { + t.Errorf("unexpected single-q summary: %q", got) + } +} + +// TestBuildAnswerSummary_MultiQuestion produces an A1/A2 layout so the +// orchestrator resume prompt clearly maps each answer to its question. +func TestBuildAnswerSummary_MultiQuestion(t *testing.T) { + got := buildAnswerSummary( + []Question{ + {ID: "q1", Prompt: "First?"}, + {ID: "q2", Prompt: "Second?"}, + }, + []Answer{ + {QuestionID: "q1", SelectedOptions: []string{"opt1"}}, + {QuestionID: "q2", CustomText: "free"}, + }, + false, "", + ) + if got == "" || !strings.Contains(got, "A1:") || !strings.Contains(got, "A2:") { + t.Errorf("expected multi-line summary with A1/A2, got %q", got) + } +} + func runRespond(t *testing.T, h *Handlers, pendingID string, body RespondBody) *httptest.ResponseRecorder { t.Helper() payload, err := json.Marshal(body) diff --git a/apps/backend/internal/clarification/store.go b/apps/backend/internal/clarification/store.go index 07f2dbf0d4..9f309c2024 100644 --- a/apps/backend/internal/clarification/store.go +++ b/apps/backend/internal/clarification/store.go @@ -132,6 +132,23 @@ func (s *Store) Respond(pendingID string, resp *Response) error { } } +// CancelRequest cancels a single pending clarification by id, unblocking any +// WaitForResponse caller that is currently parked on it. Returns true if the +// entry existed (and was removed), false otherwise. Used by callers that need +// to surface a creation-side failure immediately rather than wait for the +// 2-hour MCP timeout. +func (s *Store) CancelRequest(pendingID string) bool { + s.mu.Lock() + defer s.mu.Unlock() + pending, ok := s.pending[pendingID] + if !ok { + return false + } + close(pending.CancelCh) + delete(s.pending, pendingID) + return true +} + // CancelSession cancels all pending clarification requests for a given session. // It closes the CancelCh to unblock any WaitForResponse callers and removes entries. // Returns the list of cancelled pending IDs. diff --git a/apps/backend/internal/clarification/store_test.go b/apps/backend/internal/clarification/store_test.go index b421da5aa1..0e7ed1f8d1 100644 --- a/apps/backend/internal/clarification/store_test.go +++ b/apps/backend/internal/clarification/store_test.go @@ -22,7 +22,7 @@ func TestNewStore_CustomTimeout(t *testing.T) { func TestCreateRequest_GeneratesID(t *testing.T) { s := NewStore(time.Minute) - req := &Request{SessionID: "s1", Question: Question{Prompt: "test?"}} + req := &Request{SessionID: "s1", Questions: []Question{{Prompt: "test?"}}} id := s.CreateRequest(req) @@ -47,7 +47,7 @@ func TestCreateRequest_PreservesExistingID(t *testing.T) { func TestGetRequest_Found(t *testing.T) { s := NewStore(time.Minute) - id := s.CreateRequest(&Request{SessionID: "s1", Question: Question{Prompt: "test?"}}) + id := s.CreateRequest(&Request{SessionID: "s1", Questions: []Question{{Prompt: "test?"}}}) req, ok := s.GetRequest(id) @@ -76,14 +76,14 @@ func TestWaitForResponse_Success(t *testing.T) { // Respond in a goroutine go func() { time.Sleep(10 * time.Millisecond) - _ = s.Respond(id, &Response{Answer: &Answer{CustomText: "hello"}}) + _ = s.Respond(id, &Response{Answers: []Answer{{CustomText: "hello"}}}) }() resp, err := s.WaitForResponse(context.Background(), id) if err != nil { t.Fatalf("unexpected error: %v", err) } - if resp.Answer == nil || resp.Answer.CustomText != "hello" { + if len(resp.Answers) != 1 || resp.Answers[0].CustomText != "hello" { t.Errorf("unexpected response: %+v", resp) } @@ -155,7 +155,7 @@ func TestRespond_Success(t *testing.T) { s := NewStore(time.Minute) id := s.CreateRequest(&Request{SessionID: "s1"}) - err := s.Respond(id, &Response{Answer: &Answer{CustomText: "yes"}}) + err := s.Respond(id, &Response{Answers: []Answer{{CustomText: "yes"}}}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -209,6 +209,44 @@ func TestCancelSession_CancelsMatchingRequests(t *testing.T) { } } +// TestCancelRequest unblocks WaitForResponse for a single pending entry. +// Used by the create-message-failure recovery path so the agent doesn't +// have to wait for the full MCP timeout when the bundle could not be +// persisted. +// +// Synchronisation: the goroutine signals it has started before invoking +// WaitForResponse so we don't rely on a time.Sleep. CancelRequest may run +// either before or after the goroutine reads from the pending map, and both +// paths return an error from WaitForResponse — that's the contract under test. +func TestCancelRequest(t *testing.T) { + s := NewStore(time.Minute) + id := s.CreateRequest(&Request{SessionID: "s1"}) + + started := make(chan struct{}) + done := make(chan error, 1) + go func() { + close(started) + _, err := s.WaitForResponse(context.Background(), id) + done <- err + }() + <-started + + if !s.CancelRequest(id) { + t.Fatalf("CancelRequest returned false for known id") + } + if s.CancelRequest(id) { + t.Errorf("CancelRequest should return false the second time") + } + select { + case err := <-done: + if err == nil { + t.Errorf("expected error from cancelled WaitForResponse") + } + case <-time.After(time.Second): + t.Fatal("WaitForResponse did not return after CancelRequest") + } +} + func TestCancelSession_NoMatch(t *testing.T) { s := NewStore(time.Minute) s.CreateRequest(&Request{SessionID: "s1"}) diff --git a/apps/backend/internal/clarification/types.go b/apps/backend/internal/clarification/types.go index c65049046d..436d2e2eb3 100644 --- a/apps/backend/internal/clarification/types.go +++ b/apps/backend/internal/clarification/types.go @@ -18,30 +18,34 @@ type Question struct { ID string `json:"id"` Title string `json:"title"` // Short label (max 12 chars) Prompt string `json:"prompt"` // Full question text - Options []Option `json:"options"` // 2-4 options + Options []Option `json:"options"` // 2-6 options } -// Request represents a clarification request from an agent. +// Request represents a clarification request from an agent. A request bundles +// one or more questions; the agent stays blocked until every question has been +// answered (or the bundle is rejected as a whole). type Request struct { - PendingID string `json:"pending_id"` - SessionID string `json:"session_id"` - TaskID string `json:"task_id"` - Question Question `json:"question"` // Single question - Context string `json:"context,omitempty"` // Optional context for the question - CreatedAt time.Time `json:"created_at"` + PendingID string `json:"pending_id"` + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + Questions []Question `json:"questions"` // 1-N questions, all required + Context string `json:"context,omitempty"` // Optional shared context for all questions + CreatedAt time.Time `json:"created_at"` } // Answer represents the user's answer to a single question. type Answer struct { QuestionID string `json:"question_id"` - SelectedOptions []string `json:"selected_options,omitempty"` // Option IDs + SelectedOptions []string `json:"selected_options,omitempty"` // Option IDs (single-choice ⇒ at most one) CustomText string `json:"custom_text,omitempty"` // Free-text input } // Response represents the user's response to a clarification request. +// On success, Answers has exactly one entry per question in the request. +// On rejection, Answers may be nil and Rejected/RejectReason describe the skip. type Response struct { PendingID string `json:"pending_id"` - Answer *Answer `json:"answer,omitempty"` + Answers []Answer `json:"answers,omitempty"` Rejected bool `json:"rejected,omitempty"` RejectReason string `json:"reject_reason,omitempty"` // If rejected RespondedAt time.Time `json:"responded_at"` diff --git a/apps/backend/internal/common/gitref/gitref.go b/apps/backend/internal/common/gitref/gitref.go new file mode 100644 index 0000000000..dfa0fae0f9 --- /dev/null +++ b/apps/backend/internal/common/gitref/gitref.go @@ -0,0 +1,207 @@ +// Package gitref reads git ref state directly from the on-disk .git +// directory, without invoking the git binary. The functions here are kept +// minimal and stdlib-only so they can be imported from both the task service +// and the sqlite migration layer (where adding a service dependency would +// invert the existing layering). +package gitref + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const headFile = "HEAD" + +// DefaultBranch returns the repository's *integration* branch — the branch +// that work is meant to merge back into. It is intentionally NOT the current +// HEAD: a developer who runs the dialog while checked out on a feature +// branch must still get "main" (or "master") back, otherwise downstream +// consumers (changes-panel merge-base, executor BaseBranch fallback) anchor +// to the wrong ref. +// +// Resolution order: +// 1. refs/remotes/origin/HEAD when set (the upstream's declared default) +// 2. The first ref that exists from +// {origin/main, origin/master, main, master} +// 3. The current HEAD as a last resort, so brand-new repos with only a +// feature branch still produce a value — callers that care about +// correctness can override. +func DefaultBranch(repoPath string) (string, error) { + safe, err := guardRepoPath(repoPath) + if err != nil { + return "", err + } + gitDir, err := ResolveGitDir(safe) + if err != nil { + return "", err + } + commonDir := ResolveCommonGitDir(gitDir) + if branch := readOriginHEAD(commonDir); branch != "" { + return branch, nil + } + if branch := pickFirstExistingBranch(commonDir); branch != "" { + return branch, nil + } + return readHEADBranchFallback(gitDir) +} + +// guardRepoPath rejects relative paths and any path containing `..` segments +// before passing the value into the file-reading helpers below. Callers +// already validate against an allowlist (see service.resolveAllowedLocalPath), +// but CodeQL's go/path-injection taint analysis doesn't trace that as a +// sanitizer, so we re-check here at the I/O boundary. The check is also +// genuinely useful: any caller — migration code, tests, future callers — +// must hand us an absolute, traversal-free path or we refuse to read. +func guardRepoPath(repoPath string) (string, error) { + if repoPath == "" { + return "", fmt.Errorf("repository path is required") + } + if !filepath.IsAbs(repoPath) { + return "", fmt.Errorf("repository path must be absolute") + } + // Check the RAW input for '..' segments before cleaning. filepath.Clean + // resolves traversal away from absolute paths (`/allowed/../etc` → + // `/etc`), so iterating the cleaned path would silently pass attempted + // escapes. Splitting on both separators handles cross-platform inputs + // without depending on the OS-native separator. + for _, part := range strings.FieldsFunc(repoPath, func(r rune) bool { + return r == '/' || r == filepath.Separator + }) { + if part == ".." { + return "", fmt.Errorf("repository path must not contain '..' segments") + } + } + return filepath.Clean(repoPath), nil +} + +// ResolveGitDir returns the actual git directory for repoPath, following +// `.git` files (worktree pointers) when present. +func ResolveGitDir(repoPath string) (string, error) { + gitPath := filepath.Join(repoPath, ".git") + info, err := os.Stat(gitPath) + if err != nil { + return "", err + } + if info.IsDir() { + return gitPath, nil + } + content, err := os.ReadFile(gitPath) + if err != nil { + return "", err + } + line := strings.TrimSpace(string(content)) + if !strings.HasPrefix(line, "gitdir:") { + return "", fmt.Errorf("invalid gitdir reference") + } + gitDir := strings.TrimSpace(strings.TrimPrefix(line, "gitdir:")) + if filepath.IsAbs(gitDir) { + return gitDir, nil + } + return filepath.Clean(filepath.Join(repoPath, gitDir)), nil +} + +// ResolveCommonGitDir returns the shared git dir for a worktree, or gitDir +// itself for a regular repo. Refs (refs/heads/*, refs/remotes/*, packed-refs) +// live under the common dir, not the worktree's gitDir. +func ResolveCommonGitDir(gitDir string) string { + commonFile := filepath.Join(gitDir, "commondir") + content, err := os.ReadFile(commonFile) + if err != nil { + return gitDir + } + commonDir := strings.TrimSpace(string(content)) + if commonDir == "" { + return gitDir + } + if filepath.IsAbs(commonDir) { + return filepath.Clean(commonDir) + } + return filepath.Clean(filepath.Join(gitDir, commonDir)) +} + +func readOriginHEAD(commonDir string) string { + headPath := filepath.Join(commonDir, "refs", "remotes", "origin", headFile) + content, err := os.ReadFile(headPath) + if err != nil { + // origin/HEAD only lives in packed-refs after a `git pack-refs --all`, + // and the on-disk symref format there is gnarly to parse correctly. + // We deliberately skip that case rather than maintain a broken parser: + // pickFirstExistingBranch's origin/main → origin/master → main → + // master fallbacks cover every realistic clone in practice. + return "" + } + return parseSymbolicRefToBranch(strings.TrimSpace(string(content))) +} + +func parseSymbolicRefToBranch(line string) string { + ref, ok := strings.CutPrefix(line, "ref: ") + if !ok { + ref = line + } + switch { + case strings.HasPrefix(ref, "refs/remotes/origin/"): + return strings.TrimPrefix(ref, "refs/remotes/origin/") + case strings.HasPrefix(ref, "refs/heads/"): + return strings.TrimPrefix(ref, "refs/heads/") + } + return "" +} + +func pickFirstExistingBranch(commonDir string) string { + for _, candidate := range []struct{ ref, branch string }{ + {"refs/remotes/origin/main", "main"}, + {"refs/remotes/origin/master", "master"}, + {"refs/heads/main", "main"}, + {"refs/heads/master", "master"}, + } { + if refExists(commonDir, candidate.ref) { + return candidate.branch + } + } + return "" +} + +func refExists(commonDir, ref string) bool { + if _, err := os.Stat(filepath.Join(commonDir, ref)); err == nil { + return true + } + content, err := os.ReadFile(filepath.Join(commonDir, "packed-refs")) + if err != nil { + return false + } + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "^") { + continue + } + _, after, ok := strings.Cut(line, " ") + if !ok { + continue + } + if after == ref { + return true + } + } + return false +} + +func readHEADBranchFallback(gitDir string) (string, error) { + headPath := filepath.Join(gitDir, headFile) + content, err := os.ReadFile(headPath) + if err != nil { + return "", err + } + trimmed := strings.TrimSpace(string(content)) + if ref, ok := strings.CutPrefix(trimmed, "ref: "); ok { + // Strip refs/heads/ as a prefix rather than splitting on "/" — branch + // names legally contain slashes (e.g. "feature/my-feature"), so taking + // the last path component would silently corrupt every nested branch. + return strings.TrimPrefix(ref, "refs/heads/"), nil + } + if trimmed != "" { + return headFile, nil + } + return "", fmt.Errorf("unable to determine branch") +} diff --git a/apps/backend/internal/common/gitref/gitref_test.go b/apps/backend/internal/common/gitref/gitref_test.go new file mode 100644 index 0000000000..00e43e17c9 --- /dev/null +++ b/apps/backend/internal/common/gitref/gitref_test.go @@ -0,0 +1,67 @@ +package gitref + +import ( + "os" + "path/filepath" + "testing" +) + +// TestDefaultBranch_RejectsRelativePath — guardRepoPath refuses relative +// paths so callers can't sneak `repos/..` past the I/O boundary even if +// the upstream allowlist check is bypassed. +func TestDefaultBranch_RejectsRelativePath(t *testing.T) { + if _, err := DefaultBranch("repos/foo"); err == nil { + t.Fatal("expected error for relative path") + } +} + +// TestDefaultBranch_RejectsTraversal — explicit guard against ".." +// segments in the RAW input. We must check before filepath.Clean: Clean +// resolves '..' away from absolute paths (`/allowed/../etc` → `/etc`) and +// would silently let a traversal attempt through. +func TestDefaultBranch_RejectsTraversal(t *testing.T) { + cases := []string{ + "/tmp/../etc", + "/foo/bar/../../etc", + "/a/b/..", + } + for _, p := range cases { + if _, err := DefaultBranch(p); err == nil { + t.Errorf("expected error for path %q (contains '..')", p) + } + } +} + +// TestDefaultBranch_RejectsEmpty — guardRepoPath errors on empty input +// rather than letting filepath.Join silently turn it into ".git". +func TestDefaultBranch_RejectsEmpty(t *testing.T) { + if _, err := DefaultBranch(""); err == nil { + t.Fatal("expected error for empty path") + } +} + +// TestDefaultBranch_AcceptsAbsolutePathToValidRepo — sanity check that +// the guard doesn't reject legitimate absolute paths. We seed a minimal +// .git directory with a HEAD on main and assert the probe finds it. +func TestDefaultBranch_AcceptsAbsolutePathToValidRepo(t *testing.T) { + repoPath := t.TempDir() + if !filepath.IsAbs(repoPath) { + t.Fatalf("t.TempDir() returned non-absolute path: %q", repoPath) + } + gitDir := filepath.Join(repoPath, ".git") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatalf("mkdir git: %v", err) + } + if err := os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil { + t.Fatalf("write HEAD: %v", err) + } + branch, err := DefaultBranch(repoPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Git branch names are case-sensitive — assert exact equality, not + // EqualFold. A function that returns "Main" or "MAIN" should fail. + if branch != "main" { + t.Fatalf("expected main, got %q", branch) + } +} diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 8b8ff9767d..2e569961d2 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -198,12 +198,13 @@ const ( // Event types for GitHub integration const ( - GitHubPRFeedback = "github.pr_feedback" // PR has new feedback (UI notification only) - GitHubPRStateChanged = "github.pr_state_changed" // PR state changed (merged, closed, etc.) - GitHubNewReviewPR = "github.new_pr_to_review" // New PR found needing review - GitHubNewIssue = "github.new_issue" // New issue found matching issue watch - GitHubTaskPRUpdated = "github.task_pr.updated" // TaskPR record updated (for UI refresh) - GitHubWatchEvent = "github.watch.event" // Watch created/deleted + GitHubPRFeedback = "github.pr_feedback" // PR has new feedback (UI notification only) + GitHubPRStateChanged = "github.pr_state_changed" // PR state changed (merged, closed, etc.) + GitHubNewReviewPR = "github.new_pr_to_review" // New PR found needing review + GitHubNewIssue = "github.new_issue" // New issue found matching issue watch + GitHubTaskPRUpdated = "github.task_pr.updated" // TaskPR record updated (for UI refresh) + GitHubWatchEvent = "github.watch.event" // Watch created/deleted + GitHubRateLimitUpdated = "github.rate_limit.updated" // GitHub API rate-limit snapshot changed ) // Event types for Jira integration @@ -211,6 +212,11 @@ const ( JiraNewIssue = "jira.new_issue" // New issue found matching a Jira issue watch ) +// Event types for Linear integration +const ( + LinearNewIssue = "linear.new_issue" // New issue found matching a Linear issue watch +) + // BuildShellOutputSubject creates a shell output subject for a specific session func BuildShellOutputSubject(sessionID string) string { return ShellOutput + "." + sessionID diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index 5956fb0974..89ebfe3234 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -73,6 +73,7 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.TurnCompleted, ws.ActionSessionTurnCompleted) b.subscribe(eventBus, events.MessageQueueStatusChanged, ws.ActionMessageQueueStatusChanged) b.subscribe(eventBus, events.GitHubTaskPRUpdated, ws.ActionGitHubTaskPRUpdated) + b.subscribe(eventBus, events.GitHubRateLimitUpdated, ws.ActionGitHubRateLimitUpdated) go func() { <-ctx.Done() diff --git a/apps/backend/internal/gateway/websocket/task_notifications_test.go b/apps/backend/internal/gateway/websocket/task_notifications_test.go index 3efae8079c..4bd046838e 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications_test.go +++ b/apps/backend/internal/gateway/websocket/task_notifications_test.go @@ -39,7 +39,7 @@ func TestTaskEventBroadcaster_NoDuplicateSubscriptions(t *testing.T) { // // Update this number when adding or removing event subscriptions in // RegisterTaskNotifications — it is intentionally exact. - const wantSubscriptions = 48 + const wantSubscriptions = 49 if got := len(b.subscriptions); got != wantSubscriptions { t.Errorf("RegisterTaskNotifications created %d subscriptions, want %d — "+ "did an event get subscribed twice?", got, wantSubscriptions) diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index 03a95468c0..73c9ba7ebb 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -13,13 +13,79 @@ import ( ) // GHClient implements Client using the gh CLI. -type GHClient struct{} +type GHClient struct { + rateTracker *RateTracker +} // NewGHClient creates a new gh CLI-based client. func NewGHClient() *GHClient { return &GHClient{} } +// WithRateTracker attaches a rate tracker so stderr output is inspected for +// rate-limit signals after every gh invocation. Returns the client for +// chaining; safe to call before any commands run. +func (c *GHClient) WithRateTracker(t *RateTracker) *GHClient { + c.rateTracker = t + return c +} + +// reGHRateLimit matches the prose gh prints when a request hit a primary or +// secondary rate limit. The exact text varies by gh version and locale, but +// "rate limit" / "API rate limit" appear consistently. +var ghRateLimitMarkers = []string{"rate limit", "abuse detection"} + +func ghStderrIndicatesRateLimit(stderr string) bool { + if stderr == "" { + return false + } + lower := strings.ToLower(stderr) + for _, marker := range ghRateLimitMarkers { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +// inspectRateStderr inspects the stderr from a failed `gh` invocation and +// flags the appropriate resource bucket as exhausted. `gh api ` is REST +// (Core) by default, with `api graphql` and `api search/*` as the documented +// exceptions; non-`api` subcommands like `pr`, `issue`, and `repo` are +// implemented against GraphQL by gh itself, so they map to the GraphQL bucket. +func (c *GHClient) inspectRateStderr(args []string, stderr string) { + if c.rateTracker == nil { + return + } + if !ghStderrIndicatesRateLimit(stderr) { + return + } + c.rateTracker.markRateExhausted(resourceForGHArgs(args), time.Time{}) +} + +// resourceForGHArgs maps a `gh` argv to the rate-limit bucket the call hits. +// Exposed at package scope so the table-driven test can pin every branch. +func resourceForGHArgs(args []string) Resource { + if len(args) == 0 { + return ResourceCore + } + if args[0] != "api" { + // `gh pr`, `gh issue`, `gh repo`, etc. are all GraphQL under the hood. + return ResourceGraphQL + } + if len(args) < 2 { + return ResourceCore + } + switch { + case args[1] == "graphql": + return ResourceGraphQL + case strings.HasPrefix(args[1], "search/"): + return ResourceSearch + default: + return ResourceCore + } +} + // GHAvailable checks if the gh CLI is installed and accessible. func GHAvailable() bool { _, err := exec.LookPath("gh") @@ -516,11 +582,62 @@ func (c *GHClient) run(ctx context.Context, args ...string) (string, error) { cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { + c.inspectRateStderr(args, stderr.String()) return stdout.String(), fmt.Errorf("gh %s: %w: %s", args[0], err, stderr.String()) } return stdout.String(), nil } +// ghRateLimitResponse mirrors the GET /rate_limit JSON shape so we can seed +// the tracker on startup and after CLI failures without parsing prose. +type ghRateLimitResponse struct { + Resources struct { + Core ghRateLimitBucket `json:"core"` + GraphQL ghRateLimitBucket `json:"graphql"` + Search ghRateLimitBucket `json:"search"` + } `json:"resources"` +} + +type ghRateLimitBucket struct { + Limit int `json:"limit"` + Remaining int `json:"remaining"` + Reset int64 `json:"reset"` +} + +// FetchRateLimit calls `gh api rate_limit` and seeds the tracker with the +// returned snapshots. Best-effort: a failure (e.g. CLI absent, network) is +// logged and ignored. +func (c *GHClient) FetchRateLimit(ctx context.Context) error { + if c.rateTracker == nil { + return nil + } + out, err := c.run(ctx, "api", "rate_limit") + if err != nil { + return fmt.Errorf("fetch rate limit: %w", err) + } + var raw ghRateLimitResponse + if err := json.Unmarshal([]byte(out), &raw); err != nil { + return fmt.Errorf("parse rate_limit response: %w", err) + } + now := time.Now().UTC() + record := func(resource Resource, b ghRateLimitBucket) { + if b.Limit == 0 && b.Remaining == 0 && b.Reset == 0 { + return + } + c.rateTracker.Record(RateSnapshot{ + Resource: resource, + Limit: b.Limit, + Remaining: b.Remaining, + ResetAt: time.Unix(b.Reset, 0).UTC(), + UpdatedAt: now, + }) + } + record(ResourceCore, raw.Resources.Core) + record(ResourceGraphQL, raw.Resources.GraphQL) + record(ResourceSearch, raw.Resources.Search) + return nil +} + func (c *GHClient) parsePRList(data string, owner, repo string) ([]*PR, error) { var raw []ghPR if err := json.Unmarshal([]byte(data), &raw); err != nil { diff --git a/apps/backend/internal/github/gh_client_test.go b/apps/backend/internal/github/gh_client_test.go index 56c3a1be76..dac69d689b 100644 --- a/apps/backend/internal/github/gh_client_test.go +++ b/apps/backend/internal/github/gh_client_test.go @@ -237,3 +237,90 @@ func TestConvertGHRequestedReviewers(t *testing.T) { t.Errorf("unexpected third reviewer: %#v", got[2]) } } + +func TestGHStderrIndicatesRateLimit(t *testing.T) { + cases := []struct { + stderr string + want bool + }{ + {"GraphQL: API rate limit already exceeded for user ID 12345.", true}, + {"You have exceeded a secondary rate limit.", true}, + {"abuse detection mechanism triggered", true}, + {"network: connection refused", false}, + {"", false}, + } + for _, c := range cases { + if got := ghStderrIndicatesRateLimit(c.stderr); got != c.want { + t.Errorf("ghStderrIndicatesRateLimit(%q) = %v, want %v", c.stderr, got, c.want) + } + } +} + +func TestGHClient_InspectRateStderr_MarksGraphQL(t *testing.T) { + tracker := NewRateTracker(nil, nil) + c := NewGHClient().WithRateTracker(tracker) + c.inspectRateStderr([]string{"pr", "view", "1"}, "GraphQL: API rate limit already exceeded") + if !tracker.IsExhausted(ResourceGraphQL) { + t.Errorf("expected graphql exhausted") + } +} + +func TestGHClient_InspectRateStderr_MarksSearchForSearchEndpoints(t *testing.T) { + tracker := NewRateTracker(nil, nil) + c := NewGHClient().WithRateTracker(tracker) + c.inspectRateStderr([]string{"api", "search/issues"}, "API rate limit exceeded") + if !tracker.IsExhausted(ResourceSearch) { + t.Errorf("expected search exhausted") + } +} + +func TestResourceForGHArgs(t *testing.T) { + cases := []struct { + name string + args []string + want Resource + }{ + // REST endpoints under `gh api ` go to Core, not GraphQL. + // This is the regression: previously every gh api failure was + // attributed to GraphQL unless the path started with search/. + {"api repos REST", []string{"api", "repos/o/r/pulls/1"}, ResourceCore}, + {"api user REST", []string{"api", "user"}, ResourceCore}, + {"api rate_limit REST", []string{"api", "rate_limit"}, ResourceCore}, + + // Documented exceptions still resolve to their dedicated buckets. + {"api graphql", []string{"api", "graphql"}, ResourceGraphQL}, + {"api search/issues", []string{"api", "search/issues"}, ResourceSearch}, + {"api search/repositories", []string{"api", "search/repositories"}, ResourceSearch}, + + // Non-`api` subcommands are GraphQL — gh implements pr/issue/repo + // against the GraphQL API. + {"pr view", []string{"pr", "view", "1"}, ResourceGraphQL}, + {"issue list", []string{"issue", "list"}, ResourceGraphQL}, + {"repo view", []string{"repo", "view"}, ResourceGraphQL}, + + // Defensive defaults for malformed argv. + {"empty", nil, ResourceCore}, + {"api only", []string{"api"}, ResourceCore}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := resourceForGHArgs(tc.args); got != tc.want { + t.Errorf("resourceForGHArgs(%v) = %s, want %s", tc.args, got, tc.want) + } + }) + } +} + +// Regression: a 429 on `gh api repos/...` (REST) must mark Core, not GraphQL. +// Previously this would pause the GraphQL PR monitor incorrectly. +func TestGHClient_InspectRateStderr_RestEndpointMarksCore(t *testing.T) { + tracker := NewRateTracker(nil, nil) + c := NewGHClient().WithRateTracker(tracker) + c.inspectRateStderr([]string{"api", "repos/o/r/pulls/1"}, "API rate limit exceeded") + if !tracker.IsExhausted(ResourceCore) { + t.Errorf("expected core bucket exhausted for REST endpoint") + } + if tracker.IsExhausted(ResourceGraphQL) { + t.Errorf("REST 429 must not pause graphql bucket") + } +} diff --git a/apps/backend/internal/github/graphql.go b/apps/backend/internal/github/graphql.go new file mode 100644 index 0000000000..5cd5fe4e19 --- /dev/null +++ b/apps/backend/internal/github/graphql.go @@ -0,0 +1,540 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" +) + +// GraphQLExecutor runs a single GraphQL query against the GitHub API. Both +// PATClient (direct HTTP) and GHClient (`gh api graphql`) implement it so the +// batched poller works regardless of auth method. +type GraphQLExecutor interface { + ExecuteGraphQL(ctx context.Context, query string, variables map[string]any, out any) error +} + +// graphQLPRBatchAlias is the per-PR alias prefix; the index makes aliases +// unique within a single repository block. +const graphQLPRBatchAlias = "pr" + +// graphQLBatchChunkSize bounds the number of aliased fields per request to +// stay well under GitHub's per-query node-count limit (500). 50 PRs × ~10 +// fields = ~500 nodes, leaving headroom. +const graphQLBatchChunkSize = 50 + +// reviewNode is one PR review entry from the batched GraphQL query. +type reviewNode struct { + State string `json:"state"` + Author struct { + Login string `json:"login"` + } `json:"author"` + SubmittedAt time.Time `json:"submittedAt"` +} + +// graphQLPRRef is one entry in a batched PR-status request. +type graphQLPRRef struct { + Owner string + Repo string + Number int +} + +// graphQLBranchRef is one entry in a batched branch-lookup request. +type graphQLBranchRef struct { + Owner string + Repo string + Branch string +} + +// chunkedRefs splits refs into chunks of at most graphQLBatchChunkSize so +// callers can keep individual GraphQL queries under the node-count limit. +func chunkedRefs[T any](refs []T) [][]T { + if len(refs) == 0 { + return nil + } + out := make([][]T, 0, (len(refs)+graphQLBatchChunkSize-1)/graphQLBatchChunkSize) + for i := 0; i < len(refs); i += graphQLBatchChunkSize { + end := i + graphQLBatchChunkSize + if end > len(refs) { + end = len(refs) + } + out = append(out, refs[i:end]) + } + return out +} + +// batchedPRResult is the decoded shape of one aliased pullRequest block. +type batchedPRResult struct { + State string `json:"state"` + Title string `json:"title"` + URL string `json:"url"` + IsDraft bool `json:"isDraft"` + Mergeable string `json:"mergeable"` + MergeStatus string `json:"mergeStateStatus"` + HeadRefName string `json:"headRefName"` + BaseRefName string `json:"baseRefName"` + HeadRefOid string `json:"headRefOid"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Author struct { + Login string `json:"login"` + } `json:"author"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + MergedAt string `json:"mergedAt"` + ClosedAt string `json:"closedAt"` + Reviews struct { + Nodes []reviewNode `json:"nodes"` + } `json:"reviews"` + ReviewRequests struct { + TotalCount int `json:"totalCount"` + } `json:"reviewRequests"` + Commits struct { + Nodes []struct { + Commit struct { + StatusCheckRollup *struct { + State string `json:"state"` + } `json:"statusCheckRollup"` + } `json:"commit"` + } `json:"nodes"` + } `json:"commits"` +} + +// graphQLError mirrors a single entry in a GraphQL response's "errors" array. +type graphQLError struct { + Message string `json:"message"` + Type string `json:"type"` + Path []string `json:"path"` +} + +// graphQLErrorsToErr returns a non-nil error when the GraphQL response carries +// a top-level "errors" array. The first message is included so logs are +// actionable; the count is appended when there are multiple. +func graphQLErrorsToErr(errs []graphQLError) error { + if len(errs) == 0 { + return nil + } + if len(errs) == 1 { + return fmt.Errorf("graphql error: %s", errs[0].Message) + } + return fmt.Errorf("graphql error: %s (and %d more)", errs[0].Message, len(errs)-1) +} + +// graphQLRateLimit mirrors GitHub's top-level rateLimit field, the most +// accurate source of GraphQL quota since limits are point-cost based. +type graphQLRateLimit struct { + Limit int `json:"limit"` + Remaining int `json:"remaining"` + ResetAt time.Time `json:"resetAt"` + Cost int `json:"cost"` +} + +// buildBatchedPRQuery groups refs by (owner, repo) and emits one aliased +// repository block per group, with one aliased pullRequest per number inside. +// The shape mirrors gh pr view fields used by the existing converter so we +// can reuse the conversion logic without reshaping callers. +func buildBatchedPRQuery(refs []graphQLPRRef) (string, map[string]any) { + // Group refs by (owner, repo) and assign deterministic indices so aliases + // stay stable across runs (helpful for tests and snapshot debugging). + type group struct { + owner string + repo string + numbers []int + } + byKey := map[string]*group{} + keys := []string{} + for _, r := range refs { + key := r.Owner + "/" + r.Repo + g, ok := byKey[key] + if !ok { + g = &group{owner: r.Owner, repo: r.Repo} + byKey[key] = g + keys = append(keys, key) + } + g.numbers = append(g.numbers, r.Number) + } + sort.Strings(keys) + + var b strings.Builder + b.WriteString("query Batch { ") + for repoIdx, key := range keys { + g := byKey[key] + fmt.Fprintf(&b, `repo%d: repository(owner: %q, name: %q) { `, repoIdx, g.owner, g.repo) + for prIdx, n := range g.numbers { + fmt.Fprintf(&b, `%s%d: pullRequest(number: %d) { %s } `, graphQLPRBatchAlias, prIdx, n, prFieldsBlock()) + } + b.WriteString(`} `) + } + b.WriteString(`rateLimit { limit remaining resetAt cost } `) + b.WriteString(`}`) + return b.String(), nil +} + +// prFieldsBlock is the GraphQL field selection used in every batched PR +// query. Kept as a constant to make snapshot assertions stable and to keep +// the batched and single-PR paths returning the same data. +func prFieldsBlock() string { + return `state title url isDraft mergeable mergeStateStatus ` + + `headRefName baseRefName headRefOid additions deletions ` + + `author { login } createdAt updatedAt mergedAt closedAt ` + + `reviews(last: 100) { nodes { state author { login } submittedAt } } ` + + `reviewRequests(first: 0) { totalCount } ` + + `commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }` +} + +// buildBatchedBranchQuery emits one aliased associatedPullRequests block per +// (owner, repo, branch) ref. Used to batch the "find PR by branch" path. +func buildBatchedBranchQuery(refs []graphQLBranchRef) (string, map[string]any) { + var b strings.Builder + b.WriteString("query Branches { ") + for i, r := range refs { + fmt.Fprintf(&b, `b%d: repository(owner: %q, name: %q) { ref(qualifiedName: %q) { associatedPullRequests(first: 1, states: OPEN) { nodes { number %s } } } } `, + i, r.Owner, r.Repo, "refs/heads/"+r.Branch, prFieldsBlock()) + } + b.WriteString(`rateLimit { limit remaining resetAt cost } `) + b.WriteString(`}`) + return b.String(), nil +} + +// convertBatchedPRResult turns a batched GraphQL row into the (PR, PRStatus) +// pair the existing poller code uses. +func convertBatchedPRResult(raw *batchedPRResult, owner, repo string, number int) *PRStatus { + state := strings.ToLower(raw.State) + if raw.MergedAt != "" { + state = prStateMerged + } + pr := &PR{ + Number: number, + Title: raw.Title, + URL: raw.URL, + HTMLURL: raw.URL, + State: state, + HeadBranch: raw.HeadRefName, + HeadSHA: raw.HeadRefOid, + BaseBranch: raw.BaseRefName, + AuthorLogin: raw.Author.Login, + RepoOwner: owner, + RepoName: repo, + Draft: raw.IsDraft, + Mergeable: raw.Mergeable == "MERGEABLE", + MergeableState: strings.ToLower(raw.MergeStatus), + Additions: raw.Additions, + Deletions: raw.Deletions, + CreatedAt: raw.CreatedAt, + UpdatedAt: raw.UpdatedAt, + MergedAt: parseTimePtr(raw.MergedAt), + ClosedAt: parseTimePtr(raw.ClosedAt), + } + + reviewState := summarizeReviewState(raw.Reviews.Nodes) + checksState := "" + if len(raw.Commits.Nodes) > 0 && raw.Commits.Nodes[0].Commit.StatusCheckRollup != nil { + checksState = strings.ToLower(raw.Commits.Nodes[0].Commit.StatusCheckRollup.State) + } + return &PRStatus{ + PR: pr, + ReviewState: reviewState, + ChecksState: checksState, + MergeableState: pr.MergeableState, + } +} + +// summarizeReviewState collapses the review history to a single +// "approved"/"changes_requested"/"" value matching the existing +// service-side summary. Per-reviewer dedup: each reviewer's most-recent +// review wins, so a CHANGES_REQUESTED followed by an APPROVED from the +// same author resolves to APPROVED. CHANGES_REQUESTED beats APPROVED across +// distinct reviewers. +func summarizeReviewState(nodes []reviewNode) string { + type latest struct { + state string + at time.Time + } + byAuthor := map[string]latest{} + for _, n := range nodes { + // Anonymous reviewers (deleted users) get a unique bucket so each + // review still counts independently. + key := n.Author.Login + if key == "" { + key = ":" + n.SubmittedAt.UTC().Format(time.RFC3339Nano) + } + state := strings.ToUpper(n.State) + // COMMENTED and PENDING are non-binding states and shouldn't replace + // a prior APPROVED / CHANGES_REQUESTED for the same reviewer. + if state != reviewStateApproved && state != reviewStateChangesRequested { + if _, ok := byAuthor[key]; ok { + continue + } + } + prev, ok := byAuthor[key] + if !ok || !n.SubmittedAt.Before(prev.at) { + byAuthor[key] = latest{state: state, at: n.SubmittedAt} + } + } + hasApproval := false + for _, l := range byAuthor { + switch l.state { + case reviewStateChangesRequested: + return computedReviewStateChangesRequested + case reviewStateApproved: + hasApproval = true + } + } + if hasApproval { + return computedReviewStateApproved + } + return "" +} + +// PATClient.ExecuteGraphQL satisfies GraphQLExecutor by POSTing to /graphql. +func (c *PATClient) ExecuteGraphQL(ctx context.Context, query string, variables map[string]any, out any) error { + body, err := json.Marshal(map[string]any{"query": query, "variables": variables}) + if err != nil { + return fmt.Errorf("marshal graphql: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, githubAPIBase+"/graphql", bytes.NewReader(body)) + if err != nil { + return err + } + c.setGitHubHeaders(req) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("graphql request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + c.recordRateHeaders(resp, "/graphql") + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024)) + if resp.StatusCode >= 400 { + c.maybeMarkRateExhaustedFromBody("/graphql", resp.StatusCode, respBody) + return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: "/graphql", Body: string(respBody)} + } + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("decode graphql response: %w", err) + } + recordGraphQLRateFromPayload(c.rateTracker, respBody) + return nil +} + +// recordGraphQLRateFromPayload extracts data.rateLimit from a GraphQL +// response body and feeds it to the tracker. The GraphQL rate-limit field is +// point-cost based and more accurate than HTTP headers. +func recordGraphQLRateFromPayload(tracker *RateTracker, body []byte) { + if tracker == nil { + return + } + var probe struct { + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &probe); err != nil { + return + } + raw, ok := probe.Data["rateLimit"] + if !ok || len(raw) == 0 { + return + } + var rl graphQLRateLimit + if err := json.Unmarshal(raw, &rl); err != nil { + return + } + tracker.Record(RateSnapshot{ + Resource: ResourceGraphQL, + Limit: rl.Limit, + Remaining: rl.Remaining, + ResetAt: rl.ResetAt, + UpdatedAt: time.Now().UTC(), + }) +} + +// GHClient.ExecuteGraphQL satisfies GraphQLExecutor via `gh api graphql -f query=...`. +// Variables are passed as -F field_name=value entries; they are ignored when +// the query has no $variables. +func (c *GHClient) ExecuteGraphQL(ctx context.Context, query string, variables map[string]any, out any) error { + args := []string{"api", "graphql", "-f", "query=" + query} + for k, v := range variables { + args = append(args, "-F", fmt.Sprintf("%s=%v", k, v)) + } + stdout, err := c.run(ctx, args...) + if err != nil { + return fmt.Errorf("gh graphql: %w", err) + } + if err := json.Unmarshal([]byte(stdout), out); err != nil { + return fmt.Errorf("decode graphql response: %w", err) + } + recordGraphQLRateFromPayload(c.rateTracker, []byte(stdout)) + return nil +} + +// noopGraphQLExecutorErr is returned when the active client is the noop +// fallback (no auth). Caller paths must handle this gracefully — typically +// by skipping the batched call entirely. +var errGraphQLUnsupported = fmt.Errorf("github client does not support GraphQL") + +// graphQLExecutorFor returns an executor for the given client, or +// errGraphQLUnsupported when the client is nil/noop. +func graphQLExecutorFor(client Client) (GraphQLExecutor, error) { + if exec, ok := client.(GraphQLExecutor); ok && client != nil { + return exec, nil + } + return nil, errGraphQLUnsupported +} + +// runBatchedPRQuery executes the batched query in chunks and merges the +// results into a single map keyed by prStatusCacheKey(owner, repo, number). +func runBatchedPRQuery(ctx context.Context, exec GraphQLExecutor, refs []graphQLPRRef) (map[string]*PRStatus, error) { + if exec == nil || len(refs) == 0 { + return nil, nil + } + result := make(map[string]*PRStatus, len(refs)) + for _, chunk := range chunkedRefs(refs) { + query, vars := buildBatchedPRQuery(chunk) + var resp struct { + Data map[string]json.RawMessage `json:"data"` + Errors []graphQLError `json:"errors"` + } + if err := exec.ExecuteGraphQL(ctx, query, vars, &resp); err != nil { + return nil, err + } + // GitHub returns HTTP 200 with a top-level "errors" array for partial + // auth failures, schema mismatches, or per-alias errors. Surface them + // as an error so the caller falls back to per-watch checks instead of + // silently absorbing the failure. + if err := graphQLErrorsToErr(resp.Errors); err != nil { + return nil, err + } + if err := decodeBatchedPRChunk(chunk, resp.Data, result); err != nil { + return nil, err + } + } + return result, nil +} + +// decodeBatchedPRChunk maps the aliased response back to the input refs and +// fills in the result map. Refs whose alias is missing or null are skipped +// (e.g. PR was deleted upstream); the next poller tick will retry. +func decodeBatchedPRChunk(refs []graphQLPRRef, data map[string]json.RawMessage, result map[string]*PRStatus) error { + type group struct { + idx int + owner string + repo string + prRefs []graphQLPRRef + } + groups := []*group{} + byKey := map[string]*group{} + for _, r := range refs { + k := r.Owner + "/" + r.Repo + g, ok := byKey[k] + if !ok { + g = &group{idx: len(groups), owner: r.Owner, repo: r.Repo} + byKey[k] = g + groups = append(groups, g) + } + g.prRefs = append(g.prRefs, r) + } + keys := make([]string, 0, len(byKey)) + for k := range byKey { + keys = append(keys, k) + } + sort.Strings(keys) + // Reassign group indices in sorted order to match buildBatchedPRQuery. + for i, k := range keys { + byKey[k].idx = i + } + for _, k := range keys { + g := byKey[k] + raw, ok := data[fmt.Sprintf("repo%d", g.idx)] + if !ok || len(raw) == 0 { + continue + } + repoBlock := map[string]json.RawMessage{} + if err := json.Unmarshal(raw, &repoBlock); err != nil { + return fmt.Errorf("decode repo block: %w", err) + } + for prIdx, ref := range g.prRefs { + alias := fmt.Sprintf("%s%d", graphQLPRBatchAlias, prIdx) + rawPR, ok := repoBlock[alias] + if !ok || len(rawPR) == 0 || string(rawPR) == "null" { + continue + } + var raw batchedPRResult + if err := json.Unmarshal(rawPR, &raw); err != nil { + return fmt.Errorf("decode pr alias %s: %w", alias, err) + } + result[prStatusCacheKey(ref.Owner, ref.Repo, ref.Number)] = convertBatchedPRResult(&raw, ref.Owner, ref.Repo, ref.Number) + } + } + return nil +} + +// runBatchedBranchQuery executes the branch-lookup query in chunks and maps +// each branch ref to its first OPEN associated PR (if any). Result keys are +// "owner/repo/branch" so callers can index by their input refs. +func runBatchedBranchQuery(ctx context.Context, exec GraphQLExecutor, refs []graphQLBranchRef) (map[string]*PRStatus, error) { + if exec == nil || len(refs) == 0 { + return nil, nil + } + result := make(map[string]*PRStatus, len(refs)) + for _, chunk := range chunkedRefs(refs) { + query, vars := buildBatchedBranchQuery(chunk) + var resp struct { + Data map[string]json.RawMessage `json:"data"` + Errors []graphQLError `json:"errors"` + } + if err := exec.ExecuteGraphQL(ctx, query, vars, &resp); err != nil { + return nil, err + } + if err := graphQLErrorsToErr(resp.Errors); err != nil { + return nil, err + } + decodeBatchedBranchChunk(chunk, resp.Data, result) + } + return result, nil +} + +func decodeBatchedBranchChunk(refs []graphQLBranchRef, data map[string]json.RawMessage, result map[string]*PRStatus) { + for i, ref := range refs { + raw, ok := data[fmt.Sprintf("b%d", i)] + if !ok || len(raw) == 0 || string(raw) == "null" { + continue + } + var inner struct { + Ref *struct { + AssociatedPullRequests struct { + Nodes []struct { + Number int `json:"number"` + batchedPRResult + } `json:"nodes"` + } `json:"associatedPullRequests"` + } `json:"ref"` + } + if err := json.Unmarshal(raw, &inner); err != nil { + continue + } + if inner.Ref == nil || len(inner.Ref.AssociatedPullRequests.Nodes) == 0 { + continue + } + node := inner.Ref.AssociatedPullRequests.Nodes[0] + status := convertBatchedPRResult(&node.batchedPRResult, ref.Owner, ref.Repo, node.Number) + result[graphqlBranchKey(ref.Owner, ref.Repo, ref.Branch)] = status + } +} + +// graphqlBranchKey is the lookup key used in batched-branch result maps. +// Named graphql* to avoid collision with the mock_client.go branchKey type. +func graphqlBranchKey(owner, repo, branch string) string { + return owner + "/" + repo + "/" + branch +} + +// Compile-time assertions that the existing CLI/PAT clients satisfy +// GraphQLExecutor. If a client stops implementing it the build fails here. +var ( + _ GraphQLExecutor = (*PATClient)(nil) + _ GraphQLExecutor = (*GHClient)(nil) +) diff --git a/apps/backend/internal/github/graphql_test.go b/apps/backend/internal/github/graphql_test.go new file mode 100644 index 0000000000..6a9f887be3 --- /dev/null +++ b/apps/backend/internal/github/graphql_test.go @@ -0,0 +1,296 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" +) + +// stubGraphQLExecutor lets tests inspect/return canned responses without +// touching HTTP or the gh CLI. +type stubGraphQLExecutor struct { + queries []string + response string + err error +} + +func (s *stubGraphQLExecutor) ExecuteGraphQL(_ context.Context, query string, _ map[string]any, out any) error { + s.queries = append(s.queries, query) + if s.err != nil { + return s.err + } + return json.Unmarshal([]byte(s.response), out) +} + +func TestBuildBatchedPRQuery_GroupsByRepo(t *testing.T) { + q, _ := buildBatchedPRQuery([]graphQLPRRef{ + {Owner: "octo", Repo: "alpha", Number: 1}, + {Owner: "octo", Repo: "alpha", Number: 2}, + {Owner: "octo", Repo: "beta", Number: 9}, + }) + if !strings.Contains(q, `repo0: repository(owner: "octo", name: "alpha")`) { + t.Errorf("expected repo0 alias for octo/alpha, got: %s", q) + } + if !strings.Contains(q, `repo1: repository(owner: "octo", name: "beta")`) { + t.Errorf("expected repo1 alias for octo/beta, got: %s", q) + } + if !strings.Contains(q, `pr0: pullRequest(number: 1)`) || + !strings.Contains(q, `pr1: pullRequest(number: 2)`) { + t.Errorf("expected aliased pullRequests inside repo0: %s", q) + } + if !strings.Contains(q, "rateLimit") { + t.Errorf("expected rateLimit field in query") + } +} + +func TestBuildBatchedBranchQuery_AliasesAllBranches(t *testing.T) { + q, _ := buildBatchedBranchQuery([]graphQLBranchRef{ + {Owner: "o", Repo: "r", Branch: "feat-1"}, + {Owner: "o", Repo: "r", Branch: "feat-2"}, + }) + if !strings.Contains(q, `b0: repository`) || !strings.Contains(q, `b1: repository`) { + t.Errorf("expected b0/b1 aliases: %s", q) + } + if !strings.Contains(q, `qualifiedName: "refs/heads/feat-1"`) { + t.Errorf("expected qualifiedName for feat-1: %s", q) + } +} + +func TestRunBatchedPRQuery_DecodesAliasesBackToRefs(t *testing.T) { + exec := &stubGraphQLExecutor{ + response: `{ + "data": { + "repo0": { + "pr0": { + "state": "OPEN", "title": "PR A", "url": "https://x/1", + "isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN", + "headRefName": "h1", "baseRefName": "main", "headRefOid": "abc", + "author": {"login":"alice"}, "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-02T00:00:00Z", + "reviews": {"nodes": [{"state": "APPROVED"}]}, + "reviewRequests": {"totalCount": 0}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]} + } + }, + "rateLimit": {"limit":5000, "remaining":4999, "resetAt":"2030-01-01T00:00:00Z", "cost":1} + } + }`, + } + got, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{ + {Owner: "o", Repo: "r", Number: 42}, + }) + if err != nil { + t.Fatalf("run: %v", err) + } + status, ok := got[prStatusCacheKey("o", "r", 42)] + if !ok { + t.Fatalf("expected status for o/r#42, got keys: %v", keysOf(got)) + } + if status.ReviewState != "approved" { + t.Errorf("ReviewState = %q, want approved", status.ReviewState) + } + if status.ChecksState != "success" { + t.Errorf("ChecksState = %q, want success", status.ChecksState) + } + if status.PR == nil || status.PR.Title != "PR A" { + t.Errorf("PR.Title mismatch: %#v", status.PR) + } +} + +func TestRunBatchedPRQuery_DecodesMultipleReposIntoCorrectKeys(t *testing.T) { + // Two repos in one batch: octo/alpha#1 and octo/beta#9. The decoder sorts + // repo group keys; this test guards against future drift between + // buildBatchedPRQuery's sort and decodeBatchedPRChunk's sort by checking + // each PR lands in its correct key slot. + exec := &stubGraphQLExecutor{ + response: `{ + "data": { + "repo0": { + "pr0": { + "state": "OPEN", "title": "alpha PR", "url": "https://x/a/1", + "isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN", + "headRefName": "h1", "baseRefName": "main", "headRefOid": "aaa", + "author": {"login":"alice"}, "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", + "reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0}, + "commits": {"nodes": []} + } + }, + "repo1": { + "pr0": { + "state": "OPEN", "title": "beta PR", "url": "https://x/b/9", + "isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN", + "headRefName": "h2", "baseRefName": "main", "headRefOid": "bbb", + "author": {"login":"alice"}, "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", + "reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0}, + "commits": {"nodes": []} + } + } + } + }`, + } + got, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{ + {Owner: "octo", Repo: "alpha", Number: 1}, + {Owner: "octo", Repo: "beta", Number: 9}, + }) + if err != nil { + t.Fatalf("run: %v", err) + } + if s, ok := got[prStatusCacheKey("octo", "alpha", 1)]; !ok || s.PR == nil || s.PR.Title != "alpha PR" { + t.Errorf("alpha#1 not decoded into correct slot: %#v", s) + } + if s, ok := got[prStatusCacheKey("octo", "beta", 9)]; !ok || s.PR == nil || s.PR.Title != "beta PR" { + t.Errorf("beta#9 not decoded into correct slot: %#v", s) + } +} + +func TestRunBatchedPRQuery_PropagatesError(t *testing.T) { + exec := &stubGraphQLExecutor{err: errors.New("graphql 500")} + _, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{{Owner: "o", Repo: "r", Number: 1}}) + if err == nil { + t.Fatalf("expected error to propagate") + } +} + +func TestRunBatchedPRQuery_SurfacesGraphQLErrors(t *testing.T) { + exec := &stubGraphQLExecutor{ + response: `{"data": {}, "errors": [{"message": "Field 'foo' doesn't exist on type 'Repository'"}]}`, + } + _, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{{Owner: "o", Repo: "r", Number: 1}}) + if err == nil { + t.Fatalf("expected error from non-empty errors array") + } + if !strings.Contains(err.Error(), "Field 'foo'") { + t.Errorf("expected error to include first message, got: %v", err) + } +} + +func TestRunBatchedBranchQuery_SurfacesGraphQLErrors(t *testing.T) { + exec := &stubGraphQLExecutor{ + response: `{"data": {}, "errors": [{"message": "rate limited"}, {"message": "secondary"}]}`, + } + _, err := runBatchedBranchQuery(context.Background(), exec, []graphQLBranchRef{{Owner: "o", Repo: "r", Branch: "main"}}) + if err == nil { + t.Fatalf("expected error from non-empty errors array") + } + if !strings.Contains(err.Error(), "rate limited") || !strings.Contains(err.Error(), "and 1 more") { + t.Errorf("expected error to include first message + count, got: %v", err) + } +} + +func TestRunBatchedBranchQuery_DecodesPRNode(t *testing.T) { + exec := &stubGraphQLExecutor{ + response: `{ + "data": { + "b0": { + "ref": { + "associatedPullRequests": { + "nodes": [{ + "number": 7, + "state": "OPEN", "title": "branch PR", "url": "https://x/7", + "isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN", + "headRefName": "feat", "baseRefName": "main", "headRefOid": "deadbeef", + "author": {"login":"alice"}, + "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", + "reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0}, + "commits": {"nodes": []} + }] + } + } + } + } + }`, + } + got, err := runBatchedBranchQuery(context.Background(), exec, []graphQLBranchRef{ + {Owner: "o", Repo: "r", Branch: "feat"}, + }) + if err != nil { + t.Fatalf("run: %v", err) + } + status, ok := got[graphqlBranchKey("o", "r", "feat")] + if !ok { + t.Fatalf("expected branch result, got keys: %v", keysOf(got)) + } + if status.PR == nil || status.PR.Number != 7 { + t.Errorf("expected PR number 7, got %#v", status.PR) + } +} + +func TestSummarizeReviewState_PrefersChangesRequested(t *testing.T) { + mk := func(login, state string, sec int) reviewNode { + var n reviewNode + n.Author.Login = login + n.State = state + n.SubmittedAt = time.Unix(int64(sec), 0).UTC() + return n + } + // Different reviewers: CHANGES_REQUESTED beats APPROVED. + if got := summarizeReviewState([]reviewNode{ + mk("alice", "APPROVED", 1), + mk("bob", "CHANGES_REQUESTED", 2), + }); got != "changes_requested" { + t.Errorf("got %q", got) + } + // Different reviewers: APPROVED + COMMENTED -> approved. + if got := summarizeReviewState([]reviewNode{ + mk("alice", "APPROVED", 1), + mk("bob", "COMMENTED", 2), + }); got != "approved" { + t.Errorf("got %q", got) + } + // Single COMMENTED -> empty. + if got := summarizeReviewState([]reviewNode{ + mk("alice", "COMMENTED", 1), + }); got != "" { + t.Errorf("got %q", got) + } + // Same reviewer: CHANGES_REQUESTED then APPROVED -> approved (Greptile P1). + if got := summarizeReviewState([]reviewNode{ + mk("alice", "CHANGES_REQUESTED", 1), + mk("alice", "APPROVED", 2), + }); got != "approved" { + t.Errorf("dedup: got %q, want approved", got) + } + // Same reviewer: APPROVED then CHANGES_REQUESTED -> changes_requested. + if got := summarizeReviewState([]reviewNode{ + mk("alice", "APPROVED", 1), + mk("alice", "CHANGES_REQUESTED", 2), + }); got != "changes_requested" { + t.Errorf("dedup reverse: got %q, want changes_requested", got) + } +} + +func TestChunkedRefs_RespectsBatchSize(t *testing.T) { + refs := make([]graphQLPRRef, graphQLBatchChunkSize+5) + chunks := chunkedRefs(refs) + if len(chunks) != 2 { + t.Fatalf("expected 2 chunks for %d refs, got %d", len(refs), len(chunks)) + } + if len(chunks[0]) != graphQLBatchChunkSize { + t.Errorf("first chunk size = %d, want %d", len(chunks[0]), graphQLBatchChunkSize) + } + if len(chunks[1]) != 5 { + t.Errorf("second chunk size = %d, want 5", len(chunks[1])) + } +} + +func keysOf[K comparable, V any](m map[K]V) []K { + out := make([]K, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +func TestGraphQLExecutorFor_NoopReturnsError(t *testing.T) { + if _, err := graphQLExecutorFor(&NoopClient{}); err == nil { + t.Fatalf("expected unsupported error for NoopClient") + } + if _, err := graphQLExecutorFor(NewPATClient("token")); err != nil { + t.Fatalf("PATClient should satisfy GraphQLExecutor: %v", err) + } + if _, err := graphQLExecutorFor(NewGHClient()); err != nil { + t.Fatalf("GHClient should satisfy GraphQLExecutor: %v", err) + } +} diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index deea89d747..433d65606b 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -234,13 +234,22 @@ type RepoBranch struct { // GitHubStatus represents GitHub connection status. type GitHubStatus struct { - Authenticated bool `json:"authenticated"` - Username string `json:"username"` - AuthMethod string `json:"auth_method"` // "gh_cli", "pat", "none" - TokenConfigured bool `json:"token_configured"` - TokenSecretID string `json:"token_secret_id,omitempty"` - RequiredScopes []string `json:"required_scopes"` - Diagnostics *AuthDiagnostics `json:"diagnostics,omitempty"` + Authenticated bool `json:"authenticated"` + Username string `json:"username"` + AuthMethod string `json:"auth_method"` // "gh_cli", "pat", "none" + TokenConfigured bool `json:"token_configured"` + TokenSecretID string `json:"token_secret_id,omitempty"` + RequiredScopes []string `json:"required_scopes"` + Diagnostics *AuthDiagnostics `json:"diagnostics,omitempty"` + RateLimit *GitHubRateLimitInfo `json:"rate_limit,omitempty"` +} + +// GitHubRateLimitInfo bundles known rate-limit snapshots per resource bucket +// for surfacing in the UI. +type GitHubRateLimitInfo struct { + Core *RateSnapshot `json:"core,omitempty"` + GraphQL *RateSnapshot `json:"graphql,omitempty"` + Search *RateSnapshot `json:"search,omitempty"` } // ConfigureTokenRequest is the request body for configuring a GitHub token. diff --git a/apps/backend/internal/github/pat_client.go b/apps/backend/internal/github/pat_client.go index 8a0726ff2f..5383b21b38 100644 --- a/apps/backend/internal/github/pat_client.go +++ b/apps/backend/internal/github/pat_client.go @@ -31,9 +31,10 @@ func (e *GitHubAPIError) Error() string { // PATClient implements Client using a GitHub Personal Access Token. type PATClient struct { - token string - httpClient *http.Client - username string // cached after first GetAuthenticatedUser call + token string + httpClient *http.Client + username string // cached after first GetAuthenticatedUser call + rateTracker *RateTracker } // NewPATClient creates a new PAT-based GitHub client. @@ -46,6 +47,81 @@ func NewPATClient(token string) *PATClient { } } +// WithRateTracker attaches a rate tracker so response headers are recorded. +// Returns the client for chaining; safe to call before any requests are made. +func (c *PATClient) WithRateTracker(t *RateTracker) *PATClient { + c.rateTracker = t + return c +} + +// recordRateHeaders feeds rate-limit data from a response into the tracker. +// endpoint is used to pick the default resource bucket when the response +// omits the X-RateLimit-Resource header. +func (c *PATClient) recordRateHeaders(resp *http.Response, endpoint string) { + if c.rateTracker == nil || resp == nil { + return + } + defaultResource := ResourceCore + if strings.HasPrefix(endpoint, "/search/") { + defaultResource = ResourceSearch + } else if strings.HasPrefix(endpoint, "/graphql") { + defaultResource = ResourceGraphQL + } + snap, headersOK := parseRateHeaders(resp, defaultResource) + if headersOK { + c.rateTracker.Record(snap) + } + if !isRateLimitStatus(resp.StatusCode) { + return + } + // On a 429, prefer the real X-RateLimit-Reset over the synthetic 1h + // fallback. parseRateHeaders may return ok=true with Remaining>0 if the + // secondary-limit response carried stale headers — only skip the + // fallback when the headers themselves report exhaustion (Remaining<=0 + // + future ResetAt). Otherwise fall through to the conservative pause. + if headersOK && snap.Exhausted() { + return + } + c.rateTracker.markRateExhausted(defaultResource, time.Time{}) +} + +// isRateLimitStatus returns true for status codes GitHub uses to signal +// primary or secondary rate-limit exhaustion. 403 is documented for both +// abuse-detection and primary limits when the body indicates so; 429 is +// secondary limits. +func isRateLimitStatus(status int) bool { + return status == http.StatusTooManyRequests +} + +// maybeMarkRateExhaustedFromBody flags a rate-limit hit when GitHub returned +// a 403/429 whose body contains the rate-limit prose. The headers may be +// missing on these responses (esp. secondary limits), so the body is the +// authoritative signal. +func (c *PATClient) maybeMarkRateExhaustedFromBody(endpoint string, status int, body []byte) { + if c.rateTracker == nil { + return + } + if status != http.StatusForbidden && status != http.StatusTooManyRequests { + return + } + lower := strings.ToLower(string(body)) + if !strings.Contains(lower, "rate limit") && !strings.Contains(lower, "abuse detection") { + return + } + resource := ResourceCore + if strings.HasPrefix(endpoint, "/search/") { + resource = ResourceSearch + } else if strings.HasPrefix(endpoint, "/graphql") { + resource = ResourceGraphQL + } + // If recordRateHeaders already captured a real reset for this bucket on + // the same response, don't clobber it with the synthetic 1h fallback. + if existing, ok := c.rateTracker.Snapshot(resource); ok && existing.Exhausted() { + return + } + c.rateTracker.markRateExhausted(resource, time.Time{}) +} + // setGitHubHeaders sets the common Authorization, Accept, and API version headers. func (c *PATClient) setGitHubHeaders(req *http.Request) { req.Header.Set("Authorization", "token "+c.token) @@ -390,9 +466,11 @@ func (c *PATClient) post(ctx context.Context, endpoint string, body []byte) erro return fmt.Errorf("request POST %s: %w", endpoint, err) } defer func() { _ = resp.Body.Close() }() + c.recordRateHeaders(resp, endpoint) if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, respBody) return fmt.Errorf("GitHub API POST %s returned %d: %s", endpoint, resp.StatusCode, string(respBody)) } return nil @@ -411,9 +489,11 @@ func (c *PATClient) get(ctx context.Context, endpoint string, result interface{} return fmt.Errorf("request %s: %w", endpoint, err) } defer func() { _ = resp.Body.Close() }() + c.recordRateHeaders(resp, endpoint) if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, body) return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(body)} } return json.NewDecoder(resp.Body).Decode(result) @@ -433,9 +513,11 @@ func (c *PATClient) getPaginated(ctx context.Context, endpoint string, result in return "", fmt.Errorf("request %s: %w", endpoint, err) } defer func() { _ = resp.Body.Close() }() + c.recordRateHeaders(resp, endpoint) if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, body) return "", &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(body)} } if err := json.NewDecoder(resp.Body).Decode(result); err != nil { diff --git a/apps/backend/internal/github/pat_client_test.go b/apps/backend/internal/github/pat_client_test.go index 8d7fe1e304..7902b8aa7a 100644 --- a/apps/backend/internal/github/pat_client_test.go +++ b/apps/backend/internal/github/pat_client_test.go @@ -1,6 +1,11 @@ package github import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "strings" "testing" "time" ) @@ -180,3 +185,138 @@ func TestConvertPatRequestedReviewers(t *testing.T) { t.Errorf("unexpected third reviewer: %#v", got[2]) } } + +func TestPATClient_RecordsRateHeadersOnSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-RateLimit-Limit", "5000") + w.Header().Set("X-RateLimit-Remaining", "4998") + w.Header().Set("X-RateLimit-Reset", "2000000000") + w.Header().Set("X-RateLimit-Resource", "core") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"login":"octocat"}`)) + })) + t.Cleanup(srv.Close) + + c := newPATClientPointingAt(t, srv.URL) + tracker := NewRateTracker(nil, nil) + c.WithRateTracker(tracker) + + var out struct { + Login string `json:"login"` + } + if err := c.get(context.Background(), "/user", &out); err != nil { + t.Fatalf("get: %v", err) + } + snap, ok := tracker.Snapshot(ResourceCore) + if !ok { + t.Fatalf("expected core snapshot") + } + if snap.Remaining != 4998 || snap.Limit != 5000 { + t.Fatalf("snap = %+v", snap) + } +} + +// Regression: when a 429 carries valid X-RateLimit-Reset headers, the reset +// time from the headers must win — not the synthetic +1h fallback in +// markRateExhausted. Previously, recordRateHeaders called Record(snap) +// followed by markRateExhausted(time.Time{}), and the second call clobbered +// the real reset with a 1-hour pause that could over-throttle the poller. +func TestPATClient_RateLimit429_PreservesRealReset(t *testing.T) { + realReset := time.Now().Add(7 * time.Minute).UTC().Truncate(time.Second) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-RateLimit-Limit", "5000") + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(realReset.Unix(), 10)) + w.Header().Set("X-RateLimit-Resource", "core") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"message":"API rate limit exceeded"}`)) + })) + t.Cleanup(srv.Close) + + c := newPATClientPointingAt(t, srv.URL) + tracker := NewRateTracker(nil, nil) + c.WithRateTracker(tracker) + + var out struct{} + if err := c.get(context.Background(), "/repos/o/r/pulls/1", &out); err == nil { + t.Fatalf("expected error from 429") + } + snap, ok := tracker.Snapshot(ResourceCore) + if !ok { + t.Fatalf("expected core snapshot") + } + if !snap.ResetAt.Equal(realReset) { + t.Errorf("expected reset_at preserved from headers (%s), got %s (off by %s)", + realReset, snap.ResetAt, snap.ResetAt.Sub(realReset)) + } + if !tracker.IsExhausted(ResourceCore) { + t.Errorf("expected core to be exhausted") + } +} + +// When a 429 has no rate-limit headers, the synthetic 1h fallback still +// applies so the poller pauses instead of hammering the secondary limit. +func TestPATClient_RateLimit429_NoHeaders_UsesSyntheticReset(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"message":"abuse detection"}`)) + })) + t.Cleanup(srv.Close) + + c := newPATClientPointingAt(t, srv.URL) + tracker := NewRateTracker(nil, nil) + c.WithRateTracker(tracker) + + var out struct{} + if err := c.get(context.Background(), "/repos/o/r/pulls/1", &out); err == nil { + t.Fatalf("expected error from 429") + } + if !tracker.IsExhausted(ResourceCore) { + t.Fatalf("expected core exhausted via synthetic fallback") + } +} + +func TestPATClient_MarksExhaustedFromRateLimitBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // No headers — secondary limits sometimes omit them entirely. + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"API rate limit exceeded for user."}`)) + })) + t.Cleanup(srv.Close) + + c := newPATClientPointingAt(t, srv.URL) + tracker := NewRateTracker(nil, nil) + c.WithRateTracker(tracker) + + var out struct{} + if err := c.get(context.Background(), "/repos/o/r/pulls/1", &out); err == nil { + t.Fatalf("expected error from 403") + } + if !tracker.IsExhausted(ResourceCore) { + t.Fatalf("expected core exhausted from body parse") + } +} + +// newPATClientPointingAt builds a PATClient whose underlying HTTP client +// reroutes any github API URL to the given test server. +func newPATClientPointingAt(t *testing.T, baseURL string) *PATClient { + t.Helper() + c := NewPATClient("test-token") + c.httpClient = &http.Client{ + Transport: &rewriteTransport{base: baseURL}, + Timeout: 2 * time.Second, + } + return c +} + +type rewriteTransport struct{ base string } + +func (rt *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + rewritten := strings.Replace(req.URL.String(), githubAPIBase, rt.base, 1) + req2, err := http.NewRequestWithContext(req.Context(), req.Method, rewritten, req.Body) + if err != nil { + return nil, err + } + req2.Header = req.Header.Clone() + return http.DefaultTransport.RoundTrip(req2) +} diff --git a/apps/backend/internal/github/poller.go b/apps/backend/internal/github/poller.go index 14e45a711e..2de082c1ee 100644 --- a/apps/backend/internal/github/poller.go +++ b/apps/backend/internal/github/poller.go @@ -13,11 +13,59 @@ import ( ) const ( - defaultPRPollInterval = 30 * time.Second + defaultPRPollInterval = 1 * time.Minute defaultReviewPollInterval = 5 * time.Minute defaultIssuePollInterval = 5 * time.Minute + // rateLimitedSleepCap bounds the rate-limit sleep so a misreported reset + // (e.g. far-future reset_at after a CLI failure) cannot wedge the loop. + rateLimitedSleepCap = 10 * time.Minute ) +// searchBucketExhausted reports whether the search bucket is currently +// exhausted. Used by per-watch loops to bail out mid-cycle once a single +// search request has tripped the limit, so the remaining watches don't issue +// doomed searches that worsen secondary-limit penalties. Returns false when +// the tracker is unavailable (tests, disabled feature). +func (p *Poller) searchBucketExhausted(loop string) bool { + if p.service == nil || p.service.rateTracker == nil { + return false + } + if p.service.rateTracker.WaitDuration(ResourceSearch) <= 0 { + return false + } + p.logger.Debug("search bucket exhausted; stopping watch loop early", + zap.String("loop", loop)) + return true +} + +// waitForRateLimit returns true after sleeping the loop until the relevant +// resource bucket has quota again, or false if ctx was cancelled. When the +// bucket is healthy the call is a no-op and returns true immediately. +func (p *Poller) waitForRateLimit(ctx context.Context, resource Resource, loop string) bool { + if p.service == nil || p.service.rateTracker == nil { + return true + } + d := p.service.rateTracker.WaitDuration(resource) + if d <= 0 { + return true + } + if d > rateLimitedSleepCap { + d = rateLimitedSleepCap + } + p.logger.Info("github rate-limited; pausing poller loop", + zap.String("loop", loop), + zap.String("resource", string(resource)), + zap.Duration("sleep", d)) + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + // TaskBranchInfo describes a task+session that may need a PR watch. // RepositoryID scopes multi-repo tasks: each repo is a separate watch row. // Empty for legacy single-repo tasks. @@ -94,7 +142,9 @@ func (p *Poller) prMonitorLoop(ctx context.Context) { defer p.wg.Done() // Run an initial check immediately so existing watches are evaluated on startup. - p.checkPRWatches(ctx) + if p.waitForRateLimit(ctx, ResourceGraphQL, "pr_monitor") { + p.checkPRWatches(ctx) + } ticker := time.NewTicker(defaultPRPollInterval) defer ticker.Stop() @@ -104,6 +154,9 @@ func (p *Poller) prMonitorLoop(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: + if !p.waitForRateLimit(ctx, ResourceGraphQL, "pr_monitor") { + return + } p.checkPRWatches(ctx) } } @@ -117,11 +170,168 @@ func (p *Poller) checkPRWatches(ctx context.Context) { p.logger.Error("failed to list PR watches", zap.Error(err)) return } + if len(watches) == 0 { + return + } + + // Try the batched GraphQL path first — collapses N HTTP requests into a + // handful of multi-aliased GraphQL calls. On error or unsupported client + // (e.g. NoopClient), fall back to per-watch checks so one bad poll cycle + // doesn't lose state. + if p.tryBatchedPRWatchCheck(ctx, watches) { + return + } for _, watch := range watches { p.checkSinglePRWatch(ctx, watch) } } +// tryBatchedPRWatchCheck runs the batched GraphQL flow for the supplied +// watches. Returns true when the path succeeded so the caller can skip the +// per-watch fallback. +func (p *Poller) tryBatchedPRWatchCheck(ctx context.Context, watches []*PRWatch) bool { + exec, err := graphQLExecutorFor(p.service.client) + if err != nil { + return false + } + numbered, searching := splitPRWatches(watches) + statusByKey, ok := p.fetchBatchedStatuses(ctx, exec, numbered, searching) + if !ok { + return false + } + for _, w := range numbered { + key := prStatusCacheKey(w.Owner, w.Repo, w.PRNumber) + status, ok := statusByKey[key] + if !ok { + // Alias missing — PR may have been deleted; fall through to the + // per-watch path which already handles the not-found case. + p.checkSinglePRWatch(ctx, w) + continue + } + p.applyPRStatus(ctx, w, status) + } + for _, w := range searching { + key := graphqlBranchKey(w.Owner, w.Repo, w.Branch) + status, ok := statusByKey[key] + if !ok || status == nil || status.PR == nil { + // No PR yet for this branch — record the timestamp like the + // per-watch path so liveness updates. + now := time.Now().UTC() + _ = p.service.store.UpdatePRWatchTimestamps(ctx, w.ID, now, nil, "", "") + continue + } + p.applyDetectedPR(ctx, w, status.PR) + } + return true +} + +// fetchBatchedStatuses runs both the numbered-PR query and the branch query +// against GraphQL. The combined map keys numbered watches by +// prStatusCacheKey and searching watches by graphqlBranchKey, so callers can +// look up either kind in one place. Returns (nil, false) when any required +// query failed so the caller falls back to per-watch checks rather than +// silently absorbing the failure as "no result". +func (p *Poller) fetchBatchedStatuses( + ctx context.Context, exec GraphQLExecutor, numbered, searching []*PRWatch, +) (map[string]*PRStatus, bool) { + combined := make(map[string]*PRStatus) + + if len(numbered) > 0 { + refs := make([]graphQLPRRef, 0, len(numbered)) + for _, w := range numbered { + refs = append(refs, graphQLPRRef{Owner: w.Owner, Repo: w.Repo, Number: w.PRNumber}) + } + out, err := runBatchedPRQuery(ctx, exec, refs) + if err != nil { + p.logger.Debug("batched PR status query failed", zap.Error(err)) + return nil, false + } + for k, v := range out { + combined[k] = v + } + } + if len(searching) > 0 { + refs := make([]graphQLBranchRef, 0, len(searching)) + for _, w := range searching { + refs = append(refs, graphQLBranchRef{Owner: w.Owner, Repo: w.Repo, Branch: w.Branch}) + } + out, err := runBatchedBranchQuery(ctx, exec, refs) + if err != nil { + p.logger.Debug("batched branch query failed", zap.Error(err)) + return nil, false + } + for k, v := range out { + combined[k] = v + } + } + return combined, true +} + +// splitPRWatches partitions watches into "we know the PR number" and "still +// searching for one on this branch" buckets so each gets its own batched +// query shape. +func splitPRWatches(watches []*PRWatch) (numbered, searching []*PRWatch) { + for _, w := range watches { + if w.PRNumber == 0 { + searching = append(searching, w) + } else { + numbered = append(numbered, w) + } + } + return numbered, searching +} + +// applyPRStatus is the post-fetch processing extracted from +// checkSinglePRWatch so the batched path can reuse it. +func (p *Poller) applyPRStatus(ctx context.Context, watch *PRWatch, status *PRStatus) { + if status == nil { + return + } + hasNew := status.ChecksState != watch.LastCheckStatus || status.ReviewState != watch.LastReviewState + now := time.Now().UTC() + if err := p.service.store.UpdatePRWatchTimestamps(ctx, watch.ID, now, nil, status.ChecksState, status.ReviewState); err != nil { + p.logger.Error("failed to update PR watch timestamps", zap.String("id", watch.ID), zap.Error(err)) + } + if syncErr := p.service.SyncTaskPR(ctx, watch.TaskID, status); syncErr != nil { + p.logger.Error("failed to sync task PR", + zap.String("task_id", watch.TaskID), zap.Error(syncErr)) + return + } + if status.PR != nil && (status.PR.State == prStateMerged || status.PR.State == prStateClosed) { + p.publishPRStatusEvent(ctx, watch, status) + if resetErr := p.service.store.UpdatePRWatchPRNumber(ctx, watch.ID, 0); resetErr != nil { + p.logger.Error("failed to reset completed PR watch", + zap.String("id", watch.ID), zap.Error(resetErr)) + } + return + } + if hasNew { + p.publishPRStatusEvent(ctx, watch, status) + } +} + +// applyDetectedPR is the searching-watch counterpart to applyPRStatus — +// promotes a PRWatch from "searching" to a known PR number and records the +// task association. +func (p *Poller) applyDetectedPR(ctx context.Context, watch *PRWatch, pr *PR) { + now := time.Now().UTC() + _ = p.service.store.UpdatePRWatchTimestamps(ctx, watch.ID, now, nil, "", "") + if err := p.service.store.UpdatePRWatchPRNumber(ctx, watch.ID, pr.Number); err != nil { + p.logger.Error("failed to update PR watch with detected PR", + zap.String("watch_id", watch.ID), zap.Int("pr_number", pr.Number), zap.Error(err)) + return + } + if _, err := p.service.AssociatePRWithTask(ctx, watch.TaskID, watch.RepositoryID, pr); err != nil { + p.logger.Error("failed to associate detected PR with task", + zap.String("task_id", watch.TaskID), zap.Int("pr_number", pr.Number), zap.Error(err)) + return + } + p.logger.Info("detected PR for session branch (batched)", + zap.String("watch_id", watch.ID), + zap.String("branch", watch.Branch), + zap.Int("pr_number", pr.Number)) +} + func (p *Poller) checkSinglePRWatch(ctx context.Context, watch *PRWatch) { // PRWatch with pr_number=0 means we're still searching for a PR on this branch. if watch.PRNumber == 0 { @@ -302,8 +512,9 @@ func (p *Poller) refreshStaleBranches(ctx context.Context) { func (p *Poller) reviewQueueLoop(ctx context.Context) { defer p.wg.Done() - // Run an initial check immediately so existing watches are evaluated on startup. - p.checkReviewWatches(ctx) + if p.waitForRateLimit(ctx, ResourceSearch, "review_queue") { + p.checkReviewWatches(ctx) + } ticker := time.NewTicker(defaultReviewPollInterval) defer ticker.Stop() @@ -313,6 +524,9 @@ func (p *Poller) reviewQueueLoop(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: + if !p.waitForRateLimit(ctx, ResourceSearch, "review_queue") { + return + } p.checkReviewWatches(ctx) } } @@ -329,6 +543,12 @@ func (p *Poller) checkReviewWatches(ctx context.Context) { } p.logger.Debug("checking review watches", zap.Int("count", len(watches))) for _, watch := range watches { + // A previous iteration in this same cycle may have exhausted the + // search bucket. Stop early instead of issuing another doomed search + // that would only deepen the secondary-limit penalty. + if p.searchBucketExhausted("review_watch") { + return + } p.logger.Debug("polling review watch", zap.String("watch_id", watch.ID), zap.String("workspace_id", watch.WorkspaceID), @@ -368,8 +588,9 @@ func (p *Poller) checkReviewWatches(ctx context.Context) { func (p *Poller) issueWatchLoop(ctx context.Context) { defer p.wg.Done() - // Run an initial check immediately so existing watches are evaluated on startup. - p.checkIssueWatches(ctx) + if p.waitForRateLimit(ctx, ResourceSearch, "issue_watch") { + p.checkIssueWatches(ctx) + } ticker := time.NewTicker(defaultIssuePollInterval) defer ticker.Stop() @@ -379,6 +600,9 @@ func (p *Poller) issueWatchLoop(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: + if !p.waitForRateLimit(ctx, ResourceSearch, "issue_watch") { + return + } p.checkIssueWatches(ctx) } } @@ -395,6 +619,9 @@ func (p *Poller) checkIssueWatches(ctx context.Context) { } p.logger.Debug("checking issue watches", zap.Int("count", len(watches))) for _, watch := range watches { + if p.searchBucketExhausted("issue_watch") { + return + } p.logger.Debug("polling issue watch", zap.String("watch_id", watch.ID), zap.String("workspace_id", watch.WorkspaceID), diff --git a/apps/backend/internal/github/poller_test.go b/apps/backend/internal/github/poller_test.go index 8b47a41684..f6d1aa1c60 100644 --- a/apps/backend/internal/github/poller_test.go +++ b/apps/backend/internal/github/poller_test.go @@ -3,6 +3,9 @@ package github import ( "context" "database/sql" + "encoding/json" + "errors" + "strings" "testing" "time" @@ -533,3 +536,336 @@ func TestRefreshStaleBranches_SkipsWhenResolverReturnsEmpty(t *testing.T) { t.Errorf("expected branch unchanged when resolver returns empty, got %q", updated.Branch) } } + +// graphQLMockClient wraps MockClient with a canned GraphQL executor so tests +// exercise the batched poll path without hitting the network. +type graphQLMockClient struct { + *MockClient + prResponses []string // FIFO; one entry consumed per ExecuteGraphQL call carrying "Batch" + branchResponses []string // FIFO; consumed for "Branches" queries + prErr error // returned for the next "Batch" call + branchErr error // returned for the next "Branches" call + prQueries []string + branchQueries []string +} + +func (m *graphQLMockClient) ExecuteGraphQL(_ context.Context, query string, _ map[string]any, out any) error { + if strings.Contains(query, "query Branches") { + m.branchQueries = append(m.branchQueries, query) + if m.branchErr != nil { + return m.branchErr + } + if len(m.branchResponses) == 0 { + return errors.New("no canned branch response") + } + resp := m.branchResponses[0] + m.branchResponses = m.branchResponses[1:] + return json.Unmarshal([]byte(resp), out) + } + m.prQueries = append(m.prQueries, query) + if m.prErr != nil { + return m.prErr + } + if len(m.prResponses) == 0 { + return errors.New("no canned PR response") + } + resp := m.prResponses[0] + m.prResponses = m.prResponses[1:] + return json.Unmarshal([]byte(resp), out) +} + +func setupBatchedPollerTest(t *testing.T) (*Poller, *Service, *graphQLMockClient, *Store) { + t.Helper() + poller, svc, mockClient, store := setupPollerTest(t) + wrapped := &graphQLMockClient{MockClient: mockClient} + svc.client = wrapped + return poller, svc, wrapped, store +} + +func TestTryBatchedPRWatchCheck_NumberedWatch_AppliesStatus(t *testing.T) { + poller, _, gh, store := setupBatchedPollerTest(t) + ctx := context.Background() + + gh.prResponses = []string{`{ + "data": { + "repo0": { + "pr0": { + "state": "OPEN", "title": "Test PR", "url": "https://x/1", + "isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN", + "headRefName": "feat", "baseRefName": "main", "headRefOid": "abc", + "author": {"login": "alice"}, + "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-02T00:00:00Z", + "reviews": {"nodes": [{"state": "APPROVED", "author": {"login": "bob"}, "submittedAt": "2026-01-02T00:00:00Z"}]}, + "reviewRequests": {"totalCount": 0}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]} + } + } + } + }`} + + watch := &PRWatch{ + SessionID: "s1", TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 42, Branch: "feat", + LastCheckStatus: "pending", LastReviewState: "", + } + if err := store.CreatePRWatch(ctx, watch); err != nil { + t.Fatalf("create PR watch: %v", err) + } + taskPR := &TaskPR{ + TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 42, + PRURL: "https://x/1", PRTitle: "Test PR", HeadBranch: "feat", BaseBranch: "main", State: "open", + } + if err := store.CreateTaskPR(ctx, taskPR); err != nil { + t.Fatalf("create task PR: %v", err) + } + + if !poller.tryBatchedPRWatchCheck(ctx, []*PRWatch{watch}) { + t.Fatalf("expected batched path to succeed") + } + + updated, err := store.GetTaskPR(ctx, "t1") + if err != nil || updated == nil { + t.Fatalf("get task PR: err=%v, pr=%v", err, updated) + } + if updated.ChecksState != "success" { + t.Errorf("ChecksState = %q, want success", updated.ChecksState) + } + if updated.ReviewState != "approved" { + t.Errorf("ReviewState = %q, want approved", updated.ReviewState) + } +} + +func TestTryBatchedPRWatchCheck_SearchingWatch_DetectsPR(t *testing.T) { + poller, _, gh, store := setupBatchedPollerTest(t) + ctx := context.Background() + + gh.branchResponses = []string{`{ + "data": { + "b0": { + "ref": { + "associatedPullRequests": { + "nodes": [{ + "number": 7, + "state": "OPEN", "title": "branch PR", "url": "https://x/7", + "isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN", + "headRefName": "feat", "baseRefName": "main", "headRefOid": "deadbeef", + "author": {"login": "alice"}, + "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", + "reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0}, + "commits": {"nodes": []} + }] + } + } + } + } + }`} + + watch := &PRWatch{ + SessionID: "s2", TaskID: "t2", Owner: "o", Repo: "r", PRNumber: 0, Branch: "feat", + } + if err := store.CreatePRWatch(ctx, watch); err != nil { + t.Fatalf("create PR watch: %v", err) + } + + if !poller.tryBatchedPRWatchCheck(ctx, []*PRWatch{watch}) { + t.Fatalf("expected batched path to succeed") + } + + updated, err := store.GetPRWatchBySession(ctx, "s2") + if err != nil || updated == nil { + t.Fatalf("get PR watch: err=%v, w=%v", err, updated) + } + if updated.PRNumber != 7 { + t.Errorf("PRNumber = %d, want 7 (PR should be detected and recorded)", updated.PRNumber) + } +} + +func TestTryBatchedPRWatchCheck_FallsBackOnUnsupportedClient(t *testing.T) { + // MockClient does not implement GraphQLExecutor, so the batched path must + // return false to trigger per-watch fallback. + poller, _, _, store := setupPollerTest(t) + ctx := context.Background() + + watch := &PRWatch{SessionID: "s1", TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 1, Branch: "feat"} + if err := store.CreatePRWatch(ctx, watch); err != nil { + t.Fatalf("create PR watch: %v", err) + } + if poller.tryBatchedPRWatchCheck(ctx, []*PRWatch{watch}) { + t.Errorf("expected false when client does not implement GraphQLExecutor") + } +} + +func TestFetchBatchedStatuses_NumberedQueryError_ReturnsNilFalse(t *testing.T) { + poller, _, gh, _ := setupBatchedPollerTest(t) + ctx := context.Background() + gh.prErr = errors.New("graphql 500") + + numbered := []*PRWatch{{Owner: "o", Repo: "r", PRNumber: 1}} + got, ok := poller.fetchBatchedStatuses(ctx, gh, numbered, nil) + if ok || got != nil { + t.Errorf("expected (nil, false) on numbered query error, got (%v, %v)", got, ok) + } +} + +func TestFetchBatchedStatuses_BranchQueryError_TriggersFallback(t *testing.T) { + // Greptile P2: when only branch query fails (no numbered watches), the + // previous return of an empty non-nil map silently absorbed the failure. + // The fix returns (nil, false) so the caller falls back per-watch. + poller, _, gh, _ := setupBatchedPollerTest(t) + ctx := context.Background() + gh.branchErr = errors.New("graphql 500") + + searching := []*PRWatch{{Owner: "o", Repo: "r", PRNumber: 0, Branch: "feat"}} + got, ok := poller.fetchBatchedStatuses(ctx, gh, nil, searching) + if ok || got != nil { + t.Errorf("expected (nil, false) on branch query error, got (%v, %v)", got, ok) + } +} + +func TestApplyPRStatus_MergedPR_ResetsWatch(t *testing.T) { + poller, _, _, store := setupPollerTest(t) + ctx := context.Background() + + mergedAt := time.Now().UTC().Add(-time.Hour) + watch := &PRWatch{ + SessionID: "s1", TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 99, Branch: "feat", + } + if err := store.CreatePRWatch(ctx, watch); err != nil { + t.Fatalf("create PR watch: %v", err) + } + taskPR := &TaskPR{ + TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 99, + PRURL: "https://x/99", PRTitle: "Merged PR", HeadBranch: "feat", BaseBranch: "main", State: "open", + } + if err := store.CreateTaskPR(ctx, taskPR); err != nil { + t.Fatalf("create task PR: %v", err) + } + + status := &PRStatus{ + PR: &PR{ + Number: 99, State: prStateMerged, RepoOwner: "o", RepoName: "r", + HeadBranch: "feat", BaseBranch: "main", MergedAt: &mergedAt, URL: "https://x/99", Title: "Merged PR", + }, + } + poller.applyPRStatus(ctx, watch, status) + + updated, err := store.GetPRWatchBySession(ctx, "s1") + if err != nil || updated == nil { + t.Fatalf("get PR watch: err=%v, w=%v", err, updated) + } + if updated.PRNumber != 0 { + t.Errorf("expected watch reset to PRNumber=0 after merge, got %d", updated.PRNumber) + } +} + +func TestWaitForRateLimit_SkipsWhenHealthy(t *testing.T) { + poller, _, _, _ := setupPollerTest(t) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if !poller.waitForRateLimit(ctx, ResourceGraphQL, "test") { + t.Errorf("expected true when no exhaustion recorded") + } +} + +func TestSearchBucketExhausted(t *testing.T) { + poller, svc, _, _ := setupPollerTest(t) + + if poller.searchBucketExhausted("test") { + t.Errorf("expected false when no exhaustion recorded") + } + + // Mark the search bucket exhausted with a future reset. + svc.RateTracker().Record(RateSnapshot{ + Resource: ResourceSearch, + Limit: 30, + Remaining: 0, + ResetAt: time.Now().Add(5 * time.Minute), + UpdatedAt: time.Now(), + }) + + if !poller.searchBucketExhausted("test") { + t.Errorf("expected true when search bucket exhausted") + } + + // Other buckets exhausting must not trip the search guard. + poller2, svc2, _, _ := setupPollerTest(t) + svc2.RateTracker().Record(RateSnapshot{ + Resource: ResourceCore, + Remaining: 0, + ResetAt: time.Now().Add(5 * time.Minute), + UpdatedAt: time.Now(), + }) + if poller2.searchBucketExhausted("test") { + t.Errorf("expected false when only core (not search) is exhausted") + } +} + +// Regression: when the search bucket trips mid-cycle, checkReviewWatches must +// stop iterating so the remaining watches don't issue doomed search requests +// that deepen the secondary-limit penalty. +func TestCheckReviewWatches_BailsOutWhenSearchExhaustedMidCycle(t *testing.T) { + poller, svc, _, store := setupPollerTest(t) + ctx := context.Background() + + // Pre-exhaust the search bucket so the very first iteration short-circuits. + svc.RateTracker().Record(RateSnapshot{ + Resource: ResourceSearch, + Remaining: 0, + ResetAt: time.Now().Add(5 * time.Minute), + UpdatedAt: time.Now(), + }) + + for _, id := range []string{"rw1", "rw2", "rw3"} { + if err := store.CreateReviewWatch(ctx, &ReviewWatch{ + ID: id, + WorkspaceID: "ws1", + Enabled: true, + }); err != nil { + t.Fatalf("seed review watch %s: %v", id, err) + } + } + + // Should return cleanly without panic / without errors despite N watches + // being enabled. + poller.checkReviewWatches(ctx) +} + +func TestCheckIssueWatches_BailsOutWhenSearchExhaustedMidCycle(t *testing.T) { + poller, svc, _, store := setupPollerTest(t) + ctx := context.Background() + + svc.RateTracker().Record(RateSnapshot{ + Resource: ResourceSearch, + Remaining: 0, + ResetAt: time.Now().Add(5 * time.Minute), + UpdatedAt: time.Now(), + }) + + for _, id := range []string{"iw1", "iw2"} { + if err := store.CreateIssueWatch(ctx, &IssueWatch{ + ID: id, + WorkspaceID: "ws1", + Enabled: true, + }); err != nil { + t.Fatalf("seed issue watch %s: %v", id, err) + } + } + + poller.checkIssueWatches(ctx) +} + +func TestWaitForRateLimit_ReturnsFalseOnContextCancel(t *testing.T) { + poller, svc, _, _ := setupPollerTest(t) + // Force exhaustion with a far-future reset. + svc.RateTracker().Record(RateSnapshot{ + Resource: ResourceGraphQL, + Limit: 5000, + Remaining: 0, + ResetAt: time.Now().Add(time.Hour), + UpdatedAt: time.Now(), + }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately so the timer select returns ctx.Done first + if poller.waitForRateLimit(ctx, ResourceGraphQL, "test") { + t.Errorf("expected false when context cancelled during sleep") + } +} diff --git a/apps/backend/internal/github/provider.go b/apps/backend/internal/github/provider.go index 4030a4a755..7b5e653d25 100644 --- a/apps/backend/internal/github/provider.go +++ b/apps/backend/internal/github/provider.go @@ -2,13 +2,36 @@ package github import ( "context" + "time" "github.com/jmoiron/sqlx" + "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" ) +// rateLimitSeedTimeout caps the startup `gh api rate_limit` probe so a slow +// CLI cannot delay backend boot. +const rateLimitSeedTimeout = 5 * time.Second + +// attachRateTracker wires the rate tracker into whichever client +// implementation we ended up with, then seeds initial snapshots. The seed is +// best-effort — a missing CLI or network blip should not block startup. +func attachRateTracker(client Client, tracker *RateTracker, log *logger.Logger) { + switch c := client.(type) { + case *PATClient: + c.WithRateTracker(tracker) + case *GHClient: + c.WithRateTracker(tracker) + seedCtx, cancel := context.WithTimeout(context.Background(), rateLimitSeedTimeout) + defer cancel() + if err := c.FetchRateLimit(seedCtx); err != nil { + log.Debug("seed gh rate limit failed", zap.Error(err)) + } + } +} + // Provide creates the full GitHub integration stack: store, client, and service. // Returns the service, a cleanup function, and any error. func Provide( @@ -30,6 +53,7 @@ func Provide( } svc := NewService(client, authMethod, secrets, store, eventBus, log) + attachRateTracker(client, svc.RateTracker(), log) svc.subscribeTaskEvents() cleanup := func() error { diff --git a/apps/backend/internal/github/ratelimit.go b/apps/backend/internal/github/ratelimit.go new file mode 100644 index 0000000000..bf4790ed49 --- /dev/null +++ b/apps/backend/internal/github/ratelimit.go @@ -0,0 +1,244 @@ +package github + +import ( + "context" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +// Resource identifies a GitHub API rate-limit bucket. +// +// GitHub maintains separate counters for REST core, GraphQL, and search; the +// `gh` CLI hits GraphQL even for `pr view`, so exhausting one bucket does not +// imply the others are exhausted. +type Resource string + +const ( + ResourceCore Resource = "core" + ResourceGraphQL Resource = "graphql" + ResourceSearch Resource = "search" +) + +// rateUpdateDebounce throttles non-transition update events so settings-page +// counters update at human speed rather than once per request. +const rateUpdateDebounce = 5 * time.Second + +// RateSnapshot captures the rate-limit state for one bucket at a point in time. +type RateSnapshot struct { + Resource Resource `json:"resource"` + Remaining int `json:"remaining"` + Limit int `json:"limit"` + ResetAt time.Time `json:"reset_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Exhausted returns true when the bucket has no quota left and ResetAt is in +// the future. Limit may be unknown (0) when the snapshot was synthesized from +// an out-of-band signal (e.g. `gh` stderr). +func (s RateSnapshot) Exhausted() bool { + return s.Remaining <= 0 && s.ResetAt.After(time.Now()) +} + +// RateLimitUpdatedEvent is published when a snapshot changes, either because +// new headers arrived or because exhaustion state flipped. +// +// Carries all known buckets so subscribers can surface the full picture +// without follow-up queries. +type RateLimitUpdatedEvent struct { + Snapshots []RateSnapshot `json:"snapshots"` + // Trigger names the bucket whose update produced this event; useful for + // downstream filtering/debug. + Trigger Resource `json:"trigger"` + // ExhaustionTransition is non-empty on the tick where the trigger bucket + // flipped between exhausted and recovered ("exhausted" or "recovered"). + ExhaustionTransition string `json:"exhaustion_transition,omitempty"` +} + +// RateTracker accumulates rate-limit snapshots from the GitHub clients and +// publishes events on change. +type RateTracker struct { + mu sync.RWMutex + snapshots map[Resource]RateSnapshot + exhausted map[Resource]bool + lastEmitted map[Resource]time.Time + bus bus.EventBus + log *logger.Logger +} + +// NewRateTracker constructs a tracker. The event bus may be nil (tests). +func NewRateTracker(eventBus bus.EventBus, log *logger.Logger) *RateTracker { + return &RateTracker{ + snapshots: make(map[Resource]RateSnapshot), + exhausted: make(map[Resource]bool), + lastEmitted: make(map[Resource]time.Time), + bus: eventBus, + log: log, + } +} + +// Record stores a snapshot. Always emits an event on exhaustion transition; +// otherwise debounces to at most one update per resource per +// rateUpdateDebounce window. +func (r *RateTracker) Record(snap RateSnapshot) { + if snap.Resource == "" { + return + } + if snap.UpdatedAt.IsZero() { + snap.UpdatedAt = time.Now().UTC() + } + + r.mu.Lock() + prev := r.exhausted[snap.Resource] + now := snap.Exhausted() + r.snapshots[snap.Resource] = snap + r.exhausted[snap.Resource] = now + + transition := "" + switch { + case !prev && now: + transition = "exhausted" + case prev && !now: + transition = "recovered" + } + + shouldEmit := transition != "" + if !shouldEmit { + last := r.lastEmitted[snap.Resource] + if snap.UpdatedAt.Sub(last) >= rateUpdateDebounce { + shouldEmit = true + } + } + if shouldEmit { + r.lastEmitted[snap.Resource] = snap.UpdatedAt + } + allSnap := r.allLocked() + r.mu.Unlock() + + if !shouldEmit { + return + } + r.publish(allSnap, snap.Resource, transition) +} + +// Snapshot returns a copy of the current snapshot for resource. +func (r *RateTracker) Snapshot(resource Resource) (RateSnapshot, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + snap, ok := r.snapshots[resource] + return snap, ok +} + +// All returns a copy of every known snapshot. +func (r *RateTracker) All() map[Resource]RateSnapshot { + r.mu.RLock() + defer r.mu.RUnlock() + return r.allLocked() +} + +func (r *RateTracker) allLocked() map[Resource]RateSnapshot { + out := make(map[Resource]RateSnapshot, len(r.snapshots)) + for k, v := range r.snapshots { + out[k] = v + } + return out +} + +// IsExhausted reports whether the resource bucket is currently exhausted. +func (r *RateTracker) IsExhausted(resource Resource) bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.exhausted[resource] +} + +// WaitDuration returns how long callers should wait before retrying the +// resource, or 0 if the bucket is not exhausted. +func (r *RateTracker) WaitDuration(resource Resource) time.Duration { + r.mu.RLock() + defer r.mu.RUnlock() + if !r.exhausted[resource] { + return 0 + } + d := time.Until(r.snapshots[resource].ResetAt) + if d < 0 { + return 0 + } + return d +} + +func (r *RateTracker) publish(all map[Resource]RateSnapshot, trigger Resource, transition string) { + if r.bus == nil { + return + } + snaps := make([]RateSnapshot, 0, len(all)) + for _, s := range all { + snaps = append(snaps, s) + } + evt := &RateLimitUpdatedEvent{ + Snapshots: snaps, + Trigger: trigger, + ExhaustionTransition: transition, + } + event := bus.NewEvent(events.GitHubRateLimitUpdated, "github", evt) + if err := r.bus.Publish(context.Background(), events.GitHubRateLimitUpdated, event); err != nil && r.log != nil { + r.log.Debug("publish rate-limit event failed", zap.Error(err)) + } +} + +// parseRateHeaders extracts a snapshot from a GitHub HTTP response. Returns +// false when the response carries no rate-limit headers. +func parseRateHeaders(resp *http.Response, defaultResource Resource) (RateSnapshot, bool) { + if resp == nil { + return RateSnapshot{}, false + } + limitStr := resp.Header.Get("X-RateLimit-Limit") + remainingStr := resp.Header.Get("X-RateLimit-Remaining") + resetStr := resp.Header.Get("X-RateLimit-Reset") + if limitStr == "" && remainingStr == "" && resetStr == "" { + return RateSnapshot{}, false + } + limit, _ := strconv.Atoi(limitStr) + remaining, _ := strconv.Atoi(remainingStr) + reset, _ := strconv.ParseInt(resetStr, 10, 64) + + resource := defaultResource + if r := resp.Header.Get("X-RateLimit-Resource"); r != "" { + resource = Resource(strings.ToLower(r)) + } + return RateSnapshot{ + Resource: resource, + Limit: limit, + Remaining: remaining, + ResetAt: time.Unix(reset, 0).UTC(), + UpdatedAt: time.Now().UTC(), + }, true +} + +// markRateExhausted records an exhaustion snapshot for callers that detect +// rate-limit conditions out-of-band (e.g. parsing `gh` stderr or 4xx bodies +// without headers). Uses a conservative one-hour reset when no better value +// is known. +func (r *RateTracker) markRateExhausted(resource Resource, resetAt time.Time) { + if resource == "" { + return + } + if resetAt.IsZero() { + resetAt = time.Now().Add(time.Hour).UTC() + } + prev, _ := r.Snapshot(resource) + r.Record(RateSnapshot{ + Resource: resource, + Limit: prev.Limit, + Remaining: 0, + ResetAt: resetAt, + UpdatedAt: time.Now().UTC(), + }) +} diff --git a/apps/backend/internal/github/ratelimit_test.go b/apps/backend/internal/github/ratelimit_test.go new file mode 100644 index 0000000000..a189d73cb2 --- /dev/null +++ b/apps/backend/internal/github/ratelimit_test.go @@ -0,0 +1,230 @@ +package github + +import ( + "context" + "net/http" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +func newTestTrackerLogger(t *testing.T) *logger.Logger { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) + if err != nil { + t.Fatalf("logger: %v", err) + } + return log +} + +// captureBus collects events published to a single subject so tests can +// assert on transitions without racing. +type captureBus struct { + mu sync.Mutex + events []*bus.Event + inner bus.EventBus +} + +func newCaptureBus(t *testing.T, subject string) *captureBus { + t.Helper() + log := newTestTrackerLogger(t) + cb := &captureBus{inner: bus.NewMemoryEventBus(log)} + if _, err := cb.inner.Subscribe(subject, func(_ context.Context, e *bus.Event) error { + cb.mu.Lock() + cb.events = append(cb.events, e) + cb.mu.Unlock() + return nil + }); err != nil { + t.Fatalf("subscribe: %v", err) + } + return cb +} + +func (c *captureBus) Publish(ctx context.Context, subject string, event *bus.Event) error { + return c.inner.Publish(ctx, subject, event) +} + +func (c *captureBus) Subscribe(subject string, handler bus.EventHandler) (bus.Subscription, error) { + return c.inner.Subscribe(subject, handler) +} + +func (c *captureBus) QueueSubscribe(subject, queue string, handler bus.EventHandler) (bus.Subscription, error) { + return c.inner.QueueSubscribe(subject, queue, handler) +} + +func (c *captureBus) Request(ctx context.Context, subject string, event *bus.Event, timeout time.Duration) (*bus.Event, error) { + return c.inner.Request(ctx, subject, event, timeout) +} + +func (c *captureBus) Close() { c.inner.Close() } +func (c *captureBus) IsConnected() bool { return c.inner.IsConnected() } + +// captured returns a snapshot of events captured so far. MemoryEventBus +// dispatches to subscribers synchronously, so callers can read this directly +// after Record() / Publish() returns without polling. +func (c *captureBus) captured() []*bus.Event { + c.mu.Lock() + defer c.mu.Unlock() + return append([]*bus.Event(nil), c.events...) +} + +func (c *captureBus) requireCount(t *testing.T, count int, msg string) []*bus.Event { + t.Helper() + evs := c.captured() + if len(evs) != count { + t.Fatalf("%s: expected %d events, got %d", msg, count, len(evs)) + } + return evs +} + +func TestParseRateHeaders_DecodesAllFields(t *testing.T) { + resp := &http.Response{Header: http.Header{}} + resp.Header.Set("X-RateLimit-Limit", "5000") + resp.Header.Set("X-RateLimit-Remaining", "4242") + resp.Header.Set("X-RateLimit-Reset", "2000000000") + resp.Header.Set("X-RateLimit-Resource", "graphql") + + snap, ok := parseRateHeaders(resp, ResourceCore) + if !ok { + t.Fatalf("expected snapshot to be parsed") + } + if snap.Resource != ResourceGraphQL { + t.Errorf("resource: got %q want graphql", snap.Resource) + } + if snap.Limit != 5000 || snap.Remaining != 4242 { + t.Errorf("limit/remaining mismatch: %d / %d", snap.Limit, snap.Remaining) + } + if snap.ResetAt.Unix() != 2000000000 { + t.Errorf("reset_at: got %d want 2000000000", snap.ResetAt.Unix()) + } +} + +func TestParseRateHeaders_FallsBackToDefaultResource(t *testing.T) { + resp := &http.Response{Header: http.Header{}} + resp.Header.Set("X-RateLimit-Limit", "30") + resp.Header.Set("X-RateLimit-Remaining", "29") + resp.Header.Set("X-RateLimit-Reset", "2000000000") + + snap, ok := parseRateHeaders(resp, ResourceSearch) + if !ok || snap.Resource != ResourceSearch { + t.Fatalf("expected default resource search, got %q (ok=%v)", snap.Resource, ok) + } +} + +func TestParseRateHeaders_NoHeadersReturnsFalse(t *testing.T) { + resp := &http.Response{Header: http.Header{}} + if _, ok := parseRateHeaders(resp, ResourceCore); ok { + t.Fatalf("expected no snapshot when headers absent") + } +} + +func TestRateTracker_RecordEmitsAndTracksExhaustion(t *testing.T) { + log := newTestTrackerLogger(t) + cb := newCaptureBus(t, events.GitHubRateLimitUpdated) + tr := NewRateTracker(cb, log) + + future := time.Now().Add(20 * time.Minute) + + // First snapshot: healthy. Should publish once (initial update). + tr.Record(RateSnapshot{ + Resource: ResourceGraphQL, + Limit: 5000, + Remaining: 4000, + ResetAt: future, + UpdatedAt: time.Now(), + }) + if tr.IsExhausted(ResourceGraphQL) { + t.Fatalf("not exhausted yet") + } + + // Second snapshot: exhausted -> must publish (transition). + tr.Record(RateSnapshot{ + Resource: ResourceGraphQL, + Limit: 5000, + Remaining: 0, + ResetAt: future, + UpdatedAt: time.Now(), + }) + if !tr.IsExhausted(ResourceGraphQL) { + t.Fatalf("expected exhausted after Remaining=0 + future reset") + } + if d := tr.WaitDuration(ResourceGraphQL); d <= 0 || d > 21*time.Minute { + t.Fatalf("WaitDuration out of range: %v", d) + } + + // Third snapshot: recovered -> publishes recovery transition. + tr.Record(RateSnapshot{ + Resource: ResourceGraphQL, + Limit: 5000, + Remaining: 1500, + ResetAt: future, + UpdatedAt: time.Now(), + }) + + evts := cb.requireCount(t, 3, "expected 3 events (initial, exhausted, recovered)") + // Last two events should carry transition strings. + gotTransitions := []string{} + for _, e := range evts { + payload, ok := e.Data.(*RateLimitUpdatedEvent) + if !ok { + t.Fatalf("unexpected event payload type %T", e.Data) + } + gotTransitions = append(gotTransitions, payload.ExhaustionTransition) + } + if gotTransitions[1] != "exhausted" || gotTransitions[2] != "recovered" { + t.Errorf("transitions = %v, want [* exhausted recovered]", gotTransitions) + } +} + +func TestRateTracker_DebouncesNonTransitionUpdates(t *testing.T) { + log := newTestTrackerLogger(t) + cb := newCaptureBus(t, events.GitHubRateLimitUpdated) + tr := NewRateTracker(cb, log) + now := time.Now() + + // Two healthy snapshots within the debounce window -> only first emits. + // MemoryEventBus.Publish dispatches synchronously, so reads after Record + // see the final state without sleeping. + tr.Record(RateSnapshot{Resource: ResourceCore, Limit: 5000, Remaining: 4999, ResetAt: now.Add(time.Hour), UpdatedAt: now}) + tr.Record(RateSnapshot{Resource: ResourceCore, Limit: 5000, Remaining: 4998, ResetAt: now.Add(time.Hour), UpdatedAt: now.Add(time.Second)}) + cb.requireCount(t, 1, "expected 1 debounced event") + + // Outside the window -> emits again. + tr.Record(RateSnapshot{Resource: ResourceCore, Limit: 5000, Remaining: 4997, ResetAt: now.Add(time.Hour), UpdatedAt: now.Add(2 * rateUpdateDebounce)}) + cb.requireCount(t, 2, "expected second event after debounce window") +} + +func TestRateTracker_MarkRateExhaustedSeedsUnknownLimit(t *testing.T) { + log := newTestTrackerLogger(t) + cb := newCaptureBus(t, events.GitHubRateLimitUpdated) + tr := NewRateTracker(cb, log) + + tr.markRateExhausted(ResourceGraphQL, time.Time{}) + + if !tr.IsExhausted(ResourceGraphQL) { + t.Fatalf("expected exhausted after markRateExhausted") + } + snap, ok := tr.Snapshot(ResourceGraphQL) + if !ok { + t.Fatalf("snapshot not stored") + } + if snap.Remaining != 0 { + t.Errorf("Remaining = %d, want 0", snap.Remaining) + } + // Default reset (~1h) means WaitDuration > 0. + if d := tr.WaitDuration(ResourceGraphQL); d <= 0 { + t.Errorf("WaitDuration should be positive, got %v", d) + } +} + +func TestRateTracker_NilBusIsNoop(t *testing.T) { + tr := NewRateTracker(nil, nil) + tr.Record(RateSnapshot{Resource: ResourceCore, Limit: 5000, Remaining: 0, ResetAt: time.Now().Add(time.Minute)}) + if !tr.IsExhausted(ResourceCore) { + t.Fatalf("tracker should still record when bus is nil") + } +} diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index 5130d9b9d4..47e6a0b041 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -71,6 +71,7 @@ type Service struct { taskEventSubs []bus.Subscription searchCache *ttlCache prStatusCache *ttlCache + rateTracker *RateTracker } // NewService creates a new GitHub service. @@ -84,9 +85,80 @@ func NewService(client Client, authMethod string, secrets SecretProvider, store logger: log, searchCache: newTTLCache(), prStatusCache: newTTLCache(), + rateTracker: NewRateTracker(eventBus, log), } } +// RateTracker exposes the service's rate-limit tracker so factory callers +// can wire it into individual clients. +func (s *Service) RateTracker() *RateTracker { + return s.rateTracker +} + +// newPATClient builds a PATClient pre-wired with the service's shared rate +// tracker. Centralizing this guards against forgetting the wiring on auth +// flips (e.g. ConfigureToken), which would otherwise leave PAT calls +// invisible to the rate-limit UI, health checks, and poller throttling. +func (s *Service) newPATClient(token string) *PATClient { + c := NewPATClient(token) + attachRateTracker(c, s.rateTracker, s.logger) + return c +} + +// ExhaustedRateLimit is a tiny DTO returned to the health package — the +// health package can't import github (cycle), so it consumes a structural +// shape via interface assertion. +type ExhaustedRateLimit struct { + Resource string + ResetAt time.Time +} + +// ExhaustedRateLimits lists every resource bucket currently out of quota. +// Returns an empty slice when none are exhausted, so callers can safely +// `len()` the result. +func (s *Service) ExhaustedRateLimits() []ExhaustedRateLimit { + if s.rateTracker == nil { + return nil + } + out := []ExhaustedRateLimit{} + for resource, snap := range s.rateTracker.All() { + if snap.Exhausted() { + out = append(out, ExhaustedRateLimit{ + Resource: string(resource), + ResetAt: snap.ResetAt, + }) + } + } + return out +} + +// rateLimitInfo materializes the tracker's snapshots into the DTO shape +// returned by GetStatus and the rate-limit WS notification. Returns nil +// when no buckets are known yet so the field stays omitted. +func (s *Service) rateLimitInfo() *GitHubRateLimitInfo { + if s.rateTracker == nil { + return nil + } + all := s.rateTracker.All() + if len(all) == 0 { + return nil + } + info := &GitHubRateLimitInfo{} + if snap, ok := all[ResourceCore]; ok { + v := snap + info.Core = &v + } + if snap, ok := all[ResourceGraphQL]; ok { + v := snap + info.GraphQL = &v + } + if snap, ok := all[ResourceSearch]; ok { + v := snap + info.Search = &v + } + return info +} + // SetTaskDeleter sets the task deletion dependency for cleanup operations. func (s *Service) SetTaskDeleter(d TaskDeleter) { s.taskDeleter = d } @@ -143,6 +215,7 @@ func (s *Service) GetStatus(ctx context.Context) (*GitHubStatus, error) { status := &GitHubStatus{ AuthMethod: authMethod, RequiredScopes: RequiredGitHubScopes, + RateLimit: s.rateLimitInfo(), } // Check if a GITHUB_TOKEN secret is configured @@ -204,6 +277,7 @@ func (s *Service) retryClientCreation(ctx context.Context) { s.logger.Debug("GitHub client retry failed", zap.Error(err)) return } + attachRateTracker(client, s.rateTracker, s.logger) s.client = client s.authMethod = authMethod s.logger.Info("GitHub client recovered after retry", @@ -229,8 +303,10 @@ func (s *Service) ConfigureToken(ctx context.Context, token string) error { return fmt.Errorf("secret manager not configured") } - // Validate the token by testing authentication - testClient := NewPATClient(token) + // Validate the token by testing authentication. Wire the rate tracker + // onto the test client up front so the validation request seeds the + // shared quota and subsequent PAT-backed calls keep feeding it. + testClient := s.newPATClient(token) user, err := testClient.GetAuthenticatedUser(ctx) if err != nil { return fmt.Errorf("invalid token: %w", err) diff --git a/apps/backend/internal/github/service_token_test.go b/apps/backend/internal/github/service_token_test.go index 4d45d6dbf6..8f65214980 100644 --- a/apps/backend/internal/github/service_token_test.go +++ b/apps/backend/internal/github/service_token_test.go @@ -262,3 +262,23 @@ func TestClearToken_NoSecretManager(t *testing.T) { t.Error("expected error when secretManager is nil") } } + +// Regression: PAT clients minted via the service must share the rate tracker. +// ConfigureToken previously created a bare NewPATClient and dropped quota +// signals on the floor, so the UI / health / poller throttling all went stale +// after a token reconfigure. +func TestService_NewPATClient_AttachesRateTracker(t *testing.T) { + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) + svc := &Service{ + logger: log, + rateTracker: NewRateTracker(nil, nil), + } + + c := svc.newPATClient("ghp_x") + if c == nil { + t.Fatalf("newPATClient returned nil") + } + if c.rateTracker != svc.rateTracker { + t.Fatalf("expected rate tracker to be wired onto the new PAT client") + } +} diff --git a/apps/backend/internal/health/checks.go b/apps/backend/internal/health/checks.go index 97c34cf4e5..169fd6ca36 100644 --- a/apps/backend/internal/health/checks.go +++ b/apps/backend/internal/health/checks.go @@ -3,6 +3,7 @@ package health import ( "context" "errors" + "fmt" "time" ) @@ -12,9 +13,24 @@ type GitHubStatusProvider interface { AuthMethod() string } -// GitHubChecker checks GitHub CLI/auth availability. +// GitHubRateLimitProvider exposes per-resource exhaustion state so the health +// dialog can surface "GitHub API rate limit exhausted" alongside other setup +// issues. Implemented by *github.Service. +type GitHubRateLimitProvider interface { + ExhaustedRateLimits() []GitHubRateLimitStatus +} + +// GitHubRateLimitStatus is a minimal DTO so the health package does not have +// to import the github package (which would create a cycle). +type GitHubRateLimitStatus struct { + Resource string + ResetAt time.Time +} + +// GitHubChecker checks GitHub CLI/auth availability and rate-limit health. type GitHubChecker struct { - provider GitHubStatusProvider + provider GitHubStatusProvider + rateProvider GitHubRateLimitProvider } // NewGitHubChecker creates a checker for GitHub integration status. @@ -23,6 +39,13 @@ func NewGitHubChecker(provider GitHubStatusProvider) *GitHubChecker { return &GitHubChecker{provider: provider} } +// WithRateLimitProvider attaches a provider so the checker also surfaces +// rate-limit exhaustion. Optional — when nil the rate-limit issue is omitted. +func (c *GitHubChecker) WithRateLimitProvider(p GitHubRateLimitProvider) *GitHubChecker { + c.rateProvider = p + return c +} + func (c *GitHubChecker) Check(_ context.Context) []Issue { if c.provider == nil { return []Issue{{ @@ -46,7 +69,44 @@ func (c *GitHubChecker) Check(_ context.Context) []Issue { FixLabel: "Configure GitHub", }} } - return nil + return c.rateLimitIssues() +} + +// rateLimitIssues materializes one Issue per exhausted resource bucket. +// Returns nil when the rate-limit provider is missing or every bucket has +// quota — the issue auto-clears as soon as that happens. +func (c *GitHubChecker) rateLimitIssues() []Issue { + if c.rateProvider == nil { + return nil + } + exhausted := c.rateProvider.ExhaustedRateLimits() + if len(exhausted) == 0 { + return nil + } + issues := make([]Issue, 0, len(exhausted)) + for _, status := range exhausted { + issues = append(issues, Issue{ + ID: fmt.Sprintf("github_rate_limit_%s", status.Resource), + Category: "github", + Title: fmt.Sprintf("GitHub API rate limit exhausted (%s)", status.Resource), + Message: rateLimitMessage(status), + Severity: SeverityWarning, + FixURL: "/settings/workspace/{workspaceId}/github", + FixLabel: "View status", + }) + } + return issues +} + +func rateLimitMessage(status GitHubRateLimitStatus) string { + if status.ResetAt.IsZero() { + return "PR/issue checks are paused until the limit resets." + } + wait := time.Until(status.ResetAt).Round(time.Minute) + if wait <= 0 { + return "PR/issue checks are paused until the limit resets." + } + return fmt.Sprintf("PR/issue checks are paused; resets in %s.", wait) } // AgentDiscoveryProvider abstracts the agent discovery check. diff --git a/apps/backend/internal/health/checks_test.go b/apps/backend/internal/health/checks_test.go index e0cee4494b..31f07dd2c5 100644 --- a/apps/backend/internal/health/checks_test.go +++ b/apps/backend/internal/health/checks_test.go @@ -3,7 +3,9 @@ package health import ( "context" "fmt" + "strings" "testing" + "time" ) // --- GitHubChecker tests --- @@ -55,6 +57,41 @@ func TestGitHubChecker_Authenticated(t *testing.T) { } } +type stubRateLimitProvider struct { + exhausted []GitHubRateLimitStatus +} + +func (s *stubRateLimitProvider) ExhaustedRateLimits() []GitHubRateLimitStatus { + return s.exhausted +} + +func TestGitHubChecker_AuthenticatedButRateLimited(t *testing.T) { + rate := &stubRateLimitProvider{exhausted: []GitHubRateLimitStatus{ + {Resource: "graphql", ResetAt: time.Now().Add(15 * time.Minute)}, + }} + checker := NewGitHubChecker(&mockGitHubProvider{authenticated: true, authMethod: "gh_cli"}) + checker.WithRateLimitProvider(rate) + issues := checker.Check(context.Background()) + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(issues)) + } + if issues[0].ID != "github_rate_limit_graphql" { + t.Errorf("ID = %q, want github_rate_limit_graphql", issues[0].ID) + } + if !strings.Contains(issues[0].Message, "resets in") { + t.Errorf("message should include reset countdown, got %q", issues[0].Message) + } +} + +func TestGitHubChecker_AuthenticatedRateProviderEmpty(t *testing.T) { + rate := &stubRateLimitProvider{} + checker := NewGitHubChecker(&mockGitHubProvider{authenticated: true}) + checker.WithRateLimitProvider(rate) + if got := checker.Check(context.Background()); len(got) != 0 { + t.Errorf("expected 0 issues when rate provider empty, got %d", len(got)) + } +} + // --- AgentChecker tests --- type mockAgentProvider struct { diff --git a/apps/backend/internal/improvekandev/handler.go b/apps/backend/internal/improvekandev/handler.go index 00d390aabd..f8eb087eda 100644 --- a/apps/backend/internal/improvekandev/handler.go +++ b/apps/backend/internal/improvekandev/handler.go @@ -248,6 +248,17 @@ func (h *Handler) ensureWorkflow(ctx context.Context, workspaceID string) (*task } for _, w := range existing { if w.WorkflowTemplateID != nil && *w.WorkflowTemplateID == templateID { + // Heal records created before the workflow honored Hidden on insert. + // Best-effort: a DB failure here must not block the caller from getting + // their workflow ID, since the workflow itself is already usable. + if !w.Hidden { + if err := h.taskSvc.SetWorkflowHidden(ctx, w.ID, true); err != nil { + h.log.Warn("improve-kandev: failed to heal hidden flag on stale record", + zap.String("workflow_id", w.ID), zap.Error(err)) + } else { + w.Hidden = true + } + } return w, nil } } diff --git a/apps/backend/internal/integration/cancel_after_crash_test.go b/apps/backend/internal/integration/cancel_after_crash_test.go index 1e8fb283f5..4c2ad4a73e 100644 --- a/apps/backend/internal/integration/cancel_after_crash_test.go +++ b/apps/backend/internal/integration/cancel_after_crash_test.go @@ -31,7 +31,7 @@ func TestOrchestratorCancelAfterAgentCrash_UnsticksSession(t *testing.T) { defer client.Close() // Start the task so a session is created and an execution is registered in the simulator. - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "augment-agent", }) @@ -110,7 +110,7 @@ func TestOrchestratorCancelWhenAgentHangs_UnsticksSession(t *testing.T) { client := NewOrchestratorWSClient(t, ts.Server.URL) defer client.Close() - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "augment-agent", }) diff --git a/apps/backend/internal/integration/orchestrator_advanced_test.go b/apps/backend/internal/integration/orchestrator_advanced_test.go index 989a874dc6..76b3c25fc3 100644 --- a/apps/backend/internal/integration/orchestrator_advanced_test.go +++ b/apps/backend/internal/integration/orchestrator_advanced_test.go @@ -30,21 +30,21 @@ func TestOrchestratorTaskPriority(t *testing.T) { defer client.Close() // Start low priority first - _, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + _, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": lowPriorityTask, "agent_profile_id": "augment-agent", }) require.NoError(t, err) // Then high priority - _, err = client.SendRequest("start-2", ws.ActionOrchestratorStart, map[string]interface{}{ + _, err = client.SendRequest("start-2", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": highPriorityTask, "agent_profile_id": "augment-agent", }) require.NoError(t, err) // Then medium priority - _, err = client.SendRequest("start-3", ws.ActionOrchestratorStart, map[string]interface{}{ + _, err = client.SendRequest("start-3", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": medPriorityTask, "agent_profile_id": "augment-agent", }) @@ -54,137 +54,6 @@ func TestOrchestratorTaskPriority(t *testing.T) { assert.Equal(t, 3, ts.AgentManager.GetLaunchCount()) } -func TestOrchestratorTriggerTask(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - resp, err := client.SendRequest("trigger-1", ws.ActionOrchestratorTrigger, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeResponse, resp.Type) - - var payload map[string]interface{} - require.NoError(t, resp.ParsePayload(&payload)) - assert.True(t, payload["success"].(bool)) - assert.Equal(t, taskID, payload["task_id"]) -} - -func TestOrchestratorEndToEndWorkflow(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - workspaceID := createOrchestratorWorkspace(t, client) - - // 1. Create workflow with workflow template - workflowResp, err := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "E2E Test Workflow", - "description": "End-to-end test workflow", - "workflow_template_id": "simple", - }) - require.NoError(t, err) - - var workflowPayload map[string]interface{} - require.NoError(t, workflowResp.ParsePayload(&workflowPayload)) - workflowID := workflowPayload["id"].(string) - - // 2. Get first workflow step from workflow - stepResp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "workflow_id": workflowID, - }) - require.NoError(t, err) - - var stepListPayload map[string]interface{} - require.NoError(t, stepResp.ParsePayload(&stepListPayload)) - steps := stepListPayload["steps"].([]interface{}) - require.NotEmpty(t, steps, "workflow should have workflow steps") - workflowStepID := steps[0].(map[string]interface{})["id"].(string) - - // 3. Create task with workflow step - repoResp, err := client.SendRequest("repo-1", ws.ActionRepositoryCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "Test Repo", - "source_type": "local", - "local_path": createTempRepoDir(t), - }) - require.NoError(t, err) - - var repoPayload map[string]interface{} - require.NoError(t, repoResp.ParsePayload(&repoPayload)) - repositoryID := repoPayload["id"].(string) - - taskResp, err := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "workflow_id": workflowID, - "workflow_step_id": workflowStepID, - "title": "Implement feature X", - "description": "Create a new feature with tests", - "priority": 2, - "repository_id": repositoryID, - "base_branch": "main", - }) - require.NoError(t, err) - - var taskPayload map[string]interface{} - require.NoError(t, taskResp.ParsePayload(&taskPayload)) - taskID := taskPayload["id"].(string) - - // 4. Subscribe to task - _, err = client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) - require.NoError(t, err) - - // 5. Start orchestrator - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "test-profile-id", - }) - require.NoError(t, err) - - var startPayload map[string]interface{} - require.NoError(t, startResp.ParsePayload(&startPayload)) - assert.True(t, startPayload["success"].(bool)) - agentExecutionID := startPayload["agent_execution_id"].(string) - assert.NotEmpty(t, agentExecutionID) - sessionID, _ := startPayload["session_id"].(string) - require.NotEmpty(t, sessionID) - - _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) - require.NoError(t, err) - - // 6. Collect notifications during execution - time.Sleep(400 * time.Millisecond) - notifications := client.CollectNotifications(200 * time.Millisecond) - t.Logf("Received %d notifications", len(notifications)) - - // 7. Complete the task - completeResp, err := client.SendRequest("complete-1", ws.ActionOrchestratorComplete, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - - var completePayload map[string]interface{} - require.NoError(t, completeResp.ParsePayload(&completePayload)) - assert.True(t, completePayload["success"].(bool)) - - // 8. Verify final task state - getResp, err := client.SendRequest("get-1", ws.ActionTaskGet, map[string]string{"id": taskID}) - require.NoError(t, err) - - var getPayload map[string]interface{} - require.NoError(t, getResp.ParsePayload(&getPayload)) - assert.Equal(t, "COMPLETED", getPayload["state"].(string)) -} - // TestOrchestratorAgentMessagePersistence validates that agent messages are stored // correctly in the database without missing data or ordering issues. func TestOrchestratorAgentMessagePersistence(t *testing.T) { @@ -223,7 +92,7 @@ func TestOrchestratorAgentMessagePersistence(t *testing.T) { require.NoError(t, err) // Start task execution - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "test-profile-id", }) @@ -462,7 +331,7 @@ func TestOrchestratorAgentMessagePersistenceWithToolCalls(t *testing.T) { require.NoError(t, err) // Start task execution - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "test-profile-id", }) @@ -630,7 +499,7 @@ func TestOrchestratorAgentMessageChunkStreaming(t *testing.T) { _, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) require.NoError(t, err) - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "test-profile-id", }) diff --git a/apps/backend/internal/integration/orchestrator_basic_test.go b/apps/backend/internal/integration/orchestrator_basic_test.go index 10720e2597..4c96e9f5a5 100644 --- a/apps/backend/internal/integration/orchestrator_basic_test.go +++ b/apps/backend/internal/integration/orchestrator_basic_test.go @@ -69,7 +69,7 @@ func TestOrchestratorStartTask(t *testing.T) { assert.Equal(t, ws.MessageTypeResponse, subResp.Type) // Start task execution - resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + resp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "test-profile-id", }) @@ -111,7 +111,7 @@ func TestOrchestratorStartTaskWithAgentTypeOverride(t *testing.T) { defer client.Close() // Start with different agent profile - resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + resp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "override-profile-id", "priority": 3, @@ -134,7 +134,7 @@ func TestOrchestratorStopTask(t *testing.T) { defer client.Close() // Start task - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "augment-agent", }) diff --git a/apps/backend/internal/integration/orchestrator_interaction_test.go b/apps/backend/internal/integration/orchestrator_interaction_test.go index b95157d2c0..bd8686d7da 100644 --- a/apps/backend/internal/integration/orchestrator_interaction_test.go +++ b/apps/backend/internal/integration/orchestrator_interaction_test.go @@ -2,7 +2,6 @@ package integration import ( - "context" "fmt" "sync" "testing" @@ -11,130 +10,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/pkg/acp/protocol" - v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" ) -func TestOrchestratorPromptTask(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Subscribe to task - _, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) - require.NoError(t, err) - - // Start task - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "augment-agent", - }) - require.NoError(t, err) - - var startPayload map[string]interface{} - require.NoError(t, startResp.ParsePayload(&startPayload)) - require.True(t, startPayload["success"].(bool)) - sessionID, _ := startPayload["session_id"].(string) - require.NotEmpty(t, sessionID) - - _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) - require.NoError(t, err) - - // Test 1: Verify that prompting while agent is not yet ready is rejected. - // The simulated agent takes ~310ms (50ms launch + 60ms ACP + 200ms execution) before - // publishing AgentReady, so at 100ms the session is still in CREATED state. - time.Sleep(100 * time.Millisecond) - - // Attempt to send prompt while agent is still initializing — should be rejected - promptResp1, err := client.SendRequest("prompt-rejected", ws.ActionOrchestratorPrompt, map[string]interface{}{ - "task_id": taskID, - "session_id": sessionID, - "prompt": "This should be rejected", - }) - require.NoError(t, err) - - // Should receive an error response (type=error with ErrorPayload) - assert.Equal(t, ws.MessageTypeError, promptResp1.Type, "Expected error response when session is not ready") - var errPayload ws.ErrorPayload - require.NoError(t, promptResp1.ParsePayload(&errPayload)) - t.Logf("Prompt rejection: code=%s message=%s", errPayload.Code, errPayload.Message) - assert.NotEmpty(t, errPayload.Message) - - // Test 2: Wait for agent to complete, then send a valid prompt - // Wait for agent to reach COMPLETED or WAITING_FOR_INPUT state - waitForSessionReady := func(timeout time.Duration) bool { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - session, err := ts.TaskRepo.GetTaskSession(context.Background(), sessionID) - if err == nil && (session.State == models.TaskSessionStateWaitingForInput || session.State == models.TaskSessionStateCompleted) { - return true - } - time.Sleep(100 * time.Millisecond) - } - return false - } - - // Wait up to 5 seconds for session to be ready - if waitForSessionReady(5 * time.Second) { - // Now send a valid prompt - promptResp2, err := client.SendRequest("prompt-valid", ws.ActionOrchestratorPrompt, map[string]interface{}{ - "task_id": taskID, - "session_id": sessionID, - "prompt": "Please continue and add error handling", - }) - require.NoError(t, err) - - var promptPayload map[string]interface{} - require.NoError(t, promptResp2.ParsePayload(&promptPayload)) - assert.True(t, promptPayload["success"].(bool), "Prompt should succeed when agent is ready") - } -} - -func TestOrchestratorCompleteTask(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Start task - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "augment-agent", - }) - require.NoError(t, err) - - var startPayload map[string]interface{} - require.NoError(t, startResp.ParsePayload(&startPayload)) - require.True(t, startPayload["success"].(bool)) - - // Wait for agent to be running - time.Sleep(200 * time.Millisecond) - - // Complete task - completeResp, err := client.SendRequest("complete-1", ws.ActionOrchestratorComplete, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - - var completePayload map[string]interface{} - require.NoError(t, completeResp.ParsePayload(&completePayload)) - assert.True(t, completePayload["success"].(bool)) - - // Verify task state is COMPLETED - task, err := ts.TaskRepo.GetTask(context.Background(), taskID) - require.NoError(t, err) - assert.Equal(t, v1.TaskStateCompleted, task.State) -} - func TestOrchestratorConcurrentTasks(t *testing.T) { ts := NewOrchestratorTestServer(t) defer ts.Close() @@ -157,7 +36,7 @@ func TestOrchestratorConcurrentTasks(t *testing.T) { go func(idx int, tid string) { defer wg.Done() - resp, err := client.SendRequest(fmt.Sprintf("start-%d", idx), ws.ActionOrchestratorStart, map[string]interface{}{ + resp, err := client.SendRequest(fmt.Sprintf("start-%d", idx), ws.ActionSessionLaunch, map[string]interface{}{ "task_id": tid, "agent_profile_id": "augment-agent", }) @@ -256,7 +135,7 @@ func TestOrchestratorACPStreaming(t *testing.T) { require.NoError(t, err) // Start task - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "augment-agent", }) @@ -302,7 +181,7 @@ func TestOrchestratorMultipleClients(t *testing.T) { require.NoError(t, err) // Client 1 starts the task - startResp, err := client1.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client1.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "augment-agent", }) @@ -339,7 +218,7 @@ func TestOrchestratorErrorHandling(t *testing.T) { client := NewOrchestratorWSClient(t, ts.Server.URL) defer client.Close() - resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{}) + resp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{}) require.NoError(t, err) assert.Equal(t, ws.MessageTypeError, resp.Type) @@ -357,7 +236,7 @@ func TestOrchestratorErrorHandling(t *testing.T) { client := NewOrchestratorWSClient(t, ts.Server.URL) defer client.Close() - resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + resp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": "non-existent-task", "agent_profile_id": "augment-agent", }) @@ -366,28 +245,6 @@ func TestOrchestratorErrorHandling(t *testing.T) { assert.Equal(t, ws.MessageTypeError, resp.Type) }) - t.Run("PromptTaskMissingPrompt", func(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - resp, err := client.SendRequest("prompt-1", ws.ActionOrchestratorPrompt, map[string]interface{}{ - "task_id": taskID, - "session_id": "session-1", - }) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeError, resp.Type) - - var errPayload ws.ErrorPayload - require.NoError(t, resp.ParsePayload(&errPayload)) - assert.Contains(t, errPayload.Message, "prompt") - }) - t.Run("StopTaskNotRunning", func(t *testing.T) { ts := NewOrchestratorTestServer(t) defer ts.Close() @@ -420,7 +277,7 @@ func TestOrchestratorAgentFailure(t *testing.T) { client := NewOrchestratorWSClient(t, ts.Server.URL) defer client.Close() - resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + resp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "augment-agent", }) @@ -431,5 +288,5 @@ func TestOrchestratorAgentFailure(t *testing.T) { var errPayload ws.ErrorPayload require.NoError(t, resp.ParsePayload(&errPayload)) - assert.Contains(t, errPayload.Message, "Failed to start task") + assert.Contains(t, errPayload.Message, "Failed to launch session") } diff --git a/apps/backend/internal/integration/test_server_test.go b/apps/backend/internal/integration/test_server_test.go index e61b1fa783..4b50966167 100644 --- a/apps/backend/internal/integration/test_server_test.go +++ b/apps/backend/internal/integration/test_server_test.go @@ -217,6 +217,10 @@ func (a *testTurnServiceAdapter) GetActiveTurn(ctx context.Context, sessionID st return a.svc.GetActiveTurn(ctx, sessionID) } +func (a *testTurnServiceAdapter) AbandonOpenTurns(ctx context.Context, sessionID string) error { + return a.svc.AbandonOpenTurns(ctx, sessionID) +} + // NewOrchestratorTestServer creates a test server with full orchestrator support func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { t.Helper() diff --git a/apps/backend/internal/linear/handlers.go b/apps/backend/internal/linear/handlers.go index b119b775e7..e0ace4bc85 100644 --- a/apps/backend/internal/linear/handlers.go +++ b/apps/backend/internal/linear/handlers.go @@ -39,6 +39,12 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.GET("/issues", c.httpSearchIssues) api.GET("/issues/:id", c.httpGetIssue) api.POST("/issues/:id/state", c.httpSetIssueState) + + api.GET("/watches/issue", c.httpListIssueWatches) + api.POST("/watches/issue", c.httpCreateIssueWatch) + api.PATCH("/watches/issue/:id", c.httpUpdateIssueWatch) + api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch) + api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch) } // --- HTTP handlers --- diff --git a/apps/backend/internal/linear/handlers_issue_watch.go b/apps/backend/internal/linear/handlers_issue_watch.go new file mode 100644 index 0000000000..2265cbe1a3 --- /dev/null +++ b/apps/backend/internal/linear/handlers_issue_watch.go @@ -0,0 +1,134 @@ +package linear + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" +) + +// httpListIssueWatches returns watches scoped to one workspace when +// `workspace_id` is supplied, or every watch across all workspaces when it is +// absent (used by the install-wide settings page). +func (c *Controller) httpListIssueWatches(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + var ( + watches []*IssueWatch + err error + ) + if workspaceID == "" { + watches, err = c.service.ListAllIssueWatches(ctx.Request.Context()) + } else { + watches, err = c.service.ListIssueWatches(ctx.Request.Context(), workspaceID) + } + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"watches": watches}) +} + +func (c *Controller) httpCreateIssueWatch(ctx *gin.Context) { + var req CreateIssueWatchRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + w, err := c.service.CreateIssueWatch(ctx.Request.Context(), &req) + if err != nil { + c.writeIssueWatchError(ctx, err) + return + } + ctx.JSON(http.StatusOK, w) +} + +func (c *Controller) httpUpdateIssueWatch(ctx *gin.Context) { + id := ctx.Param("id") + if !c.assertWatchInWorkspace(ctx, id) { + return + } + var req UpdateIssueWatchRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + w, err := c.service.UpdateIssueWatch(ctx.Request.Context(), id, &req) + if err != nil { + c.writeIssueWatchError(ctx, err) + return + } + ctx.JSON(http.StatusOK, w) +} + +func (c *Controller) httpDeleteIssueWatch(ctx *gin.Context) { + id := ctx.Param("id") + if !c.assertWatchInWorkspace(ctx, id) { + return + } + if err := c.service.DeleteIssueWatch(ctx.Request.Context(), id); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +// httpTriggerIssueWatch runs a single immediate poll, ignoring the per-watch +// interval gating. Returns the count of newly-discovered issues so the user +// gets feedback without waiting for the orchestrator to fan tasks out. +func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) { + id := ctx.Param("id") + w, err := c.service.GetIssueWatch(ctx.Request.Context(), id) + if err != nil { + c.writeIssueWatchError(ctx, err) + return + } + if !workspaceMatches(ctx, w.WorkspaceID) { + ctx.JSON(http.StatusNotFound, gin.H{"error": ErrIssueWatchNotFound.Error()}) + return + } + issues, err := c.service.CheckIssueWatch(ctx.Request.Context(), w) + if err != nil { + c.writeClientError(ctx, err) + return + } + for _, issue := range issues { + c.service.publishNewLinearIssueEvent(ctx.Request.Context(), w, issue) + } + ctx.JSON(http.StatusOK, gin.H{"newIssues": len(issues)}) +} + +// assertWatchInWorkspace guards mutation/trigger endpoints against IDOR: the +// caller must supply `?workspace_id=...` matching the watch's workspace. +// Writes the response and returns false on mismatch. +func (c *Controller) assertWatchInWorkspace(ctx *gin.Context, id string) bool { + w, err := c.service.GetIssueWatch(ctx.Request.Context(), id) + if err != nil { + c.writeIssueWatchError(ctx, err) + return false + } + if !workspaceMatches(ctx, w.WorkspaceID) { + ctx.JSON(http.StatusNotFound, gin.H{"error": ErrIssueWatchNotFound.Error()}) + return false + } + return true +} + +// workspaceMatches returns true when the request's `workspace_id` query +// matches the resource's stored workspace. Empty query is rejected so callers +// can't bypass the check by omitting the parameter. +func workspaceMatches(ctx *gin.Context, resourceWorkspace string) bool { + q := ctx.Query("workspace_id") + return q != "" && q == resourceWorkspace +} + +func (c *Controller) writeIssueWatchError(ctx *gin.Context, err error) { + if errors.Is(err, ErrIssueWatchNotFound) { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, ErrInvalidConfig) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.writeClientError(ctx, err) +} diff --git a/apps/backend/internal/linear/models.go b/apps/backend/internal/linear/models.go index 6e21cc53cc..40b68514e2 100644 --- a/apps/backend/internal/linear/models.go +++ b/apps/backend/internal/linear/models.go @@ -135,3 +135,84 @@ const SecretKey = "linear:singleton:token" func LegacySecretKeyForWorkspace(workspaceID string) string { return "linear:" + workspaceID + ":token" } + +// DefaultIssueWatchPollInterval is the polling cadence assigned to a watcher +// when the caller does not specify one. Five minutes balances freshness +// against Linear rate limits when many workspaces have watches configured. +const DefaultIssueWatchPollInterval = 300 + +// IssueWatch configures periodic Linear search-polling. The filter is a +// structured SearchFilter (Linear has no JQL equivalent) persisted as JSON; +// the poller deserialises it back to SearchFilter at the store boundary. +// +// As with Jira, Linear issues have no repository affinity — the target +// workflow step's defaults determine where the resulting task runs. +type IssueWatch struct { + ID string `json:"id" db:"id"` + WorkspaceID string `json:"workspaceId" db:"workspace_id"` + WorkflowID string `json:"workflowId" db:"workflow_id"` + WorkflowStepID string `json:"workflowStepId" db:"workflow_step_id"` + Filter SearchFilter `json:"filter"` + AgentProfileID string `json:"agentProfileId" db:"agent_profile_id"` + ExecutorProfileID string `json:"executorProfileId" db:"executor_profile_id"` + Prompt string `json:"prompt" db:"prompt"` + Enabled bool `json:"enabled" db:"enabled"` + PollIntervalSeconds int `json:"pollIntervalSeconds" db:"poll_interval_seconds"` + LastPolledAt *time.Time `json:"lastPolledAt,omitempty" db:"last_polled_at"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +// IssueWatchTask deduplicates task creation per (watch, issue) tuple. The +// UNIQUE constraint on (issue_watch_id, issue_identifier) prevents two +// concurrent pollers from racing to create duplicate tasks for the same +// issue. We key on Identifier (e.g. "ENG-123") rather than the GraphQL UUID +// because it's stable, what humans see, and present on every search result. +type IssueWatchTask struct { + ID string `json:"id" db:"id"` + IssueWatchID string `json:"issueWatchId" db:"issue_watch_id"` + IssueIdentifier string `json:"issueIdentifier" db:"issue_identifier"` + IssueURL string `json:"issueUrl" db:"issue_url"` + TaskID string `json:"taskId" db:"task_id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` +} + +// NewLinearIssueEvent is published on the bus whenever the poller observes an +// issue matching a watch that has no existing dedup row. The orchestrator +// consumes this to create (and optionally auto-start) a Kandev task. +type NewLinearIssueEvent struct { + IssueWatchID string `json:"issueWatchId"` + WorkspaceID string `json:"workspaceId"` + WorkflowID string `json:"workflowId"` + WorkflowStepID string `json:"workflowStepId"` + AgentProfileID string `json:"agentProfileId"` + ExecutorProfileID string `json:"executorProfileId"` + Prompt string `json:"prompt"` + Issue *LinearIssue `json:"issue"` +} + +// CreateIssueWatchRequest is the payload for POST /api/v1/linear/watches/issue. +type CreateIssueWatchRequest struct { + WorkspaceID string `json:"workspaceId"` + WorkflowID string `json:"workflowId"` + WorkflowStepID string `json:"workflowStepId"` + Filter SearchFilter `json:"filter"` + AgentProfileID string `json:"agentProfileId"` + ExecutorProfileID string `json:"executorProfileId"` + Prompt string `json:"prompt"` + PollIntervalSeconds int `json:"pollIntervalSeconds"` + Enabled *bool `json:"enabled,omitempty"` +} + +// UpdateIssueWatchRequest is the payload for PATCH /api/v1/linear/watches/issue/:id. +// All fields are pointers so the caller can omit ones it doesn't want to change. +type UpdateIssueWatchRequest struct { + WorkflowID *string `json:"workflowId,omitempty"` + WorkflowStepID *string `json:"workflowStepId,omitempty"` + Filter *SearchFilter `json:"filter,omitempty"` + AgentProfileID *string `json:"agentProfileId,omitempty"` + ExecutorProfileID *string `json:"executorProfileId,omitempty"` + Prompt *string `json:"prompt,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + PollIntervalSeconds *int `json:"pollIntervalSeconds,omitempty"` +} diff --git a/apps/backend/internal/linear/poller.go b/apps/backend/internal/linear/poller.go index f98d85edb9..a1da701728 100644 --- a/apps/backend/internal/linear/poller.go +++ b/apps/backend/internal/linear/poller.go @@ -2,44 +2,174 @@ package linear import ( "context" + "sync" + "time" + + "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/integrations/healthpoll" ) -// Poller probes the stored Linear credentials of every configured workspace on -// the shared 90s auth-health cadence. The actual loop lives in -// internal/integrations/healthpoll; this type exists so callers can keep the -// familiar `linear.NewPoller(...).Start(ctx)` shape without depending on the -// shared package directly. +// defaultIssuePollTickInterval is how often the issue-watch loop wakes up to +// consider every enabled watcher. The actual GraphQL search is only re-run for +// a watcher when its per-watch `PollIntervalSeconds` has elapsed since +// `LastPolledAt`, so this is the *minimum* granularity, not the *actual* +// cadence. +const defaultIssuePollTickInterval = 60 * time.Second + +// Poller drives two background loops sharing a single Service: +// - auth health: probes stored credentials so the UI can show connect status. +// Delegated to internal/integrations/healthpoll for the loop semantics. +// - issue watches: runs each enabled watcher's filter and emits +// NewLinearIssueEvent for every matching issue the orchestrator hasn't yet +// seen. +// +// Both loops are cancelled together via Stop. type Poller struct { - inner *healthpoll.Poller + service *Service + logger *logger.Logger + auth *healthpoll.Poller + issueInterval time.Duration + issueTickHook func() // tests use this to observe each issue-watch tick. + + mu sync.Mutex + cancelIssueLoop context.CancelFunc + wg sync.WaitGroup + started bool } -// NewPoller returns a poller using the default 90s cadence. Returns nil when -// svc is nil so the caller's nil-check still works after the indirection. +// NewPoller returns a poller using the default cadences. Returns nil when svc +// is nil so the caller's nil-check still works after the indirection. func NewPoller(svc *Service, log *logger.Logger) *Poller { if svc == nil { return nil } - return &Poller{inner: healthpoll.New("linear", svcProber{svc}, log)} + return &Poller{ + service: svc, + logger: log, + auth: healthpoll.New("linear", svcProber{svc}, log), + issueInterval: defaultIssuePollTickInterval, + } } -// Start launches the background loop. Calling Start more than once without -// Stop is a no-op. +// SetIssueTickHook installs a callback fired at the end of each issue-watch +// tick. Production code never sets this; tests use it to wait for a tick +// without sleep-polling. +func (p *Poller) SetIssueTickHook(fn func()) { + p.mu.Lock() + p.issueTickHook = fn + p.mu.Unlock() +} + +// Start launches both background loops. Calling Start more than once without +// Stop is a no-op. A nil receiver is also a no-op. func (p *Poller) Start(ctx context.Context) { - if p == nil || p.inner == nil { + if p == nil { return } - p.inner.Start(ctx) + p.mu.Lock() + defer p.mu.Unlock() + if p.started || p.service == nil { + return + } + p.started = true + issueCtx, cancel := context.WithCancel(ctx) + p.cancelIssueLoop = cancel + p.auth.Start(ctx) + p.wg.Add(1) + go p.issueWatchLoop(issueCtx) } -// Stop cancels the loop and waits for it to drain. +// Stop cancels both loops and waits for them to drain. func (p *Poller) Stop() { - if p == nil || p.inner == nil { + if p == nil { return } - p.inner.Stop() + p.mu.Lock() + if !p.started { + p.mu.Unlock() + return + } + cancel := p.cancelIssueLoop + p.mu.Unlock() + if cancel != nil { + cancel() + } + p.auth.Stop() + p.wg.Wait() + p.mu.Lock() + p.started = false + p.mu.Unlock() +} + +func (p *Poller) issueWatchLoop(ctx context.Context) { + defer p.wg.Done() + ticker := time.NewTicker(p.issueInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.checkIssueWatches(ctx) + p.fireIssueTickHook() + } + } +} + +func (p *Poller) checkIssueWatches(ctx context.Context) { + watches, err := p.service.Store().ListEnabledIssueWatches(ctx) + if err != nil { + p.logger.Warn("linear poller: list enabled issue watches failed", zap.Error(err)) + return + } + if len(watches) == 0 { + return + } + for _, w := range watches { + if ctx.Err() != nil { + return + } + if !isIssueWatchDue(w, time.Now()) { + continue + } + newIssues, err := p.service.CheckIssueWatch(ctx, w) + if err != nil { + p.logger.Debug("linear poller: check issue watch failed", + zap.String("watch_id", w.ID), zap.Error(err)) + continue + } + for _, issue := range newIssues { + p.logger.Info("new linear issue found for watch", + zap.String("watch_id", w.ID), + zap.String("identifier", issue.Identifier), + zap.String("title", issue.Title)) + p.service.publishNewLinearIssueEvent(ctx, w, issue) + } + } +} + +// isIssueWatchDue reports whether enough time has passed since the watch was +// last polled to re-run its search. +func isIssueWatchDue(w *IssueWatch, now time.Time) bool { + if w.LastPolledAt == nil { + return true + } + interval := w.PollIntervalSeconds + if interval <= 0 { + interval = DefaultIssueWatchPollInterval + } + return now.Sub(*w.LastPolledAt) >= time.Duration(interval)*time.Second +} + +func (p *Poller) fireIssueTickHook() { + p.mu.Lock() + hook := p.issueTickHook + p.mu.Unlock() + if hook != nil { + hook() + } } // svcProber adapts *Service to healthpoll.Prober without leaking the shared diff --git a/apps/backend/internal/linear/poller_issue_watch_test.go b/apps/backend/internal/linear/poller_issue_watch_test.go new file mode 100644 index 0000000000..03d8db61c5 --- /dev/null +++ b/apps/backend/internal/linear/poller_issue_watch_test.go @@ -0,0 +1,213 @@ +package linear + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +// recordingSubscriber subscribes to LinearNewIssue on the in-memory bus and +// captures payloads so the test can assert on them without touching internals. +type recordingSubscriber struct { + mu sync.Mutex + payload []*NewLinearIssueEvent + done chan struct{} + want int +} + +func newRecordingSubscriber(eb bus.EventBus, want int) (*recordingSubscriber, error) { + r := &recordingSubscriber{done: make(chan struct{}), want: want} + _, err := eb.Subscribe(events.LinearNewIssue, func(_ context.Context, e *bus.Event) error { + r.mu.Lock() + defer r.mu.Unlock() + evt, ok := e.Data.(*NewLinearIssueEvent) + if !ok { + return nil + } + r.payload = append(r.payload, evt) + if r.want > 0 && len(r.payload) == r.want { + close(r.done) + } + return nil + }) + return r, err +} + +func (r *recordingSubscriber) snapshot() []*NewLinearIssueEvent { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*NewLinearIssueEvent, len(r.payload)) + copy(out, r.payload) + return out +} + +func TestPoller_CheckIssueWatches_PublishesNewIssuesOnly(t *testing.T) { + f := newPollerFixture(t) + ctx := context.Background() + f.saveConfig(t, "tok") + + eb := bus.NewMemoryEventBus(logger.Default()) + defer eb.Close() + f.svc.SetEventBus(eb) + + sub, err := newRecordingSubscriber(eb, 2) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + + w := newTestIssueWatch("ws-1") + if err := f.store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create watch: %v", err) + } + + f.client.searchIssuesFn = func(_ SearchFilter, _ string, _ int) (*SearchResult, error) { + return &SearchResult{ + Issues: []LinearIssue{ + {Identifier: "ENG-1", Title: "first", URL: "https://linear.app/x/issue/ENG-1"}, + {Identifier: "ENG-2", Title: "second", URL: "https://linear.app/x/issue/ENG-2"}, + }, + IsLast: true, + }, nil + } + + // First tick: both issues are new, two events publish. + f.poller.checkIssueWatches(ctx) + + <-sub.done + first := sub.snapshot() + if len(first) != 2 { + t.Fatalf("expected 2 events on first tick, got %d", len(first)) + } + idents := []string{first[0].Issue.Identifier, first[1].Issue.Identifier} + if !contains(idents, "ENG-1") || !contains(idents, "ENG-2") { + t.Errorf("expected events for ENG-1 and ENG-2, got %v", idents) + } + if first[0].WorkspaceID != "ws-1" || first[0].WorkflowID != "wf-1" { + t.Errorf("event missing watch context: %+v", first[0]) + } + + // Reserve both identifiers to simulate the orchestrator having created tasks. + for _, id := range []string{"ENG-1", "ENG-2"} { + if _, err := f.store.ReserveIssueWatchTask(ctx, w.ID, id, "https://linear.app/x/issue/"+id); err != nil { + t.Fatalf("reserve %s: %v", id, err) + } + } + + // Second tick: nothing new, no additional events should publish. + f.poller.checkIssueWatches(ctx) + if got := len(sub.snapshot()); got != 2 { + t.Errorf("expected event count to stay at 2 after dedup, got %d", got) + } +} + +func TestPoller_CheckIssueWatches_SkipsDisabled(t *testing.T) { + f := newPollerFixture(t) + ctx := context.Background() + f.saveConfig(t, "tok") + + eb := bus.NewMemoryEventBus(logger.Default()) + defer eb.Close() + f.svc.SetEventBus(eb) + + disabled := newTestIssueWatch("ws-1") + disabled.Enabled = false + if err := f.store.CreateIssueWatch(ctx, disabled); err != nil { + t.Fatalf("create disabled: %v", err) + } + + calls := 0 + f.client.searchIssuesFn = func(_ SearchFilter, _ string, _ int) (*SearchResult, error) { + calls++ + return &SearchResult{Issues: []LinearIssue{{Identifier: "ENG-1"}}, IsLast: true}, nil + } + + f.poller.checkIssueWatches(ctx) + + if calls != 0 { + t.Errorf("expected disabled watch to be skipped (no Linear call), got %d calls", calls) + } +} + +func contains(haystack []string, needle string) bool { + for _, h := range haystack { + if h == needle { + return true + } + } + return false +} + +func TestIsIssueWatchDue(t *testing.T) { + now := time.Now() + stamp := now.Add(-30 * time.Second) + cases := []struct { + name string + watch *IssueWatch + expected bool + }{ + { + name: "never polled is always due", + watch: &IssueWatch{PollIntervalSeconds: 300}, + expected: true, + }, + { + name: "polled less than interval ago is not due", + watch: &IssueWatch{PollIntervalSeconds: 300, LastPolledAt: &stamp}, + expected: false, + }, + { + name: "polled at exactly the interval boundary is due", + watch: &IssueWatch{PollIntervalSeconds: 30, LastPolledAt: &stamp}, + expected: true, + }, + { + name: "zero interval falls back to default and gates accordingly", + watch: &IssueWatch{PollIntervalSeconds: 0, LastPolledAt: &stamp}, + expected: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isIssueWatchDue(tc.watch, now); got != tc.expected { + t.Errorf("expected %v, got %v", tc.expected, got) + } + }) + } +} + +func TestPoller_CheckIssueWatches_RespectsPerWatchInterval(t *testing.T) { + f := newPollerFixture(t) + ctx := context.Background() + f.saveConfig(t, "tok") + + eb := bus.NewMemoryEventBus(logger.Default()) + defer eb.Close() + f.svc.SetEventBus(eb) + + w := newTestIssueWatch("ws-1") + w.PollIntervalSeconds = 300 + if err := f.store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + now := time.Now().UTC() + if err := f.store.UpdateIssueWatchLastPolled(ctx, w.ID, now); err != nil { + t.Fatalf("stamp: %v", err) + } + + calls := 0 + f.client.searchIssuesFn = func(_ SearchFilter, _ string, _ int) (*SearchResult, error) { + calls++ + return &SearchResult{Issues: []LinearIssue{{Identifier: "ENG-1"}}, IsLast: true}, nil + } + + f.poller.checkIssueWatches(ctx) + + if calls != 0 { + t.Errorf("expected gating to skip the Linear search, got %d call(s)", calls) + } +} diff --git a/apps/backend/internal/linear/provider.go b/apps/backend/internal/linear/provider.go index 7eb4e9933f..62c385e62c 100644 --- a/apps/backend/internal/linear/provider.go +++ b/apps/backend/internal/linear/provider.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" ) // mockEnvVar gates the in-memory mock client used in E2E tests. @@ -20,14 +21,16 @@ func MockEnabled() bool { return os.Getenv(mockEnvVar) == "true" } -// Provide builds the Linear service. Cleanup is a no-op today — the service -// holds only in-memory client caches — but the signature mirrors other -// integration providers so callers can register it uniformly. +// Provide builds the Linear service. eventBus may be nil — used in tests and +// during early boot before the bus is ready; the service falls back to a +// no-op publish path. Cleanup is a no-op today — the service holds only +// in-memory client caches — but the signature mirrors other integration +// providers so callers can register it uniformly. // // When KANDEV_MOCK_LINEAR=true, the service is wired to a process-wide // MockClient and the same instance is exposed via Service.MockClient() so the // E2E mock controller can drive it. -func Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) (*Service, func() error, error) { +func Provide(writer, reader *sqlx.DB, secrets SecretStore, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error) { store, err := NewStore(writer, reader) if err != nil { return nil, nil, err @@ -42,6 +45,9 @@ func Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) ( } svc := NewService(store, secrets, clientFn, log) svc.mockClient = mock + if eventBus != nil { + svc.SetEventBus(eventBus) + } cleanup := func() error { return nil } return svc, cleanup, nil } diff --git a/apps/backend/internal/linear/service.go b/apps/backend/internal/linear/service.go index b42b2df417..25bf49d655 100644 --- a/apps/backend/internal/linear/service.go +++ b/apps/backend/internal/linear/service.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" ) // SecretStore is the subset of the secrets store the service needs. @@ -30,6 +31,7 @@ type Service struct { clientFn ClientFactory client Client // singleton, cleared on config change. probeHook func() + eventBus bus.EventBus // mockClient is non-nil only when Provide built the service with a MockClient // (KANDEV_MOCK_LINEAR=true). Exposed via MockClient() so the e2e control // routes can drive the same instance the clientFn returns. diff --git a/apps/backend/internal/linear/service_issue_watch.go b/apps/backend/internal/linear/service_issue_watch.go new file mode 100644 index 0000000000..f999a14491 --- /dev/null +++ b/apps/backend/internal/linear/service_issue_watch.go @@ -0,0 +1,266 @@ +package linear + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +// ErrIssueWatchNotFound is returned when GetIssueWatch's caller looks up an ID +// that doesn't exist. Callers map this to HTTP 404. +var ErrIssueWatchNotFound = errors.New("linear: issue watch not found") + +// SetEventBus wires the bus used to publish NewLinearIssueEvent. Optional: if +// unset the poller still runs but observed issues do not become Kandev tasks. +func (s *Service) SetEventBus(eb bus.EventBus) { + s.mu.Lock() + s.eventBus = eb + s.mu.Unlock() +} + +// CreateIssueWatch validates the request and persists a new watch row. +func (s *Service) CreateIssueWatch(ctx context.Context, req *CreateIssueWatchRequest) (*IssueWatch, error) { + if err := validateIssueWatchCreate(req); err != nil { + return nil, err + } + w := &IssueWatch{ + WorkspaceID: req.WorkspaceID, + WorkflowID: req.WorkflowID, + WorkflowStepID: req.WorkflowStepID, + Filter: normalizeFilter(req.Filter), + AgentProfileID: req.AgentProfileID, + ExecutorProfileID: req.ExecutorProfileID, + Prompt: req.Prompt, + PollIntervalSeconds: req.PollIntervalSeconds, + Enabled: true, + } + if req.Enabled != nil { + w.Enabled = *req.Enabled + } + if err := s.store.CreateIssueWatch(ctx, w); err != nil { + return nil, err + } + return w, nil +} + +// ListIssueWatches returns the watches configured for a workspace. +func (s *Service) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) { + return s.store.ListIssueWatches(ctx, workspaceID) +} + +// ListAllIssueWatches returns every watch across all workspaces. +func (s *Service) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) { + return s.store.ListAllIssueWatches(ctx) +} + +// GetIssueWatch returns a single watch by ID or ErrIssueWatchNotFound. +func (s *Service) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) { + w, err := s.store.GetIssueWatch(ctx, id) + if err != nil { + return nil, err + } + if w == nil { + return nil, ErrIssueWatchNotFound + } + return w, nil +} + +// UpdateIssueWatch applies a partial update by patching only the fields the +// caller explicitly set, then persists the result. +func (s *Service) UpdateIssueWatch(ctx context.Context, id string, req *UpdateIssueWatchRequest) (*IssueWatch, error) { + w, err := s.GetIssueWatch(ctx, id) + if err != nil { + return nil, err + } + applyIssueWatchPatch(w, req) + if filterIsEmpty(w.Filter) { + return nil, fmt.Errorf("%w: filter must specify at least one of query, teamKey, stateIds, or assigned", ErrInvalidConfig) + } + if w.WorkflowID == "" || w.WorkflowStepID == "" { + return nil, fmt.Errorf("%w: workflowId and workflowStepId cannot be empty", ErrInvalidConfig) + } + if err := validatePollInterval(w.PollIntervalSeconds); err != nil { + return nil, err + } + if err := s.store.UpdateIssueWatch(ctx, w); err != nil { + return nil, err + } + return w, nil +} + +// DeleteIssueWatch removes the watch and its dedup rows. Idempotent. +func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error { + return s.store.DeleteIssueWatch(ctx, id) +} + +// CheckIssueWatch runs the watch's filter once and returns the issues that +// haven't been turned into tasks yet. last_polled_at is stamped regardless of +// whether the search succeeded — a failing search still counts as "we tried". +// +// Concurrency note: callers must tolerate being handed an issue that gets +// stolen by a concurrent reserver. We query the seen-set and return unseen +// identifiers, but we do NOT insert the dedup row here — that happens in the +// orchestrator's ReserveIssueWatchTask via INSERT OR IGNORE. If the manual +// /trigger endpoint and the poller tick fire for the same watch in quick +// succession, both calls can see the same identifier as unseen before either +// has reserved it. The duplicate publish is harmless (the second reserver +// loses the race and bails) but the goroutine work is wasted. Same pattern as +// the JIRA watcher. +func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) ([]*LinearIssue, error) { + defer s.stampWatchLastPolled(w.ID) + client, err := s.clientFor(ctx) + if err != nil { + return nil, err + } + res, err := client.SearchIssues(ctx, w.Filter, "", issueWatchSearchPageSize) + if err != nil { + return nil, err + } + seen, err := s.store.ListSeenIssueIdentifiers(ctx, w.ID) + if err != nil { + s.log.Warn("linear: dedup set fetch failed", + zap.String("watch_id", w.ID), zap.Error(err)) + seen = nil + } + out := make([]*LinearIssue, 0, len(res.Issues)) + for i := range res.Issues { + issue := res.Issues[i] + if _, ok := seen[issue.Identifier]; ok { + continue + } + out = append(out, &issue) + } + return out, nil +} + +// stampWatchLastPolled writes the current timestamp using a fresh background +// context with a short write deadline, so a cancelled caller ctx (e.g. shutdown) +// doesn't drop the liveness record. +func (s *Service) stampWatchLastPolled(watchID string) { + ctx, cancel := context.WithTimeout(context.Background(), authHealthWriteTimeout) + defer cancel() + if err := s.store.UpdateIssueWatchLastPolled(ctx, watchID, time.Now().UTC()); err != nil { + s.log.Warn("linear: update last_polled_at failed", + zap.String("watch_id", watchID), zap.Error(err)) + } +} + +// publishNewLinearIssueEvent emits the orchestrator-facing event for one +// freshly-observed issue. No-op when the event bus is not wired (tests, early +// boot). +func (s *Service) publishNewLinearIssueEvent(ctx context.Context, w *IssueWatch, issue *LinearIssue) { + s.mu.Lock() + eb := s.eventBus + s.mu.Unlock() + if eb == nil { + return + } + evt := bus.NewEvent(events.LinearNewIssue, "linear", &NewLinearIssueEvent{ + IssueWatchID: w.ID, + WorkspaceID: w.WorkspaceID, + WorkflowID: w.WorkflowID, + WorkflowStepID: w.WorkflowStepID, + AgentProfileID: w.AgentProfileID, + ExecutorProfileID: w.ExecutorProfileID, + Prompt: w.Prompt, + Issue: issue, + }) + if err := eb.Publish(ctx, events.LinearNewIssue, evt); err != nil { + s.log.Debug("linear: publish new issue event failed", + zap.String("watch_id", w.ID), zap.String("identifier", issue.Identifier), zap.Error(err)) + } +} + +// issueWatchSearchPageSize caps how many issues a single CheckIssueWatch call +// pulls from Linear. Mirrors the Jira watcher's limit so per-tick cost stays +// bounded for very broad filters. +const issueWatchSearchPageSize = 50 + +// MinIssueWatchPollInterval / MaxIssueWatchPollInterval bound the per-watch +// search re-run cadence. +const ( + MinIssueWatchPollInterval = 60 + MaxIssueWatchPollInterval = 3600 +) + +func validateIssueWatchCreate(req *CreateIssueWatchRequest) error { + if req.WorkspaceID == "" { + return fmt.Errorf("%w: workspaceId required", ErrInvalidConfig) + } + if req.WorkflowID == "" || req.WorkflowStepID == "" { + return fmt.Errorf("%w: workflowId and workflowStepId required", ErrInvalidConfig) + } + if filterIsEmpty(normalizeFilter(req.Filter)) { + return fmt.Errorf("%w: filter must specify at least one of query, teamKey, stateIds, or assigned", ErrInvalidConfig) + } + if req.PollIntervalSeconds != 0 { + if err := validatePollInterval(req.PollIntervalSeconds); err != nil { + return err + } + } + return nil +} + +func validatePollInterval(seconds int) error { + if seconds < MinIssueWatchPollInterval || seconds > MaxIssueWatchPollInterval { + return fmt.Errorf("%w: pollIntervalSeconds must be between %d and %d", + ErrInvalidConfig, MinIssueWatchPollInterval, MaxIssueWatchPollInterval) + } + return nil +} + +// normalizeFilter trims string fields and drops empty stateIds entries, so a +// filter that looks empty after normalization fails the at-least-one check +// instead of slipping through with whitespace. +func normalizeFilter(f SearchFilter) SearchFilter { + out := SearchFilter{ + Query: strings.TrimSpace(f.Query), + TeamKey: strings.TrimSpace(f.TeamKey), + Assigned: strings.TrimSpace(f.Assigned), + } + for _, id := range f.StateIDs { + id = strings.TrimSpace(id) + if id != "" { + out.StateIDs = append(out.StateIDs, id) + } + } + return out +} + +func filterIsEmpty(f SearchFilter) bool { + return f.Query == "" && f.TeamKey == "" && f.Assigned == "" && len(f.StateIDs) == 0 +} + +func applyIssueWatchPatch(w *IssueWatch, req *UpdateIssueWatchRequest) { + if req.WorkflowID != nil { + w.WorkflowID = *req.WorkflowID + } + if req.WorkflowStepID != nil { + w.WorkflowStepID = *req.WorkflowStepID + } + if req.Filter != nil { + w.Filter = normalizeFilter(*req.Filter) + } + if req.AgentProfileID != nil { + w.AgentProfileID = *req.AgentProfileID + } + if req.ExecutorProfileID != nil { + w.ExecutorProfileID = *req.ExecutorProfileID + } + if req.Prompt != nil { + w.Prompt = *req.Prompt + } + if req.Enabled != nil { + w.Enabled = *req.Enabled + } + if req.PollIntervalSeconds != nil { + w.PollIntervalSeconds = *req.PollIntervalSeconds + } +} diff --git a/apps/backend/internal/linear/service_issue_watch_test.go b/apps/backend/internal/linear/service_issue_watch_test.go new file mode 100644 index 0000000000..2f15c62d02 --- /dev/null +++ b/apps/backend/internal/linear/service_issue_watch_test.go @@ -0,0 +1,225 @@ +package linear + +import ( + "context" + "errors" + "testing" +) + +// withSearchResults returns a fakeClient that always returns the given issues +// for SearchIssues, ignoring the filter. +func (c *fakeClient) withSearchResults(issues []LinearIssue) *fakeClient { + c.searchIssuesFn = func(_ SearchFilter, _ string, _ int) (*SearchResult, error) { + return &SearchResult{Issues: issues, IsLast: true}, nil + } + return c +} + +func TestService_CreateIssueWatch_DefaultsAndValidation(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + + // Empty filter is rejected. + if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + }); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for empty filter, got %v", err) + } + + // Whitespace-only fields are also rejected (normalize then check). + if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: SearchFilter{Query: " ", TeamKey: " ", StateIDs: []string{" ", ""}}, + }); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for whitespace-only filter, got %v", err) + } + + // Happy path assigns ID + defaults Enabled=true. + w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: SearchFilter{TeamKey: "ENG"}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if w.ID == "" { + t.Fatal("expected ID assigned") + } + if !w.Enabled { + t.Error("expected Enabled defaulted to true") + } + if w.Filter.TeamKey != "ENG" { + t.Errorf("filter not persisted: %+v", w.Filter) + } +} + +func TestService_UpdateIssueWatch_PartialPatch(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + + created, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: SearchFilter{TeamKey: "ENG"}, + Prompt: "original", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + // Patch only the prompt; everything else must remain. + newPrompt := "updated" + updated, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{ + Prompt: &newPrompt, + }) + if err != nil { + t.Fatalf("update: %v", err) + } + if updated.Prompt != "updated" { + t.Errorf("prompt not patched: %q", updated.Prompt) + } + if updated.Filter.TeamKey != "ENG" || updated.WorkspaceID != created.WorkspaceID { + t.Errorf("unexpected mutation of unset fields: %+v", updated) + } + + // Patching filter to something empty is rejected to keep watch rows valid. + empty := SearchFilter{} + if _, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{Filter: &empty}); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for empty filter patch, got %v", err) + } +} + +func TestService_CreateIssueWatch_RejectsOutOfRangeInterval(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + for _, n := range []int{1, 30, 59, 3601, 86400} { + _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: SearchFilter{TeamKey: "ENG"}, + PollIntervalSeconds: n, + }) + if !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for pollIntervalSeconds=%d, got %v", n, err) + } + } + // Zero is allowed (the store coerces to default). + if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: SearchFilter{TeamKey: "ENG"}, + }); err != nil { + t.Errorf("expected zero pollIntervalSeconds to be accepted, got %v", err) + } +} + +func TestService_UpdateIssueWatch_RejectsEmptyWorkflowFields(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + created, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step", + Filter: SearchFilter{TeamKey: "ENG"}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + empty := "" + for _, req := range []*UpdateIssueWatchRequest{ + {WorkflowID: &empty}, + {WorkflowStepID: &empty}, + } { + if _, err := f.svc.UpdateIssueWatch(ctx, created.ID, req); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for %+v, got %v", req, err) + } + } +} + +func TestService_UpdateIssueWatch_NotFound(t *testing.T) { + f := newSvcFixture(t) + prompt := "x" + _, err := f.svc.UpdateIssueWatch(context.Background(), "ghost", &UpdateIssueWatchRequest{Prompt: &prompt}) + if !errors.Is(err, ErrIssueWatchNotFound) { + t.Errorf("expected ErrIssueWatchNotFound, got %v", err) + } +} + +func TestService_CheckIssueWatch_FiltersAlreadySeen(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + + if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + AuthMethod: AuthMethodAPIKey, Secret: "lin_api", + }); err != nil { + t.Fatalf("set config: %v", err) + } + + f.client.withSearchResults([]LinearIssue{ + {Identifier: "ENG-1", Title: "one", URL: "https://linear.app/x/issue/ENG-1"}, + {Identifier: "ENG-2", Title: "two", URL: "https://linear.app/x/issue/ENG-2"}, + {Identifier: "ENG-3", Title: "three", URL: "https://linear.app/x/issue/ENG-3"}, + }) + + w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step", + Filter: SearchFilter{TeamKey: "ENG"}, + }) + if err != nil { + t.Fatalf("create watch: %v", err) + } + + // Pre-seed ENG-2 as already turned into a task. + if _, err := f.store.ReserveIssueWatchTask(ctx, w.ID, "ENG-2", "https://linear.app/x/issue/ENG-2"); err != nil { + t.Fatalf("seed reservation: %v", err) + } + + got, err := f.svc.CheckIssueWatch(ctx, w) + if err != nil { + t.Fatalf("check: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 unseen issues, got %d", len(got)) + } + for _, i := range got { + if i.Identifier == "ENG-2" { + t.Error("ENG-2 should have been filtered as already seen") + } + } + + refreshed, _ := f.store.GetIssueWatch(ctx, w.ID) + if refreshed.LastPolledAt == nil { + t.Error("expected last_polled_at stamped after check") + } +} + +func TestService_CheckIssueWatch_StampsLastPolledOnError(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + AuthMethod: AuthMethodAPIKey, Secret: "lin_api", + }); err != nil { + t.Fatalf("set config: %v", err) + } + f.client.searchIssuesFn = func(_ SearchFilter, _ string, _ int) (*SearchResult, error) { + return nil, errors.New("upstream 500") + } + w, _ := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step", + Filter: SearchFilter{TeamKey: "ENG"}, + }) + if _, err := f.svc.CheckIssueWatch(ctx, w); err == nil { + t.Error("expected error from search to surface to caller") + } + refreshed, _ := f.store.GetIssueWatch(ctx, w.ID) + if refreshed.LastPolledAt == nil { + t.Error("expected last_polled_at stamped even on search failure (liveness signal)") + } +} diff --git a/apps/backend/internal/linear/store.go b/apps/backend/internal/linear/store.go index 3ace6e0ed4..0e913e4b5a 100644 --- a/apps/backend/internal/linear/store.go +++ b/apps/backend/internal/linear/store.go @@ -51,6 +51,35 @@ const createTablesSQL = ` created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); + + CREATE TABLE IF NOT EXISTS linear_issue_watches ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + workflow_id TEXT NOT NULL, + workflow_step_id TEXT NOT NULL, + filter_json TEXT NOT NULL DEFAULT '{}', + agent_profile_id TEXT NOT NULL DEFAULT '', + executor_profile_id TEXT NOT NULL DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT 1, + poll_interval_seconds INTEGER NOT NULL DEFAULT 300, + last_polled_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_linear_issue_watches_workspace + ON linear_issue_watches(workspace_id); + + CREATE TABLE IF NOT EXISTS linear_issue_watch_tasks ( + id TEXT PRIMARY KEY, + issue_watch_id TEXT NOT NULL, + issue_identifier TEXT NOT NULL, + issue_url TEXT NOT NULL, + task_id TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + UNIQUE(issue_watch_id, issue_identifier), + FOREIGN KEY(issue_watch_id) REFERENCES linear_issue_watches(id) ON DELETE CASCADE + ); ` // singletonID is the synthetic primary key of the (only) row in linear_configs. diff --git a/apps/backend/internal/linear/store_issue_watch.go b/apps/backend/internal/linear/store_issue_watch.go new file mode 100644 index 0000000000..3219704433 --- /dev/null +++ b/apps/backend/internal/linear/store_issue_watch.go @@ -0,0 +1,267 @@ +package linear + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +// issueWatchRow mirrors IssueWatch but stores the filter as a raw JSON string +// the way SQLite holds it. The store marshals/unmarshals at this boundary so +// service callers see a typed SearchFilter and never have to think about JSON. +type issueWatchRow struct { + ID string `db:"id"` + WorkspaceID string `db:"workspace_id"` + WorkflowID string `db:"workflow_id"` + WorkflowStepID string `db:"workflow_step_id"` + FilterJSON string `db:"filter_json"` + AgentProfileID string `db:"agent_profile_id"` + ExecutorProfileID string `db:"executor_profile_id"` + Prompt string `db:"prompt"` + Enabled bool `db:"enabled"` + PollIntervalSeconds int `db:"poll_interval_seconds"` + LastPolledAt *time.Time `db:"last_polled_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (r *issueWatchRow) toIssueWatch() (*IssueWatch, error) { + var filter SearchFilter + if r.FilterJSON != "" { + if err := json.Unmarshal([]byte(r.FilterJSON), &filter); err != nil { + return nil, fmt.Errorf("decode filter: %w", err) + } + } + return &IssueWatch{ + ID: r.ID, + WorkspaceID: r.WorkspaceID, + WorkflowID: r.WorkflowID, + WorkflowStepID: r.WorkflowStepID, + Filter: filter, + AgentProfileID: r.AgentProfileID, + ExecutorProfileID: r.ExecutorProfileID, + Prompt: r.Prompt, + Enabled: r.Enabled, + PollIntervalSeconds: r.PollIntervalSeconds, + LastPolledAt: r.LastPolledAt, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + }, nil +} + +func encodeFilter(f SearchFilter) (string, error) { + b, err := json.Marshal(f) + if err != nil { + return "", fmt.Errorf("encode filter: %w", err) + } + return string(b), nil +} + +const issueWatchColumns = `id, workspace_id, workflow_id, workflow_step_id, filter_json, + agent_profile_id, executor_profile_id, prompt, enabled, + poll_interval_seconds, last_polled_at, created_at, updated_at` + +// CreateIssueWatch persists a new issue watch row. ID and timestamps are +// assigned here so callers can pass a partially-populated struct. +func (s *Store) CreateIssueWatch(ctx context.Context, w *IssueWatch) error { + if w.ID == "" { + w.ID = uuid.New().String() + } + now := time.Now().UTC() + w.CreatedAt = now + w.UpdatedAt = now + if w.PollIntervalSeconds <= 0 { + w.PollIntervalSeconds = DefaultIssueWatchPollInterval + } + filterJSON, err := encodeFilter(w.Filter) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO linear_issue_watches (`+issueWatchColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + w.ID, w.WorkspaceID, w.WorkflowID, w.WorkflowStepID, filterJSON, + w.AgentProfileID, w.ExecutorProfileID, w.Prompt, w.Enabled, + w.PollIntervalSeconds, w.LastPolledAt, w.CreatedAt, w.UpdatedAt) + return err +} + +// GetIssueWatch returns a single watch by ID, or nil when no row matches. +func (s *Store) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) { + var row issueWatchRow + err := s.ro.GetContext(ctx, &row, + `SELECT `+issueWatchColumns+` FROM linear_issue_watches WHERE id = ?`, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return row.toIssueWatch() +} + +// ListIssueWatches returns all watches configured for a workspace. +func (s *Store) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) { + var rows []issueWatchRow + err := s.ro.SelectContext(ctx, &rows, + `SELECT `+issueWatchColumns+` FROM linear_issue_watches + WHERE workspace_id = ? ORDER BY created_at`, workspaceID) + if err != nil { + return nil, err + } + return materializeWatches(rows) +} + +// ListAllIssueWatches returns every watch across all workspaces. +func (s *Store) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) { + var rows []issueWatchRow + err := s.ro.SelectContext(ctx, &rows, + `SELECT `+issueWatchColumns+` FROM linear_issue_watches ORDER BY workspace_id, created_at`) + if err != nil { + return nil, err + } + return materializeWatches(rows) +} + +// ListEnabledIssueWatches returns every enabled watch across all workspaces, +// used by the poller to decide what to query each tick. +func (s *Store) ListEnabledIssueWatches(ctx context.Context) ([]*IssueWatch, error) { + var rows []issueWatchRow + err := s.ro.SelectContext(ctx, &rows, + `SELECT `+issueWatchColumns+` FROM linear_issue_watches + WHERE enabled = 1 ORDER BY created_at`) + if err != nil { + return nil, err + } + return materializeWatches(rows) +} + +func materializeWatches(rows []issueWatchRow) ([]*IssueWatch, error) { + out := make([]*IssueWatch, 0, len(rows)) + for i := range rows { + w, err := rows[i].toIssueWatch() + if err != nil { + return nil, err + } + out = append(out, w) + } + return out, nil +} + +// UpdateIssueWatch overwrites the mutable fields of an existing watch row. +// updated_at is bumped automatically; last_polled_at is preserved unless the +// caller explicitly sets it. +func (s *Store) UpdateIssueWatch(ctx context.Context, w *IssueWatch) error { + w.UpdatedAt = time.Now().UTC() + if w.PollIntervalSeconds <= 0 { + w.PollIntervalSeconds = DefaultIssueWatchPollInterval + } + filterJSON, err := encodeFilter(w.Filter) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, ` + UPDATE linear_issue_watches SET workflow_id = ?, workflow_step_id = ?, filter_json = ?, + agent_profile_id = ?, executor_profile_id = ?, prompt = ?, + enabled = ?, poll_interval_seconds = ?, last_polled_at = ?, updated_at = ? + WHERE id = ?`, + w.WorkflowID, w.WorkflowStepID, filterJSON, + w.AgentProfileID, w.ExecutorProfileID, w.Prompt, + w.Enabled, w.PollIntervalSeconds, w.LastPolledAt, w.UpdatedAt, w.ID) + return err +} + +// UpdateIssueWatchLastPolled stamps the last-polled timestamp without touching +// the rest of the row. +func (s *Store) UpdateIssueWatchLastPolled(ctx context.Context, id string, t time.Time) error { + _, err := s.db.ExecContext(ctx, + `UPDATE linear_issue_watches SET last_polled_at = ?, updated_at = ? WHERE id = ?`, + t, time.Now().UTC(), id) + return err +} + +// DeleteIssueWatch removes a watch and its dedup rows in a single transaction. +// The explicit child DELETE guards older databases where foreign_keys may not +// have been enabled at attach time. +func (s *Store) DeleteIssueWatch(ctx context.Context, id string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `DELETE FROM linear_issue_watch_tasks WHERE issue_watch_id = ?`, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM linear_issue_watches WHERE id = ?`, id); err != nil { + return err + } + return tx.Commit() +} + +// ReserveIssueWatchTask atomically claims a slot for a (watch, issue) pair via +// INSERT OR IGNORE. Returns true when this caller won the race and should +// proceed to create the task. +func (s *Store) ReserveIssueWatchTask(ctx context.Context, watchID, identifier, issueURL string) (bool, error) { + res, err := s.db.ExecContext(ctx, ` + INSERT OR IGNORE INTO linear_issue_watch_tasks (id, issue_watch_id, issue_identifier, issue_url, task_id, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + uuid.New().String(), watchID, identifier, issueURL, "", time.Now().UTC()) + if err != nil { + return false, err + } + rows, err := res.RowsAffected() + if err != nil { + return false, err + } + return rows == 1, nil +} + +// AssignIssueWatchTaskID stamps the created task ID onto a previously-reserved +// dedup row. +func (s *Store) AssignIssueWatchTaskID(ctx context.Context, watchID, identifier, taskID string) error { + res, err := s.db.ExecContext(ctx, ` + UPDATE linear_issue_watch_tasks SET task_id = ? + WHERE issue_watch_id = ? AND issue_identifier = ?`, + taskID, watchID, identifier) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("assign task ID: reservation row not found for watch=%s issue=%s", watchID, identifier) + } + return nil +} + +// ReleaseIssueWatchTask drops a reservation so the next poll can retry. Used +// when task creation fails after a successful reserve. +func (s *Store) ReleaseIssueWatchTask(ctx context.Context, watchID, identifier string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM linear_issue_watch_tasks WHERE issue_watch_id = ? AND issue_identifier = ?`, + watchID, identifier) + return err +} + +// ListSeenIssueIdentifiers returns the set of issue identifiers already +// reserved against a watch. +func (s *Store) ListSeenIssueIdentifiers(ctx context.Context, watchID string) (map[string]struct{}, error) { + var keys []string + err := s.ro.SelectContext(ctx, &keys, + `SELECT issue_identifier FROM linear_issue_watch_tasks WHERE issue_watch_id = ?`, watchID) + if err != nil { + return nil, err + } + out := make(map[string]struct{}, len(keys)) + for _, k := range keys { + out[k] = struct{}{} + } + return out, nil +} diff --git a/apps/backend/internal/linear/store_issue_watch_test.go b/apps/backend/internal/linear/store_issue_watch_test.go new file mode 100644 index 0000000000..300e55e979 --- /dev/null +++ b/apps/backend/internal/linear/store_issue_watch_test.go @@ -0,0 +1,261 @@ +package linear + +import ( + "context" + "testing" + "time" +) + +func newTestIssueWatch(workspaceID string) *IssueWatch { + return &IssueWatch{ + WorkspaceID: workspaceID, + WorkflowID: "wf-1", + WorkflowStepID: "step-1", + Filter: SearchFilter{TeamKey: "ENG", StateIDs: []string{"state-started"}}, + Prompt: "Investigate {{issue.identifier}}", + Enabled: true, + } +} + +func TestStore_IssueWatch_CreateGet(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + if w.ID == "" { + t.Fatal("expected ID assigned") + } + if w.PollIntervalSeconds != DefaultIssueWatchPollInterval { + t.Errorf("expected default poll interval %d, got %d", DefaultIssueWatchPollInterval, w.PollIntervalSeconds) + } + if w.CreatedAt.IsZero() || w.UpdatedAt.IsZero() { + t.Error("expected timestamps assigned") + } + + got, err := store.GetIssueWatch(ctx, w.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got == nil { + t.Fatal("expected watch, got nil") + } + if got.WorkspaceID != w.WorkspaceID || got.Prompt != w.Prompt { + t.Errorf("round-trip mismatch: %+v vs %+v", got, w) + } + // Filter survives the JSON round-trip. + if got.Filter.TeamKey != "ENG" || len(got.Filter.StateIDs) != 1 || got.Filter.StateIDs[0] != "state-started" { + t.Errorf("filter round-trip failed: %+v", got.Filter) + } +} + +func TestStore_IssueWatch_ListByWorkspace(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w1 := newTestIssueWatch("ws-1") + w1.Filter = SearchFilter{TeamKey: "A"} + w2 := newTestIssueWatch("ws-1") + w2.Filter = SearchFilter{TeamKey: "B"} + w3 := newTestIssueWatch("ws-2") + + for _, w := range []*IssueWatch{w1, w2, w3} { + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + } + + got, err := store.ListIssueWatches(ctx, "ws-1") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 watches for ws-1, got %d", len(got)) + } + for _, w := range got { + if w.WorkspaceID != "ws-1" { + t.Errorf("workspace leaked into list: %s", w.WorkspaceID) + } + } +} + +func TestStore_IssueWatch_ListEnabled(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + enabled := newTestIssueWatch("ws-1") + disabled := newTestIssueWatch("ws-2") + disabled.Enabled = false + for _, w := range []*IssueWatch{enabled, disabled} { + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + } + + got, err := store.ListEnabledIssueWatches(ctx) + if err != nil { + t.Fatalf("list enabled: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected only the enabled watch, got %d", len(got)) + } + if got[0].ID != enabled.ID { + t.Errorf("expected enabled watch returned, got %s", got[0].ID) + } +} + +func TestStore_IssueWatch_Update(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + originalCreated := w.CreatedAt + w.Filter = SearchFilter{Query: "auth bug"} + w.Enabled = false + w.PollIntervalSeconds = 60 + if err := store.UpdateIssueWatch(ctx, w); err != nil { + t.Fatalf("update: %v", err) + } + got, _ := store.GetIssueWatch(ctx, w.ID) + if got.Filter.Query != "auth bug" || got.Enabled || got.PollIntervalSeconds != 60 { + t.Errorf("update did not persist: %+v", got) + } + if !got.CreatedAt.Equal(originalCreated) { + t.Errorf("update must not change created_at: %v vs %v", got.CreatedAt, originalCreated) + } +} + +func TestStore_IssueWatch_LastPolledStamp(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + t1 := time.Now().UTC().Truncate(time.Second) + if err := store.UpdateIssueWatchLastPolled(ctx, w.ID, t1); err != nil { + t.Fatalf("stamp: %v", err) + } + got, _ := store.GetIssueWatch(ctx, w.ID) + if got.LastPolledAt == nil || !got.LastPolledAt.Equal(t1) { + t.Errorf("expected last_polled_at=%v, got %v", t1, got.LastPolledAt) + } +} + +func TestStore_IssueWatch_DeleteCascadesDedupRows(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + if _, err := store.ReserveIssueWatchTask(ctx, w.ID, "ENG-1", "https://linear.app/x/issue/ENG-1"); err != nil { + t.Fatalf("reserve: %v", err) + } + + if err := store.DeleteIssueWatch(ctx, w.ID); err != nil { + t.Fatalf("delete: %v", err) + } + gone, _ := store.GetIssueWatch(ctx, w.ID) + if gone != nil { + t.Errorf("expected watch deleted, got %+v", gone) + } + // Re-creating a watch with the same ID and reserving the same identifier + // should succeed without a UNIQUE collision — proves cascade ran. + w2 := newTestIssueWatch("ws-1") + w2.ID = w.ID + if err := store.CreateIssueWatch(ctx, w2); err != nil { + t.Fatalf("recreate watch: %v", err) + } + ok, err := store.ReserveIssueWatchTask(ctx, w2.ID, "ENG-1", "https://linear.app/x/issue/ENG-1") + if err != nil { + t.Fatalf("re-reserve: %v", err) + } + if !ok { + t.Error("expected re-reserve to succeed after cascade delete") + } +} + +func TestStore_IssueWatchTask_ReserveDedup(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + + first, err := store.ReserveIssueWatchTask(ctx, w.ID, "ENG-7", "https://linear.app/x/issue/ENG-7") + if err != nil { + t.Fatalf("first reserve: %v", err) + } + if !first { + t.Error("expected first reservation to win") + } + + second, err := store.ReserveIssueWatchTask(ctx, w.ID, "ENG-7", "https://linear.app/x/issue/ENG-7") + if err != nil { + t.Fatalf("second reserve: %v", err) + } + if second { + t.Error("expected second reservation to lose due to UNIQUE constraint") + } + + seen, err := store.ListSeenIssueIdentifiers(ctx, w.ID) + if err != nil { + t.Fatalf("list seen: %v", err) + } + if _, ok := seen["ENG-7"]; !ok { + t.Error("expected ENG-7 in seen set after reservation") + } +} + +func TestStore_IssueWatchTask_AssignTaskID(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + if _, err := store.ReserveIssueWatchTask(ctx, w.ID, "ENG-1", "https://linear.app/x/issue/ENG-1"); err != nil { + t.Fatalf("reserve: %v", err) + } + if err := store.AssignIssueWatchTaskID(ctx, w.ID, "ENG-1", "task-abc"); err != nil { + t.Fatalf("assign: %v", err) + } + + if err := store.AssignIssueWatchTaskID(ctx, w.ID, "ENG-NOPE", "task-zzz"); err == nil { + t.Error("expected error for missing reservation, got nil") + } +} + +func TestStore_IssueWatchTask_Release(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + if _, err := store.ReserveIssueWatchTask(ctx, w.ID, "ENG-1", "https://linear.app/x/issue/ENG-1"); err != nil { + t.Fatalf("reserve: %v", err) + } + if err := store.ReleaseIssueWatchTask(ctx, w.ID, "ENG-1"); err != nil { + t.Fatalf("release: %v", err) + } + again, err := store.ReserveIssueWatchTask(ctx, w.ID, "ENG-1", "https://linear.app/x/issue/ENG-1") + if err != nil { + t.Fatalf("re-reserve: %v", err) + } + if !again { + t.Error("expected reservation to succeed after release") + } +} diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 8d976b8072..9c6601cbd3 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -33,12 +33,13 @@ import ( type ClarificationService interface { CreateRequest(req *clarification.Request) string WaitForResponse(ctx context.Context, pendingID string) (*clarification.Response, error) + CancelRequest(pendingID string) bool CancelSession(sessionID string) []string } // MessageCreator creates messages for clarification requests. type MessageCreator interface { - CreateClarificationRequestMessage(ctx context.Context, taskID, sessionID, pendingID string, question clarification.Question, clarificationContext string) (string, error) + CreateClarificationRequestMessages(ctx context.Context, taskID, sessionID, pendingID string, questions []clarification.Question, clarificationContext string) ([]string, error) } // SessionRepository interface for updating session state. @@ -144,6 +145,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPListWorkspaces, h.handleListWorkspaces) d.RegisterFunc(ws.ActionMCPListWorkflows, h.handleListWorkflows) d.RegisterFunc(ws.ActionMCPListWorkflowSteps, h.handleListWorkflowSteps) + d.RegisterFunc(ws.ActionMCPListRepositories, h.handleListRepositories) d.RegisterFunc(ws.ActionMCPListTasks, h.handleListTasks) d.RegisterFunc(ws.ActionMCPCreateTask, h.handleCreateTask) d.RegisterFunc(ws.ActionMCPUpdateTask, h.handleUpdateTask) @@ -155,7 +157,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPUpdateTaskPlan, h.handleUpdateTaskPlan) d.RegisterFunc(ws.ActionMCPDeleteTaskPlan, h.handleDeleteTaskPlan) d.RegisterFunc(ws.ActionMCPClarificationTimeout, h.handleClarificationTimeout) - count := 14 + count := 15 // Config-mode handlers (registered when config deps are set) if h.workflowSvc != nil { @@ -294,6 +296,25 @@ func (h *Handlers) handleListWorkflowSteps(ctx context.Context, msg *ws.Message) }) } +// handleListRepositories lists repositories for a workspace. Exposes the same +// data the kanban "Edit task → Repositories" picker reads, so an MCP-driven +// agent (e.g. the Slack triage runner) can match a request against an actual +// repo instead of guessing or making up an ID. +func (h *Handlers) handleListRepositories(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return h.handleListByField(ctx, msg, "workspace_id", "failed to list repositories", "Failed to list repositories", + func(ctx context.Context, workspaceID string) (any, error) { + repos, err := h.taskSvc.ListRepositories(ctx, workspaceID) + if err != nil { + return nil, err + } + dtos := make([]dto.RepositoryDTO, 0, len(repos)) + for _, r := range repos { + dtos = append(dtos, dto.FromRepository(r)) + } + return dto.ListRepositoriesResponse{Repositories: dtos, Total: len(dtos)}, nil + }) +} + // handleListTasks lists tasks for a workflow. func (h *Handlers) handleListTasks(ctx context.Context, msg *ws.Message) (*ws.Message, error) { return h.handleListByField(ctx, msg, "workflow_id", "failed to list tasks", "Failed to list tasks", @@ -415,24 +436,43 @@ type taskRepoResult struct { } // resolveTaskRepositories builds the repository list for a new task. -// Priority: explicit repositories > parent task repos > source task repos. -// When inheriting from a parent, it also returns the parent's workspace/workflow IDs. +// +// For subtasks (parentID set) workspace and workflow always come from the +// parent. Explicit repositories override the parent's repos when supplied, +// otherwise the parent's repos are inherited verbatim — letting an agent +// spin up a subtask that targets a sibling repo while staying in the same +// workspace/workflow. +// +// For top-level tasks (parentID empty) explicit repos win over source-task +// inheritance; workspace falls back to the source task when available. func (h *Handlers) resolveTaskRepositories( ctx context.Context, parentID, sourceTaskID string, explicit []mcpRepositoryInput, ) (taskRepoResult, error) { - if len(explicit) > 0 { - var repos []service.TaskRepositoryInput - for _, r := range explicit { - repos = append(repos, service.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - LocalPath: r.LocalPath, - GitHubURL: r.GitHubURL, - BaseBranch: r.BaseBranch, - }) + explicitRepos := explicitRepoInputs(explicit) + + if parentID != "" { + parent, err := h.taskSvc.GetTask(ctx, parentID) + if err != nil { + return taskRepoResult{}, fmt.Errorf("invalid parent_id: %w", err) } - result := taskRepoResult{Repos: repos} + if parent.IsEphemeral { + return taskRepoResult{}, fmt.Errorf("cannot create subtasks of an ephemeral task (quick chat); omit parent_id to create a top-level task") + } + repos := explicitRepos + if repos == nil { + repos = inheritedRepoInputs(parent.Repositories) + } + return taskRepoResult{ + Repos: repos, + WorkspaceID: parent.WorkspaceID, + WorkflowID: parent.WorkflowID, + }, nil + } + + if explicitRepos != nil { + result := taskRepoResult{Repos: explicitRepos} // Inherit workspace from source task so multi-workspace installs don't // fail auto-resolution when the agent supplies an explicit repository. if sourceTaskID != "" && h.taskSvc != nil { @@ -447,29 +487,6 @@ func (h *Handlers) resolveTaskRepositories( return result, nil } - if parentID != "" { - parent, err := h.taskSvc.GetTask(ctx, parentID) - if err != nil { - return taskRepoResult{}, fmt.Errorf("invalid parent_id: %w", err) - } - if parent.IsEphemeral { - return taskRepoResult{}, fmt.Errorf("cannot create subtasks of an ephemeral task (quick chat); omit parent_id to create a top-level task") - } - var repos []service.TaskRepositoryInput - for _, r := range parent.Repositories { - repos = append(repos, service.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - CheckoutBranch: r.CheckoutBranch, - }) - } - return taskRepoResult{ - Repos: repos, - WorkspaceID: parent.WorkspaceID, - WorkflowID: parent.WorkflowID, - }, nil - } - // For top-level tasks, inherit repos and workspace from the calling agent's current task. if sourceTaskID != "" { sourceTask, err := h.taskSvc.GetTask(ctx, sourceTaskID) @@ -478,16 +495,8 @@ func (h *Handlers) resolveTaskRepositories( zap.String("source_task_id", sourceTaskID), zap.Error(err)) return taskRepoResult{}, nil } - var repos []service.TaskRepositoryInput - for _, r := range sourceTask.Repositories { - repos = append(repos, service.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - CheckoutBranch: r.CheckoutBranch, - }) - } return taskRepoResult{ - Repos: repos, + Repos: inheritedRepoInputs(sourceTask.Repositories), WorkspaceID: sourceTask.WorkspaceID, }, nil } @@ -495,6 +504,45 @@ func (h *Handlers) resolveTaskRepositories( return taskRepoResult{}, nil } +// explicitRepoInputs maps the MCP-side explicit repo list to service inputs. +// Returns nil when no explicit repos were supplied so callers can distinguish +// "agent didn't pass repos" from "agent passed an empty list". +func explicitRepoInputs(explicit []mcpRepositoryInput) []service.TaskRepositoryInput { + if len(explicit) == 0 { + return nil + } + repos := make([]service.TaskRepositoryInput, 0, len(explicit)) + for _, r := range explicit { + repos = append(repos, service.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + LocalPath: r.LocalPath, + GitHubURL: r.GitHubURL, + BaseBranch: r.BaseBranch, + }) + } + return repos +} + +// inheritedRepoInputs maps an existing task's repository list onto service +// inputs for a new task that inherits from it. +func inheritedRepoInputs(src []*models.TaskRepository) []service.TaskRepositoryInput { + if len(src) == 0 { + return nil + } + repos := make([]service.TaskRepositoryInput, 0, len(src)) + for _, r := range src { + if r == nil { + continue + } + repos = append(repos, service.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + }) + } + return repos +} + // autoStartTask launches an agent session for a newly created task in the background. // It resolves the agent profile: explicit > parent's session > source task's session > workspace default. // It resolves the executor: explicit executor_profile_id > parent's executor_profile_id > @@ -920,10 +968,10 @@ func (h *Handlers) publishQueueStatusEvent(ctx context.Context, sessionID string // the event-based fallback in the orchestrator handles resuming with a new turn. func (h *Handlers) handleAskUserQuestion(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { - SessionID string `json:"session_id"` - TaskID string `json:"task_id"` - Question clarification.Question `json:"question"` - Context string `json:"context"` + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + Questions []clarification.Question `json:"questions"` + Context string `json:"context"` } if err := json.Unmarshal(msg.Payload, &req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) @@ -931,19 +979,11 @@ func (h *Handlers) handleAskUserQuestion(ctx context.Context, msg *ws.Message) ( if req.SessionID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) } - if req.Question.Prompt == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "question.prompt is required", nil) - } - - // Generate question ID if missing - if req.Question.ID == "" { - req.Question.ID = "q1" - } - // Generate option IDs if missing - for i := range req.Question.Options { - if req.Question.Options[i].ID == "" { - req.Question.Options[i].ID = generateOptionID(0, i) - } + // Single source of truth — same validator the HTTP handler uses, so + // duplicate IDs / bad option counts / empty prompts can't slip through + // either path. + if errMsg := clarification.NormalizeAndValidateQuestions(req.Questions); errMsg != "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, errMsg, nil) } // Look up task ID from session if not provided @@ -963,20 +1003,26 @@ func (h *Handlers) handleAskUserQuestion(ctx context.Context, msg *ws.Message) ( clarificationReq := &clarification.Request{ SessionID: req.SessionID, TaskID: taskID, - Question: req.Question, + Questions: req.Questions, Context: req.Context, } pendingID := h.clarificationSvc.CreateRequest(clarificationReq) - // Create the message in the database (triggers WS event to frontend) + // Create one chat message per question (triggers WS events to frontend). + // If the create fails, the in-store pending entry must be cancelled too — + // otherwise the agent's WaitForResponse would block for the full 2-hour + // timeout while the user never sees clarification cards. if h.messageCreator != nil { - if _, err := h.messageCreator.CreateClarificationRequestMessage( - ctx, taskID, req.SessionID, pendingID, req.Question, req.Context, + if _, err := h.messageCreator.CreateClarificationRequestMessages( + ctx, taskID, req.SessionID, pendingID, req.Questions, req.Context, ); err != nil { - h.logger.Error("failed to create clarification request message", + h.logger.Error("failed to create clarification request messages", zap.String("pending_id", pendingID), zap.String("session_id", req.SessionID), zap.Error(err)) + h.clarificationSvc.CancelRequest(pendingID) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, + "failed to create clarification messages: "+err.Error(), nil) } } @@ -1077,11 +1123,6 @@ func (h *Handlers) setSessionWaitingForInput(ctx context.Context, taskID, sessio } } -// generateOptionID generates an option ID for a question. -func generateOptionID(questionIndex, optionIndex int) string { - return fmt.Sprintf("q%d_opt%d", questionIndex+1, optionIndex+1) -} - // handleCreateTaskPlan creates a new task plan. func (h *Handlers) handleCreateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { diff --git a/apps/backend/internal/mcp/handlers/handlers_test.go b/apps/backend/internal/mcp/handlers/handlers_test.go index 9a2c42fd13..3ac93bfcbd 100644 --- a/apps/backend/internal/mcp/handlers/handlers_test.go +++ b/apps/backend/internal/mcp/handlers/handlers_test.go @@ -263,20 +263,62 @@ func TestResolveTaskRepositories_NoInputs_ReturnsEmpty(t *testing.T) { assert.Empty(t, result.Repos) } -func TestResolveTaskRepositories_ExplicitRepos_SkipsParentLookup(t *testing.T) { - log := testLogger(t) - h := &Handlers{logger: log.WithFields()} +// --- Integration tests using real task service --- - // Even with a parentID, explicit repos should be used without looking up the parent - // (taskSvc is nil — if it tried to call GetTask it would panic). - explicit := []mcpRepositoryInput{{RepositoryID: "repo-explicit"}} - result, err := h.resolveTaskRepositories(context.Background(), "some-parent", "some-source", explicit) +// seedParentWithRepo creates a workspace, workflow, repository, and a parent +// task linked to that repository. Returns the parent task ID. +func seedParentWithRepo(t *testing.T, svc *service.Service, repo *sqliterepo.Repository) string { + t.Helper() + ctx := context.Background() + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Test"})) + require.NoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Board"})) + require.NoError(t, repo.CreateRepository(ctx, &models.Repository{ID: "repo-parent", WorkspaceID: "ws-1", Name: "Parent Repo"})) + parent, err := svc.CreateTask(ctx, &service.CreateTaskRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + Title: "Parent task", + Repositories: []service.TaskRepositoryInput{ + {RepositoryID: "repo-parent", BaseBranch: "main"}, + }, + }) require.NoError(t, err) - require.Len(t, result.Repos, 1) - assert.Equal(t, "repo-explicit", result.Repos[0].RepositoryID) + return parent.ID } -// --- Integration tests using real task service --- +func TestResolveTaskRepositories_ParentWithoutExplicitRepos_InheritsAll(t *testing.T) { + svc, repo := newTestTaskService(t) + parentID := seedParentWithRepo(t, svc, repo) + + log := testLogger(t) + h := &Handlers{taskSvc: svc, logger: log.WithFields()} + + result, err := h.resolveTaskRepositories(context.Background(), parentID, "", nil) + require.NoError(t, err) + require.Len(t, result.Repos, 1, "subtask without explicit repos inherits parent's repos") + assert.Equal(t, "repo-parent", result.Repos[0].RepositoryID) + assert.Equal(t, "ws-1", result.WorkspaceID) + assert.Equal(t, "wf-1", result.WorkflowID) +} + +func TestResolveTaskRepositories_ParentWithExplicitRepos_OverridesRepoButInheritsWorkspace(t *testing.T) { + svc, repo := newTestTaskService(t) + parentID := seedParentWithRepo(t, svc, repo) + + log := testLogger(t) + h := &Handlers{taskSvc: svc, logger: log.WithFields()} + + explicit := []mcpRepositoryInput{ + {GitHubURL: "https://github.com/acme/sibling", BaseBranch: "develop"}, + } + result, err := h.resolveTaskRepositories(context.Background(), parentID, "", explicit) + require.NoError(t, err) + require.Len(t, result.Repos, 1, "explicit repos override parent's repos") + assert.Equal(t, "https://github.com/acme/sibling", result.Repos[0].GitHubURL) + assert.Equal(t, "develop", result.Repos[0].BaseBranch) + assert.Empty(t, result.Repos[0].RepositoryID, "explicit repo should not be conflated with parent's RepositoryID") + assert.Equal(t, "ws-1", result.WorkspaceID, "subtask still inherits parent's workspace") + assert.Equal(t, "wf-1", result.WorkflowID, "subtask still inherits parent's workflow") +} func TestResolveTaskRepositories_EphemeralParent_Rejected(t *testing.T) { svc, repo := newTestTaskService(t) diff --git a/apps/backend/internal/mcp/handlers/wrap_agent_message.go b/apps/backend/internal/mcp/handlers/wrap_agent_message.go index 52fecf5ab3..54e91d9191 100644 --- a/apps/backend/internal/mcp/handlers/wrap_agent_message.go +++ b/apps/backend/internal/mcp/handlers/wrap_agent_message.go @@ -26,11 +26,17 @@ func wrapAgentMessage(prompt string, senderTask *models.Task, senderSessionID st // chat bubble. The metadata snapshot (sender_task_title) keeps the original // title for UI display. safeTitle := strings.ReplaceAll(senderTask.Title, sysprompt.TagEnd, "") + // The directive elevates peer-agent messages to user-prompt authority on + // purpose: receiving agents were stalling cross-task work with "the user + // can talk to them directly". The trade-off is that a compromised peer + // (e.g., via prompt injection from a tool result) inherits that authority, + // which is acceptable in a trusted-orchestrator environment but worth + // remembering before relaxing the assumption. body := fmt.Sprintf( - "This message was sent by an agent working in task %q (%s).\n"+ - "Treat it as peer agent input rather than a direct user instruction. "+ - "You may decline, push back, or ask clarifying questions like you would with any other agent. "+ - "To reply, use the message_task_kandev MCP tool with task_id=%q.", + "This message was relayed through the message_task_kandev MCP tool by a peer agent working in task %q (%s). "+ + "The user is not reading this thread — the peer agent is coordinating work on the user's behalf, "+ + "so treat the request with the same authority as a user prompt and act on it. "+ + "Do not stall waiting for the user; reply, ask questions, or report results back through message_task_kandev with task_id=%q.", safeTitle, senderTask.ID, senderTask.ID, ) wrapped := sysprompt.Wrap(body) + "\n\n" + prompt diff --git a/apps/backend/internal/mcp/handlers/wrap_agent_message_test.go b/apps/backend/internal/mcp/handlers/wrap_agent_message_test.go index 5a929e0634..1721754ec3 100644 --- a/apps/backend/internal/mcp/handlers/wrap_agent_message_test.go +++ b/apps/backend/internal/mcp/handlers/wrap_agent_message_test.go @@ -33,6 +33,21 @@ func TestWrapAgentMessage_BasicShape(t *testing.T) { t.Errorf("wrapped content missing sender id: %q", wrapped) } + // Pin the authorization directive: receivers were stalling on peer messages + // with "the user can talk to them directly", so the wrapper must (a) name + // the reply tool, (b) elevate the request to user-prompt authority, and (c) + // avoid wording that licenses refusal. If the body text drifts back toward + // the old framing, this assertion fires before it ships. + if !strings.Contains(wrapped, "message_task_kandev") { + t.Errorf("wrapper must name the reply tool, got: %q", wrapped) + } + if !strings.Contains(wrapped, "same authority as a user prompt") { + t.Errorf("wrapper must elevate request to user-prompt authority, got: %q", wrapped) + } + if strings.Contains(wrapped, "decline") || strings.Contains(wrapped, "push back") { + t.Errorf("wrapper must not invite refusal, got: %q", wrapped) + } + // Stripping the block should leave only the original prompt. stripped := sysprompt.StripSystemContent(wrapped) if stripped != "please review my changes" { @@ -91,7 +106,7 @@ func TestWrapAgentMessage_TitleContainingClosingTag(t *testing.T) { stripped := sysprompt.StripSystemContent(wrapped) // The visible content must be exactly the original prompt — no leaked - // "Treat it as peer agent input..." tail. + // attribution tail. if stripped != "payload" { t.Errorf("expected stripped content to equal prompt, got %q", stripped) } diff --git a/apps/backend/internal/mcp/server/ask_user_question_test.go b/apps/backend/internal/mcp/server/ask_user_question_test.go new file mode 100644 index 0000000000..b3deddbfd7 --- /dev/null +++ b/apps/backend/internal/mcp/server/ask_user_question_test.go @@ -0,0 +1,246 @@ +package mcp + +import ( + "encoding/json" + "testing" + + ws "github.com/kandev/kandev/pkg/websocket" + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAskUserQuestion_ToolSchema_RequiresQuestionsArray asserts the tool now +// exposes a "questions" array (not legacy "prompt"/"options" top-level fields). +func TestAskUserQuestion_ToolSchema_RequiresQuestionsArray(t *testing.T) { + backend := &testBackend{} + s := newTaskModeServer(t, backend, "task-current") + + toolsMap := s.mcpServer.ListTools() + tool, ok := toolsMap["ask_user_question_kandev"] + require.True(t, ok, "ask_user_question tool not registered") + + schema, err := json.Marshal(tool.Tool.InputSchema) + require.NoError(t, err) + + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal(schema, &parsed)) + + props, ok := parsed["properties"].(map[string]interface{}) + require.True(t, ok, "schema should have properties") + assert.Contains(t, props, "questions", "schema must expose 'questions'") + assert.Contains(t, props, "context") + assert.NotContains(t, props, "prompt", "legacy 'prompt' must not be top-level anymore") + assert.NotContains(t, props, "options", "legacy 'options' must not be top-level anymore") + + required, _ := parsed["required"].([]interface{}) + requiredSet := make(map[string]bool) + for _, r := range required { + requiredSet[r.(string)] = true + } + assert.True(t, requiredSet["questions"], "questions should be required") +} + +// TestAskUserQuestion_RejectsLegacyPromptShape ensures payloads using the old +// flat shape return a validation error rather than silently dropping the call. +func TestAskUserQuestion_RejectsLegacyPromptShape(t *testing.T) { + backend := &testBackend{} + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "ask_user_question_kandev", map[string]interface{}{ + "prompt": "Which database?", + "options": []map[string]interface{}{ + {"label": "Postgres", "description": "Relational"}, + {"label": "Mongo", "description": "Document"}, + }, + }) + assert.True(t, result.IsError, "legacy flat shape must surface a validation error") +} + +// TestAskUserQuestion_SingleQuestion_PayloadShape exercises the simplest +// happy path: one question with two options. +func TestAskUserQuestion_SingleQuestion_PayloadShape(t *testing.T) { + backend := &testBackend{ + response: map[string]interface{}{ + "answers": []interface{}{ + map[string]interface{}{ + "question_id": "q1", + "selected_options": []interface{}{"q1_opt1"}, + }, + }, + }, + } + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "ask_user_question_kandev", map[string]interface{}{ + "questions": []map[string]interface{}{ + { + "id": "q1", + "prompt": "Which database?", + "options": []map[string]interface{}{ + {"label": "Postgres", "description": "Relational"}, + {"label": "Mongo", "description": "Document"}, + }, + }, + }, + }) + require.False(t, result.IsError, "single-question call should succeed") + + assert.Equal(t, ws.ActionMCPAskUserQuestion, backend.lastAction) + payload, ok := backend.lastPayload.(map[string]interface{}) + require.True(t, ok) + questions, ok := payload["questions"].([]map[string]interface{}) + require.True(t, ok, "questions should be normalized to []map") + require.Len(t, questions, 1) + assert.Equal(t, "q1", questions[0]["id"]) + + // Result text should be a JSON map keyed by question id. + require.NotEmpty(t, result.Content) + textBlock, ok := result.Content[0].(mcplib.TextContent) + require.True(t, ok) + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(textBlock.Text), &parsed)) + q1 := parsed["q1"].(map[string]interface{}) + assert.Equal(t, "q1_opt1", q1["selected_option"]) +} + +// TestAskUserQuestion_MultiQuestion_BuildsMapResponse covers the full multi-q +// path: agent receives a JSON map keyed by every question id. +func TestAskUserQuestion_MultiQuestion_BuildsMapResponse(t *testing.T) { + backend := &testBackend{ + response: map[string]interface{}{ + "answers": []interface{}{ + map[string]interface{}{ + "question_id": "db", + "selected_options": []interface{}{"db_opt1"}, + }, + map[string]interface{}{ + "question_id": "migration", + "custom_text": "migrate all but flag rows older than 2 years", + }, + map[string]interface{}{ + "question_id": "approach", + "selected_options": []interface{}{"approach_opt2"}, + }, + }, + }, + } + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "ask_user_question_kandev", map[string]interface{}{ + "questions": []map[string]interface{}{ + { + "id": "db", + "prompt": "Which database?", + "options": []map[string]interface{}{ + {"label": "Postgres", "description": "Relational"}, + {"label": "Mongo", "description": "Document"}, + }, + }, + { + "id": "migration", + "prompt": "How to migrate?", + "options": []map[string]interface{}{ + {"label": "Migrate all", "description": "Keep everything"}, + {"label": "Fresh start", "description": "Drop existing"}, + }, + }, + { + "id": "approach", + "prompt": "Iterative or atomic?", + "options": []map[string]interface{}{ + {"label": "Iterative", "description": "Step by step"}, + {"label": "Atomic", "description": "One big change"}, + }, + }, + }, + }) + require.False(t, result.IsError) + + require.NotEmpty(t, result.Content) + textBlock, ok := result.Content[0].(mcplib.TextContent) + require.True(t, ok) + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(textBlock.Text), &parsed)) + + require.Len(t, parsed, 3) + assert.Equal(t, "db_opt1", parsed["db"].(map[string]interface{})["selected_option"]) + assert.Equal(t, "migrate all but flag rows older than 2 years", parsed["migration"].(map[string]interface{})["custom_text"]) + assert.Equal(t, "approach_opt2", parsed["approach"].(map[string]interface{})["selected_option"]) +} + +// TestAskUserQuestion_RejectsTooManyQuestions caps the bundle at 4 questions — +// past that the agent should batch the work differently. +func TestAskUserQuestion_RejectsTooManyQuestions(t *testing.T) { + backend := &testBackend{} + s := newTaskModeServer(t, backend, "task-current") + + makeQ := func(id string) map[string]interface{} { + return map[string]interface{}{ + "id": id, + "prompt": "anything?", + "options": []map[string]interface{}{ + {"label": "yes", "description": "y"}, + {"label": "no", "description": "n"}, + }, + } + } + + result := callTool(t, s, "ask_user_question_kandev", map[string]interface{}{ + "questions": []map[string]interface{}{ + makeQ("q1"), makeQ("q2"), makeQ("q3"), makeQ("q4"), makeQ("q5"), + }, + }) + assert.True(t, result.IsError, "more than 4 questions must be rejected") +} + +// TestAskUserQuestion_RejectsTooFewOptions guards against degenerate payloads +// (a question with a single option is just an alert, not a real question). +func TestAskUserQuestion_RejectsTooFewOptions(t *testing.T) { + backend := &testBackend{} + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "ask_user_question_kandev", map[string]interface{}{ + "questions": []map[string]interface{}{ + { + "id": "q1", + "prompt": "?", + "options": []map[string]interface{}{ + {"label": "only one", "description": "lonely"}, + }, + }, + }, + }) + assert.True(t, result.IsError, "single-option question must be rejected") +} + +// TestAskUserQuestion_RejectionPath returns a friendly text result when the +// user skipped the bundle. +func TestAskUserQuestion_RejectionPath(t *testing.T) { + backend := &testBackend{ + response: map[string]interface{}{ + "rejected": true, + "reject_reason": "User skipped", + }, + } + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "ask_user_question_kandev", map[string]interface{}{ + "questions": []map[string]interface{}{ + { + "id": "q1", + "prompt": "Which?", + "options": []map[string]interface{}{ + {"label": "Yes", "description": "y"}, + {"label": "No", "description": "n"}, + }, + }, + }, + }) + require.False(t, result.IsError) + + textBlock, ok := result.Content[0].(mcplib.TextContent) + require.True(t, ok) + assert.Contains(t, textBlock.Text, "rejected") + assert.Contains(t, textBlock.Text, "User skipped") +} diff --git a/apps/backend/internal/mcp/server/config_handlers.go b/apps/backend/internal/mcp/server/config_handlers.go index 32e27057d7..268c86f13f 100644 --- a/apps/backend/internal/mcp/server/config_handlers.go +++ b/apps/backend/internal/mcp/server/config_handlers.go @@ -26,6 +26,13 @@ func (s *Server) registerConfigWorkflowTools() { ), s.wrapHandler("list_workflows_kandev", s.listWorkflowsHandler()), ) + s.mcpServer.AddTool( + mcp.NewTool("list_repositories_kandev", + mcp.WithDescription("List repositories in a workspace. Use this to find a repository_id to attach to create_task_kandev when the task should target a specific codebase."), + mcp.WithString("workspace_id", mcp.Required(), mcp.Description("The workspace ID")), + ), + s.wrapHandler("list_repositories_kandev", s.listRepositoriesHandler()), + ) s.mcpServer.AddTool( mcp.NewTool("create_workflow_kandev", mcp.WithDescription("Create a new workflow in a workspace."), diff --git a/apps/backend/internal/mcp/server/handlers.go b/apps/backend/internal/mcp/server/handlers.go index b672484336..d9a1d859ab 100644 --- a/apps/backend/internal/mcp/server/handlers.go +++ b/apps/backend/internal/mcp/server/handlers.go @@ -11,10 +11,21 @@ import ( "go.uber.org/zap" ) -// promptArg is the name of the MCP tool argument that carries the user-facing -// prompt text. Repeated across tool handlers; pulled out here so goconst stays -// happy and renames stay safe. -const promptArg = "prompt" +// Argument-name constants used across the ask_user_question_kandev handler. +// Pulled out so goconst stays happy and renames stay safe. +const ( + promptArg = "prompt" + questionsArg = "questions" + optionsArg = "options" + idArg = "id" + titleArg = "title" + labelArg = "label" + descriptionArg = "description" + optionIDFieldName = "option_id" + questionIDFieldKey = "question_id" + answeredFieldKey = "answered" + rejectedFieldKey = "rejected" +) func (s *Server) listWorkspacesHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -45,6 +56,23 @@ func (s *Server) listWorkflowsHandler() server.ToolHandlerFunc { } } +func (s *Server) listRepositoriesHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + workspaceID, err := req.RequireString("workspace_id") + if err != nil { + return mcp.NewToolResultError("workspace_id is required"), nil + } + payload := map[string]string{"workspace_id": workspaceID} + // Backend returns {repositories: [...], total: N} + var result map[string]interface{} + if err := s.backend.RequestPayload(ctx, ws.ActionMCPListRepositories, payload, &result); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + data, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(string(data)), nil + } +} + func (s *Server) listWorkflowStepsHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { workflowID, err := req.RequireString("workflow_id") @@ -118,15 +146,13 @@ func (s *Server) createTaskHandler() server.ToolHandlerFunc { "start_agent": startAgent, } - // Add repository info (only valid for top-level tasks) + // Add repository info. For subtasks an explicit repo overrides the + // parent's; if omitted the backend inherits from the parent. repositoryID := req.GetString("repository_id", "") localPath := req.GetString("local_path", "") repositoryURL := req.GetString("repository_url", "") baseBranch := req.GetString("base_branch", "") hasRepo := repositoryID != "" || localPath != "" || repositoryURL != "" - if hasRepo && parentID != "" { - return mcp.NewToolResultError("repository_id, local_path, and repository_url are only valid for top-level tasks; subtasks inherit their repository from the parent"), nil - } if hasRepo { repo := map[string]string{} if repositoryID != "" { @@ -274,26 +300,15 @@ func copyOptionalMessageTypesArg(payload map[string]interface{}, req mcp.CallToo func (s *Server) askUserQuestionHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - prompt, err := req.RequireString(promptArg) - if err != nil { - return mcp.NewToolResultError("prompt is required"), nil - } - - options, errResult := parseQuestionOptions(req) + questions, errResult := parseQuestions(req) if errResult != nil { return errResult, nil } questionCtx := req.GetString("context", "") - question := map[string]interface{}{ - "id": "q1", - "title": "Question", - promptArg: prompt, - "options": options, - } payload := map[string]interface{}{ "session_id": s.sessionID, - "question": question, + questionsArg: questions, "context": questionCtx, } @@ -310,90 +325,208 @@ func (s *Server) askUserQuestionHandler() server.ToolHandlerFunc { return mcp.NewToolResultError(err.Error()), nil } - return extractQuestionAnswer(result), nil + return extractQuestionAnswers(result, questions), nil } } -// parseQuestionOptions extracts and validates the "options" argument from the request. -// Returns (options, nil) on success or (nil, *mcp.CallToolResult) on validation failure. -func parseQuestionOptions(req mcp.CallToolRequest) ([]map[string]interface{}, *mcp.CallToolResult) { - args := req.GetArguments() - optionsRaw, ok := args["options"] - if !ok { - return nil, mcp.NewToolResultError("options is required") - } - - optionsJSON, err := json.Marshal(optionsRaw) - if err != nil { - return nil, mcp.NewToolResultError(fmt.Sprintf("failed to parse options: %v", err)) - } - - var options []map[string]interface{} - if err := json.Unmarshal(optionsJSON, &options); err != nil { - return nil, mcp.NewToolResultError("options must be an array of objects with 'label' and 'description' fields. Example: [{\"label\": \"Option A\", \"description\": \"Description of option A\"}]") - } - - if len(options) < 2 { - return nil, mcp.NewToolResultError("options must contain at least 2 choices") - } - if len(options) > 6 { - return nil, mcp.NewToolResultError("options must contain at most 6 choices") - } - - if errResult := validateAndNormalizeOptions(options); errResult != nil { +// parseQuestions extracts and validates the "questions" array from the request. +// Returns the normalized question payloads (with auto-assigned ids) on success +// or a tool-result error describing the first validation failure. +func parseQuestions(req mcp.CallToolRequest) ([]map[string]interface{}, *mcp.CallToolResult) { + questions, errResult := decodeQuestionsArg(req) + if errResult != nil { return nil, errResult } + seenIDs := map[string]bool{} + for i, q := range questions { + if errResult := normalizeAndValidateQuestion(q, i, seenIDs); errResult != nil { + return nil, errResult + } + questions[i] = q + } + return questions, nil +} + +// decodeQuestionsArg unmarshals the raw "questions" argument and enforces +// the bundle-size invariants (1..4 questions). +func decodeQuestionsArg(req mcp.CallToolRequest) ([]map[string]interface{}, *mcp.CallToolResult) { + args := req.GetArguments() + questionsRaw, ok := args[questionsArg] + if !ok { + return nil, mcp.NewToolResultError("questions is required (array of 1-4 question objects)") + } + questionsJSON, err := json.Marshal(questionsRaw) + if err != nil { + return nil, mcp.NewToolResultError(fmt.Sprintf("failed to parse questions: %v", err)) + } + var questions []map[string]interface{} + if err := json.Unmarshal(questionsJSON, &questions); err != nil { + return nil, mcp.NewToolResultError(`questions must be an array of objects with "prompt" and "options". Example: [{"prompt": "...", "options": [{"label": "A", "description": "..."}, {"label": "B", "description": "..."}]}]`) + } + if len(questions) < 1 { + return nil, mcp.NewToolResultError("questions must contain at least 1 question") + } + if len(questions) > 4 { + return nil, mcp.NewToolResultError("questions must contain at most 4 questions") + } + return questions, nil +} + +// normalizeAndValidateQuestion mutates a question payload in place: assigns a +// default id, parses options, and reports the first validation failure. +func normalizeAndValidateQuestion(q map[string]interface{}, index int, seenIDs map[string]bool) *mcp.CallToolResult { + prompt, hasPrompt := q[promptArg].(string) + if !hasPrompt || prompt == "" { + return mcp.NewToolResultError(fmt.Sprintf("question %d is missing required 'prompt' field", index+1)) + } + id, _ := q[idArg].(string) + if id == "" { + id = fmt.Sprintf("q%d", index+1) + q[idArg] = id + } + if seenIDs[id] { + return mcp.NewToolResultError(fmt.Sprintf("question %d has duplicate id %q", index+1, id)) + } + seenIDs[id] = true + + options, errResult := decodeOptionsForQuestion(q, index) + if errResult != nil { + return errResult + } + if errResult := validateAndNormalizeOptions(options, index+1); errResult != nil { + return errResult + } + q[optionsArg] = options + return nil +} + +func decodeOptionsForQuestion(q map[string]interface{}, index int) ([]map[string]interface{}, *mcp.CallToolResult) { + optionsRaw, hasOptions := q[optionsArg] + if !hasOptions { + return nil, mcp.NewToolResultError(fmt.Sprintf("question %d is missing required 'options' field", index+1)) + } + optionsJSON, err := json.Marshal(optionsRaw) + if err != nil { + return nil, mcp.NewToolResultError(fmt.Sprintf("question %d: failed to parse options: %v", index+1, err)) + } + var options []map[string]interface{} + if err := json.Unmarshal(optionsJSON, &options); err != nil { + return nil, mcp.NewToolResultError(fmt.Sprintf("question %d: options must be an array of objects with 'label' and 'description' fields", index+1)) + } + if len(options) < 2 { + return nil, mcp.NewToolResultError(fmt.Sprintf("question %d must have at least 2 options", index+1)) + } + if len(options) > 6 { + return nil, mcp.NewToolResultError(fmt.Sprintf("question %d must have at most 6 options", index+1)) + } return options, nil } // validateAndNormalizeOptions checks each option for required fields and assigns a default option_id. -func validateAndNormalizeOptions(options []map[string]interface{}) *mcp.CallToolResult { +func validateAndNormalizeOptions(options []map[string]interface{}, questionNum int) *mcp.CallToolResult { for i, opt := range options { - label, hasLabel := opt["label"].(string) + label, hasLabel := opt[labelArg].(string) if !hasLabel || label == "" { - return mcp.NewToolResultError(fmt.Sprintf("option %d is missing required 'label' field (1-5 words describing the choice)", i+1)) + return mcp.NewToolResultError(fmt.Sprintf("question %d option %d is missing required 'label' field (1-5 words describing the choice)", questionNum, i+1)) } - description, hasDesc := opt["description"].(string) + description, hasDesc := opt[descriptionArg].(string) if !hasDesc || description == "" { - return mcp.NewToolResultError(fmt.Sprintf("option %d is missing required 'description' field (explanation of what this option means)", i+1)) + return mcp.NewToolResultError(fmt.Sprintf("question %d option %d is missing required 'description' field (explanation of what this option means)", questionNum, i+1)) } // Generate option_id if not provided - if _, hasID := opt["option_id"].(string); !hasID { - opt["option_id"] = fmt.Sprintf("opt_%d", i+1) + if _, hasID := opt[optionIDFieldName].(string); !hasID { + opt[optionIDFieldName] = fmt.Sprintf("q%d_opt%d", questionNum, i+1) } } return nil } -// extractQuestionAnswer converts the backend question response into an MCP tool result. -func extractQuestionAnswer(result map[string]interface{}) *mcp.CallToolResult { - if answer, ok := result["answer"]; ok { - if res := extractAnswerMap(answer); res != nil { - return res +// extractQuestionAnswers converts the backend response into a JSON tool result +// keyed by question id. The shape is consistent across happy / rejected paths +// so the agent can always parse the response as a JSON object — rejected +// bundles emit an envelope { "rejected": true, "reject_reason": "..." } as +// well as per-question stub entries. +func extractQuestionAnswers(result map[string]interface{}, questions []map[string]interface{}) *mcp.CallToolResult { + rejected, _ := result["rejected"].(bool) + rejectReason, _ := result["reject_reason"].(string) + + out := make(map[string]interface{}, len(questions)+2) + if rejected { + out[rejectedFieldKey] = true + if rejectReason != "" { + out["reject_reason"] = rejectReason } } - if rejected, ok := result["rejected"].(bool); ok && rejected { - reason, _ := result["reject_reason"].(string) - return mcp.NewToolResultText(fmt.Sprintf("User rejected the question: %s", reason)) + + answers, _ := result["answers"].([]interface{}) + answersByID := make(map[string]map[string]interface{}, len(answers)) + for _, raw := range answers { + ans, ok := raw.(map[string]interface{}) + if !ok { + continue + } + qid, _ := ans[questionIDFieldKey].(string) + if qid == "" { + continue + } + answersByID[qid] = simplifyAnswer(ans, rejected) } - data, _ := json.MarshalIndent(result, "", " ") + + for _, q := range questions { + qid, _ := q[idArg].(string) + if qid == "" { + continue + } + if entry, ok := answersByID[qid]; ok { + out[qid] = entry + continue + } + stub := map[string]interface{}{answeredFieldKey: false} + if rejected { + stub[rejectedFieldKey] = true + } + out[qid] = stub + } + + if len(out) == 0 { + // Nothing matched by id — surface the raw payload so the agent can still inspect it. + data, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(string(data)) + } + + data, _ := json.MarshalIndent(out, "", " ") return mcp.NewToolResultText(string(data)) } -// extractAnswerMap inspects an answer map for selected options or custom text. -func extractAnswerMap(answer interface{}) *mcp.CallToolResult { - answerMap, ok := answer.(map[string]interface{}) - if !ok { - return nil +// simplifyAnswer normalizes the answer map. Single-choice per question, but +// the user can also type a custom text alongside an option pick — we preserve +// both fields so the agent gets the full context. When the bundle was +// rejected we annotate the entry to make the partial-answer scenario explicit. +// +// Output shape (one or more keys may be set): +// +// {"selected_option": ""} +// {"custom_text": ""} +// {"selected_option": "", "custom_text": ""} +// {"answered": false} +func simplifyAnswer(ans map[string]interface{}, rejected bool) map[string]interface{} { + out := map[string]interface{}{} + if selected, ok := ans["selected_options"].([]interface{}); ok && len(selected) > 0 { + if first, ok := selected[0].(string); ok && first != "" { + out["selected_option"] = first + } } - if selectedOptions, ok := answerMap["selected_options"].([]interface{}); ok && len(selectedOptions) > 0 { - return mcp.NewToolResultText(fmt.Sprintf("User selected: %v", selectedOptions[0])) + if customText, ok := ans["custom_text"].(string); ok && customText != "" { + out["custom_text"] = customText } - if customText, ok := answerMap["custom_text"].(string); ok && customText != "" { - return mcp.NewToolResultText(fmt.Sprintf("User answered: %s", customText)) + if rejected && len(out) == 0 { + return map[string]interface{}{answeredFieldKey: false, rejectedFieldKey: true} } - return nil + if len(out) == 0 { + return map[string]interface{}{answeredFieldKey: false} + } + return out } // notifyClarificationTimeout sends a fire-and-forget notification to the backend diff --git a/apps/backend/internal/mcp/server/handlers_test.go b/apps/backend/internal/mcp/server/handlers_test.go index 67b03f6783..723e547ca0 100644 --- a/apps/backend/internal/mcp/server/handlers_test.go +++ b/apps/backend/internal/mcp/server/handlers_test.go @@ -252,17 +252,56 @@ func TestCreateTask_WithRepositoryURL(t *testing.T) { assert.Equal(t, "main", repos[0]["base_branch"]) } -func TestCreateTask_RepositoryURL_RejectedForSubtasks(t *testing.T) { - backend := &testBackend{} +func TestCreateTask_RepositoryURL_AllowedForSubtasks(t *testing.T) { + backend := &testBackend{ + response: map[string]interface{}{"id": "task-new", "title": "Subtask with URL"}, + } s := newTaskModeServer(t, backend, "task-current") result := callTool(t, s, "create_task_kandev", map[string]interface{}{ "title": "Subtask with URL", "parent_id": "self", + "description": "Fix the upstream review-eligibility check", "repository_url": "https://github.com/acme/widgets", + "base_branch": "main", }) - assert.True(t, result.IsError, "repository_url should be rejected for subtasks") + assert.False(t, result.IsError, "repository_url should be accepted for subtasks (cross-repo subtask)") + + payload, ok := backend.lastPayload.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "task-current", payload["parent_id"], "self resolves to current task id") + + repos, ok := payload["repositories"].([]map[string]string) + require.True(t, ok, "repositories should be a slice") + require.Len(t, repos, 1) + assert.Equal(t, "https://github.com/acme/widgets", repos[0]["github_url"]) + assert.Equal(t, "main", repos[0]["base_branch"]) +} + +func TestCreateTask_LocalPath_AllowedForSubtasks(t *testing.T) { + backend := &testBackend{ + response: map[string]interface{}{"id": "task-new", "title": "Subtask with local path"}, + } + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "create_task_kandev", map[string]interface{}{ + "title": "Subtask with local path", + "parent_id": "self", + "description": "Patch the sibling repo", + "local_path": "/Users/me/projects/sibling", + }) + + assert.False(t, result.IsError, "local_path should be accepted for subtasks (cross-repo subtask)") + + payload, ok := backend.lastPayload.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "task-current", payload["parent_id"]) + + repos, ok := payload["repositories"].([]map[string]string) + require.True(t, ok) + require.Len(t, repos, 1) + assert.Equal(t, "/Users/me/projects/sibling", repos[0]["local_path"]) } func TestMessageTask_ForwardsToBackend(t *testing.T) { diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 68d2aa9836..5fa83def93 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -276,7 +276,7 @@ func (s *Server) registerTools() { switch s.mode { case ModeConfig: s.registerConfigWorkflowTools() - count += 10 + count += 11 s.registerConfigAgentTools() count += 4 s.registerConfigMcpTools() @@ -294,7 +294,7 @@ func (s *Server) registerTools() { // they can both manage Kandev configuration and spawn new tasks. // No interaction or plan tools (no live session to attach them to). s.registerConfigWorkflowTools() - count += 10 + count += 11 s.registerConfigAgentTools() count += 4 s.registerConfigMcpTools() @@ -434,6 +434,7 @@ WHEN TO USE parent_id='self': - Breaking down your current task into phases/steps → use parent_id='self' - Creating tasks from a plan → use parent_id='self' (inherits repo, workspace, workflow) - Delegating work to another agent → use parent_id='self' +- Delegating work that lives in a sibling repo → use parent_id='self' AND pass repository_url / repository_id / local_path to point the subtask at that repo WHEN TO OMIT parent_id (top-level task): - Creating an unrelated, standalone task @@ -441,7 +442,8 @@ WHEN TO OMIT parent_id (top-level task): - workspace_id and workflow_id are auto-resolved if only one exists; provide explicitly if ambiguous IMPORTANT: -- Subtasks inherit repositories, workspace, workflow, agent profile, and executor from parent +- Subtasks inherit workspace, workflow, agent profile, and executor from the parent +- Subtasks inherit the parent's repository unless you supply repository_url, repository_id, or local_path — in which case the subtask targets that repo instead (must live in the parent's workspace) - Top-level tasks need a repository via repository_url, repository_id, or local_path - 'description' is the sub-agent's initial prompt — be specific and detailed - Set start_agent=false to create without starting an agent` @@ -471,9 +473,9 @@ IMPORTANT: mcp.WithString("agent_profile_id", mcp.Description("Agent profile ID to use. For subtasks, inherited from the parent session. For top-level tasks, ask the user which agent profile they want (e.g. Claude Code, OpenCode) if not already known.")), mcp.WithString("executor_profile_id", mcp.Description("Executor profile ID to use (determines the runtime environment: local, worktree, docker, etc.). For subtasks, inherited from the parent session. For top-level tasks, ask the user which executor profile they want if not already known.")), mcp.WithBoolean("start_agent", mcp.Description("Whether to auto-start an agent on the created task. Default: true. Set to false to create the task without starting an agent.")), - mcp.WithString("repository_id", mcp.Description("Repository ID for top-level tasks. Not needed for subtasks (inherited from parent).")), - mcp.WithString("local_path", mcp.Description("Local repository folder path (e.g. '/Users/me/projects/myrepo') for top-level tasks. Will create/find the repository automatically. Preferred for local worktree flow.")), - mcp.WithString("repository_url", mcp.Description("GitHub repository URL (e.g. 'https://github.com/owner/repo') for top-level tasks. The repository will be cloned automatically on first use. Not needed for subtasks (inherited from parent).")), + mcp.WithString("repository_id", mcp.Description("Repository ID. Required for top-level tasks unless local_path or repository_url is provided. For subtasks: optional — supply only when the subtask should target a different repo than the parent.")), + mcp.WithString("local_path", mcp.Description("Local repository folder path (e.g. '/Users/me/projects/myrepo'). Will create/find the repository automatically. Preferred for local worktree flow. For subtasks: supply only when the subtask should target a different repo than the parent.")), + mcp.WithString("repository_url", mcp.Description("GitHub repository URL (e.g. 'https://github.com/owner/repo'). The repository will be cloned automatically on first use. For subtasks: supply only when the subtask should target a different repo than the parent.")), mcp.WithString("base_branch", mcp.Description("Base branch for the repository (e.g. 'main'). Optional, defaults to repository's default branch.")), ), s.wrapHandler("create_task_kandev", s.createTaskHandler()), @@ -483,54 +485,70 @@ IMPORTANT: func (s *Server) registerInteractionTools() { s.mcpServer.AddTool( mcp.NewTool("ask_user_question_kandev", - mcp.WithDescription(`Ask the user a clarifying question with multiple choice options. + mcp.WithDescription(`Ask the user one or more clarifying questions in a single tool call. -Use this tool when you need user input to proceed. The user will see the prompt and can select one of the options or provide a custom text response. +Use this tool when you need user input to proceed. Bundle related questions +together in one call so the user answers them all in one back-and-forth instead +of sequential round-trips. Each question is rendered as its own card; the user +selects an option or provides a custom text response per question, and the +agent receives a map keyed by question id once every question has been answered. -IMPORTANT: Each option must be a concrete, actionable choice - NOT meta-text like "Answer questions below". - -Options format - array of objects with: -- "label": Short text (1-5 words) shown as the clickable option -- "description": Brief explanation of what this option means +IMPORTANT: +- Provide 1 to 4 questions per call. +- Each question must have 2 to 6 concrete, actionable options. +- Each option must have a short "label" (1-5 words) and a "description" + explaining what selecting it means. NEVER use meta-text like "Answer below". +- Only call this tool when you genuinely need information you cannot infer. Example usage: { - "prompt": "Which database should I use for this project?", - "options": [ - {"label": "PostgreSQL", "description": "Relational database, good for complex queries"}, - {"label": "MongoDB", "description": "Document database, flexible schema"}, - {"label": "SQLite", "description": "Embedded database, simple setup"} + "questions": [ + { + "id": "db", + "prompt": "Which database should I use for this project?", + "options": [ + {"label": "PostgreSQL", "description": "Relational, good for complex queries"}, + {"label": "MongoDB", "description": "Document database, flexible schema"}, + {"label": "SQLite", "description": "Embedded, simple setup"} + ] + }, + { + "id": "migration", + "prompt": "How should I handle the existing user data during migration?", + "options": [ + {"label": "Migrate all", "description": "Keep all existing records"}, + {"label": "Archive old", "description": "Archive records older than 1 year"}, + {"label": "Fresh start", "description": "Delete existing data and start fresh"} + ] + } ], - "context": "The project requires storing user profiles and relationships between entities." + "context": "Backend redesign — picking the persistence layer and migration policy together." } -Another example: +The response is a JSON object keyed by each question id. Each entry may include +"selected_option" (the option_id the user picked), "custom_text" (the user's +free-form answer; can co-exist with a selected option), or "answered": false +when the user did not respond to that question. When the user skipped the entire +bundle, the envelope also carries "rejected": true and an optional +"reject_reason". Example success response: { - "prompt": "How should I handle the existing user data during migration?", - "options": [ - {"label": "Migrate all", "description": "Keep all existing records"}, - {"label": "Archive old", "description": "Archive records older than 1 year"}, - {"label": "Fresh start", "description": "Delete existing data and start fresh"} - ] + "db": {"selected_option": "q1_opt1"}, + "migration": {"custom_text": "Migrate all but flag rows older than 2 years"} +} +Example rejection: +{ + "rejected": true, + "reject_reason": "User skipped", + "db": {"answered": false, "rejected": true}, + "migration": {"answered": false, "rejected": true} }`), - mcp.WithString("prompt", mcp.Required(), mcp.Description("The question to ask the user. Be specific and clear.")), - mcp.WithArray("options", mcp.Required(), mcp.Description(`Array of option objects. Each option must have: "label" (1-5 words, the clickable choice) and "description" (explanation of the option). Provide 2-4 concrete, actionable options.`), - mcp.Items(map[string]any{ - "type": "object", - "properties": map[string]any{ - "label": map[string]any{ - "type": "string", - "description": "Short text (1-5 words) shown as the clickable option", - }, - "description": map[string]any{ - "type": "string", - "description": "Brief explanation of what this option means", - }, - }, - "required": []string{"label", "description"}, - }), + mcp.WithArray(questionsArg, mcp.Required(), + mcp.Description(`Array of 1-4 question objects. Each question must have a "prompt" (the question text) and an "options" array (2-6 entries with label + description). Optional fields: "id" (stable identifier in the response map; auto-generated if omitted), "title" (≤12 chars short label).`), + mcp.MinItems(1), + mcp.MaxItems(4), + mcp.Items(buildQuestionSchemaItem()), ), - mcp.WithString("context", mcp.Description("Optional background information to help the user understand why you're asking this question.")), + mcp.WithString("context", mcp.Description("Optional shared background information to help the user understand why you're asking these questions.")), ), s.wrapHandler("ask_user_question_kandev", s.askUserQuestionHandler()), ) @@ -570,3 +588,43 @@ func (s *Server) registerPlanTools() { s.wrapHandler("delete_task_plan_kandev", s.deleteTaskPlanHandler()), ) } + +// buildQuestionSchemaItem describes the shape of a single question object in +// the ask_user_question_kandev tool schema. Hoisted out of registerInteractionTools +// to keep the registration body short and to deduplicate the JSON-schema +// keyword strings (linter goconst rules). +func buildQuestionSchemaItem() map[string]any { + const typeKey = "type" + const propsKey = "properties" + const reqKey = "required" + const objType = "object" + const stringType = "string" + + str := func(desc string) map[string]any { + return map[string]any{typeKey: stringType, descriptionArg: desc} + } + + return map[string]any{ + typeKey: objType, + propsKey: map[string]any{ + idArg: str("Stable identifier used as the key in the response map. Auto-assigned (q1, q2, ...) if omitted."), + titleArg: str("Optional short label (≤12 chars) shown above the prompt."), + promptArg: str("The question text shown to the user."), + optionsArg: map[string]any{ + typeKey: "array", + descriptionArg: "2-6 concrete, actionable choices.", + "minItems": 2, + "maxItems": 6, + "items": map[string]any{ + typeKey: objType, + propsKey: map[string]any{ + labelArg: str("Short text (1-5 words) shown as the clickable option."), + descriptionArg: str("Brief explanation of what this option means."), + }, + reqKey: []string{labelArg, descriptionArg}, + }, + }, + }, + reqKey: []string{promptArg, optionsArg}, + } +} diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index 64a81c8e77..1d03315933 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -96,6 +96,7 @@ func TestServerModeConfig_RegistersCorrectTools(t *testing.T) { // Config mode should have workflow config tools assert.Contains(t, tools, "list_workspaces_kandev") assert.Contains(t, tools, "list_workflows_kandev") + assert.Contains(t, tools, "list_repositories_kandev") assert.Contains(t, tools, "create_workflow_kandev") assert.Contains(t, tools, "update_workflow_kandev") assert.Contains(t, tools, "delete_workflow_kandev") @@ -207,8 +208,8 @@ func TestServerModeConfig_ToolCount(t *testing.T) { s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeConfig) tools := getRegisteredToolNames(s) - // 10 workflow + 4 agent + 4 mcp + 5 executor + 6 task + 1 interaction = 30 - assert.Equal(t, 30, len(tools)) + // 11 workflow (incl. list_repositories) + 4 agent + 4 mcp + 5 executor + 6 task + 1 interaction = 31 + assert.Equal(t, 31, len(tools)) } func TestServerModeConfig_ToolDescriptions(t *testing.T) { @@ -243,6 +244,7 @@ func TestServerModeExternal_RegistersCorrectTools(t *testing.T) { // External mode includes all config tools assert.Contains(t, tools, "list_workspaces_kandev") + assert.Contains(t, tools, "list_repositories_kandev") assert.Contains(t, tools, "create_workflow_kandev") assert.Contains(t, tools, "list_agents_kandev") assert.Contains(t, tools, "get_mcp_config_kandev") @@ -273,8 +275,8 @@ func TestServerModeExternal_ToolCount(t *testing.T) { s := New(backend, "", "", 0, log, "", true, ModeExternal) tools := getRegisteredToolNames(s) - // 10 workflow + 4 agent + 4 mcp + 5 executor + 6 task + 1 create_task = 30 - assert.Equal(t, 30, len(tools)) + // 11 workflow (incl. list_repositories) + 4 agent + 4 mcp + 5 executor + 6 task + 1 create_task = 31 + assert.Equal(t, 31, len(tools)) } func TestNewExternal_Constructs(t *testing.T) { diff --git a/apps/backend/internal/orchestrator/dto/dto.go b/apps/backend/internal/orchestrator/dto/dto.go index 8c7eb8e395..ad69845f77 100644 --- a/apps/backend/internal/orchestrator/dto/dto.go +++ b/apps/backend/internal/orchestrator/dto/dto.go @@ -27,65 +27,6 @@ type QueueResponse struct { Total int `json:"total"` } -// TriggerTaskRequest is the payload for orchestrator.trigger -type TriggerTaskRequest struct { - TaskID string `json:"task_id"` -} - -// TriggerTaskResponse is the response for orchestrator.trigger -type TriggerTaskResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - TaskID string `json:"task_id"` -} - -// StartTaskRequest is the payload for orchestrator.start -// If SessionID is provided, the existing session is resumed and prompted with workflow step config. -// If SessionID is not provided, a new session is created (requires AgentProfileID). -type StartTaskRequest struct { - TaskID string `json:"task_id"` - SessionID string `json:"session_id,omitempty"` // If provided, resume this existing session instead of creating new - AgentProfileID string `json:"agent_profile_id,omitempty"` // Required when creating new session (SessionID not provided) - ExecutorID string `json:"executor_id,omitempty"` // Executor to use (determines runtime: local, worktree, local_docker, etc.) - Priority int `json:"priority,omitempty"` - Prompt string `json:"prompt,omitempty"` // Initial prompt to send to the agent - WorkflowStepID string `json:"workflow_step_id,omitempty"` // If provided, prompt prefix/suffix and plan mode are applied from the step -} - -// StartTaskResponse is the response for orchestrator.start -type StartTaskResponse struct { - Success bool `json:"success"` - TaskID string `json:"task_id"` - AgentExecutionID string `json:"agent_execution_id"` - TaskSessionID string `json:"session_id,omitempty"` - State string `json:"state"` - WorktreePath *string `json:"worktree_path,omitempty"` - WorktreeBranch *string `json:"worktree_branch,omitempty"` -} - -// ResumeTaskSessionRequest is the payload for task.session.resume -type ResumeTaskSessionRequest struct { - TaskID string `json:"task_id"` - TaskSessionID string `json:"session_id"` -} - -// ResumeTaskSessionResponse is the response for task.session.resume -type ResumeTaskSessionResponse struct { - Success bool `json:"success"` - TaskID string `json:"task_id"` - AgentExecutionID string `json:"agent_execution_id"` - TaskSessionID string `json:"session_id,omitempty"` - State string `json:"state"` - WorktreePath *string `json:"worktree_path,omitempty"` - WorktreeBranch *string `json:"worktree_branch,omitempty"` -} - -// TaskSessionStatusRequest is the payload for task.session.status -type TaskSessionStatusRequest struct { - TaskID string `json:"task_id"` - TaskSessionID string `json:"session_id"` -} - // TaskSessionStatusResponse is the response for task.session.status type TaskSessionStatusResponse struct { // Session metadata @@ -124,52 +65,11 @@ type TaskSessionStatusResponse struct { Error string `json:"error,omitempty"` } -// StopTaskRequest is the payload for orchestrator.stop -type StopTaskRequest struct { - TaskID string `json:"task_id"` - Reason string `json:"reason,omitempty"` - Force bool `json:"force,omitempty"` -} - // SuccessResponse is a generic success response type SuccessResponse struct { Success bool `json:"success"` } -// PromptTaskRequest is the payload for orchestrator.prompt -type PromptTaskRequest struct { - TaskID string `json:"task_id"` - TaskSessionID string `json:"session_id"` - Prompt string `json:"prompt"` - Model string `json:"model,omitempty"` // Optional: switch to this model before processing prompt - PlanMode bool `json:"plan_mode,omitempty"` // Optional: enable plan mode (analyze before executing) -} - -// PromptTaskResponse is the response for orchestrator.prompt -type PromptTaskResponse struct { - Success bool `json:"success"` - StopReason string `json:"stop_reason"` -} - -// CompleteTaskRequest is the payload for orchestrator.complete -type CompleteTaskRequest struct { - TaskID string `json:"task_id"` -} - -// CompleteTaskResponse is the response for orchestrator.complete -type CompleteTaskResponse struct { - Success bool `json:"success"` - Message string `json:"message"` -} - -// PermissionRespondRequest is the payload for permission.respond -type PermissionRespondRequest struct { - SessionID string `json:"session_id"` - PendingID string `json:"pending_id"` - OptionID string `json:"option_id,omitempty"` - Cancelled bool `json:"cancelled,omitempty"` -} - // PermissionRespondResponse is the response for permission.respond type PermissionRespondResponse struct { Success bool `json:"success"` diff --git a/apps/backend/internal/orchestrator/event_handlers_clarification.go b/apps/backend/internal/orchestrator/event_handlers_clarification.go index 54e69b7514..3cb81ccb79 100644 --- a/apps/backend/internal/orchestrator/event_handlers_clarification.go +++ b/apps/backend/internal/orchestrator/event_handlers_clarification.go @@ -295,15 +295,28 @@ func (s *Service) cancelAllClarificationWatchdogs() { } // buildClarificationPrompt constructs the resume prompt from a clarification answer. +// Handles both single- and multi-question bundles: when data.Question contains +// newlines it is treated as a pre-formatted multi-line summary and embedded +// as-is rather than quoted. func buildClarificationPrompt(data clarificationAnsweredData) string { + multiQuestion := strings.Contains(data.Question, "\n") + if data.Rejected { reason := data.RejectReason if reason == "" { reason = "No reason provided" } + if multiQuestion { + return fmt.Sprintf("The user declined to answer your questions:\n%s\nReason: %s\nPlease continue without this information.", + data.Question, reason) + } return fmt.Sprintf("The user declined to answer your question: %q\nReason: %s\nPlease continue without this information.", data.Question, reason) } + if multiQuestion { + return fmt.Sprintf("You previously asked the user:\n%s\n\n%s\nPlease continue with this information.", + data.Question, data.AnswerText) + } return fmt.Sprintf("You previously asked the user: %q\n%s\nPlease continue with this information.", data.Question, data.AnswerText) } diff --git a/apps/backend/internal/orchestrator/event_handlers_linear.go b/apps/backend/internal/orchestrator/event_handlers_linear.go new file mode 100644 index 0000000000..59fa18753c --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_linear.go @@ -0,0 +1,188 @@ +package orchestrator + +import ( + "context" + "fmt" + "strings" + + "go.uber.org/zap" + + promptcfg "github.com/kandev/kandev/config/prompts" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/linear" + "github.com/kandev/kandev/internal/task/models" +) + +// LinearService is the subset of linear.Service the orchestrator needs to +// deduplicate issue→task mappings. Mirrors the JIRA equivalent. +type LinearService interface { + ReserveIssueWatchTask(ctx context.Context, watchID, identifier, issueURL string) (bool, error) + AssignIssueWatchTaskID(ctx context.Context, watchID, identifier, taskID string) error + ReleaseIssueWatchTask(ctx context.Context, watchID, identifier string) error +} + +// SetLinearService wires the Linear dedup helpers into the orchestrator so +// linear-watch handlers can claim issue slots before creating tasks. +func (s *Service) SetLinearService(l LinearService) { + s.linearService = l +} + +// subscribeLinearEvents wires the Linear event handlers onto the bus. +func (s *Service) subscribeLinearEvents() { + if s.eventBus == nil { + return + } + if _, err := s.eventBus.Subscribe(events.LinearNewIssue, s.handleNewLinearIssue); err != nil { + s.logger.Error("failed to subscribe to linear.new_issue events", zap.Error(err)) + } +} + +// handleNewLinearIssue creates a Kandev task for a freshly-observed Linear issue. +func (s *Service) handleNewLinearIssue(_ context.Context, event *bus.Event) error { + evt, ok := event.Data.(*linear.NewLinearIssueEvent) + if !ok { + return nil + } + if evt.Issue == nil { + return nil + } + s.logger.Info("new linear issue detected from watch", + zap.String("issue_watch_id", evt.IssueWatchID), + zap.String("identifier", evt.Issue.Identifier)) + + if s.issueTaskCreator == nil { + s.logger.Warn("issue task creator not configured, skipping linear task creation") + return nil + } + + // Background context: the bus delivery context may be cancelled before + // task creation finishes. The work is independent of the publisher. + go s.createLinearIssueTask(context.Background(), evt) + return nil +} + +// createLinearIssueTask mirrors createJiraIssueTask intentionally: the dedup- +// reserve → create → assign-or-release lifecycle is the same shape across +// both integrations. Refactoring out the symmetry would couple two +// integration packages we'd rather keep independent (their event payloads, +// metadata keys, and prompt placeholders all differ). +// +//nolint:dupl // intentional symmetry with createJiraIssueTask; see comment above +func (s *Service) createLinearIssueTask(ctx context.Context, evt *linear.NewLinearIssueEvent) { + issue := evt.Issue + if !s.reserveLinearIssue(ctx, evt) { + return + } + + req := &IssueTaskRequest{ + WorkspaceID: evt.WorkspaceID, + WorkflowID: evt.WorkflowID, + WorkflowStepID: evt.WorkflowStepID, + Title: fmt.Sprintf("[%s] %s", issue.Identifier, issue.Title), + Description: interpolateLinearPrompt(evt.Prompt, issue), + Metadata: map[string]interface{}{ + "linear_issue_watch_id": evt.IssueWatchID, + "linear_issue_identifier": issue.Identifier, + "linear_issue_url": issue.URL, + "linear_state": issue.StateName, + "linear_assignee": issue.AssigneeName, + models.MetaKeyAgentProfileID: evt.AgentProfileID, + models.MetaKeyExecutorProfileID: evt.ExecutorProfileID, + }, + } + + task, err := s.issueTaskCreator.CreateIssueTask(ctx, req) + if err != nil { + s.logger.Error("failed to create linear issue task", + zap.String("issue_watch_id", evt.IssueWatchID), + zap.String("identifier", issue.Identifier), + zap.Error(err)) + s.releaseLinearIssue(ctx, evt) + return + } + + s.attachLinearIssueTaskID(ctx, evt, task.ID) + s.logger.Info("created linear issue task", + zap.String("task_id", task.ID), + zap.String("identifier", issue.Identifier)) + + if !s.shouldAutoStartStep(ctx, evt.WorkflowStepID) { + return + } + if _, err := s.StartTask( + ctx, task.ID, evt.AgentProfileID, "", evt.ExecutorProfileID, + 0, task.Description, evt.WorkflowStepID, false, nil, + ); err != nil { + s.logger.Error("failed to auto-start linear issue task", + zap.String("task_id", task.ID), zap.Error(err)) + return + } + s.logger.Info("auto-started linear issue task", + zap.String("task_id", task.ID), + zap.String("identifier", issue.Identifier)) +} + +// reserveLinearIssue claims the dedup slot before task creation. When the +// linear service isn't wired (boot order corner case), proceed anyway — +// better to risk a duplicate task than silently drop the event. +func (s *Service) reserveLinearIssue(ctx context.Context, evt *linear.NewLinearIssueEvent) bool { + if s.linearService == nil { + return true + } + reserved, err := s.linearService.ReserveIssueWatchTask(ctx, evt.IssueWatchID, evt.Issue.Identifier, evt.Issue.URL) + if err != nil { + s.logger.Error("failed to reserve linear issue slot", + zap.String("identifier", evt.Issue.Identifier), zap.Error(err)) + return false + } + if !reserved { + s.logger.Debug("linear issue already reserved by concurrent handler, skipping", + zap.String("identifier", evt.Issue.Identifier)) + } + return reserved +} + +func (s *Service) releaseLinearIssue(ctx context.Context, evt *linear.NewLinearIssueEvent) { + if s.linearService == nil { + return + } + if err := s.linearService.ReleaseIssueWatchTask(ctx, evt.IssueWatchID, evt.Issue.Identifier); err != nil { + s.logger.Warn("failed to release linear issue reservation after task-create failure", + zap.String("identifier", evt.Issue.Identifier), zap.Error(err)) + } +} + +func (s *Service) attachLinearIssueTaskID(ctx context.Context, evt *linear.NewLinearIssueEvent, taskID string) { + if s.linearService == nil { + return + } + if err := s.linearService.AssignIssueWatchTaskID(ctx, evt.IssueWatchID, evt.Issue.Identifier, taskID); err != nil { + s.logger.Error("failed to assign task ID to linear issue reservation", + zap.String("task_id", taskID), + zap.String("identifier", evt.Issue.Identifier), zap.Error(err)) + } +} + +// interpolateLinearPrompt replaces {{issue.*}} placeholders with issue fields. +// When the template is empty (user didn't configure a custom prompt), it falls +// back to the embedded default at config/prompts/linear-issue-watch-default.md +// — same pattern as the github + jira watchers, so prompt content is editable +// without redeploying. +func interpolateLinearPrompt(template string, i *linear.LinearIssue) string { + if strings.TrimSpace(template) == "" { + template = promptcfg.Get("linear-issue-watch-default") + } + r := strings.NewReplacer( + "{{issue.identifier}}", i.Identifier, + "{{issue.title}}", i.Title, + "{{issue.url}}", i.URL, + "{{issue.state}}", i.StateName, + "{{issue.priority}}", i.PriorityLabel, + "{{issue.team}}", i.TeamKey, + "{{issue.assignee}}", i.AssigneeName, + "{{issue.creator}}", i.CreatorName, + "{{issue.description}}", i.Description, + ) + return r.Replace(template) +} diff --git a/apps/backend/internal/orchestrator/event_handlers_linear_test.go b/apps/backend/internal/orchestrator/event_handlers_linear_test.go new file mode 100644 index 0000000000..6a1f83e8ac --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_linear_test.go @@ -0,0 +1,43 @@ +package orchestrator + +import ( + "strings" + "testing" + + "github.com/kandev/kandev/internal/linear" +) + +func TestInterpolateLinearPrompt(t *testing.T) { + issue := &linear.LinearIssue{ + Identifier: "ENG-7", + Title: "Login fails on mobile", + URL: "https://linear.app/acme/issue/ENG-7", + StateName: "In Progress", + PriorityLabel: "High", + TeamKey: "ENG", + AssigneeName: "Alice", + CreatorName: "Bob", + Description: "Tap submit, nothing happens.", + } + + // Empty template falls back to the embedded default; assert on the load- + // bearing fields rather than the full string so editing the markdown + // doesn't break the test. + got := interpolateLinearPrompt("", issue) + if !strings.Contains(got, "ENG-7") { + t.Errorf("default template missing identifier: %q", got) + } + if !strings.Contains(got, "https://linear.app/acme/issue/ENG-7") { + t.Errorf("default template missing URL: %q", got) + } + + // All placeholders interpolate. + got = interpolateLinearPrompt( + "{{issue.identifier}} | {{issue.title}} | {{issue.state}} | {{issue.priority}} | {{issue.team}} | {{issue.assignee}} | {{issue.creator}}", + issue, + ) + want := "ENG-7 | Login fails on mobile | In Progress | High | ENG | Alice | Bob" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 0e86592bbc..be663c0061 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -255,7 +255,10 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s zap.Bool("trigger_on_enter", triggerOnEnter)) if triggerOnEnter { - s.finalizeStepEnter(ctx, taskID, sessionID, targetStep, task.Description) + // Automated transitions always clear review: the agent just completed + // a turn, so any pending review from a prior step is stale regardless + // of whether the new step has auto_start_agent. + s.finalizeStepEnter(ctx, taskID, sessionID, targetStep, task.Description, true) } else { // on_turn_start transitions: user is about to send a message, no on_enter needed. // However, we still need to switch the agent profile if the target step requires @@ -396,17 +399,20 @@ func (s *Service) processStepExitAndEnter(ctx context.Context, taskID string, se return } - s.finalizeStepEnter(ctx, taskID, session.ID, targetStep, taskDescription) + clearReview := targetStep.HasOnEnterAction(wfmodels.OnEnterAutoStartAgent) + s.finalizeStepEnter(ctx, taskID, session.ID, targetStep, taskDescription, clearReview) } -// finalizeStepEnter clears review status, reloads the session, and processes on_enter -// actions for the target step. Shared by executeStepTransition and processStepExitAndEnter. -func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID string, targetStep *wfmodels.WorkflowStep, taskDescription string) { - // Clear review status when moving to a new step - if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { - s.logger.Warn("failed to clear session review status", - zap.String("session_id", sessionID), - zap.Error(err)) +// finalizeStepEnter optionally clears review status, reloads the session, and +// processes on_enter actions for the target step. Shared by executeStepTransition +// and processStepExitAndEnter. +func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID string, targetStep *wfmodels.WorkflowStep, taskDescription string, clearReview bool) { + if clearReview { + if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { + s.logger.Warn("failed to clear session review status", + zap.String("session_id", sessionID), + zap.Error(err)) + } } // Reload session after on_exit may have changed metadata diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go index 252aabcb7e..497cd8879b 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go @@ -206,7 +206,7 @@ func TestHandleTaskMovedWithSession(t *testing.T) { }) }) - t.Run("clears review status on step transition", func(t *testing.T) { + t.Run("preserves review status on manual move to non-auto-start step", func(t *testing.T) { synctest.Test(t, func(t *testing.T) { repo := setupTestRepo(t) seedSession(t, repo, "t1", "s1", "step1") @@ -235,9 +235,48 @@ func TestHandleTaskMovedWithSession(t *testing.T) { synctest.Wait() + updated, _ := repo.GetTaskSession(ctx, "s1") + if updated.ReviewStatus == nil || *updated.ReviewStatus != "pending" { + t.Fatalf("expected pending review status to be preserved, got %#v", updated.ReviewStatus) + } + }) + }) + + t.Run("clears review status on manual move to auto-start step", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + _ = repo.UpdateSessionReviewStatus(ctx, "s1", "pending") + + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0, + Events: wfmodels.StepEvents{}, + } + stepGetter.steps["step2"] = &wfmodels.WorkflowStep{ + ID: "step2", WorkflowID: "wf1", Name: "Step 2", Position: 1, + Events: wfmodels.StepEvents{ + OnEnter: []wfmodels.OnEnterAction{ + {Type: wfmodels.OnEnterAutoStartAgent}, + }, + }, + } + + svc := createTestService(repo, stepGetter, newMockTaskRepo()) + svc.handleTaskMovedWithSession(ctx, watcher.TaskMovedEventData{ + TaskID: "t1", + SessionID: "s1", + FromStepID: "step1", + ToStepID: "step2", + TaskDescription: "test task", + }) + + synctest.Wait() + updated, _ := repo.GetTaskSession(ctx, "s1") if updated.ReviewStatus != nil && *updated.ReviewStatus != "" { - t.Errorf("expected review status to be cleared, got %q", *updated.ReviewStatus) + t.Fatalf("expected review status to be cleared, got %q", *updated.ReviewStatus) } }) }) diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index edc9b0ebe5..bdb5946622 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -421,6 +421,12 @@ func (m *mockRepository) GetMessageByPendingID(ctx context.Context, sessionID, p func (m *mockRepository) FindMessageByPendingID(ctx context.Context, pendingID string) (*models.Message, error) { return nil, nil } +func (m *mockRepository) FindMessagesByPendingID(ctx context.Context, pendingID string) ([]*models.Message, error) { + return nil, nil +} +func (m *mockRepository) FindMessageByPendingIDAndQuestion(ctx context.Context, sessionID, pendingID, questionID string) (*models.Message, error) { + return nil, nil +} func (m *mockRepository) UpdateMessage(ctx context.Context, message *models.Message) error { return nil } diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index ff362d9d5f..cbed2ecf6a 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -30,15 +30,9 @@ func NewHandlers(svc *orchestrator.Service, log *logger.Logger) *Handlers { func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionOrchestratorStatus, h.wsGetStatus) d.RegisterFunc(ws.ActionOrchestratorQueue, h.wsGetQueue) - d.RegisterFunc(ws.ActionOrchestratorTrigger, h.wsTriggerTask) - d.RegisterFunc(ws.ActionOrchestratorStart, h.wsStartTask) d.RegisterFunc(ws.ActionOrchestratorStop, h.wsStopTask) - d.RegisterFunc(ws.ActionOrchestratorPrompt, h.wsPromptTask) - d.RegisterFunc(ws.ActionOrchestratorComplete, h.wsCompleteTask) d.RegisterFunc(ws.ActionPermissionRespond, h.wsRespondToPermission) - d.RegisterFunc(ws.ActionTaskSessionResume, h.wsResumeTaskSession) d.RegisterFunc(ws.ActionTaskSessionStatus, h.wsGetTaskSessionStatus) - d.RegisterFunc(ws.ActionTaskSessionPrepare, h.wsPrepareTaskSession) d.RegisterFunc(ws.ActionAgentCancel, h.wsCancelAgent) d.RegisterFunc(ws.ActionSessionLaunch, h.wsLaunchSession) d.RegisterFunc(ws.ActionSessionEnsure, h.wsEnsureSession) @@ -79,28 +73,6 @@ func (h *Handlers) wsGetQueue(ctx context.Context, msg *ws.Message) (*ws.Message return ws.NewResponse(msg.ID, msg.Action, resp) } -type wsTriggerTaskRequest struct { - TaskID string `json:"task_id"` -} - -func (h *Handlers) wsTriggerTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsTriggerTaskRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - - // TriggerTask is a placeholder that just returns success - resp := dto.TriggerTaskResponse{ - Success: true, - Message: "Task triggered", - TaskID: req.TaskID, - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - func (h *Handlers) wsLaunchSession(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req orchestrator.LaunchSessionRequest if err := msg.ParsePayload(&req); err != nil { @@ -208,140 +180,6 @@ func (h *Handlers) wsRecoverSession(ctx context.Context, msg *ws.Message) (*ws.M return ws.NewResponse(msg.ID, msg.Action, resp) } -type wsStartTaskRequest struct { - TaskID string `json:"task_id"` - SessionID string `json:"session_id,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - ExecutorID string `json:"executor_id,omitempty"` - ExecutorProfileID string `json:"executor_profile_id,omitempty"` - Priority int `json:"priority,omitempty"` - Prompt string `json:"prompt,omitempty"` - WorkflowStepID string `json:"workflow_step_id,omitempty"` - PlanMode bool `json:"plan_mode,omitempty"` -} - -// wsStartTask delegates to LaunchSession for backward compatibility. -func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsStartTaskRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - if req.SessionID == "" && req.AgentProfileID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_profile_id is required when creating a new session", nil) - } - - launchReq := &orchestrator.LaunchSessionRequest{ - TaskID: req.TaskID, - SessionID: req.SessionID, - AgentProfileID: req.AgentProfileID, - ExecutorID: req.ExecutorID, - ExecutorProfileID: req.ExecutorProfileID, - Priority: req.Priority, - Prompt: req.Prompt, - WorkflowStepID: req.WorkflowStepID, - PlanMode: req.PlanMode, - } - resp, err := h.service.LaunchSession(ctx, launchReq) - if err != nil { - h.logger.Error("failed to start task", zap.String("task_id", req.TaskID), zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to start task: "+err.Error(), nil) - } - - // Map to legacy StartTaskResponse format for backward compatibility - legacyResp := dto.StartTaskResponse{ - Success: resp.Success, - TaskID: resp.TaskID, - AgentExecutionID: resp.AgentExecutionID, - TaskSessionID: resp.SessionID, - State: resp.State, - WorktreePath: resp.WorktreePath, - WorktreeBranch: resp.WorktreeBranch, - } - return ws.NewResponse(msg.ID, msg.Action, legacyResp) -} - -type wsResumeTaskSessionRequest struct { - TaskID string `json:"task_id"` - TaskSessionID string `json:"session_id"` -} - -// wsResumeTaskSession delegates to LaunchSession with resume intent for backward compatibility. -func (h *Handlers) wsResumeTaskSession(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsResumeTaskSessionRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - if req.TaskSessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) - } - - launchReq := &orchestrator.LaunchSessionRequest{ - TaskID: req.TaskID, - SessionID: req.TaskSessionID, - Intent: orchestrator.IntentResume, - } - resp, err := h.service.LaunchSession(ctx, launchReq) - if err != nil { - h.logger.Error("failed to resume task session", - zap.String("task_id", req.TaskID), - zap.String("session_id", req.TaskSessionID), - zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to resume task session: "+err.Error(), nil) - } - - // Map to legacy ResumeTaskSessionResponse format - legacyResp := dto.ResumeTaskSessionResponse{ - Success: resp.Success, - TaskID: resp.TaskID, - AgentExecutionID: resp.AgentExecutionID, - TaskSessionID: resp.SessionID, - State: resp.State, - WorktreePath: resp.WorktreePath, - WorktreeBranch: resp.WorktreeBranch, - } - return ws.NewResponse(msg.ID, msg.Action, legacyResp) -} - -type wsPrepareTaskSessionRequest struct { - TaskID string `json:"task_id"` -} - -type wsPrepareTaskSessionResponse struct { - SessionID string `json:"session_id"` -} - -// wsPrepareTaskSession delegates to LaunchSession with prepare intent for backward compatibility. -func (h *Handlers) wsPrepareTaskSession(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsPrepareTaskSessionRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - - launchReq := &orchestrator.LaunchSessionRequest{ - TaskID: req.TaskID, - Intent: orchestrator.IntentPrepare, - LaunchWorkspace: true, - } - resp, err := h.service.LaunchSession(ctx, launchReq) - if err != nil { - h.logger.Error("failed to prepare task session", - zap.String("task_id", req.TaskID), - zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to prepare session: "+err.Error(), nil) - } - - return ws.NewResponse(msg.ID, msg.Action, wsPrepareTaskSessionResponse{SessionID: resp.SessionID}) -} - type wsStopTaskRequest struct { TaskID string `json:"task_id"` Reason string `json:"reason,omitempty"` @@ -368,64 +206,6 @@ func (h *Handlers) wsStopTask(ctx context.Context, msg *ws.Message) (*ws.Message return ws.NewResponse(msg.ID, msg.Action, dto.SuccessResponse{Success: true}) } -type wsPromptTaskRequest struct { - TaskID string `json:"task_id"` - TaskSessionID string `json:"session_id"` - Prompt string `json:"prompt"` - Model string `json:"model,omitempty"` -} - -func (h *Handlers) wsPromptTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsPromptTaskRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - if req.TaskSessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) - } - if req.Prompt == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "prompt is required", nil) - } - - result, err := h.service.PromptTask(ctx, req.TaskID, req.TaskSessionID, req.Prompt, req.Model, false, nil, false) - if err != nil { - h.logger.Error("failed to send prompt", zap.String("task_id", req.TaskID), zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to send prompt: "+err.Error(), nil) - } - resp := dto.PromptTaskResponse{ - Success: true, - StopReason: result.StopReason, - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - -type wsCompleteTaskRequest struct { - TaskID string `json:"task_id"` -} - -func (h *Handlers) wsCompleteTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsCompleteTaskRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - - if err := h.service.CompleteTask(ctx, req.TaskID); err != nil { - h.logger.Error("failed to complete task", zap.String("task_id", req.TaskID), zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to complete task: "+err.Error(), nil) - } - resp := dto.CompleteTaskResponse{ - Success: true, - Message: "task completed", - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - type wsPermissionRespondRequest struct { SessionID string `json:"session_id"` PendingID string `json:"pending_id"` diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 2a78e877b2..7c57e518ad 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -91,6 +91,11 @@ type TurnService interface { StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) CompleteTurn(ctx context.Context, turnID string) error GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) + // AbandonOpenTurns buries any open turns for a session by setting + // completed_at = started_at (zero duration), so a subsequent prompt starts + // a fresh turn instead of adopting one that was orphaned by a crash or + // restart. Used on session resume. + AbandonOpenTurns(ctx context.Context, sessionID string) error } // TaskEventPublisher is the orchestrator's collaborator for publishing @@ -225,6 +230,9 @@ type Service struct { // Jira service for issue watch dedup operations jiraService JiraService + // Linear service for issue watch dedup operations + linearService LinearService + // Repository resolver for cloning + finding/creating repos for review tasks repositoryResolver RepositoryResolver @@ -662,6 +670,9 @@ func (s *Service) Start(ctx context.Context) error { // Subscribe to JIRA integration events s.subscribeJiraEvents() + // Subscribe to Linear integration events + s.subscribeLinearEvents() + // Subscribe to clarification events (cancel-and-resume flow) s.subscribeClarificationEvents() diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 985d7aa7c0..c164ccd355 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -747,6 +747,25 @@ func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID strin return nil, fmt.Errorf("session is completed and cannot be resumed; create a new session instead") } + // Bury any open turns from the previous run before relaunching. Without + // this, startTurnForSession adopts the orphan on the next prompt and the + // UI's running timer counts from the orphan's started_at — which can be + // hours or days ago. Zero-duration completion keeps analytics honest about + // the dead window. A failure here shouldn't block the resume; the next + // completeTurnForSession sweep will mop up. + // + // Drop the activeTurns cache entry first, mirroring completeTurnForSession. + // Otherwise a stale entry would let getActiveTurnID return the now-abandoned + // turn ID without re-reading the DB, tagging new messages to a closed turn. + if s.turnService != nil { + s.activeTurns.Delete(sessionID) + if err := s.turnService.AbandonOpenTurns(ctx, sessionID); err != nil { + s.logger.Warn("failed to abandon orphan turns on resume; continuing", + zap.String("session_id", sessionID), + zap.Error(err)) + } + } + // Use context.WithoutCancel to prevent WebSocket request timeout from canceling the resume. // Session resume can take time and shouldn't be tied to the WS request lifecycle. resumeCtx := context.WithoutCancel(ctx) diff --git a/apps/backend/internal/orchestrator/turn_lifecycle_test.go b/apps/backend/internal/orchestrator/turn_lifecycle_test.go index c0b51f47f8..8ae6fc77b3 100644 --- a/apps/backend/internal/orchestrator/turn_lifecycle_test.go +++ b/apps/backend/internal/orchestrator/turn_lifecycle_test.go @@ -49,6 +49,18 @@ func (a *repoTurnService) GetActiveTurn(ctx context.Context, sessionID string) ( return turn, err } +func (a *repoTurnService) AbandonOpenTurns(ctx context.Context, sessionID string) error { + for { + turn, err := a.GetActiveTurn(ctx, sessionID) + if err != nil || turn == nil { + return err + } + if err := a.repo.AbandonTurn(ctx, turn.ID); err != nil { + return err + } + } +} + func openTurnCount(t *testing.T, repo *sqliterepo.Repository, sessionID string) int { t.Helper() turns, err := repo.ListTurnsBySession(context.Background(), sessionID) @@ -230,3 +242,61 @@ func TestCompleteTurnIsIdempotent(t *testing.T) { t.Fatalf("expected exactly 1 turn (no phantom), got %d", len(turns)) } } + +// TestAbandonOpenTurnsZeroesDuration covers the resume-orphan path: turns +// left open by a previous crash must close with completed_at = started_at so +// the UI's running timer doesn't count from a stale start, and analytics +// doesn't accumulate hours of dead time. +func TestAbandonOpenTurnsZeroesDuration(t *testing.T) { + svc, repo := newTurnLifecycleTestService(t) + ctx := context.Background() + + stale, err := svc.turnService.StartTurn(ctx, "session1") + if err != nil { + t.Fatalf("seed turn: %v", err) + } + + if err := svc.turnService.AbandonOpenTurns(ctx, "session1"); err != nil { + t.Fatalf("AbandonOpenTurns: %v", err) + } + + if open := openTurnCount(t, repo, "session1"); open != 0 { + t.Fatalf("expected 0 open turns after abandon, got %d", open) + } + + got, err := repo.GetTurn(ctx, stale.ID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + if got.CompletedAt == nil { + t.Fatal("expected completed_at to be set after abandon") + } + if !got.CompletedAt.Equal(got.StartedAt) { + t.Fatalf("expected completed_at == started_at (zero duration), got started=%v completed=%v", + got.StartedAt, *got.CompletedAt) + } +} + +// TestAbandonOpenTurnsHandlesMultipleZombies verifies the loop closes every +// open turn for the session, mirroring the behavior of completeTurnForSession. +func TestAbandonOpenTurnsHandlesMultipleZombies(t *testing.T) { + svc, repo := newTurnLifecycleTestService(t) + ctx := context.Background() + + for i := 0; i < 4; i++ { + if _, err := svc.turnService.StartTurn(ctx, "session1"); err != nil { + t.Fatalf("seed turn %d: %v", i, err) + } + } + if open := openTurnCount(t, repo, "session1"); open != 4 { + t.Fatalf("expected 4 open turns before abandon, got %d", open) + } + + if err := svc.turnService.AbandonOpenTurns(ctx, "session1"); err != nil { + t.Fatalf("AbandonOpenTurns: %v", err) + } + + if open := openTurnCount(t, repo, "session1"); open != 0 { + t.Fatalf("expected 0 open turns after abandon, got %d", open) + } +} diff --git a/apps/backend/internal/slack/client.go b/apps/backend/internal/slack/client.go new file mode 100644 index 0000000000..ba4ed6ad60 --- /dev/null +++ b/apps/backend/internal/slack/client.go @@ -0,0 +1,63 @@ +package slack + +import ( + "context" + "errors" + "fmt" +) + +// ErrNotConfigured is returned when a Slack operation is attempted without a +// stored configuration or credentials. Wording is install-wide ("not +// configured", not "workspace not configured") because the WS path surfaces +// this raw string to clients via err.Error() without rewriting; the HTTP +// path overrides with its own friendlier message in writeClientError. +var ErrNotConfigured = errors.New("slack: not configured") + +// Client is the minimal Slack Web API surface this integration needs. The real +// implementation in cookie_client.go authenticates as the user via the xoxc- +// token + d cookie pair; tests substitute a fake. +type Client interface { + // AuthTest hits the `auth.test` endpoint, which is the cheapest call + // that confirms credentials are still valid and returns the + // authenticated user/team identifiers. Used by the health poller and the + // connection-test handler. + AuthTest(ctx context.Context) (*TestConnectionResult, error) + + // SearchMessages runs Slack's `search.messages` with a query string — + // the trigger uses this to find the user's own messages with the + // configured command prefix. Returns a flat list of matches. + SearchMessages(ctx context.Context, query string) ([]SlackMessage, error) + + // ConversationsReplies fetches the full thread for a parent message. + // When threadTS is empty (the message isn't part of a thread), returns + // just the single message anchored at triggerTS — implementations must + // pin to triggerTS, not "the channel's latest message", or concurrent + // activity in the channel between trigger and processing will give the + // agent unrelated context. + ConversationsReplies(ctx context.Context, channelID, threadTS, triggerTS string) ([]SlackMessage, error) + + // ChatGetPermalink resolves a message's canonical Slack URL. + ChatGetPermalink(ctx context.Context, channelID, ts string) (string, error) + + // ChatPostMessage posts a reply (in cookie mode, as the authenticated + // user). When threadTS is non-empty, the message is posted in-thread. + ChatPostMessage(ctx context.Context, channelID, threadTS, text string) error + + // ReactionsAdd adds an emoji reaction to a message. `name` is the bare + // emoji name (no surrounding colons), e.g. "eyes" or "white_check_mark". + // Implementations swallow Slack's `already_reacted` error so the trigger + // can call this idempotently across watermark retries. + ReactionsAdd(ctx context.Context, channelID, ts, name string) error +} + +// APIError captures a Slack API failure. Slack returns 200 with `{"ok": false, +// "error": "..."}` for almost every failure mode, so StatusCode is the HTTP +// code (usually 200) and Message is Slack's `error` string. +type APIError struct { + StatusCode int + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("slack api: status %d: %s", e.StatusCode, e.Message) +} diff --git a/apps/backend/internal/slack/cookie_client.go b/apps/backend/internal/slack/cookie_client.go new file mode 100644 index 0000000000..10b06f73fd --- /dev/null +++ b/apps/backend/internal/slack/cookie_client.go @@ -0,0 +1,339 @@ +package slack + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// SlackAPIBase is the single host for Slack's web API. The same base is used +// regardless of the user's workspace — auth scopes the request. +const SlackAPIBase = "https://slack.com/api" + +const userAgent = "kandev/1.0 (+https://github.com/kdlbs/kandev)" + +// CookieClient implements Client by speaking the same Web API the Slack +// browser app uses. The xoxc- session token goes in `Authorization: Bearer +// ` and the d cookie is sent as a `Cookie` header — Slack rejects the +// token alone for the endpoints we need. +type CookieClient struct { + http *http.Client + endpoint string + token string + cookie string + maxBodySize int64 +} + +// NewCookieClient builds a client. cfg is reserved so the signature matches +// future bot/oauth clients; it isn't read today. +func NewCookieClient(_ *SlackConfig, token, cookie string) *CookieClient { + return &CookieClient{ + http: &http.Client{Timeout: 30 * time.Second}, + endpoint: SlackAPIBase, + token: token, + cookie: cookie, + maxBodySize: 8 << 20, + } +} + +// slackEnvelope captures the `{ok, error}` shape every Slack endpoint returns. +type slackEnvelope struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +// post sends a form-encoded POST to /api/, decodes the JSON envelope, +// and unmarshals the full body into `out` if it isn't nil. Slack expects +// form-encoded bodies (not JSON) for almost every endpoint. +func (c *CookieClient) post(ctx context.Context, method string, params url.Values, out interface{}) error { + if c.token == "" || c.cookie == "" { + return ErrNotConfigured + } + body := strings.NewReader(params.Encode()) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/"+method, body) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Cookie", "d="+c.cookie) + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(io.LimitReader(resp.Body, c.maxBodySize)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &APIError{StatusCode: resp.StatusCode, Message: summarizeBody(raw)} + } + var env slackEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return &APIError{StatusCode: resp.StatusCode, Message: "invalid Slack response: " + err.Error()} + } + if !env.OK { + return &APIError{StatusCode: resp.StatusCode, Message: env.Error} + } + if out == nil { + return nil + } + return json.Unmarshal(raw, out) +} + +func summarizeBody(raw []byte) string { + const maxMsg = 500 + s := strings.TrimSpace(string(raw)) + if len(s) > maxMsg { + return s[:maxMsg] + "…" + } + if s == "" { + return "(empty body)" + } + return s +} + +// --- auth.test --- + +type authTestResponse struct { + slackEnvelope + URL string `json:"url"` + Team string `json:"team"` + User string `json:"user"` + TeamID string `json:"team_id"` + UserID string `json:"user_id"` +} + +// AuthTest implements Client. Slack's auth.test takes no parameters and +// returns the authenticated user + team. Used as the cheapest probe. +func (c *CookieClient) AuthTest(ctx context.Context) (*TestConnectionResult, error) { + var resp authTestResponse + if err := c.post(ctx, "auth.test", url.Values{}, &resp); err != nil { + var apiErr *APIError + if asAPIErr(err, &apiErr) { + return &TestConnectionResult{OK: false, Error: apiErr.Message}, nil + } + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return &TestConnectionResult{ + OK: true, + UserID: resp.UserID, + TeamID: resp.TeamID, + TeamName: resp.Team, + URL: resp.URL, + DisplayName: resp.User, + }, nil +} + +// asAPIErr is a small wrapper over errors.As without the import cycle of +// importing errors twice. +func asAPIErr(err error, target **APIError) bool { + for cur := err; cur != nil; { + if e, ok := cur.(*APIError); ok { + *target = e + return true + } + type unwrap interface{ Unwrap() error } + u, ok := cur.(unwrap) + if !ok { + return false + } + cur = u.Unwrap() + } + return false +} + +// --- search.messages --- + +type searchMessagesResponse struct { + slackEnvelope + Messages struct { + Matches []searchMatch `json:"matches"` + } `json:"messages"` +} + +type searchMatch struct { + TS string `json:"ts"` + Text string `json:"text"` + User string `json:"user"` + Username string `json:"username"` + Permalink string `json:"permalink"` + Channel struct { + ID string `json:"id"` + } `json:"channel"` + // Slack's search.messages returns thread context when the matched message + // is inside a thread. We only need the parent ts here — the trigger fetches + // the full thread separately via conversations.replies. + ThreadTS string `json:"thread_ts,omitempty"` +} + +// SearchMessages implements Client. +func (c *CookieClient) SearchMessages(ctx context.Context, query string) ([]SlackMessage, error) { + params := url.Values{} + params.Set("query", query) + params.Set("count", "30") + // Newest first so the trigger sees the most recent matches first; the + // watermark logic in trigger.go skips anything older than last_seen_ts. + params.Set("sort", "timestamp") + params.Set("sort_dir", "desc") + var resp searchMessagesResponse + if err := c.post(ctx, "search.messages", params, &resp); err != nil { + return nil, err + } + out := make([]SlackMessage, 0, len(resp.Messages.Matches)) + for _, m := range resp.Messages.Matches { + out = append(out, SlackMessage{ + TS: m.TS, + ThreadTS: m.ThreadTS, + ChannelID: m.Channel.ID, + UserID: m.User, + UserName: m.Username, + Text: m.Text, + Permalink: m.Permalink, + }) + } + return out, nil +} + +// --- conversations.replies --- + +type conversationsRepliesResponse struct { + slackEnvelope + Messages []replyMessage `json:"messages"` +} + +type replyMessage struct { + TS string `json:"ts"` + ThreadTS string `json:"thread_ts,omitempty"` + User string `json:"user"` + Username string `json:"username"` + Text string `json:"text"` +} + +// ConversationsReplies implements Client. When threadTS is empty, falls back +// to a single-message read pinned to triggerTS — Slack's conversations.history +// with `oldest=ts, latest=ts, inclusive=true, limit=1` returns exactly that +// message regardless of activity in the channel between trigger detection and +// processing. +func (c *CookieClient) ConversationsReplies(ctx context.Context, channelID, threadTS, triggerTS string) ([]SlackMessage, error) { + if channelID == "" { + return nil, fmt.Errorf("channel id required") + } + if threadTS == "" { + return c.historyAt(ctx, channelID, triggerTS) + } + params := url.Values{} + params.Set("channel", channelID) + params.Set("ts", threadTS) + params.Set("limit", "100") + var resp conversationsRepliesResponse + if err := c.post(ctx, "conversations.replies", params, &resp); err != nil { + return nil, err + } + out := make([]SlackMessage, 0, len(resp.Messages)) + for _, m := range resp.Messages { + out = append(out, SlackMessage{ + TS: m.TS, + ThreadTS: m.ThreadTS, + ChannelID: channelID, + UserID: m.User, + UserName: m.Username, + Text: m.Text, + }) + } + return out, nil +} + +// historyAt returns exactly the message at the given timestamp via +// conversations.history with the timestamp window pinned to ts on both sides +// and inclusive=true. Used as the non-threaded fallback for +// ConversationsReplies; an empty ts returns nothing rather than picking up +// whatever just landed in the channel. +func (c *CookieClient) historyAt(ctx context.Context, channelID, ts string) ([]SlackMessage, error) { + if ts == "" { + return nil, nil + } + params := url.Values{} + params.Set("channel", channelID) + params.Set("oldest", ts) + params.Set("latest", ts) + params.Set("inclusive", "true") + params.Set("limit", "1") + var resp conversationsRepliesResponse + if err := c.post(ctx, "conversations.history", params, &resp); err != nil { + return nil, err + } + out := make([]SlackMessage, 0, len(resp.Messages)) + for _, m := range resp.Messages { + out = append(out, SlackMessage{ + TS: m.TS, + ThreadTS: m.ThreadTS, + ChannelID: channelID, + UserID: m.User, + UserName: m.Username, + Text: m.Text, + }) + } + return out, nil +} + +// --- chat.getPermalink --- + +type permalinkResponse struct { + slackEnvelope + Permalink string `json:"permalink"` +} + +// ChatGetPermalink implements Client. +func (c *CookieClient) ChatGetPermalink(ctx context.Context, channelID, ts string) (string, error) { + params := url.Values{} + params.Set("channel", channelID) + params.Set("message_ts", ts) + var resp permalinkResponse + if err := c.post(ctx, "chat.getPermalink", params, &resp); err != nil { + return "", err + } + return resp.Permalink, nil +} + +// --- chat.postMessage --- + +// ChatPostMessage implements Client. Posts as the authenticated user since the +// cookie auth has no bot identity. +func (c *CookieClient) ChatPostMessage(ctx context.Context, channelID, threadTS, text string) error { + params := url.Values{} + params.Set("channel", channelID) + params.Set("text", text) + if threadTS != "" { + params.Set("thread_ts", threadTS) + } + return c.post(ctx, "chat.postMessage", params, nil) +} + +// ReactionsAdd implements Client. Slack returns `already_reacted` (a non-2xx- +// shaped error inside a 2xx envelope) when the reaction is already present; +// we swallow that so the trigger can call this idempotently after a restart +// or watermark-retry without surfacing a misleading "failed" log line. +func (c *CookieClient) ReactionsAdd(ctx context.Context, channelID, ts, name string) error { + params := url.Values{} + params.Set("channel", channelID) + params.Set("timestamp", ts) + params.Set("name", name) + err := c.post(ctx, "reactions.add", params, nil) + if err == nil { + return nil + } + var apiErr *APIError + if asAPIErr(err, &apiErr) && apiErr.Message == "already_reacted" { + return nil + } + return err +} diff --git a/apps/backend/internal/slack/cookie_client_test.go b/apps/backend/internal/slack/cookie_client_test.go new file mode 100644 index 0000000000..672f2bee05 --- /dev/null +++ b/apps/backend/internal/slack/cookie_client_test.go @@ -0,0 +1,255 @@ +package slack + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// newFakeServer returns an httptest.Server whose handler dispatches by URL path +// (the trailing API method name) to the supplied handlers map. +func newFakeServer(t *testing.T, handlers map[string]http.HandlerFunc) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method := strings.TrimPrefix(r.URL.Path, "/") + if h, ok := handlers[method]; ok { + h(w, r) + return + } + http.Error(w, "no handler for "+method, http.StatusNotFound) + })) + t.Cleanup(srv.Close) + return srv +} + +func newFakeClient(srv *httptest.Server) *CookieClient { + c := NewCookieClient(nil, "xoxc-test", "d-test") + c.endpoint = srv.URL + return c +} + +func TestCookieClient_AuthTest_Success(t *testing.T) { + var gotAuth, gotCookie string + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "auth.test": func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotCookie = r.Header.Get("Cookie") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"url":"https://acme.slack.com/","team":"Acme","user":"alice","team_id":"T0001","user_id":"U0001"}`)) + }, + }) + client := newFakeClient(srv) + res, err := client.AuthTest(context.Background()) + if err != nil { + t.Fatalf("AuthTest: %v", err) + } + if !res.OK || res.UserID != "U0001" || res.TeamID != "T0001" { + t.Errorf("unexpected result: %+v", res) + } + if gotAuth != "Bearer xoxc-test" { + t.Errorf("expected bearer auth header, got %q", gotAuth) + } + if gotCookie != "d=d-test" { + t.Errorf("expected d cookie header, got %q", gotCookie) + } +} + +func TestCookieClient_AuthTest_SlackError(t *testing.T) { + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "auth.test": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`)) + }, + }) + client := newFakeClient(srv) + res, err := client.AuthTest(context.Background()) + if err != nil { + t.Fatalf("AuthTest returned err (should surface as result): %v", err) + } + if res.OK { + t.Errorf("expected OK=false, got %+v", res) + } + if !strings.Contains(res.Error, "invalid_auth") { + t.Errorf("expected slack error in result, got %q", res.Error) + } +} + +func TestCookieClient_SearchMessages(t *testing.T) { + var gotQuery string + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "search.messages": func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + gotQuery = r.PostForm.Get("query") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "ok": true, + "messages": {"matches": [ + {"ts":"100.000001","text":"!kandev one","user":"U1","permalink":"https://example/1","channel":{"id":"C1"}}, + {"ts":"100.000002","text":"!kandev two","user":"U1","permalink":"https://example/2","channel":{"id":"C1"}} + ]} + }`)) + }, + }) + client := newFakeClient(srv) + msgs, err := client.SearchMessages(context.Background(), `from:U1 "!kandev"`) + if err != nil { + t.Fatalf("SearchMessages: %v", err) + } + if gotQuery != `from:U1 "!kandev"` { + t.Errorf("query passthrough failed, got %q", gotQuery) + } + if len(msgs) != 2 || msgs[0].TS != "100.000001" || msgs[0].ChannelID != "C1" { + t.Errorf("unexpected messages: %+v", msgs) + } +} + +func TestCookieClient_ConversationsReplies(t *testing.T) { + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "conversations.replies": func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + if r.PostForm.Get("ts") == "" { + http.Error(w, "ts required", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "ok": true, + "messages": [ + {"ts":"100.0001","user":"U1","text":"first"}, + {"ts":"100.0002","user":"U2","text":"second","thread_ts":"100.0001"} + ] + }`)) + }, + }) + client := newFakeClient(srv) + out, err := client.ConversationsReplies(context.Background(), "C1", "100.0001", "100.0001") + if err != nil { + t.Fatalf("ConversationsReplies: %v", err) + } + if len(out) != 2 || out[0].Text != "first" || out[1].ThreadTS != "100.0001" { + t.Errorf("unexpected thread: %+v", out) + } +} + +func TestCookieClient_ConversationsReplies_PinsHistoryToTriggerTSWhenNoThread(t *testing.T) { + var got struct{ oldest, latest, inclusive, limit string } + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "conversations.history": func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + got.oldest = r.PostForm.Get("oldest") + got.latest = r.PostForm.Get("latest") + got.inclusive = r.PostForm.Get("inclusive") + got.limit = r.PostForm.Get("limit") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"messages":[{"ts":"200.0001","user":"U1","text":"loner"}]}`)) + }, + }) + client := newFakeClient(srv) + out, err := client.ConversationsReplies(context.Background(), "C1", "", "200.0001") + if err != nil { + t.Fatalf("ConversationsReplies: %v", err) + } + // Both window bounds must be the trigger ts and inclusive=true; otherwise + // concurrent activity in the channel would land in the response instead + // of the actual trigger message. + if got.oldest != "200.0001" || got.latest != "200.0001" { + t.Errorf("expected oldest/latest pinned to trigger ts, got %+v", got) + } + if got.inclusive != "true" || got.limit != "1" { + t.Errorf("expected inclusive=true limit=1, got %+v", got) + } + if len(out) != 1 || out[0].Text != "loner" { + t.Errorf("unexpected fallback: %+v", out) + } +} + +func TestCookieClient_ChatPostMessage(t *testing.T) { + var got struct{ channel, text, threadTS string } + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "chat.postMessage": func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + got.channel = r.PostForm.Get("channel") + got.text = r.PostForm.Get("text") + got.threadTS = r.PostForm.Get("thread_ts") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + }, + }) + client := newFakeClient(srv) + if err := client.ChatPostMessage(context.Background(), "C1", "100.0001", "hello"); err != nil { + t.Fatalf("ChatPostMessage: %v", err) + } + if got.channel != "C1" || got.text != "hello" || got.threadTS != "100.0001" { + t.Errorf("payload mismatch: %+v", got) + } +} + +func TestCookieClient_ReactionsAdd(t *testing.T) { + var got struct{ channel, ts, name string } + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "reactions.add": func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + got.channel = r.PostForm.Get("channel") + got.ts = r.PostForm.Get("timestamp") + got.name = r.PostForm.Get("name") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + }, + }) + client := newFakeClient(srv) + if err := client.ReactionsAdd(context.Background(), "C1", "100.0001", "eyes"); err != nil { + t.Fatalf("ReactionsAdd: %v", err) + } + if got.channel != "C1" || got.ts != "100.0001" || got.name != "eyes" { + t.Errorf("payload mismatch: %+v", got) + } +} + +func TestCookieClient_ReactionsAdd_AlreadyReactedSwallowed(t *testing.T) { + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "reactions.add": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":false,"error":"already_reacted"}`)) + }, + }) + client := newFakeClient(srv) + if err := client.ReactionsAdd(context.Background(), "C1", "100.0001", "eyes"); err != nil { + t.Errorf("expected already_reacted to be swallowed, got: %v", err) + } +} + +func TestCookieClient_ReactionsAdd_OtherErrorBubbles(t *testing.T) { + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "reactions.add": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":false,"error":"channel_not_found"}`)) + }, + }) + client := newFakeClient(srv) + err := client.ReactionsAdd(context.Background(), "C1", "100.0001", "eyes") + if err == nil || !strings.Contains(err.Error(), "channel_not_found") { + t.Errorf("expected channel_not_found error, got %v", err) + } +} + +func TestCookieClient_RejectsEmptyCredentials(t *testing.T) { + // AuthTest absorbs ErrNotConfigured into the returned result rather than + // surfacing it as an error — assert on result.OK explicitly so the test + // catches the guard regression instead of vacuously passing on err==nil. + c := NewCookieClient(nil, "", "") + res, err := c.AuthTest(context.Background()) + if err != nil { + t.Fatalf("AuthTest: %v", err) + } + if res == nil || res.OK { + t.Errorf("expected ok=false when both credentials are empty, got %+v", res) + } + + c2 := NewCookieClient(nil, "xoxc-x", "") + _, err = c2.SearchMessages(context.Background(), "x") + if err == nil { + t.Error("expected error when cookie is missing") + } +} diff --git a/apps/backend/internal/slack/handlers.go b/apps/backend/internal/slack/handlers.go new file mode 100644 index 0000000000..0b9c4acf14 --- /dev/null +++ b/apps/backend/internal/slack/handlers.go @@ -0,0 +1,207 @@ +package slack + +import ( + "context" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// RegisterRoutes wires the Slack HTTP and WebSocket handlers. +func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) { + ctrl := &Controller{service: svc, logger: log} + ctrl.RegisterHTTPRoutes(router) + registerWSHandlers(dispatcher, svc) +} + +// Controller holds HTTP route handlers for the Slack integration. +type Controller struct { + service *Service + logger *logger.Logger +} + +// RegisterHTTPRoutes attaches the Slack HTTP endpoints to router. +func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { + api := router.Group("/api/v1/slack") + api.GET("/config", c.httpGetConfig) + api.POST("/config", c.httpSetConfig) + api.DELETE("/config", c.httpDeleteConfig) + api.POST("/config/test", c.httpTestConfig) +} + +// --- HTTP handlers --- + +func (c *Controller) httpGetConfig(ctx *gin.Context) { + cfg, err := c.service.GetConfig(ctx.Request.Context()) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if cfg == nil { + ctx.Status(http.StatusNoContent) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) httpSetConfig(ctx *gin.Context) { + var req SetConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + cfg, err := c.service.SetConfig(ctx.Request.Context(), &req) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, ErrInvalidConfig) { + status = http.StatusBadRequest + } + ctx.JSON(status, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) httpDeleteConfig(ctx *gin.Context) { + if err := c.service.DeleteConfig(ctx.Request.Context()); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *Controller) httpTestConfig(ctx *gin.Context) { + var req SetConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + result, err := c.service.TestConnection(ctx.Request.Context(), &req) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, result) +} + +// errCodeSlackNotConfigured is the wire-level code surfaced to the UI when +// no Slack credentials are saved. +const errCodeSlackNotConfigured = "SLACK_NOT_CONFIGURED" + +func (c *Controller) writeClientError(ctx *gin.Context, err error) { + if errors.Is(err, ErrNotConfigured) { + ctx.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Slack is not configured", + "code": errCodeSlackNotConfigured, + }) + return + } + var apiErr *APIError + if errors.As(err, &apiErr) { + status := http.StatusInternalServerError + switch apiErr.StatusCode { + case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden, http.StatusBadRequest: + status = apiErr.StatusCode + } + ctx.JSON(status, gin.H{"error": apiErr.Error()}) + return + } + c.logger.Warn("slack handler error", zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +} + +// --- WebSocket handlers --- + +func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service) { + dispatcher.RegisterFunc(ws.ActionSlackConfigGet, wsGetConfig(svc)) + dispatcher.RegisterFunc(ws.ActionSlackConfigSet, wsSetConfig(svc)) + dispatcher.RegisterFunc(ws.ActionSlackConfigDelete, wsDeleteConfig(svc)) + dispatcher.RegisterFunc(ws.ActionSlackConfigTest, wsTestConfig(svc)) +} + +func wsReply(msg *ws.Message, payload interface{}) (*ws.Message, error) { + resp, err := ws.NewResponse(msg.ID, msg.Action, payload) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return resp, nil +} + +// wsFail mirrors writeClientError's HTTP-side mapping so WS-driven flows +// surface bad-request / unauthorized / not-found / forbidden distinctly +// instead of collapsing every non-ErrNotConfigured failure into +// INTERNAL_ERROR. +func wsFail(msg *ws.Message, err error) (*ws.Message, error) { + if errors.Is(err, ErrNotConfigured) { + return ws.NewError(msg.ID, msg.Action, errCodeSlackNotConfigured, err.Error(), nil) + } + var apiErr *APIError + if errors.As(err, &apiErr) { + switch apiErr.StatusCode { + case http.StatusBadRequest: + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, apiErr.Error(), nil) + case http.StatusUnauthorized: + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeUnauthorized, apiErr.Error(), nil) + case http.StatusForbidden: + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeForbidden, apiErr.Error(), nil) + case http.StatusNotFound: + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, apiErr.Error(), nil) + } + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) +} + +func wsGetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + cfg, err := svc.GetConfig(ctx) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"config": cfg}) + } +} + +func wsSetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req SetConfigRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + cfg, err := svc.SetConfig(ctx, &req) + if err != nil { + if errors.Is(err, ErrInvalidConfig) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, err.Error(), nil) + } + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"config": cfg}) + } +} + +func wsDeleteConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + if err := svc.DeleteConfig(ctx); err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"deleted": true}) + } +} + +func wsTestConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req SetConfigRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + result, err := svc.TestConnection(ctx, &req) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, result) + } +} diff --git a/apps/backend/internal/slack/handlers_test.go b/apps/backend/internal/slack/handlers_test.go new file mode 100644 index 0000000000..82068a5fba --- /dev/null +++ b/apps/backend/internal/slack/handlers_test.go @@ -0,0 +1,54 @@ +package slack + +import ( + "encoding/json" + "errors" + "testing" + + ws "github.com/kandev/kandev/pkg/websocket" +) + +// TestWsFail_MapsAPIErrorCodes locks the wire-level error codes wsFail +// produces for each input variant. wsFail mirrors writeClientError's HTTP +// mapping so WS clients see the same distinct errors (bad-request, +// unauthorized, forbidden, not-found, internal) instead of the previous +// "everything → INTERNAL_ERROR" collapse, and a regression here would put +// the WS path silently out of sync with the HTTP path again. +func TestWsFail_MapsAPIErrorCodes(t *testing.T) { + msg := &ws.Message{ID: "1", Action: ws.ActionSlackConfigTest} + + cases := []struct { + name string + err error + wantCode string + }{ + {"ErrNotConfigured → SLACK_NOT_CONFIGURED", ErrNotConfigured, errCodeSlackNotConfigured}, + {"APIError 400 → BAD_REQUEST", &APIError{StatusCode: 400, Message: "bad"}, ws.ErrorCodeBadRequest}, + {"APIError 401 → UNAUTHORIZED", &APIError{StatusCode: 401, Message: "unauth"}, ws.ErrorCodeUnauthorized}, + {"APIError 403 → FORBIDDEN", &APIError{StatusCode: 403, Message: "forbidden"}, ws.ErrorCodeForbidden}, + {"APIError 404 → NOT_FOUND", &APIError{StatusCode: 404, Message: "notfound"}, ws.ErrorCodeNotFound}, + {"APIError 500 → INTERNAL_ERROR", &APIError{StatusCode: 500, Message: "server"}, ws.ErrorCodeInternalError}, + {"plain error → INTERNAL_ERROR", errors.New("transient"), ws.ErrorCodeInternalError}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + resp, err := wsFail(msg, c.err) + if err != nil { + t.Fatalf("wsFail returned error: %v", err) + } + if resp == nil { + t.Fatal("wsFail returned nil response") + } + var payload ws.ErrorPayload + if jsonErr := json.Unmarshal(resp.Payload, &payload); jsonErr != nil { + t.Fatalf("decode payload: %v (raw=%s)", jsonErr, string(resp.Payload)) + } + if payload.Code != c.wantCode { + t.Errorf("code mismatch: got %q want %q", payload.Code, c.wantCode) + } + if payload.Message == "" { + t.Error("expected non-empty message in error payload") + } + }) + } +} diff --git a/apps/backend/internal/slack/models.go b/apps/backend/internal/slack/models.go new file mode 100644 index 0000000000..22cdc3193f --- /dev/null +++ b/apps/backend/internal/slack/models.go @@ -0,0 +1,132 @@ +// Package slack implements the Slack integration: install-wide +// configuration storage, an HTTP client that authenticates as the user via +// the unofficial browser-session credentials (xoxc- token + d cookie), and a +// polling trigger that picks up `!kandev ` messages and turns +// them into Kandev tasks by handing them to a utility agent that has access +// to Kandev MCP tools. +// +// Slack credentials and triage settings are install-wide singletons (one +// Slack account per Kandev install) — the agent picks the destination Kandev +// workspace per-message via `list_workspaces_kandev`. Only "browser session" +// auth is supported in this v1 slice — bot tokens and user-OAuth modes are +// scoped out per docs/specs/slack-integration/spec.md. +package slack + +import "time" + +// AuthMethodCookie is the only auth method this slice supports: the xoxc- +// session token plus the `d` cookie that Slack's web client uses. +const AuthMethodCookie = "cookie" + +// DefaultCommandPrefix is the marker the trigger looks for at the start of a +// user's own message. Picked because it can't collide with Slack's own slash +// commands (which start with `/`) and is short enough to type. +const DefaultCommandPrefix = "!kandev" + +// DefaultPollIntervalSeconds is the cadence for checking Slack for new +// `!kandev …` matches. 30s is a comfortable middle ground: fast enough that +// captures feel responsive, slow enough to stay well inside Slack's +// `search.messages` rate limit (Tier 2 ~20/min). +const DefaultPollIntervalSeconds = 30 + +// MinPollIntervalSeconds is the floor users can set in the UI. Below 5s the +// `search.messages` cost dominates and a single match's utility-agent run +// takes longer than the polling interval anyway. +const MinPollIntervalSeconds = 5 + +// MaxPollIntervalSeconds caps the upper end of the slider. 10 minutes is +// long enough for "very low traffic" and short enough that a stuck/expired +// session shows up in the auth-status banner promptly. +const MaxPollIntervalSeconds = 600 + +// SlackConfig is the install-wide Slack integration configuration. The xoxc +// token and the d cookie live in the encrypted secret store under SecretKeyToken +// and SecretKeyCookie respectively. +type SlackConfig struct { + AuthMethod string `json:"authMethod" db:"auth_method"` + CommandPrefix string `json:"commandPrefix" db:"command_prefix"` + // UtilityAgentID points at a row in `utility_agents`. The agent is + // invoked for each Slack match with Kandev MCP wired in so it can call + // list_workflows_kandev, create_task_kandev, etc. Required — the trigger + // skips matches when this is empty. + UtilityAgentID string `json:"utilityAgentId" db:"utility_agent_id"` + PollIntervalSeconds int `json:"pollIntervalSeconds" db:"poll_interval_seconds"` + // SlackTeamID and SlackUserID are captured from the most recent successful + // probe so the trigger can scope `search.messages` to the user's own + // authorship without an extra round-trip per poll. + SlackTeamID string `json:"slackTeamId,omitempty" db:"slack_team_id"` + SlackUserID string `json:"slackUserId,omitempty" db:"slack_user_id"` + // LastSeenTS is the highest Slack message timestamp the trigger has + // already processed. + LastSeenTS string `json:"lastSeenTs,omitempty" db:"last_seen_ts"` + HasToken bool `json:"hasToken" db:"-"` + HasCookie bool `json:"hasCookie" db:"-"` + // LastCheckedAt / LastOk / LastError are written by the auth-health poller. + LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty" db:"last_checked_at"` + LastOk bool `json:"lastOk" db:"last_ok"` + LastError string `json:"lastError,omitempty" db:"last_error"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +// SetConfigRequest is the payload sent by the UI. Empty Token / Cookie on +// update keeps the existing stored value; non-empty replaces it. +type SetConfigRequest struct { + AuthMethod string `json:"authMethod"` + CommandPrefix string `json:"commandPrefix"` + UtilityAgentID string `json:"utilityAgentId"` + PollIntervalSeconds int `json:"pollIntervalSeconds"` + Token string `json:"token"` + Cookie string `json:"cookie"` +} + +// TestConnectionResult mirrors the Linear shape so the frontend can render +// success/failure with the same component for both integrations. +type TestConnectionResult struct { + OK bool `json:"ok"` + UserID string `json:"userId,omitempty"` + TeamID string `json:"teamId,omitempty"` + TeamName string `json:"teamName,omitempty"` + URL string `json:"url,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Error string `json:"error,omitempty"` +} + +// SlackMessage is the minimal shape we extract from Slack's web API for both +// the trigger search and the thread fetch. +type SlackMessage struct { + TS string `json:"ts"` + ThreadTS string `json:"threadTs,omitempty"` + ChannelID string `json:"channelId"` + UserID string `json:"userId,omitempty"` + UserName string `json:"userName,omitempty"` + Text string `json:"text"` + Permalink string `json:"permalink,omitempty"` +} + +// ThreadContext bundles a triggering message with the surrounding thread the +// trigger fetched for it. +type ThreadContext struct { + Trigger SlackMessage `json:"trigger"` + Messages []SlackMessage `json:"messages"` +} + +// SecretKeyToken is the secret-store key for the install-wide xoxc- token. +const SecretKeyToken = "slack:singleton:token" + +// SecretKeyCookie is the secret-store key for the install-wide `d` cookie. +// Stored separately from the token so each can be rotated/replaced +// independently — Slack rotates the d cookie much more often than the token. +const SecretKeyCookie = "slack:singleton:cookie" + +// LegacySecretKeyForToken returns the pre-singleton per-workspace token key, +// kept so the provider can promote an existing per-workspace token onto +// SecretKeyToken during the schema migration. +func LegacySecretKeyForToken(workspaceID string) string { + return "slack:" + workspaceID + ":token" +} + +// LegacySecretKeyForCookie returns the pre-singleton per-workspace cookie key. +func LegacySecretKeyForCookie(workspaceID string) string { + return "slack:" + workspaceID + ":cookie" +} diff --git a/apps/backend/internal/slack/poller.go b/apps/backend/internal/slack/poller.go new file mode 100644 index 0000000000..c1bb167d00 --- /dev/null +++ b/apps/backend/internal/slack/poller.go @@ -0,0 +1,52 @@ +package slack + +import ( + "context" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/integrations/healthpoll" +) + +// Poller probes the stored Slack credentials on the shared 90s auth-health +// cadence, mirroring Linear/Jira. The actual loop lives in +// internal/integrations/healthpoll; this wrapper exists so callers can write +// the familiar `slack.NewPoller(...).Start(ctx)` shape. +type Poller struct { + inner *healthpoll.Poller +} + +// NewPoller returns a poller using the default 90s cadence. Returns nil when +// svc is nil so the caller's nil-check still works. +func NewPoller(svc *Service, log *logger.Logger) *Poller { + if svc == nil { + return nil + } + return &Poller{inner: healthpoll.New("slack", svcProber{svc}, log)} +} + +// Start launches the background loop. +func (p *Poller) Start(ctx context.Context) { + if p == nil || p.inner == nil { + return + } + p.inner.Start(ctx) +} + +// Stop cancels the loop and waits for it to drain. +func (p *Poller) Stop() { + if p == nil || p.inner == nil { + return + } + p.inner.Stop() +} + +// svcProber adapts *Service to healthpoll.Prober. +type svcProber struct{ svc *Service } + +func (s svcProber) HasConfig(ctx context.Context) (bool, error) { + return s.svc.Store().HasConfig(ctx) +} + +func (s svcProber) RecordAuthHealth(ctx context.Context) { + s.svc.RecordAuthHealth(ctx) +} diff --git a/apps/backend/internal/slack/provider.go b/apps/backend/internal/slack/provider.go new file mode 100644 index 0000000000..b8f6dd8596 --- /dev/null +++ b/apps/backend/internal/slack/provider.go @@ -0,0 +1,64 @@ +package slack + +import ( + "context" + "time" + + "github.com/jmoiron/sqlx" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// Provide builds the Slack service. Cleanup is a no-op — the service holds +// only an in-memory client cache. Mirrors the other integration providers so +// callers register it uniformly. +// +// The agent runner is wired post-construction via SetRunner because the +// host-utility manager is built later in the backend's startup sequence. +func Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) (*Service, func() error, error) { + store, err := NewStore(writer, reader) + if err != nil { + return nil, nil, err + } + migrateLegacySecrets(store, secrets, log) + svc := NewService(store, secrets, nil, DefaultClientFactory, log) + cleanup := func() error { return nil } + return svc, cleanup, nil +} + +// migrateLegacySecrets copies the per-workspace token + cookie from the +// pre-singleton secret keys onto the new install-wide keys, then deletes the +// legacy entries. Best-effort: any error is logged and ignored — the worst +// case is the user re-pastes their credentials in the settings UI. +func migrateLegacySecrets(store *Store, secrets SecretStore, log *logger.Logger) { + source := store.MigratedFromWorkspace() + if source == "" || secrets == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + migrateOne(ctx, secrets, LegacySecretKeyForToken(source), SecretKeyToken, "Slack token", log) + migrateOne(ctx, secrets, LegacySecretKeyForCookie(source), SecretKeyCookie, "Slack d cookie", log) +} + +func migrateOne(ctx context.Context, secrets SecretStore, legacyKey, newKey, displayName string, log *logger.Logger) { + // If a fresh value already exists at the new key (someone migrated + // manually, or this codepath ran on a previous boot), don't clobber it. + if exists, err := secrets.Exists(ctx, newKey); err == nil && exists { + return + } + value, err := secrets.Reveal(ctx, legacyKey) + if err != nil || value == "" { + return + } + if err := secrets.Set(ctx, newKey, displayName, value); err != nil { + log.Warn("slack: legacy secret migration failed", + zap.String("key", legacyKey), zap.Error(err)) + return + } + if err := secrets.Delete(ctx, legacyKey); err != nil { + log.Warn("slack: legacy secret cleanup failed", + zap.String("key", legacyKey), zap.Error(err)) + } +} diff --git a/apps/backend/internal/slack/runtime.go b/apps/backend/internal/slack/runtime.go new file mode 100644 index 0000000000..f97dbdb48f --- /dev/null +++ b/apps/backend/internal/slack/runtime.go @@ -0,0 +1,302 @@ +package slack + +import ( + "context" + "errors" + "fmt" + "strings" + + "go.uber.org/zap" + + agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" + "github.com/kandev/kandev/internal/common/logger" + utilitymodels "github.com/kandev/kandev/internal/utility/models" + utilityservice "github.com/kandev/kandev/internal/utility/service" + "github.com/kandev/kandev/internal/utility/template" +) + +// agentSystemPrompt is the persona used when a utility agent's prompt template +// is empty (or doesn't reference any Slack-specific variables). It tells the +// agent it's acting as a triage assistant, names the MCP tools it has, and +// asks for a concise final reply suitable for posting back to Slack. +// +// When the user provides a custom prompt that references Slack template +// variables (e.g. {{SlackInstruction}}, {{SlackThread}}), the template wins +// and we don't append anything — the user is in full control. +const defaultSlackSystemPrompt = `You are a Kandev triage assistant. A user just sent a request from Slack — an instruction plus the surrounding thread for context. Your job: + +1. Use the Kandev MCP tools available to you (list_workspaces_kandev, list_workflows_kandev, list_workflow_steps_kandev, list_repositories_kandev, create_task_kandev, list_agents_kandev, list_executor_profiles_kandev) to figure out which Kandev workspace + workflow + column + repository the request belongs in. **Always call list_workspaces_kandev first** — Slack is install-wide, so you must pick the destination Kandev workspace from the list rather than assume one. Then list_repositories_kandev for that workspace before deciding which repo to attach — never guess a repository_id. +2. Create a Kandev task that captures the request, with a clear title and a description that gives the future agent everything it needs (the user's instruction and any thread context that matters). +3. Reply briefly with what you did — the task title, the workspace it landed in, and a short rationale. Your reply will be posted back into Slack as your final message, so write naturally for a human reader. + +Don't ask the user follow-up questions in the reply — your reply is posted in-thread without a chance for them to respond mid-run. If something is ambiguous, pick the most likely option and explain your choice.` + +// HostUtilityRunner is the slice of hostutility.Manager we depend on. +type HostUtilityRunner interface { + ExecutePromptWithMCP( + ctx context.Context, + agentType, model, mode, prompt string, + mcpServers []agentctlutil.MCPServerDTO, + ) (HostPromptResult, error) +} + +// HostPromptResult mirrors hostutility.PromptResult — duplicated here so the +// slack package doesn't import hostutility (which has heavy transitive deps). +type HostPromptResult struct { + Response string + Model string + PromptTokens int + ResponseTokens int + DurationMs int +} + +// UtilityRegistry is the slice of utility.Service the runner needs: enough to +// look up an agent and prepare a resolved prompt request that handles the +// "empty agent_id/model falls back to user defaults" path the rest of Kandev +// already implements. +type UtilityRegistry interface { + GetAgentByID(ctx context.Context, id string) (*utilitymodels.UtilityAgent, error) + PreparePromptRequest( + ctx context.Context, + utilityID string, + tmplCtx *template.Context, + defaults *utilityservice.DefaultUtilitySettings, + sessionless bool, + ) (*utilityservice.PromptRequest, error) +} + +// UserDefaults is the slice of user.Service the runner uses to resolve +// per-user default agent/model when the chosen utility agent leaves them +// empty (the normal state for built-in agents). +type UserDefaults interface { + GetDefaultUtilitySettings(ctx context.Context) (agentID, model string, err error) +} + +// MCPDescriptor describes a single MCP server to wire into the agent's +// session. Mirrors the agentctlutil.MCPServerDTO shape. +type MCPDescriptor struct { + Name string + URL string +} + +// Runner runs the utility agent for a Slack match. +type Runner struct { + utility UtilityRegistry + userDefaults UserDefaults + host HostUtilityRunner + mcpServers []MCPDescriptor + log *logger.Logger +} + +// NewRunner builds a runner. +func NewRunner(utility UtilityRegistry, userDefaults UserDefaults, host HostUtilityRunner, mcpServers []MCPDescriptor, log *logger.Logger) *Runner { + return &Runner{utility: utility, userDefaults: userDefaults, host: host, mcpServers: mcpServers, log: log} +} + +// ErrNoUtilityAgent is returned when the workspace's Slack config doesn't +// reference a utility agent. +var ErrNoUtilityAgent = errors.New("slack: no utility agent configured") + +// RunForMatch implements AgentRunner. Slack is install-wide singleton, so +// there's no workspace_id to pass — the agent picks the destination Kandev +// workspace itself via list_workspaces_kandev. +func (r *Runner) RunForMatch( + ctx context.Context, + cfg *SlackConfig, + msg SlackMessage, + instruction, permalink string, + thread []SlackMessage, +) (string, error) { + if cfg.UtilityAgentID == "" { + return "", ErrNoUtilityAgent + } + agent, err := r.utility.GetAgentByID(ctx, cfg.UtilityAgentID) + if err != nil { + return "", fmt.Errorf("load utility agent: %w", err) + } + + defaults := r.resolveDefaults(ctx) + tmplCtx := r.buildTemplateContext(msg, instruction, permalink, thread) + + prepared, err := r.utility.PreparePromptRequest(ctx, cfg.UtilityAgentID, tmplCtx, defaults, true) + if err != nil { + return "", fmt.Errorf("prepare prompt: %w", err) + } + if prepared.AgentCLI == "" { + return "", fmt.Errorf("no agent_id resolved for utility agent %q (set one on the agent or pick a default in /settings/utility-agents)", agent.Name) + } + + prompt := r.composePrompt(agent.Prompt, prepared.ResolvedPrompt, msg, instruction, permalink, thread) + mcpDTOs := r.mcpDTOs() + + r.log.Info("slack: invoking utility agent", + zap.String("utility_agent", agent.Name), + zap.String("agent_cli", prepared.AgentCLI), + zap.String("model", prepared.Model), + zap.Int("mcp_servers", len(mcpDTOs)), + zap.String("trigger_ts", msg.TS)) + + res, err := r.host.ExecutePromptWithMCP(ctx, prepared.AgentCLI, prepared.Model, "", prompt, mcpDTOs) + if err != nil { + return "", fmt.Errorf("execute utility prompt: %w", err) + } + reply := strings.TrimSpace(res.Response) + if reply == "" { + r.log.Warn("slack: agent returned empty response", + zap.String("trigger_ts", msg.TS)) + reply = "(Kandev triage agent finished without producing text — check the kanban board.)" + } + return reply, nil +} + +// resolveDefaults reads the user-default agent/model. A read failure here +// just means we'll surface a clearer "no agent_id resolved" error later +// rather than crashing the whole flow on a settings-DB blip. +func (r *Runner) resolveDefaults(ctx context.Context) *utilityservice.DefaultUtilitySettings { + if r.userDefaults == nil { + return nil + } + agentID, model, err := r.userDefaults.GetDefaultUtilitySettings(ctx) + if err != nil { + r.log.Warn("slack: read default utility settings failed", zap.Error(err)) + return nil + } + return &utilityservice.DefaultUtilitySettings{AgentID: agentID, Model: model} +} + +// buildTemplateContext maps Slack data into the existing template Context's +// Custom map so users can reference {{SlackInstruction}}, {{SlackThread}}, etc. +// in their utility agent's prompt template. UserPrompt is set too so prompts +// shared with other utility agents (which use {{UserPrompt}}) work without +// modification. +func (r *Runner) buildTemplateContext(trigger SlackMessage, instruction, permalink string, thread []SlackMessage) *template.Context { + return &template.Context{ + UserPrompt: instruction, + Custom: map[string]string{ + "SlackInstruction": instruction, + "SlackThread": formatThread(thread), + "SlackPermalink": permalink, + "SlackUser": senderLabel(trigger), + "SlackChannelID": trigger.ChannelID, + "SlackTS": trigger.TS, + }, + } +} + +// slackTemplateVars are the placeholders authored Slack-triage templates +// reference. Detecting any of these (not just "the resolver substituted +// anything") in the raw template is what tells us the user designed the +// prompt for Slack rather than reusing a generic utility-agent template +// that happens to use {{UserPrompt}}. +var slackTemplateVars = []string{ + "{{SlackInstruction}}", + "{{SlackThread}}", + "{{SlackPermalink}}", + "{{SlackUser}}", + "{{SlackChannelID}}", + "{{SlackTS}}", +} + +func referencesSlackVars(rawTemplate string) bool { + for _, v := range slackTemplateVars { + if strings.Contains(rawTemplate, v) { + return true + } + } + return false +} + +// composePrompt picks between the resolved template (when the user authored +// one and it references at least one Slack-specific placeholder) and the +// legacy "default system prompt + appended Slack context block" layout. +// +// We test for Slack-specific placeholders explicitly, not just "the resolver +// substituted anything" — a generic utility-agent prompt that uses +// {{UserPrompt}} would otherwise hijack the Slack-owned path and the agent +// would lose the MCP-tool guidance baked into the default system prompt. +// When the raw template has no Slack vars, we fall back so users with empty +// or generic templates still get a working triage prompt. +func (r *Runner) composePrompt(rawTemplate, resolved string, trigger SlackMessage, instruction, permalink string, thread []SlackMessage) string { + tmpl := strings.TrimSpace(rawTemplate) + resolvedTrim := strings.TrimSpace(resolved) + if tmpl != "" && referencesSlackVars(rawTemplate) { + return resolvedTrim + } + system := resolvedTrim + if system == "" { + system = defaultSlackSystemPrompt + } + return appendSlackContextBlock(system, trigger, instruction, permalink, thread) +} + +func appendSlackContextBlock(system string, trigger SlackMessage, instruction, permalink string, thread []SlackMessage) string { + var b strings.Builder + b.WriteString(system) + b.WriteString("\n\n--- Slack context ---\n") + if permalink != "" { + b.WriteString("Slack thread: ") + b.WriteString(permalink) + b.WriteString("\n") + } + if instruction != "" { + b.WriteString("Request from ") + b.WriteString(senderLabel(trigger)) + b.WriteString(":\n") + b.WriteString(instruction) + b.WriteString("\n") + } + if len(thread) > 0 { + b.WriteString("\nThread context:\n") + b.WriteString(formatThread(thread)) + } + return strings.TrimRight(b.String(), "\n") +} + +func formatThread(thread []SlackMessage) string { + if len(thread) == 0 { + return "" + } + var b strings.Builder + for _, m := range thread { + who := m.UserName + if who == "" { + who = m.UserID + } + if who == "" { + who = "(unknown)" + } + b.WriteString(who) + b.WriteString(" (") + b.WriteString(m.TS) + b.WriteString("): ") + b.WriteString(strings.TrimSpace(m.Text)) + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} + +func (r *Runner) mcpDTOs() []agentctlutil.MCPServerDTO { + if len(r.mcpServers) == 0 { + return nil + } + out := make([]agentctlutil.MCPServerDTO, 0, len(r.mcpServers)) + for _, m := range r.mcpServers { + out = append(out, agentctlutil.MCPServerDTO{ + Name: m.Name, + Type: "http", + URL: m.URL, + }) + } + return out +} + +func senderLabel(m SlackMessage) string { + switch { + case m.UserName != "": + return "@" + m.UserName + case m.UserID != "": + return "<@" + m.UserID + ">" + default: + return "the user" + } +} diff --git a/apps/backend/internal/slack/service.go b/apps/backend/internal/slack/service.go new file mode 100644 index 0000000000..5fc580886e --- /dev/null +++ b/apps/backend/internal/slack/service.go @@ -0,0 +1,359 @@ +package slack + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// SecretStore is the subset of secrets the service needs. +type SecretStore interface { + Reveal(ctx context.Context, id string) (string, error) + Set(ctx context.Context, id, name, value string) error + Delete(ctx context.Context, id string) error + Exists(ctx context.Context, id string) (bool, error) +} + +// Service orchestrates Slack config storage, the cached client, the +// auth-health probe, and the utility-agent run that turns a matched Slack +// message into a Kandev task. Slack is install-wide (one Slack user/team per +// Kandev install); the agent picks the destination Kandev workspace per +// message via MCP. +type Service struct { + store *Store + secrets SecretStore + runner AgentRunner + log *logger.Logger + + mu sync.Mutex + clientFn ClientFactory + client Client // singleton, cleared on config change. + probeHook func() +} + +// AgentRunner runs the configured utility agent for a Slack match. Defined as +// an interface so tests can inject a fake without spinning up agentctl. +type AgentRunner interface { + RunForMatch(ctx context.Context, cfg *SlackConfig, msg SlackMessage, instruction, permalink string, thread []SlackMessage) (string, error) +} + +// ClientFactory builds a Client from a config + the (token, cookie) pair. +type ClientFactory func(cfg *SlackConfig, token, cookie string) Client + +// DefaultClientFactory returns a real CookieClient. +func DefaultClientFactory(cfg *SlackConfig, token, cookie string) Client { + return NewCookieClient(cfg, token, cookie) +} + +// NewService wires the service. Pass nil for clientFn to use the default. +// runner may be nil — when nil, matched Slack messages are logged but no +// agent runs (useful in tests and during partial backend init). +func NewService( + store *Store, + secrets SecretStore, + runner AgentRunner, + clientFn ClientFactory, + log *logger.Logger, +) *Service { + if clientFn == nil { + clientFn = DefaultClientFactory + } + return &Service{ + store: store, + secrets: secrets, + runner: runner, + log: log, + clientFn: clientFn, + } +} + +// SetRunner wires the agent runner after construction. main.go calls this +// once the host-utility manager + utility service are available. +func (s *Service) SetRunner(r AgentRunner) { + s.mu.Lock() + defer s.mu.Unlock() + s.runner = r +} + +// Runner returns the wired runner (or nil). +func (s *Service) Runner() AgentRunner { + s.mu.Lock() + defer s.mu.Unlock() + return s.runner +} + +// GetConfig returns the singleton config enriched with HasToken/HasCookie. +func (s *Service) GetConfig(ctx context.Context) (*SlackConfig, error) { + cfg, err := s.store.GetConfig(ctx) + if err != nil || cfg == nil { + return cfg, err + } + if s.secrets == nil { + return cfg, nil + } + cfg.HasToken = s.secretExists(ctx, SecretKeyToken, "token") + cfg.HasCookie = s.secretExists(ctx, SecretKeyCookie, "cookie") + return cfg, nil +} + +func (s *Service) secretExists(ctx context.Context, id, kind string) bool { + exists, err := s.secrets.Exists(ctx, id) + if err != nil { + s.log.Warn("slack: secret exists check failed", + zap.String("kind", kind), zap.Error(err)) + } + return exists +} + +// ErrInvalidConfig is returned by SetConfig when the request fails validation. +var ErrInvalidConfig = errors.New("slack: invalid configuration") + +func (s *Service) persistSecrets(ctx context.Context, req *SetConfigRequest) error { + if s.secrets == nil { + return nil + } + if req.Token != "" { + if err := s.secrets.Set(ctx, SecretKeyToken, "Slack token", req.Token); err != nil { + return fmt.Errorf("store slack token: %w", err) + } + } + if req.Cookie != "" { + if err := s.secrets.Set(ctx, SecretKeyCookie, "Slack d cookie", req.Cookie); err != nil { + return fmt.Errorf("store slack cookie: %w", err) + } + } + return nil +} + +// SetConfig is upsert. Empty Token/Cookie on update keeps the existing values. +func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*SlackConfig, error) { + if err := validateConfigRequest(req); err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error()) + } + cfg := &SlackConfig{ + AuthMethod: req.AuthMethod, + CommandPrefix: req.CommandPrefix, + UtilityAgentID: req.UtilityAgentID, + PollIntervalSeconds: req.PollIntervalSeconds, + } + if err := s.store.UpsertConfig(ctx, cfg); err != nil { + return nil, fmt.Errorf("upsert slack config: %w", err) + } + if err := s.persistSecrets(ctx, req); err != nil { + return nil, err + } + s.invalidateClient() + go func() { + s.RecordAuthHealth(context.Background()) + }() + return s.GetConfig(ctx) +} + +// DeleteConfig removes both the row and the stored secrets. +func (s *Service) DeleteConfig(ctx context.Context) error { + if err := s.store.DeleteConfig(ctx); err != nil { + return err + } + if s.secrets != nil { + s.deleteSecret(ctx, SecretKeyToken, "token") + s.deleteSecret(ctx, SecretKeyCookie, "cookie") + } + s.invalidateClient() + return nil +} + +func (s *Service) deleteSecret(ctx context.Context, id, kind string) { + if err := s.secrets.Delete(ctx, id); err != nil { + s.log.Warn("slack: secret delete failed", + zap.String("kind", kind), zap.Error(err)) + } +} + +// TestConnection validates credentials either inline (from a fresh request) +// or from the stored secrets. +func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*TestConnectionResult, error) { + cfg, token, cookie, err := s.resolveCredentials(ctx, req) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + client := s.clientFn(cfg, token, cookie) + return client.AuthTest(ctx) +} + +// ProbeAuth validates the stored credentials. +func (s *Service) ProbeAuth(ctx context.Context) (*TestConnectionResult, error) { + client, err := s.clientFor(ctx) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return client.AuthTest(ctx) +} + +// Store exposes the underlying store so background workers can persist state. +func (s *Service) Store() *Store { + return s.store +} + +// authProbeTimeout caps a single auth-health probe. +const authProbeTimeout = 15 * time.Second + +// authHealthWriteTimeout bounds the DB write that persists the probe outcome. +const authHealthWriteTimeout = 5 * time.Second + +// SetProbeHook installs a callback fired at the end of each RecordAuthHealth. +func (s *Service) SetProbeHook(fn func()) { + s.mu.Lock() + defer s.mu.Unlock() + s.probeHook = fn +} + +// RecordAuthHealth probes credentials and writes the outcome onto the row. +func (s *Service) RecordAuthHealth(ctx context.Context) { + probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout) + defer cancel() + res, err := s.ProbeAuth(probeCtx) + ok := err == nil && res != nil && res.OK + errMsg := "" + switch { + case err != nil: + errMsg = err.Error() + case res != nil && !res.OK: + errMsg = res.Error + } + teamID, userID := "", "" + if res != nil && ok { + teamID = res.TeamID + userID = res.UserID + } + writeCtx, writeCancel := context.WithTimeout(context.Background(), authHealthWriteTimeout) + defer writeCancel() + if updateErr := s.store.UpdateAuthHealth(writeCtx, ok, errMsg, teamID, userID, time.Now().UTC()); updateErr != nil { + s.log.Warn("slack: update auth health failed", zap.Error(updateErr)) + } + if !ok { + s.invalidateClient() + } + s.mu.Lock() + hook := s.probeHook + s.mu.Unlock() + if hook != nil { + hook() + } +} + +// Client exposes the cached client to the trigger and runtime. +func (s *Service) Client(ctx context.Context) (Client, error) { + return s.clientFor(ctx) +} + +func (s *Service) clientFor(ctx context.Context) (Client, error) { + s.mu.Lock() + if s.client != nil { + c := s.client + s.mu.Unlock() + return c, nil + } + s.mu.Unlock() + + cfg, err := s.store.GetConfig(ctx) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, ErrNotConfigured + } + token, cookie, err := s.revealSecrets(ctx) + if err != nil { + return nil, err + } + if token == "" || cookie == "" { + return nil, ErrNotConfigured + } + client := s.clientFn(cfg, token, cookie) + s.mu.Lock() + defer s.mu.Unlock() + if s.client != nil { + return s.client, nil + } + s.client = client + return client, nil +} + +func (s *Service) revealSecrets(ctx context.Context) (string, string, error) { + if s.secrets == nil { + return "", "", nil + } + token, err := s.secrets.Reveal(ctx, SecretKeyToken) + if err != nil { + return "", "", fmt.Errorf("read slack token: %w", err) + } + cookie, err := s.secrets.Reveal(ctx, SecretKeyCookie) + if err != nil { + return "", "", fmt.Errorf("read slack cookie: %w", err) + } + return token, cookie, nil +} + +func (s *Service) invalidateClient() { + s.mu.Lock() + s.client = nil + s.mu.Unlock() +} + +func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) (*SlackConfig, string, string, error) { + cfg := &SlackConfig{AuthMethod: req.AuthMethod} + token, cookie := req.Token, req.Cookie + if token != "" && cookie != "" { + return cfg, token, cookie, nil + } + if s.secrets == nil { + return nil, "", "", errors.New("no secret store configured") + } + if token == "" { + stored, err := s.secrets.Reveal(ctx, SecretKeyToken) + if err != nil { + s.log.Warn("slack: token reveal failed", zap.Error(err)) + return nil, "", "", fmt.Errorf("read slack token: %w", err) + } + token = stored + } + if cookie == "" { + stored, err := s.secrets.Reveal(ctx, SecretKeyCookie) + if err != nil { + s.log.Warn("slack: cookie reveal failed", zap.Error(err)) + return nil, "", "", fmt.Errorf("read slack cookie: %w", err) + } + cookie = stored + } + if token == "" || cookie == "" { + return nil, "", "", errors.New("token and cookie required — paste both to test") + } + return cfg, token, cookie, nil +} + +func validateConfigRequest(req *SetConfigRequest) error { + if req.AuthMethod == "" { + req.AuthMethod = AuthMethodCookie + } + if req.AuthMethod != AuthMethodCookie { + return fmt.Errorf("unknown auth method: %q", req.AuthMethod) + } + req.CommandPrefix = strings.TrimSpace(req.CommandPrefix) + if req.CommandPrefix == "" { + req.CommandPrefix = DefaultCommandPrefix + } + if req.PollIntervalSeconds == 0 { + req.PollIntervalSeconds = DefaultPollIntervalSeconds + } + if req.PollIntervalSeconds < MinPollIntervalSeconds || req.PollIntervalSeconds > MaxPollIntervalSeconds { + return fmt.Errorf("pollIntervalSeconds must be between %d and %d", MinPollIntervalSeconds, MaxPollIntervalSeconds) + } + return nil +} diff --git a/apps/backend/internal/slack/store.go b/apps/backend/internal/slack/store.go new file mode 100644 index 0000000000..9cd5b2e23b --- /dev/null +++ b/apps/backend/internal/slack/store.go @@ -0,0 +1,297 @@ +package slack + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +// Store persists the install-wide Slack configuration. Secret values (xoxc +// token, d cookie) live in the shared encrypted secrets store, not here. +type Store struct { + db *sqlx.DB + ro *sqlx.DB + + // migratedFromWorkspace records the workspace_id of the row that was + // promoted into the singleton during initSchema. Provider reads this to + // migrate the per-workspace secrets to the new global keys. Empty when + // no migration ran. + migratedFromWorkspace string +} + +// NewStore creates a new Store and initializes the schema if needed. +func NewStore(writer, reader *sqlx.DB) (*Store, error) { + s := &Store{db: writer, ro: reader} + if err := s.initSchema(); err != nil { + return nil, fmt.Errorf("slack schema init: %w", err) + } + return s, nil +} + +// MigratedFromWorkspace returns the workspace_id of the row promoted to the +// singleton during the per-workspace → singleton schema migration, or "" when +// no migration ran. +func (s *Store) MigratedFromWorkspace() string { + return s.migratedFromWorkspace +} + +const createTablesSQL = ` + CREATE TABLE IF NOT EXISTS slack_configs ( + id TEXT PRIMARY KEY CHECK(id = 'singleton'), + auth_method TEXT NOT NULL, + command_prefix TEXT NOT NULL DEFAULT '', + utility_agent_id TEXT NOT NULL DEFAULT '', + poll_interval_seconds INTEGER NOT NULL DEFAULT 30, + slack_team_id TEXT NOT NULL DEFAULT '', + slack_user_id TEXT NOT NULL DEFAULT '', + last_seen_ts TEXT NOT NULL DEFAULT '', + last_checked_at DATETIME, + last_ok INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); +` + +// singletonID is the synthetic primary key of the (only) row in slack_configs. +const singletonID = "singleton" + +func (s *Store) initSchema() error { + if err := s.migrateLegacyPerWorkspaceTable(); err != nil { + return err + } + if _, err := s.db.Exec(createTablesSQL); err != nil { + return err + } + return nil +} + +// migrateLegacyPerWorkspaceTable detects the pre-singleton schema (where +// slack_configs was keyed by workspace_id) and rewrites it into the singleton +// shape. Picks the most-recently-updated row and records the source +// workspace_id so the provider can migrate the secrets. +func (s *Store) migrateLegacyPerWorkspaceTable() error { + cols, err := s.tableColumns() + if err != nil { + return err + } + if len(cols) == 0 { + return nil + } + if _, hasWorkspace := cols["workspace_id"]; !hasWorkspace { + return nil + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + // Build the SELECT against only columns present in this database — the + // per-workspace shape evolved across patches (utility_agent_id, + // poll_interval_seconds, the slack_team/slack_user/last_seen trio, and + // the auth-health trio were all added incrementally), so a deployment + // upgrading from any earlier intermediate would crash on a SELECT that + // assumed every column. Mirrors the Linear/Jira fix. + selectCols := "workspace_id, auth_method" + selectCols += pickCol(cols, "command_prefix", "''") + selectCols += pickCol(cols, "utility_agent_id", "''") + selectCols += pickCol(cols, "poll_interval_seconds", "30") + selectCols += pickCol(cols, "slack_team_id", "''") + selectCols += pickCol(cols, "slack_user_id", "''") + selectCols += pickCol(cols, "last_seen_ts", "''") + selectCols += pickCol(cols, "last_checked_at", "NULL") + selectCols += pickCol(cols, "last_ok", "0") + selectCols += pickCol(cols, "last_error", "''") + selectCols += ", created_at, updated_at" + + var sourceWorkspace, authMethod, commandPrefix, utilityAgentID sql.NullString + var pollIntervalSeconds sql.NullInt64 + var slackTeamID, slackUserID, lastSeenTS, lastError sql.NullString + var lastCheckedAt sql.NullTime + var lastOk sql.NullInt64 + var createdAt, updatedAt sql.NullTime + row := tx.QueryRow(`SELECT ` + selectCols + ` FROM slack_configs ORDER BY updated_at DESC, workspace_id DESC LIMIT 1`) + switch err := row.Scan(&sourceWorkspace, &authMethod, + &commandPrefix, &utilityAgentID, &pollIntervalSeconds, + &slackTeamID, &slackUserID, &lastSeenTS, + &lastCheckedAt, &lastOk, &lastError, + &createdAt, &updatedAt); { + case errors.Is(err, sql.ErrNoRows): + if _, err := tx.Exec(`DROP TABLE slack_configs`); err != nil { + return err + } + return tx.Commit() + case err != nil: + return err + } + if _, err := tx.Exec(`DROP TABLE slack_configs`); err != nil { + return err + } + if _, err := tx.Exec(createTablesSQL); err != nil { + return err + } + pollSeconds := int(pollIntervalSeconds.Int64) + if pollSeconds == 0 { + pollSeconds = DefaultPollIntervalSeconds + } + if _, err := tx.Exec(` + INSERT INTO slack_configs ( + id, auth_method, command_prefix, utility_agent_id, poll_interval_seconds, + slack_team_id, slack_user_id, last_seen_ts, + last_checked_at, last_ok, last_error, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + singletonID, authMethod.String, commandPrefix.String, utilityAgentID.String, pollSeconds, + slackTeamID.String, slackUserID.String, lastSeenTS.String, + nullableTime(lastCheckedAt), lastOk.Int64, lastError.String, + nullableTime(createdAt), nullableTime(updatedAt)); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + s.migratedFromWorkspace = sourceWorkspace.String + return nil +} + +// pickCol returns ", " if the column is present in the legacy schema +// or ", AS " otherwise. Lets the migration SELECT defaults +// for columns that didn't exist in earlier intermediate schemas. +func pickCol(cols map[string]struct{}, name, fallback string) string { + if _, ok := cols[name]; ok { + return ", " + name + } + return ", " + fallback + " AS " + name +} + +func nullableTime(t sql.NullTime) interface{} { + if !t.Valid { + return nil + } + return t.Time +} + +// tableColumns returns the column-name set for slack_configs. SQLite doesn't +// support parameterised identifiers in PRAGMA, and the only table this +// migration logic ever inspects is slack_configs, so the table name is +// inlined as a literal. +func (s *Store) tableColumns() (map[string]struct{}, error) { + rows, err := s.db.Query(`PRAGMA table_info(slack_configs)`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + cols := make(map[string]struct{}) + for rows.Next() { + var ( + cid int + name string + ctype string + notnull int + dflt sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return nil, err + } + cols[name] = struct{}{} + } + return cols, rows.Err() +} + +const selectConfigColumns = `auth_method, command_prefix, utility_agent_id, + poll_interval_seconds, + slack_team_id, slack_user_id, last_seen_ts, + last_checked_at, last_ok, last_error, created_at, updated_at` + +// GetConfig returns the singleton Slack config, or nil when no row exists. +func (s *Store) GetConfig(ctx context.Context) (*SlackConfig, error) { + var cfg SlackConfig + err := s.ro.GetContext(ctx, &cfg, + `SELECT `+selectConfigColumns+` FROM slack_configs WHERE id = ?`, singletonID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &cfg, nil +} + +// UpsertConfig inserts or updates the singleton row. Health columns +// (last_*) and watermark columns (last_seen_ts, slack_team_id, slack_user_id) +// are owned by the poller / trigger and aren't touched here. +func (s *Store) UpsertConfig(ctx context.Context, cfg *SlackConfig) error { + now := time.Now().UTC() + if cfg.CreatedAt.IsZero() { + cfg.CreatedAt = now + } + cfg.UpdatedAt = now + _, err := s.db.ExecContext(ctx, ` + INSERT INTO slack_configs ( + id, auth_method, + command_prefix, utility_agent_id, poll_interval_seconds, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + auth_method = excluded.auth_method, + command_prefix = excluded.command_prefix, + utility_agent_id = excluded.utility_agent_id, + poll_interval_seconds = excluded.poll_interval_seconds, + updated_at = excluded.updated_at`, + singletonID, cfg.AuthMethod, + cfg.CommandPrefix, cfg.UtilityAgentID, cfg.PollIntervalSeconds, + cfg.CreatedAt, cfg.UpdatedAt) + return err +} + +// DeleteConfig removes the singleton row. Secrets must be cleared separately. +func (s *Store) DeleteConfig(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM slack_configs WHERE id = ?`, singletonID) + return err +} + +// HasConfig reports whether the singleton row exists. Used by the auth-health +// poller to decide whether to probe at all. +func (s *Store) HasConfig(ctx context.Context) (bool, error) { + var present int + err := s.ro.GetContext(ctx, &present, + `SELECT COUNT(*) FROM slack_configs WHERE id = ?`, singletonID) + if err != nil { + return false, err + } + return present > 0, nil +} + +// UpdateAuthHealth records the result of a credential probe and (when +// supplied) the user/team identifiers captured during the same probe so the +// trigger can scope its searches without an extra round-trip. +func (s *Store) UpdateAuthHealth(ctx context.Context, ok bool, errMsg, teamID, userID string, checkedAt time.Time) error { + if teamID != "" && userID != "" { + _, err := s.db.ExecContext(ctx, ` + UPDATE slack_configs + SET last_checked_at = ?, last_ok = ?, last_error = ?, + slack_team_id = ?, slack_user_id = ? + WHERE id = ?`, + checkedAt, ok, errMsg, teamID, userID, singletonID) + return err + } + _, err := s.db.ExecContext(ctx, ` + UPDATE slack_configs + SET last_checked_at = ?, last_ok = ?, last_error = ? + WHERE id = ?`, + checkedAt, ok, errMsg, singletonID) + return err +} + +// UpdateLastSeenTS advances the trigger's watermark. +func (s *Store) UpdateLastSeenTS(ctx context.Context, ts string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE slack_configs SET last_seen_ts = ? WHERE id = ?`, + ts, singletonID) + return err +} diff --git a/apps/backend/internal/slack/store_test.go b/apps/backend/internal/slack/store_test.go new file mode 100644 index 0000000000..8bd3176991 --- /dev/null +++ b/apps/backend/internal/slack/store_test.go @@ -0,0 +1,250 @@ +package slack + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + raw, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + raw.SetMaxOpenConns(1) + raw.SetMaxIdleConns(1) + db := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + return store +} + +func TestStore_UpsertGetDelete(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + cfg := &SlackConfig{ + AuthMethod: AuthMethodCookie, + CommandPrefix: "!kandev", + UtilityAgentID: "ua-1", + PollIntervalSeconds: 30, + } + if err := store.UpsertConfig(ctx, cfg); err != nil { + t.Fatalf("upsert: %v", err) + } + + got, err := store.GetConfig(ctx) + if err != nil { + t.Fatalf("get: %v", err) + } + if got == nil { + t.Fatal("expected config, got nil") + } + if got.UtilityAgentID != "ua-1" || got.CommandPrefix != "!kandev" || got.PollIntervalSeconds != 30 { + t.Errorf("round-trip mismatch: %+v", got) + } + if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() { + t.Error("timestamps not set") + } + + cfg.UtilityAgentID = "ua-2" + cfg.CommandPrefix = "!todo" + if err := store.UpsertConfig(ctx, cfg); err != nil { + t.Fatalf("update upsert: %v", err) + } + got2, _ := store.GetConfig(ctx) + if got2.UtilityAgentID != "ua-2" || got2.CommandPrefix != "!todo" { + t.Errorf("expected updates, got %+v", got2) + } + + if err := store.DeleteConfig(ctx); err != nil { + t.Fatalf("delete: %v", err) + } + gone, err := store.GetConfig(ctx) + if err != nil { + t.Fatalf("get after delete: %v", err) + } + if gone != nil { + t.Errorf("expected nil after delete, got %+v", gone) + } +} + +func TestStore_GetConfig_Missing(t *testing.T) { + store := newTestStore(t) + cfg, err := store.GetConfig(context.Background()) + if err != nil { + t.Fatalf("get: %v", err) + } + if cfg != nil { + t.Errorf("expected nil for missing config, got %+v", cfg) + } +} + +func TestStore_HasConfig(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + has, err := store.HasConfig(ctx) + if err != nil || has { + t.Fatalf("expected no config, got has=%v err=%v", has, err) + } + _ = store.UpsertConfig(ctx, &SlackConfig{ + AuthMethod: AuthMethodCookie, + UtilityAgentID: "ua", + PollIntervalSeconds: 30, + }) + has, err = store.HasConfig(ctx) + if err != nil || !has { + t.Fatalf("expected has=true after upsert, got has=%v err=%v", has, err) + } +} + +func TestStore_UpdateAuthHealth(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + _ = store.UpsertConfig(ctx, &SlackConfig{ + AuthMethod: AuthMethodCookie, + UtilityAgentID: "ua", + PollIntervalSeconds: 30, + }) + + now := time.Now().UTC() + if err := store.UpdateAuthHealth(ctx, true, "", "T0001", "U0001", now); err != nil { + t.Fatalf("update auth health: %v", err) + } + got, _ := store.GetConfig(ctx) + if !got.LastOk || got.SlackTeamID != "T0001" || got.SlackUserID != "U0001" { + t.Errorf("expected health to be recorded, got %+v", got) + } + + if err := store.UpdateAuthHealth(ctx, false, "expired", "", "", now.Add(time.Minute)); err != nil { + t.Fatalf("update auth health 2: %v", err) + } + got2, _ := store.GetConfig(ctx) + if got2.LastOk { + t.Error("expected lastOk false") + } + if got2.LastError != "expired" { + t.Errorf("expected error message, got %q", got2.LastError) + } + if got2.SlackTeamID != "T0001" || got2.SlackUserID != "U0001" { + t.Errorf("expected captured ids preserved across failure, got team=%q user=%q", + got2.SlackTeamID, got2.SlackUserID) + } +} + +func TestStore_UpdateLastSeenTS(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + _ = store.UpsertConfig(ctx, &SlackConfig{ + AuthMethod: AuthMethodCookie, + UtilityAgentID: "ua", + PollIntervalSeconds: 30, + }) + if err := store.UpdateLastSeenTS(ctx, "1714659000.000100"); err != nil { + t.Fatalf("update ts: %v", err) + } + got, _ := store.GetConfig(ctx) + if got.LastSeenTS != "1714659000.000100" { + t.Errorf("expected ts persisted, got %q", got.LastSeenTS) + } +} + +func TestStore_LegacyPerWorkspaceMigration(t *testing.T) { + // Simulate a deployment running the pre-singleton schema: a slack_configs + // table keyed by workspace_id, with the most-recently-updated row to be + // promoted into the singleton on upgrade. + raw, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + raw.SetMaxOpenConns(1) + raw.SetMaxIdleConns(1) + db := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + + if _, err := db.Exec(` + CREATE TABLE slack_configs ( + workspace_id TEXT PRIMARY KEY, + auth_method TEXT NOT NULL, + command_prefix TEXT NOT NULL DEFAULT '', + utility_agent_id TEXT NOT NULL DEFAULT '', + poll_interval_seconds INTEGER NOT NULL DEFAULT 30, + slack_team_id TEXT NOT NULL DEFAULT '', + slack_user_id TEXT NOT NULL DEFAULT '', + last_seen_ts TEXT NOT NULL DEFAULT '', + last_checked_at DATETIME, + last_ok INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`); err != nil { + t.Fatalf("create legacy table: %v", err) + } + now := time.Now().UTC() + older := now.Add(-time.Hour) + if _, err := db.Exec(` + INSERT INTO slack_configs (workspace_id, auth_method, command_prefix, utility_agent_id, + poll_interval_seconds, slack_team_id, slack_user_id, last_seen_ts, + last_checked_at, last_ok, last_error, created_at, updated_at) + VALUES + ('ws-old', 'cookie', '!kandev', 'ua-old', 30, 'T-old', 'U-old', '100.0', ?, 1, '', ?, ?), + ('ws-new', 'cookie', '!kandev', 'ua-new', 60, 'T-new', 'U-new', '200.0', ?, 1, '', ?, ?)`, + older, older, older, now, now, now); err != nil { + t.Fatalf("seed legacy rows: %v", err) + } + + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + if got := store.MigratedFromWorkspace(); got != "ws-new" { + t.Errorf("expected migration to promote ws-new (most recently updated), got %q", got) + } + cfg, err := store.GetConfig(context.Background()) + if err != nil || cfg == nil { + t.Fatalf("get post-migration: cfg=%v err=%v", cfg, err) + } + if cfg.UtilityAgentID != "ua-new" || cfg.CommandPrefix != "!kandev" || cfg.PollIntervalSeconds != 60 { + t.Errorf("expected ws-new fields preserved, got %+v", cfg) + } + if cfg.SlackTeamID != "T-new" || cfg.SlackUserID != "U-new" || cfg.LastSeenTS != "200.0" { + t.Errorf("expected ws-new runtime state preserved, got %+v", cfg) + } +} + +func TestStore_LegacyEmptyTableMigration(t *testing.T) { + // A deployment that had the legacy table created but never inserted a + // row should still upgrade cleanly without crashing on the empty SELECT. + raw, _ := sql.Open("sqlite3", ":memory:") + raw.SetMaxOpenConns(1) + db := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + if _, err := db.Exec(` + CREATE TABLE slack_configs ( + workspace_id TEXT PRIMARY KEY, + auth_method TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`); err != nil { + t.Fatalf("create empty legacy: %v", err) + } + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store on empty legacy: %v", err) + } + if store.MigratedFromWorkspace() != "" { + t.Error("expected no migration when legacy table is empty") + } + cfg, _ := store.GetConfig(context.Background()) + if cfg != nil { + t.Errorf("expected no config post empty-legacy upgrade, got %+v", cfg) + } +} diff --git a/apps/backend/internal/slack/trigger.go b/apps/backend/internal/slack/trigger.go new file mode 100644 index 0000000000..32ebc51783 --- /dev/null +++ b/apps/backend/internal/slack/trigger.go @@ -0,0 +1,331 @@ +package slack + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// DefaultBaseInterval is how often the trigger's base loop wakes up to check +// whether a scan is due. The actual scan cadence comes from +// SlackConfig.PollIntervalSeconds; this just bounds how soon a freshly-due +// scan gets noticed. +const DefaultBaseInterval = 5 * time.Second + +// AcknowledgeReaction is the emoji we react with on a matched message before +// the agent runs. +const AcknowledgeReaction = "eyes" + +// Trigger drives the polling loop that turns matched Slack messages into +// utility-agent runs. The loop is install-wide singleton — there's at most +// one Slack-search-per-PollIntervalSeconds running, regardless of how many +// Kandev workspaces exist. +type Trigger struct { + svc *Service + log *logger.Logger + interval time.Duration + + mu sync.Mutex + cancel context.CancelFunc + wg sync.WaitGroup + started bool + + // matchHook fires after each successfully-handled match. Tests use it + // to synchronise on processing without sleep-polling. + matchHook func(msg SlackMessage, reply string) + + // lastScannedAt tracks when we last ran a scan so the configured + // PollIntervalSeconds is honoured exactly. In-memory by design — backend + // restart triggers an immediate scan, which is fine. + scannedMu sync.Mutex + lastScannedAt time.Time +} + +// NewTrigger returns a Trigger using the default base ticker. +func NewTrigger(svc *Service, log *logger.Logger) *Trigger { + if svc == nil { + return nil + } + return &Trigger{svc: svc, log: log, interval: DefaultBaseInterval} +} + +// SetInterval overrides the base-ticker cadence. Must be called before Start. +func (t *Trigger) SetInterval(d time.Duration) { + t.mu.Lock() + t.interval = d + t.mu.Unlock() +} + +// SetMatchHook installs a callback fired after each successfully-handled +// match. +func (t *Trigger) SetMatchHook(fn func(msg SlackMessage, reply string)) { + t.mu.Lock() + t.matchHook = fn + t.mu.Unlock() +} + +// Start launches the loop. +func (t *Trigger) Start(ctx context.Context) { + if t == nil { + return + } + t.mu.Lock() + defer t.mu.Unlock() + if t.started { + return + } + t.started = true + ctx, t.cancel = context.WithCancel(ctx) + t.wg.Add(1) + go t.loop(ctx) + t.log.Info("slack trigger started") +} + +// Stop cancels the loop and waits for it to drain. +func (t *Trigger) Stop() { + if t == nil { + return + } + t.mu.Lock() + if !t.started { + t.mu.Unlock() + return + } + cancel := t.cancel + t.mu.Unlock() + if cancel != nil { + cancel() + } + t.wg.Wait() + t.mu.Lock() + t.started = false + t.mu.Unlock() + t.log.Info("slack trigger stopped") +} + +func (t *Trigger) loop(ctx context.Context) { + defer t.wg.Done() + t.tick(ctx) + ticker := time.NewTicker(t.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + t.tick(ctx) + } + } +} + +// tick scans Slack once if the configured cadence has elapsed. +func (t *Trigger) tick(ctx context.Context) { + if t.svc.Runner() == nil { + return + } + cfg, err := t.svc.Store().GetConfig(ctx) + if err != nil || cfg == nil { + return + } + if !cfg.LastOk || cfg.SlackUserID == "" { + return + } + if cfg.UtilityAgentID == "" { + return + } + if !t.dueForScan(cfg.PollIntervalSeconds) { + return + } + prefix := cfg.CommandPrefix + if prefix == "" { + prefix = DefaultCommandPrefix + } + client, err := t.svc.Client(ctx) + if err != nil { + return + } + t.markScanned() + query := fmt.Sprintf("from:<@%s> %q", cfg.SlackUserID, prefix) + matches, err := client.SearchMessages(ctx, query) + if err != nil { + t.log.Warn("slack trigger: search failed", zap.Error(err)) + return + } + t.processMatches(ctx, cfg, prefix, matches, client) +} + +func (t *Trigger) processMatches(ctx context.Context, cfg *SlackConfig, prefix string, matches []SlackMessage, client Client) { + fresh := newMatchesAfter(matches, cfg.LastSeenTS, prefix) + if len(fresh) == 0 { + return + } + highest := cfg.LastSeenTS + for _, m := range fresh { + if ctx.Err() != nil { + return + } + reply, err := t.handleOne(ctx, cfg, prefix, m, client) + if err != nil { + t.log.Warn("slack trigger: handle match failed", + zap.String("ts", m.TS), zap.Error(err)) + if errors.Is(err, ErrNoUtilityAgent) { + if compareTS(m.TS, highest) > 0 { + highest = m.TS + } + continue + } + // Recoverable failure: stop the loop here so the watermark stays + // at the last consecutive success. See the original commit for + // why this is a `break` (not `return`) — the post-loop + // UpdateLastSeenTS still needs to persist prior successes. + break + } + if compareTS(m.TS, highest) > 0 { + highest = m.TS + } + t.fireHook(m, reply) + } + if highest != cfg.LastSeenTS { + if err := t.svc.Store().UpdateLastSeenTS(ctx, highest); err != nil { + t.log.Warn("slack trigger: advance watermark failed", zap.Error(err)) + } + } +} + +// handleOne runs the per-match flow: ack reaction, fetch thread, run the +// utility agent, post the reply in-thread. +func (t *Trigger) handleOne(ctx context.Context, cfg *SlackConfig, prefix string, msg SlackMessage, client Client) (string, error) { + if err := client.ReactionsAdd(ctx, msg.ChannelID, msg.TS, AcknowledgeReaction); err != nil { + t.log.Warn("slack trigger: ack reaction failed", + zap.String("ts", msg.TS), zap.Error(err)) + } + + thread, err := client.ConversationsReplies(ctx, msg.ChannelID, msg.ThreadTS, msg.TS) + if err != nil { + return "", fmt.Errorf("fetch thread: %w", err) + } + permalink := msg.Permalink + if permalink == "" { + permalink, _ = client.ChatGetPermalink(ctx, msg.ChannelID, msg.TS) + } + instruction := stripPrefix(msg.Text, prefix) + + runner := t.svc.Runner() + if runner == nil { + return "", errors.New("agent runner not configured") + } + reply, err := runner.RunForMatch(ctx, cfg, msg, instruction, permalink, thread) + if err != nil { + return "", err + } + t.replyInThread(ctx, client, msg, reply) + return reply, nil +} + +func (t *Trigger) replyInThread(ctx context.Context, client Client, msg SlackMessage, body string) { + threadTS := msg.ThreadTS + if threadTS == "" { + threadTS = msg.TS + } + if err := client.ChatPostMessage(ctx, msg.ChannelID, threadTS, body); err != nil { + t.log.Warn("slack trigger: reply post failed", + zap.String("ts", msg.TS), zap.Error(err)) + } +} + +func (t *Trigger) fireHook(msg SlackMessage, reply string) { + t.mu.Lock() + hook := t.matchHook + t.mu.Unlock() + if hook != nil { + hook(msg, reply) + } +} + +// dueForScan reports whether the configured interval has elapsed since the +// last scan. A zero/never timestamp counts as due. +func (t *Trigger) dueForScan(intervalSeconds int) bool { + if intervalSeconds < MinPollIntervalSeconds { + intervalSeconds = DefaultPollIntervalSeconds + } + t.scannedMu.Lock() + defer t.scannedMu.Unlock() + if t.lastScannedAt.IsZero() { + return true + } + return time.Since(t.lastScannedAt) >= time.Duration(intervalSeconds)*time.Second +} + +func (t *Trigger) markScanned() { + t.scannedMu.Lock() + t.lastScannedAt = time.Now() + t.scannedMu.Unlock() +} + +func newMatchesAfter(matches []SlackMessage, watermark, prefix string) []SlackMessage { + out := make([]SlackMessage, 0, len(matches)) + for _, m := range matches { + if compareTS(m.TS, watermark) <= 0 { + continue + } + if !startsWithPrefix(m.Text, prefix) { + continue + } + out = append(out, m) + } + sort.SliceStable(out, func(i, j int) bool { + return compareTS(out[i].TS, out[j].TS) < 0 + }) + return out +} + +func startsWithPrefix(text, prefix string) bool { + t := strings.TrimSpace(text) + t = strings.TrimPrefix(t, "> ") + t = strings.TrimSpace(t) + if !strings.HasPrefix(strings.ToLower(t), strings.ToLower(prefix)) { + return false + } + rest := t[len(prefix):] + if rest == "" { + return true + } + r := rest[0] + return r == ' ' || r == '\t' || r == '\n' || r == ':' || r == ',' +} + +func stripPrefix(text, prefix string) string { + t := strings.TrimSpace(text) + t = strings.TrimPrefix(t, "> ") + t = strings.TrimSpace(t) + if len(t) >= len(prefix) && strings.EqualFold(t[:len(prefix)], prefix) { + t = t[len(prefix):] + } + t = strings.TrimSpace(t) + t = strings.TrimLeft(t, ":, ") + return strings.TrimSpace(t) +} + +func compareTS(a, b string) int { + if a == b { + return 0 + } + if a == "" { + return -1 + } + if b == "" { + return 1 + } + if a < b { + return -1 + } + return 1 +} diff --git a/apps/backend/internal/slack/trigger_test.go b/apps/backend/internal/slack/trigger_test.go new file mode 100644 index 0000000000..8db683b73b --- /dev/null +++ b/apps/backend/internal/slack/trigger_test.go @@ -0,0 +1,629 @@ +package slack + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" + "github.com/kandev/kandev/internal/common/logger" + utilitymodels "github.com/kandev/kandev/internal/utility/models" + utilityservice "github.com/kandev/kandev/internal/utility/service" + "github.com/kandev/kandev/internal/utility/template" +) + +// --- prefix / parser tests --- + +func TestStartsWithPrefix(t *testing.T) { + cases := []struct { + text string + prefix string + want bool + }{ + {"!kandev fix login bug", "!kandev", true}, + {" !kandev fix login bug", "!kandev", true}, + {"!kandev: do the thing", "!kandev", true}, + {"!kandev", "!kandev", true}, + {"!kandevops handle this", "!kandev", false}, + {"hey !kandev fix this", "!kandev", false}, + {"> !kandev quoted reply", "!kandev", true}, + {"!KANDEV case insensitive", "!kandev", true}, + {"", "!kandev", false}, + } + for _, c := range cases { + if got := startsWithPrefix(c.text, c.prefix); got != c.want { + t.Errorf("startsWithPrefix(%q, %q) = %v, want %v", c.text, c.prefix, got, c.want) + } + } +} + +func TestStripPrefix(t *testing.T) { + cases := []struct { + text, prefix, want string + }{ + {"!kandev fix login bug", "!kandev", "fix login bug"}, + {" !kandev fix login bug ", "!kandev", "fix login bug"}, + {"!kandev: handle this", "!kandev", "handle this"}, + {"!kandev,please", "!kandev", "please"}, + {"> !kandev quoted reply", "!kandev", "quoted reply"}, + {"!kandev", "!kandev", ""}, + } + for _, c := range cases { + if got := stripPrefix(c.text, c.prefix); got != c.want { + t.Errorf("stripPrefix(%q, %q) = %q, want %q", c.text, c.prefix, got, c.want) + } + } +} + +func TestNewMatchesAfter(t *testing.T) { + matches := []SlackMessage{ + {TS: "100.000005", Text: "!kandev five"}, + {TS: "100.000003", Text: "!kandev three"}, + {TS: "100.000004", Text: "!kandevops noop"}, + {TS: "100.000002", Text: "!kandev two"}, + } + got := newMatchesAfter(matches, "100.000003", "!kandev") + if len(got) != 1 || got[0].TS != "100.000005" { + t.Errorf("expected only the freshest matching message, got %+v", got) + } +} + +func TestNewMatchesAfter_OrdersOldestFirst(t *testing.T) { + matches := []SlackMessage{ + {TS: "100.000010", Text: "!kandev ten"}, + {TS: "100.000005", Text: "!kandev five"}, + {TS: "100.000007", Text: "!kandev seven"}, + } + got := newMatchesAfter(matches, "", "!kandev") + if len(got) != 3 { + t.Fatalf("expected 3, got %d", len(got)) + } + if got[0].TS != "100.000005" || got[1].TS != "100.000007" || got[2].TS != "100.000010" { + t.Errorf("expected oldest-first order, got %v", []string{got[0].TS, got[1].TS, got[2].TS}) + } +} + +func TestCompareTS(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"", "", 0}, + {"", "100.0", -1}, + {"100.0", "", 1}, + {"100.0", "100.0", 0}, + {"100.0", "200.0", -1}, + {"200.0", "100.0", 1}, + } + for _, c := range cases { + if got := compareTS(c.a, c.b); got != c.want { + t.Errorf("compareTS(%q, %q) = %d, want %d", c.a, c.b, got, c.want) + } + } +} + +// --- prompt composition --- + +func TestComposePrompt_DefaultSystemWhenEmptyTemplate(t *testing.T) { + r := &Runner{} + thread := []SlackMessage{{TS: "100", UserName: "alice", Text: "thread message"}} + prompt := r.composePrompt("", "", + SlackMessage{UserName: "alice"}, "do thing", "https://slack/p100", thread) + if !strings.Contains(prompt, "Kandev triage assistant") { + t.Error("expected default system prompt when template is empty") + } + if !strings.Contains(prompt, "https://slack/p100") { + t.Error("expected permalink in appended block") + } + if !strings.Contains(prompt, "do thing") { + t.Error("expected instruction in appended block") + } +} + +func TestComposePrompt_TemplateOwnsLayoutWhenSubstituted(t *testing.T) { + r := &Runner{} + rawTemplate := "Custom: {{SlackInstruction}} for {{KandevWorkspaceID}}" + resolved := "Custom: do thing for ws-1" + prompt := r.composePrompt(rawTemplate, resolved, SlackMessage{}, "do thing", "", nil) + if prompt != "Custom: do thing for ws-1" { + t.Errorf("expected the resolved template only, got: %q", prompt) + } +} + +func TestComposePrompt_FallsBackWhenTemplateHasNoVars(t *testing.T) { + // Template with no Slack-specific vars → resolved == raw (after trim) → + // fall back so the agent still gets the Slack context block appended. + r := &Runner{} + template := "Be concise." + prompt := r.composePrompt(template, template, + SlackMessage{UserName: "alice"}, "do x", "https://slack/p", nil) + if !strings.Contains(prompt, "Be concise.") { + t.Error("expected user template at top") + } + if !strings.Contains(prompt, "Slack thread:") && !strings.Contains(prompt, "Request from") { + t.Error("expected Slack context block to be appended for non-templated systems") + } +} + +// --- runner end-to-end --- + +type fakeUtility struct { + agents map[string]*utilitymodels.UtilityAgent +} + +func (f fakeUtility) GetAgentByID(ctx context.Context, id string) (*utilitymodels.UtilityAgent, error) { + a, ok := f.agents[id] + if !ok { + return nil, errors.New("not found") + } + return a, nil +} + +func (f fakeUtility) PreparePromptRequest( + ctx context.Context, + utilityID string, + tmplCtx *template.Context, + defaults *utilityservice.DefaultUtilitySettings, + sessionless bool, +) (*utilityservice.PromptRequest, error) { + a, ok := f.agents[utilityID] + if !ok { + return nil, errors.New("not found") + } + // Resolve via the real engine so Custom vars actually substitute. + resolved, err := template.NewEngine().ResolveWithOptions(a.Prompt, tmplCtx, template.ResolveOptions{MissingAsEmpty: sessionless}) + if err != nil { + return nil, err + } + agentCLI, model := a.AgentID, a.Model + if agentCLI == "" && defaults != nil { + agentCLI = defaults.AgentID + } + if model == "" && defaults != nil { + model = defaults.Model + } + return &utilityservice.PromptRequest{ + UtilityID: utilityID, + ResolvedPrompt: resolved, + AgentCLI: agentCLI, + Model: model, + }, nil +} + +type fakeUserDefaults struct { + agentID, model string + err error +} + +func (f fakeUserDefaults) GetDefaultUtilitySettings(ctx context.Context) (string, string, error) { + return f.agentID, f.model, f.err +} + +type fakeHostRunner struct { + mu sync.Mutex + calls []hostCall + response string + err error +} + +type hostCall struct { + agentType, model, mode, prompt string + mcpServers []agentctlutil.MCPServerDTO +} + +func (f *fakeHostRunner) ExecutePromptWithMCP( + ctx context.Context, + agentType, model, mode, prompt string, + mcpServers []agentctlutil.MCPServerDTO, +) (HostPromptResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, hostCall{agentType, model, mode, prompt, mcpServers}) + if f.err != nil { + return HostPromptResult{}, f.err + } + return HostPromptResult{Response: f.response, Model: model, DurationMs: 1}, nil +} + +func TestRunner_RunForMatch_HappyPath(t *testing.T) { + utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ + "ua-1": { + ID: "ua-1", Name: "Slack triage", AgentID: "claude-acp", + Model: "claude-haiku-4-5", Enabled: true, + Prompt: "You are a Slack triage helper.", + }, + }} + host := &fakeHostRunner{response: "Created task 'Fix checkout bug' in workspace ws-1."} + mcp := []MCPDescriptor{{Name: "kandev", URL: "http://localhost:38429/mcp"}} + r := NewRunner(utility, fakeUserDefaults{}, host, mcp, logger.Default()) + + cfg := &SlackConfig{UtilityAgentID: "ua-1"} + msg := SlackMessage{TS: "100.0001", ChannelID: "C1", UserID: "U1", UserName: "alice", Text: "!kandev fix checkout"} + thread := []SlackMessage{msg} + + reply, err := r.RunForMatch(context.Background(), cfg, msg, "fix checkout", "https://example/p100", thread) + if err != nil { + t.Fatalf("RunForMatch: %v", err) + } + if !strings.Contains(reply, "Created task") { + t.Errorf("unexpected reply: %q", reply) + } + if len(host.calls) != 1 { + t.Fatalf("expected one host call, got %d", len(host.calls)) + } + call := host.calls[0] + if call.agentType != "claude-acp" || call.model != "claude-haiku-4-5" { + t.Errorf("expected agent_id+model passthrough, got %+v", call) + } + if !strings.Contains(call.prompt, "You are a Slack triage helper.") { + t.Error("expected utility agent's custom system prompt to be used") + } + if !strings.Contains(call.prompt, "fix checkout") { + t.Error("expected user instruction in prompt") + } + if len(call.mcpServers) != 1 || call.mcpServers[0].Name != "kandev" || call.mcpServers[0].URL != "http://localhost:38429/mcp" { + t.Errorf("expected kandev MCP wired, got %+v", call.mcpServers) + } + if call.mcpServers[0].Type != "http" { + t.Errorf("expected http transport, got %q", call.mcpServers[0].Type) + } +} + +func TestRunner_RunForMatch_NoUtilityAgent(t *testing.T) { + r := NewRunner(fakeUtility{}, fakeUserDefaults{}, &fakeHostRunner{}, nil, logger.Default()) + cfg := &SlackConfig{} + _, err := r.RunForMatch(context.Background(), cfg, SlackMessage{}, "x", "", nil) + if !errors.Is(err, ErrNoUtilityAgent) { + t.Errorf("expected ErrNoUtilityAgent, got %v", err) + } +} + +func TestRunner_RunForMatch_DisabledBuiltinFallsBackToDefaults(t *testing.T) { + // Built-in utility agents ship with enabled=false and empty agent_id / + // model — they're meant to use the user's default agent + model. The + // runner shouldn't reject this configuration; PreparePromptRequest + // substitutes the defaults at resolution time, matching how the rest of + // Kandev runs utility agents. + utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ + "builtin-x": {ID: "builtin-x", Name: "x", Builtin: true, Enabled: false, Prompt: ""}, + }} + host := &fakeHostRunner{response: "ok"} + r := NewRunner(utility, fakeUserDefaults{agentID: "claude-acp", model: "claude-haiku-4-5"}, + host, nil, logger.Default()) + cfg := &SlackConfig{UtilityAgentID: "builtin-x"} + if _, err := r.RunForMatch(context.Background(), cfg, SlackMessage{TS: "100"}, "x", "", nil); err != nil { + t.Fatalf("expected disabled built-in to run with user defaults, got: %v", err) + } + if len(host.calls) != 1 || host.calls[0].agentType != "claude-acp" || host.calls[0].model != "claude-haiku-4-5" { + t.Errorf("expected default agent_id+model used, got %+v", host.calls) + } +} + +func TestRunner_RunForMatch_NoAgentResolvedErrors(t *testing.T) { + // When the chosen agent has no agent_id and there are no user defaults + // either, we can't pick an inference subprocess to spawn — surface a + // clear error rather than silently failing. + utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ + "ua-1": {ID: "ua-1", Name: "no-config", Builtin: false, Enabled: false}, + }} + r := NewRunner(utility, fakeUserDefaults{}, &fakeHostRunner{}, nil, logger.Default()) + cfg := &SlackConfig{UtilityAgentID: "ua-1"} + _, err := r.RunForMatch(context.Background(), cfg, SlackMessage{}, "x", "", nil) + if err == nil || !strings.Contains(err.Error(), "no agent_id resolved") { + t.Errorf("expected 'no agent_id resolved' error, got %v", err) + } +} + +func TestRunner_RunForMatch_EmptyResponseFallsBack(t *testing.T) { + utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ + "ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "x", Enabled: true}, + }} + host := &fakeHostRunner{response: ""} + r := NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default()) + cfg := &SlackConfig{UtilityAgentID: "ua-1"} + reply, err := r.RunForMatch(context.Background(), cfg, SlackMessage{TS: "100"}, "x", "", nil) + if err != nil { + t.Fatalf("expected success with fallback reply, got error: %v", err) + } + if reply == "" { + t.Error("expected fallback reply text, got empty string") + } +} + +func TestRunner_RunForMatch_HostErrorBubbles(t *testing.T) { + utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ + "ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "x", Enabled: true}, + }} + host := &fakeHostRunner{err: errors.New("subprocess crashed")} + r := NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default()) + cfg := &SlackConfig{UtilityAgentID: "ua-1"} + _, err := r.RunForMatch(context.Background(), cfg, SlackMessage{TS: "100"}, "x", "", nil) + if err == nil || !strings.Contains(err.Error(), "subprocess crashed") { + t.Errorf("expected host error to bubble, got %v", err) + } +} + +// --- trigger end-to-end with fakes --- + +type fakeClient struct { + mu sync.Mutex + thread []SlackMessage + permalink string + posted []postRecord + reactions []reactionRecord +} + +type postRecord struct{ channel, threadTS, text string } +type reactionRecord struct{ channel, ts, name string } + +func (f *fakeClient) AuthTest(ctx context.Context) (*TestConnectionResult, error) { + return &TestConnectionResult{OK: true, UserID: "U1", TeamID: "T1"}, nil +} +func (f *fakeClient) SearchMessages(ctx context.Context, query string) ([]SlackMessage, error) { + return nil, nil +} +func (f *fakeClient) ConversationsReplies(ctx context.Context, channelID, threadTS, triggerTS string) ([]SlackMessage, error) { + return f.thread, nil +} +func (f *fakeClient) ChatGetPermalink(ctx context.Context, channelID, ts string) (string, error) { + return f.permalink, nil +} +func (f *fakeClient) ChatPostMessage(ctx context.Context, channelID, threadTS, text string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.posted = append(f.posted, postRecord{channelID, threadTS, text}) + return nil +} +func (f *fakeClient) ReactionsAdd(ctx context.Context, channelID, ts, name string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.reactions = append(f.reactions, reactionRecord{channelID, ts, name}) + return nil +} + +func TestTrigger_HandleOne_PostsAgentReplyInThread(t *testing.T) { + utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ + "ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "claude-haiku-4-5", Enabled: true}, + }} + host := &fakeHostRunner{response: "Created task X."} + + store := newTestStore(t) + factory := func(_ *SlackConfig, _ string, _ string) Client { + return &fakeClient{thread: []SlackMessage{{TS: "100.0001", Text: "!kandev x"}}} + } + svc := NewService(store, nil, NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default()), factory, logger.Default()) + ctx := context.Background() + _ = svc.Store().UpsertConfig(ctx, &SlackConfig{ + AuthMethod: AuthMethodCookie, + UtilityAgentID: "ua-1", CommandPrefix: "!kandev", + }) + + client := &fakeClient{thread: []SlackMessage{{TS: "100.0001", Text: "!kandev fix it"}}, permalink: "https://example/p100"} + svc.client = client + + cfg, _ := svc.Store().GetConfig(ctx) + trig := NewTrigger(svc, logger.Default()) + reply, err := trig.handleOne(ctx, cfg, "!kandev", SlackMessage{ + TS: "100.0001", ChannelID: "C1", Text: "!kandev fix it", + }, client) + if err != nil { + t.Fatalf("handleOne: %v", err) + } + if reply != "Created task X." { + t.Errorf("expected agent reply passthrough, got %q", reply) + } + if len(client.posted) != 1 { + t.Fatalf("expected one Slack reply, got %d", len(client.posted)) + } + if client.posted[0].text != "Created task X." || client.posted[0].channel != "C1" { + t.Errorf("unexpected slack post: %+v", client.posted[0]) + } + if client.posted[0].threadTS != "100.0001" { + t.Errorf("expected thread anchor on the trigger ts, got %q", client.posted[0].threadTS) + } + if len(client.reactions) != 1 { + t.Fatalf("expected one ack reaction, got %d", len(client.reactions)) + } + r := client.reactions[0] + if r.channel != "C1" || r.ts != "100.0001" || r.name != AcknowledgeReaction { + t.Errorf("unexpected reaction: %+v", r) + } +} + +// --- helper assertions --- + +func TestRunner_PromptIncludesAllSlackContext(t *testing.T) { + // Sanity: run a single match and inspect the prompt the host saw to + // confirm permalink + workspace + thread all made it through. Belt and + // braces against future refactors of buildAgentPrompt. + utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ + "ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "x", Enabled: true}, + }} + host := &fakeHostRunner{response: "ok"} + r := NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default()) + cfg := &SlackConfig{UtilityAgentID: "ua-1"} + msg := SlackMessage{TS: "200", UserName: "bob", Text: "!kandev investigate"} + thread := []SlackMessage{msg, {TS: "199", UserName: "alice", Text: "saw a 500 on /api/foo"}} + if _, err := r.RunForMatch(context.Background(), cfg, msg, "investigate", "https://slack/p200", thread); err != nil { + t.Fatalf("RunForMatch: %v", err) + } + prompt := host.calls[0].prompt + for _, want := range []string{"https://slack/p200", "investigate", "saw a 500", "@bob"} { + if !strings.Contains(prompt, want) { + t.Errorf("expected %q in prompt; got:\n%s", want, prompt) + } + } +} + +// --- watermark advance under partial failure --- + +// failingRunner fails handleOne the Nth time it's called (1-indexed). +type failingRunner struct { + mu sync.Mutex + calls int + failOn int + fakeErr error +} + +func (f *failingRunner) RunForMatch(ctx context.Context, cfg *SlackConfig, msg SlackMessage, instruction, permalink string, thread []SlackMessage) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + if f.calls == f.failOn { + return "", f.fakeErr + } + return "ok " + msg.TS, nil +} + +// TestTrigger_ProcessMatches_BreaksOnTransientError exercises the regression +// the previous "continue on error" loop introduced: in a batch [A, B, C] +// where B fails with a transient error and C would succeed, the watermark +// must advance only past A — not past C — so the next tick re-processes B +// and C. This is the core promise of the !kandev capture flow ("no message +// silently dropped") and easy to break with a refactor that reverts to +// `continue`. +func TestTrigger_ProcessMatches_BreaksOnTransientError(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + _ = store.UpsertConfig(ctx, &SlackConfig{ + + AuthMethod: AuthMethodCookie, + UtilityAgentID: "ua-1", + CommandPrefix: "!kandev", + }) + cfg, _ := store.GetConfig(ctx) + + runner := &failingRunner{failOn: 2, fakeErr: errors.New("transient agent failure")} + factory := func(_ *SlackConfig, _ string, _ string) Client { + return &fakeClient{} + } + svc := NewService(store, nil, runner, factory, logger.Default()) + client := &fakeClient{thread: []SlackMessage{{TS: "100.000001", Text: "!kandev a"}}} + svc.client = client + + trig := NewTrigger(svc, logger.Default()) + matches := []SlackMessage{ + {TS: "100.000001", ChannelID: "C1", Text: "!kandev a"}, + {TS: "100.000002", ChannelID: "C1", Text: "!kandev b"}, + {TS: "100.000003", ChannelID: "C1", Text: "!kandev c"}, + } + trig.processMatches(ctx, cfg, "!kandev", matches, client) + + got, _ := svc.Store().GetConfig(ctx) + if got.LastSeenTS != "100.000001" { + t.Errorf("expected watermark to advance only past A (the consecutive prefix of successes), got %q", got.LastSeenTS) + } + if runner.calls != 2 { + t.Errorf("expected loop to break after first failure (2 calls: A success, B fail); got %d calls", runner.calls) + } +} + +// TestTrigger_ProcessMatches_NoUtilityAgentSkipsWithoutBreak verifies the +// other branch of the failure logic: ErrNoUtilityAgent is unrecoverable, so +// processing continues to subsequent matches and the watermark spans all +// observed messages. Previously this was the only configured behaviour and +// is the only one a production user can hit until they pick an agent. +func TestTrigger_ProcessMatches_NoUtilityAgentSkipsWithoutBreak(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + _ = store.UpsertConfig(ctx, &SlackConfig{ + + AuthMethod: AuthMethodCookie, + CommandPrefix: "!kandev", + // Note: no UtilityAgentID — runner will return ErrNoUtilityAgent. + }) + cfg, _ := store.GetConfig(ctx) + + // Track invocations directly so we can assert the loop *didn't* break + // on ErrNoUtilityAgent (only transient errors should break the loop). + var calls int + var callsMu sync.Mutex + noAgentRunner := agentRunnerFunc(func(ctx context.Context, _ *SlackConfig, msg SlackMessage, _, _ string, _ []SlackMessage) (string, error) { + callsMu.Lock() + calls++ + callsMu.Unlock() + return "", ErrNoUtilityAgent + }) + + factory := func(_ *SlackConfig, _ string, _ string) Client { return &fakeClient{} } + svc := NewService(store, nil, noAgentRunner, factory, logger.Default()) + svc.client = &fakeClient{} + + trig := NewTrigger(svc, logger.Default()) + matches := []SlackMessage{ + {TS: "100.000001", ChannelID: "C1", Text: "!kandev a"}, + {TS: "100.000002", ChannelID: "C1", Text: "!kandev b"}, + } + trig.processMatches(ctx, cfg, "!kandev", matches, &fakeClient{}) + + got, _ := svc.Store().GetConfig(ctx) + if got.LastSeenTS != "100.000002" { + t.Errorf("expected watermark to advance past *all* matches under ErrNoUtilityAgent, got %q", got.LastSeenTS) + } + callsMu.Lock() + finalCalls := calls + callsMu.Unlock() + if finalCalls != 2 { + t.Errorf("expected runner to be invoked for every match (no break), got %d calls", finalCalls) + } +} + +// agentRunnerFunc adapts a function to the AgentRunner interface for tests. +type agentRunnerFunc func(ctx context.Context, cfg *SlackConfig, msg SlackMessage, instruction, permalink string, thread []SlackMessage) (string, error) + +func (f agentRunnerFunc) RunForMatch(ctx context.Context, cfg *SlackConfig, msg SlackMessage, instruction, permalink string, thread []SlackMessage) (string, error) { + return f(ctx, cfg, msg, instruction, permalink, thread) +} + +// TestComposePrompt_GenericTemplateGetsResolvedSubstitution verifies the +// follow-up fix to the composePrompt heuristic: when a template uses only +// non-Slack placeholders (e.g. {{UserPrompt}}), the fallback path uses the +// *resolved* template text rather than the raw template, so the agent sees +// the substituted value instead of the literal {{UserPrompt}} placeholder. +func TestComposePrompt_GenericTemplateGetsResolvedSubstitution(t *testing.T) { + r := &Runner{} + rawTemplate := "Be brief. Task: {{UserPrompt}}" + resolved := "Be brief. Task: fix checkout" + prompt := r.composePrompt(rawTemplate, resolved, + SlackMessage{UserName: "alice"}, "fix checkout", "https://slack/p", nil) + if !strings.Contains(prompt, "Task: fix checkout") { + t.Errorf("expected resolved {{UserPrompt}} in fallback path; got:\n%s", prompt) + } + if strings.Contains(prompt, "{{UserPrompt}}") { + t.Errorf("expected no literal placeholder to leak through; got:\n%s", prompt) + } +} + +// --- install-wide cadence --- + +func TestTrigger_DueForScan(t *testing.T) { + trig := &Trigger{} + if !trig.dueForScan(30) { + t.Error("expected fresh trigger to be due immediately") + } + trig.markScanned() + if trig.dueForScan(30) { + t.Error("expected trigger to not be due immediately after scan") + } + + // Backdate the marker to simulate enough elapsed time. Using a direct + // field write here rather than time.Sleep keeps the test deterministic. + trig.scannedMu.Lock() + trig.lastScannedAt = time.Now().Add(-31 * time.Second) + trig.scannedMu.Unlock() + if !trig.dueForScan(30) { + t.Error("expected trigger to be due again after 31s with 30s interval") + } + + // Bogus interval (below floor) falls back to the default — guards against + // a config row with 0 (or a corrupted row) holding the loop hostage. + trig.scannedMu.Lock() + trig.lastScannedAt = time.Now() + trig.scannedMu.Unlock() + if trig.dueForScan(0) { + t.Error("expected zero interval to behave as default 30s, not as 'always due'") + } +} diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index e201c45af5..d29ca9d02d 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -139,14 +139,15 @@ type TaskDTO struct { } type TaskRepositoryDTO struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - RepositoryID string `json:"repository_id"` - BaseBranch string `json:"base_branch"` - Position int `json:"position"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + TaskID string `json:"task_id"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + CheckoutBranch string `json:"checkout_branch,omitempty"` + Position int `json:"position"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type TaskSessionDTO struct { @@ -519,14 +520,15 @@ func FromTaskWithSessionInfo( var repositories []TaskRepositoryDTO for _, repo := range task.Repositories { repositories = append(repositories, TaskRepositoryDTO{ - ID: repo.ID, - TaskID: repo.TaskID, - RepositoryID: repo.RepositoryID, - BaseBranch: repo.BaseBranch, - Position: repo.Position, - Metadata: repo.Metadata, - CreatedAt: repo.CreatedAt, - UpdatedAt: repo.UpdatedAt, + ID: repo.ID, + TaskID: repo.TaskID, + RepositoryID: repo.RepositoryID, + BaseBranch: repo.BaseBranch, + CheckoutBranch: repo.CheckoutBranch, + Position: repo.Position, + Metadata: repo.Metadata, + CreatedAt: repo.CreatedAt, + UpdatedAt: repo.UpdatedAt, }) } diff --git a/apps/backend/internal/task/handlers/errors.go b/apps/backend/internal/task/handlers/errors.go index 701368427c..ff3f2d5a31 100644 --- a/apps/backend/internal/task/handlers/errors.go +++ b/apps/backend/internal/task/handlers/errors.go @@ -22,6 +22,20 @@ func handleNotFound(c *gin.Context, log *logger.Logger, err error, fallback stri c.JSON(http.StatusInternalServerError, gin.H{"error": "request failed"}) } +func handleSelectedMoveError(c *gin.Context, log *logger.Logger, err error) { + switch { + case isNotFound(err): + c.JSON(http.StatusNotFound, gin.H{"error": "task or workflow not found"}) + case isMoveConflict(err): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case isValidationError(err): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + log.Error("task move failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "task move failed"}) + } +} + func isNotFound(err error) bool { if err == nil { return false @@ -29,6 +43,17 @@ func isNotFound(err error) bool { return strings.Contains(strings.ToLower(err.Error()), "not found") } +func isMoveConflict(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "active session") || + strings.Contains(msg, "archived tasks cannot be moved") || + strings.Contains(msg, "different workspace") || + strings.Contains(msg, "does not belong to target workflow") +} + func isValidationError(err error) bool { if err == nil { return false diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 3ff59ddf33..35292e029c 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -139,6 +139,12 @@ func (m *mockRepository) GetMessageByPendingID(ctx context.Context, sessionID, p func (m *mockRepository) FindMessageByPendingID(ctx context.Context, pendingID string) (*models.Message, error) { return nil, nil } +func (m *mockRepository) FindMessagesByPendingID(ctx context.Context, pendingID string) ([]*models.Message, error) { + return nil, nil +} +func (m *mockRepository) FindMessageByPendingIDAndQuestion(ctx context.Context, sessionID, pendingID, questionID string) (*models.Message, error) { + return nil, nil +} func (m *mockRepository) UpdateMessage(ctx context.Context, message *models.Message) error { return nil } @@ -169,6 +175,9 @@ func (m *mockRepository) UpdateTurn(ctx context.Context, turn *models.Turn) erro func (m *mockRepository) CompleteTurn(ctx context.Context, id string) error { return nil } +func (m *mockRepository) AbandonTurn(ctx context.Context, id string) error { + return nil +} func (m *mockRepository) CompletePendingToolCallsForTurn(ctx context.Context, turnID string) (int64, error) { return 0, nil } diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 94d01ca72a..3e6be083a5 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -299,10 +299,11 @@ func (h *TaskHandlers) httpGetStepTaskCount(c *gin.Context) { } type httpBulkMoveTasksRequest struct { - SourceWorkflowID string `json:"source_workflow_id"` - SourceStepID string `json:"source_step_id,omitempty"` - TargetWorkflowID string `json:"target_workflow_id"` - TargetStepID string `json:"target_step_id"` + SourceWorkflowID string `json:"source_workflow_id"` + SourceStepID string `json:"source_step_id,omitempty"` + TargetWorkflowID string `json:"target_workflow_id"` + TargetStepID string `json:"target_step_id"` + TaskIDs []string `json:"task_ids,omitempty"` } func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) { @@ -311,7 +312,15 @@ func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - if body.SourceWorkflowID == "" || body.TargetWorkflowID == "" || body.TargetStepID == "" { + if body.TargetWorkflowID == "" || body.TargetStepID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "target_workflow_id and target_step_id are required"}) + return + } + if len(body.TaskIDs) > 0 { + h.httpBulkMoveSelectedTasks(c, body) + return + } + if body.SourceWorkflowID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "source_workflow_id, target_workflow_id, and target_step_id are required"}) return } @@ -328,6 +337,20 @@ func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) { c.JSON(http.StatusOK, dto.BulkMoveTasksResponse{MovedCount: result.MovedCount}) } +func (h *TaskHandlers) httpBulkMoveSelectedTasks(c *gin.Context, body httpBulkMoveTasksRequest) { + result, err := h.service.BulkMoveSelectedTasks( + c.Request.Context(), + body.TaskIDs, + body.TargetWorkflowID, + body.TargetStepID, + ) + if err != nil { + handleSelectedMoveError(c, h.logger, err) + return + } + c.JSON(http.StatusOK, dto.BulkMoveTasksResponse{MovedCount: result.MovedCount}) +} + type httpTaskRepositoryInput struct { RepositoryID string `json:"repository_id"` BaseBranch string `json:"base_branch"` @@ -850,7 +873,7 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) { body.WorkflowID, body.WorkflowStepID, body.Position, ) if err != nil { - handleNotFound(c, h.logger, err, "task not moved") + handleSelectedMoveError(c, h.logger, err) return } diff --git a/apps/backend/internal/task/handlers/task_http_handlers_test.go b/apps/backend/internal/task/handlers/task_http_handlers_test.go index c5d80fcdf6..70f3becdfb 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers_test.go +++ b/apps/backend/internal/task/handlers/task_http_handlers_test.go @@ -2,14 +2,21 @@ package handlers import ( "context" + "errors" + "net/http" + "net/http/httptest" "strings" "sync" "testing" + "time" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" ) func newTestLogger(t *testing.T) *logger.Logger { @@ -25,6 +32,132 @@ func newTestLogger(t *testing.T) *logger.Logger { return log } +func TestHandleSelectedMoveError(t *testing.T) { + gin.SetMode(gin.TestMode) + log := newTestLogger(t) + + tests := []struct { + name string + err error + want int + wantBodyContains string + }{ + { + name: "not found", + err: errors.New("task not found: task-1"), + want: http.StatusNotFound, + }, + { + name: "move conflict", + err: errors.New("task task-1 cannot be moved: task has an active session (running)"), + want: http.StatusConflict, + }, + { + name: "bad request validation", + err: errors.New("invalid workflow id"), + want: http.StatusBadRequest, + }, + { + name: "internal", + err: errors.New("failed to count target workflow step tasks: database is locked"), + want: http.StatusInternalServerError, + wantBodyContains: "task move failed", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + + handleSelectedMoveError(c, log, tc.err) + + assert.Equal(t, tc.want, rec.Code) + if tc.wantBodyContains != "" { + assert.Contains(t, rec.Body.String(), tc.wantBodyContains) + } + }) + } +} + +type moveTaskConflictRepo struct { + mockRepository + task *models.Task + sessions []*models.TaskSession +} + +func (m *moveTaskConflictRepo) GetTask(ctx context.Context, id string) (*models.Task, error) { + return m.task, nil +} + +func (m *moveTaskConflictRepo) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { + return m.sessions, nil +} + +func TestHTTPMoveTaskMapsMoveConflict(t *testing.T) { + gin.SetMode(gin.TestMode) + log := newTestLogger(t) + archivedAt := time.Now().UTC() + + tests := []struct { + name string + task *models.Task + sessions []*models.TaskSession + }{ + { + name: "archived task", + task: &models.Task{ + ID: "task-archived", + WorkspaceID: "workspace-1", + WorkflowID: "wf-source", + WorkflowStepID: "step-source", + ArchivedAt: &archivedAt, + }, + }, + { + name: "active session", + task: &models.Task{ + ID: "task-running", + WorkspaceID: "workspace-1", + WorkflowID: "wf-source", + WorkflowStepID: "step-source", + }, + sessions: []*models.TaskSession{{ + ID: "session-running", + TaskID: "task-running", + State: models.TaskSessionStateRunning, + }}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + repo := &moveTaskConflictRepo{task: tc.task, sessions: tc.sessions} + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + h := &TaskHandlers{service: svc, logger: log} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Params = gin.Params{{Key: "id", Value: tc.task.ID}} + c.Request = httptest.NewRequest(http.MethodPost, "/tasks/"+tc.task.ID+"/move", strings.NewReader(`{ + "workflow_id": "wf-target", + "workflow_step_id": "step-target", + "position": 0 + }`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.httpMoveTask(c) + + assert.Equal(t, http.StatusConflict, rec.Code) + }) + } +} + func TestResolveFreshBranchName(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index b87b02ee42..97ee65ca53 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -119,12 +119,13 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository_id, local_path, or github_url is required", nil) } repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - LocalPath: r.LocalPath, - Name: r.Name, - DefaultBranch: r.DefaultBranch, - GitHubURL: r.GitHubURL, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, + GitHubURL: r.GitHubURL, }) } @@ -248,12 +249,13 @@ func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M if req.Repositories != nil { for _, r := range req.Repositories { repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - LocalPath: r.LocalPath, - Name: r.Name, - DefaultBranch: r.DefaultBranch, - GitHubURL: r.GitHubURL, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, + GitHubURL: r.GitHubURL, }) } } diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 1585ca3e2e..31e5f63e8d 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -66,6 +66,8 @@ type MessageRepository interface { GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) GetMessageByPendingID(ctx context.Context, sessionID, pendingID string) (*models.Message, error) FindMessageByPendingID(ctx context.Context, pendingID string) (*models.Message, error) + FindMessagesByPendingID(ctx context.Context, pendingID string) ([]*models.Message, error) + FindMessageByPendingIDAndQuestion(ctx context.Context, sessionID, pendingID, questionID string) (*models.Message, error) UpdateMessage(ctx context.Context, message *models.Message) error ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) ListMessagesPaginated(ctx context.Context, sessionID string, opts models.ListMessagesOptions) ([]*models.Message, bool, error) @@ -80,6 +82,7 @@ type TurnRepository interface { GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error) UpdateTurn(ctx context.Context, turn *models.Turn) error CompleteTurn(ctx context.Context, id string) error + AbandonTurn(ctx context.Context, id string) error CompletePendingToolCallsForTurn(ctx context.Context, turnID string) (int64, error) ListTurnsBySession(ctx context.Context, sessionID string) ([]*models.Turn, error) } diff --git a/apps/backend/internal/task/repository/sqlite/message.go b/apps/backend/internal/task/repository/sqlite/message.go index 77db8a51db..e832a33a2b 100644 --- a/apps/backend/internal/task/repository/sqlite/message.go +++ b/apps/backend/internal/task/repository/sqlite/message.go @@ -312,8 +312,10 @@ func (r *Repository) GetMessageByPendingID(ctx context.Context, sessionID, pendi return r.getMessageByMetadataField(ctx, sessionID, "pending_id", pendingID, "") } -// FindMessageByPendingID finds a message by pending_id alone (without requiring session ID). +// FindMessageByPendingID finds the most-recent message by pending_id alone. // This is useful when we only have the pending ID (e.g., from expired clarification responses). +// For multi-question clarification requests, prefer FindMessagesByPendingID to retrieve +// every related message in one shot. func (r *Repository) FindMessageByPendingID(ctx context.Context, pendingID string) (*models.Message, error) { message := &models.Message{} var requestsInput int @@ -338,6 +340,62 @@ func (r *Repository) FindMessageByPendingID(ctx context.Context, pendingID strin return message, nil } +// FindMessagesByPendingID returns every message that carries the given pending_id +// in its metadata, ordered by creation time (oldest first). Multi-question +// clarification requests emit one message per question, all sharing the same +// pending_id; this lookup lets the canceller / status-update path touch all of +// them without N round-trips. +func (r *Repository) FindMessagesByPendingID(ctx context.Context, pendingID string) ([]*models.Message, error) { + drv := r.ro.DriverName() + query := fmt.Sprintf(` + SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at + FROM task_session_messages WHERE %s = ? + ORDER BY created_at ASC + `, dialect.JSONExtract(drv, "metadata", "pending_id")) + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), pendingID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + result, _, err := scanMessageRows(rows, 0) + return result, err +} + +// FindMessageByPendingIDAndQuestion finds the message for a specific (pending_id, +// question_id) pair within a session. Used to flip per-question status (answered / +// rejected) on multi-question clarification bundles. The persistence layer stores +// the question id at metadata.question_id (flat, alongside metadata.question) so +// the JSON path lookup works on both SQLite and Postgres. +func (r *Repository) FindMessageByPendingIDAndQuestion(ctx context.Context, sessionID, pendingID, questionID string) (*models.Message, error) { + drv := r.ro.DriverName() + query := fmt.Sprintf(` + SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at + FROM task_session_messages + WHERE task_session_id = ? AND %s = ? AND %s = ? + ORDER BY created_at ASC LIMIT 1 + `, + dialect.JSONExtract(drv, "metadata", "pending_id"), + dialect.JSONExtract(drv, "metadata", "question_id"), + ) + message := &models.Message{} + var requestsInput int + var messageType string + var metadataJSON string + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(query), sessionID, pendingID, questionID).Scan( + &message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, + &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt, + ) + if err != nil { + return nil, err + } + message.RequestsInput = requestsInput == 1 + message.Type = models.MessageType(messageType) + if metadataJSON != "" { + _ = json.Unmarshal([]byte(metadataJSON), &message.Metadata) + } + return message, nil +} + // CompletePendingToolCallsForTurn marks all non-terminal tool call messages for a turn as "complete". // This is a safety net to ensure no tool calls remain stuck in a non-terminal state (pending, // running, in_progress, etc.) after a turn completes. diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 9e5f43c48a..cc5d3f5db8 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -122,6 +122,21 @@ func (r *Repository) CompleteTurn(ctx context.Context, id string) error { return err } +// AbandonTurn marks a turn as completed with completed_at = started_at, giving it +// zero duration. Used when a turn was orphaned by an interruption (backend +// restart, agent crash) and the previous "running" window was not real work — +// recording `now` would inflate analytics and the UI's last-turn duration with +// hours of dead time. +func (r *Repository) AbandonTurn(ctx context.Context, id string) error { + now := time.Now().UTC() + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE task_session_turns + SET completed_at = started_at, updated_at = ? + WHERE id = ? AND completed_at IS NULL + `), now, id) + return err +} + // ListTurnsBySession returns all turns for a session ordered by start time func (r *Repository) ListTurnsBySession(ctx context.Context, sessionID string) ([]*models.Turn, error) { ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListTurnsBySession") diff --git a/apps/backend/internal/task/service/repository_discovery.go b/apps/backend/internal/task/service/repository_discovery.go index cfbafab43c..4a5e8b526e 100644 --- a/apps/backend/internal/task/service/repository_discovery.go +++ b/apps/backend/internal/task/service/repository_discovery.go @@ -10,6 +10,8 @@ import ( "path/filepath" "sort" "strings" + + "github.com/kandev/kandev/internal/common/gitref" ) type RepositoryDiscoveryConfig struct { @@ -555,28 +557,11 @@ func readGitDirtyFiles(ctx context.Context, repoPath string) ([]string, error) { return paths, nil } +// readGitDefaultBranch is a thin wrapper around gitref.DefaultBranch so this +// file's existing callers (ValidateLocalRepositoryPath, repoWalker.makeRepo, +// the test suite) keep their existing API. func readGitDefaultBranch(repoPath string) (string, error) { - gitDir, err := resolveGitDir(repoPath) - if err != nil { - return "", err - } - headPath := filepath.Join(gitDir, gitHEAD) - content, err := os.ReadFile(headPath) - if err != nil { - return "", err - } - trimmed := strings.TrimSpace(string(content)) - if strings.HasPrefix(trimmed, "ref: ") { - ref := strings.TrimPrefix(trimmed, "ref: ") - parts := strings.Split(ref, "/") - if len(parts) > 0 { - return parts[len(parts)-1], nil - } - } - if trimmed != "" { - return gitHEAD, nil - } - return "", fmt.Errorf("unable to determine branch") + return gitref.DefaultBranch(repoPath) } func resolveGitDir(repoPath string) (string, error) { diff --git a/apps/backend/internal/task/service/repository_discovery_test.go b/apps/backend/internal/task/service/repository_discovery_test.go index 8750c7c854..5bd13c8869 100644 --- a/apps/backend/internal/task/service/repository_discovery_test.go +++ b/apps/backend/internal/task/service/repository_discovery_test.go @@ -132,17 +132,84 @@ func TestNormalizeRootsDedupesAndCleans(t *testing.T) { } } -func TestReadGitDefaultBranch(t *testing.T) { - repoPath := t.TempDir() +// seedBareGitDir creates a minimal .git directory at repoPath with HEAD set +// to the given branch (or commit SHA for detached). Returns the gitDir path. +func seedBareGitDir(t *testing.T, repoPath, headContent string) string { + t.Helper() gitDir := filepath.Join(repoPath, ".git") if err := os.MkdirAll(gitDir, 0o755); err != nil { t.Fatalf("mkdir git: %v", err) } - - headPath := filepath.Join(gitDir, "HEAD") - if err := os.WriteFile(headPath, []byte("ref: refs/heads/develop\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte(headContent), 0o644); err != nil { t.Fatalf("write HEAD: %v", err) } + return gitDir +} + +// TestReadGitDefaultBranch_PrefersOriginHEAD — the upstream's declared +// default beats every other heuristic. Regression for the bug where the +// repo's stored default_branch latched onto whatever feature branch the user +// happened to be on at task-creation time. +func TestReadGitDefaultBranch_PrefersOriginHEAD(t *testing.T) { + repoPath := t.TempDir() + gitDir := seedBareGitDir(t, repoPath, "ref: refs/heads/feature/x\n") + if err := os.MkdirAll(filepath.Join(gitDir, "refs", "remotes", "origin"), 0o755); err != nil { + t.Fatalf("mkdir origin: %v", err) + } + if err := os.WriteFile( + filepath.Join(gitDir, "refs", "remotes", "origin", "HEAD"), + []byte("ref: refs/remotes/origin/main\n"), + 0o644, + ); err != nil { + t.Fatalf("write origin/HEAD: %v", err) + } + branch, err := readGitDefaultBranch(repoPath) + if err != nil { + t.Fatalf("readGitDefaultBranch error: %v", err) + } + if branch != "main" { + t.Fatalf("expected main, got %q", branch) + } +} + +// TestReadGitDefaultBranch_FallsBackToRemoteCandidates — when origin/HEAD +// isn't set (older clones, never run `git remote set-head`), the conventional +// origin/main / origin/master refs win over local branches. +func TestReadGitDefaultBranch_FallsBackToRemoteCandidates(t *testing.T) { + repoPath := t.TempDir() + gitDir := seedBareGitDir(t, repoPath, "ref: refs/heads/feature/y\n") + writeRef(t, filepath.Join(gitDir, "refs", "remotes", "origin", "main")) + branch, err := readGitDefaultBranch(repoPath) + if err != nil { + t.Fatalf("readGitDefaultBranch error: %v", err) + } + if branch != "main" { + t.Fatalf("expected main, got %q", branch) + } +} + +// TestReadGitDefaultBranch_FallsBackToLocalCandidates — local-only repos +// (no remotes) still produce the conventional integration branch when one +// exists locally, even if the user is checked out on a feature branch. +func TestReadGitDefaultBranch_FallsBackToLocalCandidates(t *testing.T) { + repoPath := t.TempDir() + gitDir := seedBareGitDir(t, repoPath, "ref: refs/heads/feature/z\n") + writeRef(t, filepath.Join(gitDir, "refs", "heads", "main")) + branch, err := readGitDefaultBranch(repoPath) + if err != nil { + t.Fatalf("readGitDefaultBranch error: %v", err) + } + if branch != "main" { + t.Fatalf("expected main, got %q", branch) + } +} + +// TestReadGitDefaultBranch_FallsBackToHEAD — a brand-new repo with only +// a feature branch checked out and no main/master falls back to HEAD so +// callers still get *some* answer instead of an error. +func TestReadGitDefaultBranch_FallsBackToHEAD(t *testing.T) { + repoPath := t.TempDir() + seedBareGitDir(t, repoPath, "ref: refs/heads/develop\n") branch, err := readGitDefaultBranch(repoPath) if err != nil { t.Fatalf("readGitDefaultBranch error: %v", err) @@ -150,21 +217,47 @@ func TestReadGitDefaultBranch(t *testing.T) { if branch != "develop" { t.Fatalf("expected develop, got %q", branch) } +} - if err := os.WriteFile(headPath, []byte("3a3f2d3b\n"), 0o644); err != nil { - t.Fatalf("write HEAD detached: %v", err) - } - branch, err = readGitDefaultBranch(repoPath) +// TestReadGitDefaultBranch_FallsBackToHEAD_PreservesSlashes — branch names +// can contain slashes (e.g. "feature/my-feature"). The HEAD fallback must +// strip the refs/heads/ prefix verbatim rather than split on "/" and take +// the tail, otherwise nested branches get silently corrupted into their +// last path component, which then becomes the stored default_branch and +// poisons every downstream merge-base lookup. +func TestReadGitDefaultBranch_FallsBackToHEAD_PreservesSlashes(t *testing.T) { + repoPath := t.TempDir() + seedBareGitDir(t, repoPath, "ref: refs/heads/feature/my-feature\n") + branch, err := readGitDefaultBranch(repoPath) if err != nil { - t.Fatalf("readGitDefaultBranch detached error: %v", err) + t.Fatalf("readGitDefaultBranch error: %v", err) + } + if branch != "feature/my-feature" { + t.Fatalf("expected feature/my-feature, got %q", branch) + } +} + +// TestReadGitDefaultBranch_DetachedHEADReturnsHEADLiteral — detached HEAD on +// a repo with no integration branches yields the literal "HEAD" sentinel +// (preserved from the previous behavior so callers depending on it don't +// silently start failing). +func TestReadGitDefaultBranch_DetachedHEADReturnsHEADLiteral(t *testing.T) { + repoPath := t.TempDir() + seedBareGitDir(t, repoPath, "3a3f2d3b\n") + branch, err := readGitDefaultBranch(repoPath) + if err != nil { + t.Fatalf("readGitDefaultBranch error: %v", err) } if branch != "HEAD" { t.Fatalf("expected HEAD, got %q", branch) } +} - if err := os.WriteFile(headPath, []byte("\n"), 0o644); err != nil { - t.Fatalf("write HEAD empty: %v", err) - } +// TestReadGitDefaultBranch_ErrorsOnEmptyHEAD — preserves the existing +// contract: HEAD with no content is an unparseable repo. +func TestReadGitDefaultBranch_ErrorsOnEmptyHEAD(t *testing.T) { + repoPath := t.TempDir() + seedBareGitDir(t, repoPath, "\n") if _, err := readGitDefaultBranch(repoPath); err == nil { t.Fatalf("expected error for empty HEAD") } diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 768741a0b0..0669fee689 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -22,7 +22,7 @@ func (s *Service) PublishTaskUpdated(ctx context.Context, task *models.Task) { } // publishTaskEvent publishes task events to the event bus -func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState) { +func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState, oldWorkflowIDs ...string) { if s.eventBus == nil { return } @@ -66,6 +66,9 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task * data["old_state"] = string(*oldState) data["new_state"] = string(task.State) } + if len(oldWorkflowIDs) > 0 && oldWorkflowIDs[0] != "" && oldWorkflowIDs[0] != task.WorkflowID { + data["old_workflow_id"] = oldWorkflowIDs[0] + } event := bus.NewEvent(eventType, "task-service", data) if err := s.eventBus.Publish(ctx, eventType, event); err != nil { @@ -154,12 +157,14 @@ func serializeTaskRepositories(repos []*models.TaskRepository) []map[string]inte // publishTaskMovedEvent publishes a task.moved event so the orchestrator can process // on_exit/on_enter actions for the new workflow step. -func (s *Service) publishTaskMovedEvent(ctx context.Context, task *models.Task, fromStepID, toStepID, sessionID string) { +func (s *Service) publishTaskMovedEvent(ctx context.Context, task *models.Task, fromWorkflowID, fromStepID, toStepID, sessionID string) { if s.eventBus == nil { return } data := map[string]interface{}{ "task_id": task.ID, + "from_workflow_id": fromWorkflowID, + "to_workflow_id": task.WorkflowID, "from_step_id": fromStepID, "to_step_id": toStepID, "session_id": sessionID, @@ -216,6 +221,7 @@ func (s *Service) publishWorkflowEvent(ctx context.Context, eventType string, wo "name": workflow.Name, "description": workflow.Description, "agent_profile_id": workflow.AgentProfileID, + "hidden": workflow.Hidden, "created_at": workflow.CreatedAt.Format(time.RFC3339), "updated_at": workflow.UpdatedAt.Format(time.RFC3339), } diff --git a/apps/backend/internal/task/service/service_messages.go b/apps/backend/internal/task/service/service_messages.go index 8be14b9867..d126857a14 100644 --- a/apps/backend/internal/task/service/service_messages.go +++ b/apps/backend/internal/task/service/service_messages.go @@ -584,19 +584,19 @@ func (s *Service) UpdatePermissionMessage(ctx context.Context, sessionID, pendin return nil } -// UpdateClarificationMessage updates a clarification request message's status and response. -// It includes retry logic to handle race conditions. -// The answers parameter should be a slice of answer objects with question_id, selected_options, and custom_text. -func (s *Service) UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, status string, answers interface{}) error { +// UpdateClarificationMessageForQuestion updates a single clarification message +// (identified by pending_id + question_id) with the new status and the answer +// payload. Used by both single- and multi-question clarification bundles since +// each question lives in its own chat message. +func (s *Service) UpdateClarificationMessageForQuestion(ctx context.Context, sessionID, pendingID, questionID, status string, answer interface{}) error { const maxRetries = 5 const retryDelay = 100 * time.Millisecond var message *models.Message var err error - // Retry loop to handle race condition for attempt := 0; attempt < maxRetries; attempt++ { - message, err = s.messages.GetMessageByPendingID(ctx, sessionID, pendingID) + message, err = s.messages.FindMessageByPendingIDAndQuestion(ctx, sessionID, pendingID, questionID) if err == nil { break } @@ -609,6 +609,7 @@ func (s *Service) UpdateClarificationMessage(ctx context.Context, sessionID, pen s.logger.Debug("clarification message not found, retrying", zap.String("session_id", sessionID), zap.String("pending_id", pendingID), + zap.String("question_id", questionID), zap.Int("attempt", attempt+1), zap.Int("max_retries", maxRetries)) time.Sleep(retryDelay) @@ -619,6 +620,7 @@ func (s *Service) UpdateClarificationMessage(ctx context.Context, sessionID, pen s.logger.Warn("clarification message not found for update after retries", zap.String("session_id", sessionID), zap.String("pending_id", pendingID), + zap.String("question_id", questionID), zap.Int("retries", maxRetries), zap.Error(err)) return err @@ -628,24 +630,25 @@ func (s *Service) UpdateClarificationMessage(ctx context.Context, sessionID, pen message.Metadata = make(map[string]interface{}) } message.Metadata["status"] = status - if answers != nil { - message.Metadata["response"] = answers + if answer != nil { + message.Metadata["response"] = answer } if err := s.messages.UpdateMessage(ctx, message); err != nil { s.logger.Error("failed to update clarification message", zap.String("message_id", message.ID), zap.String("pending_id", pendingID), + zap.String("question_id", questionID), zap.Error(err)) return err } - // Publish message.updated event s.publishMessageEvent(ctx, events.MessageUpdated, message) s.logger.Info("clarification message updated", zap.String("message_id", message.ID), zap.String("pending_id", pendingID), + zap.String("question_id", questionID), zap.String("status", status)) return nil diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 70f745ccff..4a33a9507e 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -181,6 +181,27 @@ func (s *Service) UpdateWorkflow(ctx context.Context, id string, req *UpdateWork return workflow, nil } +// SetWorkflowHidden flips the hidden flag on a workflow. Used by system +// flows (e.g. improve-kandev) to heal records created before Hidden was +// honored on insert. +func (s *Service) SetWorkflowHidden(ctx context.Context, id string, hidden bool) error { + workflow, err := s.workflows.GetWorkflow(ctx, id) + if err != nil { + return err + } + if workflow.Hidden == hidden { + return nil + } + workflow.Hidden = hidden + workflow.UpdatedAt = time.Now().UTC() + if err := s.workflows.UpdateWorkflow(ctx, workflow); err != nil { + s.logger.Error("failed to update workflow hidden flag", zap.String("workflow_id", id), zap.Error(err)) + return err + } + s.publishWorkflowEvent(ctx, events.WorkflowUpdated, workflow) + return nil +} + // DeleteWorkflow deletes a workflow func (s *Service) DeleteWorkflow(ctx context.Context, id string) error { workflow, err := s.workflows.GetWorkflow(ctx, id) diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 23b43878e8..79bbf94afc 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -13,6 +13,7 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" "go.uber.org/zap" + "github.com/kandev/kandev/internal/common/gitref" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/worktree" @@ -164,6 +165,18 @@ func (s *Service) resolveRepoInput(ctx context.Context, workspaceID string, repo repositoryID = repoInput.RepositoryID baseBranch = repoInput.BaseBranch if repositoryID != "" { + // Verify the repository belongs to the target workspace. Without this + // check, an agent that knows a repository UUID from another workspace + // could associate it with a task in this workspace via the MCP tool's + // repository_id fast path (the github_url and local_path branches both + // scope through FindOrCreateRepository, which is workspace-bound). + repo, lookupErr := s.repoEntities.GetRepository(ctx, repositoryID) + if lookupErr != nil { + return "", "", fmt.Errorf("looking up repository %q: %w", repositoryID, lookupErr) + } + if repo == nil || repo.WorkspaceID != workspaceID { + return "", "", fmt.Errorf("repository %q does not belong to workspace %q", repositoryID, workspaceID) + } return repositoryID, baseBranch, nil } @@ -203,9 +216,25 @@ func (s *Service) resolveRepoInput(ctx context.Context, workspaceID string, repo if name == "" { name = filepath.Base(repoInput.LocalPath) } + // Resolve default_branch by probing the repo on disk so it's anchored + // to the integration branch (origin/HEAD or main/master) rather than + // whatever feature branch the user happens to have checked out. The + // frontend's `default_branch` hint wins when set; otherwise we probe + // directly. Falling back to repoInput.BaseBranch is wrong because in + // the local-executor flow that field carries the user's working + // branch, which would permanently pin repositories.default_branch to + // a feature branch and break every downstream merge-base lookup. defaultBranch := repoInput.DefaultBranch if defaultBranch == "" { - defaultBranch = repoInput.BaseBranch + // Probe must operate on a path validated against the discovery + // allowlist — repoInput.LocalPath comes straight from the HTTP body + // and feeds into os.Stat/ReadFile inside gitref.DefaultBranch, so + // without this guard a caller could traverse the filesystem. + if safePath, pathErr := s.resolveAllowedLocalPath(repoInput.LocalPath); pathErr == nil { + if probed, err := gitref.DefaultBranch(safePath); err == nil && probed != "" && probed != "HEAD" { + defaultBranch = probed + } + } } created, createErr := s.CreateRepository(ctx, &CreateRepositoryRequest{ WorkspaceID: workspaceID, diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 7dd444e1dd..b7408e9136 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -3,6 +3,7 @@ package service import ( "context" "path/filepath" + "strings" "sync" "testing" "time" @@ -220,6 +221,33 @@ func TestService_CreateTask_AcceptsMultipleDistinctRepositories(t *testing.T) { } } +func TestService_CreateTask_RejectsRepositoryFromOtherWorkspace(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-a", Name: "A"}) + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-b", Name: "B"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-a", WorkspaceID: "ws-a", Name: "WF"}) + _ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-b", WorkspaceID: "ws-b", Name: "B repo"}) + + req := &CreateTaskRequest{ + WorkspaceID: "ws-a", + WorkflowID: "wf-a", + WorkflowStepID: "step-1", + Title: "cross-ws", + Repositories: []TaskRepositoryInput{ + {RepositoryID: "repo-b", BaseBranch: "main"}, + }, + } + _, err := svc.CreateTask(ctx, req) + if err == nil { + t.Fatal("expected cross-workspace repository error, got nil") + } + if !strings.Contains(err.Error(), "does not belong to workspace") { + t.Errorf("expected workspace-ownership error, got: %v", err) + } +} + func TestService_CreateRepository_DefaultWorktreeBranchPrefix(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index 49c1deca68..d7ca50ac8a 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -100,6 +100,73 @@ func (s *Service) GetActiveTurn(ctx context.Context, sessionID string) (*models. return turn, err } +// AbandonOpenTurns closes any open turns for a session by setting their +// completed_at = started_at. Used on session resume to bury orphan turns left +// behind by a previous crash/restart so a fresh prompt starts a fresh turn +// (preventing the UI from showing the orphan's stale started_at as the running +// timer, and preventing the orphan from poisoning analytics with hours of dead +// time). +// +// Mirrors the iteration shape of orchestrator.completeTurnForSession: the DB is +// authoritative, so we loop GetActiveTurn → AbandonTurn until none remain, with +// a sanity cap to break runaway loops. +func (s *Service) AbandonOpenTurns(ctx context.Context, sessionID string) error { + const maxIterations = 16 + closed := 0 + for closed < maxIterations { + turn, err := s.GetActiveTurn(ctx, sessionID) + if err != nil { + return err + } + if turn == nil { + return nil + } + if err := s.turns.AbandonTurn(ctx, turn.ID); err != nil { + s.logger.Error("failed to abandon turn", + zap.String("turn_id", turn.ID), + zap.String("session_id", sessionID), + zap.Error(err)) + return err + } + // Same safety net as CompleteTurn: any tool calls left in a non-terminal + // state belong to a turn that is no longer running, so flip them to + // "complete" instead of leaving them to spin forever in the UI. + if affected, err := s.turns.CompletePendingToolCallsForTurn(ctx, turn.ID); err != nil { + s.logger.Warn("failed to complete pending tool calls for abandoned turn", + zap.String("turn_id", turn.ID), + zap.Error(err)) + } else if affected > 0 { + s.logger.Info("completed pending tool calls on abandoned turn", + zap.String("turn_id", turn.ID), + zap.Int64("affected", affected)) + } + // Re-fetch so the published event carries the persisted completed_at + // (= started_at) rather than a synthesized one. + refreshed, err := s.turns.GetTurn(ctx, turn.ID) + if err != nil { + s.logger.Debug("failed to refetch abandoned turn", + zap.String("turn_id", turn.ID), + zap.Error(err)) + } else { + s.publishTurnEvent(events.TurnCompleted, refreshed) + } + s.logger.Info("abandoned orphan turn on session resume", + zap.String("turn_id", turn.ID), + zap.String("session_id", sessionID)) + closed++ + } + // Only warn if turns are *still* accumulating after the cap. Closing + // exactly maxIterations turns and then finding the session clean is not a + // runaway — same shape as completeTurnForSession. + if turn, err := s.GetActiveTurn(ctx, sessionID); err == nil && turn != nil { + s.logger.Warn("AbandonOpenTurns hit iteration cap; some orphan turns may remain", + zap.String("session_id", sessionID), + zap.Int("closed", closed), + zap.Int("max_iterations", maxIterations)) + } + return nil +} + // getOrStartTurn returns the active turn for a session, or starts a new one if none exists. // This is used to ensure messages always have a valid turn ID. func (s *Service) getOrStartTurn(ctx context.Context, sessionID string) (*models.Turn, error) { diff --git a/apps/backend/internal/task/service/service_turns_test.go b/apps/backend/internal/task/service/service_turns_test.go index 79fcb81cea..5e0fb16930 100644 --- a/apps/backend/internal/task/service/service_turns_test.go +++ b/apps/backend/internal/task/service/service_turns_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" ) @@ -412,3 +413,203 @@ func TestGetWorkspaceInfoForSession_MultiRepoReturnsTaskRoot(t *testing.T) { info.WorkspacePath) } } + +// AbandonOpenTurns: turns left open by a previous crash must close with +// completed_at = started_at so analytics' active_duration_ms doesn't get +// poisoned with the dead window and the UI's running timer doesn't count +// from a stale start. +func TestAbandonOpenTurns_ZeroesDuration(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + stale := &models.Turn{ + ID: "turn-stale", + TaskSessionID: sessionID, + TaskID: "task-123", + StartedAt: time.Now().Add(-90 * time.Hour).UTC(), + } + if err := repo.CreateTurn(ctx, stale); err != nil { + t.Fatalf("CreateTurn: %v", err) + } + + if err := svc.AbandonOpenTurns(ctx, sessionID); err != nil { + t.Fatalf("AbandonOpenTurns: %v", err) + } + + got, err := repo.GetTurn(ctx, stale.ID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + if got.CompletedAt == nil { + t.Fatal("expected completed_at to be set") + } + if !got.CompletedAt.Equal(got.StartedAt) { + t.Fatalf("expected completed_at == started_at (zero duration), got started=%v completed=%v", + got.StartedAt, *got.CompletedAt) + } +} + +// AbandonOpenTurns sweeps the same pending tool calls that CompleteTurn does: +// otherwise the UI would show "running" tool calls forever on an abandoned turn. +func TestAbandonOpenTurns_CompletesPendingToolCalls(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + turn := &models.Turn{ + ID: "turn-with-tool", + TaskSessionID: sessionID, + TaskID: "task-123", + StartedAt: time.Now().UTC(), + } + if err := repo.CreateTurn(ctx, turn); err != nil { + t.Fatalf("CreateTurn: %v", err) + } + + toolMsg := &models.Message{ + ID: "msg-tool-1", + TaskSessionID: sessionID, + TaskID: "task-123", + TurnID: turn.ID, + AuthorType: models.MessageAuthorAgent, + Type: models.MessageTypeToolCall, + Content: "running tool", + Metadata: map[string]interface{}{ + "tool_call_id": "tc-1", + "status": "running", + }, + } + if err := repo.CreateMessage(ctx, toolMsg); err != nil { + t.Fatalf("CreateMessage: %v", err) + } + + if err := svc.AbandonOpenTurns(ctx, sessionID); err != nil { + t.Fatalf("AbandonOpenTurns: %v", err) + } + + got, err := repo.GetMessageByToolCallID(ctx, sessionID, "tc-1") + if err != nil { + t.Fatalf("GetMessageByToolCallID: %v", err) + } + if got == nil { + t.Fatal("expected tool call message to exist") + } + if status, _ := got.Metadata["status"].(string); status != "complete" { + t.Fatalf("expected tool call status='complete', got %q", status) + } +} + +// AbandonOpenTurns publishes turn.completed for each closed turn so the WS +// gateway can broadcast the state change and the frontend clears its +// activeBySession entry. +func TestAbandonOpenTurns_PublishesTurnCompletedEvent(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + turn := &models.Turn{ + ID: "turn-event", + TaskSessionID: sessionID, + TaskID: "task-123", + StartedAt: time.Now().UTC(), + } + if err := repo.CreateTurn(ctx, turn); err != nil { + t.Fatalf("CreateTurn: %v", err) + } + + eventBus.ClearEvents() + if err := svc.AbandonOpenTurns(ctx, sessionID); err != nil { + t.Fatalf("AbandonOpenTurns: %v", err) + } + + var found bool + for _, ev := range eventBus.GetPublishedEvents() { + if ev.Type == events.TurnCompleted { + found = true + break + } + } + if !found { + t.Fatal("expected turn.completed event to be published") + } +} + +// AbandonOpenTurns is a no-op when no open turn exists (e.g. resume called on +// a session whose previous turn already completed cleanly). +func TestAbandonOpenTurns_NoOpenTurns(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + eventBus.ClearEvents() + if err := svc.AbandonOpenTurns(ctx, sessionID); err != nil { + t.Fatalf("AbandonOpenTurns: %v", err) + } + + if got := len(eventBus.GetPublishedEvents()); got != 0 { + t.Fatalf("expected no events when nothing to abandon, got %d", got) + } +} + +// AbandonOpenTurns iteration cap: with more than maxIterations open turns, +// the function still returns nil (caller swallows the error and the next +// completeTurnForSession sweep mops up the remainder). +func TestAbandonOpenTurns_IterationCap(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + const seeded = 20 // > maxIterations (16) + for i := 0; i < seeded; i++ { + turn := &models.Turn{ + ID: fmt.Sprintf("turn-cap-%d", i), + TaskSessionID: sessionID, + TaskID: "task-123", + StartedAt: time.Now().UTC(), + } + if err := repo.CreateTurn(ctx, turn); err != nil { + t.Fatalf("seed turn %d: %v", i, err) + } + } + + if err := svc.AbandonOpenTurns(ctx, sessionID); err != nil { + t.Fatalf("AbandonOpenTurns: %v", err) + } + + // First sweep closes exactly maxIterations; remainder still open. + turns, err := repo.ListTurnsBySession(ctx, sessionID) + if err != nil { + t.Fatalf("ListTurnsBySession: %v", err) + } + open := 0 + for _, turn := range turns { + if turn.CompletedAt == nil { + open++ + } + } + if open != seeded-16 { + t.Fatalf("expected %d open turns after first sweep (cap=16), got %d", seeded-16, open) + } + + // Second sweep finishes the job. + if err := svc.AbandonOpenTurns(ctx, sessionID); err != nil { + t.Fatalf("second AbandonOpenTurns: %v", err) + } + turns, _ = repo.ListTurnsBySession(ctx, sessionID) + for _, turn := range turns { + if turn.CompletedAt == nil { + t.Fatalf("expected all turns closed after second sweep, %s still open", turn.ID) + } + } +} diff --git a/apps/backend/internal/task/service/service_workflow.go b/apps/backend/internal/task/service/service_workflow.go index 8ac42517b6..cc2ee0ff53 100644 --- a/apps/backend/internal/task/service/service_workflow.go +++ b/apps/backend/internal/task/service/service_workflow.go @@ -228,14 +228,14 @@ func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, wo return nil, err } - if err := s.checkMoveTaskApproval(ctx, id, task.WorkflowStepID, workflowStepID); err != nil { + if err := s.validateTaskMove(ctx, task, workflowID, workflowStepID); err != nil { return nil, err } + oldWorkflowID := task.WorkflowID oldStepID := task.WorkflowStepID stepChanged := oldStepID != workflowStepID - oldState := task.State task.WorkflowID = workflowID task.WorkflowStepID = workflowStepID task.Position = position @@ -252,16 +252,11 @@ func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, wo sessionID = activeSession.ID } - // Publish state_changed event if state changed, otherwise just updated - if oldState != task.State { - s.publishTaskEvent(ctx, events.TaskStateChanged, task, &oldState) - } else { - s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) - } + s.publishTaskEvent(ctx, events.TaskUpdated, task, nil, oldWorkflowID) // Publish task.moved event so the orchestrator can process on_exit/on_enter actions if stepChanged { - s.publishTaskMovedEvent(ctx, task, oldStepID, workflowStepID, sessionID) + s.publishTaskMovedEvent(ctx, task, oldWorkflowID, oldStepID, workflowStepID, sessionID) } s.logger.Info("task moved", @@ -288,22 +283,51 @@ func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, wo return result, nil } -// checkMoveTaskApproval returns an error when the task's primary session has a pending -// review and the caller is trying to move it to a different step. -func (s *Service) checkMoveTaskApproval(ctx context.Context, taskID, currentStepID, targetStepID string) error { - if currentStepID == targetStepID { +func (s *Service) validateTaskMove(ctx context.Context, task *models.Task, workflowID, workflowStepID string) error { + if task.ArchivedAt != nil { + return fmt.Errorf("archived tasks cannot be moved") + } + if err := s.validateMoveSessions(ctx, task.ID); err != nil { + return err + } + targetWorkflow, err := s.workflows.GetWorkflow(ctx, workflowID) + if err != nil { + return fmt.Errorf("failed to get target workflow: %w", err) + } + if targetWorkflow.WorkspaceID != task.WorkspaceID { + return fmt.Errorf("target workflow is in a different workspace") + } + if s.workflowStepGetter == nil { return nil } - primarySession, err := s.sessions.GetPrimarySessionByTaskID(ctx, taskID) - if err != nil || primarySession == nil { - return nil + targetStep, err := s.workflowStepGetter.GetStep(ctx, workflowStepID) + if err != nil { + return fmt.Errorf("failed to get target workflow step: %w", err) } - if primarySession.ReviewStatus != nil && *primarySession.ReviewStatus == "pending" { - return fmt.Errorf("task is pending approval - use Approve button to proceed or send a message to request changes") + if targetStep.WorkflowID != workflowID { + return fmt.Errorf("target workflow step does not belong to target workflow") } return nil } +func (s *Service) validateMoveSessions(ctx context.Context, taskID string) error { + sessions, err := s.sessions.ListTaskSessions(ctx, taskID) + if err != nil { + return fmt.Errorf("failed to list task sessions: %w", err) + } + for _, session := range sessions { + if isSessionMoveBlocked(session.State) { + return fmt.Errorf("task has an active session (%s)", session.State) + } + } + return nil +} + +func isSessionMoveBlocked(state models.TaskSessionState) bool { + return state == models.TaskSessionStateStarting || + state == models.TaskSessionStateRunning +} + // resolvePrimaryOrActiveSession returns the primary session if it is in an active // state, otherwise falls back to the most recently started active session. func (s *Service) resolvePrimaryOrActiveSession(ctx context.Context, taskID string) *models.TaskSession { @@ -340,6 +364,74 @@ type BulkMoveTasksResult struct { MovedCount int } +// BulkMoveSelectedTasks moves an explicit task list to a target workflow step. +// The list order is treated as the visible UI order; tasks already in the +// target step are skipped. Validation reads tasks one at a time because the UI +// sends small selected batches; the move is not transactional if task state +// changes between pre-validation and an individual MoveTask call. +func (s *Service) BulkMoveSelectedTasks(ctx context.Context, taskIDs []string, targetWorkflowID, targetStepID string) (*BulkMoveTasksResult, error) { + ids := uniqueTaskIDs(taskIDs) + if len(ids) == 0 { + return &BulkMoveTasksResult{MovedCount: 0}, nil + } + + tasks, err := s.validateSelectedMoveBatch(ctx, ids, targetWorkflowID, targetStepID) + if err != nil { + return nil, err + } + + nextPosition, err := s.tasks.CountTasksByWorkflowStep(ctx, targetStepID) + if err != nil { + return nil, fmt.Errorf("failed to count target workflow step tasks: %w", err) + } + + movedCount := 0 + for _, task := range tasks { + if task.WorkflowID == targetWorkflowID && task.WorkflowStepID == targetStepID { + continue + } + if _, err := s.MoveTask(ctx, task.ID, targetWorkflowID, targetStepID, nextPosition+movedCount); err != nil { + return nil, fmt.Errorf("failed to move task %s: %w", task.ID, err) + } + movedCount++ + } + + return &BulkMoveTasksResult{MovedCount: movedCount}, nil +} + +func uniqueTaskIDs(taskIDs []string) []string { + seen := make(map[string]struct{}, len(taskIDs)) + result := make([]string, 0, len(taskIDs)) + for _, id := range taskIDs { + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} + +func (s *Service) validateSelectedMoveBatch(ctx context.Context, taskIDs []string, targetWorkflowID, targetStepID string) ([]*models.Task, error) { + tasks := make([]*models.Task, 0, len(taskIDs)) + for _, id := range taskIDs { + task, err := s.tasks.GetTask(ctx, id) + if err != nil { + return nil, err + } + if task.WorkflowID != targetWorkflowID || task.WorkflowStepID != targetStepID { + if err := s.validateTaskMove(ctx, task, targetWorkflowID, targetStepID); err != nil { + return nil, fmt.Errorf("task %s cannot be moved: %w", id, err) + } + } + tasks = append(tasks, task) + } + return tasks, nil +} + // BulkMoveTasks moves all tasks from a source workflow/step to a target workflow/step. // If sourceStepID is empty, all tasks in the source workflow are moved. func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceStepID, targetWorkflowID, targetStepID string) (*BulkMoveTasksResult, error) { @@ -361,6 +453,7 @@ func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceSte now := time.Now().UTC() for i, task := range tasks { + oldWorkflowID := task.WorkflowID task.WorkflowID = targetWorkflowID task.WorkflowStepID = targetStepID task.Position = i @@ -373,7 +466,7 @@ func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceSte return nil, fmt.Errorf("failed to move task %s: %w", task.ID, err) } - s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) + s.publishTaskEvent(ctx, events.TaskUpdated, task, nil, oldWorkflowID) } s.logger.Info("bulk moved tasks", diff --git a/apps/backend/internal/task/service/service_workflow_test.go b/apps/backend/internal/task/service/service_workflow_test.go new file mode 100644 index 0000000000..ddbbc14d7f --- /dev/null +++ b/apps/backend/internal/task/service/service_workflow_test.go @@ -0,0 +1,378 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakeWorkflowStepGetter struct { + steps map[string]*wfmodels.WorkflowStep +} + +func (f *fakeWorkflowStepGetter) GetStep(_ context.Context, stepID string) (*wfmodels.WorkflowStep, error) { + if step, ok := f.steps[stepID]; ok { + return step, nil + } + return nil, errStepNotFoundForTest +} + +func (f *fakeWorkflowStepGetter) GetNextStepByPosition(_ context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) { + for _, step := range f.steps { + if step.WorkflowID == workflowID && step.Position == currentPosition+1 { + return step, nil + } + } + return nil, nil +} + +type testStepNotFound struct{} + +func (testStepNotFound) Error() string { return "step not found" } + +var errStepNotFoundForTest = testStepNotFound{} + +// TestService_SetWorkflowHidden_HealsStaleRecord verifies the helper used by +// the improve-kandev bootstrap to flip Hidden=true on workflows created +// before the flag was honored on insert. +func TestService_SetWorkflowHidden_HealsStaleRecord(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-stale", WorkspaceID: "ws-1", Name: "Improve Kandev", Hidden: false}) + + if err := svc.SetWorkflowHidden(ctx, "wf-stale", true); err != nil { + t.Fatalf("SetWorkflowHidden: %v", err) + } + + visible, err := svc.ListWorkflows(ctx, "ws-1", false) + if err != nil { + t.Fatalf("ListWorkflows: %v", err) + } + for _, wf := range visible { + if wf.ID == "wf-stale" { + t.Fatalf("hidden workflow leaked into default listing: %+v", wf) + } + } + + all, err := svc.ListWorkflows(ctx, "ws-1", true) + if err != nil { + t.Fatalf("ListWorkflows(includeHidden): %v", err) + } + var found *models.Workflow + for _, wf := range all { + if wf.ID == "wf-stale" { + found = wf + } + } + if found == nil || !found.Hidden { + t.Fatalf("expected wf-stale to be hidden after heal, got %+v", found) + } +} + +func TestService_MoveTaskRejectsInvalidWorkflowTargets(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + + tests := []struct { + name string + taskID string + targetWF string + targetSt string + }{ + { + name: "step belongs to another workflow", + taskID: "task-invalid-step", + targetWF: "wf-source", + targetSt: "step-target", + }, + { + name: "workflow belongs to another workspace", + taskID: "task-other-workspace", + targetWF: "wf-other-workspace", + targetSt: "step-other-workspace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + createMoveTask(t, ctx, repo, tt.taskID, "wf-source", "step-source", nil) + + _, err := svc.MoveTask(ctx, tt.taskID, tt.targetWF, tt.targetSt, 0) + if err == nil { + t.Fatalf("expected move to be rejected") + } + + task, err := repo.GetTask(ctx, tt.taskID) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if task.WorkflowID != "wf-source" || task.WorkflowStepID != "step-source" { + t.Fatalf("task moved despite validation error: workflow=%s step=%s", task.WorkflowID, task.WorkflowStepID) + } + }) + } +} + +func TestService_MoveTaskAllowsPendingReviewWhenSessionIdle(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + createMoveTask(t, ctx, repo, "task-pending-review", "wf-source", "step-source", nil) + pending := "pending" + createMoveSession(t, ctx, repo, "session-pending-review", "task-pending-review", models.TaskSessionStateWaitingForInput, &pending) + + moved, err := svc.MoveTask(ctx, "task-pending-review", "wf-source", "step-review-target", 0) + if err != nil { + t.Fatalf("pending review on idle session should not block manual move: %v", err) + } + if moved.Task.WorkflowStepID != "step-review-target" { + t.Fatalf("expected step-review-target, got %s", moved.Task.WorkflowStepID) + } +} + +func TestService_MoveTaskRejectsRunningSession(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + createMoveTask(t, ctx, repo, "task-running", "wf-source", "step-source", nil) + createMoveSession(t, ctx, repo, "session-running", "task-running", models.TaskSessionStateRunning, nil) + + _, err := svc.MoveTask(ctx, "task-running", "wf-source", "step-review-target", 0) + if err == nil { + t.Fatalf("expected running session move to be rejected") + } + + task, err := repo.GetTask(ctx, "task-running") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if task.WorkflowStepID != "step-source" { + t.Fatalf("task moved despite running session: %s", task.WorkflowStepID) + } +} + +func TestService_MoveTaskRejectsArchivedTask(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + now := time.Now().UTC() + createMoveTask(t, ctx, repo, "task-archived", "wf-source", "step-source", &now) + + _, err := svc.MoveTask(ctx, "task-archived", "wf-source", "step-review-target", 0) + if err == nil { + t.Fatalf("expected archived task move to be rejected") + } +} + +func TestService_MoveTaskMovedEventIncludesSourceWorkflow(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + createMoveTask(t, ctx, repo, "task-cross-workflow", "wf-source", "step-source", nil) + eventBus.ClearEvents() + + _, err := svc.MoveTask(ctx, "task-cross-workflow", "wf-target", "step-target", 0) + if err != nil { + t.Fatalf("MoveTask: %v", err) + } + + updatedEvent := findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskUpdated) + updatedData, ok := updatedEvent.Data.(map[string]interface{}) + if !ok { + t.Fatalf("updated event data type = %T, want map[string]interface{}", updatedEvent.Data) + } + if got := updatedData["old_workflow_id"]; got != "wf-source" { + t.Fatalf("old_workflow_id = %v, want wf-source", got) + } + + event := findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskMoved) + data, ok := event.Data.(map[string]interface{}) + if !ok { + t.Fatalf("event data type = %T, want map[string]interface{}", event.Data) + } + if got := data["from_workflow_id"]; got != "wf-source" { + t.Fatalf("from_workflow_id = %v, want wf-source", got) + } + if got := data["to_workflow_id"]; got != "wf-target" { + t.Fatalf("to_workflow_id = %v, want wf-target", got) + } +} + +func TestService_BulkMoveTasksUpdatedEventIncludesSourceWorkflow(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + createMoveTask(t, ctx, repo, "task-bulk-cross-workflow", "wf-source", "step-source", nil) + eventBus.ClearEvents() + + _, err := svc.BulkMoveTasks(ctx, "wf-source", "", "wf-target", "step-target") + if err != nil { + t.Fatalf("BulkMoveTasks: %v", err) + } + + updatedEvent := findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskUpdated) + updatedData, ok := updatedEvent.Data.(map[string]interface{}) + if !ok { + t.Fatalf("updated event data type = %T, want map[string]interface{}", updatedEvent.Data) + } + if got := updatedData["old_workflow_id"]; got != "wf-source" { + t.Fatalf("old_workflow_id = %v, want wf-source", got) + } +} + +func TestService_BulkMoveSelectedTasksValidatesBatchBeforeMoving(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + createMoveTask(t, ctx, repo, "task-batch-ok", "wf-source", "step-source", nil) + createMoveTask(t, ctx, repo, "task-batch-running", "wf-source", "step-source", nil) + createMoveSession(t, ctx, repo, "session-batch-running", "task-batch-running", models.TaskSessionStateRunning, nil) + + _, err := svc.BulkMoveSelectedTasks(ctx, []string{"task-batch-ok", "task-batch-running"}, "wf-target", "step-target") + if err == nil { + t.Fatalf("expected selected batch move to be rejected") + } + + for _, id := range []string{"task-batch-ok", "task-batch-running"} { + task, err := repo.GetTask(ctx, id) + if err != nil { + t.Fatalf("GetTask(%s): %v", id, err) + } + if task.WorkflowID != "wf-source" || task.WorkflowStepID != "step-source" { + t.Fatalf("%s moved despite rejected batch: workflow=%s step=%s", id, task.WorkflowID, task.WorkflowStepID) + } + } +} + +func TestService_BulkMoveSelectedTasksSkipsCurrentTargetAndAppendsInOrder(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + createMoveTask(t, ctx, repo, "task-target-existing", "wf-target", "step-target", nil) + createMoveTask(t, ctx, repo, "task-source-a", "wf-source", "step-source", nil) + createMoveTask(t, ctx, repo, "task-target-already", "wf-target", "step-target", nil) + createMoveTask(t, ctx, repo, "task-source-b", "wf-source", "step-source", nil) + eventBus.ClearEvents() + + result, err := svc.BulkMoveSelectedTasks( + ctx, + []string{"task-source-a", "task-target-already", "task-source-b"}, + "wf-target", + "step-target", + ) + if err != nil { + t.Fatalf("BulkMoveSelectedTasks: %v", err) + } + if result.MovedCount != 2 { + t.Fatalf("MovedCount = %d, want 2", result.MovedCount) + } + + sourceA, err := repo.GetTask(ctx, "task-source-a") + if err != nil { + t.Fatalf("GetTask(task-source-a): %v", err) + } + sourceB, err := repo.GetTask(ctx, "task-source-b") + if err != nil { + t.Fatalf("GetTask(task-source-b): %v", err) + } + if sourceA.Position != 2 || sourceB.Position != 3 { + t.Fatalf("positions = (%d, %d), want (2, 3)", sourceA.Position, sourceB.Position) + } + + movedEvents := 0 + for _, event := range eventBus.GetPublishedEvents() { + if event.Type == events.TaskMoved { + movedEvents++ + } + } + if movedEvents != 2 { + t.Fatalf("task.moved events = %d, want 2", movedEvents) + } +} + +func seedMoveWorkflows(t *testing.T, ctx context.Context, repo interface { + CreateWorkspace(context.Context, *models.Workspace) error + CreateWorkflow(context.Context, *models.Workflow) error +}) { + t.Helper() + must(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"})) + must(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"})) + must(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-source", WorkspaceID: "ws-1", Name: "Source"})) + must(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-target", WorkspaceID: "ws-1", Name: "Target"})) + must(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-other-workspace", WorkspaceID: "ws-2", Name: "Other"})) +} + +func seedMoveSteps(svc *Service) { + svc.SetWorkflowStepGetter(&fakeWorkflowStepGetter{steps: map[string]*wfmodels.WorkflowStep{ + "step-source": {ID: "step-source", WorkflowID: "wf-source", Name: "Source", Position: 0}, + "step-review-target": {ID: "step-review-target", WorkflowID: "wf-source", Name: "Review", Position: 1}, + "step-target": {ID: "step-target", WorkflowID: "wf-target", Name: "Target", Position: 0}, + "step-other-workspace": {ID: "step-other-workspace", WorkflowID: "wf-other-workspace", Name: "Other", Position: 0}, + }}) +} + +func createMoveTask(t *testing.T, ctx context.Context, repo interface { + CreateTask(context.Context, *models.Task) error + ArchiveTask(context.Context, string) error +}, id, workflowID, stepID string, archivedAt *time.Time) { + t.Helper() + must(t, repo.CreateTask(ctx, &models.Task{ + ID: id, + WorkspaceID: "ws-1", + WorkflowID: workflowID, + WorkflowStepID: stepID, + Title: id, + State: v1.TaskStateTODO, + ArchivedAt: archivedAt, + })) + if archivedAt != nil { + must(t, repo.ArchiveTask(ctx, id)) + } +} + +func createMoveSession(t *testing.T, ctx context.Context, repo interface { + CreateTaskSession(context.Context, *models.TaskSession) error +}, id, taskID string, state models.TaskSessionState, reviewStatus *string) { + t.Helper() + must(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: id, + TaskID: taskID, + State: state, + IsPrimary: true, + ReviewStatus: reviewStatus, + })) +} + +func findPublishedEvent(t *testing.T, published []*bus.Event, eventType string) *bus.Event { + t.Helper() + for _, event := range published { + if event.Type == eventType { + return event + } + } + t.Fatalf("event %s not published; got %d events", eventType, len(published)) + return nil +} + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/apps/backend/internal/utility/service/service.go b/apps/backend/internal/utility/service/service.go index eac9d69828..75ea71bc6a 100644 --- a/apps/backend/internal/utility/service/service.go +++ b/apps/backend/internal/utility/service/service.go @@ -80,6 +80,12 @@ func (s *Service) CreateAgent(ctx context.Context, name, description, prompt, ag AgentID: agentID, Model: model, Builtin: false, + // Custom agents validate agent_id + model above, so they're always + // configured and ready to run at creation time. The DB's NOT NULL + // column persists the field value (not the schema default), so we + // have to set it here — otherwise new custom agents land disabled + // and never appear as runnable in pickers like the Slack triage one. + Enabled: true, } if err := s.repo.CreateAgent(ctx, agent); err != nil { diff --git a/apps/backend/internal/utility/store/sqlite.go b/apps/backend/internal/utility/store/sqlite.go index ea95950524..112950f043 100644 --- a/apps/backend/internal/utility/store/sqlite.go +++ b/apps/backend/internal/utility/store/sqlite.go @@ -83,6 +83,22 @@ func (r *sqliteRepository) initSchema() error { // Add enabled column if it doesn't exist (migration for existing DBs) _, _ = r.db.Exec(`ALTER TABLE utility_agents ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1`) + // Heal pre-existing custom agents that were created with enabled=0 due + // to a bug in CreateAgent (the Enabled field on the model defaulted to + // the zero value, so the INSERT wrote 0 even though the DB schema's + // own DEFAULT was 1). Built-ins are skipped — they have their own + // "enabled=false means fall back to user defaults" semantics. + // + // Restricted to rows whose updated_at == created_at — i.e. agents that + // have never been modified since INSERT. UpdateAgent always bumps + // updated_at, so a user who deliberately disables an agent post-fix + // has updated_at > created_at and is left alone. Without this guard + // every restart would silently re-enable any user-disabled custom agent. + _, _ = r.db.Exec(`UPDATE utility_agents SET enabled = 1 + WHERE builtin = 0 AND enabled = 0 + AND agent_id <> '' AND model <> '' + AND updated_at = created_at`) + // Seed built-in agents if err := r.seedBuiltinAgents(); err != nil { return fmt.Errorf("failed to seed built-in agents: %w", err) diff --git a/apps/backend/internal/utility/template/engine.go b/apps/backend/internal/utility/template/engine.go index 997e671f88..adbc87c382 100644 --- a/apps/backend/internal/utility/template/engine.go +++ b/apps/backend/internal/utility/template/engine.go @@ -106,7 +106,11 @@ func (e *Engine) buildTemplateData(ctx *Context) map[string]string { } // AvailableVariables returns the list of available template variables. +// Integration-specific variables (Slack, future webhooks, etc.) are listed +// in their own category so the UI can group them and users can tell which +// ones make sense in a given utility-agent context. func (e *Engine) AvailableVariables() []VariableInfo { + const categorySlack = "slack" return []VariableInfo{ {Name: "GitDiff", Description: "Output of git diff --staged or git diff", Example: "diff --git a/main.go...", Category: "git"}, {Name: "CommitLog", Description: "Git log for the current branch", Example: "abc123 Fix bug in handler", Category: "git"}, @@ -120,6 +124,12 @@ func (e *Engine) AvailableVariables() []VariableInfo { {Name: "WorkspacePath", Description: "Path to the workspace/worktree", Example: "/home/user/project", Category: "session"}, {Name: "UserPrompt", Description: "User's original prompt text", Example: "Fix the login bug", Category: "input"}, {Name: "ConversationHistory", Description: "Formatted conversation transcript", Example: "User: Fix the bug\nAgent: I'll look into...", Category: "session"}, + {Name: "SlackInstruction", Description: "User's instruction (the !kandev message minus the prefix)", Example: "investigate the safari login bug", Category: categorySlack}, + {Name: "SlackThread", Description: "Surrounding Slack thread, one line per message", Example: "alice (1714658000.0): bug repros every Monday\nbob (1714659000.0): seeing it on safari only", Category: categorySlack}, + {Name: "SlackPermalink", Description: "Canonical URL of the triggering Slack message", Example: "https://acme.slack.com/archives/C1/p1714659000000100", Category: categorySlack}, + {Name: "SlackUser", Description: "Sender of the !kandev message (@name or user id)", Example: "@alice", Category: categorySlack}, + {Name: "SlackChannelID", Description: "Slack channel id of the trigger message", Example: "C0123ABCD", Category: categorySlack}, + {Name: "SlackTS", Description: "Slack timestamp of the trigger message", Example: "1714659000.000100", Category: categorySlack}, } } @@ -128,5 +138,5 @@ type VariableInfo struct { Name string `json:"name"` Description string `json:"description"` Example string `json:"example"` - Category string `json:"category"` // "git", "task", "session" + Category string `json:"category"` // "git", "task", "session", "input", "slack" } diff --git a/apps/backend/internal/workflow/service/service.go b/apps/backend/internal/workflow/service/service.go index d3efb19fa4..c0ff7109af 100644 --- a/apps/backend/internal/workflow/service/service.go +++ b/apps/backend/internal/workflow/service/service.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "go.uber.org/zap" + workflowcfg "github.com/kandev/kandev/config/workflows" "github.com/kandev/kandev/internal/common/logger" taskmodels "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/workflow/models" @@ -54,14 +55,28 @@ func NewService(repo *repository.Repository, log *logger.Logger) *Service { // Template Operations // ============================================================================ -// ListTemplates returns all workflow templates. +// ListTemplates returns user-pickable workflow templates. Templates marked +// `hidden: true` in their embedded YAML (e.g. improve-kandev) are excluded +// from the management UI and the create-workflow picker. func (s *Service) ListTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) { templates, err := s.repo.ListTemplates(ctx) if err != nil { s.logger.Error("failed to list templates", zap.Error(err)) return nil, err } - return templates, nil + hidden, err := workflowcfg.HiddenTemplateIDs() + if err != nil { + s.logger.Error("failed to load embedded template visibility", zap.Error(err)) + return nil, err + } + result := make([]*models.WorkflowTemplate, 0, len(templates)) + for _, t := range templates { + if hidden[t.ID] { + continue + } + result = append(result, t) + } + return result, nil } // GetTemplate retrieves a workflow template by ID. diff --git a/apps/backend/internal/workflow/service/service_test.go b/apps/backend/internal/workflow/service/service_test.go index b16566dd8d..e43063a603 100644 --- a/apps/backend/internal/workflow/service/service_test.go +++ b/apps/backend/internal/workflow/service/service_test.go @@ -113,6 +113,21 @@ func createStep(t *testing.T, svc *Service, step *models.WorkflowStep) { require.NoError(t, err) } +// TestListTemplates_FiltersHidden verifies that templates marked +// `hidden: true` in their embedded YAML (improve-kandev) are excluded +// from the picker shown by the create-workflow dialog and the settings UI. +func TestListTemplates_FiltersHidden(t *testing.T) { + svc, _ := setupTestService(t) + + templates, err := svc.ListTemplates(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, templates, "ListTemplates returned no templates; the assertion below would vacuously pass") + + for _, tmpl := range templates { + assert.NotEqual(t, "improve-kandev", tmpl.ID, "hidden template must not be returned by ListTemplates") + } +} + func TestGetNextStepByPosition(t *testing.T) { t.Run("middle step returns next step", func(t *testing.T) { svc, db := setupTestService(t) diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 78f5aa4f64..3023d40d0c 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -73,10 +73,8 @@ const ( ActionTaskPlanRevisionGet = "task.plan.revision.get" ActionTaskPlanRevert = "task.plan.revert" - ActionTaskSessionList = "task.session.list" - ActionTaskSessionResume = "task.session.resume" - ActionTaskSessionStatus = "task.session.status" - ActionTaskSessionPrepare = "task.session.prepare" + ActionTaskSessionList = "task.session.list" + ActionTaskSessionStatus = "task.session.status" // Unified session launch ActionSessionLaunch = "session.launch" @@ -104,15 +102,9 @@ const ( ActionAgentResize = "agent.resize" // Resize agent PTY (passthrough mode) // Orchestrator actions - ActionOrchestratorStatus = "orchestrator.status" - ActionOrchestratorQueue = "orchestrator.queue" - ActionOrchestratorTrigger = "orchestrator.trigger" - ActionOrchestratorStart = "orchestrator.start" - ActionOrchestratorStop = "orchestrator.stop" - ActionOrchestratorPause = "orchestrator.pause" - ActionOrchestratorResume = "orchestrator.resume" - ActionOrchestratorPrompt = "orchestrator.prompt" - ActionOrchestratorComplete = "orchestrator.complete" + ActionOrchestratorStatus = "orchestrator.status" + ActionOrchestratorQueue = "orchestrator.queue" + ActionOrchestratorStop = "orchestrator.stop" // Message Queue actions ActionMessageQueueAdd = "message.queue.add" @@ -311,6 +303,7 @@ const ( ActionMCPListWorkspaces = "mcp.list_workspaces" ActionMCPListWorkflows = "mcp.list_workflows" ActionMCPListWorkflowSteps = "mcp.list_workflow_steps" + ActionMCPListRepositories = "mcp.list_repositories" ActionMCPListTasks = "mcp.list_tasks" ActionMCPCreateTask = "mcp.create_task" ActionMCPUpdateTask = "mcp.update_task" @@ -372,6 +365,7 @@ const ( ActionGitHubPRFilesGet = "github.pr_files.get" ActionGitHubPRCommitsGet = "github.pr_commits.get" ActionGitHubTaskPRUpdated = "github.task_pr.updated" // Notification + ActionGitHubRateLimitUpdated = "github.rate_limit.updated" // Notification ActionGitHubPRFeedbackNotify = "github.pr_feedback.notify" // Notification ActionGitHubNewReviewPRNotify = "github.new_review_pr.notify" // Notification ActionGitHubTaskPRSync = "github.task_pr.sync" @@ -415,6 +409,14 @@ const ( ActionLinearTeamsList = "linear.teams.list" ) +// Slack integration actions +const ( + ActionSlackConfigGet = "slack.config.get" + ActionSlackConfigSet = "slack.config.set" + ActionSlackConfigDelete = "slack.config.delete" + ActionSlackConfigTest = "slack.config.test" +) + // Error codes const ( ErrorCodeBadRequest = "BAD_REQUEST" diff --git a/apps/cli/README.md b/apps/cli/README.md index e65a916fcd..b086054566 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -4,18 +4,36 @@ Manage tasks. Orchestrate agents. Review changes. Ship value. ## Quick Start +### Homebrew + ```bash -npx kandev +brew install kdlbs/kandev/kandev +kandev ``` -Downloads the latest release, starts the backend + web app, and opens your browser. Data (worktrees, SQLite DB) is stored in `~/.kandev` by default. +### NPX (requires npm 7+) + +```bash +npx kandev@latest +``` + +Either install path resolves a platform-matched runtime (Go backend, agentctl, Next.js standalone web), launches the backend + web, and opens your browser. Data (worktrees, SQLite DB) is stored in `~/.kandev` by default. ## Version and Updates -- `npx kandev` (run mode) uses the latest GitHub release bundle by default. -- At startup, the launcher prints the resolved release tag (for example: `[kandev] release: v0.2.3 (github latest)`). -- Use `--version ` to pin a specific runtime bundle release. -- CLI package updates are checked from npm and shown as a prompt. They are not forced. +The package manager owns the runtime version. `kandev@X.Y.Z` ships with the matching runtime. + +- **Update via Homebrew**: `brew upgrade kandev` +- **Update via npm/npx**: `npx kandev@latest` or `npm install -g kandev@latest` +- **Print CLI version**: `kandev --version` + +### Advanced: pin a specific runtime tag + +`--runtime-version ` downloads a specific GitHub release runtime instead of using the installed one. For debugging compatibility issues only: + +```bash +kandev --runtime-version v0.16.0 +``` ## What You Get diff --git a/apps/cli/README_internal.md b/apps/cli/README_internal.md index 3b33a0b7f7..7779383417 100644 --- a/apps/cli/README_internal.md +++ b/apps/cli/README_internal.md @@ -1,158 +1,170 @@ -# Kandev Launcher (npx) +# Kandev CLI (internal docs) ## Architecture ```mermaid flowchart TD - A["npx kandev"] --> B["npm downloads kandev package"] - B --> C["CLI parses arguments"] - C --> D{"Command?"} + A["kandev"] --> B["CLI parses arguments"] + B --> C{"Command?"} - D -->|run| E["Fetch latest GitHub release"] - D -->|dev| F["Use local repo"] - D -->|start| G["Use local build"] + C -->|run / default| D["resolveRuntime()"] + C -->|dev| F["Use local repo"] + C -->|start| G["Use local build"] - E --> E1["Download platform zip"] - E1 --> E2["Extract to ~/.kandev/bin/"] - E2 --> E3["Start backend + Next.js standalone"] + D --> D1{"KANDEV_BUNDLE_DIR set?"} + D1 -->|yes| D2["Use env bundle (Homebrew)"] + D1 -->|no| D3["require.resolve('@kdlbs/runtime-{platform}')"] + D3 -->|hit| D4["Use installed npm bundle"] + D3 -->|miss| D5{"--runtime-version given?"} + D5 -->|yes| D6["Download from GitHub Releases (cache)"] + D5 -->|no| D7["Throw: 'No runtime found'"] + + D2 --> E["Start backend + Next.js standalone"] + D4 --> E + D6 --> E F --> F1["make dev + next dev"] - G --> G1["binary + next start"] ``` ## Overview -This package powers `npx kandev` by downloading prebuilt release bundles from GitHub Releases and running them locally. It: +This package provides the `kandev` CLI launcher. The runtime bundle (Go backend, agentctl, Next.js standalone web) is **installed by the package manager** — there is no first-run download. -- Detects OS/arch and fetches the matching bundle ZIP. -- Verifies the SHA256 checksum when available. -- Extracts the bundle into `~/.kandev/bin///`. -- Starts the backend binary and waits for the `/health` endpoint. -- Starts the Next.js standalone server with runtime `KANDEV_API_BASE_URL`. -- Uses the latest GitHub Release by default, so runtime bundles update automatically. +Two install channels share the same release artifacts: -It also supports local dev and production runs from a repo checkout. +- **npm/npx**: `kandev@X.Y.Z` declares `optionalDependencies` for `@kdlbs/runtime-{platform}@X.Y.Z`. npm 7+ filters by `os`/`cpu` and installs only the matching one. +- **Homebrew**: `kdlbs/homebrew-kandev` formula downloads the GitHub release tarball into the Cellar and sets `KANDEV_BUNDLE_DIR`. + +Both channels resolve to the same runtime bundle layout (`bin/kandev`, `bin/agentctl`, `web/server.js`). The CLI launcher (`runtime.ts`) abstracts over them. + +## Artifact shapes + +The GitHub release bundle and the npm runtime package are **different shapes** because they serve different consumers: + +``` +# GitHub release bundle (used by Homebrew + manual installs) +kandev/ +├── bin/{kandev,agentctl} +├── web/server.js +└── cli/ + ├── bin/cli.js # #!/usr/bin/env node + chmod +x + └── dist/cli.bundle.js # esbuild single-file bundle (deps inlined) + +# npm runtime package (@kdlbs/runtime-{platform}) +@kdlbs/runtime-{platform}/ +├── bin/{kandev,agentctl} +└── web/server.js +``` + +The `cli/` directory is only in the GitHub bundle (Homebrew needs the launcher). For npm installs, the main `kandev` package already provides the CLI; the runtime package only ships the binaries + web bundle. ## Commands | Command | Description | | ------------------------ | -------------------------------------------------------- | -| `kandev` or `kandev run` | Download and run release bundles (default) | +| `kandev` or `kandev run` | Run from installed runtime bundle (default) | | `kandev dev` | Run local repo with hot-reload (requires repo checkout) | | `kandev start` | Run local production build (requires `make build` first) | -## Updates - -On `run`, the launcher checks npm for the latest `kandev` CLI version and prompts: - -``` -Update available: -> . Update now? [y/N] -``` - -If you accept, it re-runs `npx kandev@latest` with the same arguments. -This check is skipped in `dev` and `start` modes. - -Note: the runtime bundles are pulled from the latest GitHub Release by default, even if the CLI version is unchanged. So: - -- **New runtime release without CLI publish**: users get new runtime automatically, but no update prompt. -- **New CLI publish**: users get an update prompt and then re-run with the new CLI. -- On startup, `run` also prints the resolved runtime release tag. -- Runtime assets are published atomically in CI (single final release upload job), so `/releases/latest` - does not surface partially uploaded platform bundles. - -You can disable the prompt with: - -```bash -KANDEV_NO_UPDATE_PROMPT=1 npx kandev -``` - -## Usage - -```bash -# Run the latest release bundle -npx kandev - -# Run a specific release tag -npx kandev run --version v0.1.0 - -# Override ports -npx kandev --backend-port 18080 --web-port 13000 - -# Local dev with hot-reload (from repo) -npx kandev dev - -# Local production build (from repo, after `make build`) -npx kandev start - -# Local test the built CLI (from repo root) -pnpm -C apps/cli build -pnpm -C apps/cli start -``` - ## CLI Options -| Option | Description | -| ----------------------- | ---------------------------------------- | -| `run` | Use release bundles (default) | -| `dev` | Use local repo with hot-reload | -| `start` | Use local production build | -| `--version ` | Release tag to install (default: latest) | -| `--backend-port ` | Override backend port | -| `--web-port ` | Override web port | -| `--help`, `-h` | Show help | +| Option | Description | +| -------------------------- | ---------------------------------------------------------- | +| `--version`, `-V` | Print CLI version and exit | +| `--port`, `--backend-port` | Backend port | +| `--web-internal-port` | Override internal Next.js port | +| `--verbose`, `-v` | Show info logs | +| `--debug` | Show debug logs + agent message dumps | +| `--runtime-version ` | **Advanced/debug**: download a specific GitHub runtime tag | +| `--help`, `-h` | Show help | + +## Updates (package-manager owned) + +The CLI no longer self-updates. Updates flow through the install channel: + +```bash +brew upgrade kandev # Homebrew +npm install -g kandev@latest # npm global +npx kandev@latest # always pulls latest +``` + +There is no `KANDEV_NO_UPDATE_PROMPT` or `KANDEV_SKIP_UPDATE` env var anymore — they're gone with the self-updater. + +## Bundling for Homebrew + +The Homebrew bundle uses `dist/cli.bundle.js`, an esbuild single-file output that inlines `tree-kill`, `tar`, and other Node deps. Required because the Cellar has no `node_modules`. The npm package uses the unbundled `dist/cli.js` and resolves deps via npm. + +```bash +pnpm -C apps/cli build # produces dist/cli.js +pnpm -C apps/cli bundle # produces dist/cli.bundle.js (~212KB) +``` + +Smoke test the bundle in a clean directory: + +```bash +mkdir /tmp/smoke && cd /tmp/smoke +node /path/to/apps/cli/dist/cli.bundle.js --help +``` + +If `Cannot find module 'tree-kill'` appears, the bundle is broken. ## Local Development ```bash -# Run CLI in dev mode +# Run CLI in dev mode (uses tsx) pnpm -C apps/cli dev # Run with arguments pnpm -C apps/cli dev -- start -pnpm -C apps/cli dev -- --backend-port 9000 +pnpm -C apps/cli dev -- --port 9000 ``` -## Build / Publish - -```bash -pnpm -C apps/cli build -npm publish --access public -``` - -The published package name is `kandev`, with a bin entry `kandev`. - ## Release -```bash -scripts/release/release.sh -``` +Releases run entirely in GitHub Actions. From the GHA UI: -The unified release script supports `cli`, `app`, or `both` in one flow. -Versioning strategy: +1. Open the **Release** workflow. +2. Click **Run workflow**. +3. Choose `bump` (patch / minor / major) from the dropdown. +4. Optionally tick `dry_run` to validate without publishing. +5. Click **Run workflow**. -- App release tags: `vM.m` -- CLI npm version: `M.m.0` -- Default bump mode: `minor` +The workflow does everything: version bump, CHANGELOG, PR, merge, tag, build, npm publish, Homebrew tap update. See [/.github/workflows/release.yml](../../.github/workflows/release.yml). + +Single SemVer flow: `apps/cli/package.json` version, git tag, npm packages, and Homebrew formula are all bumped to the same `X.Y.Z`. + +Versioning: + +- One SemVer `X.Y.Z` for everything (npm, GitHub tag, Homebrew formula) +- Git tag format: `vX.Y.Z` +- Legacy `vM.m` tags normalized to `M.m.0` during migration; new releases always use `vX.Y.Z` ## Environment Overrides -| Variable | Description | -| --------------------------- | ---------------------------------------------- | -| `KANDEV_GITHUB_OWNER` | Override GitHub repo owner (default: `kandev`) | -| `KANDEV_GITHUB_REPO` | Override GitHub repo name (default: `kandev`) | -| `KANDEV_GITHUB_TOKEN` | Optional token for GitHub API rate limits | -| `KANDEV_NO_UPDATE_PROMPT=1` | Disable the CLI update prompt | -| `KANDEV_SKIP_UPDATE=1` | Internal guard to avoid update loops | -| `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout in milliseconds | +| Variable | Description | +| ------------------------------------- | --------------------------------------------------------------------- | +| `KANDEV_BUNDLE_DIR` | Force runtime bundle location (set by Homebrew wrapper, tests) | +| `KANDEV_PORT` / `KANDEV_BACKEND_PORT` | Backend port | +| `KANDEV_WEB_PORT` | Internal Next.js port | +| `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout (ms) | +| `KANDEV_GITHUB_OWNER` | Override GitHub repo owner (default: `kdlbs`) for `--runtime-version` | +| `KANDEV_GITHUB_REPO` | Override GitHub repo name (default: `kandev`) for `--runtime-version` | +| `KANDEV_GITHUB_TOKEN` | Optional GitHub token for rate limits | ## Supported Platforms -| Platform | Asset Name | -| --------------------- | ------------------------------------------- | -| macOS (Apple Silicon) | `kandev-macos-arm64.zip` | -| macOS (Intel) | `kandev-macos-x64.zip` | -| Linux (x64) | `kandev-linux-x64.zip` | -| Linux (ARM64) | `kandev-linux-arm64.zip` | -| Windows (x64) | `kandev-windows-x64.zip` | -| Windows (ARM64) | Falls back to `windows-x64` (x64 emulation) | +| Platform | npm runtime package | GitHub asset name | +| --------------------- | ------------------------------------------- | --------------------------- | +| macOS (Apple Silicon) | `@kdlbs/runtime-darwin-arm64` | `kandev-macos-arm64.tar.gz` | +| macOS (Intel) | `@kdlbs/runtime-darwin-x64` | `kandev-macos-x64.tar.gz` | +| Linux (x64) | `@kdlbs/runtime-linux-x64` | `kandev-linux-x64.tar.gz` | +| Linux (ARM64) | `@kdlbs/runtime-linux-arm64` | `kandev-linux-arm64.tar.gz` | +| Windows (x64) | `@kdlbs/runtime-win32-x64` | `kandev-windows-x64.tar.gz` | +| Windows (ARM64) | Falls back to `windows-x64` (x64 emulation) | | + +Note: `platform.ts` uses internal naming (`macos`, `windows`); npm `os` field uses npm conventions (`darwin`, `win32`). `runtime.ts` maps between the two. + +## npm requirements + +The optional-dependency runtime resolution requires **npm 7 or newer**. npm 6 silently skips optional deps during `npx`, which would leave users with no runtime. `package.json` declares `engines.npm: ">=7"` to surface this at install time as a clear error. diff --git a/apps/cli/package.json b/apps/cli/package.json index 26284de2ad..1a576be34f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,9 +1,14 @@ { "name": "kandev", - "version": "0.16.0", + "version": "0.41.0", "private": false, - "description": "NPX launcher for Kandev", + "description": "Launcher for Kandev — manage tasks, orchestrate agents, review changes, and ship value", "license": "AGPL-3.0-only", + "repository": { + "type": "git", + "url": "git+https://github.com/kdlbs/kandev.git" + }, + "homepage": "https://github.com/kdlbs/kandev", "type": "commonjs", "bin": { "kandev": "bin/cli.js" @@ -13,12 +18,23 @@ "bin", "dist" ], + "engines": { + "npm": ">=7" + }, + "optionalDependencies": { + "@kdlbs/runtime-linux-x64": "0.0.0-bootstrap", + "@kdlbs/runtime-linux-arm64": "0.0.0-bootstrap", + "@kdlbs/runtime-darwin-x64": "0.0.0-bootstrap", + "@kdlbs/runtime-darwin-arm64": "0.0.0-bootstrap", + "@kdlbs/runtime-win32-x64": "0.0.0-bootstrap" + }, "dependencies": { "tar": "^7.5.11", "tree-kill": "^1.2.2" }, "devDependencies": { "@types/node": "^20", + "esbuild": "^0.24.0", "tsx": "^4.15.7", "typescript": "^5", "vitest": "^1.6.0" @@ -26,6 +42,7 @@ "scripts": { "dev": "unset npm_config_prefix && tsx src/cli.ts", "build": "tsc -p tsconfig.json", + "bundle": "esbuild dist/cli.js --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.js", "start": "node dist/cli.js", "test": "vitest run", "prepublishOnly": "pnpm build" diff --git a/apps/cli/src/args.test.ts b/apps/cli/src/args.test.ts index 1e83ab7ede..c7bbaddac6 100644 --- a/apps/cli/src/args.test.ts +++ b/apps/cli/src/args.test.ts @@ -66,8 +66,29 @@ describe("parseArgs", () => { expect(() => parseArgs(["--port="])).toThrow(/--port value must be an integer/); }); - it("throws ParseError on empty --version=", () => { - expect(() => parseArgs(["--version="])).toThrow(/--version requires a value/); + it("sets showVersion for --version and -V flags", () => { + expect(parseArgs(["--version"]).options.showVersion).toBe(true); + expect(parseArgs(["-V"]).options.showVersion).toBe(true); + }); + + it("parses --runtime-version as runtimeVersion", () => { + expect(parseArgs(["--runtime-version", "v0.16.0"]).options.runtimeVersion).toBe("v0.16.0"); + expect(parseArgs(["--runtime-version=v0.16.0"]).options.runtimeVersion).toBe("v0.16.0"); + }); + + it("throws ParseError on empty --runtime-version=", () => { + expect(() => parseArgs(["--runtime-version="])).toThrow(/--runtime-version requires a value/); + }); + + it("throws ParseError when --runtime-version has no value", () => { + expect(() => parseArgs(["--runtime-version"])).toThrow(ParseError); + expect(() => parseArgs(["--runtime-version"])).toThrow(/--runtime-version requires a value/); + }); + + it("throws ParseError when the next token after --runtime-version is another flag", () => { + expect(() => parseArgs(["--runtime-version", "--debug"])).toThrow( + /--runtime-version requires a value/, + ); }); }); diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 147a0cca45..254f5bc577 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -2,11 +2,12 @@ export type Command = "run" | "dev" | "start"; export type CliOptions = { command: Command; - version?: string; + runtimeVersion?: string; backendPort?: number; webPort?: number; verbose?: boolean; debug?: boolean; + showVersion?: boolean; }; export type ParseResult = { @@ -31,19 +32,23 @@ export function parseArgs(argv: string[]): ParseResult { showHelp = true; continue; } + if (arg === "--version" || arg === "-V") { + opts.showVersion = true; + continue; + } if (arg === "dev" || arg === "run" || arg === "start") { opts.command = arg; continue; } - if (arg === "--version") { - opts.version = takeValue(argv, i, "--version"); + if (arg === "--runtime-version") { + opts.runtimeVersion = takeValue(argv, i, "--runtime-version"); i += 1; continue; } - if (arg.startsWith("--version=")) { - const value = arg.slice("--version=".length); - if (value.length === 0) throw new ParseError("--version requires a value"); - opts.version = value; + if (arg.startsWith("--runtime-version=")) { + const value = arg.slice("--runtime-version=".length); + if (value.length === 0) throw new ParseError("--runtime-version requires a value"); + opts.runtimeVersion = value; continue; } if (arg === "--dev") { diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index b1d02c7dc5..2d3baa25e1 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -7,16 +7,15 @@ import { runDev } from "./dev"; import { runRelease } from "./run"; import { runStart } from "./start"; import { ensureValidPort } from "./ports"; -import { maybePromptForUpdate } from "./update"; function printHelp() { console.log(`kandev launcher Usage: - kandev run [--version ] [--port ] [--verbose] [--debug] + kandev run [--port ] [--verbose] [--debug] kandev dev [--port ] kandev start [--port ] [--verbose] [--debug] - kandev [--version ] [--port ] [--verbose] [--debug] + kandev [--port ] [--verbose] [--debug] kandev --dev [--port ] Examples: @@ -25,16 +24,16 @@ Examples: kandev --dev kandev dev kandev start - kandev --version v0.1.0 + kandev --version kandev --port 3000 kandev --debug Options: dev Use local repo for dev (make dev + next dev) if available. start Use local production build (make build + next start). - run Use release bundles (default). + run Use installed runtime bundle (default). --dev Alias for "dev". - --version Release tag to install (default: latest). + --version, -V Print CLI version and exit. --port Port for the Go backend (the URL kandev opens on in start/run). Alias for --backend-port. Also reads KANDEV_PORT or KANDEV_BACKEND_PORT. @@ -43,11 +42,14 @@ Options: --help, -h Show help. Advanced: - --backend-port Same as --port. - --web-internal-port Override the internal Next.js port. The Go backend - reverse-proxies to it; users hit the backend port. - Also reads KANDEV_WEB_PORT. - --web-port Deprecated alias for --web-internal-port. + --backend-port Same as --port. + --web-internal-port Override the internal Next.js port. The Go backend + reverse-proxies to it; users hit the backend port. + Also reads KANDEV_WEB_PORT. + --web-port Deprecated alias for --web-internal-port. + --runtime-version Download and use a specific release tag instead of + the installed runtime. For debugging only. + Example: kandev --runtime-version v0.16.0 `); } @@ -76,6 +78,12 @@ function findRepoRoot(startDir: string): string | null { async function main(): Promise { const { options, showHelp, deprecatedFlags } = parseArgs(process.argv.slice(2)); + + if (options.showVersion) { + console.log(pkg.version); + return; + } + if (showHelp) { printHelp(); return; @@ -113,9 +121,8 @@ async function main(): Promise { return; } - await maybePromptForUpdate(pkg.version, process.argv.slice(2)); await runRelease({ - version: options.version, + runtimeVersion: options.runtimeVersion, backendPort, webPort, verbose: options.verbose, diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index 82693ab875..a64db0919f 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -17,11 +17,12 @@ import { getBinaryName, getPlatformDir } from "./platform"; import { sortVersionsDesc } from "./version"; import { pickAvailablePort } from "./ports"; import { createProcessSupervisor } from "./process"; +import { resolveRuntime, validateBundle } from "./runtime"; import { attachBackendExitHandler, logStartupInfo } from "./shared"; import { launchWebApp, openBrowser } from "./web"; export type RunOptions = { - version?: string; + runtimeVersion?: string; backendPort?: number; webPort?: number; /** Show info logs from backend + web */ @@ -30,14 +31,13 @@ export type RunOptions = { debug?: boolean; }; -type PreparedRelease = { +type PreparedBundle = { bundleDir: string; backendBin: string; backendUrl: string; backendEnv: NodeJS.ProcessEnv; webEnv: NodeJS.ProcessEnv; releaseTag: string; - requestedVersion?: string; webPort: number; agentctlPort: number; dbPath: string; @@ -109,60 +109,71 @@ export function cleanOldReleases(currentTag: string) { } } -async function prepareReleaseBundle({ - version, +/** + * Download a specific release version into the local cache. + * Only used when --runtime-version is given explicitly. + */ +async function downloadRuntimeVersion(runtimeVersion: string): Promise { + const platformDir = getPlatformDir(); + const release = await getRelease(runtimeVersion); + const tag = release.tag_name; + const assetName = `kandev-${platformDir}.tar.gz`; + const cacheDir = path.join(CACHE_DIR, tag, platformDir); + + const archivePath = await ensureAsset(tag, assetName, cacheDir, (downloaded, total) => { + const percent = total ? Math.round((downloaded / total) * 100) : 0; + const mb = (downloaded / (1024 * 1024)).toFixed(1); + const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?"; + process.stderr.write(`\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`); + }); + process.stderr.write("\n"); + + ensureExtracted(archivePath, cacheDir); + cleanOldReleases(tag); + return tag; +} + +async function prepareBundleForLaunch({ + runtimeVersion, backendPort, webPort, verbose = false, debug = false, -}: RunOptions): Promise { - const platformDir = getPlatformDir(); - let tag: string; - let cacheDir: string; +}: RunOptions): Promise { + let bundleDir: string; + let releaseTag: string; - try { - const release = await getRelease(version); - tag = release.tag_name; - const assetName = `kandev-${platformDir}.tar.gz`; - cacheDir = path.join(CACHE_DIR, tag, platformDir); - - const archivePath = await ensureAsset(tag, assetName, cacheDir, (downloaded, total) => { - const percent = total ? Math.round((downloaded / total) * 100) : 0; - const mb = (downloaded / (1024 * 1024)).toFixed(1); - const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?"; - process.stderr.write(`\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`); - }); - process.stderr.write("\n"); - - ensureExtracted(archivePath, cacheDir); - cleanOldReleases(tag); - } catch (err) { - // GitHub unreachable — try to launch from cache. - const cached = findCachedRelease(platformDir, version); - if (!cached) { - const target = version ? `version ${version}` : "latest version"; - const reason = err instanceof Error ? err.message : String(err); - throw new Error( - `Failed to fetch ${target} and no cached release found.\n` + - ` Reason: ${reason}\n` + - ` Run kandev once while online to cache a release for offline use.`, - ); + if (runtimeVersion) { + // Explicit version: ensure it is in the cache (downloading if needed), then resolve. + const platformDir = getPlatformDir(); + const cached = findCachedRelease(platformDir, runtimeVersion); + let tag: string; + if (cached) { + tag = cached.tag; + bundleDir = findBundleRoot(cached.cacheDir); + } else { + try { + tag = await downloadRuntimeVersion(runtimeVersion); + const cacheDir = path.join(CACHE_DIR, tag, platformDir); + bundleDir = findBundleRoot(cacheDir); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to fetch runtime version ${runtimeVersion}.\n` + + ` Reason: ${reason}\n` + + ` Run kandev once while online to cache a release for offline use.`, + ); + } } - tag = cached.tag; - cacheDir = cached.cacheDir; - process.stderr.write(`[kandev] GitHub unreachable — using cached release ${tag}\n`); - } - - const bundleDir = findBundleRoot(cacheDir); - - const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); - if (!fs.existsSync(backendBin)) { - throw new Error(`Backend binary not found at ${backendBin}`); - } - - const agentctlBin = path.join(bundleDir, "bin", getBinaryName("agentctl")); - if (!fs.existsSync(agentctlBin)) { - throw new Error(`agentctl binary not found at ${agentctlBin}`); + // Validate the resolved bundle has all required binaries before launching. + validateBundle(bundleDir); + releaseTag = tag; + } else { + // Default path: resolve from KANDEV_BUNDLE_DIR or installed npm runtime package. + const resolved = resolveRuntime(); + bundleDir = resolved.bundleDir; + // Use KANDEV_VERSION if set (e.g. by Homebrew wrapper), otherwise show source. + releaseTag = process.env.KANDEV_VERSION ?? `(${resolved.source})`; } const actualBackendPort = backendPort ?? (await pickAvailablePort(DEFAULT_BACKEND_PORT)); @@ -176,10 +187,8 @@ async function prepareReleaseBundle({ fs.mkdirSync(DATA_DIR, { recursive: true }); const dbPath = path.join(DATA_DIR, "kandev.db"); - // Note: Release mode doesn't configure MCP server ports as it uses - // the bundled configuration. Only backend and agentctl ports are set. - // Log level defaults to warn for clean output and can be overridden - // via KANDEV_LOG_LEVEL or --verbose/--debug flags. + const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); + const backendEnv: NodeJS.ProcessEnv = { ...process.env, KANDEV_SERVER_PORT: String(actualBackendPort), @@ -194,8 +203,6 @@ async function prepareReleaseBundle({ ...process.env, KANDEV_API_BASE_URL: backendUrl, PORT: String(actualWebPort), - // Ensure Next.js standalone server binds to 127.0.0.1 so localhost health checks work. - // Without this, HOSTNAME from the host environment can cause binding issues. HOSTNAME: "127.0.0.1", }; (webEnv as Record).NODE_ENV = "production"; @@ -206,8 +213,7 @@ async function prepareReleaseBundle({ backendUrl, backendEnv, webEnv, - releaseTag: tag, - requestedVersion: version, + releaseTag, webPort: actualWebPort, agentctlPort, dbPath, @@ -235,17 +241,14 @@ export function attachRingBuffer( return () => buf; } -function launchReleaseApps(prepared: PreparedRelease): { +function launchBundle(prepared: PreparedBundle): { supervisor: ReturnType; backendProc: ReturnType; webServerPath: string; dumpBackendLogs: () => void; } { - const releaseSource = prepared.requestedVersion - ? `(requested: ${prepared.requestedVersion})` - : "(github latest)"; logStartupInfo({ - header: `release: ${prepared.releaseTag} ${releaseSource}`, + header: `release: ${prepared.releaseTag}`, ports: { backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT), webPort: prepared.webPort, @@ -259,10 +262,6 @@ function launchReleaseApps(prepared: PreparedRelease): { const supervisor = createProcessSupervisor(); supervisor.attachSignalHandlers(); - // Start backend: ignore stdin, always show stderr immediately. - // In verbose/debug mode stream stdout live; otherwise capture it into a ring - // buffer so we can dump the last few KB if the healthcheck fails (users were - // previously seeing opaque "timed out" errors with no backend context). const backendProc = spawn(prepared.backendBin, [], { cwd: path.dirname(prepared.backendBin), env: prepared.backendEnv, @@ -293,14 +292,20 @@ function launchReleaseApps(prepared: PreparedRelease): { } export async function runRelease({ - version, + runtimeVersion, backendPort, webPort, verbose = false, debug = false, }: RunOptions): Promise { - const prepared = await prepareReleaseBundle({ version, backendPort, webPort, verbose, debug }); - const { supervisor, backendProc, webServerPath, dumpBackendLogs } = launchReleaseApps(prepared); + const prepared = await prepareBundleForLaunch({ + runtimeVersion, + backendPort, + webPort, + verbose, + debug, + }); + const { supervisor, backendProc, webServerPath, dumpBackendLogs } = launchBundle(prepared); const healthTimeoutMs = resolveHealthTimeoutMs(HEALTH_TIMEOUT_MS_RELEASE); console.log("[kandev] starting backend..."); await waitForHealth(prepared.backendUrl, backendProc, healthTimeoutMs, dumpBackendLogs); diff --git a/apps/cli/src/runtime.test.ts b/apps/cli/src/runtime.test.ts new file mode 100644 index 0000000000..c0212277f8 --- /dev/null +++ b/apps/cli/src/runtime.test.ts @@ -0,0 +1,152 @@ +import fs from "node:fs"; +import Module from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { resolveRuntime, validateBundle } from "./runtime"; + +// Keep getPlatformDir deterministic across test environments. +vi.mock("./platform", () => ({ + getPlatformDir: () => "macos-arm64" as const, + getBinaryName: (base: string) => base, +})); + +function createFakeBundle(dir: string) { + fs.mkdirSync(path.join(dir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(dir, "bin", "kandev"), "fake"); + fs.writeFileSync(path.join(dir, "bin", "agentctl"), "fake"); + fs.mkdirSync(path.join(dir, "web"), { recursive: true }); + fs.writeFileSync(path.join(dir, "web", "server.js"), "fake"); +} + +describe("validateBundle", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-validate-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("passes for a complete bundle", () => { + createFakeBundle(tmpDir); + expect(() => validateBundle(tmpDir)).not.toThrow(); + }); + + it("throws when backend binary is missing", () => { + fs.mkdirSync(path.join(tmpDir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "bin", "agentctl"), "fake"); + fs.mkdirSync(path.join(tmpDir, "web"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "web", "server.js"), "fake"); + expect(() => validateBundle(tmpDir)).toThrow(/Backend binary not found/); + }); + + it("throws when agentctl is missing", () => { + fs.mkdirSync(path.join(tmpDir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "bin", "kandev"), "fake"); + fs.mkdirSync(path.join(tmpDir, "web"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "web", "server.js"), "fake"); + expect(() => validateBundle(tmpDir)).toThrow(/agentctl binary not found/); + }); + + it("throws when web server.js is missing", () => { + fs.mkdirSync(path.join(tmpDir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "bin", "kandev"), "fake"); + fs.writeFileSync(path.join(tmpDir, "bin", "agentctl"), "fake"); + expect(() => validateBundle(tmpDir)).toThrow(/Web server.*not found/); + }); +}); + +describe("resolveRuntime", () => { + let tmpDir: string; + const origEnv = { ...process.env }; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-runtime-")); + // Clear relevant env vars before each test + delete process.env.KANDEV_BUNDLE_DIR; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + // Restore the original env exactly. Don't `delete process.env.KANDEV_BUNDLE_DIR` + // afterwards — if a developer had that env var set when the suite started, + // it's already in origEnv and should remain. + process.env = { ...origEnv }; + }); + + describe("KANDEV_BUNDLE_DIR (step 1)", () => { + it("resolves from KANDEV_BUNDLE_DIR when set and valid", () => { + const bundleDir = path.join(tmpDir, "bundle"); + createFakeBundle(bundleDir); + process.env.KANDEV_BUNDLE_DIR = bundleDir; + + const result = resolveRuntime(); + expect(result.source).toBe("env"); + expect(result.bundleDir).toBe(bundleDir); + }); + + it("throws when KANDEV_BUNDLE_DIR points to an invalid bundle", () => { + const bundleDir = path.join(tmpDir, "empty-bundle"); + fs.mkdirSync(bundleDir); + process.env.KANDEV_BUNDLE_DIR = bundleDir; + + expect(() => resolveRuntime()).toThrow(/Backend binary not found/); + }); + }); + + describe("installed npm runtime package (step 2)", () => { + // Step 2 normally relies on require.resolve('@kdlbs/runtime-darwin-arm64') + // succeeding because the npm package is installed alongside the kandev + // package. In this test environment that package isn't present, so we + // intercept Node's module resolver to point at a fake bundle inside tmpDir. + function mockResolve(target: string): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ModuleAny = Module as unknown as { _resolveFilename: any }; + vi.spyOn(ModuleAny, "_resolveFilename").mockImplementationOnce(() => target); + } + + it("resolves from the installed npm runtime package", () => { + const pkgRoot = path.join(tmpDir, "npm-runtime"); + createFakeBundle(pkgRoot); + // require.resolve('/package.json') normally returns the path to the + // package.json inside node_modules. Point it at our fake bundle instead. + const pkgJsonPath = path.join(pkgRoot, "package.json"); + fs.writeFileSync(pkgJsonPath, "{}"); + mockResolve(pkgJsonPath); + + const result = resolveRuntime(); + expect(result.source).toBe("npm"); + expect(result.bundleDir).toBe(pkgRoot); + }); + + it("surfaces validateBundle errors when the npm package is installed but corrupt", () => { + const pkgRoot = path.join(tmpDir, "broken-npm-runtime"); + fs.mkdirSync(pkgRoot); + // package.json present (so require.resolve succeeds), but no bin/ or web/ + const pkgJsonPath = path.join(pkgRoot, "package.json"); + fs.writeFileSync(pkgJsonPath, "{}"); + mockResolve(pkgJsonPath); + + expect(() => resolveRuntime()).toThrow(/Backend binary not found/); + }); + }); + + describe("no runtime found", () => { + it("throws an actionable error message mentioning install paths", () => { + let error: Error | null = null; + try { + resolveRuntime(); + } catch (e) { + error = e as Error; + } + expect(error).not.toBeNull(); + expect(error!.message).toMatch(/npx kandev@latest/); + expect(error!.message).toMatch(/brew install/); + expect(error!.message).toMatch(/--runtime-version/); + }); + }); +}); diff --git a/apps/cli/src/runtime.ts b/apps/cli/src/runtime.ts new file mode 100644 index 0000000000..11fbaf6e2a --- /dev/null +++ b/apps/cli/src/runtime.ts @@ -0,0 +1,83 @@ +import path from "node:path"; +import fs from "node:fs"; + +import { resolveWebServerPath } from "./bundle"; +import { getBinaryName, getPlatformDir, type PlatformDir } from "./platform"; + +const PLATFORM_TO_NPM_PACKAGE: Record = { + "linux-x64": "@kdlbs/runtime-linux-x64", + "linux-arm64": "@kdlbs/runtime-linux-arm64", + "macos-x64": "@kdlbs/runtime-darwin-x64", + "macos-arm64": "@kdlbs/runtime-darwin-arm64", + "windows-x64": "@kdlbs/runtime-win32-x64", +}; + +export type RuntimeSource = "env" | "npm"; + +export type ResolvedRuntime = { + bundleDir: string; + source: RuntimeSource; +}; + +/** + * Resolve the runtime bundle directory using a two-step priority chain: + * + * 1. KANDEV_BUNDLE_DIR env var — set by the Homebrew formula wrapper and + * useful for local testing. Skips all other resolution. + * 2. Installed npm runtime package — looks for @kdlbs/runtime-{platform} + * in node_modules via Node module resolution. Works after + * `npx kandev@latest` or `npm install -g kandev` (requires npm 7+). + * + * The explicit `--runtime-version ` download path is handled directly + * in run.ts (which manages the GitHub download + cache itself); it does + * not flow through this function. + * + * Throws with an actionable error message if no runtime is found. + */ +export function resolveRuntime(): ResolvedRuntime { + const envBundleDir = process.env.KANDEV_BUNDLE_DIR; + if (envBundleDir) { + validateBundle(envBundleDir); + return { bundleDir: envBundleDir, source: "env" }; + } + + const platformDir = getPlatformDir(); + const packageName = PLATFORM_TO_NPM_PACKAGE[platformDir]; + let pkgJsonPath: string | null = null; + try { + pkgJsonPath = require.resolve(`${packageName}/package.json`); + } catch { + // MODULE_NOT_FOUND — npm runtime package is not installed. Fall through + // to the actionable error below. + } + if (pkgJsonPath) { + // The package IS installed. If validateBundle throws here, the bundle is + // present but corrupt — surface the error rather than the generic + // "no runtime found" message below. + const packageRoot = path.dirname(pkgJsonPath); + validateBundle(packageRoot); + return { bundleDir: packageRoot, source: "npm" }; + } + + throw new Error( + `No Kandev runtime found for ${platformDir}.\n` + + ` Install via npm (requires npm 7+): npx kandev@latest\n` + + ` Install via Homebrew: brew install kdlbs/kandev/kandev\n` + + ` Download a specific version (debug): kandev --runtime-version `, + ); +} + +export function validateBundle(bundleDir: string): void { + const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); + if (!fs.existsSync(backendBin)) { + throw new Error(`Backend binary not found in bundle at ${bundleDir}`); + } + const agentctlBin = path.join(bundleDir, "bin", getBinaryName("agentctl")); + if (!fs.existsSync(agentctlBin)) { + throw new Error(`agentctl binary not found in bundle at ${bundleDir}`); + } + const webServerPath = resolveWebServerPath(bundleDir); + if (!webServerPath) { + throw new Error(`Web server (server.js) not found in bundle at ${bundleDir}`); + } +} diff --git a/apps/cli/src/update.ts b/apps/cli/src/update.ts deleted file mode 100644 index 749d7a999f..0000000000 --- a/apps/cli/src/update.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { spawn } from "node:child_process"; -import https from "node:https"; -import readline from "node:readline"; - -import { compareVersions } from "./version"; - -function requestJson(url: string): Promise { - return new Promise((resolve, reject) => { - const req = https.get(url, { headers: { "User-Agent": "kandev-npx" } }, (res) => { - if (res.statusCode !== 200) { - return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`)); - } - let body = ""; - res.on("data", (chunk) => (body += chunk)); - res.on("end", () => { - try { - resolve(JSON.parse(body) as T); - } catch { - reject(new Error(`Failed to parse JSON from ${url}`)); - } - }); - }); - req.setTimeout(5000, () => { - req.destroy(new Error(`Request timed out fetching ${url}`)); - }); - req.on("error", reject); - }); -} - -async function getLatestNpmVersion(): Promise { - const data = await requestJson<{ "dist-tags"?: { latest?: string } }>( - "https://registry.npmjs.org/kandev", - ); - return data?.["dist-tags"]?.latest; -} - -function promptYesNo(question: string, defaultYes = false): Promise { - return new Promise((resolve) => { - if (!process.stdin.isTTY) { - resolve(false); - return; - } - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - const suffix = defaultYes ? "[Y/n]" : "[y/N]"; - rl.question(`${question} ${suffix} `, (answer: string) => { - rl.close(); - const normalized = String(answer || "") - .trim() - .toLowerCase(); - if (!normalized) { - resolve(Boolean(defaultYes)); - return; - } - resolve(normalized === "y" || normalized === "yes"); - }); - }); -} - -export async function maybePromptForUpdate(currentVersion: string, args: string[]) { - // Allow disabling update checks for CI or automation. - if (process.env.KANDEV_SKIP_UPDATE === "1" || process.env.KANDEV_NO_UPDATE_PROMPT === "1") { - return; - } - try { - const latest = await getLatestNpmVersion(); - if (!latest) return; - if (compareVersions(latest, currentVersion) <= 0) return; - const wantsUpdate = await promptYesNo( - `Update available: ${currentVersion} -> ${latest}. Update now?`, - false, - ); - if (!wantsUpdate) return; - const env = { ...process.env, KANDEV_SKIP_UPDATE: "1" }; - const child = spawn("npx", ["kandev@latest", ...args], { - stdio: "inherit", - env, - }); - child.on("exit", (code) => process.exit(code || 0)); - await new Promise(() => {}); - } catch { - // ignore update errors - } -} diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 7e200e42a7..fd80492793 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -32,7 +32,7 @@ importers: dependencies: tar: specifier: ^7.5.11 - version: 7.5.11 + version: 7.5.13 tree-kill: specifier: ^1.2.2 version: 1.2.2 @@ -40,6 +40,9 @@ importers: '@types/node': specifier: ^20 version: 20.19.28 + esbuild: + specifier: ^0.24.0 + version: 0.24.2 tsx: specifier: ^4.15.7 version: 4.21.0 @@ -49,6 +52,22 @@ importers: vitest: specifier: ^1.6.0 version: 1.6.1(@types/node@20.19.28)(happy-dom@20.8.9)(lightningcss@1.30.2) + optionalDependencies: + '@kdlbs/runtime-darwin-arm64': + specifier: 0.0.0-bootstrap + version: 0.0.0-bootstrap + '@kdlbs/runtime-darwin-x64': + specifier: 0.0.0-bootstrap + version: 0.0.0-bootstrap + '@kdlbs/runtime-linux-arm64': + specifier: 0.0.0-bootstrap + version: 0.0.0-bootstrap + '@kdlbs/runtime-linux-x64': + specifier: 0.0.0-bootstrap + version: 0.0.0-bootstrap + '@kdlbs/runtime-win32-x64': + specifier: 0.0.0-bootstrap + version: 0.0.0-bootstrap packages/theme: dependencies: @@ -400,7 +419,7 @@ importers: version: 0.55.1 next: specifier: 16.1.7 - version: 16.1.7(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.7(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -454,7 +473,7 @@ importers: version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) styled-jsx: specifier: 5.1.6 - version: 5.1.6(@babel/core@7.28.6)(react@19.2.3) + version: 5.1.6(react@19.2.3) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -890,6 +909,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.24.2': + resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -902,6 +927,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.24.2': + resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.2': resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} @@ -914,6 +945,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.24.2': + resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.2': resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} @@ -926,6 +963,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.24.2': + resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.2': resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} @@ -938,6 +981,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.24.2': + resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.2': resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} @@ -950,6 +999,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.24.2': + resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.2': resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} @@ -962,6 +1017,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.24.2': + resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.2': resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} @@ -974,6 +1035,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.24.2': + resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.2': resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} @@ -986,6 +1053,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.24.2': + resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.2': resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} @@ -998,6 +1071,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.24.2': + resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.2': resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} @@ -1010,6 +1089,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.24.2': + resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.2': resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} @@ -1022,6 +1107,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.24.2': + resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.2': resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} @@ -1034,6 +1125,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.24.2': + resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.2': resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} engines: {node: '>=18'} @@ -1046,6 +1143,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.24.2': + resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.2': resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} engines: {node: '>=18'} @@ -1058,6 +1161,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.24.2': + resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.2': resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} engines: {node: '>=18'} @@ -1070,6 +1179,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.24.2': + resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.2': resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} engines: {node: '>=18'} @@ -1082,12 +1197,24 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.24.2': + resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.2': resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.24.2': + resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.2': resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} engines: {node: '>=18'} @@ -1100,12 +1227,24 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.24.2': + resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.2': resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.24.2': + resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.2': resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} engines: {node: '>=18'} @@ -1118,6 +1257,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.24.2': + resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.2': resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} @@ -1136,6 +1281,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.24.2': + resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.2': resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} @@ -1148,6 +1299,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.24.2': + resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.2': resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} @@ -1160,6 +1317,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.24.2': + resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.2': resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} @@ -1172,6 +1335,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.24.2': + resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.2': resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} @@ -1483,6 +1652,31 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kdlbs/runtime-darwin-arm64@0.0.0-bootstrap': + resolution: {integrity: sha512-i3H7KOcw5m1KEhXhTSa9+jNBlAh0Vc8NVkiGL9BpKB5n/COrhBol7YcN1WNkxs/6WdpD9LFo2My+51wODrC9tQ==} + cpu: [arm64] + os: [darwin] + + '@kdlbs/runtime-darwin-x64@0.0.0-bootstrap': + resolution: {integrity: sha512-ia8qU58B1pWr2AM9/qsPP514L6SftSYYmbFG49kcWNZMpRhosdbKNm1GfDce4yE+MKeOpmLmjPu/pR7ZnyMJpg==} + cpu: [x64] + os: [darwin] + + '@kdlbs/runtime-linux-arm64@0.0.0-bootstrap': + resolution: {integrity: sha512-m3UUB0GYOKNVwgW8wwdsjV6dFFmTYxI9vmLMtvgWz01GW1y+Ms5YSRhb0RT5NRMvYpn7xDntlJCjtPATE+RZ7A==} + cpu: [arm64] + os: [linux] + + '@kdlbs/runtime-linux-x64@0.0.0-bootstrap': + resolution: {integrity: sha512-XpcVCGngqq9wsZR2OASQwCiylTFUVD7iDoqeeWUuzZZ1wRxdGqYNV31xrEciq+96O/pI+dn5zWgbMYQEXc309w==} + cpu: [x64] + os: [linux] + + '@kdlbs/runtime-win32-x64@0.0.0-bootstrap': + resolution: {integrity: sha512-x/KRLCugV2eOLCJQWTUmBGU08rz7ep83GdTNZLeyQ3UmOKErVARk5eC9lWNO8LnlQr8LbahK9KdRW6Jhieee2A==} + cpu: [x64] + os: [win32] + '@lezer/common@1.5.0': resolution: {integrity: sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==} @@ -4219,6 +4413,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.24.2: + resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -6411,8 +6610,8 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - tar@7.5.11: - resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} + tar@7.5.13: + resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} engines: {node: '>=18'} tiny-invariant@1.3.3: @@ -7474,120 +7673,183 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.24.2': + optional: true + '@esbuild/aix-ppc64@0.27.2': optional: true '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.24.2': + optional: true + '@esbuild/android-arm64@0.27.2': optional: true '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.24.2': + optional: true + '@esbuild/android-arm@0.27.2': optional: true '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.24.2': + optional: true + '@esbuild/android-x64@0.27.2': optional: true '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.24.2': + optional: true + '@esbuild/darwin-arm64@0.27.2': optional: true '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.24.2': + optional: true + '@esbuild/darwin-x64@0.27.2': optional: true '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.24.2': + optional: true + '@esbuild/freebsd-arm64@0.27.2': optional: true '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.24.2': + optional: true + '@esbuild/freebsd-x64@0.27.2': optional: true '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.24.2': + optional: true + '@esbuild/linux-arm64@0.27.2': optional: true '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.24.2': + optional: true + '@esbuild/linux-arm@0.27.2': optional: true '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.24.2': + optional: true + '@esbuild/linux-ia32@0.27.2': optional: true '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.24.2': + optional: true + '@esbuild/linux-loong64@0.27.2': optional: true '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.24.2': + optional: true + '@esbuild/linux-mips64el@0.27.2': optional: true '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.24.2': + optional: true + '@esbuild/linux-ppc64@0.27.2': optional: true '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.24.2': + optional: true + '@esbuild/linux-riscv64@0.27.2': optional: true '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.24.2': + optional: true + '@esbuild/linux-s390x@0.27.2': optional: true '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.24.2': + optional: true + '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.24.2': + optional: true + '@esbuild/netbsd-arm64@0.27.2': optional: true '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.24.2': + optional: true + '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.24.2': + optional: true + '@esbuild/openbsd-arm64@0.27.2': optional: true '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.24.2': + optional: true + '@esbuild/openbsd-x64@0.27.2': optional: true @@ -7597,24 +7859,36 @@ snapshots: '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.24.2': + optional: true + '@esbuild/sunos-x64@0.27.2': optional: true '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.24.2': + optional: true + '@esbuild/win32-arm64@0.27.2': optional: true '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.24.2': + optional: true + '@esbuild/win32-ia32@0.27.2': optional: true '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.24.2': + optional: true + '@esbuild/win32-x64@0.27.2': optional: true @@ -7866,6 +8140,21 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kdlbs/runtime-darwin-arm64@0.0.0-bootstrap': + optional: true + + '@kdlbs/runtime-darwin-x64@0.0.0-bootstrap': + optional: true + + '@kdlbs/runtime-linux-arm64@0.0.0-bootstrap': + optional: true + + '@kdlbs/runtime-linux-x64@0.0.0-bootstrap': + optional: true + + '@kdlbs/runtime-win32-x64@0.0.0-bootstrap': + optional: true + '@lezer/common@1.5.0': {} '@lezer/common@1.5.1': {} @@ -10724,6 +11013,34 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.24.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.24.2 + '@esbuild/android-arm': 0.24.2 + '@esbuild/android-arm64': 0.24.2 + '@esbuild/android-x64': 0.24.2 + '@esbuild/darwin-arm64': 0.24.2 + '@esbuild/darwin-x64': 0.24.2 + '@esbuild/freebsd-arm64': 0.24.2 + '@esbuild/freebsd-x64': 0.24.2 + '@esbuild/linux-arm': 0.24.2 + '@esbuild/linux-arm64': 0.24.2 + '@esbuild/linux-ia32': 0.24.2 + '@esbuild/linux-loong64': 0.24.2 + '@esbuild/linux-mips64el': 0.24.2 + '@esbuild/linux-ppc64': 0.24.2 + '@esbuild/linux-riscv64': 0.24.2 + '@esbuild/linux-s390x': 0.24.2 + '@esbuild/linux-x64': 0.24.2 + '@esbuild/netbsd-arm64': 0.24.2 + '@esbuild/netbsd-x64': 0.24.2 + '@esbuild/openbsd-arm64': 0.24.2 + '@esbuild/openbsd-x64': 0.24.2 + '@esbuild/sunos-x64': 0.24.2 + '@esbuild/win32-arm64': 0.24.2 + '@esbuild/win32-ia32': 0.24.2 + '@esbuild/win32-x64': 0.24.2 + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -10766,8 +11083,8 @@ snapshots: '@next/eslint-plugin-next': 16.1.1 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -10789,7 +11106,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -10800,22 +11117,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10826,7 +11143,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -12382,7 +12699,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - next@16.1.7(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.7(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.7 '@swc/helpers': 0.5.15 @@ -12391,7 +12708,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3) + styled-jsx: 5.1.6(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.1.7 '@next/swc-darwin-x64': 16.1.7 @@ -13534,12 +13851,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.3): + styled-jsx@5.1.6(react@19.2.3): dependencies: client-only: 0.0.1 react: 19.2.3 - optionalDependencies: - '@babel/core': 7.28.6 stylis@4.3.6: {} @@ -13561,7 +13876,7 @@ snapshots: tapable@2.3.0: {} - tar@7.5.11: + tar@7.5.13: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index eab6748863..56bbdcb749 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -12,6 +12,7 @@ import { import { listWorkspaceTaskPRs } from "@/lib/api/domains/github-api"; import { snapshotToState } from "@/lib/ssr/mapper"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow"; import type { AppState } from "@/lib/state/store"; import type { ListWorkspacesResponse, UserSettingsResponse } from "@/lib/types/http"; @@ -158,13 +159,13 @@ export default async function Page({ searchParams }: PageProps) { listQuickChatSessions(activeWorkspaceId, { cache: "no-store" }).catch(() => ({ tasks: [] })), ]); - // Active workflow defaults to the first non-hidden workflow when no preference is set, - // so hidden workflows (e.g., improve-kandev) never auto-select on load. - const workflowId = resolveActiveId( - workflowList.workflows.filter((w) => !w.hidden), - workflowIdParam, + // null preserves the user's "All Workflows" choice when more than one + // workflow is visible — only auto-pick when there's exactly one. + const workflowId = resolveDesiredWorkflowId({ + activeWorkflowId: workflowIdParam ?? null, settingsWorkflowId, - ); + workspaceWorkflows: workflowList.workflows, + }); // Map quick chat tasks to sessions const quickChatSessions = quickChatResponse.tasks diff --git a/apps/web/app/settings/integrations/page.tsx b/apps/web/app/settings/integrations/page.tsx index 7b705ae57d..c6b3c121fc 100644 --- a/apps/web/app/settings/integrations/page.tsx +++ b/apps/web/app/settings/integrations/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { IconBrandGithub, IconHexagon, IconTicket } from "@tabler/icons-react"; +import { IconBrandGithub, IconBrandSlack, IconHexagon, IconTicket } from "@tabler/icons-react"; import { Card, CardContent } from "@kandev/ui/card"; const INTEGRATIONS = [ @@ -21,6 +21,12 @@ const INTEGRATIONS = [ description: "Personal API key and team defaults.", Icon: IconHexagon, }, + { + href: "/settings/integrations/slack", + label: "Slack", + description: "Browser-session credentials and !kandev triage agent.", + Icon: IconBrandSlack, + }, ]; export default function IntegrationsIndexPage() { diff --git a/apps/web/app/settings/integrations/slack/page.tsx b/apps/web/app/settings/integrations/slack/page.tsx new file mode 100644 index 0000000000..b4f8a43924 --- /dev/null +++ b/apps/web/app/settings/integrations/slack/page.tsx @@ -0,0 +1,5 @@ +import { SlackIntegrationPage } from "@/components/slack/slack-settings"; + +export default function IntegrationsSlackPage() { + return ; +} diff --git a/apps/web/app/stats/stats-sections.tsx b/apps/web/app/stats/stats-sections.tsx index df56b6a7c0..4bf31a2b4d 100644 --- a/apps/web/app/stats/stats-sections.tsx +++ b/apps/web/app/stats/stats-sections.tsx @@ -223,7 +223,7 @@ export function WorkloadSection({ task_stats }: WorkloadSectionProps) { Longest Tasks -

Ranked by elapsed span

+

Ranked by active duration

Quickest Tasks -

Ranked by elapsed span

+

Ranked by active duration

t.active_duration_ms > 0 || t.elapsed_span_ms > 0); - const sorted = + const filtered = [...tasks].filter((t) => t.active_duration_ms > 0); + filtered.sort((a, b) => sortDirection === "desc" - ? filtered.sort((a, b) => b.elapsed_span_ms - a.elapsed_span_ms) - : filtered.sort((a, b) => a.elapsed_span_ms - b.elapsed_span_ms); - const top3 = sorted.slice(0, 3); + ? b.active_duration_ms - a.active_duration_ms + : a.active_duration_ms - b.active_duration_ms, + ); + const top3 = filtered.slice(0, 3); return (
diff --git a/apps/web/components/command-panel-footer.tsx b/apps/web/components/command-panel-footer.tsx index ca7b002340..da32d0a9f4 100644 --- a/apps/web/components/command-panel-footer.tsx +++ b/apps/web/components/command-panel-footer.tsx @@ -1,5 +1,6 @@ "use client"; +import type { Dispatch, SetStateAction } from "react"; import { formatDistanceToNow } from "date-fns"; import { IconArchive, IconArrowRight, IconHammer, IconLoader2 } from "@tabler/icons-react"; import { @@ -22,8 +23,8 @@ import type { Task } from "@/lib/types/http"; import { FileIcon } from "@/components/ui/file-icon"; const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]); -const MODE_COMMANDS: CommandPanelMode = "commands"; -const MODE_SEARCH_FILES: CommandPanelMode = "search-files"; +export const MODE_COMMANDS: CommandPanelMode = "commands"; +export const MODE_SEARCH_FILES: CommandPanelMode = "search-files"; const STEP_COLOR_MAP: Record = { "bg-slate-500": "#64748b", @@ -41,6 +42,18 @@ function getFileName(filePath: string) { return filePath.split("/").pop() ?? filePath; } +function getCommandValue(cmd: CommandItemType) { + return cmd.id + " " + cmd.label + " " + (cmd.keywords?.join(" ") ?? ""); +} + +export function getTaskResultValue(task: Task) { + return `__task:${task.id} ${task.title}`; +} + +export function getFileResultValue(filePath: string) { + return `__file:${filePath}`; +} + function CommandItemRow({ cmd, onSelect, @@ -49,11 +62,7 @@ function CommandItemRow({ onSelect: (cmd: CommandItemType) => void; }) { return ( - onSelect(cmd)} - > + onSelect(cmd)}> {cmd.icon && {cmd.icon}} {cmd.label} {cmd.shortcut && {formatShortcut(cmd.shortcut)}} @@ -90,7 +99,7 @@ function TaskResultItem({ task, stepMap, repoMap, onSelect }: TaskResultItemProp return ( onSelect(task)} className={isArchived ? "opacity-60" : ""} forceMount @@ -213,7 +222,7 @@ function FileSearchContent({ files, isSearching, search, onSelect }: FileSearchC return ( onSelect(filePath)} forceMount > @@ -288,7 +297,7 @@ export type CommandPanelViewProps = { mode: CommandPanelMode; inputCommand: CommandItemType | null; selectedValue: string; - setSelectedValue: (value: string) => void; + setSelectedValue: Dispatch>; search: string; setSearch: (value: string) => void; handleKeyDown: (e: React.KeyboardEvent) => void; diff --git a/apps/web/components/command-panel.tsx b/apps/web/components/command-panel.tsx index 0cfe0952d3..668aae3621 100644 --- a/apps/web/components/command-panel.tsx +++ b/apps/web/components/command-panel.tsx @@ -15,14 +15,20 @@ import type { Task } from "@/lib/types/http"; import { getWebSocketClient } from "@/lib/ws/connection"; import { searchWorkspaceFiles } from "@/lib/ws/workspace-files"; import { useDockviewStore } from "@/lib/state/dockview-store"; -import { CommandPanelView } from "@/components/command-panel-footer"; +import { + CommandPanelView, + MODE_COMMANDS, + MODE_SEARCH_FILES, + getFileResultValue, + getTaskResultValue, +} from "@/components/command-panel-footer"; function getFileName(filePath: string) { return filePath.split("/").pop() ?? filePath; } function useCommandPanelState() { - const [mode, setMode] = useState("commands"); + const [mode, setMode] = useState(MODE_COMMANDS); const [search, setSearch] = useState(""); const [inputCommand, setInputCommand] = useState(null); const [taskResults, setTaskResults] = useState([]); @@ -63,22 +69,14 @@ type FileSearchEffectOptions = { activeSessionId: string | null; setFileResults: (files: string[]) => void; setIsSearchingFiles: (searching: boolean) => void; - setSelectedValue: (value: string) => void; fileDebounceRef: React.RefObject | null>; }; function useFileSearchEffect(opts: FileSearchEffectOptions) { - const { - mode, - search, - activeSessionId, - setFileResults, - setIsSearchingFiles, - setSelectedValue, - fileDebounceRef, - } = opts; + const { mode, search, activeSessionId, setFileResults, setIsSearchingFiles, fileDebounceRef } = + opts; useEffect(() => { - if (mode !== "search-files" || !search.trim() || !activeSessionId) { + if (mode !== MODE_SEARCH_FILES || !search.trim() || !activeSessionId) { setFileResults([]); setIsSearchingFiles(false); return; @@ -97,7 +95,6 @@ function useFileSearchEffect(opts: FileSearchEffectOptions) { if (!cancelled) { const files = res.files ?? []; setFileResults(files); - if (files.length > 0) setSelectedValue(`__file:${files[0]}`); } } catch { if (!cancelled) setFileResults([]); @@ -109,15 +106,7 @@ function useFileSearchEffect(opts: FileSearchEffectOptions) { cancelled = true; if (fileDebounceRef.current) clearTimeout(fileDebounceRef.current); }; - }, [ - activeSessionId, - fileDebounceRef, - mode, - search, - setFileResults, - setIsSearchingFiles, - setSelectedValue, - ]); + }, [activeSessionId, fileDebounceRef, mode, search, setFileResults, setIsSearchingFiles]); } const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]); @@ -154,7 +143,7 @@ function useInlineTaskSearchEffect(opts: InlineTaskSearchOptions) { const { visibleStepIds, stepPositionMap } = useStepMaps(steps); useEffect(() => { - if (mode !== "commands") return; + if (mode !== MODE_COMMANDS) return; if (debounceRef.current) clearTimeout(debounceRef.current); abortRef.current?.abort(); @@ -275,7 +264,7 @@ function useCommandPanelEffects( useEffect(() => { if (!open) { const t = setTimeout(() => { - setMode("commands"); + setMode(MODE_COMMANDS); setSearch(""); setInputCommand(null); setTaskResults([]); @@ -302,11 +291,37 @@ function useCommandPanelEffects( activeSessionId, setFileResults, setIsSearchingFiles, - setSelectedValue, fileDebounceRef, }); } +function useFirstResultSelection(open: boolean, state: ReturnType) { + const { mode, search, taskResults, fileResults, setSelectedValue } = state; + + // `search` intentionally re-applies the first result while debounced results are loading. + useEffect(() => { + if (!open) return; + + if (mode === MODE_COMMANDS) { + const firstTask = taskResults[0]; + if (firstTask) { + setSelectedValue(getTaskResultValue(firstTask)); + return; + } + setSelectedValue((current) => (current.startsWith("__task:") ? "" : current)); + return; + } + + if (mode === MODE_SEARCH_FILES) { + const firstFile = fileResults[0]; + setSelectedValue(firstFile ? getFileResultValue(firstFile) : ""); + return; + } + + setSelectedValue(""); + }, [fileResults, mode, open, search, setSelectedValue, taskResults]); +} + function useCommandPanelHandlers( state: ReturnType, setOpen: (open: boolean) => void, @@ -382,9 +397,9 @@ function useCommandPanelHandlers( inputCommand.onInputSubmit(search.trim()); return; } - if (mode !== "commands" && e.key === "Backspace" && !search) { + if (mode !== MODE_COMMANDS && e.key === "Backspace" && !search) { e.preventDefault(); - setMode("commands"); + setMode(MODE_COMMANDS); setSearch(""); setInputCommand(null); } @@ -393,7 +408,7 @@ function useCommandPanelHandlers( ); const goBack = useCallback(() => { - setMode("commands"); + setMode(MODE_COMMANDS); setSearch(""); setInputCommand(null); }, [setMode, setSearch, setInputCommand]); @@ -434,6 +449,7 @@ export function CommandPanel() { } = state; useCommandPanelEffects(open, state, workspaceId, activeSessionId, kanbanSteps); + useFirstResultSelection(open, state); const openRef = useRef(open); useEffect(() => { @@ -443,10 +459,10 @@ export function CommandPanel() { const toggleCommands = useCallback(() => setOpen(!openRef.current), [setOpen]); const openFileSearch = useCallback(() => { - if (openRef.current && state.mode === "search-files") { + if (openRef.current && state.mode === MODE_SEARCH_FILES) { setOpen(false); } else { - state.setMode("search-files"); + state.setMode(MODE_SEARCH_FILES); state.setSearch(""); setOpen(true); } diff --git a/apps/web/components/github/github-rate-limit.tsx b/apps/web/components/github/github-rate-limit.tsx new file mode 100644 index 0000000000..bed95b7677 --- /dev/null +++ b/apps/web/components/github/github-rate-limit.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { IconAlertTriangle } from "@tabler/icons-react"; +import { Alert, AlertDescription } from "@kandev/ui/alert"; +import type { GitHubRateLimitInfo, GitHubRateLimitSnapshot } from "@/lib/types/github"; + +const RESOURCE_LABELS: Record = { + core: "REST", + graphql: "GraphQL", + search: "Search", +}; + +// useTickNow re-renders every intervalMs and exposes a stable now-value so the +// render pass stays pure (Date.now() in the render body would be impure). +function useTickNow(intervalMs: number): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); + return now; +} + +function isExhausted(snap: GitHubRateLimitSnapshot, now: number): boolean { + if (snap.remaining > 0) return false; + const reset = new Date(snap.reset_at).getTime(); + return Number.isFinite(reset) && reset > now; +} + +function formatReset(snap: GitHubRateLimitSnapshot): string { + const reset = new Date(snap.reset_at); + if (!Number.isFinite(reset.getTime())) return ""; + return formatDistanceToNow(reset, { addSuffix: true }); +} + +// latestReset returns the snapshot whose reset_at is furthest in the future. +// When multiple buckets are exhausted, background checks remain paused until +// the last one recovers, so the alert must anchor on that bucket — anchoring +// on snapshots[0] would understate the pause window. +function latestReset(snaps: GitHubRateLimitSnapshot[]): GitHubRateLimitSnapshot { + return snaps.reduce((latest, s) => + new Date(s.reset_at).getTime() > new Date(latest.reset_at).getTime() ? s : latest, + ); +} + +function snapshotsFromInfo(info: GitHubRateLimitInfo): GitHubRateLimitSnapshot[] { + const out: GitHubRateLimitSnapshot[] = []; + if (info.core) out.push(info.core); + if (info.graphql) out.push(info.graphql); + if (info.search) out.push(info.search); + return out; +} + +export function GitHubRateLimitDisplay({ info }: { info?: GitHubRateLimitInfo }) { + const now = useTickNow(30_000); + if (!info) return null; + const snapshots = snapshotsFromInfo(info); + if (snapshots.length === 0) return null; + const exhausted = snapshots.filter((s) => isExhausted(s, now)); + + return ( +
+ {exhausted.length > 0 && ( + + + + GitHub API rate limit exhausted on{" "} + {exhausted.map((s) => RESOURCE_LABELS[s.resource] ?? s.resource).join(", ")}. Background + PR/issue checks are paused until the limit resets {formatReset(latestReset(exhausted))}. + + + )} +
+ {snapshots.map((snap) => { + const label = RESOURCE_LABELS[snap.resource] ?? snap.resource; + const limit = snap.limit > 0 ? snap.limit : "?"; + const reset = formatReset(snap); + return ( + + {label}: {snap.remaining}/{limit} + {reset ? ` · resets ${reset}` : ""} + + ); + })} +
+
+ ); +} diff --git a/apps/web/components/github/github-status.tsx b/apps/web/components/github/github-status.tsx index 83cf29b9f6..5b483cf887 100644 --- a/apps/web/components/github/github-status.tsx +++ b/apps/web/components/github/github-status.tsx @@ -18,6 +18,7 @@ import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; import { useToast } from "@/components/toast-provider"; import { configureGitHubToken, clearGitHubToken } from "@/lib/api/domains/github-api"; import type { AuthDiagnostics } from "@/lib/types/github"; +import { GitHubRateLimitDisplay } from "./github-rate-limit"; function DiagnosticsOutput({ diagnostics }: { diagnostics: AuthDiagnostics }) { return ( @@ -214,6 +215,7 @@ export function GitHubStatusCard() { Token stored in secrets. This token is used by remote agents for GitHub operations.

)} +
); } diff --git a/apps/web/components/kanban-board-grid.tsx b/apps/web/components/kanban-board-grid.tsx index 3b6444a716..04b8209c25 100644 --- a/apps/web/components/kanban-board-grid.tsx +++ b/apps/web/components/kanban-board-grid.tsx @@ -12,7 +12,8 @@ import { useSensors, } from "@dnd-kit/core"; import { KanbanColumn, WorkflowStep } from "./kanban-column"; -import { KanbanCardPreview, Task } from "./kanban-card"; +import { Task } from "./kanban-card"; +import { KanbanCardPreview } from "./kanban-card-preview"; import { MobileColumnTabs } from "./kanban/mobile-column-tabs"; import { SwipeableColumns } from "./kanban/swipeable-columns"; import { MobileDropTargets } from "./kanban/mobile-drop-targets"; diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 8945e31a8f..fc7d2ae317 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -16,6 +16,7 @@ import { MobileTaskSheet } from "./kanban/mobile-task-sheet"; import { TaskMultiSelectToolbar } from "./kanban/task-multi-select-toolbar"; import { useKanbanData, useKanbanActions, useKanbanNavigation } from "@/hooks/domains/kanban"; import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; +import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow"; import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useTaskMultiSelect } from "@/hooks/use-task-multi-select"; @@ -69,14 +70,11 @@ function useWorkflowSelection({ (workflow: WorkflowsState["items"][number]) => workflow.workspaceId === workspaceId, ); - const hasSettingsWorkflow = - settings.workflowId && - workspaceWorkflows.some( - (workflow: WorkflowsState["items"][number]) => workflow.id === settings.workflowId, - ); - const fallbackWorkflowId = workspaceWorkflows.length === 1 ? workspaceWorkflows[0].id : null; - const desiredWorkflowId = - (hasSettingsWorkflow ? settings.workflowId : fallbackWorkflowId) ?? null; + const desiredWorkflowId = resolveDesiredWorkflowId({ + activeWorkflowId: workflowsState.activeId, + settingsWorkflowId: settings.workflowId, + workspaceWorkflows, + }); setActiveWorkflow(desiredWorkflowId); if (!desiredWorkflowId) { store.getState().hydrate({ diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx new file mode 100644 index 0000000000..f58ae17c24 --- /dev/null +++ b/apps/web/components/kanban-card-content.tsx @@ -0,0 +1,384 @@ +"use client"; + +import { useState } from "react"; +import { CSS, type Transform } from "@dnd-kit/utilities"; +import type { DraggableAttributes, DraggableSyntheticListeners } from "@dnd-kit/core"; +import { IconAlertCircle, IconArrowsMaximize, IconDots, IconSubtask } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Card, CardContent } from "@kandev/ui/card"; +import { Checkbox } from "@kandev/ui/checkbox"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { PRTaskIcon } from "@/components/github/pr-task-icon"; +import { + KanbanCardDropdownMenuItems, + type KanbanCardMenuEntry, +} from "@/components/kanban-card-menu-items"; +import { useAppStore } from "@/components/state-provider"; +import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip"; +import { useTaskPendingClarification } from "@/hooks/use-task-pending-clarification"; +import { getTaskStateIcon, shouldUseQuestionTaskIcon } from "@/lib/ui/state-icons"; +import { cn } from "@/lib/utils"; +import { needsAction } from "@/lib/utils/needs-action"; +import type { Task } from "@/components/kanban-card"; + +type KanbanCardActionProps = { + task: Task; + showMaximizeButton?: boolean; + onOpenFullPage?: (task: Task) => void; + menuEntries: KanbanCardMenuEntry[]; + isDeleting?: boolean; + isArchiving?: boolean; +}; + +type DraggableCardState = { + attributes: DraggableAttributes; + listeners: DraggableSyntheticListeners; + setNodeRef: (element: HTMLElement | null) => void; + transform: Transform | null; + isDragging: boolean; +}; + +export type KanbanCardShellProps = KanbanCardActionProps & + DraggableCardState & { + repositoryNames?: string[]; + isSelected?: boolean; + isMultiSelectMode?: boolean; + isPreviewed: boolean; + onClick: () => void; + onCheckboxClick: (e: React.MouseEvent) => void; + }; + +const REPO_CHIPS_VISIBLE = 2; + +function RepoChipRow({ repoNames }: { repoNames: string[] }) { + if (repoNames.length === 0) return null; + const visible = repoNames.slice(0, REPO_CHIPS_VISIBLE); + const overflow = repoNames.slice(REPO_CHIPS_VISIBLE); + const row = ( +
+ {visible.map((name) => ( + + {name} + + ))} + {overflow.length > 0 && ( + + +{overflow.length} + + )} +
+ ); + if (repoNames.length <= 1) return row; + return ( + + {row} + +
+ {repoNames.map((name) => ( + {name} + ))} +
+
+
+ ); +} + +export function KanbanCardBody({ + task, + repoNames, + actions, +}: { + task: Task; + repoNames: string[]; + actions?: React.ReactNode; +}) { + return ( + <> +
+
+ +
+

+ {task.title} +

+ +
+
+ {task.isRemoteExecutor && ( + + )} + {actions} +
+ {task.description && ( +

+ {task.description} +

+ )} + + + ); +} + +function KanbanCardBadges({ task }: { task: Task }) { + const parentTitle = useAppStore((s) => { + if (!task.parentTaskId) return null; + return s.kanban.tasks.find((t) => t.id === task.parentTaskId)?.title ?? null; + }); + + const showRow = + (task.sessionCount && task.sessionCount > 1) || + task.reviewStatus === "changes_requested" || + task.reviewStatus === "pending" || + task.parentTaskId; + + if (!showRow) return null; + + return ( +
+ {task.parentTaskId && ( + + + {parentTitle ?? "Subtask"} + + )} + {task.sessionCount && task.sessionCount > 1 && ( + + {task.sessionCount} sessions + + )} + {task.reviewStatus === "pending" && task.state !== "IN_PROGRESS" && ( +
+ + Approval Required +
+ )} + {task.reviewStatus === "changes_requested" && ( + + Changes Requested + + )} +
+ ); +} + +function KanbanCardActions({ + task, + showMaximizeButton, + onOpenFullPage, + menuEntries, + isDeleting, + isArchiving, +}: KanbanCardActionProps) { + const [menuOpen, setMenuOpen] = useState(false); + const effectiveMenuOpen = menuOpen || Boolean(isDeleting) || Boolean(isArchiving); + const hasPendingClarificationRequest = useTaskPendingClarification(task.primarySessionId); + const showQuestionIcon = shouldUseQuestionTaskIcon(task.state, hasPendingClarificationRequest); + const statusIcon = getTaskStateIcon(task.state, "h-4 w-4", hasPendingClarificationRequest); + const hasKnownSession = + Boolean(task.primarySessionId) || Boolean(task.sessionCount && task.sessionCount > 0); + + return ( +
+ {(task.state === "IN_PROGRESS" || task.state === "SCHEDULING" || showQuestionIcon) && + statusIcon} + {showMaximizeButton && onOpenFullPage && hasKnownSession && ( + + )} + +
+ ); +} + +type KanbanCardMenuProps = KanbanCardActionProps & { + effectiveMenuOpen: boolean; + setMenuOpen: (open: boolean) => void; +}; + +function KanbanCardMenu(props: KanbanCardMenuProps) { + const { effectiveMenuOpen, setMenuOpen, isDeleting, isArchiving } = props; + const { menuEntries } = props; + const isProcessing = isDeleting || isArchiving; + + return ( + { + if (!open && isProcessing) return; + setMenuOpen(open); + }} + > + + + + + + + + ); +} + +function KanbanCardCheckbox({ + taskId, + taskTitle, + isSelected, + onCheckboxClick, +}: { + taskId: string; + taskTitle: string; + isSelected?: boolean; + onCheckboxClick: (e: React.MouseEvent) => void; +}) { + return ( +
e.stopPropagation()} + data-testid={`task-select-checkbox-${taskId}`} + > + +
+ ); +} + +function KanbanCardActionSlot({ + isMultiSelectMode, + task, + showMaximizeButton, + onOpenFullPage, + menuEntries, + isDeleting, + isArchiving, +}: KanbanCardActionProps & { isMultiSelectMode?: boolean }) { + if (isMultiSelectMode) return null; + return ( + + ); +} + +export function KanbanCardShell({ + task, + repositoryNames, + attributes, + listeners, + setNodeRef, + transform, + isDragging, + isPreviewed, + isSelected, + isMultiSelectMode, + showMaximizeButton, + isDeleting, + isArchiving, + onClick, + onCheckboxClick, + onOpenFullPage, + menuEntries, +}: KanbanCardShellProps) { + const showCheckbox = isMultiSelectMode || !!isSelected; + const style = { + transform: CSS.Translate.toString(transform), + transition: "none", + willChange: isDragging ? "transform" : undefined, + }; + + return ( + + +
+ {showCheckbox && ( + + )} +
+ + } + /> +
+
+
+
+ ); +} diff --git a/apps/web/components/kanban-card-context-menu.tsx b/apps/web/components/kanban-card-context-menu.tsx new file mode 100644 index 0000000000..ca1928e9e5 --- /dev/null +++ b/apps/web/components/kanban-card-context-menu.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "@kandev/ui/context-menu"; +import { + KanbanCardContextMenuItems, + type KanbanCardMenuEntry, +} from "@/components/kanban-card-menu-items"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; + +export function KanbanCardContextMenu({ + entries, + children, +}: { + entries: KanbanCardMenuEntry[]; + children: React.ReactNode; +}) { + const { isDesktop } = useResponsiveBreakpoint(); + + if (!isDesktop) return children; + + return ( + + {children} + + + + + ); +} diff --git a/apps/web/components/kanban-card-menu-items.tsx b/apps/web/components/kanban-card-menu-items.tsx new file mode 100644 index 0000000000..1c42863264 --- /dev/null +++ b/apps/web/components/kanban-card-menu-items.tsx @@ -0,0 +1,436 @@ +"use client"; + +import { useMemo, type ReactNode } from "react"; +import { + IconArchive, + IconArrowRight, + IconLoader, + IconLogicBuffer, + IconPencil, + IconTrash, +} from "@tabler/icons-react"; +import { + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, +} from "@kandev/ui/context-menu"; +import { + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "@kandev/ui/dropdown-menu"; +import { useAppStore } from "@/components/state-provider"; +import type { WorkflowStep } from "@/components/kanban-card"; +import { + stepHasAutoStart, + type TaskMoveStep, + type TaskMoveWorkflow, +} from "@/components/task/task-move-context-menu"; +import { cn } from "@/lib/utils"; + +type ItemEntry = { + kind: "item"; + key: string; + label: ReactNode; + icon?: ReactNode; + leading?: ReactNode; + trailing?: ReactNode; + disabled?: boolean; + destructive?: boolean; + testId?: string; + onSelect?: () => void; +}; + +type SeparatorEntry = { kind: "separator"; key: string }; + +type SubmenuEntry = { + kind: "submenu"; + key: string; + label: ReactNode; + icon?: ReactNode; + disabled?: boolean; + testId?: string; + className?: string; + children: KanbanCardMenuEntry[]; +}; + +export type KanbanCardMenuEntry = ItemEntry | SeparatorEntry | SubmenuEntry; + +export type KanbanCardMoveTargets = { + currentWorkflowId: string | null; + workflowItems: TaskMoveWorkflow[]; + stepsByWorkflowId: Record; +}; + +type BuildKanbanCardMenuEntriesArgs = { + currentWorkflowId?: string | null; + currentStepId?: string | null; + workflows: TaskMoveWorkflow[]; + stepsByWorkflowId: Record; + disabled?: boolean; + isDeleting?: boolean; + isArchiving?: boolean; + onEdit?: () => void; + onArchive?: () => void; + onDelete?: () => void; + onMoveToStep?: (stepId: string) => void; + onSendToWorkflow?: (workflowId: string, stepId: string) => void; +}; + +function StepBadges({ step, isCurrent }: { step: TaskMoveStep; isCurrent: boolean }) { + const hasAutoStart = stepHasAutoStart(step); + if (!isCurrent && !hasAutoStart) return null; + + return ( + + {isCurrent && Current} + {hasAutoStart && ( + Auto-start + )} + + ); +} + +function buildStepEntry( + step: TaskMoveStep, + currentStepId: string | null | undefined, + onSelect: (stepId: string) => void, +): KanbanCardMenuEntry { + const isCurrent = step.id === currentStepId; + return { + kind: "item", + key: `step-${step.id}`, + testId: `task-context-step-${step.id}`, + disabled: isCurrent, + leading: , + label: {step.title}, + trailing: , + onSelect: () => { + if (!isCurrent) onSelect(step.id); + }, + }; +} + +function buildMoveToCurrentWorkflowSubmenu({ + steps, + currentStepId, + disabled, + onMoveToStep, +}: { + steps: TaskMoveStep[]; + currentStepId?: string | null; + disabled?: boolean; + onMoveToStep?: (stepId: string) => void; +}): KanbanCardMenuEntry | null { + if (!onMoveToStep || steps.length <= 1) return null; + return { + kind: "submenu", + key: "move-to", + testId: "task-context-move-to", + icon: , + label: "Move to", + disabled, + className: "w-48", + children: steps.map((step) => buildStepEntry(step, currentStepId, onMoveToStep)), + }; +} + +function buildWorkflowTargetEntry({ + workflow, + steps, + disabled, + onSendToWorkflow, +}: { + workflow: TaskMoveWorkflow; + steps: TaskMoveStep[]; + disabled?: boolean; + onSendToWorkflow?: (workflowId: string, stepId: string) => void; +}): KanbanCardMenuEntry { + if (steps.length === 0 || !onSendToWorkflow) { + return { + kind: "item", + key: `workflow-${workflow.id}`, + testId: `task-context-workflow-${workflow.id}`, + disabled: true, + label: {workflow.name}, + trailing: ( + + No steps + + ), + }; + } + + return { + kind: "submenu", + key: `workflow-${workflow.id}`, + testId: `task-context-workflow-${workflow.id}`, + label: {workflow.name}, + disabled, + className: "w-48", + children: steps.map((step) => + buildStepEntry(step, null, (stepId) => onSendToWorkflow(workflow.id, stepId)), + ), + }; +} + +function buildSendToWorkflowSubmenu({ + currentWorkflowId, + workflows, + stepsByWorkflowId, + disabled, + onSendToWorkflow, +}: { + currentWorkflowId?: string | null; + workflows: TaskMoveWorkflow[]; + stepsByWorkflowId: Record; + disabled?: boolean; + onSendToWorkflow?: (workflowId: string, stepId: string) => void; +}): KanbanCardMenuEntry | null { + const targets = workflows.filter((workflow) => workflow.id !== currentWorkflowId); + if (!onSendToWorkflow || !currentWorkflowId || targets.length === 0) return null; + return { + kind: "submenu", + key: "send-to-workflow", + testId: "task-context-send-to-workflow", + icon: , + label: "Send to workflow", + disabled, + className: "w-56", + children: targets.map((workflow) => + buildWorkflowTargetEntry({ + workflow, + steps: stepsByWorkflowId[workflow.id] ?? [], + disabled, + onSendToWorkflow, + }), + ), + }; +} + +export function buildKanbanCardMenuEntries({ + currentWorkflowId, + currentStepId, + workflows, + stepsByWorkflowId, + disabled, + isDeleting, + isArchiving, + onEdit, + onArchive, + onDelete, + onMoveToStep, + onSendToWorkflow, +}: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] { + const visibleWorkflows = workflows.filter((workflow) => !workflow.hidden); + const currentSteps = currentWorkflowId ? (stepsByWorkflowId[currentWorkflowId] ?? []) : []; + const isProcessing = Boolean(disabled || isDeleting || isArchiving); + const entries: KanbanCardMenuEntry[] = [ + { + kind: "item", + key: "edit", + icon: , + label: "Edit", + disabled: isProcessing || !onEdit, + onSelect: onEdit, + }, + ]; + + const moveToEntry = buildMoveToCurrentWorkflowSubmenu({ + steps: currentSteps, + currentStepId, + disabled: isProcessing, + onMoveToStep, + }); + if (moveToEntry) entries.push(moveToEntry); + + const sendToEntry = buildSendToWorkflowSubmenu({ + currentWorkflowId, + workflows: visibleWorkflows, + stepsByWorkflowId, + disabled: isProcessing, + onSendToWorkflow, + }); + if (sendToEntry) entries.push(sendToEntry); + + entries.push({ + kind: "item", + key: "archive", + icon: isArchiving ? ( + + ) : ( + + ), + label: "Archive", + disabled: isProcessing || !onArchive, + onSelect: onArchive, + }); + + entries.push({ kind: "separator", key: "delete-separator" }); + entries.push({ + kind: "item", + key: "delete", + icon: isDeleting ? ( + + ) : ( + + ), + label: "Delete", + destructive: true, + disabled: isProcessing || !onDelete, + onSelect: onDelete, + }); + + return entries; +} + +export function useKanbanCardMoveTargets( + taskId: string, + steps?: WorkflowStep[], +): KanbanCardMoveTargets { + const workflows = useAppStore((state) => state.workflows.items); + const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); + + const currentWorkflowId = useMemo(() => { + for (const [workflowId, snapshot] of Object.entries(snapshots)) { + if (snapshot.tasks.some((task) => task.id === taskId)) return workflowId; + } + return null; + }, [snapshots, taskId]); + + const workflowItems = useMemo(() => { + const current = workflows.find((workflow) => workflow.id === currentWorkflowId); + return workflows + .filter((workflow) => workflow.workspaceId === current?.workspaceId && !workflow.hidden) + .map((workflow) => ({ id: workflow.id, name: workflow.name, hidden: workflow.hidden })); + }, [workflows, currentWorkflowId]); + + const stepsByWorkflowId = useMemo>(() => { + const result: Record = {}; + for (const [workflowId, snapshot] of Object.entries(snapshots)) { + result[workflowId] = snapshot.steps + .slice() + .sort((a, b) => a.position - b.position) + .map((step) => ({ + id: step.id, + title: step.title, + color: step.color, + events: step.events, + })); + } + if (currentWorkflowId && steps) { + result[currentWorkflowId] = steps.map((step) => ({ + id: step.id, + title: step.title, + color: step.color, + events: step.events, + })); + } + return result; + }, [snapshots, currentWorkflowId, steps]); + + return { currentWorkflowId, workflowItems, stepsByWorkflowId }; +} + +function ContextEntry({ entry }: { entry: KanbanCardMenuEntry }) { + if (entry.kind === "separator") return ; + if (entry.kind === "submenu") { + return ( + + + {entry.icon} + {entry.label} + + + {entry.children.map((child) => ( + + ))} + + + ); + } + + return ( + { + if (!entry.disabled) entry.onSelect?.(); + }} + > + {entry.icon} + {entry.leading} + {entry.label} + {entry.trailing} + + ); +} + +function DropdownEntry({ entry }: { entry: KanbanCardMenuEntry }) { + if (entry.kind === "separator") return ; + if (entry.kind === "submenu") { + return ( + + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + {entry.icon} + {entry.label} + + + + {entry.children.map((child) => ( + + ))} + + + + ); + } + + return ( + { + event.stopPropagation(); + if (!entry.disabled) entry.onSelect?.(); + }} + > + {entry.icon} + {entry.leading} + {entry.label} + {entry.trailing} + + ); +} + +export function KanbanCardContextMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) { + return ( + <> + {entries.map((entry) => ( + + ))} + + ); +} + +export function KanbanCardDropdownMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) { + return ( + <> + {entries.map((entry) => ( + + ))} + + ); +} diff --git a/apps/web/components/kanban-card-preview.tsx b/apps/web/components/kanban-card-preview.tsx new file mode 100644 index 0000000000..395e4f39c7 --- /dev/null +++ b/apps/web/components/kanban-card-preview.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useMemo } from "react"; +import { Card, CardContent } from "@kandev/ui/card"; +import { KanbanCardBody } from "@/components/kanban-card-content"; +import { resolveTaskRepositoryNames, type Task } from "@/components/kanban-card"; +import { useAppStore } from "@/components/state-provider"; +import type { Repository } from "@/lib/types/http"; + +function KanbanCardPreviewLayout({ + task, + repositoryNames, +}: { + task: Task; + repositoryNames: string[]; +}) { + return ( + + + + + + ); +} + +export function KanbanCardPreview({ task }: { task: Task }) { + const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const repositoryNames = useMemo( + () => + resolveTaskRepositoryNames( + task, + Object.values(repositoriesByWorkspace).flat() as Repository[], + ), + [repositoriesByWorkspace, task], + ); + + return ; +} diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index c82554b223..b983dbe798 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -1,40 +1,19 @@ "use client"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { useDraggable } from "@dnd-kit/core"; -import { CSS } from "@dnd-kit/utilities"; +import { KanbanCardContextMenu } from "@/components/kanban-card-context-menu"; +import { KanbanCardShell } from "@/components/kanban-card-content"; import { - IconDots, - IconArrowsMaximize, - IconLoader, - IconAlertCircle, - IconSubtask, -} from "@tabler/icons-react"; -import { Checkbox } from "@kandev/ui/checkbox"; -import { Card, CardContent } from "@kandev/ui/card"; -import { Badge } from "@kandev/ui/badge"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, - DropdownMenuPortal, - DropdownMenuSeparator, -} from "@kandev/ui/dropdown-menu"; -import type { TaskState, Repository } from "@/lib/types/http"; -import { cn, getRepositoryDisplayName } from "@/lib/utils"; -import { getTaskStateIcon, shouldUseQuestionTaskIcon } from "@/lib/ui/state-icons"; -import { needsAction } from "@/lib/utils/needs-action"; + buildKanbanCardMenuEntries, + useKanbanCardMoveTargets, +} from "@/components/kanban-card-menu-items"; import { useAppStore } from "@/components/state-provider"; -import { PRTaskIcon } from "@/components/github/pr-task-icon"; -import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip"; import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; -import { useTaskPendingClarification } from "@/hooks/use-task-pending-clarification"; +import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; +import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; +import { getRepositoryDisplayName } from "@/lib/utils"; +import type { Repository, TaskState } from "@/lib/types/http"; export interface Task { id: string; @@ -46,7 +25,6 @@ export interface Task { repositoryId?: string; /** All repositories linked to the task; used to render a "+N" chip for multi-repo. */ repositories?: Array<{ id: string; repository_id: string; position: number }>; - // Workflow fields sessionCount?: number | null; primarySessionId?: string | null; reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null; @@ -63,6 +41,12 @@ export interface WorkflowStep { id: string; title: string; color: string; + events?: { + on_enter?: Array<{ type: string; config?: Record }>; + on_turn_start?: Array<{ type: string; config?: Record }>; + on_turn_complete?: Array<{ type: string; config?: Record }>; + on_exit?: Array<{ type: string; config?: Record }>; + }; } interface KanbanCardProps { @@ -80,384 +64,100 @@ interface KanbanCardProps { isDeleting?: boolean; isArchiving?: boolean; isSelected?: boolean; + selectedIds?: Set; onToggleSelect?: (taskId: string) => void; isMultiSelectMode?: boolean; } -const REPO_CHIPS_VISIBLE = 2; - -function RepoChipRow({ repoNames }: { repoNames: string[] }) { - if (repoNames.length === 0) return null; - const visible = repoNames.slice(0, REPO_CHIPS_VISIBLE); - const overflow = repoNames.slice(REPO_CHIPS_VISIBLE); - const row = ( -
- {visible.map((name) => ( - - {name} - - ))} - {overflow.length > 0 && ( - - +{overflow.length} - - )} -
- ); - if (repoNames.length <= 1) return row; - return ( - - {row} - -
- {repoNames.map((name) => ( - {name} - ))} -
-
-
- ); -} - -function KanbanCardBody({ +function useKanbanCardMenus({ task, - repoNames, - actions, -}: { - task: Task; - repoNames: string[]; - actions?: React.ReactNode; -}) { - return ( - <> -
-
- -
-

- {task.title} -

- -
-
- {task.isRemoteExecutor && ( - - )} - {actions} -
- {task.description && ( -

- {task.description} -

- )} - - - ); -} - -function KanbanCardBadges({ task }: { task: Task }) { - const parentTitle = useAppStore((s) => { - if (!task.parentTaskId) return null; - return s.kanban.tasks.find((t) => t.id === task.parentTaskId)?.title ?? null; - }); - - const showRow = - (task.sessionCount && task.sessionCount > 1) || - task.reviewStatus === "changes_requested" || - task.reviewStatus === "pending" || - task.parentTaskId; - - if (!showRow) return null; - - return ( -
- {task.parentTaskId && ( - - - {parentTitle ?? "Subtask"} - - )} - {task.sessionCount && task.sessionCount > 1 && ( - - {task.sessionCount} sessions - - )} - {task.reviewStatus === "pending" && task.state !== "IN_PROGRESS" && ( -
- - Approval Required -
- )} - {task.reviewStatus === "changes_requested" && ( - - Changes Requested - - )} -
- ); -} - -function KanbanCardLayout({ - task, - repositoryNames, - className, -}: KanbanCardProps & { className?: string }) { - return ( - - - - - - ); -} - -function KanbanCardActions({ - task, - showMaximizeButton, - onOpenFullPage, + steps, + isDeleting, + isArchiving, + isSelected, + selectedIds, onEdit, onDelete, onArchive, onMove, - steps, - isDeleting, - isArchiving, }: Pick< KanbanCardProps, | "task" - | "showMaximizeButton" - | "onOpenFullPage" + | "steps" + | "isDeleting" + | "isArchiving" + | "isSelected" + | "selectedIds" | "onEdit" | "onDelete" | "onArchive" | "onMove" - | "steps" - | "isDeleting" - | "isArchiving" >) { - const [menuOpen, setMenuOpen] = useState(false); - const effectiveMenuOpen = menuOpen || Boolean(isDeleting) || Boolean(isArchiving); - const hasPendingClarificationRequest = useTaskPendingClarification(task.primarySessionId); - const showQuestionIcon = shouldUseQuestionTaskIcon(task.state, hasPendingClarificationRequest); - const statusIcon = getTaskStateIcon(task.state, "h-4 w-4", hasPendingClarificationRequest); - const hasKnownSession = - Boolean(task.primarySessionId) || Boolean(task.sessionCount && task.sessionCount > 0); - - return ( -
- {(task.state === "IN_PROGRESS" || task.state === "SCHEDULING" || showQuestionIcon) && - statusIcon} - {showMaximizeButton && onOpenFullPage && hasKnownSession && ( - - )} - -
- ); -} - -function MoveToSubmenu({ - task, - steps, - disabled, - onMove, -}: { - task: Task; - steps: WorkflowStep[]; - disabled?: boolean; - onMove: (task: Task, targetStepId: string) => void; -}) { - return ( - - event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - > - Move to - - - - {steps - .filter((col) => col.id !== task.workflowStepId) - .map((col) => ( - { - event.stopPropagation(); - onMove(task, col.id); - }} - > -
- {col.title} - - ))} - - - - ); -} - -type KanbanCardMenuProps = { - task: Task; - effectiveMenuOpen: boolean; - setMenuOpen: (open: boolean) => void; - isDeleting?: boolean; - isArchiving?: boolean; - onEdit?: (task: Task) => void; - onDelete?: (task: Task) => void; - onArchive?: (task: Task) => void; - onMove?: (task: Task, targetStepId: string) => void; - steps?: WorkflowStep[]; -}; - -function KanbanCardMenu(props: KanbanCardMenuProps) { - const { task, effectiveMenuOpen, setMenuOpen, isDeleting, isArchiving } = props; - const { onEdit, onDelete, onArchive, onMove, steps } = props; + const moveTargets = useKanbanCardMoveTargets(task.id, steps); + const moveTasks = useTaskWorkflowMove(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showArchiveConfirm, setShowArchiveConfirm] = useState(false); - const isProcessing = isDeleting || isArchiving; + const disabled = Boolean(isDeleting || isArchiving); - return ( - <> - { - if (!open && isProcessing) return; - setMenuOpen(open); - }} - > - - - - - { - e.stopPropagation(); - onEdit?.(task); - }} - > - Edit - - {steps && steps.length > 1 && onMove && ( - - )} - {onArchive && ( - { - e.stopPropagation(); - setShowArchiveConfirm(true); - }} - > - {isArchiving ? : null} - Archive - - )} - - { - e.stopPropagation(); - setShowDeleteConfirm(true); - }} - > - Delete - - - - onDelete?.(task)} - /> - { - onArchive?.(task); - setShowArchiveConfirm(false); - }} - /> - - ); -} + const runMoveTasks = (taskIds: string[], workflowId: string, stepId: string) => { + void moveTasks(taskIds, workflowId, stepId).catch(() => { + // useTaskWorkflowMove already shows the failure toast. + }); + }; -function KanbanCardCheckbox({ - taskId, - taskTitle, - isSelected, - onCheckboxClick, -}: { - taskId: string; - taskTitle: string; - isSelected?: boolean; - onCheckboxClick: (e: React.MouseEvent) => void; -}) { - return ( -
e.stopPropagation()} - data-testid={`task-select-checkbox-${taskId}`} - > - -
- ); + const moveToStepFromDropdown = (stepId: string) => { + if (onMove) { + onMove(task, stepId); + return; + } + if (moveTargets.currentWorkflowId) { + runMoveTasks([task.id], moveTargets.currentWorkflowId, stepId); + } + }; + + const selectedTaskIds = isSelected && selectedIds?.size ? [...selectedIds] : [task.id]; + const moveSelectedToStep = (stepId: string) => { + if (selectedTaskIds.length === 1 && selectedTaskIds[0] === task.id && onMove) { + onMove(task, stepId); + return; + } + if (!moveTargets.currentWorkflowId) return; + runMoveTasks(selectedTaskIds, moveTargets.currentWorkflowId, stepId); + }; + + const menuBase = { + currentWorkflowId: moveTargets.currentWorkflowId, + currentStepId: task.workflowStepId, + workflows: moveTargets.workflowItems, + stepsByWorkflowId: moveTargets.stepsByWorkflowId, + disabled, + isDeleting, + isArchiving, + onEdit: onEdit ? () => onEdit(task) : undefined, + onArchive: onArchive ? () => setShowArchiveConfirm(true) : undefined, + onDelete: onDelete ? () => setShowDeleteConfirm(true) : undefined, + }; + + return { + dropdownMenuEntries: buildKanbanCardMenuEntries({ + ...menuBase, + onMoveToStep: moveToStepFromDropdown, + onSendToWorkflow: (workflowId, stepId) => { + runMoveTasks([task.id], workflowId, stepId); + }, + }), + contextMenuEntries: buildKanbanCardMenuEntries({ + ...menuBase, + onMoveToStep: moveSelectedToStep, + onSendToWorkflow: (workflowId, stepId) => { + runMoveTasks(selectedTaskIds, workflowId, stepId); + }, + }), + showDeleteConfirm, + setShowDeleteConfirm, + showArchiveConfirm, + setShowArchiveConfirm, + }; } export function KanbanCard({ @@ -474,6 +174,7 @@ export function KanbanCard({ isDeleting, isArchiving, isSelected, + selectedIds, onToggleSelect, isMultiSelectMode, }: KanbanCardProps) { @@ -481,14 +182,26 @@ export function KanbanCard({ id: task.id, disabled: isMultiSelectMode, }); - const isPreviewed = useAppStore((state) => state.kanbanPreviewedTaskId === task.id); - - const style = { - transform: CSS.Translate.toString(transform), - transition: "none", - willChange: isDragging ? "transform" : undefined, - }; + const { + dropdownMenuEntries, + contextMenuEntries, + showDeleteConfirm, + setShowDeleteConfirm, + showArchiveConfirm, + setShowArchiveConfirm, + } = useKanbanCardMenus({ + task, + steps, + isDeleting, + isArchiving, + isSelected, + selectedIds, + onEdit, + onDelete, + onArchive, + onMove, + }); const handleClick = () => { if (isMultiSelectMode) { @@ -503,76 +216,44 @@ export function KanbanCard({ onToggleSelect?.(task.id); }; - const showCheckbox = isMultiSelectMode || !!isSelected; - return ( - - -
- {showCheckbox && ( - - )} -
- - ) : undefined - } - /> -
-
-
-
- ); -} - -export function KanbanCardPreview({ task }: KanbanCardProps) { - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); - const repositoryNames = useMemo( - () => resolveTaskRepositoryNames(task, Object.values(repositoriesByWorkspace).flat()), - [repositoriesByWorkspace, task], - ); - - return ( - + <> + + + + onDelete?.(task)} + /> + onArchive?.(task)} + /> + ); } diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx index ed2c794656..96e203e87c 100644 --- a/apps/web/components/kanban-column.tsx +++ b/apps/web/components/kanban-column.tsx @@ -107,6 +107,7 @@ export function KanbanColumn({ isDeleting={deletingTaskId === task.id} isArchiving={archivingTaskId === task.id} isSelected={selectedIds?.has(task.id)} + selectedIds={selectedIds} onToggleSelect={onToggleSelect} isMultiSelectMode={isMultiSelectMode} /> diff --git a/apps/web/components/kanban/mobile-task-sheet.tsx b/apps/web/components/kanban/mobile-task-sheet.tsx index 7154705305..7021103475 100644 --- a/apps/web/components/kanban/mobile-task-sheet.tsx +++ b/apps/web/components/kanban/mobile-task-sheet.tsx @@ -12,6 +12,7 @@ import { import { Badge } from "@kandev/ui/badge"; import { IconArrowRight, IconEdit, IconTrash } from "@tabler/icons-react"; import type { Task } from "@/components/kanban-card"; +import { formatTaskStateLabel } from "@/lib/ui/state-labels"; type MobileTaskSheetProps = { task: Task | null; @@ -42,7 +43,7 @@ export function MobileTaskSheet({ )}
- {task.state ?? "not_started"} + {formatTaskStateLabel(task.state)}
diff --git a/apps/web/components/kanban/swimlane-kanban-content.tsx b/apps/web/components/kanban/swimlane-kanban-content.tsx index 9ec114b1f7..eabc9e904b 100644 --- a/apps/web/components/kanban/swimlane-kanban-content.tsx +++ b/apps/web/components/kanban/swimlane-kanban-content.tsx @@ -12,7 +12,8 @@ import { useSensors, } from "@dnd-kit/core"; import { KanbanColumn } from "@/components/kanban-column"; -import { KanbanCardPreview, type Task } from "@/components/kanban-card"; +import { type Task } from "@/components/kanban-card"; +import { KanbanCardPreview } from "@/components/kanban-card-preview"; import type { WorkflowStep } from "@/components/kanban-column"; import type { MoveTaskError } from "@/hooks/use-drag-and-drop"; import { useTaskActions } from "@/hooks/use-task-actions"; diff --git a/apps/web/components/linear/linear-issue-watch-dialog.tsx b/apps/web/components/linear/linear-issue-watch-dialog.tsx new file mode 100644 index 0000000000..3d790bb0e4 --- /dev/null +++ b/apps/web/components/linear/linear-issue-watch-dialog.tsx @@ -0,0 +1,589 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { Button } from "@kandev/ui/button"; +import { Switch } from "@kandev/ui/switch"; +import { Label } from "@kandev/ui/label"; +import { Input } from "@kandev/ui/input"; +import { Badge } from "@kandev/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@kandev/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useAppStore } from "@/components/state-provider"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; +import { useWorkflows } from "@/hooks/use-workflows"; +import { useWorkflowSteps, stepPlaceholder } from "@/hooks/use-workflow-steps"; +import { listLinearTeams, listLinearStates } from "@/lib/api/domains/linear-api"; +import { + ScriptEditor, + computeEditorHeight, +} from "@/components/settings/profile-edit/script-editor"; +import { + LINEAR_ISSUE_WATCH_PLACEHOLDERS, + DEFAULT_LINEAR_ISSUE_WATCH_PROMPT, +} from "./linear-issue-watch-placeholders"; +import type { + CreateLinearIssueWatchInput, + LinearIssueWatch, + LinearTeam, + LinearWorkflowState, + UpdateLinearIssueWatchInput, +} from "@/lib/types/linear"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + watch: LinearIssueWatch | null; + workspaceId?: string; + onCreate: (req: CreateLinearIssueWatchInput) => Promise; + onUpdate: (id: string, req: UpdateLinearIssueWatchInput) => Promise; +}; + +type FormState = { + workspaceId: string; + query: string; + teamKey: string; + stateIds: string[]; + assigned: string; + workflowId: string; + workflowStepId: string; + agentProfileId: string; + executorProfileId: string; + prompt: string; + enabled: boolean; + pollInterval: number; +}; + +const ASSIGNED_ANY = "__any__"; + +function makeEmptyForm(workspaceId: string): FormState { + return { + workspaceId, + query: "", + teamKey: "", + stateIds: [], + assigned: "", + workflowId: "", + workflowStepId: "", + agentProfileId: "", + executorProfileId: "", + prompt: DEFAULT_LINEAR_ISSUE_WATCH_PROMPT, + enabled: true, + pollInterval: 300, + }; +} + +function formStateFromWatch(w: LinearIssueWatch): FormState { + return { + workspaceId: w.workspaceId, + query: w.filter?.query ?? "", + teamKey: w.filter?.teamKey ?? "", + stateIds: w.filter?.stateIds ?? [], + assigned: w.filter?.assigned ?? "", + workflowId: w.workflowId, + workflowStepId: w.workflowStepId, + agentProfileId: w.agentProfileId, + executorProfileId: w.executorProfileId, + prompt: w.prompt || DEFAULT_LINEAR_ISSUE_WATCH_PROMPT, + enabled: w.enabled, + pollInterval: w.pollIntervalSeconds, + }; +} + +function useFormData(workspaceId: string) { + useSettingsData(true); + useWorkflows(workspaceId, true); + const allWorkflows = useAppStore((s) => s.workflows.items); + const workflows = useMemo(() => allWorkflows.filter((w) => !w.hidden), [allWorkflows]); + const agentProfiles = useAppStore((s) => s.agentProfiles.items); + const executors = useAppStore((s) => s.executors.items); + const allExecutorProfiles = useMemo( + () => executors.filter((e) => e.type !== "local").flatMap((e) => e.profiles ?? []), + [executors], + ); + const filteredAgentProfiles = useMemo( + () => agentProfiles.filter((p) => !p.cli_passthrough), + [agentProfiles], + ); + return { workflows, agentProfiles: filteredAgentProfiles, allExecutorProfiles }; +} + +function SelectField(props: { + label: string; + description?: string; + value: string; + onChange: (v: string) => void; + placeholder: string; + items: { id: string; label: string }[]; + disabled?: boolean; +}) { + return ( +
+ + {props.description &&

{props.description}

} + +
+ ); +} + +function StateMultiSelect({ + states, + loading, + selected, + onToggle, + disabled, +}: { + states: LinearWorkflowState[]; + loading: boolean; + selected: string[]; + onToggle: (id: string) => void; + disabled: boolean; +}) { + if (disabled) { + return ( +

+ Pick a team to choose specific workflow states. +

+ ); + } + if (loading) { + return

Loading states…

; + } + if (states.length === 0) { + return

No workflow states available.

; + } + return ( +
+ {states.map((s) => { + const active = selected.includes(s.id); + return ( + onToggle(s.id)} + > + {s.name} + + ); + })} +
+ ); +} + +// useTeamsAndStates loads the team list once Linear is configured, plus the +// states for the currently-selected team. The states map is keyed by teamKey +// so switching teams renders an empty list without us having to setState in +// an effect — only the lookup expression changes. +function useTeamsAndStates(teamKey: string) { + const [teams, setTeams] = useState([]); + const [statesByTeam, setStatesByTeam] = useState>({}); + // Track which teams we've already kicked off a fetch for so the effect + // doesn't list `statesByTeam` as a dep (which would re-fire whenever any + // team's states load). A ref mutation isn't reactive, so the sole input + // that schedules a fetch is `teamKey`. + const fetchedTeams = useRef>(new Set()); + + useEffect(() => { + let cancelled = false; + listLinearTeams() + .then((res) => { + if (!cancelled) setTeams(res.teams ?? []); + }) + .catch(() => { + if (!cancelled) setTeams([]); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!teamKey || fetchedTeams.current.has(teamKey)) return; + fetchedTeams.current.add(teamKey); + let cancelled = false; + listLinearStates(teamKey) + .then((res) => { + if (!cancelled) setStatesByTeam((prev) => ({ ...prev, [teamKey]: res.states ?? [] })); + }) + .catch(() => { + if (!cancelled) setStatesByTeam((prev) => ({ ...prev, [teamKey]: [] })); + }); + return () => { + cancelled = true; + }; + }, [teamKey]); + + const states = teamKey ? (statesByTeam[teamKey] ?? []) : []; + // "loading" = a team is selected but we haven't received a result yet. + const loadingStates = !!teamKey && statesByTeam[teamKey] === undefined; + return { teams, states, loadingStates }; +} + +function FilterFields({ + form, + setForm, +}: { + form: FormState; + setForm: React.Dispatch>; +}) { + const { teams, states, loadingStates } = useTeamsAndStates(form.teamKey); + const toggleState = useCallback( + (id: string) => + setForm((p) => ({ + ...p, + stateIds: p.stateIds.includes(id) + ? p.stateIds.filter((s) => s !== id) + : [...p.stateIds, id], + })), + [setForm], + ); + + return ( + <> +
+ setForm((p) => ({ ...p, teamKey: v, stateIds: [] }))} + placeholder="(any team)" + items={teams.map((t) => ({ id: t.key, label: `${t.name} (${t.key})` }))} + /> + setForm((p) => ({ ...p, assigned: v === ASSIGNED_ANY ? "" : v }))} + placeholder="(any)" + items={[ + { id: ASSIGNED_ANY, label: "(any)" }, + { id: "me", label: "Me" }, + { id: "unassigned", label: "Unassigned" }, + ]} + /> +
+
+ +

+ Click states to toggle. Empty matches every state on the team. +

+ +
+
+ +

+ Free-text match across title and description (optional). +

+ setForm((p) => ({ ...p, query: e.target.value }))} + placeholder="auth bug" + /> +
+ + ); +} + +function PlaceholdersHelp() { + return ( + + + + + + +

Available placeholders:

+
    + {LINEAR_ISSUE_WATCH_PLACEHOLDERS.map((p) => ( +
  • + {`{{${p.key}}}`}{" "} + {p.description} +
  • + ))} +
+
+
+
+ ); +} + +function PromptField({ value, onChange }: { value: string; onChange: (v: string) => void }) { + return ( +
+
+ + +
+

+ The prompt sent to the agent for each new issue. Type {"{{"} to insert placeholders. +

+
+ +
+
+ ); +} + +function WorkspacePicker({ + value, + onChange, + disabled, +}: { + value: string; + onChange: (v: string) => void; + disabled?: boolean; +}) { + const workspaces = useAppStore((s) => s.workspaces.items); + return ( + ({ id: w.id, label: w.name }))} + disabled={disabled} + /> + ); +} + +function AutomationFields({ + form, + setForm, +}: { + form: FormState; + setForm: React.Dispatch>; +}) { + const { workflows, agentProfiles, allExecutorProfiles } = useFormData(form.workspaceId); + const { steps, loading: stepsLoading } = useWorkflowSteps(form.workflowId); + return ( + <> +
+ setForm((p) => ({ ...p, workflowId: v, workflowStepId: "" }))} + placeholder="Select workflow" + items={workflows.map((w) => ({ id: w.id, label: w.name }))} + /> + setForm((p) => ({ ...p, workflowStepId: v }))} + placeholder={stepPlaceholder(form.workflowId, stepsLoading, steps.length)} + items={steps.map((s) => ({ id: s.id, label: s.name }))} + disabled={!form.workflowId || stepsLoading || steps.length === 0} + /> +
+
+ setForm((p) => ({ ...p, agentProfileId: v }))} + placeholder="(use step default)" + items={agentProfiles.map((p) => ({ id: p.id, label: p.label }))} + /> + setForm((p) => ({ ...p, executorProfileId: v }))} + placeholder="(use step default)" + items={allExecutorProfiles.map((p) => ({ id: p.id, label: p.name }))} + /> +
+ + ); +} + +function SettingsFields({ + form, + setForm, +}: { + form: FormState; + setForm: React.Dispatch>; +}) { + return ( + <> +
+ +

+ How often to re-run the search. Minimum 60s, maximum 3600s. +

+ setForm((p) => ({ ...p, pollInterval: Number(e.target.value) }))} + min={60} + max={3600} + /> +
+
+
+ +

Pause or resume polling.

+
+ setForm((p) => ({ ...p, enabled: v }))} + className="cursor-pointer" + /> +
+ + ); +} + +function savingLabel(saving: boolean, isEdit: boolean): string { + if (saving) return "Saving…"; + return isEdit ? "Update" : "Create"; +} + +function filterIsEmpty(form: FormState): boolean { + return ( + form.query.trim() === "" && + form.teamKey.trim() === "" && + form.assigned.trim() === "" && + form.stateIds.length === 0 + ); +} + +export function LinearIssueWatchDialog({ + open, + onOpenChange, + watch, + workspaceId, + onCreate, + onUpdate, +}: Props) { + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); + const [saving, setSaving] = useState(false); + const [form, setForm] = useState(() => makeEmptyForm(workspaceId ?? "")); + + useEffect(() => { + if (watch) { + setForm(formStateFromWatch(watch)); + } else { + setForm(makeEmptyForm(workspaceId ?? activeWorkspaceId ?? "")); + } + }, [watch, open, workspaceId, activeWorkspaceId]); + + const workspaceLocked = !!watch || !!workspaceId; + + const canSave = + !!form.workspaceId && + !filterIsEmpty(form) && + !!form.workflowId && + !!form.workflowStepId && + !!form.prompt.trim(); + + const handleSave = useCallback(async () => { + setSaving(true); + try { + const filter = { + query: form.query.trim() || undefined, + teamKey: form.teamKey.trim() || undefined, + stateIds: form.stateIds.length > 0 ? form.stateIds : undefined, + assigned: form.assigned.trim() || undefined, + }; + const payload = { + filter, + workflowId: form.workflowId, + workflowStepId: form.workflowStepId, + agentProfileId: form.agentProfileId, + executorProfileId: form.executorProfileId, + prompt: form.prompt, + enabled: form.enabled, + pollIntervalSeconds: form.pollInterval, + }; + if (watch) { + await onUpdate(watch.id, payload); + } else { + await onCreate({ ...payload, workspaceId: form.workspaceId }); + } + onOpenChange(false); + } catch { + // Error surfaced by caller's toast. + } finally { + setSaving(false); + } + }, [form, watch, onCreate, onUpdate, onOpenChange]); + + return ( + + + + {watch ? "Edit Linear Watcher" : "Create Linear Watcher"} + + Poll Linear with a structured filter and auto-create a Kandev task for each + newly-matching issue. Issues are not bound to a repository — the workflow step's + defaults decide where the task runs. + + +
+ + setForm((p) => ({ ...p, workspaceId: v, workflowId: "", workflowStepId: "" })) + } + disabled={workspaceLocked} + /> + + + setForm((p) => ({ ...p, prompt: v }))} + /> + +
+ + + + +
+
+ ); +} diff --git a/apps/web/components/linear/linear-issue-watch-placeholders.ts b/apps/web/components/linear/linear-issue-watch-placeholders.ts new file mode 100644 index 0000000000..39faf2c10c --- /dev/null +++ b/apps/web/components/linear/linear-issue-watch-placeholders.ts @@ -0,0 +1,86 @@ +import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions"; + +export const LINEAR_ISSUE_WATCH_PLACEHOLDERS: ScriptPlaceholder[] = [ + { + key: "issue.url", + description: "Linear issue URL", + example: "https://linear.app/acme/issue/ENG-7", + executor_types: [], + }, + { + key: "issue.identifier", + description: "Issue identifier", + example: "ENG-7", + executor_types: [], + }, + { + key: "issue.title", + description: "Issue title", + example: "Login fails on mobile", + executor_types: [], + }, + { + key: "issue.team", + description: "Team key", + example: "ENG", + executor_types: [], + }, + { + key: "issue.state", + description: "Workflow state name", + example: "In Progress", + executor_types: [], + }, + { + key: "issue.priority", + description: "Priority label", + example: "High", + executor_types: [], + }, + { + key: "issue.assignee", + description: "Assignee display name", + example: "Alice", + executor_types: [], + }, + { + key: "issue.creator", + description: "Issue creator display name", + example: "Bob", + executor_types: [], + }, + { + key: "issue.description", + description: "Issue description body", + example: "Tap submit, nothing happens.", + executor_types: [], + }, +]; + +// DEFAULT_LINEAR_ISSUE_WATCH_PROMPT mirrors apps/backend/config/prompts/linear-issue-watch-default.md. +// Kept in sync by hand: the UI shows this when the user clears the field, and +// the backend reads the .md when the saved prompt is empty. Diverging would +// surprise the user — they'd see one default in the dialog and another get +// sent to the agent. +export const DEFAULT_LINEAR_ISSUE_WATCH_PROMPT = `You have been assigned a Linear issue to work on. + +**Issue:** {{issue.url}} +**Identifier:** {{issue.identifier}} +**Title:** {{issue.title}} +**Team:** {{issue.team}} +**State:** {{issue.state}} +**Priority:** {{issue.priority}} +**Assignee:** {{issue.assignee}} + +## Description + +{{issue.description}} + +## Instructions + +1. Read the issue description carefully and understand the requirements. +2. Explore the codebase to understand the relevant code and architecture. +3. Implement the changes described in the issue. +4. Write or update tests to cover the changes. +5. Run the test suite to ensure nothing is broken. +6. Commit your changes with a descriptive commit message referencing {{issue.identifier}}.`; diff --git a/apps/web/components/linear/linear-issue-watch-table.tsx b/apps/web/components/linear/linear-issue-watch-table.tsx new file mode 100644 index 0000000000..96de193788 --- /dev/null +++ b/apps/web/components/linear/linear-issue-watch-table.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { IconTrash, IconRefresh, IconPlayerPlay, IconPlayerPause } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Badge } from "@kandev/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useAppStore } from "@/components/state-provider"; +import type { LinearIssueWatch, LinearSearchFilter } from "@/lib/types/linear"; + +type LinearIssueWatchTableProps = { + watches: LinearIssueWatch[]; + // showWorkspace renders a Workspace column when the table aggregates rows + // from every workspace (install-wide settings page). + showWorkspace?: boolean; + onEdit: (watch: LinearIssueWatch) => void; + onDelete: (id: string) => void; + onTrigger: (id: string) => void; + onToggleEnabled: (watch: LinearIssueWatch) => void; +}; + +function formatLastPolled(dateStr?: string | null): string { + if (!dateStr) return "Never"; + const diff = Date.now() - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return "Just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +// summarizeFilter renders the structured filter as a short tag-style label +// the user can scan at a glance. Falls back to "(any)" when somehow empty — +// the backend rejects empty filters at create/update time, so this is just +// defensive for forward-compat. +function summarizeFilter(filter: LinearSearchFilter | undefined): string { + if (!filter) return "(any)"; + const parts: string[] = []; + if (filter.teamKey) parts.push(`team:${filter.teamKey}`); + if (filter.stateIds && filter.stateIds.length > 0) { + parts.push(`state:${filter.stateIds.length} selected`); + } + if (filter.assigned) parts.push(`assigned:${filter.assigned}`); + if (filter.query) parts.push(`q:"${filter.query}"`); + return parts.length === 0 ? "(any)" : parts.join(" · "); +} + +function WatchActions({ + watch, + onToggleEnabled, + onTrigger, + onDelete, +}: { + watch: LinearIssueWatch; + onToggleEnabled: (watch: LinearIssueWatch) => void; + onTrigger: (id: string) => void; + onDelete: (id: string) => void; +}) { + return ( +
+ + + + + {watch.enabled ? "Pause" : "Enable"} + + + + + + Check now + + + + + + Delete + +
+ ); +} + +export function LinearIssueWatchTable({ + watches, + showWorkspace, + onEdit, + onDelete, + onTrigger, + onToggleEnabled, +}: LinearIssueWatchTableProps) { + const workspaces = useAppStore((s) => s.workspaces.items); + const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; + + if (watches.length === 0) { + return ( +

+ No Linear watchers configured. Create one to auto-create tasks from filtered issues. +

+ ); + } + + return ( + + + + {showWorkspace && Workspace} + Filter + Interval + Last Polled + Status + Actions + + + + {watches.map((watch) => ( + onEdit(watch)}> + {showWorkspace && ( + + {workspaceName(watch.workspaceId)} + + )} + + {summarizeFilter(watch.filter)} + + + {Math.round(watch.pollIntervalSeconds / 60)}m + + + {formatLastPolled(watch.lastPolledAt)} + + + + {watch.enabled ? "Active" : "Paused"} + + + + + + + ))} + +
+ ); +} diff --git a/apps/web/components/linear/linear-issue-watchers-section.tsx b/apps/web/components/linear/linear-issue-watchers-section.tsx new file mode 100644 index 0000000000..8c627cb742 --- /dev/null +++ b/apps/web/components/linear/linear-issue-watchers-section.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { IconBellRinging, IconPlus } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent } from "@kandev/ui/card"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { useToast } from "@/components/toast-provider"; +import { useLinearIssueWatches } from "@/hooks/domains/linear/use-linear-issue-watches"; +import { LinearIssueWatchTable } from "./linear-issue-watch-table"; +import { LinearIssueWatchDialog } from "./linear-issue-watch-dialog"; +import type { LinearIssueWatch } from "@/lib/types/linear"; + +// LinearIssueWatchersSection lists watches across every workspace in a single +// flat table on the install-wide settings page. The dialog's create flow asks +// the user to pick the workspace; per-row mutations forward each watch's +// stored workspaceId so the backend's IDOR guard accepts them. +type RawActions = { + create: ReturnType["create"]; + update: ReturnType["update"]; + remove: ReturnType["remove"]; + trigger: ReturnType["trigger"]; +}; + +function useToastedActions({ create, update, remove, trigger }: RawActions) { + const { toast } = useToast(); + + const wrappedCreate = useCallback( + async (req: Parameters[0]) => { + try { + await create(req); + toast({ description: "Watcher created", variant: "success" }); + } catch (err) { + toast({ description: `Create failed: ${String(err)}`, variant: "error" }); + throw err; + } + }, + [create, toast], + ); + + const wrappedUpdate = useCallback( + async (id: string, req: Parameters[1], rowWorkspaceId: string) => { + try { + await update(id, req, rowWorkspaceId); + toast({ description: "Watcher updated", variant: "success" }); + } catch (err) { + toast({ description: `Update failed: ${String(err)}`, variant: "error" }); + throw err; + } + }, + [update, toast], + ); + + const wrappedDelete = useCallback( + async (w: LinearIssueWatch) => { + if (!confirm("Delete this Linear watcher?")) return; + try { + await remove(w.id, w.workspaceId); + toast({ description: "Watcher deleted", variant: "success" }); + } catch (err) { + toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); + } + }, + [remove, toast], + ); + + const wrappedTrigger = useCallback( + async (w: LinearIssueWatch) => { + try { + const res = await trigger(w.id, w.workspaceId); + const n = res?.newIssues ?? 0; + const description = + n > 0 ? `Found ${n} new issue(s) — tasks will appear shortly.` : "No new issues matched."; + toast({ description, variant: "success" }); + } catch (err) { + toast({ description: `Check failed: ${String(err)}`, variant: "error" }); + } + }, + [trigger, toast], + ); + + const toggleEnabled = useCallback( + async (w: LinearIssueWatch) => { + try { + await update(w.id, { enabled: !w.enabled }, w.workspaceId); + } catch (err) { + toast({ description: `Toggle failed: ${String(err)}`, variant: "error" }); + } + }, + [update, toast], + ); + + return { + create: wrappedCreate, + update: wrappedUpdate, + remove: wrappedDelete, + trigger: wrappedTrigger, + toggleEnabled, + }; +} + +export function LinearIssueWatchersSection() { + const { items, loading, create, update, remove, trigger } = useLinearIssueWatches(); + const actions = useToastedActions({ create, update, remove, trigger }); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + + const openCreate = useCallback(() => { + setEditing(null); + setDialogOpen(true); + }, []); + const openEdit = useCallback((w: LinearIssueWatch) => { + setEditing(w); + setDialogOpen(true); + }, []); + + // Adapt the watch-aware actions back to id-keyed callbacks the table expects; + // the table looks up the watch by id when it needs to forward the per-row + // workspaceId to mutations. + const handleDelete = useCallback( + (id: string) => { + const w = items.find((item) => item.id === id); + if (w) actions.remove(w); + }, + [items, actions], + ); + const handleTrigger = useCallback( + (id: string) => { + const w = items.find((item) => item.id === id); + if (w) actions.trigger(w); + }, + [items, actions], + ); + + return ( + } + title="Linear watchers" + description="Poll a Linear filter and auto-create a Kandev task for each newly-matching issue." + action={ + + } + > + + + {loading && items.length === 0 ? ( +

Loading…

+ ) : ( + + )} +
+
+ { + const w = editing; + if (!w) throw new Error("update without editing watch"); + return actions.update(id, req, w.workspaceId); + }} + /> +
+ ); +} diff --git a/apps/web/components/linear/linear-settings.tsx b/apps/web/components/linear/linear-settings.tsx index 7ca805f299..d7dcc35cfa 100644 --- a/apps/web/components/linear/linear-settings.tsx +++ b/apps/web/components/linear/linear-settings.tsx @@ -26,6 +26,7 @@ import { listLinearTeams, } from "@/lib/api/domains/linear-api"; import type { LinearConfig, LinearTeam, TestLinearConnectionResult } from "@/lib/types/linear"; +import { LinearIssueWatchersSection } from "./linear-issue-watchers-section"; type FormState = { defaultTeamKey: string; @@ -432,6 +433,7 @@ export function LinearIntegrationPage() { return (
+
); } diff --git a/apps/web/components/quick-chat/quick-chat-content.tsx b/apps/web/components/quick-chat/quick-chat-content.tsx index ec2cca3234..db8850680d 100644 --- a/apps/web/components/quick-chat/quick-chat-content.tsx +++ b/apps/web/components/quick-chat/quick-chat-content.tsx @@ -74,7 +74,14 @@ export const QuickChatContent = memo(function QuickChatContent({ handleCancelQueue, handleQueueEditComplete, } = state; - const { taskId, pendingClarification, isQueued, queuedMessage, updateQueueContent } = panelState; + const { + taskId, + pendingClarification, + pendingClarificationGroup, + isQueued, + queuedMessage, + updateQueueContent, + } = panelState; useEffect(() => { const timer = setTimeout(() => chatInputRef.current?.focusInput(), 50); @@ -111,7 +118,7 @@ export const QuickChatContent = memo(function QuickChatContent({ {pendingClarification && (
diff --git a/apps/web/components/quick-chat/quick-chat-modal.test.ts b/apps/web/components/quick-chat/quick-chat-modal.test.ts index 770880d1fe..54b0e8ed18 100644 --- a/apps/web/components/quick-chat/quick-chat-modal.test.ts +++ b/apps/web/components/quick-chat/quick-chat-modal.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; import { createUISlice } from "@/lib/state/slices/ui/ui-slice"; +import { getStoredQuickChatNames } from "@/lib/local-storage"; import type { UISlice } from "@/lib/state/slices/ui/types"; const SESSION_ID = "sess-1"; @@ -48,3 +49,31 @@ describe("openQuickChat agentProfileId persistence", () => { expect(findSession(store)?.agentProfileId).toBe(PROFILE_ID); }); }); + +describe("renameQuickChatSession local persistence", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("updates the session name in the store", () => { + const store = makeStore(); + store.getState().openQuickChat(SESSION_ID, WORKSPACE_ID); + store.getState().renameQuickChatSession(SESSION_ID, "My renamed chat"); + expect(findSession(store)?.name).toBe("My renamed chat"); + }); + + it("persists the rename to localStorage so it survives reload", () => { + const store = makeStore(); + store.getState().openQuickChat(SESSION_ID, WORKSPACE_ID); + store.getState().renameQuickChatSession(SESSION_ID, "Persisted name"); + expect(getStoredQuickChatNames()).toEqual({ [SESSION_ID]: "Persisted name" }); + }); + + it("does not write to storage when the session does not exist in state", () => { + const store = makeStore(); + expect(() => + store.getState().renameQuickChatSession("ghost-session", "Whatever"), + ).not.toThrow(); + expect(getStoredQuickChatNames()).toEqual({}); + }); +}); diff --git a/apps/web/components/quick-chat/quick-chat-modal.tsx b/apps/web/components/quick-chat/quick-chat-modal.tsx index 23c25d60fc..22cecbe515 100644 --- a/apps/web/components/quick-chat/quick-chat-modal.tsx +++ b/apps/web/components/quick-chat/quick-chat-modal.tsx @@ -4,11 +4,12 @@ import { memo } from "react"; import { useShallow } from "zustand/react/shallow"; import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog"; import { Button } from "@kandev/ui/button"; -import { IconLoader2, IconMessageCircle, IconPlus, IconX } from "@tabler/icons-react"; +import { IconLoader2, IconMessageCircle, IconPlus } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { PassthroughTerminal } from "@/components/task/passthrough-terminal"; import { QuickChatContent } from "./quick-chat-content"; import { QuickChatDeleteDialog } from "./quick-chat-delete-dialog"; +import { QuickChatTabItem } from "./quick-chat-tab-item"; import { useQuickChatModal } from "./use-quick-chat-modal"; type QuickChatModalProps = { @@ -21,12 +22,14 @@ function QuickChatTabs({ onTabChange, onTabClose, onNewChat, + onRename, }: { sessions: Array<{ sessionId: string; workspaceId: string; name?: string }>; activeSessionId: string; onTabChange: (sessionId: string) => void; onTabClose: (sessionId: string) => void; onNewChat: () => void; + onRename: (sessionId: string, name: string) => void; }) { if (sessions.length === 0) return null; @@ -34,34 +37,18 @@ function QuickChatTabs({
{sessions.map((s, index) => { - const isActive = s.sessionId === activeSessionId; // Show "New Chat" for empty session IDs (agent picker tabs) const tabName = s.sessionId === "" ? "New Chat" : s.name || `Chat ${index + 1}`; return ( -
- - -
+ name={tabName} + isActive={s.sessionId === activeSessionId} + isRenameable={s.sessionId !== ""} + onActivate={() => onTabChange(s.sessionId)} + onClose={() => onTabClose(s.sessionId)} + onRename={(name) => onRename(s.sessionId, name)} + /> ); })}
@@ -172,6 +159,7 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic handleSelectAgent, handleCloseTab, handleConfirmClose, + handleRename, } = useQuickChatModal(workspaceId); return ( @@ -189,6 +177,7 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic onTabChange={setActiveQuickChatSession} onTabClose={handleCloseTab} onNewChat={handleNewChat} + onRename={handleRename} /> {activeSessionId && !activeSessionNeedsAgent && ( diff --git a/apps/web/components/quick-chat/quick-chat-tab-item.test.tsx b/apps/web/components/quick-chat/quick-chat-tab-item.test.tsx new file mode 100644 index 0000000000..9f438668d6 --- /dev/null +++ b/apps/web/components/quick-chat/quick-chat-tab-item.test.tsx @@ -0,0 +1,84 @@ +import { afterEach, describe, it, expect, vi } from "vitest"; +import { cleanup, render, fireEvent } from "@testing-library/react"; +import { QuickChatTabItem } from "./quick-chat-tab-item"; + +afterEach(cleanup); + +function makeProps(overrides: Partial[0]> = {}) { + return { + name: "Original", + isActive: true, + isRenameable: true, + onActivate: vi.fn(), + onClose: vi.fn(), + onRename: vi.fn(), + ...overrides, + }; +} + +const RENAME_LABEL = "Rename chat"; + +function startEditing(label: HTMLElement) { + fireEvent.doubleClick(label); +} + +describe("QuickChatTabItem rename", () => { + it("commits the rename on Enter, calling onRename exactly once", () => { + const onRename = vi.fn(); + const { getByText, getByLabelText } = render(); + startEditing(getByText("Original")); + + const input = getByLabelText(RENAME_LABEL) as HTMLInputElement; + fireEvent.change(input, { target: { value: "New name" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onRename).toHaveBeenCalledTimes(1); + expect(onRename).toHaveBeenCalledWith("New name"); + }); + + it("discards the draft on Escape — onRename is NOT called even after blur fires on unmount", () => { + const onRename = vi.fn(); + const { getByText, getByLabelText } = render(); + startEditing(getByText("Original")); + + const input = getByLabelText(RENAME_LABEL) as HTMLInputElement; + fireEvent.change(input, { target: { value: "Should be discarded" } }); + fireEvent.keyDown(input, { key: "Escape" }); + + expect(onRename).not.toHaveBeenCalled(); + }); + + it("does not call onRename when the trimmed draft equals the original name", () => { + const onRename = vi.fn(); + const { getByText, getByLabelText } = render(); + startEditing(getByText("Original")); + + const input = getByLabelText(RENAME_LABEL) as HTMLInputElement; + fireEvent.change(input, { target: { value: " Original " } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onRename).not.toHaveBeenCalled(); + }); + + it("ignores Enter while IME composition is active", () => { + const onRename = vi.fn(); + const { getByText, getByLabelText } = render(); + startEditing(getByText("Original")); + + const input = getByLabelText(RENAME_LABEL) as HTMLInputElement; + fireEvent.change(input, { target: { value: "Composing candidate" } }); + // Enter pressed while IME is composing — should be a no-op (IME confirms candidate). + fireEvent.keyDown(input, { key: "Enter", isComposing: true }); + + expect(onRename).not.toHaveBeenCalled(); + }); + + it("does not enter edit mode when isRenameable is false", () => { + const { getByText, queryByLabelText } = render( + , + ); + startEditing(getByText("Original")); + + expect(queryByLabelText(RENAME_LABEL)).toBeNull(); + }); +}); diff --git a/apps/web/components/quick-chat/quick-chat-tab-item.tsx b/apps/web/components/quick-chat/quick-chat-tab-item.tsx new file mode 100644 index 0000000000..377a627c8d --- /dev/null +++ b/apps/web/components/quick-chat/quick-chat-tab-item.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { IconX } from "@tabler/icons-react"; + +type QuickChatTabItemProps = { + name: string; + isActive: boolean; + isRenameable: boolean; + onActivate: () => void; + onClose: () => void; + onRename: (name: string) => void; +}; + +/** Tab in the quick-chat modal. Double-click the label to rename (local-only). */ +export const QuickChatTabItem = memo(function QuickChatTabItem({ + name, + isActive, + isRenameable, + onActivate, + onClose, + onRename, +}: QuickChatTabItemProps) { + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(name); + const inputRef = useRef(null); + // Both Enter and Escape close edit mode by blurring the input so onBlur is + // the single commit path. Escape additionally sets this ref so commit knows + // to skip the rename — the blur fires synchronously with the typed draft + // still in the closure, and we'd otherwise rename to whatever the user typed. + const cancelledRef = useRef(false); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const commit = useCallback(() => { + if (cancelledRef.current) { + cancelledRef.current = false; + setIsEditing(false); + return; + } + const trimmed = draft.trim(); + if (trimmed && trimmed !== name) onRename(trimmed); + setIsEditing(false); + }, [draft, name, onRename]); + + const cancel = useCallback(() => { + cancelledRef.current = true; + setDraft(name); + inputRef.current?.blur(); + }, [name]); + + const handleStartEdit = useCallback(() => { + if (!isRenameable) return; + setDraft(name); + setIsEditing(true); + }, [isRenameable, name]); + + return ( +
+ {isEditing ? ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + // IME composition uses Enter to confirm a candidate; let the IME + // handle it instead of committing the rename. + if (e.nativeEvent.isComposing) return; + if (e.key === "Enter") { + e.preventDefault(); + // Trigger blur instead of calling commit() directly: the input + // unmount on setIsEditing(false) would otherwise fire onBlur and + // call commit() a second time. + inputRef.current?.blur(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancel(); + } + }} + className="px-2.5 py-1 text-xs bg-background border border-input rounded outline-none focus:ring-1 focus:ring-ring max-w-[160px]" + /> + ) : ( + + )} + +
+ ); +}); diff --git a/apps/web/components/quick-chat/use-quick-chat-modal.ts b/apps/web/components/quick-chat/use-quick-chat-modal.ts index b59ccff41a..a2da0e70f5 100644 --- a/apps/web/components/quick-chat/use-quick-chat-modal.ts +++ b/apps/web/components/quick-chat/use-quick-chat-modal.ts @@ -155,6 +155,14 @@ export function useQuickChatModal(workspaceId: string) { [reset, store], ); + const handleRename = useCallback( + (sessionId: string, name: string) => { + if (!sessionId) return; + store.renameQuickChatSession(sessionId, name); + }, + [store], + ); + const handleConfirmClose = useCallback(async () => { if (!sessionToClose) return; const sessionId = sessionToClose; @@ -188,5 +196,6 @@ export function useQuickChatModal(workspaceId: string) { handleSelectAgent, handleCloseTab, handleConfirmClose, + handleRename, }; } diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx index d08f0f2906..ad012afdac 100644 --- a/apps/web/components/settings/settings-app-sidebar.tsx +++ b/apps/web/components/settings/settings-app-sidebar.tsx @@ -13,6 +13,7 @@ import { IconKey, IconMessageCircle, IconBrandGithub, + IconBrandSlack, IconHexagon, IconSparkles, IconWand, @@ -152,6 +153,7 @@ function IntegrationsSidebarSection({ pathname }: { pathname: string }) { { href: "/settings/integrations/github", label: "GitHub", Icon: IconBrandGithub }, { href: "/settings/integrations/jira", label: "Jira", Icon: IconTicket }, { href: "/settings/integrations/linear", label: "Linear", Icon: IconHexagon }, + { href: "/settings/integrations/slack", label: "Slack", Icon: IconBrandSlack }, ]; return ( diff --git a/apps/web/components/slack/slack-settings.tsx b/apps/web/components/slack/slack-settings.tsx new file mode 100644 index 0000000000..2988793b26 --- /dev/null +++ b/apps/web/components/slack/slack-settings.tsx @@ -0,0 +1,597 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { IconBrandSlack } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent } from "@kandev/ui/card"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Separator } from "@kandev/ui/separator"; +import { Alert, AlertDescription } from "@kandev/ui/alert"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Switch } from "@kandev/ui/switch"; +import { useToast } from "@/components/toast-provider"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { useSlackEnabled } from "@/hooks/domains/slack/use-slack-enabled"; +import { + IntegrationAuthStatusBanner, + type IntegrationAuthHealth, +} from "@/components/integrations/auth-status-banner"; +import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability"; +import { + getSlackConfig, + setSlackConfig, + deleteSlackConfig, + testSlackConnection, +} from "@/lib/api/domains/slack-api"; +import { listUtilityAgents, type UtilityAgent } from "@/lib/api/domains/utility-api"; +import type { SlackConfig, TestSlackConnectionResult } from "@/lib/types/slack"; + +const DEFAULT_PREFIX = "!kandev"; +const DEFAULT_POLL_INTERVAL_SECONDS = 30; +const MIN_POLL_INTERVAL_SECONDS = 5; +const MAX_POLL_INTERVAL_SECONDS = 600; + +type FormState = { + utilityAgentId: string; + commandPrefix: string; + pollIntervalSeconds: number; + token: string; + cookie: string; +}; + +const emptyForm: FormState = { + utilityAgentId: "", + commandPrefix: DEFAULT_PREFIX, + pollIntervalSeconds: DEFAULT_POLL_INTERVAL_SECONDS, + token: "", + cookie: "", +}; + +function configToForm(cfg: SlackConfig | null): FormState { + if (!cfg) return emptyForm; + return { + utilityAgentId: cfg.utilityAgentId, + commandPrefix: cfg.commandPrefix || DEFAULT_PREFIX, + pollIntervalSeconds: cfg.pollIntervalSeconds || DEFAULT_POLL_INTERVAL_SECONDS, + token: "", + cookie: "", + }; +} + +function configToHealth(config: SlackConfig | null): IntegrationAuthHealth | null { + if (!config?.hasToken || !config.hasCookie) return null; + if (!config.lastCheckedAt) return { ok: false, error: "", checkedAt: null }; + return { + ok: !!config.lastOk, + error: config.lastError ?? "", + checkedAt: new Date(config.lastCheckedAt), + }; +} + +function saveLabel(saving: boolean, hasConfig: boolean): string { + if (saving) return "Saving..."; + return hasConfig ? "Update" : "Save"; +} + +type SecretFieldsProps = { + form: FormState; + loading: boolean; + hasSavedToken: boolean; + hasSavedCookie: boolean; + update: (key: K, value: FormState[K]) => void; +}; + +function SecretFields({ form, loading, hasSavedToken, hasSavedCookie, update }: SecretFieldsProps) { + return ( +
+
+ + update("token", e.target.value)} + disabled={loading} + /> +
+
+ + update("cookie", e.target.value)} + disabled={loading} + /> +

+ Open Slack in your browser, copy the value of the `d` cookie and the `xoxc-` token from + local storage. Both are required. +

+
+
+ ); +} + +type UtilityAgentPickerProps = { + form: FormState; + loading: boolean; + agents: UtilityAgent[]; + loadingAgents: boolean; + update: (key: K, value: FormState[K]) => void; +}; + +function utilityAgentPlaceholder(agents: UtilityAgent[], loading: boolean): string { + if (loading) return "Loading…"; + if (agents.length === 0) return "Create one in Settings → Utility agents"; + return "Choose a utility agent"; +} + +function isAgentSelectable(a: UtilityAgent): boolean { + if (a.builtin) return true; + return a.enabled && !!a.agent_id && !!a.model; +} + +function modelSuffix(a: UtilityAgent): string { + if (a.model) return ` (${a.model})`; + if (a.builtin) return " (uses default model)"; + return ""; +} + +function utilityAgentLabel(a: UtilityAgent): string { + const base = `${a.name}${modelSuffix(a)}`; + if (a.builtin) return base; + if (!a.enabled) return `${base} — disabled`; + if (!a.agent_id || !a.model) return `${base} — not configured`; + return base; +} + +function UtilityAgentPicker({ + form, + loading, + agents, + loadingAgents, + update, +}: UtilityAgentPickerProps) { + return ( +
+ + +

+ The utility agent that interprets each Slack message and creates the Kandev task. It runs + with Kandev MCP tools wired in (list_workspaces_kandev, create_task_kandev, …) so it picks + the destination Kandev workspace + workflow + repo from context. Built-in agents use your + default model from{" "} + + Settings → Utility agents + + . +

+

+ Custom prompts can reference {"{{SlackInstruction}}"},{" "} + {"{{SlackThread}}"}, {"{{SlackPermalink}}"},{" "} + {"{{SlackUser}}"}, {"{{SlackChannelID}}"}, and{" "} + {"{{SlackTS}}"}. When at least one is used, your template owns the full prompt; + otherwise the default Slack-triage system prompt is prepended automatically. +

+
+ ); +} + +function PrefixField({ + form, + loading, + update, +}: { + form: FormState; + loading: boolean; + update: (key: K, value: FormState[K]) => void; +}) { + return ( +
+ + update("commandPrefix", e.target.value)} + disabled={loading} + /> +

+ Messages you write in Slack starting with this prefix become Kandev tasks. Default:{" "} + {DEFAULT_PREFIX} <instruction>. +

+
+ ); +} + +function PollIntervalField({ + form, + loading, + update, +}: { + form: FormState; + loading: boolean; + update: (key: K, value: FormState[K]) => void; +}) { + return ( +
+ + { + const n = Number(e.target.value); + update("pollIntervalSeconds", Number.isFinite(n) ? n : DEFAULT_POLL_INTERVAL_SECONDS); + }} + disabled={loading} + /> +

+ How often Slack is checked for new {form.commandPrefix || DEFAULT_PREFIX}{" "} + messages. Lower = more responsive, higher = fewer Slack API calls. Range:{" "} + {MIN_POLL_INTERVAL_SECONDS}–{MAX_POLL_INTERVAL_SECONDS}s. Default:{" "} + {DEFAULT_POLL_INTERVAL_SECONDS}s. +

+
+ ); +} + +function TestResultAlert({ result }: { result: TestSlackConnectionResult | null }) { + if (!result) return null; + const teamSuffix = result.teamName ? ` (${result.teamName})` : ""; + return ( + + + {result.ok + ? `Connected as ${result.displayName || result.userId}${teamSuffix}` + : `Failed: ${result.error}`} + + + ); +} + +function UnsupportedWarning() { + return ( + + + Browser session auth (unsupported): Slack rotates session cookies often, so + you may need to reconnect when authentication expires. Bot installs and user OAuth are on + the roadmap. + + + ); +} + +type ActionBarProps = { + saving: boolean; + testing: boolean; + deleting: boolean; + loading: boolean; + hasConfig: boolean; + disableSave: boolean; + disableTest: boolean; + onTest: () => void; + onSave: () => void; + onDelete: () => void; +}; + +function ActionBar({ + saving, + testing, + deleting, + loading, + hasConfig, + disableSave, + disableTest, + onTest, + onSave, + onDelete, +}: ActionBarProps) { + return ( +
+ + + {hasConfig && ( + + )} +
+ ); +} + +function useUtilityAgentsLoader() { + const [agents, setAgents] = useState(null); + useEffect(() => { + let cancelled = false; + listUtilityAgents({ cache: "no-store" }) + .then((res) => { + if (!cancelled) setAgents(res.agents ?? []); + }) + .catch(() => { + if (!cancelled) setAgents([]); + }); + return () => { + cancelled = true; + }; + }, []); + return { agents: agents ?? [], loadingAgents: agents === null }; +} + +type SettingsActionsArgs = { + form: FormState; + setConfig: (cfg: SlackConfig | null) => void; + setForm: (form: FormState) => void; + setTestResult: (r: TestSlackConnectionResult | null) => void; +}; + +function useSettingsActions({ form, setConfig, setForm, setTestResult }: SettingsActionsArgs) { + const { toast } = useToast(); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [deleting, setDeleting] = useState(false); + + const handleTest = useCallback(async () => { + setTesting(true); + setTestResult(null); + try { + const res = await testSlackConnection({ + authMethod: "cookie", + utilityAgentId: form.utilityAgentId, + commandPrefix: form.commandPrefix, + pollIntervalSeconds: form.pollIntervalSeconds, + token: form.token || undefined, + cookie: form.cookie || undefined, + }); + setTestResult(res); + } catch (err) { + setTestResult({ ok: false, error: String(err) }); + } finally { + setTesting(false); + } + }, [form, setTestResult]); + + const handleSave = useCallback(async () => { + setSaving(true); + try { + const saved = await setSlackConfig({ + authMethod: "cookie", + utilityAgentId: form.utilityAgentId, + commandPrefix: form.commandPrefix, + pollIntervalSeconds: form.pollIntervalSeconds, + token: form.token || undefined, + cookie: form.cookie || undefined, + }); + setConfig(saved); + setForm(configToForm(saved)); + setTestResult(null); + toast({ description: "Slack configuration saved", variant: "success" }); + } catch (err) { + toast({ description: `Save failed: ${String(err)}`, variant: "error" }); + } finally { + setSaving(false); + } + }, [form, toast, setConfig, setForm, setTestResult]); + + const handleDelete = useCallback(async () => { + if (!confirm("Remove Slack configuration?")) return; + setDeleting(true); + try { + await deleteSlackConfig(); + setConfig(null); + setForm(emptyForm); + setTestResult(null); + toast({ description: "Slack configuration removed", variant: "success" }); + } catch (err) { + toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); + } finally { + setDeleting(false); + } + }, [toast, setConfig, setForm, setTestResult]); + + return { saving, testing, deleting, handleTest, handleSave, handleDelete }; +} + +function useSlackSettings() { + const { toast } = useToast(); + const [config, setConfig] = useState(null); + const [form, setForm] = useState(emptyForm); + const [loading, setLoading] = useState(true); + const [testResult, setTestResult] = useState(null); + const health = configToHealth(config); + const { agents, loadingAgents } = useUtilityAgentsLoader(); + + const load = useCallback(async () => { + setLoading(true); + try { + const cfg = await getSlackConfig(); + setConfig(cfg); + setForm(configToForm(cfg)); + } catch (err) { + toast({ description: `Failed to load Slack config: ${String(err)}`, variant: "error" }); + } finally { + setLoading(false); + } + }, [toast]); + + useEffect(() => { + void load(); + }, [load]); + + // Background refresh so the auth-health banner picks up new probe results. + useEffect(() => { + const id = setInterval(() => { + getSlackConfig() + .then((cfg) => setConfig(cfg)) + .catch(() => { + /* transient failures are fine — next tick retries */ + }); + }, INTEGRATION_STATUS_REFRESH_MS); + return () => clearInterval(id); + }, []); + + const update = useCallback( + (key: K, value: FormState[K]) => + setForm((prev) => ({ ...prev, [key]: value })), + [], + ); + + const { saving, testing, deleting, handleTest, handleSave, handleDelete } = useSettingsActions({ + form, + setConfig, + setForm, + setTestResult, + }); + + return { + config, + form, + loading, + saving, + testing, + deleting, + testResult, + health, + agents, + loadingAgents, + update, + handleTest, + handleSave, + handleDelete, + }; +} + +function EnabledPill() { + const { enabled, setEnabled } = useSlackEnabled(); + return ( +
+ + +
+ ); +} + +export function SlackConnectionSection() { + const s = useSlackSettings(); + const missingSecrets = + (!s.config?.hasToken && !s.form.token) || (!s.config?.hasCookie && !s.form.cookie); + const missingAgent = !s.form.utilityAgentId; + const disableSave = s.saving || missingSecrets || missingAgent; + const disableTest = missingSecrets; + + return ( + } + title="Slack integration" + description="Capture Slack threads as Kandev tasks. Type !kandev in any thread you can see and the configured utility agent picks the workspace/workflow/repo and creates the task." + action={} + > + + + + + + + + + + + + + + + + ); +} + +export function SlackIntegrationPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/components/task-create-dialog-footer.tsx b/apps/web/components/task-create-dialog-footer.tsx index 01b6da4217..2173ce582e 100644 --- a/apps/web/components/task-create-dialog-footer.tsx +++ b/apps/web/components/task-create-dialog-footer.tsx @@ -108,6 +108,7 @@ function StartTaskSplitButton({ {isEditMode ? "Update task" : "Create without starting agent"} diff --git a/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts b/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts index 95321d64fe..aee02224a6 100644 --- a/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts +++ b/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts @@ -17,9 +17,9 @@ describe("buildRepositoriesPayload — unified rows", () => { discoveredRepositories: [], }); expect(payload).toEqual([ - { repository_id: "repo-front", base_branch: "main" }, - { repository_id: "repo-back", base_branch: "develop" }, - { repository_id: "repo-shared", base_branch: undefined }, + { repository_id: "repo-front", base_branch: "main", checkout_branch: undefined }, + { repository_id: "repo-back", base_branch: "develop", checkout_branch: undefined }, + { repository_id: "repo-shared", base_branch: undefined, checkout_branch: undefined }, ]); }); @@ -42,13 +42,156 @@ describe("buildRepositoriesPayload — unified rows", () => { { repository_id: "", base_branch: "trunk", + checkout_branch: undefined, local_path: "/home/me/projects/local-project", default_branch: "trunk", }, - { repository_id: "repo-back", base_branch: "main" }, + { repository_id: "repo-back", base_branch: "main", checkout_branch: undefined }, + ]); + }); +}); + +// Regression for the "new branch on local executor" bug: the chip's branch +// is the working branch on disk (e.g. "feature/x"), not the integration +// branch. We must send it as `checkout_branch`, with `base_branch` anchored +// to the repo's `default_branch`. Without this, agentctl recomputes +// merge-base(HEAD, origin/feature/x) which collapses to HEAD and the +// changes panel is empty after refresh. +describe("buildRepositoriesPayload — local executor branch split (core)", () => { + it("rowBranch != default_branch → swap into checkout_branch", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [{ key: "r0", repositoryId: "repo-1", branch: "feature/x" }], + discoveredRepositories: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + workspaceRepositories: [{ id: "repo-1", default_branch: "main" }] as any, + isLocalExecutor: true, + }); + expect(payload).toEqual([ + { repository_id: "repo-1", base_branch: "main", checkout_branch: "feature/x" }, ]); }); + it("rowBranch matches default_branch → no checkout_branch", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [{ key: "r0", repositoryId: "repo-1", branch: "main" }], + discoveredRepositories: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + workspaceRepositories: [{ id: "repo-1", default_branch: "main" }] as any, + isLocalExecutor: true, + }); + expect(payload).toEqual([ + { repository_id: "repo-1", base_branch: "main", checkout_branch: undefined }, + ]); + }); + + it("localPath row uses discoveredRepositories.default_branch", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [{ key: "r0", localPath: "/p/r", branch: "feature/y" }], + discoveredRepositories: [ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { path: "/p/r", default_branch: "main" } as any, + ], + isLocalExecutor: true, + }); + expect(payload).toEqual([ + { + repository_id: "", + base_branch: "main", + checkout_branch: "feature/y", + local_path: "/p/r", + default_branch: "main", + }, + ]); + }); +}); + +describe("buildRepositoriesPayload — local executor branch split (edge cases)", () => { + it("fresh-branch flow: skips the split so the picked base is preserved as base_branch", () => { + // When the user enables "Fork a new branch", the chip's branch is the + // BASE TO FORK FROM (e.g. "develop"), not a working branch. The backend + // creates a new branch from that base and rewrites base_branch to the + // new branch name. If we split here, base_branch would land on the + // repo's default ("main") and the fork would happen from main instead + // of develop — silently wrong. + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [{ key: "r0", repositoryId: "repo-1", branch: "develop" }], + discoveredRepositories: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + workspaceRepositories: [{ id: "repo-1", default_branch: "main" }] as any, + isLocalExecutor: true, + freshBranch: { confirmDiscard: false, consentedDirtyFiles: [] }, + }); + expect(payload).toEqual([ + { + repository_id: "repo-1", + base_branch: "develop", + checkout_branch: undefined, + fresh_branch: true, + confirm_discard: false, + consented_dirty_files: [], + }, + ]); + }); + + it("falls through when default_branch is unknown (legacy repos)", () => { + // Repos created before the backend probe fix may have an unset + // default_branch in the workspace store. If we synthesize base_branch= + // rowBranch here (as the original draft did), we reproduce the very bug + // this PR fixes: agentctl recomputes merge-base(HEAD, origin/) + // → collapses to HEAD → empty changes panel. Better to leave the legacy + // shape alone — the next backend createRepository call will populate + // default_branch via the gitref probe. + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [{ key: "r0", repositoryId: "repo-1", branch: "feature/x" }], + discoveredRepositories: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + workspaceRepositories: [{ id: "repo-1", default_branch: "" }] as any, + isLocalExecutor: true, + }); + expect(payload).toEqual([ + { repository_id: "repo-1", base_branch: "feature/x", checkout_branch: undefined }, + ]); + }); + + it("non-local executor leaves rowBranch as base_branch (worktree flow unchanged)", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [{ key: "r0", repositoryId: "repo-1", branch: "main" }], + discoveredRepositories: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + workspaceRepositories: [{ id: "repo-1", default_branch: "main" }] as any, + isLocalExecutor: false, + }); + expect(payload).toEqual([ + { repository_id: "repo-1", base_branch: "main", checkout_branch: undefined }, + ]); + }); +}); + +describe("buildRepositoriesPayload — single-row and URL mode", () => { it("URL mode produces a single github_url entry and ignores the rows", () => { const payload = buildRepositoriesPayload({ useGitHubUrl: true, @@ -77,6 +220,8 @@ describe("buildRepositoriesPayload — unified rows", () => { repositories: [{ key: "r0", repositoryId: "repo-only", branch: "main" }], discoveredRepositories: [], }); - expect(payload).toEqual([{ repository_id: "repo-only", base_branch: "main" }]); + expect(payload).toEqual([ + { repository_id: "repo-only", base_branch: "main", checkout_branch: undefined }, + ]); }); }); diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index 51cf63fd97..6cde8bd3e4 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -1,5 +1,5 @@ import type { useRouter } from "next/navigation"; -import type { Task, Branch, LocalRepository } from "@/lib/types/http"; +import type { Task, Branch, LocalRepository, Repository } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; import type { AppState } from "@/lib/state/store"; import type { StepType, TaskRepoRow } from "@/components/task-create-dialog-types"; @@ -172,6 +172,18 @@ export function buildRepositoriesPayload(opts: { repositories: TaskRepoRow[]; /** Used to look up `default_branch` for `localPath` rows. */ discoveredRepositories: LocalRepository[]; + /** Workspace repositories — used to look up `default_branch` for `repositoryId` rows. */ + workspaceRepositories?: Repository[]; + /** + * For the local executor (no worktree), the chip's branch field represents + * the working branch on disk, not the parent integration branch. Send it as + * `checkout_branch` so the session's `base_branch` stays anchored to the + * repo's `default_branch` (which is what git log / cumulative diff use as + * the merge-base reference). Without this, "new branch on local executor" + * collapses the merge-base recomputation to HEAD and the changes panel + * goes empty after a refresh. + */ + isLocalExecutor?: boolean; /** * Optional fresh-branch metadata. The UI gates this to single-row + local * executor; when present we apply it to every row (which is at most one). @@ -195,23 +207,81 @@ export function buildRepositoriesPayload(opts: { consented_dirty_files: opts.freshBranch.consentedDirtyFiles, } : {}; + // Fresh-branch flow inverts the chip's semantics: instead of "the working + // branch I'm on", row.branch becomes "the base I want to fork from". The + // backend then creates a new branch and rewrites repos[i].BaseBranch to it. + // Splitting here would force `base_branch=default_branch, checkout_branch= + // ` and the backend would fork from the wrong base. Skip the + // split entirely when fresh-branch is active. + const isLocalExecutor = !!opts.isLocalExecutor && !opts.freshBranch; return opts.repositories .filter((row) => row.repositoryId || row.localPath) .map((row) => { + const defaultBranch = resolveRowDefaultBranch(row, opts); + const branches = splitLocalExecutorBranches({ + rowBranch: row.branch, + defaultBranch, + isLocalExecutor, + }); if (row.repositoryId) { return { repository_id: row.repositoryId, - base_branch: row.branch || undefined, + base_branch: branches.base_branch, + checkout_branch: branches.checkout_branch, ...fresh, }; } - const local = opts.discoveredRepositories.find((d) => d.path === row.localPath); return { repository_id: "", - base_branch: row.branch || undefined, + base_branch: branches.base_branch, + checkout_branch: branches.checkout_branch, local_path: row.localPath, - default_branch: local?.default_branch || undefined, + default_branch: defaultBranch || undefined, ...fresh, }; }); } + +function resolveRowDefaultBranch( + row: TaskRepoRow, + opts: { + discoveredRepositories: LocalRepository[]; + workspaceRepositories?: Repository[]; + }, +): string | undefined { + if (row.repositoryId) { + return opts.workspaceRepositories?.find((r) => r.id === row.repositoryId)?.default_branch; + } + return opts.discoveredRepositories.find((d) => d.path === row.localPath)?.default_branch; +} + +/** + * For the local executor: return `base_branch=defaultBranch`, + * `checkout_branch=rowBranch` so the session anchors merge-base to the repo's + * integration branch while the preparer still checks out the user's working + * branch. When `rowBranch === defaultBranch` we omit checkout_branch — the + * preparer treats matching values as "use current state" and skips git ops. + * + * For non-local executors (worktree-based): keep the historical shape where + * `base_branch=rowBranch` (the worktree creates a new branch off of it). + */ +function splitLocalExecutorBranches(args: { + rowBranch?: string; + defaultBranch?: string; + isLocalExecutor: boolean; +}): { base_branch: string | undefined; checkout_branch: string | undefined } { + // Without a known default_branch we can't anchor base_branch to the + // integration ref — and using rowBranch as a stand-in reproduces the + // exact bug this PR fixes (changes panel collapses to HEAD on refresh). + // Fall through to the legacy non-split shape: a workspace repo with an + // unset default_branch is no worse off than before, and the backend's + // resolveRepoInput probe will populate it on the next CreateRepository + // call. Wait for that probe rather than synthesizing a guess here. + if (!args.isLocalExecutor || !args.defaultBranch) { + return { base_branch: args.rowBranch || undefined, checkout_branch: undefined }; + } + const base = args.defaultBranch; + const checkout = + args.rowBranch && args.rowBranch !== args.defaultBranch ? args.rowBranch : undefined; + return { base_branch: base, checkout_branch: checkout }; +} diff --git a/apps/web/components/task-create-dialog-setup.tsx b/apps/web/components/task-create-dialog-setup.tsx index 61fe021bc3..589f18e778 100644 --- a/apps/web/components/task-create-dialog-setup.tsx +++ b/apps/web/components/task-create-dialog-setup.tsx @@ -144,6 +144,7 @@ type SubmitWiringArgs = { props: TaskCreateDialogProps; fs: ReturnType; computed: ReturnType["computed"]; + workspaceRepositories: ReturnType["repositories"]; repositoryLocalPath: string; isSessionMode: boolean; isEditMode: boolean; @@ -153,6 +154,7 @@ function useSubmitHandlersWiring({ props, fs, computed, + workspaceRepositories, repositoryLocalPath, isSessionMode, isEditMode, @@ -171,6 +173,7 @@ function useSubmitHandlersWiring({ effectiveDefaultStepId: computed.effectiveDefaultStepId, repositories: fs.repositories, discoveredRepositories: fs.discoveredRepositories, + workspaceRepositories, useGitHubUrl: fs.useGitHubUrl, githubUrl: fs.githubUrl, githubPrHeadBranch: fs.githubPrHeadBranch, @@ -256,6 +259,7 @@ export function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { props, fs, computed, + workspaceRepositories: repositories, repositoryLocalPath, isSessionMode, isEditMode, diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx index 2717feba53..eec89f6345 100644 --- a/apps/web/components/task-create-dialog-submit.tsx +++ b/apps/web/components/task-create-dialog-submit.tsx @@ -33,6 +33,7 @@ export function useTaskSubmitHandlers({ effectiveDefaultStepId, repositories, discoveredRepositories, + workspaceRepositories, useGitHubUrl, githubUrl, githubPrHeadBranch, @@ -128,6 +129,8 @@ export function useTaskSubmitHandlers({ githubPrHeadBranch, repositories, discoveredRepositories, + workspaceRepositories, + isLocalExecutor, freshBranch: buildFreshBranchPayload(consentedDirtyFiles), }), // buildFreshBranchPayload is a closure over current scope; lint exception kept narrow. @@ -139,6 +142,8 @@ export function useTaskSubmitHandlers({ githubPrHeadBranch, repositories, discoveredRepositories, + workspaceRepositories, + isLocalExecutor, isFreshBranchActive, ], ); diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index 2e9d225877..586acf715b 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -227,6 +227,8 @@ export type SubmitHandlersDeps = { repositories: TaskRepoRow[]; /** All on-machine discovered repos — used to look up `default_branch` for `localPath` rows. */ discoveredRepositories: LocalRepository[]; + /** Workspace repositories — used to look up `default_branch` for `repositoryId` rows. */ + workspaceRepositories: Repository[]; useGitHubUrl: boolean; githubUrl: string; githubPrHeadBranch: string | null; diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index c2a7a918fc..45e66b943c 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -343,6 +343,7 @@ type SubmitWiringArgs = { props: TaskCreateDialogProps; fs: ReturnType; computed: ReturnType["computed"]; + workspaceRepositories: ReturnType["repositories"]; repositoryLocalPath: string; isSessionMode: boolean; isEditMode: boolean; @@ -352,6 +353,7 @@ function useSubmitHandlersWiring({ props, fs, computed, + workspaceRepositories, repositoryLocalPath, isSessionMode, isEditMode, @@ -370,6 +372,7 @@ function useSubmitHandlersWiring({ effectiveDefaultStepId: computed.effectiveDefaultStepId, repositories: fs.repositories, discoveredRepositories: fs.discoveredRepositories, + workspaceRepositories, useGitHubUrl: fs.useGitHubUrl, githubUrl: fs.githubUrl, githubPrHeadBranch: fs.githubPrHeadBranch, @@ -460,6 +463,7 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { props, fs, computed, + workspaceRepositories: repositories, repositoryLocalPath, isSessionMode, isEditMode, diff --git a/apps/web/components/task/__tests__/use-tab-maximize.test.ts b/apps/web/components/task/__tests__/use-tab-maximize.test.ts new file mode 100644 index 0000000000..6619665048 --- /dev/null +++ b/apps/web/components/task/__tests__/use-tab-maximize.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; + +const SIDEBAR_GROUP_ID = "sidebar-group"; + +const mockMaximizeGroup = vi.fn(); +const mockExitMaximizedLayout = vi.fn(); + +const storeState: { + preMaximizeLayout: object | null; + sidebarGroupId: string; + isRestoringLayout: boolean; + maximizeGroup: typeof mockMaximizeGroup; + exitMaximizedLayout: typeof mockExitMaximizedLayout; +} = { + preMaximizeLayout: null, + sidebarGroupId: SIDEBAR_GROUP_ID, + isRestoringLayout: false, + maximizeGroup: mockMaximizeGroup, + exitMaximizedLayout: mockExitMaximizedLayout, +}; + +vi.mock("@/lib/state/dockview-store", () => ({ + useDockviewStore: Object.assign( + (selector: (state: typeof storeState) => unknown) => selector(storeState), + { getState: () => storeState }, + ), +})); + +import { useTabMaximizeOnDoubleClick } from "../use-tab-maximize"; + +function makeApi(groupId: string | undefined) { + return { group: groupId === undefined ? undefined : { id: groupId } } as Parameters< + typeof useTabMaximizeOnDoubleClick + >[0]; +} + +function makeEvent() { + return { + stopPropagation: vi.fn(), + preventDefault: vi.fn(), + } as unknown as React.MouseEvent; +} + +describe("useTabMaximizeOnDoubleClick", () => { + beforeEach(() => { + mockMaximizeGroup.mockClear(); + mockExitMaximizedLayout.mockClear(); + storeState.preMaximizeLayout = null; + storeState.sidebarGroupId = SIDEBAR_GROUP_ID; + storeState.isRestoringLayout = false; + }); + + it("calls maximizeGroup when not currently maximized", () => { + const { result } = renderHook(() => useTabMaximizeOnDoubleClick(makeApi("group-a"))); + result.current(makeEvent()); + expect(mockMaximizeGroup).toHaveBeenCalledWith("group-a"); + expect(mockExitMaximizedLayout).not.toHaveBeenCalled(); + }); + + it("calls exitMaximizedLayout when currently maximized", () => { + storeState.preMaximizeLayout = { columns: [] }; + const { result } = renderHook(() => useTabMaximizeOnDoubleClick(makeApi("group-a"))); + result.current(makeEvent()); + expect(mockExitMaximizedLayout).toHaveBeenCalledTimes(1); + expect(mockMaximizeGroup).not.toHaveBeenCalled(); + }); + + it("no-ops when groupId is the sidebar group", () => { + const { result } = renderHook(() => useTabMaximizeOnDoubleClick(makeApi(SIDEBAR_GROUP_ID))); + result.current(makeEvent()); + expect(mockMaximizeGroup).not.toHaveBeenCalled(); + expect(mockExitMaximizedLayout).not.toHaveBeenCalled(); + }); + + it("no-ops while a layout restore is in progress", () => { + storeState.isRestoringLayout = true; + const { result } = renderHook(() => useTabMaximizeOnDoubleClick(makeApi("group-a"))); + result.current(makeEvent()); + expect(mockMaximizeGroup).not.toHaveBeenCalled(); + expect(mockExitMaximizedLayout).not.toHaveBeenCalled(); + }); + + it("no-ops when api.group is missing", () => { + const { result } = renderHook(() => useTabMaximizeOnDoubleClick(makeApi(undefined))); + result.current(makeEvent()); + expect(mockMaximizeGroup).not.toHaveBeenCalled(); + }); + + it("reads groupId fresh at call time, not from render-time closure", () => { + const api = makeApi("group-a"); + const { result } = renderHook(() => useTabMaximizeOnDoubleClick(api)); + // Simulate dockview reassigning the panel's group after layout rebuild. + (api.group as { id: string }).id = "group-b"; + result.current(makeEvent()); + expect(mockMaximizeGroup).toHaveBeenCalledWith("group-b"); + }); + + it("stops the event from propagating to the underlying tab", () => { + const event = makeEvent(); + const { result } = renderHook(() => useTabMaximizeOnDoubleClick(makeApi("group-a"))); + result.current(event); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/task/changes-panel-helpers.ts b/apps/web/components/task/changes-panel-helpers.ts index 56fb1a4392..f139fe2e11 100644 --- a/apps/web/components/task/changes-panel-helpers.ts +++ b/apps/web/components/task/changes-panel-helpers.ts @@ -149,10 +149,15 @@ type MergedCommit = { }; /** - * Merge local session commits and PR commits into a single list. Local - * commits matched to a PR commit are marked pushed; unmatched are unpushed. - * PR-only commits (e.g. from other contributors) are appended as pushed. - * Order: unpushed first, then pushed. + * Merge local session commits and PR commits into a single list. A commit is + * pushed when EITHER source confirms it: the backend's `pushed` field + * (computed from the upstream tracking ref) or a SHA match in `prCommits` (PR + * existence implies the commit is on the remote). The git signal fixes the + * original bug — pushed commits showing as unpushed when no PR exists — while + * keeping the PR signal so a commit visible only via a PR (e.g. authored by + * another contributor, or a rebased SHA the local repo doesn't track yet) + * still renders as pushed. PR commits not represented locally are appended + * as pushed. Order: unpushed first, then pushed. */ export function mergeCommits( localCommits: { @@ -162,24 +167,28 @@ export function mergeCommits( deletions: number; /** Multi-repo: name of the repo this commit was made in. */ repository_name?: string; + pushed?: boolean; }[], prCommits: { sha: string; message: string; additions: number; deletions: number }[], ): MergedCommit[] { - const matchesPR = (localSha: string) => - prCommits.some((pr) => pr.sha.startsWith(localSha) || localSha.startsWith(pr.sha)); + const shaMatches = (a: string, b: string) => a.startsWith(b) || b.startsWith(a); const unpushed: MergedCommit[] = []; const pushed: MergedCommit[] = []; const matchedPRShas = new Set(); for (const c of localCommits) { - if (matchesPR(c.commit_sha)) { + const matchesPR = prCommits.some((pr) => shaMatches(pr.sha, c.commit_sha)); + const isPushed = c.pushed === true || matchesPR; + if (isPushed) { pushed.push({ ...c, pushed: true }); + } else { + unpushed.push({ ...c, pushed: false }); + } + if (matchesPR) { for (const pr of prCommits) { - if (pr.sha.startsWith(c.commit_sha) || c.commit_sha.startsWith(pr.sha)) { + if (shaMatches(pr.sha, c.commit_sha)) { matchedPRShas.add(pr.sha); } } - } else { - unpushed.push({ ...c, pushed: false }); } } for (const pr of prCommits) { diff --git a/apps/web/components/task/changes-panel.test.ts b/apps/web/components/task/changes-panel.test.ts index 8b714a75c6..3eb01404de 100644 --- a/apps/web/components/task/changes-panel.test.ts +++ b/apps/web/components/task/changes-panel.test.ts @@ -71,7 +71,36 @@ describe("mergeCommits", () => { ]); }); - it("marks local commits as pushed when they match PR commits", () => { + it("trusts the backend pushed flag even with no PR (the bug fix)", () => { + // The original bug: local commits were marked unpushed whenever no PR + // existed, because pushed was derived from PR-SHA matching alone. + // Sourcing pushed from git on the backend means commits pushed to a + // branch without a PR show as pushed. + const local = [ + { ...makeLocal("aaa1111", "pushed"), pushed: true }, + { ...makeLocal("bbb2222", "local"), pushed: false }, + ]; + const result = mergeCommits(local, []); + expect(result[0]).toMatchObject({ commit_sha: "bbb2222", pushed: false }); + expect(result[1]).toMatchObject({ commit_sha: "aaa1111", pushed: true }); + }); + + it("backend pushed flag wins over PR-SHA mismatch (post-rebase scenario)", () => { + // After a rebase the local SHA no longer matches the PR's SHA, but the + // commit IS on the remote (force-pushed). The backend's pushed=true + // must override the missing PR match. + const local = [{ ...makeLocal("rebased1", "rebased"), pushed: true }]; + const pr = [makePRFull("oldsha9999", "rebased")]; + const result = mergeCommits(local, pr); + expect(result[0]).toMatchObject({ commit_sha: "rebased1", pushed: true }); + // PR-only SHA still appears, since it isn't matched. + expect(result).toHaveLength(2); + expect(result[1]).toMatchObject({ commit_sha: "oldsha9999", pushed: true }); + }); + + it("falls back to PR-SHA matching when backend pushed flag is absent", () => { + // Older commit_created notifications don't carry the pushed flag. + // PR matching kicks in as a backstop. const local = [makeLocal("aaa1111", "first")]; const pr = [makePRFull("aaa1111bbbccc", "first", "user")]; const result = mergeCommits(local, pr); diff --git a/apps/web/components/task/changes-tab.tsx b/apps/web/components/task/changes-tab.tsx index 226ec31a68..4dd00d236c 100644 --- a/apps/web/components/task/changes-tab.tsx +++ b/apps/web/components/task/changes-tab.tsx @@ -13,6 +13,7 @@ import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-sta import { useSessionCommits } from "@/hooks/domains/session/use-session-commits"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { cn } from "@kandev/ui/lib/utils"; +import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; /** Auto-activate the changes panel only when it lives in the right sidebar. */ function autoActivateChangesPanel(): void { @@ -35,6 +36,7 @@ function autoActivateChangesPanel(): void { */ export function ChangesTab(props: IDockviewPanelHeaderProps) { const { api, containerApi } = props; + const onDoubleClick = useTabMaximizeOnDoubleClick(api); const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); const gitStatus = useSessionGitStatus(activeSessionId); @@ -118,7 +120,10 @@ export function ChangesTab(props: IDockviewPanelHeaderProps) { return ( - +
{badgeCount > 0 && ( diff --git a/apps/web/components/task/chat/clarification-input-overlay.tsx b/apps/web/components/task/chat/clarification-input-overlay.tsx index 49ea2d2837..f286bfd450 100644 --- a/apps/web/components/task/chat/clarification-input-overlay.tsx +++ b/apps/web/components/task/chat/clarification-input-overlay.tsx @@ -1,10 +1,8 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { IconX, IconMessageQuestion, IconInfoCircle } from "@tabler/icons-react"; import ReactMarkdown from "react-markdown"; -import { cn } from "@/lib/utils"; -import { getBackendConfig } from "@/lib/config"; import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components"; import type { Message, @@ -12,262 +10,392 @@ import type { ClarificationAnswer, ClarificationQuestion, } from "@/lib/types/http"; - -const RESULT_EXPIRED = "expired"; +import { useClarificationGroup } from "@/hooks/domains/session/use-clarification-group"; +import { + ClarificationCarouselNav, + ClarificationCustomInput, + ClarificationOptions, + ClarificationStepper, +} from "./clarification-overlay-parts"; type ClarificationInputOverlayProps = { - message: Message; + messages: readonly Message[] | null | undefined; onResolved: () => void; }; -/** - * Post a response to the clarification endpoint. Returns true on success. - */ -async function postClarificationResponse( - pendingId: string, - body: Record, -): Promise<"ok" | "expired" | "error"> { - const { apiBaseUrl } = getBackendConfig(); - const response = await fetch(`${apiBaseUrl}/api/v1/clarification/${pendingId}/respond`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (response.ok) return "ok"; - if (response.status === 410) { - const data = await response.json().catch(() => ({})); - console.warn("Clarification expired:", data); - return RESULT_EXPIRED; - } - console.error("Clarification request failed:", response.status, response.statusText); - return "error"; -} - -function handleClarificationResult(result: "ok" | "expired" | "error", onResolved: () => void) { - if (result === "ok" || result === RESULT_EXPIRED) onResolved(); -} - -type ClarificationOptionsProps = { +type SingleQuestionMeta = { + message: Message; + metadata: ClarificationRequestMetadata; question: ClarificationQuestion; - isSubmitting: boolean; - onSelectOption: (optionId: string) => void; + questionId: string; }; -function ClarificationOptions({ - question, - isSubmitting, - onSelectOption, -}: ClarificationOptionsProps) { - return ( -
- {question.options.map((option) => ( - - ))} -
- ); +function readSingleQuestionMeta(message: Message | null | undefined): SingleQuestionMeta | null { + if (!message) return null; + const metadata = message.metadata as ClarificationRequestMetadata | undefined; + if (!metadata?.question) return null; + const questionId = metadata.question_id ?? metadata.question.id; + if (!questionId) return null; + return { message, metadata, question: metadata.question, questionId }; } -type ClarificationCustomInputProps = { - customText: string; +function resolveQuestionMessages(messages: readonly Message[] | null | undefined): Message[] { + if (messages && messages.length > 0) return [...messages]; + return []; +} + +function sortMessagesByQuestionIndex(messages: Message[]): Message[] { + return messages.slice().sort((a, b) => { + const ai = (a.metadata as ClarificationRequestMetadata | undefined)?.question_index ?? 0; + const bi = (b.metadata as ClarificationRequestMetadata | undefined)?.question_index ?? 0; + return ai - bi; + }); +} + +type CardProps = { + meta: SingleQuestionMeta; + index: number; + total: number; + selectedOption: string | null; + customCommittedText: string | null; + customDraft: string; isSubmitting: boolean; - onChange: (text: string) => void; - onSubmit: () => void; + showAgentDisconnected: boolean; + onSelectOption: (optionId: string) => void; + onCustomDraftChange: (text: string) => void; + onSubmitCustom: (text: string) => void; }; -function ClarificationCustomInput({ - customText, - isSubmitting, - onChange, - onSubmit, -}: ClarificationCustomInputProps) { +function ClarificationCard(props: CardProps) { + const { + meta, + index, + total, + selectedOption, + customCommittedText, + customDraft, + isSubmitting, + showAgentDisconnected, + onSelectOption, + onCustomDraftChange, + onSubmitCustom, + } = props; + const { question, metadata } = meta; return ( -
- • - onChange(e.target.value)} - disabled={isSubmitting} - data-testid="clarification-input" - className="flex-1 text-sm bg-transparent placeholder:text-muted-foreground focus:outline-none" - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey && customText.trim()) { - e.preventDefault(); - onSubmit(); - } - }} +
+
+ + {total > 1 && ( + + Question {index + 1} of {total} + + )} + {metadata.question.title && ( + + {total > 1 ? "· " : ""} + {metadata.question.title} + + )} +
+
+ + {question.prompt} + +
+ + {showAgentDisconnected && ( +
+ + The agent has moved on. Your response will be sent as a new message. +
+ )} +
); } -type UseClarificationHandlersParams = { - metadata: ClarificationRequestMetadata | undefined; - isSubmitting: boolean; - setIsSubmitting: React.Dispatch>; - customText: string; - onResolved: () => void; -}; - -function useClarificationHandlers({ - metadata, - isSubmitting, - setIsSubmitting, - customText, - onResolved, -}: UseClarificationHandlersParams) { - const handleSubmitOption = useCallback( - async (optionId: string) => { - if (!metadata?.pending_id || isSubmitting) return; - setIsSubmitting(true); - try { - const answer: ClarificationAnswer = { - question_id: metadata.question.id, - selected_options: [optionId], - }; - const result = await postClarificationResponse(metadata.pending_id, { - answers: [answer], - rejected: false, - }); - handleClarificationResult(result, onResolved); - } catch (error) { - console.error("Failed to submit clarification response:", error); - } finally { - setIsSubmitting(false); - } - }, - [metadata, isSubmitting, onResolved, setIsSubmitting], - ); - - const handleSubmitCustom = useCallback(async () => { - if (!metadata?.pending_id || isSubmitting || !customText.trim()) return; - setIsSubmitting(true); - try { - const answer: ClarificationAnswer = { - question_id: metadata.question.id, - selected_options: [], - custom_text: customText.trim(), - }; - const result = await postClarificationResponse(metadata.pending_id, { - answers: [answer], - rejected: false, - }); - handleClarificationResult(result, onResolved); - } catch (error) { - console.error("Failed to submit clarification response:", error); - } finally { - setIsSubmitting(false); +function useResolveCallback( + submitState: ReturnType["submitState"], + onResolved: () => void, +) { + const last = useRef(submitState); + useEffect(() => { + if (last.current !== submitState && submitState === "ok") { + onResolved(); } - }, [metadata, isSubmitting, customText, onResolved, setIsSubmitting]); - - const handleSkip = useCallback(async () => { - if (!metadata?.pending_id || isSubmitting) return; - setIsSubmitting(true); - try { - const result = await postClarificationResponse(metadata.pending_id, { - answers: [], - rejected: true, - reject_reason: "User skipped", - }); - handleClarificationResult(result, onResolved); - } catch (error) { - console.error("Failed to skip clarification:", error); - } finally { - setIsSubmitting(false); - } - }, [metadata, isSubmitting, onResolved, setIsSubmitting]); - - return { handleSubmitOption, handleSubmitCustom, handleSkip }; + last.current = submitState; + }, [submitState, onResolved]); } -/** - * Inline clarification UI - simple numbered text options like Conductor. - */ -export function ClarificationInputOverlay({ message, onResolved }: ClarificationInputOverlayProps) { - const metadata = message.metadata as ClarificationRequestMetadata | undefined; - const [customText, setCustomText] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const { handleSubmitOption, handleSubmitCustom, handleSkip } = useClarificationHandlers({ - metadata, - isSubmitting, - setIsSubmitting, - customText, - onResolved, +type CarouselShortcutArgs = { + meta: SingleQuestionMeta; + activeIndex: number; + total: number; + canSubmit: boolean; + onPick: (index: number) => void; + onPrev: () => void; + onNext: () => void; + onSkip: () => void; + onSubmit: () => void; +}; + +// shouldIgnoreShortcut filters out events that the overlay must not handle: +// keystrokes inside an input/textarea (the user is typing) and any modifier +// combo (so we don't hijack browser shortcuts like Cmd/Ctrl+1..9 for tab +// switching or Alt+ArrowLeft for back-navigation). +function shouldIgnoreShortcut(e: KeyboardEvent): boolean { + if ( + e.target instanceof HTMLElement && + (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") + ) { + return true; + } + return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; +} + +function CarouselKeyboardShortcuts(args: CarouselShortcutArgs) { + const optionsCount = args.meta.question.options.length; + const isLast = args.activeIndex === args.total - 1; + const { canSubmit, onPick, onPrev, onNext, onSkip, onSubmit } = args; + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (shouldIgnoreShortcut(e)) return; + if (e.key === "Escape") { + e.preventDefault(); + onSkip(); + return; + } + if (e.key === "ArrowLeft") { + e.preventDefault(); + onPrev(); + return; + } + if (e.key === "ArrowRight") { + e.preventDefault(); + if (isLast && canSubmit) onSubmit(); + else onNext(); + return; + } + const num = Number.parseInt(e.key, 10); + if (Number.isFinite(num) && num >= 1 && num <= optionsCount) { + e.preventDefault(); + onPick(num - 1); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [optionsCount, isLast, canSubmit, onPick, onPrev, onNext, onSkip, onSubmit]); + return null; +} + +type CarouselBodyProps = { + sortedMessages: Message[]; + group: ReturnType; + activeIndex: number; + setActiveIndex: (idx: number) => void; + customDrafts: Record; + setCustomDrafts: React.Dispatch>>; +}; + +function ClarificationCarouselBody({ + sortedMessages, + group, + activeIndex, + setActiveIndex, + customDrafts, + setCustomDrafts, +}: CarouselBodyProps) { + const total = sortedMessages.length; + const activeMessage = sortedMessages[Math.min(activeIndex, total - 1)] ?? null; + const meta = activeMessage ? readSingleQuestionMeta(activeMessage) : null; + const showAgentDisconnectedAtTop = sortedMessages.some( + (m) => (m.metadata as ClarificationRequestMetadata | undefined)?.agent_disconnected === true, + ); + const isSubmitting = group.submitState === "submitting"; + + const isSingleQuestion = total === 1; + + const allAnswered = sortedMessages.every((m) => { + const id = readSingleQuestionMeta(m)?.questionId; + return id ? Boolean(group.answers[id]) : false; }); - if (!metadata?.question) return null; + const handleSubmit = useCallback(() => { + if (allAnswered) void group.submitCollected(); + }, [allAnswered, group]); - const question = metadata.question; + if (!meta) return null; + + const stored = group.answers[meta.questionId]; + const selectedOption = + stored?.selected_options && stored.selected_options.length > 0 + ? stored.selected_options[0] + : null; + const customCommittedText = stored?.custom_text ?? null; + const draft = customDrafts[meta.questionId] ?? ""; + + // Records the new answer, then either auto-submits (single-question case + // takes the override path so the freshly recorded answer is included even + // though setState is async) or auto-advances to the next step. + const commitAnswer = (answer: ClarificationAnswer) => { + group.recordAnswer(meta.questionId, answer); + if (isSingleQuestion) { + void group.submitCollected({ [meta.questionId]: answer }); + return; + } + if (activeIndex < total - 1) { + setActiveIndex(activeIndex + 1); + } + }; + + const onSelectOption = (optionId: string) => { + commitAnswer({ + question_id: meta.questionId, + selected_options: [optionId], + }); + }; + const onSubmitCustom = (text: string) => { + if (!text.trim()) return; + commitAnswer({ + question_id: meta.questionId, + selected_options: [], + custom_text: text.trim(), + }); + }; return ( -
- + <> + + setCustomDrafts((prev) => ({ ...prev, [meta.questionId]: value })) + } + onSubmitCustom={onSubmitCustom} + /> + {!isSingleQuestion && ( + setActiveIndex(Math.max(0, activeIndex - 1))} + onNext={() => setActiveIndex(Math.min(total - 1, activeIndex + 1))} + onSubmit={handleSubmit} + canSubmit={allAnswered} + /> + )} + onSelectOption(meta.question.options[idx].option_id)} + onPrev={() => setActiveIndex(Math.max(0, activeIndex - 1))} + onNext={() => setActiveIndex(Math.min(total - 1, activeIndex + 1))} + onSkip={() => void group.skipAll("User skipped")} + onSubmit={handleSubmit} + /> + + ); +} -
-
- -
- - {question.prompt} - -
+export function ClarificationInputOverlay({ + messages, + onResolved, +}: ClarificationInputOverlayProps) { + const sortedMessages = useMemo( + () => sortMessagesByQuestionIndex(resolveQuestionMessages(messages)), + [messages], + ); + const group = useClarificationGroup(sortedMessages); + const [customDrafts, setCustomDrafts] = useState>({}); + const [rawActiveIndex, setActiveIndex] = useState(0); + // Clamp the active index to the current bundle size so late-arriving + // messages or shrunk bundles never put us out of range. + const total = sortedMessages.length; + const activeIndex = total === 0 ? 0 : Math.min(rawActiveIndex, total - 1); + + useResolveCallback(group.submitState, onResolved); + + if (sortedMessages.length === 0) return null; + const isSubmitting = group.submitState === "submitting"; + + const isAnsweredAt = (index: number) => { + const m = sortedMessages[index]; + if (!m) return false; + const id = readSingleQuestionMeta(m)?.questionId; + return id ? Boolean(group.answers[id]) : false; + }; + + return ( +
+
+
+ {total > 1 && ( + + )} + {total > 1 && ( + + {group.answeredCount} of {group.total} answered + + )}
- - {metadata.agent_disconnected && ( -
- - The agent has moved on. Your response will be sent as a new message. -
- )} - +
+
); } + +export type { ClarificationAnswer }; diff --git a/apps/web/components/task/chat/clarification-overlay-parts.tsx b/apps/web/components/task/chat/clarification-overlay-parts.tsx new file mode 100644 index 0000000000..761fa890dd --- /dev/null +++ b/apps/web/components/task/chat/clarification-overlay-parts.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { IconCheck, IconCornerDownLeft, IconArrowLeft, IconArrowRight } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import type { ClarificationOption } from "@/lib/types/http"; + +export function stepClassName(active: boolean, answered: boolean): string { + if (active) { + return "bg-blue-500 text-white border-blue-500 shadow-[0_0_0_3px_rgba(59,130,246,0.18)]"; + } + if (answered) { + return "bg-blue-500/20 text-blue-600 border-blue-500/40 dark:text-blue-300"; + } + return "bg-muted text-muted-foreground border-border hover:bg-muted/70"; +} + +type StepperProps = { + total: number; + activeIndex: number; + isAnswered: (index: number) => boolean; + onJump: (index: number) => void; + isSubmitting: boolean; +}; + +export function ClarificationStepper({ + total, + activeIndex, + isAnswered, + onJump, + isSubmitting, +}: StepperProps) { + return ( +
+ {Array.from({ length: total }).map((_, i) => { + const answered = isAnswered(i); + const active = i === activeIndex; + return ( +
+ + {i < total - 1 && ( + + ); + })} +
+ ); +} + +type OptionListProps = { + options: ClarificationOption[]; + selectedOption: string | null; + isSubmitting: boolean; + onSelectOption: (optionId: string) => void; +}; + +// The agent receives both the selected option id and the custom text when +// both are present, so we never visually grey out one when the other is +// active — that previously created an asymmetric dimming bug where typing +// a custom answer dimmed the options but selecting an option did not dim +// the custom input. +export function ClarificationOptions({ + options, + selectedOption, + isSubmitting, + onSelectOption, +}: OptionListProps) { + return ( +
+ {options.map((option, idx) => { + const isSelected = selectedOption === option.option_id; + return ( + + ); + })} +
+ ); +} + +type CustomInputProps = { + draft: string; + isSubmitting: boolean; + committedText: string | null; + onChange: (text: string) => void; + onSubmit: (text: string) => void; +}; + +export function ClarificationCustomInput({ + draft, + isSubmitting, + committedText, + onChange, + onSubmit, +}: CustomInputProps) { + return ( +
+ ↳ + onChange(e.target.value)} + disabled={isSubmitting} + data-testid="clarification-input" + className="flex-1 text-sm bg-transparent placeholder:text-muted-foreground/60 focus:outline-none" + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey && draft.trim()) { + e.preventDefault(); + onSubmit(draft.trim()); + } + }} + /> + +
+ ); +} + +type CarouselNavProps = { + activeIndex: number; + total: number; + isSubmitting: boolean; + onPrev: () => void; + onNext: () => void; + onSubmit: () => void; + canSubmit: boolean; +}; + +export function ClarificationCarouselNav({ + activeIndex, + total, + isSubmitting, + onPrev, + onNext, + onSubmit, + canSubmit, +}: CarouselNavProps) { + const isFirst = activeIndex === 0; + const isLast = activeIndex === total - 1; + return ( +
+ + {isLast ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts index 79db21873d..b9a0e63ffc 100644 --- a/apps/web/components/task/chat/use-chat-panel-state.ts +++ b/apps/web/components/task/chat/use-chat-panel-state.ts @@ -31,6 +31,13 @@ import type { BuiltInPreset } from "@/lib/state/layout-manager/presets"; const EMPTY_CONTEXT_FILES: ContextFile[] = []; const PLAN_CONTEXT_PATH = "plan:context"; +// Tracks sessions for which the plan layout has already been auto-applied. +// Module-scoped so it survives TaskChatPanel remounts (dockview's fromJSON +// tears down and rebuilds the portal-hosted panel, which would otherwise +// reset a component-local ref and cause the plan preset to be re-applied — +// clobbering the just-restored saved layout on env switch). +const autoAppliedPlanSessions = new Set(); + export type CommentsState = { planComments: PlanComment[]; pendingCommentsByFile: Record; @@ -82,21 +89,20 @@ function useAutoApplyPlanLayout(opts: AutoApplyPlanLayoutOpts) { setPlanMode, addContextFile, } = opts; - const autoAppliedPlanSessionRef = useRef(null); useEffect(() => { if (!resolvedSessionId || !taskId) return; // Reset the guard when plan mode is disabled so future plan-mode steps // in the same session can be auto-applied (e.g. after proceeding away and back). - if (!sessionMetaPlanMode && autoAppliedPlanSessionRef.current === resolvedSessionId) { - autoAppliedPlanSessionRef.current = null; + if (!sessionMetaPlanMode && autoAppliedPlanSessions.has(resolvedSessionId)) { + autoAppliedPlanSessions.delete(resolvedSessionId); return; } - if (autoAppliedPlanSessionRef.current === resolvedSessionId) return; + if (autoAppliedPlanSessions.has(resolvedSessionId)) return; // Only auto-apply if both the session metadata AND the current step agree on plan mode. // sessionMetaPlanMode can be stale (deepMerge hydration preserves deleted keys), // so we cross-check with the step's actual configuration. if (sessionMetaPlanMode && currentStepHasPlanMode) { - autoAppliedPlanSessionRef.current = resolvedSessionId; + autoAppliedPlanSessions.add(resolvedSessionId); setActiveDocument(resolvedSessionId, { type: "plan", taskId }); applyBuiltInPreset("plan"); setPlanMode(resolvedSessionId, true); diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index fa5d302aee..82b22cf29c 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -12,6 +12,7 @@ import { themeKandev } from "@/lib/layout/dockview-theme"; import { useDockviewStore, performLayoutSwitch } from "@/lib/state/dockview-store"; import { tryRestoreLayout } from "./dockview-layout-restore"; import { + setupContainerResizeSync, setupGroupTracking, setupLayoutPersistence, setupPortalCleanup, @@ -42,6 +43,7 @@ import { ChangesTab } from "./changes-tab"; import { PlanTab } from "./plan-tab"; import { PreviewFileTab, PreviewDiffTab, PreviewCommitTab, PinnedDefaultTab } from "./preview-tab"; import { SessionTab } from "./session-tab"; +import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; import { setupSessionTabSync, setupChatPanelSafetyNet, @@ -152,7 +154,15 @@ const components: Record> = // --- TAB COMPONENTS --- function PermanentTab(props: IDockviewPanelHeaderProps) { - return ; + const onDoubleClick = useTabMaximizeOnDoubleClick(props.api); + return ( +
+ +
+ ); } /** Sync the user's default saved layout from settings into the dockview store. */ @@ -188,7 +198,8 @@ const tabComponents: Record state.workspaces.activeId); - const workflowId = useAppStore((state) => state.workflows.activeId); + // Read kanban.workflowId (task snapshot), not workflows.activeId (homepage filter), to preserve "All Workflows" across task navigation. + const workflowId = useAppStore((state) => state.kanban.workflowId); const workspaceName = useAppStore((state) => { const ws = state.workspaces.items.find((w: { id: string }) => w.id === workspaceId); return ws?.name ?? "Workspace"; @@ -449,6 +460,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ const setApi = useDockviewStore((s) => s.setApi); const buildDefaultLayout = useDockviewStore((s) => s.buildDefaultLayout); const saveTimerRef = useRef | null>(null); + const readyDisposersRef = useRef void>>([]); const appStore = useAppStoreApi(); const effectiveSessionId = @@ -484,11 +496,12 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ useDockviewStore.setState({ currentLayoutEnvId: currentEnvId }); - setupGroupTracking(api); + readyDisposersRef.current.push(setupGroupTracking(api)); setupSessionTabSync(api, appStore); setupChatPanelSafetyNet(api, appStore); setupLayoutPersistence(api, saveTimerRef, envIdRef); setupPortalCleanup(api, appStore); + readyDisposersRef.current.push(setupContainerResizeSync(api)); }, [setApi, buildDefaultLayout, initialLayout, appStore], ); @@ -508,7 +521,9 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ // Clean up on unmount (e.g. navigating away from session page) useEffect(() => { const timerRef = saveTimerRef; + const disposersRef = readyDisposersRef; return () => { + for (const dispose of disposersRef.current.splice(0)) dispose(); if (timerRef.current) clearTimeout(timerRef.current); panelPortalManager.releaseAll(); }; diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index 6e111eab2a..f8840b218f 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -251,8 +251,9 @@ export function RightHeaderActions(props: IDockviewHeaderActionsProps) { function SidebarRightActions() { const workspaceId = useAppStore((state) => state.workspaces.activeId); - const workflowId = useAppStore((state) => state.workflows.activeId); const kanban = useAppStore((state) => state.kanban); + // Use kanban.workflowId (task context) not workflows.activeId so "All Workflows" isn't clobbered when viewing a task. + const workflowId = kanban.workflowId; const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const activeTaskTitle = useAppStore((state) => { const id = state.tasks.activeTaskId; diff --git a/apps/web/components/task/dockview-layout-restore.ts b/apps/web/components/task/dockview-layout-restore.ts index 6b0656f292..9fdd9a25d8 100644 --- a/apps/web/components/task/dockview-layout-restore.ts +++ b/apps/web/components/task/dockview-layout-restore.ts @@ -2,7 +2,9 @@ import type { DockviewReadyEvent, SerializedDockview } from "dockview-react"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { applyLayoutFixups } from "@/lib/state/dockview-layout-builders"; import { isLayoutShapeHealthy } from "@/lib/state/dockview-layout-health"; -import { getEnvLayout, getEnvMaximizeState } from "@/lib/local-storage"; +import { measureDockviewContainer } from "@/lib/state/dockview-measure"; +import type { LayoutState } from "@/lib/state/layout-manager"; +import { getEnvLayout, getEnvMaximizeState, removeEnvMaximizeState } from "@/lib/local-storage"; const LAYOUT_STORAGE_KEY = "dockview-layout-v1"; @@ -65,17 +67,29 @@ export function sanitizeLayout( } /* eslint-enable @typescript-eslint/no-explicit-any */ +type SavedMax = ReturnType; + +/** + * Apply a saved maximize blob onto the live dockview api and mirror the full + * maximize state into the store. Single source of truth for both restore + * call sites — keeping `preMaximizeLayout` and `maximizedGroupId` in lockstep. + */ +function applySavedMaximize(api: DockviewReadyEvent["api"], savedMax: NonNullable): void { + api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview); + const { width, height } = measureDockviewContainer(api); + api.layout(width, height); + const ids = applyLayoutFixups(api); + useDockviewStore.setState({ + ...ids, + preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LayoutState, + maximizedGroupId: ids.centerGroupId, + }); +} + function applyFixupsWithMaximize(api: DockviewReadyEvent["api"], envId: string | null): void { const savedMax = envId ? getEnvMaximizeState(envId) : null; if (savedMax) { - api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview); - api.layout(api.width, api.height); - const ids = applyLayoutFixups(api); - type LM = import("@/lib/state/layout-manager").LayoutState; - useDockviewStore.setState({ - ...ids, - preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LM, - }); + applySavedMaximize(api, savedMax); } else { const ids = applyLayoutFixups(api); useDockviewStore.setState(ids); @@ -86,16 +100,13 @@ function tryRestoreMaximizeOnly(api: DockviewReadyEvent["api"], envId: string): const savedMax = getEnvMaximizeState(envId); if (!savedMax) return false; try { - api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview); - api.layout(api.width, api.height); - const ids = applyLayoutFixups(api); - type LM = import("@/lib/state/layout-manager").LayoutState; - useDockviewStore.setState({ - ...ids, - preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LM, - }); + applySavedMaximize(api, savedMax); return true; } catch { + // Drop the bad blob so subsequent page loads for this env don't keep + // re-attempting the same failing fromJSON. Mirrors the self-heal in + // dockview-store's restoreMaximizeFromStorage. + removeEnvMaximizeState(envId); return false; } } diff --git a/apps/web/components/task/dockview-layout-setup.ts b/apps/web/components/task/dockview-layout-setup.ts index 71440016f8..0b279db863 100644 --- a/apps/web/components/task/dockview-layout-setup.ts +++ b/apps/web/components/task/dockview-layout-setup.ts @@ -38,6 +38,34 @@ function trackPinnedWidths(api: DockviewReadyEvent["api"]): void { } } +/** + * Keep dockview's internal grid width in sync with the live DOM container. + * + * Dockview's own ResizeObserver occasionally drifts: a sequence of + * fromJSON calls (each carrying a recorded `grid.width`) plus a viewport + * change (devtools open/close, window resize) can leave `api.width` pinned + * at a value smaller than the actual container, after which every + * subsequent layout op pins it there. Observing the parent element and + * forcing `api.layout` on every resize is a cheap belt-and-suspenders fix. + */ +export function setupContainerResizeSync(api: DockviewReadyEvent["api"]): () => void { + if (typeof window === "undefined" || typeof ResizeObserver === "undefined") { + return () => {}; + } + const dv = document.querySelector(".dv-dockview") as HTMLElement | null; + const parent = dv?.parentElement; + if (!parent) return () => {}; + const ro = new ResizeObserver(() => { + const w = parent.clientWidth; + const h = parent.clientHeight; + if (w <= 0 || h <= 0) return; + if (w === api.width && h === api.height) return; + api.layout(w, h); + }); + ro.observe(parent); + return () => ro.disconnect(); +} + export function setupGroupTracking(api: DockviewReadyEvent["api"]): () => void { const d1 = api.onDidActiveGroupChange((group) => { useDockviewStore.setState({ activeGroupId: group?.id ?? null }); @@ -58,9 +86,24 @@ export function setupLayoutPersistence( ): void { api.onDidLayoutChange(() => { if (useDockviewStore.getState().isRestoringLayout) return; + // While maximized, the live layout is the 2-column overlay. Persisting it + // as the env's regular layout would mean: if we ever fall back to that + // layout (e.g. maximize state lost), the user gets a truncated layout + // instead of their real one. The dedicated maximize-state slot (managed + // by maximizeGroup / saveOutgoingEnv) already captures the overlay. + if (useDockviewStore.getState().preMaximizeLayout !== null) return; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { + // Re-check at fire time: a maximize (or another restore) may have + // started after this timer was scheduled. Persisting api.toJSON() now + // would write the maximize overlay as the env's regular layout — the + // bug this guard is meant to prevent. + const live = useDockviewStore.getState(); + if (live.preMaximizeLayout !== null || live.isRestoringLayout) { + saveTimerRef.current = null; + return; + } try { const json = api.toJSON(); const envId = envIdRef.current; diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts index 03b46162af..f294ba874d 100644 --- a/apps/web/components/task/dockview-session-tabs.ts +++ b/apps/web/components/task/dockview-session-tabs.ts @@ -242,6 +242,58 @@ function reconcileRemovedSessionPanels( const EMPTY_SESSION_IDS_KEY = ""; +/** + * Drop the generic "chat" placeholder once a real session is active. + * Skips removal in maximized state to preserve the saved maximize layout. + * Returns true when callers should continue ensuring session panels; + * false when the maximized state intentionally suppresses them. + */ +function prepareLayoutForSessionPanels(api: DockviewApi): boolean { + const preMaximizeLayout = useDockviewStore.getState().preMaximizeLayout; + const chatPanel = api.getPanel("chat"); + if (chatPanel && !preMaximizeLayout) { + api.removePanel(chatPanel); + } + // In maximized state, session panels are intentionally absent from the + // layout — they'll be restored when the user exits maximize. + return preMaximizeLayout === null; +} + +/** + * Decide whether to force-activate the session panel after it (and any + * sibling tabs) have been ensured. + * + * - It was just created by `ensureSessionPanel` (no prior dockview state + * to honor), or + * - The hook is mounting for the first time (initial page load) — preserve + * the long-standing behavior of focusing the agent tab so the chat is + * visible immediately, even when a saved layout had a different center + * tab active, or + * - The user switched sessions within the same task (intra-task switch + * where dockview hasn't re-activated the new session for us). + * + * After an env (task) switch the prev refs are populated and the task + * changed — `restoreSavedActiveViews` has already applied the saved active + * panel for the incoming task, so calling setActive here would override it + * and force the agent tab on top of whatever the user had focused. + */ +function shouldActivateSessionPanel(args: { + sessionPanelExistedBefore: boolean; + prevTaskId: string | null; + prevSessionId: string | null; + currentTaskId: string | null; + currentSessionId: string; +}): boolean { + const { sessionPanelExistedBefore, prevTaskId, prevSessionId, currentTaskId, currentSessionId } = + args; + if (!sessionPanelExistedBefore) return true; + const isFirstMount = prevTaskId === null && prevSessionId === null; + if (isFirstMount) return true; + const taskChanged = prevTaskId !== currentTaskId; + const sessionChanged = prevSessionId !== currentSessionId; + return sessionChanged && !taskChanged; +} + /** * Open a dockview tab for every session of the active task and keep them in sync * with the store. @@ -256,6 +308,8 @@ const EMPTY_SESSION_IDS_KEY = ""; */ export function useAutoSessionTab(effectiveSessionId: string | null) { const sessionTabCreatedRef = useRef>(new Set()); + const prevTaskIdRef = useRef(null); + const prevSessionIdRef = useRef(null); const appStore = useAppStoreApi(); // Key-based dependency so the effect re-runs when the task's session list @@ -298,17 +352,7 @@ export function useAutoSessionTab(effectiveSessionId: string | null) { // be re-created by ensureSessionPanel. if (!currentSessionIds.includes(effectiveSessionId)) return; - // Remove the generic "chat" placeholder as soon as a real session is - // active — per-session tabs replace it. Skip in maximized state to - // preserve the saved maximize layout. - const chatPanel = api.getPanel("chat"); - if (chatPanel && !useDockviewStore.getState().preMaximizeLayout) { - api.removePanel(chatPanel); - } - - // In maximized state, session panels are intentionally absent from the - // layout — they'll be restored when the user exits maximize. - if (useDockviewStore.getState().preMaximizeLayout !== null) { + if (!prepareLayoutForSessionPanels(api)) { sessionTabCreatedRef.current.add(effectiveSessionId); return; } @@ -316,6 +360,7 @@ export function useAutoSessionTab(effectiveSessionId: string | null) { const initialPosition = resolveInitialPosition(api); // Active panel first so its group becomes the anchor for siblings. + const sessionPanelExistedBefore = !!api.getPanel(`session:${effectiveSessionId}`); ensureSessionPanel( api, effectiveSessionId, @@ -325,7 +370,16 @@ export function useAutoSessionTab(effectiveSessionId: string | null) { ); const activePanel = api.getPanel(`session:${effectiveSessionId}`); if (activePanel) { - activePanel.api.setActive(); + const shouldActivate = shouldActivateSessionPanel({ + sessionPanelExistedBefore, + prevTaskId: prevTaskIdRef.current, + prevSessionId: prevSessionIdRef.current, + currentTaskId: tid ?? null, + currentSessionId: effectiveSessionId, + }); + if (shouldActivate) { + activePanel.api.setActive(); + } useDockviewStore.setState({ centerGroupId: activePanel.group.id }); } @@ -337,5 +391,7 @@ export function useAutoSessionTab(effectiveSessionId: string | null) { if (sid === effectiveSessionId) continue; ensureSessionPanel(api, sid, siblingAnchor, true, sessionTabCreatedRef.current); } + prevTaskIdRef.current = tid ?? null; + prevSessionIdRef.current = effectiveSessionId; }, [effectiveSessionId, sessionIdsKey, appStore]); } diff --git a/apps/web/components/task/mobile/mobile-picker-sheet.tsx b/apps/web/components/task/mobile/mobile-picker-sheet.tsx new file mode 100644 index 0000000000..177dcae8da --- /dev/null +++ b/apps/web/components/task/mobile/mobile-picker-sheet.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { type ReactNode } from "react"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from "@kandev/ui/drawer"; + +type MobilePickerSheetProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + /** Optional trailing element rendered to the right of the title (e.g. a "+" CTA). */ + headerAction?: ReactNode; + children: ReactNode; +}; + +/** + * Bottom-sheet shell for picker patterns (sessions, terminals, repos). Wraps + * shadcn Drawer with a consistent header layout — picker components only need + * to render their list inside `children`. + */ +export function MobilePickerSheet({ + open, + onOpenChange, + title, + description, + headerAction, + children, +}: MobilePickerSheetProps) { + return ( + + + +
+ {title} + {headerAction} +
+ {description && {description}} +
+
{children}
+
+
+ ); +} diff --git a/apps/web/components/task/mobile/mobile-pill-button.tsx b/apps/web/components/task/mobile/mobile-pill-button.tsx new file mode 100644 index 0000000000..b3034e605f --- /dev/null +++ b/apps/web/components/task/mobile/mobile-pill-button.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { forwardRef, type ReactNode } from "react"; +import { IconChevronDown } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; + +type MobilePillButtonProps = { + /** Optional icon to lead with (e.g. folder, terminal). */ + icon?: ReactNode; + /** Primary visible label. Hidden when `compact` is true. */ + label: string; + /** Optional count badge ("3", "2/4", etc.) shown after the label. */ + count?: string | number; + /** Hide the label, keeping just icon + count + chevron. For tight viewports. */ + compact?: boolean; + /** Stretch the pill to fill its parent's width and left-align the label. + * Use when the pill owns the entire header bar with no other content. */ + fullWidth?: boolean; + /** Whether the picker this opens is currently open. Drives aria-expanded. */ + isOpen?: boolean; + /** Tap handler — opens the associated picker sheet. */ + onClick: () => void; + /** Stable test id. */ + "data-testid"?: string; + /** Accessible label override. Defaults to `label`. */ + ariaLabel?: string; +}; + +/** + * Header trigger for a bottom-sheet picker. Pills look like buttons (background + * + chevron) so users see them as interactive, not a passive status badge. + */ +export const MobilePillButton = forwardRef( + function MobilePillButton( + { icon, label, count, compact, fullWidth, isOpen, onClick, ariaLabel, ...rest }, + ref, + ) { + return ( + + ); + }, +); diff --git a/apps/web/components/task/mobile/mobile-repo-pill.tsx b/apps/web/components/task/mobile/mobile-repo-pill.tsx new file mode 100644 index 0000000000..e20552fc79 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-repo-pill.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { memo, useEffect, useMemo, useState } from "react"; +import { IconFolder } from "@tabler/icons-react"; +import { useAppStore } from "@/components/state-provider"; +import { MobilePillButton } from "./mobile-pill-button"; +import { MobilePickerSheet } from "./mobile-picker-sheet"; +import { MobileReposSection, useTaskRepoCount } from "./mobile-repos-section"; + +const COMPACT_VIEWPORT_PX = 360; + +/** Returns true once the viewport is narrower than the compact threshold. */ +function useIsCompactViewport(): boolean { + const [isCompact, setIsCompact] = useState(false); + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${COMPACT_VIEWPORT_PX - 1}px)`); + const update = () => setIsCompact(mql.matches); + update(); + mql.addEventListener("change", update); + return () => mql.removeEventListener("change", update); + }, []); + return isCompact; +} + +function useTaskActiveRepoName(taskId: string | null, workspaceId: string | null): string | null { + const workspaceRepos = useAppStore((s) => + workspaceId ? (s.repositories.itemsByWorkspaceId[workspaceId] ?? []) : [], + ); + const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); + const activeRepoId = useAppStore((s) => + activeSessionId ? (s.taskSessions.items[activeSessionId]?.repository_id ?? null) : null, + ); + const taskRepos = useAppStore((s) => { + if (!taskId) return undefined; + const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); + return task?.repositories; + }); + return useMemo(() => { + if (!activeRepoId) { + // Fallback to the position-primary task repo when no session is active + // yet. Match the picker's ordering (mobile-repos-section sorts by + // position) so the pill label and the first sheet row agree. + const sorted = taskRepos ? [...taskRepos].sort((a, b) => a.position - b.position) : []; + const fallback = sorted[0]?.repository_id; + if (!fallback) return null; + const repo = workspaceRepos.find((r) => r.id === fallback); + return repo?.name ?? repo?.local_path ?? null; + } + const repo = workspaceRepos.find((r) => r.id === activeRepoId); + return repo?.name ?? repo?.local_path ?? null; + }, [activeRepoId, taskRepos, workspaceRepos]); +} + +export const MobileRepoPill = memo(function MobileRepoPill({ + taskId, + workspaceId, +}: { + taskId: string | null; + workspaceId: string | null; +}) { + const repoCount = useTaskRepoCount(taskId); + const activeName = useTaskActiveRepoName(taskId, workspaceId); + const isCompact = useIsCompactViewport(); + const [open, setOpen] = useState(false); + + if (repoCount <= 1) return null; + + const label = activeName ?? "Repo"; + return ( + <> + } + label={label} + compact={isCompact} + isOpen={open} + onClick={() => setOpen(true)} + data-testid="mobile-repo-pill" + ariaLabel={`Active repository: ${label}. Tap to switch.`} + /> + + setOpen(false)} + /> + + + ); +}); diff --git a/apps/web/components/task/mobile/mobile-repos-section.tsx b/apps/web/components/task/mobile/mobile-repos-section.tsx new file mode 100644 index 0000000000..ac00a5af94 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-repos-section.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { memo, useCallback, useMemo } from "react"; +import { IconCheck, IconFolder, IconGitBranch } from "@tabler/icons-react"; +import { useAppStore } from "@/components/state-provider"; +import { useToast } from "@/components/toast-provider"; +import type { Repository, TaskSession } from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; + +type RepoRow = { + taskRepositoryId: string; + repositoryId: string; + name: string; + baseBranch: string; + checkoutBranch?: string; + sessionCount: number; + /** A representative session for this repo, if any (preferring primary). */ + switchToSessionId: string | null; +}; + +function pickSwitchToSession( + sessions: TaskSession[], + repositoryId: string, + primarySessionId: string | null | undefined, +): string | null { + const onRepo = sessions.filter((s) => s.repository_id === repositoryId); + if (onRepo.length === 0) return null; + if (primarySessionId && onRepo.some((s) => s.id === primarySessionId)) return primarySessionId; + // Most recently started session on this repo as fallback. + const sorted = [...onRepo].sort( + (a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime(), + ); + return sorted[0].id; +} + +function buildRepoRows( + taskRepositories: NonNullable, + workspaceRepos: Repository[], + taskSessions: TaskSession[], + primarySessionId: string | null | undefined, +): RepoRow[] { + return [...taskRepositories] + .sort((a, b) => a.position - b.position) + .map((tr) => { + const repo = workspaceRepos.find((r) => r.id === tr.repository_id); + const onRepoSessions = taskSessions.filter((s) => s.repository_id === tr.repository_id); + return { + taskRepositoryId: tr.id, + repositoryId: tr.repository_id, + name: repo?.name ?? repo?.local_path ?? "Repository", + baseBranch: tr.base_branch, + checkoutBranch: tr.checkout_branch, + sessionCount: onRepoSessions.length, + switchToSessionId: pickSwitchToSession(taskSessions, tr.repository_id, primarySessionId), + }; + }); +} + +function useTaskRepoRows(taskId: string | null, workspaceId: string | null): RepoRow[] { + const taskRepositories = useAppStore((s) => { + if (!taskId) return undefined; + const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); + return task?.repositories; + }); + const workspaceRepos = useAppStore((s) => + workspaceId ? (s.repositories.itemsByWorkspaceId[workspaceId] ?? []) : [], + ); + const taskSessions = useAppStore((s) => + taskId ? (s.taskSessionsByTask.itemsByTaskId[taskId] ?? []) : [], + ); + const primarySessionId = useAppStore((s) => { + if (!taskId) return null; + const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); + return task?.primarySessionId ?? null; + }); + return useMemo( + () => + taskRepositories + ? buildRepoRows(taskRepositories, workspaceRepos, taskSessions, primarySessionId) + : [], + [taskRepositories, workspaceRepos, taskSessions, primarySessionId], + ); +} + +function RepoRowItem({ + row, + isActive, + onSelect, +}: { + row: RepoRow; + isActive: boolean; + onSelect: (row: RepoRow) => void; +}) { + return ( +
onSelect(row)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(row); + } + }} + data-testid={`mobile-repo-row-${row.repositoryId}`} + className={`flex items-center gap-2 px-2 py-2 rounded-md cursor-pointer select-none ${ + isActive ? "bg-accent" : "hover:bg-accent/50" + }`} + > + +
+ {row.name} +
+ + + {row.checkoutBranch || row.baseBranch} + + {row.sessionCount > 0 && ( + + {row.sessionCount} session{row.sessionCount === 1 ? "" : "s"} + + )} +
+
+ {isActive && } +
+ ); +} + +export const MobileReposSection = memo(function MobileReposSection({ + taskId, + workspaceId, + onClose, +}: { + taskId: string | null; + workspaceId: string | null; + onClose: () => void; +}) { + const setActiveSession = useAppStore((s) => s.setActiveSession); + const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); + const activeRepoId = useAppStore((s) => + activeSessionId ? (s.taskSessions.items[activeSessionId]?.repository_id ?? null) : null, + ); + const rows = useTaskRepoRows(taskId, workspaceId); + const { toast } = useToast(); + + const handleSelect = useCallback( + (row: RepoRow) => { + if (!taskId) return; + if (row.switchToSessionId) { + setActiveSession(taskId, row.switchToSessionId); + onClose(); + return; + } + // We don't auto-launch from a repo tap — that would create a side effect + // for what looks like navigation. Surface a hint so the tap doesn't + // appear silently broken. + toast({ + title: "No session on this repo yet", + description: "Open the session picker to launch one.", + }); + }, + [taskId, setActiveSession, onClose, toast], + ); + + if (!taskId) { + return ( +
No active task
+ ); + } + if (rows.length === 0) { + return ( +
+ This task has no repositories. +
+ ); + } + + return ( +
+ {rows.map((row) => ( + + ))} +
+ ); +}); + +/** Returns the count of repositories attached to the active task. */ +export function useTaskRepoCount(taskId: string | null): number { + return ( + useAppStore((s) => { + if (!taskId) return 0; + const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); + return task?.repositories?.length ?? 0; + }) ?? 0 + ); +} diff --git a/apps/web/components/task/mobile/mobile-sessions-section.tsx b/apps/web/components/task/mobile/mobile-sessions-section.tsx new file mode 100644 index 0000000000..439ea77ae9 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-sessions-section.tsx @@ -0,0 +1,396 @@ +"use client"; + +import { memo, useCallback, useMemo, useState } from "react"; +import { IconDotsVertical, IconPlus, IconStar } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@kandev/ui/alert-dialog"; +import { AgentLogo } from "@/components/agent-logo"; +import { useAppStore } from "@/components/state-provider"; +import { useTaskSessions } from "@/hooks/use-task-sessions"; +import { + useSessionActions, + isSessionStoppable, + isSessionDeletable, + isSessionResumable, +} from "@/hooks/domains/session/use-session-actions"; +import { NewSessionDialog } from "../new-session-dialog"; +import { MobilePillButton } from "./mobile-pill-button"; +import { MobilePickerSheet } from "./mobile-picker-sheet"; +import { formatTaskSessionStateLabel } from "@/lib/ui/state-labels"; +import type { TaskSession, TaskSessionState } from "@/lib/types/http"; +import type { AgentProfileOption } from "@/lib/state/slices"; + +type SessionRow = { + id: string; + agentName: string | null; + agentLabel: string; + state: TaskSessionState | null; + isPrimary: boolean; + index: number; + startedAt: string; +}; + +function buildSessionRows( + sessions: TaskSession[], + agentProfiles: AgentProfileOption[], + primarySessionId: string | null | undefined, +): SessionRow[] { + const sorted = [...sessions].sort( + (a, b) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime(), + ); + return sorted.map((s, idx) => { + const profile = agentProfiles.find((p) => p.id === s.agent_profile_id); + const labelParts = profile?.label.split(" • ") ?? []; + return { + id: s.id, + agentName: profile?.agent_name ?? null, + agentLabel: labelParts[1] || labelParts[0] || profile?.label || "Agent", + state: (s.state as TaskSessionState | undefined) ?? null, + isPrimary: primarySessionId ? s.id === primarySessionId : !!s.is_primary, + index: idx + 1, + startedAt: s.started_at, + }; + }); +} + +const STATE_TONE: Partial> = { + RUNNING: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400", + STARTING: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400", + WAITING_FOR_INPUT: "bg-amber-500/15 text-amber-600 dark:text-amber-400", + FAILED: "bg-destructive/15 text-destructive", +}; + +function StateBadge({ state }: { state: TaskSessionState | null }) { + if (!state) return null; + const tone = STATE_TONE[state] ?? "bg-foreground/10 text-muted-foreground"; + return ( + + {formatTaskSessionStateLabel(state)} + + ); +} + +function SessionActionsMenu({ + state, + isPrimary, + onSetPrimary, + onStop, + onResume, + onAskDelete, +}: { + state: TaskSessionState | null; + isPrimary: boolean; + onSetPrimary: () => void; + onStop: () => void; + onResume: () => void; + onAskDelete: () => void; +}) { + return ( + + + + + e.stopPropagation()}> + + Set as Primary + + + {state && isSessionStoppable(state) && ( + + Stop + + )} + {state && isSessionResumable(state) && ( + + Resume + + )} + {state && isSessionDeletable(state) && ( + + Delete + + )} + + + ); +} + +function DeleteSessionConfirmDialog({ + open, + onOpenChange, + isPrimary, + isOnlySession, + onConfirm, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + isPrimary: boolean; + isOnlySession: boolean; + onConfirm: () => void; +}) { + return ( + + + + Delete session? + +
+

This will permanently delete the conversation history with this session.

+ {isPrimary && !isOnlySession && ( +

+ This is the primary session. Another session will be set as primary. +

+ )} + {isOnlySession && ( +

This is the only session for this task.

+ )} +
+
+
+ + Cancel + { + onOpenChange(false); + onConfirm(); + }} + className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + +
+
+ ); +} + +function SessionRowItem({ + row, + taskId, + isActive, + totalSessions, + onSelect, +}: { + row: SessionRow; + taskId: string; + isActive: boolean; + totalSessions: number; + onSelect: (sessionId: string) => void; +}) { + const [confirmDelete, setConfirmDelete] = useState(false); + const actions = useSessionActions({ sessionId: row.id, taskId }); + const isOnly = totalSessions === 1; + const showBadges = totalSessions > 1; + + return ( + <> +
onSelect(row.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(row.id); + } + }} + data-testid={`mobile-session-row-${row.id}`} + className={`flex items-center gap-2 px-2 py-2 rounded-md cursor-pointer select-none ${ + isActive ? "bg-accent" : "hover:bg-accent/50" + }`} + > + {row.isPrimary && showBadges && ( + + )} + {showBadges && ( + + {row.index} + + )} + {row.agentName && } + {row.agentLabel} + + void actions.setPrimary()} + onStop={() => void actions.stop()} + onResume={() => void actions.resume()} + onAskDelete={() => setConfirmDelete(true)} + /> +
+ void actions.remove()} + /> + + ); +} + +function useSessionRows(taskId: string | null) { + const agentProfiles = useAppStore((s) => s.agentProfiles.items); + const primarySessionId = useAppStore((s) => { + if (!taskId) return null; + const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); + return task?.primarySessionId ?? null; + }); + const { sessions, isLoading } = useTaskSessions(taskId); + const rows = useMemo( + () => buildSessionRows(sessions, agentProfiles, primarySessionId), + [sessions, agentProfiles, primarySessionId], + ); + return { rows, isLoading, primarySessionId }; +} + +const MobileSessionsList = memo(function MobileSessionsList({ + taskId, + onClose, +}: { + taskId: string | null; + onClose: () => void; +}) { + const setActiveSession = useAppStore((s) => s.setActiveSession); + const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); + const { rows, isLoading } = useSessionRows(taskId); + const [launchOpen, setLaunchOpen] = useState(false); + + const handleSelect = useCallback( + (sessionId: string) => { + if (!taskId) return; + setActiveSession(taskId, sessionId); + onClose(); + }, + [taskId, setActiveSession, onClose], + ); + + if (!taskId) { + return ( +
No active task
+ ); + } + + return ( +
+
+ + {rows.length} session{rows.length === 1 ? "" : "s"} + + +
+
+ {isLoading && rows.length === 0 && ( +
+ Loading sessions… +
+ )} + {!isLoading && rows.length === 0 && ( +
+ No sessions yet. Launch one to get started. +
+ )} + {rows.map((row) => ( + + ))} +
+ {launchOpen && ( + + )} +
+ ); +}); + +function useActiveSessionPillLabel(taskId: string | null): { + label: string; + count: string | undefined; +} { + const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); + const { rows } = useSessionRows(taskId); + const activeRow = rows.find((r) => r.id === activeSessionId); + const total = rows.length; + const idx = activeRow?.index; + let count: string | undefined; + if (total > 1 && idx) count = `${idx}/${total}`; + else if (total > 1) count = `${total}`; + return { label: activeRow?.agentLabel ?? "Session", count }; +} + +export const MobileSessionsPicker = memo(function MobileSessionsPicker({ + taskId, + compact, + fullWidth, +}: { + taskId: string | null; + compact?: boolean; + fullWidth?: boolean; +}) { + const [open, setOpen] = useState(false); + const { label, count } = useActiveSessionPillLabel(taskId); + if (!taskId) return null; + return ( + <> + setOpen(true)} + data-testid="mobile-sessions-pill" + ariaLabel={`Active session: ${label}. Tap to switch.`} + /> + + setOpen(false)} /> + + + ); +}); diff --git a/apps/web/components/task/mobile/mobile-terminal-pane.tsx b/apps/web/components/task/mobile/mobile-terminal-pane.tsx new file mode 100644 index 0000000000..3d9d6b2b07 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-terminal-pane.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { memo, useCallback, useEffect, useState } from "react"; +import { PassthroughTerminal } from "../passthrough-terminal"; +import { setActiveTerminalSender } from "@/lib/terminal/mobile-active-terminal"; +import { sendShellInput } from "@/lib/terminal/send-shell-input"; +import { MobileTerminalsPicker } from "./mobile-terminals-section"; +import { MobileTerminalsProvider, useMobileTerminalsContext } from "./mobile-terminals-context"; +import type { Terminal as XtermTerminal } from "@xterm/xterm"; +import type { Terminal } from "@/hooks/domains/session/use-terminals"; + +function TerminalSlot({ + terminal, + sessionId, + environmentId, + isActive, +}: { + terminal: Terminal; + sessionId: string; + environmentId: string | null; + isActive: boolean; +}) { + // Track xterm + ws in state so the registration / data-routing effects + // re-run once each is ready (PassthroughTerminal initialises both + // asynchronously when the container starts at 0×0). A ref-based dep would + // silently miss those paths on first mount. + const [xterm, setXterm] = useState(null); + const [ws, setWs] = useState(null); + + const handleWsReady = useCallback((nextWs: WebSocket) => { + setWs(nextWs); + // Drop the registry sender if this socket closes — otherwise the key-bar + // would route taps into a dead WS during reconnect, instead of falling + // through to sendShellInput's WS-fallback path. + nextWs.addEventListener( + "close", + () => setWs((current) => (current === nextWs ? null : current)), + { once: true }, + ); + }, []); + + // Forward OS-keyboard input through sendShellInput so the key-bar's sticky + // Ctrl/Shift modifiers are applied before the bytes hit the wire. Without + // this, AttachAddon would auto-send raw onData and modifiers would be a + // no-op for OS-keyboard typing on mobile. + useEffect(() => { + if (!isActive || !xterm) return; + const disposable = xterm.onData((data) => sendShellInput(sessionId, data)); + return () => disposable.dispose(); + }, [isActive, xterm, sessionId]); + + // Register the active key-bar sender only while the dedicated WS is open. + // sendShellInput's fallback path runs whenever no sender is registered, so + // skipping registration during connect/reconnect lets the key-bar still + // dispatch instead of silently dropping keystrokes. + useEffect(() => { + if (!isActive || !ws || ws.readyState !== WebSocket.OPEN) return; + const sender = (data: string) => ws.send(new TextEncoder().encode(data)); + setActiveTerminalSender(sender); + return () => setActiveTerminalSender(null); + }, [isActive, terminal.id, ws]); + + return ( +
+ +
+ ); +} + +function MobileTerminalPaneInner({ sessionId }: { sessionId: string | null }) { + const { terminals, terminalTabValue, environmentId } = useMobileTerminalsContext(); + const activeId = terminals.find((t) => t.id === terminalTabValue)?.id ?? terminals[0]?.id; + + if (!sessionId || !environmentId) { + return ( +
+ Terminal unavailable — no active session. +
+ ); + } + + return ( +
+
+ +
+
+ {terminals.length === 0 && ( +
+ Starting terminal… +
+ )} + {terminals.map((t) => ( + + ))} +
+
+ ); +} + +export const MobileTerminalPane = memo(function MobileTerminalPane({ + sessionId, +}: { + sessionId: string | null; +}) { + return ( + + + + ); +}); diff --git a/apps/web/components/task/mobile/mobile-terminals-context.tsx b/apps/web/components/task/mobile/mobile-terminals-context.tsx new file mode 100644 index 0000000000..8b8ab62ba1 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-terminals-context.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { createContext, memo, useContext, type ReactNode } from "react"; +import { useMobileTerminals } from "@/hooks/domains/session/use-mobile-terminals"; + +type MobileTerminalsContextValue = ReturnType; + +const MobileTerminalsContext = createContext(null); + +/** + * Provider that owns the single `useMobileTerminals` instance for a mobile + * terminal pane. Both the chip-style picker pill and the picker sheet's list + * read state from this context instead of calling `useMobileTerminals` + * themselves — three separate `useState`-backed lists used to drift out of + * sync after `addTerminal` / `removeTerminal`, leaving the pane showing the + * wrong active terminal until a server WS push reconciled them. + */ +export const MobileTerminalsProvider = memo(function MobileTerminalsProvider({ + sessionId, + children, +}: { + sessionId: string | null; + children: ReactNode; +}) { + const value = useMobileTerminals(sessionId); + return ( + {children} + ); +}); + +export function useMobileTerminalsContext(): MobileTerminalsContextValue { + const ctx = useContext(MobileTerminalsContext); + if (!ctx) { + throw new Error("useMobileTerminalsContext must be used inside a "); + } + return ctx; +} diff --git a/apps/web/components/task/mobile/mobile-terminals-section.tsx b/apps/web/components/task/mobile/mobile-terminals-section.tsx new file mode 100644 index 0000000000..986e37e871 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-terminals-section.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { memo, useCallback, useState } from "react"; +import { IconPlus, IconTerminal2, IconX } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@kandev/ui/alert-dialog"; +import { useAppStore } from "@/components/state-provider"; +import { stopUserShell } from "@/lib/api/domains/user-shell-api"; +import { useUserShells } from "@/hooks/domains/session/use-user-shells"; +import { releaseAutoCreatedEnvironment } from "@/hooks/domains/session/use-mobile-terminals"; +import { MobilePillButton } from "./mobile-pill-button"; +import { MobilePickerSheet } from "./mobile-picker-sheet"; +import { useMobileTerminalsContext } from "./mobile-terminals-context"; +import type { Terminal } from "@/hooks/domains/session/use-terminals"; + +function TerminalRow({ + terminal, + isActive, + isRunning, + onSelect, + onAskClose, +}: { + terminal: Terminal; + isActive: boolean; + isRunning: boolean; + onSelect: (id: string) => void; + onAskClose: (terminal: Terminal) => void; +}) { + return ( +
onSelect(terminal.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(terminal.id); + } + }} + data-testid={`mobile-terminal-row-${terminal.id}`} + className={`flex items-center gap-2 px-2 py-2 rounded-md cursor-pointer select-none ${ + isActive ? "bg-accent" : "hover:bg-accent/50" + }`} + > + + {terminal.label} + {isRunning && ( + + running + + )} + {terminal.closable && ( + + )} +
+ ); +} + +function CloseTerminalConfirmDialog({ + terminal, + onOpenChange, + onConfirm, +}: { + terminal: Terminal | null; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) { + return ( + + + + Close terminal? + + {`This stops the “${terminal?.label ?? ""}” shell and any process it's running.`} + + + + Cancel + { + onOpenChange(false); + onConfirm(); + }} + className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Close terminal + + + + + ); +} + +type CloseHandlerArgs = { + sessionId: string | null; + environmentId: string | null; + terminals: Terminal[]; + terminalTabValue: string; + removeTerminal: (id: string) => void; + setRightPanelActiveTab: (sessionId: string, tab: string) => void; +}; + +function useTerminalCloseHandler({ + sessionId, + environmentId, + terminals, + terminalTabValue, + removeTerminal, + setRightPanelActiveTab, +}: CloseHandlerArgs) { + const [pendingClose, setPendingClose] = useState(null); + + const handleConfirmClose = useCallback(async () => { + const t = pendingClose; + if (!t || !sessionId) return; + try { + // Stop the remote shell first; only mutate UI on success so a failed + // stop doesn't orphan the shell server-side while the picker hides it. + if (environmentId) await stopUserShell(environmentId, t.id); + // If the closed terminal was active, advance the active tab to a + // remaining one so the picker doesn't leave terminalTabValue pointing + // at a deleted id (which would render every row as inactive). + if (terminalTabValue === t.id) { + const next = terminals.find((row) => row.id !== t.id); + if (next) setRightPanelActiveTab(sessionId, next.id); + } + removeTerminal(t.id); + // If this was the last terminal, release the auto-create guard so the + // pane recreates a default shell on next render instead of getting + // stuck on the "Starting terminal…" placeholder forever. + if (environmentId && terminals.length <= 1) { + releaseAutoCreatedEnvironment(environmentId); + } + setPendingClose(null); + } catch (err) { + console.error("Failed to stop terminal:", err); + } + }, [ + pendingClose, + sessionId, + environmentId, + terminals, + terminalTabValue, + removeTerminal, + setRightPanelActiveTab, + ]); + + return { pendingClose, setPendingClose, handleConfirmClose }; +} + +const MobileTerminalsList = memo(function MobileTerminalsList({ + sessionId, + onClose, +}: { + sessionId: string | null; + onClose: () => void; +}) { + const { terminals, terminalTabValue, addTerminal, removeTerminal, environmentId } = + useMobileTerminalsContext(); + const { shells } = useUserShells(environmentId); + const setRightPanelActiveTab = useAppStore((s) => s.setRightPanelActiveTab); + const { pendingClose, setPendingClose, handleConfirmClose } = useTerminalCloseHandler({ + sessionId, + environmentId, + terminals, + terminalTabValue, + removeTerminal, + setRightPanelActiveTab, + }); + + const isShellRunning = useCallback( + (id: string) => shells.find((s) => s.terminalId === id)?.running ?? false, + [shells], + ); + + const handleSelect = useCallback( + (id: string) => { + if (sessionId) setRightPanelActiveTab(sessionId, id); + onClose(); + }, + [sessionId, setRightPanelActiveTab, onClose], + ); + + const handleAskClose = useCallback( + (terminal: Terminal) => setPendingClose(terminal), + [setPendingClose], + ); + + if (!sessionId) { + return ( +
No active session
+ ); + } + + return ( +
+
+ + {terminals.length} terminal{terminals.length === 1 ? "" : "s"} + + +
+
+ {terminals.length === 0 && ( +
+ No terminals yet. +
+ )} + {terminals.map((t) => ( + + ))} +
+ { + if (!open) setPendingClose(null); + }} + onConfirm={handleConfirmClose} + /> +
+ ); +}); + +function useActiveTerminalPillLabel(): { label: string; count: string | undefined } { + const { terminals, terminalTabValue } = useMobileTerminalsContext(); + const activeIdx = terminals.findIndex((t) => t.id === terminalTabValue); + const idx = activeIdx >= 0 ? activeIdx : 0; + const active = terminals[idx]; + const total = terminals.length; + let count: string | undefined; + if (total > 1) count = `${idx + 1}/${total}`; + return { label: active?.label ?? "Terminal", count }; +} + +export const MobileTerminalsPicker = memo(function MobileTerminalsPicker({ + sessionId, + compact, + fullWidth, +}: { + sessionId: string | null; + compact?: boolean; + fullWidth?: boolean; +}) { + const [open, setOpen] = useState(false); + const { label, count } = useActiveTerminalPillLabel(); + if (!sessionId) return null; + return ( + <> + setOpen(true)} + data-testid="mobile-terminals-pill" + ariaLabel={`Active terminal: ${label}. Tap to switch.`} + /> + + setOpen(false)} /> + + + ); +}); diff --git a/apps/web/components/task/mobile/session-mobile-layout.tsx b/apps/web/components/task/mobile/session-mobile-layout.tsx index 60244b822b..50d0f54bb8 100644 --- a/apps/web/components/task/mobile/session-mobile-layout.tsx +++ b/apps/web/components/task/mobile/session-mobile-layout.tsx @@ -8,9 +8,10 @@ import { TaskChatPanel } from "../task-chat-panel"; import { TaskPlanPanel } from "../task-plan-panel"; import { TaskChangesPanel } from "../task-changes-panel"; import { TaskFilesPanel } from "../task-files-panel"; -import { ShellTerminal } from "../shell-terminal"; import { PassthroughTerminal } from "../passthrough-terminal"; import { MobileTerminalKeybar, KEYBAR_HEIGHT_PX } from "./mobile-terminal-keybar"; +import { MobileTerminalPane } from "./mobile-terminal-pane"; +import { MobileSessionsPicker } from "./mobile-sessions-section"; import { SessionPanelContent } from "@kandev/ui/pannel-session"; import { useSessionLayoutState } from "@/hooks/use-session-layout-state"; import { useVisualViewportOffset } from "@/hooks/use-visual-viewport-offset"; @@ -39,28 +40,36 @@ function MobileChatPanelContent({ activeTaskId, isPassthroughMode, effectiveSessionId, - sessionId, onOpenFile, }: { activeTaskId: string | null; isPassthroughMode: boolean; effectiveSessionId: string | null; - sessionId?: string | null; onOpenFile: (path: string) => void; }) { - if (activeTaskId && isPassthroughMode) { + if (!activeTaskId) { return ( -
- +
+ No task selected
); } - if (activeTaskId) { - return ; - } return ( -
- No task selected +
+
+ +
+ {isPassthroughMode ? ( +
+ +
+ ) : ( + + )}
); } @@ -70,7 +79,6 @@ type MobilePanelAreaProps = { activeTaskId: string | null; isPassthroughMode: boolean; effectiveSessionId: string | null; - sessionId?: string | null; selectedDiff: { path: string; content?: string } | null; handleOpenFileFromChat: (path: string) => void; handleClearSelectedDiff: () => void; @@ -85,7 +93,6 @@ function MobilePanelArea({ activeTaskId, isPassthroughMode, effectiveSessionId, - sessionId, selectedDiff, handleOpenFileFromChat, handleClearSelectedDiff, @@ -112,12 +119,11 @@ function MobilePanelArea({ }} > {currentMobilePanel === "chat" && ( -
+
@@ -147,11 +153,11 @@ function MobilePanelArea({ {currentMobilePanel === "terminal" && (
- - + +
)} @@ -159,6 +165,53 @@ function MobilePanelArea({ ); } +type MobileTopBarStickyProps = { + activeTaskId: string | null; + workspaceId: string | null; + taskTitle?: string; + effectiveSessionId: string | null; + baseBranch?: string; + worktreeBranch?: string | null; + onMenuClick: () => void; + showApproveButton: boolean; + onApprove: () => void; + isRemoteExecutor?: boolean; + remoteExecutorType?: string | null; + remoteExecutorName?: string | null; + remoteState?: string | null; + remoteCreatedAt?: string | null; + remoteCheckedAt?: string | null; + remoteStatusError?: string | null; +}; + +function MobileTopBarSticky(props: MobileTopBarStickyProps) { + return ( +
+ +
+ ); +} + function useMobilePanelHandlers({ handleSelectDiff, handlePanelChange, @@ -235,29 +288,24 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({ return (
- {/* Fixed Top Bar */} -
- -
+ {/* Content Area - fixed height panels that manage their own scrolling */} + {isRemoteExecutor && ( ({ + id: step.id, + title: step.name, + color: step.color, + position: step.position, + events: step.events, + // Carry optional step capabilities forward so downstream UI doesn't see + // them as missing after a workspace switch (until a full reload). + allow_manual_move: step.allow_manual_move, + prompt: step.prompt, + is_start_step: step.is_start_step, + show_in_command_panel: step.show_in_command_panel, + agent_profile_id: step.agent_profile_id, + })), + tasks: snapshot.tasks.map((task) => ({ + id: task.id, + workflowStepId: task.workflow_step_id, + title: task.title, + description: task.description ?? undefined, + position: task.position ?? 0, + state: task.state, + repositoryId: task.repositories?.[0]?.repository_id ?? undefined, + // Carry the full TaskRepository array so the mobile repo picker + // (useTaskRepoCount + MobileReposSection) keeps working after a + // workspace switch. Without this, the picker silently disappears for + // multi-repo tasks because length defaults to 0. + repositories: task.repositories?.map((r) => ({ + id: r.id, + repository_id: r.repository_id, + base_branch: r.base_branch, + checkout_branch: r.checkout_branch, + position: r.position, + })), + primarySessionId: task.primary_session_id ?? undefined, + primarySessionState: task.primary_session_state ?? undefined, + sessionCount: task.session_count ?? undefined, + reviewStatus: task.review_status ?? undefined, + primaryExecutorId: task.primary_executor_id ?? undefined, + primaryExecutorType: task.primary_executor_type ?? undefined, + primaryExecutorName: task.primary_executor_name ?? undefined, + isRemoteExecutor: task.is_remote_executor ?? false, + updatedAt: task.updated_at, + })), + }; +} + +function sortByUpdatedAtDesc(items: T[]): T[] { + return [...items].sort((a, b) => { + const aDate = a.updated_at ? new Date(a.updated_at).getTime() : 0; + const bDate = b.updated_at ? new Date(b.updated_at).getTime() : 0; + return bDate - aDate; + }); +} + +export function useSheetData(workspaceId: string | null, workflowId: string | null) { + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const sessionsById = useAppStore((state) => state.taskSessions.items); + const sessionsByTaskId = useAppStore((state) => state.taskSessionsByTask.itemsByTaskId); + const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId); + const envIdBySessionId = useAppStore((state) => state.environmentIdBySessionId); + const messagesBySession = useAppStore((state) => state.messages.bySession); + const { tasks } = useTasks(workflowId); + const steps = useAppStore((state) => state.kanban.steps); + const workspaces = useAppStore((state) => state.workspaces.items); + const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const kanbanIsLoading = useAppStore((state) => state.kanban.isLoading ?? false); + + const selectedTaskId = useMemo(() => { + if (activeSessionId) return sessionsById[activeSessionId]?.task_id ?? activeTaskId; + return activeTaskId; + }, [activeSessionId, activeTaskId, sessionsById]); + + const tasksWithRepositories = useMemo(() => { + const repositories = workspaceId ? (repositoriesByWorkspace[workspaceId] ?? []) : []; + const repositoryPathsById = new Map( + repositories.map((repo: Repository) => [repo.id, repo.local_path]), + ); + return tasks.map((task: KanbanState["tasks"][number]) => { + const sessionInfo = getSessionInfoForTask( + task.id, + sessionsByTaskId, + gitStatusByEnvId, + envIdBySessionId, + ); + return { + id: task.id, + title: task.title, + state: task.state as TaskState | undefined, + sessionState: + sessionInfo.sessionState ?? (task.primarySessionState as TaskSessionState | undefined), + description: task.description, + workflowStepId: task.workflowStepId, + repositoryPath: task.repositoryId ? repositoryPathsById.get(task.repositoryId) : undefined, + diffStats: sessionInfo.diffStats, + updatedAt: sessionInfo.updatedAt ?? task.updatedAt, + isRemoteExecutor: task.isRemoteExecutor, + remoteExecutorType: task.primaryExecutorType ?? undefined, + remoteExecutorName: task.primaryExecutorName ?? undefined, + primarySessionId: task.primarySessionId ?? null, + hasPendingClarification: hasPendingClarificationForSession( + messagesBySession, + task.primarySessionId, + ), + }; + }); + }, [ + repositoriesByWorkspace, + tasks, + workspaceId, + sessionsByTaskId, + gitStatusByEnvId, + envIdBySessionId, + messagesBySession, + ]); + + const dialogSteps = useMemo( + () => + steps.map((step: KanbanState["steps"][number]) => ({ + id: step.id, + title: step.title, + color: step.color, + events: step.events, + })), + [steps], + ); + + return { + activeTaskId, + selectedTaskId, + steps, + workspaces, + kanbanIsLoading, + tasksWithRepositories, + dialogSteps, + }; +} + +type SheetNavOptions = { + workspaceId: string | null; + store: ReturnType; + loadTaskSessionsForTask: ( + taskId: string, + ) => Promise>; + setActiveSession: (taskId: string, sessionId: string) => void; + setActiveTask: (taskId: string) => void; + onOpenChange: (open: boolean) => void; +}; + +async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) { + const { store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange } = opts; + store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: true } })); + try { + const workflowsResponse = await listWorkflows(newWorkspaceId, { + cache: "no-store", + includeHidden: true, + }); + const newWorkspaceWorkflows = workflowsResponse.workflows ?? []; + const firstWorkflow = newWorkspaceWorkflows.find((w) => !w.hidden); + if (!firstWorkflow) { + store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } })); + return; + } + const snapshot = await fetchWorkflowSnapshot(firstWorkflow.id); + store.setState((state) => ({ + ...state, + workflows: { + ...state.workflows, + items: [ + ...state.workflows.items.filter( + (w: { workspaceId: string }) => w.workspaceId !== newWorkspaceId, + ), + ...newWorkspaceWorkflows.map((w) => ({ + id: w.id, + workspaceId: w.workspace_id, + name: w.name, + hidden: w.hidden, + })), + ], + activeId: firstWorkflow.id, + }, + kanban: mapSnapshotToKanban(snapshot, firstWorkflow.id), + })); + const mostRecentTask = sortByUpdatedAtDesc(snapshot.tasks)[0]; + if (mostRecentTask) { + const sessions = await loadTaskSessionsForTask(mostRecentTask.id); + const mostRecentSession = sortByUpdatedAtDesc(sessions)[0]; + if (mostRecentSession) { + setActiveSession(mostRecentTask.id, mostRecentSession.id); + } else { + setActiveTask(mostRecentTask.id); + } + replaceTaskUrl(mostRecentTask.id); + } + onOpenChange(false); + } catch (error) { + console.error("Failed to switch workspace:", error); + store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } })); + } +} + +function mapTaskRepositories( + repositories: Task["repositories"], +): KanbanState["tasks"][number]["repositories"] { + return repositories?.map((r) => ({ + id: r.id, + repository_id: r.repository_id, + base_branch: r.base_branch, + checkout_branch: r.checkout_branch, + position: r.position, + })); +} + +function mergeSessionFields( + task: Task, + existing: KanbanState["tasks"][number] | undefined, + taskSessionId: string | null, +) { + return { + primarySessionId: + taskSessionId ?? task.primary_session_id ?? existing?.primarySessionId ?? undefined, + primarySessionState: task.primary_session_state ?? existing?.primarySessionState ?? undefined, + sessionCount: task.session_count ?? existing?.sessionCount ?? (taskSessionId ? 1 : undefined), + reviewStatus: task.review_status ?? existing?.reviewStatus ?? undefined, + }; +} + +/** + * Build the kanban-store representation of a task for an upsert. Session- + * derived fields (primarySessionId, sessionCount, etc.) fall through new + * DTO → existing entry → meta.taskSessionId — that way an "edit" call doesn't + * wipe sessions the existing entry carried, and "create with session" still + * sets the primary correctly. + */ +function buildKanbanTaskUpsert( + task: Task, + existing: KanbanState["tasks"][number] | undefined, + meta: { taskSessionId?: string | null } | undefined, +): KanbanState["tasks"][number] { + const taskSessionId = meta?.taskSessionId ?? null; + return { + id: task.id, + workflowStepId: task.workflow_step_id, + title: task.title, + description: task.description, + position: task.position ?? 0, + state: task.state, + repositoryId: task.repositories?.[0]?.repository_id ?? undefined, + repositories: mapTaskRepositories(task.repositories), + updatedAt: task.updated_at, + ...mergeSessionFields(task, existing, taskSessionId), + primaryExecutorId: task.primary_executor_id ?? undefined, + primaryExecutorType: task.primary_executor_type ?? undefined, + primaryExecutorName: task.primary_executor_name ?? undefined, + isRemoteExecutor: task.is_remote_executor ?? false, + }; +} + +function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { + const { + workspaceId, + store, + loadTaskSessionsForTask, + setActiveSession, + setActiveTask, + onOpenChange, + } = opts; + + const handleWorkspaceChange = useCallback( + async (newWorkspaceId: string) => { + if (newWorkspaceId === workspaceId) return; + await switchWorkspace(newWorkspaceId, { + workspaceId, + store, + loadTaskSessionsForTask, + setActiveSession, + setActiveTask, + onOpenChange, + }); + }, + // Spread the individual fields rather than the `opts` object so callers + // re-passing a fresh literal each render don't defeat memoization. + [workspaceId, store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange], + ); + + const handleTaskCreated = useCallback( + (task: Task, _mode: "create" | "edit", meta?: { taskSessionId?: string | null }) => { + store.setState((state) => { + if (state.kanban.workflowId !== task.workflow_id) return state; + const existing = state.kanban.tasks.find( + (item: KanbanState["tasks"][number]) => item.id === task.id, + ); + const nextTask = buildKanbanTaskUpsert(task, existing, meta); + return { + ...state, + kanban: { + ...state.kanban, + tasks: state.kanban.tasks.some( + (item: KanbanState["tasks"][number]) => item.id === task.id, + ) + ? state.kanban.tasks.map((item: KanbanState["tasks"][number]) => + item.id === task.id ? nextTask : item, + ) + : [...state.kanban.tasks, nextTask], + }, + }; + }); + setActiveTask(task.id); + if (meta?.taskSessionId) { + setActiveSession(task.id, meta.taskSessionId); + } + replaceTaskUrl(task.id); + onOpenChange(false); + }, + [store, setActiveTask, setActiveSession, onOpenChange], + ); + + return { handleWorkspaceChange, handleTaskCreated }; +} + +type SelectTaskOptions = { + setActiveTask: (taskId: string) => void; + setActiveSession: (taskId: string, sessionId: string) => void; + loadTaskSessionsForTask: SheetNavOptions["loadTaskSessionsForTask"]; + onOpenChange: (open: boolean) => void; +}; + +async function selectTaskWithoutPrimarySession(taskId: string, opts: SelectTaskOptions) { + const { setActiveTask, setActiveSession, loadTaskSessionsForTask, onOpenChange } = opts; + try { + const sessions = await loadTaskSessionsForTask(taskId); + const sessionId = sessions[0]?.id ?? null; + if (sessionId) { + setActiveSession(taskId, sessionId); + replaceTaskUrl(taskId); + onOpenChange(false); + return; + } + // No session — prepare workspace. + const { request } = buildPrepareRequest(taskId); + try { + const resp = await launchSession(request); + if (resp.session_id) { + setActiveSession(taskId, resp.session_id); + replaceTaskUrl(taskId); + onOpenChange(false); + return; + } + } catch { + // Fall through to default navigation. + } + } catch (error) { + // Loading sessions can reject (network / 5xx). Don't strand the user; + // fall back to plain task navigation so URL + state still align with tap. + console.error("Failed to load sessions for task:", error); + } + setActiveTask(taskId); + replaceTaskUrl(taskId); + onOpenChange(false); +} + +export function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolean) => void) { + const setActiveTask = useAppStore((state) => state.setActiveTask); + const setActiveSession = useAppStore((state) => state.setActiveSession); + const store = useAppStoreApi(); + const [deletingTaskId, setDeletingTaskId] = useState(null); + const { deleteTaskById } = useTaskActions(); + const archiveAndSwitch = useArchiveAndSwitchTask(); + const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store }); + + const handleSelectTask = useCallback( + (taskId: string) => { + const kanbanTasks = store.getState().kanban.tasks; + const task = kanbanTasks.find((t) => t.id === taskId); + if (task?.primarySessionId) { + setActiveSession(taskId, task.primarySessionId); + loadTaskSessionsForTask(taskId); + replaceTaskUrl(taskId); + onOpenChange(false); + return; + } + void selectTaskWithoutPrimarySession(taskId, { + setActiveTask, + setActiveSession, + loadTaskSessionsForTask, + onOpenChange, + }); + }, + [loadTaskSessionsForTask, setActiveSession, setActiveTask, store, onOpenChange], + ); + + const [archivingTask, setArchivingTask] = useState<{ id: string; title: string } | null>(null); + const [isArchiving, setIsArchiving] = useState(false); + + const handleArchiveTask = useCallback( + (taskId: string) => { + const task = store.getState().kanban.tasks.find((t) => t.id === taskId); + setArchivingTask({ id: taskId, title: task?.title ?? "this task" }); + }, + [store], + ); + + const handleArchiveConfirm = useCallback(async () => { + if (!archivingTask) return; + setIsArchiving(true); + try { + await archiveAndSwitch(archivingTask.id); + } catch (error) { + console.error("Failed to archive task:", error); + } finally { + setIsArchiving(false); + setArchivingTask(null); + } + }, [archivingTask, archiveAndSwitch]); + + const handleDeleteTask = useCallback( + async (taskId: string) => { + setDeletingTaskId(taskId); + // Capture active state before the async API call — the WS "task.deleted" + // handler may clear activeTaskId/activeSessionId before removeTaskFromBoard runs. + const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } = + store.getState().tasks; + try { + await deleteTaskById(taskId); + await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId }); + } finally { + setDeletingTaskId(null); + } + }, + [deleteTaskById, removeTaskFromBoard, store], + ); + + const { handleWorkspaceChange, handleTaskCreated } = useWorkspaceAndTaskCreatedActions({ + workspaceId, + store, + loadTaskSessionsForTask, + setActiveSession, + setActiveTask, + onOpenChange, + }); + + return { + deletingTaskId, + handleSelectTask, + handleArchiveTask, + handleDeleteTask, + handleWorkspaceChange, + handleTaskCreated, + archivingTask, + setArchivingTask, + isArchiving, + handleArchiveConfirm, + }; +} diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 2ea8aaed80..509ced78ce 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -1,34 +1,19 @@ "use client"; -import { useCallback, useMemo, useState, memo } from "react"; +import { useMemo, useState, memo } from "react"; import { IconPlus } from "@tabler/icons-react"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; import { Button } from "@kandev/ui/button"; import { TaskSwitcher } from "../task-switcher"; import type { TaskSwitcherItem } from "../task-switcher"; import { applyView } from "@/lib/sidebar/apply-view"; +import { useEffectiveSidebarView } from "@/hooks/domains/sidebar/use-effective-sidebar-view"; +import { useSidebarTaskPrefs } from "@/hooks/domains/sidebar/use-sidebar-task-prefs"; import { WorkspaceSwitcher } from "../workspace-switcher"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import { TaskArchiveConfirmDialog } from "../task-archive-confirm-dialog"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import { replaceTaskUrl } from "@/lib/links"; -import { fetchWorkflowSnapshot, listWorkflows } from "@/lib/api"; -import { launchSession } from "@/lib/services/session-launch-service"; -import { buildPrepareRequest } from "@/lib/services/session-launch-helpers"; -import { useTasks } from "@/hooks/use-tasks"; -import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; -import { useTaskRemoval } from "@/hooks/use-task-removal"; -import { getSessionInfoForTask } from "@/lib/utils/session-info"; -import { hasPendingClarificationForSession } from "@/lib/utils/pending-clarification"; -import type { - TaskState, - TaskSessionState, - Workspace, - Repository, - Task, - WorkflowSnapshot, -} from "@/lib/types/http"; -import type { KanbanState } from "@/lib/state/slices"; +import { useSheetData, useSheetActions } from "./session-task-switcher-sheet-hooks"; +import type { Workspace } from "@/lib/types/http"; type SessionTaskSwitcherSheetProps = { open: boolean; @@ -37,361 +22,6 @@ type SessionTaskSwitcherSheetProps = { workflowId: string | null; }; -// Helper to map workflow snapshot to kanban state -function mapSnapshotToKanban(snapshot: WorkflowSnapshot, newWorkflowId: string) { - return { - workflowId: newWorkflowId, - isLoading: false, - steps: snapshot.steps.map((step) => ({ - id: step.id, - title: step.name, - color: step.color, - position: step.position, - events: step.events, - })), - tasks: snapshot.tasks.map((task) => ({ - id: task.id, - workflowStepId: task.workflow_step_id, - title: task.title, - description: task.description ?? undefined, - position: task.position ?? 0, - state: task.state, - repositoryId: task.repositories?.[0]?.repository_id ?? undefined, - primarySessionId: task.primary_session_id ?? undefined, - primarySessionState: task.primary_session_state ?? undefined, - sessionCount: task.session_count ?? undefined, - reviewStatus: task.review_status ?? undefined, - primaryExecutorId: task.primary_executor_id ?? undefined, - primaryExecutorType: task.primary_executor_type ?? undefined, - primaryExecutorName: task.primary_executor_name ?? undefined, - isRemoteExecutor: task.is_remote_executor ?? false, - updatedAt: task.updated_at, - })), - }; -} - -// Helper to sort by updated_at descending -function sortByUpdatedAtDesc(items: T[]): T[] { - return [...items].sort((a, b) => { - const aDate = a.updated_at ? new Date(a.updated_at).getTime() : 0; - const bDate = b.updated_at ? new Date(b.updated_at).getTime() : 0; - return bDate - aDate; - }); -} - -function useSheetData(workspaceId: string | null, workflowId: string | null) { - const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); - const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); - const sessionsById = useAppStore((state) => state.taskSessions.items); - const sessionsByTaskId = useAppStore((state) => state.taskSessionsByTask.itemsByTaskId); - const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId); - const envIdBySessionId = useAppStore((state) => state.environmentIdBySessionId); - const messagesBySession = useAppStore((state) => state.messages.bySession); - const { tasks } = useTasks(workflowId); - const steps = useAppStore((state) => state.kanban.steps); - const workspaces = useAppStore((state) => state.workspaces.items); - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); - const kanbanIsLoading = useAppStore((state) => state.kanban.isLoading ?? false); - - const selectedTaskId = useMemo(() => { - if (activeSessionId) return sessionsById[activeSessionId]?.task_id ?? activeTaskId; - return activeTaskId; - }, [activeSessionId, activeTaskId, sessionsById]); - - const tasksWithRepositories = useMemo(() => { - const repositories = workspaceId ? (repositoriesByWorkspace[workspaceId] ?? []) : []; - const repositoryPathsById = new Map( - repositories.map((repo: Repository) => [repo.id, repo.local_path]), - ); - return tasks.map((task: KanbanState["tasks"][number]) => { - const sessionInfo = getSessionInfoForTask( - task.id, - sessionsByTaskId, - gitStatusByEnvId, - envIdBySessionId, - ); - return { - id: task.id, - title: task.title, - state: task.state as TaskState | undefined, - sessionState: - sessionInfo.sessionState ?? (task.primarySessionState as TaskSessionState | undefined), - description: task.description, - workflowStepId: task.workflowStepId, - repositoryPath: task.repositoryId ? repositoryPathsById.get(task.repositoryId) : undefined, - diffStats: sessionInfo.diffStats, - updatedAt: sessionInfo.updatedAt ?? task.updatedAt, - isRemoteExecutor: task.isRemoteExecutor, - remoteExecutorType: task.primaryExecutorType ?? undefined, - remoteExecutorName: task.primaryExecutorName ?? undefined, - primarySessionId: task.primarySessionId ?? null, - hasPendingClarification: hasPendingClarificationForSession( - messagesBySession, - task.primarySessionId, - ), - }; - }); - }, [ - repositoriesByWorkspace, - tasks, - workspaceId, - sessionsByTaskId, - gitStatusByEnvId, - envIdBySessionId, - messagesBySession, - ]); - - const dialogSteps = useMemo( - () => - steps.map((step: KanbanState["steps"][number]) => ({ - id: step.id, - title: step.title, - color: step.color, - events: step.events, - })), - [steps], - ); - - return { - activeTaskId, - selectedTaskId, - steps, - workspaces, - kanbanIsLoading, - tasksWithRepositories, - dialogSteps, - }; -} - -type SheetNavOptions = { - workspaceId: string | null; - store: ReturnType; - loadTaskSessionsForTask: ( - taskId: string, - ) => Promise>; - setActiveSession: (taskId: string, sessionId: string) => void; - setActiveTask: (taskId: string) => void; - onOpenChange: (open: boolean) => void; -}; - -async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) { - const { store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange } = opts; - store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: true } })); - try { - const workflowsResponse = await listWorkflows(newWorkspaceId, { - cache: "no-store", - includeHidden: true, - }); - const newWorkspaceWorkflows = workflowsResponse.workflows ?? []; - const firstWorkflow = newWorkspaceWorkflows.find((w) => !w.hidden); - if (!firstWorkflow) { - store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } })); - return; - } - const snapshot = await fetchWorkflowSnapshot(firstWorkflow.id); - store.setState((state) => ({ - ...state, - workflows: { - ...state.workflows, - items: [ - ...state.workflows.items.filter( - (w: { workspaceId: string }) => w.workspaceId !== newWorkspaceId, - ), - ...newWorkspaceWorkflows.map((w) => ({ - id: w.id, - workspaceId: w.workspace_id, - name: w.name, - hidden: w.hidden, - })), - ], - activeId: firstWorkflow.id, - }, - kanban: mapSnapshotToKanban(snapshot, firstWorkflow.id), - })); - const mostRecentTask = sortByUpdatedAtDesc(snapshot.tasks)[0]; - if (mostRecentTask) { - const sessions = await loadTaskSessionsForTask(mostRecentTask.id); - const mostRecentSession = sortByUpdatedAtDesc(sessions)[0]; - if (mostRecentSession) { - setActiveSession(mostRecentTask.id, mostRecentSession.id); - } else { - setActiveTask(mostRecentTask.id); - } - replaceTaskUrl(mostRecentTask.id); - } - onOpenChange(false); - } catch (error) { - console.error("Failed to switch workspace:", error); - store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } })); - } -} - -function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { - const { workspaceId, store, setActiveSession, setActiveTask, onOpenChange } = opts; - - const handleWorkspaceChange = useCallback( - async (newWorkspaceId: string) => { - if (newWorkspaceId === workspaceId) return; - await switchWorkspace(newWorkspaceId, opts); - }, - [workspaceId, opts], - ); - - const handleTaskCreated = useCallback( - (task: Task, _mode: "create" | "edit", meta?: { taskSessionId?: string | null }) => { - store.setState((state) => { - if (state.kanban.workflowId !== task.workflow_id) return state; - const nextTask = { - id: task.id, - workflowStepId: task.workflow_step_id, - title: task.title, - description: task.description, - position: task.position ?? 0, - state: task.state, - repositoryId: task.repositories?.[0]?.repository_id ?? undefined, - updatedAt: task.updated_at, - primaryExecutorId: task.primary_executor_id ?? undefined, - primaryExecutorType: task.primary_executor_type ?? undefined, - primaryExecutorName: task.primary_executor_name ?? undefined, - isRemoteExecutor: task.is_remote_executor ?? false, - }; - return { - ...state, - kanban: { - ...state.kanban, - tasks: state.kanban.tasks.some( - (item: KanbanState["tasks"][number]) => item.id === task.id, - ) - ? state.kanban.tasks.map((item: KanbanState["tasks"][number]) => - item.id === task.id ? nextTask : item, - ) - : [...state.kanban.tasks, nextTask], - }, - }; - }); - setActiveTask(task.id); - if (meta?.taskSessionId) { - setActiveSession(task.id, meta.taskSessionId); - } - replaceTaskUrl(task.id); - onOpenChange(false); - }, - [store, setActiveTask, setActiveSession, onOpenChange], - ); - - return { handleWorkspaceChange, handleTaskCreated }; -} - -function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolean) => void) { - const setActiveTask = useAppStore((state) => state.setActiveTask); - const setActiveSession = useAppStore((state) => state.setActiveSession); - const store = useAppStoreApi(); - const [deletingTaskId, setDeletingTaskId] = useState(null); - const { deleteTaskById } = useTaskActions(); - const archiveAndSwitch = useArchiveAndSwitchTask(); - const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store }); - - const handleSelectTask = useCallback( - (taskId: string) => { - const kanbanTasks = store.getState().kanban.tasks; - const task = kanbanTasks.find((t) => t.id === taskId); - if (task?.primarySessionId) { - setActiveSession(taskId, task.primarySessionId); - loadTaskSessionsForTask(taskId); - replaceTaskUrl(taskId); - onOpenChange(false); - return; - } - loadTaskSessionsForTask(taskId).then(async (sessions) => { - const sessionId = sessions[0]?.id ?? null; - if (sessionId) { - setActiveSession(taskId, sessionId); - replaceTaskUrl(taskId); - onOpenChange(false); - return; - } - // No session — prepare workspace - const { request } = buildPrepareRequest(taskId); - try { - const resp = await launchSession(request); - if (resp.session_id) { - setActiveSession(taskId, resp.session_id); - } - } catch { - // Fall through to default behavior - } - setActiveTask(taskId); - replaceTaskUrl(taskId); - onOpenChange(false); - }); - }, - [loadTaskSessionsForTask, setActiveSession, setActiveTask, store, onOpenChange], - ); - - const [archivingTask, setArchivingTask] = useState<{ id: string; title: string } | null>(null); - const [isArchiving, setIsArchiving] = useState(false); - - const handleArchiveTask = useCallback( - (taskId: string) => { - const task = store.getState().kanban.tasks.find((t) => t.id === taskId); - setArchivingTask({ id: taskId, title: task?.title ?? "this task" }); - }, - [store], - ); - - const handleArchiveConfirm = useCallback(async () => { - if (!archivingTask) return; - setIsArchiving(true); - try { - await archiveAndSwitch(archivingTask.id); - } catch (error) { - console.error("Failed to archive task:", error); - } finally { - setIsArchiving(false); - setArchivingTask(null); - } - }, [archivingTask, archiveAndSwitch]); - - const handleDeleteTask = useCallback( - async (taskId: string) => { - setDeletingTaskId(taskId); - // Capture active state before the async API call — the WS "task.deleted" - // handler may clear activeTaskId/activeSessionId before removeTaskFromBoard runs. - const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } = - store.getState().tasks; - try { - await deleteTaskById(taskId); - await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId }); - } finally { - setDeletingTaskId(null); - } - }, - [deleteTaskById, removeTaskFromBoard, store], - ); - - const { handleWorkspaceChange, handleTaskCreated } = useWorkspaceAndTaskCreatedActions({ - workspaceId, - store, - loadTaskSessionsForTask, - setActiveSession, - setActiveTask, - onOpenChange, - }); - - return { - deletingTaskId, - handleSelectTask, - handleArchiveTask, - handleDeleteTask, - handleWorkspaceChange, - handleTaskCreated, - archivingTask, - setArchivingTask, - isArchiving, - handleArchiveConfirm, - }; -} - function MobileTaskList({ tasks, activeTaskId, @@ -411,12 +41,13 @@ function MobileTaskList({ deletingTaskId: string | null; isLoading?: boolean; }) { - const sidebarSlice = useAppStore((s) => s.sidebarViews); - const view = useMemo(() => { - const active = sidebarSlice.views.find((v) => v.id === sidebarSlice.activeViewId); - return active ?? sidebarSlice.views[0]; - }, [sidebarSlice]); - const grouped = useMemo(() => applyView(tasks, view), [tasks, view]); + const view = useEffectiveSidebarView(); + const { pinnedTaskIds, orderedTaskIds, togglePinnedTask, handleReorderGroup } = + useSidebarTaskPrefs(); + const grouped = useMemo( + () => applyView(tasks, view, { pinnedTaskIds, orderedTaskIds }), + [tasks, view, pinnedTaskIds, orderedTaskIds], + ); return ( @@ -482,23 +98,23 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
({ id: w.id, name: w.name }))} + workspaces={data.workspaces.map((w: Workspace) => ({ id: w.id, name: w.name }))} activeWorkspaceId={workspaceId} - onSelect={handleWorkspaceChange} + onSelect={actions.handleWorkspaceChange} />
@@ -509,19 +125,19 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ mode="create" workspaceId={workspaceId} workflowId={workflowId} - defaultStepId={dialogSteps[0]?.id ?? null} - steps={dialogSteps} - onSuccess={handleTaskCreated} + defaultStepId={data.dialogSteps[0]?.id ?? null} + steps={data.dialogSteps} + onSuccess={actions.handleTaskCreated} /> { - if (!open) setArchivingTask(null); + if (!open) actions.setArchivingTask(null); }} - taskTitle={archivingTask?.title ?? ""} - isArchiving={isArchiving} - onConfirm={handleArchiveConfirm} + taskTitle={actions.archivingTask?.title ?? ""} + isArchiving={actions.isArchiving} + onConfirm={actions.handleArchiveConfirm} /> ); diff --git a/apps/web/components/task/new-subtask-dialog.tsx b/apps/web/components/task/new-subtask-dialog.tsx index 5ba5278e5f..dcb483ef5a 100644 --- a/apps/web/components/task/new-subtask-dialog.tsx +++ b/apps/web/components/task/new-subtask-dialog.tsx @@ -1,40 +1,31 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; -import { Badge } from "@kandev/ui/badge"; -import { Button } from "@kandev/ui/button"; -import { Input } from "@kandev/ui/input"; -import { Textarea } from "@kandev/ui/textarea"; -import { IconGitBranch } from "@tabler/icons-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; -import { createTask } from "@/lib/api/domains/kanban-api"; -import { replaceTaskUrl } from "@/lib/links"; -import { AgentSelector, ExecutorProfileSelector } from "@/components/task-create-dialog-selectors"; + import { useAgentProfileOptions, useExecutorProfileOptions, } from "@/components/task-create-dialog-options"; +import { useDialogHandlers } from "@/components/task-create-dialog-handlers"; +import { + useDiscoverReposEffect, + useGitHubUrlBranchesEffect, +} from "@/components/task-create-dialog-effects"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; -import { EnhancePromptButton } from "@/components/enhance-prompt-button"; +import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured"; -import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; import { useSummarizeSession } from "@/hooks/use-summarize-session"; import { useTaskSessions } from "@/hooks/use-task-sessions"; import { getLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; -import type { ExecutorProfile } from "@/lib/types/http"; +import type { ExecutorProfile, Repository } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; -import { IconLoader2 } from "@tabler/icons-react"; -import { toMessageAttachments } from "@/components/task-create-dialog-helpers"; -import { - ContextSelect, - useDialogAttachments, - AttachButton, - toContextItems, -} from "./session-dialog-shared"; -import { ContextZone } from "./chat/context-items/context-zone"; +import { useSubtaskFormState } from "./new-subtask-form-state"; +import { PromptZone, SubtaskFormBody } from "./new-subtask-form-parts"; +import { useSubtaskPromptZone, useSubtaskSubmit } from "./use-subtask-submit"; type NewSubtaskDialogProps = { open: boolean; @@ -130,17 +121,76 @@ function useAutoSelectExecutorProfile( }, [allProfiles, executorProfileId, setExecutorProfileId]); } -function activateSubtaskSession(opts: { - sessionId: string; - taskId: string; - setActiveTask: (taskId: string) => void; - setActiveSession: (taskId: string, sessionId: string) => void; +/** + * Seeds the chip row with the parent task's repo + branch when the form + * mounts. Form parent passes `key={open}` so each dialog open remounts the + * form, which is when this fires. The user can still change or add repos + * after seeding. + */ +function useSeedParentRepository( + fs: ReturnType, + parentRepositoryId: string | null, + baseBranch: string | null, +) { + useEffect(() => { + if (!parentRepositoryId) return; + fs.setRepositories([ + { key: "subtask-row-1", repositoryId: parentRepositoryId, branch: baseBranch ?? "" }, + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} + +/** Pre-fills the agent profile with the parent session's profile on mount. */ +function useSeedAgentProfileId( + fs: ReturnType, + defaultProfileId: string, +) { + useEffect(() => { + if (defaultProfileId) fs.setAgentProfileId(defaultProfileId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} + +/** + * Centralizes the Context selector's branching logic (Blank / Copy parent + * prompt / Summarize session N) so the form component stays under the + * complexity cap. + */ +function useContextChangeHandler(opts: { + setContextValue: (v: string) => void; + setHasPrompt: (v: boolean) => void; + promptRef: React.RefObject; + initialPrompt: string | null; + summarize: (sessionId: string) => Promise; }) { - opts.setActiveTask(opts.taskId); - opts.setActiveSession(opts.taskId, opts.sessionId); - // Layout switch is handled by useEnvSwitchCleanup when the new session's - // task_environment_id is present; the hook subscribes to env-id updates. - replaceTaskUrl(opts.taskId); + const { setContextValue, setHasPrompt, promptRef, initialPrompt, summarize } = opts; + return useCallback( + async (value: string) => { + if (!value) return; + setContextValue(value); + const ta = promptRef.current; + if (!ta) return; + if (value === "copy_prompt" && initialPrompt) { + ta.value = initialPrompt; + setHasPrompt(true); + return; + } + if (value === "blank") { + ta.value = ""; + setHasPrompt(false); + return; + } + if (value.startsWith("summarize:")) { + const result = await summarize(value.slice("summarize:".length)); + if (result && promptRef.current) { + promptRef.current.value = result; + setHasPrompt(true); + } + } + }, + [setContextValue, setHasPrompt, promptRef, initialPrompt, summarize], + ); } type SubtaskFormProps = { @@ -153,12 +203,16 @@ type SubtaskFormProps = { executors: Array<{ id: string; type: string; name: string; profiles?: ExecutorProfile[] }>; workspaceId: string | null; workflowId: string | null; - repositoryId: string | null; + /** The parent task's repository — used as the default for the subtask. */ + parentRepositoryId: string | null; baseBranch: string | null; + /** Workspace repositories the user can pick from to override the default. */ + availableRepositories: Repository[]; + /** Whether the parent dialog is open — required for the GitHub URL effect. */ + isOpen: boolean; onClose: () => void; }; -// eslint-disable-next-line max-lines-per-function function NewSubtaskForm({ parentTaskId, defaultTitle, @@ -169,284 +223,94 @@ function NewSubtaskForm({ executors, workspaceId, workflowId, - repositoryId, + parentRepositoryId, baseBranch, + availableRepositories, + isOpen, onClose, }: SubtaskFormProps) { const { toast } = useToast(); - const setActiveTask = useAppStore((s) => s.setActiveTask); - const setActiveSession = useAppStore((s) => s.setActiveSession); const isUtilityConfigured = useIsUtilityConfigured(); const { summarize, isSummarizing } = useSummarizeSession(); const [isCreating, setIsCreating] = useState(false); const [title, setTitle] = useState(defaultTitle); const [hasPrompt, setHasPrompt] = useState(false); const [contextValue, setContextValue] = useState("blank"); - const [selectedProfileId, setSelectedProfileId] = useState(defaultProfileId); - const [executorProfileId, setExecutorProfileId] = useState(""); - const promptRef = useRef(null); - const { - attachments, - isDragging, - fileInputRef, - handleRemoveAttachment, - handlePaste, - handleDragOver, - handleDragLeave, - handleDrop, - handleAttachClick, - handleFileInputChange, - } = useDialogAttachments(isCreating || isSummarizing); - const contextItems = useMemo( - () => toContextItems(attachments, handleRemoveAttachment), - [attachments, handleRemoveAttachment], - ); + // Shim DialogFormState shared with the create-task dialog so RepoChipsRow, + // useDialogHandlers, and the GitHub URL branches effect work unchanged. + const fs = useSubtaskFormState(); + useSeedParentRepository(fs, parentRepositoryId, baseBranch); + useSeedAgentProfileId(fs, defaultProfileId); + const handlers = useDialogHandlers(fs, availableRepositories); + useGitHubUrlBranchesEffect(fs, isOpen); + // Fetch on-disk repos so the chip dropdown shows "on disk" entries + // (matches the create-task dialog's RepoChipsRow behavior). + useDiscoverReposEffect(fs, isOpen, workspaceId, false, toast); const profileOptions = useAgentProfileOptions(agentProfiles); const sessionOptions = useSessionOptions(parentTaskId); - const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({ - sessionId: null, - taskTitle: title, - }); - const handleEnhancePrompt = useCallback(() => { - const current = promptRef.current?.value?.trim(); - if (!current) return; - enhancePrompt(current, (enhanced) => { - if (promptRef.current) { - promptRef.current.value = enhanced; - setHasPrompt(true); - } - }); - }, [enhancePrompt]); - const allExecutorProfiles = useExecutorProfiles(executors); const executorProfileOptions = useExecutorProfileOptions(allExecutorProfiles); - useAutoSelectExecutorProfile(allExecutorProfiles, executorProfileId, setExecutorProfileId); - - const handleContextChange = useCallback( - async (value: string) => { - if (!value) return; - setContextValue(value); - if (value === "copy_prompt" && initialPrompt && promptRef.current) { - promptRef.current.value = initialPrompt; - setHasPrompt(true); - } else if (value === "blank" && promptRef.current) { - promptRef.current.value = ""; - setHasPrompt(false); - } else if (value.startsWith("summarize:")) { - const sessionId = value.slice("summarize:".length); - const result = await summarize(sessionId); - if (result && promptRef.current) { - promptRef.current.value = result; - setHasPrompt(true); - } - } - }, - [initialPrompt, summarize], - ); - - const resolvePrompt = useCallback(() => { - const typed = promptRef.current?.value?.trim() ?? ""; - if (contextValue === "copy_prompt" && !typed && initialPrompt) return initialPrompt; - return typed; - }, [contextValue, initialPrompt]); - - const performCreate = useCallback( - async (trimmedTitle: string, prompt: string) => { - const repositories = repositoryId - ? [{ repository_id: repositoryId, base_branch: baseBranch || undefined }] - : []; - const profileId = selectedProfileId || defaultProfileId || undefined; - - const response = await createTask({ - workspace_id: workspaceId!, - workflow_id: workflowId!, - title: trimmedTitle, - description: prompt, - repositories, - start_agent: true, - agent_profile_id: profileId, - executor_profile_id: executorProfileId || undefined, - parent_id: parentTaskId, - attachments: toMessageAttachments(attachments), - }); - - const newSessionId = response.session_id ?? response.primary_session_id ?? null; - if (newSessionId) { - activateSubtaskSession({ - sessionId: newSessionId, - taskId: response.id, - setActiveTask, - setActiveSession, - }); - } - }, - [ - repositoryId, - baseBranch, - selectedProfileId, - defaultProfileId, - workspaceId, - workflowId, - executorProfileId, - parentTaskId, - attachments, - setActiveTask, - setActiveSession, - ], - ); - - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - const trimmedTitle = title.trim(); - if (!trimmedTitle || !workspaceId || !workflowId) return; - const prompt = resolvePrompt(); - if (!prompt) return; - - setIsCreating(true); - try { - await performCreate(trimmedTitle, prompt); - onClose(); - } catch (error) { - toast({ - title: "Failed to create subtask", - description: error instanceof Error ? error.message : "Unknown error", - variant: "error", - }); - } finally { - setIsCreating(false); - } - }, - [title, workspaceId, workflowId, resolvePrompt, performCreate, onClose, toast], - ); - - const showSessions = isUtilityConfigured ? sessionOptions : []; - + useAutoSelectExecutorProfile(allExecutorProfiles, fs.executorProfileId, fs.setExecutorProfileId); + const promptZone = useSubtaskPromptZone({ + taskTitle: title, + inputDisabled: isCreating || isSummarizing, + contextValue, + initialPrompt, + setHasPrompt, + }); + const handleContextChange = useContextChangeHandler({ + setContextValue, + setHasPrompt, + promptRef: promptZone.promptRef, + initialPrompt, + summarize, + }); + const { handleSubmit } = useSubtaskSubmit({ + fs, + parentTaskId, + defaultProfileId, + workspaceId, + workflowId, + availableRepositories, + attachments: promptZone.attachments.attachments, + resolvePrompt: promptZone.resolvePrompt, + title, + setIsCreating, + onClose, + }); return ( -
-
- - setTitle(e.target.value)} - placeholder="Subtask title" - className="text-sm" - data-testid="subtask-title-input" - disabled={isCreating} + -
- {worktreeBranch && ( -
- - - {worktreeBranch} - - Same branch as current session -
- )} -
- - -
- {profileOptions.length > 1 && ( -
- - -
- )} - -
-
- -