diff --git a/CHANGELOG.md b/CHANGELOG.md index 214ef2ce..75376a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ the release PR, and frozen at the tag — never an auto-generated commit list. ## Unreleased +### Feedback + +- The [feedback page](marketing/landing-prototype/src/feedback.js) supports Google sign-in, + durable private submissions, owner-approved public descriptions, older feedback pages, + and approval/progress email updates. Sending remains closed until service configuration + and rollout verification are complete. + ### Usage - **See remaining allowance before historical cost.** The redesigned @@ -17,6 +24,16 @@ the release PR, and frozen at the tag — never an auto-generated commit list. ### Sidebar +- **Workspace favicons in the sidebar.** [Project headers](src/ui/agent-rail.tsx) + show the workspace favicon when available, falling back to the folder icon + when the image is missing or cannot be displayed. + +- **Deck identity in the sidebar.** The [top row](src/ui/sidebar-toggle.tsx) now pairs + the collapse control on the left with the Deck logo, name and running version + on the right, separated from navigation by a border. The bordered `New Workspace` + button sits below it, above + the [scrolling project list](src/ui/agent-rail.tsx). + - **Agent usage replaces Unread.** The [compact sidebar summary](src/ui/usage/agent-usage-summary.tsx) shows agent logos and remaining allowance, with reset times on hover. Electron reads Codex limits through its CLI and Claude Code limits through a @@ -25,6 +42,26 @@ the release PR, and frozen at the tag — never an auto-generated commit list. ### Agents +- **Agent context above each terminal pane.** The + [compact header](src/terminal/pane-agent-header.tsx) shows the agent logo and + the same latest message as the sidebar. Claude Code panes offer an Effort + button that opens the CLI's native model/effort picker. Use Left/Right to + choose effort and S to apply it to this session only; other agents have no + effort button. + +- Fresh Codex panes no longer show busy bars while their startup screen is painting + before the first input, through the [input guard](src/terminal/agent-attention.ts). + +- **Fresh Codex panes keep their own conversation.** The rail + [requires an exact session identity](src/terminal/session-tail-store.ts), so a new pane + stays blank until its own conversation is available, even when another Codex is active + in the same folder. + +- **Launch agents side by side.** The compact [agent launch page](src/launcher/agent-launch-page.tsx) + opens from checkout New agent controls or Cmd/Ctrl+T. Run adds a pane beside the target; + Back and Escape return without creating a terminal. Right-click actions keep their + existing new-tab and shell behavior. + - **Signals are off again after upgrading from 1.1.** Deck 1.1 saved every agent's reporting switch as on, and 1.2.0 took that as your choice, so it kept adding its hooks to your Claude and Codex settings. This update @@ -34,6 +71,11 @@ the release PR, and frozen at the tag — never an auto-generated commit list. - **Codex no longer asks you to review your own hooks again.** Deck's Codex hooks [stay where they are](electron/agent-hooks/codex-hooks.ts) when Deck starts, instead of moving behind hooks you added later. +- **Codex rows stop showing the busy bars while Codex is idle.** Codex 0.154 + animates its prompt background, which Deck read as work in progress. Deck now + [launches Codex with its idle animations off](src/lib/agents/codex.ts); if you + wrote your own Codex command, add `-c tui.animations=false` or use the new + "No idle animations" switch under Settings → Agents. ### Updates diff --git a/README.md b/README.md index 49e21ce1..669631e5 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,8 @@ local agent storage. ## Built-in agents - **[Claude Code](https://claude.com/claude-code):** `claude --dangerously-skip-permissions` -- **[Codex](https://developers.openai.com/codex/cli):** `codex --dangerously-bypass-approvals-and-sandbox` +- **[Codex](https://developers.openai.com/codex/cli):** `codex --dangerously-bypass-approvals-and-sandbox -c tui.animations=false` + (the last flag stops Codex's idle animations from reading as a working agent) - **[OpenCode](https://opencode.ai):** `opencode` - **[Antigravity](https://antigravity.google):** `agy --dangerously-skip-permissions` - **[Gemini CLI](https://github.com/google-gemini/gemini-cli):** `gemini --yolo` diff --git a/backend/README.md b/backend/README.md index b70705aa..551d138a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -3,7 +3,7 @@ Specification and decisions: [DECK-1](https://linear.app/mxrsv/issue/DECK-1) `decided`. This directory is deployed independently from the desktop and landing. It shares no database with sibling SpaceVibe products. Entry point: [Worker](src/worker.mjs) `current`; -[deployment configuration](wrangler.jsonc) `current`; [privacy notice](../marketing/public/privacy/2026-09-12/index.html) `current`. +[deployment configuration](wrangler.jsonc) `current`; [privacy notice](../marketing/public/privacy/2026-09-18/index.html) `current`. ## Run and deploy @@ -37,7 +37,8 @@ prevents stale retries from reinserting identifiers already removed by retention 204 means the D1 upsert completed. 400/413 are terminal, 429/503 retryable. Other routes return 404 and non-POST ingest returns 405. No route reads or exports usage data; the one -read route, the feedback board below, never touches D1. A shared +read route, the feedback board below, reads only feedback tables through the +[feedback repository](src/feedback-repository.mjs). A shared 1000-request/minute, per-Cloudflare-location limiter bounds writes without reading IPs; it is best effort, not a strict global spending cap. Its namespace is service-specific. @@ -55,35 +56,89 @@ and [the update-counter column](migrations/0002-update-counters.sql). ## Feedback -The landing's `/feedback` page ([DECK-101](https://linear.app/mxrsv/issue/DECK-101)) talks to -[`/v1/feedback`](src/feedback-routes.mjs), which keeps nothing in D1: Linear is the only store. -`POST` [validates](src/feedback-payload.mjs) a title, body, category and optional draft UUID (up -to 16 KB, so 2,000 characters fit in any script), drops a filled honeypot with a silent 204, -applies its own `FEEDBACK_LIMITER`, then creates an issue in SpaceVibe-Deck with the `Feedback` -label in **Backlog**. The visitor's text sits in a fenced block under `## User report`, with -linear.app links defused so a paste cannot mention anyone; `Other` gets `Needs decision` instead -of a Type label. The draft UUID becomes the issue id, so a resend after a lost answer finds the -issue that already landed instead of creating a second one. `GET` returns the board — identifier, -title, category, column and update time, never the description — edge-cached for 60 seconds per -Cloudflare location. CORS allows only `deck.spacevibe.dev` and the local landing preview. - -Backlog is the moderation gate: nothing there is public. Moving an issue to Todo publishes its -title, so rewrite a title in Linear before publishing it. The [board query](src/linear-feedback.mjs) -asks Linear only for published state types and maps by type: unstarted → pending, started → -review, completed → done (30 most recent). Every connection names its own `first`: Linear's -complexity budget is per user and shared with every other tool using the key owner's account. - -With logging off, the `17 * * * *` [probe](src/linear-feedback.mjs) is the only signal that -submissions fail: it rejects when the key, team, labels or Backlog state are missing or archived, -so check that cron's invocations after any key or workflow change. The landing keeps sending -closed (`SUBMISSIONS_OPEN` in [feedback-api.js](../marketing/landing-prototype/src/feedback-api.js)) -until the Worker ships; flip it only after a probe passes. Team, label and Backlog IDs are `vars` -in [wrangler.jsonc](wrangler.jsonc); the key is a secret, and without it both routes answer 503: +[DECK-101](https://linear.app/mxrsv/issue/DECK-101) uses [D1 storage](src/feedback-repository.mjs) +as its source of truth. [POST /v1/feedback](src/feedback-routes.mjs) requires a +[verified Google ID token](src/feedback-auth.mjs), validates the report and required draft UUID, +and returns 201 only after saving a private pending record. A repeat of the same payload and +Google subject/draft ID returns the existing receipt; changed content returns 409. Public and +Linear issue IDs are generated by the server. A filled honeypot is rejected, never acknowledged +as saved. Neither a Linear outage nor an email failure can discard accepted feedback. + +The [minute job](src/feedback-sync.mjs) creates a Backlog issue and retries using its stable +server-generated UUID. The owner approves in Linear: Todo publishes Pending; In Progress, +Blocked and Ready for Review map to the middle column; Done remains public. Backlog, cancellation, +duplicate, manual archive or removal of the Feedback label hide the report. A signed +[Issue webhook](src/feedback-webhook.mjs) persists refresh requests and hides deleted issues. +Only D1-owned issue IDs are processed. A missing/inaccessible API response is not treated as +proof of deletion. Webhook delivery and polling are complementary: five records rotate per minute, +so synchronization can take longer than a minute as the backlog grows. + +[GET /v1/feedback](src/feedback-routes.mjs) needs no login. It returns original submitted title, +description, category, status, dates and identifiers; never email, Google subject or internal +Linear description. Editing Linear text does not rewrite the original public report: hide an +inappropriate report rather than assuming a Linear edit redacts it. Reads use no-store and an +opaque pagination cursor; there is no permanent cap on old Done items. Feedback has no automatic +expiry (including automatically archived Linear Done issues) and is excluded from [analytics retention](src/usage-repository.mjs). Hiding or deleting +an issue hides the public card but retains the D1 record; it is not a personal-data erasure. + +Publication and notification outbox entries commit in one D1 transaction. The first publication +and first In Progress milestone each receive one [Resend notification](src/feedback-mail.mjs). +Signed intermediate In Progress events are retained even when the next poll already sees Done. +Email payloads and idempotency keys remain stable on retries. Ambiguous deliveries stop retrying +after 23 hours (before Resend's 24-hour key expiry) and set `needs_review = 1`; check provider +receipts before manually reconciling them. This avoids claiming exactly-once email delivery. +Hidden reports do not start pending email deliveries; a provider request already in flight cannot +be recalled. Email recipient and title are stored privately in the outbox. + +### Configuration and opening intake + +The [deployment defaults](wrangler.jsonc) keep both `FEEDBACK_SYNC_ENABLED` and +`FEEDBACK_SUBMISSIONS_OPEN` false. After approval, apply [0003-feedback.sql](migrations/0003-feedback.sql) +with the existing migration command. The migration adds tables without rewriting usage data. +Configure a Google web client with `https://deck.spacevibe.dev` as an authorized JavaScript origin +(and the documented localhost preview origins if needed), then set `GOOGLE_CLIENT_ID` as a Worker +variable. Sign-in accepts verified Gmail and Google Workspace email; no Gmail inbox permission, +Google client secret, refresh token or stored browser credential is needed. + +Configure these Worker secrets using Wrangler's interactive prompts; never paste values into +an issue, source file or command argument: ```sh npx wrangler secret put LINEAR_API_KEY +npx wrangler secret put LINEAR_WEBHOOK_SECRET +npx wrangler secret put RESEND_API_KEY ``` +Set `FEEDBACK_EMAIL_FROM` to a verified Resend sender. Create a team-scoped Linear Issue webhook +at `https://api.deck.spacevibe.dev/v1/feedback/linear-webhook`; its signing secret must match. +Check `FEEDBACK_TEAM_ID`, `FEEDBACK_LABEL_ID`, `FEEDBACK_BACKLOG_STATE_ID` and +`FEEDBACK_PROGRESS_STATE_ID` against the workflow. Google client ID and sender are configuration, +not fabricated defaults. The [configuration endpoint](src/feedback-routes.mjs) refuses to open +intake when a required value or synchronization is absent. + +After deploying the Worker, enable synchronization and verify the hourly +[configuration probe](src/linear-feedback.mjs). Review the dated privacy notice before publishing +it. Enable `FEEDBACK_BOARD_OPEN` in the [landing API module](../marketing/landing-prototype/src/feedback-api.js) +for public reads. Open the Worker submission switch and landing `SUBMISSIONS_OPEN` only as part of +an authorized acceptance run: sign in, submit, approve, move to In Progress, confirm both emails, +then hide the report. A local build or mocked provider test does not establish these results. +The [dev-only demo](../marketing/landing-prototype/src/feedback-demo.js) remains available for +visual review without production credentials. + +For recovery, close intake while keeping synchronization and public reads enabled. Preserve D1 +records and pending jobs; do not revert to the old direct-to-Linear writer or drop tables. +Check scheduled invocation outcomes and count-only outbox queries (no email/body exports): + +```sql +SELECT count(*) AS unsynced FROM feedback WHERE linear_synced = 0 AND deleted_at IS NULL; +SELECT count(*) AS pending_mail FROM feedback_mail WHERE sent_at IS NULL AND needs_review = 0; +SELECT count(*) AS mail_needing_review FROM feedback_mail WHERE needs_review = 1; +``` + +There is no external failure alert configured. A failed minute invocation needs investigation; +a skipped lease means another invocation owns the bounded batch. Provider acceptance is not proof +of inbox delivery. Pending runtime and owner acceptance are tracked on the issue. + ## Operations and privacy Worker logging, invocation logging, tracing and Logpush are disabled. Do not use `wrangler diff --git a/backend/migrations/0003-feedback.sql b/backend/migrations/0003-feedback.sql new file mode 100644 index 00000000..9f7d8a0f --- /dev/null +++ b/backend/migrations/0003-feedback.sql @@ -0,0 +1,43 @@ +-- Feedback has owner-controlled retention, independent of usage_days cleanup. +CREATE TABLE feedback ( + id TEXT PRIMARY KEY, + draft_id TEXT NOT NULL, + google_sub TEXT NOT NULL, + email TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + category TEXT NOT NULL CHECK (category IN ('bug', 'idea', 'other')), + status TEXT NOT NULL DEFAULT 'private' CHECK (status IN ('private', 'pending', 'review', 'done', 'hidden')), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + linear_identifier TEXT, + linear_synced INTEGER NOT NULL DEFAULT 0, + source_updated_at INTEGER NOT NULL DEFAULT 0, + checked_at INTEGER NOT NULL DEFAULT 0, + deleted_at INTEGER, + UNIQUE (google_sub, draft_id) +); +CREATE INDEX feedback_public ON feedback(status, created_at DESC, id DESC); +CREATE INDEX feedback_sync ON feedback(linear_synced, checked_at); + +CREATE TABLE feedback_mail ( + id TEXT PRIMARY KEY, + feedback_id TEXT NOT NULL REFERENCES feedback(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('approved', 'progress')), + recipient TEXT NOT NULL, + title TEXT NOT NULL, + created_at INTEGER NOT NULL, + first_attempt_at INTEGER, + next_attempt_at INTEGER NOT NULL DEFAULT 0, + sent_at INTEGER, + needs_review INTEGER NOT NULL DEFAULT 0, + UNIQUE (feedback_id, kind) +); +CREATE INDEX feedback_mail_pending ON feedback_mail(sent_at, needs_review, next_attempt_at); + +CREATE TABLE feedback_jobs ( + name TEXT PRIMARY KEY, + lease_token TEXT, + lease_until INTEGER NOT NULL DEFAULT 0 +); +INSERT INTO feedback_jobs(name) VALUES ('sync'); diff --git a/backend/src/contract.test.mjs b/backend/src/contract.test.mjs index 561749bc..4d8d6104 100644 --- a/backend/src/contract.test.mjs +++ b/backend/src/contract.test.mjs @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { build } from "esbuild"; +import { FEEDBACK_SYNC_CRON } from "./feedback-sync.mjs"; import { FEEDBACK_PROBE_CRON } from "./linear-feedback.mjs"; import { AGENT_KEYS, SURFACE_KEYS, UPDATE_KEYS, COUNTER_CAP, validPayload } from "./payload.mjs"; @@ -80,14 +81,15 @@ test("deployment disables logs, traces, public preview URLs and exposes only the // drifted schedule would keep raw rows past it and nothing else would notice, // because logs and traces are off by design. // The second cron is the hourly feedback probe; the Worker dispatches on it. - assert.deepEqual(config.triggers.crons, ["0 3 * * *", FEEDBACK_PROBE_CRON]); + assert.deepEqual(config.triggers.crons, ["0 3 * * *", FEEDBACK_PROBE_CRON, FEEDBACK_SYNC_CRON]); }); test("privacy routes publish the dated notice and include its source in the deployment", async () => { const config = JSON.parse(await readFile(new URL("../../vercel.json", import.meta.url), "utf8")); // `/privacy` serves the newest notice; every earlier dated copy stays reachable. for (const [source, notice] of [ - ["/privacy", "2026-09-12"], + ["/privacy", "2026-09-18"], + ["/privacy/2026-09-18", "2026-09-18"], ["/privacy/2026-09-12", "2026-09-12"], ["/privacy/2026-09-07", "2026-09-07"], ]) { @@ -101,7 +103,7 @@ test("privacy routes publish the dated notice and include its source in the depl const ignore = await readFile(new URL("../../.vercelignore", import.meta.url), "utf8"); assert.ok(ignore.includes("!/marketing/public")); const html = await readFile( - new URL("../../marketing/public/privacy/2026-09-12/index.html", import.meta.url), + new URL("../../marketing/public/privacy/2026-09-18/index.html", import.meta.url), "utf8", ); assert.doesNotMatch(html, /anonymous/i); @@ -116,6 +118,10 @@ test("privacy routes publish the dated notice and include its source in the depl "no in-app opt-out", "1.0.0", "Share usage stats", + "Google", + "Resend", + "private until", + "do not automatically expire", ]) { assert.ok(html.replace(/\s+/g, " ").includes(term), term); } diff --git a/backend/src/feedback-auth.mjs b/backend/src/feedback-auth.mjs new file mode 100644 index 00000000..3bde4396 --- /dev/null +++ b/backend/src/feedback-auth.mjs @@ -0,0 +1,96 @@ +import { PayloadError } from "./payload.mjs"; + +const GOOGLE_KEYS = "https://www.googleapis.com/oauth2/v3/certs"; +const ISSUERS = ["https://accounts.google.com", "accounts.google.com"]; +const TOKEN_MAX_BYTES = 8192; +const FETCH_TIMEOUT_MS = 5000; +const MAX_KEY_CACHE_MS = 60 * 60_000; +let cachedKeys = null; + +function decode(part) { + if (!/^[A-Za-z0-9_-]+$/.test(part)) throw new PayloadError(401); + try { + return Uint8Array.from(atob(part.replace(/-/g, "+").replace(/_/g, "/")), (c) => + c.charCodeAt(0), + ); + } catch { + throw new PayloadError(401); + } +} + +function decodeJson(part) { + try { + return JSON.parse(new TextDecoder().decode(decode(part))); + } catch { + throw new PayloadError(401); + } +} + +async function signingKey(kid, now) { + // A fixed trusted endpoint: never follow token-supplied jku/x5u URLs. + if (!cachedKeys || cachedKeys.expires <= now) { + const response = await fetch(GOOGLE_KEYS, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!response.ok) throw new Error("Google keys unavailable"); + const body = await response.json(); + if (!Array.isArray(body.keys)) throw new Error("Invalid Google key response"); + const maxAge = Number(response.headers.get("cache-control")?.match(/max-age=(\d+)/)?.[1] ?? 0); + cachedKeys = { keys: body.keys, expires: now + Math.min(maxAge * 1000, MAX_KEY_CACHE_MS) }; + } + const key = cachedKeys.keys.find( + (item) => item.kid === kid && item.kty === "RSA" && item.alg === "RS256" && item.use === "sig", + ); + if (!key) throw new PayloadError(401); + return crypto.subtle.importKey( + "jwk", + key, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); +} + +/** GIS callback credentials are bearer tokens kept only in the page's memory. */ +export async function authenticateFeedback(request, env, now = Date.now()) { + if (!env.GOOGLE_CLIENT_ID) throw new Error("Google sign-in is not configured"); + const authorization = request.headers.get("authorization") ?? ""; + if (!authorization.startsWith("Bearer ") || authorization.length > TOKEN_MAX_BYTES) + throw new PayloadError(401); + const parts = authorization.slice(7).split("."); + if (parts.length !== 3) throw new PayloadError(401); + const header = decodeJson(parts[0]); + const claims = decodeJson(parts[1]); + if (header?.alg !== "RS256" || typeof header.kid !== "string" || header.crit) + throw new PayloadError(401); + const key = await signingKey(header.kid, now); + const valid = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + key, + decode(parts[2]), + new TextEncoder().encode(`${parts[0]}.${parts[1]}`), + ); + if (!valid || !validClaims(claims, env.GOOGLE_CLIENT_ID, now)) throw new PayloadError(401); + return { sub: claims.sub, email: claims.email }; +} + +function validClaims(c, audience, now) { + return ( + c && + ISSUERS.includes(c.iss) && + c.aud === audience && + (!c.azp || c.azp === audience) && + Number.isFinite(c.exp) && + c.exp * 1000 > now && + Number.isFinite(c.iat) && + c.iat * 1000 <= now + 60_000 && + (c.nbf === undefined || (Number.isFinite(c.nbf) && c.nbf * 1000 <= now)) && + typeof c.sub === "string" && + c.sub.length > 0 && + c.sub.length <= 255 && + c.email_verified === true && + typeof c.email === "string" && + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) && + c.email.length <= 254 && + // Google is authoritative for Gmail and verified Workspace email only. + (c.email.toLowerCase().endsWith("@gmail.com") || (typeof c.hd === "string" && c.hd.length > 0)) + ); +} diff --git a/backend/src/feedback-mail.mjs b/backend/src/feedback-mail.mjs new file mode 100644 index 00000000..d7756817 --- /dev/null +++ b/backend/src/feedback-mail.mjs @@ -0,0 +1,54 @@ +const RESEND_ENDPOINT = "https://api.resend.com/emails"; +const TIMEOUT_MS = 10_000; +// Resend only remembers idempotency keys for 24h. Stop ambiguous retries +// before that expires; a maintainer reconciles delivery before retrying. +const SAFE_RETRY_WINDOW_MS = 23 * 60 * 60_000; +const BOARD_URL = "https://deck.spacevibe.dev/feedback"; + +export async function deliverFeedbackMail(env, repository, now = Date.now()) { + const pending = await repository.pendingMail(now); + let failed = 0; + for (const event of pending) { + try { + if (!env.RESEND_API_KEY || !env.FEEDBACK_EMAIL_FROM) throw new Error("Email not configured"); + if (event.first_attempt_at !== null && now - event.first_attempt_at >= SAFE_RETRY_WINDOW_MS) { + await repository.mailNeedsReview(event.id); + failed += 1; + continue; + } + const attempt = await repository.startMail(event.id, now); + if (!attempt) continue; + await sendMail(env, attempt); + await repository.mailSent(event.id, now); + } catch { + // The durable row remains pending; report only a count, never PII. + failed += 1; + } + } + return failed; +} + +async function sendMail(env, event) { + const progress = event.kind === "progress"; + const message = progress + ? "Work has started on your feedback." + : "Your feedback has been approved and is now public."; + const response = await fetch(RESEND_ENDPOINT, { + method: "POST", + headers: { + authorization: `Bearer ${env.RESEND_API_KEY}`, + "content-type": "application/json", + "idempotency-key": event.id, + }, + body: JSON.stringify({ + from: env.FEEDBACK_EMAIL_FROM, + to: [event.recipient], + subject: progress ? "Your Deck feedback is in progress" : "Your Deck feedback is approved", + text: `${message}\n\n${event.title}\n\nView the feedback board: ${BOARD_URL}\n\nYou received this update because you submitted feedback to SpaceVibe Deck.`, + }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (!response.ok) throw new Error("Email provider unavailable"); + const body = await response.json(); + if (typeof body.id !== "string" || !body.id) throw new Error("Email receipt missing"); +} diff --git a/backend/src/feedback-payload.mjs b/backend/src/feedback-payload.mjs index f9523dbe..5d1ba42c 100644 --- a/backend/src/feedback-payload.mjs +++ b/backend/src/feedback-payload.mjs @@ -9,7 +9,7 @@ export const FEEDBACK_CATEGORIES = ["bug", "idea", "other"]; export const TITLE_MIN = 3; export const TITLE_MAX = 120; export const BODY_MAX = 2000; -const REQUIRED_FIELDS = ["title", "category"]; +const REQUIRED_FIELDS = ["title", "category", "id"]; // `website` is a honeypot the landing keeps out of layout, where neither a // person nor browser autofill can reach it; `id` is the draft's UUID v4. const OPTIONAL_FIELDS = ["body", "website", "id"]; @@ -41,7 +41,7 @@ export function parseFeedback(value) { !REQUIRED_FIELDS.every((key) => Object.hasOwn(value, key)) || !Object.values(value).every((field) => typeof field === "string") || !FEEDBACK_CATEGORIES.includes(value.category) || - (value.id !== undefined && !UUID_V4.test(value.id)) + !UUID_V4.test(value.id) ) { return undefined; } diff --git a/backend/src/feedback-repository.mjs b/backend/src/feedback-repository.mjs new file mode 100644 index 00000000..8c63d30d --- /dev/null +++ b/backend/src/feedback-repository.mjs @@ -0,0 +1,214 @@ +import { PayloadError, UUID_V4 } from "./payload.mjs"; + +export const PAGE_SIZE = 30; +export const SYNC_BATCH_SIZE = 5; +export const PUBLIC_STATUSES = ["pending", "review", "done"]; +const LEASE_MS = 5 * 60_000; + +export function parseBoardCursor(value) { + if (!value) return null; + const [time, id, extra] = value.split(":"); + if (extra !== undefined || !/^\d{1,16}$/.test(time) || !UUID_V4.test(id)) { + throw new PayloadError(400); + } + const createdAt = Number(time); + if (!Number.isSafeInteger(createdAt)) throw new PayloadError(400); + return { createdAt, id }; +} + +function publicItem(row) { + return { + id: row.id, + identifier: row.linear_identifier, + title: row.title, + description: row.body, + category: row.category, + status: row.status, + updatedAt: new Date(row.updated_at).toISOString(), + }; +} + +/** SQL and identity never escape this boundary through a public projection. */ +export function createFeedbackRepository(db) { + const statement = (sql, ...args) => db.prepare(sql).bind(...args); + const findById = (id) => statement("SELECT * FROM feedback WHERE id = ?", id).first(); + + async function create(input, user, now = Date.now()) { + // The public/Linear id is server-generated; a visitor cannot collide with + // another user's id or an existing unrelated issue in the owner's workspace. + await statement( + `INSERT INTO feedback (id, draft_id, google_sub, email, title, body, category, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(google_sub, draft_id) DO NOTHING`, + crypto.randomUUID(), + input.id, + user.sub, + user.email, + input.title, + input.body, + input.category, + now, + now, + ).run(); + const row = await statement( + "SELECT * FROM feedback WHERE google_sub = ? AND draft_id = ?", + user.sub, + input.id, + ).first(); + if (!row) throw new Error("Feedback persistence failed"); + if (row.title !== input.title || row.body !== input.body || row.category !== input.category) { + throw new PayloadError(409); + } + return row.id; + } + + async function listPublic(cursor) { + const rows = await statement( + `SELECT id, linear_identifier, title, body, category, status, updated_at, created_at + FROM feedback WHERE status IN ('pending', 'review', 'done') AND deleted_at IS NULL + AND (? IS NULL OR created_at < ? OR (created_at = ? AND id < ?)) + ORDER BY created_at DESC, id DESC LIMIT ?`, + cursor?.createdAt ?? null, + cursor?.createdAt ?? null, + cursor?.createdAt ?? null, + cursor?.id ?? null, + PAGE_SIZE + 1, + ).all(); + const page = rows.results.slice(0, PAGE_SIZE); + const last = page.at(-1); + return { + items: page.map(publicItem), + nextCursor: rows.results.length > PAGE_SIZE ? `${last.created_at}:${last.id}` : null, + }; + } + + async function applyModeration(id, state, now = Date.now()) { + // D1 batch is transactional. Only the latest authoritative Linear snapshot + // can change visibility; milestone rows and visibility commit together. + const update = statement( + `UPDATE feedback SET status = ?, source_updated_at = ?, updated_at = ?, + linear_identifier = ?, linear_synced = 1 + WHERE id = ? AND source_updated_at < ? AND deleted_at IS NULL`, + state.status, + state.version, + now, + state.identifier, + id, + state.version, + ); + const milestone = (kind, enabled) => + statement( + `INSERT INTO feedback_mail (id, feedback_id, kind, recipient, title, created_at) + SELECT id || ':' || ?, id, ?, email, title, ? FROM feedback + WHERE id = ? AND source_updated_at = ? AND deleted_at IS NULL + AND status IN ('pending', 'review', 'done') AND ? = 1 + ON CONFLICT(feedback_id, kind) DO NOTHING`, + kind, + kind, + now, + id, + state.version, + enabled ? 1 : 0, + ); + await db.batch([ + update, + milestone("approved", PUBLIC_STATUSES.includes(state.status)), + milestone("progress", state.inProgress), + ]); + } + + return { + create, + findById, + listPublic, + applyModeration, + async remove(id, now = Date.now()) { + await statement( + "UPDATE feedback SET status = 'hidden', deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL", + now, + now, + id, + ).run(); + }, + async pendingSync() { + const { results } = await statement( + `SELECT * FROM feedback WHERE deleted_at IS NULL + ORDER BY checked_at, linear_synced, created_at LIMIT ?`, + SYNC_BATCH_SIZE, + ).all(); + return results; + }, + async checked(id, now) { + await statement("UPDATE feedback SET checked_at = ? WHERE id = ?", now, id).run(); + }, + async synced(id) { + await statement("UPDATE feedback SET linear_synced = 1 WHERE id = ?", id).run(); + }, + async requestSync(id, reachedProgress = false, now = Date.now()) { + // Preserve intermediate In Progress events even if the next poll already + // observes Done. Visibility still comes from the latest Linear snapshot. + const milestone = (kind) => + statement( + `INSERT INTO feedback_mail (id, feedback_id, kind, recipient, title, created_at) + SELECT id || ':' || ?, id, ?, email, title, ? FROM feedback + WHERE id = ? AND deleted_at IS NULL AND ? = 1 + ON CONFLICT(feedback_id, kind) DO NOTHING`, + kind, + kind, + now, + id, + reachedProgress ? 1 : 0, + ); + await db.batch([ + statement("UPDATE feedback SET checked_at = 0 WHERE id = ?", id), + milestone("approved"), + milestone("progress"), + ]); + }, + async acquireLease(now) { + const token = crypto.randomUUID(); + const row = await statement( + `UPDATE feedback_jobs SET lease_token = ?, lease_until = ? + WHERE name = 'sync' AND lease_until < ? RETURNING lease_token`, + token, + now + LEASE_MS, + now, + ).first(); + return row?.lease_token ?? null; + }, + async releaseLease(token) { + await statement( + "UPDATE feedback_jobs SET lease_until = 0 WHERE name = 'sync' AND lease_token = ?", + token, + ).run(); + }, + async pendingMail(now) { + const { results } = await statement( + `SELECT m.* FROM feedback_mail m JOIN feedback f ON f.id = m.feedback_id + WHERE m.sent_at IS NULL AND m.needs_review = 0 AND m.next_attempt_at <= ? + AND f.deleted_at IS NULL AND f.status IN ('pending', 'review', 'done') + ORDER BY m.created_at, m.kind LIMIT ?`, + now, + SYNC_BATCH_SIZE, + ).all(); + return results; + }, + async startMail(id, now) { + return statement( + `UPDATE feedback_mail SET first_attempt_at = COALESCE(first_attempt_at, ?), next_attempt_at = ? + WHERE id = ? AND sent_at IS NULL AND EXISTS ( + SELECT 1 FROM feedback f WHERE f.id = feedback_mail.feedback_id + AND f.deleted_at IS NULL AND f.status IN ('pending', 'review', 'done') + ) RETURNING *`, + now, + now + 60_000, + id, + ).first(); + }, + async mailSent(id, now) { + await statement("UPDATE feedback_mail SET sent_at = ? WHERE id = ?", now, id).run(); + }, + async mailNeedsReview(id) { + await statement("UPDATE feedback_mail SET needs_review = 1 WHERE id = ?", id).run(); + }, + }; +} diff --git a/backend/src/feedback-routes.mjs b/backend/src/feedback-routes.mjs index 3e0bfe03..79e05b9b 100644 --- a/backend/src/feedback-routes.mjs +++ b/backend/src/feedback-routes.mjs @@ -1,102 +1,98 @@ +import { authenticateFeedback } from "./feedback-auth.mjs"; import { readFeedback } from "./feedback-payload.mjs"; -import { createFeedbackIssue, listFeedbackBoard } from "./linear-feedback.mjs"; +import { createFeedbackRepository, parseBoardCursor } from "./feedback-repository.mjs"; import { PayloadError } from "./payload.mjs"; export const FEEDBACK_PATH = "/v1/feedback"; +export const FEEDBACK_CONFIG_PATH = `${FEEDBACK_PATH}/config`; const ALLOWED_ORIGINS = [ "https://deck.spacevibe.dev", - // `npm run prototype:landing` serves the page from here. "http://127.0.0.1:5173", "http://localhost:5173", ]; const METHODS = "GET, POST, OPTIONS"; -const BOARD_MAX_AGE_SECONDS = 60; -// A synthetic key: one board for every visitor, whatever their origin. -const BOARD_CACHE_KEY = "https://api.deck.spacevibe.dev/v1/feedback"; - const HEADERS = { "cache-control": "no-store", "x-content-type-options": "nosniff" }; -function corsHeaders(request) { +function headersFor(request) { const origin = request.headers.get("origin"); - return ALLOWED_ORIGINS.includes(origin) - ? { "access-control-allow-origin": origin, vary: "origin" } - : { vary: "origin" }; + return { + ...HEADERS, + vary: "origin", + ...(ALLOWED_ORIGINS.includes(origin) ? { "access-control-allow-origin": origin } : {}), + }; } -/** - * The board body is cached without CORS headers and wrapped per request, so a - * response built for one origin is never replayed to another. - */ -async function readBoard(env) { - const cache = globalThis.caches?.default; - const key = new Request(BOARD_CACHE_KEY); - const cached = await cache?.match(key); - if (cached) return cached.text(); - const body = JSON.stringify({ items: await listFeedbackBoard(env) }); - await cache?.put( - key, - new Response(body, { - headers: { - "content-type": "application/json", - "cache-control": `public, max-age=${BOARD_MAX_AGE_SECONDS}`, - }, - }), +function json(body, headers, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { ...headers, "content-type": "application/json" }, + }); +} + +export function feedbackConfigured(env) { + return Boolean( + env.FEEDBACK_SYNC_ENABLED === "true" && + env.GOOGLE_CLIENT_ID && + env.LINEAR_API_KEY && + env.LINEAR_WEBHOOK_SECRET && + env.FEEDBACK_PROGRESS_STATE_ID && + env.RESEND_API_KEY && + env.FEEDBACK_EMAIL_FROM && + env.DB, ); - return body; } -async function board(env, headers) { - try { - return new Response(await readBoard(env), { - status: 200, +export async function handleFeedback(request, env) { + const headers = headersFor(request); + const url = new URL(request.url); + if (request.method === "OPTIONS") + return new Response(null, { + status: 204, headers: { ...headers, - "content-type": "application/json", - "cache-control": `public, max-age=${BOARD_MAX_AGE_SECONDS}`, + "access-control-allow-methods": METHODS, + "access-control-allow-headers": "content-type, authorization", + "access-control-max-age": "86400", }, }); - } catch { - // Never log a Linear error; it can echo issue content. - return new Response(null, { status: 503, headers }); - } -} - -async function submit(request, env, headers) { try { - const feedback = await readFeedback(request); - // A bot sees success and moves on; nothing reaches Linear or the limiter. - if (feedback.spam) return new Response(null, { status: 204, headers }); - // A shared per-location budget bounds writes without reading an IP address. - const limit = await env.FEEDBACK_LIMITER.limit({ key: "feedback" }); - if (!limit.success) return new Response(null, { status: 429, headers }); - await createFeedbackIssue(env, feedback); - return new Response(null, { status: 204, headers }); - } catch (error) { - // Never log the submission or a Linear error. Body errors are the sender's; - // anything else is an infrastructure failure worth retrying. - const status = error instanceof PayloadError ? error.status : 503; - return new Response(null, { status, headers }); - } -} - -export function handleFeedback(request, env) { - const headers = { ...HEADERS, ...corsHeaders(request) }; - switch (request.method) { - case "OPTIONS": - return new Response(null, { - status: 204, - headers: { - ...headers, - "access-control-allow-methods": METHODS, - "access-control-allow-headers": "content-type", - "access-control-max-age": "86400", + if (url.pathname === FEEDBACK_CONFIG_PATH && request.method === "GET") { + return json( + { + googleClientId: env.GOOGLE_CLIENT_ID || null, + submissionsOpen: env.FEEDBACK_SUBMISSIONS_OPEN === "true" && feedbackConfigured(env), }, - }); - case "GET": - return board(env, headers); - case "POST": - return submit(request, env, headers); - default: + headers, + ); + } + if (url.pathname !== FEEDBACK_PATH) return new Response(null, { status: 405, headers }); + const repository = createFeedbackRepository(env.DB); + if (request.method === "GET") { + return json( + await repository.listPublic(parseBoardCursor(url.searchParams.get("cursor"))), + headers, + ); + } + if (request.method !== "POST") return new Response(null, { status: 405, headers: { ...headers, allow: METHODS } }); + if (!ALLOWED_ORIGINS.includes(request.headers.get("origin"))) throw new PayloadError(403); + if (env.FEEDBACK_SUBMISSIONS_OPEN !== "true" || !feedbackConfigured(env)) + throw new PayloadError(503); + const feedback = await readFeedback(request); + // Never acknowledge a discarded submission as saved. + if (feedback.spam) throw new PayloadError(400); + const preAuth = await env.FEEDBACK_LIMITER.limit({ key: "feedback-auth" }); + if (!preAuth.success) throw new PayloadError(429); + const user = await authenticateFeedback(request, env); + const limit = await env.FEEDBACK_LIMITER.limit({ key: `feedback-user:${user.sub}` }); + if (!limit.success) throw new PayloadError(429); + const id = await repository.create(feedback, user); + return json({ id, status: "private" }, headers, 201); + } catch (error) { + // Do not echo provider/SQL failures, which can contain private content. + return new Response(null, { + status: error instanceof PayloadError ? error.status : 503, + headers, + }); } } diff --git a/backend/src/feedback-sync.mjs b/backend/src/feedback-sync.mjs new file mode 100644 index 00000000..00f7cc16 --- /dev/null +++ b/backend/src/feedback-sync.mjs @@ -0,0 +1,34 @@ +import { createFeedbackIssue, readFeedbackState } from "./linear-feedback.mjs"; +import { createFeedbackRepository } from "./feedback-repository.mjs"; +import { deliverFeedbackMail } from "./feedback-mail.mjs"; + +export const FEEDBACK_SYNC_CRON = "* * * * *"; + +export async function syncFeedback(env, now = Date.now()) { + // Separate from intake: closing new submissions must not stop moderation or mail. + if (env.FEEDBACK_SYNC_ENABLED !== "true") return; + const repository = createFeedbackRepository(env.DB); + const lease = await repository.acquireLease(now); + if (!lease) return; + let failed = 0; + try { + const rows = await repository.pendingSync(); + for (const row of rows) { + // Rotate failures to the back as well, so a broken issue cannot starve others. + await repository.checked(row.id, now); + try { + if (!row.linear_synced) { + await createFeedbackIssue(env, row); + await repository.synced(row.id); + } + await repository.applyModeration(row.id, await readFeedbackState(env, row.id), now); + } catch { + failed += 1; + } + } + failed += await deliverFeedbackMail(env, repository, now); + } finally { + await repository.releaseLease(lease); + } + if (failed) throw new Error(`Feedback background jobs failed: ${failed}`); +} diff --git a/backend/src/feedback-webhook.mjs b/backend/src/feedback-webhook.mjs new file mode 100644 index 00000000..317de560 --- /dev/null +++ b/backend/src/feedback-webhook.mjs @@ -0,0 +1,86 @@ +import { createFeedbackRepository } from "./feedback-repository.mjs"; +import { PayloadError, UUID_V4 } from "./payload.mjs"; + +export const FEEDBACK_WEBHOOK_PATH = "/v1/feedback/linear-webhook"; +const BODY_LIMIT = 128 * 1024; +const MAX_AGE_MS = 60_000; +const HEADERS = { "cache-control": "no-store", "x-content-type-options": "nosniff" }; + +async function readSignedEvent(request, secret) { + const signature = request.headers.get("linear-signature") ?? ""; + if (!/^[a-f0-9]{64}$/i.test(signature)) throw new PayloadError(401); + if (!request.body) throw new PayloadError(400); + const reader = request.body.getReader(); + const chunks = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > BODY_LIMIT) { + await reader.cancel(); + throw new PayloadError(413); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const raw = new Uint8Array(await new Blob(chunks).arrayBuffer()); + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"], + ); + const bytes = Uint8Array.from(signature.match(/../g), (hex) => parseInt(hex, 16)); + if (!(await crypto.subtle.verify("HMAC", key, bytes, raw))) throw new PayloadError(401); + let event; + try { + event = JSON.parse(new TextDecoder().decode(raw)); + } catch { + throw new PayloadError(400); + } + if ( + !event || + !Number.isSafeInteger(event.webhookTimestamp) || + Math.abs(Date.now() - event.webhookTimestamp) > MAX_AGE_MS + ) { + throw new PayloadError(401); + } + return event; +} + +export async function handleFeedbackWebhook(request, env) { + if (request.method !== "POST") return new Response(null, { status: 405, headers: HEADERS }); + try { + if (!env.LINEAR_WEBHOOK_SECRET) throw new Error("Webhook not configured"); + const event = await readSignedEvent(request, env.LINEAR_WEBHOOK_SECRET); + if (event.type === "Issue" && ["create", "update", "remove"].includes(event.action)) { + if (!UUID_V4.test(event.data?.id ?? "")) throw new PayloadError(400); + const repository = createFeedbackRepository(env.DB); + // Ignore all issues except ids created by this feedback repository. + const row = await repository.findById(event.data.id); + if (row && event.action === "remove") await repository.remove(row.id); + else if (row) { + const reachedProgress = + event.data.stateId === env.FEEDBACK_PROGRESS_STATE_ID && + event.data.teamId === env.FEEDBACK_TEAM_ID && + !event.data.archivedAt && + Array.isArray(event.data.labelIds) && + event.data.labelIds.includes(env.FEEDBACK_LABEL_ID); + await repository.requestSync(row.id, reachedProgress); + } + } + // Persist the refresh request before acknowledgment. No network round trip + // here: Linear requires an acknowledgment within five seconds. + return new Response(null, { status: 200, headers: HEADERS }); + } catch (error) { + return new Response(null, { + status: error instanceof PayloadError ? error.status : 503, + headers: HEADERS, + }); + } +} diff --git a/backend/src/feedback.test.mjs b/backend/src/feedback.test.mjs index 34101a96..96b1d2e6 100644 --- a/backend/src/feedback.test.mjs +++ b/backend/src/feedback.test.mjs @@ -1,337 +1,573 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; import { test } from "node:test"; -import { FEEDBACK_PROBE_CRON } from "./linear-feedback.mjs"; +import { createFeedbackRepository, parseBoardCursor, PAGE_SIZE } from "./feedback-repository.mjs"; +import { syncFeedback } from "./feedback-sync.mjs"; +import { createFeedbackIssue, describeFeedback, probeFeedbackConfig } from "./linear-feedback.mjs"; +import { authenticateFeedback } from "./feedback-auth.mjs"; import worker from "./worker.mjs"; -const URL_FEEDBACK = "https://api.deck.spacevibe.dev/v1/feedback"; const ORIGIN = "https://deck.spacevibe.dev"; -const DRAFT_ID = "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed"; -const BUG_LABEL = "ed9079dd-f534-4092-98ec-241335972834"; -const FEATURE_LABEL = "404554e5-9175-474e-b211-1a31ebde49e2"; -const IMPROVEMENT_LABEL = "ad32e36f-0027-41db-9e49-ad99864dc7cc"; -const NEEDS_DECISION_LABEL = "0968e82c-0db3-48ce-83c5-fe4afd04a5b3"; -const feedback = { - title: "Split panes lose focus", - body: "After closing a pane the terminal\nno longer takes keys.", +const API = "https://api.deck.spacevibe.dev/v1/feedback"; +const user = { sub: "google-subject", email: "reporter@gmail.com" }; +const input = { + id: "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed", + title: "Split pane focus", + body: "Steps\nDetails", category: "bug", website: "", }; +const keys = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], +); +const jwk = { + ...(await crypto.subtle.exportKey("jwk", keys.publicKey)), + kid: "test-key", + alg: "RS256", + use: "sig", +}; +const json = (body, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +function database(t) { + const sqlite = new DatabaseSync(":memory:"); + sqlite.exec(readFileSync(new URL("../migrations/0003-feedback.sql", import.meta.url), "utf8")); + t.after(() => sqlite.close()); + const prepare = (sql) => ({ + bind: (...args) => ({ + run: async () => ({ meta: { changes: sqlite.prepare(sql).run(...args).changes } }), + first: async () => sqlite.prepare(sql).get(...args) ?? null, + all: async () => ({ results: sqlite.prepare(sql).all(...args) }), + }), + }); + const binding = { + prepare, + batch: async (statements) => { + sqlite.exec("BEGIN"); + try { + const result = await Promise.all(statements.map((s) => s.run())); + sqlite.exec("COMMIT"); + return result; + } catch (error) { + sqlite.exec("ROLLBACK"); + throw error; + } + }, + }; + return { sqlite, binding, repository: createFeedbackRepository(binding) }; +} -function environment(overrides = {}) { +function env(db, overrides = {}) { return { - LINEAR_API_KEY: "lin_api_test", - FEEDBACK_TEAM_ID: "team-id", - FEEDBACK_LABEL_ID: "feedback-label", - FEEDBACK_BACKLOG_STATE_ID: "backlog-state", + DB: db, + GOOGLE_CLIENT_ID: "test.apps.googleusercontent.com", + LINEAR_API_KEY: "test-linear", + LINEAR_WEBHOOK_SECRET: "test-webhook-signing-secret", + RESEND_API_KEY: "test-mail", + FEEDBACK_EMAIL_FROM: "Deck ", + FEEDBACK_TEAM_ID: "team", + FEEDBACK_LABEL_ID: "label", + FEEDBACK_BACKLOG_STATE_ID: "backlog", + FEEDBACK_PROGRESS_STATE_ID: "progress", + FEEDBACK_SUBMISSIONS_OPEN: "true", + FEEDBACK_SYNC_ENABLED: "true", FEEDBACK_LIMITER: { limit: async () => ({ success: true }) }, ...overrides, }; } -function submit(body = feedback, init = {}) { - return new Request(URL_FEEDBACK, { +async function token(overrides = {}) { + const now = Math.floor(Date.now() / 1000); + const header = Buffer.from(JSON.stringify({ alg: "RS256", kid: "test-key" })).toString( + "base64url", + ); + const claims = Buffer.from( + JSON.stringify({ + iss: "https://accounts.google.com", + aud: "test.apps.googleusercontent.com", + sub: user.sub, + email: user.email, + email_verified: true, + iat: now, + exp: now + 3600, + ...overrides, + }), + ).toString("base64url"); + const signed = `${header}.${claims}`; + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + keys.privateKey, + new TextEncoder().encode(signed), + ); + return `${signed}.${Buffer.from(signature).toString("base64url")}`; +} + +function submit(body, credential, origin = ORIGIN) { + return new Request(API, { method: "POST", - headers: { "content-type": "application/json", origin: ORIGIN }, - body: typeof body === "string" ? body : JSON.stringify(body), - ...init, + headers: { "content-type": "application/json", authorization: `Bearer ${credential}`, origin }, + body: JSON.stringify(body), }); } -/** Record every Linear call and answer with `reply`. */ -function stubLinear(t, reply) { +function providers(t, respond = () => json({}, 503)) { const calls = []; t.mock.method(globalThis, "fetch", async (url, init) => { - calls.push({ url, init, request: JSON.parse(init.body) }); - return reply(calls.at(-1)); + if (url === "https://www.googleapis.com/oauth2/v3/certs") return json({ keys: [jwk] }); + const call = { url, init, body: JSON.parse(init.body) }; + calls.push(call); + return respond(call); }); return calls; } -const json = (value, status = 200) => - new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); -const created = () => json({ data: { issueCreate: { success: true } } }); +const state = (status = "pending", version = 100, inProgress = false) => ({ + status, + version, + inProgress, + identifier: "DECK-999", +}); -test("a honeypot submission answers 204 without touching Linear or the limiter", async (t) => { - const calls = stubLinear(t, created); - let limited = 0; - const env = environment({ - FEEDBACK_LIMITER: { limit: async () => (limited++, { success: true }) }, - }); - const response = await worker.fetch(submit({ ...feedback, website: "https://spam" }), env); - assert.equal(response.status, 204); +test("POST acknowledges only persisted private feedback and does not call Linear", async (t) => { + const { binding, sqlite } = database(t); + const calls = providers(t); + const response = await worker.fetch(submit(input, await token()), env(binding)); + assert.equal(response.status, 201); + const receipt = await response.json(); + assert.equal(receipt.status, "private"); + const row = sqlite.prepare("SELECT * FROM feedback").get(); + assert.equal(row.id, receipt.id); + assert.notEqual(row.id, input.id); + assert.equal(row.email, user.email); + assert.equal(row.status, "private"); assert.equal(calls.length, 0); - assert.equal(limited, 0); + assert.deepEqual((await (await worker.fetch(new Request(API), env(binding))).json()).items, []); }); -test("malformed submissions are terminal and never reach Linear", async (t) => { - const calls = stubLinear(t, created); - for (const body of [ - { ...feedback, email: "someone@example.com" }, - { ...feedback, title: "x".repeat(121) }, - { ...feedback, title: " \n\t " }, - { ...feedback, body: "y".repeat(2001) }, - { ...feedback, category: "praise" }, - { ...feedback, title: 42 }, - { ...feedback, id: "not-a-uuid" }, - { body: "no title", category: "idea" }, - "{", +test("D1 rejection returns 503 and never reports success", async (t) => { + providers(t); + const db = { + prepare() { + throw new Error("storage down"); + }, + }; + assert.equal((await worker.fetch(submit(input, await token()), env(db))).status, 503); +}); + +test("authentication checks signature, audience, issuer, expiry and verified Google email", async (t) => { + providers(t); + assert.deepEqual(await authenticateFeedback(submit(input, await token()), env()), user); + for (const claims of [ + { aud: "other" }, + { iss: "evil" }, + { exp: 1 }, + { email_verified: false }, + { sub: "" }, + { email: "user@third-party.example" }, + { iat: 9999999999 }, ]) { - assert.equal((await worker.fetch(submit(body), environment())).status, 400); + await assert.rejects(authenticateFeedback(submit(input, await token(claims)), env()), { + status: 401, + }); + } + const valid = await token(); + const [header, body, signature] = valid.split("."); + const tampered = `${header}.${Buffer.from(JSON.stringify({ sub: "attacker" })).toString("base64url")}.${signature}`; + await assert.rejects(authenticateFeedback(submit(input, tampered), env()), { status: 401 }); + for (const bad of ["", "abc", `${header}.${body}.!`, `e30.${body}.${signature}`]) { + await assert.rejects(authenticateFeedback(submit(input, bad), env()), { status: 401 }); } - const wrongType = submit(feedback, { headers: { "content-type": "text/plain" } }); - assert.equal((await worker.fetch(wrongType, environment())).status, 400); - assert.equal(calls.length, 0); }); -test("the body cap is measured in bytes and fits 2,000 characters of any script", async (t) => { - const calls = stubLinear(t, created); - const cjk = await worker.fetch(submit({ ...feedback, body: "漢".repeat(2000) }), environment()); - assert.equal(cjk.status, 204); - const oversized = await worker.fetch( - submit({ ...feedback, website: "x".repeat(17000) }), - environment(), +test("invalid input, honeypot and foreign origins never save; closed intake leaves public reads", async (t) => { + const { binding, sqlite, repository } = database(t); + providers(t); + const credential = await token(); + for (const value of [ + { ...input, id: "" }, + { ...input, title: "x" }, + { ...input, body: "x".repeat(2001) }, + { ...input, email: "spoof@gmail.com" }, + { ...input, category: "bad" }, + { ...input, website: "spam" }, + ]) { + assert.equal((await worker.fetch(submit(value, credential), env(binding))).status, 400); + } + assert.equal( + (await worker.fetch(submit(input, credential, "https://evil.example"), env(binding))).status, + 403, + ); + assert.equal((await worker.fetch(submit(input, ""), env(binding))).status, 401); + assert.equal( + ( + await worker.fetch( + submit(input, credential), + env(binding, { FEEDBACK_LIMITER: { limit: async () => ({ success: false }) } }), + ) + ).status, + 429, ); - assert.equal(oversized.status, 413); - assert.equal(calls.length, 1); + assert.equal(sqlite.prepare("SELECT count(*) n FROM feedback").get().n, 0); + const id = await repository.create(input, user); + await repository.applyModeration(id, state()); + const closed = env(binding, { FEEDBACK_SUBMISSIONS_OPEN: "false" }); + assert.equal((await worker.fetch(submit(input, credential), closed)).status, 503); + const page = await (await worker.fetch(new Request(API), closed)).json(); + assert.equal(page.items.length, 1); + assert.equal(page.items[0].description, input.body); + assert.doesNotMatch(JSON.stringify(page), /google-subject|reporter@gmail|draft_id/); }); -test("the limiter answers a retryable 429 before Linear", async (t) => { - const calls = stubLinear(t, () => json({})); - const env = environment({ FEEDBACK_LIMITER: { limit: async () => ({ success: false }) } }); - assert.equal((await worker.fetch(submit(), env)).status, 429); - assert.equal(calls.length, 0); +test("retries are immutable and scoped to Google subject, not user-supplied public ids", async (t) => { + const { repository, sqlite } = database(t); + const id = await repository.create(input, user); + assert.equal(await repository.create(input, user), id); + await assert.rejects(repository.create({ ...input, body: "Changed" }, user), { status: 409 }); + assert.notEqual(await repository.create(input, { ...user, sub: "another-user" }), id); + assert.equal(sqlite.prepare("SELECT count(*) n FROM feedback").get().n, 2); }); -test("a valid submission creates one Backlog issue in the agreed format", async (t) => { - const calls = stubLinear(t, created); - const response = await worker.fetch( - submit({ ...feedback, title: " Split\tpanes \n lose focus ", id: DRAFT_ID }), - environment(), - ); - assert.equal(response.status, 204); - assert.equal(response.headers.get("access-control-allow-origin"), ORIGIN); - assert.equal(calls.length, 1); - assert.equal(calls[0].url, "https://api.linear.app/graphql"); - assert.equal(calls[0].init.headers.authorization, "lin_api_test"); - assert.ok(calls[0].init.signal instanceof AbortSignal); - const { input } = calls[0].request.variables; - assert.equal(input.id, DRAFT_ID); - assert.equal(input.teamId, "team-id"); - assert.equal(input.stateId, "backlog-state"); - assert.equal(input.title, "Split panes lose focus"); - assert.deepEqual(input.labelIds, ["feedback-label", BUG_LABEL]); - assert.equal( - input.description, - [ - "## User report", - "", - "```text", - "After closing a pane the terminal", - "no longer takes keys.", - "```", - "", - "## Submission", - "", - "- Category: Bug", - "- Source: deck.spacevibe.dev/feedback", - ].join("\n"), - ); +test("public pagination reaches old Done items with equal timestamps and no private rows", async (t) => { + const { repository } = database(t); + for (let index = 0; index < PAGE_SIZE + 4; index++) { + const id = await repository.create({ ...input, id: crypto.randomUUID() }, user, 1); + await repository.applyModeration(id, state("done")); + } + await repository.create({ ...input, id: crypto.randomUUID() }, user); + const first = await repository.listPublic(null); + const last = await repository.listPublic(parseBoardCursor(first.nextCursor)); + assert.equal(first.items.length, PAGE_SIZE); + assert.equal(last.items.length, 4); + assert.equal(last.nextCursor, null); + assert.equal(new Set([...first.items, ...last.items].map((x) => x.id)).size, PAGE_SIZE + 4); + assert.throws(() => parseBoardCursor("0:bad"), { status: 400 }); }); -test("visitor text stays inert: the fence outgrows backticks and linear.app links are defused", async (t) => { - const calls = stubLinear(t, created); - const body = "See ```` here ![x](https://evil.example/p.png) https://Linear.app/mxrsv/profiles/x"; - await worker.fetch(submit({ ...feedback, body }), environment()); - await worker.fetch(submit({ ...feedback, body: "" }), environment()); - const [fenced, empty] = calls.map((call) => call.request.variables.input.description); - assert.match(fenced, /\n`````text\nSee ```` here !\[x\]\(https:\/\/evil\.example\/p\.png\) /); - assert.match(fenced, /https:\/\/linear\[\.\]app\/mxrsv\/profiles\/x\n`````\n/); - assert.match(empty, /^## User report\n\nNo details were given\.\n/); +test("moderation is monotonic, milestones are atomic and duplicates cannot send again", async (t) => { + const { repository, sqlite } = database(t); + const id = await repository.create(input, user); + await repository.applyModeration(id, state("pending", 100)); + await repository.applyModeration(id, state("review", 200, true)); + await repository.applyModeration(id, state("review", 200, true)); + await repository.applyModeration(id, state("hidden", 150)); + assert.equal((await repository.findById(id)).status, "review"); + assert.equal(sqlite.prepare("SELECT count(*) n FROM feedback_mail").get().n, 2); + await repository.applyModeration(id, state("hidden", 300)); + assert.equal((await repository.listPublic(null)).items.length, 0); + assert.equal((await repository.pendingMail(Date.now())).length, 0); + await repository.remove(id); + await repository.applyModeration(id, state("pending", 400)); + assert.equal((await repository.findById(id)).status, "hidden"); }); -test("ideas carry the Feature label; other asks for a decision", async (t) => { - const calls = stubLinear(t, created); - await worker.fetch(submit({ ...feedback, category: "idea" }), environment()); - await worker.fetch(submit({ ...feedback, category: "other" }), environment()); - assert.deepEqual(calls[0].request.variables.input.labelIds, ["feedback-label", FEATURE_LABEL]); - assert.deepEqual(calls[1].request.variables.input.labelIds, [ - "feedback-label", - NEEDS_DECISION_LABEL, - ]); - assert.match(calls[1].request.variables.input.description, /- Category: Other/); +test("failure creating a milestone rolls back publication", async (t) => { + const { repository, sqlite } = database(t); + const id = await repository.create(input, user); + sqlite.exec( + "CREATE TRIGGER fail_mail BEFORE INSERT ON feedback_mail BEGIN SELECT RAISE(ABORT, 'mail unavailable'); END;", + ); + await assert.rejects(repository.applyModeration(id, state())); + assert.equal((await repository.findById(id)).status, "private"); }); -test("every Linear failure is a retryable 503", async (t) => { - for (const reply of [ - () => json({ errors: [{ message: "bad" }] }), - () => json({ data: { issueCreate: { success: false } } }), - () => json({}, 500), - () => json({ errors: [{ extensions: { code: "RATELIMITED" } }] }, 400), - () => { - throw new TypeError("network down"); +function linearSnapshot(id, type = "unstarted", version = 100) { + return { + data: { + issues: { + nodes: [ + { + id, + identifier: "DECK-999", + updatedAt: new Date(version).toISOString(), + archivedAt: null, + team: { id: "team" }, + state: { type, id: type === "started" ? "progress" : "todo" }, + labels: { nodes: [{ id: "label" }] }, + }, + ], + }, }, - ]) { - t.mock.restoreAll(); - stubLinear(t, reply); - assert.equal((await worker.fetch(submit(), environment())).status, 503); - } + }; +} + +test("Linear outage keeps durable intake; retry creates one linked issue and sends milestones once", async (t) => { + const { repository, binding, sqlite } = database(t); + const id = await repository.create(input, user); + providers(t); + await assert.rejects(syncFeedback(env(binding)), /background jobs failed/); + assert.equal((await repository.findById(id)).linear_synced, 0); t.mock.restoreAll(); - const calls = stubLinear(t, () => json({})); - const missingKey = environment({ LINEAR_API_KEY: undefined }); - assert.equal((await worker.fetch(submit(), missingKey)).status, 503); + const calls = providers(t, ({ url, body }) => + url.includes("resend") + ? json({ id: "email-receipt" }) + : body.query.includes("issueCreate") + ? json({ data: { issueCreate: { success: true } } }) + : json(linearSnapshot(id)), + ); + await syncFeedback(env(binding)); + await syncFeedback(env(binding)); + assert.equal((await repository.findById(id)).status, "pending"); + assert.equal(calls.filter((c) => c.body.query?.includes("issueCreate")).length, 1); + const mail = calls.filter((c) => c.url.includes("resend")); + assert.equal(mail.length, 1); + assert.deepEqual(mail[0].body.to, [user.email]); + assert.equal(mail[0].init.headers["idempotency-key"], `${id}:approved`); + assert.equal(sqlite.prepare("SELECT sent_at FROM feedback_mail").get().sent_at > 0, true); +}); + +test("mail retries keep their key and stop before the provider's deduplication window expires", async (t) => { + const { repository, binding, sqlite } = database(t); + const id = await repository.create(input, user); + await repository.synced(id); + await repository.applyModeration(id, state()); + const started = Date.now(); + const calls = providers(t, ({ url }) => + url.includes("resend") ? json({}, 503) : json(linearSnapshot(id)), + ); + await assert.rejects(syncFeedback(env(binding), started)); + await assert.rejects(syncFeedback(env(binding), started + 60_000)); + await assert.rejects(syncFeedback(env(binding), started + 24 * 60 * 60_000)); + const mail = calls.filter((c) => c.url.includes("resend")); + assert.equal(mail.length, 2); + assert.equal(mail[0].init.headers["idempotency-key"], mail[1].init.headers["idempotency-key"]); + assert.equal(sqlite.prepare("SELECT needs_review FROM feedback_mail").get().needs_review, 1); +}); + +test("concurrent scheduled jobs share a lease", async (t) => { + const { repository, binding } = database(t); + await repository.create(input, user); + const lease = await repository.acquireLease(Date.now()); + const calls = providers(t); + await syncFeedback(env(binding)); assert.equal(calls.length, 0); + await repository.releaseLease("wrong-token"); + assert.equal(await repository.acquireLease(Date.now()), null); + await repository.releaseLease(lease); + assert.ok(await repository.acquireLease(Date.now())); }); -test("a retry whose issue already landed succeeds instead of duplicating", async (t) => { - const calls = stubLinear(t, ({ request }) => { - if (request.query.includes("issueCreate")) throw new TypeError("answer lost"); - return json({ data: { issue: { id: request.variables.id } } }); +async function webhook(event, secret = "test-webhook-signing-secret") { + const raw = JSON.stringify({ + type: "Issue", + action: "update", + webhookTimestamp: Date.now(), + ...event, }); - const response = await worker.fetch(submit({ ...feedback, id: DRAFT_ID }), environment()); - assert.equal(response.status, 204); - assert.equal(calls.length, 2); - assert.deepEqual(calls[1].request.variables, { id: DRAFT_ID }); + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(raw)); + return new Request(`${API}/linear-webhook`, { + method: "POST", + headers: { "linear-signature": Buffer.from(signature).toString("hex") }, + body: raw, + }); +} - t.mock.restoreAll(); - const missing = stubLinear(t, ({ request }) => - request.query.includes("issueCreate") - ? json({}, 500) - : json({ errors: [{ message: "Entity not found" }] }), +test("signed webhooks persist refresh/delete, reject stale/forged events and ignore unrelated ids", async (t) => { + const { repository, binding } = database(t); + const id = await repository.create(input, user); + await repository.applyModeration(id, state()); + await repository.checked(id, 100); + const calls = providers(t); + assert.equal((await worker.fetch(await webhook({ data: { id } }), env(binding))).status, 200); + assert.equal((await repository.findById(id)).checked_at, 0); + assert.equal( + (await worker.fetch(await webhook({ data: { id }, action: "remove" }, "wrong"), env(binding))) + .status, + 401, ); assert.equal( - (await worker.fetch(submit({ ...feedback, id: DRAFT_ID }), environment())).status, - 503, + (await worker.fetch(await webhook({ data: { id }, webhookTimestamp: 1 }), env(binding))).status, + 401, ); - assert.equal(missing.length, 2); + assert.equal( + (await worker.fetch(await webhook({ data: { id: crypto.randomUUID() } }), env(binding))).status, + 200, + ); + assert.equal( + (await worker.fetch(await webhook({ data: { id }, action: "remove" }), env(binding))).status, + 200, + ); + assert.equal((await repository.listPublic(null)).items.length, 0); + assert.equal(calls.length, 0); }); -const issue = (identifier, type, updatedAt, labels = []) => ({ - identifier, - title: `${identifier} title`, - updatedAt, - state: { type }, - labels: { nodes: labels.map((id) => ({ id })) }, - description: "PRIVATE BODY TEXT", +test("Linear issue creation fences text, assigns type and recovers a lost response", async (t) => { + const description = describeFeedback({ body: "```` https://linear.app/a", category: "other" }); + assert.match(description, /`````text/); + assert.match(description, /linear\[\.\]app/); + const calls = providers(t, ({ body }) => + body.query.includes("issueCreate") + ? json({}, 503) + : json({ data: { issue: { id: input.id } } }), + ); + await createFeedbackIssue(env(), input); + assert.equal(calls.length, 2); + assert.equal(calls[0].body.variables.input.id, input.id); + assert.equal(calls[0].body.variables.input.stateId, "backlog"); }); -const boardReply = (open, done = []) => - json({ data: { open: { nodes: open }, done: { nodes: done } } }); - -function board(url = URL_FEEDBACK) { - return new Request(url, { headers: { origin: ORIGIN } }); -} - -test("the board asks only for published states and maps them by type", async (t) => { - const calls = stubLinear(t, () => - boardReply( - [ - issue("DECK-2", "unstarted", "2026-09-14T09:00:00.000Z", [BUG_LABEL]), - issue("DECK-3", "started", "2026-09-14T11:00:00.000Z", [IMPROVEMENT_LABEL]), - issue("DECK-4", "started", "2026-09-13T11:00:00.000Z"), - issue("DECK-7", "canceled", "2026-09-14T12:00:00.000Z"), - ], - [issue("DECK-6", "completed", "2026-09-11T11:00:00.000Z")], - ), +test("configuration stays closed when dependencies are missing and preflight allows bearer auth", async (t) => { + const { binding } = database(t); + const response = await worker.fetch( + new Request(`${API}/config`), + env(binding, { RESEND_API_KEY: undefined }), ); - const response = await worker.fetch(board(), environment()); - assert.equal(response.status, 200); - assert.equal(response.headers.get("cache-control"), "public, max-age=60"); - assert.equal(response.headers.get("access-control-allow-origin"), ORIGIN); - const text = await response.text(); - assert.doesNotMatch(text, /PRIVATE BODY TEXT|description/); - const { items } = JSON.parse(text); - assert.deepEqual( - items.map(({ id, status, category }) => [id, status, category]), - [ - ["DECK-3", "review", "idea"], - ["DECK-2", "pending", "bug"], - ["DECK-4", "review", "other"], - ["DECK-6", "done", "other"], - ], + assert.equal((await response.json()).submissionsOpen, false); + const preflight = await worker.fetch( + new Request(API, { method: "OPTIONS", headers: { origin: ORIGIN } }), + env(binding), ); - assert.deepEqual(Object.keys(items[0]).sort(), [ - "category", - "id", - "status", - "title", - "updatedAt", - ]); - const { query, variables } = calls[0].request; - assert.deepEqual(variables, { - team: "team-id", - label: "feedback-label", - types: [BUG_LABEL, FEATURE_LABEL, IMPROVEMENT_LABEL], - }); - assert.match(query, /state: \{ type: \{ in: \["unstarted", "started"\] \} \}/); - assert.match(query, /state: \{ type: \{ eq: "completed" \} \}/); - // No connection is left to Linear's 50-node default price. - assert.match(query, /labels\(first: 3,/); + assert.equal(preflight.headers.get("access-control-allow-origin"), ORIGIN); + assert.match(preflight.headers.get("access-control-allow-headers"), /authorization/); }); -test("the Done column keeps only the 30 most recent items", async (t) => { - const done = Array.from({ length: 40 }, (_, index) => - issue(`DECK-${index}`, "completed", new Date(Date.UTC(2026, 8, 1, index)).toISOString()), +test("probe rejects missing or archived Linear configuration", async (t) => { + providers(t, () => + json({ + data: { viewer: { id: "user" }, team: { id: "team" }, feedback: { archivedAt: "date" } }, + }), ); - stubLinear(t, () => boardReply([], done)); - const { items } = await (await worker.fetch(board(), environment())).json(); - assert.equal(items.length, 30); - assert.equal(items[0].id, "DECK-39"); - assert.equal(items.at(-1).id, "DECK-10"); + await assert.rejects(probeFeedbackConfig(env())); }); -test("a Linear failure on the board is a 503", async (t) => { - stubLinear(t, () => json({ errors: [{ message: "bad" }] })); - assert.equal((await worker.fetch(board(), environment())).status, 503); +test("a signed In Progress event followed by Done before polling still delivers both milestones", async (t) => { + const { repository, binding, sqlite } = database(t); + const id = await repository.create(input, user); + await repository.synced(id); + const data = { id, stateId: "progress", teamId: "team", labelIds: ["label"], archivedAt: null }; + assert.equal((await worker.fetch(await webhook({ data }), env(binding))).status, 200); + assert.equal((await worker.fetch(await webhook({ data }), env(binding))).status, 200); + assert.equal(sqlite.prepare("SELECT count(*) n FROM feedback_mail").get().n, 2); + assert.equal((await repository.listPublic(null)).items.length, 0); + const calls = providers(t, ({ url }) => + url.includes("resend") ? json({ id: "receipt" }) : json(linearSnapshot(id, "completed", 300)), + ); + await syncFeedback(env(binding)); + assert.equal((await repository.findById(id)).status, "done"); + assert.equal(calls.filter((c) => c.url.includes("resend")).length, 2); }); -const probe = { cron: FEEDBACK_PROBE_CRON, scheduledTime: 0 }; -const healthy = { - viewer: { id: "user" }, - team: { id: "team-id" }, - feedback: { archivedAt: null }, - bug: { archivedAt: null }, - feature: { archivedAt: null }, - decision: { archivedAt: null }, - backlog: { archivedAt: null }, -}; - -test("the hourly probe passes only while key, team, labels and Backlog all hold", async (t) => { - const calls = stubLinear(t, () => json({ data: healthy })); - await worker.scheduled(probe, environment()); - assert.equal(calls.length, 1); - assert.equal(calls[0].request.variables.state, "backlog-state"); - - for (const data of [ - { ...healthy, feedback: { archivedAt: "2026-09-15T00:00:00.000Z" } }, - { ...healthy, backlog: null }, - ]) { +test("actual Linear state mappings preserve moderation and never hide a missing snapshot", async (t) => { + const { repository, binding, sqlite } = database(t); + const id = await repository.create(input, user); + await repository.synced(id); + const variants = [ + ["backlog", {}, false], + ["unstarted", {}, true], + ["started", {}, true], + ["completed", {}, true], + ["canceled", {}, false], + ["unstarted", { archivedAt: new Date(900).toISOString() }, false], + [ + "completed", + { archivedAt: new Date(900).toISOString(), autoArchivedAt: new Date(900).toISOString() }, + true, + ], + ["unstarted", { labels: { nodes: [] } }, false], + ["unstarted", { team: { id: "other" } }, false], + ]; + for (const [index, [type, changes, visible]] of variants.entries()) { t.mock.restoreAll(); - stubLinear(t, () => json({ data })); - await assert.rejects(worker.scheduled(probe, environment()), /feedback|backlog/); + const snapshot = linearSnapshot(id, type, (index + 1) * 1000); + const changed = { + data: { issues: { nodes: [{ ...snapshot.data.issues.nodes[0], ...changes }] } }, + }; + providers(t, ({ url }) => (url.includes("resend") ? json({ id: "receipt" }) : json(changed))); + await syncFeedback(env(binding)); + assert.equal( + (await repository.listPublic(null)).items.length, + visible ? 1 : 0, + type + JSON.stringify(changes), + ); } + assert.equal(sqlite.prepare("SELECT count(*) n FROM feedback_mail").get().n, 2); + await repository.applyModeration(id, state("pending", 10000)); t.mock.restoreAll(); - stubLinear(t, () => json({}, 401)); - await assert.rejects(worker.scheduled(probe, environment())); - await assert.rejects(worker.scheduled(probe, environment({ LINEAR_API_KEY: undefined }))); + providers(t, () => json({ data: { issues: { nodes: [] } } })); + await assert.rejects(syncFeedback(env(binding))); + assert.equal((await repository.listPublic(null)).items.length, 1); }); -test("CORS: preflight answers the allowed origin; others get no allow-origin", async () => { - const preflight = await worker.fetch( - new Request(URL_FEEDBACK, { method: "OPTIONS", headers: { origin: "http://127.0.0.1:5173" } }), - environment(), +test("lost mail receipt and failed sent_at write retry the identical payload without early delivery", async (t) => { + const { repository, binding, sqlite } = database(t); + const id = await repository.create(input, user); + await repository.synced(id); + await repository.applyModeration(id, state()); + const now = Date.now(); + let requests = 0; + const calls = providers(t, ({ url }) => { + if (!url.includes("resend")) return json(linearSnapshot(id)); + requests++; + if (requests === 1) throw new Error("receipt lost after provider accepted"); + return json({ id: "same-receipt" }); + }); + await assert.rejects(syncFeedback(env(binding), now)); + await syncFeedback(env(binding), now + 30_000); + assert.equal(requests, 1); + sqlite.exec( + "CREATE TRIGGER fail_sent BEFORE UPDATE OF sent_at ON feedback_mail BEGIN SELECT RAISE(ABORT, 'write failed'); END;", + ); + await assert.rejects(syncFeedback(env(binding), now + 60_000)); + sqlite.exec("DROP TRIGGER fail_sent;"); + await syncFeedback(env(binding), now + 120_000); + await syncFeedback(env(binding), now + 180_000); + const mail = calls.filter((c) => c.url.includes("resend")); + assert.equal(mail.length, 3); + assert.deepEqual( + mail.map((c) => c.body), + [mail[0].body, mail[0].body, mail[0].body], ); - assert.equal(preflight.status, 204); - assert.equal(preflight.headers.get("access-control-allow-origin"), "http://127.0.0.1:5173"); - assert.equal(preflight.headers.get("access-control-allow-methods"), "GET, POST, OPTIONS"); - assert.equal(preflight.headers.get("access-control-allow-headers"), "content-type"); - assert.equal(preflight.headers.get("access-control-max-age"), "86400"); - assert.equal(preflight.headers.get("vary"), "origin"); - const foreign = await worker.fetch( - new Request(URL_FEEDBACK, { method: "OPTIONS", headers: { origin: "https://evil.example" } }), - environment(), + assert.equal(new Set(mail.map((c) => c.init.headers["idempotency-key"])).size, 1); +}); + +test("webhook write failures do not acknowledge removal or refresh", async (t) => { + const { repository, binding, sqlite } = database(t); + const id = await repository.create(input, user); + await repository.applyModeration(id, state()); + sqlite.exec( + "CREATE TRIGGER fail_refresh BEFORE UPDATE ON feedback BEGIN SELECT RAISE(ABORT, 'write failed'); END;", + ); + for (const action of ["update", "remove"]) { + assert.equal( + (await worker.fetch(await webhook({ action, data: { id } }), env(binding))).status, + 503, + ); + } + assert.equal((await repository.listPublic(null)).items.length, 1); + sqlite.exec("DROP TRIGGER fail_refresh;"); + assert.equal( + (await worker.fetch(await webhook({ action: "remove", data: { id } }), env(binding))).status, + 200, + ); + assert.equal((await repository.listPublic(null)).items.length, 0); +}); + +test("body limits allow full CJK details and reject oversized streams", async (t) => { + const { binding } = database(t); + providers(t); + const credential = await token(); + assert.equal( + (await worker.fetch(submit({ ...input, body: "漢".repeat(2000) }, credential), env(binding))) + .status, + 201, + ); + assert.equal( + (await worker.fetch(submit({ ...input, website: "x".repeat(17000) }, credential), env(binding))) + .status, + 413, ); - assert.equal(foreign.headers.get("access-control-allow-origin"), null); - const put = await worker.fetch(new Request(URL_FEEDBACK, { method: "PUT" }), environment()); - assert.equal(put.status, 405); - assert.equal(put.headers.get("allow"), "GET, POST, OPTIONS"); }); diff --git a/backend/src/linear-feedback.mjs b/backend/src/linear-feedback.mjs index b2c5b741..c9bc56de 100644 --- a/backend/src/linear-feedback.mjs +++ b/backend/src/linear-feedback.mjs @@ -1,6 +1,6 @@ /** - * Linear is the only store for public feedback (DECK-101): the Worker creates - * issues and reads their state back; the owner moves them in Linear itself. + * Linear is the moderation surface. D1 owns the original feedback and retries + * delivery here; the owner controls publication by moving the linked issue. */ const LINEAR_ENDPOINT = "https://api.linear.app/graphql"; // A hung Linear call must not hang the visitor; the landing keeps the draft. @@ -15,52 +15,24 @@ export const FEEDBACK_PROBE_CRON = "17 * * * *"; /** Workspace labels in SpaceVibe-Deck. */ const BUG_LABEL = "ed9079dd-f534-4092-98ec-241335972834"; const FEATURE_LABEL = "404554e5-9175-474e-b211-1a31ebde49e2"; -const IMPROVEMENT_LABEL = "ad32e36f-0027-41db-9e49-ad99864dc7cc"; // "Other" carries no Type; the owner picks one while triaging. const NEEDS_DECISION_LABEL = "0968e82c-0db3-48ce-83c5-fe4afd04a5b3"; const LABEL_BY_CATEGORY = { bug: BUG_LABEL, idea: FEATURE_LABEL, other: NEEDS_DECISION_LABEL }; -const TYPE_LABELS = [BUG_LABEL, FEATURE_LABEL, IMPROVEMENT_LABEL]; const CATEGORY_NAME = { bug: "Bug", idea: "Idea", other: "Other" }; -/** - * Backlog is the moderation gate: an unreviewed submission is never public. - * Moving it to Todo publishes it. The board asks Linear for published state - * types only, so Backlog, Canceled and Duplicate never take its slots, and it - * maps by type so renaming a status neither hides nor leaks cards. - */ -const STATUS_BY_STATE_TYPE = { unstarted: "pending", started: "review", completed: "done" }; -export const DONE_LIMIT = 30; -const OPEN_LIMIT = 100; - const CREATE_ISSUE = `mutation CreateFeedback($input: IssueCreateInput!) { issueCreate(input: $input) { success } }`; - const FIND_ISSUE = `query FindFeedback($id: String!) { issue(id: $id) { id } }`; - -// Every connection names its own `first`: Linear prices an unbounded one at 50 -// nodes, and its complexity budget is shared with the key owner's other tools. -const BOARD_FILTER = "team: { id: { eq: $team } }, labels: { some: { id: { eq: $label } } }"; -const LIST_ISSUES = `query FeedbackBoard($team: ID!, $label: ID!, $types: [ID!]) { - open: issues( - first: ${OPEN_LIMIT} - orderBy: updatedAt - filter: { ${BOARD_FILTER}, state: { type: { in: ["unstarted", "started"] } } } - ) { nodes { ...BoardIssue } } - done: issues( - first: ${DONE_LIMIT} - orderBy: updatedAt - filter: { ${BOARD_FILTER}, state: { type: { eq: "completed" } } } - ) { nodes { ...BoardIssue } } -} -fragment BoardIssue on Issue { - identifier - title - updatedAt - state { type } - labels(first: 3, filter: { id: { in: $types } }) { nodes { id } } +const FEEDBACK_STATE = `query FeedbackState($id: ID!, $label: ID!) { + issues(first: 1, includeArchived: true, filter: { id: { eq: $id } }) { + nodes { + id identifier updatedAt archivedAt autoArchivedAt team { id } state { id type } + labels(first: 1, filter: { id: { eq: $label } }) { nodes { id } } + } + } }`; const PROBE = `query FeedbackProbe( @@ -145,56 +117,36 @@ export async function createFeedbackIssue(env, feedback) { } } -function category(labelIds) { - if (labelIds.includes(BUG_LABEL)) return "bug"; - if (labelIds.includes(FEATURE_LABEL) || labelIds.includes(IMPROVEMENT_LABEL)) return "idea"; - return "other"; -} - -/** Only these five fields leave the Worker; the description never does. */ -export function toBoardItem(issue) { - const status = STATUS_BY_STATE_TYPE[issue?.state?.type]; - if ( - !status || - typeof issue.identifier !== "string" || - typeof issue.title !== "string" || - typeof issue.updatedAt !== "string" - ) { - return undefined; +/** Only D1-owned issue ids are passed here; the public board never queries Linear. */ +export async function readFeedbackState(env, id) { + const data = await linear(env, FEEDBACK_STATE, { id, label: env.FEEDBACK_LABEL_ID }); + const issue = data.issues?.nodes?.[0]; + // Missing/inaccessible is not proof of owner deletion. Signed remove events + // handle deletion; an API outage must never silently remove public feedback. + if (!issue || issue.id !== id || !issue.state || !Array.isArray(issue.labels?.nodes)) { + throw new Error("Feedback issue is unavailable"); } - const labelIds = (issue.labels?.nodes ?? []).map((label) => label?.id); + const version = Math.max(Date.parse(issue.updatedAt), Date.parse(issue.archivedAt) || 0); + if (!Number.isFinite(version) || typeof issue.identifier !== "string") { + throw new Error("Invalid feedback state"); + } + // Automatic Linear archiving must not age old approved feedback off the board. + const manuallyArchived = issue.archivedAt && !issue.autoArchivedAt; + const visible = + issue.team?.id === env.FEEDBACK_TEAM_ID && + !manuallyArchived && + issue.labels.nodes.some((label) => label.id === env.FEEDBACK_LABEL_ID); + const status = visible + ? ({ unstarted: "pending", started: "review", completed: "done" }[issue.state.type] ?? "hidden") + : "hidden"; return { - id: issue.identifier, - title: issue.title, - category: category(labelIds), status, - updatedAt: issue.updatedAt, + version, + identifier: issue.identifier, + inProgress: visible && issue.state.id === env.FEEDBACK_PROGRESS_STATE_ID, }; } -export function toBoard(nodes) { - const items = nodes - .map(toBoardItem) - .filter(Boolean) - .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - let done = 0; - // Done only grows; the column keeps the most recent items instead of the whole history. - return items.filter((item) => item.status !== "done" || ++done <= DONE_LIMIT); -} - -export async function listFeedbackBoard(env) { - const data = await linear(env, LIST_ISSUES, { - team: env.FEEDBACK_TEAM_ID, - label: env.FEEDBACK_LABEL_ID, - types: TYPE_LABELS, - }); - const open = data.open?.nodes; - const done = data.done?.nodes; - if (!Array.isArray(open) || !Array.isArray(done)) - throw new Error("Linear returned no issue list"); - return toBoard([...open, ...done]); -} - /** * Reject unless the key works and the team, labels and Backlog state it writes * to still exist unarchived. Names only — nothing a visitor typed. diff --git a/backend/src/runtime.test.mjs b/backend/src/runtime.test.mjs index 9d146832..5bb9b820 100644 --- a/backend/src/runtime.test.mjs +++ b/backend/src/runtime.test.mjs @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { build } from "esbuild"; import { Miniflare } from "miniflare"; +import { createFeedbackRepository } from "./feedback-repository.mjs"; import { createUsageRepository, RAW_RETENTION_MS } from "./usage-repository.mjs"; test( @@ -60,6 +61,38 @@ test( assert.equal(rows.results.length, 1); assert.deepEqual(JSON.parse(rows.results[0].agents), { claude: 3 }); const repository = createUsageRepository(db); + const feedbackRepository = createFeedbackRepository(db); + const feedbackId = await feedbackRepository.create( + { + id: crypto.randomUUID(), + title: "Keep my feedback", + body: "Private until approved", + category: "idea", + }, + { sub: "google-id", email: "sender@gmail.com" }, + ); + const moderation = { + status: "pending", + version: 1000, + identifier: "DECK-999", + inProgress: false, + }; + await db + .prepare( + "CREATE TRIGGER refuse_feedback_mail BEFORE INSERT ON feedback_mail BEGIN SELECT raise(ABORT, 'refused'); END", + ) + .run(); + await assert.rejects(feedbackRepository.applyModeration(feedbackId, moderation)); + assert.equal((await feedbackRepository.findById(feedbackId)).status, "private"); + await db.prepare("DROP TRIGGER refuse_feedback_mail").run(); + await feedbackRepository.applyModeration(feedbackId, moderation); + const feedbackResponse = await mf.dispatchFetch("https://api.deck.spacevibe.dev/v1/feedback"); + assert.equal(feedbackResponse.status, 200); + assert.equal((await feedbackResponse.json()).items[0].description, "Private until approved"); + const lease = await feedbackRepository.acquireLease(Date.now()); + assert.ok(lease); + assert.equal(await feedbackRepository.acquireLease(Date.now()), null); + await feedbackRepository.releaseLease(lease); // `expire` puts the aggregate insert and the raw delete in one `db.batch`, // and the whole retention design rests on that being a transaction: if the @@ -80,6 +113,7 @@ test( await repository.expire(Date.now() + RAW_RETENTION_MS); await repository.expire(Date.now() + RAW_RETENTION_MS); + assert.equal((await feedbackRepository.findById(feedbackId)).status, "pending"); assert.equal((await db.prepare("SELECT count(*) AS n FROM usage_days").first()).n, 0); assert.equal( (await db.prepare("SELECT participating_installs AS n FROM usage_aggregates").first()).n, diff --git a/backend/src/worker.mjs b/backend/src/worker.mjs index 7654ae10..c3c4fdcb 100644 --- a/backend/src/worker.mjs +++ b/backend/src/worker.mjs @@ -1,4 +1,6 @@ -import { FEEDBACK_PATH, handleFeedback } from "./feedback-routes.mjs"; +import { FEEDBACK_SYNC_CRON, syncFeedback } from "./feedback-sync.mjs"; +import { FEEDBACK_WEBHOOK_PATH, handleFeedbackWebhook } from "./feedback-webhook.mjs"; +import { FEEDBACK_PATH, FEEDBACK_CONFIG_PATH, handleFeedback } from "./feedback-routes.mjs"; import { FEEDBACK_PROBE_CRON, probeFeedbackConfig } from "./linear-feedback.mjs"; import { PayloadError, readPayload } from "./payload.mjs"; import { createUsageRepository } from "./usage-repository.mjs"; @@ -12,7 +14,9 @@ const HEADERS = { "cache-control": "no-store", "x-content-type-options": "nosnif export default { async fetch(request, env) { const url = new URL(request.url); - if (url.pathname === FEEDBACK_PATH) return handleFeedback(request, env); + if (url.pathname === FEEDBACK_WEBHOOK_PATH) return handleFeedbackWebhook(request, env); + if (url.pathname === FEEDBACK_PATH || url.pathname === FEEDBACK_CONFIG_PATH) + return handleFeedback(request, env); if (url.pathname !== "/v1/ping") return new Response(null, { status: 404, headers: HEADERS }); if (request.method !== "POST") return new Response(null, { status: 405, headers: { ...HEADERS, allow: "POST" } }); @@ -39,6 +43,10 @@ export default { } }, async scheduled(controller, env) { + if (controller.cron === FEEDBACK_SYNC_CRON) { + await syncFeedback(env); + return; + } // The feedback probe has its own cron: its failure means "feedback is // broken", never "retention is overdue". if (controller.cron === FEEDBACK_PROBE_CRON) { diff --git a/backend/wrangler.jsonc b/backend/wrangler.jsonc index bbd61541..129de9b5 100644 --- a/backend/wrangler.jsonc +++ b/backend/wrangler.jsonc @@ -9,26 +9,45 @@ "logpush": false, "observability": { "enabled": false, - "logs": { "enabled": false, "invocation_logs": false }, - "traces": { "enabled": false } + "logs": { + "enabled": false, + "invocation_logs": false + }, + "traces": { + "enabled": false + } }, - "routes": [{ "pattern": "api.deck.spacevibe.dev", "custom_domain": true }], + "routes": [ + { + "pattern": "api.deck.spacevibe.dev", + "custom_domain": true + } + ], "ratelimits": [ { "name": "INGEST_LIMITER", "namespace_id": "34201", - "simple": { "limit": 1000, "period": 60 } + "simple": { + "limit": 1000, + "period": 60 + } }, { "name": "FEEDBACK_LIMITER", "namespace_id": "34202", - "simple": { "limit": 20, "period": 60 } + "simple": { + "limit": 20, + "period": 60 + } } ], "vars": { "FEEDBACK_TEAM_ID": "cb65f88e-7252-4bcf-b4c7-30030b26ecea", "FEEDBACK_LABEL_ID": "d67dc808-53f9-4fde-9483-2cffe14c0169", - "FEEDBACK_BACKLOG_STATE_ID": "cd3a4095-d1d6-48a3-8d10-82231ff5f521" + "FEEDBACK_BACKLOG_STATE_ID": "cd3a4095-d1d6-48a3-8d10-82231ff5f521", + "FEEDBACK_SUBMISSIONS_OPEN": "false", + "FEEDBACK_SYNC_ENABLED": "false", + "FEEDBACK_PROGRESS_STATE_ID": "dc9f6de9-3d8a-45e5-a7c4-ea2bcec5cace" }, "d1_databases": [ { @@ -38,5 +57,7 @@ "migrations_dir": "migrations" } ], - "triggers": { "crons": ["0 3 * * *", "17 * * * *"] } + "triggers": { + "crons": ["0 3 * * *", "17 * * * *", "* * * * *"] + } } diff --git a/docs/DESIGN-LANGUAGE.md b/docs/DESIGN-LANGUAGE.md index ac305e18..41f05fcb 100644 --- a/docs/DESIGN-LANGUAGE.md +++ b/docs/DESIGN-LANGUAGE.md @@ -3260,6 +3260,25 @@ Numbered 32 because §22 stays reserved and §31 was the previous highest rule. shown inert.** This applies DL-19.7 to host-only workspace actions and to the runtime selector: capability absence is not presented as a disabled feature. +- **DL-32.6** **The compact agent launcher is a transient stage page.** It + uses the owner-approved Original composition, compact cards and separate + `Run` buttons, with existing agent logos and no fabricated profile/recent + badges. The sidebar and strip keep their existing places; the page owns no + strip item. Covered content remains mounted but inert, and the native browser + is hidden. Back/Escape return without creating a process; Settings returns + focus to the page. This is separate from the legacy Quick Launch popover + in DL-32.4 ([page](../src/launcher/agent-launch-page.tsx), + [treatment](../src/launcher/agent-launch-page.css), [integration](../src/ui/app.tsx)). + +- **DL-32.7** **Agent panes carry one compact identity header.** On Electron, + the [pane header](../src/terminal/pane-agent-header.tsx) uses the sidebar's + latest message, an agent logo and a trailing Claude-only effort control. The message + stays on one line with ellipsis; its full text is available on hover. + This agent header remains visible when the legacy shell pane bar is hidden. + Hover stays neutral and keyboard focus stays explicit in the + [treatment](../src/terminal/pane-agent-header.css). The control opens Claude's + native model/effort picker; Deck does not display an unverified effort value. + ## 33. Sidebar usage and retained activity rows The sidebar uses [AgentUsageSummary](../src/ui/usage/agent-usage-summary.tsx), diff --git a/docs/internals/agent-rail.md b/docs/internals/agent-rail.md index 6f499f69..f886309a 100644 --- a/docs/internals/agent-rail.md +++ b/docs/internals/agent-rail.md @@ -54,6 +54,11 @@ The words (`failed`, `needs you`, `working`, `done`, `idle`) live only in the ro accessible name. Where attention and phase come from is in [terminal.md](terminal.md#agent-phase-and-attention). +Codex's [output-timing fallback](../../src/terminal/agent-attention.ts) is armed only +after genuine keyboard or paste [input](../../src/terminal/pane.ts) reaches the current +agent generation. Launch commands and terminal capability replies do not arm it; explicit +OSC and lifecycle reports remain authoritative. + ## Agent usage and session re-entry The [sidebar summary](../../src/ui/usage/agent-usage-summary.tsx) replaces Unread @@ -128,12 +133,30 @@ no-op, and the rail falls back to agent names. - **Only Claude Code, Codex and OpenCode produce a tail.** Gemini has no candidate scan, Antigravity's store is an undocumented protobuf, and custom agents are unknown. Those rows keep their agent name. -- **A pane is asked about only once it has run something** (`hasRun`, or it was resumed), so a - fresh pane cannot wear a previous session's sentence. Requests are debounced 300ms on - `tabViews`, never on a timer. +- **Codex panes are never ranked.** The [tail store](../../src/terminal/session-tail-store.ts) + requires a session-id fact and sends `preferredId` with `exact: true`; without a fact it + sends no request, even for a resumed pane. A missing rollout leaves the row blank. + A new fact discards any previous guessed pairing and its text, including late replies. +- **Open writer locks identify Codex before its first prompt.** On Electron macOS/Linux, + [one background `lsof` batch](../../electron/platform/codex-thread-locks.ts) reads each + Codex foreground pid's open `~/.codex/thread-writer-locks/.lock` files. + One unique lock supplies the id. A process can also hold subagent locks: multiple locks + require exactly one matching rollout with an explicit interactive string `source`; + object-valued subagent sources and `exec` are excluded. Missing tools, unreadable metadata + or ambiguity yield no identity. This is an undocumented Codex internal; Windows supplies + no lock identity and Tauri has no implementation. The + [pty-info reader](../../electron/pty/info.ts) binds background results to the observed + process generation; the [client's availability gate](../../src/terminal/pty-client.ts) + and existing tracker carry the fact to the pane without inferring activity. +- **Other agents can still use transcript ranking.** The + [store](../../src/terminal/session-tail-store.ts) asks after `hasRun`, a resume mark or an + exact fact. Fresh generations carry `notBefore`; the + [resolver](../../electron/resume/resolve.ts) drops older candidates. This floor only + narrows a guess. Exact pins bypass ranking, and resume marks omit the floor. Requests + are debounced 300ms on `tabViews`, never on a timer. - **The pane→session pairing is remembered and pinned.** A request carries `preferredId`, and `resolveSessionTails` runs two passes: every pin is honoured through - `findCandidateById` (no 30-day cutoff, no ranking) before any unpinned pane is ranked by + `findCandidateById` (no 30-day cutoff, no ranking) before any eligible, non-exact pane is ranked by mtime proximity through the same `selectCandidate` that session restore uses. The two passes exist because the earlier one-pass version let an unpinned pane earlier in the request take a later pane's pinned session, which is how three rows once printed the same @@ -247,46 +270,30 @@ rather than inheriting a surface whose git and session sources it lacks. ## One create control per checkout -The rail once spent five controls on "create" that did three different things, four of them -spawning a plain shell with no word about where. **Pressing `+` opens the checkout menu; -the destination is stated by position, never re-chosen** -([action menu](../../src/ui/worktree-card-menus.tsx)). - -- The project header's `+` and the tab strip's `+` are **gone in both layouts**. The open - card's `New agent` row, the bare row of a checkout with nothing open, a folder's flat - entries and the closed strip's `+` all raise the same actions menu - ([`useActionsMenu`](../../src/ui/worktree-card.tsx)); a press starts nothing. - The menu groups quick agents first and all other actions second, with a single - separator ([action groups](../../src/ui/worktree-card-menus.tsx)). - `Open shell` opens a new shell tab; selecting an agent launches it, and - `New split here` creates a shell pane. Up to five quick agents are selected in - [Settings](../../src/ui/settings/quick-agents-section.tsx). An unset selection uses - the first five available agents; an explicit empty selection stays empty. - Saved unavailable agents are omitted without substitution - ([selection model](../../src/settings/quick-agents.ts)). -- **⌘T raises that same menu free-standing** under the stage strip, with a one-line - destination heading and an `Open another project…` row, built from a `MenuSubject` the - chord derives from the same scans the rail reads - ([`subjectForWorkspace`](../../src/ui/agent-rail-model.ts)). With no workspace it raises the - Open board. `TabManager.newTab()` is unchanged: `App` answers the chord through the - `railKeyboardMenuFor` signal rather than calling back into the tab layer, which is what - removes the recursion the deferred path had. -- A **remembered** project prints its checkouts as rowless groups - ([`rememberedWorktrees`](../../src/ui/agent-rail-model.ts)) so their bare rows are its way - back in. The sidebar's own `+ New` is untouched. -- **No new IPC.** Agent and shell rows use `openQuickAgent`, with `null` selecting a shell. - The folder row uses `open_in_app`; the external terminal row is absent - ([callbacks](../../src/ui/app.tsx)). - The one fork is [`splitInWorkspace`](../../src/terminal/tab-manager.ts): `split-row` acts on - the **active** pane and the card that raised the menu may not own it, so doing it honestly - is materialize-then-split, which is `TabManager`'s business. It takes a path and answers a - boolean, so no pane id leaves the terminal layer. An empty checkout materializes and stops — - a fresh tab's single pane *is* the pane the row promised. -- Both surfaces are `position: fixed` over the stage, so `railCardMenuOpen` joined - `browserPanelObscured`: the browser's `WebContentsView` is a native layer above the - renderer and would otherwise cover the menu. The menu hangs to the **right** of the card, - because the rail is a column on the window's left edge and a menu below it would cover the - rail it was raised from. +The checkout create controls open the Electron +[agent launch page](../../src/launcher/agent-launch-page.tsx): the expanded `New agent` +row, collapsed `+`, bare checkout and flat folder row share this route. A press creates +nothing. `Run` splits right beside a captured pane in that checkout, or opens one first +pane when it has no live tab. The page adds no task-strip item; Back and Escape restore +the previous surface ([page state](../../src/launcher/agent-launch-page-store.ts)). + +Right-click still raises the [checkout actions menu](../../src/ui/worktree-card-menus.tsx). +Its quick agents open new tabs, `Open shell` opens a new shell tab and `New split here` +creates a shell split, materializing one pane when no matching tab exists. The two menu +groups and single separator remain. The page and menu use the same up-to-five +[Quick agents selection](../../src/settings/quick-agents.ts): unset uses defaults, an +explicit empty selection stays empty, and unavailable choices are omitted without +replacement. + +Cmd/Ctrl+T opens or dismisses the page for the active workspace; without a workspace it +opens the Open board. Frame New, dragging New onto a pane, and the Tauri menu fallback +retain their existing paths ([entry routing](../../src/ui/app.tsx)). The project-header +and task-strip create buttons remain absent. + +The native browser is obscured while the page or a rail menu covers its stage. +[App](../../src/ui/app.tsx) retains the underlying terminal DOM and makes covered stage +content inert, so opening the launcher neither resizes nor stops a terminal. Right-click +menus keep their fixed placement beside the rail card. ## Other surfaces in the column diff --git a/docs/internals/terminal.md b/docs/internals/terminal.md index 1c6ecf70..9c74d940 100644 --- a/docs/internals/terminal.md +++ b/docs/internals/terminal.md @@ -10,6 +10,18 @@ window-transfer protocols. The vocabulary is in [glossary.md](glossary.md). ## PTY ownership (main) +The Electron [agent pane header](../../src/terminal/pane-agent-header.tsx) reads +the same pane tail as the sidebar and disposes its subscriptions with the pane. +The Claude-only Effort button sends the default Meta+P shortcut through the +[pane's existing input callback](../../src/terminal/pane.ts). Claude owns the +model/effort picker, its supported levels and confirmation. Left/Right chooses +effort; S applies it to this session only on Claude Code 2.1.257 and later. +Deck never submits a slash command, clears a draft, persists a default, or claims +an effort value was applied. Custom Claude keybindings can change this shortcut; +the [control's tooltip](../../src/lib/agent-effort.ts) identifies the default. +Other agents have no effort control. The experimental Codex App Server bridge +was removed when the owner narrowed the scope to Claude Code. + [`electron/pty/manager.ts`](../../electron/pty/manager.ts) spawns and owns every PTY. Ids are process-local integers from 1, never reused. @@ -124,6 +136,27 @@ Ids are process-local integers from 1, never reused. - The closed-tab stack holds 10 snapshots in memory (`layout`, `cwds`, `name`, `dotColor`, `workspacePath`); ⌘⇧T reopens with fresh shells and does not re-run agents. +## Transient agent launch targets + +The [agent launch page](../../src/launcher/agent-launch-page-store.ts) captures stable +terminal tab/pane identity before covering the stage. Its +[target resolver](../../src/terminal/agent-launch-target.ts) prefers the active matching +checkout tab, then an exact root, then a matching nested tab; another worktree is not a +fallback. Windows path keys are comparison-only. Split launches require a fresh known +cwd; unavailable cwd does not fall back to home. + +[TabManager](../../src/terminal/tab-manager.ts) validates the request before insertion. +[TerminalManager](../../src/terminal/terminal-manager.ts) discards only newly spawned +resources if cancellation, disposal or transfer invalidates that operation. Registration +or insertion is the commit boundary: later page dismissal suppresses stale navigation, +not the committed agent. Adapter preparation rechecks the committed pane's ownership and +launchability independently of page visibility before arming its command. Optional guards +and no-focus initialization preserve existing materialization callers' defaults. + +The page's launch receipt means a pane was created, not that its CLI is ready. Shell +readiness still belongs to [AgentLauncher](../../src/terminal/agent-launch.ts); command-write +failures from this route are reported visibly without automatically creating another pane. + ## Materialization and agent launch `MaterializeIntent` ([`tab-materialize.ts`](../../src/terminal/tab-materialize.ts)) is the diff --git a/docs/user/agents.md b/docs/user/agents.md index 6b97ffd7..8ed1bf72 100644 --- a/docs/user/agents.md +++ b/docs/user/agents.md @@ -9,13 +9,19 @@ and reads the tool's own session logs where it knows their format. Deck recognises five agents out of the box. Each ships with a launch command, and the command is shown on screen in Settings → Agents rather than hidden behind a label. -| Agent | Launch command | -| ----------- | -------------------------------------------------- | -| Claude Code | `claude --dangerously-skip-permissions` | -| Codex | `codex --dangerously-bypass-approvals-and-sandbox` | -| OpenCode | `opencode` | -| Antigravity | `agy --dangerously-skip-permissions` | -| Gemini CLI | `gemini --yolo` | +| Agent | Launch command | +| ----------- | --------------------------------------------------------------------------- | +| Claude Code | `claude --dangerously-skip-permissions` | +| Codex | `codex --dangerously-bypass-approvals-and-sandbox -c tui.animations=false` | +| OpenCode | `opencode` | +| Antigravity | `agy --dangerously-skip-permissions` | +| Gemini CLI | `gemini --yolo` | + +Codex's `-c tui.animations=false` turns off its idle animations. Codex keeps repainting its +prompt while it waits, and Deck would read that as an agent still working, so the row would +show the busy bars before you have typed anything and after the reply has landed. If you +write your own Codex command, keep that flag, or set `animations = false` under `[tui]` in +`~/.codex/config.toml` to turn the animations off in every terminal. Several of these skip the tool's own confirmation prompts. That is the point of Deck, which exists to run agents that keep working, and it is also why every command is spelled out and diff --git a/electron/images.test.ts b/electron/images.test.ts index 36192ade..37b4cdf4 100644 --- a/electron/images.test.ts +++ b/electron/images.test.ts @@ -1,6 +1,6 @@ /** Translated from `src-tauri/src/images.rs`. */ import { afterEach, describe, expect, it } from "vitest"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readImageAsDataUrl, scanWorkspaceFavicon } from "./images"; @@ -98,4 +98,24 @@ describe("scanWorkspaceFavicon", () => { it("returns null for a folder with no favicon", async () => { await expect(scanWorkspaceFavicon(tempDir())).resolves.toBe(null); }); + + // The rail scans every project it draws, so a repository must not be able to + // point a candidate at a file outside itself and get its bytes back. + it("refuses a candidate that links outside the workspace", async () => { + const dir = tempDir(); + const outside = tempDir(); + writeFileSync(join(outside, "secret.png"), "private"); + symlinkSync(join(outside, "secret.png"), join(dir, "favicon.png")); + + await expect(scanWorkspaceFavicon(dir)).resolves.toBe(null); + }); + + it("still reads a symlink that stays inside the workspace", async () => { + const dir = tempDir(); + mkdirSync(join(dir, "assets")); + writeFileSync(join(dir, "assets", "mark.png"), "x"); + symlinkSync(join(dir, "assets", "mark.png"), join(dir, "favicon.png")); + + await expect(scanWorkspaceFavicon(dir)).resolves.toContain("data:image/png"); + }); }); diff --git a/electron/images.ts b/electron/images.ts index aef18e61..62cc7ae3 100644 --- a/electron/images.ts +++ b/electron/images.ts @@ -69,15 +69,32 @@ const FAVICON_CANDIDATES = [ "app/favicon.ico", ] as const; -/** A project favicon under `dir` as a data URL, or null. Default workspace logo. */ +/** + * A project favicon under `dir` as a data URL, or null. Default workspace logo. + * + * Every candidate must resolve to a real file INSIDE `dir`. The rail scans + * this for every project it draws, with no user gesture, so a repository + * shipping `favicon.png` as a symlink out of the tree would otherwise hand any + * file the user can read back to the renderer as image bytes. Containment is + * checked on the RESOLVED path, so a link that stays inside still works and a + * link planted higher up the candidate's own path is caught too. + */ export async function scanWorkspaceFavicon(dir: string): Promise { + const root = await fs.realpath(dir).catch(() => null); + if (root === null) { + return null; + } for (const candidate of FAVICON_CANDIDATES) { const target = path.join(dir, candidate); try { - if (!(await fs.stat(target)).isFile()) { + const resolved = await fs.realpath(target); + if (resolved !== root && !resolved.startsWith(root + path.sep)) { + continue; + } + if (!(await fs.stat(resolved)).isFile()) { continue; } - return await readImageAsDataUrl(target); + return await readImageAsDataUrl(resolved); } catch { // Missing or unreadable: try the next candidate. } diff --git a/electron/ipc/register-services.ts b/electron/ipc/register-services.ts index 637f2ecd..697ba9c0 100644 --- a/electron/ipc/register-services.ts +++ b/electron/ipc/register-services.ts @@ -72,6 +72,7 @@ export function registerServices(deps: RegisterServicesDeps): void { ipcMain.handle(CHANNELS.detectAgents, (_event, { names }) => detectAgentsSafely(names ?? [])); ipcMain.handle(CHANNELS.dirsExist, (_event, { paths }) => dirsExist(paths)); ipcMain.handle(CHANNELS.desktopEnvironment, () => ({ + isDevelopment: !app.isPackaged, // `homeDir`, not `home`: Rust's struct is `#[serde(rename_all = "camelCase")]` // so that has always been the wire key. `platform.ts` rejects anything else, // the caller swallows the error, and the app silently falls back to diff --git a/electron/main.ts b/electron/main.ts index d6a0da8b..3adf74c0 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -193,11 +193,11 @@ function createWindow(label: string): BrowserWindow { width: 1100, height: 720, titleBarStyle: "hiddenInset", - // The pre-render ground follows `--bg`'s default (`src/styles.css` :root, + // The pre-render ground follows `--bg`'s default (`styles/01-tokens.css` :root, // `FALLBACK_BG` in theme-vars.ts), or window-open and resize flash a - // colour the app never shows again. Tauri's window config carries the - // same value for the same reason. - backgroundColor: "#16161e", + // colour the app never shows again. Tauri's window config carries the same + // value for the same reason — changing one without the other is the drift. + backgroundColor: "#0a0a0a", webPreferences: { preload: PRELOAD, contextIsolation: true, diff --git a/electron/platform/codex-thread-locks.test.ts b/electron/platform/codex-thread-locks.test.ts new file mode 100644 index 00000000..db0ce198 --- /dev/null +++ b/electron/platform/codex-thread-locks.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { parseCodexLocks, selectCodexThread, processCodexSessions } from "./codex-thread-locks"; + +vi.mock("node:child_process", () => ({ execFile: vi.fn() })); +const HOME = "/home/test"; +const MAIN = "01900000-0000-7000-8000-000000000001"; +const CHILD = "01900000-0000-7000-8000-000000000002"; +const lock = (id: string) => `n${HOME}/.codex/thread-writer-locks/${id}.lock`; +afterEach(() => vi.resetAllMocks()); + +describe("Codex thread writer locks", () => { + it("joins unique lock ids to their process, ignoring other paths and malformed ids", () => { + const locks = parseCodexLocks( + [ + "p42", + "fcwd", + `n${HOME}`, + "f7", + lock(MAIN), + "f8", + lock(MAIN), + "p43", + lock(CHILD), + "pbad", + lock(MAIN), + "p44", + lock("invalid"), + "n/tmp/.codex/thread-writer-locks/01900000-0000-7000-8000-000000000001.lock", + ].join("\n"), + HOME, + ); + expect(locks).toEqual( + new Map([ + [42, [MAIN]], + [43, [CHILD]], + ]), + ); + }); + it("identifies one lock before a rollout exists", () => { + expect(selectCodexThread([MAIN], new Set())).toBe(MAIN); + expect(selectCodexThread([], new Set())).toBeNull(); + }); + it("requires exactly one verified interactive main thread among multiple locks", () => { + expect(selectCodexThread([MAIN, CHILD], new Set([MAIN]))).toBe(MAIN); + expect(selectCodexThread([MAIN, CHILD], new Set())).toBeNull(); + expect(selectCodexThread([MAIN, CHILD], new Set([MAIN, CHILD]))).toBeNull(); + }); + it("batches pids once with bounded lsof and does not need a rollout for one lock", async () => { + vi.mocked(execFile).mockImplementation((( + _file: unknown, + _args: unknown, + _opts: unknown, + cb: Function, + ) => { + cb(null, `p42\n${lock(MAIN)}\np43\n${lock(CHILD)}\n`); + }) as typeof execFile); + expect(await processCodexSessions([42, 43, 42], HOME, "darwin")).toEqual( + new Map([ + [42, MAIN], + [43, CHILD], + ]), + ); + expect(execFile).toHaveBeenCalledOnce(); + expect(execFile).toHaveBeenCalledWith( + "/usr/sbin/lsof", + ["-nP", "-p", "42,43", "-Fpn"], + expect.objectContaining({ timeout: expect.any(Number), maxBuffer: expect.any(Number) }), + expect.any(Function), + ); + }); + it("fails closed on missing lsof or partial output from a failed command", async () => { + vi.mocked(execFile).mockImplementation((( + _file: unknown, + _args: unknown, + _opts: unknown, + cb: Function, + ) => { + cb(Object.assign(new Error("lsof unavailable"), { code: "ENOENT" }), `p42\n${lock(MAIN)}\n`); + }) as typeof execFile); + expect(await processCodexSessions([42], HOME, "linux")).toEqual(new Map()); + }); + it.each([ + ["cli", { subagent: { parent_thread_id: MAIN } }, MAIN], + ["cli", "vscode", null], + [undefined, { subagent: {} }, null], + ["exec", { subagent: {} }, null], + ])( + "reads actual rollout metadata to distinguish main from subagent (%j, %j)", + async (source, childSource, expected) => { + const home = mkdtempSync(path.join(tmpdir(), "deck-codex-locks-")); + try { + const dir = path.join(home, ".codex", "sessions"); + mkdirSync(dir, { recursive: true }); + for (const [id, value] of [ + [MAIN, source], + [CHILD, childSource], + ]) { + writeFileSync( + path.join(dir, `rollout-${id}.jsonl`), + JSON.stringify({ + type: "session_meta", + payload: { id, source: value, cwd: "/w" }, + }) + "\n", + ); + } + vi.mocked(execFile).mockImplementation((( + _file: unknown, + _args: unknown, + _opts: unknown, + cb: Function, + ) => { + cb( + null, + `p42\nn${home}/.codex/thread-writer-locks/${MAIN}.lock\nn${home}/.codex/thread-writer-locks/${CHILD}.lock\n`, + ); + }) as typeof execFile); + expect((await processCodexSessions([42], home, "darwin")).get(42) ?? null).toBe(expected); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, + ); + + it("does not launch lsof on Windows or for an empty batch", async () => { + expect(await processCodexSessions([42], HOME, "win32")).toEqual(new Map()); + expect(await processCodexSessions([], HOME, "darwin")).toEqual(new Map()); + expect(execFile).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/platform/codex-thread-locks.ts b/electron/platform/codex-thread-locks.ts new file mode 100644 index 00000000..fc44453a --- /dev/null +++ b/electron/platform/codex-thread-locks.ts @@ -0,0 +1,75 @@ +/** Codex 0.154's undocumented, open writer locks identify a process before its first prompt. */ +import { execFile } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { CODEX_RESTORE_SCAN, listCodexFiles, readCodexRecord } from "../resume/codex"; + +const LSOF_TIMEOUT_MS = 1_500; +const LSOF_MAX_BYTES = 4 * 1024 * 1024; +const THREAD_LOCK = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.lock$/i; + +export function parseCodexLocks(output: string, home: string): Map { + const prefix = `${path.join(home, ".codex", "thread-writer-locks")}/`; + const locks = new Map(); + let pid: number | null = null; + for (const line of output.split("\n")) { + if (line.startsWith("p")) { + const value = Number(line.slice(1)); + pid = Number.isSafeInteger(value) && value > 0 ? value : null; + } else if (pid !== null && line.startsWith(`n${prefix}`)) { + const match = THREAD_LOCK.exec(line.slice(prefix.length + 1)); + if (match === null) continue; + const previous = locks.get(pid) ?? []; + if (!previous.includes(match[1])) locks.set(pid, [...previous, match[1]]); + } + } + return locks; +} + +export function selectCodexThread( + locks: readonly string[], + interactive: ReadonlySet, +): string | null { + if (locks.length === 1) return locks[0]; + const mains = locks.filter((id) => interactive.has(id)); + return mains.length === 1 ? mains[0] : null; +} + +function readLocks(pids: readonly number[], home: string, platform: NodeJS.Platform) { + return new Promise>((resolve) => { + execFile( + platform === "darwin" ? "/usr/sbin/lsof" : "lsof", + ["-nP", "-p", pids.join(","), "-Fpn"], + { encoding: "utf8", timeout: LSOF_TIMEOUT_MS, maxBuffer: LSOF_MAX_BYTES }, + (error, stdout) => { + // Partial output cannot prove there was only ONE lock. Fail closed. + resolve(error ? new Map() : parseCodexLocks(stdout, home)); + }, + ); + }); +} + +export async function processCodexSessions( + pids: readonly number[], + home = os.homedir(), + platform = process.platform, +): Promise> { + const uniquePids = [...new Set(pids)].filter((pid) => Number.isSafeInteger(pid) && pid > 0); + if (platform === "win32" || uniquePids.length === 0) return new Map(); + const locks = await readLocks(uniquePids, home, platform); + const ambiguous = new Set([...locks.values()].filter((ids) => ids.length > 1).flat()); + const interactive = new Set(); + if (ambiguous.size > 0) { + const options = { ...CODEX_RESTORE_SCAN, requireInteractiveSource: true }; + for (const file of listCodexFiles(home, true).slice(0, options.maxFiles)) { + const record = readCodexRecord(file, options); + if (record !== null && ambiguous.has(record.id)) interactive.add(record.id); + } + } + return new Map( + uniquePids.flatMap((pid) => { + const id = selectCodexThread(locks.get(pid) ?? [], interactive); + return id === null ? [] : [[pid, id] as const]; + }), + ); +} diff --git a/electron/pty/info.test.ts b/electron/pty/info.test.ts index ee8b3bae..8786e513 100644 --- a/electron/pty/info.test.ts +++ b/electron/pty/info.test.ts @@ -176,3 +176,78 @@ describe("createPtyInfoReader", () => { expect(infos[0]?.cwd).toBe("/fresh"); }); }); + +describe("Codex session facts", () => { + const codexRows = (pid = 777) => + parsePsTable(`610 610 ${pid} ttys002 zsh\n${pid} ${pid} ${pid} ttys002 codex`); + const panes = [snapshot(2, 610, "ttys002")]; + it("does not block classification on lsof and surfaces a fact on the next poll", async () => { + let finish!: (value: Map) => void; + const sessions = vi.fn( + () => + new Promise>((resolve) => { + finish = resolve; + }), + ); + const reader = createPtyInfoReader( + () => ({ + readProcessTable: async () => codexRows(), + foregroundProcess, + processCwds: async () => new Map(), + }), + sessions, + ); + expect((await reader.read(panes))[0].codexSessionId).toBeNull(); + expect(sessions).toHaveBeenCalledWith([777]); + finish(new Map([[777, "own"]])); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect((await reader.read(panes))[0].codexSessionId).toBe("own"); + }); + it("withdraws a previously verified identity when the same pid has no reliable lock reading", async () => { + const sessions = vi + .fn() + .mockResolvedValueOnce(new Map([[777, "own"]])) + .mockResolvedValue(new Map()); + const reader = createPtyInfoReader( + () => ({ + readProcessTable: async () => codexRows(), + foregroundProcess, + processCwds: async () => new Map(), + }), + sessions, + ); + await reader.read(panes); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect((await reader.read(panes))[0].codexSessionId).toBe("own"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect((await reader.read(panes))[0].codexSessionId).toBeNull(); + }); + + it("never applies an old pid's late lock reading to a new foreground process", async () => { + let pid = 777; + let finish!: (value: Map) => void; + const sessions = vi + .fn() + .mockImplementationOnce( + () => + new Promise>((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue(new Map()); + const reader = createPtyInfoReader( + () => ({ + readProcessTable: async () => codexRows(pid), + foregroundProcess, + processCwds: async () => new Map(), + }), + sessions, + ); + await reader.read(panes); + pid = 888; + expect((await reader.read(panes))[0].codexSessionId).toBeNull(); + finish(new Map([[777, "stranger"]])); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect((await reader.read(panes))[0].codexSessionId).toBeNull(); + }); +}); diff --git a/electron/pty/info.ts b/electron/pty/info.ts index fcbb0797..fb6a6714 100644 --- a/electron/pty/info.ts +++ b/electron/pty/info.ts @@ -18,6 +18,7 @@ import { } from "../platform/classify"; import * as macos from "../platform/macos"; import * as windows from "../platform/windows"; +import { processCodexSessions } from "../platform/codex-thread-locks"; import type { PtySessionSnapshot } from "./session-store"; export interface PtyInfoPlatform { @@ -37,6 +38,7 @@ function platform(): PtyInfoPlatform { export interface PtyInfo { readonly id: number; readonly processId: number | null; + readonly codexSessionId?: string | null; readonly cwd: string | null; readonly process: string | null; readonly kind: Classification["kind"]; @@ -105,7 +107,73 @@ export interface PtyInfoReader { * Stateful reader so cwd discovery can lag behind process classification * without losing the last verified directory for the same foreground pid. */ -export function createPtyInfoReader(getPlatform: () => PtyInfoPlatform = platform): PtyInfoReader { +export function createPtyInfoReader( + getPlatform: () => PtyInfoPlatform = platform, + readCodexSessions: ( + pids: readonly number[], + ) => Promise> = processCodexSessions, +): PtyInfoReader { + type Identity = { + readonly pid: number; + readonly shellPid: number; + readonly sessionId: string | null; + }; + const identities = new Map(); + let pendingIdentities = new Map(); + let identityRefresh: Promise | null = null; + + function refreshIdentities(): void { + if (identityRefresh !== null || pendingIdentities.size === 0) return; + const targets = pendingIdentities; + pendingIdentities = new Map(); + const current = Promise.resolve() + .then(() => + readCodexSessions([...new Set([...targets.values()].map((target) => target.pid))]), + ) + .catch((error) => { + console.warn("Deck: Codex identity refresh failed", error); + return new Map(); + }) + .then((sessions) => { + for (const [paneId, target] of targets) { + // Object identity includes the observed foreground generation. A late + // reading cannot attach to a replacement process (even with pid reuse). + if (identities.get(paneId) === target) { + identities.set(paneId, { ...target, sessionId: sessions.get(target.pid) ?? null }); + } + } + }) + .finally(() => { + if (identityRefresh === current) identityRefresh = null; + refreshIdentities(); + }); + identityRefresh = current; + } + + function withCodexIdentity( + infos: PtyInfo[], + snapshots: readonly PtySessionSnapshot[], + ): PtyInfo[] { + const result = infos.map((info) => { + const shellPid = snapshots.find((snapshot) => snapshot.id === info.id)?.pid; + if (info.agent !== "codex" || info.processId === null || shellPid === undefined) { + identities.delete(info.id); + pendingIdentities.delete(info.id); + return info; + } + const old = identities.get(info.id); + const identity = + old?.pid === info.processId && old.shellPid === shellPid + ? old + : { pid: info.processId, shellPid, sessionId: null }; + identities.set(info.id, identity); + pendingIdentities.set(info.id, identity); + return { ...info, codexSessionId: identity.sessionId }; + }); + refreshIdentities(); + return result; + } + const cwdByPane = new Map(); let pendingCwdTargets = new Map(); let cwdRefresh: Promise | null = null; @@ -198,7 +266,10 @@ export function createPtyInfoReader(getPlatform: () => PtyInfoPlatform = platfor } scheduleCwdRefresh(processPlatform); } - return buildPtyInfo(snapshots, rows, cachedCwds, processPlatform, agentMatchers); + return withCodexIdentity( + buildPtyInfo(snapshots, rows, cachedCwds, processPlatform, agentMatchers), + snapshots, + ); }, }; } diff --git a/electron/resume/codex.ts b/electron/resume/codex.ts index afb35e52..0db0af89 100644 --- a/electron/resume/codex.ts +++ b/electron/resume/codex.ts @@ -23,6 +23,8 @@ import { export interface CodexScanOptions extends ScanOptions { readonly includeArchived: boolean; readonly interactiveOnly: boolean; + /** Live multi-lock pairing requires affirmative session_meta source evidence. */ + readonly requireInteractiveSource?: boolean; } /** @@ -124,6 +126,15 @@ export function readCodexRecord( return null; } const meta = payload as Record; + if ( + options.requireInteractiveSource && + ((first as Record).type !== "session_meta" || + typeof meta.source !== "string" || + meta.source === "" || + isNonInteractiveSource(meta.source)) + ) { + return null; + } if (options.interactiveOnly && isNonInteractiveSource(meta.source)) { return null; } diff --git a/electron/resume/session-tail.test.ts b/electron/resume/session-tail.test.ts index af9b26f0..7088103f 100644 --- a/electron/resume/session-tail.test.ts +++ b/electron/resume/session-tail.test.ts @@ -647,6 +647,14 @@ describe("resolveSessionTails", () => { ).toEqual([{ id: "oc1", tail: "Working tree clean — nothing staged.", model: null }]); }); + it("an exact Codex id without a rollout stays blank beside a same-cwd stranger", () => { + const request = { agent: "codex", cwd: "/tmp/codex", lastSeenAt: T1 }; + expect(resolveSessionTails(home, [request])[0]?.id).toBe("cx1"); + expect( + resolveSessionTails(home, [{ ...request, preferredId: "not-written", exact: true }]), + ).toEqual([null]); + }); + it("(w) an exact pin is honoured when on disk and answered null when not — never ranked (stage 1)", () => { // The registry says pane A runs `s2` and pane B runs `fresh-not-on-disk` // (a session whose file is not written yet). Before `exact`, B would have diff --git a/electron/wire-contract.test.ts b/electron/wire-contract.test.ts index af80b4c3..0c41b0f3 100644 --- a/electron/wire-contract.test.ts +++ b/electron/wire-contract.test.ts @@ -79,6 +79,7 @@ describe("desktop_environment", () => { // the caller swallowed it, and the app fell back to platform "unsupported" // where `hasPrimaryModifier` is false for every event. expect(SERVICES).toContain('homeDir: app.getPath("home")'); + expect(SERVICES).toContain("isDevelopment: !app.isPackaged"); expect(() => parseDesktopEnvironment({ platform: "macos", homeDir: "/Users/dev" }), ).not.toThrow(); diff --git a/marketing/landing-prototype/src/copy.js b/marketing/landing-prototype/src/copy.js index 3ff5f93a..7f3de14a 100644 --- a/marketing/landing-prototype/src/copy.js +++ b/marketing/landing-prototype/src/copy.js @@ -33,10 +33,11 @@ export const messages = { feedbackIntro: "Hit a bug, or wish Deck did something new? Tell us. Accepted requests show up on the board and move along as we work on them.", feedbackTitleHint: "At least 3 characters", - feedbackSentBody: "Every note gets read. Once it has been reviewed, it shows up on the board.", + feedbackSentBody: + "Saved for review. We will email you when it is approved and when work starts.", feedbackSendAnother: "Send another", feedbackEmptyPending: "Nothing waiting right now.", - feedbackEmptyReview: "Nothing in review yet.", + feedbackEmptyReview: "Nothing in progress yet.", feedbackEmptyDone: "Finished requests land here.", feedbackFormTitle: "Send feedback", feedbackCategoryLabel: "Type", @@ -51,13 +52,16 @@ export const messages = { feedbackSending: "Sending…", feedbackSent: "Thank you — it is in!", feedbackErrorInvalid: "Check the title (3–120 characters) and the details (up to 2,000).", + feedbackErrorAuth: "Sign in with Google again. Your draft has been kept.", + feedbackErrorConflict: + "This draft was already received with different details. Copy your edits before starting another report.", feedbackErrorRate: "Too many submissions right now. Try again in a minute.", feedbackErrorServer: "Could not send your feedback. Try again later.", feedbackNotice: - "Titles of accepted feedback are shown publicly. Leave out personal information.", + "After approval, your title, details and feedback type are public. Keep personal information out of your report. Your Google email stays private and receives approval and progress updates.", feedbackBoardTitle: "Board", feedbackColumnPending: "Pending", - feedbackColumnReview: "In review", + feedbackColumnReview: "In progress", feedbackColumnDone: "Done", feedbackLoading: "Loading the board…", feedbackError: "Could not load the board.", @@ -162,10 +166,11 @@ export const messages = { feedbackIntro: "Gặp lỗi, hay muốn Deck làm thêm điều gì? Cứ kể cho chúng tôi. Góp ý được duyệt sẽ lên bảng và đi dần qua các cột theo tiến độ.", feedbackTitleHint: "Tối thiểu 3 ký tự", - feedbackSentBody: "Góp ý nào cũng được đọc. Sau khi duyệt, nó sẽ hiện trên bảng.", + feedbackSentBody: + "Saved for review. We will email you when it is approved and when work starts.", feedbackSendAnother: "Gửi thêm góp ý", feedbackEmptyPending: "Hiện chưa có gì đang chờ.", - feedbackEmptyReview: "Chưa có gì đang xem xét.", + feedbackEmptyReview: "Nothing in progress yet.", feedbackEmptyDone: "Việc đã xong sẽ nằm ở đây.", feedbackFormTitle: "Gửi góp ý", feedbackCategoryLabel: "Loại", @@ -180,14 +185,18 @@ export const messages = { feedbackSubmit: "Gửi góp ý", feedbackSending: "Đang gửi…", feedbackSent: "Cảm ơn bạn — đã nhận!", - feedbackErrorInvalid: "Kiểm tra lại tiêu đề (3–120 ký tự) và phần chi tiết (tối đa 2.000 ký tự).", + feedbackErrorInvalid: + "Kiểm tra lại tiêu đề (3–120 ký tự) và phần chi tiết (tối đa 2.000 ký tự).", + feedbackErrorAuth: "Sign in with Google again. Your draft has been kept.", + feedbackErrorConflict: + "This draft was already received with different details. Copy your edits before starting another report.", feedbackErrorRate: "Đang có quá nhiều lượt gửi. Thử lại sau một phút.", feedbackErrorServer: "Không gửi được góp ý. Thử lại sau nhé.", feedbackNotice: - "Tiêu đề của góp ý đã duyệt sẽ hiển thị công khai. Đừng ghi thông tin cá nhân.", + "After approval, your title, details and feedback type are public. Keep personal information out of your report. Your Google email stays private and receives approval and progress updates.", feedbackBoardTitle: "Bảng tiến độ", feedbackColumnPending: "Đang chờ", - feedbackColumnReview: "Đang xem xét", + feedbackColumnReview: "In progress", feedbackColumnDone: "Đã xong", feedbackLoading: "Đang tải bảng…", feedbackError: "Không tải được bảng.", @@ -195,7 +204,8 @@ export const messages = { "Sắp mở gửi góp ý. Những gì bạn viết ở đây được giữ lại trên máy này, nên lúc mở gửi nó vẫn còn nguyên.", feedbackSubmitClosed: "Sắp mở gửi", feedbackDraftSaved: "Đã lưu nháp trên máy này", - feedbackDraftRefused: "Trình duyệt này không cho lưu nháp. Hãy chép lại nội dung trước khi rời trang.", + feedbackDraftRefused: + "Trình duyệt này không cho lưu nháp. Hãy chép lại nội dung trước khi rời trang.", feedbackBoardSoon: "Bảng sẽ hiện khi mở gửi góp ý.", panelRestoreTitle: "Đóng rồi mở lại. Không mất mạch nào.", panelRestoreBody: diff --git a/marketing/landing-prototype/src/feedback-api.js b/marketing/landing-prototype/src/feedback-api.js index 11e35ca9..4c64621c 100644 --- a/marketing/landing-prototype/src/feedback-api.js +++ b/marketing/landing-prototype/src/feedback-api.js @@ -1,6 +1,6 @@ /** * The landing's side of the feedback contract served by `backend/` - * (DECK-101). The Worker owns validation and the Linear round trip; this + * (DECK-101). The Worker owns authentication and durable storage; this * module only shapes the request and refuses to trust the response. */ @@ -12,6 +12,8 @@ export const FEEDBACK_API_URL = "https://api.deck.spacevibe.dev/v1/feedback"; * the API. Flip to true in the same change that deploys the Worker. */ export const SUBMISSIONS_OPEN = false; +// Keep public reads enabled when closing intake after the initial rollout. +export const FEEDBACK_BOARD_OPEN = false; /** Board columns, left to right. */ export const FEEDBACK_STATUSES = ["pending", "review", "done"]; @@ -42,6 +44,7 @@ function isCard(item) { item !== null && typeof item === "object" && typeof item.id === "string" && + typeof item.description === "string" && typeof item.title === "string" && item.title.length > 0 && FEEDBACK_STATUSES.includes(item.status) && @@ -72,8 +75,10 @@ export function groupFeedbackBoard(payload) { ); } -export async function fetchFeedbackBoard(fetchImpl = fetch) { - const response = await fetchImpl(FEEDBACK_API_URL, { +export async function fetchFeedbackBoard(cursor = null, fetchImpl = fetch) { + const url = new URL(FEEDBACK_API_URL); + if (cursor) url.searchParams.set("cursor", cursor); + const response = await fetchImpl(url.toString(), { headers: { accept: "application/json" }, signal: AbortSignal.timeout(BOARD_TIMEOUT_MS), }); @@ -82,12 +87,16 @@ export async function fetchFeedbackBoard(fetchImpl = fetch) { throw new Error(`Feedback board request failed with ${response.status}.`); } - return groupFeedbackBoard(await response.json()); + const payload = await response.json(); + if (payload.nextCursor !== null && typeof payload.nextCursor !== "string") { + throw new Error("Feedback board response has an invalid cursor."); + } + return { board: groupFeedbackBoard(payload), nextCursor: payload.nextCursor }; } /** - * `id` is the draft's UUID: a resend after a lost answer names the same Linear - * issue instead of creating a second one. Omitted when the browser has none. + * `id` is the draft's UUID: a resend after a lost answer names the same stored + * submission instead of creating a second one. Omitted when the browser has none. * * @param {{ title: string, body: string, category: string, website: string, id: string }} input */ @@ -97,7 +106,10 @@ export async function submitFeedback(input, fetchImpl = fetch) { try { response = await fetchImpl(FEEDBACK_API_URL, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + authorization: `Bearer ${input.credential ?? ""}`, + }, body: JSON.stringify({ title: input.title, body: input.body, @@ -111,9 +123,15 @@ export async function submitFeedback(input, fetchImpl = fetch) { throw new FeedbackSubmitError("server"); } - if (response.ok) { + if (response.status === 201) { + const receipt = await response.json(); + if (typeof receipt.id !== "string" || receipt.status !== "private") { + throw new FeedbackSubmitError("server"); + } return; } + if (response.status === 401) throw new FeedbackSubmitError("auth"); + if (response.status === 409) throw new FeedbackSubmitError("conflict"); if (response.status === 400 || response.status === 413) { throw new FeedbackSubmitError("invalid"); @@ -121,3 +139,18 @@ export async function submitFeedback(input, fetchImpl = fetch) { throw new FeedbackSubmitError(response.status === 429 ? "rate" : "server"); } + +export async function fetchFeedbackConfig(fetchImpl = fetch) { + const response = await fetchImpl(`${FEEDBACK_API_URL}/config`, { + signal: AbortSignal.timeout(BOARD_TIMEOUT_MS), + }); + if (!response.ok) throw new Error("Feedback configuration unavailable"); + const config = await response.json(); + if ( + typeof config.submissionsOpen !== "boolean" || + (config.googleClientId !== null && typeof config.googleClientId !== "string") + ) { + throw new Error("Invalid feedback configuration"); + } + return config; +} diff --git a/marketing/landing-prototype/src/feedback-api.test.js b/marketing/landing-prototype/src/feedback-api.test.js index 4a6b3695..61531974 100644 --- a/marketing/landing-prototype/src/feedback-api.test.js +++ b/marketing/landing-prototype/src/feedback-api.test.js @@ -1,76 +1,117 @@ import { describe, expect, it } from "vitest"; - -import { FeedbackSubmitError, groupFeedbackBoard, submitFeedback } from "./feedback-api.js"; +import { + FeedbackSubmitError, + groupFeedbackBoard, + submitFeedback, + fetchFeedbackBoard, + fetchFeedbackConfig, +} from "./feedback-api.js"; const card = (overrides) => ({ - id: "DECK-1", + id: "report-id", title: "Split panes", + description: "Steps", category: "idea", status: "pending", updatedAt: "2026-09-14T10:00:00.000Z", ...overrides, }); +const json = (value, status = 200) => new Response(JSON.stringify(value), { status }); -describe("groupFeedbackBoard", () => { - it("groups by status, newest first, and drops malformed items", () => { +describe("public board", () => { + it("groups approved cards and drops malformed/private cards", () => { const board = groupFeedbackBoard({ items: [ - card({ id: "DECK-2", updatedAt: "2026-09-12T10:00:00.000Z" }), - card({ id: "DECK-3" }), - card({ id: "DECK-4", status: "done" }), - card({ id: "DECK-5", status: "backlog" }), - card({ id: "DECK-6", title: "" }), - { id: "DECK-7" }, + card({ id: "older", updatedAt: "2026-09-12T00:00:00Z" }), + card({ id: "newer" }), + card({ id: "done", status: "done" }), + card({ status: "private" }), + card({ description: undefined }), ], }); - - expect(board.pending.map((item) => item.id)).toEqual(["DECK-3", "DECK-2"]); + expect(board.pending.map((x) => x.id)).toEqual(["newer", "older"]); expect(board.review).toEqual([]); - expect(board.done.map((item) => item.id)).toEqual(["DECK-4"]); - }); - - it("rejects a response without an items list", () => { + expect(board.done.map((x) => x.id)).toEqual(["done"]); expect(() => groupFeedbackBoard({})).toThrow(); }); + it("requests older pages without auth and preserves the next cursor", async () => { + let requested; + const page = await fetchFeedbackBoard("123:uuid", async (url, init) => { + requested = { url, init }; + return json({ items: [card()], nextCursor: "122:uuid" }); + }); + expect(new URL(requested.url).searchParams.get("cursor")).toBe("123:uuid"); + expect(requested.init.headers.authorization).toBeUndefined(); + expect(page.nextCursor).toBe("122:uuid"); + expect(page.board.pending).toHaveLength(1); + await expect( + fetchFeedbackBoard(null, async () => json({ items: [], nextCursor: {} })), + ).rejects.toThrow(); + }); }); -describe("submitFeedback", () => { - const input = { title: "Split panes", body: "", category: "idea", website: "", id: "" }; - - it("names the draft's id when it has one, with a timeout on the request", async () => { - const sent = []; - const capture = async (_url, init) => { - sent.push({ body: JSON.parse(init.body), signal: init.signal }); - return new Response(null, { status: 204 }); - }; - const id = "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed"; - - await submitFeedback({ ...input, id }, capture); - await submitFeedback(input, capture); - - expect(sent[0].body.id).toBe(id); - expect("id" in sent[1].body).toBe(false); - expect(sent[0].signal).toBeInstanceOf(AbortSignal); +describe("durable submission", () => { + const input = { + title: "Split panes", + body: "", + category: "idea", + website: "", + id: "draft-uuid", + credential: "google-credential", + }; + it("sends auth only in the header and accepts a persisted receipt", async () => { + let sent; + await submitFeedback(input, async (_url, init) => { + sent = init; + return json({ id: "stored-id", status: "private" }, 201); + }); + expect(sent.headers.authorization).toBe("Bearer google-credential"); + expect(JSON.parse(sent.body)).toEqual({ + title: input.title, + body: "", + category: "idea", + website: "", + id: "draft-uuid", + }); + expect(sent.signal).toBeInstanceOf(AbortSignal); }); - - const respond = (status) => async () => new Response(null, { status }); - - it("maps the Worker's status codes to a reason", async () => { - await expect(submitFeedback(input, respond(204))).resolves.toBeUndefined(); - + it("never treats an empty or malformed success response as persisted feedback", async () => { + for (const response of [ + new Response(null, { status: 204 }), + json({ status: "private" }, 201), + json({ id: "x", status: "public" }, 201), + ]) { + await expect(submitFeedback(input, async () => response)).rejects.toBeInstanceOf( + FeedbackSubmitError, + ); + } + }); + it("distinguishes sign-in expiry and idempotency conflicts from retryable errors", async () => { for (const [status, reason] of [ [400, "invalid"], [413, "invalid"], + [401, "auth"], + [409, "conflict"], [429, "rate"], [503, "server"], ]) { - await expect(submitFeedback(input, respond(status))).rejects.toMatchObject({ reason }); + await expect( + submitFeedback(input, async () => new Response(null, { status })), + ).rejects.toMatchObject({ reason }); } - await expect( submitFeedback(input, async () => { - throw new TypeError("offline"); + throw new Error("offline"); }), - ).rejects.toBeInstanceOf(FeedbackSubmitError); + ).rejects.toMatchObject({ reason: "server" }); + }); +}); + +describe("feedback configuration", () => { + it("validates provider config without treating a server error as open intake", async () => { + const config = { googleClientId: "test-client", submissionsOpen: false }; + expect(await fetchFeedbackConfig(async () => json(config))).toEqual(config); + await expect(fetchFeedbackConfig(async () => json({}))).rejects.toThrow(); + await expect(fetchFeedbackConfig(async () => json({}, 503))).rejects.toThrow(); }); }); diff --git a/marketing/landing-prototype/src/feedback-auth.js b/marketing/landing-prototype/src/feedback-auth.js new file mode 100644 index 00000000..dc674135 --- /dev/null +++ b/marketing/landing-prototype/src/feedback-auth.js @@ -0,0 +1,84 @@ +const GOOGLE_SCRIPT = "https://accounts.google.com/gsi/client"; +const LOAD_TIMEOUT_MS = 12_000; +let loading; + +function loadGoogle() { + if (globalThis.google?.accounts?.id) return Promise.resolve(); + if (loading) return loading; + loading = new Promise((resolve, reject) => { + const script = document.createElement("script"); + const fail = () => { + clearTimeout(timer); + script.remove(); + loading = undefined; + reject(new Error("Google sign-in could not load")); + }; + const timer = setTimeout(fail, LOAD_TIMEOUT_MS); + script.src = GOOGLE_SCRIPT; + script.async = true; + script.onload = () => { + clearTimeout(timer); + if (globalThis.google?.accounts?.id) resolve(); + else fail(); + }; + script.onerror = fail; + document.head.append(script); + }); + return loading; +} + +/** Tokens live only in memory and are verified by the Worker on every submit. */ +export function createFeedbackAuth(root, clientId, changed) { + let credential = null; + const container = root.querySelector("[data-google-signin]"); + const status = root.querySelector("[data-auth-status]"); + const signout = root.querySelector("[data-auth-signout]"); + const retry = root.querySelector("[data-auth-retry]"); + const section = root.querySelector("[data-feedback-auth]"); + section.hidden = false; + const update = (message) => { + status.textContent = message; + container.hidden = Boolean(credential); + signout.hidden = !credential; + changed(Boolean(credential)); + }; + const reset = () => { + credential = null; + globalThis.google?.accounts?.id.disableAutoSelect(); + update("Sign in with Google to send feedback and receive email updates."); + }; + async function start() { + retry.hidden = true; + update("Loading Google sign-in…"); + try { + await loadGoogle(); + google.accounts.id.initialize({ + client_id: clientId, + auto_select: false, + callback: (response) => { + if (typeof response.credential !== "string" || !response.credential) { + reset(); + return; + } + credential = response.credential; + update("Signed in with Google. Your email stays private."); + }, + }); + google.accounts.id.renderButton(container, { + type: "standard", + theme: "outline", + size: "large", + text: "signin_with", + shape: "pill", + }); + reset(); + } catch { + update("Google sign-in is unavailable. Your draft is still here."); + retry.hidden = false; + } + } + signout.addEventListener("click", reset); + retry.addEventListener("click", () => void start()); + void start(); + return { token: () => credential, reset }; +} diff --git a/marketing/landing-prototype/src/feedback-board-view.js b/marketing/landing-prototype/src/feedback-board-view.js index 6989a8e2..801cd8ce 100644 --- a/marketing/landing-prototype/src/feedback-board-view.js +++ b/marketing/landing-prototype/src/feedback-board-view.js @@ -53,6 +53,8 @@ export function renderBoardSection(copy) { + + `; } @@ -89,7 +91,7 @@ function createCard(item, copy, locale, index) { const id = document.createElement("span"); id.className = "feedback-card__id"; - id.textContent = item.id; + id.textContent = item.identifier || item.id; top.append(tag, id); // User-submitted text: always textContent, never markup. @@ -103,7 +105,10 @@ function createCard(item, copy, locale, index) { time.dataset.feedbackDate = item.updatedAt; time.textContent = formatRelativeDay(item.updatedAt, locale); - card.append(top, title, time); + const description = document.createElement("p"); + description.className = "feedback-card__description"; + description.textContent = item.description; + card.append(top, title, description, time); return card; } diff --git a/marketing/landing-prototype/src/feedback-controller.test.js b/marketing/landing-prototype/src/feedback-controller.test.js new file mode 100644 index 00000000..00713b96 --- /dev/null +++ b/marketing/landing-prototype/src/feedback-controller.test.js @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; + +const api = vi.hoisted(() => ({ submit: vi.fn(), board: vi.fn(), config: vi.fn() })); +vi.mock("./feedback-api.js", async (original) => ({ + ...(await original()), + SUBMISSIONS_OPEN: true, + submitFeedback: api.submit, + fetchFeedbackBoard: api.board, + fetchFeedbackConfig: api.config, +})); +vi.mock("./feedback-auth.js", () => ({ + createFeedbackAuth: (_root, _client, changed) => { + queueMicrotask(() => changed(true)); + return { token: () => "credential", reset: () => changed(false) }; + }, +})); + +afterEach(() => { + document.body.replaceChildren(); + localStorage.clear(); + vi.resetModules(); + vi.clearAllMocks(); +}); +async function mount() { + document.body.innerHTML = '
'; + api.config.mockResolvedValue({ googleClientId: "client", submissionsOpen: true }); + api.board.mockResolvedValue({ board: { pending: [], review: [], done: [] }, nextCursor: null }); + await import("./feedback.js"); + await vi.waitFor(() => + expect(document.querySelector("#feedback-root").dataset.authReady).toBe("true"), + ); + return document.querySelector(".feedback-form"); +} + +it("keeps category and id after a lost acknowledgment and reload, then clears only after success", async () => { + let form = await mount(); + form.querySelector('[name="title"]').value = "Keep this idea"; + form.querySelector('[name="body"]').value = "Details"; + form.querySelector('[name="category"][value="idea"]').checked = true; + api.submit.mockRejectedValueOnce(new Error("response lost")); + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await vi.waitFor(() => expect(api.submit).toHaveBeenCalledTimes(1)); + const first = api.submit.mock.calls[0][0]; + const stored = JSON.parse(localStorage.getItem("deck.landing.feedbackDraft.v1")); + expect(stored.category).toBe("idea"); + expect(stored.id).toBe(first.id); + vi.resetModules(); + form = await mount(); + expect(form.querySelector('[name="category"]:checked').value).toBe("idea"); + api.submit.mockResolvedValueOnce(undefined); + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await vi.waitFor(() => expect(api.submit).toHaveBeenCalledTimes(2)); + expect(api.submit.mock.calls[1][0]).toEqual(first); + await vi.waitFor(() => expect(localStorage.getItem("deck.landing.feedbackDraft.v1")).toBeNull()); +}); diff --git a/marketing/landing-prototype/src/feedback-demo.js b/marketing/landing-prototype/src/feedback-demo.js index 94aef563..e57ba932 100644 --- a/marketing/landing-prototype/src/feedback-demo.js +++ b/marketing/landing-prototype/src/feedback-demo.js @@ -13,13 +13,55 @@ const ago = (days) => new Date(Date.now() - days * DAY_MS).toISOString(); const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const ITEMS = [ - { id: "DECK-131", title: "Let me pin an agent card to the top of the rail", category: "idea", status: "pending", updatedAt: ago(0) }, - { id: "DECK-128", title: "Windows installer fails on a secondary drive", category: "bug", status: "pending", updatedAt: ago(1) }, - { id: "DECK-126", title: "Export token usage as CSV", category: "idea", status: "pending", updatedAt: ago(3) }, - { id: "DECK-122", title: "Codex pane loses scrollback after resume", category: "bug", status: "review", updatedAt: ago(0) }, - { id: "DECK-119", title: "Keyboard shortcut to jump to the next waiting agent", category: "idea", status: "review", updatedAt: ago(4) }, - { id: "DECK-110", title: "Support Gemini CLI in the agent catalog", category: "other", status: "done", updatedAt: ago(6) }, - { id: "DECK-104", title: "Browser tab should remember zoom level", category: "idea", status: "done", updatedAt: ago(12) }, + { + id: "DECK-131", + title: "Let me pin an agent card to the top of the rail", + category: "idea", + status: "pending", + updatedAt: ago(0), + }, + { + id: "DECK-128", + title: "Windows installer fails on a secondary drive", + category: "bug", + status: "pending", + updatedAt: ago(1), + }, + { + id: "DECK-126", + title: "Export token usage as CSV", + category: "idea", + status: "pending", + updatedAt: ago(3), + }, + { + id: "DECK-122", + title: "Codex pane loses scrollback after resume", + category: "bug", + status: "review", + updatedAt: ago(0), + }, + { + id: "DECK-119", + title: "Keyboard shortcut to jump to the next waiting agent", + category: "idea", + status: "review", + updatedAt: ago(4), + }, + { + id: "DECK-110", + title: "Support Gemini CLI in the agent catalog", + category: "other", + status: "done", + updatedAt: ago(6), + }, + { + id: "DECK-104", + title: "Browser tab should remember zoom level", + category: "idea", + status: "done", + updatedAt: ago(12), + }, ]; export function createDemoFeedbackApi(mode) { @@ -31,7 +73,18 @@ export function createDemoFeedbackApi(mode) { throw new Error("Demo board failure."); } - return groupFeedbackBoard({ items: mode === "empty" ? [] : ITEMS }); + return { + board: groupFeedbackBoard({ + items: + mode === "empty" + ? [] + : ITEMS.map((item) => ({ + ...item, + description: "An example report shared by a Deck user.", + })), + }), + nextCursor: null, + }; }, async submit() { await wait(LATENCY_MS); diff --git a/marketing/landing-prototype/src/feedback-draft.js b/marketing/landing-prototype/src/feedback-draft.js index ecfe2823..bcb8385b 100644 --- a/marketing/landing-prototype/src/feedback-draft.js +++ b/marketing/landing-prototype/src/feedback-draft.js @@ -18,7 +18,7 @@ const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f /** * A fresh id for a new draft, or "" where the browser has no crypto.randomUUID - * (insecure context) — the Worker then creates the issue without one. + * (insecure context) — sending then stays blocked instead of losing idempotency. */ export function newDraftId() { return globalThis.crypto?.randomUUID?.() ?? ""; diff --git a/marketing/landing-prototype/src/feedback-form-view.js b/marketing/landing-prototype/src/feedback-form-view.js index 0cdb927d..cfe6f240 100644 --- a/marketing/landing-prototype/src/feedback-form-view.js +++ b/marketing/landing-prototype/src/feedback-form-view.js @@ -28,6 +28,12 @@ function renderForm(copy) {