diff --git a/.github/workflows/taskboard-build.yml b/.github/workflows/taskboard-build.yml new file mode 100644 index 000000000..4f95c9f7c --- /dev/null +++ b/.github/workflows/taskboard-build.yml @@ -0,0 +1,48 @@ +name: Taskboard checks + +on: + pull_request: + paths: + - "apps/codex-taskboard/**" + - ".github/workflows/taskboard-build.yml" + push: + branches: + - main + paths: + - "apps/codex-taskboard/**" + - ".github/workflows/taskboard-build.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + taskboard: + name: Taskboard checks + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: apps/codex-taskboard + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: apps/codex-taskboard/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: TypeScript check + run: npm run typecheck + + - name: Build frontend + run: npm run build:web + + - name: Test + run: npm test diff --git a/apps/codex-taskboard/.gitignore b/apps/codex-taskboard/.gitignore new file mode 100644 index 000000000..f6ee3fb48 --- /dev/null +++ b/apps/codex-taskboard/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +.data/ +.idea/ +.codex-install-source +.git-original-removed-by-codex/ +.wrangler/ +.dev.vars* +.tmp/ +cloud-migration-exports/ +.playwright-cli/ +*.log +.DS_Store diff --git a/apps/codex-taskboard/AGENTS.md b/apps/codex-taskboard/AGENTS.md new file mode 100644 index 000000000..403084c6c --- /dev/null +++ b/apps/codex-taskboard/AGENTS.md @@ -0,0 +1,13 @@ +# Project Development Rules + +For feature work in this repository, use this order: + +1. Before implementation, prove the real operation path to the user: entry point → user or agent action → data change or other side effect → observable result. Cite the actual component, API, and file involved, or demonstrate the path in the product. This proof is not a test. +2. Implement the requested main path with the smallest direct change that makes it work. +3. After implementation, demonstrate or verify only that direct operation path and give the result to the user for confirmation. +4. Before the user confirms the feature works, do not proactively add guardrails, mutation or regression tests, legacy compatibility protection, defensive extensions, or speculative fallback behavior. +5. User confirmation does not automatically authorize that follow-up work. Add targeted protection or tests only when the user explicitly asks for them, or when the user reports a concrete failure scenario that requires them. + +The primary objective is to make the requested function work. Focus on the feature implementation itself and avoid over-design; safety, guardrails, and testing must not dominate the work or turn the feature into a surrounding engineering project. This rule supersedes the earlier standing instruction that every feature must be developed test-first. Test-first language in older issues does not apply unless the user restates it for that issue after this rule. + +This ordering does not waive higher-priority safety or security requirements. Keep validation that is necessary at real external boundaries, such as user input or external APIs, but do not expand it into hypothetical protection beyond the requested path. diff --git a/apps/codex-taskboard/README.md b/apps/codex-taskboard/README.md new file mode 100644 index 000000000..6648fdbb3 --- /dev/null +++ b/apps/codex-taskboard/README.md @@ -0,0 +1,142 @@ +# Codex Taskboard + +A local-first issue board that runs in a browser and can be embedded in Codex through the standalone CDP launcher or its injection script. The same HTTP API powers the React UI and the `taskctl` CLI used by the bundled Codex Skill. + +## Requirements + +- Node.js 22.5 or newer + +## Clone or download + +Download the repository ZIP from GitHub, or clone it with Git: + +```bash +git clone https://github.com/BigPizzaV3/CodexPlusPlus.git +cd CodexPlusPlus/apps/codex-taskboard +``` + +## Run locally + +```bash +npm install +npm run build +npm start +``` + +Open . The SQLite database is stored at `.data/taskboard.sqlite`. + +For development with live frontend reload: + +```bash +npm run dev +``` + +The Vite UI runs at and proxies API requests to the local service. + +## Use the CLI + +Run it from the project: + +```bash +npm run taskctl -- project create \ + --id my-project \ + --name "My project" \ + --workspace-path /absolute/path/to/repository + +npm run taskctl -- issue create \ + --project my-project \ + --title "Implement the next slice" \ + --status todo \ + --priority high \ + --labels product,mvp +``` + +Use `npm link` if you want `taskctl` on your shell path. Set `CODEX_TASKBOARD_URL` to point the CLI at another local service. Cloud deployments are configured through the loopback companion with `taskctl cloud login`. + +## Install the Codex Skill + +Copy or symlink `skills/manage-taskboard` into the Codex skills directory, then start a new Codex task: + +```bash +ln -s /absolute/path/to/codex-taskboard/skills/manage-taskboard \ + ~/.codex/skills/manage-taskboard +``` + +The Skill teaches Codex to inspect an issue, move it to `in_progress`, use optimistic versions, verify the work, and then move it to `in_review`; it moves the issue to `done` only after the user explicitly confirms acceptance or asks to mark it complete. + +## Embed in Codex + +### Recommended: keep your current window and open a separate Taskboard window + +Keep the existing Codex window open. From the Taskboard repository, start a second Codex instance with a dedicated CDP port: + +```bash +open -n -a /Applications/ChatGPT.app --args \ + --remote-debugging-port=9231 \ + --remote-allow-origins=http://127.0.0.1:9231 +``` + +After the new Codex window appears, run the injector in another terminal: + +```bash +CODEX_TASKBOARD_HOST=127.0.0.1 \ +npm run codex:inject -- --port 9231 --open +``` + +Keep the injector terminal running while using the embedded panel. The original Codex window remains unchanged, and the new window receives the Taskboard sidebar entry. If port `9231` is occupied, use another port in both commands. + +### Alternative: restart Codex with the standalone launcher + +Quit every running Codex window, then run: + +```bash +CODEX_TASKBOARD_HOST=127.0.0.1 npm run codex +``` + +This starts the local Taskboard service when needed, launches the official macOS Codex app with a loopback-only CDP port, injects a native-looking Taskboard entry after Plugins, and keeps watching both the service and replacement renderers. Opening Taskboard asks this launcher to health-check the fixed local service, restart it when needed, and rebuild a failed iframe. Keep this command running while using the embedded panel. The launcher does not modify `ChatGPT.app` or its `app.asar`. + +Codex 26.715.52143 ships a renderer CSP that blocks arbitrary HTTP iframes. The launcher therefore enables CDP CSP bypass, reloads that renderer once, installs the document-start script, and waits until the Taskboard OOPIF is actually loaded. CDP is unauthenticated to other processes on the same machine, so only run trusted local code while the launcher is active. + +To inject into a Codex instance that was already launched with CDP by another method, run: + +```bash +npm run codex:inject -- --port 9229 --open +``` + +This command also stays resident so the injected tab can restart Taskboard after a service exit. Stop it with `Ctrl-C`. + +The script adds a Taskboard entry to the Codex sidebar and renders the iframe across Codex's complete main workspace, including the contextual titlebar area so Taskboard's own header does not leave an empty strip. That full rectangular header is placed above Electron's draggable layer and marked `no-drag`; because the native contextual actions are suppressed while Taskboard is active, its own actions use their normal edge padding without an artificial right-side gap. The native sidebar stays mounted, while the previous page selection and contextual header are temporarily suppressed; choosing another Codex page restores them. + +The "Open in conversation" action selects the corresponding native Codex project when one is available and opens an unsent native composer with `$manage-taskboard ISSUE-ID`. A conversation is attributed only after it actually processes the issue: `taskctl` reads Codex's `CODEX_THREAD_ID` and records that ID on the issue or comment mutation. Recorded IDs are clickable through Codex's native route bridge. Each issue can bind either one Git branch or one worktree; the options are scanned from the selected Codex project's repository instead of being typed by hand. The integration uses Codex's existing project, composer, and route markers; it does not patch React, replace `fetch`, load private chunks, or edit Codex data files. + +To use a different UI origin, set `window.__CODEX_TASKBOARD_URL__` before the user script runs. + +## Configuration + +| Variable | Default | Purpose | +| --- | --- | --- | +| `CODEX_TASKBOARD_HOST` | `127.0.0.1` | HTTP bind address; set `0.0.0.0` only to explicitly share on a private LAN | +| `TASKBOARD_SHARED_SECRET` | unset | Required for `0.0.0.0`; protects every request with Basic authentication | +| `CODEX_TASKBOARD_PORT` | `47823` | Local HTTP port | +| `CODEX_TASKBOARD_DATA_DIR` | `.data` | SQLite data directory | +| `CODEX_TASKBOARD_URL` | `http://127.0.0.1:47823` | CLI API origin | + +By default, the service binds only to `127.0.0.1`. LAN sharing is opt-in: set `CODEX_TASKBOARD_HOST=0.0.0.0` and a non-empty `TASKBOARD_SHARED_SECRET`. Startup prints a warning before binding and then lists detected LAN URLs. Every LAN request must use Basic authentication and an HTTP `Origin` matching its `Host`; cross-origin requests are rejected and the server does not emit wildcard CORS headers. Do not expose the service to the public internet. + +Task, comment, and attachment changes are broadcast to every open client through server-sent events; reconnecting clients perform a full refresh so changes made while disconnected are not missed. `taskctl` remains configured for the local companion URL; use the browser UI or an authenticated deployment boundary for shared access. + +## Share through Cloudflare + +For two trusted collaborators, the taskboard can run on Cloudflare with Worker Static Assets and API routes, D1 as the authoritative business database, and a private R2 bucket for attachments. The deployment uses HTTPS Basic Authentication with a shared password and refreshes open boards after a global revision changes. + +Each device keeps its own project checkout mapping and continues to use a local companion for Codex, Git/worktree, Skill, and MCP capabilities. Cloud mode never falls back to or double-writes the local SQLite database. + +See [Cloud collaboration](docs/cloud-collaboration.md) for owner deployment, existing GitHub installation setup, password rotation, local path mapping, and the one-time local-data migration flow. + +## Verify + +```bash +npm run check +``` + +This runs TypeScript checking, a production frontend build, and the server/CLI/injection test suite. diff --git a/apps/codex-taskboard/cli/taskctl.mjs b/apps/codex-taskboard/cli/taskctl.mjs new file mode 100644 index 000000000..afe01f6e0 --- /dev/null +++ b/apps/codex-taskboard/cli/taskctl.mjs @@ -0,0 +1,855 @@ +#!/usr/bin/env node + +import { realpathSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { normalizeCloudUrl } from "../server/cloud-config.mjs"; +import { + DEFAULT_PROJECT_ID, + TASK_STATUSES, + isTaskPriority, + isTaskStatus, +} from "../shared/domain.mjs"; + +export const SCHEMA_VERSION = 2; +export const DEFAULT_API_URL = "http://127.0.0.1:47823"; + +const BOOLEAN_OPTIONS = new Set(["json"]); + +const COMMAND_OPTIONS = new Map([ + ["project list", new Set(["json"])], + ["project create", new Set(["id", "name", "workspace-path", "json"])], + ["project map", new Set(["workspace-path", "json"])], + ["cloud login", new Set(["url", "actor-name", "json"])], + ["cloud status", new Set(["json"])], + ["cloud logout", new Set(["json"])], + ["issue list", new Set(["project", "status", "json"])], + ["issue get", new Set(["json"])], + [ + "issue create", + new Set([ + "project", + "title", + "description", + "description-file", + "status", + "priority", + "labels", + "thread-id", + "git-branch", + "worktree-path", + "worktree-branch", + "due-date", + "recurrence-interval", + "recurrence-unit", + "json", + ]), + ], + [ + "issue update", + new Set([ + "title", + "description", + "description-file", + "status", + "priority", + "labels", + "thread-id", + "git-branch", + "worktree-path", + "worktree-branch", + "due-date", + "recurrence-interval", + "recurrence-unit", + "if-version", + "json", + ]), + ], + ["issue move", new Set(["status", "thread-id", "if-version", "json"])], + ["issue archive", new Set(["thread-id", "if-version", "json"])], + ["issue restore", new Set(["thread-id", "if-version", "json"])], + ["issue relation", new Set(["type", "issue", "thread-id", "if-version", "json"])], + ["comment list", new Set(["json"])], + ["comment add", new Set(["body", "thread-id", "json"])], + ["comment update", new Set(["body", "thread-id", "if-version", "json"])], + ["comment delete", new Set(["thread-id", "if-version", "json"])], + ["attachment download", new Set(["output", "json"])], + ["context current", new Set(["cwd", "json"])], +]); + +class TaskctlError extends Error { + constructor(message, { code = "TASKCTL_ERROR", exitCode = 2, details } = {}) { + super(message); + this.name = "TaskctlError"; + this.code = code; + this.exitCode = exitCode; + this.details = details; + } +} + +export function parseArgs(argv) { + if (!Array.isArray(argv)) { + throw new TypeError("argv must be an array"); + } + + const positionals = []; + const options = {}; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--") { + positionals.push(...argv.slice(index + 1)); + break; + } + + if (!token.startsWith("--")) { + positionals.push(token); + continue; + } + + const equalsIndex = token.indexOf("="); + const name = token.slice(2, equalsIndex === -1 ? undefined : equalsIndex); + if (!name) { + throw usageError("Invalid empty option"); + } + + if (Object.hasOwn(options, name)) { + throw usageError(`Option --${name} may only be specified once`); + } + + if (BOOLEAN_OPTIONS.has(name)) { + if (equalsIndex !== -1) { + throw usageError(`Option --${name} does not accept a value`); + } + options[name] = true; + continue; + } + + if (equalsIndex !== -1) { + options[name] = token.slice(equalsIndex + 1); + continue; + } + + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw usageError(`Option --${name} requires a value`); + } + options[name] = value; + index += 1; + } + + return { + resource: positionals[0], + action: positionals[1], + operands: positionals.slice(2), + options, + }; +} + +export async function main(argv = process.argv.slice(2), overrides = {}) { + const stdout = overrides.stdout ?? process.stdout; + const stderr = overrides.stderr ?? process.stderr; + + try { + const parsed = parseArgs(argv); + const result = await execute(parsed, overrides); + writeJson(stdout, { ...result, schemaVersion: SCHEMA_VERSION }); + return 0; + } catch (error) { + const normalized = normalizeError(error); + const payload = { + schemaVersion: SCHEMA_VERSION, + error: { + code: normalized.code, + message: normalized.message, + }, + }; + if (normalized.details !== undefined) { + payload.error.details = normalized.details; + } + writeJson(stderr, payload); + return normalized.exitCode; + } +} + +async function execute(parsed, overrides) { + const command = `${parsed.resource ?? ""} ${parsed.action ?? ""}`.trim(); + const allowedOptions = COMMAND_OPTIONS.get(command); + if (!allowedOptions) { + throw usageError( + "Expected one of: project list/create/map, cloud login/status/logout, issue list/get/create/update/move/archive/restore/relation, comment list/add/update/delete, attachment download, context current", + ); + } + validateOptions(parsed.options, allowedOptions); + + const env = overrides.env ?? process.env; + const usesCompanionControl = command.startsWith("cloud ") || command === "project map"; + const api = createApiClient(overrides, { + baseUrl: usesCompanionControl || env.CODEX_TASKBOARD_COMPANION_URL !== undefined + ? resolveCompanionUrl(env) + : undefined, + }); + switch (command) { + case "project list": + expectOperandCount(parsed, 0); + return api.request("GET", "/api/projects"); + case "project create": + expectOperandCount(parsed, 0); + return api.request("POST", "/api/projects", { + ...optionalField("id", parsed.options.id), + name: requiredOption(parsed.options, "name"), + ...optionalField( + "workspacePath", + parsed.options["workspace-path"] === undefined + ? undefined + : resolveInputPath(parsed.options["workspace-path"], overrides), + ), + }); + case "project map": + expectOperandCount(parsed, 1); + return api.request( + "PUT", + `/api/local/project-mappings/${encodeURIComponent(parsed.operands[0])}`, + { + workspacePath: resolveInputPath( + requiredOption(parsed.options, "workspace-path"), + overrides, + ), + }, + ); + case "cloud login": + expectOperandCount(parsed, 0); + return cloudLogin( + api, + requiredOption(parsed.options, "url"), + requiredOption(parsed.options, "actor-name"), + overrides, + ); + case "cloud status": + expectOperandCount(parsed, 0); + return api.request("GET", "/api/local/cloud-session"); + case "cloud logout": + expectOperandCount(parsed, 0); + return api.request("DELETE", "/api/local/cloud-session"); + case "issue list": + expectOperandCount(parsed, 0); + return listIssues(api, parsed.options); + case "issue get": + expectOperandCount(parsed, 1); + return api.request("GET", taskPath(parsed.operands[0])); + case "issue create": + expectOperandCount(parsed, 0); + return createIssue(api, parsed.options, overrides); + case "issue update": + expectOperandCount(parsed, 1); + return updateIssue(api, parsed.operands[0], parsed.options, overrides); + case "issue move": + expectOperandCount(parsed, 1); + return moveIssue(api, parsed.operands[0], parsed.options, overrides); + case "issue archive": + expectOperandCount(parsed, 1); + return archiveIssue(api, parsed.operands[0], parsed.options, overrides, "archive"); + case "issue restore": + expectOperandCount(parsed, 1); + return archiveIssue(api, parsed.operands[0], parsed.options, overrides, "restore"); + case "issue relation": + expectOperandCount(parsed, 2); + return mutateIssueRelation( + api, + parsed.operands[0], + parsed.operands[1], + parsed.options, + overrides, + ); + case "comment list": + expectOperandCount(parsed, 1); + return api.request("GET", `${taskPath(parsed.operands[0])}/comments`); + case "comment add": + expectOperandCount(parsed, 1); + return api.request("POST", `${taskPath(parsed.operands[0])}/comments`, { + body: requiredOption(parsed.options, "body"), + threadId: resolveThreadId(parsed.options, overrides), + }); + case "comment update": + expectOperandCount(parsed, 1); + return api.request("PATCH", commentPath(parsed.operands[0]), { + body: requiredOption(parsed.options, "body"), + threadId: resolveThreadId(parsed.options, overrides), + version: explicitVersion(parsed.options["if-version"]), + }); + case "comment delete": + expectOperandCount(parsed, 1); + return api.request("DELETE", commentPath(parsed.operands[0]), { + threadId: resolveThreadId(parsed.options, overrides), + version: explicitVersion(parsed.options["if-version"]), + }); + case "attachment download": + expectOperandCount(parsed, 1); + return downloadAttachment(api, parsed.operands[0], parsed.options, overrides); + case "context current": + expectOperandCount(parsed, 0); + return currentContext(api, parsed.options, overrides); + default: + throw usageError(`Unsupported command: ${command}`); + } +} + +function createApiClient(overrides, { baseUrl: explicitBaseUrl } = {}) { + const fetchImplementation = overrides.fetch ?? globalThis.fetch; + if (typeof fetchImplementation !== "function") { + throw new TaskctlError("fetch is not available", { + code: "CLIENT_UNAVAILABLE", + exitCode: 3, + }); + } + + const env = overrides.env ?? process.env; + const baseUrl = normalizeBaseUrl(explicitBaseUrl ?? env.CODEX_TASKBOARD_URL ?? DEFAULT_API_URL); + + return { + async request(method, pathname, body) { + let response; + try { + response = await fetchImplementation(new URL(pathname, `${baseUrl}/`), { + method, + headers: { + accept: "application/json", + "x-taskboard-client": "taskctl", + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + } catch (error) { + throw new TaskctlError(`Cannot reach taskboard service at ${baseUrl}`, { + code: "SERVICE_UNAVAILABLE", + exitCode: 3, + details: error instanceof Error ? error.message : String(error), + }); + } + + const payload = await readResponse(response); + if (!response.ok) { + const apiError = extractApiError(payload, response.status); + throw new TaskctlError(apiError.message, { + code: apiError.code, + exitCode: response.status === 409 ? 5 : 4, + details: apiError.details, + }); + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new TaskctlError("Taskboard service returned an invalid JSON response", { + code: "INVALID_RESPONSE", + exitCode: 4, + }); + } + return payload; + }, + async download(pathname) { + let response; + try { + response = await fetchImplementation(new URL(pathname, `${baseUrl}/`), { + headers: { + accept: "*/*", + "x-taskboard-client": "taskctl", + }, + }); + } catch (error) { + throw new TaskctlError(`Cannot reach taskboard service at ${baseUrl}`, { + code: "SERVICE_UNAVAILABLE", + exitCode: 3, + details: error instanceof Error ? error.message : String(error), + }); + } + + if (!response.ok) { + const payload = await readResponse(response); + const apiError = extractApiError(payload, response.status); + throw new TaskctlError(apiError.message, { + code: apiError.code, + exitCode: response.status === 409 ? 5 : 4, + details: apiError.details, + }); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + return { + bytes, + contentType: response.headers.get("content-type"), + size: Number(response.headers.get("content-length")) || bytes.byteLength, + }; + }, + }; +} + +async function downloadAttachment(api, attachmentId, options, overrides) { + const output = resolveInputPath(requiredOption(options, "output"), overrides); + const downloaded = await api.download(attachmentContentPath(attachmentId)); + const write = overrides.writeFile ?? writeFile; + try { + await write(output, downloaded.bytes); + } catch (error) { + throw new TaskctlError(`Cannot write attachment file: ${output}`, { + code: "FILE_WRITE_FAILED", + exitCode: 2, + details: error instanceof Error ? error.message : String(error), + }); + } + return { + attachmentId, + output, + contentType: downloaded.contentType, + size: downloaded.size, + }; +} + +async function cloudLogin(api, rawUrl, actorName, overrides) { + let remoteUrl; + try { + remoteUrl = normalizeCloudUrl(rawUrl); + } catch (error) { + throw new TaskctlError(error instanceof Error ? error.message : String(error), { + code: error?.code ?? "INVALID_CLOUD_URL", + exitCode: 2, + }); + } + const sharedKey = overrides.readSecret + ? await overrides.readSecret() + : await readSecretFromInput( + overrides.stdin ?? process.stdin, + overrides.stderr ?? process.stderr, + ); + if (typeof sharedKey !== "string" || !sharedKey) { + throw usageError("Cloud shared key cannot be empty"); + } + return api.request("PUT", "/api/local/cloud-session", { + remoteUrl, + actorName, + sharedKey, + }); +} + +async function readSecretFromInput(input, output) { + if (!input.isTTY) { + let value = ""; + for await (const chunk of input) value += chunk; + return value.replace(/\r?\n$/, ""); + } + + return new Promise((resolve, reject) => { + let value = ""; + const wasRaw = input.isRaw; + const wasPaused = input.isPaused(); + const finish = (error) => { + input.off("data", onData); + input.setRawMode(wasRaw); + if (wasPaused) input.pause(); + output.write("\n"); + if (error) reject(error); + else resolve(value); + }; + const onData = (chunk) => { + for (const character of String(chunk)) { + if (character === "\r" || character === "\n") return finish(); + if (character === "\u0003") { + return finish(new TaskctlError("Cloud login canceled", { + code: "CANCELED", + exitCode: 2, + })); + } + if (character === "\u007f" || character === "\b") { + value = value.slice(0, -1); + } else { + value += character; + } + } + }; + output.write("Shared key: "); + input.setRawMode(true); + input.setEncoding("utf8"); + input.resume(); + input.on("data", onData); + }); +} + +async function listIssues(api, options) { + if (options.status !== undefined) { + assertStatus(options.status); + } + const search = new URLSearchParams(); + if (options.project !== undefined) search.set("projectId", options.project); + if (options.status !== undefined) search.set("status", options.status); + const query = search.size > 0 ? `?${search}` : ""; + return api.request("GET", `/api/tasks${query}`); +} + +async function createIssue(api, options, overrides) { + const status = options.status ?? "backlog"; + const priority = options.priority ?? "none"; + assertStatus(status); + assertPriority(priority); + + const developmentContext = developmentContextFromOptions(options, overrides); + const recurrence = recurrenceFromOptions(options); + const threadId = resolveThreadId(options, overrides); + return api.request("POST", "/api/tasks", { + projectId: requiredOption(options, "project"), + title: requiredOption(options, "title"), + description: await resolveDescription(options, overrides), + status, + priority, + labels: parseLabels(options.labels), + threadId, + ...optionalField("developmentContext", developmentContext), + ...optionalField("dueDate", options["due-date"]), + ...optionalField("recurrence", recurrence), + }); +} + +async function updateIssue(api, taskId, options, overrides) { + if (options.status !== undefined) assertStatus(options.status); + if (options.priority !== undefined) assertPriority(options.priority); + + const developmentContext = developmentContextFromOptions(options, overrides); + const recurrence = recurrenceFromOptions(options); + const threadId = resolveThreadId(options, overrides); + const patch = { + ...optionalField("title", options.title), + ...optionalField("status", options.status), + ...optionalField("priority", options.priority), + ...optionalField("labels", options.labels === undefined ? undefined : parseLabels(options.labels)), + ...optionalField("developmentContext", developmentContext), + ...optionalField("dueDate", options["due-date"]), + ...optionalField("recurrence", recurrence), + }; + if (options.description !== undefined || options["description-file"] !== undefined) { + patch.description = await resolveDescription(options, overrides); + } + + if (Object.keys(patch).length === 0) { + throw usageError("issue update requires at least one field to update"); + } + patch.threadId = threadId; + patch.version = await resolveVersion(api, taskId, options["if-version"]); + return api.request("PATCH", taskPath(taskId), patch); +} + +async function moveIssue(api, taskId, options, overrides) { + const status = requiredOption(options, "status"); + assertStatus(status); + const threadId = resolveThreadId(options, overrides); + return api.request("POST", `${taskPath(taskId)}/move`, { + status, + threadId, + version: await resolveVersion(api, taskId, options["if-version"]), + }); +} + +async function archiveIssue(api, taskId, options, overrides, action) { + const threadId = resolveThreadId(options, overrides); + return api.request("POST", `${taskPath(taskId)}/${action}`, { + threadId, + version: await resolveVersion(api, taskId, options["if-version"]), + }); +} + +async function mutateIssueRelation(api, action, taskId, options, overrides) { + if (action !== "add" && action !== "remove") { + throw usageError("issue relation action must be add or remove"); + } + const type = requiredOption(options, "type"); + if (!["parent", "blocks", "blocked_by", "related"].includes(type)) { + throw usageError("--type must be parent, blocks, blocked_by, or related"); + } + const relatedTaskId = requiredOption(options, "issue"); + const threadId = resolveThreadId(options, overrides); + const version = await resolveVersion(api, taskId, options["if-version"]); + return api.request( + action === "add" ? "POST" : "DELETE", + `${taskPath(taskId)}/relations/${type}/${encodeURIComponent(relatedTaskId)}`, + { threadId, version }, + ); +} + +async function currentContext(api, options, overrides) { + const cwd = path.resolve(options.cwd ?? overrides.cwd ?? process.cwd()); + const response = await api.request("GET", "/api/projects"); + const projects = Array.isArray(response.projects) ? response.projects : []; + const matchingProjects = projects + .filter((candidate) => workspaceContains(candidate?.workspacePath, cwd)) + .sort((left, right) => right.workspacePath.length - left.workspacePath.length); + const project = matchingProjects[0] + ?? projects.find((candidate) => candidate?.id === DEFAULT_PROJECT_ID) + ?? projects[0] + ?? null; + return { cwd, project }; +} + +function workspaceContains(workspacePath, cwd) { + if (typeof workspacePath !== "string" || workspacePath.length === 0) return false; + const relative = path.relative(path.resolve(workspacePath), cwd); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +} + +function resolveInputPath(value, overrides) { + return path.resolve(overrides.cwd ?? process.cwd(), value); +} + +async function resolveVersion(api, taskId, rawVersion) { + if (rawVersion !== undefined) { + const version = Number(rawVersion); + if (!Number.isSafeInteger(version) || version < 1) { + throw usageError("--if-version must be a positive integer"); + } + return version; + } + + const response = await api.request("GET", taskPath(taskId)); + const version = response.task?.version; + if (!Number.isSafeInteger(version) || version < 1) { + throw new TaskctlError("Taskboard service returned a task without a valid version", { + code: "INVALID_RESPONSE", + exitCode: 4, + }); + } + return version; +} + +async function resolveDescription(options, overrides) { + if (options.description !== undefined && options["description-file"] !== undefined) { + throw usageError("Use either --description or --description-file, not both"); + } + if (options["description-file"] === undefined) { + return options.description ?? ""; + } + + const read = overrides.readFile ?? readFile; + try { + return await read(options["description-file"], "utf8"); + } catch (error) { + throw new TaskctlError(`Cannot read description file: ${options["description-file"]}`, { + code: "FILE_READ_FAILED", + exitCode: 2, + details: error instanceof Error ? error.message : String(error), + }); + } +} + +function parseLabels(rawLabels) { + if (rawLabels === undefined || rawLabels === "") return []; + return [...new Set(rawLabels.split(",").map((label) => label.trim()).filter(Boolean))]; +} + +function developmentContextFromOptions(options, overrides) { + const branch = options["git-branch"]; + const worktreePath = options["worktree-path"]; + const worktreeBranch = options["worktree-branch"]; + if (branch !== undefined && (worktreePath !== undefined || worktreeBranch !== undefined)) { + throw usageError("Use either --git-branch or --worktree-path/--worktree-branch, not both"); + } + if (worktreeBranch !== undefined && worktreePath === undefined) { + throw usageError("--worktree-branch requires --worktree-path"); + } + if (branch !== undefined) return { type: "branch", branch }; + if (worktreePath !== undefined) { + return { + type: "worktree", + path: resolveInputPath(worktreePath, overrides), + branch: worktreeBranch ?? null, + }; + } + return undefined; +} + +function recurrenceFromOptions(options) { + const rawInterval = options["recurrence-interval"]; + const unit = options["recurrence-unit"]; + if (rawInterval === undefined && unit === undefined) return undefined; + if (rawInterval === undefined || unit === undefined) { + throw usageError("Use --recurrence-interval and --recurrence-unit together"); + } + const interval = Number(rawInterval); + if (!Number.isSafeInteger(interval) || interval < 1 || interval > 365) { + throw usageError("--recurrence-interval must be an integer from 1 to 365"); + } + if (!["day", "week", "month", "year"].includes(unit)) { + throw usageError("--recurrence-unit must be day, week, month, or year"); + } + return { interval, unit }; +} + +function resolveThreadId(options, overrides) { + const env = overrides.env ?? process.env; + const value = options["thread-id"] ?? env.CODEX_THREAD_ID; + if (typeof value !== "string" || value.trim().length === 0) { + throw usageError("Codex conversation attribution requires --thread-id or CODEX_THREAD_ID"); + } + const threadId = value.trim(); + if (threadId.length > 256) { + throw usageError("--thread-id and CODEX_THREAD_ID cannot exceed 256 characters"); + } + return threadId; +} + +function requiredOption(options, name) { + const value = options[name]; + if (value === undefined || value === "") { + throw usageError(`Missing required option --${name}`); + } + return value; +} + +function optionalField(name, value) { + return value === undefined ? {} : { [name]: value }; +} + +function validateOptions(options, allowedOptions) { + for (const name of Object.keys(options)) { + if (!allowedOptions.has(name)) { + throw usageError(`Unknown option --${name}`); + } + } +} + +function expectOperandCount(parsed, expected) { + if (parsed.operands.length !== expected) { + throw usageError( + expected === 0 + ? `${parsed.resource} ${parsed.action} does not accept positional arguments` + : `${parsed.resource} ${parsed.action} requires exactly ${expected} positional ${ + expected === 1 ? "argument" : "arguments" + }`, + ); + } +} + +function assertStatus(status) { + if (!isTaskStatus(status)) { + throw usageError(`Invalid status: ${status}. Expected one of: ${TASK_STATUSES.join(", ")}`); + } +} + +function assertPriority(priority) { + if (!isTaskPriority(priority)) { + throw usageError(`Invalid priority: ${priority}`); + } +} + +function taskPath(taskId) { + if (!taskId) throw usageError("Missing issue id"); + return `/api/tasks/${encodeURIComponent(taskId)}`; +} + +function commentPath(commentId) { + if (!commentId) throw usageError("Missing comment id"); + return `/api/comments/${encodeURIComponent(commentId)}`; +} + +function attachmentContentPath(attachmentId) { + if (!attachmentId) throw usageError("Missing attachment id"); + return `/api/attachments/${encodeURIComponent(attachmentId)}/content`; +} + +function explicitVersion(rawVersion) { + if (rawVersion === undefined) throw usageError("Missing required option --if-version"); + const version = Number(rawVersion); + if (!Number.isSafeInteger(version) || version < 1) { + throw usageError("--if-version must be a positive integer"); + } + return version; +} + +function normalizeBaseUrl(rawUrl) { + let url; + try { + url = new URL(rawUrl); + } catch { + throw usageError("CODEX_TASKBOARD_URL must be a valid URL"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw usageError("CODEX_TASKBOARD_URL must use http or https"); + } + url.pathname = url.pathname.replace(/\/$/, ""); + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/, ""); +} + +function resolveCompanionUrl(env) { + const rawUrl = env.CODEX_TASKBOARD_COMPANION_URL + ?? env.CODEX_TASKBOARD_URL + ?? DEFAULT_API_URL; + let url; + try { + url = new URL(rawUrl); + } catch { + throw usageError("Local companion URL must be a valid URL"); + } + const isLoopback = url.hostname === "localhost" + || url.hostname === "127.0.0.1" + || url.hostname === "[::1]"; + if ( + !isLoopback + || (url.protocol !== "http:" && url.protocol !== "https:") + || url.username + || url.password + || (url.pathname !== "/" && url.pathname !== "") + || url.search + || url.hash + ) { + throw usageError("Local companion URL must be a loopback HTTP or HTTPS origin"); + } + return url.origin; +} + +async function readResponse(response) { + const text = await response.text(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + throw new TaskctlError("Taskboard service returned invalid JSON", { + code: "INVALID_RESPONSE", + exitCode: 4, + }); + } +} + +function extractApiError(payload, status) { + if (payload?.error && typeof payload.error === "object") { + return { + code: payload.error.code ?? `HTTP_${status}`, + message: payload.error.message ?? `Taskboard service returned HTTP ${status}`, + details: payload.error.details, + }; + } + return { + code: payload?.code ?? `HTTP_${status}`, + message: + payload?.message ?? + (typeof payload?.error === "string" ? payload.error : `Taskboard service returned HTTP ${status}`), + details: payload?.details, + }; +} + +function normalizeError(error) { + if (error instanceof TaskctlError) return error; + return new TaskctlError(error instanceof Error ? error.message : String(error), { + code: "INTERNAL_ERROR", + exitCode: 1, + }); +} + +function usageError(message) { + return new TaskctlError(message, { code: "USAGE_ERROR", exitCode: 2 }); +} + +function writeJson(stream, payload) { + stream.write(`${JSON.stringify(payload)}\n`); +} + +const entrypoint = process.argv[1] ? realpathSync(process.argv[1]) : ""; +if (entrypoint === realpathSync(fileURLToPath(import.meta.url))) { + process.exitCode = await main(); +} diff --git a/apps/codex-taskboard/cloud/migrations/0001_initial.sql b/apps/codex-taskboard/cloud/migrations/0001_initial.sql new file mode 100644 index 000000000..edc013434 --- /dev/null +++ b/apps/codex-taskboard/cloud/migrations/0001_initial.sql @@ -0,0 +1,33 @@ +CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT NOT NULL, workspace_path TEXT CHECK (workspace_path IS NULL), next_task_number INTEGER NOT NULL DEFAULT 1 CHECK (next_task_number > 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL); +CREATE TABLE tasks (id TEXT PRIMARY KEY, identifier TEXT NOT NULL UNIQUE, project_id TEXT NOT NULL REFERENCES projects(id), title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL CHECK (status IN ('backlog', 'todo', 'in_progress', 'in_review', 'blocked', 'done', 'canceled')), priority TEXT NOT NULL CHECK (priority IN ('none', 'urgent', 'high', 'medium', 'low')), labels TEXT NOT NULL DEFAULT '[]', sort_order REAL NOT NULL, thread_id TEXT, creator_type TEXT NOT NULL CHECK (creator_type IN ('user', 'agent')), creator_id TEXT NOT NULL, creator_name TEXT NOT NULL, creator_avatar_url TEXT, assignee_type TEXT NOT NULL CHECK (assignee_type IN ('user', 'agent')), assignee_id TEXT NOT NULL, assignee_name TEXT NOT NULL, assignee_avatar_url TEXT, workflow_id TEXT, development_context_type TEXT CHECK (development_context_type IS NULL OR development_context_type IN ('branch', 'worktree')), development_branch TEXT, due_date TEXT, recurrence_interval INTEGER, recurrence_unit TEXT, archived_at TEXT, version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL); +CREATE INDEX tasks_project_status_sort ON tasks(project_id, archived_at, status, sort_order, created_at); +CREATE TABLE task_relations (relation_type TEXT NOT NULL CHECK (relation_type IN ('parent', 'blocks', 'related')), source_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, target_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, created_at TEXT NOT NULL, CHECK (source_task_id <> target_task_id), CHECK (relation_type <> 'related' OR source_task_id < target_task_id), PRIMARY KEY (relation_type, source_task_id, target_task_id)); +CREATE INDEX task_relations_target ON task_relations(relation_type, target_task_id); +CREATE UNIQUE INDEX task_relations_one_parent ON task_relations(target_task_id) WHERE relation_type = 'parent'; +CREATE TRIGGER task_relations_prevent_parent_cycle BEFORE INSERT ON task_relations WHEN NEW.relation_type = 'parent' BEGIN SELECT RAISE(ABORT, 'RELATION_CYCLE') WHERE EXISTS (WITH RECURSIVE ancestors(id) AS (SELECT source_task_id FROM task_relations WHERE relation_type = 'parent' AND target_task_id = NEW.source_task_id UNION SELECT task_relations.source_task_id FROM task_relations JOIN ancestors ON task_relations.target_task_id = ancestors.id WHERE task_relations.relation_type = 'parent') SELECT 1 FROM ancestors WHERE id = NEW.target_task_id); END; +CREATE TABLE comments (id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, body TEXT NOT NULL, thread_id TEXT, author_type TEXT NOT NULL CHECK (author_type IN ('user', 'agent')), author_id TEXT NOT NULL, author_name TEXT NOT NULL, author_avatar_url TEXT, version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL); +CREATE INDEX comments_task_created ON comments(task_id, created_at, id); +CREATE TABLE attachments (id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, comment_id TEXT REFERENCES comments(id) ON DELETE CASCADE, filename TEXT NOT NULL, content_type TEXT NOT NULL, size INTEGER NOT NULL CHECK (size >= 0), created_at TEXT NOT NULL); +CREATE INDEX attachments_task_created ON attachments(task_id, created_at, id); +CREATE INDEX attachments_comment_created ON attachments(comment_id, created_at, id); +CREATE TABLE workflow_workspaces (project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE, workspace TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), updated_at TEXT NOT NULL); +CREATE TABLE global_revision (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0)); +INSERT INTO global_revision (singleton, revision) VALUES (1, 0); +CREATE TRIGGER projects_revision_insert AFTER INSERT ON projects BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER projects_revision_update AFTER UPDATE ON projects BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER projects_revision_delete AFTER DELETE ON projects BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER tasks_revision_insert AFTER INSERT ON tasks BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER tasks_revision_update AFTER UPDATE ON tasks BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER tasks_revision_delete AFTER DELETE ON tasks BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER task_relations_revision_insert AFTER INSERT ON task_relations BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER task_relations_revision_update AFTER UPDATE ON task_relations BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER task_relations_revision_delete AFTER DELETE ON task_relations BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER comments_revision_insert AFTER INSERT ON comments BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER comments_revision_update AFTER UPDATE ON comments BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER comments_revision_delete AFTER DELETE ON comments BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER attachments_revision_insert AFTER INSERT ON attachments BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER attachments_revision_update AFTER UPDATE ON attachments BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER attachments_revision_delete AFTER DELETE ON attachments BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER workflow_workspaces_revision_insert AFTER INSERT ON workflow_workspaces BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER workflow_workspaces_revision_update AFTER UPDATE ON workflow_workspaces BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER workflow_workspaces_revision_delete AFTER DELETE ON workflow_workspaces BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; diff --git a/apps/codex-taskboard/cloud/migrations/0002_project_archive.sql b/apps/codex-taskboard/cloud/migrations/0002_project_archive.sql new file mode 100644 index 000000000..5a4f5c431 --- /dev/null +++ b/apps/codex-taskboard/cloud/migrations/0002_project_archive.sql @@ -0,0 +1,2 @@ +ALTER TABLE projects ADD COLUMN archived_at TEXT; +CREATE INDEX projects_archived_created ON projects(archived_at, created_at, id); diff --git a/apps/codex-taskboard/cloud/src/index.mjs b/apps/codex-taskboard/cloud/src/index.mjs new file mode 100644 index 000000000..6e8c64dbf --- /dev/null +++ b/apps/codex-taskboard/cloud/src/index.mjs @@ -0,0 +1,2274 @@ +import { normalizeWorkflowSnapshot } from "../../shared/workflow-control-flow.mjs"; +import { DEFAULT_PROJECT_ID } from "../../shared/domain.mjs"; + +const JSON_BODY_LIMIT = 1024 * 1024; +const ATTACHMENT_BODY_LIMIT = 25 * 1024 * 1024; +const PROJECT_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; +const TASK_STATUSES = [ + "backlog", + "todo", + "in_progress", + "in_review", + "blocked", + "done", + "canceled", +]; +const TASK_PRIORITIES = ["none", "urgent", "high", "medium", "low"]; +const INLINE_ATTACHMENT_TYPES = new Set([ + "application/pdf", + "image/avif", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", + "text/plain", +]); + +class ApiError extends Error { + constructor(status, code, message, details) { + super(message); + this.status = status; + this.code = code; + this.details = details; + } +} + +function json(status, value, headers = {}) { + return new Response(JSON.stringify(value), { + status, + headers: { + "cache-control": "no-store", + "content-type": "application/json; charset=utf-8", + ...headers, + }, + }); +} + +function empty(status, headers = {}) { + return new Response(null, { + status, + headers: { "cache-control": "no-store", ...headers }, + }); +} + +function methodNotAllowed(allowed) { + throw new ApiError(405, "METHOD_NOT_ALLOWED", "Method not allowed", { + allowed, + }); +} + +function assertPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new ApiError(400, "INVALID_BODY", "Request body must be a JSON object"); + } +} + +function assertAllowedKeys(value, allowed) { + const unknown = Object.keys(value).filter((key) => !allowed.has(key)); + if (unknown.length > 0) { + throw new ApiError( + 400, + "UNKNOWN_FIELD", + `Unknown field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}`, + ); + } +} + +function stringField(value, name, { + required = false, + nullable = false, + maxLength, +} = {}) { + if (value === undefined) { + if (required) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' is required`); + } + return undefined; + } + if (nullable && value === null) return null; + if (typeof value !== "string") { + throw new ApiError( + 400, + "INVALID_FIELD", + `'${name}' must be a string${nullable ? " or null" : ""}`, + ); + } + const normalized = value.trim(); + if (required && normalized.length === 0) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' cannot be empty`); + } + if (normalized.length > maxLength) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' cannot exceed ${maxLength} characters`); + } + return normalized; +} + +function parseVersion(value, { allowZero = false } = {}) { + if (!Number.isSafeInteger(value) || value < (allowZero ? 0 : 1)) { + throw new ApiError( + 400, + "INVALID_FIELD", + `'version' must be a ${allowZero ? "non-negative" : "positive"} integer`, + ); + } + return value; +} + +function parseStatus(value, fallback) { + const status = value ?? fallback; + if (!TASK_STATUSES.includes(status)) { + throw new ApiError( + 400, + "INVALID_FIELD", + `'status' must be one of: ${TASK_STATUSES.join(", ")}`, + ); + } + return status; +} + +function parsePriority(value, fallback) { + const priority = value ?? fallback; + if (!TASK_PRIORITIES.includes(priority)) { + throw new ApiError( + 400, + "INVALID_FIELD", + "'priority' must be none, urgent, high, medium, or low", + ); + } + return priority; +} + +function parseLabels(value) { + if (!Array.isArray(value) || value.length > 20) { + throw new ApiError(400, "INVALID_FIELD", "'labels' must be an array with at most 20 entries"); + } + const labels = value.map((label) => { + if (typeof label !== "string") { + throw new ApiError(400, "INVALID_FIELD", "Every label must be a string"); + } + const normalized = label.trim(); + if (normalized.length === 0 || normalized.length > 64) { + throw new ApiError(400, "INVALID_FIELD", "Labels must contain 1 to 64 characters"); + } + return normalized; + }); + if (new Set(labels).size !== labels.length) { + throw new ApiError(400, "INVALID_FIELD", "Labels must be unique"); + } + return labels; +} + +function parseSortOrder(value) { + if ( + typeof value !== "number" + || !Number.isFinite(value) + || Math.abs(value) > 1_000_000_000_000 + ) { + throw new ApiError( + 400, + "INVALID_FIELD", + "'sortOrder' must be a finite number between -1000000000000 and 1000000000000", + ); + } + return value; +} + +function parseDueDate(value) { + const date = stringField(value, "dueDate", { nullable: true, maxLength: 10 }); + if (date !== null && date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + throw new ApiError(400, "INVALID_FIELD", "'dueDate' must use YYYY-MM-DD"); + } + return date; +} + +function parseRecurrence(value) { + if (value === null) return null; + assertPlainObject(value); + assertAllowedKeys(value, new Set(["interval", "unit"])); + if (!Number.isSafeInteger(value.interval) || value.interval < 1 || value.interval > 365) { + throw new ApiError( + 400, + "INVALID_FIELD", + "'recurrence.interval' must be an integer from 1 to 365", + ); + } + if (!["day", "week", "month", "year"].includes(value.unit)) { + throw new ApiError( + 400, + "INVALID_FIELD", + "'recurrence.unit' must be day, week, month, or year", + ); + } + return { interval: value.interval, unit: value.unit }; +} + +function parseDevelopmentContext(value) { + if (value === null) return null; + assertPlainObject(value); + if (value.type === "branch") { + assertAllowedKeys(value, new Set(["type", "branch"])); + return { + type: "branch", + branch: stringField(value.branch, "developmentContext.branch", { + required: true, + maxLength: 512, + }), + }; + } + if (value.type === "worktree") { + assertAllowedKeys(value, new Set(["type", "path", "branch"])); + const worktreePath = stringField(value.path, "developmentContext.path", { + maxLength: 4096, + }); + if (worktreePath?.includes("\0")) { + throw new ApiError( + 400, + "INVALID_FIELD", + "'developmentContext.path' cannot contain null bytes", + ); + } + return { + type: "worktree", + branch: stringField(value.branch ?? null, "developmentContext.branch", { + nullable: true, + maxLength: 512, + }), + }; + } + throw new ApiError( + 400, + "INVALID_FIELD", + "'developmentContext.type' must be branch or worktree", + ); +} + +function parseThreadId(value) { + if (value === undefined) return undefined; + return stringField(value, "threadId", { required: true, maxLength: 256 }); +} + +function parseWorkflowId(value) { + const workflowId = stringField(value, "workflowId", { + nullable: true, + maxLength: 128, + }); + if (workflowId === "") { + throw new ApiError(400, "INVALID_FIELD", "'workflowId' cannot be empty"); + } + return workflowId; +} + +function parseAssigneeTarget(value) { + if (value === undefined) return undefined; + if (!["current-user", "codex-agent"].includes(value)) { + throw new ApiError( + 400, + "INVALID_FIELD", + "'assigneeTarget' must be current-user or codex-agent", + ); + } + return value; +} + +function slugify(value) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64); +} + +function validateProjectId(value) { + const id = stringField(value, "id", { required: true, maxLength: 64 }); + if (!PROJECT_ID_PATTERN.test(id)) { + throw new ApiError( + 400, + "INVALID_FIELD", + "'id' must be a lowercase slug containing letters, numbers, or hyphens", + ); + } + return id; +} + +function projectPrefix(projectId) { + const prefix = projectId.toUpperCase().replace(/[^A-Z0-9]+/g, ""); + return (prefix || "TASK").slice(0, 12); +} + +function now() { + return new Date().toISOString(); +} + +function uuid() { + return crypto.randomUUID(); +} + +function decodeBasicCredentials(header) { + if (!header?.startsWith("Basic ")) return null; + let bytes; + try { + const binary = atob(header.slice(6).trim()); + bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + } catch { + return null; + } + let value; + try { + value = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return null; + } + const separator = value.indexOf(":"); + if (separator < 1) return null; + return { + username: value.slice(0, separator), + password: value.slice(separator + 1), + }; +} + +function unauthorized() { + return json( + 401, + { error: { code: "UNAUTHORIZED", message: "Valid Basic credentials are required" } }, + { "www-authenticate": 'Basic realm="Codex Taskboard", charset="UTF-8"' }, + ); +} + +async function authenticate(request, env) { + if (typeof env.TASKBOARD_SHARED_SECRET !== "string" || env.TASKBOARD_SHARED_SECRET === "") { + throw new ApiError( + 500, + "SERVER_MISCONFIGURED", + "TASKBOARD_SHARED_SECRET is not configured", + ); + } + const credentials = decodeBasicCredentials(request.headers.get("authorization")); + if (!credentials) return null; + const encoder = new TextEncoder(); + const [providedSecret, configuredSecret] = await Promise.all([ + crypto.subtle.digest("SHA-256", encoder.encode(credentials.password)), + crypto.subtle.digest("SHA-256", encoder.encode(env.TASKBOARD_SHARED_SECRET)), + ]); + if (!crypto.subtle.timingSafeEqual(providedSecret, configuredSecret)) return null; + const username = stringField(credentials.username, "Basic username", { + required: true, + maxLength: 120, + }); + const userId = `basic:${encodeURIComponent(username.toLowerCase())}`; + if (request.headers.get("x-taskboard-client") === "taskctl") { + return { + type: "agent", + id: `${userId}:codex-agent`, + name: `Codex Agent (${username})`, + avatarUrl: null, + username, + }; + } + return { + type: "user", + id: userId, + name: username, + avatarUrl: null, + username, + }; +} + +function resolveAssignee(target, actor) { + if (target === undefined || target === "current-user") return actor; + const userId = `basic:${encodeURIComponent(actor.username.toLowerCase())}`; + return { + type: "agent", + id: `${userId}:codex-agent`, + name: `Codex Agent (${actor.username})`, + avatarUrl: null, + }; +} + +async function readJson(request) { + const contentType = request.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().startsWith("application/json")) { + throw new ApiError( + 415, + "UNSUPPORTED_MEDIA_TYPE", + "Content-Type must be application/json", + ); + } + const contentLength = Number(request.headers.get("content-length") ?? 0); + if (contentLength > JSON_BODY_LIMIT) { + throw new ApiError(413, "BODY_TOO_LARGE", "JSON body cannot exceed 1 MiB"); + } + const text = await request.text(); + if (new TextEncoder().encode(text).byteLength > JSON_BODY_LIMIT) { + throw new ApiError(413, "BODY_TOO_LARGE", "JSON body cannot exceed 1 MiB"); + } + try { + return JSON.parse(text); + } catch { + throw new ApiError(400, "INVALID_JSON", "Request body is not valid JSON"); + } +} + +async function readAttachment(request) { + const contentLength = Number(request.headers.get("content-length") ?? 0); + if (contentLength > ATTACHMENT_BODY_LIMIT) { + throw new ApiError(413, "BODY_TOO_LARGE", "Attachment cannot exceed 25 MiB"); + } + const body = await request.arrayBuffer(); + if (body.byteLength > ATTACHMENT_BODY_LIMIT) { + throw new ApiError(413, "BODY_TOO_LARGE", "Attachment cannot exceed 25 MiB"); + } + return body; +} + +function parseAttachmentHeaders(request) { + const encodedFilename = request.headers.get("x-taskboard-filename"); + if (encodedFilename === null) { + throw new ApiError(400, "INVALID_FILENAME", "X-Taskboard-Filename is required"); + } + let filename; + try { + filename = decodeURIComponent(encodedFilename).trim(); + } catch { + throw new ApiError( + 400, + "INVALID_FILENAME", + "Attachment filename contains invalid encoding", + ); + } + if ( + filename.length === 0 + || filename.length > 240 + || filename === "." + || filename === ".." + || /[\u0000-\u001f\u007f/\\]/.test(filename) + ) { + throw new ApiError(400, "INVALID_FILENAME", "Attachment filename is invalid"); + } + const rawContentType = request.headers.get("content-type"); + const contentType = rawContentType + ? rawContentType.split(";", 1)[0].trim().toLowerCase() + : "application/octet-stream"; + if ( + contentType.length === 0 + || contentType.length > 200 + || !/^[!#$%&'*+.^_`|~0-9a-z-]+\/[!#$%&'*+.^_`|~0-9a-z-]+$/.test(contentType) + ) { + throw new ApiError( + 415, + "UNSUPPORTED_MEDIA_TYPE", + "Attachment Content-Type is invalid", + ); + } + return { filename, contentType }; +} + +function projectFromRow(row) { + return { + id: row.id, + name: row.name, + workspacePath: null, + archivedAt: row.archived_at, + issueCount: Number(row.issue_count ?? 0), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function developmentContextFromRow(row) { + if (row.development_context_type === "worktree") { + return { + type: "worktree", + path: null, + branch: row.development_branch, + }; + } + if (row.development_context_type === "branch") { + return { type: "branch", branch: row.development_branch }; + } + return null; +} + +function taskFromRow(row) { + return { + id: row.id, + identifier: row.identifier, + projectId: row.project_id, + title: row.title, + description: row.description, + status: row.status, + priority: row.priority, + labels: JSON.parse(row.labels), + sortOrder: row.sort_order, + threadId: row.thread_id, + creatorType: row.creator_type, + creatorId: row.creator_id, + creatorName: row.creator_name, + creatorAvatarUrl: row.creator_avatar_url, + assignee: { + type: row.assignee_type, + id: row.assignee_id, + name: row.assignee_name, + avatarUrl: row.assignee_avatar_url, + }, + workflowId: row.workflow_id, + developmentContext: developmentContextFromRow(row), + dueDate: row.due_date, + recurrence: row.recurrence_interval && row.recurrence_unit + ? { interval: row.recurrence_interval, unit: row.recurrence_unit } + : null, + archivedAt: row.archived_at, + version: row.version, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function taskRelationSummaryFromRow(row) { + return { + id: row.id, + identifier: row.identifier, + projectId: row.project_id, + title: row.title, + status: row.status, + priority: row.priority, + threadId: row.thread_id, + assignee: { + type: row.assignee_type, + id: row.assignee_id, + name: row.assignee_name, + avatarUrl: row.assignee_avatar_url, + }, + archivedAt: row.archived_at, + }; +} + +function commentFromRow(row, attachments = []) { + return { + id: row.id, + taskId: row.task_id, + body: row.body, + threadId: row.thread_id, + authorType: row.author_type, + authorId: row.author_id, + authorName: row.author_name, + authorAvatarUrl: row.author_avatar_url, + attachments, + version: row.version, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function attachmentFromRow(row) { + return { + id: row.id, + taskId: row.task_id, + commentId: row.comment_id, + filename: row.filename, + contentType: row.content_type, + size: row.size, + createdAt: row.created_at, + }; +} + +async function all(statement) { + return (await statement.all()).results; +} + +function changed(result) { + if (typeof result?.meta?.changes !== "number") { + throw new Error("D1 mutation did not return change metadata"); + } + return result.meta.changes > 0; +} + +async function requireProject(env, id) { + const row = await env.DB.prepare( + "SELECT * FROM projects WHERE id = ? AND archived_at IS NULL", + ).bind(id).first(); + if (!row) { + throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${id}' does not exist`); + } + return row; +} + +async function taskRow(env, id) { + return env.DB.prepare( + "SELECT * FROM tasks WHERE id = ? OR identifier = ?", + ).bind(id, id).first(); +} + +async function requireTaskRow(env, id) { + const row = await taskRow(env, id); + if (!row) throw new ApiError(404, "TASK_NOT_FOUND", `Task '${id}' does not exist`); + return row; +} + +function assertTaskVersion(row, expectedVersion) { + if (row.version !== expectedVersion) { + throw new ApiError( + 409, + "VERSION_CONFLICT", + "Task was changed by another client", + { expectedVersion, actualVersion: row.version }, + ); + } +} + +async function attachmentsForComment(env, commentId) { + return ( + await all( + env.DB.prepare( + "SELECT * FROM attachments WHERE comment_id = ? ORDER BY created_at, id", + ).bind(commentId), + ) + ).map(attachmentFromRow); +} + +async function hydrateComment(env, row) { + return commentFromRow(row, await attachmentsForComment(env, row.id)); +} + +async function hydrateTask(env, row) { + const task = taskFromRow(row); + const [parent, subIssues, blockedBy, blocks, related] = await Promise.all([ + env.DB.prepare(` + SELECT tasks.* + FROM task_relations + JOIN tasks ON tasks.id = task_relations.source_task_id + WHERE task_relations.relation_type = 'parent' + AND task_relations.target_task_id = ? + `).bind(task.id).first(), + all(env.DB.prepare(` + SELECT tasks.* + FROM task_relations + JOIN tasks ON tasks.id = task_relations.target_task_id + WHERE task_relations.relation_type = 'parent' + AND task_relations.source_task_id = ? + ORDER BY tasks.sort_order, tasks.created_at, tasks.id + `).bind(task.id)), + all(env.DB.prepare(` + SELECT tasks.* + FROM task_relations + JOIN tasks ON tasks.id = task_relations.source_task_id + WHERE task_relations.relation_type = 'blocks' + AND task_relations.target_task_id = ? + ORDER BY tasks.sort_order, tasks.created_at, tasks.id + `).bind(task.id)), + all(env.DB.prepare(` + SELECT tasks.* + FROM task_relations + JOIN tasks ON tasks.id = task_relations.target_task_id + WHERE task_relations.relation_type = 'blocks' + AND task_relations.source_task_id = ? + ORDER BY tasks.sort_order, tasks.created_at, tasks.id + `).bind(task.id)), + all(env.DB.prepare(` + SELECT tasks.* + FROM task_relations + JOIN tasks ON tasks.id = CASE + WHEN task_relations.source_task_id = ? THEN task_relations.target_task_id + ELSE task_relations.source_task_id + END + WHERE task_relations.relation_type = 'related' + AND ( + task_relations.source_task_id = ? + OR task_relations.target_task_id = ? + ) + ORDER BY tasks.sort_order, tasks.created_at, tasks.id + `).bind(task.id, task.id, task.id)), + ]); + task.relations = { + parent: parent ? taskRelationSummaryFromRow(parent) : null, + subIssues: subIssues.map(taskRelationSummaryFromRow), + blockedBy: blockedBy.map(taskRelationSummaryFromRow), + blocks: blocks.map(taskRelationSummaryFromRow), + related: related.map(taskRelationSummaryFromRow), + }; + return task; +} + +async function getTask(env, id) { + const row = await taskRow(env, id); + return row ? hydrateTask(env, row) : null; +} + +function parseProjectCreate(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["id", "name", "workspacePath"])); + const name = stringField(body.name, "name", { required: true, maxLength: 120 }); + const id = validateProjectId(body.id ?? slugify(name)); + if (body.workspacePath !== undefined && body.workspacePath !== null) { + const workspacePath = stringField(body.workspacePath, "workspacePath", { + required: true, + maxLength: 4096, + }); + if (workspacePath.includes("\0")) { + throw new ApiError(400, "INVALID_FIELD", "'workspacePath' cannot contain null bytes"); + } + } + return { id, name }; +} + +function parseTaskCreate(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set([ + "projectId", + "title", + "description", + "status", + "priority", + "labels", + "sortOrder", + "threadId", + "assigneeTarget", + "workflowId", + "developmentContext", + "dueDate", + "recurrence", + ])); + const input = { + projectId: validateProjectId(body.projectId ?? "local"), + title: stringField(body.title, "title", { required: true, maxLength: 240 }), + description: stringField(body.description ?? "", "description", { maxLength: 100_000 }), + status: parseStatus(body.status, "backlog"), + priority: parsePriority(body.priority, "none"), + labels: body.labels === undefined ? [] : parseLabels(body.labels), + sortOrder: body.sortOrder === undefined ? undefined : parseSortOrder(body.sortOrder), + threadId: parseThreadId(body.threadId), + assigneeTarget: parseAssigneeTarget(body.assigneeTarget), + workflowId: parseWorkflowId(body.workflowId ?? null), + developmentContext: parseDevelopmentContext(body.developmentContext ?? null), + dueDate: parseDueDate(body.dueDate ?? null), + recurrence: parseRecurrence(body.recurrence ?? null), + }; + if (input.recurrence && !input.dueDate) { + throw new ApiError(400, "INVALID_FIELD", "A recurring issue requires 'dueDate'"); + } + return input; +} + +function parseTaskPatch(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set([ + "version", + "title", + "description", + "status", + "priority", + "labels", + "threadId", + "assigneeTarget", + "workflowId", + "developmentContext", + "dueDate", + "recurrence", + ])); + const changes = {}; + if (body.title !== undefined) { + changes.title = stringField(body.title, "title", { required: true, maxLength: 240 }); + } + if (body.description !== undefined) { + changes.description = stringField(body.description, "description", { maxLength: 100_000 }); + } + if (body.status !== undefined) changes.status = parseStatus(body.status); + if (body.priority !== undefined) changes.priority = parsePriority(body.priority); + if (body.labels !== undefined) changes.labels = parseLabels(body.labels); + if (body.workflowId !== undefined) changes.workflowId = parseWorkflowId(body.workflowId); + if (body.developmentContext !== undefined) { + changes.developmentContext = parseDevelopmentContext(body.developmentContext); + } + if (body.dueDate !== undefined) changes.dueDate = parseDueDate(body.dueDate); + if (body.recurrence !== undefined) changes.recurrence = parseRecurrence(body.recurrence); + const assigneeTarget = parseAssigneeTarget(body.assigneeTarget); + if (Object.keys(changes).length === 0 && assigneeTarget === undefined) { + throw new ApiError(400, "INVALID_BODY", "PATCH requires at least one task field"); + } + return { + version: parseVersion(body.version), + changes, + threadId: parseThreadId(body.threadId), + assigneeTarget, + }; +} + +function parseMove(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["version", "status", "sortOrder", "threadId"])); + return { + version: parseVersion(body.version), + status: parseStatus(body.status), + sortOrder: body.sortOrder === undefined ? undefined : parseSortOrder(body.sortOrder), + threadId: parseThreadId(body.threadId), + }; +} + +function parseVersionMutation(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["version", "threadId"])); + return { + version: parseVersion(body.version), + threadId: parseThreadId(body.threadId), + }; +} + +function parseCommentCreate(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["body", "threadId"])); + return { + body: stringField(body.body ?? "", "body", { maxLength: 100_000 }), + threadId: parseThreadId(body.threadId), + }; +} + +function parseCommentPatch(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["version", "body", "threadId"])); + if (body.body === undefined) { + throw new ApiError(400, "INVALID_FIELD", "'body' is required"); + } + return { + version: parseVersion(body.version), + body: stringField(body.body, "body", { maxLength: 100_000 }), + threadId: parseThreadId(body.threadId), + }; +} + +function parseTaskFilters(searchParams) { + const allowed = new Set(["projectId", "status", "archived"]); + for (const key of searchParams.keys()) { + if (!allowed.has(key)) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", `Unknown query parameter: ${key}`); + } + if (searchParams.getAll(key).length > 1) { + throw new ApiError(400, "INVALID_QUERY_PARAMETER", `'${key}' cannot be repeated`); + } + } + const projectId = searchParams.get("projectId"); + const status = searchParams.get("status"); + const archived = searchParams.get("archived") ?? "false"; + if (projectId !== null) validateProjectId(projectId); + if (status !== null) parseStatus(status); + if (!["false", "true", "all"].includes(archived)) { + throw new ApiError( + 400, + "INVALID_QUERY_PARAMETER", + "'archived' must be false, true, or all", + ); + } + return { projectId, status, archived }; +} + +function sanitizeWorkflowNodeData(value) { + if (Array.isArray(value)) return value.map(sanitizeWorkflowNodeData); + if (value === null || typeof value !== "object") return value; + + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== "gitWorktreePath") + .map(([key, child]) => [key, sanitizeWorkflowNodeData(child)]), + ); +} + +function parseWorkflowWorkspace(value) { + assertPlainObject(value); + assertAllowedKeys(value, new Set(["version", "tabs", "activeWorkflowId", "snapshots"])); + if (value.version !== 1) { + throw new ApiError(400, "INVALID_FIELD", "'workspace.version' must be 1"); + } + if (!Array.isArray(value.tabs) || value.tabs.length === 0 || value.tabs.length > 100) { + throw new ApiError( + 400, + "INVALID_FIELD", + "'workspace.tabs' must contain 1 to 100 workflows", + ); + } + const tabs = value.tabs.map((tab, index) => { + assertPlainObject(tab); + assertAllowedKeys(tab, new Set(["id", "name"])); + return { + id: stringField(tab.id, `workspace.tabs[${index}].id`, { + required: true, + maxLength: 128, + }), + name: stringField(tab.name, `workspace.tabs[${index}].name`, { + required: true, + maxLength: 120, + }), + }; + }); + if (new Set(tabs.map((tab) => tab.id)).size !== tabs.length) { + throw new ApiError(400, "INVALID_FIELD", "'workspace.tabs' ids must be unique"); + } + const activeWorkflowId = stringField( + value.activeWorkflowId, + "workspace.activeWorkflowId", + { required: true, maxLength: 128 }, + ); + if (!tabs.some((tab) => tab.id === activeWorkflowId)) { + throw new ApiError( + 400, + "INVALID_FIELD", + "'workspace.activeWorkflowId' must reference a workflow tab", + ); + } + assertPlainObject(value.snapshots); + const snapshots = {}; + for (const tab of tabs) { + const snapshot = value.snapshots[tab.id]; + assertPlainObject(snapshot); + assertAllowedKeys(snapshot, new Set(["nodes", "edges", "flow", "selectedNodeId"])); + if (!Array.isArray(snapshot.nodes) || snapshot.nodes.length > 10_000) { + throw new ApiError( + 400, + "INVALID_FIELD", + `'workspace.snapshots.${tab.id}.nodes' must be an array`, + ); + } + if ( + snapshot.flow === undefined + && (!Array.isArray(snapshot.edges) || snapshot.edges.length > 20_000) + ) { + throw new ApiError( + 400, + "INVALID_FIELD", + `'workspace.snapshots.${tab.id}.edges' must be an array`, + ); + } + if (snapshot.flow !== undefined && snapshot.edges !== undefined) { + throw new ApiError( + 400, + "INVALID_FIELD", + `'workspace.snapshots.${tab.id}' cannot contain both 'flow' and 'edges'`, + ); + } + const selectedNodeId = stringField( + snapshot.selectedNodeId ?? null, + `workspace.snapshots.${tab.id}.selectedNodeId`, + { nullable: true, maxLength: 256 }, + ); + const nodes = snapshot.nodes.map((node) => { + if ( + node === null + || Array.isArray(node) + || typeof node !== "object" + || node.data === null + || Array.isArray(node.data) + || typeof node.data !== "object" + ) { + return node; + } + return { ...node, data: sanitizeWorkflowNodeData(node.data) }; + }); + try { + snapshots[tab.id] = normalizeWorkflowSnapshot({ + nodes, + edges: snapshot.edges, + flow: snapshot.flow, + selectedNodeId, + }); + } catch (error) { + throw new ApiError( + 400, + "INVALID_FIELD", + `'workspace.snapshots.${tab.id}' is not a valid workflow: ${error.message}`, + ); + } + } + return { version: 1, tabs, activeWorkflowId, snapshots }; +} + +async function listProjects(env) { + const rows = await all(env.DB.prepare(` + SELECT + projects.id, + projects.name, + projects.workspace_path, + projects.archived_at, + projects.created_at, + projects.updated_at, + COUNT(tasks.id) AS issue_count + FROM projects + LEFT JOIN tasks + ON tasks.project_id = projects.id + AND tasks.archived_at IS NULL + WHERE projects.archived_at IS NULL + GROUP BY + projects.id, + projects.name, + projects.workspace_path, + projects.archived_at, + projects.created_at, + projects.updated_at + ORDER BY projects.created_at, projects.id + `)); + return rows.map(projectFromRow); +} + +async function getProject(env, id) { + const row = await env.DB.prepare(` + SELECT + projects.id, + projects.name, + projects.workspace_path, + projects.archived_at, + projects.created_at, + projects.updated_at, + COUNT(tasks.id) AS issue_count + FROM projects + LEFT JOIN tasks + ON tasks.project_id = projects.id + AND tasks.archived_at IS NULL + WHERE projects.id = ? AND projects.archived_at IS NULL + GROUP BY + projects.id, + projects.name, + projects.workspace_path, + projects.archived_at, + projects.created_at, + projects.updated_at + `).bind(id).first(); + return row ? projectFromRow(row) : null; +} + +async function createProject(env, input) { + const timestamp = now(); + try { + await env.DB.prepare(` + INSERT INTO projects ( + id, name, workspace_path, next_task_number, created_at, updated_at + ) VALUES (?, ?, NULL, 1, ?, ?) + `).bind(input.id, input.name, timestamp, timestamp).run(); + } catch (error) { + if (String(error.message).includes("UNIQUE constraint failed")) { + throw new ApiError(409, "PROJECT_EXISTS", `Project '${input.id}' already exists`); + } + throw error; + } + return getProject(env, input.id); +} + +async function archiveProject(env, id) { + if (id === DEFAULT_PROJECT_ID) { + throw new ApiError(409, "DEFAULT_PROJECT_PROTECTED", "The default project cannot be archived or deleted"); + } + const project = await getProject(env, id); + if (!project) throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${id}' does not exist`); + + const timestamp = now(); + const result = await env.DB.prepare(` + UPDATE projects + SET archived_at = ?, updated_at = ? + WHERE id = ? AND archived_at IS NULL + `).bind(timestamp, timestamp, id).run(); + if (!changed(result)) { + throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${id}' does not exist`); + } + return { ...project, archivedAt: timestamp, updatedAt: timestamp }; +} + +async function deleteProject(env, id) { + if (id === DEFAULT_PROJECT_ID) { + throw new ApiError(409, "DEFAULT_PROJECT_PROTECTED", "The default project cannot be archived or deleted"); + } + const project = await getProject(env, id); + if (!project) throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${id}' does not exist`); + + const attachments = await all(env.DB.prepare(` + SELECT attachments.* + FROM attachments + JOIN tasks ON tasks.id = attachments.task_id + WHERE tasks.project_id = ? + `).bind(id)); + const results = await env.DB.batch([ + env.DB.prepare("DELETE FROM tasks WHERE project_id = ?").bind(id), + env.DB.prepare("DELETE FROM projects WHERE id = ?").bind(id), + ]); + if (!changed(results[1])) { + throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${id}' does not exist`); + } + await Promise.all(attachments.map((attachment) => env.ATTACHMENTS.delete(attachment.id))); + return project; +} + +async function listTasks(env, filters) { + const where = []; + const values = []; + if (filters.projectId) { + where.push("project_id = ?"); + values.push(filters.projectId); + } + if (filters.status) { + where.push("status = ?"); + values.push(filters.status); + } + if (filters.archived === "false") { + where.push("archived_at IS NULL"); + } else if (filters.archived === "true") { + where.push("archived_at IS NOT NULL"); + } + const rows = await all( + env.DB.prepare(` + SELECT * FROM tasks + ${where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""} + ORDER BY + CASE status + WHEN 'backlog' THEN 1 + WHEN 'todo' THEN 2 + WHEN 'in_progress' THEN 3 + WHEN 'in_review' THEN 4 + WHEN 'blocked' THEN 5 + WHEN 'done' THEN 6 + WHEN 'canceled' THEN 7 + END, + sort_order, + created_at, + id + `).bind(...values), + ); + return Promise.all(rows.map((row) => hydrateTask(env, row))); +} + +async function createTask(env, input, actor) { + await requireProject(env, input.projectId); + let sortOrder = input.sortOrder; + if (sortOrder === undefined) { + const row = await env.DB.prepare(` + SELECT COALESCE(MAX(sort_order), 0) AS maximum + FROM tasks + WHERE project_id = ? AND status = ? AND archived_at IS NULL + `).bind(input.projectId, input.status).first(); + sortOrder = row.maximum + 1000; + } + const id = uuid(); + const timestamp = now(); + const assignee = resolveAssignee(input.assigneeTarget, actor); + const results = await env.DB.batch([ + env.DB.prepare(` + INSERT INTO tasks ( + id, identifier, project_id, title, description, status, priority, labels, + sort_order, thread_id, creator_type, creator_id, creator_name, creator_avatar_url, + assignee_type, assignee_id, assignee_name, assignee_avatar_url, + workflow_id, development_context_type, development_branch, + due_date, recurrence_interval, recurrence_unit, + archived_at, version, created_at, updated_at + ) + SELECT + ?, + ? || '-' || CAST(next_task_number AS TEXT), + projects.id, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + NULL, 1, ?, ? + FROM projects + WHERE projects.id = ? AND projects.archived_at IS NULL + `).bind( + id, + projectPrefix(input.projectId), + input.title, + input.description, + input.status, + input.priority, + JSON.stringify(input.labels), + sortOrder, + input.threadId ?? null, + actor.type, + actor.id, + actor.name, + actor.avatarUrl, + assignee.type, + assignee.id, + assignee.name, + assignee.avatarUrl, + input.workflowId, + input.developmentContext?.type ?? null, + input.developmentContext?.branch ?? null, + input.dueDate, + input.recurrence?.interval ?? null, + input.recurrence?.unit ?? null, + timestamp, + timestamp, + input.projectId, + ), + env.DB.prepare(` + UPDATE projects + SET next_task_number = next_task_number + 1, updated_at = ? + WHERE id = ? AND archived_at IS NULL + `).bind(timestamp, input.projectId), + ]); + if (!changed(results[0]) || !changed(results[1])) { + throw new ApiError( + 404, + "PROJECT_NOT_FOUND", + `Project '${input.projectId}' does not exist`, + ); + } + return getTask(env, id); +} + +async function updateTask(env, id, input, actor) { + const current = await requireTaskRow(env, id); + assertTaskVersion(current, input.version); + const currentTask = taskFromRow(current); + const dueDate = Object.hasOwn(input.changes, "dueDate") + ? input.changes.dueDate + : currentTask.dueDate; + const recurrence = Object.hasOwn(input.changes, "recurrence") + ? input.changes.recurrence + : currentTask.recurrence; + if (recurrence && !dueDate) { + throw new ApiError(400, "INVALID_FIELD", "A recurring issue requires a due date"); + } + + const assignments = []; + const values = []; + const columns = { + title: "title", + description: "description", + status: "status", + priority: "priority", + labels: "labels", + workflowId: "workflow_id", + dueDate: "due_date", + }; + for (const [key, value] of Object.entries(input.changes)) { + if (key === "developmentContext") { + assignments.push("development_context_type = ?", "development_branch = ?"); + values.push(value?.type ?? null, value?.branch ?? null); + } else if (key === "recurrence") { + assignments.push("recurrence_interval = ?", "recurrence_unit = ?"); + values.push(value?.interval ?? null, value?.unit ?? null); + } else { + assignments.push(`${columns[key]} = ?`); + values.push(key === "labels" ? JSON.stringify(value) : value); + } + } + if (input.assigneeTarget !== undefined) { + const assignee = resolveAssignee(input.assigneeTarget, actor); + assignments.push( + "assignee_type = ?", + "assignee_id = ?", + "assignee_name = ?", + "assignee_avatar_url = ?", + ); + values.push(assignee.type, assignee.id, assignee.name, assignee.avatarUrl); + } + if (input.threadId !== undefined) { + assignments.push("thread_id = ?"); + values.push(input.threadId); + } + assignments.push("version = version + 1", "updated_at = ?"); + values.push(now(), current.id, input.version); + const result = await env.DB.prepare(` + UPDATE tasks + SET ${assignments.join(", ")} + WHERE id = ? AND version = ? + `).bind(...values).run(); + if (!changed(result)) { + const latest = await requireTaskRow(env, current.id); + throw new ApiError( + 409, + "VERSION_CONFLICT", + "Task was changed by another client", + { expectedVersion: input.version, actualVersion: latest.version }, + ); + } + return getTask(env, current.id); +} + +async function moveTask(env, id, input) { + const current = await requireTaskRow(env, id); + assertTaskVersion(current, input.version); + if (current.archived_at !== null) { + throw new ApiError(409, "TASK_ARCHIVED", "Archived tasks cannot be moved"); + } + let sortOrder = input.sortOrder; + if (sortOrder === undefined) { + const row = await env.DB.prepare(` + SELECT COALESCE(MAX(sort_order), 0) AS maximum + FROM tasks + WHERE project_id = ? AND status = ? AND archived_at IS NULL AND id != ? + `).bind(current.project_id, input.status, current.id).first(); + sortOrder = row.maximum + 1000; + } + const result = await env.DB.prepare(` + UPDATE tasks + SET + status = ?, + sort_order = ?, + thread_id = COALESCE(?, thread_id), + version = version + 1, + updated_at = ? + WHERE id = ? AND version = ? + `).bind( + input.status, + sortOrder, + input.threadId ?? null, + now(), + current.id, + input.version, + ).run(); + if (!changed(result)) { + const latest = await requireTaskRow(env, current.id); + throw new ApiError( + 409, + "VERSION_CONFLICT", + "Task was changed by another client", + { expectedVersion: input.version, actualVersion: latest.version }, + ); + } + return getTask(env, current.id); +} + +async function archiveTask(env, id, input) { + const current = await requireTaskRow(env, id); + assertTaskVersion(current, input.version); + const timestamp = now(); + const result = await env.DB.prepare(` + UPDATE tasks + SET + archived_at = ?, + thread_id = COALESCE(?, thread_id), + version = version + 1, + updated_at = ? + WHERE id = ? AND version = ? + `).bind(timestamp, input.threadId ?? null, timestamp, current.id, input.version).run(); + if (!changed(result)) { + const latest = await requireTaskRow(env, current.id); + throw new ApiError( + 409, + "VERSION_CONFLICT", + "Task was changed by another client", + { expectedVersion: input.version, actualVersion: latest.version }, + ); + } + return getTask(env, current.id); +} + +async function restoreTask(env, id, input) { + const current = await requireTaskRow(env, id); + assertTaskVersion(current, input.version); + if (current.archived_at === null) { + throw new ApiError(409, "TASK_NOT_ARCHIVED", "Only archived tasks can be restored"); + } + const result = await env.DB.prepare(` + UPDATE tasks + SET + archived_at = NULL, + thread_id = COALESCE(?, thread_id), + version = version + 1, + updated_at = ? + WHERE id = ? AND version = ? + `).bind(input.threadId ?? null, now(), current.id, input.version).run(); + if (!changed(result)) { + const latest = await requireTaskRow(env, current.id); + throw new ApiError( + 409, + "VERSION_CONFLICT", + "Task was changed by another client", + { expectedVersion: input.version, actualVersion: latest.version }, + ); + } + return getTask(env, current.id); +} + +function relationEndpoints(type, taskId, relatedTaskId) { + if (type === "parent") { + return { + relationType: "parent", + sourceTaskId: relatedTaskId, + targetTaskId: taskId, + }; + } + if (type === "blocks") { + return { + relationType: "blocks", + sourceTaskId: taskId, + targetTaskId: relatedTaskId, + }; + } + if (type === "blocked_by") { + return { + relationType: "blocks", + sourceTaskId: relatedTaskId, + targetTaskId: taskId, + }; + } + if (type === "related") { + const [sourceTaskId, targetTaskId] = [taskId, relatedTaskId].sort(); + return { relationType: "related", sourceTaskId, targetTaskId }; + } + throw new ApiError( + 400, + "INVALID_FIELD", + "'relation type' must be parent, blocks, blocked_by, or related", + ); +} + +async function assertRelationTasks(env, taskId, relatedTaskId, expectedVersion) { + const task = await requireTaskRow(env, taskId); + const relatedTask = await requireTaskRow(env, relatedTaskId); + assertTaskVersion(task, expectedVersion); + if (task.id === relatedTask.id) { + throw new ApiError(400, "SELF_RELATION", "An issue cannot be related to itself"); + } + if (task.project_id !== relatedTask.project_id) { + throw new ApiError( + 400, + "CROSS_PROJECT_RELATION", + "Issue relations must stay within one project", + ); + } + return { task, relatedTask }; +} + +async function addRelation(env, taskId, type, relatedTaskId, input) { + const { task, relatedTask } = await assertRelationTasks( + env, + taskId, + relatedTaskId, + input.version, + ); + const endpoints = relationEndpoints(type, task.id, relatedTask.id); + const statements = []; + if (endpoints.relationType === "parent") { + const cycle = await env.DB.prepare(` + WITH RECURSIVE ancestors(id) AS ( + SELECT source_task_id + FROM task_relations + WHERE relation_type = 'parent' AND target_task_id = ? + UNION + SELECT task_relations.source_task_id + FROM task_relations + JOIN ancestors ON task_relations.target_task_id = ancestors.id + WHERE task_relations.relation_type = 'parent' + ) + SELECT 1 AS found FROM ancestors WHERE id = ? + `).bind(relatedTask.id, task.id).first(); + if (cycle) { + throw new ApiError(409, "RELATION_CYCLE", "This parent would create a cycle"); + } + const existing = await env.DB.prepare(` + SELECT source_task_id + FROM task_relations + WHERE relation_type = 'parent' AND target_task_id = ? + `).bind(task.id).first(); + if (existing?.source_task_id === relatedTask.id) { + throw new ApiError(409, "RELATION_EXISTS", "This parent relation already exists"); + } + if (existing) { + statements.push( + env.DB.prepare(` + DELETE FROM task_relations + WHERE relation_type = 'parent' + AND target_task_id = ? + AND EXISTS ( + SELECT 1 FROM tasks WHERE id = ? AND version = ? + ) + `).bind(task.id, task.id, input.version), + ); + } + } else { + const existing = await env.DB.prepare(` + SELECT 1 AS found + FROM task_relations + WHERE relation_type = ? AND source_task_id = ? AND target_task_id = ? + `).bind( + endpoints.relationType, + endpoints.sourceTaskId, + endpoints.targetTaskId, + ).first(); + if (existing) { + throw new ApiError(409, "RELATION_EXISTS", "This issue relation already exists"); + } + } + statements.push( + env.DB.prepare(` + INSERT INTO task_relations ( + relation_type, source_task_id, target_task_id, created_at + ) + SELECT ?, ?, ?, ? + WHERE EXISTS ( + SELECT 1 FROM tasks WHERE id = ? AND version = ? + ) + `).bind( + endpoints.relationType, + endpoints.sourceTaskId, + endpoints.targetTaskId, + now(), + task.id, + input.version, + ), + env.DB.prepare(` + UPDATE tasks + SET + thread_id = COALESCE(?, thread_id), + version = version + 1, + updated_at = ? + WHERE id = ? AND version = ? + `).bind(input.threadId ?? null, now(), task.id, input.version), + ); + let results; + try { + results = await env.DB.batch(statements); + } catch (error) { + const message = String(error.message); + if (message.includes("RELATION_CYCLE")) { + throw new ApiError(409, "RELATION_CYCLE", "This parent would create a cycle"); + } + if ( + message.includes("UNIQUE constraint failed") + && message.includes("task_relations") + ) { + throw new ApiError(409, "RELATION_EXISTS", "This issue relation already exists"); + } + throw error; + } + if (!changed(results.at(-1))) { + const latest = await requireTaskRow(env, task.id); + throw new ApiError( + 409, + "VERSION_CONFLICT", + "Task was changed by another client", + { expectedVersion: input.version, actualVersion: latest.version }, + ); + } + return { + task: await getTask(env, task.id), + relatedTask: await getTask(env, relatedTask.id), + }; +} + +async function removeRelation(env, taskId, type, relatedTaskId, input) { + const { task, relatedTask } = await assertRelationTasks( + env, + taskId, + relatedTaskId, + input.version, + ); + const endpoints = relationEndpoints(type, task.id, relatedTask.id); + const exists = await env.DB.prepare(` + SELECT 1 AS found + FROM task_relations + WHERE relation_type = ? AND source_task_id = ? AND target_task_id = ? + `).bind( + endpoints.relationType, + endpoints.sourceTaskId, + endpoints.targetTaskId, + ).first(); + if (!exists) { + throw new ApiError(404, "RELATION_NOT_FOUND", "This issue relation does not exist"); + } + const results = await env.DB.batch([ + env.DB.prepare(` + DELETE FROM task_relations + WHERE relation_type = ? + AND source_task_id = ? + AND target_task_id = ? + AND EXISTS ( + SELECT 1 FROM tasks WHERE id = ? AND version = ? + ) + `).bind( + endpoints.relationType, + endpoints.sourceTaskId, + endpoints.targetTaskId, + task.id, + input.version, + ), + env.DB.prepare(` + UPDATE tasks + SET + thread_id = COALESCE(?, thread_id), + version = version + 1, + updated_at = ? + WHERE id = ? AND version = ? + `).bind(input.threadId ?? null, now(), task.id, input.version), + ]); + if (!changed(results.at(-1))) { + const latest = await requireTaskRow(env, task.id); + throw new ApiError( + 409, + "VERSION_CONFLICT", + "Task was changed by another client", + { expectedVersion: input.version, actualVersion: latest.version }, + ); + } + return { + task: await getTask(env, task.id), + relatedTask: await getTask(env, relatedTask.id), + }; +} + +async function getWorkflow(env, projectId) { + await requireProject(env, projectId); + const row = await env.DB.prepare(` + SELECT project_id, workspace, version, updated_at + FROM workflow_workspaces + WHERE project_id = ? + `).bind(projectId).first(); + return row + ? { + projectId: row.project_id, + workspace: JSON.parse(row.workspace), + version: row.version, + updatedAt: row.updated_at, + } + : { projectId, workspace: null, version: 0, updatedAt: null }; +} + +async function saveWorkflow(env, projectId, expectedVersion, workspace) { + await requireProject(env, projectId); + const current = await env.DB.prepare(` + SELECT version FROM workflow_workspaces WHERE project_id = ? + `).bind(projectId).first(); + const actualVersion = current?.version ?? 0; + if (actualVersion !== expectedVersion) { + throw new ApiError( + 409, + "VERSION_CONFLICT", + "Workflow was changed by another client", + { expectedVersion, actualVersion }, + ); + } + const timestamp = now(); + if (current) { + const result = await env.DB.prepare(` + UPDATE workflow_workspaces + SET workspace = ?, version = version + 1, updated_at = ? + WHERE project_id = ? AND version = ? + `).bind( + JSON.stringify(workspace), + timestamp, + projectId, + expectedVersion, + ).run(); + if (!changed(result)) { + const latest = await env.DB.prepare(` + SELECT version FROM workflow_workspaces WHERE project_id = ? + `).bind(projectId).first(); + throw new ApiError( + 409, + "VERSION_CONFLICT", + "Workflow was changed by another client", + { expectedVersion, actualVersion: latest?.version ?? 0 }, + ); + } + } else { + try { + await env.DB.prepare(` + INSERT INTO workflow_workspaces (project_id, workspace, version, updated_at) + VALUES (?, ?, 1, ?) + `).bind(projectId, JSON.stringify(workspace), timestamp).run(); + } catch (error) { + if (String(error.message).includes("UNIQUE constraint failed")) { + const latest = await env.DB.prepare(` + SELECT version FROM workflow_workspaces WHERE project_id = ? + `).bind(projectId).first(); + throw new ApiError( + 409, + "VERSION_CONFLICT", + "Workflow was changed by another client", + { expectedVersion, actualVersion: latest.version }, + ); + } + throw error; + } + } + return getWorkflow(env, projectId); +} + +async function listComments(env, taskId) { + const task = await requireTaskRow(env, taskId); + const rows = await all(env.DB.prepare(` + SELECT * FROM comments + WHERE task_id = ? + ORDER BY created_at, id + `).bind(task.id)); + return Promise.all(rows.map((row) => hydrateComment(env, row))); +} + +async function createComment(env, taskId, input, actor) { + const task = await requireTaskRow(env, taskId); + const id = uuid(); + const timestamp = now(); + await env.DB.prepare(` + INSERT INTO comments ( + id, task_id, body, thread_id, author_type, author_id, author_name, + author_avatar_url, version, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) + `).bind( + id, + task.id, + input.body, + input.threadId ?? null, + actor.type, + actor.id, + actor.name, + actor.avatarUrl, + timestamp, + timestamp, + ).run(); + const row = await env.DB.prepare("SELECT * FROM comments WHERE id = ?").bind(id).first(); + return hydrateComment(env, row); +} + +async function requireCommentRow(env, id) { + const row = await env.DB.prepare("SELECT * FROM comments WHERE id = ?").bind(id).first(); + if (!row) { + throw new ApiError(404, "COMMENT_NOT_FOUND", `Comment '${id}' does not exist`); + } + return row; +} + +function assertCommentVersion(row, expectedVersion) { + if (row.version !== expectedVersion) { + throw new ApiError( + 409, + "VERSION_CONFLICT", + "Comment was changed by another client", + { expectedVersion, actualVersion: row.version }, + ); + } +} + +async function updateComment(env, id, input) { + const current = await requireCommentRow(env, id); + assertCommentVersion(current, input.version); + const result = await env.DB.prepare(` + UPDATE comments + SET + body = ?, + thread_id = COALESCE(?, thread_id), + version = version + 1, + updated_at = ? + WHERE id = ? AND version = ? + `).bind( + input.body, + input.threadId ?? null, + now(), + current.id, + input.version, + ).run(); + if (!changed(result)) { + const latest = await requireCommentRow(env, current.id); + throw new ApiError( + 409, + "VERSION_CONFLICT", + "Comment was changed by another client", + { expectedVersion: input.version, actualVersion: latest.version }, + ); + } + const row = await requireCommentRow(env, current.id); + return hydrateComment(env, row); +} + +async function deleteComment(env, id, expectedVersion) { + const current = await requireCommentRow(env, id); + assertCommentVersion(current, expectedVersion); + const attachments = await attachmentsForComment(env, current.id); + const result = await env.DB.prepare(` + DELETE FROM comments WHERE id = ? AND version = ? + `).bind(current.id, expectedVersion).run(); + if (!changed(result)) { + const latest = await requireCommentRow(env, current.id); + throw new ApiError( + 409, + "VERSION_CONFLICT", + "Comment was changed by another client", + { expectedVersion, actualVersion: latest.version }, + ); + } + await Promise.all(attachments.map((attachment) => env.ATTACHMENTS.delete(attachment.id))); +} + +async function listTaskAttachments(env, taskId) { + const task = await requireTaskRow(env, taskId); + return ( + await all(env.DB.prepare(` + SELECT * FROM attachments + WHERE task_id = ? AND comment_id IS NULL + ORDER BY created_at, id + `).bind(task.id)) + ).map(attachmentFromRow); +} + +async function listCommentAttachments(env, commentId) { + await requireCommentRow(env, commentId); + return attachmentsForComment(env, commentId); +} + +async function uploadAttachment(env, ownerType, ownerId, request) { + let taskId; + let commentId = null; + if (ownerType === "task") { + taskId = (await requireTaskRow(env, ownerId)).id; + } else { + const comment = await requireCommentRow(env, ownerId); + taskId = comment.task_id; + commentId = comment.id; + } + const metadata = parseAttachmentHeaders(request); + const body = await readAttachment(request); + const id = uuid(); + await env.ATTACHMENTS.put(id, body, { + httpMetadata: { contentType: metadata.contentType }, + }); + try { + await env.DB.prepare(` + INSERT INTO attachments ( + id, task_id, comment_id, filename, content_type, size, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `).bind( + id, + taskId, + commentId, + metadata.filename, + metadata.contentType, + body.byteLength, + now(), + ).run(); + } catch (error) { + await env.ATTACHMENTS.delete(id); + throw error; + } + const row = await env.DB.prepare("SELECT * FROM attachments WHERE id = ?").bind(id).first(); + return attachmentFromRow(row); +} + +async function requireAttachment(env, id) { + const row = await env.DB.prepare("SELECT * FROM attachments WHERE id = ?").bind(id).first(); + if (!row) { + throw new ApiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${id}' does not exist`); + } + return attachmentFromRow(row); +} + +async function deleteAttachment(env, id) { + const attachment = await requireAttachment(env, id); + await env.DB.prepare("DELETE FROM attachments WHERE id = ?").bind(attachment.id).run(); + await env.ATTACHMENTS.delete(attachment.id); + return attachment; +} + +function requireNoQuery(url, routeName) { + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError( + 400, + "UNKNOWN_QUERY_PARAMETER", + `${routeName} does not accept query parameters`, + ); + } +} + +function decodePathPart(value, label) { + let decoded; + try { + decoded = decodeURIComponent(value); + } catch { + throw new ApiError(400, "INVALID_PATH", `${label} contains invalid encoding`); + } + if (decoded.length === 0 || decoded.length > 128) { + throw new ApiError(400, "INVALID_PATH", `${label} is invalid`); + } + return decoded; +} + +async function attachmentContent(env, id, request) { + const attachment = await requireAttachment(env, id); + const object = await env.ATTACHMENTS.get(attachment.id); + if (!object) { + throw new ApiError( + 404, + "ATTACHMENT_NOT_FOUND", + `Attachment '${id}' does not exist`, + ); + } + const encodedFilename = encodeURIComponent(attachment.filename).replace( + /['()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ); + const canOpenInline = INLINE_ATTACHMENT_TYPES.has(attachment.contentType); + return new Response(request.method === "HEAD" ? null : object.body, { + status: 200, + headers: { + "cache-control": "private, no-store", + "content-disposition": `${ + canOpenInline ? "inline" : "attachment" + }; filename*=UTF-8''${encodedFilename}`, + "content-length": String(attachment.size), + "content-security-policy": "sandbox; default-src 'none'", + "content-type": canOpenInline + ? attachment.contentType + : "application/octet-stream", + }, + }); +} + +async function routeApi(request, env, actor, url) { + const { pathname } = url; + + if (pathname === "/api/meta") { + if (request.method !== "GET") methodNotAllowed(["GET"]); + requireNoQuery(url, "GET /api/meta"); + return json(200, { + mode: "cloud", + manageTaskboardSkillPath: null, + realtime: { transport: "poll", intervalMs: 2000 }, + localCapabilities: { available: false }, + }); + } + + if (pathname === "/api/revisions") { + if (request.method !== "GET") methodNotAllowed(["GET"]); + const unknown = [...url.searchParams.keys()].filter((key) => key !== "since"); + if (unknown.length > 0) { + throw new ApiError( + 400, + "UNKNOWN_QUERY_PARAMETER", + `Unknown query parameter: ${unknown[0]}`, + ); + } + if (url.searchParams.getAll("since").length !== 1) { + throw new ApiError( + 400, + "INVALID_QUERY_PARAMETER", + "'since' must be provided once", + ); + } + const rawSince = url.searchParams.get("since"); + if (!/^\d+$/.test(rawSince ?? "")) { + throw new ApiError( + 400, + "INVALID_QUERY_PARAMETER", + "'since' must be a non-negative integer", + ); + } + const since = Number(rawSince); + if (!Number.isSafeInteger(since)) { + throw new ApiError( + 400, + "INVALID_QUERY_PARAMETER", + "'since' must be a non-negative integer", + ); + } + const revision = await env.DB.prepare(` + SELECT revision FROM global_revision WHERE singleton = 1 + `).first("revision"); + return json(200, { changed: revision > since, revision }); + } + + if ( + pathname === "/api/device-workspaces" + || pathname === "/api/workflow-capabilities" + || /^\/api\/projects\/[^/]+\/development-contexts$/.test(pathname) + ) { + if (request.method !== "GET") methodNotAllowed(["GET"]); + throw new ApiError( + 409, + "LOCAL_COMPANION_REQUIRED", + "This capability requires the local Codex companion", + ); + } + + if (pathname === "/api/events") { + if (request.method !== "GET") methodNotAllowed(["GET"]); + throw new ApiError( + 409, + "POLLING_REQUIRED", + "Cloud collaboration uses revision polling", + ); + } + + if (pathname === "/api/projects") { + if (request.method === "GET") { + requireNoQuery(url, "GET /api/projects"); + return json(200, { projects: await listProjects(env) }); + } + if (request.method === "POST") { + return json(201, { + project: await createProject(env, parseProjectCreate(await readJson(request))), + }); + } + methodNotAllowed(["GET", "POST"]); + } + + const projectArchiveMatch = pathname.match(/^\/api\/projects\/([^/]+)\/archive$/); + if (projectArchiveMatch) { + requireNoQuery(url, "Project archive route"); + const projectId = validateProjectId( + decodePathPart(projectArchiveMatch[1], "Project id"), + ); + if (request.method === "POST") { + return json(200, { project: await archiveProject(env, projectId) }); + } + methodNotAllowed(["POST"]); + } + + const projectDeleteMatch = pathname.match(/^\/api\/projects\/([^/]+)$/); + if (projectDeleteMatch) { + requireNoQuery(url, "Project delete route"); + const projectId = validateProjectId( + decodePathPart(projectDeleteMatch[1], "Project id"), + ); + if (request.method === "DELETE") { + await deleteProject(env, projectId); + return empty(204); + } + methodNotAllowed(["DELETE"]); + } + + const workflowMatch = pathname.match( + /^\/api\/projects\/([^/]+)\/workflow-workspace$/, + ); + if (workflowMatch) { + requireNoQuery(url, "Workflow workspace routes"); + const projectId = validateProjectId( + decodePathPart(workflowMatch[1], "Project id"), + ); + if (request.method === "GET") { + return json(200, { workflow: await getWorkflow(env, projectId) }); + } + if (request.method === "PUT") { + const body = await readJson(request); + assertPlainObject(body); + assertAllowedKeys(body, new Set(["version", "workspace"])); + const version = parseVersion(body.version, { allowZero: true }); + const workspace = parseWorkflowWorkspace(body.workspace); + return json(200, { + workflow: await saveWorkflow(env, projectId, version, workspace), + }); + } + methodNotAllowed(["GET", "PUT"]); + } + + if (pathname === "/api/tasks") { + if (request.method === "GET") { + return json(200, { + tasks: await listTasks(env, parseTaskFilters(url.searchParams)), + }); + } + if (request.method === "POST") { + return json(201, { + task: await createTask(env, parseTaskCreate(await readJson(request)), actor), + }); + } + methodNotAllowed(["GET", "POST"]); + } + + const relationMatch = pathname.match( + /^\/api\/tasks\/([^/]+)\/relations\/([^/]+)\/([^/]+)$/, + ); + if (relationMatch) { + requireNoQuery(url, "Issue relation routes"); + const taskId = decodePathPart(relationMatch[1], "Task id"); + const type = decodePathPart(relationMatch[2], "Relation type"); + const relatedTaskId = decodePathPart(relationMatch[3], "Related task id"); + const input = parseVersionMutation(await readJson(request)); + if (request.method === "POST") { + return json(200, await addRelation(env, taskId, type, relatedTaskId, input)); + } + if (request.method === "DELETE") { + return json(200, await removeRelation(env, taskId, type, relatedTaskId, input)); + } + methodNotAllowed(["POST", "DELETE"]); + } + + const taskCommentsMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/comments$/); + if (taskCommentsMatch) { + requireNoQuery(url, "Comment routes"); + const taskId = decodePathPart(taskCommentsMatch[1], "Task id"); + if (request.method === "GET") { + return json(200, { comments: await listComments(env, taskId) }); + } + if (request.method === "POST") { + return json(201, { + comment: await createComment( + env, + taskId, + parseCommentCreate(await readJson(request)), + actor, + ), + }); + } + methodNotAllowed(["GET", "POST"]); + } + + const commentAttachmentsMatch = pathname.match( + /^\/api\/comments\/([^/]+)\/attachments$/, + ); + if (commentAttachmentsMatch) { + requireNoQuery(url, "Attachment routes"); + const commentId = decodePathPart(commentAttachmentsMatch[1], "Comment id"); + if (request.method === "GET") { + return json(200, { + attachments: await listCommentAttachments(env, commentId), + }); + } + if (request.method === "POST") { + return json(201, { + attachment: await uploadAttachment(env, "comment", commentId, request), + }); + } + methodNotAllowed(["GET", "POST"]); + } + + const commentMatch = pathname.match(/^\/api\/comments\/([^/]+)$/); + if (commentMatch) { + requireNoQuery(url, "Comment routes"); + const commentId = decodePathPart(commentMatch[1], "Comment id"); + if (request.method === "PATCH") { + return json(200, { + comment: await updateComment( + env, + commentId, + parseCommentPatch(await readJson(request)), + ), + }); + } + if (request.method === "DELETE") { + const { version } = parseVersionMutation(await readJson(request)); + await deleteComment(env, commentId, version); + return empty(204); + } + methodNotAllowed(["PATCH", "DELETE"]); + } + + const taskAttachmentsMatch = pathname.match( + /^\/api\/tasks\/([^/]+)\/attachments$/, + ); + if (taskAttachmentsMatch) { + requireNoQuery(url, "Attachment routes"); + const taskId = decodePathPart(taskAttachmentsMatch[1], "Task id"); + if (request.method === "GET") { + return json(200, { + attachments: await listTaskAttachments(env, taskId), + }); + } + if (request.method === "POST") { + return json(201, { + attachment: await uploadAttachment(env, "task", taskId, request), + }); + } + methodNotAllowed(["GET", "POST"]); + } + + const attachmentContentMatch = pathname.match( + /^\/api\/attachments\/([^/]+)\/content$/, + ); + if (attachmentContentMatch) { + requireNoQuery(url, "Attachment routes"); + if (!["GET", "HEAD"].includes(request.method)) methodNotAllowed(["GET", "HEAD"]); + return attachmentContent( + env, + decodePathPart(attachmentContentMatch[1], "Attachment id"), + request, + ); + } + + const attachmentMatch = pathname.match(/^\/api\/attachments\/([^/]+)$/); + if (attachmentMatch) { + requireNoQuery(url, "Attachment routes"); + if (request.method !== "DELETE") methodNotAllowed(["DELETE"]); + await deleteAttachment( + env, + decodePathPart(attachmentMatch[1], "Attachment id"), + ); + return empty(204); + } + + const taskMatch = pathname.match( + /^\/api\/tasks\/([^/]+)(?:\/(archive|restore|move))?$/, + ); + if (taskMatch) { + const taskId = decodePathPart(taskMatch[1], "Task id"); + const action = taskMatch[2]; + requireNoQuery(url, "Task routes"); + if (!action && request.method === "GET") { + const task = await getTask(env, taskId); + if (!task) { + throw new ApiError(404, "TASK_NOT_FOUND", `Task '${taskId}' does not exist`); + } + return json(200, { task }); + } + if (!action && request.method === "PATCH") { + return json(200, { + task: await updateTask( + env, + taskId, + parseTaskPatch(await readJson(request)), + actor, + ), + }); + } + if (action === "move" && request.method === "POST") { + return json(200, { + task: await moveTask(env, taskId, parseMove(await readJson(request))), + }); + } + if (action === "archive" && request.method === "POST") { + return json(200, { + task: await archiveTask( + env, + taskId, + parseVersionMutation(await readJson(request)), + ), + }); + } + if (action === "restore" && request.method === "POST") { + return json(200, { + task: await restoreTask( + env, + taskId, + parseVersionMutation(await readJson(request)), + ), + }); + } + methodNotAllowed(action ? ["POST"] : ["GET", "PATCH"]); + } + + throw new ApiError(404, "NOT_FOUND", "API route not found"); +} + +function withSecurityHeaders(response) { + const secured = new Response(response.body, response); + secured.headers.set("x-content-type-options", "nosniff"); + secured.headers.set("referrer-policy", "no-referrer"); + return secured; +} + +export default { + async fetch(request, env) { + const url = new URL(request.url); + try { + if (url.pathname === "/health") { + if (request.method !== "GET") methodNotAllowed(["GET"]); + return withSecurityHeaders(json(200, { status: "ok" })); + } + + const actor = await authenticate(request, env); + if (!actor) return withSecurityHeaders(unauthorized()); + + const response = url.pathname.startsWith("/api/") + ? await routeApi(request, env, actor, url) + : env.ASSETS + ? await env.ASSETS.fetch(request) + : json(404, { error: { code: "NOT_FOUND", message: "Resource not found" } }); + return withSecurityHeaders(response); + } catch (error) { + if (error instanceof ApiError) { + const payload = { + error: { code: error.code, message: error.message }, + }; + if (error.details !== undefined) payload.error.details = error.details; + const headers = error.status === 405 && error.details?.allowed + ? { allow: error.details.allowed.join(", ") } + : {}; + return withSecurityHeaders(json(error.status, payload, headers)); + } + console.error(error); + return withSecurityHeaders(json(500, { + error: { code: "INTERNAL_ERROR", message: "Internal server error" }, + })); + } + }, +}; diff --git a/apps/codex-taskboard/docs/cloud-collaboration.md b/apps/codex-taskboard/docs/cloud-collaboration.md new file mode 100644 index 000000000..853fdd23a --- /dev/null +++ b/apps/codex-taskboard/docs/cloud-collaboration.md @@ -0,0 +1,194 @@ +# Cloud collaboration + +Codex Taskboard can run as a small shared Cloudflare deployment for two trusted collaborators: + +- one Worker serves the built UI and the JSON API; +- D1 is the authoritative business database; +- a private R2 bucket stores attachments; +- UI, API, and attachment routes use HTTPS Basic Authentication; `/health` is public; +- open boards poll a global revision every two seconds and refresh after a change. + +The production resource names are: + +| Resource | Name | +| --- | --- | +| Worker | `codex-taskboard` | +| D1 database | `codex-taskboard-db` | +| R2 bucket | `codex-taskboard-attachments` | + +This is intentionally a shared-password trust model. The Basic username is only the actor name displayed in task and comment attribution, not a verified identity. Anyone who knows the shared password has full read and write access and can choose any actor name. Use it only with the other trusted collaborator. + +## What stays local + +The cloud stores project, issue, comment, relation, workflow, and attachment data. It does not store a device's absolute project or worktree paths. + +Each collaborator runs the local companion for Codex, Git/worktree scanning, installed Skill/MCP discovery, and project path mapping. The companion keeps the cloud URL, actor name, shared password, and device-specific project mappings in `.data/cloud-companion.json` with mode `0600`. + +When cloud mode is active, the cloud is the only business-data source. A failed cloud request fails visibly. The companion does not fall back to the local SQLite database and does not write to both databases. `taskctl cloud logout` returns that device to its separate local mode; it does not merge local and cloud data. + +## Owner: validate locally + +Install dependencies and build the frontend: + +```bash +npm ci +npm run build:web +``` + +Create an ignored `.dev.vars` file containing a local-only value for `TASKBOARD_SHARED_SECRET`, apply the D1 migration to Wrangler's local state, and start the Worker: + +```bash +npm run cloud:migrate:local +npm run dev:cloud +``` + +Open the printed loopback URL. The browser shows its native Basic Authentication prompt. Enter any local actor name as the username and the value from `.dev.vars` as the password. + +Local Wrangler state lives under `.wrangler/` and is not committed. + +## Owner: deploy + +Authenticate Wrangler first: + +```bash +npx wrangler login +npx wrangler whoami +``` + +Provision the production D1 database and private R2 bucket using the exact names above. + +```bash +npx wrangler d1 create codex-taskboard-db +npx wrangler r2 bucket create codex-taskboard-attachments +``` + +`wrangler.jsonc` contains one production configuration and identifies the D1 binding by its resource name and `database_id`. A D1 database ID is public metadata and does not grant access, so it can be committed. Wrangler local development creates persistent local equivalents under `.wrangler/`; those are local simulations, not additional Cloudflare environments. + +Apply the remote D1 migration and validate the deployment bundle: + +```bash +npm run cloud:migrate +npm run cloud:deploy:dry-run +``` + +Set the shared password through Wrangler's private interactive prompt after the database schema is ready. Do not put the value in `wrangler.jsonc`, a shell command, a log, or a committed file. Then deploy the production Worker: + +```bash +npx wrangler secret put TASKBOARD_SHARED_SECRET +npm run cloud:deploy +``` + +These commands create or update Cloudflare resources. This repository contains the production D1 database ID for the binding, but it does not contain the shared password or any API or OAuth token. Keep those credentials out of Git; cloning the repository does not grant access or mean the Worker has already been deployed. + +Give the other collaborator the deployed Worker HTTPS origin and shared password through a trusted channel. Never publish the password in the repository, an issue, or logs. + +Current Cloudflare references: + +- [Workers Static Assets binding](https://developers.cloudflare.com/workers/static-assets/binding/) +- [D1 migrations](https://developers.cloudflare.com/d1/reference/migrations/) +- [Create an R2 bucket](https://developers.cloudflare.com/r2/buckets/create-buckets/) +- [Workers secrets](https://developers.cloudflare.com/workers/configuration/secrets/) + +## Friend: connect an existing GitHub installation + +The owner follows this device setup too, using the owner's own actor name and checkout path. The friend does not need your local database or your filesystem paths. They update their existing clone and build the current UI: + +```bash +git pull --ff-only +npm ci +npm run build:web +``` + +Start the local companion: + +```bash +CODEX_TASKBOARD_HOST=127.0.0.1 npm start +``` + +In a second terminal, configure cloud mode. Use the deployed HTTPS Worker origin, choose the actor name that should appear on their actions, and enter the shared password only at the private `Shared key:` prompt: + +```bash +npm run taskctl -- cloud login \ + --url https://YOUR-WORKER-ORIGIN \ + --actor-name "FRIEND-DISPLAY-NAME" + +npm run taskctl -- cloud status +npm run taskctl -- project list +``` + +The shared password is not part of the command and is not echoed by the prompt. + +For every cloud project used with Codex, map its project ID to that friend's own absolute checkout path: + +```bash +npm run taskctl -- project map PROJECT_ID \ + --workspace-path /absolute/path/on/their/device +``` + +The owner runs the same mapping command with the owner's own path. Mappings are intentionally different on each device and are never synchronized to D1. + +Launch the injected Codex window: + +```bash +CODEX_TASKBOARD_HOST=127.0.0.1 npm run codex +``` + +`npm run codex` reuses or starts the loopback companion. Keep it running while using the embedded board. The companion supplies local Codex/Git/Skill/MCP capabilities and sends the shared password to the Worker only in the HTTPS Basic `Authorization` header. It does not write that password to D1 or R2, return it to the browser UI, or print it in logs. Device paths also stay off Cloudflare. + +Do not point `CODEX_TASKBOARD_URL` directly at the cloud origin for this workflow. `taskctl` talks to the loopback companion, which applies Basic Authentication and the device's local project mapping. If the companion uses a non-default loopback port, set `CODEX_TASKBOARD_COMPANION_URL` to that loopback origin. + +## Browser-only access + +Either collaborator can open the deployed HTTPS Worker URL directly. The browser's native Basic Authentication prompt asks for: + +- username: the actor display name for that browser; +- password: the shared password. + +The browser view supports the shared board and attachments. Device-only Codex, Git/worktree, Skill, and MCP capabilities still require the local companion. + +## Rotate or revoke the shared password + +The owner rotates the Worker secret using Wrangler's interactive prompt: + +```bash +npx wrangler secret put TASKBOARD_SHARED_SECRET +``` + +After rotation, both devices rerun `taskctl cloud login` and enter the new password. Browser-only users must authenticate again; closing the authenticated browser session or clearing site authentication may be necessary because browsers cache Basic credentials. + +Because both collaborators share one password, rotation affects both at once. There is no individual-user revocation in this two-person trust model. + +## Advanced: one-time import of existing local data + +The migration tool takes a consistent SQLite snapshot with `VACUUM INTO`, removes structured device-only paths, exports attachment hashes, and writes a private bundle. The default local paths are: + +```bash +npm run cloud:data -- export \ + --database .data/taskboard.sqlite \ + --attachments .data/attachments \ + --output cloud-migration-exports/initial +``` + +The output directory contains issue content and attachment bytes. It is mode-restricted and ignored by Git, but it must still be handled as private data. This export is optional when starting with an empty cloud board. + +Before importing, authenticate Wrangler, provision the named D1 and R2 resources, and run `npm run cloud:migrate` so the remote D1 schema exists. The target D1 must contain no projects, and none of the bundle's attachment keys may already exist in R2. Import refuses a non-empty target instead of merging or overwriting it. + +Run the one-time Wrangler adapter with an explicit remote-operation acknowledgement: + +```bash +TASKBOARD_MIGRATION_REMOTE=1 npm run cloud:data -- import \ + --bundle cloud-migration-exports/initial \ + --adapter ./scripts/wrangler-cloud-adapter.mjs + +TASKBOARD_MIGRATION_REMOTE=1 npm run cloud:data -- verify \ + --bundle cloud-migration-exports/initial \ + --adapter ./scripts/wrangler-cloud-adapter.mjs +``` + +`TASKBOARD_MIGRATION_REMOTE=1` is a deliberate safety gate for these two commands. The adapter uses the current Wrangler login and the production resource names from `wrangler.jsonc`; it does not add a migration HTTP endpoint or store Cloudflare credentials. The commands are not run automatically by deployment, so having the repository does not mean data has already been imported. + +The adapter has a local-persistence integration test that does not access remote Cloudflare resources: + +```bash +node --test test/cloud-migration.test.mjs +``` diff --git a/apps/codex-taskboard/inject/codex-taskboard.user.js b/apps/codex-taskboard/inject/codex-taskboard.user.js new file mode 100644 index 000000000..06d905458 --- /dev/null +++ b/apps/codex-taskboard/inject/codex-taskboard.user.js @@ -0,0 +1,1870 @@ +(() => { + "use strict"; + + const VERSION = "0.6.8"; + const SOURCE_HASH = window.__CODEX_TASKBOARD_SOURCE_HASH__; + const SENTINEL_KEY = "__codexTaskboardInjection__"; + const DEFAULT_TASKBOARD_URL = "http://127.0.0.1:47823/?host=codex"; + const ENTRY_ID = "codex-taskboard-entry"; + const PAGE_ID = "codex-taskboard-page"; + const FRAME_ID = "codex-taskboard-frame"; + const NATIVE_THREAD_PANEL_ID = "codex-taskboard-native-thread-panel"; + const NATIVE_THREAD_PANEL_BODY_ID = "codex-taskboard-native-thread-panel-body"; + const DRAG_REGION_ID = "codex-taskboard-drag-region"; + const NO_DRAG_LEFT_ID = "codex-taskboard-no-drag-left"; + const NO_DRAG_RIGHT_ID = "codex-taskboard-no-drag-right"; + const STATUS_ID = "codex-taskboard-status"; + const STYLE_ID = "codex-taskboard-inject-style"; + const OWNED_ATTRIBUTE = "data-codex-taskboard-owned"; + const HIDDEN_ATTRIBUTE = "data-codex-taskboard-native-hidden"; + const HOST_ATTRIBUTE = "data-codex-taskboard-page-host"; + const NATIVE_SELECTED_ATTRIBUTE = "data-codex-taskboard-native-selected"; + const NATIVE_THREAD_HOST_ATTRIBUTE = "data-codex-taskboard-native-thread-host"; + const HOST_BINDING_NAME = "__codexTaskboardHostV1"; + const HOST_HEARTBEAT_NAME = "__codexTaskboardHostHeartbeatV1"; + const REATTACH_DELAY_MS = 160; + const FRAME_READY_TIMEOUT_MS = 12_000; + const HOST_REQUEST_TIMEOUT_MS = 12_000; + const HOST_HEARTBEAT_MAX_AGE_MS = 8_000; + const MACOS_TITLEBAR_SAFE_LEFT = 80; + const FRAME_REFRESH_PARAM = "__codex_taskboard_refresh"; + const PLUGIN_LABELS = ["插件", "plugins"]; + const NATIVE_PAGE_LABELS = [ + "新建任务", + "新对话", + "new task", + "new chat", + "new conversation", + "拉取请求", + "pull requests", + "站点", + "sites", + "已安排", + "scheduled", + "插件", + "plugins", + ]; + const PROJECT_SECTION_LABELS = ["projects", "项目"]; + const TASK_SECTION_LABELS = ["tasks", "任务", "chats", "对话"]; + const CODEX_ICONS = { + chevronDown: { + viewBox: "0 0 16 16", + content: ``, + }, + close: { + viewBox: "0 0 16 16", + content: ``, + }, + expand: { + viewBox: "0 0 16 16", + content: ``, + }, + openExternal: { + viewBox: "0 0 16 16", + content: ``, + }, + panel: { + viewBox: "0 0 16 16", + content: ``, + }, + }; + + const previous = window[SENTINEL_KEY]; + if (previous?.sourceHash === SOURCE_HASH && typeof previous.refresh === "function") { + previous.refresh(); + return; + } + try { + previous?.destroy?.(); + } catch (_) {} + + let entry = null; + let page = null; + let frame = null; + let dragRegion = null; + let noDragLeft = null; + let noDragRight = null; + let status = null; + let frameOrigin = ""; + let frameReady = false; + let frameReadyWaiters = new Set(); + let hostRequests = new Map(); + let hostRequestSequence = 0; + let observer = null; + let reattachTimer = null; + let lastFocusedElement = null; + let hostContextSnapshot = null; + let mutedNativeSelections = new Map(); + let openGeneration = 0; + let pendingThreadCreation = null; + let lastNativeThreadId = ""; + let active = false; + let destroyed = false; + let nativeThreadPanel = null; + let nativeThreadPanelBody = null; + let nativeThreadPanelHost = null; + let nativeThreadPanelHostStyle = ""; + let nativeThreadPanelSize = "compact"; + let nativeThreadPanelThreadId = ""; + let nativeThreadPanelWorkspacePath = ""; + let nativeThreadPanelThreadTitle = ""; + let nativeThreadPanelCanAttach = false; + let nativeThreadPanelResizeObserver = null; + let suppressNativeCloseUntil = 0; + + function normalizedLabel(value) { + return String(value || "").replace(/\s+/g, " ").trim().toLowerCase(); + } + + function normalizeThreadId(value) { + return String(value || "").trim().replace(/^(?:local|cloud):/i, ""); + } + + function resolveTaskboardUrl() { + const configured = typeof window.__CODEX_TASKBOARD_URL__ === "string" + ? window.__CODEX_TASKBOARD_URL__.trim() + : ""; + try { + const url = new URL(configured || DEFAULT_TASKBOARD_URL); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Unsupported taskboard URL protocol"); + } + if (!url.searchParams.has("host")) url.searchParams.set("host", "codex"); + return url; + } catch (_) { + return new URL(DEFAULT_TASKBOARD_URL); + } + } + + function isLocalTaskboardOrigin(origin) { + try { + const { protocol, hostname } = new URL(origin); + return (protocol === "http:" || protocol === "https:") + && (hostname === "127.0.0.1" || hostname === "localhost"); + } catch (_) { + return false; + } + } + + function installStyles() { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.setAttribute(OWNED_ATTRIBUTE, "true"); + style.textContent = ` + #${ENTRY_ID}[aria-current="page"] { + background: var(--color-token-list-hover-background, color-mix(in srgb, currentColor 8%, transparent)); + color: var(--color-token-foreground, inherit); + } + #${ENTRY_ID}:focus-visible { + outline: 2px solid var(--color-token-border, Highlight); + outline-offset: 2px; + } + [${HOST_ATTRIBUTE}="true"] { + position: relative !important; + z-index: 31 !important; + pointer-events: none !important; + } + [${HIDDEN_ATTRIBUTE}="true"] { + visibility: hidden !important; + pointer-events: none !important; + } + [${NATIVE_SELECTED_ATTRIBUTE}="true"] { + background-color: transparent !important; + } + [${NATIVE_SELECTED_ATTRIBUTE}="true"] [class*="text-token-list-active-selection"] { + color: var(--color-token-foreground, inherit) !important; + } + #${PAGE_ID} { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + min-width: 0; + min-height: 0; + overflow: hidden; + background: Canvas; + color: CanvasText; + pointer-events: auto; + } + #${PAGE_ID}[hidden] { + display: none !important; + } + #${FRAME_ID} { + display: block; + width: 100%; + height: 100%; + border: 0; + background: Canvas; + } + #${FRAME_ID}[hidden] { + display: none !important; + } + #${DRAG_REGION_ID} { + position: absolute; + z-index: 2; + background: transparent; + pointer-events: none; + -webkit-app-region: drag; + } + #${NO_DRAG_LEFT_ID}, + #${NO_DRAG_RIGHT_ID} { + position: absolute; + z-index: 2; + background: transparent; + pointer-events: none; + -webkit-app-region: no-drag; + } + #${DRAG_REGION_ID}[hidden], + #${NO_DRAG_LEFT_ID}[hidden], + #${NO_DRAG_RIGHT_ID}[hidden] { + display: none !important; + } + #${STATUS_ID} { + position: absolute; + inset: 0; + display: grid; + place-items: center; + padding: 24px; + color: var(--color-token-text-secondary, color-mix(in srgb, CanvasText 60%, transparent)); + font: 13px/1.5 system-ui, sans-serif; + text-align: center; + } + #${STATUS_ID}[hidden] { + display: none !important; + } + #${STATUS_ID} button { + margin-top: 10px; + border: 1px solid var(--color-token-border, color-mix(in srgb, CanvasText 16%, transparent)); + border-radius: 7px; + padding: 5px 10px; + background: var(--color-token-main-surface-secondary, Canvas); + color: var(--color-token-foreground, CanvasText); + cursor: pointer; + } + #${NATIVE_THREAD_PANEL_ID} { + position: fixed; + right: 12px; + bottom: 12px; + width: min(440px, calc(100vw - 24px)); + height: min(620px, calc(100vh - 24px)); + max-width: calc(100vw - 24px); + max-height: calc(100vh - 24px); + box-sizing: border-box; + z-index: 8; + display: grid; + grid-template-rows: 38px minmax(0, 1fr); + pointer-events: none; + color: var(--color-token-foreground, CanvasText); + font: 13px/1.4 system-ui, sans-serif; + } + #${NATIVE_THREAD_PANEL_ID}[hidden] { + display: none !important; + } + #${NATIVE_THREAD_PANEL_ID}[data-size="maximized"] { + right: 12px; + bottom: 12px; + width: min(760px, calc(100vw - 24px)); + height: min(760px, calc(100vh - 24px)); + max-width: calc(100vw - 24px); + max-height: calc(100vh - 24px); + grid-template-rows: 40px minmax(0, 1fr); + } + #${NATIVE_THREAD_PANEL_ID} .codex-taskboard-native-thread-header { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + min-height: 0; + padding: 0 7px 0 11px; + border: 1px solid var(--color-token-border, color-mix(in srgb, CanvasText 14%, transparent)); + border-bottom: 0; + border-radius: 14px 14px 0 0; + background: var(--color-token-main-surface-primary, Canvas); + box-shadow: 0 10px 30px color-mix(in srgb, CanvasText 15%, transparent); + pointer-events: auto; + -webkit-app-region: no-drag; + } + #${NATIVE_THREAD_PANEL_ID} .codex-taskboard-native-thread-title { + min-width: 0; + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: 650; + font-size: 13px; + } + #${NATIVE_THREAD_PANEL_ID} .codex-taskboard-native-thread-id { + display: none; + color: var(--color-token-text-secondary, color-mix(in srgb, CanvasText 58%, transparent)); + font-size: 12px; + font-weight: 500; + } + #${NATIVE_THREAD_PANEL_ID} button { + border: 1px solid transparent; + border-radius: 8px; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + color: var(--color-token-text-secondary, color-mix(in srgb, CanvasText 68%, transparent)); + cursor: pointer; + display: grid; + place-items: center; + flex: 0 0 auto; + font: 16px/1 system-ui, sans-serif; + } + #${NATIVE_THREAD_PANEL_ID} button:hover { + background: var(--color-token-list-hover-background, color-mix(in srgb, CanvasText 8%, transparent)); + color: var(--color-token-foreground, CanvasText); + } + #${NATIVE_THREAD_PANEL_ID} button svg { + display: block; + width: 16px; + height: 16px; + } + #${NATIVE_THREAD_PANEL_BODY_ID} { + min-width: 0; + min-height: 0; + border-radius: 0 0 14px 14px; + pointer-events: none; + } + #${NATIVE_THREAD_PANEL_ID}[data-native-host="false"] #${NATIVE_THREAD_PANEL_BODY_ID} { + display: grid; + place-items: center; + padding: 20px; + border: 1px solid var(--color-token-border, color-mix(in srgb, CanvasText 14%, transparent)); + border-top: 0; + background: var(--color-token-main-surface-primary, Canvas); + box-shadow: 0 14px 42px color-mix(in srgb, CanvasText 16%, transparent); + pointer-events: auto; + } + #${NATIVE_THREAD_PANEL_ID} .codex-taskboard-native-thread-fallback { + max-width: 320px; + display: grid; + justify-items: center; + gap: 12px; + color: var(--color-token-text-secondary, color-mix(in srgb, CanvasText 62%, transparent)); + text-align: center; + } + #${NATIVE_THREAD_PANEL_ID} .codex-taskboard-native-thread-fallback p { + margin: 0; + font: 13px/1.55 system-ui, sans-serif; + } + #${NATIVE_THREAD_PANEL_ID} .codex-taskboard-native-thread-fallback button { + width: auto; + height: 32px; + padding: 0 12px; + border: 1px solid var(--color-token-border, color-mix(in srgb, CanvasText 16%, transparent)); + border-radius: 8px; + background: var(--color-token-main-surface-secondary, Canvas); + color: var(--color-token-foreground, CanvasText); + font: 13px/1 system-ui, sans-serif; + } + [${NATIVE_THREAD_HOST_ATTRIBUTE}="true"] { + visibility: visible !important; + pointer-events: auto !important; + margin: 0 !important; + min-width: 0 !important; + min-height: 0 !important; + --thread-content-max-width: 100% !important; + overflow: hidden !important; + background: var(--color-token-main-surface-primary, Canvas) !important; + border: 1px solid var(--color-token-border, color-mix(in srgb, CanvasText 14%, transparent)) !important; + border-top: 0 !important; + border-radius: 0 0 14px 14px !important; + box-shadow: 0 14px 42px color-mix(in srgb, CanvasText 16%, transparent) !important; + } + [${NATIVE_THREAD_HOST_ATTRIBUTE}="true"] .thread-scroll-container > .flex.min-h-full.shrink-0.flex-col.justify-start { + transform: none !important; + width: 100% !important; + max-width: 100% !important; + } + [${NATIVE_THREAD_HOST_ATTRIBUTE}="true"] .thread-scroll-container { + scrollbar-color: color-mix(in srgb, CanvasText 36%, transparent) transparent !important; + } + [${NATIVE_THREAD_HOST_ATTRIBUTE}="true"] .thread-scroll-container .mx-auto.w-full[class*="max-w-"] { + width: 100% !important; + max-width: 100% !important; + } + [${NATIVE_THREAD_HOST_ATTRIBUTE}="true"] .thread-scroll-container .px-toolbar { + padding-left: 12px !important; + padding-right: 12px !important; + } + [${NATIVE_THREAD_HOST_ATTRIBUTE}="true"] .pointer-events-none.absolute[class*="right-0"][class*="z-40"] { + display: none !important; + } + @media (max-width: 720px) { + #${NATIVE_THREAD_PANEL_ID} { + right: 8px; + bottom: 8px; + width: calc(100vw - 16px); + max-width: calc(100vw - 16px); + } + #${NATIVE_THREAD_PANEL_ID}[data-size="maximized"] { + right: 8px; + bottom: 8px; + width: calc(100vw - 16px); + height: min(760px, calc(100vh - 16px)); + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + } + } + @media (max-height: 680px) { + #${NATIVE_THREAD_PANEL_ID} { + bottom: 8px; + height: calc(100vh - 16px); + max-height: calc(100vh - 16px); + } + #${NATIVE_THREAD_PANEL_ID}[data-size="maximized"] { + right: 8px; + bottom: 8px; + width: min(760px, calc(100vw - 16px)); + height: calc(100vh - 16px); + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + } + } + `; + (document.head || document.documentElement).appendChild(style); + } + + function buttonMatches(button, labels) { + if (!button) return false; + const text = normalizedLabel(button.textContent || button.getAttribute("aria-label")); + return labels.includes(text); + } + + function findReferenceButton() { + const scroll = document.querySelector("[data-app-action-sidebar-scroll]"); + if (!scroll) return null; + const buttons = Array.from(scroll.querySelectorAll("button")); + const plugin = buttons.find((button) => buttonMatches(button, PLUGIN_LABELS)); + if (plugin?.parentElement) return plugin; + + const firstSection = scroll.querySelector("[data-app-action-sidebar-section]"); + const sectionTop = firstSection?.getBoundingClientRect().top ?? Number.POSITIVE_INFINITY; + const groups = Array.from(scroll.querySelectorAll("div")).filter((element) => { + const directButtons = Array.from(element.children).filter((child) => child.tagName === "BUTTON"); + return directButtons.length >= 3 && element.getBoundingClientRect().top < sectionTop; + }); + const group = groups.sort((left, right) => right.children.length - left.children.length)[0]; + return Array.from(group?.children || []).filter((child) => child.tagName === "BUTTON").at(-1) || null; + } + + function applyCodexIcon(svg, name) { + const icon = CODEX_ICONS[name]; + if (!icon) return; + svg.setAttribute("viewBox", icon.viewBox); + svg.setAttribute("fill", "currentColor"); + svg.removeAttribute("stroke"); + svg.removeAttribute("stroke-width"); + svg.removeAttribute("stroke-linecap"); + svg.removeAttribute("stroke-linejoin"); + svg.style.fill = "currentColor"; + svg.style.stroke = "none"; + svg.innerHTML = icon.content; + } + + function createCodexIcon(name) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("width", "16"); + svg.setAttribute("height", "16"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + applyCodexIcon(svg, name); + return svg; + } + + function setIconButton(button, name) { + button.replaceChildren(createCodexIcon(name)); + } + + function replaceEntryIcon(button) { + const icon = button.querySelector("svg"); + if (!icon) return; + applyCodexIcon(icon, "panel"); + } + + function createEntry(reference) { + const button = reference.cloneNode(true); + button.id = ENTRY_ID; + button.type = "button"; + button.removeAttribute("disabled"); + button.removeAttribute("aria-expanded"); + button.removeAttribute("aria-controls"); + button.removeAttribute("aria-describedby"); + button.removeAttribute("data-state"); + button.setAttribute("aria-label", "打开任务面板"); + button.setAttribute("title", "任务面板"); + button.setAttribute(OWNED_ATTRIBUTE, "true"); + button.querySelectorAll("[id]").forEach((node) => node.removeAttribute("id")); + const label = button.querySelector(".text-fade-truncate") + || Array.from(button.querySelectorAll("span")).find((node) => buttonMatches(node, PLUGIN_LABELS)); + if (label) label.textContent = "任务面板"; + else button.textContent = "任务面板"; + replaceEntryIcon(button); + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + openTaskboard(); + }); + return button; + } + + function syncEntryState() { + if (!entry) return; + if (active && entry.getAttribute("aria-current") !== "page") { + entry.setAttribute("aria-current", "page"); + } else if (!active && entry.hasAttribute("aria-current")) { + entry.removeAttribute("aria-current"); + } + } + + function ensureEntry() { + if (destroyed || !document.body) return; + installStyles(); + const reference = findReferenceButton(); + if (!reference?.parentElement) return; + if (!entry) entry = createEntry(reference); + if (entry.parentElement !== reference.parentElement || entry.previousElementSibling !== reference) { + reference.after(entry); + } + syncEntryState(); + } + + function findPageHost() { + const direct = document.querySelector(".app-shell-main-content-frame"); + if (direct?.closest?.("[data-app-shell-main-content-layout]")) return direct; + + const viewport = document.querySelector("[data-app-shell-main-content-layout]"); + if (!viewport) return null; + const viewportRect = viewport.getBoundingClientRect(); + const headerBottom = document.querySelector("main > header")?.getBoundingClientRect().bottom + ?? viewportRect.top; + return Array.from(viewport.children).find((candidate) => { + const rect = candidate.getBoundingClientRect(); + return rect.width >= viewportRect.width * 0.8 + && rect.height >= viewportRect.height * 0.7 + && rect.top >= headerBottom - 1; + }) || null; + } + + function findPageMount() { + const frameHost = findPageHost(); + const viewport = frameHost?.closest?.("[data-app-shell-main-content-layout]"); + const surface = viewport?.parentElement; + if (!frameHost || !viewport || !surface || !surface.closest("main")) return null; + return { frameHost, surface }; + } + + function muteNativeSelection() { + if (!active) return; + document.querySelectorAll('aside nav[role="navigation"] [aria-current]') + .forEach((node) => { + if (node === entry || node.closest(`#${ENTRY_ID}`)) return; + if (!mutedNativeSelections.has(node)) { + mutedNativeSelections.set(node, node.getAttribute("aria-current")); + } + node.removeAttribute("aria-current"); + node.setAttribute(NATIVE_SELECTED_ATTRIBUTE, "true"); + }); + } + + function restoreNativeSelection() { + mutedNativeSelections.forEach((ariaCurrent, node) => { + if (!node.isConnected) return; + node.setAttribute("aria-current", ariaCurrent); + node.removeAttribute(NATIVE_SELECTED_ATTRIBUTE); + }); + mutedNativeSelections.clear(); + document.querySelectorAll(`[${NATIVE_SELECTED_ATTRIBUTE}="true"]`) + .forEach((node) => node.removeAttribute(NATIVE_SELECTED_ATTRIBUTE)); + } + + function hideNativeHeader() { + document.querySelectorAll('[data-testid="app-shell-header-context-menu-surface"]') + .forEach((surface) => { + Array.from(surface.children).forEach((child) => { + if (child.getAttribute(OWNED_ATTRIBUTE) !== "true") { + child.setAttribute(HIDDEN_ATTRIBUTE, "true"); + } + }); + }); + } + + function currentTheme() { + const root = document.documentElement; + const explicit = String(root.dataset.theme || root.getAttribute("data-color-theme") || "").toLowerCase(); + if (explicit.includes("dark") || root.classList.contains("dark")) return "dark"; + if (explicit.includes("light") || root.classList.contains("light")) return "light"; + try { + return window.getComputedStyle(root).colorScheme.includes("dark") ? "dark" : "light"; + } catch (_) { + return "light"; + } + } + + function threadIdFromLocation() { + const source = `${window.location.pathname || ""}${window.location.search || ""}${window.location.hash || ""}`; + const match = source.match(/(?:session|conversation|thread)(?:\/|=|:|-)([A-Za-z0-9_.-]+)/i) + || source.match(/\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?:[/?#]|$)/) + || source.match(/\/([A-Za-z0-9_-]{24,})(?:[/?#]|$)/); + return match ? decodeURIComponent(match[1]) : ""; + } + + function activeThreadRow() { + const rows = Array.from(document.querySelectorAll("[data-app-action-sidebar-thread-id]")); + return rows.find((row) => row.getAttribute("data-app-action-sidebar-thread-active") === "true") + || rows.find((row) => ["page", "true"].includes(row.getAttribute("aria-current"))) + || null; + } + + function readCodexProjects() { + const seen = new Set(); + return Array.from(document.querySelectorAll("[data-app-action-sidebar-project-row]")) + .flatMap((row) => { + const id = row.getAttribute("data-app-action-sidebar-project-id")?.trim(); + const name = ( + row.getAttribute("data-app-action-sidebar-project-label") + || row.getAttribute("aria-label") + || "" + ).trim(); + if (!id || !name || seen.has(id)) return []; + seen.add(id); + return [{ id, name }]; + }); + } + + function readVisibleThreadIds(scope) { + const seen = new Set(); + const root = scope && typeof scope.querySelectorAll === "function" ? scope : document; + return Array.from(root.querySelectorAll("[data-app-action-sidebar-thread-id]")) + .flatMap((row) => { + const threadId = normalizeThreadId(row.getAttribute("data-app-action-sidebar-thread-id")); + if (!threadId || threadId.startsWith("client-new-thread:") || seen.has(threadId)) return []; + seen.add(threadId); + return [threadId]; + }) + .sort(); + } + + function findProjectsSection() { + return Array.from(document.querySelectorAll("[data-app-action-sidebar-section-heading]")) + .find((node) => PROJECT_SECTION_LABELS.includes(normalizedLabel( + node.getAttribute("data-app-action-sidebar-section-heading") || node.textContent, + ))) + ?.closest("[data-app-action-sidebar-section]") || null; + } + + function findTasksSection() { + return Array.from(document.querySelectorAll("[data-app-action-sidebar-section]")) + .find((section) => { + const heading = section.querySelector("[data-app-action-sidebar-section-heading]"); + const label = heading?.getAttribute("data-app-action-sidebar-section-heading") + || heading?.textContent + || section.textContent; + return TASK_SECTION_LABELS.includes(normalizedLabel(label)); + }) || null; + } + + async function captureHostContext() { + let projects = readCodexProjects(); + let section = findProjectsSection(); + const sectionDeadline = Date.now() + 1_200; + while (!section && Date.now() < sectionDeadline) { + await new Promise((resolve) => window.setTimeout(resolve, 40)); + section = findProjectsSection(); + } + const tasksSection = findTasksSection(); + const expandedSections = [section, tasksSection].filter((candidate) => ( + candidate?.getAttribute("data-app-action-sidebar-section-collapsed") === "true" + )); + expandedSections.forEach((candidate) => ( + candidate.querySelector("[data-app-action-sidebar-section-toggle]")?.click() + )); + if (expandedSections.length > 0) { + const deadline = Date.now() + 1_200; + do { + await new Promise((resolve) => window.setTimeout(resolve, 40)); + projects = readCodexProjects(); + } while ((projects.length === 0 || !activeThreadRow()) && Date.now() < deadline); + } + const context = readHostContext(projects); + expandedSections.forEach((candidate) => { + if (candidate.isConnected && candidate.getAttribute("data-app-action-sidebar-section-collapsed") === "false") { + candidate.querySelector("[data-app-action-sidebar-section-toggle]")?.click(); + } + }); + return context; + } + + function workspaceFromLocation() { + try { + const url = new URL(window.location.href); + return url.searchParams.get("workspace") || url.searchParams.get("cwd") || ""; + } catch (_) { + return ""; + } + } + + function titlebarLeftInset() { + if (!/Macintosh|Mac OS X/.test(navigator.userAgent)) return 0; + if (nativeSidebarCollapsed()) return MACOS_TITLEBAR_SAFE_LEFT; + const surfaceLeft = findPageMount()?.surface.getBoundingClientRect().left; + if (!Number.isFinite(surfaceLeft)) return 0; + return Math.max(0, Math.ceil(MACOS_TITLEBAR_SAFE_LEFT - surfaceLeft)); + } + + function nativeSidebarTrigger() { + const triggers = Array.from( + document.querySelectorAll('[data-app-shell-sidebar-trigger="true"]'), + ); + return triggers.find((trigger) => getComputedStyle(trigger).visibility !== "hidden") + || triggers[0] + || null; + } + + function nativeSidebarCollapsed() { + const label = normalizedLabel(nativeSidebarTrigger()?.getAttribute("aria-label")); + return label.startsWith("显示") || label.startsWith("show "); + } + + function expandNativeSidebar() { + const trigger = nativeSidebarTrigger(); + if (!trigger || !nativeSidebarCollapsed()) return; + trigger.click(); + window.setTimeout(postHostContext, REATTACH_DELAY_MS); + } + + function userIdFromName(name) { + const slug = name.normalize("NFKD") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 96); + if (slug) return slug; + let hash = 2166136261; + for (const character of name) { + hash ^= character.codePointAt(0); + hash = Math.imul(hash, 16777619); + } + return `codex-user-${(hash >>> 0).toString(36)}`; + } + + function readCodexUser() { + const avatar = Array.from(document.querySelectorAll("img")) + .find((image) => image.src.includes("cdn.auth0.com/avatars/")); + const profileButton = avatar?.closest("button") + || Array.from(document.querySelectorAll('button[aria-haspopup="menu"]')).find((button) => ( + normalizedLabel(button.getAttribute("aria-label")).includes("profile") + || normalizedLabel(button.getAttribute("aria-label")).includes("个人资料") + )); + const name = profileButton?.textContent?.replace(/\s+/g, " ").trim(); + if (!name) return null; + const avatarUrl = avatar?.currentSrc || avatar?.src || null; + return { + type: "user", + id: userIdFromName(name), + name, + avatarUrl, + }; + } + + function readHostContext(projects = readCodexProjects()) { + const row = activeThreadRow(); + const activeThreadId = normalizeThreadId(row?.getAttribute("data-app-action-sidebar-thread-id")); + if (activeThreadId) lastNativeThreadId = activeThreadId; + const threadId = activeThreadId || lastNativeThreadId || normalizeThreadId(threadIdFromLocation()); + const projectList = row?.closest?.("[data-app-action-sidebar-project-list-id]"); + const projectRow = row?.closest?.("[data-app-action-sidebar-project-id]") + || document.querySelector('[data-app-action-sidebar-project-row][aria-current="page"]') + || document.querySelector('[data-app-action-sidebar-project-row][data-app-action-sidebar-project-active="true"]'); + const projectId = projectList?.getAttribute("data-app-action-sidebar-project-list-id") + || projectRow?.getAttribute("data-app-action-sidebar-project-id") + || ""; + const workspacePath = workspaceFromLocation(); + const payload = { + theme: currentTheme(), + projects, + user: readCodexUser() ?? undefined, + visibleThreadIds: readVisibleThreadIds(projectList), + titlebarLeftInset: titlebarLeftInset(), + sidebarCollapsed: nativeSidebarCollapsed(), + }; + if (workspacePath) payload.workspacePath = workspacePath; + if (projectId) payload.projectId = projectId; + if (threadId) payload.threadId = threadId; + return payload; + } + + function postToFrame(message) { + if (!frame?.contentWindow || !frameOrigin) return; + frame.contentWindow.postMessage(message, frameOrigin); + } + + function dispatchHostMessage(message) { + window.postMessage(message, window.location.origin); + } + + function postHostContext() { + if (!frame) return; + const liveContext = readHostContext(); + const payload = hostContextSnapshot + ? { + ...hostContextSnapshot, + ...liveContext, + projects: liveContext.projects.length > 0 + ? liveContext.projects + : hostContextSnapshot.projects, + } + : liveContext; + postToFrame({ type: "taskboard:host-context", payload }); + postToFrame({ type: "taskboard:theme", theme: payload.theme }); + } + + function threadRowTitle(row) { + return typeof row?.getAttribute === "function" + ? row.getAttribute("data-app-action-sidebar-thread-title") || "" + : ""; + } + + function findThreadRow(threadId, threadTitle = "") { + const normalizedThreadId = normalizeThreadId(threadId); + const rows = Array.from(document.querySelectorAll("[data-app-action-sidebar-thread-id]")); + if (normalizedThreadId) { + const exact = rows.find((row) => normalizeThreadId(row.getAttribute("data-app-action-sidebar-thread-id")) === normalizedThreadId); + if (exact) return exact; + } + const normalizedThreadTitle = normalizedLabel(threadTitle); + if (!normalizedThreadTitle) return null; + return rows.find((row) => normalizedLabel(threadRowTitle(row)) === normalizedThreadTitle) || null; + } + + function routeForThread(threadId) { + return `/local/${encodeURIComponent(threadId)}`; + } + + function deepLinkForThread(threadId) { + const normalizedThreadId = normalizeThreadId(threadId); + return normalizedThreadId ? `codex://threads/${encodeURIComponent(normalizedThreadId)}` : ""; + } + + function openThreadDeepLink(threadId) { + const deepLink = deepLinkForThread(threadId); + if (deepLink) window.location.assign(deepLink); + } + + function normalizeWorkspacePath(value) { + return typeof value === "string" ? value.trim() : ""; + } + + async function setActiveWorkspaceRoot(workspacePath) { + const root = normalizeWorkspacePath(workspacePath); + if (!root) return false; + const bridge = window.electronBridge; + if (!bridge || typeof bridge.sendMessageFromView !== "function") return false; + await bridge.sendMessageFromView({ + type: "electron-set-active-workspace-root", + root, + }); + return true; + } + + function nativeThreadIsActive(threadId, threadTitle = "") { + const normalizedThreadId = normalizeThreadId(threadId); + if (!normalizedThreadId) return false; + const normalizedThreadTitle = normalizedLabel(threadTitle); + const activeThreadId = normalizeThreadId( + activeThreadRow()?.getAttribute("data-app-action-sidebar-thread-id"), + ); + return activeThreadId === normalizedThreadId + || normalizeThreadId(threadIdFromLocation()) === normalizedThreadId + || ( + normalizedThreadTitle + && normalizedLabel(threadRowTitle(activeThreadRow())) === normalizedThreadTitle + ); + } + + async function waitForNativeThread(threadId, threadTitle = "", timeoutMs = 1_500) { + const deadline = Date.now() + timeoutMs; + do { + if (nativeThreadIsActive(threadId, threadTitle)) return true; + await new Promise((resolve) => window.setTimeout(resolve, 80)); + } while (Date.now() < deadline); + return nativeThreadIsActive(threadId, threadTitle); + } + + async function waitForThreadRow(threadId, threadTitle = "", timeoutMs = 800) { + const deadline = Date.now() + timeoutMs; + do { + const row = findThreadRow(threadId, threadTitle); + if (row?.isConnected) return row; + await new Promise((resolve) => window.setTimeout(resolve, 80)); + } while (Date.now() < deadline); + return findThreadRow(threadId, threadTitle); + } + + async function navigateNativeThread( + threadId, + { closeCurrentTaskboard, workspacePath, threadTitle } = { closeCurrentTaskboard: false, workspacePath: "", threadTitle: "" }, + ) { + if (typeof threadId !== "string" || !threadId.trim()) return false; + const normalizedThreadId = normalizeThreadId(threadId); + lastNativeThreadId = normalizedThreadId; + const normalizedWorkspacePath = normalizeWorkspacePath(workspacePath); + const normalizedThreadTitle = normalizedLabel(threadTitle); + if (normalizedWorkspacePath) { + try { + await setActiveWorkspaceRoot(normalizedWorkspacePath); + } catch (_) {} + } + const row = normalizedWorkspacePath + ? await waitForThreadRow(normalizedThreadId, normalizedThreadTitle) + : findThreadRow(normalizedThreadId, normalizedThreadTitle); + if (closeCurrentTaskboard) closeTaskboard(false); + + if (row?.isConnected) { + if (!closeCurrentTaskboard) suppressNativeCloseUntil = Date.now() + 1_000; + row.click?.(); + return true; + } + + try { + if (!closeCurrentTaskboard) suppressNativeCloseUntil = Date.now() + 1_000; + await dispatchHostMessage({ + type: "navigate-to-route", + path: routeForThread(normalizedThreadId), + }); + } catch (_) { + return false; + } + return waitForNativeThread(normalizedThreadId, normalizedThreadTitle); + } + + async function openThread(threadId, workspacePath = "", threadTitle = "") { + const targetWorkspacePath = normalizeWorkspacePath(workspacePath) || nativeThreadPanelWorkspacePath; + const targetThreadTitle = normalizedLabel(threadTitle) || nativeThreadPanelThreadTitle; + const opened = await navigateNativeThread(threadId, { + closeCurrentTaskboard: true, + workspacePath: targetWorkspacePath, + threadTitle: targetThreadTitle, + }); + if (!opened) openThreadDeepLink(threadId); + } + + function restoreNativeThreadHost() { + nativeThreadPanelResizeObserver?.disconnect?.(); + nativeThreadPanelResizeObserver = null; + if (!nativeThreadPanelHost) return; + nativeThreadPanelHost.style.cssText = nativeThreadPanelHostStyle; + nativeThreadPanelHost.removeAttribute(NATIVE_THREAD_HOST_ATTRIBUTE); + nativeThreadPanelHost = null; + nativeThreadPanelHostStyle = ""; + } + + function clearNativeThreadFallback() { + if (nativeThreadPanel) nativeThreadPanel.dataset.nativeHost = "true"; + nativeThreadPanelBody?.replaceChildren(); + } + + function showNativeThreadFallback(threadId) { + if (!nativeThreadPanel || !nativeThreadPanelBody) return; + nativeThreadPanel.dataset.nativeHost = "false"; + nativeThreadPanelBody.replaceChildren(); + + const fallback = document.createElement("div"); + fallback.className = "codex-taskboard-native-thread-fallback"; + + const message = document.createElement("p"); + message.textContent = "当前 Codex 侧栏没有加载这个原对话,已停止挂载当前新对话页面。请完整打开后继续。"; + + const action = document.createElement("button"); + action.type = "button"; + action.textContent = "完整打开"; + action.addEventListener("click", () => void openThread(threadId, "", nativeThreadPanelThreadTitle)); + + fallback.append(message, action); + nativeThreadPanelBody.append(fallback); + } + + function mountNativeThreadPanel() { + const mount = findPageMount(); + if (!mount || !nativeThreadPanel) return null; + if (nativeThreadPanel.parentElement !== mount.surface) { + mount.surface.appendChild(nativeThreadPanel); + } + return mount; + } + + function syncNativeThreadPanelLayout() { + if ( + !active + || !nativeThreadPanel + || nativeThreadPanel.hidden + || !nativeThreadPanelBody + || !nativeThreadPanelHost + || !nativeThreadPanelHost.isConnected + ) return; + const surface = nativeThreadPanel.parentElement; + if (!surface) return; + const surfaceRect = surface.getBoundingClientRect(); + const bodyRect = nativeThreadPanelBody.getBoundingClientRect(); + if (bodyRect.width <= 0 || bodyRect.height <= 0) return; + Object.assign(nativeThreadPanelHost.style, { + position: "absolute", + left: `${Math.max(0, bodyRect.left - surfaceRect.left)}px`, + top: `${Math.max(0, bodyRect.top - surfaceRect.top)}px`, + width: `${bodyRect.width}px`, + height: `${bodyRect.height}px`, + zIndex: "7", + boxSizing: "border-box", + }); + nativeThreadPanelHost.removeAttribute(HIDDEN_ATTRIBUTE); + nativeThreadPanelHost.setAttribute(NATIVE_THREAD_HOST_ATTRIBUTE, "true"); + } + + function scheduleNativeThreadPanelAttach() { + for (const delay of [80, 220, 500, 1_000]) { + window.setTimeout(() => { + if (nativeThreadPanelThreadId && nativeThreadPanelCanAttach) attachNativeThreadHost(); + }, delay); + } + } + + function attachNativeThreadHost() { + if (!nativeThreadPanelCanAttach) return false; + const mount = mountNativeThreadPanel(); + if (!mount || !nativeThreadPanel || nativeThreadPanel.hidden) return false; + if (nativeThreadPanelHost !== mount.frameHost) { + restoreNativeThreadHost(); + nativeThreadPanelHost = mount.frameHost; + nativeThreadPanelHostStyle = nativeThreadPanelHost.getAttribute("style") || ""; + if (typeof ResizeObserver === "function") { + nativeThreadPanelResizeObserver = new ResizeObserver(syncNativeThreadPanelLayout); + nativeThreadPanelResizeObserver.observe(nativeThreadPanel); + } + } + clearNativeThreadFallback(); + syncNativeThreadPanelLayout(); + return true; + } + + function closeNativeThreadPanel({ remountTaskboard = true } = {}) { + nativeThreadPanelThreadId = ""; + nativeThreadPanelWorkspacePath = ""; + nativeThreadPanelThreadTitle = ""; + nativeThreadPanelCanAttach = false; + if (nativeThreadPanel) nativeThreadPanel.hidden = true; + nativeThreadPanelBody?.replaceChildren(); + restoreNativeThreadHost(); + if (remountTaskboard && active) mountActivePage(); + } + + function updateNativeThreadPanelTitle(title, threadId) { + if (!nativeThreadPanel) return; + const titleNode = nativeThreadPanel.querySelector(".codex-taskboard-native-thread-title"); + const idNode = nativeThreadPanel.querySelector(".codex-taskboard-native-thread-id"); + if (titleNode) { + titleNode.textContent = "继续对话"; + titleNode.title = title || normalizeThreadId(threadId) || "继续对话"; + } + if (idNode) idNode.textContent = normalizeThreadId(threadId); + } + + function toggleNativeThreadPanelSize() { + nativeThreadPanelSize = nativeThreadPanelSize === "maximized" ? "compact" : "maximized"; + if (nativeThreadPanel) { + nativeThreadPanel.dataset.size = nativeThreadPanelSize; + const button = nativeThreadPanel.querySelector("[data-codex-taskboard-native-thread-size]"); + if (button) { + setIconButton(button, nativeThreadPanelSize === "maximized" ? "chevronDown" : "expand"); + button.setAttribute("aria-label", nativeThreadPanelSize === "maximized" ? "缩小对话浮窗" : "放大对话浮窗"); + } + } + requestAnimationFrame(syncNativeThreadPanelLayout); + } + + function createNativeThreadPanel() { + const panel = document.createElement("section"); + panel.id = NATIVE_THREAD_PANEL_ID; + panel.hidden = true; + panel.dataset.size = nativeThreadPanelSize; + panel.dataset.nativeHost = "true"; + panel.setAttribute(OWNED_ATTRIBUTE, "true"); + panel.setAttribute("role", "dialog"); + panel.setAttribute("aria-label", "继续 Codex 对话"); + + const header = document.createElement("div"); + header.className = "codex-taskboard-native-thread-header"; + + const title = document.createElement("div"); + title.className = "codex-taskboard-native-thread-title"; + title.textContent = "继续对话"; + + const id = document.createElement("span"); + id.className = "codex-taskboard-native-thread-id"; + + const full = document.createElement("button"); + full.type = "button"; + setIconButton(full, "openExternal"); + full.title = "跳转到完整 Codex 对话"; + full.setAttribute("aria-label", "完整打开 Codex 对话"); + full.addEventListener("click", () => { + const threadId = nativeThreadPanelThreadId; + if (threadId) void openThread(threadId); + }); + + const size = document.createElement("button"); + size.type = "button"; + size.dataset.codexTaskboardNativeThreadSize = "true"; + setIconButton(size, nativeThreadPanelSize === "maximized" ? "chevronDown" : "expand"); + size.title = "放大或缩小浮窗"; + size.setAttribute("aria-label", nativeThreadPanelSize === "maximized" ? "缩小对话浮窗" : "放大对话浮窗"); + size.addEventListener("click", toggleNativeThreadPanelSize); + + const close = document.createElement("button"); + close.type = "button"; + setIconButton(close, "close"); + close.title = "关闭对话浮窗"; + close.setAttribute("aria-label", "关闭对话浮窗"); + close.addEventListener("click", () => closeNativeThreadPanel()); + + header.append(title, id, full, size, close); + + const body = document.createElement("div"); + body.id = NATIVE_THREAD_PANEL_BODY_ID; + panel.append(header, body); + nativeThreadPanel = panel; + nativeThreadPanelBody = body; + return panel; + } + + async function showNativeThreadPanel(payload) { + const threadId = normalizeThreadId(payload?.threadId); + if (!threadId) return; + const title = typeof payload?.title === "string" ? payload.title.trim() : ""; + const workspacePath = normalizeWorkspacePath(payload?.workspacePath); + const threadTitle = typeof payload?.threadTitle === "string" ? payload.threadTitle.trim() : ""; + const inferredThreadTitle = title.replace(/^[A-Z][A-Z0-9_-]*-\d+\s+/, "").trim(); + nativeThreadPanelThreadId = threadId; + nativeThreadPanelWorkspacePath = workspacePath; + nativeThreadPanelThreadTitle = threadTitle || inferredThreadTitle; + nativeThreadPanelCanAttach = false; + if (!active) openTaskboard(); + if (!nativeThreadPanel) createNativeThreadPanel(); + nativeThreadPanel.hidden = false; + nativeThreadPanel.dataset.size = nativeThreadPanelSize; + nativeThreadPanel.dataset.nativeHost = "true"; + updateNativeThreadPanelTitle(title, threadId); + clearNativeThreadFallback(); + mountActivePage(); + mountNativeThreadPanel(); + const opened = await navigateNativeThread(threadId, { + closeCurrentTaskboard: false, + workspacePath, + threadTitle: threadTitle || title, + }); + if (!opened) { + restoreNativeThreadHost(); + nativeThreadPanelCanAttach = false; + showNativeThreadFallback(threadId); + return; + } + nativeThreadPanelCanAttach = true; + attachNativeThreadHost(); + scheduleNativeThreadPanelAttach(); + } + + function projectRowById(projectId) { + if (typeof projectId !== "string" || !projectId.trim()) return null; + return Array.from(document.querySelectorAll("[data-app-action-sidebar-project-row]")) + .find((row) => row.getAttribute("data-app-action-sidebar-project-id") === projectId.trim()) || null; + } + + function projectRowByLabel(label) { + if (typeof label !== "string" || !label.trim()) return null; + const expected = normalizedLabel(label); + return Array.from(document.querySelectorAll("[data-app-action-sidebar-project-row]")) + .find((row) => normalizedLabel(row.getAttribute("data-app-action-sidebar-project-label")) === expected) || null; + } + + async function ensureProjectRows() { + let section = findProjectsSection(); + const deadline = Date.now() + 1_200; + while (!section && Date.now() < deadline) { + await new Promise((resolve) => window.setTimeout(resolve, 40)); + section = findProjectsSection(); + } + if (section?.getAttribute("data-app-action-sidebar-section-collapsed") === "true") { + section.querySelector("[data-app-action-sidebar-section-toggle]")?.click(); + } + while (readCodexProjects().length === 0 && Date.now() < deadline) { + await new Promise((resolve) => window.setTimeout(resolve, 40)); + } + } + + async function waitForPreparedComposer(identifier, skillPath) { + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + const editor = document.querySelector('[data-codex-composer="true"][contenteditable="true"]'); + if (editor && editor.getClientRects().length > 0) { + const containsIdentifier = normalizedLabel(editor.textContent).includes(normalizedLabel(identifier)); + const skillMention = Array.from(editor.querySelectorAll("[skill-mention-name]")) + .find((mention) => ( + mention.getAttribute("skill-mention-name") === "manage-taskboard" + && mention.getAttribute("skill-mention-path") === skillPath + )); + if (containsIdentifier && skillMention) return editor; + } + await new Promise((resolve) => window.setTimeout(resolve, 80)); + } + throw new Error("Codex 对话输入框没有生成 manage-taskboard Skill 引用"); + } + + async function createThreadForTask(payload) { + const taskId = typeof payload?.taskId === "string" ? payload.taskId.trim() : ""; + const identifier = typeof payload?.identifier === "string" ? payload.identifier.trim() : ""; + const instruction = typeof payload?.instruction === "string" ? payload.instruction.trim() : ""; + const skillName = typeof payload?.skillName === "string" ? payload.skillName.trim() : ""; + const skillDisplayName = typeof payload?.skillDisplayName === "string" + ? payload.skillDisplayName.trim() + : ""; + const skillPath = typeof payload?.skillPath === "string" ? payload.skillPath.trim() : ""; + const workspacePath = typeof payload?.workspacePath === "string" + ? payload.workspacePath.trim() + : ""; + if ( + !taskId + || !identifier + || !instruction + || !skillName + || !skillDisplayName + || !skillPath + || pendingThreadCreation + ) return; + pendingThreadCreation = taskId; + try { + const bridge = window.electronBridge; + if (!bridge || typeof bridge.sendMessageFromView !== "function") { + throw new Error("当前 Codex 版本没有提供原生对话导航能力"); + } + + if (workspacePath) { + await bridge.sendMessageFromView({ + type: "electron-set-active-workspace-root", + root: workspacePath, + }); + } else { + await ensureProjectRows(); + const snapshotProjectId = hostContextSnapshot?.projectId || ""; + const requestedProjectId = typeof payload.codexProjectId === "string" + ? payload.codexProjectId.trim() + : ""; + const row = projectRowByLabel(payload.workspaceLabel) + || projectRowById(requestedProjectId) + || projectRowById(snapshotProjectId) + || projectRowByLabel(payload.projectName); + if (row?.getAttribute("data-app-action-sidebar-project-collapsed") === "true") { + row.click?.(); + await new Promise((resolve) => window.setTimeout(resolve, 120)); + } + const selectProject = row?.querySelector("[data-app-action-sidebar-select-project]"); + selectProject?.click?.(); + if (selectProject) await new Promise((resolve) => window.setTimeout(resolve, 120)); + } + + closeTaskboard(false); + await dispatchHostMessage({ + type: "navigate-to-route", + path: "/", + state: { + focusComposerNonce: Date.now(), + }, + }); + await requestHostTaskComposerPrefill({ + instruction, + skillDisplayName, + skillName, + skillPath, + }); + await waitForPreparedComposer(identifier, skillPath); + postToFrame({ type: "taskboard:thread-prepared", payload: { taskId } }); + } catch (error) { + postToFrame({ + type: "taskboard:thread-create-error", + payload: { taskId, error: error instanceof Error ? error.message : "无法创建 Codex 对话" }, + }); + } finally { + pendingThreadCreation = null; + } + } + + function buildAutomationHostPayload(payload) { + return { + requestId: payload.requestId, + operation: payload.operation, + taskboardProjectId: payload.taskboardProjectId, + codexProjectId: payload.codexProjectId, + projectName: payload.projectName, + workspacePath: payload.workspacePath, + skillPath: payload.skillPath, + ...(payload.automationId === undefined ? {} : { automationId: payload.automationId }), + enabledByUser: payload.enabledByUser, + quotaAware: payload.quotaAware, + intervalMinutes: payload.intervalMinutes, + model: payload.model, + reasoningEffort: payload.reasoningEffort, + }; + } + + async function handleAutomationRequest(payload) { + const requestId = typeof payload?.requestId === "string" ? payload.requestId : ""; + if (!requestId) return; + if (!isLocalTaskboardOrigin(frameOrigin)) { + postToFrame({ + type: "taskboard:automation-response", + payload: { requestId, ok: false, error: "仅本地任务面板可用" }, + }); + return; + } + try { + const response = await requestHost( + "automation", + buildAutomationHostPayload(payload), + ); + postToFrame({ + type: "taskboard:automation-response", + payload: response.error + ? { requestId, ok: false, error: response.error } + : { + requestId, + ok: true, + item: response.item, + items: response.items, + quota: response.quota, + policy: response.policy, + }, + }); + } catch (error) { + postToFrame({ + type: "taskboard:automation-response", + payload: { + requestId, + ok: false, + error: error instanceof Error ? error.message : "Codex 自动任务操作失败", + }, + }); + } + } + + function onFrameMessage(event) { + if (!frame || event.source !== frame.contentWindow || event.origin !== frameOrigin) return; + const message = event.data; + if (!message || typeof message !== "object") return; + if (message.type === "taskboard:ready") { + frameReady = true; + frameReadyWaiters.forEach(({ resolve, timer }) => { + window.clearTimeout(timer); + resolve(); + }); + frameReadyWaiters.clear(); + if (active) showFrame(); + postHostContext(); + return; + } + if (message.type === "taskboard:drag-region") { + updateDragRegion(message.payload); + return; + } + if (message.type === "taskboard:open-thread") { + void openThread(message.payload?.threadId, message.payload?.workspacePath, message.payload?.threadTitle); + return; + } + if (message.type === "taskboard:show-thread-panel") { + void showNativeThreadPanel(message.payload); + return; + } + if (message.type === "taskboard:expand-sidebar") { + expandNativeSidebar(); + return; + } + if (message.type === "taskboard:automation-request") { + void handleAutomationRequest(message.payload); + return; + } + if (message.type === "taskboard:create-thread") void createThreadForTask(message.payload); + } + + function updateDragRegion(payload) { + if (!dragRegion || !noDragLeft || !noDragRight) return; + const [x, y, width, height] = [payload?.x, payload?.y, payload?.width, payload?.height]; + if (![x, y, width, height].every((value) => Number.isFinite(value)) || width <= 0 || height <= 0) { + dragRegion.hidden = true; + noDragLeft.hidden = true; + noDragRight.hidden = true; + return; + } + const left = Math.max(0, x); + const right = left + width; + dragRegion.style.left = `${left}px`; + dragRegion.style.top = `${Math.max(0, y)}px`; + dragRegion.style.width = `${width}px`; + dragRegion.style.height = `${height}px`; + noDragLeft.style.left = "0"; + noDragLeft.style.top = `${Math.max(0, y)}px`; + noDragLeft.style.width = `${left}px`; + noDragLeft.style.height = `${height}px`; + noDragRight.style.left = `${right}px`; + noDragRight.style.top = `${Math.max(0, y)}px`; + noDragRight.style.right = "0"; + noDragRight.style.height = `${height}px`; + dragRegion.hidden = false; + noDragLeft.hidden = left <= 0; + noDragRight.hidden = right >= page.clientWidth; + } + + function createPage() { + const section = document.createElement("section"); + section.id = PAGE_ID; + section.hidden = true; + section.setAttribute(OWNED_ATTRIBUTE, "true"); + section.setAttribute("role", "region"); + section.setAttribute("aria-label", "任务面板"); + + status = document.createElement("div"); + status.id = STATUS_ID; + status.setAttribute("role", "status"); + status.setAttribute("aria-live", "polite"); + section.appendChild(status); + + dragRegion = document.createElement("div"); + dragRegion.id = DRAG_REGION_ID; + dragRegion.hidden = true; + dragRegion.setAttribute(OWNED_ATTRIBUTE, "true"); + dragRegion.setAttribute("aria-hidden", "true"); + section.appendChild(dragRegion); + + noDragLeft = document.createElement("div"); + noDragLeft.id = NO_DRAG_LEFT_ID; + noDragLeft.hidden = true; + noDragLeft.setAttribute(OWNED_ATTRIBUTE, "true"); + noDragLeft.setAttribute("aria-hidden", "true"); + section.appendChild(noDragLeft); + + noDragRight = document.createElement("div"); + noDragRight.id = NO_DRAG_RIGHT_ID; + noDragRight.hidden = true; + noDragRight.setAttribute(OWNED_ATTRIBUTE, "true"); + noDragRight.setAttribute("aria-hidden", "true"); + section.appendChild(noDragRight); + return section; + } + + function showLoading() { + if (!status) return; + status.replaceChildren(document.createTextNode("正在启动任务面板…")); + status.hidden = false; + if (frame) frame.hidden = true; + } + + function showFrame() { + if (status) status.hidden = true; + if (frame) { + frame.hidden = false; + frame.focus?.(); + } + } + + function showLoadError(message) { + if (!status) return; + const content = document.createElement("div"); + const text = document.createElement("div"); + text.textContent = message; + const retry = document.createElement("button"); + retry.type = "button"; + retry.textContent = "重新启动"; + retry.addEventListener("click", openTaskboard, { once: true }); + content.append(text, retry); + status.replaceChildren(content); + status.hidden = false; + if (frame) frame.hidden = true; + } + + function cancelFrameReadyWaiters(error) { + frameReadyWaiters.forEach(({ reject, timer }) => { + window.clearTimeout(timer); + reject(error); + }); + frameReadyWaiters.clear(); + } + + function waitForFrameReady() { + if (frameReady) return Promise.resolve(); + return new Promise((resolve, reject) => { + const waiter = { + resolve, + reject, + timer: window.setTimeout(() => { + frameReadyWaiters.delete(waiter); + reject(new Error("任务面板页面加载超时")); + }, FRAME_READY_TIMEOUT_MS), + }; + frameReadyWaiters.add(waiter); + }); + } + + function loadTaskboardFrame(cacheBust = false) { + cancelFrameReadyWaiters(new Error("任务面板正在重新加载")); + frame?.remove(); + frame = null; + frameReady = false; + if (dragRegion) dragRegion.hidden = true; + if (noDragLeft) noDragLeft.hidden = true; + if (noDragRight) noDragRight.hidden = true; + + const taskboardUrl = resolveTaskboardUrl(); + if (cacheBust) { + taskboardUrl.searchParams.set(FRAME_REFRESH_PARAM, Date.now().toString(36)); + } + frameOrigin = taskboardUrl.origin; + const nextFrame = document.createElement("iframe"); + nextFrame.id = FRAME_ID; + nextFrame.hidden = true; + nextFrame.src = taskboardUrl.href; + nextFrame.title = "任务面板"; + nextFrame.referrerPolicy = "no-referrer"; + nextFrame.setAttribute("allow", "clipboard-read; clipboard-write"); + nextFrame.addEventListener("load", postHostContext); + frame = nextFrame; + page.appendChild(nextFrame); + } + + function reloadFrame() { + if (!frame) return false; + const generation = ++openGeneration; + if (active) showLoading(); + loadTaskboardFrame(true); + if (active) { + void waitForFrameReady() + .then(() => { + if (!active || generation !== openGeneration) return; + showFrame(); + postHostContext(); + }) + .catch((error) => { + if (!active || generation !== openGeneration) return; + showLoadError(error.message); + }); + } + return true; + } + + function managedTaskboardOrigin() { + const configured = typeof window.__CODEX_TASKBOARD_MANAGED_ORIGIN__ === "string" + ? window.__CODEX_TASKBOARD_MANAGED_ORIGIN__.trim() + : ""; + try { + return new URL(configured || DEFAULT_TASKBOARD_URL).origin; + } catch (_) { + return new URL(DEFAULT_TASKBOARD_URL).origin; + } + } + + function hasLiveHostBinding() { + const heartbeat = Number(window[HOST_HEARTBEAT_NAME]); + return typeof window[HOST_BINDING_NAME] === "function" + && Number.isFinite(heartbeat) + && Date.now() - heartbeat <= HOST_HEARTBEAT_MAX_AGE_MS; + } + + function requestHost(action, payload = {}) { + const binding = window[HOST_BINDING_NAME]; + if (!hasLiveHostBinding()) { + return Promise.reject(new Error("Taskboard 启动器未运行,无法操作 Codex 对话输入框")); + } + + const id = `${Date.now().toString(36)}-${(++hostRequestSequence).toString(36)}`; + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + hostRequests.delete(id); + reject(new Error("任务面板启动器没有响应")); + }, HOST_REQUEST_TIMEOUT_MS); + hostRequests.set(id, { resolve, reject, timeout }); + try { + binding(JSON.stringify({ ...payload, id, action })); + } catch (error) { + window.clearTimeout(timeout); + hostRequests.delete(id); + reject(error); + } + }); + } + + function requestHostEnsure(taskboardUrl) { + if (taskboardUrl.origin !== managedTaskboardOrigin() || !hasLiveHostBinding()) { + return Promise.resolve({ managed: false, restarted: false }); + } + return requestHost("ensure"); + } + + function requestHostTaskComposerPrefill({ + instruction, + skillDisplayName, + skillName, + skillPath, + }) { + return requestHost("prefill-task-composer", { + instruction, + skillDisplayName, + skillName, + skillPath, + }); + } + + function frameMatchesTaskboardUrl(taskboardUrl) { + if (!frame) return false; + try { + const loadedUrl = new URL(frame.getAttribute("src") || frame.src); + loadedUrl.searchParams.delete(FRAME_REFRESH_PARAM); + const expectedUrl = new URL(taskboardUrl.href); + expectedUrl.searchParams.delete(FRAME_REFRESH_PARAM); + return loadedUrl.href === expectedUrl.href; + } catch (_) { + return false; + } + } + + function onHostResponse(response) { + if (!response || typeof response !== "object" || typeof response.id !== "string") return; + const pending = hostRequests.get(response.id); + if (!pending) return; + window.clearTimeout(pending.timeout); + hostRequests.delete(response.id); + if (response.ok) pending.resolve(response); + else pending.reject(new Error(response.error || "任务面板服务启动失败")); + } + + async function prepareTaskboard(generation) { + const taskboardUrl = resolveTaskboardUrl(); + const canReuseFrame = Boolean( + frameReady + && frame?.isConnected + && frameMatchesTaskboardUrl(taskboardUrl), + ); + if (canReuseFrame) showFrame(); + else showLoading(); + + try { + const [result, context] = await Promise.all([ + requestHostEnsure(taskboardUrl), + captureHostContext(), + ]); + if (!active || generation !== openGeneration) return; + hostContextSnapshot = context; + if (!frameReady || result.restarted || !frameMatchesTaskboardUrl(taskboardUrl)) { + showLoading(); + loadTaskboardFrame(); + await waitForFrameReady(); + } + if (!active || generation !== openGeneration) return; + showFrame(); + postHostContext(); + } catch (error) { + if (!active || generation !== openGeneration) return; + const bindingAvailable = hasLiveHostBinding(); + showLoadError(bindingAvailable + ? error.message + : "任务面板服务未就绪。请保持 Taskboard 启动器运行后重试。"); + } + } + + function restoreNativeContent() { + document.querySelectorAll(`[${HIDDEN_ATTRIBUTE}="true"]`) + .forEach((node) => node.removeAttribute(HIDDEN_ATTRIBUTE)); + document.querySelectorAll(`[${HOST_ATTRIBUTE}="true"]`) + .forEach((node) => node.removeAttribute(HOST_ATTRIBUTE)); + } + + function mountActivePage() { + if (!active) return; + if (!page) page = createPage(); + const mount = findPageMount(); + if (!mount) return; + const { surface } = mount; + + if (page.parentElement !== surface) { + restoreNativeContent(); + surface.appendChild(page); + } + surface.setAttribute(HOST_ATTRIBUTE, "true"); + Array.from(surface.children).forEach((child) => { + if (child === nativeThreadPanelHost) { + child.removeAttribute(HIDDEN_ATTRIBUTE); + child.setAttribute(NATIVE_THREAD_HOST_ATTRIBUTE, "true"); + return; + } + if (child !== page && child.getAttribute(OWNED_ATTRIBUTE) !== "true") { + child.setAttribute(HIDDEN_ATTRIBUTE, "true"); + } + }); + hideNativeHeader(); + muteNativeSelection(); + page.hidden = false; + syncNativeThreadPanelLayout(); + document.documentElement.setAttribute("data-codex-taskboard-open", "true"); + } + + function closeTaskboard(restoreFocus = true) { + if (!active && page?.hidden !== false) return; + openGeneration += 1; + active = false; + closeNativeThreadPanel({ remountTaskboard: false }); + if (page) page.hidden = true; + restoreNativeContent(); + restoreNativeSelection(); + document.documentElement.removeAttribute("data-codex-taskboard-open"); + syncEntryState(); + if (restoreFocus) lastFocusedElement?.focus?.(); + lastFocusedElement = null; + hostContextSnapshot = null; + } + + function openTaskboard() { + if (destroyed) return; + if (!active) { + lastFocusedElement = document.activeElement; + hostContextSnapshot = null; + } + const generation = ++openGeneration; + active = true; + ensureEntry(); + mountActivePage(); + syncEntryState(); + void prepareTaskboard(generation); + } + + function isNativePageNavigation(target) { + const clickable = target?.closest?.("button,a,[role='button'],[data-app-action-sidebar-thread-id]"); + if (!clickable || clickable === entry || clickable.closest(`#${ENTRY_ID}`)) return false; + if (!clickable.closest("aside nav[role='navigation']")) return false; + if (clickable.hasAttribute("data-app-action-sidebar-section-toggle")) return false; + if (buttonMatches(clickable, NATIVE_PAGE_LABELS)) return true; + return Boolean(clickable.closest( + "[data-app-action-sidebar-thread-id]," + + "[data-app-action-sidebar-project-row]," + + "[data-app-action-sidebar-project-id]", + )); + } + + function onDocumentClick(event) { + const threadRow = event.target?.closest?.("[data-app-action-sidebar-thread-id]"); + const clickedThreadId = normalizeThreadId(threadRow?.getAttribute?.("data-app-action-sidebar-thread-id")); + if (clickedThreadId) lastNativeThreadId = clickedThreadId; + if (clickedThreadId && clickedThreadId === normalizeThreadId(nativeThreadPanelThreadId)) return; + if (Date.now() < suppressNativeCloseUntil) return; + if (!active || !isNativePageNavigation(event.target)) return; + closeTaskboard(false); + } + + function scheduleRefresh() { + if (destroyed || reattachTimer !== null) return; + reattachTimer = window.setTimeout(() => { + reattachTimer = null; + ensureEntry(); + mountActivePage(); + postHostContext(); + }, REATTACH_DELAY_MS); + } + + function refresh() { + ensureEntry(); + mountActivePage(); + postHostContext(); + } + + function mount() { + document.removeEventListener("DOMContentLoaded", mount); + if (destroyed || observer || !document.documentElement) return; + ensureEntry(); + observer = new MutationObserver(scheduleRefresh); + observer.observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: [ + "class", + "data-theme", + "data-color-theme", + "data-app-action-sidebar-project-label", + "data-app-action-sidebar-thread-active", + "aria-label", + "aria-current", + ], + }); + } + + function destroy() { + if (destroyed) return; + destroyed = true; + if (reattachTimer !== null) window.clearTimeout(reattachTimer); + reattachTimer = null; + observer?.disconnect(); + observer = null; + cancelFrameReadyWaiters(new Error("任务面板已关闭")); + hostRequests.forEach(({ reject, timeout }) => { + window.clearTimeout(timeout); + reject(new Error("任务面板已关闭")); + }); + hostRequests.clear(); + pendingThreadCreation = null; + document.removeEventListener("DOMContentLoaded", mount); + document.removeEventListener("click", onDocumentClick, true); + window.removeEventListener("message", onFrameMessage); + window.removeEventListener("popstate", onNativeRouteChange); + window.removeEventListener("hashchange", onNativeRouteChange); + window.removeEventListener("resize", scheduleRefresh); + closeTaskboard(false); + document.querySelectorAll(`[${OWNED_ATTRIBUTE}="true"]`).forEach((node) => node.remove()); + entry = null; + page = null; + frame = null; + dragRegion = null; + noDragLeft = null; + noDragRight = null; + status = null; + frameOrigin = ""; + if (window[SENTINEL_KEY] === api) delete window[SENTINEL_KEY]; + } + + function onNativeRouteChange() { + if (nativeThreadPanelThreadId) { + suppressNativeCloseUntil = Date.now() + 1_000; + if (nativeThreadPanelCanAttach) scheduleNativeThreadPanelAttach(); + return; + } + if (active) closeTaskboard(false); + } + + const api = { + version: VERSION, + sourceHash: SOURCE_HASH, + refresh, + reloadFrame, + open: openTaskboard, + close: closeTaskboard, + destroy, + hostResponse: onHostResponse, + }; + window[SENTINEL_KEY] = api; + + window.addEventListener("message", onFrameMessage); + window.addEventListener("popstate", onNativeRouteChange); + window.addEventListener("hashchange", onNativeRouteChange); + window.addEventListener("resize", scheduleRefresh); + document.addEventListener("click", onDocumentClick, true); + if (document.documentElement) mount(); + else document.addEventListener("DOMContentLoaded", mount, { once: true }); +})(); diff --git a/apps/codex-taskboard/package-lock.json b/apps/codex-taskboard/package-lock.json new file mode 100644 index 000000000..65e30cfeb --- /dev/null +++ b/apps/codex-taskboard/package-lock.json @@ -0,0 +1,4647 @@ +{ + "name": "codex-taskboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codex-taskboard", + "version": "0.1.0", + "dependencies": { + "@lobehub/icons-static-svg": "^1.94.0", + "@xyflow/react": "^12.11.2", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "bin": { + "taskctl": "cli/taskctl.mjs" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "miniflare": "^4.20260722.0", + "typescript": "^7.0.2", + "vite": "^8.1.5", + "wrangler": "^4.114.0" + }, + "engines": { + "node": ">=22.5" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260722.1.tgz", + "integrity": "sha512-vZOP8vIS3NwnuaO+gz0FZ7kIGeiO3bZmxV35Ph9zOXKSREhDFlH7wQ7mkCdhW3O4jnXsew+XT7b+DNEI2CcJGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260722.1.tgz", + "integrity": "sha512-EmIQymihDq6WNdER4+LF8Qn80yqayBUpJ+tkOO7wmY8pmgfyXjIUFNXotl21AHovTeu2seR7HdVUgeN/BilCWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260722.1.tgz", + "integrity": "sha512-jvZ3k9fxcnEn04s80CgIYxQfpOyAiz/8qC42DP8EBa9tR27qWyg9wmm31zIobVlrgBZn/+8NfdP73avRGcQOjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260722.1.tgz", + "integrity": "sha512-BOSB55SMNdy+DA5uj2WirgiNanpHGis5PVvXH1wSfvjRKr4JGgWK+EZzxz0RFUo6QjjQQC/NimEzNZ7va7jmKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260722.1.tgz", + "integrity": "sha512-sYM8YgUpKnRz2xjvdJLX1Ojzoi4MlA4gk8WTTExhGydjYB2UTs5NIbv0ZmpKgMoK9io3ixgmiW56ZnTbcWOdiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@lobehub/icons-static-svg": { + "version": "1.94.0", + "resolved": "https://registry.npmjs.org/@lobehub/icons-static-svg/-/icons-static-svg-1.94.0.tgz", + "integrity": "sha512-Inx1TYkjLH6YeHOIHeVW9+OM/xxRnk8TmcQVKquFUDBmE3X9sUuRGt7kALrrDBNNAbrWz7Qq6fAiFj9E9Mmw9Q==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@xyflow/react": { + "version": "12.11.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.79", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/miniflare": { + "version": "4.20260722.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260722.0.tgz", + "integrity": "sha512-LW6ABMhCx/yIEFBLC/DO4yAhdm2T/G7jp7pr5T2kj895+CCIaHZqpMXdW9O6YE48LcYcCJChwWc8aEs1vpbTXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.28.0", + "workerd": "1.20260722.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/workerd": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260722.1.tgz", + "integrity": "sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260722.1", + "@cloudflare/workerd-darwin-arm64": "1.20260722.1", + "@cloudflare/workerd-linux-64": "1.20260722.1", + "@cloudflare/workerd-linux-arm64": "1.20260722.1", + "@cloudflare/workerd-windows-64": "1.20260722.1" + } + }, + "node_modules/wrangler": { + "version": "4.114.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.114.0.tgz", + "integrity": "sha512-M65P25t5UHA1TIJfgZXDcj+YzVobgKdRguM2QPz0xnxLFuOcuE3ErgllDht0iaho7MS4o0g/Bb4YK2+GT+bibg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "4.20260722.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260722.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260722.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/apps/codex-taskboard/package.json b/apps/codex-taskboard/package.json new file mode 100644 index 000000000..30dd4173c --- /dev/null +++ b/apps/codex-taskboard/package.json @@ -0,0 +1,52 @@ +{ + "name": "codex-taskboard", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22.5" + }, + "bin": { + "taskctl": "./cli/taskctl.mjs" + }, + "scripts": { + "codex": "node scripts/codex-injector.mjs --launch --watch --open", + "codex:inject": "node scripts/codex-injector.mjs --watch", + "codex:daemon": "node scripts/codex-injector.mjs --daemon --open", + "codex:refresh": "node scripts/codex-injector.mjs --refresh", + "dev": "node scripts/dev.mjs", + "dev:server": "node --watch server/index.mjs --dev", + "dev:web": "vite --config web/vite.config.ts", + "dev:cloud": "npm run build:web && wrangler dev --local", + "build": "vite build --config web/vite.config.ts && node scripts/codex-injector.mjs --refresh-if-running", + "build:web": "vite build --config web/vite.config.ts", + "typecheck": "tsc -p web/tsconfig.json --noEmit", + "start": "node server/index.mjs", + "taskctl": "node cli/taskctl.mjs", + "test": "node --test", + "test:cloud": "node --test test/cloud-shared-worker.test.mjs", + "cloud:migrate:local": "wrangler d1 migrations apply codex-taskboard-db --local", + "cloud:migrate": "wrangler d1 migrations apply codex-taskboard-db --remote", + "cloud:deploy:dry-run": "npm run build:web && wrangler deploy --dry-run", + "cloud:deploy": "npm run build:web && wrangler deploy", + "cloud:data": "node scripts/migrate-to-cloud.mjs", + "check": "npm run typecheck && npm run build && npm test" + }, + "dependencies": { + "@lobehub/icons-static-svg": "^1.94.0", + "@xyflow/react": "^12.11.2", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "miniflare": "^4.20260722.0", + "typescript": "^7.0.2", + "vite": "^8.1.5", + "wrangler": "^4.114.0" + } +} diff --git a/apps/codex-taskboard/scripts/codex-injector-runtime.mjs b/apps/codex-taskboard/scripts/codex-injector-runtime.mjs new file mode 100644 index 000000000..7d9814388 --- /dev/null +++ b/apps/codex-taskboard/scripts/codex-injector-runtime.mjs @@ -0,0 +1,165 @@ +const HOST_REQUEST_ERROR = "自动认领配置暂时无法应用,请刷新后重试"; +const AUTOMATION_SCHEMA_DIAGNOSTIC = "AUTOMATION_SCHEMA_MISMATCH"; + +function parseHostRequest(payload, parseAutomationRequest) { + if (typeof payload !== "string" || payload.length > 4_096) { + return { id: null, request: null, error: HOST_REQUEST_ERROR }; + } + + let request; + try { + request = JSON.parse(payload); + } catch { + return { id: null, request: null, error: HOST_REQUEST_ERROR }; + } + + const id = ( + request + && typeof request.id === "string" + && /^[a-z0-9-]{1,80}$/i.test(request.id) + ) ? request.id : null; + if (!id) return { id: null, request: null, error: HOST_REQUEST_ERROR }; + if (request.action === "ensure") return { id, request, error: null }; + if (request.action === "automation") { + const parsed = parseAutomationRequest(request); + return parsed + ? { id, request: parsed, error: null } + : { + id, + request: null, + error: HOST_REQUEST_ERROR, + diagnosticCode: AUTOMATION_SCHEMA_DIAGNOSTIC, + }; + } + if ( + request.action === "prefill-task-composer" + && typeof request.instruction === "string" + && request.instruction.length > 0 + && request.instruction.length <= 1_024 + && typeof request.skillName === "string" + && /^[a-z0-9][a-z0-9-]{0,79}$/i.test(request.skillName) + && typeof request.skillDisplayName === "string" + && request.skillDisplayName.length > 0 + && request.skillDisplayName.length <= 120 + && typeof request.skillPath === "string" + && request.skillPath.length > 0 + && request.skillPath.length <= 1_024 + ) { + return { id, request, error: null }; + } + return { id, request: null, error: HOST_REQUEST_ERROR }; +} + +export async function handleHostBindingPayload(params, handlers) { + const parsed = parseHostRequest(params.payload, handlers.parseAutomationRequest); + if (!parsed.request) { + if (!parsed.id) return { responded: false, accepted: false }; + await handlers.sendResponse(params.executionContextId, { + id: parsed.id, + ok: false, + error: parsed.error, + ...(parsed.diagnosticCode ? { diagnosticCode: parsed.diagnosticCode } : {}), + }); + return { responded: true, accepted: false }; + } + + try { + let result; + if (parsed.request.action === "ensure") { + result = await handlers.ensure(); + } else if (parsed.request.action === "automation") { + result = await handlers.runAutomation(parsed.request, params.executionContextId); + } else { + result = await handlers.prefill(parsed.request, params.executionContextId); + } + await handlers.sendResponse(params.executionContextId, { + id: parsed.request.id, + ok: true, + ...result, + }); + } catch (error) { + await handlers.sendResponse(params.executionContextId, { + id: parsed.request.id, + ok: false, + error: error.message, + }); + } + return { responded: true, accepted: true }; +} + +export async function reconcileInjectionRuntime({ + currentStatus, + source, + sourceHash, + removeRegisteredSource, + registerCurrentSource, + evaluateCurrentSource, + publishRegistration, + reopen, +}) { + if (currentStatus.scriptIdentifier) { + try { + await removeRegisteredSource(currentStatus.scriptIdentifier); + } catch {} + } + const scriptIdentifier = await registerCurrentSource(source); + await evaluateCurrentSource(source); + await publishRegistration(scriptIdentifier); + const replaced = currentStatus.sourceHash !== sourceHash; + const shouldRemainOpen = currentStatus.pageVisible === true; + if (replaced && shouldRemainOpen) await reopen(); + return { replaced, scriptIdentifier, shouldRemainOpen }; +} + +export function findResidentInjectorPids({ + processList, + currentPid, + injectorPath, + projectRoot, + port, + defaultPort, + cwdForPid, +}) { + const absoluteScript = new RegExp( + `(?:^|\\s)${escapeRegExp(injectorPath)}(?=\\s|$)`, + ); + const relativeScript = /(?:^|\s)(?:\.\/)?scripts\/codex-injector\.mjs(?=\s|$)/; + const residents = []; + + for (const line of processList.split("\n")) { + const match = line.trim().match(/^(\d+)\s+(.+)$/); + if (!match) continue; + const pid = Number(match[1]); + const command = match[2]; + if (pid === currentPid || !/(?:^|\s)--watch(?=\s|$)/.test(command)) continue; + const scriptMatches = absoluteScript.test(command) + || (relativeScript.test(command) && cwdForPid(pid) === projectRoot); + if (!scriptMatches || commandPort(command, defaultPort) !== port) continue; + residents.push(pid); + } + return residents; +} + +export async function restartResidentInjector(port, handlers) { + const previousPids = handlers.findResidents(port); + if (previousPids.length === 0) return { previousPids: [], pid: null, restarted: false }; + + for (const pid of previousPids) await handlers.stopResident(pid); + const startupToken = handlers.createStartupToken(); + const started = handlers.startResident(port, startupToken); + await handlers.waitUntilReady(port, started.pid, startupToken); + return { + previousPids, + pid: started.pid, + restarted: true, + }; +} + +function commandPort(command, defaultPort) { + const match = command.match(/(?:^|\s)--port(?:=(\d+)|\s+(\d+))(?=\s|$)/); + return match ? Number(match[1] ?? match[2]) : defaultPort; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/apps/codex-taskboard/scripts/codex-injector.mjs b/apps/codex-taskboard/scripts/codex-injector.mjs new file mode 100644 index 000000000..50072853d --- /dev/null +++ b/apps/codex-taskboard/scripts/codex-injector.mjs @@ -0,0 +1,1425 @@ +#!/usr/bin/env node + +import { spawn, spawnSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +import { resolvePort } from "../server/app.mjs"; +import { + parseTaskboardAutomationHostRequest, + reconcileTaskboardAutomation, +} from "../shared/taskboard-automation.mjs"; +import { + findResidentInjectorPids, + handleHostBindingPayload, + reconcileInjectionRuntime, + restartResidentInjector, +} from "./codex-injector-runtime.mjs"; +import { readCodexQuotaStatus } from "./codex-rate-limits.mjs"; + +const injectorPath = fileURLToPath(import.meta.url); +const projectRoot = path.resolve(path.dirname(injectorPath), ".."); +const defaultCodexDebuggingPort = 9229; +const injectionPath = path.join(projectRoot, "inject", "codex-taskboard.user.js"); +const automationPoliciesPath = path.join(projectRoot, ".data", "codex-automation-policies.json"); +const taskboardOrigin = `http://127.0.0.1:${resolvePort()}`; +const taskboardHealthUrl = `${taskboardOrigin}/health`; +const taskboardPageUrl = `${taskboardOrigin}/?host=codex`; +const cdpHosts = [...new Set([process.env.CODEX_CDP_HOST, "localhost", "127.0.0.1"].filter(Boolean))]; +const hostBindingName = "__codexTaskboardHostV1"; +const hostHeartbeatName = "__codexTaskboardHostHeartbeatV1"; +const hostStartupTokenName = "__codexTaskboardHostStartupTokenV1"; +const injectionSourceHashName = "__CODEX_TASKBOARD_SOURCE_HASH__"; +const injectionScriptIdentifierName = "__CODEX_TASKBOARD_SCRIPT_IDENTIFIER__"; +const codexAutomationMethods = new Set([ + "list-automations", + "automation-create", + "automation-update", +]); +let codexAutomationRequestSequence = 0; +const quotaPolicyTimers = new Map(); +const quotaPolicyRecords = new Map(); +const quotaPolicyQueues = new Map(); +let quotaPoliciesLoadPromise = null; +let quotaPoliciesWritePromise = Promise.resolve(); +let quotaPoliciesRestored = false; + +function parseArgs(argv) { + const options = { + port: defaultCodexDebuggingPort, + portExplicit: false, + launch: false, + watch: false, + open: false, + refresh: false, + refreshIfRunning: false, + attachExisting: false, + startupToken: null, + daemon: false, + backgroundTaskboard: false, + screenshot: null, + appPath: "/Applications/ChatGPT.app", + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--launch") options.launch = true; + else if (arg === "--watch") options.watch = true; + else if (arg === "--open") options.open = true; + else if (arg === "--refresh") options.refresh = true; + else if (arg === "--refresh-if-running") options.refreshIfRunning = true; + else if (arg === "--attach-existing") options.attachExisting = true; + else if (arg === "--startup-token") { + options.startupToken = argv[++index]; + if (!/^[a-z0-9-]{1,100}$/i.test(options.startupToken || "")) { + throw new Error("--startup-token must be an identifier"); + } + } + else if (arg === "--daemon") options.daemon = true; + else if (arg === "--background-taskboard") options.backgroundTaskboard = true; + else if (arg === "--port") { + options.port = Number(argv[++index]); + options.portExplicit = true; + } + else if (arg === "--screenshot") options.screenshot = path.resolve(argv[++index]); + else if (arg === "--app-path") options.appPath = path.resolve(argv[++index]); + else throw new Error(`Unknown option: ${arg}`); + } + + if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) { + throw new Error("--port must be an integer between 1 and 65535"); + } + return options; +} + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); + return response.json(); +} + +async function isReachable(url) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1_500) }); + return response.ok; + } catch { + return false; + } +} + +async function waitUntilReachable(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await isReachable(url)) return; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${url}`); +} + +function cdpHttpUrl(host, port, pathname = "") { + return `http://${host}:${port}${pathname}`; +} + +async function resolveCdpBaseUrl(port) { + for (const host of cdpHosts) { + const url = cdpHttpUrl(host, port, "/json/version"); + if (await isReachable(url)) return cdpHttpUrl(host, port); + } + return null; +} + +async function waitUntilCdpReachable(port, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const baseUrl = await resolveCdpBaseUrl(port); + if (baseUrl) return baseUrl; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for Codex CDP on ${cdpHosts.map((host) => `${host}:${port}`).join(" or ")}`); +} + +async function fetchCdpJson(port, pathname) { + const baseUrl = await resolveCdpBaseUrl(port); + if (!baseUrl) { + throw new Error(`Codex CDP is not listening on ${cdpHosts.map((host) => `${host}:${port}`).join(" or ")}`); + } + return fetchJson(`${baseUrl}${pathname}`); +} + +function startTaskboard({ detached }) { + return spawn(process.execPath, [path.join(projectRoot, "server", "index.mjs")], { + cwd: projectRoot, + detached, + stdio: detached ? "ignore" : "inherit", + windowsHide: process.platform === "win32", + }); +} + +function createTaskboardSupervisor({ detached }) { + let child = null; + let ensureInFlight = null; + let retryAfter = 0; + let stopping = false; + + async function ensure({ force = false } = {}) { + if (await isReachable(taskboardHealthUrl)) { + return { status: "ok", restarted: false }; + } + if (ensureInFlight) return ensureInFlight; + if (!force && Date.now() < retryAfter) { + throw new Error("Taskboard restart is waiting before its next attempt"); + } + + ensureInFlight = (async () => { + if (child?.exitCode === null && !child.killed) { + try { + await waitUntilReachable(taskboardHealthUrl, 3_000); + return { status: "ok", restarted: false }; + } catch (_) {} + } + + const started = startTaskboard({ detached }); + child = started; + if (detached) started.unref(); + started.once("error", (error) => { + if (!stopping) console.error(`Taskboard process error: ${error.message}`); + }); + started.once("exit", (code, signal) => { + if (child === started) child = null; + if (!stopping && !detached && code !== 0) { + console.error(`Taskboard exited (${signal || code}); it will be restarted automatically.`); + } + }); + + try { + await waitUntilReachable(taskboardHealthUrl, 10_000); + retryAfter = 0; + return { status: "ok", restarted: true }; + } catch (error) { + retryAfter = Date.now() + 2_000; + throw error; + } + })(); + + try { + return await ensureInFlight; + } finally { + ensureInFlight = null; + } + } + + function stop() { + stopping = true; + if (child?.exitCode === null && !child.killed) child.kill("SIGTERM"); + } + + return { ensure, stop }; +} + +function codexIsRunning() { + return spawnSync("/usr/bin/pgrep", ["-x", "ChatGPT"], { stdio: "ignore" }).status === 0; +} + +function launchCodex(appPath, port) { + return spawn( + "/usr/bin/open", + [ + "-W", + "-a", + appPath, + "--args", + `--remote-debugging-port=${port}`, + `--remote-allow-origins=http://localhost:${port},http://127.0.0.1:${port}`, + ], + { stdio: "ignore" }, + ); +} + +function windowsProcessRows() { + const script = "Get-CimInstance Win32_Process | ForEach-Object { if ($_.CommandLine) { '{0} {1}' -f $_.ProcessId, $_.CommandLine } }"; + const result = spawnSync("powershell.exe", [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ], { + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + windowsHide: process.platform === "win32", + }); + return result.status === 0 ? result.stdout : ""; +} + +function unixProcessRows() { + const result = spawnSync("/bin/ps", ["-axo", "pid=,command="], { + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + }); + return result.status === 0 ? result.stdout : ""; +} + +function processRows() { + return process.platform === "win32" ? windowsProcessRows() : unixProcessRows(); +} + +class CdpConnection { + constructor(url) { + this.socket = new WebSocket(url); + this.sequence = 0; + this.pending = new Map(); + this.eventWaiters = new Map(); + this.eventHandlers = new Map(); + this.closed = false; + } + + async open() { + await new Promise((resolve, reject) => { + this.socket.addEventListener("open", resolve, { once: true }); + this.socket.addEventListener("error", () => reject(new Error("CDP WebSocket connection failed")), { + once: true, + }); + }); + this.socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)); + if (!message.id) { + const waiters = this.eventWaiters.get(message.method) || []; + this.eventWaiters.delete(message.method); + waiters.forEach((waiter) => waiter.resolve(message.params)); + const handlers = this.eventHandlers.get(message.method) || []; + handlers.forEach((handler) => { + try { + Promise.resolve(handler(message.params)).catch((error) => { + console.error(`CDP ${message.method} handler failed: ${error.message}`); + }); + } catch (error) { + console.error(`CDP ${message.method} handler failed: ${error.message}`); + } + }); + return; + } + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) pending.reject(new Error(message.error.message)); + else pending.resolve(message.result); + }); + this.socket.addEventListener("close", () => { + this.closed = true; + const error = new Error("CDP WebSocket closed"); + this.pending.forEach((pending) => pending.reject(error)); + this.pending.clear(); + this.eventWaiters.forEach((waiters) => waiters.forEach((waiter) => waiter.reject(error))); + this.eventWaiters.clear(); + this.eventHandlers.clear(); + }); + } + + send(method, params = {}) { + const id = ++this.sequence; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + waitFor(method, timeoutMs) { + return new Promise((resolve, reject) => { + const waiters = this.eventWaiters.get(method) || []; + const timeout = setTimeout(() => { + this.eventWaiters.set( + method, + (this.eventWaiters.get(method) || []).filter((waiter) => waiter.resolve !== wrappedResolve), + ); + reject(new Error(`Timed out waiting for CDP event ${method}`)); + }, timeoutMs); + const wrappedResolve = (value) => { + clearTimeout(timeout); + resolve(value); + }; + waiters.push({ resolve: wrappedResolve, reject }); + this.eventWaiters.set(method, waiters); + }); + } + + on(method, handler) { + const handlers = this.eventHandlers.get(method) || []; + handlers.push(handler); + this.eventHandlers.set(method, handlers); + return () => { + this.eventHandlers.set( + method, + (this.eventHandlers.get(method) || []).filter((candidate) => candidate !== handler), + ); + }; + } + + close() { + this.socket.close(); + } +} + +async function codexTargets(port) { + const targets = await fetchCdpJson(port, "/json/list"); + return targets.filter( + (target) => + target.type === "page" && + target.webSocketDebuggerUrl && + !target.url?.includes("initialRoute=%2Fglobal-dictation") && + (target.url?.startsWith("app://") || target.title === "Codex"), + ); +} + +function codexDebuggingPorts(preferredPort) { + const ports = new Set([preferredPort]); + + for (const line of processRows().split("\n")) { + const command = line.trim().replace(/^\d+\s+/, ""); + if (!/(?:ChatGPT|Codex)(?:\.exe|\.app)?/i.test(command)) continue; + const match = command.match(/--remote-debugging-port=(\d+)/); + if (match) ports.add(Number(match[1])); + } + return [...ports]; +} + +function processCwd(pid) { + if (process.platform === "win32") return null; + const result = spawnSync("/usr/sbin/lsof", [ + "-a", + "-p", + String(pid), + "-d", + "cwd", + "-Fn", + ], { + encoding: "utf8", + maxBuffer: 64 * 1024, + }); + if (result.status !== 0) return null; + const cwd = result.stdout.split("\n").find((line) => line.startsWith("n"))?.slice(1); + return cwd ? path.resolve(cwd) : null; +} + +function residentInjectorPids(port) { + return findResidentInjectorPids({ + processList: processRows(), + currentPid: process.pid, + injectorPath, + projectRoot, + port, + defaultPort: defaultCodexDebuggingPort, + cwdForPid: processCwd, + }); +} + +function startResidentInjector( + port, + shouldOpen, + attachExisting = false, + startupToken = null, +) { + const [existingPid] = residentInjectorPids(port); + if (existingPid) return { pid: existingPid, started: false }; + const args = [injectorPath, "--watch", "--port", String(port)]; + args.push("--background-taskboard"); + if (shouldOpen) args.push("--open"); + if (attachExisting) args.push("--attach-existing"); + if (startupToken) args.push("--startup-token", startupToken); + const child = spawn(process.execPath, args, { + cwd: projectRoot, + detached: true, + stdio: "ignore", + windowsHide: process.platform === "win32", + }); + child.unref(); + return { pid: child.pid, started: true }; +} + +async function stopResidentInjector(pid) { + process.kill(pid, "SIGTERM"); + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + await new Promise((resolve) => setTimeout(resolve, 50)); + } catch { + return; + } + } + throw new Error(`Timed out stopping resident Taskboard injector ${pid}`); +} + +async function waitForResidentInjectorReady(port, pid, startupToken, expectedSourceHash) { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + const targets = await codexTargets(port); + for (const target of targets) { + const cdp = new CdpConnection(target.webSocketDebuggerUrl); + await cdp.open(); + try { + const readiness = await cdp.send("Runtime.evaluate", { + expression: `({ + token: window[${JSON.stringify(hostStartupTokenName)}], + taskboardEntryMounted: Boolean(document.getElementById("codex-taskboard-entry")), + sourceHash: window.__codexTaskboardInjection__?.sourceHash || null + })`, + returnByValue: true, + }); + if ( + readiness.result.value?.token === startupToken + && readiness.result.value.taskboardEntryMounted + && readiness.result.value.sourceHash === expectedSourceHash + ) return; + } finally { + cdp.close(); + } + } + } catch {} + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for resident Taskboard injector ${pid}`); +} + +async function restartResidentInjectorForRefresh(port) { + const { sourceHash } = await currentInjectionSource(); + return restartResidentInjector(port, { + findResidents: residentInjectorPids, + stopResident: stopResidentInjector, + createStartupToken: randomUUID, + startResident: (targetPort, startupToken) => ( + startResidentInjector(targetPort, false, true, startupToken) + ), + waitUntilReady: (targetPort, pid, startupToken) => ( + waitForResidentInjectorReady(targetPort, pid, startupToken, sourceHash) + ), + }); +} + +async function refreshTaskboardFrames(port) { + const targets = await codexTargets(port); + const results = []; + + for (const target of targets) { + const cdp = new CdpConnection(target.webSocketDebuggerUrl); + await cdp.open(); + try { + await cdp.send("Runtime.enable"); + const evaluation = await cdp.send("Runtime.evaluate", { + expression: `(() => { + const taskboard = window.__codexTaskboardInjection__; + if (typeof taskboard?.reloadFrame === "function") { + return { refreshed: taskboard.reloadFrame(), via: "injection" }; + } + const frame = document.getElementById("codex-taskboard-frame"); + if (!frame) return { refreshed: false, via: "not-mounted" }; + const url = new URL(frame.getAttribute("src") || frame.src); + url.searchParams.set("__codex_taskboard_refresh", Date.now().toString(36)); + frame.setAttribute("src", url.href); + return { refreshed: true, via: "fallback", frameUrl: url.href }; + })()`, + returnByValue: true, + }); + if (evaluation.exceptionDetails) { + throw new Error( + evaluation.exceptionDetails.exception?.description || "Taskboard frame refresh failed", + ); + } + results.push({ + targetId: target.id, + title: target.title, + url: target.url, + ...evaluation.result.value, + }); + } finally { + cdp.close(); + } + } + + return results; +} + +function frameTreeContains(frameTree, expectedUrl) { + if (frameTree.frame?.url === expectedUrl) return true; + return frameTree.childFrames?.some((child) => frameTreeContains(child, expectedUrl)) || false; +} + +async function waitForFrame(cdp, expectedUrl, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const [{ targetInfos }, { frameTree }] = await Promise.all([ + cdp.send("Target.getTargets"), + cdp.send("Page.getFrameTree"), + ]); + if ( + targetInfos.some((target) => target.type === "iframe" && target.url === expectedUrl) || + frameTreeContains(frameTree, expectedUrl) + ) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return false; +} + +async function requestCodexAutomationViaCdp(cdp, executionContextId, method, params) { + if (!codexAutomationMethods.has(method)) { + throw new Error(`Unsupported Codex automation method: ${method}`); + } + const requestId = [ + "taskboard-automation", + process.pid, + Date.now().toString(36), + (++codexAutomationRequestSequence).toString(36), + ].join("-"); + const evaluation = await cdp.send("Runtime.evaluate", { + expression: `(() => new Promise((resolve) => { + const method = ${JSON.stringify(method)}; + const params = ${JSON.stringify(params)}; + const requestId = ${JSON.stringify(requestId)}; + const bridge = window.electronBridge; + if (!bridge || typeof bridge.sendMessageFromView !== "function") { + resolve({ ok: false, error: "当前 Codex 版本没有提供原生自动任务能力" }); + return; + } + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + window.clearTimeout(timeout); + window.removeEventListener("message", onMessage); + resolve(result); + }; + const onMessage = (event) => { + const message = event.data; + if ( + !message + || typeof message !== "object" + || message.type !== "fetch-response" + || message.requestId !== requestId + ) return; + finish({ + ok: true, + responseType: message.responseType, + status: message.status, + bodyJsonString: message.bodyJsonString, + }); + }; + const timeout = window.setTimeout( + () => finish({ ok: false, error: "Codex 自动任务接口没有响应" }), + 10_000, + ); + window.addEventListener("message", onMessage); + Promise.resolve(bridge.sendMessageFromView({ + type: "fetch", + requestId, + method: "POST", + url: \`vscode://codex/${method}\`, + body: JSON.stringify(params), + })).catch((error) => { + finish({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + }); + }))()`, + ...(Number.isInteger(executionContextId) ? { contextId: executionContextId } : {}), + awaitPromise: true, + returnByValue: true, + }); + if (evaluation.exceptionDetails) { + throw new Error( + evaluation.exceptionDetails.exception?.description + || "Codex automation request failed", + ); + } + const response = evaluation.result.value; + if (!response?.ok) throw new Error(response?.error || "Codex automation request failed"); + if (!Number.isInteger(response.status) || response.status < 200 || response.status >= 300) { + throw new Error(`Codex automation request returned HTTP ${response.status}`); + } + if (typeof response.bodyJsonString !== "string" || response.bodyJsonString.length === 0) { + return {}; + } + try { + return JSON.parse(response.bodyJsonString); + } catch { + throw new Error("Codex automation request returned invalid JSON"); + } +} + +async function applyTaskboardAutomationPolicy(request, rpc, stillCurrent = () => true) { + const quota = request.quotaAware + ? await readCodexQuotaStatus(request.model) + : null; + if (!stillCurrent()) return { quota, stale: true }; + const shouldRun = request.enabledByUser + && (!request.quotaAware || quota?.state === "available"); + const result = await reconcileTaskboardAutomation( + { ...request, operation: shouldRun ? "ensure-active" : "pause" }, + rpc, + ); + if (result?.error === "not-found") { + return { ...(quota ? { quota } : {}) }; + } + return { ...result, ...(quota ? { quota } : {}) }; +} + +function storedAutomationPolicy(request) { + return { + taskboardProjectId: request.taskboardProjectId, + codexProjectId: request.codexProjectId, + projectName: request.projectName, + workspacePath: request.workspacePath, + skillPath: request.skillPath, + ...(request.automationId ? { automationId: request.automationId } : {}), + enabledByUser: request.enabledByUser, + quotaAware: request.quotaAware, + intervalMinutes: request.intervalMinutes, + model: request.model, + reasoningEffort: request.reasoningEffort, + }; +} + +function restoredAutomationPolicy(value) { + return parseTaskboardAutomationHostRequest({ + ...value, + id: "restored-policy", + action: "automation", + requestId: "restored-policy", + operation: "apply-policy", + }); +} + +async function ensureQuotaPoliciesLoaded() { + if (quotaPoliciesLoadPromise) return quotaPoliciesLoadPromise; + quotaPoliciesLoadPromise = (async () => { + let stored = {}; + try { + stored = JSON.parse(await readFile(automationPoliciesPath, "utf8")); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + if (!stored || typeof stored !== "object" || Array.isArray(stored)) return; + for (const value of Object.values(stored)) { + const request = restoredAutomationPolicy(value); + if (!request) continue; + quotaPolicyRecords.set(request.taskboardProjectId, { version: 1, request }); + } + })(); + return quotaPoliciesLoadPromise; +} + +function persistQuotaPolicies() { + const data = Object.fromEntries( + [...quotaPolicyRecords.entries()].map(([projectId, record]) => [ + projectId, + storedAutomationPolicy(record.request), + ]), + ); + quotaPoliciesWritePromise = quotaPoliciesWritePromise + .catch(() => {}) + .then(async () => { + await mkdir(path.dirname(automationPoliciesPath), { recursive: true }); + await writeFile(automationPoliciesPath, `${JSON.stringify(data, null, 2)}\n`, { + mode: 0o600, + }); + }); + return quotaPoliciesWritePromise; +} + +function scheduleQuotaPolicyCheck(record, cdp, result) { + const { request, version } = record; + const key = request.taskboardProjectId; + const previous = quotaPolicyTimers.get(key); + if (previous) clearTimeout(previous); + quotaPolicyTimers.delete(key); + if (!request.enabledByUser || !request.quotaAware) return; + + const nextRunAt = Number(result.item?.nextRunAt); + const nextRunDelay = Number.isFinite(nextRunAt) && nextRunAt > Date.now() + ? Math.max(1_000, nextRunAt - Date.now() - 15_000) + : 60_000; + const resetDelay = result.quota?.state === "blocked" + && Number.isFinite(result.quota.resetsAt) + ? Math.max(1_000, result.quota.resetsAt * 1_000 - Date.now() + 1_000) + : nextRunDelay; + const timer = setTimeout(async () => { + if (quotaPolicyRecords.get(key)?.version !== version) return; + try { + await enqueueCurrentQuotaPolicy(key, cdp); + } catch (error) { + console.error(`Taskboard quota policy check failed: ${error.message}`); + const current = quotaPolicyRecords.get(key); + if (current?.version === version) { + scheduleQuotaPolicyCheck(current, cdp, { quota: { state: "unknown" } }); + } + } + }, Math.min(nextRunDelay, resetDelay)); + timer.unref(); + quotaPolicyTimers.set(key, timer); +} + +function enqueueQuotaPolicyMutation(record, cdp, rpc) { + const key = record.request.taskboardProjectId; + const previous = quotaPolicyQueues.get(key) ?? Promise.resolve(); + const run = previous + .catch(() => {}) + .then(async () => { + const current = quotaPolicyRecords.get(key); + if (!current || current.version !== record.version) return { stale: true }; + const result = await applyTaskboardAutomationPolicy( + current.request, + rpc, + () => quotaPolicyRecords.get(key)?.version === current.version, + ); + if (result.stale) return result; + if (result.item?.id && quotaPolicyRecords.get(key)?.version === current.version) { + current.request = { ...current.request, automationId: result.item.id }; + await persistQuotaPolicies(); + } + scheduleQuotaPolicyCheck(current, cdp, result); + return result; + }); + const tracked = run.finally(() => { + if (quotaPolicyQueues.get(key) === tracked) quotaPolicyQueues.delete(key); + }); + quotaPolicyQueues.set(key, tracked); + return tracked; +} + +async function updateAndApplyQuotaPolicy(request, cdp, rpc) { + await ensureQuotaPoliciesLoaded(); + const previous = quotaPolicyRecords.get(request.taskboardProjectId); + const record = { + version: (previous?.version ?? 0) + 1, + request, + }; + quotaPolicyRecords.set(request.taskboardProjectId, record); + try { + await persistQuotaPolicies(); + return await enqueueQuotaPolicyMutation(record, cdp, rpc); + } catch (error) { + if (quotaPolicyRecords.get(request.taskboardProjectId)?.version === record.version) { + if (previous) quotaPolicyRecords.set(request.taskboardProjectId, previous); + else quotaPolicyRecords.delete(request.taskboardProjectId); + await persistQuotaPolicies(); + } + throw error; + } +} + +async function readStoredAutomationPolicy(projectId) { + await ensureQuotaPoliciesLoaded(); + const record = quotaPolicyRecords.get(projectId); + return record ? storedAutomationPolicy(record.request) : null; +} + +async function enqueueCurrentQuotaPolicy(projectId, cdp) { + await ensureQuotaPoliciesLoaded(); + const record = quotaPolicyRecords.get(projectId); + if (!record) return { stale: true }; + return enqueueQuotaPolicyMutation( + record, + cdp, + (method, body) => requestCodexAutomationViaCdp(cdp, undefined, method, body), + ); +} + +async function restoreQuotaPolicies(cdp) { + if (quotaPoliciesRestored) return; + quotaPoliciesRestored = true; + await ensureQuotaPoliciesLoaded(); + for (const [projectId, record] of quotaPolicyRecords) { + if (record.request.enabledByUser && record.request.quotaAware) { + void enqueueCurrentQuotaPolicy(projectId, cdp).catch((error) => { + console.error(`Taskboard quota policy restore failed: ${error.message}`); + }); + } + } +} + +async function prefillTaskComposerViaCdp(cdp, executionContextId, request) { + const { + instruction, + skillDisplayName, + skillName, + skillPath, + } = request; + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + const prepared = await cdp.send("Runtime.evaluate", { + expression: `(() => { + const instruction = ${JSON.stringify(instruction)}; + const skillName = ${JSON.stringify(skillName)}; + const skillPath = ${JSON.stringify(skillPath)}; + const editor = Array.from(document.querySelectorAll( + '[data-codex-composer="true"][contenteditable="true"]' + )).find((candidate) => candidate.getClientRects().length > 0); + if (!editor) return { ready: false }; + const mention = Array.from(editor.querySelectorAll("[skill-mention-name]")) + .find((candidate) => ( + candidate.getAttribute("skill-mention-name") === skillName + && candidate.getAttribute("skill-mention-path") === skillPath + )); + if (mention && (editor.textContent || "").includes(instruction)) { + return { ready: true, matches: true }; + } + editor.focus(); + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(editor); + selection?.removeAllRanges(); + selection?.addRange(range); + return { ready: true, matches: false }; + })()`, + contextId: executionContextId, + returnByValue: true, + }); + if (!prepared.result.value?.ready) { + await new Promise((resolve) => setTimeout(resolve, 80)); + continue; + } + if (prepared.result.value.matches) return { prefilled: true }; + + await cdp.send("Input.insertText", { text: "$" }); + break; + } + + let selectedSkill = false; + while (Date.now() < deadline) { + const selection = await cdp.send("Runtime.evaluate", { + expression: `(() => { + const displayName = ${JSON.stringify(skillDisplayName)}; + const overlay = Array.from(document.querySelectorAll( + '[data-composer-overlay-floating-ui="true"]' + )).find((candidate) => candidate.getClientRects().length > 0); + if (!overlay) return { ready: false }; + const button = Array.from(overlay.querySelectorAll( + 'button[data-list-navigation-item="true"]' + )).find((candidate) => Array.from(candidate.querySelectorAll("span")) + .some((label) => (label.textContent || "").trim() === displayName)); + if (!button) return { ready: true, found: false }; + button.click(); + return { ready: true, found: true }; + })()`, + contextId: executionContextId, + returnByValue: true, + }); + if (selection.result.value?.found) { + selectedSkill = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 80)); + } + if (!selectedSkill) { + throw new Error(`Timed out while selecting the ${skillDisplayName} Skill`); + } + + let mentionReady = false; + while (Date.now() < deadline) { + const mention = await cdp.send("Runtime.evaluate", { + expression: `(() => { + const skillName = ${JSON.stringify(skillName)}; + const skillPath = ${JSON.stringify(skillPath)}; + const editor = Array.from(document.querySelectorAll( + '[data-codex-composer="true"][contenteditable="true"]' + )).find((candidate) => candidate.getClientRects().length > 0); + if (!editor) return { ready: false }; + const selected = Array.from(editor.querySelectorAll("[skill-mention-name]")) + .find((candidate) => candidate.getAttribute("skill-mention-name") === skillName); + return { + ready: Boolean(selected), + pathMatches: selected?.getAttribute("skill-mention-path") === skillPath, + }; + })()`, + contextId: executionContextId, + returnByValue: true, + }); + if (mention.result.value?.ready) { + if (!mention.result.value.pathMatches) { + throw new Error(`Codex selected a different ${skillDisplayName} Skill`); + } + mentionReady = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 80)); + } + if (!mentionReady) { + throw new Error(`Timed out while creating the ${skillDisplayName} Skill mention`); + } + + await cdp.send("Input.insertText", { text: instruction }); + while (Date.now() < deadline) { + const verified = await cdp.send("Runtime.evaluate", { + expression: `(() => { + const instruction = ${JSON.stringify(instruction)}; + const skillName = ${JSON.stringify(skillName)}; + const skillPath = ${JSON.stringify(skillPath)}; + const editor = Array.from(document.querySelectorAll( + '[data-codex-composer="true"][contenteditable="true"]' + )).find((candidate) => candidate.getClientRects().length > 0); + const mention = editor && Array.from(editor.querySelectorAll("[skill-mention-name]")) + .find((candidate) => ( + candidate.getAttribute("skill-mention-name") === skillName + && candidate.getAttribute("skill-mention-path") === skillPath + )); + return Boolean(mention && (editor.textContent || "").includes(instruction)); + })()`, + contextId: executionContextId, + returnByValue: true, + }); + if (verified.result.value === true) return { prefilled: true }; + await new Promise((resolve) => setTimeout(resolve, 80)); + } + throw new Error("Timed out while writing the issue instruction into the Codex composer"); +} + +async function sendHostResponse(cdp, executionContextId, response) { + await cdp.send("Runtime.evaluate", { + expression: `window.__codexTaskboardInjection__?.hostResponse(${JSON.stringify(response)})`, + contextId: executionContextId, + returnByValue: true, + }); +} + +async function installTaskboardHostBinding(cdp, supervisor) { + cdp.on("Runtime.bindingCalled", async (params) => { + if (params.name !== hostBindingName) return; + await handleHostBindingPayload(params, { + parseAutomationRequest: parseTaskboardAutomationHostRequest, + ensure: () => supervisor.ensure({ force: true }), + runAutomation: (request, executionContextId) => ( + (async () => { + const rpc = (method, body) => requestCodexAutomationViaCdp( + cdp, + executionContextId, + method, + body, + ); + const result = request.operation === "apply-policy" + ? await updateAndApplyQuotaPolicy(request, cdp, rpc) + : await reconcileTaskboardAutomation(request, rpc); + if (request.operation === "list") { + const policy = await readStoredAutomationPolicy(request.taskboardProjectId); + return { ...result, ...(policy ? { policy } : {}) }; + } + return result; + })() + ), + prefill: (request, executionContextId) => ( + prefillTaskComposerViaCdp(cdp, executionContextId, request) + ), + sendResponse: (executionContextId, response) => ( + sendHostResponse(cdp, executionContextId, response) + ), + }); + }); + await cdp.send("Runtime.addBinding", { name: hostBindingName }); + await restoreQuotaPolicies(cdp); +} + +async function publishHostHeartbeat(cdp, startupToken) { + await cdp.send("Runtime.evaluate", { + expression: `(() => { + window[${JSON.stringify(hostHeartbeatName)}] = Date.now(); + window[${JSON.stringify(hostStartupTokenName)}] = ${JSON.stringify(startupToken)}; + })()`, + returnByValue: true, + }); +} + +async function readInjectionStatus(cdp) { + const status = await cdp.send("Runtime.evaluate", { + expression: `({ + version: window.__codexTaskboardInjection__?.version || null, + sourceHash: window.__codexTaskboardInjection__?.sourceHash || null, + scriptIdentifier: window[${JSON.stringify(injectionScriptIdentifierName)}] || null, + entryMounted: Boolean(document.getElementById("codex-taskboard-entry")), + pageMounted: Boolean(document.getElementById("codex-taskboard-page")), + pageVisible: document.getElementById("codex-taskboard-page")?.hidden === false, + frameUrl: document.getElementById("codex-taskboard-frame")?.src || null + })`, + returnByValue: true, + }); + return status.result.value; +} + +async function waitForInjectionStatus(cdp, shouldOpen, expectedSourceHash, timeoutMs) { + const deadline = Date.now() + timeoutMs; + let status = await readInjectionStatus(cdp); + while ( + Date.now() < deadline + && ( + status.sourceHash !== expectedSourceHash + || !status.entryMounted + || (shouldOpen && (!status.pageVisible || !status.frameUrl)) + ) + ) { + await new Promise((resolve) => setTimeout(resolve, 250)); + status = await readInjectionStatus(cdp); + } + return status; +} + +async function evaluateInjectionSource(cdp, source) { + const evaluation = await cdp.send("Runtime.evaluate", { + expression: source, + awaitPromise: true, + returnByValue: true, + }); + if (evaluation.exceptionDetails) { + throw new Error( + evaluation.exceptionDetails.exception?.description || "Taskboard injection failed", + ); + } +} + +async function publishInjectionScriptIdentifier(cdp, scriptIdentifier) { + await cdp.send("Runtime.evaluate", { + expression: `window[${JSON.stringify(injectionScriptIdentifierName)}] = ${JSON.stringify(scriptIdentifier)}`, + returnByValue: true, + }); +} + +async function registerInjectionSource(cdp, source) { + const registration = await cdp.send("Page.addScriptToEvaluateOnNewDocument", { + source: `${source}\n//# sourceURL=codex-taskboard.user.js`, + }); + return registration.identifier; +} + +async function injectTarget( + target, + source, + sourceHash, + shouldOpen, + screenshotPath, + keepAlive, + supervisor, + attachExisting, + startupToken, +) { + const cdp = new CdpConnection(target.webSocketDebuggerUrl); + let retained = false; + await cdp.open(); + try { + await cdp.send("Page.enable"); + await cdp.send("Page.setBypassCSP", { enabled: true }); + await cdp.send("Runtime.enable"); + if (keepAlive) await installTaskboardHostBinding(cdp, supervisor); + if (keepAlive && attachExisting) { + const currentStatus = await readInjectionStatus(cdp); + const reconciled = await reconcileInjectionRuntime({ + currentStatus, + source, + sourceHash, + removeRegisteredSource: (identifier) => cdp.send( + "Page.removeScriptToEvaluateOnNewDocument", + { identifier }, + ), + registerCurrentSource: (currentSource) => registerInjectionSource(cdp, currentSource), + evaluateCurrentSource: (currentSource) => evaluateInjectionSource(cdp, currentSource), + publishRegistration: (identifier) => publishInjectionScriptIdentifier(cdp, identifier), + reopen: () => cdp.send("Runtime.evaluate", { + expression: "window.__codexTaskboardInjection__?.open()", + returnByValue: true, + }), + }); + cdp.on("Page.loadEventFired", () => ( + publishInjectionScriptIdentifier(cdp, reconciled.scriptIdentifier) + )); + await publishHostHeartbeat(cdp, startupToken); + const status = await waitForInjectionStatus( + cdp, + reconciled.shouldRemainOpen, + sourceHash, + 15_000, + ); + const frameLoaded = status.frameUrl + ? await waitForFrame(cdp, status.frameUrl, 15_000) + : false; + retained = true; + return { + result: { ...status, cspBypassed: true, frameLoaded }, + connection: cdp, + }; + } + const scriptIdentifier = await registerInjectionSource(cdp, source); + cdp.on("Page.loadEventFired", () => ( + publishInjectionScriptIdentifier(cdp, scriptIdentifier) + )); + const reloaded = cdp.waitFor("Page.loadEventFired", 15_000); + await cdp.send("Page.reload"); + await reloaded; + await evaluateInjectionSource(cdp, source); + await publishInjectionScriptIdentifier(cdp, scriptIdentifier); + if (keepAlive) await publishHostHeartbeat(cdp, startupToken); + if (shouldOpen) { + await cdp.send("Runtime.evaluate", { + expression: `(() => { + const taskboard = window.__codexTaskboardInjection__; + taskboard?.close(); + taskboard?.open(); + })()`, + }); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + const status = await waitForInjectionStatus(cdp, shouldOpen, sourceHash, 15_000); + const frameLoaded = status.frameUrl + ? await waitForFrame(cdp, status.frameUrl, 15_000) + : false; + if (shouldOpen && !frameLoaded) { + throw new Error("Taskboard iframe did not finish loading in the Codex renderer"); + } + const result = { + ...status, + cspBypassed: true, + frameLoaded, + }; + if (screenshotPath) { + const screenshot = await cdp.send("Page.captureScreenshot", { format: "png" }); + await writeFile(screenshotPath, Buffer.from(screenshot.data, "base64")); + result.screenshot = screenshotPath; + } + retained = keepAlive; + return { result, connection: retained ? cdp : null }; + } finally { + if (!retained) cdp.close(); + } +} + +async function injectAll( + port, + source, + sourceHash, + shouldOpen, + screenshotPath, + injectedTargets, + keepAlive, + supervisor, + attachExisting, + startupToken, +) { + const targets = await codexTargets(port); + if (targets.length === 0) throw new Error("No Codex renderer target found"); + + const activeIds = new Set(targets.map((target) => target.id)); + for (const [id, connection] of injectedTargets) { + if (!activeIds.has(id) || connection.closed) { + connection.close(); + injectedTargets.delete(id); + } + } + + const results = []; + for (const target of targets) { + if (injectedTargets.has(target.id)) continue; + const firstTarget = injectedTargets.size === 0 && results.length === 0; + const { result, connection } = await injectTarget( + target, + source, + sourceHash, + shouldOpen && firstTarget, + firstTarget ? screenshotPath : null, + keepAlive, + supervisor, + attachExisting, + startupToken, + ); + if (connection) injectedTargets.set(target.id, connection); + results.push({ targetId: target.id, title: target.title, url: target.url, ...result }); + } + return results; +} + +async function injectInitialWhenRendererReady(options) { + const deadline = Date.now() + (options.keepAlive ? 30_000 : 0); + while (true) { + try { + return await injectAll( + options.port, + options.source, + options.sourceHash, + options.shouldOpen, + options.screenshotPath, + options.injectedTargets, + options.keepAlive, + options.supervisor, + options.attachExisting, + options.startupToken, + ); + } catch (error) { + if ( + !options.keepAlive + || error.message !== "No Codex renderer target found" + || Date.now() >= deadline + ) { + throw error; + } + console.error(`Waiting for Codex renderer: ${error.message}`); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } +} + +async function currentInjectionSource() { + const userScript = await readFile(injectionPath, "utf8"); + const runtimeSource = `window.__CODEX_TASKBOARD_MANAGED_ORIGIN__ = ${JSON.stringify(taskboardOrigin)}; +if (typeof window.__CODEX_TASKBOARD_URL__ !== "string" || !window.__CODEX_TASKBOARD_URL__.trim()) { + window.__CODEX_TASKBOARD_URL__ = ${JSON.stringify(taskboardPageUrl)}; +} +${userScript}`; + const sourceHash = createHash("sha256").update(runtimeSource).digest("hex"); + return { + sourceHash, + source: `window[${JSON.stringify(injectionSourceHashName)}] = ${JSON.stringify(sourceHash)}; +${runtimeSource}`, + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + + if (options.daemon) { + let port = options.port; + if (!options.portExplicit) { + const candidates = codexDebuggingPorts(options.port); + const activePort = await Promise.any(candidates.map(async (candidate) => { + if (!(await resolveCdpBaseUrl(candidate))) { + throw new Error("unreachable"); + } + if ((await codexTargets(candidate)).length === 0) throw new Error("not Codex"); + return candidate; + })).catch(() => null); + if (!activePort) throw new Error("No debuggable Codex window found"); + port = activePort; + } + console.log(JSON.stringify({ launcher: startResidentInjector(port, options.open), port }, null, 2)); + return; + } + + if (options.refresh || options.refreshIfRunning) { + const ports = options.portExplicit + ? [options.port] + : codexDebuggingPorts(options.port); + const refreshed = []; + for (const port of ports) { + if (!(await resolveCdpBaseUrl(port))) continue; + if (options.refreshIfRunning) await restartResidentInjectorForRefresh(port); + const results = await refreshTaskboardFrames(port); + refreshed.push(...results.map((result) => ({ port, ...result }))); + } + if (refreshed.length === 0) { + if (options.refreshIfRunning) { + console.log(JSON.stringify({ refreshed: [], skipped: "No debuggable Codex window is running" })); + return; + } + throw new Error(`No debuggable Codex window found on ports: ${ports.join(", ")}`); + } + console.log(JSON.stringify({ refreshed }, null, 2)); + return; + } + + let codexProcess = null; + const supervisor = createTaskboardSupervisor({ + detached: !options.watch || options.backgroundTaskboard, + }); + + try { + const cdpReachable = Boolean(await resolveCdpBaseUrl(options.port)); + if (!cdpReachable) { + if (!options.launch) { + throw new Error(`Codex CDP is not listening on ${cdpHosts.map((host) => `${host}:${options.port}`).join(" or ")}`); + } + if (codexIsRunning()) { + throw new Error( + "Codex is already running without this CDP port. Quit Codex completely, then run this command again.", + ); + } + } + + await supervisor.ensure({ force: true }); + + if (!cdpReachable) { + codexProcess = launchCodex(options.appPath, options.port); + await waitUntilCdpReachable(options.port, 30_000); + } + + const { source, sourceHash } = await currentInjectionSource(); + const injectedTargets = new Map(); + const firstResults = await injectInitialWhenRendererReady({ + port: options.port, + source, + sourceHash, + shouldOpen: options.open, + screenshotPath: options.screenshot, + injectedTargets, + keepAlive: options.watch, + supervisor, + attachExisting: options.attachExisting, + startupToken: options.startupToken, + }); + console.log(JSON.stringify({ injected: firstResults }, null, 2)); + + if (!options.watch) { + codexProcess?.unref(); + return; + } + + const stop = () => { + injectedTargets.forEach((connection) => connection.close()); + supervisor.stop(); + process.exit(0); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + + while (true) { + await new Promise((resolve) => setTimeout(resolve, 2_000)); + if (!(await resolveCdpBaseUrl(options.port))) break; + try { + await supervisor.ensure(); + } catch (error) { + console.error(`Waiting for Taskboard service: ${error.message}`); + } + for (const connection of injectedTargets.values()) { + try { + await publishHostHeartbeat(connection, options.startupToken); + } catch (_) {} + } + try { + const results = await injectAll( + options.port, + source, + sourceHash, + false, + null, + injectedTargets, + true, + supervisor, + options.attachExisting, + options.startupToken, + ); + if (results.length > 0) console.log(JSON.stringify({ injected: results }, null, 2)); + } catch (error) { + if ((codexProcess && codexProcess.exitCode !== null) || !(await resolveCdpBaseUrl(options.port))) { + break; + } + console.error(`Waiting for Codex renderer: ${error.message}`); + } + } + supervisor.stop(); + } catch (error) { + supervisor.stop(); + throw error; + } +} + +main().catch((error) => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/apps/codex-taskboard/scripts/codex-rate-limits.mjs b/apps/codex-taskboard/scripts/codex-rate-limits.mjs new file mode 100644 index 000000000..9a54fc8e3 --- /dev/null +++ b/apps/codex-taskboard/scripts/codex-rate-limits.mjs @@ -0,0 +1,156 @@ +import { spawn } from "node:child_process"; +import readline from "node:readline"; + +const REQUEST_TIMEOUT_MS = 8_000; + +export async function readCodexQuotaStatus(model) { + const checkedAt = Date.now(); + try { + const session = startAppServer(); + try { + await session.request("initialize", { + clientInfo: { name: "codex-taskboard", version: "0.1.0" }, + }); + session.notify("initialized", {}); + const account = await session.request("account/read", { refreshToken: false }); + if (account?.account?.type === "apiKey") { + return { state: "unavailable", reason: "api-key", checkedAt }; + } + if (account?.account?.type !== "chatgpt") { + return { state: "unknown", checkedAt }; + } + const result = await session.request("account/rateLimits/read", {}); + return evaluateRateLimits(result, model, checkedAt); + } finally { + session.close(); + } + } catch { + return { state: "unknown", checkedAt }; + } +} + +function startAppServer() { + const child = spawn("codex", ["app-server", "--stdio"], { + stdio: ["pipe", "pipe", "ignore"], + }); + const pending = new Map(); + let sequence = 0; + let closed = false; + const lines = readline.createInterface({ input: child.stdout }); + + lines.on("line", (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + if (!Number.isInteger(message?.id)) return; + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + clearTimeout(request.timeout); + if (message.error) request.reject(new Error("Codex App Server request failed")); + else request.resolve(message.result); + }); + + const failPending = () => { + if (closed) return; + closed = true; + for (const request of pending.values()) { + clearTimeout(request.timeout); + request.reject(new Error("Codex App Server stopped")); + } + pending.clear(); + }; + child.once("error", failPending); + child.once("exit", failPending); + + function write(message) { + child.stdin.write(`${JSON.stringify(message)}\n`); + } + + return { + request(method, params) { + const id = ++sequence; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id); + reject(new Error("Codex App Server request timed out")); + }, REQUEST_TIMEOUT_MS); + pending.set(id, { resolve, reject, timeout }); + write({ id, method, params }); + }); + }, + notify(method, params) { + write({ method, params }); + }, + close() { + closed = true; + lines.close(); + child.stdin.end(); + child.kill("SIGTERM"); + for (const request of pending.values()) { + clearTimeout(request.timeout); + request.reject(new Error("Codex App Server stopped")); + } + pending.clear(); + }, + }; +} + +function evaluateRateLimits(result, model, checkedAt) { + const snapshots = result?.rateLimitsByLimitId; + const entries = snapshots && typeof snapshots === "object" && !Array.isArray(snapshots) + ? Object.entries(snapshots).filter(([, snapshot]) => ( + snapshot && typeof snapshot === "object" + )) + : []; + const normalizedModel = normalizeName(model); + const snapshot = entries.find(([, value]) => ( + normalizeName(value.limitName) === normalizedModel + ))?.[1] + ?? entries.find(([limitId]) => limitId === "codex")?.[1] + ?? ( + result?.rateLimits + && typeof result.rateLimits === "object" + && !Array.isArray(result.rateLimits) + ? result.rateLimits + : null + ); + if (!snapshot) return { state: "unknown", checkedAt }; + + const windows = [snapshot.primary, snapshot.secondary].filter(Boolean); + const creditsAvailable = snapshot.credits?.unlimited === true + || snapshot.credits?.hasCredits === true; + const exhaustedWindows = windows.filter((window) => ( + Number(window.usedPercent) >= 100 + )); + const individuallyExhausted = Number(snapshot.individualLimit?.remainingPercent) <= 0; + const blocked = Boolean(snapshot.rateLimitReachedType) + || snapshot.spendControlReached === true + || individuallyExhausted + || (exhaustedWindows.length > 0 && !creditsAvailable); + + if (!blocked) return { state: "available", checkedAt }; + + const resetCandidates = [ + ...exhaustedWindows.map((window) => Number(window.resetsAt)), + Number(snapshot.individualLimit?.resetsAt), + ]; + if (snapshot.rateLimitReachedType || snapshot.spendControlReached === true) { + resetCandidates.push(...windows.map((window) => Number(window.resetsAt))); + } + const resetsAt = Math.max(...resetCandidates.filter(Number.isFinite)); + return { + state: "blocked", + checkedAt, + ...(Number.isFinite(resetsAt) ? { resetsAt } : {}), + }; +} + +function normalizeName(value) { + return typeof value === "string" + ? value.toLowerCase().replace(/[^a-z0-9]+/g, "") + : ""; +} diff --git a/apps/codex-taskboard/scripts/dev.mjs b/apps/codex-taskboard/scripts/dev.mjs new file mode 100644 index 000000000..34929e9a1 --- /dev/null +++ b/apps/codex-taskboard/scripts/dev.mjs @@ -0,0 +1,28 @@ +import { spawn } from "node:child_process"; + +const children = [ + spawn(process.execPath, ["--watch", "server/index.mjs", "--dev"], { + stdio: "inherit", + }), + spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "dev:web"], { + stdio: "inherit", + }), +]; + +let shuttingDown = false; + +function stop(exitCode = 0) { + if (shuttingDown) return; + shuttingDown = true; + for (const child of children) child.kill("SIGTERM"); + process.exitCode = exitCode; +} + +for (const child of children) { + child.on("exit", (code, signal) => { + if (!shuttingDown && code !== 0 && signal !== "SIGTERM") stop(code ?? 1); + }); +} + +process.on("SIGINT", () => stop()); +process.on("SIGTERM", () => stop()); diff --git a/apps/codex-taskboard/scripts/migrate-to-cloud.mjs b/apps/codex-taskboard/scripts/migrate-to-cloud.mjs new file mode 100644 index 000000000..3eab1289c --- /dev/null +++ b/apps/codex-taskboard/scripts/migrate-to-cloud.mjs @@ -0,0 +1,787 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { DatabaseSync } from "node:sqlite"; + +const SCHEMA_VERSION = 1; +const WRANGLER_D1_STATEMENT_MAX_BYTES = 90_000; +const TABLE_ORDER = [ + "projects", + "tasks", + "comments", + "task_relations", + "attachments", + "workflow_workspaces", +]; +const LOCAL_WORKFLOW_PATH_FIELDS = new Set(["gitWorktreePath"]); +const SORT_FIELDS = { + projects: ["id"], + tasks: ["project_id", "identifier", "id"], + comments: ["task_id", "created_at", "id"], + task_relations: ["source_task_id", "target_task_id", "relation_type"], + attachments: ["task_id", "comment_id", "created_at", "id"], + workflow_workspaces: ["project_id"], +}; +function compareValues(left, right) { + if (left === right) return 0; + if (left == null) return -1; + if (right == null) return 1; + return String(left) < String(right) ? -1 : 1; +} + +function sortRows(table, rows) { + const fields = SORT_FIELDS[table]; + return rows.sort((left, right) => { + for (const field of fields) { + const comparison = compareValues(left[field], right[field]); + if (comparison !== 0) return comparison; + } + return 0; + }); +} + +function sqliteString(value) { + return `'${String(value).replaceAll("'", "''")}'`; +} + +function sanitizeWorkflowValue(value) { + if (Array.isArray(value)) return value.map(sanitizeWorkflowValue); + if (!value || typeof value !== "object") return value; + + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [ + key, + LOCAL_WORKFLOW_PATH_FIELDS.has(key) ? null : sanitizeWorkflowValue(child), + ]), + ); +} + +function sanitizeWorkflowRow(row) { + let workspace; + try { + workspace = JSON.parse(row.workspace); + } catch { + throw new Error(`Workflow workspace for project '${row.project_id}' is not valid JSON`); + } + return { + ...row, + workspace: JSON.stringify(sanitizeWorkflowValue(workspace)), + }; +} + +function buildProjectCounts(tables) { + const counts = {}; + for (const project of tables.projects) { + counts[project.id] = { + projects: 1, + tasks: 0, + comments: 0, + attachments: 0, + task_relations: 0, + workflow_workspaces: 0, + }; + } + + const taskProjects = new Map(); + for (const task of tables.tasks) { + if (!counts[task.project_id]) { + throw new Error(`Task '${task.id}' references unknown project '${task.project_id}'`); + } + taskProjects.set(task.id, task.project_id); + counts[task.project_id].tasks += 1; + } + for (const comment of tables.comments) { + const projectId = taskProjects.get(comment.task_id); + if (!projectId) throw new Error(`Comment '${comment.id}' references unknown task '${comment.task_id}'`); + counts[projectId].comments += 1; + } + for (const attachment of tables.attachments) { + const projectId = taskProjects.get(attachment.task_id); + if (!projectId) { + throw new Error(`Attachment '${attachment.id}' references unknown task '${attachment.task_id}'`); + } + counts[projectId].attachments += 1; + } + for (const relation of tables.task_relations) { + const projectId = taskProjects.get(relation.source_task_id); + if (!projectId) { + throw new Error( + `Task relation references unknown source task '${relation.source_task_id}'`, + ); + } + counts[projectId].task_relations += 1; + } + for (const workspace of tables.workflow_workspaces) { + if (!counts[workspace.project_id]) { + throw new Error(`Workflow workspace references unknown project '${workspace.project_id}'`); + } + counts[workspace.project_id].workflow_workspaces += 1; + } + + return Object.fromEntries( + Object.entries(counts).sort(([left], [right]) => (left < right ? -1 : 1)), + ); +} + +function assertSafeAttachmentId(id) { + if ( + typeof id !== "string" + || id.length === 0 + || id === "." + || id === ".." + || id.includes("/") + || id.includes("\\") + || id.includes("\0") + ) { + throw new Error(`Attachment id '${id}' is not safe to migrate`); + } +} + +function attachmentObjectKey(attachmentId) { + return attachmentId; +} + +async function readAttachmentPayloads(tables, attachmentsDirectory) { + const taskProjects = new Map(tables.tasks.map((task) => [task.id, task.project_id])); + const payloads = []; + + for (const attachment of tables.attachments) { + assertSafeAttachmentId(attachment.id); + const sourcePath = path.join(attachmentsDirectory, attachment.id); + let file; + try { + file = await lstat(sourcePath); + } catch (error) { + if (error?.code === "ENOENT") { + throw new Error(`Attachment '${attachment.id}' is missing from '${attachmentsDirectory}'`); + } + throw error; + } + if (!file.isFile()) { + throw new Error(`Attachment '${attachment.id}' is not a regular file`); + } + + const body = await readFile(sourcePath); + if (attachment.size !== body.byteLength) { + throw new Error( + `Attachment '${attachment.id}' size mismatch: SQLite=${attachment.size}, file=${body.byteLength}`, + ); + } + const projectId = taskProjects.get(attachment.task_id); + payloads.push({ + id: attachment.id, + projectId, + objectKey: attachmentObjectKey(attachment.id), + size: body.byteLength, + sha256: createHash("sha256").update(body).digest("hex"), + body, + }); + } + + return payloads; +} + +async function readSnapshot(databasePath) { + const snapshotDirectory = await mkdtemp(path.join(os.tmpdir(), "taskboard-cloud-snapshot-")); + const snapshotPath = path.join(snapshotDirectory, "taskboard.sqlite"); + let source; + let snapshot; + try { + source = new DatabaseSync(databasePath, { readOnly: true }); + source.exec(`PRAGMA busy_timeout = 5000; VACUUM INTO ${sqliteString(snapshotPath)}`); + source.close(); + source = null; + + snapshot = new DatabaseSync(snapshotPath, { readOnly: true }); + const integrity = snapshot.prepare("PRAGMA integrity_check").all(); + if ( + integrity.length !== 1 + || String(Object.values(integrity[0])[0]).toLowerCase() !== "ok" + ) { + throw new Error("SQLite snapshot failed PRAGMA integrity_check"); + } + const foreignKeyViolations = snapshot.prepare("PRAGMA foreign_key_check").all(); + if (foreignKeyViolations.length > 0) { + throw new Error( + `SQLite snapshot failed PRAGMA foreign_key_check (${foreignKeyViolations.length} violation(s))`, + ); + } + const tables = Object.fromEntries( + TABLE_ORDER.map((table) => [ + table, + sortRows(table, snapshot.prepare(`SELECT * FROM "${table}"`).all()), + ]), + ); + snapshot.close(); + snapshot = null; + return tables; + } finally { + snapshot?.close(); + source?.close(); + await rm(snapshotDirectory, { recursive: true, force: true }); + } +} + +function assertCountsMatch(expected, actual) { + const expectedProjects = Object.keys(expected).sort(); + const actualProjects = Object.keys(actual ?? {}).sort(); + if (JSON.stringify(expectedProjects) !== JSON.stringify(actualProjects)) { + throw new Error( + `Cloud count mismatch: expected projects ${expectedProjects.join(", ")}, got ${actualProjects.join(", ")}`, + ); + } + for (const projectId of expectedProjects) { + for (const table of TABLE_ORDER) { + const expectedCount = Number(expected[projectId][table]); + const actualCount = Number(actual[projectId]?.[table]); + if (actualCount !== expectedCount) { + throw new Error( + `Cloud count mismatch for project '${projectId}' table '${table}': expected ${expectedCount}, got ${actualCount}`, + ); + } + } + } +} + +function validateBundle(bundle) { + if (!bundle || bundle.schemaVersion !== SCHEMA_VERSION) { + throw new Error(`Unsupported cloud migration schema version '${bundle?.schemaVersion}'`); + } + for (const table of TABLE_ORDER) { + if (!Array.isArray(bundle.tables?.[table])) { + throw new Error(`Cloud migration bundle is missing table '${table}'`); + } + } + if (!Array.isArray(bundle.attachments)) { + throw new Error("Cloud migration bundle is missing attachment payloads"); + } + + const calculatedCounts = buildProjectCounts(bundle.tables); + assertCountsMatch(calculatedCounts, bundle.counts?.byProject); + const attachmentRows = new Map(bundle.tables.attachments.map((row) => [row.id, row])); + const attachmentPayloads = new Map( + bundle.attachments.map((attachment) => [attachment.id, attachment]), + ); + if ( + attachmentRows.size !== bundle.tables.attachments.length + || attachmentPayloads.size !== bundle.attachments.length + || attachmentRows.size !== attachmentPayloads.size + ) { + throw new Error("Cloud migration attachment metadata and payload counts do not match"); + } + const taskProjects = new Map( + bundle.tables.tasks.map((task) => [task.id, task.project_id]), + ); + for (const row of attachmentRows.values()) { + const attachment = attachmentPayloads.get(row.id); + if (!attachment) { + throw new Error(`Attachment metadata '${row.id}' has no payload`); + } + const body = Buffer.from(attachment.body); + const sha256 = createHash("sha256").update(body).digest("hex"); + if (body.byteLength !== attachment.size) { + throw new Error(`Attachment '${attachment.id}' size verification failed`); + } + if (sha256 !== attachment.sha256) { + throw new Error(`Attachment '${attachment.id}' SHA-256 verification failed`); + } + if (Number(row.size) !== attachment.size) { + throw new Error(`Attachment '${attachment.id}' metadata size does not match its payload`); + } + const projectId = taskProjects.get(row.task_id); + if (attachment.projectId !== projectId) { + throw new Error( + `Attachment '${attachment.id}' project does not match its task project`, + ); + } + if (attachment.objectKey !== attachmentObjectKey(attachment.id)) { + throw new Error(`Attachment '${attachment.id}' object key must match its id`); + } + if ( + typeof attachment.objectKey !== "string" + || path.posix.isAbsolute(attachment.objectKey) + || attachment.objectKey.split("/").includes("..") + ) { + throw new Error(`Attachment '${attachment.id}' has an unsafe object key`); + } + } +} + +export async function createCloudMigrationBundle({ + databasePath, + attachmentsDirectory, +}) { + const tables = await readSnapshot(databasePath); + tables.projects = tables.projects.map((project) => ({ + ...project, + workspace_path: null, + })); + tables.tasks = tables.tasks.map((task) => ({ + ...task, + worktree_path: null, + })); + tables.workflow_workspaces = tables.workflow_workspaces.map(sanitizeWorkflowRow); + + return { + schemaVersion: SCHEMA_VERSION, + createdAt: new Date().toISOString(), + counts: { + byProject: buildProjectCounts(tables), + }, + tables, + attachments: await readAttachmentPayloads(tables, attachmentsDirectory), + }; +} + +const CLOUD_COLUMNS = { + projects: ["id", "name", "workspace_path", "next_task_number", "created_at", "updated_at"], + tasks: [ + "id", "identifier", "project_id", "title", "description", "status", "priority", "labels", + "sort_order", "thread_id", "creator_type", "creator_id", "creator_name", + "creator_avatar_url", "assignee_type", "assignee_id", "assignee_name", + "assignee_avatar_url", "workflow_id", "development_context_type", "development_branch", + "due_date", "recurrence_interval", "recurrence_unit", "archived_at", "version", + "created_at", "updated_at", + ], + comments: [ + "id", "task_id", "body", "thread_id", "author_type", "author_id", "author_name", + "author_avatar_url", "version", "created_at", "updated_at", + ], + task_relations: ["relation_type", "source_task_id", "target_task_id", "created_at"], + attachments: ["id", "task_id", "comment_id", "filename", "content_type", "size", "created_at"], + workflow_workspaces: ["project_id", "workspace", "version", "updated_at"], +}; + +function cloudTaskRow(task) { + const isWorktree = task.worktree_branch != null; + return { + ...task, + development_context_type: isWorktree + ? "worktree" + : task.git_branch != null + ? "branch" + : null, + development_branch: isWorktree ? task.worktree_branch : task.git_branch, + }; +} + +function cloudRows(table, rows) { + return table === "tasks" ? rows.map(cloudTaskRow) : rows; +} + +function insertTableSql(table) { + const columns = CLOUD_COLUMNS[table]; + return `INSERT INTO "${table}" (${columns.map((column) => `"${column}"`).join(", ")}) + SELECT ${columns.map((column) => `json_extract(value, '$.${column}')`).join(", ")} + FROM json_each(?)`; +} + +export function createCloudD1ImportPlan(tables) { + return TABLE_ORDER.map((table) => { + const columns = CLOUD_COLUMNS[table]; + const values = cloudRows(table, tables[table]).map((row) => ( + Object.fromEntries(columns.map((column) => [column, row[column] ?? null])) + )); + return { table, sql: insertTableSql(table), json: JSON.stringify(values) }; + }); +} + +function inlineD1InsertStatement(table, values) { + const json = JSON.stringify(values); + if (json.includes("\0")) throw new Error("D1 migration JSON cannot contain null bytes"); + return `${insertTableSql(table).replace("?", sqliteString(json))};`; +} + +export function createCloudD1ImportSql(tables) { + const statements = []; + for (const { table, json } of createCloudD1ImportPlan(tables)) { + const values = JSON.parse(json); + if (values.length === 0) { + statements.push(inlineD1InsertStatement(table, values)); + continue; + } + + let chunk = []; + for (const row of values) { + const candidate = [...chunk, row]; + const statement = inlineD1InsertStatement(table, candidate); + if (Buffer.byteLength(statement, "utf8") < WRANGLER_D1_STATEMENT_MAX_BYTES) { + chunk = candidate; + continue; + } + if (chunk.length === 0) { + const identity = row.id ?? row.project_id ?? "unknown"; + throw new Error( + `D1 import single row '${table}:${identity}' exceeds 90,000 bytes`, + ); + } + statements.push(inlineD1InsertStatement(table, chunk)); + chunk = [row]; + if ( + Buffer.byteLength( + inlineD1InsertStatement(table, chunk), + "utf8", + ) >= WRANGLER_D1_STATEMENT_MAX_BYTES + ) { + const identity = row.id ?? row.project_id ?? "unknown"; + throw new Error( + `D1 import single row '${table}:${identity}' exceeds 90,000 bytes`, + ); + } + } + statements.push(inlineD1InsertStatement(table, chunk)); + } + return statements.join("\n"); +} + +export const CLOUD_PROJECT_COUNTS_SQL = ` + SELECT p.id AS project_id, 1 AS projects, + (SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS tasks, + (SELECT COUNT(*) FROM comments c JOIN tasks t ON t.id = c.task_id + WHERE t.project_id = p.id) AS comments, + (SELECT COUNT(*) FROM attachments a JOIN tasks t ON t.id = a.task_id + WHERE t.project_id = p.id) AS attachments, + (SELECT COUNT(*) FROM task_relations r JOIN tasks t ON t.id = r.source_task_id + WHERE t.project_id = p.id) AS task_relations, + (SELECT COUNT(*) FROM workflow_workspaces w + WHERE w.project_id = p.id) AS workflow_workspaces + FROM projects p ORDER BY p.id +`; + +export function createCloudBindingMigrationAdapters({ d1, r2 }) { + return { + d1: { + async importTables(tables) { + await d1.batch( + createCloudD1ImportPlan(tables).map(({ sql, json }) => ( + d1.prepare(sql).bind(json) + )), + ); + }, + async countByProject() { + const result = await d1.prepare(CLOUD_PROJECT_COUNTS_SQL).all(); + return Object.fromEntries(result.results.map((row) => [ + row.project_id, + Object.fromEntries(TABLE_ORDER.map((table) => [table, Number(row[table])])), + ])); + }, + }, + r2: { + put: (key, body, options) => r2.put(key, body, options), + head: (key) => r2.head(key), + delete: (key) => r2.delete(key), + }, + }; +} + +async function verifyR2Attachments(bundle, r2) { + let verified = 0; + for (const attachment of bundle.attachments) { + const object = await r2.head(attachment.objectKey); + if (!object) throw new Error(`R2 object '${attachment.objectKey}' is missing`); + if (Number(object.size) !== attachment.size) { + throw new Error(`R2 size verification failed for attachment '${attachment.id}'`); + } + if (object.customMetadata?.sha256 !== attachment.sha256) { + throw new Error(`R2 SHA-256 hash verification failed for attachment '${attachment.id}'`); + } + verified += 1; + } + return verified; +} + +export async function verifyCloudMigrationBundle(bundle, { d1, r2 }) { + validateBundle(bundle); + const counts = await d1.countByProject(); + assertCountsMatch(bundle.counts.byProject, counts); + const verified = await verifyR2Attachments(bundle, r2); + return { + counts: { byProject: structuredClone(bundle.counts.byProject) }, + attachments: { verified }, + }; +} + +export async function importCloudMigrationBundle(bundle, { d1, r2 }) { + validateBundle(bundle); + + const existingCounts = await d1.countByProject(); + if (Object.keys(existingCounts).length > 0) { + throw new Error("Cloud migration target D1 is not empty"); + } + for (const attachment of bundle.attachments) { + if (await r2.head(attachment.objectKey)) { + throw new Error(`R2 object '${attachment.objectKey}' already exists`); + } + } + + const attachmentRows = new Map(bundle.tables.attachments.map((row) => [row.id, row])); + const uploadedKeys = []; + let d1Committed = false; + try { + for (const attachment of bundle.attachments) { + const row = attachmentRows.get(attachment.id); + await r2.put(attachment.objectKey, Buffer.from(attachment.body), { + customMetadata: { sha256: attachment.sha256 }, + httpMetadata: { contentType: row.content_type }, + }); + uploadedKeys.push(attachment.objectKey); + } + const verified = await verifyR2Attachments(bundle, r2); + + await d1.importTables(bundle.tables); + d1Committed = true; + const counts = await d1.countByProject(); + assertCountsMatch(bundle.counts.byProject, counts); + return { + counts: { byProject: structuredClone(bundle.counts.byProject) }, + attachments: { verified }, + }; + } catch (error) { + if (!d1Committed && typeof r2.delete === "function") { + const cleanup = await Promise.allSettled(uploadedKeys.map((key) => r2.delete(key))); + const failures = cleanup.filter((result) => result.status === "rejected"); + if (failures.length > 0) { + throw new AggregateError( + [error, ...failures.map((result) => result.reason)], + `${error.message}; cleanup failed for ${failures.length} R2 object(s)`, + ); + } + } + throw error; + } +} + +function bundleFile(outputDirectory, relativePath) { + const resolvedRoot = path.resolve(outputDirectory); + const resolved = path.resolve(resolvedRoot, relativePath); + if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) { + throw new Error(`Unsafe bundle path '${relativePath}'`); + } + return resolved; +} + +async function writePrivateFile(filename, contents) { + await writeFile(filename, contents, { mode: 0o600, flag: "wx" }); + await chmod(filename, 0o600); +} + +export async function writeCloudMigrationBundle(bundle, outputDirectory) { + validateBundle(bundle); + const parent = path.dirname(path.resolve(outputDirectory)); + await mkdir(parent, { recursive: true }); + await mkdir(outputDirectory, { mode: 0o700 }); + try { + const dataDirectory = path.join(outputDirectory, "data"); + const attachmentsDirectory = path.join(outputDirectory, "attachments"); + await mkdir(dataDirectory, { mode: 0o700 }); + await mkdir(attachmentsDirectory, { mode: 0o700 }); + + const tableFiles = {}; + for (const table of TABLE_ORDER) { + const relativePath = `data/${table}.json`; + await writePrivateFile( + bundleFile(outputDirectory, relativePath), + `${JSON.stringify(bundle.tables[table], null, 2)}\n`, + ); + tableFiles[table] = { + file: relativePath, + rows: bundle.tables[table].length, + }; + } + + const attachmentFiles = []; + for (const attachment of bundle.attachments) { + const relativePath = `attachments/${encodeURIComponent(attachment.id)}`; + await writePrivateFile( + bundleFile(outputDirectory, relativePath), + Buffer.from(attachment.body), + ); + attachmentFiles.push({ + id: attachment.id, + projectId: attachment.projectId, + objectKey: attachment.objectKey, + file: relativePath, + size: attachment.size, + sha256: attachment.sha256, + }); + } + + const manifest = { + schemaVersion: SCHEMA_VERSION, + createdAt: bundle.createdAt, + counts: bundle.counts, + tables: tableFiles, + attachments: attachmentFiles, + }; + await writePrivateFile( + path.join(outputDirectory, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + return path.resolve(outputDirectory); + } catch (error) { + await rm(outputDirectory, { recursive: true, force: true }); + throw error; + } +} + +async function readJsonFile(filename, label) { + try { + return JSON.parse(await readFile(filename, "utf8")); + } catch (error) { + throw new Error(`Cannot read ${label}: ${error.message}`); + } +} + +export async function readCloudMigrationBundle(inputDirectory) { + const manifest = await readJsonFile( + path.join(inputDirectory, "manifest.json"), + "cloud migration manifest", + ); + if (manifest.schemaVersion !== SCHEMA_VERSION) { + throw new Error(`Unsupported cloud migration schema version '${manifest.schemaVersion}'`); + } + + const tables = {}; + for (const table of TABLE_ORDER) { + const entry = manifest.tables?.[table]; + if (!entry?.file) throw new Error(`Cloud migration manifest is missing table '${table}'`); + const rows = await readJsonFile( + bundleFile(inputDirectory, entry.file), + `table '${table}'`, + ); + if (!Array.isArray(rows) || rows.length !== entry.rows) { + throw new Error(`Cloud migration row count mismatch for table '${table}'`); + } + tables[table] = rows; + } + + const attachments = []; + for (const entry of manifest.attachments ?? []) { + const body = await readFile(bundleFile(inputDirectory, entry.file)); + attachments.push({ ...entry, body }); + } + const bundle = { + schemaVersion: manifest.schemaVersion, + createdAt: manifest.createdAt, + counts: manifest.counts, + tables, + attachments, + }; + validateBundle(bundle); + return bundle; +} + +function parseOptions(args, allowed, required) { + const options = {}; + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const value = args[index + 1]; + if (!flag?.startsWith("--") || value == null || value.startsWith("--")) { + throw new Error(`Expected a value after '${flag ?? ""}'`); + } + const name = flag.slice(2); + if (!allowed.has(name)) throw new Error(`Unknown option '--${name}'`); + if (Object.hasOwn(options, name)) throw new Error(`Duplicate option '--${name}'`); + options[name] = value; + } + for (const name of required) { + if (!options[name]) throw new Error(`Missing required option '--${name}'`); + } + return options; +} + +const HELP = `Usage: + node scripts/migrate-to-cloud.mjs export --database --attachments --output + node scripts/migrate-to-cloud.mjs import --bundle --adapter + node scripts/migrate-to-cloud.mjs verify --bundle --adapter + +The bundled Wrangler adapter uses Wrangler authentication without reading or +storing credentials. Remote operations require TASKBOARD_MIGRATION_REMOTE=1.`; + +async function loadAdapterModule(modulePath, context) { + const loaded = await import(pathToFileURL(path.resolve(modulePath)).href); + const factory = loaded.createCloudMigrationAdapters ?? loaded.default; + if (typeof factory !== "function") { + throw new Error( + "Adapter module must export createCloudMigrationAdapters or a default factory", + ); + } + return factory(context); +} + +export async function runCli( + argv, + { + stdout = process.stdout, + loadAdapters = loadAdapterModule, + } = {}, +) { + const [command, ...args] = argv; + if (!command || command === "--help" || command === "help") { + stdout.write(`${HELP}\n`); + return 0; + } + + if (command === "export") { + const options = parseOptions( + args, + new Set(["database", "attachments", "output"]), + ["database", "attachments", "output"], + ); + const bundle = await createCloudMigrationBundle({ + databasePath: options.database, + attachmentsDirectory: options.attachments, + }); + const output = await writeCloudMigrationBundle(bundle, options.output); + stdout.write(`${JSON.stringify({ output, counts: bundle.counts })}\n`); + return 0; + } + + if (command !== "import" && command !== "verify") { + throw new Error(`Unknown command '${command}'`); + } + const options = parseOptions( + args, + new Set(["bundle", "adapter"]), + ["bundle", "adapter"], + ); + const bundle = await readCloudMigrationBundle(options.bundle); + const adapters = await loadAdapters(options.adapter, { bundle, command }); + if (!adapters?.d1 || !adapters?.r2) { + throw new Error("Adapter factory must return d1 and r2 adapters"); + } + if (command === "import" && typeof adapters.d1.importTables !== "function") { + throw new Error("Import adapter must provide atomic d1.importTables"); + } + if (command === "import" && typeof adapters.r2.delete !== "function") { + throw new Error("Import adapter must provide r2.delete for failed-migration cleanup"); + } + try { + const result = command === "import" + ? await importCloudMigrationBundle(bundle, adapters) + : await verifyCloudMigrationBundle(bundle, adapters); + stdout.write(`${JSON.stringify(result)}\n`); + } finally { + await adapters.cleanup?.(); + } + return 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runCli(process.argv.slice(2)).catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/apps/codex-taskboard/scripts/wrangler-cloud-adapter.mjs b/apps/codex-taskboard/scripts/wrangler-cloud-adapter.mjs new file mode 100644 index 000000000..1ba8e8790 --- /dev/null +++ b/apps/codex-taskboard/scripts/wrangler-cloud-adapter.mjs @@ -0,0 +1,222 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + chmod, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +import { + CLOUD_PROJECT_COUNTS_SQL, + createCloudD1ImportSql, +} from "./migrate-to-cloud.mjs"; + +const execFile = promisify(execFileCallback); +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const defaultWrangler = process.platform === "win32" + ? process.execPath + : path.join(projectRoot, "node_modules", ".bin", "wrangler"); +const defaultWranglerArgs = process.platform === "win32" + ? [path.join(projectRoot, "node_modules", "wrangler", "bin", "wrangler.js")] + : []; + +function parseD1Results(stdout) { + const parsed = JSON.parse(stdout); + const resultSets = Array.isArray(parsed) ? parsed : [parsed]; + return resultSets.flatMap((result) => result.results ?? result.result?.[0]?.results ?? []); +} + +function missingR2Object(error) { + const output = `${error?.message ?? ""}\n${error?.stdout ?? ""}\n${error?.stderr ?? ""}`; + return /not found|does not exist|NoSuchKey|10007/i.test(output); +} + +export function createWranglerCloudAdapters({ + remote, + persistTo, + configPath, + wranglerExecutable = defaultWrangler, + database = "codex-taskboard-db", + bucket = "codex-taskboard-attachments", + preparedImportSql, + environment = process.env, + runCommand = execFile, +} = {}) { + const remoteEnabled = environment.TASKBOARD_MIGRATION_REMOTE === "1"; + const useRemote = remote ?? remoteEnabled; + if (useRemote && !remoteEnabled) { + throw new Error("Remote migration requires TASKBOARD_MIGRATION_REMOTE=1"); + } + + const resolvedPersistTo = persistTo ?? environment.TASKBOARD_MIGRATION_PERSIST_TO; + if (!useRemote && !resolvedPersistTo) { + throw new Error( + "Local migration requires TASKBOARD_MIGRATION_PERSIST_TO", + ); + } + const resolvedConfig = path.resolve( + configPath + ?? environment.TASKBOARD_MIGRATION_CONFIG + ?? path.join(projectRoot, "wrangler.jsonc"), + ); + const modeArguments = useRemote + ? ["--remote"] + : ["--local", "--persist-to", path.resolve(resolvedPersistTo)]; + const temporaryDirectory = mkdtemp( + path.join(os.tmpdir(), "taskboard-wrangler-migration-"), + ).then(async (directory) => { + await chmod(directory, 0o700); + return directory; + }); + let commandQueue = Promise.resolve(); + let sequence = 0; + + function run(args) { + const commandArgs = runCommand === execFile && wranglerExecutable === defaultWrangler + ? [...defaultWranglerArgs, ...args] + : args; + const result = commandQueue.then(() => runCommand(wranglerExecutable, commandArgs, { + cwd: projectRoot, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + })); + commandQueue = result.catch(() => {}); + return result; + } + + async function privateFile(extension, contents) { + const directory = await temporaryDirectory; + const filename = path.join(directory, `${sequence += 1}${extension}`); + await writeFile(filename, contents, { flag: "wx", mode: 0o600 }); + await chmod(filename, 0o600); + return filename; + } + + const d1 = { + async importTables(tables) { + const sqlPath = await privateFile( + ".sql", + `${preparedImportSql ?? createCloudD1ImportSql(tables)}\n`, + ); + await run([ + "d1", + "execute", + database, + ...modeArguments, + "--file", + sqlPath, + "--yes", + "--config", + resolvedConfig, + ]); + }, + async countByProject() { + const result = await run([ + "d1", + "execute", + database, + ...modeArguments, + "--command", + CLOUD_PROJECT_COUNTS_SQL, + "--json", + "--config", + resolvedConfig, + ]); + return Object.fromEntries(parseD1Results(result.stdout).map((row) => [ + row.project_id, + { + projects: Number(row.projects), + tasks: Number(row.tasks), + comments: Number(row.comments), + task_relations: Number(row.task_relations), + attachments: Number(row.attachments), + workflow_workspaces: Number(row.workflow_workspaces), + }, + ])); + }, + }; + + const r2 = { + async put(key, body, options) { + const bodyPath = await privateFile(".attachment", Buffer.from(body)); + const contentType = options?.httpMetadata?.contentType; + await run([ + "r2", + "object", + "put", + `${bucket}/${key}`, + ...modeArguments, + "--file", + bodyPath, + "--force", + ...(contentType ? ["--content-type", contentType] : []), + "--config", + resolvedConfig, + ]); + }, + async head(key) { + const directory = await temporaryDirectory; + const outputPath = path.join(directory, `${sequence += 1}.download`); + try { + await run([ + "r2", + "object", + "get", + `${bucket}/${key}`, + ...modeArguments, + "--file", + outputPath, + "--config", + resolvedConfig, + ]); + } catch (error) { + if (missingR2Object(error)) return null; + throw error; + } + await chmod(outputPath, 0o600); + const body = await readFile(outputPath); + return { + size: body.byteLength, + customMetadata: { + sha256: createHash("sha256").update(body).digest("hex"), + }, + }; + }, + async delete(key) { + await run([ + "r2", + "object", + "delete", + `${bucket}/${key}`, + ...modeArguments, + "--force", + "--config", + resolvedConfig, + ]); + }, + }; + + return { + d1, + r2, + async cleanup() { + await rm(await temporaryDirectory, { recursive: true, force: true }); + }, + }; +} + +export function createCloudMigrationAdapters({ bundle, command } = {}) { + const preparedImportSql = command === "import" + ? createCloudD1ImportSql(bundle.tables) + : undefined; + return createWranglerCloudAdapters({ preparedImportSql }); +} + +export default createCloudMigrationAdapters; diff --git a/apps/codex-taskboard/server/ai-chat-catalog.mjs b/apps/codex-taskboard/server/ai-chat-catalog.mjs new file mode 100644 index 000000000..70c4f0495 --- /dev/null +++ b/apps/codex-taskboard/server/ai-chat-catalog.mjs @@ -0,0 +1,253 @@ +import { readFile, realpath, stat } from "node:fs/promises"; +import path from "node:path"; + +import { ApiError } from "./database.mjs"; +import { execFileExecutable, spawnExecutable } from "./executable.mjs"; +const CATALOG_TIMEOUT_MS = 10_000; +const CATALOG_MAX_BUFFER = 2 * 1024 * 1024; + +async function existingDirectory(value) { + if (typeof value !== "string" || !path.isAbsolute(value.trim())) return null; + try { + const resolved = await realpath(value.trim()); + return (await stat(resolved)).isDirectory() ? resolved : null; + } catch { + return null; + } +} + +export async function loadDeviceWorkspaces(codexStatePath, database) { + const workspaces = new Map(); + let localProjects = {}; + try { + const state = JSON.parse(await readFile(codexStatePath, "utf8")); + if ( + state?.["local-projects"] + && typeof state["local-projects"] === "object" + && !Array.isArray(state["local-projects"]) + ) { + localProjects = state["local-projects"]; + } + } catch {} + + for (const [projectId, project] of Object.entries(localProjects)) { + if (!Array.isArray(project?.rootPaths)) continue; + for (const rootPath of project.rootPaths) { + const workspacePath = await existingDirectory(rootPath); + if (!workspacePath) continue; + workspaces.set(projectId, workspacePath); + break; + } + } + + for (const project of await database.listProjects()) { + if (workspaces.has(project.id)) continue; + const workspacePath = await existingDirectory(project.workspacePath); + if (workspacePath) workspaces.set(project.id, workspacePath); + } + return workspaces; +} + +export async function resolveAiWorkspace(projectId, codexStatePath, database) { + const project = await database.getProject(projectId); + if (!project) { + throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`); + } + const workspaces = await loadDeviceWorkspaces(codexStatePath, database); + const workspacePath = workspaces.get(projectId); + if (!workspacePath) { + throw new ApiError( + 409, + "PROJECT_WORKSPACE_UNAVAILABLE", + `Project '${projectId}' has no available device workspace`, + ); + } + return { + workspacePath, + addDirectories: [...new Set(workspaces.values())].filter((candidate) => candidate !== workspacePath), + project, + }; +} + +function sanitizeModels(value) { + if (!Array.isArray(value)) throw new Error("Codex returned an invalid model catalog"); + return value.flatMap((model) => { + if ( + !model + || typeof model !== "object" + || (model.visibility !== undefined && model.visibility !== "list") + || typeof model.slug !== "string" + || !model.slug.trim() + ) { + return []; + } + const slug = model.slug.trim(); + const efforts = Array.isArray(model.supported_reasoning_levels) + ? [...new Set(model.supported_reasoning_levels.flatMap((level) => ( + typeof level?.effort === "string" && level.effort.trim() ? [level.effort.trim()] : [] + )))] + : []; + const serviceTiers = Array.isArray(model.service_tiers) + ? model.service_tiers.flatMap((tier) => ( + typeof tier?.id === "string" + && tier.id.trim() + && typeof tier.name === "string" + && tier.name.trim() + ? [{ id: tier.id.trim(), name: tier.name.trim() }] + : [] + )) + : []; + return [{ + slug, + displayName: typeof model.display_name === "string" && model.display_name.trim() + ? model.display_name.trim() + : slug, + description: typeof model.description === "string" ? model.description : "", + defaultReasoningEffort: typeof model.default_reasoning_level === "string" + ? model.default_reasoning_level.trim() + : "", + supportedReasoningEfforts: efforts, + serviceTiers, + }]; + }); +} + +function listSkills(codexExecutable, workspacePath, processEnv) { + return new Promise((resolve, reject) => { + const child = spawnExecutable(codexExecutable, ["app-server", "--stdio"], { + cwd: workspacePath, + env: processEnv, + stdio: ["pipe", "pipe", "ignore"], + }); + let buffer = ""; + let settled = false; + const timeout = setTimeout( + () => finish(new Error("Timed out while reading Codex skills")), + CATALOG_TIMEOUT_MS, + ); + + function finish(error, value) { + if (settled) return; + settled = true; + clearTimeout(timeout); + child.stdin.end(); + child.kill("SIGTERM"); + if (error) reject(error); + else resolve(value); + } + + function send(message) { + child.stdin.write(`${JSON.stringify(message)}\n`); + } + + function handleMessage(message) { + if (message?.id === 1) { + if (message.error) return finish(new Error("Codex app-server rejected initialization")); + send({ method: "initialized" }); + send({ + id: 2, + method: "skills/list", + params: { cwds: [workspacePath], forceReload: false }, + }); + return; + } + if (message?.id !== 2) return; + if (message.error) return finish(new Error("Codex app-server could not list skills")); + finish(null, Array.isArray(message.result?.data) ? message.result.data : []); + } + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + buffer += chunk; + if (buffer.length > CATALOG_MAX_BUFFER) { + finish(new Error("Codex skills response exceeded the catalog size limit")); + return; + } + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex >= 0 && !settled) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + if (line) { + try { + handleMessage(JSON.parse(line)); + } catch {} + } + newlineIndex = buffer.indexOf("\n"); + } + }); + child.stdin.on("error", (error) => finish(error)); + child.once("error", (error) => finish(error)); + child.once("exit", (code, signal) => { + if (!settled) { + finish(new Error(`Codex app-server exited before listing skills (${signal || code})`)); + } + }); + child.once("spawn", () => { + send({ + id: 1, + method: "initialize", + params: { + clientInfo: { name: "codex-taskboard", version: "0.1.0" }, + capabilities: { experimentalApi: true }, + }, + }); + }); + }); +} + +function sanitizeSkills(entries) { + const unique = new Map(); + for (const entry of entries) { + if (!Array.isArray(entry?.skills)) continue; + for (const skill of entry.skills) { + if ( + !skill + || typeof skill !== "object" + || skill.enabled === false + || typeof skill.name !== "string" + || !skill.name.trim() + ) { + continue; + } + const id = skill.name.trim(); + if (unique.has(id)) continue; + const displayName = typeof skill.interface?.displayName === "string" + ? skill.interface.displayName.trim() + : ""; + unique.set(id, { + id, + label: displayName || id, + description: typeof skill.description === "string" ? skill.description.trim() : "", + path: typeof skill.path === "string" ? skill.path.trim() : "", + scope: ["user", "repo", "system", "admin"].includes(skill.scope) ? skill.scope : "user", + }); + } + } + return [...unique.values()].sort((left, right) => left.label.localeCompare(right.label)); +} + +export async function discoverAiCatalog({ + codexExecutable, + codexStatePath, + database, + projectId, + processEnv, +}) { + const { workspacePath } = await resolveAiWorkspace(projectId, codexStatePath, database); + const [modelResult, skillEntries] = await Promise.all([ + execFileExecutable(codexExecutable, ["debug", "models"], { + cwd: workspacePath, + env: processEnv, + encoding: "utf8", + timeout: CATALOG_TIMEOUT_MS, + maxBuffer: CATALOG_MAX_BUFFER, + }), + listSkills(codexExecutable, workspacePath, processEnv), + ]); + const modelCatalog = JSON.parse(modelResult.stdout); + return { + models: sanitizeModels(modelCatalog?.models), + skills: sanitizeSkills(skillEntries), + sandboxes: ["read-only", "workspace-write", "danger-full-access"], + }; +} diff --git a/apps/codex-taskboard/server/ai-chat-process.mjs b/apps/codex-taskboard/server/ai-chat-process.mjs new file mode 100644 index 000000000..3c36e9d9a --- /dev/null +++ b/apps/codex-taskboard/server/ai-chat-process.mjs @@ -0,0 +1,465 @@ +import { spawnExecutable } from "./executable.mjs"; + +const VISIBLE_TEXT_LIMIT = 65_536; +const STDERR_LIMIT = 65_536; +const SKILL_MARKER = "\uFFFC"; +const ITEM_TYPES = new Set([ + "agent_message", + "command_execution", + "file_change", + "mcp_tool_call", + "web_search", + "todo_list", + "error", +]); + +function cappedText(value) { + return typeof value === "string" ? value.slice(0, VISIBLE_TEXT_LIMIT) : ""; +} + +function errorMessage(value) { + if (typeof value === "string") return cappedText(value); + if (value && typeof value === "object") return cappedText(value.message); + return ""; +} + +function detailText(value) { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return cappedText(value); + try { + return cappedText(JSON.stringify(value)); + } catch { + return ""; + } +} + +function itemStatus(rawType, item) { + if (typeof item.status === "string") return cappedText(item.status); + return rawType.slice("item.".length); +} + +function normalizedItem(rawType, item) { + const status = itemStatus(rawType, item); + const itemId = cappedText(item.id); + const baseData = { + status, + ...(itemId ? { itemId } : {}), + }; + + if (item.type === "agent_message") { + return { + kind: "event", + type: item.type, + role: "assistant", + content: cappedText(item.text), + data: baseData, + }; + } + + if (item.type === "command_execution") { + const command = cappedText(item.command); + const output = cappedText(item.aggregated_output); + return { + kind: "event", + type: item.type, + role: "activity", + content: command, + data: { + ...baseData, + command, + ...(output ? { output } : {}), + ...(Number.isInteger(item.exit_code) ? { exitCode: item.exit_code } : {}), + }, + }; + } + + if (item.type === "file_change") { + const changes = Array.isArray(item.changes) + ? item.changes.map((change) => ({ + path: cappedText(change?.path), + kind: cappedText(change?.kind), + })).filter((change) => change.path) + : []; + const content = cappedText(changes.map((change) => change.path).join("\n")); + return { + kind: "event", + type: item.type, + role: "activity", + content, + data: { + ...baseData, + files: cappedText(changes.map((change) => change.path).join("\n")).split("\n").filter(Boolean), + ...(changes.length > 0 ? { detail: detailText(changes) } : {}), + }, + }; + } + + if (item.type === "mcp_tool_call") { + const server = cappedText(item.server); + const tool = cappedText(item.tool); + const detail = detailText({ + ...(item.arguments !== undefined ? { arguments: item.arguments } : {}), + ...(item.result !== undefined ? { result: item.result } : {}), + ...(item.error !== undefined ? { error: item.error } : {}), + }); + return { + kind: "event", + type: item.type, + role: item.error ? "error" : "activity", + content: cappedText([server, tool].filter(Boolean).join(".")), + data: { + ...baseData, + ...(server ? { server } : {}), + ...(tool ? { tool } : {}), + ...(detail && detail !== "{}" ? { detail } : {}), + }, + }; + } + + if (item.type === "web_search") { + const query = cappedText(item.query); + return { + kind: "event", + type: item.type, + role: "activity", + content: query, + data: { ...baseData, ...(query ? { query } : {}) }, + }; + } + + if (item.type === "todo_list") { + const items = Array.isArray(item.items) + ? item.items.map((todo) => ({ + text: cappedText(todo?.text), + ...(typeof todo?.completed === "boolean" ? { completed: todo.completed } : {}), + })).filter((todo) => todo.text) + : []; + return { + kind: "event", + type: item.type, + role: "activity", + content: cappedText(items.map((todo) => todo.text).join("\n")), + data: { + ...baseData, + ...(items.length > 0 ? { detail: detailText(items) } : {}), + }, + }; + } + + const message = errorMessage(item.message ?? item.error); + return { + kind: "event", + type: item.type, + role: "error", + content: message, + data: baseData, + }; +} + +export function buildCodexArgs(thread, addDirectories, imagePaths = []) { + const permission = thread.sandbox === "read-only" + ? { + sandbox: "workspace-write", + approvalPolicy: "on-request", + reviewer: "user", + } + : thread.sandbox === "workspace-write" + ? { + sandbox: "workspace-write", + approvalPolicy: "on-request", + reviewer: "auto_review", + } + : { + sandbox: "danger-full-access", + approvalPolicy: "never", + reviewer: null, + }; + const args = [ + "exec", + "--json", + "--color", + "never", + "-C", + thread.origin.workspacePath, + "-s", + permission.sandbox, + "-c", + `approval_policy="${permission.approvalPolicy}"`, + ]; + if (permission.reviewer) { + args.push("-c", `approvals_reviewer="${permission.reviewer}"`); + } + for (const directory of addDirectories) { + args.push("--add-dir", directory); + } + if (thread.model) { + args.push("-m", thread.model); + } + if (thread.reasoningEffort) { + args.push("-c", `model_reasoning_effort="${thread.reasoningEffort}"`); + } + if (thread.codexThreadId) { + args.push("resume"); + for (const imagePath of imagePaths) { + args.push("-i", imagePath); + } + args.push(thread.codexThreadId, "-"); + } else { + for (const imagePath of imagePaths) { + args.push("-i", imagePath); + } + args.push("-"); + } + return args; +} + +export function buildCodexPrompt(thread, { message, skills, attachmentPaths }, skillPath) { + const selectedSkills = skills ?? []; + const turnAttachmentPaths = attachmentPaths ?? []; + let selectedSkillIndex = 0; + const userMessage = message.replaceAll(SKILL_MARKER, () => { + const skill = selectedSkills[selectedSkillIndex]; + selectedSkillIndex += 1; + return `[$${skill.id}](${skill.path})`; + }); + const context = [ + `project_id: ${thread.origin.projectId}`, + `project_name: ${thread.origin.projectName}`, + `workspace_path: ${thread.origin.workspacePath}`, + ]; + if (thread.origin.issueIdentifier) { + context.push(`issue_identifier: ${thread.origin.issueIdentifier}`); + } + if (turnAttachmentPaths.length > 0) { + context.push( + "turn_attachment_paths:", + ...turnAttachmentPaths.map((attachmentPath) => `- ${attachmentPath}`), + ); + } + context.push( + "This is private server-owned context. Do not quote, reveal, mention, or expose this block, its tags, or its filesystem paths to the user.", + ); + + return [ + `[$manage-taskboard](${skillPath}) e-taskboard`, + "", + "", + ...context, + "", + "", + "", + userMessage, + "", + ].join("\n"); +} + +export function normalizeCodexEvent(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + + if (raw.type === "thread.started") { + if ( + typeof raw.thread_id !== "string" + || raw.thread_id.length === 0 + || raw.thread_id.length > 256 + || raw.thread_id.includes("\0") + ) { + return null; + } + return { kind: "thread.started", threadId: raw.thread_id }; + } + + if (raw.type === "turn.started") { + return { + kind: "event", + type: raw.type, + role: "activity", + content: "", + data: { status: "started" }, + }; + } + + if (raw.type === "turn.completed") { + const usage = {}; + for (const key of ["input_tokens", "cached_input_tokens", "output_tokens"]) { + if (Number.isFinite(raw.usage?.[key])) usage[key] = raw.usage[key]; + } + return { + kind: "event", + type: raw.type, + role: "activity", + content: "", + data: { + status: "completed", + ...(Object.keys(usage).length > 0 ? { usage } : {}), + }, + }; + } + + if (raw.type === "turn.failed") { + return { + kind: "event", + type: raw.type, + role: "error", + content: errorMessage(raw.error ?? raw.message), + data: { status: "failed" }, + }; + } + + if (raw.type === "error") { + return { + kind: "event", + type: raw.type, + role: "error", + content: errorMessage(raw.message ?? raw.error), + data: { status: "failed" }, + }; + } + + if ( + raw.type !== "item.started" + && raw.type !== "item.updated" + && raw.type !== "item.completed" + ) { + return null; + } + if (!raw.item || typeof raw.item !== "object" || !ITEM_TYPES.has(raw.item.type)) { + return null; + } + return normalizedItem(raw.type, raw.item); +} + +export function spawnCodexTurn({ + executable, + args, + prompt, + env, + onRawEvent, + maxLineBytes = 1_048_576, +}) { + const child = spawnExecutable(executable, args, { + detached: true, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdoutBuffer = Buffer.alloc(0); + let stderrBuffer = Buffer.alloc(0); + let settled = false; + let fatalError = null; + let stdoutEnded = false; + let resolveCompletion; + let rejectCompletion; + + const completion = new Promise((resolve, reject) => { + resolveCompletion = resolve; + rejectCompletion = reject; + }); + + function terminateProcessGroup() { + if (Number.isInteger(child.pid)) { + try { + process.kill(-child.pid, "SIGKILL"); + return; + } catch {} + } + child.kill("SIGKILL"); + } + + function rejectWithDiagnostic(error) { + if (settled || fatalError) return; + fatalError = error instanceof Error ? error : new Error(String(error)); + terminateProcessGroup(); + } + + function consumeLine(line) { + if (fatalError) return; + if (line.length > maxLineBytes) { + rejectWithDiagnostic(new Error(`Codex JSONL line exceeded ${maxLineBytes} bytes`)); + return; + } + if (line.at(-1) === 13) line = line.subarray(0, -1); + if (line.toString("utf8").trim() === "") return; + let raw; + try { + raw = JSON.parse(line.toString("utf8")); + } catch { + rejectWithDiagnostic(new Error("Codex emitted malformed JSONL")); + return; + } + try { + onRawEvent(raw); + } catch (error) { + rejectWithDiagnostic(error); + } + } + + function consumeChunk(chunk) { + if (settled) return; + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + let offset = 0; + while (offset < bytes.length && !settled && !fatalError) { + const newline = bytes.indexOf(10, offset); + if (newline === -1) { + const remainder = bytes.subarray(offset); + if (stdoutBuffer.length + remainder.length > maxLineBytes) { + rejectWithDiagnostic(new Error(`Codex JSONL line exceeded ${maxLineBytes} bytes`)); + return; + } + stdoutBuffer = stdoutBuffer.length === 0 + ? Buffer.from(remainder) + : Buffer.concat([stdoutBuffer, remainder]); + return; + } + const segment = bytes.subarray(offset, newline); + if (stdoutBuffer.length + segment.length > maxLineBytes) { + rejectWithDiagnostic(new Error(`Codex JSONL line exceeded ${maxLineBytes} bytes`)); + return; + } + const line = stdoutBuffer.length === 0 + ? segment + : Buffer.concat([stdoutBuffer, segment]); + stdoutBuffer = Buffer.alloc(0); + consumeLine(line); + offset = newline + 1; + } + } + + function finishStdout() { + if (stdoutEnded) return; + stdoutEnded = true; + if (!fatalError && stdoutBuffer.length > 0) { + const line = stdoutBuffer; + stdoutBuffer = Buffer.alloc(0); + consumeLine(line); + } + } + + child.stdout.on("data", consumeChunk); + child.stdout.on("end", finishStdout); + child.stderr.on("data", (chunk) => { + if (stderrBuffer.length >= STDERR_LIMIT) return; + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + stderrBuffer = Buffer.concat([ + stderrBuffer, + bytes.subarray(0, STDERR_LIMIT - stderrBuffer.length), + ]); + }); + child.on("error", rejectWithDiagnostic); + child.on("close", (exitCode, signal) => { + finishStdout(); + if (settled) return; + settled = true; + if (fatalError) { + if (stderrBuffer.length > 0) { + fatalError.stderr = stderrBuffer.toString("utf8"); + } + rejectCompletion(fatalError); + return; + } + resolveCompletion({ exitCode, signal }); + }); + child.stdin.on("error", () => {}); + child.stdin.end(prompt); + + return { child, completion }; +} diff --git a/apps/codex-taskboard/server/ai-chat.mjs b/apps/codex-taskboard/server/ai-chat.mjs new file mode 100644 index 000000000..138a9506e --- /dev/null +++ b/apps/codex-taskboard/server/ai-chat.mjs @@ -0,0 +1,585 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { ApiError } from "./database.mjs"; +import { discoverAiCatalog, resolveAiWorkspace } from "./ai-chat-catalog.mjs"; +import { + buildCodexArgs, + buildCodexPrompt, + normalizeCodexEvent, + spawnCodexTurn, +} from "./ai-chat-process.mjs"; + +const SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]); +const ERROR_CONTENT_LIMIT = 65_536; +const CODEX_IMAGE_TYPES = new Set([ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]); + +function cappedError(value) { + const message = value instanceof Error ? value.message : String(value ?? ""); + return message.slice(0, ERROR_CONTENT_LIMIT); +} + +function signalProcessGroup(child, signal) { + if (Number.isInteger(child?.pid)) { + try { + process.kill(-child.pid, signal); + return; + } catch {} + } + try { + child?.kill(signal); + } catch {} +} + +function wait(milliseconds) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, milliseconds); + timer.unref(); + }); +} + +export class AiChatService { + constructor(options) { + this.database = options.database; + this.codexExecutable = options.codexExecutable; + this.codexStatePath = options.codexStatePath; + this.manageTaskboardSkillPath = options.manageTaskboardSkillPath; + this.processEnv = options.processEnv ?? process.env; + this.killGraceMs = options.killGraceMs ?? 1_000; + this.active = new Map(); + this.listeners = new Map(); + this.completions = new Map(); + } + + listThreads() { + return this.database.listAiChatThreads(); + } + + getThread(threadId) { + const thread = this.database.getAiChatThread(threadId); + if (!thread) { + throw new ApiError( + 404, + "AI_CHAT_THREAD_NOT_FOUND", + `AI chat thread '${threadId}' does not exist`, + ); + } + return thread; + } + + getThreadSnapshot(threadId) { + const thread = this.getThread(threadId); + return { + thread, + events: this.database.listAiChatEvents(threadId), + runs: this.database.listAiChatRuns(threadId), + }; + } + + getRun(runId) { + const run = this.database.getAiChatRun(runId); + if (!run) { + throw new ApiError(404, "AI_CHAT_RUN_NOT_FOUND", `AI chat run '${runId}' does not exist`); + } + return run; + } + + subscribe(threadId, listener) { + let listeners = this.listeners.get(threadId); + if (!listeners) { + listeners = new Set(); + this.listeners.set(threadId, listeners); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) this.listeners.delete(threadId); + }; + } + + async getCatalog(projectId) { + return discoverAiCatalog({ + codexExecutable: this.codexExecutable, + codexStatePath: this.codexStatePath, + database: this.database, + projectId, + processEnv: this.processEnv, + }); + } + + async createThread(input) { + const [catalog, resolved] = await Promise.all([ + this.getCatalog(input.projectId), + resolveAiWorkspace(input.projectId, this.codexStatePath, this.database), + ]); + const model = this.#resolveModel(catalog, input.model); + const reasoningEffort = input.reasoningEffort ?? model.defaultReasoningEffort; + this.#validateReasoningEffort(model, reasoningEffort); + const sandbox = input.sandbox ?? "workspace-write"; + this.#validateSandbox(sandbox); + + let issue; + if (input.issueId !== undefined) { + issue = this.database.getTask(input.issueId); + if (!issue || issue.projectId !== input.projectId || issue.archivedAt != null) { + throw new ApiError( + 404, + "AI_CHAT_ISSUE_NOT_FOUND", + `Task '${input.issueId}' is not an active task in project '${input.projectId}'`, + ); + } + } + + return this.database.createAiChatThread({ + title: input.title ?? issue?.identifier ?? "New conversation", + origin: { + projectId: resolved.project.id, + projectName: resolved.project.name, + workspacePath: resolved.workspacePath, + ...(issue ? { issueId: issue.id, issueIdentifier: issue.identifier } : {}), + }, + ...(input.codexThreadId ? { codexThreadId: input.codexThreadId } : {}), + model: model.slug, + reasoningEffort, + sandbox, + }); + } + + async updateThread(threadId, changes) { + let thread = this.getThread(threadId); + const changesSettings = ["model", "reasoningEffort", "sandbox"].some( + (key) => Object.hasOwn(changes, key), + ); + const wasActive = changesSettings && this.#threadIsActive(thread); + + if (Object.hasOwn(changes, "sandbox")) this.#validateSandbox(changes.sandbox); + if (Object.hasOwn(changes, "model") || Object.hasOwn(changes, "reasoningEffort")) { + const catalog = await this.getCatalog(thread.origin.projectId); + thread = this.getThread(threadId); + const model = this.#resolveModel(catalog, changes.model ?? thread.model); + const reasoningEffort = changes.reasoningEffort ?? thread.reasoningEffort; + this.#validateReasoningEffort(model, reasoningEffort); + } + if (wasActive || (changesSettings && this.#threadIsActive(thread))) { + throw new ApiError( + 409, + "THREAD_BUSY", + `AI chat thread '${threadId}' has a running turn`, + ); + } + + return this.database.updateAiChatThread(threadId, changes); + } + + deleteThread(threadId) { + const thread = this.getThread(threadId); + if (this.#threadIsActive(thread)) { + throw new ApiError( + 409, + "THREAD_BUSY", + `AI chat thread '${threadId}' has a running turn`, + ); + } + return this.database.deleteAiChatThread(threadId); + } + + async startTurn(threadId, input) { + let thread = this.getThread(threadId); + if (this.#threadIsActive(thread)) { + throw new ApiError( + 409, + "THREAD_BUSY", + `AI chat thread '${threadId}' has a running turn`, + ); + } + this.#validateTurnInput(input); + if (thread.sandbox === "danger-full-access" && input.dangerFullAccessConfirmed !== true) { + throw new ApiError( + 400, + "DANGER_CONFIRMATION_REQUIRED", + "danger-full-access must be confirmed for every turn", + ); + } + + const [catalog, resolved] = await Promise.all([ + this.getCatalog(thread.origin.projectId), + resolveAiWorkspace(thread.origin.projectId, this.codexStatePath, this.database), + ]); + + thread = this.getThread(threadId); + if (this.#threadIsActive(thread)) { + throw new ApiError( + 409, + "THREAD_BUSY", + `AI chat thread '${threadId}' has a running turn`, + ); + } + if (thread.sandbox === "danger-full-access" && input.dangerFullAccessConfirmed !== true) { + throw new ApiError( + 400, + "DANGER_CONFIRMATION_REQUIRED", + "danger-full-access must be confirmed for every turn", + ); + } + const model = this.#resolveModel(catalog, thread.model); + this.#validateReasoningEffort(model, thread.reasoningEffort); + if (resolved.workspacePath !== thread.origin.workspacePath) { + throw new ApiError( + 409, + "PROJECT_WORKSPACE_CHANGED", + "The project's device workspace no longer matches this conversation", + ); + } + + const skillIds = input.skillIds ?? []; + const availableSkills = new Map( + catalog.skills + .filter((skill) => skill.id !== "manage-taskboard") + .map((skill) => [skill.id, skill]), + ); + for (const skillId of skillIds) { + if (!availableSkills.has(skillId)) { + throw new ApiError(400, "INVALID_SKILL", `Unknown or unavailable skill '${skillId}'`); + } + } + const selectedSkills = skillIds.map((skillId) => availableSkills.get(skillId)); + + const attachments = input.attachments ?? []; + const { + temporaryDirectory, + attachmentPaths, + imagePaths, + } = await this.#writeTurnAttachments(attachments); + try { + const args = buildCodexArgs(thread, resolved.addDirectories, imagePaths); + const prompt = buildCodexPrompt( + thread, + { + message: input.message, + skills: selectedSkills, + attachmentPaths, + }, + this.manageTaskboardSkillPath, + ); + const run = this.database.createAiChatRun({ threadId }); + this.#emit(threadId, { type: "ai.run", run }); + const userEventData = {}; + if (skillIds.length > 0) userEventData.skillIds = skillIds; + if (attachments.length > 0) { + userEventData.attachments = attachments.map(({ filename, contentType, size }) => ({ + filename, + contentType, + size, + })); + } + const userEvent = this.database.insertAiChatEvent({ + threadId, + runId: run.id, + type: "user_message", + role: "user", + content: input.message, + data: Object.keys(userEventData).length > 0 ? userEventData : undefined, + }); + this.#emit(threadId, { type: "ai.event", event: userEvent }); + + const resumingThreadId = thread.codexThreadId; + let startedThreadId = null; + let terminalOutcome = null; + let terminalError = ""; + const { child, completion } = spawnCodexTurn({ + executable: this.codexExecutable, + args, + prompt, + env: this.processEnv, + onRawEvent: (raw) => { + const normalized = normalizeCodexEvent(raw); + if (!normalized) return; + if (normalized.kind === "thread.started") { + if ( + (resumingThreadId && normalized.threadId !== resumingThreadId) + || (startedThreadId && normalized.threadId !== startedThreadId) + ) { + throw new Error("Codex returned an unexpected thread id"); + } + startedThreadId = normalized.threadId; + this.database.updateAiChatThread(threadId, { codexThreadId: normalized.threadId }); + return; + } + const event = this.database.insertAiChatEvent({ + threadId, + runId: run.id, + type: normalized.type, + role: normalized.role, + content: normalized.content, + data: normalized.data, + }); + if (raw.type === "turn.completed" && terminalOutcome === null) { + terminalOutcome = "completed"; + } else if (raw.type === "turn.failed" || raw.type === "error") { + terminalOutcome = "failed"; + terminalError ||= normalized.content; + } + this.#emit(threadId, { type: "ai.event", event }); + }, + }); + + const active = { child, threadId, interrupted: false, temporaryDirectory }; + this.active.set(run.id, active); + const finalization = completion.then( + (result) => this.#finishRun({ + run, + active, + result, + resumingThreadId, + startedThreadId: () => startedThreadId, + terminalOutcome: () => terminalOutcome, + terminalError: () => terminalError, + }), + (error) => this.#finishRun({ + run, + active, + error, + resumingThreadId, + startedThreadId: () => startedThreadId, + terminalOutcome: () => terminalOutcome, + terminalError: () => terminalError, + }), + ); + this.completions.set(run.id, finalization); + void finalization.finally(() => this.completions.delete(run.id)).catch(() => {}); + return run; + } catch (error) { + if (temporaryDirectory) { + await rm(temporaryDirectory, { recursive: true, force: true }); + } + throw error; + } + } + + async interrupt(runId) { + let run = this.getRun(runId); + if (run.status !== "running") return run; + + const active = this.active.get(runId); + if (!active) { + run = this.database.updateAiChatRun(runId, { + status: "interrupted", + error: "Interrupted", + finishedAt: new Date().toISOString(), + }); + this.#emit(run.threadId, { type: "ai.run", run }); + return run; + } + + active.interrupted = true; + signalProcessGroup(active.child, "SIGTERM"); + const timer = setTimeout(() => { + if (this.active.has(runId)) signalProcessGroup(active.child, "SIGKILL"); + }, this.killGraceMs); + timer.unref(); + + const completion = this.completions.get(runId); + if (completion) { + await Promise.race([completion.catch(() => {}), wait(this.killGraceMs + 25)]); + } + return this.getRun(runId); + } + + async close() { + const entries = [...this.active.entries()]; + for (const [, active] of entries) { + active.interrupted = true; + signalProcessGroup(active.child, "SIGTERM"); + } + + const completions = entries + .map(([runId]) => this.completions.get(runId)) + .filter(Boolean); + if (completions.length > 0) { + const settled = Promise.allSettled(completions); + await Promise.race([settled, wait(this.killGraceMs)]); + for (const [runId, active] of entries) { + if (this.active.has(runId)) signalProcessGroup(active.child, "SIGKILL"); + } + await settled; + } + this.listeners.clear(); + } + + #resolveModel(catalog, requestedModel) { + const model = requestedModel === undefined + ? catalog.models[0] + : catalog.models.find((candidate) => candidate.slug === requestedModel); + if (!model) { + throw new ApiError( + 400, + "INVALID_MODEL", + requestedModel === undefined + ? "Codex did not provide an available model" + : `Unknown model '${requestedModel}'`, + ); + } + return model; + } + + #validateReasoningEffort(model, reasoningEffort) { + if (!model.supportedReasoningEfforts.includes(reasoningEffort)) { + throw new ApiError( + 400, + "INVALID_REASONING_EFFORT", + `Reasoning effort '${reasoningEffort}' is not supported by model '${model.slug}'`, + ); + } + } + + #validateSandbox(sandbox) { + if (!SANDBOXES.has(sandbox)) { + throw new ApiError( + 400, + "INVALID_SANDBOX", + "'sandbox' must be read-only, workspace-write, or danger-full-access", + ); + } + } + + #validateTurnInput(input) { + if ( + !input + || typeof input.message !== "string" + || input.message.length > 100_000 + || ( + input.message.trim() === "" + && (!Array.isArray(input.attachments) || input.attachments.length === 0) + ) + ) { + throw new ApiError( + 400, + "INVALID_MESSAGE", + "A message or at least one attachment is required", + ); + } + if ( + input.skillIds !== undefined + && ( + !Array.isArray(input.skillIds) + || input.skillIds.length > 20 + || input.skillIds.some((skillId) => typeof skillId !== "string" || !skillId) + ) + ) { + throw new ApiError( + 400, + "INVALID_SKILL", + "'skillIds' must contain at most 20 skill ids", + ); + } + } + + async #writeTurnAttachments(attachments) { + if (attachments.length === 0) { + return { temporaryDirectory: null, attachmentPaths: [], imagePaths: [] }; + } + const temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), "codex-taskboard-ai-turn-"), + ); + try { + const attachmentPaths = []; + const imagePaths = []; + for (const [index, attachment] of attachments.entries()) { + const attachmentPath = path.join( + temporaryDirectory, + `attachment-${index + 1}-${attachment.filename}`, + ); + await writeFile(attachmentPath, attachment.data, { flag: "wx", mode: 0o600 }); + attachmentPaths.push(attachmentPath); + if (CODEX_IMAGE_TYPES.has(attachment.contentType)) imagePaths.push(attachmentPath); + } + return { temporaryDirectory, attachmentPaths, imagePaths }; + } catch (error) { + await rm(temporaryDirectory, { recursive: true, force: true }); + throw error; + } + } + + #threadIsActive(thread) { + return Boolean(thread.currentRun) + || [...this.active.values()].some((active) => active.threadId === thread.id); + } + + async #finishRun({ + run, + active, + result, + error, + resumingThreadId, + startedThreadId, + terminalOutcome, + terminalError, + }) { + let status; + let publicError = null; + if (active.interrupted) { + status = "interrupted"; + publicError = "Interrupted"; + } else if (error) { + status = "failed"; + publicError = cappedError(error) || "Codex turn failed"; + } else if (terminalOutcome() === "failed") { + status = "failed"; + publicError = terminalError() || "Codex reported a failed turn"; + } else if (result.exitCode !== 0) { + status = "failed"; + publicError = result.exitCode === null + ? `Codex exited due to signal ${result.signal ?? "unknown"}` + : `Codex exited with code ${result.exitCode}`; + } else if (terminalOutcome() !== "completed") { + status = "failed"; + publicError = "Codex exited without reporting turn completion"; + } else if (!resumingThreadId && !startedThreadId()) { + status = "failed"; + publicError = "Codex did not provide a thread id"; + } else { + status = "completed"; + } + + try { + if (status === "failed" && terminalOutcome() !== "failed") { + const errorEvent = this.database.insertAiChatEvent({ + threadId: run.threadId, + runId: run.id, + type: "error", + role: "error", + content: cappedError(publicError), + data: { status: "failed" }, + }); + this.#emit(run.threadId, { type: "ai.event", event: errorEvent }); + } + const updated = this.database.updateAiChatRun(run.id, { + status, + exitCode: result?.exitCode ?? null, + error: publicError === null ? null : cappedError(publicError), + finishedAt: new Date().toISOString(), + }); + this.#emit(run.threadId, { type: "ai.run", run: updated }); + return updated; + } finally { + this.active.delete(run.id); + if (active.temporaryDirectory) { + await rm(active.temporaryDirectory, { recursive: true, force: true }); + } + } + } + + #emit(threadId, event) { + for (const listener of this.listeners.get(threadId) ?? []) { + try { + listener(event); + } catch {} + } + } +} diff --git a/apps/codex-taskboard/server/app.mjs b/apps/codex-taskboard/server/app.mjs new file mode 100644 index 000000000..055124d8f --- /dev/null +++ b/apps/codex-taskboard/server/app.mjs @@ -0,0 +1,2418 @@ +import { randomUUID, timingSafeEqual } from "node:crypto"; +import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { isIP } from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; + +import { + DEFAULT_PROJECT_ID, + TASK_STATUSES, + isTaskPriority, + isTaskStatus, +} from "../shared/domain.mjs"; +import { normalizeWorkflowSnapshot } from "../shared/workflow-control-flow.mjs"; +import { AiChatService } from "./ai-chat.mjs"; +import { execFileExecutable, spawnExecutable } from "./executable.mjs"; +import { createCloudConfigStore } from "./cloud-config.mjs"; +import { + CloudProxyError, + createCloudProxy, + isLocalCompanionRoute, +} from "./cloud-proxy.mjs"; +import { ApiError, TaskboardDatabase } from "./database.mjs"; + +const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const JSON_BODY_LIMIT = 1024 * 1024; +const ATTACHMENT_BODY_LIMIT = 25 * 1024 * 1024; +const AI_CHAT_TURN_BODY_LIMIT = 25 * 1024 * 1024; +const AI_CHAT_ATTACHMENT_LIMIT = 10; +const AI_CHAT_SKILL_MARKER = "\uFFFC"; +const INLINE_ATTACHMENT_TYPES = new Set([ + "application/pdf", + "image/avif", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", + "text/plain", +]); +const PROJECT_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; +const TRUSTED_EMBED_ORIGINS = new Set(["app://-"]); +const LOOPBACK_TASKBOARD_HOST = "127.0.0.1"; +const LAN_TASKBOARD_HOST = "0.0.0.0"; +const TASKBOARD_SHARED_SECRET_ENV = "TASKBOARD_SHARED_SECRET"; +const TASKBOARD_BIND_HOST_HEADER = "x-taskboard-bind-host"; +export const LAN_SHARING_WARNING = + "LAN sharing is enabled; requests require Basic auth and same-origin protection."; +const CODEX_AGENT_ACTOR = { + type: "agent", + id: "codex-agent", + name: "Codex Agent", + avatarUrl: null, +}; +const CONTENT_TYPES = new Map([ + [".css", "text/css; charset=utf-8"], + [".html", "text/html; charset=utf-8"], + [".ico", "image/x-icon"], + [".jpeg", "image/jpeg"], + [".jpg", "image/jpeg"], + [".js", "text/javascript; charset=utf-8"], + [".json", "application/json; charset=utf-8"], + [".map", "application/json; charset=utf-8"], + [".png", "image/png"], + [".svg", "image/svg+xml"], + [".webp", "image/webp"], + [".woff", "font/woff"], + [".woff2", "font/woff2"], +]); + +function sendJson(response, status, value, headers = {}) { + const body = JSON.stringify(value); + response.writeHead(status, { + "cache-control": "no-store", + "content-length": Buffer.byteLength(body), + "content-type": "application/json; charset=utf-8", + ...headers, + }); + response.end(body); +} + +function sendEmpty(response, status, headers = {}) { + response.writeHead(status, { "cache-control": "no-store", ...headers }); + response.end(); +} + +function toFetchRequest(request) { + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (Array.isArray(value)) { + for (const entry of value) headers.append(name, entry); + } else if (value !== undefined) { + headers.set(name, value); + } + } + const init = { method: request.method, headers }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = Readable.toWeb(request); + init.duplex = "half"; + } + return new Request(`http://127.0.0.1${request.url}`, init); +} + +async function sendFetchResponse(response, upstream) { + response.statusCode = upstream.status; + response.statusMessage = upstream.statusText; + for (const [name, value] of upstream.headers) { + if ( + name === "connection" + || name === "content-encoding" + || name === "content-length" + || name === "set-cookie" + || name === "transfer-encoding" + ) { + continue; + } + response.setHeader(name, value); + } + const cookies = upstream.headers.getSetCookie?.() ?? []; + if (cookies.length > 0) response.setHeader("set-cookie", cookies); + if (!upstream.body) { + response.end(); + return; + } + await new Promise((resolve, reject) => { + const body = Readable.fromWeb(upstream.body); + body.once("error", reject); + response.once("finish", resolve); + body.pipe(response); + }); +} + +async function deleteTaskAndAttachments(database, task, attachmentsDirectory) { + const result = database.deleteTask(task.id, task.version); + for (const attachment of result.attachments) { + try { + await unlink(path.join(attachmentsDirectory, attachment.id)); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + return result.task; +} + +function normalizeHostname(hostname) { + return hostname.toLowerCase().replace(/^\[|\]$/g, ""); +} + +function isTrustedNetworkHost(hostname) { + const host = normalizeHostname(hostname); + if (host === "localhost" || host === "::1" || host.endsWith(".local")) return true; + if (isIP(host) === 4) { + const octets = host.split(".").map(Number); + return octets[0] === 127 + || octets[0] === 10 + || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) + || (octets[0] === 192 && octets[1] === 168) + || (octets[0] === 169 && octets[1] === 254); + } + if (isIP(host) === 6) { + return host.startsWith("fc") + || host.startsWith("fd") + || /^fe[89ab]/.test(host); + } + return false; +} + +function assertTrustedNetworkRequest(request) { + let requestUrl; + try { + requestUrl = new URL(`http://${request.headers.host ?? ""}`); + } catch { + throw new ApiError(403, "INVALID_HOST", "Request Host must be local or private"); + } + const host = requestUrl.hostname; + if (!isTrustedNetworkHost(host)) { + throw new ApiError(403, "INVALID_HOST", "Request Host must be local or private"); + } + + const origin = request.headers.origin; + if (!origin) return requestUrl.host; + if (TRUSTED_EMBED_ORIGINS.has(origin)) return requestUrl.host; + let originHost; + try { + originHost = new URL(origin).hostname; + } catch { + throw new ApiError(403, "INVALID_ORIGIN", "Request Origin must be local or private"); + } + if (!isTrustedNetworkHost(originHost)) { + throw new ApiError(403, "INVALID_ORIGIN", "Request Origin must be local or private"); + } + return requestUrl.host; +} + +function assertLanSameOriginRequest(request, requestAuthority) { + const origin = request.headers.origin; + if (!origin || TRUSTED_EMBED_ORIGINS.has(origin)) return; + let originUrl; + try { + originUrl = new URL(origin); + } catch { + throw new ApiError(403, "INVALID_ORIGIN", "Request Origin must be local or private"); + } + if (originUrl.protocol !== "http:" || originUrl.host !== requestAuthority) { + throw new ApiError(403, "INVALID_ORIGIN", "LAN Origin must match the request Host"); + } +} + +function resolveSharedSecret(value = process.env[TASKBOARD_SHARED_SECRET_ENV]) { + const secret = String(value ?? "").trim(); + return secret.length > 0 ? secret : null; +} + +function basicAuthChallenge() { + const error = new ApiError(401, "LAN_AUTH_REQUIRED", "LAN sharing requires valid Basic auth"); + error.headers = { "www-authenticate": "Basic realm=\"Codex Taskboard\", charset=\"UTF-8\"" }; + return error; +} + +function parseBasicAuth(value) { + if (typeof value !== "string" || !value.toLowerCase().startsWith("basic ")) return null; + const encoded = value.slice(6).trim(); + let decoded; + try { + decoded = Buffer.from(encoded, "base64").toString("utf8"); + } catch { + return null; + } + const separator = decoded.indexOf(":"); + if (separator < 1) return null; + return { + username: decoded.slice(0, separator), + password: decoded.slice(separator + 1), + }; +} + +function secretEquals(actual, expected) { + const actualBytes = Buffer.from(actual); + const expectedBytes = Buffer.from(expected); + return actualBytes.length === expectedBytes.length + && timingSafeEqual(actualBytes, expectedBytes); +} + +function assertLanAuthorization(request, sharedSecret) { + if (!sharedSecret) { + throw new ApiError(403, "LAN_SECRET_REQUIRED", `${TASKBOARD_SHARED_SECRET_ENV} is required for LAN sharing`); + } + const credentials = parseBasicAuth(request.headers.authorization); + if (!credentials || !secretEquals(credentials.password, sharedSecret)) { + throw basicAuthChallenge(); + } +} + +function assertLoopbackRequest(request) { + const address = request.socket.remoteAddress; + if ( + address !== "127.0.0.1" + && address !== "::1" + && address !== "::ffff:127.0.0.1" + ) { + throw new ApiError(403, "LOCAL_ONLY", "This endpoint is only available on this device"); + } +} + +function assertPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new ApiError(400, "INVALID_BODY", "Request body must be a JSON object"); + } +} + +function assertAllowedKeys(value, allowed) { + const unknown = Object.keys(value).filter((key) => !allowed.has(key)); + if (unknown.length > 0) { + throw new ApiError(400, "UNKNOWN_FIELD", `Unknown field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}`); + } +} + +function assertAllowedQuery(searchParams, allowed, routeLabel) { + for (const key of searchParams.keys()) { + if (!allowed.has(key)) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", `${routeLabel} does not accept query parameter '${key}'`); + } + if (searchParams.getAll(key).length !== 1) { + throw new ApiError(400, "INVALID_QUERY_PARAMETER", `Query parameter '${key}' cannot be repeated`); + } + } +} + +function assertNoQuery(searchParams, routeLabel) { + assertAllowedQuery(searchParams, new Set(), routeLabel); +} + +function decodeRouteSegment(value, name) { + let decoded; + try { + decoded = decodeURIComponent(value); + } catch { + throw new ApiError(400, "INVALID_PATH", `${name} contains invalid encoding`); + } + if (!decoded || decoded.length > 256 || decoded.includes("\0")) { + throw new ApiError(400, "INVALID_PATH", `${name} is invalid`); + } + return decoded; +} + +function isLoopbackAddress(value) { + if (typeof value !== "string") return false; + const address = value.toLowerCase().split("%", 1)[0]; + return address === "::1" + || address === "127.0.0.1" + || address.startsWith("127.") + || address === "::ffff:127.0.0.1" + || address.startsWith("::ffff:127."); +} + +function assertAiLoopbackRequest(request) { + if (!isLoopbackAddress(request.socket.remoteAddress)) { + throw new ApiError(403, "LOCAL_AI_LOOPBACK_REQUIRED", "Local AI routes are only available from this device"); + } +} + +function stringField(value, name, { required = false, nullable = false, maxLength }) { + if (value === undefined) { + if (required) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' is required`); + } + return undefined; + } + if (nullable && value === null) { + return null; + } + if (typeof value !== "string") { + throw new ApiError(400, "INVALID_FIELD", `'${name}' must be a string${nullable ? " or null" : ""}`); + } + const normalized = value.trim(); + if (required && normalized.length === 0) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' cannot be empty`); + } + if (normalized.length > maxLength) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' cannot exceed ${maxLength} characters`); + } + return normalized; +} + +function pathField(value, name) { + const normalized = stringField(value, name, { nullable: true, maxLength: 4096 }); + if (normalized === "") { + throw new ApiError(400, "INVALID_FIELD", `'${name}' cannot be empty`); + } + if (normalized?.includes("\0")) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' cannot contain null bytes`); + } + return normalized; +} + +function parseDueDate(value, name = "dueDate") { + const date = stringField(value, name, { nullable: true, maxLength: 10 }); + if (date !== null && date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' must use YYYY-MM-DD`); + } + return date; +} + +function parseDevelopmentContext(value) { + if (value === null) return null; + assertPlainObject(value); + if (value.type === "branch") { + assertAllowedKeys(value, new Set(["type", "branch"])); + return { + type: "branch", + branch: stringField(value.branch, "developmentContext.branch", { required: true, maxLength: 512 }), + }; + } + if (value.type === "worktree") { + assertAllowedKeys(value, new Set(["type", "path", "branch"])); + const worktreePath = stringField(value.path, "developmentContext.path", { required: true, maxLength: 4096 }); + if (worktreePath.includes("\0")) { + throw new ApiError(400, "INVALID_FIELD", "'developmentContext.path' cannot contain null bytes"); + } + return { + type: "worktree", + path: worktreePath, + branch: stringField(value.branch ?? null, "developmentContext.branch", { nullable: true, maxLength: 512 }), + }; + } + throw new ApiError(400, "INVALID_FIELD", "'developmentContext.type' must be branch or worktree"); +} + +function parseRecurrence(value) { + if (value === null) return null; + assertPlainObject(value); + assertAllowedKeys(value, new Set(["interval", "unit"])); + if (!Number.isSafeInteger(value.interval) || value.interval < 1 || value.interval > 365) { + throw new ApiError(400, "INVALID_FIELD", "'recurrence.interval' must be an integer from 1 to 365"); + } + if (!["day", "week", "month", "year"].includes(value.unit)) { + throw new ApiError(400, "INVALID_FIELD", "'recurrence.unit' must be day, week, month, or year"); + } + return { interval: value.interval, unit: value.unit }; +} + +function parseVersion(value) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new ApiError(400, "INVALID_FIELD", "'version' must be a positive integer"); + } + return value; +} + +function parseWorkflowVersion(value) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new ApiError(400, "INVALID_FIELD", "'version' must be a non-negative integer"); + } + return value; +} + +function parseWorkflowWorkspace(value) { + assertPlainObject(value); + assertAllowedKeys(value, new Set(["version", "tabs", "activeWorkflowId", "snapshots"])); + if (value.version !== 1) { + throw new ApiError(400, "INVALID_FIELD", "'workspace.version' must be 1"); + } + if (!Array.isArray(value.tabs) || value.tabs.length === 0 || value.tabs.length > 100) { + throw new ApiError(400, "INVALID_FIELD", "'workspace.tabs' must contain 1 to 100 workflows"); + } + const tabs = value.tabs.map((tab, index) => { + assertPlainObject(tab); + assertAllowedKeys(tab, new Set(["id", "name"])); + return { + id: stringField(tab.id, `workspace.tabs[${index}].id`, { required: true, maxLength: 128 }), + name: stringField(tab.name, `workspace.tabs[${index}].name`, { required: true, maxLength: 120 }), + }; + }); + if (new Set(tabs.map((tab) => tab.id)).size !== tabs.length) { + throw new ApiError(400, "INVALID_FIELD", "'workspace.tabs' ids must be unique"); + } + const activeWorkflowId = stringField(value.activeWorkflowId, "workspace.activeWorkflowId", { + required: true, + maxLength: 128, + }); + if (!tabs.some((tab) => tab.id === activeWorkflowId)) { + throw new ApiError(400, "INVALID_FIELD", "'workspace.activeWorkflowId' must reference a workflow tab"); + } + assertPlainObject(value.snapshots); + const snapshots = {}; + for (const tab of tabs) { + const snapshot = value.snapshots[tab.id]; + assertPlainObject(snapshot); + assertAllowedKeys(snapshot, new Set(["nodes", "edges", "flow", "selectedNodeId"])); + if (!Array.isArray(snapshot.nodes) || snapshot.nodes.length > 10_000) { + throw new ApiError(400, "INVALID_FIELD", `'workspace.snapshots.${tab.id}.nodes' must be an array`); + } + if (snapshot.flow === undefined && (!Array.isArray(snapshot.edges) || snapshot.edges.length > 20_000)) { + throw new ApiError(400, "INVALID_FIELD", `'workspace.snapshots.${tab.id}.edges' must be an array`); + } + if (snapshot.flow !== undefined && snapshot.edges !== undefined) { + throw new ApiError(400, "INVALID_FIELD", `'workspace.snapshots.${tab.id}' cannot contain both 'flow' and 'edges'`); + } + const selectedNodeId = stringField( + snapshot.selectedNodeId ?? null, + `workspace.snapshots.${tab.id}.selectedNodeId`, + { nullable: true, maxLength: 256 }, + ); + try { + snapshots[tab.id] = normalizeWorkflowSnapshot({ + nodes: snapshot.nodes, + edges: snapshot.edges, + flow: snapshot.flow, + selectedNodeId, + }); + } catch (error) { + throw new ApiError( + 400, + "INVALID_FIELD", + `'workspace.snapshots.${tab.id}' is not a valid workflow: ${error.message}`, + ); + } + } + return { version: 1, tabs, activeWorkflowId, snapshots }; +} + +function parseWorkflowWorkspaceSave(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["version", "workspace"])); + return { + version: parseWorkflowVersion(body.version), + workspace: parseWorkflowWorkspace(body.workspace), + }; +} + +function parseSortOrder(value) { + if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > 1_000_000_000_000) { + throw new ApiError(400, "INVALID_FIELD", "'sortOrder' must be a finite number between -1000000000000 and 1000000000000"); + } + return value; +} + +function parseLabels(value) { + if (!Array.isArray(value) || value.length > 20) { + throw new ApiError(400, "INVALID_FIELD", "'labels' must be an array with at most 20 entries"); + } + const labels = value.map((label) => { + if (typeof label !== "string") { + throw new ApiError(400, "INVALID_FIELD", "Every label must be a string"); + } + const normalized = label.trim(); + if (normalized.length === 0 || normalized.length > 64) { + throw new ApiError(400, "INVALID_FIELD", "Labels must contain 1 to 64 characters"); + } + return normalized; + }); + if (new Set(labels).size !== labels.length) { + throw new ApiError(400, "INVALID_FIELD", "Labels must be unique"); + } + return labels; +} + +function parseStatus(value, fallback) { + const result = value ?? fallback; + if (!isTaskStatus(result)) { + throw new ApiError(400, "INVALID_FIELD", `'status' must be one of: ${TASK_STATUSES.join(", ")}`); + } + return result; +} + +function parsePriority(value, fallback) { + const result = value ?? fallback; + if (!isTaskPriority(result)) { + throw new ApiError(400, "INVALID_FIELD", "'priority' must be none, urgent, high, medium, or low"); + } + return result; +} + +function slugify(value) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64); +} + +function validateProjectId(value, { required = true } = {}) { + const id = stringField(value, "id", { required, maxLength: 64 }); + if (id !== undefined && !PROJECT_ID_PATTERN.test(id)) { + throw new ApiError(400, "INVALID_FIELD", "'id' must be a lowercase slug containing letters, numbers, or hyphens"); + } + return id; +} + +function parseProjectCreate(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["id", "name", "workspacePath"])); + const name = stringField(body.name, "name", { required: true, maxLength: 120 }); + const id = validateProjectId(body.id ?? slugify(name)); + if (!id) { + throw new ApiError(400, "INVALID_FIELD", "Project name must contain at least one letter or number when 'id' is omitted"); + } + const workspacePath = stringField(body.workspacePath ?? null, "workspacePath", { nullable: true, maxLength: 4096 }); + if (workspacePath === "") { + throw new ApiError(400, "INVALID_FIELD", "'workspacePath' cannot be empty"); + } + if (workspacePath?.includes("\0")) { + throw new ApiError(400, "INVALID_FIELD", "'workspacePath' cannot contain null bytes"); + } + return { id, name, workspacePath }; +} + +function parseThreadId(value) { + if (value === undefined) return undefined; + return stringField(value, "threadId", { required: true, maxLength: 256 }); +} + +function normalizeCodexThreadId(value) { + return String(value ?? "").trim().replace(/^(?:local|cloud):/i, ""); +} + +function parseCodexThreadSync(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["projectId", "visibleThreadIds"])); + const projectId = validateProjectId(body.projectId); + if (!Array.isArray(body.visibleThreadIds) || body.visibleThreadIds.length > 1000) { + throw new ApiError(400, "INVALID_FIELD", "'visibleThreadIds' must be an array of at most 1000 items"); + } + const seen = new Set(); + const visibleThreadIds = []; + for (const value of body.visibleThreadIds) { + if (typeof value !== "string") { + throw new ApiError(400, "INVALID_FIELD", "'visibleThreadIds' must contain strings"); + } + const threadId = normalizeCodexThreadId(value); + if (!threadId || threadId.startsWith("client-new-thread:") || seen.has(threadId)) continue; + seen.add(threadId); + visibleThreadIds.push(threadId); + } + return { projectId, visibleThreadIds }; +} + +function requestHeader(request, name) { + const value = request.headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +function actorFromRequest(request) { + if (request.headers["x-taskboard-client"] === "taskctl") { + return CODEX_AGENT_ACTOR; + } + + const rawId = requestHeader(request, "x-taskboard-user-id"); + const rawName = requestHeader(request, "x-taskboard-user-name"); + const rawAvatarUrl = requestHeader(request, "x-taskboard-user-avatar"); + if (rawId === undefined && rawName === undefined && rawAvatarUrl === undefined) { + return { type: "user", id: "local-user", name: "本地用户", avatarUrl: null }; + } + if (rawId === undefined || rawName === undefined) { + throw new ApiError(400, "INVALID_ACTOR", "User identity requires both an ID and name"); + } + + const id = stringField(rawId, "X-Taskboard-User-Id", { required: true, maxLength: 96 }); + if (!/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/.test(id)) { + throw new ApiError(400, "INVALID_ACTOR", "User ID contains unsupported characters"); + } + let decodedName; + try { + decodedName = decodeURIComponent(rawName); + } catch { + throw new ApiError(400, "INVALID_ACTOR", "User name is not valid URL-encoded text"); + } + const name = stringField(decodedName, "X-Taskboard-User-Name", { required: true, maxLength: 120 }); + + let avatarUrl = null; + if (rawAvatarUrl !== undefined) { + const value = stringField(rawAvatarUrl, "X-Taskboard-User-Avatar", { required: true, maxLength: 2048 }); + let parsed; + try { + parsed = new URL(value); + } catch { + throw new ApiError(400, "INVALID_ACTOR", "User avatar URL is invalid"); + } + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new ApiError(400, "INVALID_ACTOR", "User avatar URL must use HTTP or HTTPS"); + } + avatarUrl = parsed.toString(); + } + return { type: "user", id, name, avatarUrl }; +} + +function parseAssigneeTarget(value) { + if (value === undefined) return undefined; + if (value !== "current-user" && value !== "codex-agent") { + throw new ApiError(400, "INVALID_FIELD", "'assigneeTarget' must be current-user or codex-agent"); + } + return value; +} + +function resolveAssignee(target, actor) { + if (target === undefined) return actor; + if (target === "codex-agent") return CODEX_AGENT_ACTOR; + if (actor.type !== "user") { + throw new ApiError(400, "INVALID_FIELD", "'current-user' requires a user request identity"); + } + return actor; +} + +function parseWorkflowId(value) { + const workflowId = stringField(value, "workflowId", { nullable: true, maxLength: 128 }); + if (workflowId === "") { + throw new ApiError(400, "INVALID_FIELD", "'workflowId' cannot be empty"); + } + return workflowId; +} + +function parseTaskCreate(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set([ + "projectId", "title", "description", "status", "priority", "labels", "sortOrder", "threadId", + "assigneeTarget", "workflowId", "developmentContext", "dueDate", "recurrence", + ])); + const projectId = validateProjectId(body.projectId ?? DEFAULT_PROJECT_ID); + const task = { + projectId, + title: stringField(body.title, "title", { required: true, maxLength: 240 }), + description: stringField(body.description ?? "", "description", { maxLength: 100_000 }), + status: parseStatus(body.status, "backlog"), + priority: parsePriority(body.priority, "none"), + labels: body.labels === undefined ? [] : parseLabels(body.labels), + sortOrder: body.sortOrder === undefined ? undefined : parseSortOrder(body.sortOrder), + threadId: parseThreadId(body.threadId), + assigneeTarget: parseAssigneeTarget(body.assigneeTarget), + workflowId: parseWorkflowId(body.workflowId ?? null), + developmentContext: parseDevelopmentContext(body.developmentContext ?? null), + dueDate: parseDueDate(body.dueDate ?? null), + recurrence: parseRecurrence(body.recurrence ?? null), + }; + if (task.recurrence && !task.dueDate) { + throw new ApiError(400, "INVALID_FIELD", "A recurring issue requires 'dueDate'"); + } + return task; +} + +function parseTaskPatch(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set([ + "version", "title", "description", "status", "priority", "labels", "threadId", + "assigneeTarget", "workflowId", "developmentContext", "dueDate", "recurrence", + ])); + const version = parseVersion(body.version); + const threadId = parseThreadId(body.threadId); + const assigneeTarget = parseAssigneeTarget(body.assigneeTarget); + const changes = {}; + if (body.title !== undefined) changes.title = stringField(body.title, "title", { required: true, maxLength: 240 }); + if (body.description !== undefined) changes.description = stringField(body.description, "description", { maxLength: 100_000 }); + if (body.status !== undefined) changes.status = parseStatus(body.status); + if (body.priority !== undefined) changes.priority = parsePriority(body.priority); + if (body.labels !== undefined) changes.labels = parseLabels(body.labels); + if (body.workflowId !== undefined) changes.workflowId = parseWorkflowId(body.workflowId); + if (body.developmentContext !== undefined) changes.developmentContext = parseDevelopmentContext(body.developmentContext); + if (body.dueDate !== undefined) changes.dueDate = parseDueDate(body.dueDate); + if (body.recurrence !== undefined) changes.recurrence = parseRecurrence(body.recurrence); + if (changes.recurrence && body.dueDate === null) { + throw new ApiError(400, "INVALID_FIELD", "A recurring issue requires 'dueDate'"); + } + if (Object.keys(changes).length === 0 && assigneeTarget === undefined) { + throw new ApiError(400, "INVALID_BODY", "PATCH requires at least one task field"); + } + return { version, changes, threadId, assigneeTarget }; +} + +function parseMove(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["version", "status", "sortOrder", "threadId", "projectId"])); + return { + version: parseVersion(body.version), + status: parseStatus(body.status), + sortOrder: body.sortOrder === undefined ? undefined : parseSortOrder(body.sortOrder), + threadId: parseThreadId(body.threadId), + projectId: body.projectId === undefined ? undefined : validateProjectId(body.projectId), + }; +} + +function parseArchive(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["version", "threadId"])); + return { version: parseVersion(body.version), threadId: parseThreadId(body.threadId) }; +} + +function parseIssueRelationType(value) { + if (!["parent", "blocks", "blocked_by", "related"].includes(value)) { + throw new ApiError( + 400, + "INVALID_FIELD", + "'relation type' must be parent, blocks, blocked_by, or related", + ); + } + return value; +} + +function parseCommentCreate(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["body", "threadId"])); + return { + body: stringField(body.body ?? "", "body", { maxLength: 100_000 }), + threadId: parseThreadId(body.threadId), + }; +} + +function parseCommentPatch(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["version", "body", "threadId"])); + if (body.body === undefined) { + throw new ApiError(400, "INVALID_FIELD", "'body' is required"); + } + return { + version: parseVersion(body.version), + body: stringField(body.body, "body", { maxLength: 100_000 }), + threadId: parseThreadId(body.threadId), + }; +} + +function parseAttachmentHeaders(request) { + const encodedFilename = request.headers["x-taskboard-filename"]; + if (typeof encodedFilename !== "string") { + throw new ApiError(400, "INVALID_FILENAME", "X-Taskboard-Filename is required"); + } + let filename; + try { + filename = decodeURIComponent(encodedFilename).trim(); + } catch { + throw new ApiError(400, "INVALID_FILENAME", "Attachment filename contains invalid encoding"); + } + if ( + filename.length === 0 + || filename.length > 240 + || filename === "." + || filename === ".." + || /[\u0000-\u001f\u007f/\\]/.test(filename) + ) { + throw new ApiError(400, "INVALID_FILENAME", "Attachment filename is invalid"); + } + + const rawContentType = request.headers["content-type"]; + const contentType = typeof rawContentType === "string" + ? rawContentType.split(";", 1)[0].trim().toLowerCase() + : "application/octet-stream"; + if (contentType.length === 0 || contentType.length > 200 || !/^[!#$%&'*+.^_`|~0-9a-z-]+\/[!#$%&'*+.^_`|~0-9a-z-]+$/.test(contentType)) { + throw new ApiError(415, "UNSUPPORTED_MEDIA_TYPE", "Attachment Content-Type is invalid"); + } + return { filename, contentType }; +} + +async function readBody(request, limit, tooLargeMessage) { + const declaredLength = Number(request.headers["content-length"] ?? 0); + if (Number.isFinite(declaredLength) && declaredLength > limit) { + throw new ApiError(413, "BODY_TOO_LARGE", tooLargeMessage); + } + + const chunks = []; + let length = 0; + for await (const chunk of request) { + length += chunk.length; + if (length > limit) { + throw new ApiError(413, "BODY_TOO_LARGE", tooLargeMessage); + } + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +async function readJson( + request, + limit = JSON_BODY_LIMIT, + tooLargeMessage = "Request body cannot exceed 1 MiB", +) { + const contentType = request.headers["content-type"]?.split(";", 1)[0].trim().toLowerCase(); + if (contentType !== "application/json") { + throw new ApiError(415, "UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json"); + } + const body = await readBody(request, limit, tooLargeMessage); + const length = body.length; + if (length === 0) { + throw new ApiError(400, "INVALID_JSON", "Request body cannot be empty"); + } + try { + return JSON.parse(body.toString("utf8")); + } catch { + throw new ApiError(400, "INVALID_JSON", "Request body must contain valid JSON"); + } +} + +async function assertEmptyRequestBody(request, routeLabel) { + const body = await readBody(request, JSON_BODY_LIMIT, "Request body cannot exceed 1 MiB"); + if (body.length > 0) { + throw new ApiError(400, "INVALID_BODY", `${routeLabel} does not accept a request body`); + } +} + +function parseTaskFilters(searchParams) { + const allowed = new Set(["projectId", "status", "archived"]); + for (const key of searchParams.keys()) { + if (!allowed.has(key)) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", `Unknown query parameter '${key}'`); + } + if (searchParams.getAll(key).length !== 1) { + throw new ApiError(400, "INVALID_QUERY_PARAMETER", `Query parameter '${key}' cannot be repeated`); + } + } + + const projectIdValue = searchParams.get("projectId"); + const statusValue = searchParams.get("status"); + const archived = searchParams.get("archived") ?? "false"; + if (statusValue !== null && !isTaskStatus(statusValue)) { + throw new ApiError(400, "INVALID_QUERY_PARAMETER", "Invalid task status"); + } + if (!new Set(["true", "false", "all"]).has(archived)) { + throw new ApiError(400, "INVALID_QUERY_PARAMETER", "'archived' must be true, false, or all"); + } + const projectId = projectIdValue === null ? undefined : validateProjectId(projectIdValue); + return { projectId, status: statusValue ?? undefined, archived }; +} + +function parseAiSandbox(value) { + if (value === undefined) return undefined; + if (!["read-only", "workspace-write", "danger-full-access"].includes(value)) { + throw new ApiError( + 400, + "INVALID_SANDBOX", + "'sandbox' must be read-only, workspace-write, or danger-full-access", + ); + } + return value; +} + +function parseAiSetting(value, name, maxLength) { + const setting = stringField(value, name, { maxLength }); + if (setting === "") { + throw new ApiError(400, "INVALID_FIELD", `'${name}' cannot be empty`); + } + return setting; +} + +function parseAiThreadCreate(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set([ + "projectId", + "issueId", + "title", + "codexThreadId", + "model", + "reasoningEffort", + "sandbox", + ])); + return { + projectId: validateProjectId(body.projectId), + issueId: parseAiSetting(body.issueId, "issueId", 128), + title: parseAiSetting(body.title, "title", 160), + codexThreadId: body.codexThreadId === undefined + ? undefined + : stringField(body.codexThreadId, "codexThreadId", { required: true, maxLength: 256 }), + model: parseAiSetting(body.model, "model", 128), + reasoningEffort: parseAiSetting(body.reasoningEffort, "reasoningEffort", 64), + sandbox: parseAiSandbox(body.sandbox), + }; +} + +function parseAiThreadPatch(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["title", "model", "reasoningEffort", "sandbox"])); + const input = {}; + if (body.title !== undefined) input.title = parseAiSetting(body.title, "title", 160); + if (body.model !== undefined) input.model = parseAiSetting(body.model, "model", 128); + if (body.reasoningEffort !== undefined) { + input.reasoningEffort = parseAiSetting(body.reasoningEffort, "reasoningEffort", 64); + } + if (body.sandbox !== undefined) input.sandbox = parseAiSandbox(body.sandbox); + if (Object.keys(input).length === 0) { + throw new ApiError(400, "INVALID_BODY", "PATCH requires at least one thread setting"); + } + return input; +} + +function parseAiSkillIds(value) { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.length > 20) { + throw new ApiError(400, "INVALID_FIELD", "'skillIds' must be an array with at most 20 entries"); + } + const skillIds = value.map((skillId, index) => ( + stringField(skillId, `skillIds[${index}]`, { required: true, maxLength: 256 }) + )); + return skillIds; +} + +function parseAiAttachments(value) { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > AI_CHAT_ATTACHMENT_LIMIT) { + throw new ApiError( + 400, + "INVALID_ATTACHMENT", + `'attachments' must be an array with at most ${AI_CHAT_ATTACHMENT_LIMIT} files`, + ); + } + return value.map((attachment, index) => { + assertPlainObject(attachment); + assertAllowedKeys(attachment, new Set(["filename", "contentType", "dataBase64"])); + const filename = stringField(attachment.filename, `attachments[${index}].filename`, { + required: true, + maxLength: 240, + }); + if (/[\u0000-\u001f\u007f/\\]/.test(filename)) { + throw new ApiError( + 400, + "INVALID_ATTACHMENT", + `'attachments[${index}].filename' is invalid`, + ); + } + const contentType = stringField( + attachment.contentType, + `attachments[${index}].contentType`, + { required: true, maxLength: 256 }, + ).toLowerCase(); + const dataBase64 = stringField( + attachment.dataBase64, + `attachments[${index}].dataBase64`, + { required: true, maxLength: AI_CHAT_TURN_BODY_LIMIT }, + ); + if ( + dataBase64.length % 4 !== 0 + || !/^[A-Za-z0-9+/]+={0,2}$/.test(dataBase64) + ) { + throw new ApiError( + 400, + "INVALID_ATTACHMENT", + `'attachments[${index}].dataBase64' must contain valid base64`, + ); + } + const data = Buffer.from(dataBase64, "base64"); + if (data.length === 0 || data.toString("base64") !== dataBase64) { + throw new ApiError( + 400, + "INVALID_ATTACHMENT", + `'attachments[${index}].dataBase64' must contain valid base64`, + ); + } + return { filename, contentType, data, size: data.length }; + }); +} + +function parseAiTurn(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set([ + "message", + "skillIds", + "dangerFullAccessConfirmed", + "attachments", + ])); + if ( + body.dangerFullAccessConfirmed !== undefined + && typeof body.dangerFullAccessConfirmed !== "boolean" + ) { + throw new ApiError(400, "INVALID_FIELD", "'dangerFullAccessConfirmed' must be a boolean"); + } + const message = stringField(body.message ?? "", "message", { maxLength: 100_000 }); + const skillIds = parseAiSkillIds(body.skillIds) ?? []; + if (message.split(AI_CHAT_SKILL_MARKER).length - 1 !== skillIds.length) { + throw new ApiError(400, "INVALID_FIELD", "'skillIds' must match the Skill markers in 'message'"); + } + const attachments = parseAiAttachments(body.attachments); + if (message === "" && attachments.length === 0) { + throw new ApiError( + 400, + "INVALID_MESSAGE", + "A message or at least one attachment is required", + ); + } + return { + message, + skillIds, + dangerFullAccessConfirmed: body.dangerFullAccessConfirmed, + attachments, + }; +} + +class EventHub { + constructor() { + this.clients = new Set(); + this.keepAlive = setInterval(() => { + for (const response of this.clients) response.write(": keep-alive\n\n"); + }, 20_000); + this.keepAlive.unref(); + } + + connect(request, response) { + response.writeHead(200, { + connection: "keep-alive", + "cache-control": "no-cache, no-transform", + "content-type": "text/event-stream; charset=utf-8", + "x-accel-buffering": "no", + }); + response.write(": connected\n\n"); + this.clients.add(response); + request.once("close", () => this.clients.delete(response)); + } + + emit(type, value) { + const event = { + type, + projectId: value.projectId ?? value.project?.id ?? value.task?.projectId, + taskId: value.task?.id ?? value.comment?.taskId ?? value.attachment?.taskId, + ...value, + at: new Date().toISOString(), + }; + const message = `event: ${type}\ndata: ${JSON.stringify(event)}\n\n`; + for (const response of this.clients) response.write(message); + } + + close() { + clearInterval(this.keepAlive); + for (const response of this.clients) response.end(); + this.clients.clear(); + } +} + +async function serveStatic(request, response, pathname, staticDirectory) { + if (request.method !== "GET" && request.method !== "HEAD") return false; + let decodedPath; + try { + decodedPath = decodeURIComponent(pathname); + } catch { + throw new ApiError(400, "INVALID_PATH", "URL path contains invalid encoding"); + } + if (decodedPath.includes("\0")) { + throw new ApiError(400, "INVALID_PATH", "URL path is invalid"); + } + + const root = path.resolve(staticDirectory); + const relativePath = decodedPath === "/" ? "index.html" : decodedPath.replace(/^\/+/, ""); + let filename = path.resolve(root, relativePath); + if (filename !== root && !filename.startsWith(`${root}${path.sep}`)) { + throw new ApiError(400, "INVALID_PATH", "URL path is outside the static directory"); + } + + let fileStats; + try { + fileStats = await stat(filename); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + if (!fileStats?.isFile() && !path.extname(relativePath)) { + filename = path.join(root, "index.html"); + try { + fileStats = await stat(filename); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + if (!fileStats?.isFile()) return false; + + const body = await readFile(filename); + const headers = { + "cache-control": path.basename(filename) === "index.html" ? "no-cache" : "public, max-age=31536000, immutable", + "content-length": body.length, + "content-type": CONTENT_TYPES.get(path.extname(filename).toLowerCase()) ?? "application/octet-stream", + }; + response.writeHead(200, headers); + response.end(request.method === "HEAD" ? undefined : body); + return true; +} + +function methodNotAllowed(response, allowed) { + sendJson(response, 405, { + error: { code: "METHOD_NOT_ALLOWED", message: `Allowed methods: ${allowed.join(", ")}` }, + }, { allow: allowed.join(", ") }); +} + +function codexProjectRoot(state, projectId) { + if (!projectId || !state || typeof state !== "object") return null; + const project = state["local-projects"]?.[projectId]; + const root = Array.isArray(project?.rootPaths) ? project.rootPaths[0] : null; + return typeof root === "string" && root.trim() ? root : null; +} + +async function readCodexProjectWorkspaces(codexStatePath) { + try { + const state = JSON.parse(await readFile(codexStatePath, "utf8")); + const projects = state["local-projects"]; + if (!projects || typeof projects !== "object" || Array.isArray(projects)) return {}; + return Object.fromEntries(Object.keys(projects).flatMap((projectId) => { + const root = codexProjectRoot(state, projectId); + return root ? [[projectId, root]] : []; + })); + } catch { + return {}; + } +} + +async function readCodexProjectMetadata(codexStatePath) { + try { + const state = JSON.parse(await readFile(codexStatePath, "utf8")); + const projects = state["local-projects"]; + if (!projects || typeof projects !== "object" || Array.isArray(projects)) return {}; + return Object.fromEntries(Object.entries(projects).flatMap(([projectId, project]) => { + if (!project || typeof project !== "object" || Array.isArray(project)) return []; + const name = typeof project.name === "string" && project.name.trim() + ? project.name.trim() + : null; + const workspacePath = codexProjectRoot(state, projectId); + if (!name && !workspacePath) return []; + return [[projectId, { name, workspacePath }]]; + })); + } catch { + return {}; + } +} + +async function listProjectsWithCodexMetadata(database, codexStatePath) { + const projects = database.listProjects(); + const codexProjects = await readCodexProjectMetadata(codexStatePath); + return projects.map((project) => { + const codexProject = codexProjects[project.id]; + const name = codexProject?.name ?? project.name; + const workspacePath = project.workspacePath ?? codexProject?.workspacePath ?? null; + return name === project.name && workspacePath === project.workspacePath + ? project + : { ...project, name, workspacePath }; + }); +} + +async function readCodexThreadTitle(codexSessionIndexPath, threadId) { + try { + const lines = (await readFile(codexSessionIndexPath, "utf8")).split(/\r?\n/); + let title = null; + for (const line of lines) { + if (!line.trim()) continue; + let record; + try { + record = JSON.parse(line); + } catch { + continue; + } + if ( + record?.id === threadId + && typeof record.thread_name === "string" + && record.thread_name.trim() + ) { + title = record.thread_name.trim(); + } + } + return title; + } catch { + return null; + } +} + +function addCodexThreadId(target, value) { + const threadId = normalizeCodexThreadId(value); + if (threadId && !threadId.startsWith("client-new-thread:")) target.add(threadId); +} + +async function readCodexThreadSyncState(resolved, projectId) { + const sidebarThreadIds = new Set(); + const deletedThreadIds = new Set(); + const knownThreadIds = new Set(); + let hasSidebarProject = false; + try { + const state = JSON.parse(await readFile(resolved.codexStatePath, "utf8")); + const projectOrder = state["sidebar-project-thread-orders"]?.[projectId]; + if (projectOrder && typeof projectOrder === "object" && Array.isArray(projectOrder.threadIds)) { + hasSidebarProject = true; + for (const threadId of projectOrder.threadIds) addCodexThreadId(sidebarThreadIds, threadId); + } + const deletedPrefix = "codex-writing-block-deleted-thread-v1:"; + for (const key of Object.keys(state)) { + if (key.startsWith(deletedPrefix) && state[key]) addCodexThreadId(deletedThreadIds, key.slice(deletedPrefix.length)); + } + } catch {} + + try { + const lines = (await readFile(resolved.codexSessionIndexPath, "utf8")).split(/\r?\n/); + for (const line of lines) { + if (!line.trim()) continue; + try { + addCodexThreadId(knownThreadIds, JSON.parse(line)?.id); + } catch {} + } + } catch {} + + try { + for (const filename of await readdir(resolved.codexArchivedSessionsPath)) { + const match = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i.exec(filename); + if (match) addCodexThreadId(knownThreadIds, match[1]); + } + } catch {} + + return { hasSidebarProject, sidebarThreadIds, knownThreadIds, deletedThreadIds }; +} + +async function syncTasksForCodexThreads(database, resolved, input, events) { + const state = await readCodexThreadSyncState(resolved, input.projectId); + const visibleThreadIds = state.hasSidebarProject + ? state.sidebarThreadIds + : new Set(input.visibleThreadIds); + if (!state.hasSidebarProject && visibleThreadIds.size === 0) return; + + const tasks = database.listTasks({ projectId: input.projectId, archived: "false" }); + for (const task of tasks) { + const threadId = normalizeCodexThreadId(task.threadId); + if (!threadId || visibleThreadIds.has(threadId)) continue; + if (state.deletedThreadIds.has(threadId) || !state.knownThreadIds.has(threadId)) { + const deleted = await deleteTaskAndAttachments(database, task, resolved.attachmentsDirectory); + events.emit("task.deleted", { task: deleted }); + } else { + const archived = database.archiveTask(task.id, task.version, task.threadId); + events.emit("task.archived", { task: archived }); + } + } +} + +function latestThreadCwd(value, threadId) { + const matches = []; + const stack = [value]; + while (stack.length > 0) { + const candidate = stack.pop(); + if (!candidate || typeof candidate !== "object") continue; + if (candidate.conversationId === threadId && typeof candidate.cwd === "string" && candidate.cwd.trim()) { + matches.push(candidate); + } + stack.push(...(Array.isArray(candidate) ? candidate : Object.values(candidate))); + } + matches.sort((left, right) => Number(right.updatedAtMs ?? 0) - Number(left.updatedAtMs ?? 0)); + return matches[0]?.cwd ?? null; +} + +async function resolveProjectWorkspace(project, codexProjectId, codexThreadId, codexStatePath, codexProcessesPath) { + try { + const state = JSON.parse(await readFile(codexStatePath, "utf8")); + const assignment = state["thread-project-assignments"]?.[codexThreadId]; + const root = codexProjectRoot(state, project.id) + ?? codexProjectRoot(state, codexProjectId) + ?? codexProjectRoot(state, assignment?.projectId) + ?? (typeof assignment?.cwd === "string" ? assignment.cwd : null); + if (root) return root; + } catch {} + if (project.workspacePath) return project.workspacePath; + if (!codexThreadId) return null; + try { + const processes = JSON.parse(await readFile(codexProcessesPath, "utf8")); + return latestThreadCwd(processes, codexThreadId); + } catch { + return null; + } +} + +function parseWorktrees(output) { + const contexts = []; + for (const block of output.trim().split(/\n\s*\n/)) { + if (!block) continue; + let worktreePath = ""; + let branch = null; + for (const line of block.split("\n")) { + if (line.startsWith("worktree ")) worktreePath = line.slice(9); + if (line.startsWith("branch refs/heads/")) branch = line.slice(18); + } + if (worktreePath) contexts.push({ type: "worktree", path: worktreePath, branch }); + } + return contexts; +} + +async function scanDevelopmentContexts(workspacePath) { + if (!workspacePath) return { workspacePath: null, contexts: [] }; + try { + const rootResult = await execFileExecutable("git", ["-C", workspacePath, "rev-parse", "--show-toplevel"], { + timeout: 4_000, + maxBuffer: 1024 * 1024, + }); + const root = rootResult.stdout.trim(); + const [branchesResult, worktreesResult] = await Promise.all([ + execFileExecutable("git", ["-C", root, "for-each-ref", "--format=%(refname:short)", "refs/heads"], { + timeout: 4_000, + maxBuffer: 1024 * 1024, + }), + execFileExecutable("git", ["-C", root, "worktree", "list", "--porcelain"], { + timeout: 4_000, + maxBuffer: 1024 * 1024, + }), + ]); + const branches = branchesResult.stdout.split("\n").map((branch) => branch.trim()).filter(Boolean); + return { + workspacePath: root, + contexts: [ + ...branches.map((branch) => ({ type: "branch", branch })), + ...parseWorktrees(worktreesResult.stdout), + ], + }; + } catch { + return { workspacePath, contexts: [] }; + } +} + +async function discoverSkills(codexExecutable, workspacePath) { + const entries = await new Promise((resolve, reject) => { + const child = spawnExecutable(codexExecutable, ["app-server", "--stdio"], { + cwd: workspacePath, + stdio: ["pipe", "pipe", "ignore"], + }); + let settled = false; + let buffer = ""; + const timeout = setTimeout(() => { + finish(new Error("Timed out while reading Codex skills")); + }, 10_000); + + function finish(error, value) { + if (settled) return; + settled = true; + clearTimeout(timeout); + child.stdin.end(); + child.kill("SIGTERM"); + if (error) reject(error); + else resolve(value); + } + + function send(message) { + child.stdin.write(`${JSON.stringify(message)}\n`); + } + + function handleMessage(message) { + if (message?.id === 1) { + if (message.error) { + finish(new Error("Codex app-server rejected initialization")); + return; + } + send({ method: "initialized" }); + send({ + id: 2, + method: "skills/list", + params: { cwds: [workspacePath], forceReload: false }, + }); + return; + } + if (message?.id !== 2) return; + if (message.error) { + finish(new Error("Codex app-server could not list skills")); + return; + } + finish(null, Array.isArray(message.result?.data) ? message.result.data : []); + } + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + buffer += chunk; + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex >= 0) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + if (line) { + try { + handleMessage(JSON.parse(line)); + } catch {} + } + newlineIndex = buffer.indexOf("\n"); + } + }); + child.stdin.on("error", (error) => finish(error)); + child.once("error", (error) => finish(error)); + child.once("exit", (code, signal) => { + if (!settled) { + finish(new Error(`Codex app-server exited before listing skills (${signal || code})`)); + } + }); + child.once("spawn", () => { + send({ + id: 1, + method: "initialize", + params: { + clientInfo: { name: "codex-taskboard", version: "0.1.0" }, + capabilities: { experimentalApi: true }, + }, + }); + }); + }); + + const unique = new Map(); + for (const entry of entries) { + if (!Array.isArray(entry?.skills)) continue; + for (const skill of entry.skills) { + if ( + !skill + || typeof skill !== "object" + || skill.enabled === false + || typeof skill.name !== "string" + || !skill.name.trim() + ) { + continue; + } + const id = skill.name.trim(); + if (unique.has(id)) continue; + const displayName = typeof skill.interface?.displayName === "string" + ? skill.interface.displayName.trim() + : ""; + unique.set(id, { + id, + label: displayName || id, + description: typeof skill.description === "string" ? skill.description.trim() : "", + path: typeof skill.path === "string" ? skill.path.trim() : "", + scope: ["user", "repo", "system", "admin"].includes(skill.scope) + ? skill.scope + : "user", + }); + } + } + return [...unique.values()].sort((left, right) => left.label.localeCompare(right.label)); +} + +async function discoverMcpServers(codexExecutable) { + const result = await execFileExecutable(codexExecutable, ["mcp", "list", "--json"], { + timeout: 8_000, + maxBuffer: 2 * 1024 * 1024, + }); + const entries = JSON.parse(result.stdout); + if (!Array.isArray(entries)) throw new Error("Codex returned an invalid MCP server list"); + return entries + .filter((entry) => ( + entry + && typeof entry === "object" + && typeof entry.name === "string" + && entry.name.trim() + && entry.enabled !== false + )) + .map((entry) => ({ + id: entry.name.trim(), + label: entry.name.trim(), + transport: typeof entry.transport?.type === "string" + ? entry.transport.type + : "unknown", + })) + .sort((left, right) => left.label.localeCompare(right.label)); +} + +async function discoverWorkflowCapabilities(resolved, workspacePath) { + const [skills, mcpServers] = await Promise.all([ + discoverSkills(resolved.codexExecutable, workspacePath), + discoverMcpServers(resolved.codexExecutable), + ]); + return { skills, mcpServers }; +} + +export function resolveServerOptions(options = {}) { + const configuredDataDirectory = options.dataDirectory ?? process.env.CODEX_TASKBOARD_DATA_DIR; + const dataDirectory = configuredDataDirectory + ? path.resolve(configuredDataDirectory) + : path.join(PROJECT_ROOT, ".data"); + const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex"); + return { + dataDirectory, + databasePath: options.databasePath ?? path.join(dataDirectory, "taskboard.sqlite"), + attachmentsDirectory: options.attachmentsDirectory ?? path.join(dataDirectory, "attachments"), + cloudConfigPath: options.cloudConfigPath ?? path.join(dataDirectory, "cloud-companion.json"), + staticDirectory: options.staticDirectory ?? path.join(PROJECT_ROOT, "dist", "web"), + skillPath: options.skillPath ?? path.join(PROJECT_ROOT, "skills", "manage-taskboard", "SKILL.md"), + codexExecutable: options.codexExecutable ?? process.env.CODEX_EXECUTABLE ?? "codex", + codexStatePath: options.codexStatePath + ?? path.join(codexHome, ".codex-global-state.json"), + codexSessionIndexPath: options.codexSessionIndexPath + ?? path.join(codexHome, "session_index.jsonl"), + codexArchivedSessionsPath: options.codexArchivedSessionsPath + ?? path.join(codexHome, "archived_sessions"), + codexProcessesPath: options.codexProcessesPath + ?? path.join(codexHome, "process_manager", "chat_processes.json"), + }; +} + +export function resolvePort(value = process.env.CODEX_TASKBOARD_PORT ?? "47823") { + const port = typeof value === "number" ? value : Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("CODEX_TASKBOARD_PORT must be an integer between 1 and 65535"); + } + return port; +} + +export function resolveHost(value = process.env.CODEX_TASKBOARD_HOST ?? LOOPBACK_TASKBOARD_HOST) { + const host = String(value).trim(); + if (host !== LOOPBACK_TASKBOARD_HOST && host !== LAN_TASKBOARD_HOST) { + throw new Error(`CODEX_TASKBOARD_HOST must be ${LOOPBACK_TASKBOARD_HOST} or ${LAN_TASKBOARD_HOST}`); + } + return host; +} + +export function createTaskboardServer(options = {}) { + const resolved = resolveServerOptions(options); + const sharedSecret = resolveSharedSecret(options.sharedSecret); + const database = new TaskboardDatabase(resolved.databasePath); + const events = new EventHub(); + const cloudConfig = options.cloudConfigStore ?? createCloudConfigStore({ + configPath: resolved.cloudConfigPath, + }); + const cloudProxy = createCloudProxy({ + configStore: cloudConfig, + fetch: options.remoteFetch ?? globalThis.fetch, + resolveDevelopmentContext: async (projectId, context) => { + if (!context.branch) return null; + const config = await cloudConfig.read(); + const workspacePath = config.projectMappings[projectId]; + if (!workspacePath) return null; + const result = await scanDevelopmentContexts(workspacePath); + return result.contexts.find((candidate) => ( + candidate.type === "worktree" && candidate.branch === context.branch + )) ?? null; + }, + }); + const aiChat = new AiChatService({ + database, + codexExecutable: resolved.codexExecutable, + codexStatePath: resolved.codexStatePath, + manageTaskboardSkillPath: resolved.skillPath, + }); + const aiEventResponses = new Set(); + let activeHost = LOOPBACK_TASKBOARD_HOST; + + const server = createServer(async (request, response) => { + response.setHeader("x-content-type-options", "nosniff"); + response.setHeader("referrer-policy", "no-referrer"); + try { + const requestHost = assertTrustedNetworkRequest(request); + if (activeHost === LAN_TASKBOARD_HOST) { + assertLanSameOriginRequest(request, requestHost); + assertLanAuthorization(request, sharedSecret); + } + const url = new URL(request.url, "http://127.0.0.1"); + const pathname = url.pathname; + const isLocalAiRoute = pathname === "/api/local/ai" || pathname.startsWith("/api/local/ai/"); + if (isLocalAiRoute) { + assertAiLoopbackRequest(request); + } else if (pathname.startsWith("/api/local/")) { + assertLoopbackRequest(request); + } + const isMachineCapabilityRoute = pathname === "/api/meta" + || pathname === "/api/device-workspaces" + || pathname === "/api/workflow-capabilities" + || /^\/api\/projects\/[^/]+\/development-contexts$/.test(pathname); + const capabilityCloudConfig = isMachineCapabilityRoute + ? await cloudConfig.read() + : null; + if (capabilityCloudConfig?.remoteUrl) assertLoopbackRequest(request); + + if (pathname === "/health") { + if (request.method !== "GET") return methodNotAllowed(response, ["GET"]); + return sendJson(response, 200, { status: "ok" }, { + [TASKBOARD_BIND_HOST_HEADER]: activeHost, + }); + } + + if (pathname === "/api/local/cloud-session") { + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Cloud session routes do not accept query parameters"); + } + if (request.method === "GET") { + const config = await cloudConfig.read(); + return sendJson(response, 200, config.remoteUrl + ? { + mode: "cloud", + remoteUrl: config.remoteUrl, + actorName: config.actorName, + authenticated: true, + } + : { mode: "local", authenticated: false }); + } + if (request.method === "PUT") { + const body = await readJson(request); + assertPlainObject(body); + assertAllowedKeys(body, new Set(["remoteUrl", "actorName", "sharedKey"])); + try { + const config = await cloudConfig.configure({ + remoteUrl: body.remoteUrl, + actorName: body.actorName, + sharedKey: body.sharedKey, + }); + return sendJson(response, 200, { + mode: "cloud", + remoteUrl: config.remoteUrl, + actorName: config.actorName, + authenticated: true, + }); + } catch (error) { + throw new ApiError(400, error.code ?? "INVALID_CLOUD_CONFIG", error.message); + } + } + if (request.method === "DELETE") { + await cloudConfig.clearCloud(); + return sendJson(response, 200, { mode: "local", authenticated: false }); + } + return methodNotAllowed(response, ["GET", "PUT", "DELETE"]); + } + + const projectMappingRoute = pathname.match(/^\/api\/local\/project-mappings\/([^/]+)$/); + if (projectMappingRoute) { + if (request.method !== "PUT") return methodNotAllowed(response, ["PUT"]); + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Project mapping routes do not accept query parameters"); + } + let projectId; + try { + projectId = decodeURIComponent(projectMappingRoute[1]); + } catch { + throw new ApiError(400, "INVALID_PATH", "Project id contains invalid encoding"); + } + validateProjectId(projectId); + const body = await readJson(request); + assertPlainObject(body); + assertAllowedKeys(body, new Set(["workspacePath"])); + const workspacePath = pathField(body.workspacePath, "workspacePath"); + if (!workspacePath || !path.isAbsolute(workspacePath)) { + throw new ApiError(400, "INVALID_FIELD", "'workspacePath' must be absolute"); + } + const config = await cloudConfig.read(); + if (config.remoteUrl) { + await cloudConfig.setProjectWorkspace(projectId, workspacePath); + return sendJson(response, 200, { projectId, workspacePath }); + } + const project = database.mapProjectWorkspace(projectId, workspacePath); + return sendJson(response, 200, { projectId, workspacePath, project }); + } + + if (pathname === "/api/meta") { + if (request.method !== "GET") return methodNotAllowed(response, ["GET"]); + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "GET /api/meta does not accept query parameters"); + } + return sendJson(response, 200, { + manageTaskboardSkillPath: resolved.skillPath, + capabilities: { localAiChat: isLoopbackAddress(request.socket.remoteAddress) }, + ...(capabilityCloudConfig?.remoteUrl + ? { + mode: "cloud", + realtime: { transport: "poll", intervalMs: 2000 }, + localCapabilities: { available: true }, + } + : {}), + }); + } + + if (pathname === "/api/local/codex/threads/sync") { + if (request.method !== "POST") return methodNotAllowed(response, ["POST"]); + assertNoQuery(url.searchParams, "POST /api/local/codex/threads/sync"); + await syncTasksForCodexThreads(database, resolved, parseCodexThreadSync(await readJson(request)), events); + return sendEmpty(response, 204); + } + + const codexThreadRoute = pathname.match(/^\/api\/local\/codex\/threads\/([^/]+)$/); + if (codexThreadRoute) { + if (request.method !== "GET") return methodNotAllowed(response, ["GET"]); + assertNoQuery(url.searchParams, "GET /api/local/codex/threads/:id"); + const threadId = parseThreadId(decodeRouteSegment(codexThreadRoute[1], "Thread id")); + return sendJson(response, 200, { + thread: { + id: threadId, + title: await readCodexThreadTitle(resolved.codexSessionIndexPath, threadId), + }, + }); + } + + if (pathname === "/api/local/ai/catalog") { + if (request.method !== "GET") return methodNotAllowed(response, ["GET"]); + assertAllowedQuery(url.searchParams, new Set(["projectId"]), "GET /api/local/ai/catalog"); + const projectId = validateProjectId(url.searchParams.get("projectId") ?? undefined); + return sendJson(response, 200, await aiChat.getCatalog(projectId)); + } + + if (pathname === "/api/local/ai/threads") { + assertNoQuery(url.searchParams, "/api/local/ai/threads"); + if (request.method === "GET") { + return sendJson(response, 200, { threads: await aiChat.listThreads() }); + } + if (request.method === "POST") { + const thread = await aiChat.createThread(parseAiThreadCreate(await readJson(request))); + return sendJson(response, 201, { thread }); + } + return methodNotAllowed(response, ["GET", "POST"]); + } + + const aiThreadEventsRoute = pathname.match(/^\/api\/local\/ai\/threads\/([^/]+)\/events$/); + if (aiThreadEventsRoute) { + if (request.method !== "GET") return methodNotAllowed(response, ["GET"]); + assertNoQuery(url.searchParams, "GET /api/local/ai/threads/:id/events"); + const threadId = decodeRouteSegment(aiThreadEventsRoute[1], "Thread id"); + await aiChat.getThreadSnapshot(threadId); + response.writeHead(200, { + connection: "keep-alive", + "cache-control": "no-cache, no-transform", + "content-type": "text/event-stream; charset=utf-8", + "x-accel-buffering": "no", + }); + aiEventResponses.add(response); + const unsubscribe = aiChat.subscribe(threadId, (event) => { + const type = event?.type === "ai.run" ? "ai.run" : "ai.event"; + response.write(`event: ${type}\ndata: ${JSON.stringify(event)}\n\n`); + }); + response.write(": connected\n\n"); + response.write('event: ai.event\ndata: {"type":"ai.event"}\n\n'); + const keepAlive = setInterval(() => response.write(": keep-alive\n\n"), 20_000); + keepAlive.unref(); + request.once("close", () => { + clearInterval(keepAlive); + unsubscribe(); + aiEventResponses.delete(response); + }); + return; + } + + const aiThreadTurnRoute = pathname.match(/^\/api\/local\/ai\/threads\/([^/]+)\/turns$/); + if (aiThreadTurnRoute) { + if (request.method !== "POST") return methodNotAllowed(response, ["POST"]); + assertNoQuery(url.searchParams, "POST /api/local/ai/threads/:id/turns"); + const threadId = decodeRouteSegment(aiThreadTurnRoute[1], "Thread id"); + const run = await aiChat.startTurn( + threadId, + parseAiTurn(await readJson( + request, + AI_CHAT_TURN_BODY_LIMIT, + "AI chat turn body cannot exceed 25 MiB", + )), + ); + return sendJson(response, 202, { run }); + } + + const aiThreadRoute = pathname.match(/^\/api\/local\/ai\/threads\/([^/]+)$/); + if (aiThreadRoute) { + assertNoQuery(url.searchParams, "/api/local/ai/threads/:id"); + const threadId = decodeRouteSegment(aiThreadRoute[1], "Thread id"); + if (request.method === "GET") { + return sendJson(response, 200, await aiChat.getThreadSnapshot(threadId)); + } + if (request.method === "PATCH") { + const thread = await aiChat.updateThread(threadId, parseAiThreadPatch(await readJson(request))); + return sendJson(response, 200, { thread }); + } + if (request.method === "DELETE") { + await assertEmptyRequestBody(request, "DELETE /api/local/ai/threads/:id"); + const thread = await aiChat.deleteThread(threadId); + const linkedTask = thread.origin.issueId ? database.getTask(thread.origin.issueId) : null; + if (linkedTask) { + const deleted = await deleteTaskAndAttachments(database, linkedTask, resolved.attachmentsDirectory); + events.emit("task.deleted", { task: deleted }); + } + return sendEmpty(response, 204); + } + return methodNotAllowed(response, ["GET", "PATCH", "DELETE"]); + } + + const aiInterruptRoute = pathname.match(/^\/api\/local\/ai\/runs\/([^/]+)\/interrupt$/); + if (aiInterruptRoute) { + if (request.method !== "POST") return methodNotAllowed(response, ["POST"]); + assertNoQuery(url.searchParams, "POST /api/local/ai/runs/:id/interrupt"); + const runId = decodeRouteSegment(aiInterruptRoute[1], "Run id"); + await assertEmptyRequestBody(request, "POST /api/local/ai/runs/:id/interrupt"); + const run = await aiChat.interrupt(runId); + return sendJson(response, 200, { run }); + } + + if (pathname === "/api/device-workspaces") { + if (request.method !== "GET") return methodNotAllowed(response, ["GET"]); + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "GET /api/device-workspaces does not accept query parameters"); + } + return sendJson(response, 200, { + workspaces: await readCodexProjectWorkspaces(resolved.codexStatePath), + }); + } + + if (pathname === "/api/workflow-capabilities") { + if (request.method !== "GET") return methodNotAllowed(response, ["GET"]); + const unknownQuery = [...url.searchParams.keys()].filter((key) => key !== "workspacePath"); + if (unknownQuery.length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", `Unknown query parameter: ${unknownQuery[0]}`); + } + const workspacePath = stringField( + url.searchParams.get("workspacePath") ?? null, + "workspacePath", + { nullable: true, maxLength: 4096 }, + ); + if (workspacePath?.includes("\0")) { + throw new ApiError(400, "INVALID_FIELD", "'workspacePath' cannot contain null bytes"); + } + if (workspacePath && !path.isAbsolute(workspacePath)) { + throw new ApiError(400, "INVALID_FIELD", "'workspacePath' must be absolute"); + } + return sendJson( + response, + 200, + await discoverWorkflowCapabilities(resolved, workspacePath ?? PROJECT_ROOT), + ); + } + + let currentCloudConfig = null; + if (pathname.startsWith("/api/")) { + currentCloudConfig = await cloudConfig.read(); + if (currentCloudConfig.remoteUrl) { + assertLoopbackRequest(request); + if (!isLocalCompanionRoute(pathname)) { + return sendFetchResponse( + response, + await cloudProxy.forward(toFetchRequest(request)), + ); + } + } + } + + if (pathname === "/api/projects") { + if (request.method === "GET") { + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "GET /api/projects does not accept query parameters"); + } + return sendJson(response, 200, { + projects: await listProjectsWithCodexMetadata(database, resolved.codexStatePath), + }); + } + if (request.method === "POST") { + const project = database.createProject(parseProjectCreate(await readJson(request))); + events.emit("project.created", { project }); + return sendJson(response, 201, { project }); + } + return methodNotAllowed(response, ["GET", "POST"]); + } + + const projectArchiveRoute = pathname.match(/^\/api\/projects\/([^/]+)\/archive$/); + if (projectArchiveRoute) { + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Project archive route does not accept query parameters"); + } + let projectId; + try { + projectId = decodeURIComponent(projectArchiveRoute[1]); + } catch { + throw new ApiError(400, "INVALID_PATH", "Project id contains invalid encoding"); + } + validateProjectId(projectId); + if (request.method === "POST") { + const project = database.archiveProject(projectId); + events.emit("project.archived", { project }); + return sendJson(response, 200, { project }); + } + return methodNotAllowed(response, ["POST"]); + } + + const projectDeleteRoute = pathname.match(/^\/api\/projects\/([^/]+)$/); + if (projectDeleteRoute) { + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Project delete route does not accept query parameters"); + } + let projectId; + try { + projectId = decodeURIComponent(projectDeleteRoute[1]); + } catch { + throw new ApiError(400, "INVALID_PATH", "Project id contains invalid encoding"); + } + validateProjectId(projectId); + if (request.method === "DELETE") { + const result = database.deleteProject(projectId); + for (const attachment of result.attachments) { + try { + await unlink(path.join(resolved.attachmentsDirectory, attachment.id)); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + events.emit("project.deleted", { project: result.project }); + return sendEmpty(response, 204); + } + return methodNotAllowed(response, ["DELETE"]); + } + + const workflowWorkspaceRoute = pathname.match(/^\/api\/projects\/([^/]+)\/workflow-workspace$/); + if (workflowWorkspaceRoute) { + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Workflow workspace routes do not accept query parameters"); + } + let projectId; + try { + projectId = decodeURIComponent(workflowWorkspaceRoute[1]); + } catch { + throw new ApiError(400, "INVALID_PATH", "Project id contains invalid encoding"); + } + validateProjectId(projectId); + if (request.method === "GET") { + return sendJson(response, 200, { workflow: database.getWorkflowWorkspace(projectId) }); + } + if (request.method === "PUT") { + const input = parseWorkflowWorkspaceSave(await readJson(request)); + const workflow = database.saveWorkflowWorkspace(projectId, input.version, input.workspace); + events.emit("workflow.updated", { + projectId, + workflowVersion: workflow.version, + }); + return sendJson(response, 200, { workflow }); + } + return methodNotAllowed(response, ["GET", "PUT"]); + } + + const developmentContextsRoute = pathname.match(/^\/api\/projects\/([^/]+)\/development-contexts$/); + if (developmentContextsRoute) { + if (request.method !== "GET") return methodNotAllowed(response, ["GET"]); + const unknownQuery = [...url.searchParams.keys()].filter((key) => ( + !["codexProjectId", "codexThreadId", "workspacePath"].includes(key) + )); + if (unknownQuery.length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", `Unknown query parameter: ${unknownQuery[0]}`); + } + let projectId; + try { + projectId = decodeURIComponent(developmentContextsRoute[1]); + } catch { + throw new ApiError(400, "INVALID_PATH", "Project id contains invalid encoding"); + } + validateProjectId(projectId); + const project = currentCloudConfig.remoteUrl + ? { + id: projectId, + workspacePath: currentCloudConfig.projectMappings[projectId] ?? null, + } + : database.getProject(projectId); + if (!project) throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`); + const codexProjectId = stringField(url.searchParams.get("codexProjectId") ?? null, "codexProjectId", { + nullable: true, + maxLength: 128, + }); + const codexThreadId = stringField(url.searchParams.get("codexThreadId") ?? null, "codexThreadId", { + nullable: true, + maxLength: 256, + }); + const deviceWorkspacePath = stringField( + url.searchParams.get("workspacePath") ?? null, + "workspacePath", + { nullable: true, maxLength: 4096 }, + ); + if (deviceWorkspacePath?.includes("\0")) { + throw new ApiError(400, "INVALID_FIELD", "'workspacePath' cannot contain null bytes"); + } + const workspacePath = deviceWorkspacePath ?? await resolveProjectWorkspace( + project, + codexProjectId, + codexThreadId, + resolved.codexStatePath, + resolved.codexProcessesPath, + ); + return sendJson(response, 200, await scanDevelopmentContexts(workspacePath)); + } + + if (pathname === "/api/tasks") { + if (request.method === "GET") { + return sendJson(response, 200, { tasks: database.listTasks(parseTaskFilters(url.searchParams)) }); + } + if (request.method === "POST") { + const actor = actorFromRequest(request); + const { assigneeTarget, ...input } = parseTaskCreate(await readJson(request)); + const task = database.createTask({ + ...input, + actor, + assignee: resolveAssignee(assigneeTarget, actor), + }); + events.emit("task.created", { task }); + return sendJson(response, 201, { task }); + } + return methodNotAllowed(response, ["GET", "POST"]); + } + + if (pathname === "/api/events") { + if (request.method !== "GET") return methodNotAllowed(response, ["GET"]); + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "GET /api/events does not accept query parameters"); + } + events.connect(request, response); + return; + } + + const taskRelationRoute = pathname.match( + /^\/api\/tasks\/([^/]+)\/relations\/([^/]+)\/([^/]+)$/, + ); + if (taskRelationRoute) { + let taskId; + let type; + let relatedTaskId; + try { + taskId = decodeURIComponent(taskRelationRoute[1]); + type = decodeURIComponent(taskRelationRoute[2]); + relatedTaskId = decodeURIComponent(taskRelationRoute[3]); + } catch { + throw new ApiError(400, "INVALID_PATH", "Issue relation path contains invalid encoding"); + } + if ( + taskId.length === 0 + || taskId.length > 128 + || relatedTaskId.length === 0 + || relatedTaskId.length > 128 + ) { + throw new ApiError(400, "INVALID_PATH", "Issue relation task id is invalid"); + } + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Issue relation routes do not accept query parameters"); + } + const relationType = parseIssueRelationType(type); + if (request.method === "POST") { + const { version, threadId } = parseArchive(await readJson(request)); + const result = database.addTaskRelation( + taskId, + version, + relationType, + relatedTaskId, + threadId, + ); + events.emit("task.relation.updated", result); + return sendJson(response, 200, result); + } + if (request.method === "DELETE") { + const { version, threadId } = parseArchive(await readJson(request)); + const result = database.removeTaskRelation( + taskId, + version, + relationType, + relatedTaskId, + threadId, + ); + events.emit("task.relation.updated", result); + return sendJson(response, 200, result); + } + return methodNotAllowed(response, ["POST", "DELETE"]); + } + + const taskCommentsRoute = pathname.match(/^\/api\/tasks\/([^/]+)\/comments$/); + if (taskCommentsRoute) { + let taskId; + try { + taskId = decodeURIComponent(taskCommentsRoute[1]); + } catch { + throw new ApiError(400, "INVALID_PATH", "Task id contains invalid encoding"); + } + if (taskId.length === 0 || taskId.length > 128) { + throw new ApiError(400, "INVALID_PATH", "Task id is invalid"); + } + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Comment routes do not accept query parameters"); + } + if (request.method === "GET") { + return sendJson(response, 200, { comments: database.listComments(taskId) }); + } + if (request.method === "POST") { + const comment = database.createComment(taskId, { + ...parseCommentCreate(await readJson(request)), + actor: actorFromRequest(request), + }); + const task = database.getTask(taskId); + events.emit("comment.created", { comment, task }); + return sendJson(response, 201, { comment }); + } + return methodNotAllowed(response, ["GET", "POST"]); + } + + const commentRoute = pathname.match(/^\/api\/comments\/([^/]+)$/); + if (commentRoute) { + let id; + try { + id = decodeURIComponent(commentRoute[1]); + } catch { + throw new ApiError(400, "INVALID_PATH", "Comment id contains invalid encoding"); + } + if (id.length === 0 || id.length > 128) { + throw new ApiError(400, "INVALID_PATH", "Comment id is invalid"); + } + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Comment routes do not accept query parameters"); + } + if (request.method === "PATCH") { + const patch = parseCommentPatch(await readJson(request)); + const comment = database.updateComment(id, patch.version, patch.body, patch.threadId); + const task = database.getTask(comment.taskId); + events.emit("comment.updated", { comment, task }); + return sendJson(response, 200, { comment }); + } + if (request.method === "DELETE") { + const { version } = parseArchive(await readJson(request)); + const comment = database.deleteComment(id, version); + for (const attachment of comment.attachments) { + try { + await unlink(path.join(resolved.attachmentsDirectory, attachment.id)); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + const task = database.getTask(comment.taskId); + events.emit("comment.deleted", { comment, task }); + return sendEmpty(response, 204); + } + return methodNotAllowed(response, ["PATCH", "DELETE"]); + } + + const commentAttachmentsRoute = pathname.match(/^\/api\/comments\/([^/]+)\/attachments$/); + if (commentAttachmentsRoute) { + let commentId; + try { + commentId = decodeURIComponent(commentAttachmentsRoute[1]); + } catch { + throw new ApiError(400, "INVALID_PATH", "Comment id contains invalid encoding"); + } + if (commentId.length === 0 || commentId.length > 128) { + throw new ApiError(400, "INVALID_PATH", "Comment id is invalid"); + } + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Attachment routes do not accept query parameters"); + } + if (request.method === "GET") { + return sendJson(response, 200, { attachments: database.listCommentAttachments(commentId) }); + } + if (request.method === "POST") { + const comment = database.getComment(commentId); + if (!comment) throw new ApiError(404, "COMMENT_NOT_FOUND", `Comment '${commentId}' does not exist`); + const metadata = parseAttachmentHeaders(request); + const body = await readBody(request, ATTACHMENT_BODY_LIMIT, "Attachment cannot exceed 25 MiB"); + const id = randomUUID(); + await mkdir(resolved.attachmentsDirectory, { recursive: true }); + const storagePath = path.join(resolved.attachmentsDirectory, id); + await writeFile(storagePath, body, { flag: "wx" }); + let attachment; + try { + attachment = database.createCommentAttachment(commentId, { id, ...metadata, size: body.length }); + } catch (error) { + await unlink(storagePath); + throw error; + } + const task = database.getTask(comment.taskId); + events.emit("attachment.created", { attachment, comment: database.getComment(commentId), task }); + return sendJson(response, 201, { attachment }); + } + return methodNotAllowed(response, ["GET", "POST"]); + } + + const taskAttachmentsRoute = pathname.match(/^\/api\/tasks\/([^/]+)\/attachments$/); + if (taskAttachmentsRoute) { + let taskId; + try { + taskId = decodeURIComponent(taskAttachmentsRoute[1]); + } catch { + throw new ApiError(400, "INVALID_PATH", "Task id contains invalid encoding"); + } + if (taskId.length === 0 || taskId.length > 128) { + throw new ApiError(400, "INVALID_PATH", "Task id is invalid"); + } + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Attachment routes do not accept query parameters"); + } + if (request.method === "GET") { + return sendJson(response, 200, { attachments: database.listAttachments(taskId) }); + } + if (request.method === "POST") { + const task = database.getTask(taskId); + if (!task) throw new ApiError(404, "TASK_NOT_FOUND", `Task '${taskId}' does not exist`); + const metadata = parseAttachmentHeaders(request); + const body = await readBody(request, ATTACHMENT_BODY_LIMIT, "Attachment cannot exceed 25 MiB"); + const id = randomUUID(); + await mkdir(resolved.attachmentsDirectory, { recursive: true }); + const storagePath = path.join(resolved.attachmentsDirectory, id); + await writeFile(storagePath, body, { flag: "wx" }); + let attachment; + try { + attachment = database.createAttachment(taskId, { id, ...metadata, size: body.length }); + } catch (error) { + await unlink(storagePath); + throw error; + } + events.emit("attachment.created", { attachment, task }); + return sendJson(response, 201, { attachment }); + } + return methodNotAllowed(response, ["GET", "POST"]); + } + + const attachmentContentRoute = pathname.match(/^\/api\/attachments\/([^/]+)\/content$/); + if (attachmentContentRoute) { + let id; + try { + id = decodeURIComponent(attachmentContentRoute[1]); + } catch { + throw new ApiError(400, "INVALID_PATH", "Attachment id contains invalid encoding"); + } + if (id.length === 0 || id.length > 128) { + throw new ApiError(400, "INVALID_PATH", "Attachment id is invalid"); + } + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Attachment routes do not accept query parameters"); + } + if (request.method !== "GET" && request.method !== "HEAD") { + return methodNotAllowed(response, ["GET", "HEAD"]); + } + const attachment = database.getAttachment(id); + if (!attachment) throw new ApiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${id}' does not exist`); + const body = await readFile(path.join(resolved.attachmentsDirectory, attachment.id)); + const encodedFilename = encodeURIComponent(attachment.filename).replace(/['()*]/g, (character) => ( + `%${character.charCodeAt(0).toString(16).toUpperCase()}` + )); + const canOpenInline = INLINE_ATTACHMENT_TYPES.has(attachment.contentType); + response.writeHead(200, { + "cache-control": "private, no-store", + "content-disposition": `${canOpenInline ? "inline" : "attachment"}; filename*=UTF-8''${encodedFilename}`, + "content-length": body.length, + "content-security-policy": "sandbox; default-src 'none'", + "content-type": canOpenInline ? attachment.contentType : "application/octet-stream", + }); + response.end(request.method === "HEAD" ? undefined : body); + return; + } + + const attachmentRoute = pathname.match(/^\/api\/attachments\/([^/]+)$/); + if (attachmentRoute) { + let id; + try { + id = decodeURIComponent(attachmentRoute[1]); + } catch { + throw new ApiError(400, "INVALID_PATH", "Attachment id contains invalid encoding"); + } + if (id.length === 0 || id.length > 128) { + throw new ApiError(400, "INVALID_PATH", "Attachment id is invalid"); + } + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Attachment routes do not accept query parameters"); + } + if (request.method !== "DELETE") return methodNotAllowed(response, ["DELETE"]); + const attachment = database.getAttachment(id); + if (!attachment) throw new ApiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${id}' does not exist`); + try { + await unlink(path.join(resolved.attachmentsDirectory, attachment.id)); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + database.deleteAttachment(id); + const task = database.getTask(attachment.taskId); + events.emit("attachment.deleted", { attachment, task }); + return sendEmpty(response, 204); + } + + const taskRoute = pathname.match(/^\/api\/tasks\/([^/]+)(?:\/(archive|restore|move))?$/); + if (taskRoute) { + let id; + try { + id = decodeURIComponent(taskRoute[1]); + } catch { + throw new ApiError(400, "INVALID_PATH", "Task id contains invalid encoding"); + } + if (id.length === 0 || id.length > 128) { + throw new ApiError(400, "INVALID_PATH", "Task id is invalid"); + } + const action = taskRoute[2]; + if (!action && request.method === "GET") { + if ([...url.searchParams.keys()].length > 0) { + throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "GET /api/tasks/:id does not accept query parameters"); + } + const task = database.getTask(id); + if (!task) throw new ApiError(404, "TASK_NOT_FOUND", `Task '${id}' does not exist`); + return sendJson(response, 200, { task }); + } + if (!action && request.method === "PATCH") { + const { version, changes, threadId, assigneeTarget } = parseTaskPatch(await readJson(request)); + if (assigneeTarget !== undefined) { + changes.assignee = resolveAssignee(assigneeTarget, actorFromRequest(request)); + } + const task = database.updateTask(id, version, changes, threadId); + events.emit("task.updated", { task }); + return sendJson(response, 200, { task }); + } + if (!action && request.method === "DELETE") { + const { version } = parseArchive(await readJson(request)); + const task = await deleteTaskAndAttachments(database, { id, version }, resolved.attachmentsDirectory); + events.emit("task.deleted", { task }); + return sendEmpty(response, 204); + } + if (action === "move" && request.method === "POST") { + const move = parseMove(await readJson(request)); + const previousProjectId = move.projectId === undefined ? undefined : database.getTask(id).projectId; + const task = database.moveTask(id, move.version, move.status, move.sortOrder, move.threadId, move.projectId); + events.emit("task.moved", { task, previousProjectId }); + return sendJson(response, 200, { task }); + } + if (action === "archive" && request.method === "POST") { + const { version, threadId } = parseArchive(await readJson(request)); + const task = database.archiveTask(id, version, threadId); + events.emit("task.archived", { task }); + return sendJson(response, 200, { task }); + } + if (action === "restore" && request.method === "POST") { + const { version, threadId } = parseArchive(await readJson(request)); + const task = database.restoreTask(id, version, threadId); + events.emit("task.restored", { task }); + return sendJson(response, 200, { task }); + } + return methodNotAllowed(response, action ? ["POST"] : ["GET", "PATCH", "DELETE"]); + } + + if (pathname.startsWith("/api/")) { + throw new ApiError(404, "NOT_FOUND", "API route not found"); + } + if (await serveStatic(request, response, pathname, resolved.staticDirectory)) return; + throw new ApiError(404, "NOT_FOUND", "Resource not found"); + } catch (error) { + if (response.headersSent) { + response.destroy(error); + return; + } + if (error instanceof ApiError) { + const payload = { error: { code: error.code, message: error.message } }; + if (error.details !== undefined) payload.error.details = error.details; + sendJson(response, error.status, payload, error.headers); + return; + } + if (error instanceof CloudProxyError) { + const payload = { error: { code: error.code, message: error.message } }; + if (error.details !== undefined) payload.error.details = error.details; + sendJson(response, error.status, payload); + return; + } + console.error(error); + sendJson(response, 500, { error: { code: "INTERNAL_ERROR", message: "Internal server error" } }); + } + }); + + let listening = false; + return { + database, + aiChat, + server, + options: resolved, + async listen({ host = LOOPBACK_TASKBOARD_HOST, port = resolvePort() } = {}) { + if (host !== LOOPBACK_TASKBOARD_HOST && host !== LAN_TASKBOARD_HOST) { + throw new Error(`Taskboard server must bind to ${LOOPBACK_TASKBOARD_HOST} or ${LAN_TASKBOARD_HOST}`); + } + if (host === LAN_TASKBOARD_HOST && !sharedSecret) { + throw new Error(`${TASKBOARD_SHARED_SECRET_ENV} is required when CODEX_TASKBOARD_HOST=${LAN_TASKBOARD_HOST}`); + } + activeHost = host; + await new Promise((resolve, reject) => { + const onError = (error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(port, host); + }); + listening = true; + return server.address(); + }, + async close() { + const serverClosed = listening + ? new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }) + : Promise.resolve(); + events.close(); + for (const response of aiEventResponses) response.end(); + aiEventResponses.clear(); + await aiChat.close(); + await serverClosed; + listening = false; + database.close(); + }, + }; +} diff --git a/apps/codex-taskboard/server/cloud-config.mjs b/apps/codex-taskboard/server/cloud-config.mjs new file mode 100644 index 000000000..a8a500767 --- /dev/null +++ b/apps/codex-taskboard/server/cloud-config.mjs @@ -0,0 +1,189 @@ +import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const CONFIG_VERSION = 1; + +class CloudConfigError extends Error { + constructor(code, message) { + super(message); + this.name = "CloudConfigError"; + this.code = code; + } +} + +function emptyConfig() { + return { + version: CONFIG_VERSION, + remoteUrl: null, + actorName: null, + sharedKey: null, + projectMappings: {}, + }; +} + +export function normalizeCloudUrl(value) { + let url; + try { + url = new URL(value); + } catch { + throw new CloudConfigError("INVALID_CLOUD_URL", "Cloud taskboard URL must be a valid URL"); + } + const isLoopback = url.hostname === "localhost" + || url.hostname === "127.0.0.1" + || url.hostname === "[::1]"; + if ( + (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback)) + || url.username + || url.password + || url.pathname !== "/" + || url.search + || url.hash + ) { + throw new CloudConfigError( + "INVALID_CLOUD_URL", + "Cloud taskboard URL must be an HTTPS origin (loopback HTTP is allowed for development)", + ); + } + return url.origin; +} + +function validateCredentials(actorName, sharedKey) { + if ( + typeof actorName !== "string" + || !actorName.trim() + || actorName.length > 120 + || actorName.includes(":") + ) { + throw new CloudConfigError( + "INVALID_CLOUD_ACTOR", + "Cloud actor name must be 1 to 120 characters and cannot contain ':'", + ); + } + if (typeof sharedKey !== "string" || !sharedKey || sharedKey.length > 4096) { + throw new CloudConfigError( + "INVALID_CLOUD_KEY", + "Cloud shared key must be 1 to 4096 characters", + ); + } + return { actorName: actorName.trim(), sharedKey }; +} + +function validateProjectMappings(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new CloudConfigError("INVALID_CLOUD_CONFIG", "Cloud project mappings are invalid"); + } + const projectMappings = {}; + for (const [projectId, workspacePath] of Object.entries(value)) { + if (!projectId || typeof workspacePath !== "string" || !path.isAbsolute(workspacePath)) { + throw new CloudConfigError("INVALID_CLOUD_CONFIG", "Cloud project mappings are invalid"); + } + projectMappings[projectId] = workspacePath; + } + return projectMappings; +} + +function parseConfig(value) { + if ( + value === null + || typeof value !== "object" + || Array.isArray(value) + || value.version !== CONFIG_VERSION + ) { + throw new CloudConfigError("INVALID_CLOUD_CONFIG", "Cloud companion configuration is invalid"); + } + const allowedKeys = new Set([ + "version", + "remoteUrl", + "actorName", + "sharedKey", + "projectMappings", + ]); + if (Object.keys(value).some((key) => !allowedKeys.has(key))) { + throw new CloudConfigError("INVALID_CLOUD_CONFIG", "Cloud companion configuration is invalid"); + } + const projectMappings = validateProjectMappings(value.projectMappings); + if (value.remoteUrl === null && value.actorName === null && value.sharedKey === null) { + return { ...emptyConfig(), projectMappings }; + } + const credentials = validateCredentials(value.actorName, value.sharedKey); + return { + version: CONFIG_VERSION, + remoteUrl: normalizeCloudUrl(value.remoteUrl), + ...credentials, + projectMappings, + }; +} + +export function createCloudConfigStore({ configPath }) { + if (!configPath) throw new Error("configPath is required"); + let pendingWrite = Promise.resolve(); + + async function readFromDisk() { + try { + return parseConfig(JSON.parse(await readFile(configPath, "utf8"))); + } catch (error) { + if (error?.code === "ENOENT") return emptyConfig(); + throw error; + } + } + + async function writeAtomically(config) { + await mkdir(path.dirname(configPath), { recursive: true }); + const temporaryPath = `${configPath}.${process.pid}.${Date.now()}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + await chmod(temporaryPath, 0o600); + await rename(temporaryPath, configPath); + } + + function update(mutator) { + const operation = pendingWrite.then(async () => { + const next = mutator(await readFromDisk()); + await writeAtomically(next); + return next; + }); + pendingWrite = operation.catch(() => {}); + return operation; + } + + return { + async read() { + await pendingWrite; + return readFromDisk(); + }, + async configure({ remoteUrl, actorName, sharedKey }) { + const normalizedUrl = normalizeCloudUrl(remoteUrl); + const credentials = validateCredentials(actorName, sharedKey); + return update((config) => ({ + ...config, + remoteUrl: normalizedUrl, + ...credentials, + })); + }, + clearCloud() { + return update((config) => ({ + ...config, + remoteUrl: null, + actorName: null, + sharedKey: null, + })); + }, + setProjectWorkspace(projectId, workspacePath) { + if (typeof projectId !== "string" || !projectId.trim()) { + throw new CloudConfigError("INVALID_PROJECT_MAPPING", "projectId is required"); + } + if (typeof workspacePath !== "string" || !path.isAbsolute(workspacePath)) { + throw new CloudConfigError( + "INVALID_PROJECT_MAPPING", + "workspacePath must be absolute", + ); + } + return update((config) => ({ + ...config, + projectMappings: { + ...config.projectMappings, + [projectId]: workspacePath, + }, + })); + }, + }; +} diff --git a/apps/codex-taskboard/server/cloud-proxy.mjs b/apps/codex-taskboard/server/cloud-proxy.mjs new file mode 100644 index 000000000..e1c1cd166 --- /dev/null +++ b/apps/codex-taskboard/server/cloud-proxy.mjs @@ -0,0 +1,261 @@ +import path from "node:path"; + +import { normalizeCloudUrl } from "./cloud-config.mjs"; + +const LOCAL_COMPANION_ROUTES = new Set([ + "/health", + "/api/meta", + "/api/device-workspaces", + "/api/workflow-capabilities", + "/api/local/cloud-session", +]); + +export class CloudProxyError extends Error { + constructor(status, code, message, details) { + super(message); + this.name = "CloudProxyError"; + this.status = status; + this.code = code; + this.details = details; + } +} + +export function isLocalCompanionRoute(pathname) { + return LOCAL_COMPANION_ROUTES.has(pathname) + || pathname.startsWith("/api/local/") + || /^\/api\/projects\/[^/]+\/development-contexts$/.test(pathname); +} + +function basicAuthorization(actorName, sharedKey) { + return `Basic ${Buffer.from(`${actorName}:${sharedKey}`, "utf8").toString("base64")}`; +} + +function removeGitWorktreePaths(value) { + if (Array.isArray(value)) { + for (const item of value) removeGitWorktreePaths(item); + return; + } + if (value === null || typeof value !== "object") return; + for (const [key, item] of Object.entries(value)) { + if (key === "gitWorktreePath") { + delete value[key]; + } else { + removeGitWorktreePaths(item); + } + } +} + +async function prepareRequest(request) { + const url = new URL(request.url); + let projectWorkspace = null; + let body = request.body; + const isJson = request.headers.get("content-type")?.includes("application/json"); + const isProjectCreate = request.method === "POST" && url.pathname === "/api/projects"; + const isTaskMutation = ( + (request.method === "POST" && url.pathname === "/api/tasks") + || (request.method === "PATCH" && /^\/api\/tasks\/[^/]+$/.test(url.pathname)) + ); + const isWorkflowMutation = request.method === "PUT" + && /^\/api\/projects\/[^/]+\/workflow-workspace$/.test(url.pathname); + + if (isJson && (isProjectCreate || isTaskMutation || isWorkflowMutation)) { + let payload; + try { + payload = await request.clone().json(); + } catch { + throw new CloudProxyError(400, "INVALID_JSON", "Request body must contain valid JSON"); + } + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { + throw new CloudProxyError(400, "INVALID_BODY", "Request body must be a JSON object"); + } + if (isProjectCreate && Object.hasOwn(payload, "workspacePath")) { + if (typeof payload.workspacePath === "string") { + if (!path.isAbsolute(payload.workspacePath)) { + throw new CloudProxyError( + 400, + "INVALID_PROJECT_MAPPING", + "Project workspacePath must be absolute", + ); + } + projectWorkspace = { + projectId: typeof payload.id === "string" ? payload.id : null, + workspacePath: payload.workspacePath, + }; + } + delete payload.workspacePath; + } + if (isTaskMutation && payload.developmentContext?.type === "worktree") { + payload.developmentContext = { + type: "worktree", + ...(payload.developmentContext.branch === undefined + ? {} + : { branch: payload.developmentContext.branch }), + }; + } + if (isWorkflowMutation) removeGitWorktreePaths(payload.workspace); + body = JSON.stringify(payload); + } + + return { body, projectWorkspace }; +} + +async function localizeTask(task, resolveDevelopmentContext) { + if (!task || typeof task !== "object" || task.developmentContext?.type !== "worktree") { + return task; + } + const cloudContext = { + type: "worktree", + ...(task.developmentContext.branch === undefined + ? {} + : { branch: task.developmentContext.branch }), + }; + const localContext = resolveDevelopmentContext + ? await resolveDevelopmentContext(task.projectId, cloudContext) + : null; + return { + ...task, + developmentContext: localContext ?? { ...cloudContext, path: null }, + }; +} + +async function localizeResponse( + response, + { readConfig, setProjectWorkspace, projectWorkspace, resolveDevelopmentContext }, +) { + if (response.status === 401) return response; + if (!response.headers.get("content-type")?.includes("application/json")) return response; + const payload = await response.json(); + + if (response.ok && projectWorkspace) { + const projectId = projectWorkspace.projectId ?? payload.project?.id; + if (projectId) { + await setProjectWorkspace(projectId, projectWorkspace.workspacePath); + } + } + + const config = await readConfig(); + if (Array.isArray(payload.projects)) { + payload.projects = payload.projects.map((project) => ({ + ...project, + workspacePath: config.projectMappings[project.id] ?? null, + })); + } + if (payload.project && typeof payload.project === "object") { + payload.project = { + ...payload.project, + workspacePath: config.projectMappings[payload.project.id] ?? null, + }; + } + if (payload.task) { + payload.task = await localizeTask(payload.task, resolveDevelopmentContext); + } + if (Array.isArray(payload.tasks)) { + const contexts = new Map(); + const resolveOnce = resolveDevelopmentContext + ? (projectId, context) => { + const key = `${projectId ?? ""}\0${context.branch ?? ""}`; + if (!contexts.has(key)) { + contexts.set(key, resolveDevelopmentContext(projectId, context)); + } + return contexts.get(key); + } + : null; + payload.tasks = await Promise.all( + payload.tasks.map((task) => localizeTask(task, resolveOnce)), + ); + } + + const headers = new Headers(response.headers); + headers.delete("content-length"); + headers.delete("content-encoding"); + return new Response(JSON.stringify(payload), { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +export function createCloudProxy({ + configStore, + getConfig, + fetch: fetchImplementation = globalThis.fetch, + resolveDevelopmentContext, +}) { + const readConfig = getConfig ?? (() => configStore.read()); + const setProjectWorkspace = configStore?.setProjectWorkspace?.bind(configStore); + + return { + async forward(request) { + const config = await readConfig(); + if (!config?.remoteUrl || !config.actorName || !config.sharedKey) { + throw new CloudProxyError( + 409, + "CLOUD_NOT_CONFIGURED", + "Cloud collaboration is not configured", + ); + } + let remoteUrl; + try { + remoteUrl = normalizeCloudUrl(config.remoteUrl); + } catch (error) { + throw new CloudProxyError( + 500, + "INVALID_CLOUD_CONFIG", + error instanceof Error ? error.message : String(error), + ); + } + + const sourceUrl = new URL(request.url); + const upstreamUrl = new URL( + `${sourceUrl.pathname}${sourceUrl.search}`, + `${remoteUrl}/`, + ); + const headers = new Headers(request.headers); + headers.delete("authorization"); + headers.delete("host"); + headers.delete("connection"); + headers.delete("transfer-encoding"); + for (const name of [...headers.keys()]) { + if (name.toLowerCase().startsWith("x-taskboard-user-")) headers.delete(name); + } + headers.set("authorization", basicAuthorization(config.actorName, config.sharedKey)); + + const prepared = await prepareRequest(request); + if (prepared.projectWorkspace && !setProjectWorkspace) { + throw new CloudProxyError( + 500, + "PROJECT_MAPPING_UNAVAILABLE", + "Local project mapping storage is unavailable", + ); + } + if (typeof prepared.body === "string") headers.delete("content-length"); + const init = { + method: request.method, + headers, + redirect: "manual", + }; + if (request.method !== "GET" && request.method !== "HEAD" && prepared.body !== null) { + init.body = prepared.body; + if (typeof prepared.body !== "string") init.duplex = "half"; + } + + let response; + try { + response = await fetchImplementation(upstreamUrl, init); + } catch (error) { + throw new CloudProxyError( + 502, + "REMOTE_UNAVAILABLE", + `Cannot reach cloud taskboard at ${remoteUrl}`, + error instanceof Error ? error.message : String(error), + ); + } + return localizeResponse(response, { + readConfig, + setProjectWorkspace, + projectWorkspace: prepared.projectWorkspace, + resolveDevelopmentContext, + }); + }, + }; +} diff --git a/apps/codex-taskboard/server/database.mjs b/apps/codex-taskboard/server/database.mjs new file mode 100644 index 000000000..c673e899d --- /dev/null +++ b/apps/codex-taskboard/server/database.mjs @@ -0,0 +1,1706 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { DEFAULT_PROJECT_ID, DEFAULT_PROJECT_NAME } from "../shared/domain.mjs"; + +export class ApiError extends Error { + constructor(status, code, message, details) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + this.details = details; + } +} + +function now() { + return new Date().toISOString(); +} + +function taskFromRow(row) { + const developmentContext = row.worktree_path + ? { type: "worktree", path: row.worktree_path, branch: row.worktree_branch } + : row.git_branch + ? { type: "branch", branch: row.git_branch } + : null; + return { + id: row.id, + identifier: row.identifier, + projectId: row.project_id, + title: row.title, + description: row.description, + status: row.status, + priority: row.priority, + labels: JSON.parse(row.labels), + sortOrder: row.sort_order, + threadId: row.thread_id, + creatorType: row.creator_type, + creatorId: row.creator_id, + creatorName: row.creator_name, + creatorAvatarUrl: row.creator_avatar_url, + assignee: { + type: row.assignee_type, + id: row.assignee_id, + name: row.assignee_name, + avatarUrl: row.assignee_avatar_url, + }, + workflowId: row.workflow_id, + developmentContext, + dueDate: row.due_date, + recurrence: row.recurrence_interval && row.recurrence_unit + ? { interval: row.recurrence_interval, unit: row.recurrence_unit } + : null, + archivedAt: row.archived_at, + version: row.version, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function taskRelationSummaryFromRow(row) { + return { + id: row.id, + identifier: row.identifier, + projectId: row.project_id, + title: row.title, + status: row.status, + priority: row.priority, + threadId: row.thread_id, + assignee: { + type: row.assignee_type, + id: row.assignee_id, + name: row.assignee_name, + avatarUrl: row.assignee_avatar_url, + }, + archivedAt: row.archived_at, + }; +} + +function commentFromRow(row) { + return { + id: row.id, + taskId: row.task_id, + body: row.body, + threadId: row.thread_id, + authorType: row.author_type, + authorId: row.author_id, + authorName: row.author_name, + authorAvatarUrl: row.author_avatar_url, + attachments: [], + version: row.version, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function attachmentFromRow(row) { + return { + id: row.id, + taskId: row.task_id, + commentId: row.comment_id, + filename: row.filename, + contentType: row.content_type, + size: row.size, + createdAt: row.created_at, + }; +} + +function projectFromRow(row) { + return { + id: row.id, + name: row.name, + workspacePath: row.workspace_path, + archivedAt: row.archived_at, + issueCount: Number(row.issue_count ?? 0), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function workflowWorkspaceFromRow(row) { + return { + projectId: row.project_id, + workspace: JSON.parse(row.workspace), + version: row.version, + updatedAt: row.updated_at, + }; +} + +function aiChatRunFromRow(row) { + return { + id: row.id, + threadId: row.thread_id, + status: row.status, + exitCode: row.exit_code, + error: row.error, + startedAt: row.started_at, + finishedAt: row.finished_at, + }; +} + +function aiChatThreadFromRow(row) { + return { + id: row.id, + title: row.title, + status: row.status, + origin: { + projectId: row.origin_project_id, + projectName: row.origin_project_name, + workspacePath: row.origin_workspace_path, + ...(row.origin_issue_id ? { issueId: row.origin_issue_id } : {}), + ...(row.origin_issue_identifier ? { issueIdentifier: row.origin_issue_identifier } : {}), + }, + codexThreadId: row.codex_thread_id, + model: row.model, + reasoningEffort: row.reasoning_effort, + sandbox: row.sandbox, + currentRun: null, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function aiChatEventFromRow(row) { + return { + id: row.id, + threadId: row.thread_id, + runId: row.run_id, + type: row.type, + role: row.role, + content: row.content, + data: row.data === null ? null : JSON.parse(row.data), + createdAt: row.created_at, + }; +} + +function projectPrefix(projectId) { + const prefix = projectId.toUpperCase().replace(/[^A-Z0-9]+/g, ""); + return (prefix || "TASK").slice(0, 12); +} + +export class TaskboardDatabase { + constructor(filename) { + mkdirSync(path.dirname(filename), { recursive: true }); + this.database = new DatabaseSync(filename); + this.database.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;"); + this.#migrate(); + this.interruptAbandonedAiChatRuns(); + } + + #migrate() { + this.database.exec(` + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + workspace_path TEXT, + next_task_number INTEGER NOT NULL DEFAULT 1 CHECK (next_task_number > 0), + archived_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL UNIQUE, + project_id TEXT NOT NULL REFERENCES projects(id), + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL CHECK (status IN ( + 'backlog', 'todo', 'in_progress', 'in_review', 'blocked', 'done', 'canceled' + )), + priority TEXT NOT NULL CHECK (priority IN ('none', 'urgent', 'high', 'medium', 'low')), + labels TEXT NOT NULL DEFAULT '[]', + sort_order REAL NOT NULL, + thread_id TEXT, + creator_type TEXT NOT NULL DEFAULT 'user', + creator_id TEXT NOT NULL DEFAULT 'local-user', + creator_name TEXT NOT NULL DEFAULT '本地用户', + creator_avatar_url TEXT, + assignee_type TEXT NOT NULL DEFAULT 'user' CHECK (assignee_type IN ('user', 'agent')), + assignee_id TEXT NOT NULL DEFAULT 'local-user', + assignee_name TEXT NOT NULL DEFAULT '本地用户', + assignee_avatar_url TEXT, + workflow_id TEXT, + git_branch TEXT, + worktree_path TEXT, + worktree_branch TEXT, + due_date TEXT, + recurrence_interval INTEGER, + recurrence_unit TEXT, + archived_at TEXT, + version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS tasks_project_status_sort + ON tasks(project_id, archived_at, status, sort_order, created_at); + + CREATE TABLE IF NOT EXISTS comments ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + body TEXT NOT NULL, + thread_id TEXT, + author_type TEXT NOT NULL DEFAULT 'user', + author_id TEXT NOT NULL, + author_name TEXT NOT NULL, + author_avatar_url TEXT, + version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS comments_task_created + ON comments(task_id, created_at, id); + + CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + comment_id TEXT REFERENCES comments(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + content_type TEXT NOT NULL, + size INTEGER NOT NULL CHECK (size >= 0), + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS attachments_task_created + ON attachments(task_id, created_at, id); + + CREATE TABLE IF NOT EXISTS workflow_workspaces ( + project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE, + workspace TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS ai_chat_threads ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('idle', 'running', 'failed')), + origin_project_id TEXT NOT NULL, + origin_project_name TEXT NOT NULL, + origin_workspace_path TEXT NOT NULL, + origin_issue_id TEXT, + origin_issue_identifier TEXT, + codex_thread_id TEXT, + model TEXT NOT NULL, + reasoning_effort TEXT NOT NULL, + sandbox TEXT NOT NULL CHECK (sandbox IN ( + 'read-only', 'workspace-write', 'danger-full-access' + )), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS ai_chat_threads_updated + ON ai_chat_threads(updated_at DESC, id); + + CREATE TABLE IF NOT EXISTS ai_chat_runs ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES ai_chat_threads(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ( + 'running', 'completed', 'failed', 'interrupted' + )), + exit_code INTEGER, + error TEXT, + started_at TEXT NOT NULL, + finished_at TEXT + ); + + CREATE INDEX IF NOT EXISTS ai_chat_runs_thread_started + ON ai_chat_runs(thread_id, started_at, id); + + CREATE UNIQUE INDEX IF NOT EXISTS ai_chat_runs_one_active + ON ai_chat_runs(thread_id) + WHERE status = 'running'; + + CREATE TABLE IF NOT EXISTS ai_chat_events ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES ai_chat_threads(id) ON DELETE CASCADE, + run_id TEXT REFERENCES ai_chat_runs(id) ON DELETE CASCADE, + type TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'activity', 'error')), + content TEXT NOT NULL, + data TEXT, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS ai_chat_events_thread_created + ON ai_chat_events(thread_id, created_at, id); + + `); + + const projectColumns = this.database.prepare("PRAGMA table_info(projects)").all(); + if (!projectColumns.some((column) => column.name === "workspace_path")) { + this.database.exec("ALTER TABLE projects ADD COLUMN workspace_path TEXT"); + } + if (!projectColumns.some((column) => column.name === "archived_at")) { + this.database.exec("ALTER TABLE projects ADD COLUMN archived_at TEXT"); + } + this.database.exec(` + CREATE INDEX IF NOT EXISTS projects_archived_created + ON projects(archived_at, created_at, id) + `); + + const taskColumns = this.database.prepare("PRAGMA table_info(tasks)").all(); + const hasThreadId = taskColumns.some((column) => column.name === "thread_id"); + const hasLinkedThreadId = taskColumns.some((column) => column.name === "linked_thread_id"); + if (!hasThreadId) { + this.database.exec("ALTER TABLE tasks ADD COLUMN thread_id TEXT"); + } + if (hasLinkedThreadId) { + this.database.exec(` + UPDATE tasks + SET thread_id = COALESCE(thread_id, linked_thread_id) + `); + this.database.exec("ALTER TABLE tasks DROP COLUMN linked_thread_id"); + } + if (!taskColumns.some((column) => column.name === "git_branch")) { + this.database.exec("ALTER TABLE tasks ADD COLUMN git_branch TEXT"); + } + if (!taskColumns.some((column) => column.name === "worktree_path")) { + this.database.exec("ALTER TABLE tasks ADD COLUMN worktree_path TEXT"); + } + if (!taskColumns.some((column) => column.name === "worktree_branch")) { + this.database.exec("ALTER TABLE tasks ADD COLUMN worktree_branch TEXT"); + } + if (!taskColumns.some((column) => column.name === "due_date")) { + this.database.exec("ALTER TABLE tasks ADD COLUMN due_date TEXT"); + } + if (!taskColumns.some((column) => column.name === "recurrence_interval")) { + this.database.exec("ALTER TABLE tasks ADD COLUMN recurrence_interval INTEGER"); + } + if (!taskColumns.some((column) => column.name === "recurrence_unit")) { + this.database.exec("ALTER TABLE tasks ADD COLUMN recurrence_unit TEXT"); + } + this.#migrateTaskStatuses(); + const migratedTaskColumns = this.database.prepare("PRAGMA table_info(tasks)").all(); + if (!migratedTaskColumns.some((column) => column.name === "creator_type")) { + this.database.exec("ALTER TABLE tasks ADD COLUMN creator_type TEXT NOT NULL DEFAULT 'user'"); + } + if (!migratedTaskColumns.some((column) => column.name === "creator_id")) { + this.database.exec("ALTER TABLE tasks ADD COLUMN creator_id TEXT NOT NULL DEFAULT 'local-user'"); + } + if (!migratedTaskColumns.some((column) => column.name === "creator_name")) { + this.database.exec("ALTER TABLE tasks ADD COLUMN creator_name TEXT NOT NULL DEFAULT '本地用户'"); + } + if (!migratedTaskColumns.some((column) => column.name === "creator_avatar_url")) { + this.database.exec("ALTER TABLE tasks ADD COLUMN creator_avatar_url TEXT"); + } + if (!migratedTaskColumns.some((column) => column.name === "workflow_id")) { + this.database.exec("ALTER TABLE tasks ADD COLUMN workflow_id TEXT"); + } + this.database.exec(` + UPDATE tasks + SET creator_type = 'agent', creator_id = 'codex-agent', creator_name = 'Codex Agent' + WHERE thread_id IS NOT NULL AND version = 1 AND creator_id = 'local-user' + `); + const identityTaskColumns = this.database.prepare("PRAGMA table_info(tasks)").all(); + const assigneeMigrations = [ + ["assignee_type", "TEXT CHECK (assignee_type IN ('user', 'agent'))", "creator_type"], + ["assignee_id", "TEXT", "creator_id"], + ["assignee_name", "TEXT", "creator_name"], + ["assignee_avatar_url", "TEXT", "creator_avatar_url"], + ].filter(([column]) => !identityTaskColumns.some((current) => current.name === column)); + if (assigneeMigrations.length > 0) { + this.database.exec("BEGIN IMMEDIATE"); + try { + for (const [column, definition, source] of assigneeMigrations) { + this.database.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`); + this.database.exec(`UPDATE tasks SET ${column} = ${source}`); + } + this.database.exec("COMMIT"); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + } + this.database.exec(` + CREATE INDEX IF NOT EXISTS tasks_project_status_sort + ON tasks(project_id, archived_at, status, sort_order, created_at) + `); + this.database.exec(` + CREATE TABLE IF NOT EXISTS task_relations ( + relation_type TEXT NOT NULL CHECK (relation_type IN ('parent', 'blocks', 'related')), + source_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + target_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + CHECK (source_task_id <> target_task_id), + CHECK (relation_type <> 'related' OR source_task_id < target_task_id), + PRIMARY KEY (relation_type, source_task_id, target_task_id) + ); + + CREATE INDEX IF NOT EXISTS task_relations_target + ON task_relations(relation_type, target_task_id); + + CREATE UNIQUE INDEX IF NOT EXISTS task_relations_one_parent + ON task_relations(target_task_id) + WHERE relation_type = 'parent'; + `); + + const commentColumns = this.database.prepare("PRAGMA table_info(comments)").all(); + if (!commentColumns.some((column) => column.name === "thread_id")) { + this.database.exec("ALTER TABLE comments ADD COLUMN thread_id TEXT"); + } + if (!commentColumns.some((column) => column.name === "author_type")) { + this.database.exec("ALTER TABLE comments ADD COLUMN author_type TEXT NOT NULL DEFAULT 'user'"); + } + if (!commentColumns.some((column) => column.name === "author_avatar_url")) { + this.database.exec("ALTER TABLE comments ADD COLUMN author_avatar_url TEXT"); + } + this.database.exec(` + UPDATE comments + SET author_type = 'agent', author_id = 'codex-agent', author_name = 'Codex Agent' + WHERE thread_id IS NOT NULL AND author_id = 'local' + `); + this.database.exec(` + UPDATE comments + SET author_id = 'local-user' + WHERE author_id = 'local' + `); + + const hasTaskThreads = this.database.prepare(` + SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'task_threads' + `).get(); + if (hasTaskThreads) { + this.database.exec(` + UPDATE tasks + SET thread_id = COALESCE(thread_id, ( + SELECT task_threads.thread_id + FROM task_threads + LEFT JOIN comments + ON comments.task_id = task_threads.task_id + AND comments.thread_id = task_threads.thread_id + WHERE task_threads.task_id = tasks.id + GROUP BY task_threads.task_id, task_threads.thread_id, task_threads.created_at + ORDER BY + CASE WHEN COUNT(comments.id) > 0 THEN 1 ELSE 0 END, + task_threads.created_at DESC, + task_threads.thread_id DESC + LIMIT 1 + )) + WHERE thread_id IS NULL + `); + this.database.exec("DROP TABLE task_threads"); + } + + const attachmentColumns = this.database.prepare("PRAGMA table_info(attachments)").all(); + if (!attachmentColumns.some((column) => column.name === "comment_id")) { + this.database.exec("ALTER TABLE attachments ADD COLUMN comment_id TEXT REFERENCES comments(id) ON DELETE CASCADE"); + } + this.database.exec("CREATE INDEX IF NOT EXISTS attachments_comment_created ON attachments(comment_id, created_at, id)"); + + const timestamp = now(); + this.database.prepare(` + INSERT INTO projects (id, name, workspace_path, next_task_number, created_at, updated_at) + VALUES (?, ?, NULL, 1, ?, ?) + ON CONFLICT(id) DO NOTHING + `).run(DEFAULT_PROJECT_ID, DEFAULT_PROJECT_NAME, timestamp, timestamp); + this.database.prepare(` + UPDATE projects + SET name = ?, updated_at = ? + WHERE id = ? AND name IN ('Local', '无项目议题') + `).run(DEFAULT_PROJECT_NAME, timestamp, DEFAULT_PROJECT_ID); + } + + close() { + this.database.close(); + } + + #migrateTaskStatuses() { + const tasksSql = this.database.prepare(` + SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'tasks' + `).get()?.sql ?? ""; + if ( + tasksSql.includes("'in_review'") + && tasksSql.includes("'blocked'") + && tasksSql.includes("'canceled'") + ) { + return; + } + + this.database.exec("PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE"); + try { + this.database.exec(` + CREATE TABLE tasks_status_migration ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL UNIQUE, + project_id TEXT NOT NULL REFERENCES projects(id), + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL CHECK (status IN ( + 'backlog', 'todo', 'in_progress', 'in_review', 'blocked', 'done', 'canceled' + )), + priority TEXT NOT NULL CHECK (priority IN ('none', 'urgent', 'high', 'medium', 'low')), + labels TEXT NOT NULL DEFAULT '[]', + sort_order REAL NOT NULL, + thread_id TEXT, + git_branch TEXT, + worktree_path TEXT, + worktree_branch TEXT, + due_date TEXT, + recurrence_interval INTEGER, + recurrence_unit TEXT, + archived_at TEXT, + version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + INSERT INTO tasks_status_migration ( + id, identifier, project_id, title, description, status, priority, labels, + sort_order, thread_id, git_branch, worktree_path, worktree_branch, + due_date, recurrence_interval, recurrence_unit, + archived_at, version, created_at, updated_at + ) + SELECT + id, identifier, project_id, title, description, status, priority, labels, + sort_order, thread_id, git_branch, worktree_path, worktree_branch, + due_date, recurrence_interval, recurrence_unit, + archived_at, version, created_at, updated_at + FROM tasks; + + DROP TABLE tasks; + ALTER TABLE tasks_status_migration RENAME TO tasks; + `); + this.database.exec("COMMIT"); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } finally { + this.database.exec("PRAGMA foreign_keys = ON"); + } + + const violation = this.database.prepare("PRAGMA foreign_key_check").get(); + if (violation) { + throw new Error(`Task status migration produced a foreign key violation in '${violation.table}'`); + } + } + + listProjects() { + return this.database.prepare(` + SELECT + projects.id, + projects.name, + projects.workspace_path, + projects.archived_at, + projects.created_at, + projects.updated_at, + COUNT(tasks.id) AS issue_count + FROM projects + LEFT JOIN tasks + ON tasks.project_id = projects.id + AND tasks.archived_at IS NULL + WHERE projects.archived_at IS NULL + GROUP BY + projects.id, + projects.name, + projects.workspace_path, + projects.archived_at, + projects.created_at, + projects.updated_at + ORDER BY projects.created_at, projects.id + `).all().map(projectFromRow); + } + + createProject(input) { + const timestamp = now(); + try { + this.database.prepare(` + INSERT INTO projects (id, name, workspace_path, next_task_number, created_at, updated_at) + VALUES (?, ?, ?, 1, ?, ?) + `).run(input.id, input.name, input.workspacePath, timestamp, timestamp); + } catch (error) { + if (String(error.message).includes("UNIQUE constraint failed")) { + throw new ApiError(409, "PROJECT_EXISTS", `Project '${input.id}' already exists`); + } + throw error; + } + return this.getProject(input.id); + } + + mapProjectWorkspace(id, workspacePath) { + const current = this.getProject(id); + if (!current) throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${id}' does not exist`); + + const timestamp = now(); + const result = this.database.prepare(` + UPDATE projects + SET workspace_path = ?, updated_at = ? + WHERE id = ? AND archived_at IS NULL + `).run(workspacePath, timestamp, id); + if (result.changes !== 1) { + throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${id}' does not exist`); + } + return this.getProject(id); + } + + getProject(id) { + const row = this.database.prepare(` + SELECT + projects.id, + projects.name, + projects.workspace_path, + projects.archived_at, + projects.created_at, + projects.updated_at, + COUNT(tasks.id) AS issue_count + FROM projects + LEFT JOIN tasks + ON tasks.project_id = projects.id + AND tasks.archived_at IS NULL + WHERE projects.id = ? AND projects.archived_at IS NULL + GROUP BY + projects.id, + projects.name, + projects.workspace_path, + projects.archived_at, + projects.created_at, + projects.updated_at + `).get(id); + return row ? projectFromRow(row) : null; + } + + archiveProject(id) { + if (id === DEFAULT_PROJECT_ID) { + throw new ApiError(409, "DEFAULT_PROJECT_PROTECTED", "The default project cannot be archived or deleted"); + } + const current = this.getProject(id); + if (!current) throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${id}' does not exist`); + + const timestamp = now(); + const result = this.database.prepare(` + UPDATE projects + SET archived_at = ?, updated_at = ? + WHERE id = ? AND archived_at IS NULL + `).run(timestamp, timestamp, id); + if (result.changes !== 1) { + throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${id}' does not exist`); + } + return { ...current, archivedAt: timestamp, updatedAt: timestamp }; + } + + deleteProject(id) { + if (id === DEFAULT_PROJECT_ID) { + throw new ApiError(409, "DEFAULT_PROJECT_PROTECTED", "The default project cannot be archived or deleted"); + } + const project = this.getProject(id); + if (!project) throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${id}' does not exist`); + + let attachments = []; + this.database.exec("BEGIN IMMEDIATE"); + try { + attachments = this.database.prepare(` + SELECT attachments.* + FROM attachments + JOIN tasks ON tasks.id = attachments.task_id + WHERE tasks.project_id = ? + `).all(id).map(attachmentFromRow); + this.database.prepare("DELETE FROM tasks WHERE project_id = ?").run(id); + const result = this.database.prepare("DELETE FROM projects WHERE id = ?").run(id); + if (result.changes !== 1) { + throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${id}' does not exist`); + } + this.database.exec("COMMIT"); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + return { project, attachments }; + } + + getWorkflowWorkspace(projectId) { + if (!this.database.prepare("SELECT 1 FROM projects WHERE id = ? AND archived_at IS NULL").get(projectId)) { + throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`); + } + const row = this.database.prepare(` + SELECT project_id, workspace, version, updated_at + FROM workflow_workspaces + WHERE project_id = ? + `).get(projectId); + return row + ? workflowWorkspaceFromRow(row) + : { projectId, workspace: null, version: 0, updatedAt: null }; + } + + saveWorkflowWorkspace(projectId, expectedVersion, workspace) { + const timestamp = now(); + this.database.exec("BEGIN IMMEDIATE"); + try { + if (!this.database.prepare("SELECT 1 FROM projects WHERE id = ? AND archived_at IS NULL").get(projectId)) { + throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`); + } + const current = this.database.prepare(` + SELECT version FROM workflow_workspaces WHERE project_id = ? + `).get(projectId); + const actualVersion = current?.version ?? 0; + if (actualVersion !== expectedVersion) { + throw new ApiError(409, "VERSION_CONFLICT", "Workflow was changed by another client", { + expectedVersion, + actualVersion, + }); + } + if (current) { + this.database.prepare(` + UPDATE workflow_workspaces + SET workspace = ?, version = version + 1, updated_at = ? + WHERE project_id = ? AND version = ? + `).run(JSON.stringify(workspace), timestamp, projectId, expectedVersion); + } else { + this.database.prepare(` + INSERT INTO workflow_workspaces (project_id, workspace, version, updated_at) + VALUES (?, ?, 1, ?) + `).run(projectId, JSON.stringify(workspace), timestamp); + } + this.database.exec("COMMIT"); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + return this.getWorkflowWorkspace(projectId); + } + + listAiChatThreads() { + return this.database.prepare(` + SELECT * FROM ai_chat_threads + ORDER BY updated_at DESC, id + `).all().map((row) => this.#aiChatThreadWithCurrentRun(row)); + } + + getAiChatThread(id) { + const row = this.database.prepare("SELECT * FROM ai_chat_threads WHERE id = ?").get(id); + return row ? this.#aiChatThreadWithCurrentRun(row) : null; + } + + createAiChatThread(input) { + const id = input.id ?? randomUUID(); + const timestamp = input.createdAt ?? now(); + this.database.prepare(` + INSERT INTO ai_chat_threads ( + id, title, status, + origin_project_id, origin_project_name, origin_workspace_path, + origin_issue_id, origin_issue_identifier, + codex_thread_id, model, reasoning_effort, sandbox, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + input.title, + input.status ?? "idle", + input.origin.projectId, + input.origin.projectName, + input.origin.workspacePath, + input.origin.issueId ?? null, + input.origin.issueIdentifier ?? null, + input.codexThreadId ?? null, + input.model, + input.reasoningEffort, + input.sandbox, + timestamp, + input.updatedAt ?? timestamp, + ); + return this.getAiChatThread(id); + } + + updateAiChatThread(id, changes) { + const current = this.getAiChatThread(id); + if (!current) { + throw new ApiError(404, "AI_CHAT_THREAD_NOT_FOUND", `AI chat thread '${id}' does not exist`); + } + const columns = { + title: "title", + status: "status", + codexThreadId: "codex_thread_id", + model: "model", + reasoningEffort: "reasoning_effort", + sandbox: "sandbox", + }; + const assignments = []; + const values = []; + for (const [key, column] of Object.entries(columns)) { + if (!Object.hasOwn(changes, key)) continue; + assignments.push(`${column} = ?`); + values.push(changes[key]); + } + if (assignments.length === 0) return current; + assignments.push("updated_at = ?"); + values.push(changes.updatedAt ?? now(), id); + this.database.prepare(` + UPDATE ai_chat_threads SET ${assignments.join(", ")} WHERE id = ? + `).run(...values); + return this.getAiChatThread(id); + } + + deleteAiChatThread(id) { + const current = this.getAiChatThread(id); + if (!current) { + throw new ApiError(404, "AI_CHAT_THREAD_NOT_FOUND", `AI chat thread '${id}' does not exist`); + } + this.database.prepare("DELETE FROM ai_chat_threads WHERE id = ?").run(id); + return current; + } + + listAiChatRuns(threadId) { + return this.database.prepare(` + SELECT * FROM ai_chat_runs + WHERE thread_id = ? + ORDER BY started_at, id + `).all(threadId).map(aiChatRunFromRow); + } + + getAiChatRun(id) { + const row = this.database.prepare("SELECT * FROM ai_chat_runs WHERE id = ?").get(id); + return row ? aiChatRunFromRow(row) : null; + } + + createAiChatRun(input) { + const id = input.id ?? randomUUID(); + const timestamp = input.startedAt ?? now(); + this.database.exec("BEGIN IMMEDIATE"); + try { + this.database.prepare(` + INSERT INTO ai_chat_runs ( + id, thread_id, status, exit_code, error, started_at, finished_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + id, + input.threadId, + input.status ?? "running", + input.exitCode ?? null, + input.error ?? null, + timestamp, + input.finishedAt ?? null, + ); + if ((input.status ?? "running") === "running") { + this.database.prepare(` + UPDATE ai_chat_threads + SET status = 'running', updated_at = ? + WHERE id = ? + `).run(timestamp, input.threadId); + } + this.database.exec("COMMIT"); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + return this.getAiChatRun(id); + } + + updateAiChatRun(id, changes) { + const current = this.getAiChatRun(id); + if (!current) { + throw new ApiError(404, "AI_CHAT_RUN_NOT_FOUND", `AI chat run '${id}' does not exist`); + } + const columns = { + status: "status", + exitCode: "exit_code", + error: "error", + finishedAt: "finished_at", + }; + const assignments = []; + const values = []; + for (const [key, column] of Object.entries(columns)) { + if (!Object.hasOwn(changes, key)) continue; + assignments.push(`${column} = ?`); + values.push(changes[key]); + } + if (assignments.length === 0) return current; + + this.database.exec("BEGIN IMMEDIATE"); + try { + values.push(id); + this.database.prepare(` + UPDATE ai_chat_runs SET ${assignments.join(", ")} WHERE id = ? + `).run(...values); + const status = changes.status ?? current.status; + if (status !== "running") { + const threadStatus = status === "failed" ? "failed" : "idle"; + this.database.prepare(` + UPDATE ai_chat_threads + SET status = ?, updated_at = ? + WHERE id = ? + AND NOT EXISTS ( + SELECT 1 FROM ai_chat_runs + WHERE thread_id = ? AND status = 'running' + ) + `).run(threadStatus, changes.finishedAt ?? now(), current.threadId, current.threadId); + } + this.database.exec("COMMIT"); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + return this.getAiChatRun(id); + } + + insertAiChatEvent(input) { + const id = input.id ?? randomUUID(); + const timestamp = input.createdAt ?? now(); + this.database.prepare(` + INSERT INTO ai_chat_events ( + id, thread_id, run_id, type, role, content, data, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + input.threadId, + input.runId ?? null, + input.type, + input.role, + input.content, + input.data === undefined || input.data === null ? null : JSON.stringify(input.data), + timestamp, + ); + const row = this.database.prepare("SELECT * FROM ai_chat_events WHERE id = ?").get(id); + return aiChatEventFromRow(row); + } + + listAiChatEvents(threadId) { + return this.database.prepare(` + SELECT * FROM ai_chat_events + WHERE thread_id = ? + ORDER BY created_at, rowid + `).all(threadId).map(aiChatEventFromRow); + } + + interruptAbandonedAiChatRuns() { + const timestamp = now(); + this.database.exec("BEGIN IMMEDIATE"); + try { + const result = this.database.prepare(` + UPDATE ai_chat_runs + SET + status = 'interrupted', + error = COALESCE(error, 'Taskboard service restarted'), + finished_at = COALESCE(finished_at, ?) + WHERE status = 'running' + `).run(timestamp); + if (result.changes > 0) { + this.database.prepare(` + UPDATE ai_chat_threads + SET status = 'idle', updated_at = ? + WHERE status = 'running' + AND NOT EXISTS ( + SELECT 1 FROM ai_chat_runs + WHERE ai_chat_runs.thread_id = ai_chat_threads.id + AND ai_chat_runs.status = 'running' + ) + `).run(timestamp); + } + this.database.exec("COMMIT"); + return Number(result.changes); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + } + + listTasks(filters) { + const where = []; + const values = []; + if (filters.projectId) { + where.push("project_id = ?"); + values.push(filters.projectId); + } + if (filters.status) { + where.push("status = ?"); + values.push(filters.status); + } + if (filters.archived === "false") { + where.push("archived_at IS NULL"); + } else if (filters.archived === "true") { + where.push("archived_at IS NOT NULL"); + } + + const sql = ` + SELECT * FROM tasks + ${where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""} + ORDER BY + CASE status + WHEN 'backlog' THEN 1 + WHEN 'todo' THEN 2 + WHEN 'in_progress' THEN 3 + WHEN 'in_review' THEN 4 + WHEN 'blocked' THEN 5 + WHEN 'done' THEN 6 + WHEN 'canceled' THEN 7 + END, + sort_order, + created_at, + id + `; + return this.database.prepare(sql).all(...values).map((row) => this.#taskWithRelations(row)); + } + + getTask(id) { + const row = this.database.prepare("SELECT * FROM tasks WHERE id = ? OR identifier = ?").get(id, id); + return row ? this.#taskWithRelations(row) : null; + } + + createTask(input) { + this.database.exec("BEGIN IMMEDIATE"); + try { + const project = this.database.prepare(` + SELECT id, next_task_number FROM projects WHERE id = ? AND archived_at IS NULL + `).get(input.projectId); + if (!project) { + throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${input.projectId}' does not exist`); + } + + const number = project.next_task_number; + const identifier = `${projectPrefix(project.id)}-${number}`; + const id = randomUUID(); + const timestamp = now(); + let sortOrder = input.sortOrder; + if (sortOrder === undefined) { + const row = this.database.prepare(` + SELECT COALESCE(MAX(sort_order), 0) AS maximum + FROM tasks + WHERE project_id = ? AND status = ? AND archived_at IS NULL + `).get(input.projectId, input.status); + sortOrder = row.maximum + 1000; + } + + this.database.prepare(` + UPDATE projects SET next_task_number = next_task_number + 1, updated_at = ? WHERE id = ? AND archived_at IS NULL + `).run(timestamp, input.projectId); + this.database.prepare(` + INSERT INTO tasks ( + id, identifier, project_id, title, description, status, priority, labels, + sort_order, thread_id, creator_type, creator_id, creator_name, creator_avatar_url, + assignee_type, assignee_id, assignee_name, assignee_avatar_url, + workflow_id, git_branch, worktree_path, worktree_branch, + due_date, recurrence_interval, recurrence_unit, + archived_at, version, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 1, ?, ?) + `).run( + id, + identifier, + input.projectId, + input.title, + input.description, + input.status, + input.priority, + JSON.stringify(input.labels), + sortOrder, + input.threadId ?? null, + input.actor.type, + input.actor.id, + input.actor.name, + input.actor.avatarUrl, + input.assignee.type, + input.assignee.id, + input.assignee.name, + input.assignee.avatarUrl, + input.workflowId, + input.developmentContext?.type === "branch" ? input.developmentContext.branch : null, + input.developmentContext?.type === "worktree" ? input.developmentContext.path : null, + input.developmentContext?.type === "worktree" ? input.developmentContext.branch : null, + input.dueDate, + input.recurrence?.interval ?? null, + input.recurrence?.unit ?? null, + timestamp, + timestamp, + ); + this.database.exec("COMMIT"); + return this.getTask(id); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + } + + updateTask(id, version, changes, threadId) { + const current = this.#requireTask(id); + this.#requireVersion(current, version); + const dueDate = Object.hasOwn(changes, "dueDate") ? changes.dueDate : current.dueDate; + const recurrence = Object.hasOwn(changes, "recurrence") ? changes.recurrence : current.recurrence; + if (recurrence && !dueDate) { + throw new ApiError(400, "INVALID_FIELD", "A recurring issue requires a due date"); + } + + const columns = { + title: "title", + description: "description", + status: "status", + priority: "priority", + labels: "labels", + workflowId: "workflow_id", + dueDate: "due_date", + }; + const assignments = []; + const values = []; + for (const [key, value] of Object.entries(changes)) { + if (key === "developmentContext") { + assignments.push("git_branch = ?", "worktree_path = ?", "worktree_branch = ?"); + values.push( + value?.type === "branch" ? value.branch : null, + value?.type === "worktree" ? value.path : null, + value?.type === "worktree" ? value.branch : null, + ); + continue; + } + if (key === "recurrence") { + assignments.push("recurrence_interval = ?", "recurrence_unit = ?"); + values.push(value?.interval ?? null, value?.unit ?? null); + continue; + } + if (key === "assignee") { + assignments.push( + "assignee_type = ?", + "assignee_id = ?", + "assignee_name = ?", + "assignee_avatar_url = ?", + ); + values.push(value.type, value.id, value.name, value.avatarUrl); + continue; + } + assignments.push(`${columns[key]} = ?`); + values.push(key === "labels" ? JSON.stringify(value) : value); + } + if (threadId !== undefined) { + assignments.push("thread_id = ?"); + values.push(threadId); + } + assignments.push("version = version + 1", "updated_at = ?"); + const timestamp = now(); + values.push(timestamp, current.id, version); + + this.database.exec("BEGIN IMMEDIATE"); + try { + const result = this.database.prepare(` + UPDATE tasks SET ${assignments.join(", ")} WHERE id = ? AND version = ? + `).run(...values); + if (result.changes !== 1) { + this.#throwMissingOrConflict(id, version); + } + this.database.exec("COMMIT"); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + return this.getTask(current.id); + } + + moveTask(id, version, status, sortOrder, threadId, projectId) { + const current = this.#requireTask(id); + this.#requireVersion(current, version); + if (current.archivedAt !== null) { + throw new ApiError(409, "TASK_ARCHIVED", "Archived tasks cannot be moved"); + } + const sourceProjectId = current.projectId; + const targetProjectId = projectId ?? sourceProjectId; + if (!this.database.prepare("SELECT 1 FROM projects WHERE id = ? AND archived_at IS NULL").get(targetProjectId)) { + throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${targetProjectId}' does not exist`); + } + if (sortOrder === undefined) { + const row = this.database.prepare(` + SELECT COALESCE(MAX(sort_order), 0) AS maximum + FROM tasks + WHERE project_id = ? AND status = ? AND archived_at IS NULL AND id != ? + `).get(targetProjectId, status, current.id); + sortOrder = row.maximum + 1000; + } + + const timestamp = now(); + this.database.exec("BEGIN IMMEDIATE"); + try { + const result = this.database.prepare(` + UPDATE tasks + SET project_id = ?, status = ?, sort_order = ?, thread_id = COALESCE(?, thread_id), + version = version + 1, updated_at = ? + WHERE id = ? AND version = ? + `).run(targetProjectId, status, sortOrder, threadId ?? null, timestamp, current.id, version); + if (result.changes !== 1) { + this.#throwMissingOrConflict(id, version); + } + this.database.prepare("UPDATE projects SET updated_at = ? WHERE id = ?").run(timestamp, sourceProjectId); + if (targetProjectId !== sourceProjectId) { + this.database.prepare("UPDATE projects SET updated_at = ? WHERE id = ?").run(timestamp, targetProjectId); + } + this.database.exec("COMMIT"); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + return this.getTask(current.id); + } + + archiveTask(id, version, threadId) { + const current = this.#requireTask(id); + this.#requireVersion(current, version); + const timestamp = now(); + this.database.exec("BEGIN IMMEDIATE"); + try { + const result = this.database.prepare(` + UPDATE tasks + SET archived_at = ?, thread_id = COALESCE(?, thread_id), version = version + 1, updated_at = ? + WHERE id = ? AND version = ? + `).run(timestamp, threadId ?? null, timestamp, current.id, version); + if (result.changes !== 1) { + this.#throwMissingOrConflict(id, version); + } + this.database.exec("COMMIT"); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + return this.getTask(current.id); + } + + deleteTask(id, version) { + const current = this.#requireTask(id); + this.#requireVersion(current, version); + let attachments = []; + this.database.exec("BEGIN IMMEDIATE"); + try { + attachments = this.database.prepare(` + SELECT * FROM attachments + WHERE task_id = ? + `).all(current.id).map(attachmentFromRow); + const result = this.database.prepare(` + DELETE FROM tasks + WHERE id = ? AND version = ? + `).run(current.id, version); + if (result.changes !== 1) { + this.#throwMissingOrConflict(id, version); + } + this.database.exec("COMMIT"); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + return { task: current, attachments }; + } + + restoreTask(id, version, threadId) { + const current = this.#requireTask(id); + this.#requireVersion(current, version); + if (current.archivedAt === null) { + throw new ApiError(409, "TASK_NOT_ARCHIVED", "Only archived tasks can be restored"); + } + const timestamp = now(); + this.database.exec("BEGIN IMMEDIATE"); + try { + const result = this.database.prepare(` + UPDATE tasks + SET archived_at = NULL, thread_id = COALESCE(?, thread_id), version = version + 1, updated_at = ? + WHERE id = ? AND version = ? + `).run(threadId ?? null, timestamp, current.id, version); + if (result.changes !== 1) { + this.#throwMissingOrConflict(id, version); + } + this.database.exec("COMMIT"); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + return this.getTask(current.id); + } + + addTaskRelation(id, version, type, relatedId, threadId) { + this.database.exec("BEGIN IMMEDIATE"); + try { + const task = this.#requireTask(id); + const relatedTask = this.#requireTask(relatedId); + this.#requireVersion(task, version); + this.#validateRelationTasks(task, relatedTask); + + const { relationType, sourceTaskId, targetTaskId } = this.#relationEndpoints( + type, + task.id, + relatedTask.id, + ); + if (relationType === "parent") { + this.#assertNoParentCycle(task.id, relatedTask.id); + const existing = this.database.prepare(` + SELECT source_task_id + FROM task_relations + WHERE relation_type = 'parent' AND target_task_id = ? + `).get(task.id); + if (existing?.source_task_id === relatedTask.id) { + throw new ApiError(409, "RELATION_EXISTS", "This parent relation already exists"); + } + if (existing) { + this.database.prepare(` + DELETE FROM task_relations + WHERE relation_type = 'parent' AND target_task_id = ? + `).run(task.id); + } + } else { + const existing = this.database.prepare(` + SELECT 1 + FROM task_relations + WHERE relation_type = ? AND source_task_id = ? AND target_task_id = ? + `).get(relationType, sourceTaskId, targetTaskId); + if (existing) { + throw new ApiError(409, "RELATION_EXISTS", "This issue relation already exists"); + } + } + + this.database.prepare(` + INSERT INTO task_relations ( + relation_type, source_task_id, target_task_id, created_at + ) VALUES (?, ?, ?, ?) + `).run(relationType, sourceTaskId, targetTaskId, now()); + this.#touchTask(task.id, version, threadId); + this.database.exec("COMMIT"); + return { + task: this.getTask(task.id), + relatedTask: this.getTask(relatedTask.id), + }; + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + } + + removeTaskRelation(id, version, type, relatedId, threadId) { + this.database.exec("BEGIN IMMEDIATE"); + try { + const task = this.#requireTask(id); + const relatedTask = this.#requireTask(relatedId); + this.#requireVersion(task, version); + this.#validateRelationTasks(task, relatedTask); + const { relationType, sourceTaskId, targetTaskId } = this.#relationEndpoints( + type, + task.id, + relatedTask.id, + ); + const removed = this.database.prepare(` + DELETE FROM task_relations + WHERE relation_type = ? AND source_task_id = ? AND target_task_id = ? + `).run(relationType, sourceTaskId, targetTaskId); + if (removed.changes !== 1) { + throw new ApiError(404, "RELATION_NOT_FOUND", "This issue relation does not exist"); + } + this.#touchTask(task.id, version, threadId); + this.database.exec("COMMIT"); + return { + task: this.getTask(task.id), + relatedTask: this.getTask(relatedTask.id), + }; + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + } + + listComments(taskId) { + const task = this.#requireTask(taskId); + return this.database.prepare(` + SELECT * FROM comments + WHERE task_id = ? + ORDER BY created_at, id + `).all(task.id).map((row) => this.#commentWithAttachments(row)); + } + + createComment(taskId, input) { + const task = this.#requireTask(taskId); + const id = randomUUID(); + const timestamp = now(); + this.database.prepare(` + INSERT INTO comments ( + id, task_id, body, thread_id, author_type, author_id, author_name, author_avatar_url, + version, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) + `).run( + id, + task.id, + input.body, + input.threadId ?? null, + input.actor.type, + input.actor.id, + input.actor.name, + input.actor.avatarUrl, + timestamp, + timestamp, + ); + return this.getComment(id); + } + + getComment(id) { + const row = this.database.prepare("SELECT * FROM comments WHERE id = ?").get(id); + return row ? this.#commentWithAttachments(row) : null; + } + + updateComment(id, version, body, threadId) { + const current = this.#requireComment(id); + this.#requireCommentVersion(current, version); + const result = this.database.prepare(` + UPDATE comments + SET body = ?, thread_id = COALESCE(?, thread_id), version = version + 1, updated_at = ? + WHERE id = ? AND version = ? + `).run(body, threadId ?? null, now(), id, version); + if (result.changes !== 1) { + this.#throwMissingCommentOrConflict(id, version); + } + return this.getComment(id); + } + + deleteComment(id, version) { + const current = this.#requireComment(id); + this.#requireCommentVersion(current, version); + const result = this.database.prepare(` + DELETE FROM comments WHERE id = ? AND version = ? + `).run(id, version); + if (result.changes !== 1) { + this.#throwMissingCommentOrConflict(id, version); + } + return current; + } + + listAttachments(taskId) { + const task = this.#requireTask(taskId); + return this.database.prepare(` + SELECT * FROM attachments + WHERE task_id = ? AND comment_id IS NULL + ORDER BY created_at, id + `).all(task.id).map(attachmentFromRow); + } + + createAttachment(taskId, input) { + const task = this.#requireTask(taskId); + this.database.prepare(` + INSERT INTO attachments (id, task_id, comment_id, filename, content_type, size, created_at) + VALUES (?, ?, NULL, ?, ?, ?, ?) + `).run(input.id, task.id, input.filename, input.contentType, input.size, now()); + return this.getAttachment(input.id); + } + + listCommentAttachments(commentId) { + const comment = this.database.prepare("SELECT id FROM comments WHERE id = ?").get(commentId); + if (!comment) { + throw new ApiError(404, "COMMENT_NOT_FOUND", `Comment '${commentId}' does not exist`); + } + return this.#attachmentsForComment(commentId); + } + + createCommentAttachment(commentId, input) { + const comment = this.#requireComment(commentId); + this.database.prepare(` + INSERT INTO attachments (id, task_id, comment_id, filename, content_type, size, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(input.id, comment.taskId, comment.id, input.filename, input.contentType, input.size, now()); + return this.getAttachment(input.id); + } + + getAttachment(id) { + const row = this.database.prepare("SELECT * FROM attachments WHERE id = ?").get(id); + return row ? attachmentFromRow(row) : null; + } + + deleteAttachment(id) { + const attachment = this.getAttachment(id); + if (!attachment) { + throw new ApiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${id}' does not exist`); + } + this.database.prepare("DELETE FROM attachments WHERE id = ?").run(id); + return attachment; + } + + #commentWithAttachments(row) { + const comment = commentFromRow(row); + comment.attachments = this.#attachmentsForComment(comment.id); + return comment; + } + + #aiChatThreadWithCurrentRun(row) { + const thread = aiChatThreadFromRow(row); + const currentRun = this.database.prepare(` + SELECT * FROM ai_chat_runs + WHERE thread_id = ? AND status = 'running' + ORDER BY started_at DESC, id DESC + LIMIT 1 + `).get(thread.id); + thread.currentRun = currentRun ? aiChatRunFromRow(currentRun) : null; + return thread; + } + + #attachmentsForComment(commentId) { + return this.database.prepare(` + SELECT * FROM attachments + WHERE comment_id = ? + ORDER BY created_at, id + `).all(commentId).map(attachmentFromRow); + } + + #taskWithRelations(row) { + const task = taskFromRow(row); + const parent = this.database.prepare(` + SELECT tasks.* + FROM task_relations + JOIN tasks ON tasks.id = task_relations.source_task_id + WHERE task_relations.relation_type = 'parent' + AND task_relations.target_task_id = ? + `).get(task.id); + const subIssues = this.database.prepare(` + SELECT tasks.* + FROM task_relations + JOIN tasks ON tasks.id = task_relations.target_task_id + WHERE task_relations.relation_type = 'parent' + AND task_relations.source_task_id = ? + ORDER BY tasks.sort_order, tasks.created_at, tasks.id + `).all(task.id); + const blockedBy = this.database.prepare(` + SELECT tasks.* + FROM task_relations + JOIN tasks ON tasks.id = task_relations.source_task_id + WHERE task_relations.relation_type = 'blocks' + AND task_relations.target_task_id = ? + ORDER BY tasks.sort_order, tasks.created_at, tasks.id + `).all(task.id); + const blocks = this.database.prepare(` + SELECT tasks.* + FROM task_relations + JOIN tasks ON tasks.id = task_relations.target_task_id + WHERE task_relations.relation_type = 'blocks' + AND task_relations.source_task_id = ? + ORDER BY tasks.sort_order, tasks.created_at, tasks.id + `).all(task.id); + const related = this.database.prepare(` + SELECT tasks.* + FROM task_relations + JOIN tasks ON tasks.id = CASE + WHEN task_relations.source_task_id = ? THEN task_relations.target_task_id + ELSE task_relations.source_task_id + END + WHERE task_relations.relation_type = 'related' + AND ( + task_relations.source_task_id = ? + OR task_relations.target_task_id = ? + ) + ORDER BY tasks.sort_order, tasks.created_at, tasks.id + `).all(task.id, task.id, task.id); + task.relations = { + parent: parent ? taskRelationSummaryFromRow(parent) : null, + subIssues: subIssues.map(taskRelationSummaryFromRow), + blockedBy: blockedBy.map(taskRelationSummaryFromRow), + blocks: blocks.map(taskRelationSummaryFromRow), + related: related.map(taskRelationSummaryFromRow), + }; + return task; + } + + #validateRelationTasks(task, relatedTask) { + if (task.id === relatedTask.id) { + throw new ApiError(400, "SELF_RELATION", "An issue cannot be related to itself"); + } + if (task.projectId !== relatedTask.projectId) { + throw new ApiError(400, "CROSS_PROJECT_RELATION", "Issue relations must stay within one project"); + } + } + + #relationEndpoints(type, taskId, relatedTaskId) { + if (type === "parent") { + return { + relationType: "parent", + sourceTaskId: relatedTaskId, + targetTaskId: taskId, + }; + } + if (type === "blocks") { + return { + relationType: "blocks", + sourceTaskId: taskId, + targetTaskId: relatedTaskId, + }; + } + if (type === "blocked_by") { + return { + relationType: "blocks", + sourceTaskId: relatedTaskId, + targetTaskId: taskId, + }; + } + const [sourceTaskId, targetTaskId] = [taskId, relatedTaskId].sort(); + return { relationType: "related", sourceTaskId, targetTaskId }; + } + + #assertNoParentCycle(childId, parentId) { + const cycle = this.database.prepare(` + WITH RECURSIVE ancestors(id) AS ( + SELECT source_task_id + FROM task_relations + WHERE relation_type = 'parent' AND target_task_id = ? + UNION + SELECT task_relations.source_task_id + FROM task_relations + JOIN ancestors ON task_relations.target_task_id = ancestors.id + WHERE task_relations.relation_type = 'parent' + ) + SELECT 1 FROM ancestors WHERE id = ? + `).get(parentId, childId); + if (cycle) { + throw new ApiError(409, "RELATION_CYCLE", "This parent would create a cycle"); + } + } + + #touchTask(id, version, threadId) { + const result = this.database.prepare(` + UPDATE tasks + SET thread_id = COALESCE(?, thread_id), version = version + 1, updated_at = ? + WHERE id = ? AND version = ? + `).run(threadId ?? null, now(), id, version); + if (result.changes !== 1) { + this.#throwMissingOrConflict(id, version); + } + } + + #requireTask(id) { + const task = this.getTask(id); + if (!task) { + throw new ApiError(404, "TASK_NOT_FOUND", `Task '${id}' does not exist`); + } + return task; + } + + #requireComment(id) { + const comment = this.getComment(id); + if (!comment) { + throw new ApiError(404, "COMMENT_NOT_FOUND", `Comment '${id}' does not exist`); + } + return comment; + } + + #requireVersion(task, expectedVersion) { + if (task.version !== expectedVersion) { + throw new ApiError(409, "VERSION_CONFLICT", "Task was changed by another client", { + expectedVersion, + actualVersion: task.version, + }); + } + } + + #requireCommentVersion(comment, expectedVersion) { + if (comment.version !== expectedVersion) { + throw new ApiError(409, "VERSION_CONFLICT", "Comment was changed by another client", { + expectedVersion, + actualVersion: comment.version, + }); + } + } + + #throwMissingOrConflict(id, expectedVersion) { + const task = this.getTask(id); + if (!task) { + throw new ApiError(404, "TASK_NOT_FOUND", `Task '${id}' does not exist`); + } + throw new ApiError(409, "VERSION_CONFLICT", "Task was changed by another client", { + expectedVersion, + actualVersion: task.version, + }); + } + + #throwMissingCommentOrConflict(id, expectedVersion) { + const comment = this.getComment(id); + if (!comment) { + throw new ApiError(404, "COMMENT_NOT_FOUND", `Comment '${id}' does not exist`); + } + throw new ApiError(409, "VERSION_CONFLICT", "Comment was changed by another client", { + expectedVersion, + actualVersion: comment.version, + }); + } +} diff --git a/apps/codex-taskboard/server/executable.mjs b/apps/codex-taskboard/server/executable.mjs new file mode 100644 index 000000000..e7cbc3a96 --- /dev/null +++ b/apps/codex-taskboard/server/executable.mjs @@ -0,0 +1,41 @@ +import { execFile, spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +function usesNodeRuntime(executable) { + const ext = path.extname(executable).toLowerCase(); + if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return true; + try { + const firstLine = readFileSync(executable, "utf8").split(/\r?\n/, 1)[0]; + return /^#!.*\bnode(?:\.exe)?\b/i.test(firstLine); + } catch { + return false; + } +} + +function normalizeExecutable(executable, args = []) { + if (usesNodeRuntime(executable)) { + return { executable: process.execPath, args: [executable, ...args] }; + } + return { executable, args }; +} + +function childProcessOptions(options) { + return { + ...options, + windowsHide: process.platform === "win32", + }; +} + +export function spawnExecutable(executable, args, options) { + const command = normalizeExecutable(executable, args); + return spawn(command.executable, command.args, childProcessOptions(options)); +} + +export function execFileExecutable(executable, args, options) { + const command = normalizeExecutable(executable, args); + return execFileAsync(command.executable, command.args, childProcessOptions(options)); +} diff --git a/apps/codex-taskboard/server/index.mjs b/apps/codex-taskboard/server/index.mjs new file mode 100644 index 000000000..1786f9573 --- /dev/null +++ b/apps/codex-taskboard/server/index.mjs @@ -0,0 +1,46 @@ +import os from "node:os"; +import { pathToFileURL } from "node:url"; + +import { + LAN_SHARING_WARNING, + createTaskboardServer, + resolveHost, + resolvePort, +} from "./app.mjs"; + +export { createTaskboardServer, resolveHost, resolvePort, resolveServerOptions } from "./app.mjs"; + +async function main() { + const app = createTaskboardServer(); + const host = resolveHost(); + if (host === "0.0.0.0") { + console.warn(LAN_SHARING_WARNING); + } + const address = await app.listen({ host, port: resolvePort() }); + console.log(`Codex Taskboard listening on http://127.0.0.1:${address.port}`); + if (host === "0.0.0.0") { + const addresses = Object.values(os.networkInterfaces()) + .flat() + .filter((entry) => entry?.family === "IPv4" && !entry.internal) + .map((entry) => entry.address); + for (const lanAddress of [...new Set(addresses)]) { + console.log(`Codex Taskboard available on LAN at http://${lanAddress}:${address.port}`); + } + } + + let closing = false; + const close = async () => { + if (closing) return; + closing = true; + await app.close(); + }; + process.once("SIGINT", () => close().then(() => process.exit(0))); + process.once("SIGTERM", () => close().then(() => process.exit(0))); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/apps/codex-taskboard/shared/domain.mjs b/apps/codex-taskboard/shared/domain.mjs new file mode 100644 index 000000000..bdf28a56e --- /dev/null +++ b/apps/codex-taskboard/shared/domain.mjs @@ -0,0 +1,21 @@ +export const TASK_STATUSES = [ + "backlog", + "todo", + "in_progress", + "in_review", + "blocked", + "done", + "canceled", +]; +export const TASK_PRIORITIES = ["none", "urgent", "high", "medium", "low"]; + +export const DEFAULT_PROJECT_ID = "local"; +export const DEFAULT_PROJECT_NAME = "无项目"; + +export function isTaskStatus(value) { + return TASK_STATUSES.includes(value); +} + +export function isTaskPriority(value) { + return TASK_PRIORITIES.includes(value); +} diff --git a/apps/codex-taskboard/shared/taskboard-automation-options.d.mts b/apps/codex-taskboard/shared/taskboard-automation-options.d.mts new file mode 100644 index 000000000..f9a5ab186 --- /dev/null +++ b/apps/codex-taskboard/shared/taskboard-automation-options.d.mts @@ -0,0 +1,44 @@ +export type AutomationModel = + | "gpt-5.6-sol" + | "gpt-5.6-terra" + | "gpt-5.6-luna" + | "gpt-5.5" + | "gpt-5.4" + | "gpt-5.4-mini"; + +export type AutomationReasoningEffort = + | "low" + | "medium" + | "high" + | "xhigh" + | "max" + | "ultra"; + +export interface AutomationModelOption { + readonly label: string; + readonly slug: AutomationModel; + readonly defaultEffort: AutomationReasoningEffort; + readonly efforts: readonly AutomationReasoningEffort[]; +} + +export const AUTOMATION_MODELS: readonly AutomationModelOption[]; + +export function getAutomationModel(value: AutomationModel): AutomationModelOption; +export function getAutomationModel(value: unknown): AutomationModelOption | undefined; +export function isAutomationModel(value: unknown): value is AutomationModel; +export function isAutomationReasoningEffort( + value: unknown, +): value is AutomationReasoningEffort; +export function isSupportedModelEffort( + model: unknown, + effort: unknown, +): model is AutomationModel; +export function withAutomationModel< + T extends { model: AutomationModel; reasoningEffort: AutomationReasoningEffort }, +>( + options: T, + model: AutomationModel, +): Omit & { + model: AutomationModel; + reasoningEffort: AutomationReasoningEffort; +}; diff --git a/apps/codex-taskboard/shared/taskboard-automation-options.mjs b/apps/codex-taskboard/shared/taskboard-automation-options.mjs new file mode 100644 index 000000000..85ad5f102 --- /dev/null +++ b/apps/codex-taskboard/shared/taskboard-automation-options.mjs @@ -0,0 +1,68 @@ +export const AUTOMATION_MODELS = [ + { + label: "5.6 Sol", + slug: "gpt-5.6-sol", + defaultEffort: "low", + efforts: ["low", "medium", "high", "xhigh", "max", "ultra"], + }, + { + label: "5.6 Terra", + slug: "gpt-5.6-terra", + defaultEffort: "medium", + efforts: ["low", "medium", "high", "xhigh", "max", "ultra"], + }, + { + label: "5.6 Luna", + slug: "gpt-5.6-luna", + defaultEffort: "medium", + efforts: ["low", "medium", "high", "xhigh", "max"], + }, + { + label: "5.5", + slug: "gpt-5.5", + defaultEffort: "medium", + efforts: ["low", "medium", "high", "xhigh"], + }, + { + label: "5.4", + slug: "gpt-5.4", + defaultEffort: "medium", + efforts: ["low", "medium", "high", "xhigh"], + }, + { + label: "5.4 Mini", + slug: "gpt-5.4-mini", + defaultEffort: "medium", + efforts: ["low", "medium", "high", "xhigh"], + }, +]; + +const MODELS_BY_SLUG = new Map(AUTOMATION_MODELS.map((model) => [model.slug, model])); +const REASONING_EFFORTS = new Set(AUTOMATION_MODELS.flatMap((model) => model.efforts)); + +export function getAutomationModel(value) { + return MODELS_BY_SLUG.get(value); +} + +export function isAutomationModel(value) { + return MODELS_BY_SLUG.has(value); +} + +export function isAutomationReasoningEffort(value) { + return REASONING_EFFORTS.has(value); +} + +export function isSupportedModelEffort(model, effort) { + return getAutomationModel(model)?.efforts.includes(effort) ?? false; +} + +export function withAutomationModel(options, model) { + const nextModel = getAutomationModel(model); + return { + ...options, + model, + reasoningEffort: nextModel.efforts.includes(options.reasoningEffort) + ? options.reasoningEffort + : nextModel.defaultEffort, + }; +} diff --git a/apps/codex-taskboard/shared/taskboard-automation.mjs b/apps/codex-taskboard/shared/taskboard-automation.mjs new file mode 100644 index 000000000..ad3017e18 --- /dev/null +++ b/apps/codex-taskboard/shared/taskboard-automation.mjs @@ -0,0 +1,181 @@ +import path from "node:path"; +import { isSupportedModelEffort } from "./taskboard-automation-options.mjs"; + +const AUTOMATION_OPERATIONS = new Set(["ensure-active", "pause", "list", "apply-policy"]); +const INTERVAL_MINUTES = new Set([5, 10, 15, 30, 60]); +const HOST_REQUEST_FIELDS = new Set([ + "id", + "action", + "requestId", + "operation", + "taskboardProjectId", + "codexProjectId", + "projectName", + "workspacePath", + "skillPath", + "automationId", + "enabledByUser", + "quotaAware", + "intervalMinutes", + "model", + "reasoningEffort", +]); + +export function parseTaskboardAutomationHostRequest(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + if (Object.keys(value).some((field) => !HOST_REQUEST_FIELDS.has(field))) return null; + if (value.action !== "automation") return null; + if (!validIdentifier(value.id, 80) || !validIdentifier(value.requestId, 100)) return null; + if (!AUTOMATION_OPERATIONS.has(value.operation)) return null; + if (!validProjectId(value.taskboardProjectId)) return null; + if (!validText(value.codexProjectId, 256) || !validText(value.projectName, 200)) return null; + if (!validAbsolutePath(value.workspacePath) || !validAbsolutePath(value.skillPath)) return null; + if (!INTERVAL_MINUTES.has(value.intervalMinutes)) return null; + if (!isSupportedModelEffort(value.model, value.reasoningEffort)) return null; + if (value.automationId !== undefined && !validText(value.automationId, 256)) return null; + if (typeof value.enabledByUser !== "boolean" || typeof value.quotaAware !== "boolean") return null; + + return { + id: value.id, + action: "automation", + requestId: value.requestId, + operation: value.operation, + taskboardProjectId: value.taskboardProjectId, + codexProjectId: value.codexProjectId, + projectName: value.projectName, + workspacePath: value.workspacePath, + skillPath: value.skillPath, + ...(value.automationId === undefined ? {} : { automationId: value.automationId }), + enabledByUser: value.enabledByUser, + quotaAware: value.quotaAware, + intervalMinutes: value.intervalMinutes, + model: value.model, + reasoningEffort: value.reasoningEffort, + }; +} + +export function buildTaskboardAutomationName(request) { + return `Taskboard 自动认领 · ${request.taskboardProjectId}`; +} + +export function buildTaskboardAutomationPrompt(request) { + return [ + `[$manage-taskboard](${request.skillPath}) e-taskboard 每 ${request.intervalMinutes} 分钟检查任务面板中的「${request.projectName}」项目(项目 ID:${request.taskboardProjectId},项目目录:${request.workspacePath})。`, + "每次仅处理一个 todo:先用 issue get 读取最新任务内容,并用 comment list 读取全部评论,确认是否包含已完成后被打回的返工要求。", + "认领时使用最新 version 将任务移动到 in_progress;若发生版本冲突或最新状态已变化,立即跳过,避免多个 Agent 抢同一任务。", + "若任务已绑定 branch 或 worktree,必须在该任务绑定的开发上下文执行,避免并行 Agent 修改同一工作目录。", + "执行完成并验证后,先用 comment add 记录关键改动、验证结果、执行结果和剩余风险,再使用最新 version 将任务移动到 in_review;不要直接标记为 done。", + ].join("\n"); +} + +export function buildTaskboardAutomationSpec(request) { + return { + kind: "cron", + name: buildTaskboardAutomationName(request), + prompt: buildTaskboardAutomationPrompt(request), + projectId: request.codexProjectId, + executionEnvironment: "local", + localEnvironmentConfigPath: null, + model: request.model, + reasoningEffort: request.reasoningEffort, + rrule: `RRULE:FREQ=MINUTELY;INTERVAL=${request.intervalMinutes}`, + }; +} + +export async function reconcileTaskboardAutomation(request, rpc) { + const listed = await rpc("list-automations", {}); + const items = Array.isArray(listed?.items) ? listed.items : []; + const name = buildTaskboardAutomationName(request); + const matchingItems = items.filter((item) => item?.name === name); + + if (request.operation === "list") { + return { items: matchingItems.map(sanitizeAutomation).filter(Boolean) }; + } + + const existing = ( + request.automationId + ? matchingItems.find((item) => item?.id === request.automationId) + : null + ) ?? matchingItems[0]; + const spec = buildTaskboardAutomationSpec(request); + + if (request.operation === "pause") { + if (!existing) return { error: "not-found" }; + if (automationMatchesSpec(existing, spec, "PAUSED")) return { item: existing }; + return rpc("automation-update", { ...spec, id: existing.id, status: "PAUSED" }); + } + + if (request.operation !== "ensure-active") { + throw new Error(`Unsupported automation operation: ${request.operation}`); + } + if (existing) { + if (automationMatchesSpec(existing, spec, "ACTIVE")) return { item: existing }; + return rpc("automation-update", { + ...spec, + id: existing.id, + status: "ACTIVE", + }); + } + return rpc("automation-create", spec); +} + +function sanitizeAutomation(item) { + if ( + !validText(item?.id, 256) + || (item.status !== "ACTIVE" && item.status !== "PAUSED") + || !isSupportedModelEffort(item.model, item.reasoningEffort) + || !validRrule(item.rrule) + ) return null; + return { + id: item.id, + status: item.status, + model: item.model, + reasoningEffort: item.reasoningEffort, + rrule: item.rrule, + ...( + item.nextRunAt === null || Number.isFinite(item.nextRunAt) + ? { nextRunAt: item.nextRunAt } + : {} + ), + }; +} + +function validRrule(value) { + return typeof value === "string" + && /^RRULE:FREQ=MINUTELY;INTERVAL=(5|10|15|30|60)$/.test(value); +} + +function automationMatchesSpec(item, spec, status) { + return item?.status === status + && Object.entries(spec).every(([field, value]) => ( + field === "projectId" + ? (item.projectId ?? item.target?.projectId) === value + : item[field] === value + )); +} + +function validIdentifier(value, maxLength) { + return typeof value === "string" + && value.length > 0 + && value.length <= maxLength + && /^[a-z0-9-]+$/i.test(value); +} + +function validProjectId(value) { + return typeof value === "string" + && value.length > 0 + && value.length <= 128 + && /^[a-z0-9._-]+$/i.test(value); +} + +function validText(value, maxLength) { + return typeof value === "string" + && value.trim() === value + && value.length > 0 + && value.length <= maxLength + && !/[\u0000-\u001f\u007f]/.test(value); +} + +function validAbsolutePath(value) { + return validText(value, 2_048) && path.isAbsolute(value); +} diff --git a/apps/codex-taskboard/shared/workflow-control-flow.d.mts b/apps/codex-taskboard/shared/workflow-control-flow.d.mts new file mode 100644 index 000000000..5ffd83fda --- /dev/null +++ b/apps/codex-taskboard/shared/workflow-control-flow.d.mts @@ -0,0 +1,159 @@ +export type WorkflowConditionOutcome = "true" | "false"; + +export const WORKFLOW_TRIGGER_KINDS: readonly [ + "issue-trigger", + "rss-trigger", + "pull-request-submitted-trigger", + "repository-issue-submitted-trigger", + "git-status-trigger", +]; +export function isWorkflowTriggerKind(kind: string): boolean; + +export interface WorkflowSequenceRefSegment { + conditionId: string; + outcome: WorkflowConditionOutcome; +} + +export type WorkflowSequenceRef = WorkflowSequenceRefSegment[]; + +export interface WorkflowStepItem { + type: "step"; + nodeId: string; +} + +export interface WorkflowConditionItem { + type: "condition"; + nodeId: string; + branches: { + true: WorkflowSequence; + false: WorkflowSequence; + }; +} + +export type WorkflowItem = WorkflowStepItem | WorkflowConditionItem; + +export interface WorkflowSequence { + items: WorkflowItem[]; +} + +export interface WorkflowFlow { + version: 2; + root: WorkflowSequence; +} + +export interface WorkflowControlNode { + id: string; + parentId?: string; + position?: { x: number; y: number }; + measured?: { width?: number; height?: number }; + style?: { width?: number | string; height?: number | string }; + data?: { kind?: string; acceptsChildren?: boolean }; +} + +export interface WorkflowInsertion { + sequenceRef: WorkflowSequenceRef; + index: number; +} + +export interface WorkflowDerivedEdge { + id: string; + source: string; + target: string; + type: "workflowInsert"; + data: { + points: Array<{ x: number; y: number }>; + insertion?: WorkflowInsertion; + buttonX?: number; + buttonY?: number; + conditionId?: string; + conditionOutcome?: WorkflowConditionOutcome; + branchStart?: boolean; + labelX?: number; + labelY?: number; + }; +} + +export function createWorkflowFlow(stepIds?: string[]): WorkflowFlow; +export function workflowNodeIds(flow: WorkflowFlow): string[]; +export function getWorkflowSequence( + flow: WorkflowFlow, + sequenceRef: WorkflowSequenceRef, +): WorkflowSequence; +export function findWorkflowItem( + flow: WorkflowFlow, + nodeId: string, +): { sequenceRef: WorkflowSequenceRef; index: number; item: WorkflowItem } | null; +export function assertWorkflowFlow( + flow: WorkflowFlow, + nodes: T[], +): WorkflowFlow; +export function migrateWorkflowSnapshotV1( + nodes: T[], + edges: unknown[], + selectedNodeId?: string | null, +): { nodes: T[]; flow: WorkflowFlow; selectedNodeId: string | null }; +export function normalizeWorkflowSnapshot( + snapshot: { + nodes: T[]; + flow?: WorkflowFlow; + edges?: unknown[]; + selectedNodeId?: string | null; + }, +): { nodes: T[]; flow: WorkflowFlow; selectedNodeId: string | null }; +export function serializeWorkflowSnapshot( + nodes: T[], + flow: WorkflowFlow, + selectedNodeId?: string | null, +): { nodes: T[]; flow: WorkflowFlow; selectedNodeId: string | null }; +export function insertWorkflowNode( + flow: WorkflowFlow, + sequenceRef: WorkflowSequenceRef, + index: number, + nodeId: string, + kind: string, +): WorkflowFlow; +export function deleteWorkflowNode( + flow: WorkflowFlow, + nodeId: string, +): { flow: WorkflowFlow; removedNodeIds: string[] }; +export function moveWorkflowNode( + flow: WorkflowFlow, + nodeId: string, + targetSequenceRef: WorkflowSequenceRef, + targetIndex: number, +): WorkflowFlow; +export function deriveWorkflowLayout( + flow: WorkflowFlow, + nodes: T[], +): { + positions: Record; + conditions: Record; + branchBounds: Record; + bounds: { left: number; right: number; top: number; bottom: number }; + }>; + virtualNodes: Array<{ + id: string; + kind: string; + position: { x: number; y: number }; + }>; + edges: WorkflowDerivedEdge[]; + insertionPoints: Array<{ + id: string; + edgeId: string | null; + x: number; + y: number; + insertion: WorkflowInsertion; + }>; + bounds: { left: number; right: number; top: number; bottom: number }; +}; diff --git a/apps/codex-taskboard/shared/workflow-control-flow.mjs b/apps/codex-taskboard/shared/workflow-control-flow.mjs new file mode 100644 index 000000000..6c51905d4 --- /dev/null +++ b/apps/codex-taskboard/shared/workflow-control-flow.mjs @@ -0,0 +1,723 @@ +import { + normalizeWorkflowConditionBranches, +} from "./workflow-sequence.mjs"; + +const NODE_WIDTH = 250; +const DEFAULT_NODE_HEIGHT = 138; +const SEQUENCE_GAP = 58; +const BRANCH_GAP = 110; +const SPLIT_OFFSET = 28; +const BRANCH_TOP_OFFSET = 94; +const MERGE_RAIL_OFFSET = 36; +const MERGE_OFFSET = 28; +const ROOT_TOP = 48; + +export const WORKFLOW_TRIGGER_KINDS = Object.freeze([ + "issue-trigger", + "rss-trigger", + "pull-request-submitted-trigger", + "repository-issue-submitted-trigger", + "git-status-trigger", +]); + +const WORKFLOW_TRIGGER_KIND_SET = new Set(WORKFLOW_TRIGGER_KINDS); + +export function isWorkflowTriggerKind(kind) { + return WORKFLOW_TRIGGER_KIND_SET.has(kind); +} + +function stepItem(nodeId) { + return { type: "step", nodeId }; +} + +function conditionItem(nodeId, trueItems = [], falseItems = []) { + return { + type: "condition", + nodeId, + branches: { + true: { items: trueItems }, + false: { items: falseItems }, + }, + }; +} + +function legacySequenceItems(nodeIds, nodesById, persistedBranchesByCondition) { + const items = []; + for (let index = 0; index < nodeIds.length; index += 1) { + const nodeId = nodeIds[index]; + if (nodesById.get(nodeId)?.data?.kind !== "condition") { + items.push(stepItem(nodeId)); + continue; + } + const persistedBranches = persistedBranchesByCondition.get(nodeId); + if (persistedBranches) { + items.push(conditionItem( + nodeId, + legacySequenceItems( + persistedBranches.true, + nodesById, + persistedBranchesByCondition, + ), + legacySequenceItems( + persistedBranches.false, + nodesById, + persistedBranchesByCondition, + ), + )); + continue; + } + items.push(conditionItem( + nodeId, + legacySequenceItems( + nodeIds.slice(index + 1), + nodesById, + persistedBranchesByCondition, + ), + [], + )); + break; + } + return items; +} + +function cloneFlow(flow) { + return JSON.parse(JSON.stringify(flow)); +} + +function virtualNodeId(nodeId) { + return nodeId.startsWith("__flow-") + || nodeId.startsWith("__condition-") + || nodeId === "__workflow-sequence-end__"; +} + +function visitItems(sequence, visitor, sequenceRef = []) { + sequence.items.forEach((item, index) => { + visitor(item, sequenceRef, index); + if (item.type !== "condition") return; + visitItems( + item.branches.true, + visitor, + [...sequenceRef, { conditionId: item.nodeId, outcome: "true" }], + ); + visitItems( + item.branches.false, + visitor, + [...sequenceRef, { conditionId: item.nodeId, outcome: "false" }], + ); + }); +} + +function itemNodeIds(item) { + const ids = [item.nodeId]; + if (item.type === "condition") { + for (const branch of [item.branches.true, item.branches.false]) { + for (const child of branch.items) ids.push(...itemNodeIds(child)); + } + } + return ids; +} + +function isTriggerNode(node) { + return isWorkflowTriggerKind(node?.data?.kind); +} + +function removeWorkflowItem(sequence, nodeId) { + const index = sequence.items.findIndex((item) => item.nodeId === nodeId); + if (index >= 0) return sequence.items.splice(index, 1)[0]; + for (const item of sequence.items) { + if (item.type !== "condition") continue; + for (const branch of [item.branches.true, item.branches.false]) { + const removed = removeWorkflowItem(branch, nodeId); + if (removed) return removed; + } + } + return null; +} + +export function createWorkflowFlow(stepIds = []) { + return { + version: 2, + root: { + items: stepIds.map(stepItem), + }, + }; +} + +export function workflowNodeIds(flow) { + const ids = []; + visitItems(flow.root, (item) => ids.push(item.nodeId)); + return ids; +} + +export function getWorkflowSequence(flow, sequenceRef) { + let sequence = flow.root; + for (const segment of sequenceRef) { + const item = sequence.items.find((candidate) => ( + candidate.type === "condition" && candidate.nodeId === segment.conditionId + )); + if (!item) throw new Error(`Workflow condition ${segment.conditionId} is not in this sequence`); + sequence = item.branches[segment.outcome]; + } + return sequence; +} + +export function findWorkflowItem(flow, nodeId) { + let found = null; + visitItems(flow.root, (item, sequenceRef, index) => { + if (!found && item.nodeId === nodeId) { + found = { sequenceRef, index, item }; + } + }); + return found; +} + +export function assertWorkflowFlow(flow, nodes) { + if (!flow || flow.version !== 2 || !flow.root || !Array.isArray(flow.root.items)) { + throw new Error("Workflow control flow must use version 2"); + } + const nodeIds = new Set(nodes.map((node) => node.id)); + if (nodeIds.size !== nodes.length) throw new Error("Workflow node ids must be unique"); + const nodesById = new Map(nodes.map((node) => [node.id, node])); + const visited = new Set(); + let rootTriggerCount = 0; + visitItems(flow.root, (item, sequenceRef, index) => { + if ( + !item + || (item.type !== "step" && item.type !== "condition") + || typeof item.nodeId !== "string" + ) { + throw new Error("Workflow items must be steps or conditions"); + } + if (virtualNodeId(item.nodeId)) throw new Error("Workflow flow cannot persist virtual nodes"); + if (visited.has(item.nodeId)) throw new Error("Workflow node ids must be unique"); + if (!nodeIds.has(item.nodeId)) throw new Error(`Workflow flow references missing node ${item.nodeId}`); + const referencedNode = nodesById.get(item.nodeId); + if (referencedNode.parentId) { + throw new Error(`Workflow flow item ${item.nodeId} must reference a root node`); + } + const referencedKind = referencedNode?.data?.kind; + if ( + typeof referencedKind === "string" + && referencedKind.endsWith("-trigger") + && !isWorkflowTriggerKind(referencedKind) + ) { + throw new Error(`Unsupported workflow trigger kind ${referencedKind}`); + } + visited.add(item.nodeId); + const referencesConditionNode = referencedNode?.data?.kind === "condition"; + if (item.type === "condition" && !referencesConditionNode) { + throw new Error(`Workflow condition item ${item.nodeId} must reference a condition node`); + } + if (item.type === "step" && referencesConditionNode) { + throw new Error(`Workflow condition node ${item.nodeId} must use a condition item`); + } + if (item.type === "condition") { + if ( + !item.branches + || !item.branches.true + || !item.branches.false + || !Array.isArray(item.branches.true.items) + || !Array.isArray(item.branches.false.items) + ) { + throw new Error("Workflow conditions must own true and false sequences"); + } + } + if (isTriggerNode(referencedNode)) { + if (sequenceRef.length > 0 || index !== 0) { + throw new Error("Workflow trigger must stay first in the root sequence"); + } + rootTriggerCount += 1; + } + }); + if (flow.root.items.length > 0 && rootTriggerCount !== 1) { + throw new Error("A non-empty workflow must contain exactly one root-first trigger"); + } + for (const node of nodes) { + if (!node.parentId) { + if (!virtualNodeId(node.id) && !visited.has(node.id)) { + throw new Error(`Workflow node ${node.id} is missing from control flow`); + } + continue; + } + const parent = nodesById.get(node.parentId); + if (!parent) { + throw new Error(`Workflow child node ${node.id} references missing parent ${node.parentId}`); + } + if (parent.parentId) { + throw new Error(`Workflow child node ${node.id} must reference a root parent`); + } + if (parent.data?.acceptsChildren !== true) { + throw new Error(`Workflow parent node ${parent.id} must set data.acceptsChildren to true`); + } + } + return flow; +} + +export function migrateWorkflowSnapshotV1(nodes, edges, selectedNodeId = null) { + let persistedNodes = nodes.filter((node) => !virtualNodeId(node.id)); + const rootNodes = persistedNodes.filter((node) => !node.parentId); + const graph = normalizeWorkflowConditionBranches(rootNodes, edges); + const nodesById = new Map(persistedNodes.map((node) => [node.id, node])); + const persistedBranchesByCondition = new Map(); + if (graph.conditionId) { + persistedBranchesByCondition.set(graph.conditionId, graph.branches); + } + const rootItems = legacySequenceItems( + graph.trunkStepIds, + nodesById, + persistedBranchesByCondition, + ); + const flow = { version: 2, root: { items: rootItems } }; + const triggerIds = workflowNodeIds(flow).filter((nodeId) => isTriggerNode(nodesById.get(nodeId))); + const primaryTriggerId = triggerIds[0] ?? null; + for (const duplicateTriggerId of triggerIds.slice(1)) { + removeWorkflowItem(flow.root, duplicateTriggerId); + } + if (primaryTriggerId && flow.root.items[0]?.nodeId !== primaryTriggerId) { + const primaryTrigger = removeWorkflowItem(flow.root, primaryTriggerId); + if (primaryTrigger) flow.root.items.unshift(primaryTrigger); + } + const discardedTriggerIds = new Set(triggerIds.slice(1)); + persistedNodes = persistedNodes.filter((node) => !discardedTriggerIds.has(node.id)); + assertWorkflowFlow(flow, persistedNodes); + return { + nodes: persistedNodes, + flow, + selectedNodeId: persistedNodes.some((node) => node.id === selectedNodeId) + ? selectedNodeId + : null, + }; +} + +export function normalizeWorkflowSnapshot(snapshot) { + if (snapshot?.flow !== undefined) { + if (snapshot.flow?.version !== 2) { + throw new Error("Workflow control flow must use version 2"); + } + const nodes = snapshot.nodes.filter((node) => !virtualNodeId(node.id)); + assertWorkflowFlow(snapshot.flow, nodes); + return { + nodes, + flow: cloneFlow(snapshot.flow), + selectedNodeId: nodes.some((node) => node.id === snapshot.selectedNodeId) + ? snapshot.selectedNodeId + : null, + }; + } + if (!Array.isArray(snapshot?.edges)) { + throw new Error("Legacy workflow snapshots must provide edges"); + } + return migrateWorkflowSnapshotV1( + snapshot?.nodes ?? [], + snapshot?.edges ?? [], + snapshot?.selectedNodeId ?? null, + ); +} + +export function serializeWorkflowSnapshot(nodes, flow, selectedNodeId = null) { + const persistedNodes = nodes.filter((node) => !virtualNodeId(node.id)); + assertWorkflowFlow(flow, persistedNodes); + return { + nodes: persistedNodes, + flow: cloneFlow(flow), + selectedNodeId: persistedNodes.some((node) => node.id === selectedNodeId) + ? selectedNodeId + : null, + }; +} + +export function insertWorkflowNode(flow, sequenceRef, index, nodeId, kind) { + const next = cloneFlow(flow); + const sequence = getWorkflowSequence(next, sequenceRef); + const insertionIndex = Math.max(0, Math.min(Math.trunc(index), sequence.items.length)); + if (kind === "condition") { + const tail = sequence.items.splice(insertionIndex); + sequence.items.push(conditionItem(nodeId, tail, [])); + } else { + sequence.items.splice(insertionIndex, 0, stepItem(nodeId)); + } + return next; +} + +export function deleteWorkflowNode(flow, nodeId) { + const next = cloneFlow(flow); + const found = findWorkflowItem(next, nodeId); + if (!found) return { flow: next, removedNodeIds: [] }; + const rootFirst = next.root.items[0]; + if (rootFirst?.nodeId === nodeId) throw new Error("Workflow trigger cannot be deleted"); + const sequence = getWorkflowSequence(next, found.sequenceRef); + const [removed] = sequence.items.splice(found.index, 1); + return { + flow: next, + removedNodeIds: itemNodeIds(removed), + }; +} + +export function moveWorkflowNode(flow, nodeId, targetSequenceRef, targetIndex) { + const found = findWorkflowItem(flow, nodeId); + if (!found) return cloneFlow(flow); + if (found.sequenceRef.length === 0 && found.index === 0) { + throw new Error("Workflow trigger must stay first"); + } + if ( + found.item.type === "condition" + && targetSequenceRef.some((segment) => segment.conditionId === nodeId) + ) { + throw new Error("Workflow condition cannot move into its own descendant"); + } + const next = cloneFlow(flow); + const sourceSequence = getWorkflowSequence(next, found.sequenceRef); + const [item] = sourceSequence.items.splice(found.index, 1); + const targetSequence = getWorkflowSequence(next, targetSequenceRef); + const insertionIndex = Math.max(0, Math.min(Math.trunc(targetIndex), targetSequence.items.length)); + if (targetSequenceRef.length === 0 && insertionIndex === 0) { + throw new Error("Workflow trigger must stay first"); + } + targetSequence.items.splice(insertionIndex, 0, item); + return next; +} + +function nodeHeight(node) { + const height = Number(node?.style?.height ?? node?.measured?.height); + return Number.isFinite(height) && height > 0 ? height : DEFAULT_NODE_HEIGHT; +} + +function measureSequence(sequence, nodesById) { + let width = NODE_WIDTH; + let height = 0; + sequence.items.forEach((item, index) => { + const measured = measureItem(item, nodesById); + width = Math.max(width, measured.width); + height += measured.height; + if (index < sequence.items.length - 1) height += SEQUENCE_GAP; + }); + return { width, height }; +} + +function measureItem(item, nodesById) { + const height = nodeHeight(nodesById.get(item.nodeId)); + if (item.type === "step") return { width: NODE_WIDTH, height }; + const trueMeasure = measureSequence(item.branches.true, nodesById); + const falseMeasure = measureSequence(item.branches.false, nodesById); + return { + width: Math.max(NODE_WIDTH, trueMeasure.width + BRANCH_GAP + falseMeasure.width), + height: height + + BRANCH_TOP_OFFSET + + Math.max(trueMeasure.height, falseMeasure.height) + + MERGE_RAIL_OFFSET + + MERGE_OFFSET, + }; +} + +function route(...points) { + const compact = []; + for (const point of points) { + const previous = compact.at(-1); + if (previous && previous.x === point.x && previous.y === point.y) continue; + compact.push(point); + } + return compact; +} + +function boundsForNode(x, y, height) { + return { + left: x - NODE_WIDTH / 2, + right: x + NODE_WIDTH / 2, + top: y, + bottom: y + height, + }; +} + +function unionBounds(...bounds) { + return { + left: Math.min(...bounds.map((entry) => entry.left)), + right: Math.max(...bounds.map((entry) => entry.right)), + top: Math.min(...bounds.map((entry) => entry.top)), + bottom: Math.max(...bounds.map((entry) => entry.bottom)), + }; +} + +export function deriveWorkflowLayout(flow, nodes) { + const nodesById = new Map(nodes.filter((node) => !node.parentId).map((node) => [node.id, node])); + const positions = {}; + const conditions = {}; + const virtualNodes = []; + const edges = []; + const insertionPoints = []; + const insertionKeys = new Set(); + let edgeNumber = 0; + + function addVirtual(id, kind, x, y) { + virtualNodes.push({ id, kind, position: { x, y } }); + } + + function addEdge(source, target, points, options = {}) { + const id = options.id ?? `__flow-edge-${edgeNumber++}`; + const data = { + points: route(...points), + ...(options.conditionOutcome + ? { + conditionId: options.conditionId, + conditionOutcome: options.conditionOutcome, + branchStart: options.branchStart === true, + labelX: options.labelX, + labelY: options.labelY, + } + : {}), + }; + if (options.insertion) { + const key = JSON.stringify(options.insertion); + if (insertionKeys.has(key)) { + throw new Error(`Duplicate workflow insertion point ${key}`); + } + insertionKeys.add(key); + data.insertion = options.insertion; + data.buttonX = options.buttonX; + data.buttonY = options.buttonY; + insertionPoints.push({ + id: `__flow-insert-${insertionPoints.length}`, + edgeId: id, + x: options.buttonX, + y: options.buttonY, + insertion: options.insertion, + }); + } + edges.push({ + id, + source, + target, + type: "workflowInsert", + data, + }); + } + + function placeSequence(sequence, sequenceRef, centerX, startY) { + let previous = null; + let firstInput = null; + let bounds = null; + sequence.items.forEach((item, index) => { + const itemY = previous ? previous.y + SEQUENCE_GAP : startY; + const placed = placeItem(item, sequenceRef, centerX, itemY); + if (!firstInput) firstInput = placed.input; + if (previous) { + const insertion = { sequenceRef, index }; + addEdge( + previous.id, + placed.input.id, + route(previous, placed.input), + { + insertion, + buttonX: centerX, + buttonY: previous.y + (placed.input.y - previous.y) / 2, + }, + ); + } + previous = placed.output; + bounds = bounds ? unionBounds(bounds, placed.bounds) : placed.bounds; + }); + const emptyBounds = { + left: centerX - NODE_WIDTH / 2, + right: centerX + NODE_WIDTH / 2, + top: startY, + bottom: startY, + }; + return { + firstInput, + output: previous, + bounds: bounds ?? emptyBounds, + top: startY, + bottom: previous?.y ?? startY, + }; + } + + function placeItem(item, sequenceRef, centerX, y) { + const height = nodeHeight(nodesById.get(item.nodeId)); + positions[item.nodeId] = { x: centerX, y }; + const input = { id: item.nodeId, x: centerX, y }; + const nodeBottom = y + height; + const ownBounds = boundsForNode(centerX, y, height); + if (item.type === "step") { + return { + input, + output: { id: item.nodeId, x: centerX, y: nodeBottom }, + bounds: ownBounds, + }; + } + + const conditionId = item.nodeId; + const trueMeasure = measureSequence(item.branches.true, nodesById); + const falseMeasure = measureSequence(item.branches.false, nodesById); + const totalWidth = trueMeasure.width + BRANCH_GAP + falseMeasure.width; + const left = centerX - totalWidth / 2; + const branchCenters = { + true: left + trueMeasure.width / 2, + false: left + trueMeasure.width + BRANCH_GAP + falseMeasure.width / 2, + }; + const splitY = nodeBottom + SPLIT_OFFSET; + const branchTop = nodeBottom + BRANCH_TOP_OFFSET; + const splitId = `__flow-split-${conditionId}`; + addVirtual(splitId, "flow-split", centerX, splitY); + addEdge( + conditionId, + splitId, + [{ id: conditionId, x: centerX, y: nodeBottom }, { x: centerX, y: splitY }], + { id: `__flow-${conditionId}-to-split` }, + ); + + const branchLayouts = {}; + for (const outcome of ["true", "false"]) { + const branch = item.branches[outcome]; + const branchRef = [...sequenceRef, { conditionId, outcome }]; + const branchX = branchCenters[outcome]; + const placed = placeSequence(branch, branchRef, branchX, branchTop); + const emptyId = `__flow-empty-${conditionId}-${outcome}`; + let branchInput = placed.firstInput; + let branchOutput = placed.output; + if (!branchInput) { + addVirtual(emptyId, "flow-empty", branchX, branchTop); + branchInput = { id: emptyId, x: branchX, y: branchTop }; + branchOutput = branchInput; + } + addEdge( + splitId, + branchInput.id, + [ + { id: splitId, x: centerX, y: splitY }, + { x: branchX, y: splitY }, + branchInput, + ], + { + id: `__flow-${conditionId}-${outcome}-start`, + conditionId, + conditionOutcome: outcome, + branchStart: true, + labelX: branchX, + labelY: splitY + 14, + insertion: { sequenceRef: branchRef, index: 0 }, + buttonX: branchX, + buttonY: splitY + (branchTop - splitY) * 0.68, + }, + ); + branchLayouts[outcome] = { + ...placed, + input: branchInput, + output: branchOutput, + bounds: placed.firstInput + ? placed.bounds + : { + left: branchX - NODE_WIDTH / 2, + right: branchX + NODE_WIDTH / 2, + top: branchTop, + bottom: branchTop, + }, + sequenceRef: branchRef, + length: branch.items.length, + }; + } + + const branchBottom = Math.max( + branchLayouts.true.output.y, + branchLayouts.false.output.y, + ); + const mergeRailY = branchBottom + MERGE_RAIL_OFFSET; + const mergeY = mergeRailY + MERGE_OFFSET; + const mergeId = `__flow-merge-${conditionId}`; + addVirtual(mergeId, "flow-merge", centerX, mergeY); + for (const outcome of ["true", "false"]) { + const branch = branchLayouts[outcome]; + addEdge( + branch.output.id, + mergeId, + [ + branch.output, + { x: branch.output.x, y: mergeRailY }, + { x: centerX, y: mergeRailY }, + { id: mergeId, x: centerX, y: mergeY }, + ], + branch.length > 0 + ? { + id: `__flow-${conditionId}-${outcome}-merge`, + conditionId, + conditionOutcome: outcome, + insertion: { + sequenceRef: branch.sequenceRef, + index: branch.length, + }, + buttonX: branch.output.x, + buttonY: branch.output.y + (mergeRailY - branch.output.y) / 2, + } + : { + id: `__flow-${conditionId}-${outcome}-merge`, + conditionId, + conditionOutcome: outcome, + }, + ); + } + const branchBounds = { + true: branchLayouts.true.bounds, + false: branchLayouts.false.bounds, + }; + const bounds = unionBounds( + ownBounds, + branchBounds.true, + branchBounds.false, + { + left: centerX, + right: centerX, + top: splitY, + bottom: mergeY, + }, + ); + conditions[conditionId] = { + nodeBottom, + splitY, + mergeRailY, + mergeY, + mergeX: centerX, + branchCenters, + branchBounds, + bounds, + }; + return { + input, + output: { id: mergeId, x: centerX, y: mergeY }, + bounds, + }; + } + + const root = placeSequence(flow.root, [], 0, ROOT_TOP); + if (flow.root.items.length === 0) { + const insertion = { sequenceRef: [], index: 0 }; + insertionPoints.push({ + id: "__flow-insert-root-empty", + edgeId: null, + x: 0, + y: ROOT_TOP, + insertion, + }); + } else { + const endId = "__flow-end-root"; + const endY = root.output.y + SEQUENCE_GAP; + addVirtual(endId, "flow-end", 0, endY); + addEdge( + root.output.id, + endId, + [root.output, { id: endId, x: 0, y: endY }], + { + id: "__flow-root-end", + insertion: { sequenceRef: [], index: flow.root.items.length }, + buttonX: 0, + buttonY: root.output.y + SEQUENCE_GAP / 2, + }, + ); + } + + return { + positions, + conditions, + virtualNodes, + edges, + insertionPoints, + bounds: root.bounds, + }; +} diff --git a/apps/codex-taskboard/shared/workflow-sequence.d.mts b/apps/codex-taskboard/shared/workflow-sequence.d.mts new file mode 100644 index 000000000..786c1fe08 --- /dev/null +++ b/apps/codex-taskboard/shared/workflow-sequence.d.mts @@ -0,0 +1,65 @@ +export interface WorkflowSequenceNode { + id: string; + parentId?: string; + position: { x: number; y: number }; + data?: { kind?: string }; +} + +export interface WorkflowSequenceEdge { + id: string; + source: string; + target: string; + sourceHandle?: string | null; + data?: { + conditionId?: string; + conditionOutcome?: "true" | "false"; + }; +} + +export interface WorkflowConditionGraph { + trunkStepIds: string[]; + conditionId: string | null; + branches: { + true: string[]; + false: string[]; + }; + migrated: boolean; +} + +export function orderedWorkflowStepIds( + nodes: WorkflowSequenceNode[], + edges: WorkflowSequenceEdge[], +): string[]; + +export function insertWorkflowStep( + stepIds: string[], + stepId: string, + afterStepId: string | null, +): string[]; + +export function reorderWorkflowStep( + stepIds: string[], + stepId: string, + targetIndex: number, + pinnedStepId?: string, +): string[]; + +export function workflowSequenceEdges(stepIds: string[]): WorkflowSequenceEdge[]; + +export function workflowConditionEdges( + trunkStepIds: string[], + conditionId: string, + branches: WorkflowConditionGraph["branches"], +): WorkflowSequenceEdge[]; + +export function normalizeWorkflowConditionBranches( + nodes: WorkflowSequenceNode[], + edges: WorkflowSequenceEdge[], +): WorkflowConditionGraph; + +export function layoutWorkflowSteps( + nodes: T[], + stepIds: string[], + heights?: Record, + options?: { top?: number; gap?: number }, +): T[]; diff --git a/apps/codex-taskboard/shared/workflow-sequence.mjs b/apps/codex-taskboard/shared/workflow-sequence.mjs new file mode 100644 index 000000000..bd008ad5a --- /dev/null +++ b/apps/codex-taskboard/shared/workflow-sequence.mjs @@ -0,0 +1,226 @@ +function nodePosition(node) { + return { + x: Number.isFinite(node.position?.x) ? node.position.x : 0, + y: Number.isFinite(node.position?.y) ? node.position.y : 0, + }; +} + +function compareNodes(left, right) { + const leftPosition = nodePosition(left); + const rightPosition = nodePosition(right); + return leftPosition.y - rightPosition.y + || leftPosition.x - rightPosition.x + || left.id.localeCompare(right.id); +} + +export function orderedWorkflowStepIds(nodes, edges) { + const roots = nodes.filter((node) => !node.parentId); + const rootsById = new Map(roots.map((node) => [node.id, node])); + const outgoing = new Map(roots.map((node) => [node.id, []])); + const incoming = new Map(roots.map((node) => [node.id, 0])); + + for (const edge of edges) { + if ( + edge.source === edge.target + || !rootsById.has(edge.source) + || !rootsById.has(edge.target) + ) { + continue; + } + const targets = outgoing.get(edge.source); + if (targets.includes(edge.target)) continue; + targets.push(edge.target); + incoming.set(edge.target, incoming.get(edge.target) + 1); + } + + for (const targets of outgoing.values()) { + targets.sort((leftId, rightId) => ( + compareNodes(rootsById.get(leftId), rootsById.get(rightId)) + )); + } + + const ordered = []; + const visited = new Set(); + const visit = (id) => { + if (visited.has(id)) return; + visited.add(id); + ordered.push(id); + for (const targetId of outgoing.get(id)) visit(targetId); + }; + + roots + .filter((node) => incoming.get(node.id) === 0) + .sort(compareNodes) + .forEach((node) => visit(node.id)); + roots.sort(compareNodes).forEach((node) => visit(node.id)); + return ordered; +} + +export function insertWorkflowStep(stepIds, stepId, afterStepId) { + const next = stepIds.filter((id) => id !== stepId); + if (afterStepId === null) { + next.push(stepId); + return next; + } + const afterIndex = next.indexOf(afterStepId); + next.splice(afterIndex < 0 ? next.length : afterIndex + 1, 0, stepId); + return next; +} + +export function reorderWorkflowStep(stepIds, stepId, targetIndex, pinnedStepId) { + if (stepId === pinnedStepId || !stepIds.includes(stepId)) return [...stepIds]; + const next = stepIds.filter((id) => id !== stepId); + const minimumIndex = next[0] === pinnedStepId ? 1 : 0; + const insertionIndex = Math.max( + minimumIndex, + Math.min(Number.isFinite(targetIndex) ? Math.trunc(targetIndex) : next.length, next.length), + ); + next.splice(insertionIndex, 0, stepId); + return next; +} + +export function workflowSequenceEdges(stepIds) { + return stepIds.slice(1).map((target, index) => { + const source = stepIds[index]; + return { + id: `sequence-${source}-${target}`, + source, + target, + }; + }); +} + +function conditionEdgeOutcome(edge) { + const outcome = edge.data?.conditionOutcome; + return outcome === "true" || outcome === "false" ? outcome : null; +} + +function orderedConditionBranchIds(nodes, edges, conditionId, outcome) { + const nodeIds = new Set(nodes.map((node) => node.id)); + const branchEdges = edges.filter((edge) => ( + edge.data?.conditionId === conditionId + && conditionEdgeOutcome(edge) === outcome + && nodeIds.has(edge.source) + && nodeIds.has(edge.target) + )); + const outgoing = new Map(); + for (const edge of branchEdges) { + if (!outgoing.has(edge.source)) outgoing.set(edge.source, edge.target); + } + const ordered = []; + const visited = new Set([conditionId]); + let current = conditionId; + while (outgoing.has(current)) { + const next = outgoing.get(current); + if (visited.has(next)) break; + visited.add(next); + ordered.push(next); + current = next; + } + const branchNodeIds = new Set(); + for (const edge of branchEdges) { + if (edge.source !== conditionId) branchNodeIds.add(edge.source); + if (edge.target !== conditionId) branchNodeIds.add(edge.target); + } + nodes + .filter((node) => branchNodeIds.has(node.id) && !visited.has(node.id)) + .sort(compareNodes) + .forEach((node) => ordered.push(node.id)); + return ordered; +} + +export function workflowConditionEdges(trunkStepIds, conditionId, branches) { + const edges = workflowSequenceEdges(trunkStepIds); + for (const outcome of ["true", "false"]) { + const stepIds = branches[outcome] ?? []; + const branchSequence = [conditionId, ...stepIds]; + for (let index = 1; index < branchSequence.length; index += 1) { + const source = branchSequence[index - 1]; + const target = branchSequence[index]; + edges.push({ + id: `condition-${conditionId}-${outcome}-${source}-${target}`, + source, + target, + sourceHandle: source === conditionId ? `condition-${outcome}` : undefined, + data: { + conditionId, + conditionOutcome: outcome, + }, + }); + } + } + return edges; +} + +export function normalizeWorkflowConditionBranches(nodes, edges) { + const persistedConditionEdge = edges.find((edge) => conditionEdgeOutcome(edge)); + if (persistedConditionEdge) { + const conditionId = persistedConditionEdge.data.conditionId; + const branches = { + true: orderedConditionBranchIds(nodes, edges, conditionId, "true"), + false: orderedConditionBranchIds(nodes, edges, conditionId, "false"), + }; + const branchNodeIds = new Set([...branches.true, ...branches.false]); + const trunkNodes = nodes.filter((node) => !node.parentId && !branchNodeIds.has(node.id)); + const trunkEdges = edges.filter((edge) => !conditionEdgeOutcome(edge)); + const trunkStepIds = orderedWorkflowStepIds(trunkNodes, trunkEdges); + return { + trunkStepIds, + conditionId, + branches, + migrated: false, + }; + } + + const linearStepIds = orderedWorkflowStepIds(nodes, edges); + const conditionIndex = linearStepIds.findIndex((id) => ( + nodes.find((node) => node.id === id)?.data?.kind === "condition" + )); + if (conditionIndex < 0) { + return { + trunkStepIds: linearStepIds, + conditionId: null, + branches: { true: [], false: [] }, + migrated: false, + }; + } + return { + trunkStepIds: linearStepIds.slice(0, conditionIndex + 1), + conditionId: linearStepIds[conditionIndex], + branches: { + true: linearStepIds.slice(conditionIndex + 1), + false: [], + }, + migrated: conditionIndex < linearStepIds.length - 1, + }; +} + +export function layoutWorkflowSteps( + nodes, + stepIds, + heights = {}, + { top = 0, gap = 0 } = {}, +) { + const positions = new Map(); + let y = top; + for (const id of stepIds) { + positions.set(id, { x: 0, y }); + const height = Number.isFinite(heights[id]) ? heights[id] : 0; + y += height + gap; + } + const laidOut = nodes.map((node) => ( + !node.parentId && positions.has(node.id) + ? { ...node, position: positions.get(node.id) } + : node + )); + const nodesById = new Map(laidOut.map((node) => [node.id, node])); + const orderedRoots = stepIds + .map((id) => nodesById.get(id)) + .filter((node) => node && !node.parentId); + const orderedRootIds = new Set(orderedRoots.map((node) => node.id)); + return [ + ...orderedRoots, + ...laidOut.filter((node) => !node.parentId && !orderedRootIds.has(node.id)), + ...laidOut.filter((node) => node.parentId), + ]; +} diff --git a/apps/codex-taskboard/skills/manage-taskboard/SKILL.md b/apps/codex-taskboard/skills/manage-taskboard/SKILL.md new file mode 100644 index 000000000..86d2f14c8 --- /dev/null +++ b/apps/codex-taskboard/skills/manage-taskboard/SKILL.md @@ -0,0 +1,33 @@ +--- +name: manage-taskboard +description: Manage taskboard projects, issues, issue relations, and comments through the taskctl CLI. Use when Codex needs to track a new requirement, inspect project work, create or update issues, relate dependent work, add progress notes, begin work on an issue, record completion, or coordinate concurrent updates. +--- + +# Manage Taskboard + +Use `taskctl` for every project, issue, and comment operation. Read [references/cli.md](references/cli.md) before choosing a command or option. + +## Workflow + +1. Search for an existing issue before creating one. Use `context current`, then list the project issues and compare their identifiers, titles, descriptions, and status. + - If an issue already tracks the same requirement, append the new requirement or acceptance detail to that issue without discarding its existing scope. + - If the work depends on, blocks, is blocked by, or is closely related to another issue, add the matching issue relation. + - Use a parent/sub-issue relation when one requirement is a contained part of a larger issue. A child has one parent; a parent may have many sub-issues. + - Create a new issue only when no existing issue reasonably tracks the requirement. + - Do not create, append, or relate a tiny or trivial request that does not benefit from durable tracking. + - When a tracked request will be split across parallel or sub-agent work, create one child issue per sub-agent deliverable before dispatching the sub-agent. Add a `parent` relation from each child issue to the parent issue, pass the child issue identifier in that sub-agent's prompt, and write that sub-agent's progress/result comments to the child issue rather than only to the parent. + - Keep the parent issue as the coordination and summary issue. Child issues should carry the individual agent status, conversation link, verification evidence, and final handoff state so the parent card's sub-issue progress reflects the real parallel work. +2. Before executing an issue, read the latest issue content and all comments. Treat comments as part of the current requirements, especially when completed work has been returned for changes. + - In a description or comment, `![alt](/api/attachments//content)` marks an inline image at that exact position in the text. + - When understanding that image is necessary, use `attachment download` to save it locally, then inspect the saved file with an available image-viewing tool. +3. Create or update issues with the CLI; consume its JSON output. + Issues created through `taskctl` are assigned to Codex Agent by default. Later CLI updates do not change the assignee. +4. Let `taskctl` attribute every issue, relation, or comment mutation to the current Codex conversation through `CODEX_THREAD_ID`. Outside Codex, pass the exact conversation id with `--thread-id`. +5. To claim a `todo` issue, move it to `in_progress` with `--if-version` from the latest read before starting implementation. If this claim reports a version conflict or a new read shows that its status changed, skip the issue and do not implement it. +6. Include `--if-version ` on every concurrent update, using the version returned by the latest read. +7. Before requesting review, verify the requested work and acceptance criteria. +8. After implementation and self-verification, add a comment summarizing the key changes, verification, result, and remaining risks; then move the issue to `in_review`. Never move it directly to `done`. +9. Move an issue from `in_review` to `done` only when the user explicitly confirms acceptance or explicitly asks to mark it complete. Codex self-verification alone is not sufficient. +10. Move work that cannot continue to `blocked`, and work that will not continue to `canceled`. + +For version conflicts outside the initial claim, read the issue again, reconcile the newer state, and retry with its current version. diff --git a/apps/codex-taskboard/skills/manage-taskboard/agents/openai.yaml b/apps/codex-taskboard/skills/manage-taskboard/agents/openai.yaml new file mode 100644 index 000000000..95e034684 --- /dev/null +++ b/apps/codex-taskboard/skills/manage-taskboard/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Manage Taskboard" + short_description: "Manage projects and issues with taskctl CLI" + default_prompt: "Use $manage-taskboard to manage this project and its issues with taskctl." diff --git a/apps/codex-taskboard/skills/manage-taskboard/references/cli.md b/apps/codex-taskboard/skills/manage-taskboard/references/cli.md new file mode 100644 index 000000000..7b9e08966 --- /dev/null +++ b/apps/codex-taskboard/skills/manage-taskboard/references/cli.md @@ -0,0 +1,151 @@ +# taskctl CLI + +`taskctl` emits JSON. Add `--json` when making the output contract explicit. + +## Context and projects + +```bash +taskctl context current [--cwd PATH] [--json] +taskctl project list [--json] +taskctl project create --name NAME [--id ID] [--workspace-path PATH] [--json] +taskctl project map PROJECT_ID --workspace-path PATH [--json] +``` + +Use `--workspace-path` to associate a project with a local repository. `context current` chooses the most specific project whose workspace contains the current directory, then falls back to the `local` project. + +Set `CODEX_TASKBOARD_URL` to override the default local API origin, `http://127.0.0.1:47823`. + +For a shared cloud board, keep `taskctl` pointed at the loopback companion and configure the upstream HTTPS origin through it: + +```bash +taskctl cloud login --url HTTPS_ORIGIN --actor-name NAME [--json] +taskctl cloud status [--json] +taskctl project list [--json] +taskctl project map PROJECT_ID --workspace-path /absolute/local/path [--json] +taskctl cloud logout [--json] +``` + +`cloud login` reads the shared password from a private `Shared key:` prompt. The actor name is the display attribution sent through Basic Authentication. The companion stores its configuration with mode `0600`; project mappings stay on the current device and can differ between collaborators. In cloud mode, failed upstream writes fail rather than falling back to or double-writing the local SQLite database. + +Every issue or comment write must be attributed to a Codex conversation. In Codex, `taskctl` reads the current conversation from `CODEX_THREAD_ID`. Outside Codex, pass `--thread-id ID` explicitly. An explicit option takes precedence over the environment. Read commands do not require a conversation id. + +Every successful command writes one JSON object with `schemaVersion` to stdout. The current schema version is `2`. Errors write one JSON object to stderr. Exit codes are `0` for success, `2` for invalid input, `3` when the service is unavailable, `4` for API or response errors, and `5` for conflicts. + +## Read issues + +```bash +taskctl issue list [--project PROJECT_ID] [--status STATUS] [--json] +taskctl issue get ID [--json] +``` + +## Create issues + +```bash +taskctl issue create \ + --project PROJECT_ID \ + --title TITLE \ + [--description TEXT | --description-file FILE] \ + [--status STATUS] \ + [--priority PRIORITY] \ + [--labels a,b] \ + [--thread-id ID] \ + [--git-branch BRANCH] \ + [--worktree-path PATH] \ + [--worktree-branch BRANCH] \ + [--due-date YYYY-MM-DD] \ + [--recurrence-interval N --recurrence-unit day|week|month|year] \ + [--json] +``` + +Statuses are `backlog`, `todo`, `in_progress`, `in_review`, `blocked`, `done`, and `canceled`. Priorities are `none`, `urgent`, `high`, `medium`, and `low`. + +Issues created through `taskctl` are assigned to Codex Agent by default. Other CLI writes preserve the existing assignee. + +## Update issues + +Read the issue immediately before a write and pass its `version` with `--if-version`. + +```bash +taskctl issue update ID \ + [--title TITLE] \ + [--description TEXT | --description-file FILE] \ + [--status STATUS] \ + [--priority PRIORITY] \ + [--labels a,b] \ + [--thread-id ID] \ + [--git-branch BRANCH] \ + [--worktree-path PATH] \ + [--worktree-branch BRANCH] \ + [--due-date YYYY-MM-DD] \ + [--recurrence-interval N --recurrence-unit day|week|month|year] \ + [--if-version N] \ + [--json] + +taskctl issue move ID --status STATUS [--thread-id ID] [--if-version N] [--json] +taskctl issue archive ID [--thread-id ID] [--if-version N] [--json] +taskctl issue restore ID [--thread-id ID] [--if-version N] [--json] +``` + +Use `issue move` to set `in_progress` before implementation and `in_review` after implementation and self-verification. Codex must not move work directly from `in_progress` to `done`; use `done` only after the user explicitly confirms acceptance or explicitly asks to mark the issue complete. Use `blocked` when work cannot continue and `canceled` when it will not continue. On a version conflict, fetch the issue again and reconcile before retrying. + +Use either `--git-branch` or `--worktree-path`/`--worktree-branch`; an issue has only one development context. Issue JSON stores it as `developmentContext`, either `{ "type": "branch", "branch": "..." }` or `{ "type": "worktree", "path": "...", "branch": "..." }`. Its singular `threadId` is the Codex conversation that most recently created or changed the issue itself. Recurrence requires a due date. + +## Issue relations + +Read the anchor issue immediately before adding or removing a relation and use its current version. Relation writes require Codex conversation attribution like every other issue write. + +```bash +taskctl issue relation add ISSUE_ID \ + --type parent \ + --issue PARENT_ISSUE_ID \ + [--thread-id ID] \ + [--if-version N] \ + [--json] + +taskctl issue relation add ISSUE_ID \ + --type blocks|blocked_by|related \ + --issue RELATED_ISSUE_ID \ + [--thread-id ID] \ + [--if-version N] \ + [--json] + +taskctl issue relation remove ISSUE_ID \ + --type parent|blocks|blocked_by|related \ + --issue RELATED_ISSUE_ID \ + [--thread-id ID] \ + [--if-version N] \ + [--json] +``` + +For `--type parent`, `ISSUE_ID` is the child and `PARENT_ISSUE_ID` is its parent. Adding another parent replaces the child's current parent atomically. To add an existing issue as a sub-issue of `LOCAL-6`, anchor the command on the child and pass `--issue LOCAL-6`. + +For `blocks`, the anchor issue blocks the related issue. For `blocked_by`, the related issue blocks the anchor. `related` is symmetric. Self-relations, duplicates, parent cycles, and relations between different projects are rejected. + +## Issue comments + +Use the issue id to read or append comments. Comment updates and deletes require the latest comment `version` returned by `comment list`. + +```bash +taskctl comment list ISSUE_ID [--json] +taskctl comment add ISSUE_ID --body TEXT [--thread-id ID] [--json] +taskctl comment update COMMENT_ID --body TEXT --if-version N [--thread-id ID] [--json] +taskctl comment delete COMMENT_ID --if-version N [--thread-id ID] [--json] +``` + +Each comment JSON object independently records the most recent conversation that created or changed that comment as `threadId`. Comment operations never change the parent issue's `threadId`. + +## Download inline images + +Issue descriptions and comments may contain inline images at exact positions in their Markdown: + +```markdown +![alt text](/api/attachments/ATTACHMENT_ID/content) +``` + +Download an inline image to an explicit local path before inspecting it: + +```bash +taskctl attachment download ATTACHMENT_ID --output PATH [--json] +``` + +The command writes the response body as binary data and returns the absolute output path, content type, and size in its JSON result. Choose the output filename yourself; `taskctl` does not infer or append an extension. diff --git a/apps/codex-taskboard/test/actor-identity.test.mjs b/apps/codex-taskboard/test/actor-identity.test.mjs new file mode 100644 index 000000000..79ff700d1 --- /dev/null +++ b/apps/codex-taskboard/test/actor-identity.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; + +const typesSource = await readFile(new URL("../web/src/types.ts", import.meta.url), "utf8"); +const detailSource = await readFile(new URL("../web/src/components/TaskDetail.tsx", import.meta.url), "utf8"); +const avatarSource = await readFile(new URL("../web/src/components/ActorAvatar.tsx", import.meta.url), "utf8"); +const apiSource = await readFile(new URL("../web/src/api.ts", import.meta.url), "utf8"); +const appSource = await readFile(new URL("../web/src/App.tsx", import.meta.url), "utf8"); +const injectSource = await readFile(new URL("../inject/codex-taskboard.user.js", import.meta.url), "utf8"); +const styles = await readFile(new URL("../web/src/styles.css", import.meta.url), "utf8"); + +test("task and comment contracts expose persisted user or agent identities", () => { + assert.match(typesSource, /export type ActorType = "user" \| "agent"/); + assert.match(typesSource, /export interface ActorIdentity/); + assert.match(typesSource, /avatarUrl: string \| null/); + assert.match(typesSource, /creatorType: ActorType/); + assert.match(typesSource, /creatorId: string/); + assert.match(typesSource, /creatorName: string/); + assert.match(typesSource, /creatorAvatarUrl: string \| null/); + assert.match(typesSource, /authorType: ActorType/); + assert.match(typesSource, /authorId: string/); + assert.match(typesSource, /authorName: string/); + assert.match(typesSource, /authorAvatarUrl: string \| null/); +}); + +test("issue activity renders distinct avatars, IDs, and styles for users and agents", () => { + assert.match(avatarSource, /function ActorAvatar/); + assert.match(avatarSource, /actor-avatar-\$\{actor\.type\}/); + assert.match(avatarSource, /actor\.type === "agent"/); + assert.match(avatarSource, /className="actor-avatar-image actor-avatar-agent-image"/); + assert.match(avatarSource, /src="\/codex-agent-logo\.png"/); + assert.match(avatarSource, /actor\.avatarUrl/); + assert.match(detailSource, /currentTask\.creatorType/); + assert.match(detailSource, /currentTask\.creatorId/); + assert.match(detailSource, /currentTask\.creatorAvatarUrl/); + assert.match(detailSource, /comment\.authorType/); + assert.match(detailSource, /comment\.authorId/); + assert.match(detailSource, /comment\.authorAvatarUrl/); + assert.match(detailSource, /currentUser\.name/); + assert.match(detailSource, /currentUser\.id/); + assert.match(detailSource, /className="actor-id"/); + assert.match(styles, /\.actor-avatar-agent/); + assert.match(styles, /\.actor-avatar-user/); + assert.match(styles, /\.actor-avatar-image/); + assert.match( + styles, + /\.actor-avatar-agent\s*\{[^}]*overflow:\s*visible;[^}]*border:\s*0;[^}]*border-radius:\s*0;[^}]*background:\s*transparent;/s, + ); + assert.match(styles, /\.actor-avatar-agent-image\s*\{[^}]*object-fit:\s*contain;/s); + assert.doesNotMatch(styles, /\.comment-entry\.is-agent \.comment-card/); + assert.match(styles, /\.actor-id/); +}); + +test("agent avatar asset is a transparent PNG logo", async () => { + const logo = await readFile(new URL("../web/public/codex-agent-logo.png", import.meta.url)); + assert.deepEqual([...logo.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]); + assert.equal(logo[25], 6); +}); + +test("comment metadata separators never become avatar content", () => { + assert.match(detailSource, /className="comment-edited"/); + assert.doesNotMatch(styles, /\.comment-header > span(?::before)?/); + assert.match( + styles, + /\.comment-header \.comment-edited::before\s*\{[^}]*content:\s*"·"/s, + ); + assert.match( + styles, + /\.actor-avatar-image\s*\{[^}]*display:\s*block;[^}]*width:\s*100%;[^}]*height:\s*100%/s, + ); +}); + +test("Codex host identity is forwarded to user-authored taskboard mutations", () => { + assert.match(injectSource, /function readCodexUser\(\)/); + assert.match(injectSource, /cdn\.auth0\.com\/avatars/); + assert.match(injectSource, /user: readCodexUser\(\)/); + assert.match(typesSource, /user\?: ActorIdentity/); + assert.match(apiSource, /export function setCurrentUserActor/); + assert.match(apiSource, /X-Taskboard-User-Id/); + assert.match(apiSource, /X-Taskboard-User-Name/); + assert.match(apiSource, /X-Taskboard-User-Avatar/); + assert.match(appSource, /setCurrentUserActor\(payload\.user\)/); +}); diff --git a/apps/codex-taskboard/test/ai-chat-api.test.mjs b/apps/codex-taskboard/test/ai-chat-api.test.mjs new file mode 100644 index 000000000..a66cc2a4f --- /dev/null +++ b/apps/codex-taskboard/test/ai-chat-api.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + createAiChatThread, + deleteAiChatThread, + getAiChatCatalog, + getAiChatThread, + interruptAiChatRun, + listAiChatThreads, + startAiChatTurn, + subscribeAiChatThread, + updateAiChatThread, +} from "../web/src/api.ts"; + +const thread = { + id: "thread-1", + title: "LOCAL-103", + status: "idle", + origin: { + projectId: "project / one", + projectName: "Project One", + workspacePath: "/never/render/or/send", + issueId: "issue-1", + issueIdentifier: "LOCAL-103", + }, + codexThreadId: null, + model: "codex-real", + reasoningEffort: "high", + sandbox: "read-only", + createdAt: "2026-07-27T00:00:00.000Z", + updatedAt: "2026-07-27T00:00:00.000Z", + currentRun: null, +}; + +function json(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +test("local AI API client follows the fixed catalog, thread, turn and interrupt contract", async () => { + const previousFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (path, init = {}) => { + calls.push({ path, init }); + if (String(path).startsWith("/api/local/ai/catalog")) { + return json({ + models: [{ + slug: "codex-real", + displayName: "Codex Real", + description: "Local catalog", + defaultReasoningEffort: "high", + supportedReasoningEfforts: ["high"], + serviceTiers: [], + }], + skills: [{ id: "real-skill", label: "Real Skill", scope: "user" }], + sandboxes: ["read-only", "workspace-write", "danger-full-access"], + }); + } + if (path === "/api/local/ai/threads" && !init.method) return json({ threads: [thread] }); + if (path === "/api/local/ai/threads" && init.method === "POST") return json({ thread }); + if (path === "/api/local/ai/threads/thread-1" && !init.method) { + return json({ thread, events: [], runs: [] }); + } + if (path === "/api/local/ai/threads/thread-1" && init.method === "PATCH") return json({ thread }); + if (path === "/api/local/ai/threads/thread-1" && init.method === "DELETE") { + return new Response(null, { status: 204 }); + } + if (path === "/api/local/ai/threads/thread-1/turns") { + return json({ run: { id: "run-1", threadId: "thread-1", status: "running" } }, 202); + } + if (path === "/api/local/ai/runs/run-1/interrupt") { + return json({ run: { id: "run-1", threadId: "thread-1", status: "interrupted" } }); + } + throw new Error(`Unexpected request ${String(path)}`); + }; + + try { + const catalog = await getAiChatCatalog("project / one"); + assert.equal(catalog.models[0].slug, "codex-real"); + assert.equal(calls.at(-1).path, "/api/local/ai/catalog?projectId=project%20%2F%20one"); + + assert.equal((await listAiChatThreads())[0].id, "thread-1"); + await createAiChatThread({ projectId: "project / one", issueId: "issue-1" }); + assert.deepEqual(JSON.parse(calls.at(-1).init.body), { + projectId: "project / one", + issueId: "issue-1", + }); + + assert.equal((await getAiChatThread("thread-1")).thread.id, "thread-1"); + await updateAiChatThread("thread-1", { + model: "codex-real", + reasoningEffort: "high", + sandbox: "workspace-write", + }); + assert.deepEqual(JSON.parse(calls.at(-1).init.body), { + model: "codex-real", + reasoningEffort: "high", + sandbox: "workspace-write", + }); + + await deleteAiChatThread("thread-1"); + assert.equal(calls.at(-1).init.method, "DELETE"); + + await startAiChatTurn("thread-1", { + message: "公开的用户消息", + skillIds: ["real-skill"], + dangerFullAccessConfirmed: true, + }); + const turnBody = JSON.parse(calls.at(-1).init.body); + assert.deepEqual(turnBody, { + message: "公开的用户消息", + skillIds: ["real-skill"], + dangerFullAccessConfirmed: true, + }); + assert.equal("workspacePath" in turnBody, false); + assert.equal("model" in turnBody, false); + assert.equal("hiddenPrompt" in turnBody, false); + + assert.equal((await interruptAiChatRun("run-1")).status, "interrupted"); + } finally { + globalThis.fetch = previousFetch; + } +}); + +test("aborted catalog requests preserve AbortError instead of reporting a service outage", async () => { + const previousFetch = globalThis.fetch; + const abortError = new DOMException("The operation was aborted", "AbortError"); + globalThis.fetch = async () => { + throw abortError; + }; + + try { + await assert.rejects( + () => getAiChatCatalog("project-1"), + (error) => error === abortError, + ); + } finally { + globalThis.fetch = previousFetch; + } +}); + +test("thread event subscription listens only for public snapshot hints and closes cleanly", () => { + const PreviousEventSource = globalThis.EventSource; + const listeners = new Map(); + let openedUrl = ""; + let closed = false; + globalThis.EventSource = class { + constructor(url) { + openedUrl = url; + } + + addEventListener(type, listener) { + listeners.set(type, listener); + } + + close() { + closed = true; + } + }; + + try { + const hints = []; + const unsubscribe = subscribeAiChatThread("thread / 1", (type) => hints.push(type)); + assert.equal(openedUrl, "/api/local/ai/threads/thread%20%2F%201/events"); + listeners.get("ai.event")(); + listeners.get("ai.run")(); + assert.deepEqual(hints, ["ai.event", "ai.run"]); + unsubscribe(); + assert.equal(closed, true); + } finally { + globalThis.EventSource = PreviousEventSource; + } +}); diff --git a/apps/codex-taskboard/test/ai-chat-database.test.mjs b/apps/codex-taskboard/test/ai-chat-database.test.mjs new file mode 100644 index 000000000..8d27bf6b7 --- /dev/null +++ b/apps/codex-taskboard/test/ai-chat-database.test.mjs @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { TaskboardDatabase } from "../server/database.mjs"; + +async function createFixture() { + const directory = await mkdtemp(path.join(os.tmpdir(), "taskboard-ai-database-")); + const filename = path.join(directory, "taskboard.sqlite"); + const database = new TaskboardDatabase(filename); + return { + database, + directory, + filename, + async close() { + this.database.close(); + await rm(directory, { recursive: true, force: true }); + }, + }; +} + +test("AI chat persistence stores threads, runs, and visible events without hidden prompt fields", async () => { + const fixture = await createFixture(); + try { + const thread = fixture.database.createAiChatThread({ + id: "thread-1", + title: "New conversation", + status: "idle", + origin: { + projectId: "local", + projectName: "Local", + workspacePath: "/tmp/project", + issueId: "task-1", + issueIdentifier: "LOCAL-1", + }, + codexThreadId: null, + model: "gpt-real", + reasoningEffort: "high", + sandbox: "workspace-write", + }); + assert.equal(thread.origin.issueIdentifier, "LOCAL-1"); + assert.equal(thread.currentRun, null); + + const run = fixture.database.createAiChatRun({ + id: "run-1", + threadId: thread.id, + status: "running", + }); + fixture.database.insertAiChatEvent({ + id: "event-1", + threadId: thread.id, + runId: run.id, + type: "agent_message", + role: "assistant", + content: "Visible answer", + data: { status: "completed" }, + }); + fixture.database.updateAiChatThread(thread.id, { + status: "running", + codexThreadId: "codex-thread-1", + }); + + assert.equal(fixture.database.getAiChatThread(thread.id).currentRun.id, run.id); + assert.equal(fixture.database.listAiChatThreads()[0].codexThreadId, "codex-thread-1"); + assert.deepEqual(fixture.database.listAiChatEvents(thread.id).map((event) => event.content), [ + "Visible answer", + ]); + assert.equal(fixture.database.listAiChatRuns(thread.id)[0].status, "running"); + + for (const table of ["ai_chat_threads", "ai_chat_runs", "ai_chat_events"]) { + const columns = fixture.database.database.prepare(`PRAGMA table_info(${table})`).all(); + assert.equal( + columns.some((column) => /prompt|raw/i.test(column.name)), + false, + `${table} must not persist hidden prompts or raw Codex JSONL`, + ); + } + } finally { + await fixture.close(); + } +}); + +test("opening the database interrupts abandoned runs and preserves resumable Codex thread ids", async () => { + const fixture = await createFixture(); + fixture.database.createAiChatThread({ + id: "thread-1", + title: "New conversation", + status: "running", + origin: { + projectId: "local", + projectName: "Local", + workspacePath: "/tmp/project", + }, + codexThreadId: "codex-thread-1", + model: "gpt-real", + reasoningEffort: "medium", + sandbox: "read-only", + }); + fixture.database.createAiChatRun({ + id: "run-1", + threadId: "thread-1", + status: "running", + }); + fixture.database.close(); + + const reopened = new TaskboardDatabase(fixture.filename); + fixture.database = reopened; + try { + assert.equal(reopened.getAiChatRun("run-1").status, "interrupted"); + assert.equal(reopened.getAiChatRun("run-1").finishedAt === null, false); + assert.equal(reopened.getAiChatThread("thread-1").codexThreadId, "codex-thread-1"); + assert.equal(reopened.getAiChatThread("thread-1").status, "idle"); + assert.equal(reopened.getAiChatThread("thread-1").currentRun, null); + } finally { + await fixture.close(); + } +}); + +test("deleting an AI chat thread removes its runs and visible events", async () => { + const fixture = await createFixture(); + try { + fixture.database.createAiChatThread({ + id: "thread-1", + title: "New conversation", + status: "idle", + origin: { + projectId: "local", + projectName: "Local", + workspacePath: "/tmp/project", + }, + codexThreadId: null, + model: "gpt-real", + reasoningEffort: "medium", + sandbox: "read-only", + }); + fixture.database.createAiChatRun({ + id: "run-1", + threadId: "thread-1", + status: "completed", + finishedAt: new Date().toISOString(), + }); + fixture.database.insertAiChatEvent({ + id: "event-1", + threadId: "thread-1", + runId: "run-1", + type: "agent_message", + role: "assistant", + content: "Visible answer", + }); + + fixture.database.deleteAiChatThread("thread-1"); + + assert.equal(fixture.database.getAiChatThread("thread-1"), null); + assert.equal(fixture.database.getAiChatRun("run-1"), null); + assert.equal( + fixture.database.database.prepare("SELECT COUNT(*) AS count FROM ai_chat_events").get().count, + 0, + ); + } finally { + await fixture.close(); + } +}); + +test("AI chat events with the same timestamp retain SQLite insertion order", async () => { + const fixture = await createFixture(); + try { + fixture.database.createAiChatThread({ + id: "thread-1", + title: "New conversation", + origin: { + projectId: "local", + projectName: "Local", + workspacePath: "/tmp/project", + }, + model: "gpt-real", + reasoningEffort: "medium", + sandbox: "read-only", + }); + const createdAt = "2026-07-27T12:00:00.000Z"; + fixture.database.insertAiChatEvent({ + id: "z-first", + threadId: "thread-1", + type: "agent_message", + role: "assistant", + content: "first", + createdAt, + }); + fixture.database.insertAiChatEvent({ + id: "a-second", + threadId: "thread-1", + type: "agent_message", + role: "assistant", + content: "second", + createdAt, + }); + + assert.deepEqual( + fixture.database.listAiChatEvents("thread-1").map((event) => event.id), + ["z-first", "a-second"], + ); + } finally { + await fixture.close(); + } +}); diff --git a/apps/codex-taskboard/test/ai-chat-runner.test.mjs b/apps/codex-taskboard/test/ai-chat-runner.test.mjs new file mode 100644 index 000000000..5ec4e691d --- /dev/null +++ b/apps/codex-taskboard/test/ai-chat-runner.test.mjs @@ -0,0 +1,423 @@ +import assert from "node:assert/strict"; +import { chmod, mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { TaskboardDatabase } from "../server/database.mjs"; +import { AiChatService } from "../server/ai-chat.mjs"; +import { normalizeCodexEvent } from "../server/ai-chat-process.mjs"; + +async function waitFor(predicate, timeout = 4_000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const value = await predicate(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error("Timed out waiting for condition"); +} + +test("normalized item events retain a bounded public item id", () => { + const itemId = "x".repeat(70_000); + const normalized = normalizeCodexEvent({ + type: "item.updated", + item: { + id: itemId, + type: "command_execution", + command: "npm test", + status: "in_progress", + }, + }); + + assert.equal(normalized.data.itemId, itemId.slice(0, 65_536)); +}); + +async function createFixture() { + const directory = await mkdtemp(path.join(os.tmpdir(), "taskboard-ai-runner-")); + const workspacePath = path.join(directory, "workspace"); + const otherWorkspacePath = path.join(directory, "other-workspace"); + await Promise.all([mkdir(workspacePath), mkdir(otherWorkspacePath)]); + const [workspace, otherWorkspace] = await Promise.all([ + realpath(workspacePath), + realpath(otherWorkspacePath), + ]); + const capturePath = path.join(directory, "capture.jsonl"); + const descendantPath = path.join(directory, "descendant-alive"); + const executable = path.join(directory, "fake-codex.mjs"); + await writeFile(executable, `#!/usr/bin/env node +import { appendFileSync } from "node:fs"; +import { spawn } from "node:child_process"; +const args = process.argv.slice(2); +if (args[0] === "debug" && args[1] === "models") { + if (args.length !== 2) process.exit(2); + process.stdout.write(JSON.stringify({models:[{ + slug:"gpt-real", display_name:"GPT Real", description:"Real fixture", + default_reasoning_level:"medium", + supported_reasoning_levels:[{effort:"low"},{effort:"medium"},{effort:"high"}], + service_tiers:[{id:"priority",name:"Fast",description:"fixture"}] + }]})); + process.exit(0); +} +if (args[0] === "app-server") { + process.stdin.setEncoding("utf8"); + let buffer = ""; + process.stdin.on("data", (chunk) => { + buffer += chunk; + let index; + while ((index = buffer.indexOf("\\n")) >= 0) { + const line = buffer.slice(0, index); buffer = buffer.slice(index + 1); + if (!line.trim()) continue; + const message = JSON.parse(line); + if (message.id === 1) process.stdout.write('{"id":1,"result":{"platformFamily":"unix"}}\\n'); + if (message.id === 2) process.stdout.write('{"id":2,"result":{"data":[{"skills":[{"name":"real-skill","enabled":true,"scope":"repo","interface":{"displayName":"Real Skill"}},{"name":"disabled","enabled":false,"scope":"user"}]}]}}\\n'); + } + }); +} else if (args[0] === "exec") { + process.stdin.setEncoding("utf8"); + let prompt = ""; + process.stdin.on("data", (chunk) => { prompt += chunk; }); + process.stdin.on("end", () => { + appendFileSync(process.env.FAKE_CAPTURE_PATH, JSON.stringify({args,prompt}) + "\\n"); + const emit = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); + if (!args.includes("resume")) emit({type:"thread.started",thread_id:"codex-thread-1"}); + emit({type:"turn.started"}); + if (prompt.includes("MALFORMED_STUBBORN") || prompt.includes("CALLBACK_FATAL_STUBBORN")) { + spawn(process.execPath, [ + "-e", + 'process.on("SIGTERM", () => {}); setTimeout(() => require("node:fs").writeFileSync(process.env.FAKE_DESCENDANT_PATH, "alive"), 300); setInterval(() => {}, 1000)', + ], {env:process.env,stdio:"ignore"}); + process.on("SIGTERM", () => {}); + setInterval(() => {}, 1000); + if (prompt.includes("CALLBACK_FATAL_STUBBORN")) { + emit({type:"thread.started",thread_id:"unexpected-thread"}); + } else { + process.stdout.write("{not-json}\\n"); + } + return; + } + if (prompt.includes("MALFORMED")) { + process.stdout.write("{not-json}\\n"); + return; + } + emit({type:"item.completed",item:{type:"reasoning",text:"SECRET REASONING"}}); + emit({type:"item.completed",item:{type:"agent_message",text:"Visible answer"}}); + emit({type:"item.completed",item:{type:"command_execution",command:"npm test",status:"completed",exit_code:0,aggregated_output:"ok"}}); + if (prompt.includes("TURN_FAILED_ZERO")) { + emit({type:"turn.failed",error:{message:"Protocol turn failed"}}); + return; + } + if (prompt.includes("ROOT_ERROR_ZERO")) { + emit({type:"error",message:"Protocol root error"}); + return; + } + if (prompt.includes("NO_TERMINAL")) return; + if (prompt.includes("ITEM_ERROR")) { + emit({type:"item.completed",item:{id:"item-error-1",type:"error",message:"Recoverable item error"}}); + } + if (prompt.includes("WAIT")) { + const timer = setTimeout(() => { emit({type:"turn.completed",usage:{input_tokens:1,output_tokens:2}}); }, 800); + process.on("SIGTERM", () => { clearTimeout(timer); process.exit(143); }); + return; + } + if (prompt.includes("FAIL")) process.exit(7); + emit({type:"turn.completed",usage:{input_tokens:1,output_tokens:2}}); + }); +} +`); + await chmod(executable, 0o755); + + const codexStatePath = path.join(directory, "codex-state.json"); + await writeFile(codexStatePath, JSON.stringify({ + "local-projects": { + project: { rootPaths: [workspace] }, + other: { rootPaths: [otherWorkspace] }, + }, + })); + const databasePath = path.join(directory, "taskboard.sqlite"); + const database = new TaskboardDatabase(databasePath); + database.createProject({ id: "project", name: "Project", workspacePath: null }); + database.createProject({ id: "other", name: "Other", workspacePath: null }); + const service = new AiChatService({ + database, + codexExecutable: executable, + codexStatePath, + manageTaskboardSkillPath: "/fixture/manage-taskboard/SKILL.md", + processEnv: { + ...process.env, + FAKE_CAPTURE_PATH: capturePath, + FAKE_DESCENDANT_PATH: descendantPath, + }, + killGraceMs: 50, + }); + return { + capturePath, + database, + databasePath, + descendantPath, + directory, + otherWorkspace, + service, + workspace, + async close() { + await this.service.close(); + this.database.close(); + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + await rm(directory, { recursive: true, force: true }); + return; + } catch (error) { + if (!["EBUSY", "EPERM"].includes(error?.code) || attempt === 19) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + }, + }; +} + +test("Codex turns use stdin, explicit resume ids, server-owned cwd and sanitized visible events", async () => { + const fixture = await createFixture(); + try { + const catalog = await fixture.service.getCatalog("project"); + assert.deepEqual(catalog.models, [{ + slug: "gpt-real", + displayName: "GPT Real", + description: "Real fixture", + defaultReasoningEffort: "medium", + supportedReasoningEfforts: ["low", "medium", "high"], + serviceTiers: [{ id: "priority", name: "Fast" }], + }]); + assert.deepEqual(catalog.skills, [{ + id: "real-skill", + label: "Real Skill", + description: "", + path: "", + scope: "repo", + }]); + + const thread = await fixture.service.createThread({ + projectId: "project", + model: "gpt-real", + reasoningEffort: "high", + sandbox: "workspace-write", + }); + assert.equal(thread.origin.workspacePath, fixture.workspace); + + const first = await fixture.service.startTurn(thread.id, { + message: "HIDDEN_SENTINEL \uFFFC first", + skillIds: ["real-skill"], + }); + await waitFor(() => fixture.service.getRun(first.id)?.status !== "running"); + const second = await fixture.service.startTurn(thread.id, { message: "second" }); + await waitFor(() => fixture.service.getRun(second.id)?.status !== "running"); + + const captures = (await readFile(fixture.capturePath, "utf8")).trim().split("\n").map(JSON.parse); + assert.deepEqual(captures[0].args, [ + "exec", "--json", "--color", "never", + "-C", fixture.workspace, + "-s", "workspace-write", + "-c", 'approval_policy="on-request"', + "-c", 'approvals_reviewer="auto_review"', + "--add-dir", fixture.otherWorkspace, + "-m", "gpt-real", + "-c", 'model_reasoning_effort="high"', + "-", + ]); + assert.equal(captures[0].args.join(" ").includes("HIDDEN_SENTINEL"), false); + assert.match(captures[0].prompt, /\[\$manage-taskboard\]\(\/fixture\/manage-taskboard\/SKILL\.md\) e-taskboard/); + assert.match(captures[0].prompt, /\$real-skill/); + assert.match(captures[0].prompt, /HIDDEN_SENTINEL \[\$real-skill\]\(\) first/); + assert.deepEqual(captures[1].args, [ + "exec", "--json", "--color", "never", + "-C", fixture.workspace, + "-s", "workspace-write", + "-c", 'approval_policy="on-request"', + "-c", 'approvals_reviewer="auto_review"', + "--add-dir", fixture.otherWorkspace, + "-m", "gpt-real", + "-c", 'model_reasoning_effort="high"', + "resume", "codex-thread-1", "-", + ]); + assert.equal(captures[1].args.includes("--last"), false); + + const snapshot = fixture.service.getThreadSnapshot(thread.id); + assert.equal(snapshot.thread.codexThreadId, "codex-thread-1"); + assert.equal(snapshot.events.some((event) => event.content?.includes("SECRET REASONING")), false); + assert.equal(snapshot.events.some((event) => event.content === "Visible answer"), true); + assert.equal(snapshot.events.some((event) => event.type === "command_execution"), true); + const serialized = JSON.stringify(snapshot); + assert.equal(serialized.includes("HIDDEN_SENTINEL"), true); + assert.equal(serialized.includes(""), false); + const persisted = JSON.stringify( + fixture.database.database.prepare("SELECT * FROM ai_chat_events").all(), + ); + assert.equal(persisted.includes(""), false); + assert.equal(persisted.includes("SECRET REASONING"), false); + } finally { + await fixture.close(); + } +}); + +test("same-thread turns are locked, different threads run concurrently, failures and interrupts settle", async () => { + const fixture = await createFixture(); + try { + const firstThread = await fixture.service.createThread({ projectId: "project" }); + const secondThread = await fixture.service.createThread({ projectId: "other" }); + const waiting = await fixture.service.startTurn(firstThread.id, { message: "WAIT" }); + await assert.rejects( + fixture.service.startTurn(firstThread.id, { message: "must reject" }), + (error) => error.code === "THREAD_BUSY", + ); + const parallel = await fixture.service.startTurn(secondThread.id, { message: "normal" }); + await waitFor(() => fixture.service.getRun(parallel.id)?.status === "completed"); + const interrupted = await fixture.service.interrupt(waiting.id); + assert.equal(interrupted.id, waiting.id); + await waitFor(() => fixture.service.getRun(waiting.id)?.status === "interrupted"); + + const failed = await fixture.service.startTurn(firstThread.id, { message: "FAIL" }); + await waitFor(() => fixture.service.getRun(failed.id)?.status === "failed"); + assert.equal(fixture.service.getRun(failed.id).exitCode, 7); + assert.equal( + fixture.service.getThreadSnapshot(firstThread.id).events.some( + (event) => event.role === "error" && event.content.includes("code 7"), + ), + true, + ); + } finally { + await fixture.close(); + } +}); + +test("malformed Codex JSONL fails the run", async () => { + const fixture = await createFixture(); + try { + const thread = await fixture.service.createThread({ projectId: "project" }); + const run = await fixture.service.startTurn(thread.id, { message: "MALFORMED" }); + await waitFor(() => fixture.service.getRun(run.id)?.status === "failed"); + + const failed = fixture.service.getRun(run.id); + assert.equal(failed.error, "Codex emitted malformed JSONL"); + assert.equal( + fixture.service.getThreadSnapshot(thread.id).events.some( + (event) => event.role === "error" + && event.content === "Codex emitted malformed JSONL", + ), + true, + ); + } finally { + await fixture.close(); + } +}); + +test("parser and event callback failures kill a SIGTERM-resistant process group", async () => { + const fixture = await createFixture(); + try { + for (const [message, expectedError] of [ + ["MALFORMED_STUBBORN", "Codex emitted malformed JSONL"], + ["CALLBACK_FATAL_STUBBORN", "Codex returned an unexpected thread id"], + ]) { + await rm(fixture.descendantPath, { force: true }); + const thread = await fixture.service.createThread({ projectId: "project" }); + const run = await fixture.service.startTurn(thread.id, { message }); + await waitFor(() => fixture.service.getRun(run.id).status === "failed", 700); + assert.equal(fixture.service.getRun(run.id).error, expectedError); + await new Promise((resolve) => setTimeout(resolve, 350)); + await assert.rejects(readFile(fixture.descendantPath), (error) => error.code === "ENOENT"); + } + } finally { + await fixture.close(); + } +}); + +test("protocol terminal events determine run success and item errors remain non-fatal", async () => { + const fixture = await createFixture(); + try { + for (const [message, expectedStatus] of [ + ["TURN_FAILED_ZERO", "failed"], + ["ROOT_ERROR_ZERO", "failed"], + ["NO_TERMINAL", "failed"], + ["ITEM_ERROR", "completed"], + ]) { + const thread = await fixture.service.createThread({ projectId: "project" }); + const run = await fixture.service.startTurn(thread.id, { message }); + await waitFor(() => fixture.service.getRun(run.id).status !== "running"); + assert.equal(fixture.service.getRun(run.id).status, expectedStatus, message); + } + } finally { + await fixture.close(); + } +}); + +test("startTurn revalidates the latest danger sandbox and persisted model settings", async () => { + const fixture = await createFixture(); + try { + for (const scenario of [ + { + changes: { sandbox: "danger-full-access" }, + expectedCode: "DANGER_CONFIRMATION_REQUIRED", + }, + { + changes: { model: "retired-model" }, + expectedCode: "INVALID_MODEL", + }, + { + changes: { reasoningEffort: "ultra" }, + expectedCode: "INVALID_REASONING_EFFORT", + }, + ]) { + const thread = await fixture.service.createThread({ projectId: "project" }); + const originalGetCatalog = fixture.service.getCatalog.bind(fixture.service); + let releaseCatalog; + let catalogRequested = false; + const catalogGate = new Promise((resolve) => { + releaseCatalog = resolve; + }); + fixture.service.getCatalog = async (...args) => { + catalogRequested = true; + await catalogGate; + return originalGetCatalog(...args); + }; + + const pending = fixture.service.startTurn(thread.id, { message: "must not spawn" }); + await waitFor(() => catalogRequested); + fixture.database.updateAiChatThread(thread.id, scenario.changes); + releaseCatalog(); + await assert.rejects(pending, (error) => error.code === scenario.expectedCode); + assert.equal(fixture.database.listAiChatRuns(thread.id).length, 0); + fixture.service.getCatalog = originalGetCatalog; + } + } finally { + await fixture.close(); + } +}); + +test("startup marks abandoned runs interrupted while preserving the Codex thread id", async () => { + const fixture = await createFixture(); + const thread = await fixture.service.createThread({ projectId: "project" }); + fixture.database.database.prepare( + "UPDATE ai_chat_threads SET codex_thread_id = ?, status = 'running' WHERE id = ?", + ).run("preserved-session", thread.id); + fixture.database.database.prepare(` + INSERT INTO ai_chat_runs ( + id, thread_id, status, exit_code, error, started_at, finished_at + ) VALUES ('abandoned', ?, 'running', NULL, NULL, ?, NULL) + `).run(thread.id, new Date().toISOString()); + await fixture.service.close(); + fixture.database.close(); + fixture.database = new TaskboardDatabase(fixture.databasePath); + const restarted = new AiChatService({ + database: fixture.database, + codexExecutable: path.join(fixture.directory, "fake-codex.mjs"), + codexStatePath: path.join(fixture.directory, "codex-state.json"), + manageTaskboardSkillPath: "/fixture/manage-taskboard/SKILL.md", + }); + fixture.service = restarted; + try { + assert.equal(restarted.getRun("abandoned").status, "interrupted"); + assert.equal(restarted.getThread(thread.id).codexThreadId, "preserved-session"); + } finally { + await fixture.close(); + } +}); diff --git a/apps/codex-taskboard/test/ai-chat-server.test.mjs b/apps/codex-taskboard/test/ai-chat-server.test.mjs new file mode 100644 index 000000000..e140a82ec --- /dev/null +++ b/apps/codex-taskboard/test/ai-chat-server.test.mjs @@ -0,0 +1,342 @@ +import assert from "node:assert/strict"; +import { chmod, mkdtemp, mkdir, realpath, rm, writeFile } from "node:fs/promises"; +import { request as httpRequest } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { createTaskboardServer } from "../server/index.mjs"; + +const LAN_SHARED_SECRET = "test-taskboard-secret"; + +async function createServerFixture(host = "127.0.0.1") { + const directory = await mkdtemp(path.join(os.tmpdir(), "taskboard-ai-server-")); + const workspacePath = path.join(directory, "workspace"); + await mkdir(workspacePath); + const workspace = await realpath(workspacePath); + const codexExecutable = path.join(directory, "fake-codex.mjs"); + await writeFile(codexExecutable, `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "debug") { + process.stdout.write('{"models":[{"slug":"gpt-real","display_name":"GPT Real","description":"","default_reasoning_level":"low","supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}],"service_tiers":[]}]}'); +} else if (args[0] === "app-server") { + process.stdin.setEncoding("utf8"); let buffer=""; + process.stdin.on("data", chunk => { buffer += chunk; let i; + while ((i=buffer.indexOf("\\n"))>=0) { const line=buffer.slice(0,i); buffer=buffer.slice(i+1); + if (!line.trim()) continue; const message=JSON.parse(line); + if (message.id===1) process.stdout.write('{"id":1,"result":{}}\\n'); + if (message.id===2) process.stdout.write('{"id":2,"result":{"data":[{"skills":[{"name":"real-skill","enabled":true,"scope":"repo","interface":null}]}]}}\\n'); + } + }); +} else { + process.stdin.resume(); + process.stdin.on("end", () => { + process.stdout.write('{"type":"thread.started","thread_id":"session-1"}\\n'); + process.stdout.write('{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}\\n'); + process.stdout.write('{"type":"turn.completed"}\\n'); + }); +} +`); + await chmod(codexExecutable, 0o755); + const codexStatePath = path.join(directory, "codex-state.json"); + await writeFile(codexStatePath, JSON.stringify({ + "local-projects": { local: { rootPaths: [workspace] } }, + })); + const app = createTaskboardServer({ + dataDirectory: directory, + ...(host === "0.0.0.0" ? { sharedSecret: LAN_SHARED_SECRET } : {}), + codexExecutable, + codexStatePath, + skillPath: "/fixture/manage-taskboard/SKILL.md", + }); + const address = await app.listen({ host, port: 0 }); + return { + app, + baseUrl: `http://127.0.0.1:${address.port}`, + directory, + workspace, + async close() { + await app.close(); + await rm(directory, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 50, + }); + }, + }; +} + +function privateLanAddress() { + return Object.values(os.networkInterfaces()) + .flat() + .find((entry) => { + if (entry?.family !== "IPv4" || entry.internal) return false; + const [first, second] = entry.address.split(".").map(Number); + return first === 10 + || (first === 172 && second >= 16 && second <= 31) + || (first === 192 && second === 168) + || (first === 169 && second === 254); + })?.address; +} + +async function requestFrom(address, port, pathname) { + return new Promise((resolve, reject) => { + const outgoing = httpRequest({ + host: address, + port, + path: pathname, + headers: { + authorization: `Basic ${Buffer.from(`codex:${LAN_SHARED_SECRET}`).toString("base64")}`, + host: `${address}:${port}`, + }, + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => resolve({ + status: response.statusCode, + body: JSON.parse(Buffer.concat(chunks).toString("utf8")), + })); + }); + outgoing.on("error", reject); + outgoing.end(); + }); +} + +async function request(baseUrl, pathname, options = {}) { + const response = await fetch(`${baseUrl}${pathname}`, { + ...options, + headers: { "content-type": "application/json", ...options.headers }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + const text = await response.text(); + return { response, body: text ? JSON.parse(text) : undefined }; +} + +test("loopback AI API freezes server-owned origin and rejects injected execution fields", async () => { + const fixture = await createServerFixture(); + try { + const meta = await request(fixture.baseUrl, "/api/meta"); + assert.equal(meta.body.capabilities.localAiChat, true); + const catalog = await request(fixture.baseUrl, "/api/local/ai/catalog?projectId=local"); + assert.equal(catalog.response.status, 200); + assert.equal(catalog.body.models[0].slug, "gpt-real"); + assert.equal(catalog.body.skills[0].id, "real-skill"); + + const injected = await request(fixture.baseUrl, "/api/local/ai/threads", { + method: "POST", + body: { projectId: "local", workspacePath: "/tmp/evil", argv: ["--dangerously-bypass-approvals-and-sandbox"] }, + }); + assert.equal(injected.response.status, 400); + assert.equal(injected.body.error.code, "UNKNOWN_FIELD"); + + const created = await request(fixture.baseUrl, "/api/local/ai/threads", { + method: "POST", + body: { + projectId: "local", + model: "gpt-real", + reasoningEffort: "high", + sandbox: "read-only", + }, + }); + assert.equal(created.response.status, 201); + assert.equal(created.body.thread.origin.workspacePath, fixture.workspace); + const threadId = created.body.thread.id; + + const invalidSkill = await request(fixture.baseUrl, `/api/local/ai/threads/${threadId}/turns`, { + method: "POST", + body: { message: "hello\uFFFC", skillIds: ["invented-skill"] }, + }); + assert.equal(invalidSkill.response.status, 400); + assert.equal(invalidSkill.body.error.code, "INVALID_SKILL"); + + const turn = await request(fixture.baseUrl, `/api/local/ai/threads/${threadId}/turns`, { + method: "POST", + body: { message: "hello\uFFFC", skillIds: ["real-skill"] }, + }); + assert.equal(turn.response.status, 202); + assert.equal(turn.body.run.threadId, threadId); + + let snapshot; + for (let index = 0; index < 100; index += 1) { + snapshot = await request(fixture.baseUrl, `/api/local/ai/threads/${threadId}`); + if (snapshot.body.runs[0]?.status !== "running") break; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.equal(snapshot.body.thread.codexThreadId, "session-1"); + assert.equal(snapshot.body.events.some((event) => event.content === "ok"), true); + } finally { + await fixture.close(); + } +}); + +test("danger-full-access requires confirmation on every turn and thread settings are validated", async () => { + const fixture = await createServerFixture(); + try { + const created = await request(fixture.baseUrl, "/api/local/ai/threads", { + method: "POST", + body: { + projectId: "local", + model: "gpt-real", + reasoningEffort: "low", + sandbox: "danger-full-access", + }, + }); + assert.equal(created.response.status, 201); + const threadId = created.body.thread.id; + const denied = await request(fixture.baseUrl, `/api/local/ai/threads/${threadId}/turns`, { + method: "POST", + body: { message: "hello" }, + }); + assert.equal(denied.response.status, 400); + assert.equal(denied.body.error.code, "DANGER_CONFIRMATION_REQUIRED"); + const allowed = await request(fixture.baseUrl, `/api/local/ai/threads/${threadId}/turns`, { + method: "POST", + body: { message: "hello", dangerFullAccessConfirmed: true }, + }); + assert.equal(allowed.response.status, 202); + + const invalidModel = await request(fixture.baseUrl, `/api/local/ai/threads/${threadId}`, { + method: "PATCH", + body: { model: "invented-model", reasoningEffort: "high" }, + }); + assert.equal(invalidModel.response.status, 400); + assert.equal(invalidModel.body.error.code, "INVALID_MODEL"); + } finally { + await fixture.close(); + } +}); + +test("thread management, interrupt and query contracts stay narrow", async () => { + const fixture = await createServerFixture(); + try { + const created = await request(fixture.baseUrl, "/api/local/ai/threads", { + method: "POST", + body: { projectId: "local", title: "Original" }, + }); + const threadId = created.body.thread.id; + + const list = await request(fixture.baseUrl, "/api/local/ai/threads"); + assert.equal(list.response.status, 200); + assert.equal(list.body.threads.some((thread) => thread.id === threadId), true); + + const unknownQuery = await request(fixture.baseUrl, "/api/local/ai/threads?projectId=local"); + assert.equal(unknownQuery.response.status, 400); + assert.equal(unknownQuery.body.error.code, "UNKNOWN_QUERY_PARAMETER"); + + const updated = await request(fixture.baseUrl, `/api/local/ai/threads/${threadId}`, { + method: "PATCH", + body: { title: "Renamed", sandbox: "workspace-write" }, + }); + assert.equal(updated.response.status, 200); + assert.equal(updated.body.thread.title, "Renamed"); + + const interruptedMissing = await request(fixture.baseUrl, "/api/local/ai/runs/missing/interrupt", { + method: "POST", + }); + assert.equal(interruptedMissing.response.status, 404); + + const removed = await request(fixture.baseUrl, `/api/local/ai/threads/${threadId}`, { + method: "DELETE", + }); + assert.equal(removed.response.status, 204); + const missing = await request(fixture.baseUrl, `/api/local/ai/threads/${threadId}`); + assert.equal(missing.response.status, 404); + } finally { + await fixture.close(); + } +}); + +test("local AI routes reject private-LAN clients while ordinary API routes remain available", async (context) => { + const address = privateLanAddress(); + if (!address) { + context.skip("No private LAN interface is available"); + return; + } + const fixture = await createServerFixture("0.0.0.0"); + const port = fixture.app.server.address().port; + try { + const projects = await requestFrom(address, port, "/api/projects"); + assert.equal(projects.status, 200); + const metadata = await requestFrom(address, port, "/api/meta"); + assert.equal(metadata.status, 200); + assert.equal(metadata.body.capabilities.localAiChat, false); + const ai = await requestFrom(address, port, "/api/local/ai/threads"); + assert.equal(ai.status, 403); + assert.equal(ai.body.error.code, "LOCAL_AI_LOOPBACK_REQUIRED"); + } finally { + await fixture.close(); + } +}); + +test("AI SSE is live-only and thread snapshots remain the durable source", async () => { + const fixture = await createServerFixture(); + try { + const created = await request(fixture.baseUrl, "/api/local/ai/threads", { + method: "POST", + body: { projectId: "local" }, + }); + const threadId = created.body.thread.id; + const controller = new AbortController(); + const response = await fetch(`${fixture.baseUrl}/api/local/ai/threads/${threadId}/events`, { + signal: controller.signal, + }); + assert.equal(response.status, 200); + const reader = response.body.getReader(); + let connected = ""; + while (!connected.includes("event: ai.event")) { + const chunk = await reader.read(); + assert.equal(chunk.done, false); + connected += new TextDecoder().decode(chunk.value); + } + assert.match(connected, /connected/); + const turn = await request(fixture.baseUrl, `/api/local/ai/threads/${threadId}/turns`, { + method: "POST", + body: { message: "hello" }, + }); + assert.equal(turn.response.status, 202); + let streamed = ""; + while (!streamed.includes("ai.event")) { + const chunk = await reader.read(); + assert.equal(chunk.done, false); + streamed += new TextDecoder().decode(chunk.value); + } + assert.match(streamed, /event: ai\.(event|run)/); + controller.abort(); + } finally { + await fixture.close(); + } +}); + +test("server close stops accepting requests before AI shutdown completes", async () => { + const fixture = await createServerFixture(); + let appClosed = false; + try { + let releaseAiClose; + const aiCloseGate = new Promise((resolve) => { + releaseAiClose = resolve; + }); + fixture.app.aiChat.close = () => aiCloseGate; + + const closing = fixture.app.close(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const acceptedDuringClose = await fetch(`${fixture.baseUrl}/health`) + .then(() => true, () => false); + releaseAiClose(); + await closing; + appClosed = true; + + assert.equal(acceptedDuringClose, false); + } finally { + if (appClosed) { + await rm(fixture.directory, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 50, + }); + } else { + await fixture.close(); + } + } +}); diff --git a/apps/codex-taskboard/test/ai-chat-state.test.mjs b/apps/codex-taskboard/test/ai-chat-state.test.mjs new file mode 100644 index 000000000..26a455378 --- /dev/null +++ b/apps/codex-taskboard/test/ai-chat-state.test.mjs @@ -0,0 +1,282 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + aiChatEventStatus, + buildThreadCreateInput, + buildTurnInput, + chatPrimaryAction, + createAiSnapshotRefreshQueue, + filterVisibleAiEvents, + insertSkillMention, + isAiChatCapabilityAvailable, + needsDangerConfirmation, + normalizeChatSelection, + patchAiChatSnapshot, + readSkillMention, + routeChatState, + settingsForNewAiThread, + shouldRefreshAiSnapshot, +} from "../web/src/aiChatState.ts"; + +const models = [ + { + slug: "codex-real-model", + displayName: "Codex Real Model", + description: "Host model", + defaultReasoningEffort: "high", + supportedReasoningEfforts: ["medium", "high"], + serviceTiers: [{ id: "priority", name: "Priority" }], + }, + { + slug: "codex-fast-model", + displayName: "Codex Fast Model", + description: "Fast host model", + defaultReasoningEffort: "low", + supportedReasoningEfforts: ["low"], + serviceTiers: [], + }, +]; + +test("AI chat is exposed only when the local capability is explicit", () => { + assert.equal(isAiChatCapabilityAvailable({ localAiChat: true }), true); + assert.equal(isAiChatCapabilityAvailable({ localAiChat: false }), false); + assert.equal(isAiChatCapabilityAvailable(undefined), false); +}); + +test("new threads freeze the current project and optional issue as server identifiers", () => { + assert.deepEqual(buildThreadCreateInput("project-1", "issue-1"), { + projectId: "project-1", + issueId: "issue-1", + }); + assert.deepEqual(buildThreadCreateInput("project-1", null), { + projectId: "project-1", + }); + assert.equal(buildThreadCreateInput("", null), null); +}); + +test("route changes update only the next origin and preserve the selected global thread", () => { + assert.deepEqual( + routeChatState( + { selectedThreadId: "thread-a", pendingProjectId: "project-a", pendingIssueId: "issue-a" }, + "project-b", + "issue-b", + ), + { + selectedThreadId: "thread-a", + pendingProjectId: "project-b", + pendingIssueId: "issue-b", + }, + ); +}); + +test("new-thread settings are reused only when they belong to the current project catalog", () => { + const settings = { + model: "codex-real-model", + reasoningEffort: "high", + sandbox: "workspace-write", + }; + assert.deepEqual(settingsForNewAiThread("project-a", "project-a", settings), settings); + assert.deepEqual(settingsForNewAiThread("project-b", "project-a", settings), {}); + assert.deepEqual(settingsForNewAiThread("project-b", null, settings), {}); +}); + +test("PATCH results can update only the snapshot for the thread that started the request", () => { + const threadA = { + id: "thread-a", + title: "A", + status: "idle", + origin: { projectId: "project-a" }, + }; + const threadB = { + id: "thread-b", + title: "B", + status: "idle", + origin: { projectId: "project-b" }, + }; + const current = { thread: threadB, events: [], runs: [] }; + + assert.equal(patchAiChatSnapshot(current, "thread-a", threadA), current); + assert.deepEqual(patchAiChatSnapshot(current, "thread-b", { + ...threadB, + title: "B updated", + }), { + ...current, + thread: { ...threadB, title: "B updated" }, + }); +}); + +test("model and effort selections are restricted to the real catalog", () => { + assert.deepEqual(normalizeChatSelection(models, "codex-real-model", "medium"), { + model: "codex-real-model", + reasoningEffort: "medium", + }); + assert.deepEqual(normalizeChatSelection(models, "codex-real-model", "fake-effort"), { + model: "codex-real-model", + reasoningEffort: "high", + }); + assert.deepEqual(normalizeChatSelection(models, "missing-model", "high"), { + model: "codex-real-model", + reasoningEffort: "high", + }); + assert.equal(normalizeChatSelection([], "missing-model", "high"), null); +}); + +test("@ skill mentions keep a visible label while sending only the selected real id", () => { + assert.deepEqual(readSkillMention("请用 @cl", 6), { + start: 3, + end: 6, + query: "cl", + }); + assert.deepEqual(insertSkillMention("请用 @cl 检查", 3, 6, { + id: "cloudflare", + label: "Cloudflare", + scope: "user", + }), { + value: "请用 @Cloudflare 检查", + caret: 14, + skillId: "cloudflare", + }); +}); + +test("turn input cannot contain cwd, hidden context, model overrides or arbitrary args", () => { + const input = buildTurnInput("检查 LOCAL-103", ["cloudflare"], false); + assert.deepEqual(input, { + message: "检查 LOCAL-103", + skillIds: ["cloudflare"], + }); + assert.equal(JSON.stringify(input).includes("workspacePath"), false); + assert.equal(JSON.stringify(input).includes("manage-taskboard"), false); + assert.equal(JSON.stringify(input).includes("model"), false); + assert.deepEqual(buildTurnInput("执行", [], true), { + message: "执行", + dangerFullAccessConfirmed: true, + }); +}); + +test("runtime controls distinguish send, stop, danger confirmation and SSE refresh hints", () => { + assert.equal(chatPrimaryAction("running", "hello"), "stop"); + assert.equal(chatPrimaryAction("idle", "hello"), "send"); + assert.equal(chatPrimaryAction("idle", " "), "disabled"); + assert.equal(chatPrimaryAction("idle", "hello", true), "disabled"); + assert.equal(chatPrimaryAction("running", "hello", true), "disabled"); + assert.equal(needsDangerConfirmation("danger-full-access", false), true); + assert.equal(needsDangerConfirmation("danger-full-access", true), false); + assert.equal(needsDangerConfirmation("workspace-write", false), false); + assert.equal(shouldRefreshAiSnapshot("ai.event"), true); + assert.equal(shouldRefreshAiSnapshot("ai.run"), true); + assert.equal(shouldRefreshAiSnapshot("unrelated"), false); +}); + +test("visible activity keeps only the latest lifecycle item without merging messages", () => { + const events = filterVisibleAiEvents([ + { + id: "1", + type: "agent_message", + role: "assistant", + content: "公开回复一", + data: { itemId: "shared-message" }, + }, + { + id: "2", + type: "agent_message", + role: "assistant", + content: "公开回复二", + data: { itemId: "shared-message" }, + }, + { + id: "3", + type: "command_execution", + role: "activity", + content: "npm test", + data: { itemId: "command-1", status: "started" }, + }, + { + id: "4", + type: "command_execution", + role: "activity", + content: "npm test", + data: { itemId: "command-1", status: "in_progress" }, + }, + { + id: "5", + type: "command_execution", + role: "activity", + content: "npm test", + data: { itemId: "command-1", status: "completed" }, + }, + { id: "6", type: "todo_list", role: "activity", content: "完成测试" }, + { id: "7", type: "turn.failed", role: "error", content: "执行失败" }, + { id: "8", type: "user_message", role: "user", content: "第一条", data: { itemId: "user-1" } }, + { id: "9", type: "user_message", role: "user", content: "第二条", data: { itemId: "user-1" } }, + { id: "10", type: "reasoning", role: "activity", content: "private chain of thought" }, + { id: "11", type: "raw_jsonl", role: "activity", content: "{\"secret\":true}" }, + ]); + assert.deepEqual(events.map((event) => event.id), ["1", "2", "5", "6", "7", "8", "9"]); +}); + +test("activity status treats started, running and in_progress as active and failures as failed", () => { + assert.equal(aiChatEventStatus({ + id: "1", + type: "command_execution", + role: "activity", + content: "", + data: { status: "started" }, + }), "running"); + assert.equal(aiChatEventStatus({ + id: "2", + type: "todo_list", + role: "activity", + content: "", + data: { status: "in_progress" }, + }), "running"); + assert.equal(aiChatEventStatus({ + id: "3", + type: "turn.failed", + role: "error", + content: "failed", + }), "failed"); + assert.equal(aiChatEventStatus({ + id: "4", + type: "file_change", + role: "activity", + content: "", + data: { status: "completed" }, + }), "completed"); +}); + +test("snapshot hint refreshes allow one in-flight request and one queued request", async () => { + const calls = []; + const releases = []; + const queue = createAiSnapshotRefreshQueue(async (threadId) => { + calls.push(threadId); + await new Promise((resolve) => releases.push(resolve)); + }); + + const first = queue.request("thread-1"); + const queued = [ + queue.request("thread-1"), + queue.request("thread-1"), + queue.request("thread-1"), + ]; + assert.deepEqual(calls, ["thread-1"]); + + releases.shift()(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls, ["thread-1", "thread-1"]); + + releases.shift()(); + await Promise.all([first, ...queued]); + assert.deepEqual(calls, ["thread-1", "thread-1"]); + queue.clear(); +}); + +test("reasoning and raw JSONL events are excluded from the visible timeline", () => { + const events = filterVisibleAiEvents([ + { id: "1", type: "agent_message", role: "assistant", content: "公开回复" }, + { id: "2", type: "reasoning", role: "activity", content: "private chain of thought" }, + { id: "3", type: "raw_jsonl", role: "activity", content: "{\"secret\":true}" }, + { id: "4", type: "command", role: "activity", content: "npm test" }, + ]); + assert.deepEqual(events.map((event) => event.id), ["1", "4"]); +}); diff --git a/apps/codex-taskboard/test/ai-chat-ui.test.mjs b/apps/codex-taskboard/test/ai-chat-ui.test.mjs new file mode 100644 index 000000000..809013d65 --- /dev/null +++ b/apps/codex-taskboard/test/ai-chat-ui.test.mjs @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; + +import { + buildThreadCreateInput, + buildTurnInput, + chatPrimaryAction, + filterVisibleAiEvents, + insertSkillMention, + isAiChatCapabilityAvailable, + needsDangerConfirmation, + normalizeChatSelection, + routeChatState, + shouldRefreshAiSnapshot, +} from "../web/src/aiChatState.ts"; + +const appSource = await readFile(new URL("../web/src/App.tsx", import.meta.url), "utf8"); +const chatSource = await readFile( + new URL("../web/src/components/AiChat.tsx", import.meta.url), + "utf8", +); +const apiSource = await readFile(new URL("../web/src/api.ts", import.meta.url), "utf8"); +const styles = await readFile(new URL("../web/src/styles.css", import.meta.url), "utf8"); + +const models = [ + { + slug: "codex-real-model", + displayName: "Codex Real Model", + description: "Host model", + defaultReasoningEffort: "high", + supportedReasoningEfforts: ["medium", "high"], + serviceTiers: [{ id: "priority", name: "Priority" }], + }, + { + slug: "codex-fast-model", + displayName: "Codex Fast Model", + description: "Fast host model", + defaultReasoningEffort: "low", + supportedReasoningEfforts: ["low"], + serviceTiers: [], + }, +]; + +test("AI chat capability is local-only and thread creation freezes the current origin", () => { + assert.equal(isAiChatCapabilityAvailable({ localAiChat: true }), true); + assert.equal(isAiChatCapabilityAvailable({ localAiChat: false }), false); + assert.equal(isAiChatCapabilityAvailable(undefined), false); + assert.deepEqual(buildThreadCreateInput("project-1", "task-1"), { + projectId: "project-1", + issueId: "task-1", + }); + assert.deepEqual(buildThreadCreateInput("project-1", null), { + projectId: "project-1", + }); + assert.equal(buildThreadCreateInput("", null), null); +}); + +test("navigation updates only the pending new-thread origin and preserves the selected global thread", () => { + assert.deepEqual( + routeChatState( + { selectedThreadId: "thread-a", pendingProjectId: "project-a", pendingIssueId: "issue-a" }, + "project-b", + "issue-b", + ), + { + selectedThreadId: "thread-a", + pendingProjectId: "project-b", + pendingIssueId: "issue-b", + }, + ); +}); + +test("model and effort selections are normalized exclusively against the real catalog", () => { + assert.deepEqual(normalizeChatSelection(models, "codex-real-model", "medium"), { + model: "codex-real-model", + reasoningEffort: "medium", + }); + assert.deepEqual(normalizeChatSelection(models, "codex-real-model", "fake-effort"), { + model: "codex-real-model", + reasoningEffort: "high", + }); + assert.deepEqual(normalizeChatSelection(models, "missing-model", "high"), { + model: "codex-real-model", + reasoningEffort: "high", + }); + assert.equal(normalizeChatSelection([], "missing-model", "high"), null); +}); + +test("@ skill insertion uses the selected real skill id while keeping the mention visible", () => { + assert.deepEqual(insertSkillMention("请用 @cl 检查", 3, 6, { + id: "cloudflare", + label: "Cloudflare", + scope: "user", + }), { + value: "请用 @Cloudflare 检查", + caret: 14, + skillId: "cloudflare", + }); +}); + +test("turn input contains only visible user content, real skill ids and one-time confirmation", () => { + assert.deepEqual(buildTurnInput("检查 LOCAL-103", ["cloudflare"], false), { + message: "检查 LOCAL-103", + skillIds: ["cloudflare"], + }); + assert.deepEqual(buildTurnInput("执行", [], true), { + message: "执行", + dangerFullAccessConfirmed: true, + }); + assert.equal(JSON.stringify(buildTurnInput("hello", [], false)).includes("workspacePath"), false); + assert.equal(JSON.stringify(buildTurnInput("hello", [], false)).includes("manage-taskboard"), false); +}); + +test("running threads expose stop, danger-full-access requires confirmation, and SSE is a refresh hint", () => { + assert.equal(chatPrimaryAction("running", "hello"), "stop"); + assert.equal(chatPrimaryAction("idle", "hello"), "send"); + assert.equal(chatPrimaryAction("idle", " "), "disabled"); + assert.equal(needsDangerConfirmation("danger-full-access", false), true); + assert.equal(needsDangerConfirmation("danger-full-access", true), false); + assert.equal(needsDangerConfirmation("workspace-write", false), false); + assert.equal(shouldRefreshAiSnapshot("ai.event"), true); + assert.equal(shouldRefreshAiSnapshot("ai.run"), true); + assert.equal(shouldRefreshAiSnapshot("unrelated"), false); +}); + +test("reasoning and raw JSONL events never enter the visible activity timeline", () => { + const events = filterVisibleAiEvents([ + { id: "1", type: "agent_message", role: "assistant", content: "公开回复" }, + { id: "2", type: "reasoning", role: "activity", content: "private chain of thought" }, + { id: "3", type: "raw_jsonl", role: "activity", content: "{\"secret\":true}" }, + { id: "4", type: "command", role: "activity", content: "npm test" }, + ]); + assert.deepEqual(events.map((event) => event.id), ["1", "4"]); +}); + +test("App wires the global panel outside project/detail branches and hides it without local capability", () => { + assert.match(appSource, /