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 8efe5914b..946b82af4 100644 --- a/cloud/src/index.mjs +++ b/cloud/src/index.mjs @@ -1,4 +1,17 @@ import { normalizeWorkflowSnapshot } from "../../shared/workflow-control-flow.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; @@ -384,8 +397,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", @@ -688,6 +704,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)); +} + function parseProjectCreate(body) { assertPlainObject(body); assertAllowedKeys(body, new Set(["id", "name", "workspacePath"])); @@ -807,6 +1153,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"])); @@ -1820,6 +2332,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); @@ -1963,6 +2488,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, { @@ -1977,6 +2531,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/package.json b/package.json index 30dd4173c..eaaa5a586 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "start": "node server/index.mjs", "taskctl": "node cli/taskctl.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", diff --git a/scripts/migrate-to-cloud.mjs b/scripts/migrate-to-cloud.mjs index 3eab1289c..4841e171c 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) { @@ -404,6 +456,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)) { @@ -412,36 +554,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"); } @@ -456,7 +569,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 `; @@ -656,6 +774,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), @@ -679,6 +801,7 @@ export async function readCloudMigrationBundle(inputDirectory) { tables, attachments, }; + normalizeBackwardCompatibleContextTables(bundle); validateBundle(bundle); return bundle; } 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 d70e8c64a..9e48b6a9f 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -15,6 +15,14 @@ import { 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 { normalizeWorkflowSnapshot } from "../shared/workflow-control-flow.mjs"; import { AiChatService } from "./ai-chat.mjs"; import { createCloudConfigStore } from "./cloud-config.mjs"; @@ -636,6 +644,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( @@ -1628,6 +1809,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"]); @@ -1704,6 +1921,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/database.mjs b/server/database.mjs index 87fbd98ac..b85a2f675 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); @@ -115,6 +123,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 workflowWorkspaceFromRow(row) { return { projectId: row.project_id, @@ -270,6 +317,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 ai_chat_threads ( id TEXT PRIMARY KEY, title TEXT NOT NULL, @@ -626,6 +734,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, + }); + } + getWorkflowWorkspace(projectId) { if (!this.database.prepare("SELECT 1 FROM projects WHERE id = ?").get(projectId)) { throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`); 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/test/board-views.test.mjs b/test/board-views.test.mjs index 88f18dcac..f416daf5f 100644 --- a/test/board-views.test.mjs +++ b/test/board-views.test.mjs @@ -16,10 +16,11 @@ const serverSource = await readFile(new URL("../server/app.mjs", import.meta.url const styles = await readFile(new URL("../web/src/components/workflow.css", import.meta.url), "utf8"); const globalStyles = await readFile(new URL("../web/src/styles.css", import.meta.url), "utf8"); -test("the taskboard defaults to issues and exposes issue and node mode tabs", () => { - assert.match(appSource, /type BoardView = "issues" \| "workflow"/); +test("the taskboard defaults to issues and exposes issue, context and node mode tabs", () => { + assert.match(appSource, /type BoardView = "issues" \| "context" \| "workflow"/); assert.match(appSource, /useState\("issues"\)/); assert.match(appSource, />\s*议题看板\s*<\/button>/); + assert.match(appSource, />\s*Context\s*<\/button>/); assert.match(appSource, />\s*节点模式\s*<\/button>/); assert.match(appSource, /aria-pressed=\{boardView === "issues"\}/); assert.match(appSource, /aria-pressed=\{boardView === "workflow"\}/); diff --git a/test/cloud-companion.test.mjs b/test/cloud-companion.test.mjs index 707ae7204..7b18fd187 100644 --- a/test/cloud-companion.test.mjs +++ b/test/cloud-companion.test.mjs @@ -273,6 +273,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", @@ -535,6 +541,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 a4cb1515b..077d6689e 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, }, }; } @@ -562,6 +567,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); @@ -574,6 +581,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({ @@ -670,7 +753,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); }); @@ -718,6 +801,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/context-ui.test.mjs b/test/context-ui.test.mjs new file mode 100644 index 000000000..6426a3440 --- /dev/null +++ b/test/context-ui.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; + +const typesSource = await readFile(new URL("../web/src/types.ts", import.meta.url), "utf8"); +const apiSource = await readFile(new URL("../web/src/api.ts", import.meta.url), "utf8"); +const viewSource = await readFile(new URL("../web/src/components/ContextView.tsx", import.meta.url), "utf8").catch(() => ""); +const appSource = await readFile(new URL("../web/src/App.tsx", import.meta.url), "utf8"); +const styles = await readFile(new URL("../web/src/styles.css", import.meta.url), "utf8"); + +test("context client exposes the ASH-46 DTOs and all endpoints", () => { + assert.match(typesSource, /ProjectContextEntry/); + for (const type of ["ProjectContextEntry", "ProjectContextRevision", "ProjectContextListResponse"]) { + assert.match(typesSource, new RegExp(`interface ${type}`)); + } + for (const method of [ + "listProjectContext", "getProjectContextBrief", "createProjectContextEntry", + "getProjectContextEntry", "updateProjectContextEntry", "archiveProjectContextEntry", + "restoreProjectContextEntry", "listProjectContextRevisions", + ]) { + assert.match(apiSource, new RegExp(`export async function ${method}`)); + } + assert.match(apiSource, /query\.set\("kind"/); + assert.match(apiSource, /query\.set\("tag"/); + assert.match(apiSource, /query\.set\("archived"/); +}); + +test("context view uses safe markdown and preserves drafts on version conflict", () => { + assert.match(viewSource, /ReactMarkdown/); + assert.match(viewSource, /remarkGfm/); + assert.doesNotMatch(viewSource, /dangerouslySetInnerHTML|rehypeRaw/); + assert.match(viewSource, /getProjectContextEntry/); + assert.match(viewSource, /VERSION_CONFLICT/); + assert.match(viewSource, /archived: showArchived \? "all" : "false"/); + assert.match(viewSource, /sortContextEntries/); + assert.match(viewSource, /entryMatchesCurrentFilters/); + assert.match(viewSource, /handleMobileBack/); + assert.match(viewSource, /archiveTarget/); + assert.match(viewSource, /actionConflict/); + assert.match(viewSource, /aria-invalid={validationField === "body"}/); + assert.match(viewSource, /刷新后重试/); + assert.match(viewSource, /sourceThreadId/); + assert.match(viewSource, /visibleSourceIdentifier/); + assert.doesNotMatch(viewSource, /恢复.*Codex|打开.*会话/); +}); + +test("context view is integrated as an accessible project peer tab", () => { + assert.match(appSource, /BoardView = "issues" \| "context" \| "workflow"/); + assert.match(appSource, /ContextView/); + assert.match(appSource, /boardView === "context"/); + assert.match(appSource, /context\.created/); + assert.match(appSource, /局域网模式未启用账号认证/); + assert.match(styles, /\.context-view/); + assert.match(styles, /\.context-editor-field input:focus-visible/); + assert.match(styles, /@media \(max-width: 760px\)/); +}); diff --git a/test/helpers/cloud-worker-harness.mjs b/test/helpers/cloud-worker-harness.mjs index 3b4de0132..8a152051d 100644 --- a/test/helpers/cloud-worker-harness.mjs +++ b/test/helpers/cloud-worker-harness.mjs @@ -1,4 +1,4 @@ -import { access, mkdtemp, readFile, rm } from "node:fs/promises"; +import { access, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -7,11 +7,28 @@ import { Miniflare } from "miniflare"; const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const ENTRY_PATH = path.join(PROJECT_ROOT, "cloud", "src", "index.mjs"); -const MIGRATION_PATH = path.join(PROJECT_ROOT, "cloud", "migrations", "0001_initial.sql"); +const MIGRATIONS_DIRECTORY = path.join(PROJECT_ROOT, "cloud", "migrations"); + +async function migrationPaths() { + const entries = await readdir(MIGRATIONS_DIRECTORY); + const paths = entries + .filter((entry) => entry.endsWith(".sql")) + .sort() + .map((entry) => path.join(MIGRATIONS_DIRECTORY, entry)); + if (paths.length === 0) throw new Error("Cloud implementation has no D1 migrations"); + return paths; +} async function requireCloudImplementation() { const missing = []; - for (const filename of [ENTRY_PATH, MIGRATION_PATH]) { + let migrations = []; + try { + migrations = await migrationPaths(); + } catch (error) { + if (error.code !== "ENOENT") throw error; + missing.push(path.relative(PROJECT_ROOT, MIGRATIONS_DIRECTORY)); + } + for (const filename of [ENTRY_PATH, ...migrations]) { try { await access(filename); } catch (error) { @@ -22,12 +39,13 @@ async function requireCloudImplementation() { if (missing.length > 0) { throw new Error(`Cloud implementation is missing:\n${missing.join("\n")}`); } + return migrations; } export async function createCloudWorkerHarness({ sharedSecret = "two-person-shared-secret", } = {}) { - await requireCloudImplementation(); + const migrations = await requireCloudImplementation(); const persistenceRoot = await mkdtemp(path.join(os.tmpdir(), "taskboard-cloud-worker-")); const miniflare = new Miniflare({ modules: true, @@ -48,7 +66,9 @@ export async function createCloudWorkerHarness({ try { await miniflare.ready; const db = await miniflare.getD1Database("DB"); - await db.exec(await readFile(MIGRATION_PATH, "utf8")); + for (const migration of migrations) { + await db.exec(await readFile(migration, "utf8")); + } const attachments = await miniflare.getR2Bucket("ATTACHMENTS"); async function request(pathname, { 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); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 9489e6662..33e32ca94 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -45,6 +45,7 @@ import { import { BoardColumn, STATUS_DETAILS } from "./components/BoardColumn"; import { AiChat } from "./components/AiChat"; import { BoardSettingsMenu } from "./components/BoardSettingsMenu"; +import { ContextView } from "./components/ContextView"; import { HiddenColumns } from "./components/HiddenColumns"; import { resolveInlineMediaMarkdown, @@ -90,7 +91,7 @@ import { createRevisionPoller, getRevisionPollingInterval } from "./revisionPoll type ConnectionState = "connecting" | "live" | "reconnecting"; type Theme = "light" | "dark"; -type BoardView = "issues" | "workflow"; +type BoardView = "issues" | "context" | "workflow"; const SHOW_WORKFLOW_BOARD_ENTRY = false; const WorkflowBoard = lazy(() => import("./components/WorkflowBoard").then((module) => ({ @@ -219,6 +220,10 @@ const EVENT_NAMES = [ "attachment.deleted", "project.created", "workflow.updated", + "context.created", + "context.updated", + "context.archive", + "context.restore", ] as const; function isTheme(value: unknown): value is Theme { @@ -427,6 +432,7 @@ interface LocalRealtimeSyncProps { setConnection: Dispatch>; setCommentsRevision: Dispatch>; setAttachmentsRevision: Dispatch>; + setContextRevision: Dispatch>; } function LocalRealtimeSync({ @@ -438,6 +444,7 @@ function LocalRealtimeSync({ setConnection, setCommentsRevision, setAttachmentsRevision, + setContextRevision, }: LocalRealtimeSyncProps) { useEffect(() => { const source = new EventSource("/api/events"); @@ -482,6 +489,10 @@ function LocalRealtimeSync({ if (selectedProjectId) void refreshWorkflowOptions(selectedProjectId); return; } + if (event.type.startsWith("context.")) { + setContextRevision((current) => current + 1); + return; + } if (event.type.startsWith("comment.")) { if (!detailTaskId || !payload.taskId || payload.taskId === detailTaskId) { setCommentsRevision((current) => current + 1); @@ -523,6 +534,7 @@ function LocalRealtimeSync({ setAttachmentsRevision, setCommentsRevision, setConnection, + setContextRevision, ]); return null; @@ -560,6 +572,8 @@ export function App() { const [commentsRevision, setCommentsRevision] = useState(0); const [attachmentsRevision, setAttachmentsRevision] = useState(0); const [workflowRevision, setWorkflowRevision] = useState(0); + const [contextRevision, setContextRevision] = useState(0); + const [contextCreateRequest, setContextCreateRequest] = useState(0); const [workflowOptions, setWorkflowOptions] = useState(DEFAULT_WORKFLOW_OPTIONS); const [contextMenu, setContextMenu] = useState(null); const [draggedTaskId, setDraggedTaskId] = useState(null); @@ -1238,6 +1252,7 @@ export function App() { void refreshWorkflowOptions(projectId).catch(() => {}); } setWorkflowRevision((current) => current + 1); + setContextRevision((current) => current + 1); setCommentsRevision((current) => current + 1); setAttachmentsRevision((current) => current + 1); }, @@ -1325,6 +1340,10 @@ export function App() { event.preventDefault(); document.getElementById("task-search")?.focus(); } + if (event.key === "/" && !detailTaskId && selectedProjectId && boardView === "context") { + event.preventDefault(); + document.getElementById("context-search")?.focus(); + } if (event.key === "Escape" && detailTaskId) { closeTaskDetail(); } @@ -1823,6 +1842,7 @@ export function App() { setConnection={setConnection} setCommentsRevision={setCommentsRevision} setAttachmentsRevision={setAttachmentsRevision} + setContextRevision={setContextRevision} /> )} {!embedded && ( @@ -2000,13 +2020,15 @@ export function App() { onChange={(options) => void saveProjectAutomation(options)} /> )} - {selectedProjectId && boardView === "issues" && ( + {selectedProjectId && (boardView === "issues" || boardView === "context") && ( @@ -2017,8 +2039,15 @@ export function App() {