diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 000000000..4d1eb5812 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "dashi-taskboard-local", + "interface": { + "displayName": "Dashi Taskboard Local" + }, + "plugins": [ + { + "name": "dashi-taskboard", + "source": { + "source": "local", + "path": "./" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 000000000..3b0324b71 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,37 @@ +{ + "name": "dashi-taskboard", + "version": "0.1.0", + "description": "Manage taskboard issues and share durable project context through a local companion.", + "author": { + "name": "chuspeeism", + "url": "https://github.com/chuspeeism" + }, + "homepage": "https://github.com/chuspeeism/dashi-taskboard", + "repository": "https://github.com/chuspeeism/dashi-taskboard", + "license": "UNLICENSED", + "keywords": [ + "codex", + "taskboard", + "project-context" + ], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "Dashi Taskboard", + "shortDescription": "Manage issues and durable shared project context.", + "longDescription": "Use taskboard issue workflows and a local stdio MCP server to read and publish shared project context through the companion HTTP API.", + "developerName": "chuspeeism", + "category": "Productivity", + "capabilities": [ + "Read", + "Write" + ], + "websiteURL": "https://github.com/chuspeeism/dashi-taskboard", + "defaultPrompt": [ + "Review the current taskboard issue and its shared project context.", + "Search this project's durable decisions and constraints.", + "Publish a concise handoff after completing the issue." + ], + "brandColor": "#2563EB" + } +} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..a672cf3f8 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "dashi-taskboard": { + "command": "dashi-taskboard-mcp", + "args": [] + } + } +} diff --git a/README.md b/README.md index f1a6586a2..4f64da21e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. +A local-first issue board with an official Codex plugin for issue workflows and durable shared project context. The plugin bundles two Skills and a stdio MCP server; the React UI and `taskctl` use the same companion HTTP API. An optional CDP injector can place the browser UI inside Codex, but it is separate from the official plugin. ## Requirements @@ -44,18 +44,40 @@ npm run taskctl -- issue create \ Use `npm link` if you want `taskctl` on your shell path. Set `CODEX_TASKBOARD_URL` to point the CLI at another local or LAN service. Cloud deployments are configured through the loopback companion with `taskctl cloud login`. -## Install the Codex Skill +## Install the official Codex plugin -Copy or symlink `skills/manage-taskboard` into the Codex skills directory, then start a new Codex task: +Install dependencies and expose the plugin's `taskctl` and `dashi-taskboard-mcp` bins: ```bash -ln -s /absolute/path/to/codex-taskboard/skills/manage-taskboard \ - ~/.codex/skills/manage-taskboard +npm ci +npm link +npm run build:web +npm run plugin:validate ``` -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. +Keep the loopback companion running, then register this checkout's repository marketplace and install the plugin: -## Embed in Codex +```bash +CODEX_TASKBOARD_HOST=127.0.0.1 npm start +``` + +Run these in another terminal on first installation: + +```bash +codex plugin marketplace add . +codex plugin add dashi-taskboard@dashi-taskboard-local +codex plugin list --json +``` + +Restart Codex or start a new thread after installation so it discovers `manage-taskboard`, `shared-project-context`, and the seven `taskboard_context_*` MCP tools. The plugin does not start the companion and does not add a visual sidebar; open for the board UI. See [Shared project context](docs/shared-project-context.md) for the tool contract, two-device setup, security boundary, and troubleshooting. + +After pulling plugin changes, `npm link` alone does not refresh Codex's cached Skills and MCP declaration. Follow the [local plugin refresh procedure](docs/shared-project-context.md#refresh-an-existing-installation), then start a new thread. + +`manage-taskboard` teaches Codex to inspect and claim issues with optimistic versions, verify work, publish durable context when useful, and hand work to `in_review`. It moves an issue to `done` only after explicit user acceptance. `shared-project-context` reads and writes context through the MCP server without direct SQLite, D1, credential, or Codex-state access. + +## Optional CDP UI injection (not the official plugin) + +The following launcher adds the browser board as a visual Codex sidebar entry. It is optional compatibility tooling and is not the official plugin or its MCP transport. The plugin remains usable when CDP injection is disabled. ### Manual: use a dedicated CDP port @@ -122,6 +144,9 @@ To use a different UI origin, set `window.__CODEX_TASKBOARD_URL__` before the us | `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 | +| `CODEX_TASKBOARD_COMPANION_URL` | `CODEX_TASKBOARD_URL` or `http://127.0.0.1:47823` | Loopback companion origin for cloud control and MCP | + +When `CODEX_TASKBOARD_URL` points `taskctl` at a LAN service, set `CODEX_TASKBOARD_COMPANION_URL` separately to the local loopback companion before starting Codex. The MCP server intentionally rejects a LAN origin with `INVALID_COMPANION_URL`. `npm start` prints both the local URL and the available LAN URLs. Teammates on the same trusted network can open one of those LAN URLs and use the same taskboard service. 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. A teammate using `taskctl` can point it at the shared service with `CODEX_TASKBOARD_URL=http://:47823`. @@ -131,7 +156,7 @@ LAN mode has no account authentication: anyone on the trusted local network who 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. +Each device installs the plugin, keeps its own project checkout mapping, and continues to use a local companion for Codex, Git/worktree, Skill, and MCP capabilities. Installing the plugin does not distribute board data, the shared password, or device mappings. 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. diff --git a/cloud/migrations/0002_project_context.sql b/cloud/migrations/0002_project_context.sql new file mode 100644 index 000000000..17bc9e40e --- /dev/null +++ b/cloud/migrations/0002_project_context.sql @@ -0,0 +1,11 @@ +CREATE TABLE project_context_entries (id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('requirement', 'decision', 'constraint', 'fact', 'risk', 'handoff', 'summary')), title TEXT NOT NULL, body TEXT NOT NULL CHECK (length(CAST(body AS BLOB)) <= 65536), tags TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags) = 1 AND json_type(tags) = 'array'), source_type TEXT NOT NULL CHECK (source_type IN ('manual', 'issue', 'comment', 'thread_summary', 'agent')), source_id TEXT, source_thread_id TEXT, author_type TEXT NOT NULL CHECK (author_type IN ('user', 'agent')), author_id TEXT NOT NULL, author_name TEXT NOT NULL, pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)), archived_at TEXT, version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), idempotency_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); +CREATE UNIQUE INDEX project_context_entries_project_idempotency ON project_context_entries(project_id, idempotency_key) WHERE idempotency_key IS NOT NULL; +CREATE INDEX project_context_entries_project_page ON project_context_entries(project_id, archived_at, created_at DESC, id DESC); +CREATE INDEX project_context_entries_project_kind ON project_context_entries(project_id, archived_at, kind, created_at DESC, id DESC); +CREATE INDEX project_context_entries_project_pinned ON project_context_entries(project_id, archived_at, pinned DESC, updated_at DESC, id); +CREATE TABLE project_context_revisions (id TEXT PRIMARY KEY, entry_id TEXT NOT NULL REFERENCES project_context_entries(id) ON DELETE CASCADE, version INTEGER NOT NULL CHECK (version > 0), title TEXT NOT NULL, body TEXT NOT NULL CHECK (length(CAST(body AS BLOB)) <= 65536), kind TEXT NOT NULL CHECK (kind IN ('requirement', 'decision', 'constraint', 'fact', 'risk', 'handoff', 'summary')), tags TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags) = 1 AND json_type(tags) = 'array'), author_id TEXT NOT NULL, author_name TEXT NOT NULL, created_at TEXT NOT NULL); +CREATE UNIQUE INDEX project_context_revisions_entry_version_unique ON project_context_revisions(entry_id, version); +CREATE INDEX project_context_revisions_entry_versions ON project_context_revisions(entry_id, version DESC); +CREATE TRIGGER project_context_entries_global_revision_insert AFTER INSERT ON project_context_entries BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER project_context_entries_global_revision_update AFTER UPDATE ON project_context_entries BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; +CREATE TRIGGER project_context_entries_global_revision_delete AFTER DELETE ON project_context_entries BEGIN UPDATE global_revision SET revision = revision + 1 WHERE singleton = 1; END; diff --git a/cloud/src/index.mjs b/cloud/src/index.mjs index 4f605c43e..99cc303fc 100644 --- a/cloud/src/index.mjs +++ b/cloud/src/index.mjs @@ -1,8 +1,21 @@ import { normalizeWorkflowSnapshot } from "../../shared/workflow-control-flow.mjs"; +import { PROJECT_ID_PATTERN } from "../../shared/domain.mjs"; +import { + buildProjectContextBrief, + contextEntryFromRow, + contextRevisionFromRow, + decodeContextCursor, + encodeContextCursor, + sameContextCreatePayload, + CONTEXT_BODY_MAX_BYTES, + CONTEXT_KINDS, + CONTEXT_LIST_DEFAULT_LIMIT, + CONTEXT_LIST_MAX_LIMIT, + CONTEXT_SOURCE_TYPES, +} from "../../shared/project-context.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", @@ -385,8 +398,11 @@ function resolveAssignee(target, actor) { } async function readJson(request) { - const contentType = request.headers.get("content-type") ?? ""; - if (!contentType.toLowerCase().startsWith("application/json")) { + const contentType = (request.headers.get("content-type") ?? "") + .split(";", 1)[0] + .trim() + .toLowerCase(); + if (contentType !== "application/json") { throw new ApiError( 415, "UNSUPPORTED_MEDIA_TYPE", @@ -857,6 +873,336 @@ async function getTask(env, id) { return row ? hydrateTask(env, row) : null; } +async function contextEntryRow(env, id) { + return env.DB.prepare("SELECT * FROM project_context_entries WHERE id = ?").bind(id).first(); +} + +async function requireContextEntryRow(env, id) { + const row = await contextEntryRow(env, id); + if (!row) throw new ApiError(404, "CONTEXT_NOT_FOUND", `Context entry '${id}' does not exist`); + return row; +} + +function assertContextVersion(row, expectedVersion) { + if (row.version !== expectedVersion) { + throw new ApiError(409, "VERSION_CONFLICT", "Context entry was changed by another client", { + expectedVersion, + actualVersion: row.version, + }); + } +} + +async function listProjectContext(env, projectId, filters) { + await requireProject(env, projectId); + const where = ["project_context_entries.project_id = ?"]; + const values = [projectId]; + if (filters.archived === "false") where.push("project_context_entries.archived_at IS NULL"); + else if (filters.archived === "true") where.push("project_context_entries.archived_at IS NOT NULL"); + if (filters.kind !== undefined) { + where.push("project_context_entries.kind = ?"); + values.push(filters.kind); + } + if (filters.tag !== undefined) { + where.push(`EXISTS ( + SELECT 1 FROM json_each(project_context_entries.tags) + WHERE json_each.value = ? + )`); + values.push(filters.tag); + } + if (filters.pinned !== undefined) { + where.push("project_context_entries.pinned = ?"); + values.push(filters.pinned ? 1 : 0); + } + if (filters.query !== undefined) { + const escaped = filters.query + .replaceAll("\\", "\\\\") + .replaceAll("%", "\\%") + .replaceAll("_", "\\_"); + const pattern = `%${escaped}%`; + where.push(`( + project_context_entries.title LIKE ? ESCAPE '\\' + OR project_context_entries.body LIKE ? ESCAPE '\\' + OR EXISTS ( + SELECT 1 FROM json_each(project_context_entries.tags) + WHERE json_each.value LIKE ? ESCAPE '\\' + ) + )`); + values.push(pattern, pattern, pattern); + } + if (filters.cursor) { + where.push(`( + project_context_entries.created_at < ? + OR (project_context_entries.created_at = ? AND project_context_entries.id < ?) + )`); + values.push(filters.cursor[0], filters.cursor[0], filters.cursor[1]); + } + const rows = await all(env.DB.prepare(` + SELECT * FROM project_context_entries + WHERE ${where.join(" AND ")} + ORDER BY project_context_entries.created_at DESC, project_context_entries.id DESC + LIMIT ? + `).bind(...values, filters.limit + 1)); + const hasMore = rows.length > filters.limit; + const entries = rows.slice(0, filters.limit).map(contextEntryFromRow); + return { + entries, + nextCursor: hasMore + ? encodeContextCursor([rows[filters.limit - 1].created_at, rows[filters.limit - 1].id]) + : null, + }; +} + +async function getProjectContextBrief(env, projectId) { + await requireProject(env, projectId); + const rows = await all(env.DB.prepare(` + SELECT * FROM project_context_entries + WHERE project_id = ? + AND archived_at IS NULL + AND (pinned = 1 OR kind IN ('requirement', 'constraint', 'decision', 'risk', 'handoff', 'summary')) + ORDER BY + CASE + WHEN pinned = 1 THEN 0 + WHEN kind IN ('requirement', 'constraint', 'decision') THEN 1 + WHEN kind IN ('risk', 'handoff') THEN 2 + ELSE 3 + END, + updated_at DESC, + id ASC + LIMIT 1000 + `).bind(projectId)); + return buildProjectContextBrief(rows.map(contextEntryFromRow)); +} + +async function listContextRevisions(env, id) { + const entry = await requireContextEntryRow(env, id); + const rows = await all(env.DB.prepare(` + SELECT * FROM project_context_revisions + WHERE entry_id = ? + ORDER BY version ASC, id ASC + `).bind(entry.id)); + return rows.map(contextRevisionFromRow); +} + +function contextRevisionValues(entry, version, actor, timestamp, id = crypto.randomUUID()) { + return [ + id, + entry.id, + version, + entry.title, + entry.body, + entry.kind, + JSON.stringify(entry.tags), + actor.id, + actor.name, + timestamp, + ]; +} + +async function sameContextCreatePayloadForExistingEntry(env, entry, input) { + if (entry.version <= 1) return sameContextCreatePayload(entry, input); + const originalRow = await env.DB.prepare(` + SELECT * + FROM project_context_revisions + WHERE entry_id = ? AND version = 1 + `).bind(entry.id).first(); + if (!originalRow) return sameContextCreatePayload(entry, input); + const original = contextRevisionFromRow(originalRow); + // Revision snapshots intentionally follow the public schema and do not carry + // the mutable pinned flag. Compare the original create fields, not later edits. + return sameContextCreatePayload( + { + ...entry, + kind: original.kind, + title: original.title, + body: original.body, + tags: original.tags, + }, + input, + { ignorePinned: true }, + ); +} + +async function createContextEntry(env, projectId, input, authContext) { + await requireProject(env, projectId); + if (input.idempotencyKey !== null) { + const existingRow = await env.DB.prepare(` + SELECT * FROM project_context_entries + WHERE project_id = ? AND idempotency_key = ? + `).bind(projectId, input.idempotencyKey).first(); + if (existingRow) { + const existing = contextEntryFromRow(existingRow); + if (!(await sameContextCreatePayloadForExistingEntry(env, existing, input))) { + throw new ApiError(409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used with different context content"); + } + return { entry: existing, created: false }; + } + } + const id = crypto.randomUUID(); + const timestamp = now(); + const actor = authContext.actor; + try { + const results = await env.DB.batch([ + env.DB.prepare(` + INSERT INTO project_context_entries ( + id, project_id, kind, title, body, tags, + source_type, source_id, source_thread_id, + author_type, author_id, author_name, pinned, archived_at, + version, idempotency_key, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 1, ?, ?, ?) + `).bind( + id, + projectId, + input.kind, + input.title, + input.body, + JSON.stringify(input.tags), + input.sourceType, + input.sourceId, + input.sourceThreadId, + actor.type, + actor.id, + actor.name, + input.pinned ? 1 : 0, + input.idempotencyKey, + timestamp, + timestamp, + ), + env.DB.prepare(` + INSERT INTO project_context_revisions ( + id, entry_id, version, title, body, kind, tags, + author_id, author_name, created_at + ) + SELECT ?, id, 1, title, body, kind, tags, ?, ?, ? + FROM project_context_entries + WHERE id = ? AND version = 1 + `).bind( + crypto.randomUUID(), + actor.id, + actor.name, + timestamp, + id, + ), + ]); + if (!changed(results[0])) { + throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`); + } + return { entry: contextEntryFromRow(await requireContextEntryRow(env, id)), created: true }; + } catch (error) { + if (!String(error?.message).includes("UNIQUE constraint failed")) throw error; + const existingRow = await env.DB.prepare(` + SELECT * FROM project_context_entries + WHERE project_id = ? AND idempotency_key = ? + `).bind(projectId, input.idempotencyKey).first(); + if (!existingRow) throw error; + const existing = contextEntryFromRow(existingRow); + if (!(await sameContextCreatePayloadForExistingEntry(env, existing, input))) { + throw new ApiError(409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used with different context content"); + } + return { entry: existing, created: false }; + } +} + +async function updateContextEntry(env, id, expectedVersion, changes, authContext) { + const currentRow = await requireContextEntryRow(env, id); + assertContextVersion(currentRow, expectedVersion); + const current = contextEntryFromRow(currentRow); + const next = { ...current, ...changes, version: expectedVersion + 1 }; + const timestamp = now(); + const actor = authContext.actor; + const assignments = []; + const values = []; + const columns = { kind: "kind", title: "title", body: "body", tags: "tags", pinned: "pinned" }; + for (const [key, column] of Object.entries(columns)) { + if (!Object.hasOwn(changes, key)) continue; + assignments.push(`${column} = ?`); + values.push(key === "tags" ? JSON.stringify(changes[key]) : key === "pinned" ? (changes[key] ? 1 : 0) : changes[key]); + } + values.push(timestamp, id, expectedVersion); + const results = await env.DB.batch([ + env.DB.prepare(` + INSERT INTO project_context_revisions ( + id, entry_id, version, title, body, kind, tags, + author_id, author_name, created_at + ) + SELECT ?, id, ?, ?, ?, ?, ?, ?, ?, ? + FROM project_context_entries + WHERE id = ? AND version = ? + `).bind( + crypto.randomUUID(), + next.version, + next.title, + next.body, + next.kind, + JSON.stringify(next.tags), + actor.id, + actor.name, + timestamp, + id, + expectedVersion, + ), + env.DB.prepare(` + UPDATE project_context_entries + SET ${assignments.concat(["version = version + 1", "updated_at = ?"]).join(", ")} + WHERE id = ? AND version = ? + `).bind(...values), + ]); + if (!changed(results[1])) { + const latest = await requireContextEntryRow(env, id); + throw new ApiError(409, "VERSION_CONFLICT", "Context entry was changed by another client", { + expectedVersion, + actualVersion: latest.version, + }); + } + return contextEntryFromRow(await requireContextEntryRow(env, id)); +} + +async function setContextArchived(env, id, expectedVersion, authContext, archived) { + const currentRow = await requireContextEntryRow(env, id); + assertContextVersion(currentRow, expectedVersion); + const current = contextEntryFromRow(currentRow); + if (archived && current.archivedAt !== null) { + throw new ApiError(409, "CONTEXT_ALREADY_ARCHIVED", "Context entry is already archived"); + } + if (!archived && current.archivedAt === null) { + throw new ApiError(409, "CONTEXT_NOT_ARCHIVED", "Only archived context entries can be restored"); + } + const nextVersion = expectedVersion + 1; + const timestamp = now(); + const actor = authContext.actor; + const results = await env.DB.batch([ + env.DB.prepare(` + INSERT INTO project_context_revisions ( + id, entry_id, version, title, body, kind, tags, + author_id, author_name, created_at + ) + SELECT ?, id, ?, title, body, kind, tags, ?, ?, ? + FROM project_context_entries + WHERE id = ? AND version = ? + `).bind( + crypto.randomUUID(), + nextVersion, + actor.id, + actor.name, + timestamp, + id, + expectedVersion, + ), + env.DB.prepare(` + UPDATE project_context_entries + SET archived_at = ?, version = version + 1, updated_at = ? + WHERE id = ? AND version = ? + `).bind(archived ? timestamp : null, timestamp, id, expectedVersion), + ]); + if (!changed(results[1])) { + const latest = await requireContextEntryRow(env, id); + throw new ApiError(409, "VERSION_CONFLICT", "Context entry was changed by another client", { + expectedVersion, + actualVersion: latest.version, + }); + } + return contextEntryFromRow(await requireContextEntryRow(env, id)); +} + async function taskActivityComments(env, taskIds) { const commentsByTask = new Map(taskIds.map((taskId) => [taskId, []])); const batches = []; @@ -1027,6 +1373,172 @@ function parseVersionMutation(body) { }; } +function parseContextKind(value, name = "kind") { + if (!CONTEXT_KINDS.includes(value)) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' must be one of: ${CONTEXT_KINDS.join(", ")}`); + } + return value; +} + +function parseContextSourceType(value, name = "sourceType") { + if (!CONTEXT_SOURCE_TYPES.includes(value)) { + throw new ApiError( + 400, + "INVALID_FIELD", + `'${name}' must be one of: ${CONTEXT_SOURCE_TYPES.join(", ")}`, + ); + } + return value; +} + +function parseContextBody(value, name = "body", { required = false } = {}) { + if (value === undefined) { + if (required) throw new ApiError(400, "INVALID_FIELD", `'${name}' is required`); + return undefined; + } + if (typeof value !== "string") { + throw new ApiError(400, "INVALID_FIELD", `'${name}' must be a string`); + } + if (new TextEncoder().encode(value).byteLength > CONTEXT_BODY_MAX_BYTES) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' cannot exceed 65536 UTF-8 bytes`); + } + return value; +} + +function parseContextTags(value, name = "tags") { + if (!Array.isArray(value) || value.length > 20) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' must be an array with at most 20 entries`); + } + const tags = value.map((tag, index) => { + if (typeof tag !== "string") throw new ApiError(400, "INVALID_FIELD", `'${name}[${index}]' must be a string`); + const normalized = tag.trim(); + if (normalized.length === 0 || normalized.length > 64) { + throw new ApiError(400, "INVALID_FIELD", `'${name}[${index}]' must contain 1 to 64 characters`); + } + return normalized; + }); + if (new Set(tags).size !== tags.length) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' must contain unique values`); + } + return tags; +} + +function parseContextBoolean(value, name) { + if (value === "true" || value === true) return true; + if (value === "false" || value === false) return false; + throw new ApiError(400, "INVALID_QUERY_PARAMETER", `'${name}' must be true or false`); +} + +function parseContextCreate(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set([ + "kind", "title", "body", "tags", "sourceType", "sourceId", "sourceThreadId", + "pinned", "idempotencyKey", + ])); + if (body.pinned !== undefined && typeof body.pinned !== "boolean") { + throw new ApiError(400, "INVALID_FIELD", "'pinned' must be a boolean"); + } + return { + kind: parseContextKind(body.kind), + title: stringField(body.title, "title", { required: true, maxLength: 240 }), + body: parseContextBody(body.body, "body", { required: true }), + tags: body.tags === undefined ? [] : parseContextTags(body.tags), + sourceType: body.sourceType === undefined ? "manual" : parseContextSourceType(body.sourceType), + sourceId: stringField(body.sourceId ?? null, "sourceId", { + required: true, + nullable: true, + maxLength: 256, + }), + sourceThreadId: stringField(body.sourceThreadId ?? null, "sourceThreadId", { + required: true, + nullable: true, + maxLength: 256, + }), + pinned: body.pinned ?? false, + idempotencyKey: stringField(body.idempotencyKey ?? null, "idempotencyKey", { + required: true, + nullable: true, + maxLength: 256, + }), + }; +} + +function parseContextPatch(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["version", "kind", "title", "body", "tags", "pinned"])); + const version = parseVersion(body.version); + const changes = {}; + if (body.kind !== undefined) changes.kind = parseContextKind(body.kind); + if (body.title !== undefined) changes.title = stringField(body.title, "title", { required: true, maxLength: 240 }); + if (body.body !== undefined) changes.body = parseContextBody(body.body); + if (body.tags !== undefined) changes.tags = parseContextTags(body.tags); + if (body.pinned !== undefined) { + if (typeof body.pinned !== "boolean") throw new ApiError(400, "INVALID_FIELD", "'pinned' must be a boolean"); + changes.pinned = body.pinned; + } + if (Object.keys(changes).length === 0) { + throw new ApiError(400, "INVALID_BODY", "PATCH requires at least one context field"); + } + return { version, changes }; +} + +function parseContextMutation(body, routeLabel) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["version"])); + if (body.version === undefined) throw new ApiError(400, "INVALID_FIELD", `'version' is required for ${routeLabel}`); + return { version: parseVersion(body.version) }; +} + +function parseContextListFilters(url) { + const allowed = new Set(["query", "kind", "tag", "pinned", "archived", "limit", "cursor"]); + for (const key of url.searchParams.keys()) { + if (!allowed.has(key)) throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", `Unknown query parameter: ${key}`); + if (url.searchParams.getAll(key).length > 1) { + throw new ApiError(400, "INVALID_QUERY_PARAMETER", `'${key}' cannot be repeated`); + } + } + const rawLimit = url.searchParams.get("limit"); + const limit = rawLimit === null ? CONTEXT_LIST_DEFAULT_LIMIT : Number(rawLimit); + if (!/^\d+$/.test(rawLimit ?? String(CONTEXT_LIST_DEFAULT_LIMIT)) + || !Number.isSafeInteger(limit) + || limit < 1 + || limit > CONTEXT_LIST_MAX_LIMIT) { + throw new ApiError(400, "INVALID_QUERY_PARAMETER", `'limit' must be an integer from 1 to ${CONTEXT_LIST_MAX_LIMIT}`); + } + const archived = url.searchParams.get("archived") ?? "false"; + if (!["false", "true", "all"].includes(archived)) { + throw new ApiError(400, "INVALID_QUERY_PARAMETER", "'archived' must be false, true, or all"); + } + const query = url.searchParams.get("query"); + if (query !== null && query.length > 256) throw new ApiError(400, "INVALID_QUERY_PARAMETER", "'query' cannot exceed 256 characters"); + const rawTag = url.searchParams.get("tag"); + const tag = rawTag === null ? undefined : stringField(rawTag, "tag", { required: true, maxLength: 64 }); + const kind = url.searchParams.get("kind"); + if (kind !== null) parseContextKind(kind); + const rawCursor = url.searchParams.get("cursor"); + let cursor; + if (rawCursor !== null) { + try { + cursor = decodeContextCursor(rawCursor); + } catch { + throw new ApiError(400, "INVALID_QUERY_PARAMETER", "'cursor' is invalid"); + } + } + return { + query: query === null ? undefined : query, + kind: kind ?? undefined, + tag, + pinned: url.searchParams.has("pinned") ? parseContextBoolean(url.searchParams.get("pinned"), "pinned") : undefined, + archived, + limit, + cursor, + }; +} + +function contextAuthContext(actor) { + return { mechanism: "basic", actor }; +} + function parseCommentCreate(body) { assertPlainObject(body); assertAllowedKeys(body, new Set(["body", "threadId"])); @@ -2241,6 +2753,19 @@ function decodePathPart(value, label) { return decoded; } +function decodeContextPathPart(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 > 256 || decoded.includes("\0")) { + 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); @@ -2393,6 +2918,35 @@ async function routeApi(request, env, actor, url) { methodNotAllowed(["GET", "PUT"]); } + const projectContextBriefMatch = pathname.match( + /^\/api\/projects\/([^/]+)\/context\/brief$/, + ); + if (projectContextBriefMatch) { + if (request.method !== "GET") methodNotAllowed(["GET"]); + requireNoQuery(url, "GET /api/projects/:projectId/context/brief"); + const projectId = validateProjectId(decodePathPart(projectContextBriefMatch[1], "Project id")); + return json(200, await getProjectContextBrief(env, projectId)); + } + + const projectContextMatch = pathname.match(/^\/api\/projects\/([^/]+)\/context$/); + if (projectContextMatch) { + const projectId = validateProjectId(decodePathPart(projectContextMatch[1], "Project id")); + if (request.method === "GET") { + return json(200, await listProjectContext(env, projectId, parseContextListFilters(url))); + } + if (request.method === "POST") { + requireNoQuery(url, "POST /api/projects/:projectId/context"); + const result = await createContextEntry( + env, + projectId, + parseContextCreate(await readJson(request)), + contextAuthContext(actor), + ); + return json(result.created ? 201 : 200, { entry: result.entry }); + } + methodNotAllowed(["GET", "POST"]); + } + if (pathname === "/api/tasks") { if (request.method === "GET") { return json(200, { @@ -2407,6 +2961,55 @@ async function routeApi(request, env, actor, url) { methodNotAllowed(["GET", "POST"]); } + const contextRevisionsMatch = pathname.match(/^\/api\/context\/([^/]+)\/revisions$/); + if (contextRevisionsMatch) { + if (request.method !== "GET") methodNotAllowed(["GET"]); + requireNoQuery(url, "GET /api/context/:id/revisions"); + const id = decodeContextPathPart(contextRevisionsMatch[1], "Context entry id"); + return json(200, { revisions: await listContextRevisions(env, id) }); + } + + const contextActionMatch = pathname.match(/^\/api\/context\/([^/]+)\/(archive|restore)$/); + if (contextActionMatch) { + if (request.method !== "POST") methodNotAllowed(["POST"]); + requireNoQuery(url, "Context archive/restore routes"); + const id = decodeContextPathPart(contextActionMatch[1], "Context entry id"); + const input = parseContextMutation( + await readJson(request), + `POST /api/context/:id/${contextActionMatch[2]}`, + ); + const entry = await setContextArchived( + env, + id, + input.version, + contextAuthContext(actor), + contextActionMatch[2] === "archive", + ); + return json(200, { entry }); + } + + const contextEntryMatch = pathname.match(/^\/api\/context\/([^/]+)$/); + if (contextEntryMatch) { + requireNoQuery(url, "Context entry routes"); + const id = decodeContextPathPart(contextEntryMatch[1], "Context entry id"); + if (request.method === "GET") { + return json(200, { entry: contextEntryFromRow(await requireContextEntryRow(env, id)) }); + } + if (request.method === "PATCH") { + const input = parseContextPatch(await readJson(request)); + return json(200, { + entry: await updateContextEntry( + env, + id, + input.version, + input.changes, + contextAuthContext(actor), + ), + }); + } + methodNotAllowed(["GET", "PATCH"]); + } + const relationMatch = pathname.match( /^\/api\/tasks\/([^/]+)\/relations\/([^/]+)\/([^/]+)$/, ); diff --git a/docs/cloud-collaboration.md b/docs/cloud-collaboration.md index 853fdd23a..3ea5d8c4b 100644 --- a/docs/cloud-collaboration.md +++ b/docs/cloud-collaboration.md @@ -22,7 +22,9 @@ This is intentionally a shared-password trust model. The Basic username is only 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`. +Each collaborator installs the official plugin and runs the local companion for Codex, Git/worktree scanning, 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`. + +Plugin installation does not distribute credentials or business data. Codex chats, execution environments, and session state also stay on their originating device. A context entry's `sourceThreadId` is provenance only; it cannot restore or transfer a Codex session. 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. @@ -96,9 +98,23 @@ The owner follows this device setup too, using the owner's own actor name and ch ```bash git pull --ff-only npm ci +npm link npm run build:web +npm run plugin:validate +``` + +On first installation, register this checkout's marketplace and install the official plugin: + +```bash +codex plugin marketplace add . +codex plugin add dashi-taskboard@dashi-taskboard-local +codex plugin list --json ``` +The plugin bundles both Skills and the stdio MCP server, but it does not start the companion or share the cloud password. Repeat the plugin installation and device configuration on both computers. + +For a plugin that is already installed, pulling and running `npm link` does not refresh Codex's cached Skills or MCP declaration. On each device, follow [Refresh an existing installation](shared-project-context.md#refresh-an-existing-installation), reinstall from `dashi-taskboard-local`, and start a new thread. + Start the local companion: ```bash @@ -127,16 +143,22 @@ npm run taskctl -- project map PROJECT_ID \ 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: +Restart Codex or start a new thread after plugin installation. Keep the companion running while using `manage-taskboard`, `shared-project-context`, or any `taskboard_context_*` tool. Verify the checkout mapping from that checkout: ```bash -CODEX_TASKBOARD_HOST=127.0.0.1 npm run codex +npm run taskctl -- context current --json ``` -`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. +Explicitly confirm that the CLI result contains the intended `project.id` and that `workspacePath` is this device's checkout; `taskctl context current` can otherwise return its legacy fallback. In the new Codex thread, require `taskboard_context_current_project` to return the same project ID because the MCP resolver never falls back. + +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, expose it to MCP, or print it in logs. Device paths also stay off Cloudflare. + +The browser board remains available at the local companion URL. To add that browser UI to a separate Codex window, optionally run `CODEX_TASKBOARD_HOST=127.0.0.1 npm run codex`. This CDP launcher is UI compatibility tooling, not the official plugin, and is not required for Skills or MCP tools. 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. +See [Shared project context](shared-project-context.md) for the seven tool contracts, structured errors, security boundary, and `VERSION_CONFLICT` / `REMOTE_UNAVAILABLE` troubleshooting. + ## Browser-only access Either collaborator can open the deployed HTTPS Worker URL directly. The browser's native Basic Authentication prompt asks for: diff --git a/docs/shared-project-context.md b/docs/shared-project-context.md new file mode 100644 index 000000000..74aba9937 --- /dev/null +++ b/docs/shared-project-context.md @@ -0,0 +1,125 @@ +# Shared project context + +The Dashi Taskboard official plugin lets Codex read and publish durable project knowledge without sharing a Codex session or giving the model direct database or cloud credentials. It packages the `manage-taskboard` and `shared-project-context` Skills with a local stdio MCP server. + +## Architecture + +```text +Codex + -> official plugin Skills and taskboard_context_* MCP tools + -> dashi-taskboard-mcp over stdio + -> loopback companion HTTP API + -> local SQLite, or authenticated Worker API -> D1 +``` + +The MCP process accepts only a loopback `http://` or `https://` companion origin. It uses the companion HTTP API and never imports SQLite code, queries D1, reads the companion credential file, or inspects Codex internal state. In cloud mode the companion is the authentication and device-mapping boundary: it sends Basic Authentication to the HTTPS Worker and never exposes that header or password to MCP. + +The cloud service is authoritative whenever cloud mode is active. `REMOTE_UNAVAILABLE` and other cloud failures are returned to the caller; there is no local fallback or dual write. + +## Tools and HTTP routes + +| MCP tool | Companion request | Purpose | +| --- | --- | --- | +| `taskboard_context_current_project` | `GET /api/projects` | Resolve the most-specific workspace mapping and return only public project metadata. | +| `taskboard_context_brief` | `GET /api/projects/:projectId/context/brief` | Load prioritized active requirements, constraints, decisions, risks, handoffs, and summary context. | +| `taskboard_context_search` | `GET /api/projects/:projectId/context` | Search/filter entries and page with `nextCursor`. | +| `taskboard_context_get` | `GET /api/context/:id` | Load one complete context entry. | +| `taskboard_context_publish` | `POST /api/projects/:projectId/context` | Publish an agent-authored entry with a required stable `idempotencyKey`. | +| `taskboard_context_update` | `PATCH /api/context/:id` | Update fields using a required positive `version`. | +| `taskboard_context_archive` | `POST /api/context/:id/archive` | Archive an entry using a required positive `version`. | + +Every tool has a closed input schema and returns both structured JSON and a short model-readable text result. Successful structured results use `ok: true`. Failures use `ok: false`, set the MCP error flag, and return only `status`, `code`, `category`, `action`, and safe numeric version details when available. + +Project-scoped tools accept an explicit `projectId`; otherwise they resolve the current process directory through canonical local device mappings. Resolution never selects an unrelated fallback project and rejects duplicate mappings for the same directory. Current-project output omits `cwd`, `workspacePath`, and all device mappings. HTTP requests have a 10-second timeout across headers and body, reject redirects and any path that could leave the loopback origin, never include an incoming Authorization value, and return only allowlisted error codes and version details rather than upstream exception text, full environment data, or credential fields. + +`taskboard_context_publish` always sends `sourceType: agent`. Use `sourceId` and `sourceThreadId` only for provenance. `sourceThreadId` does not contain a chat and cannot grant access to, transfer, or restore a Codex conversation on another device. + +## Local installation + +Requirements: Node.js 22.5 or newer and a Codex release with plugin support. + +From the repository checkout: + +```bash +npm ci +npm link +npm run build +npm run plugin:validate +``` + +`npm link` exposes `taskctl` and `dashi-taskboard-mcp` on `PATH`. Start the companion on loopback and leave it running: + +```bash +CODEX_TASKBOARD_HOST=127.0.0.1 npm start +``` + +In another terminal, register the repository marketplace and install its plugin: + +```bash +codex plugin marketplace add . +codex plugin add dashi-taskboard@dashi-taskboard-local +codex plugin list --json +``` + +Restart Codex or start a new thread. New sessions should expose both Skills and exactly seven `taskboard_context_*` tools. The plugin does not start the companion and does not embed the board UI. Use the browser at , or separately enable the optional CDP UI injector documented in the README. + +## Refresh an existing installation + +Codex caches installed local plugin contents. After pulling changes, update dependencies and UI artifacts, validate the clean manifest, add one local cachebuster, and reinstall from the already configured marketplace: + +```bash +npm ci +npm link +npm run build:web +npm run plugin:validate +python3 "$CODEX_HOME/skills/.system/plugin-creator/scripts/update_plugin_cachebuster.py" . +codex plugin add dashi-taskboard@dashi-taskboard-local +``` + +The helper replaces any existing `+codex.*` suffix and intentionally changes only the local `.codex-plugin/plugin.json` version so Codex sees new contents. Do not commit the generated timestamp as a product release version. Start a new thread after reinstalling. If `CODEX_HOME` is not exported, use the equivalent `skills/.system/plugin-creator/scripts/update_plugin_cachebuster.py` path under the active Codex home. + +## Two-device cloud setup + +Plugin files, cloud access, and project mappings are separate concerns. On each trusted device: + +1. Pull the same repository revision, then run `npm ci`, `npm link`, `npm run build:web`, and `npm run plugin:validate`. +2. Register/install `dashi-taskboard@dashi-taskboard-local` on that device, then restart Codex or start a new thread. +3. Start the local companion with `CODEX_TASKBOARD_HOST=127.0.0.1 npm start`. +4. Run `taskctl cloud login --url HTTPS_ORIGIN --actor-name NAME` and enter the shared password only at the private prompt. +5. Run `taskctl project list`, then map each shared project with `taskctl project map PROJECT_ID --workspace-path /that/devices/checkout`. +6. Run `taskctl context current --json` from the checkout and explicitly confirm its `project.id` and `workspacePath`; this legacy CLI command can fall back when no mapping matches. Then call `taskboard_context_current_project` in a new Codex thread and require the expected project ID. The MCP result is the strict no-fallback check. + +The owner performs the same device steps. Each mapping stays local and may differ. Installing the plugin does not copy the shared password, D1 data, attachments, Codex chats, execution environments, or filesystem paths. See [Cloud collaboration](cloud-collaboration.md) for Worker provisioning and password rotation. + +## Security and permission boundary + +- The Basic password grants full shared-board read/write access. Actor names are display attribution, not verified identities; there is no per-user RBAC or individual revocation. +- The companion config is device-local and mode `0600`. Never put the shared password in plugin JSON, MCP arguments, an issue, context entry, shell history, or logs. +- MCP publishes only explicit tool arguments. It does not automatically scrape chats, command output, files, or environment variables. +- The Skills prohibit publishing credentials, private paths, raw chats, full logs, temporary diagnostics, and complete environment data. Potentially sensitive context requires explicit user approval. +- Context bodies are shared application data. Review them before publication; API and transport filtering cannot identify every secret a user may type into a body. +- Use a stable issue/source-scoped `idempotencyKey` for retries. Use the current `version` for update/archive and reload before resolving a conflict. + +## Troubleshooting + +| Code or symptom | Action | +| --- | --- | +| `dashi-taskboard-mcp` not found | Run `npm link`, confirm the npm global bin directory is on `PATH`, and start a new Codex session. | +| Plugin or tools missing | Run `npm run plugin:validate`, `codex plugin list --json`, reinstall from `dashi-taskboard-local`, then restart Codex or open a new thread. | +| `COMPANION_UNAVAILABLE` | Start the loopback companion. Check `CODEX_TASKBOARD_COMPANION_URL` only if using a non-default loopback port. | +| `INVALID_COMPANION_URL` | Use a credential-free loopback HTTP(S) origin with no path, query, or fragment. | +| `PROJECT_MAPPING_NOT_FOUND` | Run `taskctl project list` and map the intended project to this device's checkout. Do not use another project's fallback. | +| `PROJECT_NOT_FOUND` / `CONTEXT_NOT_FOUND` | Refresh the project or entry identifier. The server does not fabricate missing data. | +| `VERSION_CONFLICT` | Reload the entry, reconcile the newer state, and retry with `actualVersion`. The compatibility alias `CONTEXT_VERSION_CONFLICT` is also recognized. | +| `IDEMPOTENCY_CONFLICT` | Reuse the original content for that key, or choose a new stable key for a genuinely different entry. The compatibility alias `CONTEXT_IDEMPOTENCY_CONFLICT` is also recognized. | +| `CONTEXT_ALREADY_ARCHIVED` | Reload and treat the entry as archived; do not retry blindly. | +| `UNAUTHORIZED` | Repeat `taskctl cloud login` through the companion. Never place Basic credentials in MCP configuration. | +| `CLOUD_NOT_CONFIGURED` | Configure the cloud origin and shared key through `taskctl cloud login`. | +| `REMOTE_UNAVAILABLE` | Check Worker/network availability and retry later. Do not switch to local data for the same operation. | +| `SERVER_MISCONFIGURED` | Ask the deployment owner to check Worker bindings and server configuration. | + +## Operational changes and rollback + +This plugin layer adds no database migration and changes no context API route. Rollback consists of removing or disabling the plugin and unlinking its npm bins; existing SQLite/D1 context remains intact. Disabling the plugin stops agent access but does not stop the companion or delete shared entries. + +The current shared-password design is intentionally small-team only. A future centralized deployment should add OIDC or another verified identity layer, scoped permissions, audit export, and explicit App Server/session architecture rather than treating `sourceThreadId` as session sharing. diff --git a/docs/superpowers/plans/2026-08-06-shared-project-context-mcp.md b/docs/superpowers/plans/2026-08-06-shared-project-context-mcp.md new file mode 100644 index 000000000..92adb9e2e --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-shared-project-context-mcp.md @@ -0,0 +1,252 @@ +# Shared Project Context MCP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a tested official Codex plugin and stdio MCP server that safely reads and writes shared project context through the existing companion HTTP API. + +**Architecture:** A dependency-injected companion client owns loopback URL validation, timeout-bounded HTTP, cwd mapping, and sanitized errors. A separate MCP registration module owns strict Zod schemas and structured/text results; a tiny executable connects it to stdio. Plugin, Skill, marketplace, tests, and docs wrap that server without changing the ASH-46 database/API layer. + +**Tech Stack:** Node.js 22 ESM, `@modelcontextprotocol/server`/`client` v2, Zod v4, `node:test`, Codex plugin JSON. + +--- + +### Task 1: Companion Client Contract + +**Files:** +- Create: `test/mcp-context.test.mjs` +- Create: `mcp/companion-client.mjs` +- Modify: `package.json` +- Modify: `package-lock.json` + +- [ ] **Step 1: Install the stable official SDK test/runtime dependency** + +Run: `npm install @modelcontextprotocol/server@2.0.0 zod@^4.4.3` + +Run: `npm install --save-dev @modelcontextprotocol/client@2.0.0` + +Expected: lockfile records the current stable v2 server/client packages and Zod; no unrelated direct dependency changes. + +- [ ] **Step 2: Write failing client tests** + +Cover loopback-only URL validation, 10-second abort signal use, most-specific cwd mapping with no fallback, project output without `cwd`/`workspacePath`, path/query encoding, JSON response parsing, and sanitized categories for 404/409/401/502. + +- [ ] **Step 3: Verify RED** + +Run: `node --test test/mcp-context.test.mjs` + +Expected: FAIL because `mcp/companion-client.mjs` does not exist. + +- [ ] **Step 4: Implement the minimal client** + +Export `CompanionError`, `createCompanionClient`, `resolveCompanionUrl`, `resolveMappedProject`, and `publicProject`. Requests must send `accept`, `content-type` when needed, and `x-taskboard-client: taskctl`, but never Authorization. Whitelist only numeric `expectedVersion` and `actualVersion` details. + +- [ ] **Step 5: Verify GREEN** + +Run: `node --test test/mcp-context.test.mjs` + +Expected: all client tests pass. + +### Task 2: Strict MCP Tools and Real Stdio Protocol + +**Files:** +- Modify: `test/mcp-context.test.mjs` +- Create: `mcp/context-server.mjs` +- Create: `mcp/server.mjs` + +- [ ] **Step 1: Add failing stdio protocol tests** + +Start a fake loopback companion and use SDK `Client` + `StdioClientTransport` to spawn `node mcp/server.mjs`. Assert initialize succeeds, tools/list contains exactly the seven required names with closed object schemas, and tools/call returns both `structuredContent` and text. + +Add success cases for current project, brief/search/get, idempotent publish replay, versioned update/archive, plus 404, version conflict, idempotency conflict, archived-state conflict, 401, and `REMOTE_UNAVAILABLE` cases. Seed sentinels in project paths, Authorization-like fields, password values, and environment variables; assert serialized MCP responses omit them. + +- [ ] **Step 2: Verify RED** + +Run: `node --test test/mcp-context.test.mjs` + +Expected: FAIL because the server/tools are missing. + +- [ ] **Step 3: Implement strict tools** + +Register: + +```text +taskboard_context_current_project +taskboard_context_brief +taskboard_context_search +taskboard_context_get +taskboard_context_publish +taskboard_context_update +taskboard_context_archive +``` + +Use strict Zod input/output schemas, read/write annotations, encoded HTTP paths/query parameters, required `idempotencyKey` for publish, and required positive `version` for update/archive. Return `{ok:true,...}` or `{ok:false,error:{status,code,category,action,details?}}` as structured content plus concise text; set `isError: true` on failures. + +- [ ] **Step 4: Implement stdio entry** + +Add a Node shebang, build the server, call the v2 dual-era `serveStdio` entry, close on SIGINT/SIGTERM, and send fatal startup errors to stderr only. + +- [ ] **Step 5: Verify GREEN** + +Run: `node --test test/mcp-context.test.mjs` + +Expected: all initialize/list/call, error, idempotency, and leak tests pass. + +### Task 3: Official Plugin and Marketplace + +**Files:** +- Create: `test/plugin-structure.test.mjs` +- Create: `.codex-plugin/plugin.json` +- Create: `.mcp.json` +- Create: `.agents/plugins/marketplace.json` +- Create: `scripts/validate-plugin.mjs` +- Modify: `package.json` + +- [ ] **Step 1: Write failing structure tests** + +Assert the plugin name matches the repo directory, the manifest points to `./skills/` and `./.mcp.json`, the MCP map invokes `dashi-taskboard-mcp`, the npm bin points at `mcp/server.mjs`, marketplace policy fields are present, local source is inside the repo root, and all referenced paths exist. + +- [ ] **Step 2: Verify RED** + +Run: `node --test test/plugin-structure.test.mjs` + +Expected: FAIL because plugin files and validator do not exist. + +- [ ] **Step 3: Create minimal valid artifacts** + +Use plugin name `dashi-taskboard`, semver `0.1.0`, real repository/homepage/license metadata, `mcpServers: "./.mcp.json"`, `skills: "./skills/"`, read/write capabilities, and a repo marketplace entry with `AVAILABLE`, `ON_INSTALL`, and `Productivity`. + +Add `plugin:validate` to run the repository validator, both Skill validators, and the plugin-creator validator documented in the developer workflow. + +- [ ] **Step 4: Verify GREEN** + +Run: `node --test test/plugin-structure.test.mjs` + +Run: `npm run plugin:validate` + +Expected: structure tests and validators pass without touching personal Codex config. + +### Task 4: Update `manage-taskboard` Skill + +**Files:** +- Modify: `test/manage-taskboard-skill.test.mjs` +- Modify: `skills/manage-taskboard/SKILL.md` +- Modify: `skills/manage-taskboard/agents/openai.yaml` only if generated metadata is stale + +- [ ] **Step 1: Add failing workflow assertions** + +Require current-project resolution, issue/comment reads before brief/search, title/label/description search terms, requirement/constraint/decision/risk enforcement, source-and-time conflict reporting, durable-only publication, stable idempotency key, sensitive-data opt-in, and agent source attribution. + +- [ ] **Step 2: Verify RED** + +Run: `node --test test/manage-taskboard-skill.test.mjs` + +Expected: new assertions fail against the old Skill. + +- [ ] **Step 3: Add the minimal shared-context workflow** + +Preserve all existing taskctl claim/version/review rules. Reference the `shared-project-context` Skill as required for project-context operations and state that context tools, not taskctl, own shared context. + +- [ ] **Step 4: Verify GREEN and validate** + +Run: `node --test test/manage-taskboard-skill.test.mjs` + +Run: `python3 /scripts/quick_validate.py skills/manage-taskboard` + +Expected: assertions and validation pass. + +### Task 5: Add `shared-project-context` Skill + +**Files:** +- Modify: `test/manage-taskboard-skill.test.mjs` +- Create: `skills/shared-project-context/SKILL.md` +- Create: `skills/shared-project-context/agents/openai.yaml` + +- [ ] **Step 1: Add failing new-Skill assertions** + +Require MCP-only reads/writes, current-project/brief/search/get order, source-time conflict reporting, publish allowlist, stable idempotency, versioned update/archive, explicit error actions, sensitive-data rules, agent attribution, and bans on direct DB/Codex-state access. + +- [ ] **Step 2: Verify RED** + +Run: `node --test test/manage-taskboard-skill.test.mjs` + +Expected: FAIL because the new Skill is absent. + +- [ ] **Step 3: Initialize and write the Skill** + +Run the official `init_skill.py shared-project-context --path skills` with interface values, replace every generated placeholder using `apply_patch`, and keep only the concise workflow plus `agents/openai.yaml`. + +- [ ] **Step 4: Verify GREEN and validate** + +Run: `node --test test/manage-taskboard-skill.test.mjs` + +Run: `python3 /scripts/quick_validate.py skills/shared-project-context` + +Expected: assertions and validation pass with no placeholder text. + +### Task 6: User and Operations Documentation + +**Files:** +- Create: `docs/shared-project-context.md` +- Modify: `README.md` +- Modify: `docs/cloud-collaboration.md` + +- [ ] **Step 1: Document architecture and security** + +Describe official plugin -> Skills/MCP -> loopback companion -> SQLite or Worker/D1, all tool-to-HTTP mappings, structured errors, project mapping, no fallback, no secrets/paths, Basic Auth limits, and `sourceThreadId` as provenance only. + +- [ ] **Step 2: Document installation and two-device operation** + +Give executable steps for `npm ci`, `npm link`, companion startup, local marketplace registration/plugin install, artifact validation, cloud login, per-device project mapping, restart/new thread, and troubleshooting by actual error code. + +- [ ] **Step 3: Separate official plugin from optional CDP UI** + +Make the plugin the primary Codex agent integration. Retitle existing injection instructions as optional UI compatibility and explicitly state that they do not constitute the official plugin. + +- [ ] **Step 4: Check documentation references** + +Run: `rg -n "official plugin|CDP|REMOTE_UNAVAILABLE|VERSION_CONFLICT|sourceThreadId|npm link" README.md docs/shared-project-context.md docs/cloud-collaboration.md` + +Expected: each installation, boundary, and troubleshooting concept is present. + +### Task 7: Final Verification and Handoff + +**Files:** +- Review all changed files + +- [ ] **Step 1: Run focused checks** + +Run: `node --test test/mcp-context.test.mjs test/plugin-structure.test.mjs test/manage-taskboard-skill.test.mjs test/project-context.test.mjs test/cloud-project-context.test.mjs test/cloud-companion.test.mjs` + +Expected: all new/focused tests pass except only the recorded pre-existing cloud `/api/meta` assertion if still present. + +- [ ] **Step 2: Run plugin and build checks** + +Run: `npm run plugin:validate` + +Run: `npm run typecheck` + +Run: `npm run build` + +Expected: all pass; build may retain the existing chunk-size warning. + +- [ ] **Step 3: Compare the full suite to baseline** + +Run: `npm run check` + +Expected: no failures beyond the recorded baseline of 18 unrelated failures; all new tests pass. + +- [ ] **Step 4: Review for leaks and accidental scope** + +Run: `git diff --check` + +Run: `git status --short` + +Inspect the diff for secrets, absolute paths, internal Codex state access, database imports from `mcp/`, and unrelated changes. + +- [ ] **Step 5: Commit, push, and open the linked PR** + +Use an `ASH-48` title without `Closes`/`Fixes`/`Resolves`. Include actual verification output and the upstream error-code conflict in the PR description. + +- [ ] **Step 6: Deliver through Multica** + +Post exactly one ASH-48 comment with files, API/tool contract, migration/rollback, verification, operations impact, risks, PR URL, and next owner; then move ASH-48 to `in_review`. diff --git a/docs/superpowers/specs/2026-08-06-shared-project-context-mcp-design.md b/docs/superpowers/specs/2026-08-06-shared-project-context-mcp-design.md new file mode 100644 index 000000000..3ec497cf9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-shared-project-context-mcp-design.md @@ -0,0 +1,44 @@ +# Shared Project Context MCP Design + +## Scope + +Package Codex Taskboard as an official Codex plugin containing the existing `manage-taskboard` Skill, a new `shared-project-context` Skill, and a local stdio MCP server. The MCP server consumes only the loopback companion HTTP API and does not import or access SQLite, D1, cloud credentials, or Codex internal state. + +## Chosen Approach + +Use a focused MCP companion client rather than wrapping `taskctl` as a subprocess or refactoring the existing CLI. A subprocess wrapper would weaken tool schemas and error contracts. A shared CLI refactor would broaden regression risk without improving the required HTTP boundary. + +The MCP server runs as the npm bin `dashi-taskboard-mcp`. The plugin's `.mcp.json` invokes that stable command, so the manifest contains no installation path. Local developers run `npm ci && npm link` before installing the repo marketplace. + +## Components + +- `mcp/companion-client.mjs`: validate a loopback companion origin, issue timeout-bounded JSON requests, resolve the most-specific cwd mapping, strip local path fields, and normalize errors into a whitelist-only model response. +- `mcp/context-server.mjs`: define the seven strict tools with Zod, call the companion client, and return `structuredContent` plus concise text for success and failure. +- `mcp/server.mjs`: connect the configured server to the official SDK stdio transport without writing non-protocol output to stdout. +- `.codex-plugin/plugin.json` and `.mcp.json`: package both Skills and the bundled server according to the current official format. +- `.agents/plugins/marketplace.json`: expose the repository-root plugin for local development without changing personal marketplace state. + +## Tool Semantics + +`taskboard_context_current_project` resolves the most-specific mapped project from `cwd`; it does not silently fall back to an unrelated project. Project-scoped tools accept an optional explicit `projectId`, otherwise they resolve the current project. `brief` and `search` read project data, `get` reads one entry, `publish` requires a stable `idempotencyKey`, and `update`/`archive` require a positive `version`. + +All tools return an `ok` discriminant, JSON structured data, and short model-readable text. Tool errors retain the upstream HTTP status and code but replace message/details with a stable category, action, and whitelisted numeric version fields. The implementation recognizes both the actual `VERSION_CONFLICT`/`IDEMPOTENCY_CONFLICT` codes and the stale handoff aliases, while always returning the actual upstream code. + +## Security Boundary + +- Only loopback HTTP(S) companion origins are accepted. +- MCP never sends or returns an Authorization header or cloud shared password. +- Current-project output omits `cwd`, `workspacePath`, and every device mapping. +- Error output never includes upstream URLs, network exception text, complete environment data, or unfiltered details. +- User-authored context body remains application data; the server does not inspect Codex chats or internal files. +- Cloud failures remain failures; MCP has no local fallback or dual-write path. + +## Skill Workflow + +Before work, resolve the project, read the issue/comments, load the brief, and search by meaningful issue terms. Treat requirements, constraints, decisions, and risks as work constraints. When entries conflict, show both sources and `updatedAt` values rather than choosing silently. + +After work, publish only durable decisions, constraints, state, risks, handoffs, summaries, or next steps. Never publish raw chats, full command output, temporary debugging, credentials, or private paths. Potentially sensitive publication requires an explicit user request, and agent inference uses `sourceType: agent`. + +## Verification + +Use the official MCP client over a spawned stdio process to cover initialize, tools/list, tools/call, success, 404, all relevant 409 classes, 401, remote unavailability, idempotent replay, and leak sentinels. Validate both Skills, the plugin manifest, the marketplace, focused tests, typecheck/build, and the full check against the recorded 18-failure baseline. diff --git a/mcp/companion-client.mjs b/mcp/companion-client.mjs new file mode 100644 index 000000000..4ff145be4 --- /dev/null +++ b/mcp/companion-client.mjs @@ -0,0 +1,319 @@ +import path from "node:path"; + +const DEFAULT_COMPANION_URL = "http://127.0.0.1:47823"; +const DEFAULT_TIMEOUT_MS = 10_000; +const PUBLIC_PROJECT_FIELDS = ["id", "name", "createdAt", "updatedAt", "issueCount"]; + +const ERROR_GUIDANCE = new Map([ + ["INVALID_COMPANION_URL", ["invalid_configuration", "Set the companion URL to a loopback HTTP or HTTPS origin."]], + ["INVALID_COMPANION_PATH", ["invalid_request", "Use a companion API path that starts with a single slash."]], + ["INVALID_COMPANION_RESPONSE", ["invalid_response", "Restart or update the local taskboard companion and retry."]], + ["COMPANION_REDIRECT_BLOCKED", ["invalid_response", "Use a direct loopback companion URL without redirects."]], + ["PROJECT_MAPPING_NOT_FOUND", ["project_not_mapped", "Map the current workspace to a taskboard project and retry."]], + ["PROJECT_MAPPING_AMBIGUOUS", ["project_mapping_ambiguous", "Keep exactly one taskboard project mapping for the current workspace and retry."]], + ["UNAUTHORIZED", ["authentication_required", "Log in through the local companion and retry."]], + ["VERSION_CONFLICT", ["version_conflict", "Reload the context entry, then retry with its current version."]], + ["CONTEXT_VERSION_CONFLICT", ["version_conflict", "Reload the context entry, then retry with its current version."]], + ["IDEMPOTENCY_CONFLICT", ["idempotency_conflict", "Reuse the original content or choose a new idempotency key."]], + ["CONTEXT_IDEMPOTENCY_CONFLICT", ["idempotency_conflict", "Reuse the original content or choose a new idempotency key."]], + ["CONTEXT_ALREADY_ARCHIVED", ["already_archived", "Reload the context entry; it is already archived."]], + ["CONTEXT_NOT_ARCHIVED", ["not_archived", "Reload the context entry; only archived entries can be restored."]], + ["CLOUD_NOT_CONFIGURED", ["cloud_not_configured", "Configure cloud collaboration in the local companion and retry."]], + ["SERVER_MISCONFIGURED", ["server_misconfigured", "Ask the taskboard administrator to check the server configuration."]], + ["REMOTE_UNAVAILABLE", ["cloud_unavailable", "Check the cloud taskboard connection and retry."]], + ["COMPANION_UNAVAILABLE", ["companion_unavailable", "Start the local taskboard companion and retry."]], +]); + +const SAFE_ERROR_CODES = new Set([ + ...ERROR_GUIDANCE.keys(), + "INVALID_BODY", + "INVALID_FIELD", + "INVALID_JSON", + "INVALID_PATH", + "INVALID_QUERY_PARAMETER", + "UNKNOWN_FIELD", + "UNKNOWN_QUERY_PARAMETER", + "PROJECT_NOT_FOUND", + "CONTEXT_NOT_FOUND", + "NOT_FOUND", +]); + +export class CompanionError extends Error { + constructor(status, code, message, details) { + super(message); + this.name = "CompanionError"; + this.status = status; + this.code = code; + this.details = details; + } +} + +export function publicCompanionError(error) { + const normalized = error instanceof CompanionError + ? error + : new CompanionError(500, "INTERNAL_ERROR", "Unexpected companion client failure"); + const code = safeErrorCode(normalized.status, normalized.code); + const fallback = normalized.status === 400 + ? ["invalid_request", "Check the tool arguments and retry."] + : normalized.status === 404 + ? ["not_found", "Refresh the project or context entry identifier and retry."] + : normalized.status === 409 + ? ["conflict", "Reload the affected context and retry."] + : ["request_failed", "Check the taskboard service status and retry."]; + const [category, action] = ERROR_GUIDANCE.get(code) ?? fallback; + const details = {}; + for (const field of ["expectedVersion", "actualVersion"]) { + if (Number.isSafeInteger(normalized.details?.[field])) { + details[field] = normalized.details[field]; + } + } + return { + status: normalized.status, + code, + category, + action, + ...(Object.keys(details).length === 0 ? {} : { details }), + }; +} + +function safeErrorCode(status, code) { + if (typeof code === "string" && ( + SAFE_ERROR_CODES.has(code) + || /^HTTP_[1-5]\d{2}$/.test(code) + )) { + return code; + } + return `HTTP_${status}`; +} + +export function resolveCompanionUrl(env = process.env) { + const rawUrl = env.CODEX_TASKBOARD_COMPANION_URL + ?? env.CODEX_TASKBOARD_URL + ?? DEFAULT_COMPANION_URL; + let url; + try { + url = new URL(rawUrl); + } catch { + throw invalidCompanionUrl(); + } + const loopback = url.hostname === "localhost" + || url.hostname === "[::1]" + || isIpv4Loopback(url.hostname); + if ( + !loopback + || (url.protocol !== "http:" && url.protocol !== "https:") + || url.username + || url.password + || url.pathname !== "/" + || url.search + || url.hash + ) { + throw invalidCompanionUrl(); + } + return url.origin; +} + +function isIpv4Loopback(hostname) { + if (!/^127(?:\.\d{1,3}){3}$/.test(hostname)) return false; + return hostname.split(".").every((part) => Number(part) <= 255); +} + +function invalidCompanionUrl() { + return new CompanionError( + 400, + "INVALID_COMPANION_URL", + "Local companion URL is invalid", + ); +} + +export function workspaceContains(workspacePath, cwd) { + if (!path.isAbsolute(workspacePath ?? "") || !path.isAbsolute(cwd ?? "")) return false; + const relative = path.relative(path.resolve(workspacePath), path.resolve(cwd)); + return relative === "" + || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +} + +export function resolveMappedProject(projects, cwd) { + if (!Array.isArray(projects) || !path.isAbsolute(cwd ?? "")) return null; + const canonicalCwd = path.resolve(cwd); + const matches = projects.flatMap((project) => { + if (!path.isAbsolute(project?.workspacePath ?? "")) return []; + const workspacePath = path.resolve(project.workspacePath); + if (!workspaceContains(workspacePath, canonicalCwd)) return []; + return [{ project, workspacePath }]; + }).sort((left, right) => right.workspacePath.length - left.workspacePath.length); + const match = matches[0]; + if (!match) return null; + const ambiguous = matches.some((candidate) => ( + candidate.workspacePath === match.workspacePath + && candidate.project?.id !== match.project?.id + )); + if (ambiguous) { + throw new CompanionError( + 409, + "PROJECT_MAPPING_AMBIGUOUS", + "Current workspace has multiple taskboard project mappings", + ); + } + return match.project; +} + +export function publicProject(project) { + if (!project || typeof project !== "object") return null; + const result = {}; + for (const field of PUBLIC_PROJECT_FIELDS) { + if (project[field] !== undefined) result[field] = project[field]; + } + return typeof result.id === "string" && typeof result.name === "string" ? result : null; +} + +function requestPath(pathOrSegments) { + if (Array.isArray(pathOrSegments)) { + if (pathOrSegments.length === 0 || pathOrSegments.some((part) => typeof part !== "string" || part.length === 0)) { + throw invalidCompanionPath(); + } + return `/${pathOrSegments.map((part) => encodeURIComponent(part)).join("/")}`; + } + if ( + typeof pathOrSegments !== "string" + || !pathOrSegments.startsWith("/") + || pathOrSegments.startsWith("//") + || pathOrSegments.includes("\\") + ) { + throw invalidCompanionPath(); + } + return pathOrSegments; +} + +function invalidCompanionPath() { + return new CompanionError( + 400, + "INVALID_COMPANION_PATH", + "Companion API path is invalid", + ); +} + +function appendQuery(url, query) { + if (query === undefined) return; + if (query === null || typeof query !== "object" || Array.isArray(query)) { + throw invalidCompanionPath(); + } + for (const [name, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.append(name, String(value)); + } +} + +export function createCompanionClient({ + baseUrl, + env = process.env, + cwd = process.cwd(), + fetch: fetchImplementation = globalThis.fetch, + createTimeoutSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), + timeoutMs = DEFAULT_TIMEOUT_MS, +} = {}) { + const origin = baseUrl === undefined + ? resolveCompanionUrl(env) + : resolveCompanionUrl({ CODEX_TASKBOARD_COMPANION_URL: baseUrl }); + if (typeof fetchImplementation !== "function") { + throw new Error("fetch is not available"); + } + if (typeof createTimeoutSignal !== "function") { + throw new Error("createTimeoutSignal must be a function"); + } + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) { + throw new Error("timeoutMs must be a positive integer"); + } + + async function request(method, pathOrSegments, options = {}) { + const pathname = requestPath(pathOrSegments); + const url = new URL(pathname, `${origin}/`); + appendQuery(url, options.query); + if (url.origin !== origin) throw invalidCompanionPath(); + const hasBody = Object.hasOwn(options, "body") && options.body !== undefined; + let response; + let text; + try { + response = await fetchImplementation(url, { + method, + headers: { + accept: "application/json", + "x-taskboard-client": "taskctl", + ...(hasBody ? { "content-type": "application/json" } : {}), + }, + signal: createTimeoutSignal(timeoutMs), + redirect: "manual", + ...(hasBody ? { body: JSON.stringify(options.body) } : {}), + }); + if (response.status >= 300 && response.status < 400) { + throw new CompanionError( + 502, + "COMPANION_REDIRECT_BLOCKED", + "Local taskboard companion returned a redirect", + ); + } + text = await response.text(); + } catch (error) { + if (error instanceof CompanionError) throw error; + throw new CompanionError( + 503, + "COMPANION_UNAVAILABLE", + "Local taskboard companion is unavailable", + ); + } + + let payload = {}; + if (text) { + try { + payload = JSON.parse(text); + } catch { + if (!response.ok) { + throw new CompanionError( + response.status, + response.status === 401 ? "UNAUTHORIZED" : `HTTP_${response.status}`, + "Taskboard companion request failed", + ); + } + throw new CompanionError( + 502, + "INVALID_COMPANION_RESPONSE", + "Local taskboard companion returned invalid JSON", + ); + } + } + if (!response.ok) { + const code = response.status === 401 + ? "UNAUTHORIZED" + : safeErrorCode(response.status, payload?.error?.code); + throw new CompanionError( + response.status, + code, + "Taskboard companion request failed", + payload?.error?.details, + ); + } + return payload; + } + + return { + request, + async currentProject(requestCwd = cwd) { + const payload = await request("GET", "/api/projects"); + if (!Array.isArray(payload.projects)) { + throw new CompanionError( + 502, + "INVALID_COMPANION_RESPONSE", + "Local taskboard companion returned an invalid project list", + ); + } + const project = publicProject(resolveMappedProject(payload.projects, requestCwd)); + if (!project) { + throw new CompanionError( + 404, + "PROJECT_MAPPING_NOT_FOUND", + "Current workspace has no taskboard project mapping", + ); + } + return project; + }, + }; +} diff --git a/mcp/context-server.mjs b/mcp/context-server.mjs new file mode 100644 index 000000000..f03444abc --- /dev/null +++ b/mcp/context-server.mjs @@ -0,0 +1,378 @@ +import { McpServer } from "@modelcontextprotocol/server"; +import * as z from "zod/v4"; + +import { PROJECT_ID_PATTERN } from "../shared/domain.mjs"; +import { + CONTEXT_BODY_MAX_BYTES, + CONTEXT_KINDS, +} from "../shared/project-context.mjs"; +import { + CompanionError, + createCompanionClient, + publicCompanionError, +} from "./companion-client.mjs"; + +const SERVER_INFO = Object.freeze({ name: "dashi-taskboard", version: "0.1.0" }); +const ENTRY_FIELDS = Object.freeze([ + "id", + "projectId", + "kind", + "title", + "body", + "tags", + "sourceType", + "sourceId", + "sourceThreadId", + "authorType", + "authorId", + "authorName", + "pinned", + "archivedAt", + "version", + "idempotencyKey", + "createdAt", + "updatedAt", +]); + +const projectIdSchema = z.string().trim().min(1).max(64).regex(PROJECT_ID_PATTERN); +const entryIdSchema = z.string().trim().min(1).max(256); +const versionSchema = z.number().int().positive().safe(); +const nullableStringSchema = z.string().nullable(); +const tagSchema = z.string().trim().min(1).max(64); +const tagsSchema = z.array(tagSchema).max(20).refine( + (tags) => new Set(tags).size === tags.length, + { message: "Tags must be unique." }, +); + +const errorDetailsSchema = z.strictObject({ + expectedVersion: z.number().int().safe().optional(), + actualVersion: z.number().int().safe().optional(), +}); +const publicErrorSchema = z.strictObject({ + status: z.number().int().min(100).max(599), + code: z.string(), + category: z.string(), + action: z.string(), + details: errorDetailsSchema.optional(), +}); +const projectSchema = z.strictObject({ + id: z.string(), + name: z.string(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + issueCount: z.number().int().nonnegative().optional(), +}); +const entrySchema = z.strictObject({ + id: z.string(), + projectId: z.string(), + kind: z.enum(CONTEXT_KINDS), + title: z.string(), + body: z.string(), + tags: z.array(z.string()), + sourceType: z.string(), + sourceId: nullableStringSchema, + sourceThreadId: nullableStringSchema, + authorType: z.string(), + authorId: z.string(), + authorName: z.string(), + pinned: z.boolean(), + archivedAt: nullableStringSchema, + version: versionSchema, + idempotencyKey: nullableStringSchema, + createdAt: z.string(), + updatedAt: z.string(), +}); + +function outputSchema(properties) { + return z.strictObject({ + ok: z.boolean(), + ...Object.fromEntries( + Object.entries(properties).map(([name, schema]) => [name, schema.optional()]), + ), + error: publicErrorSchema.optional(), + }); +} + +const currentProjectInputSchema = z.strictObject({}); +const projectScopeShape = { projectId: projectIdSchema.optional() }; +const briefInputSchema = z.strictObject(projectScopeShape); +const searchInputSchema = z.strictObject({ + ...projectScopeShape, + query: z.string().max(256).optional(), + kind: z.enum(CONTEXT_KINDS).optional(), + tag: tagSchema.optional(), + pinned: z.boolean().optional(), + archived: z.enum(["false", "true", "all"]).optional(), + limit: z.number().int().min(1).max(100).optional(), + cursor: z.string().min(1).max(8_192).optional(), +}); +const getInputSchema = z.strictObject({ id: entryIdSchema }); +const publishInputSchema = z.strictObject({ + ...projectScopeShape, + kind: z.enum(CONTEXT_KINDS), + title: z.string().trim().min(1).max(240), + body: z.string(), + tags: tagsSchema.optional(), + sourceId: z.string().trim().max(256).nullable().optional(), + sourceThreadId: z.string().trim().max(256).nullable().optional(), + pinned: z.boolean().optional(), + idempotencyKey: z.string().trim().min(1).max(256), +}); +const updateInputSchema = z.strictObject({ + id: entryIdSchema, + version: versionSchema, + kind: z.enum(CONTEXT_KINDS).optional(), + title: z.string().trim().min(1).max(240).optional(), + body: z.string().optional(), + tags: tagsSchema.optional(), + pinned: z.boolean().optional(), +}).refine( + ({ id: _id, version: _version, ...changes }) => Object.values(changes).some( + (value) => value !== undefined, + ), + { message: "At least one context field is required." }, +); +const archiveInputSchema = z.strictObject({ + id: entryIdSchema, + version: versionSchema, +}); + +const currentProjectOutputSchema = outputSchema({ project: projectSchema }); +const briefOutputSchema = outputSchema({ + brief: z.string(), + includedEntryIds: z.array(z.string()), + truncated: z.boolean(), +}); +const searchOutputSchema = outputSchema({ + entries: z.array(entrySchema), + nextCursor: nullableStringSchema, +}); +const entryOutputSchema = outputSchema({ entry: entrySchema }); + +const READ_ONLY_ANNOTATIONS = Object.freeze({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +}); +const WRITE_ANNOTATIONS = Object.freeze({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, +}); + +function publicEntry(value) { + const candidate = {}; + if (value && typeof value === "object") { + for (const field of ENTRY_FIELDS) { + if (Object.hasOwn(value, field)) candidate[field] = value[field]; + } + } + const parsed = entrySchema.safeParse(candidate); + if (!parsed.success) throw invalidCompanionResponse(); + return parsed.data; +} + +function invalidCompanionResponse() { + return new CompanionError( + 502, + "INVALID_COMPANION_RESPONSE", + "Local taskboard companion returned an invalid response", + ); +} + +function requireObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw invalidCompanionResponse(); + } + return value; +} + +function validateBodyBytes(body) { + if (Buffer.byteLength(body, "utf8") > CONTEXT_BODY_MAX_BYTES) { + throw new CompanionError(400, "INVALID_FIELD", "Context body is too large"); + } +} + +function textResult(structuredContent, text, isError = false) { + return { + content: [{ type: "text", text }], + structuredContent, + ...(isError ? { isError: true } : {}), + }; +} + +function safeTool(handler, successText) { + return async (args) => { + try { + const data = await handler(args); + const structuredContent = { ok: true, ...data }; + return textResult(structuredContent, successText(data)); + } catch (error) { + const safeError = publicCompanionError(error); + return textResult( + { ok: false, error: safeError }, + `${safeError.code}: ${safeError.action}`, + true, + ); + } + }; +} + +async function projectIdFor(client, explicitProjectId) { + if (explicitProjectId !== undefined) return explicitProjectId; + return (await client.currentProject()).id; +} + +function requireProject(value) { + const parsed = projectSchema.safeParse(value); + if (!parsed.success) throw invalidCompanionResponse(); + return parsed.data; +} + +function requireBrief(value) { + const payload = requireObject(value); + if ( + typeof payload.brief !== "string" + || !Array.isArray(payload.includedEntryIds) + || payload.includedEntryIds.some((id) => typeof id !== "string") + || typeof payload.truncated !== "boolean" + ) { + throw invalidCompanionResponse(); + } + return { + brief: payload.brief, + includedEntryIds: payload.includedEntryIds, + truncated: payload.truncated, + }; +} + +function requireEntryPayload(value) { + return { entry: publicEntry(requireObject(value).entry) }; +} + +export function createContextServer({ companionClient } = {}) { + const client = companionClient ?? createCompanionClient(); + const server = new McpServer(SERVER_INFO); + + server.registerTool("taskboard_context_current_project", { + title: "Current taskboard project", + description: "Resolve the most-specific taskboard project mapped to this process working directory.", + inputSchema: currentProjectInputSchema, + outputSchema: currentProjectOutputSchema, + annotations: READ_ONLY_ANNOTATIONS, + }, safeTool( + async () => ({ project: requireProject(await client.currentProject()) }), + ({ project }) => `Current taskboard project: ${project.name} (${project.id}).`, + )); + + server.registerTool("taskboard_context_brief", { + title: "Shared project context brief", + description: "Load the prioritized shared context brief for a taskboard project.", + inputSchema: briefInputSchema, + outputSchema: briefOutputSchema, + annotations: READ_ONLY_ANNOTATIONS, + }, safeTool(async ({ projectId }) => { + const id = await projectIdFor(client, projectId); + return requireBrief(await client.request( + "GET", + ["api", "projects", id, "context", "brief"], + )); + }, ({ includedEntryIds, truncated }) => ( + `Loaded ${includedEntryIds.length} shared context entries${truncated ? " (truncated)" : ""}.` + ))); + + server.registerTool("taskboard_context_search", { + title: "Search shared project context", + description: "Search and page through shared context entries for a taskboard project.", + inputSchema: searchInputSchema, + outputSchema: searchOutputSchema, + annotations: READ_ONLY_ANNOTATIONS, + }, safeTool(async ({ projectId, ...query }) => { + const id = await projectIdFor(client, projectId); + const payload = requireObject(await client.request( + "GET", + ["api", "projects", id, "context"], + { query }, + )); + if (!Array.isArray(payload.entries)) throw invalidCompanionResponse(); + if (payload.nextCursor !== null && typeof payload.nextCursor !== "string") { + throw invalidCompanionResponse(); + } + return { + entries: payload.entries.map(publicEntry), + nextCursor: payload.nextCursor, + }; + }, ({ entries, nextCursor }) => ( + `Found ${entries.length} shared context ${entries.length === 1 ? "entry" : "entries"}${nextCursor ? "; more are available" : ""}.` + ))); + + server.registerTool("taskboard_context_get", { + title: "Get shared context entry", + description: "Get one shared context entry by identifier.", + inputSchema: getInputSchema, + outputSchema: entryOutputSchema, + annotations: READ_ONLY_ANNOTATIONS, + }, safeTool(async ({ id }) => requireEntryPayload(await client.request( + "GET", + ["api", "context", id], + )), ({ entry }) => `Loaded shared context entry ${entry.id} at version ${entry.version}.`)); + + server.registerTool("taskboard_context_publish", { + title: "Publish shared project context", + description: "Publish an agent-authored shared context entry with a stable idempotency key.", + inputSchema: publishInputSchema, + outputSchema: entryOutputSchema, + annotations: { ...WRITE_ANNOTATIONS, idempotentHint: true }, + }, safeTool(async ({ projectId, body, tags, pinned, ...input }) => { + validateBodyBytes(body); + const id = await projectIdFor(client, projectId); + return requireEntryPayload(await client.request( + "POST", + ["api", "projects", id, "context"], + { + body: { + ...input, + body, + tags: tags ?? [], + sourceType: "agent", + pinned: pinned ?? false, + }, + }, + )); + }, ({ entry }) => `Published shared context entry ${entry.id} at version ${entry.version}.`)); + + server.registerTool("taskboard_context_update", { + title: "Update shared context entry", + description: "Update a shared context entry using optimistic version locking.", + inputSchema: updateInputSchema, + outputSchema: entryOutputSchema, + annotations: WRITE_ANNOTATIONS, + }, safeTool(async ({ id, body, ...patch }) => { + if (body !== undefined) validateBodyBytes(body); + return requireEntryPayload(await client.request( + "PATCH", + ["api", "context", id], + { body: { ...patch, ...(body === undefined ? {} : { body }) } }, + )); + }, ({ entry }) => `Updated shared context entry ${entry.id} to version ${entry.version}.`)); + + server.registerTool("taskboard_context_archive", { + title: "Archive shared context entry", + description: "Archive a shared context entry using optimistic version locking.", + inputSchema: archiveInputSchema, + outputSchema: entryOutputSchema, + annotations: { + ...WRITE_ANNOTATIONS, + destructiveHint: true, + idempotentHint: true, + }, + }, safeTool(async ({ id, version }) => requireEntryPayload(await client.request( + "POST", + ["api", "context", id, "archive"], + { body: { version } }, + )), ({ entry }) => `Archived shared context entry ${entry.id} at version ${entry.version}.`)); + + return server; +} diff --git a/mcp/server.mjs b/mcp/server.mjs new file mode 100755 index 000000000..d80638932 --- /dev/null +++ b/mcp/server.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +import { serveStdio } from "@modelcontextprotocol/server/stdio"; + +import { createContextServer } from "./context-server.mjs"; + +const handle = serveStdio(() => createContextServer(), { + onerror() { + console.error("Taskboard MCP transport error."); + }, +}); + +let closing = false; +async function close() { + if (closing) return; + closing = true; + await handle.close(); +} + +process.once("SIGINT", () => { + close().catch(() => { + process.exitCode = 1; + }); +}); +process.once("SIGTERM", () => { + close().catch(() => { + process.exitCode = 1; + }); +}); diff --git a/package-lock.json b/package-lock.json index 92afaaccb..12fc42272 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,21 @@ "version": "0.1.0", "dependencies": { "@lobehub/icons-static-svg": "^1.94.0", + "@modelcontextprotocol/server": "^2.0.0", "@xyflow/react": "^12.11.2", "dhtmlx-gantt": "^10.0.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "zod": "^4.4.3" }, "bin": { + "dashi-taskboard-mcp": "mcp/server.mjs", "taskctl": "cli/taskctl.mjs" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", @@ -750,9 +754,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -770,9 +771,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -790,9 +788,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -810,9 +805,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -830,9 +822,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -850,9 +839,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -870,9 +856,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -890,9 +873,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -910,9 +890,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -936,9 +913,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -962,9 +936,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -988,9 +959,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1014,9 +982,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1040,9 +1005,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1066,9 +1028,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1092,9 +1051,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1241,6 +1197,50 @@ "integrity": "sha512-Inx1TYkjLH6YeHOIHeVW9+OM/xxRnk8TmcQVKquFUDBmE3X9sUuRGt7kALrrDBNNAbrWz7Qq6fAiFj9E9Mmw9Q==", "license": "MIT" }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "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", @@ -1392,9 +1392,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1412,9 +1409,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1432,9 +1426,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1452,9 +1443,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1472,9 +1460,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1492,9 +1477,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2245,6 +2227,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2498,6 +2495,29 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -2649,6 +2669,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -2802,9 +2839,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2826,9 +2860,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2850,9 +2881,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2874,9 +2902,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3856,6 +3881,16 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -3890,6 +3925,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.5.20", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", @@ -4141,6 +4186,29 @@ "@img/sharp-win32-x64": "0.35.2" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4508,6 +4576,22 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/workerd": { "version": "1.20260722.1", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260722.1.tgz", @@ -4612,6 +4696,15 @@ "error-stack-parser-es": "^1.0.5" } }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/package.json b/package.json index 266446dfa..1f23a693d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "node": ">=22.5" }, "bin": { - "taskctl": "./cli/taskctl.mjs" + "taskctl": "./cli/taskctl.mjs", + "dashi-taskboard-mcp": "./mcp/server.mjs" }, "scripts": { "codex": "node scripts/codex-injector.mjs --launch --watch --open --port 9231", @@ -24,8 +25,9 @@ "typecheck": "tsc -p web/tsconfig.json --noEmit", "start": "node server/index.mjs", "taskctl": "node cli/taskctl.mjs", + "plugin:validate": "node scripts/validate-plugin.mjs", "test": "node --test", - "test:cloud": "node --test test/cloud-shared-worker.test.mjs", + "test:cloud": "node --test test/cloud-shared-worker.test.mjs test/cloud-project-context.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", @@ -35,14 +37,17 @@ }, "dependencies": { "@lobehub/icons-static-svg": "^1.94.0", + "@modelcontextprotocol/server": "^2.0.0", "@xyflow/react": "^12.11.2", "dhtmlx-gantt": "^10.0.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "zod": "^4.4.3" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", diff --git a/scripts/migrate-to-cloud.mjs b/scripts/migrate-to-cloud.mjs index c3c8e7945..131b40734 100644 --- a/scripts/migrate-to-cloud.mjs +++ b/scripts/migrate-to-cloud.mjs @@ -24,7 +24,13 @@ const TABLE_ORDER = [ "task_relations", "attachments", "workflow_workspaces", + "project_context_entries", + "project_context_revisions", ]; +const BACKWARD_COMPATIBLE_CONTEXT_TABLES = new Set([ + "project_context_entries", + "project_context_revisions", +]); const LOCAL_WORKFLOW_PATH_FIELDS = new Set(["gitWorktreePath"]); const SORT_FIELDS = { projects: ["id"], @@ -33,6 +39,8 @@ const SORT_FIELDS = { task_relations: ["source_task_id", "target_task_id", "relation_type"], attachments: ["task_id", "comment_id", "created_at", "id"], workflow_workspaces: ["project_id"], + project_context_entries: ["project_id", "created_at", "id"], + project_context_revisions: ["entry_id", "version", "id"], }; function compareValues(left, right) { if (left === right) return 0; @@ -91,6 +99,8 @@ function buildProjectCounts(tables) { attachments: 0, task_relations: 0, workflow_workspaces: 0, + project_context_entries: 0, + project_context_revisions: 0, }; } @@ -129,6 +139,21 @@ function buildProjectCounts(tables) { } counts[workspace.project_id].workflow_workspaces += 1; } + const contextProjects = new Map(); + for (const entry of tables.project_context_entries) { + if (!counts[entry.project_id]) { + throw new Error(`Context entry '${entry.id}' references unknown project '${entry.project_id}'`); + } + contextProjects.set(entry.id, entry.project_id); + counts[entry.project_id].project_context_entries += 1; + } + for (const revision of tables.project_context_revisions) { + const projectId = contextProjects.get(revision.entry_id); + if (!projectId) { + throw new Error(`Context revision '${revision.id}' references unknown entry '${revision.entry_id}'`); + } + counts[projectId].project_context_revisions += 1; + } return Object.fromEntries( Object.entries(counts).sort(([left], [right]) => (left < right ? -1 : 1)), @@ -218,12 +243,15 @@ async function readSnapshot(databasePath) { `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()), - ]), - ); + const existingTables = new Set(snapshot.prepare(` + SELECT name FROM sqlite_schema WHERE type = 'table' + `).all().map((row) => row.name)); + const tables = Object.fromEntries(TABLE_ORDER.map((table) => { + if (!existingTables.has(table) && BACKWARD_COMPATIBLE_CONTEXT_TABLES.has(table)) { + return [table, []]; + } + return [table, sortRows(table, snapshot.prepare(`SELECT * FROM "${table}"`).all())]; + })); snapshot.close(); snapshot = null; return tables; @@ -234,6 +262,20 @@ async function readSnapshot(databasePath) { } } +function normalizeBackwardCompatibleContextTables(bundle) { + if (!bundle || bundle.schemaVersion !== SCHEMA_VERSION) return bundle; + bundle.tables ??= {}; + for (const table of BACKWARD_COMPATIBLE_CONTEXT_TABLES) { + if (bundle.tables[table] === undefined) bundle.tables[table] = []; + } + for (const counts of Object.values(bundle.counts?.byProject ?? {})) { + for (const table of BACKWARD_COMPATIBLE_CONTEXT_TABLES) { + if (counts[table] === undefined) counts[table] = 0; + } + } + return bundle; +} + function assertCountsMatch(expected, actual) { const expectedProjects = Object.keys(expected).sort(); const actualProjects = Object.keys(actual ?? {}).sort(); @@ -259,6 +301,7 @@ function validateBundle(bundle) { if (!bundle || bundle.schemaVersion !== SCHEMA_VERSION) { throw new Error(`Unsupported cloud migration schema version '${bundle?.schemaVersion}'`); } + normalizeBackwardCompatibleContextTables(bundle); for (const table of TABLE_ORDER) { if (!Array.isArray(bundle.tables?.[table])) { throw new Error(`Cloud migration bundle is missing table '${table}'`); @@ -362,6 +405,15 @@ const CLOUD_COLUMNS = { 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"], + project_context_entries: [ + "id", "project_id", "kind", "title", "body", "tags", "source_type", "source_id", + "source_thread_id", "author_type", "author_id", "author_name", "pinned", "archived_at", + "version", "idempotency_key", "created_at", "updated_at", + ], + project_context_revisions: [ + "id", "entry_id", "version", "title", "body", "kind", "tags", "author_id", + "author_name", "created_at", + ], }; function cloudTaskRow(task) { @@ -405,6 +457,96 @@ function inlineD1InsertStatement(table, values) { return `${insertTableSql(table).replace("?", sqliteString(json))};`; } +function contextBodyColumn(table) { + return table === "project_context_entries" || table === "project_context_revisions" + ? "body" + : null; +} + +function contextBodyUpdateStatement(table, column, id, body) { + return `UPDATE "${table}" SET "${column}" = "${column}" || ${sqliteString(body)} WHERE "id" = ${sqliteString(id)};`; +} + +function splitContextBodyStatements(table, row) { + const column = contextBodyColumn(table); + if (!column || typeof row.body !== "string") return null; + if (row.body.includes("\0")) { + throw new Error("D1 migration context body cannot contain null bytes"); + } + + const baseRow = { ...row, [column]: "" }; + const insert = inlineD1InsertStatement(table, [baseRow]); + if (Buffer.byteLength(insert, "utf8") >= WRANGLER_D1_STATEMENT_MAX_BYTES) { + throw new Error( + `D1 import single row '${table}:${row.id ?? "unknown"}' exceeds 90,000 bytes`, + ); + } + + const statements = [insert]; + let chunk = ""; + let chunkBytes = 0; + const chunks = []; + const updateOverhead = Buffer.byteLength( + contextBodyUpdateStatement(table, column, row.id, ""), + "utf8", + ); + for (const character of Array.from(row.body)) { + const characterBytes = Buffer.byteLength(character, "utf8") + + (character === "'" ? 1 : 0); + if (updateOverhead + chunkBytes + characterBytes < WRANGLER_D1_STATEMENT_MAX_BYTES) { + chunk += character; + chunkBytes += characterBytes; + continue; + } + if (chunk.length === 0) { + throw new Error( + `D1 import single row '${table}:${row.id ?? "unknown"}' exceeds 90,000 bytes`, + ); + } + chunks.push(chunk); + chunk = character; + chunkBytes = characterBytes; + } + if (chunk.length > 0) chunks.push(chunk); + for (const part of chunks) { + statements.push(contextBodyUpdateStatement(table, column, row.id, part)); + } + return statements; +} + +function inlineD1TableStatements(table, values) { + const statements = []; + let chunk = []; + for (const row of values) { + const candidate = [...chunk, row]; + if (Buffer.byteLength(inlineD1InsertStatement(table, candidate), "utf8") < WRANGLER_D1_STATEMENT_MAX_BYTES) { + chunk = candidate; + continue; + } + + if (chunk.length > 0) { + statements.push(inlineD1InsertStatement(table, chunk)); + chunk = []; + } + const single = inlineD1InsertStatement(table, [row]); + if (Buffer.byteLength(single, "utf8") < WRANGLER_D1_STATEMENT_MAX_BYTES) { + chunk = [row]; + continue; + } + const split = splitContextBodyStatements(table, row); + if (split) { + statements.push(...split); + continue; + } + const identity = row.id ?? row.project_id ?? "unknown"; + throw new Error( + `D1 import single row '${table}:${identity}' exceeds 90,000 bytes`, + ); + } + if (chunk.length > 0) statements.push(inlineD1InsertStatement(table, chunk)); + return statements; +} + export function createCloudD1ImportSql(tables) { const statements = []; for (const { table, json } of createCloudD1ImportPlan(tables)) { @@ -413,36 +555,7 @@ export function createCloudD1ImportSql(tables) { 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)); + statements.push(...inlineD1TableStatements(table, values)); } return statements.join("\n"); } @@ -457,7 +570,12 @@ export const CLOUD_PROJECT_COUNTS_SQL = ` (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 + WHERE w.project_id = p.id) AS workflow_workspaces, + (SELECT COUNT(*) FROM project_context_entries e + WHERE e.project_id = p.id) AS project_context_entries, + (SELECT COUNT(*) FROM project_context_revisions r + JOIN project_context_entries e ON e.id = r.entry_id + WHERE e.project_id = p.id) AS project_context_revisions FROM projects p ORDER BY p.id `; @@ -664,6 +782,10 @@ export async function readCloudMigrationBundle(inputDirectory) { const tables = {}; for (const table of TABLE_ORDER) { const entry = manifest.tables?.[table]; + if (!entry?.file && BACKWARD_COMPATIBLE_CONTEXT_TABLES.has(table)) { + tables[table] = []; + continue; + } if (!entry?.file) throw new Error(`Cloud migration manifest is missing table '${table}'`); const rows = await readJsonFile( bundleFile(inputDirectory, entry.file), @@ -687,6 +809,7 @@ export async function readCloudMigrationBundle(inputDirectory) { tables, attachments, }; + normalizeBackwardCompatibleContextTables(bundle); validateBundle(bundle); return bundle; } diff --git a/scripts/validate-plugin.mjs b/scripts/validate-plugin.mjs new file mode 100755 index 000000000..b7516c033 --- /dev/null +++ b/scripts/validate-plugin.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const PLUGIN_NAME = path.basename(ROOT); + +function readJson(relativePath) { + return JSON.parse(readFileSync(path.join(ROOT, relativePath), "utf8")); +} + +function check(condition, message) { + if (!condition) throw new Error(message); +} + +function resolveInsideRoot(relativePath, label) { + check(typeof relativePath === "string" && relativePath.startsWith("./"), `${label} must start with './'.`); + const resolved = path.resolve(ROOT, relativePath); + check( + resolved === ROOT || resolved.startsWith(`${ROOT}${path.sep}`), + `${label} must stay inside the repository.`, + ); + check(existsSync(resolved), `${label} points to a missing path.`); + return resolved; +} + +function validateRepositoryPlugin() { + const packageJson = readJson("package.json"); + const manifest = readJson(".codex-plugin/plugin.json"); + const mcpManifest = readJson(".mcp.json"); + const marketplace = readJson(".agents/plugins/marketplace.json"); + + check(manifest.name === PLUGIN_NAME, "Plugin name must match the repository directory."); + check(manifest.version === packageJson.version, "Plugin and package versions must match."); + check(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(manifest.version), "Plugin version must be semver."); + check(manifest.license === "UNLICENSED", "Private repository license metadata must remain explicit."); + check(manifest.skills === "./skills/", "Plugin must expose the repository Skills."); + check(manifest.mcpServers === "./.mcp.json", "Plugin must reference the MCP manifest."); + resolveInsideRoot(manifest.skills, "skills"); + resolveInsideRoot(manifest.mcpServers, "mcpServers"); + + const mcp = mcpManifest.mcpServers?.[PLUGIN_NAME]; + check(mcp?.command === "dashi-taskboard-mcp", "MCP manifest must invoke the npm bin."); + check(Array.isArray(mcp.args) && mcp.args.length === 0, "MCP bin must not depend on local path arguments."); + check( + packageJson.bin?.["dashi-taskboard-mcp"] === "./mcp/server.mjs", + "Package must expose mcp/server.mjs as dashi-taskboard-mcp.", + ); + resolveInsideRoot(packageJson.bin["dashi-taskboard-mcp"], "MCP bin"); + + const entry = marketplace.plugins?.find((plugin) => plugin.name === PLUGIN_NAME); + check(entry?.source?.source === "local", "Marketplace plugin source must be local."); + check(entry.source.path === "./", "Marketplace must expose the repository-root plugin."); + resolveInsideRoot(entry.source.path, "Marketplace source"); + check(entry.policy?.installation === "AVAILABLE", "Marketplace installation policy must be AVAILABLE."); + check(entry.policy?.authentication === "ON_INSTALL", "Marketplace auth policy must be ON_INSTALL."); + check(entry.category === "Productivity", "Marketplace category must be Productivity."); +} + +function runPythonValidator(script, args) { + const result = spawnSync("python3", [script, ...args], { + cwd: ROOT, + encoding: "utf8", + }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + check(result.status === 0, `${path.basename(script)} failed.`); +} + +function runInstalledCodexValidators() { + const codexHome = process.env.CODEX_HOME; + if (!codexHome) { + console.log("CODEX_HOME is not set; skipped installed Codex validators."); + return; + } + + const pluginValidator = path.join( + codexHome, + "skills", + ".system", + "plugin-creator", + "scripts", + "validate_plugin.py", + ); + const skillValidator = path.join( + codexHome, + "skills", + ".system", + "skill-creator", + "scripts", + "quick_validate.py", + ); + if (existsSync(pluginValidator)) runPythonValidator(pluginValidator, [ROOT]); + else console.log("Installed plugin validator not found; repository checks still passed."); + + if (!existsSync(skillValidator)) { + console.log("Installed Skill validator not found; repository checks still passed."); + return; + } + const skillsRoot = path.join(ROOT, "skills"); + for (const name of readdirSync(skillsRoot).sort()) { + const skillRoot = path.join(skillsRoot, name); + if (statSync(skillRoot).isDirectory()) runPythonValidator(skillValidator, [skillRoot]); + } +} + +validateRepositoryPlugin(); +console.log(`Repository plugin validation passed: ${PLUGIN_NAME}`); +runInstalledCodexValidators(); diff --git a/scripts/wrangler-cloud-adapter.mjs b/scripts/wrangler-cloud-adapter.mjs index 15e5ff35b..088e77a4d 100644 --- a/scripts/wrangler-cloud-adapter.mjs +++ b/scripts/wrangler-cloud-adapter.mjs @@ -129,6 +129,8 @@ export function createWranglerCloudAdapters({ task_relations: Number(row.task_relations), attachments: Number(row.attachments), workflow_workspaces: Number(row.workflow_workspaces), + project_context_entries: Number(row.project_context_entries), + project_context_revisions: Number(row.project_context_revisions), }, ])); }, diff --git a/server/app.mjs b/server/app.mjs index fcfac3d83..a5609d9e9 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -11,10 +11,19 @@ import { promisify } from "node:util"; import { DEFAULT_PROJECT_ID, + PROJECT_ID_PATTERN, TASK_STATUSES, isTaskPriority, isTaskStatus, } from "../shared/domain.mjs"; +import { + CONTEXT_BODY_MAX_BYTES, + CONTEXT_KINDS, + CONTEXT_LIST_DEFAULT_LIMIT, + CONTEXT_LIST_MAX_LIMIT, + CONTEXT_SOURCE_TYPES, + decodeContextCursor, +} from "../shared/project-context.mjs"; import { resolveCodexExecutable } from "../shared/codex-executable.mjs"; import { normalizeWorkflowSnapshot } from "../shared/workflow-control-flow.mjs"; import { AiChatService } from "./ai-chat.mjs"; @@ -46,7 +55,6 @@ const INLINE_ATTACHMENT_TYPES = new Set([ "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 CODEX_AGENT_ACTOR = { type: "agent", @@ -86,7 +94,7 @@ function sendEmpty(response, status, headers = {}) { response.end(); } -function toFetchRequest(request) { +function toFetchRequest(request, signal) { const headers = new Headers(); for (const [name, value] of Object.entries(request.headers)) { if (Array.isArray(value)) { @@ -95,7 +103,7 @@ function toFetchRequest(request) { headers.set(name, value); } } - const init = { method: request.method, headers }; + const init = { method: request.method, headers, signal }; if (request.method !== "GET" && request.method !== "HEAD") { init.body = Readable.toWeb(request); init.duplex = "half"; @@ -132,6 +140,24 @@ async function sendFetchResponse(response, upstream) { }); } +async function forwardCloudRequest(request, response, cloudProxy) { + const controller = new AbortController(); + const abort = () => controller.abort(); + const abortOnClose = () => { + if (!response.writableFinished) abort(); + }; + request.once("aborted", abort); + response.once("close", abortOnClose); + if (request.aborted || response.destroyed) abort(); + try { + const upstream = await cloudProxy.forward(toFetchRequest(request, controller.signal)); + await sendFetchResponse(response, upstream); + } finally { + request.off("aborted", abort); + response.off("close", abortOnClose); + } +} + function normalizeHostname(hostname) { return hostname.toLowerCase().replace(/^\[|\]$/g, ""); } @@ -644,6 +670,179 @@ function parseArchive(body) { return { version: parseVersion(body.version), threadId: parseThreadId(body.threadId) }; } +function parseContextKind(value, name = "kind") { + if (!CONTEXT_KINDS.includes(value)) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' must be one of: ${CONTEXT_KINDS.join(", ")}`); + } + return value; +} + +function parseContextSourceType(value, name = "sourceType") { + if (!CONTEXT_SOURCE_TYPES.includes(value)) { + throw new ApiError( + 400, + "INVALID_FIELD", + `'${name}' must be one of: ${CONTEXT_SOURCE_TYPES.join(", ")}`, + ); + } + return value; +} + +function parseContextBody(value, name = "body", { required = false } = {}) { + if (value === undefined) { + if (required) throw new ApiError(400, "INVALID_FIELD", `'${name}' is required`); + return undefined; + } + if (typeof value !== "string") { + throw new ApiError(400, "INVALID_FIELD", `'${name}' must be a string`); + } + if (Buffer.byteLength(value, "utf8") > CONTEXT_BODY_MAX_BYTES) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' cannot exceed 65536 UTF-8 bytes`); + } + return value; +} + +function parseContextTags(value, name = "tags") { + if (!Array.isArray(value) || value.length > 20) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' must be an array with at most 20 entries`); + } + const tags = value.map((tag, index) => { + if (typeof tag !== "string") { + throw new ApiError(400, "INVALID_FIELD", `'${name}[${index}]' must be a string`); + } + const normalized = tag.trim(); + if (normalized.length === 0 || normalized.length > 64) { + throw new ApiError(400, "INVALID_FIELD", `'${name}[${index}]' must contain 1 to 64 characters`); + } + return normalized; + }); + if (new Set(tags).size !== tags.length) { + throw new ApiError(400, "INVALID_FIELD", `'${name}' must contain unique values`); + } + return tags; +} + +function parseContextBoolean(value, name) { + if (value === "true" || value === true) return true; + if (value === "false" || value === false) return false; + throw new ApiError(400, "INVALID_QUERY_PARAMETER", `'${name}' must be true or false`); +} + +function parseContextCreate(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set([ + "kind", "title", "body", "tags", "sourceType", "sourceId", "sourceThreadId", + "pinned", "idempotencyKey", + ])); + if (body.pinned !== undefined && typeof body.pinned !== "boolean") { + throw new ApiError(400, "INVALID_FIELD", "'pinned' must be a boolean"); + } + return { + kind: parseContextKind(body.kind), + title: stringField(body.title, "title", { required: true, maxLength: 240 }), + body: parseContextBody(body.body, "body", { required: true }), + tags: body.tags === undefined ? [] : parseContextTags(body.tags), + sourceType: body.sourceType === undefined ? "manual" : parseContextSourceType(body.sourceType), + sourceId: stringField(body.sourceId ?? null, "sourceId", { + required: true, + nullable: true, + maxLength: 256, + }), + sourceThreadId: stringField(body.sourceThreadId ?? null, "sourceThreadId", { + required: true, + nullable: true, + maxLength: 256, + }), + pinned: body.pinned ?? false, + idempotencyKey: stringField(body.idempotencyKey ?? null, "idempotencyKey", { + required: true, + nullable: true, + maxLength: 256, + }), + }; +} + +function parseContextPatch(body) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["version", "kind", "title", "body", "tags", "pinned"])); + const version = parseVersion(body.version); + const changes = {}; + if (body.kind !== undefined) changes.kind = parseContextKind(body.kind); + if (body.title !== undefined) { + changes.title = stringField(body.title, "title", { required: true, maxLength: 240 }); + } + if (body.body !== undefined) changes.body = parseContextBody(body.body); + if (body.tags !== undefined) changes.tags = parseContextTags(body.tags); + if (body.pinned !== undefined) { + if (typeof body.pinned !== "boolean") throw new ApiError(400, "INVALID_FIELD", "'pinned' must be a boolean"); + changes.pinned = body.pinned; + } + if (Object.keys(changes).length === 0) { + throw new ApiError(400, "INVALID_BODY", "PATCH requires at least one context field"); + } + return { version, changes }; +} + +function parseContextMutation(body, routeLabel) { + assertPlainObject(body); + assertAllowedKeys(body, new Set(["version"])); + if (body.version === undefined) { + throw new ApiError(400, "INVALID_FIELD", `'version' is required for ${routeLabel}`); + } + return { version: parseVersion(body.version) }; +} + +function parseContextListFilters(searchParams) { + assertAllowedQuery( + searchParams, + new Set(["query", "kind", "tag", "pinned", "archived", "limit", "cursor"]), + "GET /api/projects/:projectId/context", + ); + const rawLimit = searchParams.get("limit"); + const limit = rawLimit === null ? CONTEXT_LIST_DEFAULT_LIMIT : Number(rawLimit); + if (!/^\d+$/.test(rawLimit ?? String(CONTEXT_LIST_DEFAULT_LIMIT)) + || !Number.isSafeInteger(limit) + || limit < 1 + || limit > CONTEXT_LIST_MAX_LIMIT) { + throw new ApiError(400, "INVALID_QUERY_PARAMETER", `'limit' must be an integer from 1 to ${CONTEXT_LIST_MAX_LIMIT}`); + } + const archived = searchParams.get("archived") ?? "false"; + if (!["false", "true", "all"].includes(archived)) { + throw new ApiError(400, "INVALID_QUERY_PARAMETER", "'archived' must be false, true, or all"); + } + const rawPinned = searchParams.get("pinned"); + const query = searchParams.get("query"); + if (query !== null && query.length > 256) { + throw new ApiError(400, "INVALID_QUERY_PARAMETER", "'query' cannot exceed 256 characters"); + } + const rawTag = searchParams.get("tag"); + const tag = rawTag === null ? undefined : stringField(rawTag, "tag", { required: true, maxLength: 64 }); + const kind = searchParams.get("kind"); + if (kind !== null) parseContextKind(kind, "kind"); + let cursor; + const rawCursor = searchParams.get("cursor"); + if (rawCursor !== null) { + try { + cursor = decodeContextCursor(rawCursor); + } catch { + throw new ApiError(400, "INVALID_QUERY_PARAMETER", "'cursor' is invalid"); + } + } + return { + query: query === null ? undefined : query, + kind: kind ?? undefined, + tag, + pinned: rawPinned === null ? undefined : parseContextBoolean(rawPinned, "pinned"), + archived, + limit, + cursor, + }; +} + +function contextAuthContext(request) { + return { mechanism: "local", actor: actorFromRequest(request) }; +} + function parseIssueRelationType(value) { if (!["parent", "blocks", "blocked_by", "related"].includes(value)) { throw new ApiError( @@ -1873,10 +2072,7 @@ export function createTaskboardServer(options = {}) { if (currentCloudConfig.remoteUrl) { assertLoopbackRequest(request); if (!isLocalCompanionRoute(pathname)) { - return sendFetchResponse( - response, - await cloudProxy.forward(toFetchRequest(request)), - ); + return await forwardCloudRequest(request, response, cloudProxy); } } } @@ -1948,6 +2144,42 @@ export function createTaskboardServer(options = {}) { return methodNotAllowed(response, ["GET", "PUT"]); } + const projectContextBriefRoute = pathname.match(/^\/api\/projects\/([^/]+)\/context\/brief$/); + if (projectContextBriefRoute) { + if (request.method !== "GET") return methodNotAllowed(response, ["GET"]); + assertNoQuery(url.searchParams, "GET /api/projects/:projectId/context/brief"); + const projectId = validateProjectId( + decodeRouteSegment(projectContextBriefRoute[1], "Project id"), + ); + return sendJson(response, 200, database.getProjectContextBrief(projectId)); + } + + const projectContextRoute = pathname.match(/^\/api\/projects\/([^/]+)\/context$/); + if (projectContextRoute) { + const projectId = validateProjectId( + decodeRouteSegment(projectContextRoute[1], "Project id"), + ); + if (request.method === "GET") { + return sendJson(response, 200, database.listProjectContext( + projectId, + parseContextListFilters(url.searchParams), + )); + } + if (request.method === "POST") { + assertNoQuery(url.searchParams, "POST /api/projects/:projectId/context"); + const result = database.createContextEntry( + projectId, + parseContextCreate(await readJson(request)), + contextAuthContext(request).actor, + ); + if (result.created) { + events.emit("context.created", { projectId, entry: result.entry }); + } + return sendJson(response, result.created ? 201 : 200, { entry: result.entry }); + } + return methodNotAllowed(response, ["GET", "POST"]); + } + const developmentContextsRoute = pathname.match(/^\/api\/projects\/([^/]+)\/development-contexts$/); if (developmentContextsRoute) { if (request.method !== "GET") return methodNotAllowed(response, ["GET"]); @@ -2026,6 +2258,54 @@ export function createTaskboardServer(options = {}) { return; } + const contextRevisionsRoute = pathname.match(/^\/api\/context\/([^/]+)\/revisions$/); + if (contextRevisionsRoute) { + if (request.method !== "GET") return methodNotAllowed(response, ["GET"]); + assertNoQuery(url.searchParams, "GET /api/context/:id/revisions"); + const id = decodeRouteSegment(contextRevisionsRoute[1], "Context entry id"); + return sendJson(response, 200, { revisions: database.listContextRevisions(id) }); + } + + const contextActionRoute = pathname.match(/^\/api\/context\/([^/]+)\/(archive|restore)$/); + if (contextActionRoute) { + if (request.method !== "POST") return methodNotAllowed(response, ["POST"]); + assertNoQuery(url.searchParams, "Context archive/restore routes"); + const id = decodeRouteSegment(contextActionRoute[1], "Context entry id"); + const { version } = parseContextMutation( + await readJson(request), + `POST /api/context/:id/${contextActionRoute[2]}`, + ); + const actor = contextAuthContext(request).actor; + const entry = contextActionRoute[2] === "archive" + ? database.archiveContextEntry(id, version, actor) + : database.restoreContextEntry(id, version, actor); + events.emit(`context.${contextActionRoute[2]}`, { projectId: entry.projectId, entry }); + return sendJson(response, 200, { entry }); + } + + const contextEntryRoute = pathname.match(/^\/api\/context\/([^/]+)$/); + if (contextEntryRoute) { + assertNoQuery(url.searchParams, "Context entry routes"); + const id = decodeRouteSegment(contextEntryRoute[1], "Context entry id"); + if (request.method === "GET") { + const entry = database.getContextEntry(id); + if (!entry) throw new ApiError(404, "CONTEXT_NOT_FOUND", `Context entry '${id}' does not exist`); + return sendJson(response, 200, { entry }); + } + if (request.method === "PATCH") { + const patch = parseContextPatch(await readJson(request)); + const entry = database.updateContextEntry( + id, + patch.version, + patch.changes, + contextAuthContext(request).actor, + ); + events.emit("context.updated", { projectId: entry.projectId, entry }); + return sendJson(response, 200, { entry }); + } + return methodNotAllowed(response, ["GET", "PATCH"]); + } + const taskRelationRoute = pathname.match( /^\/api\/tasks\/([^/]+)\/relations\/([^/]+)\/([^/]+)$/, ); diff --git a/server/cloud-proxy.mjs b/server/cloud-proxy.mjs index e211095c2..7cb2c2362 100644 --- a/server/cloud-proxy.mjs +++ b/server/cloud-proxy.mjs @@ -3,6 +3,8 @@ import path from "node:path"; import { DEFAULT_PROJECT_ID } from "../shared/domain.mjs"; import { normalizeCloudUrl } from "./cloud-config.mjs"; +const DEFAULT_CLOUD_PROXY_TIMEOUT_MS = 30_000; + const LOCAL_COMPANION_ROUTES = new Set([ "/health", "/api/meta", @@ -200,9 +202,20 @@ export function createCloudProxy({ configStore, getConfig, fetch: fetchImplementation = globalThis.fetch, + createTimeoutSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), + timeoutMs = DEFAULT_CLOUD_PROXY_TIMEOUT_MS, resolveDevelopmentContext, assertTaskProjectMoveAllowed, }) { + if (typeof fetchImplementation !== "function") { + throw new Error("fetch is not available"); + } + if (typeof createTimeoutSignal !== "function") { + throw new Error("createTimeoutSignal must be a function"); + } + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) { + throw new Error("timeoutMs must be a positive integer"); + } const readConfig = getConfig ?? (() => configStore.read()); const setProjectWorkspace = configStore?.setProjectWorkspace?.bind(configStore); @@ -228,10 +241,16 @@ export function createCloudProxy({ } const sourceUrl = new URL(request.url); - const upstreamUrl = new URL( - `${sourceUrl.pathname}${sourceUrl.search}`, - `${remoteUrl}/`, - ); + if (sourceUrl.pathname.startsWith("//") || sourceUrl.pathname.includes("\\")) { + throw new CloudProxyError(400, "INVALID_PATH", "Request path is invalid"); + } + const remoteOrigin = new URL(remoteUrl); + const upstreamUrl = new URL(remoteOrigin); + upstreamUrl.pathname = sourceUrl.pathname; + upstreamUrl.search = sourceUrl.search; + if (upstreamUrl.origin !== remoteOrigin.origin) { + throw new CloudProxyError(400, "INVALID_PATH", "Request path is invalid"); + } const headers = new Headers(request.headers); headers.delete("authorization"); headers.delete("host"); @@ -257,6 +276,10 @@ export function createCloudProxy({ method: request.method, headers, redirect: "manual", + signal: AbortSignal.any([ + request.signal, + createTimeoutSignal(timeoutMs), + ]), }; if (request.method !== "GET" && request.method !== "HEAD" && prepared.body !== null) { init.body = prepared.body; @@ -274,12 +297,22 @@ export function createCloudProxy({ error instanceof Error ? error.message : String(error), ); } - return localizeResponse(response, { - readConfig, - setProjectWorkspace, - projectWorkspace: prepared.projectWorkspace, - resolveDevelopmentContext, - }); + try { + return await localizeResponse(response, { + readConfig, + setProjectWorkspace, + projectWorkspace: prepared.projectWorkspace, + resolveDevelopmentContext, + }); + } catch (error) { + if (!init.signal.aborted) throw error; + throw new CloudProxyError( + 502, + "REMOTE_UNAVAILABLE", + `Cannot reach cloud taskboard at ${remoteUrl}`, + error instanceof Error ? error.message : String(error), + ); + } }, }; } diff --git a/server/database.mjs b/server/database.mjs index ef3164bce..3f17408f4 100644 --- a/server/database.mjs +++ b/server/database.mjs @@ -3,6 +3,14 @@ import { mkdirSync } from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; +import { + buildProjectContextBrief, + contextEntryFromRow, + contextRevisionFromRow, + encodeContextCursor, + sameContextCreatePayload, +} from "../shared/project-context.mjs"; + export class ApiError extends Error { constructor(status, code, message, details) { super(message); @@ -254,6 +262,45 @@ function projectFromRow(row) { }; } +function contextRevisionValues(entry, version, actor, timestamp, id = randomUUID()) { + return [ + id, + entry.id, + version, + entry.title, + entry.body, + entry.kind, + JSON.stringify(entry.tags), + actor.id, + actor.name, + timestamp, + ]; +} + +function sameContextCreatePayloadForExistingEntry(database, entry, input) { + if (entry.version <= 1) return sameContextCreatePayload(entry, input); + const originalRow = database.prepare(` + SELECT * + FROM project_context_revisions + WHERE entry_id = ? AND version = 1 + `).get(entry.id); + if (!originalRow) return sameContextCreatePayload(entry, input); + const original = contextRevisionFromRow(originalRow); + // Revision snapshots intentionally follow the public schema and do not carry + // the mutable pinned flag. Compare the original create fields, not later edits. + return sameContextCreatePayload( + { + ...entry, + kind: original.kind, + title: original.title, + body: original.body, + tags: original.tags, + }, + input, + { ignorePinned: true }, + ); +} + function projectSummaryFromRow(row) { return { projectId: row.project_id, @@ -435,6 +482,67 @@ export class TaskboardDatabase { updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS project_context_entries ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ( + 'requirement', 'decision', 'constraint', 'fact', 'risk', 'handoff', 'summary' + )), + title TEXT NOT NULL, + body TEXT NOT NULL CHECK (length(CAST(body AS BLOB)) <= 65536), + tags TEXT NOT NULL DEFAULT '[]' + CHECK (json_valid(tags) = 1 AND json_type(tags) = 'array'), + source_type TEXT NOT NULL CHECK (source_type IN ( + 'manual', 'issue', 'comment', 'thread_summary', 'agent' + )), + source_id TEXT, + source_thread_id TEXT, + author_type TEXT NOT NULL CHECK (author_type IN ('user', 'agent')), + author_id TEXT NOT NULL, + author_name TEXT NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)), + archived_at TEXT, + version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), + idempotency_key TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS project_context_entries_project_idempotency + ON project_context_entries(project_id, idempotency_key) + WHERE idempotency_key IS NOT NULL; + + CREATE INDEX IF NOT EXISTS project_context_entries_project_page + ON project_context_entries(project_id, archived_at, created_at DESC, id DESC); + + CREATE INDEX IF NOT EXISTS project_context_entries_project_kind + ON project_context_entries(project_id, archived_at, kind, created_at DESC, id DESC); + + CREATE INDEX IF NOT EXISTS project_context_entries_project_pinned + ON project_context_entries(project_id, archived_at, pinned DESC, updated_at DESC, id); + + CREATE TABLE IF NOT EXISTS project_context_revisions ( + id TEXT PRIMARY KEY, + entry_id TEXT NOT NULL REFERENCES project_context_entries(id) ON DELETE CASCADE, + version INTEGER NOT NULL CHECK (version > 0), + title TEXT NOT NULL, + body TEXT NOT NULL CHECK (length(CAST(body AS BLOB)) <= 65536), + kind TEXT NOT NULL CHECK (kind IN ( + 'requirement', 'decision', 'constraint', 'fact', 'risk', 'handoff', 'summary' + )), + tags TEXT NOT NULL DEFAULT '[]' + CHECK (json_valid(tags) = 1 AND json_type(tags) = 'array'), + author_id TEXT NOT NULL, + author_name TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS project_context_revisions_entry_version_unique + ON project_context_revisions(entry_id, version); + + CREATE INDEX IF NOT EXISTS project_context_revisions_entry_versions + ON project_context_revisions(entry_id, version DESC); + CREATE TABLE IF NOT EXISTS project_summaries ( project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE, summary TEXT, @@ -828,6 +936,307 @@ export class TaskboardDatabase { return row ? projectFromRow(row) : null; } + getContextEntry(id) { + const row = this.database.prepare(` + SELECT * FROM project_context_entries WHERE id = ? + `).get(id); + return row ? contextEntryFromRow(row) : null; + } + + listProjectContext(projectId, filters) { + if (!this.getProject(projectId)) { + throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`); + } + + const where = ["project_context_entries.project_id = ?"]; + const values = [projectId]; + if (filters.archived === "false") { + where.push("project_context_entries.archived_at IS NULL"); + } else if (filters.archived === "true") { + where.push("project_context_entries.archived_at IS NOT NULL"); + } + if (filters.kind !== undefined) { + where.push("project_context_entries.kind = ?"); + values.push(filters.kind); + } + if (filters.tag !== undefined) { + where.push(`EXISTS ( + SELECT 1 FROM json_each(project_context_entries.tags) + WHERE json_each.value = ? + )`); + values.push(filters.tag); + } + if (filters.pinned !== undefined) { + where.push("project_context_entries.pinned = ?"); + values.push(filters.pinned ? 1 : 0); + } + if (filters.query !== undefined) { + const escaped = filters.query + .replaceAll("\\", "\\\\") + .replaceAll("%", "\\%") + .replaceAll("_", "\\_"); + const pattern = `%${escaped}%`; + where.push(`( + project_context_entries.title LIKE ? ESCAPE '\\' + OR project_context_entries.body LIKE ? ESCAPE '\\' + OR EXISTS ( + SELECT 1 FROM json_each(project_context_entries.tags) + WHERE json_each.value LIKE ? ESCAPE '\\' + ) + )`); + values.push(pattern, pattern, pattern); + } + if (filters.cursor) { + where.push(`( + project_context_entries.created_at < ? + OR ( + project_context_entries.created_at = ? + AND project_context_entries.id < ? + ) + )`); + values.push(filters.cursor[0], filters.cursor[0], filters.cursor[1]); + } + + const rows = this.database.prepare(` + SELECT * + FROM project_context_entries + WHERE ${where.join(" AND ")} + ORDER BY project_context_entries.created_at DESC, project_context_entries.id DESC + LIMIT ? + `).all(...values, filters.limit + 1); + const hasMore = rows.length > filters.limit; + const entries = rows.slice(0, filters.limit).map(contextEntryFromRow); + return { + entries, + nextCursor: hasMore + ? encodeContextCursor([ + rows[filters.limit - 1].created_at, + rows[filters.limit - 1].id, + ]) + : null, + }; + } + + getProjectContextBrief(projectId) { + if (!this.getProject(projectId)) { + throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`); + } + const entries = this.database.prepare(` + SELECT * + FROM project_context_entries + WHERE project_id = ? + AND archived_at IS NULL + AND (pinned = 1 OR kind IN ('requirement', 'constraint', 'decision', 'risk', 'handoff', 'summary')) + ORDER BY + CASE + WHEN pinned = 1 THEN 0 + WHEN kind IN ('requirement', 'constraint', 'decision') THEN 1 + WHEN kind IN ('risk', 'handoff') THEN 2 + ELSE 3 + END, + updated_at DESC, + id ASC + LIMIT 1000 + `).all(projectId).map(contextEntryFromRow); + return buildProjectContextBrief(entries); + } + + listContextRevisions(id) { + const entry = this.getContextEntry(id); + if (!entry) { + throw new ApiError(404, "CONTEXT_NOT_FOUND", `Context entry '${id}' does not exist`); + } + return this.database.prepare(` + SELECT * + FROM project_context_revisions + WHERE entry_id = ? + ORDER BY version ASC, id ASC + `).all(entry.id).map(contextRevisionFromRow); + } + + createContextEntry(projectId, input, actor) { + this.database.exec("BEGIN IMMEDIATE"); + try { + if (!this.database.prepare("SELECT 1 FROM projects WHERE id = ?").get(projectId)) { + throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`); + } + if (input.idempotencyKey !== null) { + const existingRow = this.database.prepare(` + SELECT * FROM project_context_entries + WHERE project_id = ? AND idempotency_key = ? + `).get(projectId, input.idempotencyKey); + if (existingRow) { + const existing = contextEntryFromRow(existingRow); + if (!sameContextCreatePayloadForExistingEntry(this.database, existing, input)) { + throw new ApiError( + 409, + "IDEMPOTENCY_CONFLICT", + "Idempotency key was already used with different context content", + ); + } + this.database.exec("COMMIT"); + return { entry: existing, created: false }; + } + } + + const id = randomUUID(); + const timestamp = now(); + this.database.prepare(` + INSERT INTO project_context_entries ( + id, project_id, kind, title, body, tags, + source_type, source_id, source_thread_id, + author_type, author_id, author_name, pinned, archived_at, + version, idempotency_key, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 1, ?, ?, ?) + `).run( + id, + projectId, + input.kind, + input.title, + input.body, + JSON.stringify(input.tags), + input.sourceType, + input.sourceId, + input.sourceThreadId, + actor.type, + actor.id, + actor.name, + input.pinned ? 1 : 0, + input.idempotencyKey, + timestamp, + timestamp, + ); + const entry = this.getContextEntry(id); + this.database.prepare(` + INSERT INTO project_context_revisions ( + id, entry_id, version, title, body, kind, tags, + author_id, author_name, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(...contextRevisionValues(entry, 1, actor, timestamp)); + this.database.exec("COMMIT"); + return { entry: this.getContextEntry(id), created: true }; + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + } + + updateContextEntry(id, expectedVersion, changes, actor) { + this.database.exec("BEGIN IMMEDIATE"); + try { + const current = this.#requireContextEntry(id); + this.#requireContextVersion(current, expectedVersion); + const next = { + ...current, + ...changes, + version: current.version + 1, + }; + const timestamp = now(); + this.database.prepare(` + INSERT INTO project_context_revisions ( + id, entry_id, version, title, body, kind, tags, + author_id, author_name, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(...contextRevisionValues(next, next.version, actor, timestamp)); + const assignments = []; + const values = []; + const columns = { + kind: "kind", + title: "title", + body: "body", + tags: "tags", + pinned: "pinned", + }; + for (const [key, column] of Object.entries(columns)) { + if (!Object.hasOwn(changes, key)) continue; + assignments.push(`${column} = ?`); + values.push(key === "tags" ? JSON.stringify(changes[key]) : key === "pinned" ? (changes[key] ? 1 : 0) : changes[key]); + } + assignments.push("version = version + 1", "updated_at = ?"); + values.push(timestamp, id, expectedVersion); + const result = this.database.prepare(` + UPDATE project_context_entries + SET ${assignments.join(", ")} + WHERE id = ? AND version = ? + `).run(...values); + if (result.changes !== 1) this.#throwContextMissingOrConflict(id, expectedVersion); + this.database.exec("COMMIT"); + return this.getContextEntry(id); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + } + + archiveContextEntry(id, expectedVersion, actor) { + return this.#setContextArchived(id, expectedVersion, actor, true); + } + + restoreContextEntry(id, expectedVersion, actor) { + return this.#setContextArchived(id, expectedVersion, actor, false); + } + + #setContextArchived(id, expectedVersion, actor, archived) { + this.database.exec("BEGIN IMMEDIATE"); + try { + const current = this.#requireContextEntry(id); + this.#requireContextVersion(current, expectedVersion); + if (archived && current.archivedAt !== null) { + throw new ApiError(409, "CONTEXT_ALREADY_ARCHIVED", "Context entry is already archived"); + } + if (!archived && current.archivedAt === null) { + throw new ApiError(409, "CONTEXT_NOT_ARCHIVED", "Only archived context entries can be restored"); + } + const timestamp = now(); + const next = { ...current, version: current.version + 1 }; + this.database.prepare(` + INSERT INTO project_context_revisions ( + id, entry_id, version, title, body, kind, tags, + author_id, author_name, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(...contextRevisionValues(next, next.version, actor, timestamp)); + const result = this.database.prepare(` + UPDATE project_context_entries + SET archived_at = ?, version = version + 1, updated_at = ? + WHERE id = ? AND version = ? + `).run(archived ? timestamp : null, timestamp, id, expectedVersion); + if (result.changes !== 1) this.#throwContextMissingOrConflict(id, expectedVersion); + this.database.exec("COMMIT"); + return this.getContextEntry(id); + } catch (error) { + this.database.exec("ROLLBACK"); + throw error; + } + } + + #requireContextEntry(id) { + const entry = this.getContextEntry(id); + if (!entry) { + throw new ApiError(404, "CONTEXT_NOT_FOUND", `Context entry '${id}' does not exist`); + } + return entry; + } + + #requireContextVersion(entry, expectedVersion) { + if (entry.version !== expectedVersion) { + throw new ApiError(409, "VERSION_CONFLICT", "Context entry was changed by another client", { + expectedVersion, + actualVersion: entry.version, + }); + } + } + + #throwContextMissingOrConflict(id, expectedVersion) { + const entry = this.getContextEntry(id); + if (!entry) { + throw new ApiError(404, "CONTEXT_NOT_FOUND", `Context entry '${id}' does not exist`); + } + throw new ApiError(409, "VERSION_CONFLICT", "Context entry was changed by another client", { + expectedVersion, + actualVersion: entry.version, + }); + } + getProjectSummary(projectId) { const row = this.database.prepare(` SELECT project_id, summary, generated_at, attempted_at, error diff --git a/shared/domain.mjs b/shared/domain.mjs index cadb594b5..9f1c2d7a5 100644 --- a/shared/domain.mjs +++ b/shared/domain.mjs @@ -10,6 +10,7 @@ export const TASK_STATUSES = [ export const TASK_PRIORITIES = ["none", "urgent", "high", "medium", "low"]; export const DEFAULT_PROJECT_ID = "local"; +export const PROJECT_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; export function isTaskStatus(value) { return TASK_STATUSES.includes(value); diff --git a/shared/project-context.mjs b/shared/project-context.mjs new file mode 100644 index 000000000..21268149c --- /dev/null +++ b/shared/project-context.mjs @@ -0,0 +1,223 @@ +export const CONTEXT_KINDS = Object.freeze([ + "requirement", + "decision", + "constraint", + "fact", + "risk", + "handoff", + "summary", +]); + +export const CONTEXT_SOURCE_TYPES = Object.freeze([ + "manual", + "issue", + "comment", + "thread_summary", + "agent", +]); + +export const CONTEXT_BODY_MAX_BYTES = 65_536; +export const CONTEXT_LIST_DEFAULT_LIMIT = 50; +export const CONTEXT_LIST_MAX_LIMIT = 100; +export const CONTEXT_BRIEF_MAX_CHARS = 12_000; + +const CONTEXT_CURSOR_VALUE_MAX_CHARS = 512; +const CONTEXT_CURSOR_MAX_CHARS = 8_192; +const BRIEF_PRIMARY_KINDS = new Set(["requirement", "constraint", "decision"]); +const BRIEF_SECONDARY_KINDS = new Set(["risk", "handoff"]); + +export function contextEntryFromRow(row) { + return { + id: row.id, + projectId: row.project_id, + kind: row.kind, + title: row.title, + body: row.body, + tags: parseRowTags(row.tags), + sourceType: row.source_type, + sourceId: row.source_id ?? null, + sourceThreadId: row.source_thread_id ?? null, + authorType: row.author_type, + authorId: row.author_id, + authorName: row.author_name, + pinned: row.pinned === true || row.pinned === 1, + archivedAt: row.archived_at ?? null, + version: row.version, + idempotencyKey: row.idempotency_key ?? null, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export function contextRevisionFromRow(row) { + return { + id: row.id, + entryId: row.entry_id, + version: row.version, + title: row.title, + body: row.body, + kind: row.kind, + tags: parseRowTags(row.tags), + authorId: row.author_id, + authorName: row.author_name, + createdAt: row.created_at, + }; +} + +export function encodeContextCursor(tuple) { + validateCursorTuple(tuple); + const bytes = new TextEncoder().encode(JSON.stringify(tuple)); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/u, ""); +} + +export function decodeContextCursor(cursor) { + if ( + typeof cursor !== "string" + || cursor.length === 0 + || cursor.length > CONTEXT_CURSOR_MAX_CHARS + || !/^[A-Za-z0-9_-]+$/u.test(cursor) + || cursor.length % 4 === 1 + ) { + throw invalidCursorError(); + } + + try { + const base64 = cursor + .replaceAll("-", "+") + .replaceAll("_", "/") + .padEnd(cursor.length + ((4 - (cursor.length % 4)) % 4), "="); + const binary = atob(base64); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + validateCursorTuple(value); + if (encodeContextCursor(value) !== cursor) throw invalidCursorError(); + return Object.freeze(value); + } catch { + throw invalidCursorError(); + } +} + +export function sameContextCreatePayload(entry, input, { ignorePinned = false } = {}) { + return entry.kind === input.kind + && entry.title === input.title + && entry.body === input.body + && sameStringArray(entry.tags ?? [], input.tags ?? []) + && entry.sourceType === (input.sourceType ?? "manual") + && (entry.sourceId ?? null) === (input.sourceId ?? null) + && (entry.sourceThreadId ?? null) === (input.sourceThreadId ?? null) + && (ignorePinned || entry.pinned === (input.pinned ?? false)); +} + +export function buildProjectContextBrief(entries) { + const selected = entries + .filter((entry) => entry.archivedAt == null) + .map((entry) => ({ entry, priority: briefPriority(entry) })) + .filter(({ priority }) => Number.isFinite(priority)) + .sort((left, right) => ( + left.priority - right.priority + || compareDescending(left.entry.updatedAt, right.entry.updatedAt) + || compareAscending(String(left.entry.id), String(right.entry.id)) + )); + + const includedEntryIds = []; + const seenEntryIds = new Set(); + let brief = ""; + let truncated = false; + let unpinnedSummarySelected = false; + + for (const { entry } of selected) { + if (seenEntryIds.has(entry.id)) continue; + if (!entry.pinned && entry.kind === "summary") { + if (unpinnedSummarySelected) continue; + unpinnedSummarySelected = true; + } + seenEntryIds.add(entry.id); + + const block = formatBriefEntry(entry); + const separator = brief.length === 0 ? "" : "\n\n"; + const available = CONTEXT_BRIEF_MAX_CHARS - brief.length; + if (separator.length + block.length <= available) { + brief += separator + block; + includedEntryIds.push(entry.id); + continue; + } + + truncated = true; + const availableForBlock = available - separator.length; + if (availableForBlock > 0) { + brief += separator + block.slice(0, availableForBlock); + includedEntryIds.push(entry.id); + } + break; + } + + return { brief, includedEntryIds, truncated }; +} + +function parseRowTags(tags) { + return typeof tags === "string" ? JSON.parse(tags) : tags; +} + +function validateCursorTuple(tuple) { + if ( + !Array.isArray(tuple) + || tuple.length !== 2 + || tuple.some((value) => ( + typeof value !== "string" + || value.length === 0 + || value.length > CONTEXT_CURSOR_VALUE_MAX_CHARS + )) + ) { + throw invalidCursorError(); + } +} + +function invalidCursorError() { + return new Error("Invalid project context cursor"); +} + +function sameStringArray(left, right) { + return Array.isArray(left) + && Array.isArray(right) + && left.length === right.length + && left.every((value, index) => value === right[index]); +} + +function briefPriority(entry) { + if (entry.pinned) return 0; + if (BRIEF_PRIMARY_KINDS.has(entry.kind)) return 1; + if (BRIEF_SECONDARY_KINDS.has(entry.kind)) return 2; + if (entry.kind === "summary") return 3; + return Number.POSITIVE_INFINITY; +} + +function compareDescending(left, right) { + if (left === right) return 0; + return left < right ? 1 : -1; +} + +function compareAscending(left, right) { + if (left === right) return 0; + return left < right ? -1 : 1; +} + +function formatBriefEntry(entry) { + const lines = [`## [${entry.kind}] ${entry.title}`]; + if (entry.tags.length > 0) { + lines.push(`- Tags: ${entry.tags.map((tag) => JSON.stringify(tag)).join(", ")}`); + } + const source = [entry.sourceType]; + if (entry.sourceId !== null) source.push(`id=${JSON.stringify(entry.sourceId)}`); + if (entry.sourceThreadId !== null) { + source.push(`thread=${JSON.stringify(entry.sourceThreadId)}`); + } + lines.push(`- Source: ${source.join("; ")}`); + lines.push(`- Updated: ${entry.updatedAt}`); + lines.push("", entry.body); + return lines.join("\n"); +} diff --git a/skills/manage-taskboard/SKILL.md b/skills/manage-taskboard/SKILL.md index a440d4288..41ebfa668 100644 --- a/skills/manage-taskboard/SKILL.md +++ b/skills/manage-taskboard/SKILL.md @@ -5,17 +5,27 @@ description: Manage taskboard work with taskctl. Use for e-taskboard prompts, is # Manage Taskboard -Use `taskctl` for every project, issue, relation, and comment operation. Consume its JSON output. Use the exact issue identifier returned by the taskboard or supplied in the prompt. Never assume, derive, or rewrite an identifier prefix. +Use `taskctl` for every project, issue, relation, and comment operation. Consume its JSON output. Use the exact issue identifier returned by the taskboard or supplied in the prompt. Never assume, derive, or rewrite an identifier prefix. For shared context operations, use the `$shared-project-context` Skill's MCP tools, not `taskctl`. Open only the relevant section of [references/cli.md](references/cli.md) when command syntax is needed. ## Core workflow -1. For an existing issue, run `issue get` and `comment list` before acting. Treat comments as current requirements, including returned work. +1. For an existing issue, load its task and shared context in this order: + - Call `taskboard_context_current_project`. Stop and fix the workspace mapping if it does not resolve the intended project. + - Read the latest issue content and all comments: run `issue get` and `comment list` before acting. Treat comments as current requirements, including returned work. + - Call `taskboard_context_brief` for the resolved project. + - Call `taskboard_context_search` using the issue title, labels, and meaningful terms from its description. Use `taskboard_context_get` when a result needs full inspection. + - Treat shared requirements, constraints, decisions, and risks as work constraints. If entries conflict, report each source and `updatedAt`; never choose silently. 2. For a new durable requirement, run `context current` and search existing project issues before creating one. Update a matching issue instead of creating a duplicate. Do not track trivial requests. 3. Before starting or resuming work, read the issue again and move it to `in_progress` with its current `version`. Stop if the status changed or the write conflicts. 4. Execute only the requested work in the issue's branch or worktree when one is bound. -5. Verify the requested operation path. Add a comment with the changes, verification result, outcome, and remaining risks. Read the issue again, then move it to `in_review` with its current `version`. +5. Verify the requested operation path. + - Before the handoff, publish only durable decision, constraint, current state, risk, handoff, summary, or next step information that will help later work. + - Use a stable issue-scoped `idempotencyKey`, such as `issue:::`. Reuse it for retries; update an existing entry with its current `version` when the durable fact changes. + - MCP publication is agent-authored and must remain `sourceType: agent`; do not present an inference as user-confirmed. + - Never publish credentials, private paths, raw chats, full logs, or temporary diagnostics. Before publishing potentially sensitive material, obtain explicit user approval. + - Add a comment with the changes, verification result, outcome, and remaining risks. Read the issue again, then move it to `in_review` with its current `version`. 6. Move an issue to `done` only after the user explicitly accepts it or asks to complete it. Use `blocked` when work cannot continue and `canceled` when it will not continue. ## Other operations diff --git a/skills/shared-project-context/SKILL.md b/skills/shared-project-context/SKILL.md new file mode 100644 index 000000000..52a92fc71 --- /dev/null +++ b/skills/shared-project-context/SKILL.md @@ -0,0 +1,41 @@ +--- +name: shared-project-context +description: Read, search, publish, update, and archive durable project context through the Dashi Taskboard MCP server. Use before project work to load shared requirements, decisions, constraints, facts, risks, handoffs, or summaries, and after work when a durable outcome should be shared across devices or collaborators. +--- + +# Shared Project Context + +Use the taskboard context MCP tools exclusively. Never use direct database or SQLite access, `curl`, local companion files, or Codex internal state for shared context. A cloud error remains an error: there is no local fallback or dual write. + +## Read Workflow + +1. Call `taskboard_context_current_project`. Stop on `PROJECT_MAPPING_NOT_FOUND`; map this workspace to the intended taskboard project before continuing. Do not select an unrelated project. +2. Call `taskboard_context_brief` for prioritized requirements, constraints, decisions, risks, handoffs, and the latest summary. +3. Call `taskboard_context_search` with focused terms, kinds, or tags. Follow `nextCursor` when more results matter. +4. Call `taskboard_context_get` for each result whose full body or provenance is needed. + +Context can be stale or contradictory. When entries conflict, show each source and `updatedAt`, explain the conflict, and never choose silently. Ask for clarification when the conflict changes the requested work. + +## Write Workflow + +- Use `taskboard_context_publish` only for durable `requirement`, `decision`, `constraint`, `fact`, `risk`, `handoff`, or `summary` entries. Put a durable next step in an appropriate handoff or summary. +- Choose a stable `idempotencyKey` tied to the source and topic. Reuse the same key for retries so a replay cannot create a duplicate. +- MCP publication is agent-derived and remains `sourceType: agent`. Include `sourceId` or `sourceThreadId` only as provenance; neither field grants access to or restores a Codex session. +- Before changing an entry, load it with `taskboard_context_get`, then call `taskboard_context_update` with its current `version`. On a conflict, reload and reconcile before retrying. +- Before removing an entry from active context, load it, then call `taskboard_context_archive` with its current `version`. Archiving is destructive to active visibility; confirm the intent when it is not already explicit. + +Never publish credentials, cookies, tokens, shared passwords, private device paths, raw chats, full logs, temporary diagnostics, or complete environment data. Before publishing potentially sensitive material, obtain explicit user approval. Keep inferences labeled as agent conclusions rather than user-confirmed requirements. + +## Error Actions + +- `PROJECT_MAPPING_NOT_FOUND`: map this device's workspace and retry. +- `PROJECT_NOT_FOUND` or `CONTEXT_NOT_FOUND`: refresh the project or entry identifier; do not fabricate an entry. +- `VERSION_CONFLICT` (or the compatibility alias `CONTEXT_VERSION_CONFLICT`): reload the entry and retry only after reconciling the current version. +- `IDEMPOTENCY_CONFLICT` (or `CONTEXT_IDEMPOTENCY_CONFLICT`): reuse the original content or deliberately choose a new key for a different entry. +- `CONTEXT_ALREADY_ARCHIVED`: reload the entry and treat it as archived; do not retry blindly. +- `CONTEXT_NOT_ARCHIVED`: reload before any restore workflow; this plugin does not expose a restore tool. +- `UNAUTHORIZED`: authenticate through the local companion, then retry. Never pass an Authorization header to the MCP tool. +- `CLOUD_NOT_CONFIGURED`: configure cloud collaboration in the companion before retrying. +- `REMOTE_UNAVAILABLE`: report the cloud outage and stop; never fall back to local context. +- `COMPANION_UNAVAILABLE`: start the local companion and retry. +- `SERVER_MISCONFIGURED`: ask the taskboard administrator to inspect server configuration. diff --git a/skills/shared-project-context/agents/openai.yaml b/skills/shared-project-context/agents/openai.yaml new file mode 100644 index 000000000..d38718d01 --- /dev/null +++ b/skills/shared-project-context/agents/openai.yaml @@ -0,0 +1,11 @@ +interface: + display_name: "Shared Project Context" + short_description: "Read and publish durable project context" + default_prompt: "Use $shared-project-context to load this project context before work." +dependencies: + tools: + - type: "mcp" + value: "dashi-taskboard" + description: "Dashi Taskboard shared project context tools" +policy: + allow_implicit_invocation: true diff --git a/test/cloud-companion.test.mjs b/test/cloud-companion.test.mjs index 248ed485a..ba78ac68d 100644 --- a/test/cloud-companion.test.mjs +++ b/test/cloud-companion.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { mkdtemp, readFile, rm, stat } 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"; @@ -199,6 +200,24 @@ test("cloud proxy replaces client identity with Basic Auth and makes exactly one }); }); +test("cloud proxy rejects network-path URLs before attaching cloud credentials", async () => { + const { createCloudProxy } = await importCloudProxy(); + const calls = []; + const proxy = createCloudProxy({ + configStore: memoryConfigStore(), + fetch: async (url, init) => { + calls.push({ url: url.toString(), init }); + return jsonResponse({ projects: [] }); + }, + }); + + await assert.rejects( + proxy.forward(new Request("http://127.0.0.1//outside.example.test/collect")), + (error) => error?.status === 400 && error?.code === "INVALID_PATH", + ); + assert.equal(calls.length, 0); +}); + test("cloud companion blocks project moves for issue-linked local AI chats", async () => { const directory = await mkdtemp(path.join(os.tmpdir(), "taskboard-cloud-chat-move-")); temporaryDirectories.push(directory); @@ -272,6 +291,114 @@ test("configured cloud mode fails explicitly and never falls back to the local d assert.equal(localFallbackCalls, 0); }); +test("cloud proxy applies its deadline while consuming an upstream JSON body", async () => { + const { createCloudProxy } = await importCloudProxy(); + const timeoutController = new AbortController(); + let requestedTimeout; + let upstreamSignal; + let startedResolve; + const started = new Promise((resolve) => { + startedResolve = resolve; + }); + const proxy = createCloudProxy({ + configStore: memoryConfigStore(), + timeoutMs: 3210, + createTimeoutSignal(timeoutMs) { + requestedTimeout = timeoutMs; + return timeoutController.signal; + }, + fetch: async (_url, init) => { + upstreamSignal = init.signal; + startedResolve(); + return new Response(new ReadableStream({ + start(controller) { + if (!init.signal) { + controller.error(new Error("missing upstream signal")); + return; + } + init.signal.addEventListener("abort", () => { + controller.error(init.signal.reason); + }, { once: true }); + }, + }), { + headers: { "content-type": "application/json" }, + }); + }, + }); + + const pendingError = proxy.forward( + new Request("http://127.0.0.1:47823/api/projects"), + ).then(() => null, (error) => error); + await started; + timeoutController.abort(new DOMException("deadline exceeded", "TimeoutError")); + const error = await pendingError; + + assert.equal(requestedTimeout, 3210); + assert.ok(upstreamSignal instanceof AbortSignal); + assert.equal(upstreamSignal.aborted, true); + assert.equal(error?.status, 502); + assert.equal(error?.code, "REMOTE_UNAVAILABLE"); +}); + +test("disconnecting a companion client aborts the in-flight cloud request", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "taskboard-cloud-abort-")); + temporaryDirectories.push(directory); + let startedResolve; + let abortedResolve; + let releaseUpstream; + const started = new Promise((resolve) => { + startedResolve = resolve; + }); + const aborted = new Promise((resolve) => { + abortedResolve = resolve; + }); + const app = createTaskboardServer({ + dataDirectory: directory, + cloudConfigStore: memoryConfigStore(), + remoteFetch: async (_url, init) => new Promise((_resolve, reject) => { + startedResolve(init.signal); + releaseUpstream = () => reject(new Error("test cleanup")); + init.signal?.addEventListener("abort", () => { + abortedResolve(); + reject(init.signal.reason); + }, { once: true }); + }), + }); + const address = await app.listen({ host: "127.0.0.1", port: 0 }); + let clientRequest; + + try { + clientRequest = httpRequest({ + host: "127.0.0.1", + port: address.port, + path: "/api/projects", + method: "GET", + }); + clientRequest.on("error", () => {}); + clientRequest.end(); + const upstreamSignal = await started; + + clientRequest.destroy(); + let timeout; + try { + await Promise.race([ + aborted, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error("upstream request was not aborted")), 1000); + }), + ]); + } finally { + clearTimeout(timeout); + } + assert.ok(upstreamSignal instanceof AbortSignal); + assert.equal(upstreamSignal.aborted, true); + } finally { + clientRequest?.destroy(); + releaseUpstream?.(); + await app.close(); + } +}); + test("cloud proxy preserves upstream 401 responses and binary attachment streams", async () => { const { createCloudProxy } = await importCloudProxy(); const unauthorized = new Response("invalid shared key", { @@ -322,6 +449,12 @@ test("cloud routing keeps machine-specific capability endpoints in the local com for (const pathname of [ "/api/projects", "/api/projects/portfolio/workflow-workspace", + "/api/projects/portfolio/context", + "/api/projects/portfolio/context/brief", + "/api/context/context-1", + "/api/context/context-1/revisions", + "/api/context/context-1/archive", + "/api/context/context-1/restore", "/api/tasks", "/api/tasks/PORTFOLIO-1", "/api/comments/comment-1", @@ -585,6 +718,89 @@ test("configured server proxies business APIs without touching local rows and ad } }); +test("configured server context writes never fall back to or double-write local SQLite", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "taskboard-context-cloud-server-")); + temporaryDirectories.push(directory); + const configPath = path.join(directory, "companion.json"); + const { createCloudConfigStore } = await importCloudConfig(); + await createCloudConfigStore({ configPath }).configure({ + remoteUrl: "https://tasks.example.test", + actorName: "Alice", + sharedKey: "two-person-shared-key", + }); + const upstreamCalls = []; + let failRemote = false; + const remoteEntry = { + id: "remote-context", + projectId: "portfolio", + kind: "decision", + title: "Remote only", + body: "Cloud owns this row", + tags: [], + sourceType: "manual", + sourceId: null, + sourceThreadId: null, + authorType: "user", + authorId: "basic:alice", + authorName: "Alice", + pinned: false, + archivedAt: null, + version: 1, + idempotencyKey: "remote-only", + createdAt: "2026-08-05T00:00:00.000Z", + updatedAt: "2026-08-05T00:00:00.000Z", + }; + const app = createTaskboardServer({ + dataDirectory: directory, + cloudConfigPath: configPath, + remoteFetch: async (url, init) => { + upstreamCalls.push({ url: url.toString(), init }); + if (failRemote) throw new Error("cloud unavailable"); + return jsonResponse({ entry: remoteEntry }, 201); + }, + }); + const address = await app.listen({ port: 0 }); + const baseUrl = `http://127.0.0.1:${address.port}`; + const contextBody = { + kind: "decision", + title: "Remote only", + body: "Cloud owns this row", + idempotencyKey: "remote-only", + }; + + try { + const created = await fetch(`${baseUrl}/api/projects/portfolio/context`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(contextBody), + }); + assert.equal(created.status, 201); + assert.deepEqual(await created.json(), { entry: remoteEntry }); + assert.equal(upstreamCalls.length, 1); + assert.equal(upstreamCalls[0].url, "https://tasks.example.test/api/projects/portfolio/context"); + assert.equal( + app.database.database.prepare("SELECT COUNT(*) AS count FROM project_context_entries").get().count, + 0, + ); + + failRemote = true; + const failed = await fetch(`${baseUrl}/api/projects/portfolio/context`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...contextBody, idempotencyKey: "remote-failure" }), + }); + assert.equal(failed.status, 502); + assert.equal((await failed.json()).error.code, "REMOTE_UNAVAILABLE"); + assert.equal(upstreamCalls.length, 2); + assert.equal( + app.database.database.prepare("SELECT COUNT(*) AS count FROM project_context_entries").get().count, + 0, + ); + } finally { + await app.close(); + } +}); + test("cloud mode exposes machine capabilities only to loopback while local mode keeps LAN access", async (t) => { const lanAddress = firstLanAddress(); if (!lanAddress) { diff --git a/test/cloud-migration.test.mjs b/test/cloud-migration.test.mjs index e5d9d0885..5231e9946 100644 --- a/test/cloud-migration.test.mjs +++ b/test/cloud-migration.test.mjs @@ -26,6 +26,7 @@ import { runCli, writeCloudMigrationBundle, } from "../scripts/migrate-to-cloud.mjs"; +import { TaskboardDatabase } from "../server/database.mjs"; import { createCloudWorkerHarness } from "./helpers/cloud-worker-harness.mjs"; const fixtures = []; @@ -373,6 +374,8 @@ function expectedProjectCounts() { attachments: 2, task_relations: 1, workflow_workspaces: 1, + project_context_entries: 0, + project_context_revisions: 0, }, beta: { projects: 1, @@ -381,6 +384,8 @@ function expectedProjectCounts() { attachments: 1, task_relations: 0, workflow_workspaces: 0, + project_context_entries: 0, + project_context_revisions: 0, }, }; } @@ -394,6 +399,8 @@ function expectedCloudBaselineCounts() { attachments: 0, task_relations: 0, workflow_workspaces: 0, + project_context_entries: 0, + project_context_revisions: 0, }, }; } @@ -575,6 +582,8 @@ test("cloud import calls D1 and R2 adapters, then verifies project counts and ob "task_relations", "attachments", "workflow_workspaces", + "project_context_entries", + "project_context_revisions", ]); assert.deepEqual(result.counts.byProject, expectedProjectCounts()); assert.equal(result.attachments.verified, 3); @@ -587,6 +596,82 @@ test("cloud import calls D1 and R2 adapters, then verifies project counts and ob } }); +test("context entries and revisions survive a local-to-D1 migration round trip", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "project-context-cloud-migration-")); + const databasePath = path.join(directory, "taskboard.sqlite"); + const attachmentsDirectory = path.join(directory, "attachments"); + await mkdir(attachmentsDirectory, { recursive: true }); + const actor = { type: "user", id: "alice", name: "Alice" }; + let database; + try { + database = new TaskboardDatabase(databasePath); + database.createProject({ id: "context-project", name: "Context project", workspacePath: null }); + const created = database.createContextEntry("context-project", { + kind: "decision", + title: "Use D1", + body: "Context body", + tags: ["migration"], + sourceType: "manual", + sourceId: null, + sourceThreadId: null, + pinned: true, + idempotencyKey: "migration-context", + }, actor).entry; + const updated = database.updateContextEntry(created.id, 1, { + body: "Updated context body", + tags: ["migration", "cloud"], + }, actor); + const archived = database.archiveContextEntry(updated.id, 2, actor); + database.close(); + database = null; + + const bundle = await createCloudMigrationBundle({ + databasePath, + attachmentsDirectory, + }); + assert.equal(bundle.counts.byProject["context-project"].project_context_entries, 1); + assert.equal(bundle.counts.byProject["context-project"].project_context_revisions, 3); + assert.equal(bundle.tables.project_context_entries[0].version, 3); + assert.equal(bundle.tables.project_context_entries[0].archived_at, archived.archivedAt); + assert.deepEqual( + bundle.tables.project_context_revisions.map((revision) => revision.version), + [1, 2, 3], + ); + + const cloud = await createCloudWorkerHarness(); + try { + const result = await importCloudMigrationBundle(bundle, createCloudBindingMigrationAdapters({ + d1: cloud.db, + r2: cloud.attachments, + })); + assert.equal(result.counts.byProject["context-project"].project_context_entries, 1); + const entry = await cloud.db.prepare(` + SELECT id, version, archived_at, body, tags + FROM project_context_entries + WHERE project_id = ? + `).bind("context-project").first(); + assert.equal(entry.version, 3); + assert.equal(entry.archived_at, archived.archivedAt); + assert.equal(entry.body, "Updated context body"); + assert.deepEqual(JSON.parse(entry.tags), ["migration", "cloud"]); + const revisions = await cloud.db.prepare(` + SELECT version, body FROM project_context_revisions + WHERE entry_id = ? ORDER BY version + `).bind(entry.id).all(); + assert.deepEqual(revisions.results, [ + { version: 1, body: "Context body" }, + { version: 2, body: "Updated context body" }, + { version: 3, body: "Updated context body" }, + ]); + } finally { + await cloud.dispose(); + } + } finally { + database?.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + test("cloud import rejects D1 count drift and R2 hash drift", async () => { const fixture = await createMigrationFixture(); const bundle = await createCloudMigrationBundle({ @@ -683,7 +768,7 @@ test("D1 binding import uses one JSON statement per table for 100+ rows", async await adapters.d1.importTables(tables); assert.equal(batches.length, 1); - assert.equal(batches[0].length, 6); + assert.equal(batches[0].length, 8); for (const statement of batches[0]) assert.match(statement.sql, /json_each\(\?\)/); assert.equal(JSON.parse(batches[0][1].values[0]).length, 125); }); @@ -731,6 +816,126 @@ test("Wrangler D1 SQL chunks large tables below the remote statement byte limit" () => createCloudD1ImportSql(oversized), /single row.*90,?000 bytes/i, ); + + const contextTables = structuredClone(bundle.tables); + const contextBody = '"'.repeat(65_536); + const contextRevisionBody = "'".repeat(65_536); + contextTables.project_context_entries = [{ + id: "context-large", + project_id: "alpha", + kind: "decision", + title: "Large context", + body: contextRevisionBody, + tags: "[]", + source_type: "manual", + source_id: null, + source_thread_id: null, + author_type: "user", + author_id: "alice", + author_name: "Alice", + pinned: 0, + archived_at: null, + version: 1, + idempotency_key: "context-large", + created_at: timestamp, + updated_at: timestamp, + }]; + contextTables.project_context_revisions = [{ + id: "context-large-revision", + entry_id: "context-large", + version: 1, + title: "Large context", + body: contextBody, + kind: "decision", + tags: "[]", + author_id: "alice", + author_name: "Alice", + created_at: timestamp, + }]; + const contextSql = createCloudD1ImportSql(contextTables); + assert.match(contextSql, /UPDATE "project_context_entries"/); + assert.match(contextSql, /UPDATE "project_context_revisions"/); + const contextStatements = contextSql + .split(/;\n(?=(?:INSERT INTO|UPDATE ))/) + .map((statement) => statement.endsWith(";") ? statement : `${statement};`); + for (const statement of contextStatements) { + assert.ok( + Buffer.byteLength(statement, "utf8") < 90_000, + `context statement was ${Buffer.byteLength(statement, "utf8")} bytes`, + ); + } +}); + +test("Wrangler SQL imports a maximum-size context body without truncation", async () => { + const body = '"'.repeat(65_536); + const revisionBody = "'".repeat(65_536); + const timestamp = "2026-07-24T12:00:00.000Z"; + const tables = { + projects: [{ + id: "context-project", + name: "Context project", + workspace_path: null, + next_task_number: 1, + created_at: timestamp, + updated_at: timestamp, + }], + tasks: [], + comments: [], + task_relations: [], + attachments: [], + workflow_workspaces: [], + project_context_entries: [{ + id: "context-large", + project_id: "context-project", + kind: "decision", + title: "Large context", + body, + tags: "[]", + source_type: "manual", + source_id: null, + source_thread_id: null, + author_type: "user", + author_id: "alice", + author_name: "Alice", + pinned: 0, + archived_at: null, + version: 1, + idempotency_key: "context-large", + created_at: timestamp, + updated_at: timestamp, + }], + project_context_revisions: [{ + id: "context-large-revision", + entry_id: "context-large", + version: 1, + title: "Large context", + body: revisionBody, + kind: "decision", + tags: "[]", + author_id: "alice", + author_name: "Alice", + created_at: timestamp, + }], + }; + const database = new DatabaseSync(":memory:"); + try { + database.exec(await readFile(path.join(projectRoot, "cloud", "migrations", "0001_initial.sql"), "utf8")); + database.exec(await readFile(path.join(projectRoot, "cloud", "migrations", "0002_project_context.sql"), "utf8")); + const statements = createCloudD1ImportSql(tables) + .split(/;\n(?=(?:INSERT INTO|UPDATE ))/) + .map((statement) => statement.endsWith(";") ? statement : `${statement};`); + for (const statement of statements) database.exec(statement); + const entry = database.prepare(` + SELECT body FROM project_context_entries WHERE id = ? + `).get("context-large"); + const revision = database.prepare(` + SELECT body FROM project_context_revisions WHERE id = ? + `).get("context-large-revision"); + assert.equal(entry.body, body); + assert.equal(revision.body, revisionBody); + } finally { + database.close(); + } }); test("real D1 batch atomically imports a bundle containing local and maps development fields", async () => { diff --git a/test/cloud-project-context.test.mjs b/test/cloud-project-context.test.mjs new file mode 100644 index 000000000..017d40497 --- /dev/null +++ b/test/cloud-project-context.test.mjs @@ -0,0 +1,333 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { createCloudWorkerHarness } from "./helpers/cloud-worker-harness.mjs"; + +let cloud; +const alice = "Alice"; +const bob = "Bob"; + +before(async () => { + cloud = await createCloudWorkerHarness(); +}); + +after(async () => { + await cloud?.dispose(); +}); + +async function createProject(id, actorName = alice) { + return cloud.request("/api/projects", { + method: "POST", + actorName, + json: { id, name: id.toUpperCase(), workspacePath: `/Users/${actorName}/${id}` }, + }); +} + +async function createEntry(projectId, input, actorName = alice) { + return cloud.request(`/api/projects/${projectId}/context`, { + method: "POST", + actorName, + json: { + kind: "decision", + title: "Default decision", + body: "Default body", + ...input, + }, + }); +} + +async function currentRevision(actorName = alice) { + const result = await cloud.request("/api/revisions?since=0", { actorName }); + assert.equal(result.response.status, 200); + return result.body.revision; +} + +test("D1 migration and Basic Auth expose the same trusted context entry contract", async () => { + const unauthenticated = await cloud.request("/api/projects/cloud-context/context"); + assert.equal(unauthenticated.response.status, 401); + + const schema = await cloud.db.prepare(` + SELECT name, type FROM sqlite_schema + WHERE name LIKE 'project_context_%' + ORDER BY name + `).all(); + assert.deepEqual(schema.results.map((row) => row.name), [ + "project_context_entries", + "project_context_entries_global_revision_delete", + "project_context_entries_global_revision_insert", + "project_context_entries_global_revision_update", + "project_context_entries_project_idempotency", + "project_context_entries_project_kind", + "project_context_entries_project_page", + "project_context_entries_project_pinned", + "project_context_revisions", + "project_context_revisions_entry_version_unique", + "project_context_revisions_entry_versions", + ]); + + await createProject("cloud-context"); + const unexpectedQuery = await cloud.request("/api/projects/cloud-context/context?unexpected=1", { + method: "POST", + actorName: alice, + json: { + kind: "decision", + title: "Rejected query", + body: "Should not be created", + }, + }); + assert.equal(unexpectedQuery.response.status, 400); + assert.equal(unexpectedQuery.body.error.code, "UNKNOWN_QUERY_PARAMETER"); + const unsupportedContentType = await cloud.request("/api/projects/cloud-context/context", { + method: "POST", + actorName: alice, + headers: { "content-type": "application/jsonx" }, + body: JSON.stringify({ kind: "decision", title: "Bad media", body: "Rejected" }), + }); + assert.equal(unsupportedContentType.response.status, 415); + assert.equal(unsupportedContentType.body.error.code, "UNSUPPORTED_MEDIA_TYPE"); + const injected = await createEntry("cloud-context", { + title: "Injected author", + authorId: "attacker", + }); + assert.equal(injected.response.status, 400); + assert.equal(injected.body.error.code, "UNKNOWN_FIELD"); + + const before = await currentRevision(); + const created = await createEntry("cloud-context", { + title: "Choose D1", + body: "Shared through the Worker", + tags: ["cloud", "decision"], + sourceType: "issue", + sourceId: "ASH-46", + sourceThreadId: "source-only", + pinned: true, + idempotencyKey: "cloud-decision", + }); + assert.equal(created.response.status, 201); + assert.deepEqual(Object.keys(created.body.entry), [ + "id", "projectId", "kind", "title", "body", "tags", "sourceType", "sourceId", + "sourceThreadId", "authorType", "authorId", "authorName", "pinned", "archivedAt", + "version", "idempotencyKey", "createdAt", "updatedAt", + ]); + assert.equal(created.body.entry.authorType, "user"); + assert.equal(created.body.entry.authorName, alice); + assert.match(created.body.entry.authorId, /^basic:/); + assert.equal(created.body.entry.pinned, true); + assert.doesNotMatch(JSON.stringify(created.body), /\/Users\//); + const stored = await cloud.db.prepare(` + SELECT * FROM project_context_entries WHERE id = ? + `).bind(created.body.entry.id).first(); + assert.equal(Object.keys(stored).some((key) => /path|workspace|worktree/i.test(key)), false); + assert.ok(await currentRevision() > before); + + const replayRevision = await currentRevision(); + const replay = await createEntry("cloud-context", { + title: "Choose D1", + body: "Shared through the Worker", + tags: ["cloud", "decision"], + sourceType: "issue", + sourceId: "ASH-46", + sourceThreadId: "source-only", + pinned: true, + idempotencyKey: "cloud-decision", + }); + assert.equal(replay.response.status, 200); + assert.equal(replay.body.entry.id, created.body.entry.id); + assert.equal(await currentRevision(), replayRevision); + const conflict = await createEntry("cloud-context", { + title: "Different", + idempotencyKey: "cloud-decision", + }); + assert.equal(conflict.response.status, 409); + assert.equal(conflict.body.error.code, "IDEMPOTENCY_CONFLICT"); +}); + +test("Worker context search, cursor, brief, lifecycle and UTF-8 limits match local behavior", async () => { + const projectId = "cloud-lifecycle"; + await createProject(projectId); + const pinned = await createEntry(projectId, { + kind: "fact", + title: "Pinned fact 100%", + body: "Pinned first", + tags: ["cloud"], + pinned: true, + }); + const requirement = await createEntry(projectId, { + kind: "requirement", + title: "Requirement", + body: "Required body", + tags: ["launch"], + idempotencyKey: "requirement-key", + }); + const risk = await createEntry(projectId, { + kind: "risk", + title: "Risk", + body: "Monitor rollout", + tags: ["launch"], + }); + const oldSummary = await createEntry(projectId, { + kind: "summary", + title: "Old summary", + body: "Outdated summary", + }); + const summary = await createEntry(projectId, { + kind: "summary", + title: "Summary", + body: "Summary body", + }); + await createEntry(projectId, { kind: "fact", title: "Excluded", body: "No pin" }); + + for (const [query, expected] of [ + ["query=%25", pinned.body.entry.id], + ["query=rollout", risk.body.entry.id], + ["kind=requirement", requirement.body.entry.id], + ["tag=cloud", pinned.body.entry.id], + ["pinned=true", pinned.body.entry.id], + ]) { + const result = await cloud.request(`/api/projects/${projectId}/context?${query}`, { actorName: alice }); + assert.equal(result.response.status, 200, query); + assert.deepEqual(result.body.entries.map((entry) => entry.id), [expected], query); + } + + const paged = []; + let cursor = null; + do { + const page = await cloud.request( + `/api/projects/${projectId}/context?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, + { actorName: alice }, + ); + assert.equal(page.response.status, 200); + paged.push(...page.body.entries.map((entry) => entry.id)); + cursor = page.body.nextCursor; + } while (cursor); + assert.equal(paged.length, 6); + assert.equal(new Set(paged).size, paged.length); + + const brief1 = await cloud.request(`/api/projects/${projectId}/context/brief`, { actorName: alice }); + const brief2 = await cloud.request(`/api/projects/${projectId}/context/brief`, { actorName: bob }); + assert.deepEqual(brief1.body, brief2.body); + assert.deepEqual(brief1.body.includedEntryIds, [ + pinned.body.entry.id, + requirement.body.entry.id, + risk.body.entry.id, + summary.body.entry.id, + ]); + assert.equal(brief1.body.includedEntryIds.includes(oldSummary.body.entry.id), false); + + const updated = await cloud.request(`/api/context/${requirement.body.entry.id}`, { + method: "PATCH", + actorName: bob, + json: { version: 1, title: "Updated requirement", pinned: true }, + }); + assert.equal(updated.response.status, 200); + const replayAfterUpdate = await createEntry(projectId, { + kind: "requirement", + title: "Requirement", + body: "Required body", + tags: ["launch"], + idempotencyKey: requirement.body.entry.idempotencyKey, + }, alice); + assert.equal(replayAfterUpdate.response.status, 200); + assert.equal(replayAfterUpdate.body.entry.id, requirement.body.entry.id); + const changedReplay = await createEntry(projectId, { + kind: "requirement", + title: "Requirement", + body: "Different original body", + tags: ["launch"], + idempotencyKey: requirement.body.entry.idempotencyKey, + }, alice); + assert.equal(changedReplay.response.status, 409); + assert.equal(changedReplay.body.error.code, "IDEMPOTENCY_CONFLICT"); + const revisionBeforeStale = await currentRevision(); + const stale = await cloud.request(`/api/context/${requirement.body.entry.id}`, { + method: "PATCH", + actorName: alice, + json: { version: 1, title: "Stale" }, + }); + assert.equal(stale.response.status, 409); + assert.deepEqual(stale.body.error.details, { expectedVersion: 1, actualVersion: 2 }); + assert.equal(await currentRevision(), revisionBeforeStale); + + const archived = await cloud.request(`/api/context/${requirement.body.entry.id}/archive`, { + method: "POST", + actorName: bob, + json: { version: 2 }, + }); + assert.equal(archived.response.status, 200); + const defaultList = await cloud.request( + `/api/projects/${projectId}/context?query=Updated`, + { actorName: alice }, + ); + assert.deepEqual(defaultList.body.entries, []); + const archivedList = await cloud.request( + `/api/projects/${projectId}/context?archived=true&query=Updated`, + { actorName: alice }, + ); + assert.deepEqual(archivedList.body.entries.map((entry) => entry.id), [requirement.body.entry.id]); + const restored = await cloud.request(`/api/context/${requirement.body.entry.id}/restore`, { + method: "POST", + actorName: alice, + json: { version: 3 }, + }); + assert.equal(restored.body.entry.version, 4); + assert.equal(restored.body.entry.archivedAt, null); + const revisions = await cloud.request(`/api/context/${requirement.body.entry.id}/revisions`, { + actorName: alice, + }); + assert.deepEqual(revisions.body.revisions.map((revision) => revision.version), [1, 2, 3, 4]); + assert.equal(revisions.body.revisions[1].authorName, bob); + + const exact = await createEntry(projectId, { + title: "Exact bytes", + body: "é".repeat(32_768), + pinned: true, + }); + assert.equal(exact.response.status, 201); + const over = await createEntry(projectId, { + title: "Too many bytes", + body: "é".repeat(32_769), + }); + assert.equal(over.response.status, 400); + const truncated = await cloud.request(`/api/projects/${projectId}/context/brief`, { actorName: alice }); + assert.equal(truncated.body.truncated, true); + assert.ok(truncated.body.brief.length <= 12_000); +}); + +test("D1 concurrency permits one idempotent create and one optimistic update winner", async () => { + const projectId = "cloud-races"; + await createProject(projectId); + const creates = await Promise.all(Array.from({ length: 8 }, () => createEntry(projectId, { + title: "Only once", + body: "Idempotent content", + tags: ["race"], + idempotencyKey: "race-key", + }))); + assert.equal(creates.filter((result) => result.response.status === 201).length, 1); + assert.equal(creates.filter((result) => result.response.status === 200).length, 7); + assert.equal(new Set(creates.map((result) => result.body.entry.id)).size, 1); + const entry = creates[0].body.entry; + assert.equal(await cloud.db.prepare(` + SELECT COUNT(*) AS count FROM project_context_entries WHERE project_id = ? + `).bind(projectId).first("count"), 1); + assert.equal(await cloud.db.prepare(` + SELECT COUNT(*) AS count FROM project_context_revisions WHERE entry_id = ? + `).bind(entry.id).first("count"), 1); + + const updates = await Promise.all([ + cloud.request(`/api/context/${entry.id}`, { + method: "PATCH", + actorName: alice, + json: { version: 1, title: "Alice wins" }, + }), + cloud.request(`/api/context/${entry.id}`, { + method: "PATCH", + actorName: bob, + json: { version: 1, title: "Bob wins" }, + }), + ]); + assert.deepEqual(updates.map((result) => result.response.status).sort(), [200, 409]); + const latest = await cloud.request(`/api/context/${entry.id}`, { actorName: alice }); + assert.equal(latest.body.entry.version, 2); + const revisions = await cloud.request(`/api/context/${entry.id}/revisions`, { actorName: alice }); + assert.deepEqual(revisions.body.revisions.map((revision) => revision.version), [1, 2]); +}); diff --git a/test/manage-taskboard-skill.test.mjs b/test/manage-taskboard-skill.test.mjs index b3de930f4..68d599341 100644 --- a/test/manage-taskboard-skill.test.mjs +++ b/test/manage-taskboard-skill.test.mjs @@ -18,3 +18,61 @@ test("the taskboard skill coordinates safe issue execution and review handoff", /Verify the requested operation path[\s\S]*Add a comment with the changes, verification result, outcome, and remaining risks[\s\S]*Read the issue again, then move it to `in_review` with its current `version`/i, ); }); + +test("the taskboard skill reads shared context before implementation and publishes durable outcomes", () => { + assert.match(skillSource, /\$shared-project-context/i); + assert.match( + skillSource, + /taskboard_context_current_project[\s\S]*read the latest issue content and all comments[\s\S]*taskboard_context_brief[\s\S]*taskboard_context_search/i, + ); + assert.match(skillSource, /issue title[\s\S]*labels[\s\S]*meaningful terms.*description/i); + assert.match(skillSource, /requirements?.*constraints?.*decisions?.*risks?.*work constraints/is); + assert.match(skillSource, /conflict[\s\S]*source[\s\S]*`updatedAt`[\s\S]*never choose silently/i); + assert.match(skillSource, /durable.*decision.*constraint.*state.*risk.*handoff.*summary.*next step/is); + assert.match(skillSource, /stable.*issue-scoped.*`idempotencyKey`/i); + assert.match(skillSource, /potentially sensitive[\s\S]*explicit (?:user )?approval/i); + assert.match(skillSource, /`sourceType: agent`/i); + assert.match(skillSource, /shared context.*MCP tools[\s\S]*not `taskctl`/i); +}); + +test("shared project context Skill uses only the seven guarded MCP operations", async () => { + const sharedSkill = await readFile( + new URL("../skills/shared-project-context/SKILL.md", import.meta.url), + "utf8", + ); + const agentManifest = await readFile( + new URL("../skills/shared-project-context/agents/openai.yaml", import.meta.url), + "utf8", + ); + + assert.match(sharedSkill, /^---\nname: shared-project-context\n/m); + assert.match(sharedSkill, /MCP tools exclusively|exclusively.*MCP tools/i); + assert.match( + sharedSkill, + /taskboard_context_current_project[\s\S]*taskboard_context_brief[\s\S]*taskboard_context_search[\s\S]*taskboard_context_get/i, + ); + assert.match(sharedSkill, /conflict[\s\S]*source[\s\S]*`updatedAt`[\s\S]*never choose silently/i); + assert.match(sharedSkill, /requirement.*decision.*constraint.*fact.*risk.*handoff.*summary/is); + assert.match(sharedSkill, /stable.*`idempotencyKey`/i); + assert.match(sharedSkill, /taskboard_context_update[\s\S]*current `version`/i); + assert.match(sharedSkill, /taskboard_context_archive[\s\S]*current `version`/i); + for (const code of [ + "PROJECT_MAPPING_NOT_FOUND", + "CONTEXT_NOT_FOUND", + "VERSION_CONFLICT", + "IDEMPOTENCY_CONFLICT", + "CONTEXT_ALREADY_ARCHIVED", + "UNAUTHORIZED", + "CLOUD_NOT_CONFIGURED", + "REMOTE_UNAVAILABLE", + "COMPANION_UNAVAILABLE", + ]) { + assert.match(sharedSkill, new RegExp(code)); + } + assert.match(sharedSkill, /potentially sensitive[\s\S]*explicit (?:user )?approval/i); + assert.match(sharedSkill, /`sourceType: agent`/i); + assert.match(sharedSkill, /never[\s\S]*(?:direct database|SQLite)[\s\S]*`curl`[\s\S]*Codex internal state/i); + assert.match(sharedSkill, /no local fallback|never fall back.*local/i); + assert.match(agentManifest, /value: "dashi-taskboard"/i); + assert.match(agentManifest, /\$shared-project-context/i); +}); diff --git a/test/mcp-context.test.mjs b/test/mcp-context.test.mjs new file mode 100644 index 000000000..3f2b5905f --- /dev/null +++ b/test/mcp-context.test.mjs @@ -0,0 +1,1051 @@ +import assert from "node:assert/strict"; +import { createServer as createHttpServer } from "node:http"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +import { + CompanionError, + createCompanionClient, + publicCompanionError, + publicProject, + resolveCompanionUrl, + resolveMappedProject, + workspaceContains, +} from "../mcp/companion-client.mjs"; + +const DEFAULT_COMPANION_URL = "http://127.0.0.1:47823"; +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const MCP_SERVER_ENTRY = path.join(REPO_ROOT, "mcp", "server.mjs"); +const PRIVATE_PATH_SENTINEL = "/private/device/alice/taskboard"; +const PASSWORD_SENTINEL = "mcp-shared-password-sentinel"; +const ENV_SENTINEL = "mcp-environment-secret-sentinel"; +const ERROR_CODE_SENTINEL = "MCP_SECRET_ERROR_CODE_SENTINEL"; + +const BASE_ENTRY = Object.freeze({ + id: "context-1", + projectId: "project-1", + kind: "decision", + title: "Use the companion boundary", + body: "The MCP server uses the loopback companion HTTP API.", + tags: ["mcp", "architecture"], + sourceType: "agent", + sourceId: "ASH-48", + sourceThreadId: null, + authorType: "agent", + authorId: "codex", + authorName: "Codex", + pinned: true, + archivedAt: null, + version: 1, + idempotencyKey: "ASH-48:decision:companion-boundary", + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", +}); + +function jsonResponse(value, status = 200) { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function errorResponse(status, code, details = undefined) { + return jsonResponse({ + error: { + code, + message: "upstream message must not reach the model", + ...(details === undefined ? {} : { details }), + }, + }, status); +} + +async function captureError(operation) { + try { + await operation(); + } catch (error) { + return error; + } + assert.fail("Expected operation to reject"); +} + +test("resolveCompanionUrl uses the explicit, legacy, and default loopback origins in order", () => { + assert.equal(resolveCompanionUrl({}), DEFAULT_COMPANION_URL); + assert.equal( + resolveCompanionUrl({ CODEX_TASKBOARD_URL: "http://localhost:49001/" }), + "http://localhost:49001", + ); + assert.equal( + resolveCompanionUrl({ + CODEX_TASKBOARD_COMPANION_URL: "https://[::1]:49002/", + CODEX_TASKBOARD_URL: "http://localhost:49001", + }), + "https://[::1]:49002", + ); + assert.equal( + resolveCompanionUrl({ CODEX_TASKBOARD_COMPANION_URL: "http://127.24.5.9:49003" }), + "http://127.24.5.9:49003", + ); +}); + +test("resolveCompanionUrl rejects credentials, non-loopback hosts, paths, queries, and fragments", () => { + const rejected = [ + "not a URL", + "ftp://127.0.0.1:47823", + "http://0.0.0.0:47823", + "http://192.168.1.20:47823", + "https://tasks.example.test", + "http://localhost.example.test:47823", + "http://127.example.test:47823", + "http://alice:shared-password@127.0.0.1:47823", + "http://127.0.0.1:47823/api", + "http://127.0.0.1:47823/?token=secret", + "http://127.0.0.1:47823/#secret", + ]; + + for (const rawUrl of rejected) { + assert.throws( + () => resolveCompanionUrl({ CODEX_TASKBOARD_COMPANION_URL: rawUrl }), + (error) => { + assert.ok(error instanceof CompanionError, rawUrl); + assert.deepEqual(publicCompanionError(error), { + status: 400, + code: "INVALID_COMPANION_URL", + category: "invalid_configuration", + action: "Set the companion URL to a loopback HTTP or HTTPS origin.", + }); + assert.equal(JSON.stringify(publicCompanionError(error)).includes(rawUrl), false); + return true; + }, + ); + } +}); + +test("workspaceContains requires absolute directory containment instead of a shared prefix", () => { + const workspace = path.resolve("/work/projects/taskboard"); + assert.equal(workspaceContains(workspace, workspace), true); + assert.equal(workspaceContains(workspace, path.join(workspace, "packages", "api")), true); + assert.equal(workspaceContains(workspace, path.resolve("/work/projects/taskboard-copy")), false); + assert.equal(workspaceContains(workspace, path.resolve("/work/projects")), false); + assert.equal(workspaceContains("relative/project", path.resolve("/work/projects/taskboard")), false); + assert.equal(workspaceContains(workspace, "relative/cwd"), false); +}); + +test("resolveMappedProject chooses the most-specific workspace and never falls back", () => { + const projects = [ + { id: "root", name: "Root", workspacePath: path.resolve("/work") }, + { id: "repo", name: "Repo", workspacePath: path.resolve("/work/repo") }, + { id: "package", name: "Package", workspacePath: path.resolve("/work/repo/packages/api") }, + { id: "unmapped", name: "Unmapped", workspacePath: null }, + ]; + + assert.equal( + resolveMappedProject(projects, path.resolve("/work/repo/packages/api/src")).id, + "package", + ); + assert.equal(resolveMappedProject(projects, path.resolve("/work/repo/web")).id, "repo"); + assert.equal(resolveMappedProject(projects, path.resolve("/elsewhere")), null); + assert.equal(resolveMappedProject([], path.resolve("/work/repo")), null); +}); + +test("resolveMappedProject ranks canonical paths and rejects ambiguous mappings", () => { + const repo = path.resolve("/work/repo"); + const packagePath = path.join(repo, "packages"); + const paddedRepo = `${repo}${path.sep}.${path.sep}.${path.sep}.${path.sep}.${path.sep}`; + + assert.equal(resolveMappedProject([ + { id: "padded-root", name: "Padded root", workspacePath: paddedRepo }, + { id: "package", name: "Package", workspacePath: packagePath }, + ], path.join(packagePath, "api")).id, "package"); + + assert.throws( + () => resolveMappedProject([ + { id: "repo-a", name: "Repository A", workspacePath: repo }, + { id: "repo-b", name: "Repository B", workspacePath: `${repo}${path.sep}.` }, + ], path.join(repo, "src")), + (error) => { + assert.ok(error instanceof CompanionError); + assert.deepEqual(publicCompanionError(error), { + status: 409, + code: "PROJECT_MAPPING_AMBIGUOUS", + category: "project_mapping_ambiguous", + action: "Keep exactly one taskboard project mapping for the current workspace and retry.", + }); + return true; + }, + ); +}); + +test("publicProject exposes only stable project metadata", () => { + const project = publicProject({ + id: "taskboard", + name: "Taskboard", + workspacePath: "/Users/alice/private/taskboard", + cwd: "/Users/alice/private/taskboard/packages/api", + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T01:00:00.000Z", + issueCount: 4, + authorization: "Basic secret", + password: "shared-password", + env: { SECRET: "environment-secret" }, + arbitrary: { path: "/private/arbitrary" }, + }); + + assert.deepEqual(project, { + id: "taskboard", + name: "Taskboard", + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T01:00:00.000Z", + issueCount: 4, + }); + assert.doesNotMatch(JSON.stringify(project), /Users|authorization|password|environment|arbitrary/i); +}); + +test("client sends timeout-bounded JSON requests without credentials", async () => { + const calls = []; + const timeoutController = new AbortController(); + let requestedTimeout; + const client = createCompanionClient({ + env: { + CODEX_TASKBOARD_COMPANION_URL: "http://127.0.0.1:49000", + AUTHORIZATION: "Basic environment-secret", + SHARED_PASSWORD: "shared-password", + }, + createTimeoutSignal(timeoutMs) { + requestedTimeout = timeoutMs; + return timeoutController.signal; + }, + fetch: async (url, init) => { + calls.push({ url, init }); + return jsonResponse({ entry: { id: "entry-1" } }, 201); + }, + }); + + const payload = await client.request( + "POST", + ["api", "projects", "team/alpha", "context"], + { + query: { query: "100% + /?", cursor: "a+b/c=", omitted: undefined }, + body: { title: "Decision", body: "Keep the public contract" }, + }, + ); + + assert.deepEqual(payload, { entry: { id: "entry-1" } }); + assert.equal(requestedTimeout, 10_000); + assert.equal(calls.length, 1); + assert.equal( + calls[0].url.toString(), + "http://127.0.0.1:49000/api/projects/team%2Falpha/context?query=100%25+%2B+%2F%3F&cursor=a%2Bb%2Fc%3D", + ); + assert.equal(calls[0].init.method, "POST"); + assert.equal(calls[0].init.signal, timeoutController.signal); + assert.equal(calls[0].init.redirect, "manual"); + assert.deepEqual(JSON.parse(calls[0].init.body), { + title: "Decision", + body: "Keep the public contract", + }); + const headers = new Headers(calls[0].init.headers); + assert.equal(headers.get("accept"), "application/json"); + assert.equal(headers.get("content-type"), "application/json"); + assert.equal(headers.get("x-taskboard-client"), "taskctl"); + assert.equal(headers.has("authorization"), false); + assert.doesNotMatch(JSON.stringify(calls), /environment-secret|shared-password/); +}); + +test("client keeps GET requests bodyless and parses JSON text", async () => { + let requestInit; + const client = createCompanionClient({ + env: {}, + fetch: async (_url, init) => { + requestInit = init; + return new Response('{"projects":[]}', { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + assert.deepEqual(await client.request("GET", "/api/projects"), { projects: [] }); + assert.equal("body" in requestInit, false); + assert.equal(new Headers(requestInit.headers).has("content-type"), false); +}); + +test("currentProject resolves through /api/projects and strips the workspace path", async () => { + let requestedUrl; + const client = createCompanionClient({ + cwd: path.resolve("/work/repo/packages/mcp"), + env: {}, + fetch: async (url) => { + requestedUrl = url.toString(); + return jsonResponse({ + projects: [ + { + id: "repo", + name: "Repository", + workspacePath: path.resolve("/work/repo"), + issueCount: 2, + }, + { id: "other", name: "Other", workspacePath: path.resolve("/other") }, + ], + }); + }, + }); + + assert.deepEqual(await client.currentProject(), { + id: "repo", + name: "Repository", + issueCount: 2, + }); + assert.equal(requestedUrl, `${DEFAULT_COMPANION_URL}/api/projects`); +}); + +test("currentProject reports an actionable error instead of selecting an unrelated project", async () => { + const client = createCompanionClient({ + cwd: path.resolve("/not/mapped"), + env: {}, + fetch: async () => jsonResponse({ + projects: [{ id: "other", name: "Other", workspacePath: path.resolve("/other") }], + }), + }); + + const error = await captureError(() => client.currentProject()); + assert.deepEqual(publicCompanionError(error), { + status: 404, + code: "PROJECT_MAPPING_NOT_FOUND", + category: "project_not_mapped", + action: "Map the current workspace to a taskboard project and retry.", + }); +}); + +test("client maps API failures to safe, distinct, actionable errors", async (t) => { + const cases = [ + [400, "INVALID_FIELD", "invalid_request", "Check the tool arguments and retry."], + [401, "UNAUTHORIZED", "authentication_required", "Log in through the local companion and retry."], + [404, "CONTEXT_NOT_FOUND", "not_found", "Refresh the project or context entry identifier and retry."], + [409, "VERSION_CONFLICT", "version_conflict", "Reload the context entry, then retry with its current version."], + [409, "CONTEXT_VERSION_CONFLICT", "version_conflict", "Reload the context entry, then retry with its current version."], + [409, "IDEMPOTENCY_CONFLICT", "idempotency_conflict", "Reuse the original content or choose a new idempotency key."], + [409, "CONTEXT_IDEMPOTENCY_CONFLICT", "idempotency_conflict", "Reuse the original content or choose a new idempotency key."], + [409, "CONTEXT_ALREADY_ARCHIVED", "already_archived", "Reload the context entry; it is already archived."], + [409, "CONTEXT_NOT_ARCHIVED", "not_archived", "Reload the context entry; only archived entries can be restored."], + [409, "CLOUD_NOT_CONFIGURED", "cloud_not_configured", "Configure cloud collaboration in the local companion and retry."], + [500, "SERVER_MISCONFIGURED", "server_misconfigured", "Ask the taskboard administrator to check the server configuration."], + [502, "REMOTE_UNAVAILABLE", "cloud_unavailable", "Check the cloud taskboard connection and retry."], + ]; + + for (const [status, code, category, action] of cases) { + await t.test(`${status} ${code}`, async () => { + const client = createCompanionClient({ + env: {}, + fetch: async () => errorResponse(status, code, { + expectedVersion: 3, + actualVersion: 4, + workspacePath: "/Users/alice/private/project", + authorization: "Basic secret", + password: "shared-password", + env: { SECRET: "environment-secret" }, + arbitrary: "must be removed", + }), + }); + + const error = await captureError(() => client.request("GET", "/api/context/entry")); + assert.ok(error instanceof CompanionError); + assert.deepEqual(publicCompanionError(error), { + status, + code, + category, + action, + details: { expectedVersion: 3, actualVersion: 4 }, + }); + assert.doesNotMatch( + JSON.stringify(publicCompanionError(error)), + /upstream message|Users|authorization|password|environment|arbitrary|127\.0\.0\.1/i, + ); + }); + } +}); + +test("client drops non-numeric version details", async () => { + const client = createCompanionClient({ + env: {}, + fetch: async () => errorResponse(409, "VERSION_CONFLICT", { + expectedVersion: "3 /Users/alice", + actualVersion: null, + }), + }); + + const error = await captureError(() => client.request("PATCH", "/api/context/entry")); + assert.deepEqual(publicCompanionError(error), { + status: 409, + code: "VERSION_CONFLICT", + category: "version_conflict", + action: "Reload the context entry, then retry with its current version.", + }); +}); + +test("client maps local network and timeout failures without leaking exception details", async (t) => { + for (const failure of [ + new Error("connect ECONNREFUSED http://127.0.0.1:47823 /Users/alice shared-password"), + new DOMException("environment-secret", "TimeoutError"), + ]) { + await t.test(failure.name, async () => { + const client = createCompanionClient({ + env: {}, + fetch: async () => { throw failure; }, + }); + const error = await captureError(() => client.request("GET", "/api/projects")); + assert.deepEqual(publicCompanionError(error), { + status: 503, + code: "COMPANION_UNAVAILABLE", + category: "companion_unavailable", + action: "Start the local taskboard companion and retry.", + }); + assert.doesNotMatch( + JSON.stringify(publicCompanionError(error)), + /ECONNREFUSED|127\.0\.0\.1|Users|password|environment-secret/i, + ); + }); + } +}); + +test("client keeps the timeout active while consuming the response body", async () => { + const client = createCompanionClient({ + env: {}, + fetch: async () => ({ + ok: true, + status: 200, + async text() { + throw new DOMException("private response body stalled", "TimeoutError"); + }, + }), + }); + + const error = await captureError(() => client.request("GET", "/api/projects")); + assert.deepEqual(publicCompanionError(error), { + status: 503, + code: "COMPANION_UNAVAILABLE", + category: "companion_unavailable", + action: "Start the local taskboard companion and retry.", + }); + assert.doesNotMatch(JSON.stringify(publicCompanionError(error)), /private|stalled|TimeoutError/i); +}); + +test("client preserves a text 401 as an authentication error", async () => { + const client = createCompanionClient({ + env: {}, + fetch: async () => new Response("invalid shared key", { + status: 401, + headers: { "content-type": "text/plain" }, + }), + }); + + const error = await captureError(() => client.request("GET", "/api/projects")); + assert.deepEqual(publicCompanionError(error), { + status: 401, + code: "UNAUTHORIZED", + category: "authentication_required", + action: "Log in through the local companion and retry.", + }); +}); + +test("client rejects invalid JSON and absolute request URLs without leaking either value", async () => { + const invalidJsonClient = createCompanionClient({ + env: {}, + fetch: async () => new Response("shared-password at /Users/alice", { status: 200 }), + }); + const invalidJson = await captureError( + () => invalidJsonClient.request("GET", "/api/projects"), + ); + assert.deepEqual(publicCompanionError(invalidJson), { + status: 502, + code: "INVALID_COMPANION_RESPONSE", + category: "invalid_response", + action: "Restart or update the local taskboard companion and retry.", + }); + + let fetchCalled = false; + const pathClient = createCompanionClient({ + env: {}, + fetch: async () => { + fetchCalled = true; + return jsonResponse({}); + }, + }); + const invalidPath = await captureError( + () => pathClient.request("GET", "https://tasks.example.test/private?token=secret"), + ); + assert.deepEqual(publicCompanionError(invalidPath), { + status: 400, + code: "INVALID_COMPANION_PATH", + category: "invalid_request", + action: "Use a companion API path that starts with a single slash.", + }); + assert.equal(fetchCalled, false); + assert.doesNotMatch(JSON.stringify(publicCompanionError(invalidPath)), /example|private|token|secret/i); + + const backslashPath = await captureError( + () => pathClient.request("POST", "/\\\\example.test/collect", { + body: { secret: "must-not-leave-loopback" }, + }), + ); + assert.deepEqual(publicCompanionError(backslashPath), { + status: 400, + code: "INVALID_COMPANION_PATH", + category: "invalid_request", + action: "Use a companion API path that starts with a single slash.", + }); + assert.equal(fetchCalled, false); +}); + +test("client refuses companion redirects without replaying a private request body", async (t) => { + let sinkRequests = 0; + const sink = createHttpServer((_request, response) => { + sinkRequests += 1; + response.writeHead(204); + response.end(); + }); + await new Promise((resolve, reject) => { + sink.once("error", reject); + sink.listen(0, "127.0.0.1", resolve); + }); + t.after(() => new Promise((resolve, reject) => { + sink.closeAllConnections(); + sink.close((error) => error ? reject(error) : resolve()); + })); + + const redirector = createHttpServer((_request, response) => { + response.writeHead(307, { + connection: "close", + location: `http://127.0.0.1:${sink.address().port}/collect`, + }); + response.end(); + }); + await new Promise((resolve, reject) => { + redirector.once("error", reject); + redirector.listen(0, "127.0.0.1", resolve); + }); + t.after(() => new Promise((resolve, reject) => { + redirector.closeAllConnections(); + redirector.close((error) => error ? reject(error) : resolve()); + })); + + const client = createCompanionClient({ + baseUrl: `http://127.0.0.1:${redirector.address().port}`, + }); + const error = await captureError(() => client.request("POST", "/api/context", { + body: { secret: "private-context-sentinel" }, + })); + + assert.deepEqual(publicCompanionError(error), { + status: 502, + code: "COMPANION_REDIRECT_BLOCKED", + category: "invalid_response", + action: "Use a direct loopback companion URL without redirects.", + }); + assert.equal(sinkRequests, 0); +}); + +function sendHttpJson(response, status, payload) { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(payload)); +} + +async function requestJson(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + if (chunks.length === 0) return undefined; + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +function fixtureError(code, details = undefined) { + return { + error: { + code, + message: `Do not leak ${PASSWORD_SENTINEL} at ${PRIVATE_PATH_SENTINEL}`, + ...(details === undefined ? {} : { + details: { + ...details, + authorization: `Basic ${PASSWORD_SENTINEL}`, + workspacePath: PRIVATE_PATH_SENTINEL, + env: { MCP_TEST_SECRET: ENV_SENTINEL }, + }, + }), + }, + }; +} + +async function startContextCompanion() { + const requests = []; + const published = new Map(); + let createCount = 0; + const server = createHttpServer(async (request, response) => { + try { + const url = new URL(request.url, "http://127.0.0.1"); + const body = await requestJson(request); + requests.push({ + method: request.method, + pathname: url.pathname, + query: Object.fromEntries(url.searchParams), + headers: request.headers, + body, + }); + + if (request.method === "GET" && url.pathname === "/api/projects") { + sendHttpJson(response, 200, { + projects: [{ + id: "project-1", + name: "Shared Taskboard", + workspacePath: REPO_ROOT, + issueCount: 48, + deviceMappings: [{ workspacePath: PRIVATE_PATH_SENTINEL }], + authorization: `Basic ${PASSWORD_SENTINEL}`, + password: PASSWORD_SENTINEL, + env: { MCP_TEST_SECRET: ENV_SENTINEL }, + }], + }); + return; + } + + if ( + request.method === "GET" + && ( + url.pathname === "/api/projects/project-1/context/brief" + || url.pathname === "/api/projects/api--v2/context/brief" + ) + ) { + sendHttpJson(response, 200, { + brief: "## [decision] Use the companion boundary\n\nRead and write through HTTP.", + includedEntryIds: [BASE_ENTRY.id], + truncated: false, + workspacePath: PRIVATE_PATH_SENTINEL, + }); + return; + } + + if ( + request.method === "GET" + && url.pathname === "/api/projects/project-1/context" + ) { + sendHttpJson(response, 200, { + entries: [{ + ...BASE_ENTRY, + workspacePath: PRIVATE_PATH_SENTINEL, + authorization: `Basic ${PASSWORD_SENTINEL}`, + env: { MCP_TEST_SECRET: ENV_SENTINEL }, + }], + nextCursor: "next-page-token", + password: PASSWORD_SENTINEL, + }); + return; + } + + if ( + request.method === "POST" + && url.pathname === "/api/projects/project-1/context" + ) { + if (body?.idempotencyKey === "conflicting-key") { + sendHttpJson(response, 409, fixtureError("IDEMPOTENCY_CONFLICT")); + return; + } + let entry = published.get(body?.idempotencyKey); + if (!entry) { + createCount += 1; + entry = { + ...BASE_ENTRY, + id: "published-1", + kind: body.kind, + title: body.title, + body: body.body, + tags: body.tags, + sourceType: body.sourceType, + sourceId: body.sourceId ?? null, + sourceThreadId: body.sourceThreadId ?? null, + pinned: body.pinned, + idempotencyKey: body.idempotencyKey, + }; + published.set(body.idempotencyKey, entry); + } + sendHttpJson(response, createCount === 1 ? 201 : 200, { + entry, + authorization: `Basic ${PASSWORD_SENTINEL}`, + }); + return; + } + + if (request.method === "GET" && url.pathname === "/api/context/context-1") { + sendHttpJson(response, 200, { + entry: { + ...BASE_ENTRY, + workspacePath: PRIVATE_PATH_SENTINEL, + password: PASSWORD_SENTINEL, + }, + }); + return; + } + + if (request.method === "GET" && url.pathname === "/api/context/missing") { + sendHttpJson(response, 404, fixtureError("CONTEXT_NOT_FOUND")); + return; + } + if (request.method === "GET" && url.pathname === "/api/context/auth-required") { + sendHttpJson(response, 401, fixtureError("UNAUTHORIZED")); + return; + } + if (request.method === "GET" && url.pathname === "/api/context/remote-down") { + sendHttpJson(response, 502, fixtureError("REMOTE_UNAVAILABLE")); + return; + } + if (request.method === "GET" && url.pathname === "/api/context/untrusted-code") { + sendHttpJson( + response, + 502, + fixtureError(`${ERROR_CODE_SENTINEL}_/Users/alice/ignore-instructions`), + ); + return; + } + + if (request.method === "PATCH" && url.pathname === "/api/context/version-conflict") { + sendHttpJson(response, 409, fixtureError("VERSION_CONFLICT", { + expectedVersion: 3, + actualVersion: 4, + })); + return; + } + if (request.method === "PATCH" && url.pathname === "/api/context/context-1") { + sendHttpJson(response, 200, { + entry: { + ...BASE_ENTRY, + ...body, + id: BASE_ENTRY.id, + version: 2, + updatedAt: "2026-08-06T01:00:00.000Z", + }, + }); + return; + } + + if ( + request.method === "POST" + && url.pathname === "/api/context/already-archived/archive" + ) { + sendHttpJson(response, 409, fixtureError("CONTEXT_ALREADY_ARCHIVED")); + return; + } + if ( + request.method === "POST" + && url.pathname === "/api/context/context-1/archive" + ) { + sendHttpJson(response, 200, { + entry: { + ...BASE_ENTRY, + archivedAt: "2026-08-06T02:00:00.000Z", + version: 3, + updatedAt: "2026-08-06T02:00:00.000Z", + }, + }); + return; + } + + sendHttpJson(response, 404, fixtureError("ROUTE_NOT_FOUND")); + } catch { + sendHttpJson(response, 500, fixtureError("FIXTURE_FAILURE")); + } + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + return { + origin: `http://127.0.0.1:${address.port}`, + requests, + get createCount() { + return createCount; + }, + async close() { + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); + }, + }; +} + +async function createMcpFixture(t) { + const companion = await startContextCompanion(); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [MCP_SERVER_ENTRY], + cwd: REPO_ROOT, + env: { + CODEX_TASKBOARD_COMPANION_URL: companion.origin, + MCP_TEST_SECRET: ENV_SENTINEL, + }, + stderr: "pipe", + }); + let stderr = ""; + transport.stderr?.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + const client = new Client({ name: "taskboard-mcp-test", version: "1.0.0" }); + t.after(async () => { + await client.close().catch(() => {}); + await companion.close(); + assert.equal(stderr, ""); + }); + await client.connect(transport); + return { client, companion }; +} + +function assertStructuredResult(result) { + assert.equal(Array.isArray(result.content), true); + assert.equal(result.content[0]?.type, "text"); + assert.equal(typeof result.content[0]?.text, "string"); + assert.ok(result.content[0].text.length > 0); + assert.equal(typeof result.structuredContent, "object"); + return result.structuredContent; +} + +test("stdio server initializes and advertises exactly seven strict context tools", async (t) => { + const { client } = await createMcpFixture(t); + + assert.deepEqual(client.getServerVersion(), { + name: "dashi-taskboard", + version: "0.1.0", + }); + assert.deepEqual(client.getServerCapabilities(), { tools: { listChanged: true } }); + + const { tools } = await client.listTools(); + assert.deepEqual(tools.map((tool) => tool.name), [ + "taskboard_context_current_project", + "taskboard_context_brief", + "taskboard_context_search", + "taskboard_context_get", + "taskboard_context_publish", + "taskboard_context_update", + "taskboard_context_archive", + ]); + for (const tool of tools) { + assert.equal(tool.inputSchema.type, "object", tool.name); + assert.equal(tool.inputSchema.additionalProperties, false, tool.name); + assert.equal(tool.outputSchema.type, "object", tool.name); + assert.equal(tool.outputSchema.additionalProperties, false, tool.name); + } + + const byName = new Map(tools.map((tool) => [tool.name, tool])); + assert.deepEqual(byName.get("taskboard_context_publish").inputSchema.required.sort(), [ + "body", + "idempotencyKey", + "kind", + "title", + ]); + assert.deepEqual(byName.get("taskboard_context_update").inputSchema.required.sort(), ["id", "version"]); + assert.deepEqual(byName.get("taskboard_context_archive").inputSchema.required.sort(), ["id", "version"]); + assert.equal(byName.get("taskboard_context_get").annotations.readOnlyHint, true); + assert.equal(byName.get("taskboard_context_publish").annotations.idempotentHint, true); + assert.equal(byName.get("taskboard_context_archive").annotations.destructiveHint, true); +}); + +test("stdio project-scoped tools accept every project id allowed by the service", async (t) => { + const { client, companion } = await createMcpFixture(t); + const result = await client.callTool({ + name: "taskboard_context_brief", + arguments: { projectId: "api--v2" }, + }); + + assert.equal(result.isError, undefined); + assert.deepEqual(assertStructuredResult(result), { + ok: true, + brief: "## [decision] Use the companion boundary\n\nRead and write through HTTP.", + includedEntryIds: [BASE_ENTRY.id], + truncated: false, + }); + assert.equal(companion.requests.some((request) => ( + request.pathname === "/api/projects/api--v2/context/brief" + )), true); +}); + +test("stdio tools route reads and versioned writes through the companion safely", async (t) => { + const { client, companion } = await createMcpFixture(t); + const results = []; + + results.push(await client.callTool({ + name: "taskboard_context_current_project", + arguments: {}, + })); + assert.deepEqual(assertStructuredResult(results.at(-1)), { + ok: true, + project: { id: "project-1", name: "Shared Taskboard", issueCount: 48 }, + }); + + results.push(await client.callTool({ name: "taskboard_context_brief", arguments: {} })); + assert.deepEqual(assertStructuredResult(results.at(-1)), { + ok: true, + brief: "## [decision] Use the companion boundary\n\nRead and write through HTTP.", + includedEntryIds: [BASE_ENTRY.id], + truncated: false, + }); + + results.push(await client.callTool({ + name: "taskboard_context_search", + arguments: { + query: "companion + http", + kind: "decision", + tag: "mcp", + pinned: true, + archived: "false", + limit: 10, + cursor: "cursor/with+symbols=", + }, + })); + assert.deepEqual(assertStructuredResult(results.at(-1)), { + ok: true, + entries: [BASE_ENTRY], + nextCursor: "next-page-token", + }); + + results.push(await client.callTool({ + name: "taskboard_context_get", + arguments: { id: BASE_ENTRY.id }, + })); + assert.deepEqual(assertStructuredResult(results.at(-1)), { ok: true, entry: BASE_ENTRY }); + + const publishArguments = { + kind: "handoff", + title: "Backend handoff", + body: "The MCP contract is ready for review.", + tags: ["ASH-48"], + sourceId: "ASH-48", + sourceThreadId: "thread-1", + pinned: false, + idempotencyKey: "ASH-48:handoff:backend", + }; + const firstPublish = await client.callTool({ + name: "taskboard_context_publish", + arguments: publishArguments, + }); + const replayedPublish = await client.callTool({ + name: "taskboard_context_publish", + arguments: publishArguments, + }); + results.push(firstPublish, replayedPublish); + assert.deepEqual( + assertStructuredResult(firstPublish).entry, + assertStructuredResult(replayedPublish).entry, + ); + assert.equal(companion.createCount, 1); + + results.push(await client.callTool({ + name: "taskboard_context_update", + arguments: { + id: BASE_ENTRY.id, + version: 1, + title: "Use the strict companion boundary", + pinned: false, + }, + })); + assert.equal(assertStructuredResult(results.at(-1)).entry.version, 2); + + results.push(await client.callTool({ + name: "taskboard_context_archive", + arguments: { id: BASE_ENTRY.id, version: 2 }, + })); + assert.equal(assertStructuredResult(results.at(-1)).entry.version, 3); + + const searchRequest = companion.requests.find((request) => ( + request.method === "GET" + && request.pathname === "/api/projects/project-1/context" + )); + assert.deepEqual(searchRequest.query, { + query: "companion + http", + kind: "decision", + tag: "mcp", + pinned: "true", + archived: "false", + limit: "10", + cursor: "cursor/with+symbols=", + }); + const publishRequests = companion.requests.filter((request) => request.method === "POST" && ( + request.pathname === "/api/projects/project-1/context" + )); + assert.equal(publishRequests.length, 2); + assert.equal(publishRequests[0].body.sourceType, "agent"); + assert.equal(publishRequests[0].body.idempotencyKey, publishArguments.idempotencyKey); + assert.deepEqual( + companion.requests.find((request) => request.method === "PATCH").body, + { version: 1, title: "Use the strict companion boundary", pinned: false }, + ); + assert.deepEqual( + companion.requests.find((request) => request.pathname.endsWith("/archive")).body, + { version: 2 }, + ); + for (const request of companion.requests) { + assert.equal(request.headers.authorization, undefined); + assert.equal(request.headers["x-taskboard-client"], "taskctl"); + } + + const serialized = JSON.stringify(results); + assert.doesNotMatch(serialized, new RegExp(PRIVATE_PATH_SENTINEL)); + assert.doesNotMatch(serialized, new RegExp(PASSWORD_SENTINEL)); + assert.doesNotMatch(serialized, new RegExp(ENV_SENTINEL)); + assert.doesNotMatch(serialized, /workspacePath|authorization|password|deviceMappings|env/i); +}); + +test("stdio tools preserve safe error codes and actionable conflict details", async (t) => { + const { client } = await createMcpFixture(t); + const cases = [ + ["taskboard_context_get", { id: "missing" }, 404, "CONTEXT_NOT_FOUND", "not_found"], + ["taskboard_context_get", { id: "auth-required" }, 401, "UNAUTHORIZED", "authentication_required"], + ["taskboard_context_get", { id: "remote-down" }, 502, "REMOTE_UNAVAILABLE", "cloud_unavailable"], + ["taskboard_context_get", { id: "untrusted-code" }, 502, "HTTP_502", "request_failed"], + [ + "taskboard_context_update", + { id: "version-conflict", version: 3, title: "New title" }, + 409, + "VERSION_CONFLICT", + "version_conflict", + ], + [ + "taskboard_context_publish", + { + kind: "risk", + title: "Conflict", + body: "Different content", + idempotencyKey: "conflicting-key", + }, + 409, + "IDEMPOTENCY_CONFLICT", + "idempotency_conflict", + ], + [ + "taskboard_context_archive", + { id: "already-archived", version: 1 }, + 409, + "CONTEXT_ALREADY_ARCHIVED", + "already_archived", + ], + ]; + + for (const [name, args, status, code, category] of cases) { + const result = await client.callTool({ name, arguments: args }); + assert.equal(result.isError, true, name); + const structured = assertStructuredResult(result); + assert.equal(structured.ok, false, name); + assert.equal(structured.error.status, status, name); + assert.equal(structured.error.code, code, name); + assert.equal(structured.error.category, category, name); + assert.equal(typeof structured.error.action, "string", name); + if (code === "VERSION_CONFLICT") { + assert.deepEqual(structured.error.details, { expectedVersion: 3, actualVersion: 4 }); + } + const serialized = JSON.stringify(result); + assert.doesNotMatch(serialized, new RegExp(PRIVATE_PATH_SENTINEL)); + assert.doesNotMatch(serialized, new RegExp(PASSWORD_SENTINEL)); + assert.doesNotMatch(serialized, new RegExp(ENV_SENTINEL)); + assert.doesNotMatch(serialized, new RegExp(ERROR_CODE_SENTINEL)); + assert.doesNotMatch(serialized, /upstream|authorization|workspacePath|password|env/i); + } +}); diff --git a/test/plugin-structure.test.mjs b/test/plugin-structure.test.mjs new file mode 100644 index 000000000..c90ab407a --- /dev/null +++ b/test/plugin-structure.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { access, readFile } from "node:fs/promises"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +async function readJson(relativePath) { + return JSON.parse(await readFile(path.join(REPO_ROOT, relativePath), "utf8")); +} + +test("official plugin manifest exposes the repository Skills and MCP server", async () => { + const manifest = await readJson(".codex-plugin/plugin.json"); + const packageJson = await readJson("package.json"); + + assert.equal(manifest.name, path.basename(REPO_ROOT)); + assert.equal(manifest.version, packageJson.version); + assert.equal(manifest.skills, "./skills/"); + assert.equal(manifest.mcpServers, "./.mcp.json"); + assert.equal(manifest.repository, "https://github.com/chuspeeism/dashi-taskboard"); + assert.equal(manifest.license, "UNLICENSED"); + assert.deepEqual(manifest.interface.capabilities, ["Read", "Write"]); + + for (const referencedPath of [manifest.skills, manifest.mcpServers]) { + await access(path.resolve(REPO_ROOT, referencedPath)); + } +}); + +test("MCP manifest invokes the portable npm bin without local paths or credentials", async () => { + const mcpManifest = await readJson(".mcp.json"); + const packageJson = await readJson("package.json"); + + assert.deepEqual(mcpManifest, { + mcpServers: { + "dashi-taskboard": { + command: "dashi-taskboard-mcp", + args: [], + }, + }, + }); + assert.equal(packageJson.bin["dashi-taskboard-mcp"], "./mcp/server.mjs"); + assert.equal(packageJson.scripts["plugin:validate"], "node scripts/validate-plugin.mjs"); + assert.doesNotMatch(JSON.stringify(mcpManifest), /authorization|password|token|workspacePath|\/home\//i); + await access(path.resolve(REPO_ROOT, packageJson.bin["dashi-taskboard-mcp"])); + await access(path.join(REPO_ROOT, "scripts", "validate-plugin.mjs")); +}); + +test("repository marketplace makes the root plugin available with install-time auth", async () => { + const marketplace = await readJson(".agents/plugins/marketplace.json"); + assert.equal(marketplace.name, "dashi-taskboard-local"); + assert.equal(typeof marketplace.interface.displayName, "string"); + assert.equal(marketplace.plugins.length, 1); + assert.deepEqual(marketplace.plugins[0], { + name: "dashi-taskboard", + source: { source: "local", path: "./" }, + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: "Productivity", + }); + assert.equal( + marketplace.plugins[0].name, + (await readJson(".codex-plugin/plugin.json")).name, + ); + await access(path.resolve(REPO_ROOT, marketplace.plugins[0].source.path)); +}); diff --git a/test/project-context.test.mjs b/test/project-context.test.mjs new file mode 100644 index 000000000..a02850e97 --- /dev/null +++ b/test/project-context.test.mjs @@ -0,0 +1,344 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, test } from "node:test"; + +import { createTaskboardServer } from "../server/index.mjs"; +import { TaskboardDatabase } from "../server/database.mjs"; + +const running = []; + +afterEach(async () => { + while (running.length > 0) { + const { app, directory } = running.pop(); + await app?.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +async function startServer() { + const directory = await mkdtemp(path.join(os.tmpdir(), "project-context-local-")); + const app = createTaskboardServer({ dataDirectory: directory }); + const address = await app.listen({ port: 0 }); + running.push({ app, directory }); + return { app, baseUrl: `http://127.0.0.1:${address.port}`, directory }; +} + +async function request(baseUrl, pathname, { body, headers: inputHeaders, ...init } = {}) { + const headers = new Headers(inputHeaders); + if (body !== undefined) headers.set("content-type", "application/json"); + const response = await fetch(`${baseUrl}${pathname}`, { + ...init, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + return { response, body: text ? JSON.parse(text) : undefined }; +} + +const aliceHeaders = { + "x-taskboard-user-id": "alice", + "x-taskboard-user-name": encodeURIComponent("Alice"), +}; + +async function createEntry(baseUrl, input, headers = aliceHeaders) { + return request(baseUrl, "/api/projects/local/context", { + method: "POST", + headers, + body: { + kind: "decision", + title: "Default decision", + body: "Default body", + ...input, + }, + }); +} + +test("local context migration is repeatable and preserves entries and revisions", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "project-context-migration-")); + running.push({ app: null, directory }); + const databasePath = path.join(directory, "taskboard.sqlite"); + const input = { + kind: "decision", + title: "Keep the schema", + body: "Migration replay must preserve this entry.", + tags: ["migration"], + sourceType: "manual", + sourceId: null, + sourceThreadId: null, + pinned: false, + idempotencyKey: "migration-entry", + }; + const actor = { type: "user", id: "alice", name: "Alice" }; + + let database = new TaskboardDatabase(databasePath); + const created = database.createContextEntry("local", input, actor).entry; + database.close(); + database = new TaskboardDatabase(databasePath); + assert.equal(database.getContextEntry(created.id).title, input.title); + assert.deepEqual(database.listContextRevisions(created.id).map((revision) => revision.version), [1]); + const schemaNames = database.database.prepare(` + SELECT name FROM sqlite_schema + WHERE name LIKE 'project_context_%' + ORDER BY name + `).all().map((row) => row.name); + assert.deepEqual(schemaNames, [ + "project_context_entries", + "project_context_entries_project_idempotency", + "project_context_entries_project_kind", + "project_context_entries_project_page", + "project_context_entries_project_pinned", + "project_context_revisions", + "project_context_revisions_entry_version_unique", + "project_context_revisions_entry_versions", + ]); + database.close(); +}); + +test("local context API covers identity, idempotency, filters and stable pagination", async () => { + const { baseUrl } = await startServer(); + const created = await createEntry(baseUrl, { + title: "Choose 100% D1", + body: "Cloud decision body", + tags: ["cloud", "launch"], + sourceType: "issue", + sourceId: "ASH-46", + sourceThreadId: "thread-source-only", + pinned: true, + idempotencyKey: "decision-1", + authorName: "Injected", + }); + assert.equal(created.response.status, 400); + assert.equal(created.body.error.code, "UNKNOWN_FIELD"); + const unexpectedQuery = await request(baseUrl, "/api/projects/local/context?unexpected=1", { + method: "POST", + headers: aliceHeaders, + body: { + kind: "decision", + title: "Rejected query", + body: "Should not be created", + }, + }); + assert.equal(unexpectedQuery.response.status, 400); + assert.equal(unexpectedQuery.body.error.code, "UNKNOWN_QUERY_PARAMETER"); + const unsupportedContentType = await fetch(`${baseUrl}/api/projects/local/context`, { + method: "POST", + headers: { ...aliceHeaders, "content-type": "application/jsonx" }, + body: JSON.stringify({ kind: "decision", title: "Bad media", body: "Rejected" }), + }); + assert.equal(unsupportedContentType.status, 415); + assert.equal((await unsupportedContentType.json()).error.code, "UNSUPPORTED_MEDIA_TYPE"); + + const first = await createEntry(baseUrl, { + title: "Choose 100% D1", + body: "Cloud decision body", + tags: ["cloud", "launch"], + sourceType: "issue", + sourceId: "ASH-46", + sourceThreadId: "thread-source-only", + pinned: true, + idempotencyKey: "decision-1", + }); + assert.equal(first.response.status, 201); + assert.deepEqual(Object.keys(first.body.entry), [ + "id", "projectId", "kind", "title", "body", "tags", "sourceType", "sourceId", + "sourceThreadId", "authorType", "authorId", "authorName", "pinned", "archivedAt", + "version", "idempotencyKey", "createdAt", "updatedAt", + ]); + assert.equal(first.body.entry.authorId, "alice"); + assert.equal(first.body.entry.authorName, "Alice"); + assert.equal(first.body.entry.version, 1); + assert.equal(first.body.entry.archivedAt, null); + + const replay = await createEntry(baseUrl, { + title: "Choose 100% D1", + body: "Cloud decision body", + tags: ["cloud", "launch"], + sourceType: "issue", + sourceId: "ASH-46", + sourceThreadId: "thread-source-only", + pinned: true, + idempotencyKey: "decision-1", + }); + assert.equal(replay.response.status, 200); + assert.equal(replay.body.entry.id, first.body.entry.id); + const idempotencyConflict = await createEntry(baseUrl, { + title: "Different content", + idempotencyKey: "decision-1", + }); + assert.equal(idempotencyConflict.response.status, 409); + assert.equal(idempotencyConflict.body.error.code, "IDEMPOTENCY_CONFLICT"); + + const risk = await createEntry(baseUrl, { + kind: "risk", + title: "Launch risk", + body: "Monitor the rollout", + tags: ["launch"], + }); + const fact = await createEntry(baseUrl, { + kind: "fact", + title: "Unpinned fact", + body: "Not selected by brief", + tags: ["fact"], + }); + for (const [query, expectedId] of [ + ["query=%25", first.body.entry.id], + ["query=rollout", risk.body.entry.id], + ["query=fact", fact.body.entry.id], + ["kind=risk", risk.body.entry.id], + ["tag=cloud", first.body.entry.id], + ["pinned=true", first.body.entry.id], + ]) { + const listed = await request(baseUrl, `/api/projects/local/context?${query}`); + assert.equal(listed.response.status, 200, query); + assert.deepEqual(listed.body.entries.map((entry) => entry.id), [expectedId], query); + } + + const pagedIds = []; + let cursor = null; + do { + const page = await request( + baseUrl, + `/api/projects/local/context?limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, + ); + assert.equal(page.response.status, 200); + pagedIds.push(...page.body.entries.map((entry) => entry.id)); + cursor = page.body.nextCursor; + } while (cursor); + assert.equal(pagedIds.length, 3); + assert.equal(new Set(pagedIds).size, pagedIds.length); + + const invalidCursor = await request(baseUrl, "/api/projects/local/context?cursor=not+base64"); + assert.equal(invalidCursor.response.status, 400); + assert.equal(invalidCursor.body.error.code, "INVALID_QUERY_PARAMETER"); +}); + +test("local context lifecycle returns 409 without mutation and keeps deterministic revisions", async () => { + const { baseUrl } = await startServer(); + const created = await createEntry(baseUrl, { + title: "Original", + tags: ["decision"], + idempotencyKey: "lifecycle", + }); + const id = created.body.entry.id; + const updated = await request(baseUrl, `/api/context/${id}`, { + method: "PATCH", + headers: aliceHeaders, + body: { version: 1, title: "Updated", pinned: true }, + }); + assert.equal(updated.response.status, 200); + assert.equal(updated.body.entry.version, 2); + + const replayAfterUpdate = await createEntry(baseUrl, { + title: "Original", + tags: ["decision"], + idempotencyKey: "lifecycle", + }); + assert.equal(replayAfterUpdate.response.status, 200); + assert.equal(replayAfterUpdate.body.entry.id, id); + const changedReplay = await createEntry(baseUrl, { + title: "Original", + body: "Different original body", + tags: ["decision"], + idempotencyKey: "lifecycle", + }); + assert.equal(changedReplay.response.status, 409); + assert.equal(changedReplay.body.error.code, "IDEMPOTENCY_CONFLICT"); + + const stale = await request(baseUrl, `/api/context/${id}`, { + method: "PATCH", + headers: aliceHeaders, + body: { version: 1, title: "Stale" }, + }); + assert.equal(stale.response.status, 409); + assert.equal(stale.body.error.code, "VERSION_CONFLICT"); + assert.deepEqual(stale.body.error.details, { expectedVersion: 1, actualVersion: 2 }); + const afterStale = await request(baseUrl, `/api/context/${id}`); + assert.equal(afterStale.body.entry.title, "Updated"); + + const archived = await request(baseUrl, `/api/context/${id}/archive`, { + method: "POST", + headers: aliceHeaders, + body: { version: 2 }, + }); + assert.equal(archived.response.status, 200); + assert.equal(archived.body.entry.version, 3); + assert.ok(archived.body.entry.archivedAt); + const defaultList = await request(baseUrl, "/api/projects/local/context"); + assert.deepEqual(defaultList.body.entries, []); + const archivedList = await request(baseUrl, "/api/projects/local/context?archived=true"); + assert.deepEqual(archivedList.body.entries.map((entry) => entry.id), [id]); + + const restored = await request(baseUrl, `/api/context/${id}/restore`, { + method: "POST", + headers: aliceHeaders, + body: { version: 3 }, + }); + assert.equal(restored.response.status, 200); + assert.equal(restored.body.entry.version, 4); + assert.equal(restored.body.entry.archivedAt, null); + const revisions = await request(baseUrl, `/api/context/${id}/revisions`); + assert.deepEqual(revisions.body.revisions.map((revision) => revision.version), [1, 2, 3, 4]); + assert.deepEqual(revisions.body.revisions.map((revision) => revision.title), [ + "Original", "Updated", "Updated", "Updated", + ]); +}); + +test("local context brief is deterministic and the Markdown body uses a 64 KiB UTF-8 limit", async () => { + const { baseUrl } = await startServer(); + const pinned = await createEntry(baseUrl, { + kind: "fact", + title: "Pinned fact", + body: "Pinned first", + pinned: true, + }); + const requirement = await createEntry(baseUrl, { + kind: "requirement", + title: "Requirement", + body: "Required next", + }); + const risk = await createEntry(baseUrl, { + kind: "risk", + title: "Risk", + body: "Risk after primary", + }); + const oldSummary = await createEntry(baseUrl, { + kind: "summary", + title: "Old summary", + body: "Outdated summary", + }); + const summary = await createEntry(baseUrl, { + kind: "summary", + title: "Summary", + body: "Summary last", + }); + await createEntry(baseUrl, { kind: "fact", title: "Excluded fact", body: "Not pinned" }); + + const firstBrief = await request(baseUrl, "/api/projects/local/context/brief"); + const secondBrief = await request(baseUrl, "/api/projects/local/context/brief"); + assert.deepEqual(firstBrief.body, secondBrief.body); + assert.deepEqual(firstBrief.body.includedEntryIds, [ + pinned.body.entry.id, + requirement.body.entry.id, + risk.body.entry.id, + summary.body.entry.id, + ]); + assert.equal(firstBrief.body.includedEntryIds.includes(oldSummary.body.entry.id), false); + + const exact = await createEntry(baseUrl, { + title: "Exact byte limit", + body: "é".repeat(32_768), + pinned: true, + }); + assert.equal(exact.response.status, 201); + const over = await createEntry(baseUrl, { + title: "Over byte limit", + body: "é".repeat(32_769), + }); + assert.equal(over.response.status, 400); + assert.equal(over.body.error.code, "INVALID_FIELD"); + const truncated = await request(baseUrl, "/api/projects/local/context/brief"); + assert.equal(truncated.body.truncated, true); + assert.ok(truncated.body.brief.length <= 12_000); +});