diff --git a/deploy.json b/deploy.json index 1d97ed3d..284c6bfa 100644 --- a/deploy.json +++ b/deploy.json @@ -81,7 +81,7 @@ { "id": "decision-center", "repo": "https://github.com/pikasTech/unidesk", - "commitId": "b5486a61ab0aa6c227366a95d1afa68281584359" + "commitId": "3ca82e9946ac4bc4a7e059df79c01e21407efb6f" } ] }, @@ -125,7 +125,7 @@ { "id": "decision-center", "repo": "https://github.com/pikasTech/unidesk", - "commitId": "b5486a61ab0aa6c227366a95d1afa68281584359", + "commitId": "3ca82e9946ac4bc4a7e059df79c01e21407efb6f", "artifact": { "kind": "source-build", "repository": "unidesk/decision-center", diff --git a/scripts/artifact-registry-local-provider-contract-test.ts b/scripts/artifact-registry-local-provider-contract-test.ts new file mode 100644 index 00000000..b5581e75 --- /dev/null +++ b/scripts/artifact-registry-local-provider-contract-test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from "node:fs"; + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +const artifactRegistrySource = readFileSync("scripts/src/artifact-registry.ts", "utf8"); +const deploySource = readFileSync("scripts/src/deploy.ts", "utf8"); + +assertCondition( + artifactRegistrySource.includes('function isLocalProvider(providerId: string): boolean'), + "artifact-registry must define an explicit local provider predicate", +); +assertCondition( + artifactRegistrySource.includes('providerId === "local"') && artifactRegistrySource.includes('providerId === "D601-local"'), + "local provider predicate must accept only explicit local aliases", +); +assertCondition( + artifactRegistrySource.includes('runCommand(["bash", "-lc", script], repoRoot, { timeoutMs })'), + "local provider must execute the generated artifact script directly with bash -lc", +); +assertCondition( + artifactRegistrySource.includes('local bash -lc '), + "readonly command shape must disclose local execution instead of host.ssh", +); +assertCondition( + deploySource.includes('providerId: string;'), + "deploy options must carry providerId for artifact consumers", +); +assertCondition( + deploySource.includes('providerId: optionValue(args, ["--provider-id", "--provider"]) ?? "D601"'), + "deploy apply must parse provider-id with D601 as the default", +); +assertCondition( + deploySource.includes('"--provider-id", options.providerId'), + "deploy apply must forward provider-id to artifact-registry deploy-service", +); + +process.stdout.write(`${JSON.stringify({ + ok: true, + checks: [ + "artifact-registry supports an explicit local/D601-local provider for D601 host CLI execution", + "local provider runs the same generated scripts through bash -lc without provider SSH self-dispatch", + "deploy apply forwards --provider-id to artifact-registry consumers while defaulting to D601", + ], +}, null, 2)}\n`); diff --git a/scripts/decision-center-desired-state-contract-test.ts b/scripts/decision-center-desired-state-contract-test.ts index 17d8888e..02968838 100644 --- a/scripts/decision-center-desired-state-contract-test.ts +++ b/scripts/decision-center-desired-state-contract-test.ts @@ -4,7 +4,7 @@ import { rootPath } from "./src/config"; type JsonRecord = Record; -const verifiedDecisionCenterCommit = "b5486a61ab0aa6c227366a95d1afa68281584359"; +const verifiedDecisionCenterCommit = "3ca82e9946ac4bc4a7e059df79c01e21407efb6f"; const verifiedFrontendCommit = "7b9aa4261c216586954cdf926ce2c914a9db5ae3"; function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { diff --git a/scripts/decision-center-diary-summary-contract-test.ts b/scripts/decision-center-diary-summary-contract-test.ts new file mode 100644 index 00000000..9a26b018 --- /dev/null +++ b/scripts/decision-center-diary-summary-contract-test.ts @@ -0,0 +1,136 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runDecisionCenterCommandAsync } from "./src/decision-center"; + +type JsonRecord = Record; + +interface FetchCall { + path: string; + init?: { method?: string; body?: unknown }; +} + +function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +function source(path: string): string { + return readFileSync(path, "utf8"); +} + +function asRecord(value: unknown): JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : {}; +} + +function includesAll(text: string, snippets: string[]): boolean { + return snippets.every((snippet) => text.includes(snippet)); +} + +function makeFetcher(calls: FetchCall[]) { + return async (path: string, init?: { method?: string; body?: unknown }): Promise => { + calls.push({ path, init }); + return { ok: true, status: 200, body: { ok: true, action: "updated", entry: { id: "diary_issue_191" } } }; + }; +} + +async function assertCliPayloadContract(): Promise { + const checks: string[] = []; + const config = {} as Parameters[0]; + const tempDir = mkdtempSync(join(tmpdir(), "unidesk-diary-summary-")); + try { + const bodyFile = join(tempDir, "body.md"); + const bodyText = "# 2099-12-31\n\n## 进展\n- body from file\n"; + writeFileSync(bodyFile, bodyText, "utf8"); + + { + const calls: FetchCall[] = []; + const result = asRecord(await runDecisionCenterCommandAsync(config, [ + "diary", + "upsert", + "2099-12-31", + "--title", + "Issue 191 Repro", + "--summary", + "explicit summary stays separate", + "--body-file", + bodyFile, + "--source-file", + "issue-191-contract", + ], makeFetcher(calls))); + const call = calls[0]; + const body = asRecord(call?.init?.body); + const bodySource = asRecord(result.bodySource); + assertCondition(call?.path === "/api/microservices/decision-center/proxy/api/diary/entries/2099-12-31", "diary upsert must address date route", { call }); + assertCondition(call?.init?.method === "PUT", "diary upsert must use PUT", { call }); + assertCondition(body.summary === "explicit summary stays separate", "CLI must send explicit summary as its own payload field", { body }); + assertCondition(body.body === bodyText, "CLI must keep body populated from --body-file", { body }); + assertCondition(body.sourceFile === "issue-191-contract", "CLI must keep sourceFile disambiguation", { body }); + assertCondition(bodySource.kind === "file" && bodySource.path === bodyFile, "CLI must disclose file body source", { bodySource }); + checks.push("cli-diary-upsert-summary-plus-body-file-payload"); + } + + { + const calls: FetchCall[] = []; + const result = asRecord(await runDecisionCenterCommandAsync(config, [ + "diary", + "upsert", + "2099-12-31", + "--summary", + "summary only update", + ], makeFetcher(calls))); + const body = asRecord(calls[0]?.init?.body); + const bodySource = asRecord(result.bodySource); + assertCondition(body.summary === "summary only update", "CLI must support summary-only diary updates", { body }); + assertCondition(!("body" in body), "summary-only updates must not synthesize body from summary", { body }); + assertCondition(bodySource.kind === "none", "summary-only updates must disclose no body source", { bodySource }); + checks.push("cli-diary-upsert-summary-only-payload"); + } + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + return checks; +} + +export async function runDecisionCenterDiarySummaryContract(): Promise { + const service = source("src/components/microservices/decision-center/src/index.ts"); + const cli = source("scripts/src/decision-center.ts"); + + assertCondition( + includesAll(cli, [ + "function optionalBodyFromArgs(args: string[], command: string)", + "const summary = optionValue(args, [\"--summary\"])", + "if (summary !== undefined) payload.summary = summary", + "bodySource: { kind: \"none\" }", + ]), + "CLI must route --summary independently from body input", + ); + + assertCondition( + includesAll(service, [ + "summary TEXT NOT NULL DEFAULT ''", + "ALTER TABLE decision_center_diary_entries ADD COLUMN IF NOT EXISTS summary TEXT NOT NULL DEFAULT ''", + "summary: row.summary || summaryFromBody(body)", + "function normalizeDiarySummary(value: unknown, body: string): string", + "const summaryProvided = \"summary\" in input", + "normalizeDiarySummary(input.summary, body)", + "id, entry_date, month, title, summary, body, source_file", + "summary = ${summary}", + "OR summary IS DISTINCT FROM ${summary}", + ]), + "Decision Center backend must persist explicit diary summary separately from body", + ); + + const cliChecks = await assertCliPayloadContract(); + return { + ok: true, + checks: [ + "cli-summary-independent-field-contract", + "backend-diary-summary-column-contract", + ...cliChecks, + ], + }; +} + +if (import.meta.main) { + process.stdout.write(`${JSON.stringify(await runDecisionCenterDiarySummaryContract(), null, 2)}\n`); +} diff --git a/scripts/src/artifact-registry.ts b/scripts/src/artifact-registry.ts index 74ff0d09..683ccbfe 100644 --- a/scripts/src/artifact-registry.ts +++ b/scripts/src/artifact-registry.ts @@ -1245,9 +1245,14 @@ function registryRecommendedAction(classification: ArtifactRegistryFailureClassi } function readonlyRemoteCommandShape(action: "status" | "health", options: ArtifactRegistryOptions): string { + if (isLocalProvider(options.providerId)) return `local bash -lc timeoutMs=${options.timeoutMs}`; return `host.ssh provider=${options.providerId} mode=exec argv=bash -lc timeoutMs=${options.timeoutMs}`; } +function isLocalProvider(providerId: string): boolean { + return providerId === "local" || providerId === "D601-local"; +} + function classifyProviderSshCommandFailure(command: CommandResult): ArtifactRegistryFailureClassification { const output = `${command.stderr}\n${command.stdout}`.toLowerCase(); if (command.timedOut || output.includes("timed out") || output.includes("timeout")) return "remote-command-timeout"; @@ -1652,8 +1657,11 @@ function artifactRegistryDeployJsonMirrors( } function runRemoteScript(options: ArtifactRegistryOptions, script: string, timeoutMs = options.timeoutMs, runtime: ArtifactRegistryCommandRuntime = {}): CommandResult { - const command = [process.execPath, "scripts/cli.ts", "ssh", options.providerId, "argv", "bash", "-lc", script]; if (runtime.runRemoteScriptForTest !== undefined) return runtime.runRemoteScriptForTest(options, script, timeoutMs); + if (isLocalProvider(options.providerId)) { + return runCommand(["bash", "-lc", script], repoRoot, { timeoutMs }); + } + const command = [process.execPath, "scripts/cli.ts", "ssh", options.providerId, "argv", "bash", "-lc", script]; return runCommand(command, repoRoot, { timeoutMs }); } diff --git a/scripts/src/check.ts b/scripts/src/check.ts index 8b4eb6d2..b85e53ce 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -461,6 +461,8 @@ export async function runChecks(config: UniDeskConfig, options: CheckOptions = d fileItem("scripts/src/ci.ts"), fileItem("scripts/src/e2e.ts"), fileItem("scripts/deploy-artifact-matrix-contract-test.ts"), + fileItem("scripts/artifact-registry-local-provider-contract-test.ts"), + fileItem("scripts/decision-center-diary-summary-contract-test.ts"), fileItem("scripts/decision-center-desired-state-contract-test.ts"), fileItem("scripts/code-queue-prompt-observation-test.ts"), fileItem("scripts/check-command-progress-contract-test.ts"), @@ -523,6 +525,8 @@ export async function runChecks(config: UniDeskConfig, options: CheckOptions = d items.push(await commandItem("provider:runner-triage-contract", ["bun", "scripts/provider-runner-triage-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); items.push(await commandItem("ssh:argv-guidance-contract", ["bun", "scripts/ssh-argv-guidance-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); items.push(await commandItem("deploy:artifact-matrix-contract", ["bun", "scripts/deploy-artifact-matrix-contract-test.ts"], 90_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("artifact-registry:local-provider-contract", ["bun", "scripts/artifact-registry-local-provider-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); + items.push(await commandItem("decision-center:diary-summary-contract", ["bun", "scripts/decision-center-diary-summary-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); items.push(await commandItem("decision-center:desired-state-contract", ["bun", "scripts/decision-center-desired-state-contract-test.ts"], 30_000, process.env, options.checkHeartbeatMs)); items.push(await commandItem("code-queue:active-run-heartbeat-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:active-run-heartbeat-visible"], 30_000, process.env, options.checkHeartbeatMs)); items.push(await commandItem("code-queue:trace-gap-not-stale", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:trace-gap-not-stale"], 30_000, process.env, options.checkHeartbeatMs)); @@ -572,6 +576,8 @@ export async function runChecks(config: UniDeskConfig, options: CheckOptions = d items.push(skippedItem("provider:runner-triage-contract", "Provider runner triage contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("ssh:argv-guidance-contract", "SSH argv guidance and failure hint contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("deploy:artifact-matrix-contract", "deploy artifact matrix contract is opt-in with script checks", "--scripts-typecheck or --full")); + items.push(skippedItem("artifact-registry:local-provider-contract", "artifact registry local provider contract is opt-in with script checks", "--scripts-typecheck or --full")); + items.push(skippedItem("decision-center:diary-summary-contract", "Decision Center diary summary contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("decision-center:desired-state-contract", "Decision Center desired-state drift contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:liveness-diagnostics-fixtures", "Code Queue liveness diagnostics fixtures are opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("baidu-netdisk:artifact-guard-contract", "Baidu Netdisk artifact guard contract is opt-in with script checks", "--scripts-typecheck or --full")); diff --git a/scripts/src/decision-center.ts b/scripts/src/decision-center.ts index e0b79001..dd9333bc 100644 --- a/scripts/src/decision-center.ts +++ b/scripts/src/decision-center.ts @@ -151,7 +151,7 @@ function readMarkdownFile(path: string): { absolutePath: string; markdown: strin return { absolutePath, markdown }; } -function bodyFromArgs(args: string[], command: string): { body: string; bodySource: Record } { +function optionalBodyFromArgs(args: string[], command: string): { body: string | undefined; bodySource: Record } { const body = optionValue(args, ["--body"]); const bodyFile = optionValue(args, ["--body-file", "--markdown-file"]); const markdownFile = optionValue(args, ["--file"]); @@ -163,6 +163,12 @@ function bodyFromArgs(args: string[], command: string): { body: string; bodySour const { absolutePath, markdown } = readMarkdownFile(file); return { body: markdown, bodySource: { kind: "file", path: absolutePath } }; } + return { body: undefined, bodySource: { kind: "none" } }; +} + +function bodyFromArgs(args: string[], command: string): { body: string; bodySource: Record } { + const { body, bodySource } = optionalBodyFromArgs(args, command); + if (body !== undefined) return { body, bodySource }; throw new Error(`${command} requires --body text or --body-file path`); } @@ -407,14 +413,18 @@ async function todayDiaryAsync(fetcher: (path: string, init?: { method?: string; function diaryEditPayload(args: string[], command: string): { key: string; payload: Record; bodySource: Record } { const key = positionalArgs(args)[0]; if (!key) throw new Error(`${command} requires entry id or YYYY-MM-DD date`); - const { body, bodySource } = bodyFromArgs(args, command); - const payload: Record = { body }; + const { body, bodySource } = optionalBodyFromArgs(args, command); + const payload: Record = {}; const title = optionValue(args, ["--title"]); + const summary = optionValue(args, ["--summary"]); const sourceFile = optionValue(args, ["--source-file", "--source-path", "--source"]); + if (body !== undefined) payload.body = body; if (title !== undefined) payload.title = title; + if (summary !== undefined) payload.summary = summary; if (sourceFile !== undefined) payload.sourceFile = sourceFile; const tags = splitList(optionValues(args, ["--tag", "--tags"])); if (tags.length > 0) payload.tags = tags; + if (Object.keys(payload).length === 0) throw new Error(`${command} requires --body text, --body-file path, --summary, --title, --source-file, or --tag`); return { key, payload, bodySource }; } @@ -429,26 +439,34 @@ async function editDiaryAsync(args: string[], fetcher: (path: string, init?: { m } function editTodayDiary(args: string[]): unknown { - const { body, bodySource } = bodyFromArgs(args, "decision diary today --edit"); - const payload: Record = { body }; + const { body, bodySource } = optionalBodyFromArgs(args, "decision diary today --edit"); + const payload: Record = {}; const title = optionValue(args, ["--title"]); + const summary = optionValue(args, ["--summary"]); const sourceFile = optionValue(args, ["--source-file", "--source-path", "--source"]); + if (body !== undefined) payload.body = body; if (title !== undefined) payload.title = title; + if (summary !== undefined) payload.summary = summary; if (sourceFile !== undefined) payload.sourceFile = sourceFile; const tags = splitList(optionValues(args, ["--tag", "--tags"])); if (tags.length > 0) payload.tags = tags; + if (Object.keys(payload).length === 0) throw new Error("decision diary today --edit requires --body text, --body-file path, --summary, --title, --source-file, or --tag"); return { bodySource, result: unwrapProxyResponse(decisionProxy("/api/diary/today", { method: "PUT", body: payload })) }; } async function editTodayDiaryAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise): Promise { - const { body, bodySource } = bodyFromArgs(args, "decision diary today --edit"); - const payload: Record = { body }; + const { body, bodySource } = optionalBodyFromArgs(args, "decision diary today --edit"); + const payload: Record = {}; const title = optionValue(args, ["--title"]); + const summary = optionValue(args, ["--summary"]); const sourceFile = optionValue(args, ["--source-file", "--source-path", "--source"]); + if (body !== undefined) payload.body = body; if (title !== undefined) payload.title = title; + if (summary !== undefined) payload.summary = summary; if (sourceFile !== undefined) payload.sourceFile = sourceFile; const tags = splitList(optionValues(args, ["--tag", "--tags"])); if (tags.length > 0) payload.tags = tags; + if (Object.keys(payload).length === 0) throw new Error("decision diary today --edit requires --body text, --body-file path, --summary, --title, --source-file, or --tag"); return { bodySource, result: unwrapProxyResponse(await decisionProxyAsync(fetcher, "/api/diary/today", { method: "PUT", body: payload })) }; } diff --git a/scripts/src/deploy.ts b/scripts/src/deploy.ts index 7c7345db..17d92dac 100644 --- a/scripts/src/deploy.ts +++ b/scripts/src/deploy.ts @@ -40,6 +40,7 @@ interface DeployOptions { environment: DeployEnvironment | null; serviceId: string | null; commitOverride: string | null; + providerId: string; runNow: boolean; dryRun: boolean; force: boolean; @@ -221,7 +222,7 @@ export function deployHelp(action: string | undefined = undefined): Record", default: defaultTimeoutMs, description: "Per-step timeout budget where supported." }, + { name: "--provider-id ", default: "D601", description: "Provider used by artifact-registry consumers; use local only from the D601 host CLI." }, { name: "--run-now", description: "Run apply in the foreground worker process; omit it for fire-and-forget async job mode." }, { name: "guard code-queue-source --root ", description: "Validate Code Queue hostPath source relative imports before any scheduler rollout; failures report degradedReason and missing import targets." }, ], @@ -523,6 +525,7 @@ function parseOptions(args: string[]): DeployOptions { environment, serviceId, commitOverride: commitOverride?.toLowerCase() ?? null, + providerId: optionValue(args, ["--provider-id", "--provider"]) ?? "D601", runNow: args.includes("--run-now"), dryRun: args.includes("--dry-run"), force: args.includes("--force"), @@ -2882,6 +2885,7 @@ async function runDevArtifactConsumerService( "--commit", commit, "--source-repo", desired.repo, "--timeout-ms", String(options.timeoutMs), + "--provider-id", options.providerId, "--run-now", ...(options.dryRun ? ["--dry-run"] : []), ]; @@ -3404,6 +3408,7 @@ async function runArtifactConsumerApplyNow( ...(options.dryRun && hasDeployJsonExecutorContract(service) ? ["--deploy-json-service", encodeDeployJsonServiceContract(service)] : []), "--env", environment, "--timeout-ms", String(options.timeoutMs), + "--provider-id", options.providerId, "--run-now", ...(options.dryRun ? ["--dry-run"] : []), ]; diff --git a/scripts/src/help.ts b/scripts/src/help.ts index c6605d87..bca92b1c 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -42,10 +42,10 @@ export function rootHelp(): unknown { { command: "decision diary import [--source-file path] [--tag tag] [--include-entries]", description: "Import a dated work log Markdown into PostgreSQL diary entries split as YYYY-MM/YYYY-MM-DD.md." }, { command: "decision diary list [--month YYYY-MM] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--limit N] [--include-body]", description: "List daily Markdown diary entries stored by Decision Center." }, { command: "decision diary history [--month YYYY-MM] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--limit N] [--include-body]", description: "Read diary history through the productized history API alias." }, - { command: "decision diary today [--edit --body-file path] [--title text] [--tag tag]", description: "Get or create today's diary entry using the service's real current date; --edit saves today's Markdown." }, + { command: "decision diary today [--edit --body-file path|--body text|--summary text] [--title text] [--tag tag]", description: "Get or create today's diary entry using the service's real current date; --edit saves today's Markdown and optional explicit summary." }, { command: "decision diary months", description: "List available Decision Center diary months with day counts." }, { command: "decision diary show [--source-file path]", description: "Show one daily diary Markdown entry; source-file disambiguates same-day entries from multiple imports." }, - { command: "decision diary edit|upsert --body-file path [--title text] [--source-file path] [--tag tag]", description: "Create or edit one daily diary entry through PUT /api/diary/entries/:idOrDate via backend-core proxy." }, + { command: "decision diary edit|upsert [--body-file path|--body text] [--summary text] [--title text] [--source-file path] [--tag tag]", description: "Create or edit one daily diary entry through PUT /api/diary/entries/:idOrDate via backend-core proxy, keeping summary independent from body." }, { command: "decision list [--doc-no DC-...] [--doc-type ...] [--doc-priority P0|P1|P2|P3] [--year YYYY] [--type ...] [--status ...] [--level|--priority ...] [--limit N] [--include-body]", description: "List Decision Center records through the user-service proxy; bodies are omitted unless --include-body is set." }, { command: "decision requirement list|create|show|update|upsert [id|docNo] [--title text] [--body-file path] [--type external_goal|internal_goal|goal|decision|blocker|debt|experiment] [--doc-no DC-...] [--doc-type ...] [--doc-priority P0|P1|P2|P3] [--signer text] [--issued-at ISO]", description: "Manage productized requirement records over the PostgreSQL records model, excluding meeting records." }, { command: "decision show ", description: "Show one Decision Center record." }, diff --git a/src/components/microservices/decision-center/src/index.ts b/src/components/microservices/decision-center/src/index.ts index ee630f51..31eee877 100644 --- a/src/components/microservices/decision-center/src/index.ts +++ b/src/components/microservices/decision-center/src/index.ts @@ -102,6 +102,7 @@ interface DiaryEntryRow { entry_date: Date | string; month: string; title: string; + summary: string; body: string; source_file: string; markdown_path: string; @@ -714,6 +715,7 @@ async function ensureSchema(): Promise { entry_date DATE NOT NULL, month TEXT NOT NULL, title TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '', source_file TEXT NOT NULL DEFAULT '', markdown_path TEXT NOT NULL, @@ -727,6 +729,7 @@ async function ensureSchema(): Promise { CONSTRAINT decision_center_diary_entries_source_date_unique UNIQUE (source_file, entry_date) ) `; + await sql`ALTER TABLE decision_center_diary_entries ADD COLUMN IF NOT EXISTS summary TEXT NOT NULL DEFAULT ''`; await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_diary_entries_month_date ON decision_center_diary_entries(month DESC, entry_date DESC)`; await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_diary_entries_updated ON decision_center_diary_entries(updated_at DESC)`; }, { retryRead: true }); @@ -1310,7 +1313,7 @@ function diaryEntryFromRow(row: DiaryEntryRow, options: { includeBody?: boolean date: dateOnly(row.entry_date), month: row.month, title: row.title, - summary: summaryFromBody(body), + summary: row.summary || summaryFromBody(body), body: includeBody ? body : "", sourceFile: row.source_file, markdownPath: row.markdown_path, @@ -1322,6 +1325,11 @@ function diaryEntryFromRow(row: DiaryEntryRow, options: { includeBody?: boolean }; } +function normalizeDiarySummary(value: unknown, body: string): string { + const explicit = asText(value).replace(/\s+/gu, " ").trim(); + return (explicit || summaryFromBody(body)).slice(0, 280); +} + function splitDiaryMarkdown(markdown: string): DiaryDraft[] { const normalized = markdown.replace(/\r\n?/gu, "\n"); const lines = normalized.split("\n"); @@ -1410,20 +1418,22 @@ function diaryTitleFor(date: string, body: string, rawTitle: unknown): string { async function upsertDiaryEntry(draft: DiaryDraft, sourceFile: string, tags: string[]): Promise<{ entry: DiaryEntry; action: "created" | "updated" | "unchanged" }> { if (draft.body.length > 300_000) throw new HttpError(400, "diary day body must be at most 300000 characters", { date: draft.date, length: draft.body.length }); const hash = contentHash(draft.body); + const summary = summaryFromBody(draft.body); const rows = await withDatabaseRecovery("upsert_diary_entry", () => sql` WITH existing AS ( - SELECT content_hash + SELECT content_hash, summary FROM decision_center_diary_entries WHERE source_file = ${sourceFile} AND entry_date = ${draft.date}::date ), upsert AS ( INSERT INTO decision_center_diary_entries ( - id, entry_date, month, title, body, source_file, markdown_path, tags, content_hash + id, entry_date, month, title, summary, body, source_file, markdown_path, tags, content_hash ) VALUES ( ${`diary_${randomUUID()}`}, ${draft.date}::date, ${draft.month}, ${draft.title}, + ${summary}, ${draft.body}, ${sourceFile}, ${draft.markdownPath}, @@ -1434,13 +1444,16 @@ async function upsertDiaryEntry(draft: DiaryDraft, sourceFile: string, tags: str SET month = EXCLUDED.month, title = EXCLUDED.title, + summary = EXCLUDED.summary, body = EXCLUDED.body, markdown_path = EXCLUDED.markdown_path, tags = EXCLUDED.tags, content_hash = EXCLUDED.content_hash, imported_at = now(), updated_at = CASE - WHEN decision_center_diary_entries.content_hash IS DISTINCT FROM EXCLUDED.content_hash THEN now() + WHEN decision_center_diary_entries.content_hash IS DISTINCT FROM EXCLUDED.content_hash + OR decision_center_diary_entries.summary IS DISTINCT FROM EXCLUDED.summary + THEN now() ELSE decision_center_diary_entries.updated_at END RETURNING * @@ -1448,7 +1461,7 @@ async function upsertDiaryEntry(draft: DiaryDraft, sourceFile: string, tags: str SELECT upsert.*, NOT EXISTS(SELECT 1 FROM existing) AS inserted, - EXISTS(SELECT 1 FROM existing WHERE existing.content_hash IS DISTINCT FROM upsert.content_hash) AS changed + EXISTS(SELECT 1 FROM existing WHERE existing.content_hash IS DISTINCT FROM upsert.content_hash OR existing.summary IS DISTINCT FROM upsert.summary) AS changed FROM upsert `); const row = rows[0]; @@ -1513,6 +1526,7 @@ async function listDiaryEntries(url: URL): Promise { entry_date, month, title, + summary, CASE WHEN ${includeBody}::boolean THEN body ELSE left(body, 4000) END AS body, source_file, markdown_path, @@ -1569,6 +1583,7 @@ async function getDiaryEntry(key: string, options: { sourceFile?: string } = {}) async function upsertDiaryEntryByKey(key: string, input: Record): Promise { const keyDate = /^\d{4}-\d{2}-\d{2}$/u.test(key) ? validDateFilter(key, "date") : ""; const bodyProvided = "body" in input || "markdown" in input; + const summaryProvided = "summary" in input; const titleProvided = "title" in input; const tagsProvided = "tags" in input; const sourceFileProvided = "sourceFile" in input || "sourcePath" in input || "source" in input; @@ -1592,6 +1607,11 @@ async function upsertDiaryEntryByKey(key: string, input: Record const date = existing === undefined ? keyDate : dateOnly(existing.entry_date); const body = bodyProvided ? asText(input.body ?? input.markdown) : existing?.body ?? defaultDiaryBody(date); if (body.length > 300_000) throw new HttpError(400, "diary day body must be at most 300000 characters", { date, length: body.length }); + const summary = summaryProvided + ? normalizeDiarySummary(input.summary, body) + : bodyProvided || existing === undefined + ? summaryFromBody(body) + : existing.summary || summaryFromBody(body); const title = titleProvided || existing === undefined ? diaryTitleFor(date, body, input.title) : existing.title; const tags = tagsProvided ? asStringArray(input.tags, "tags") : Array.isArray(existing?.tags) ? existing.tags.map(String) : []; const finalSourceFile = sourceFileProvided ? sourceFile : validateDiarySourceFile(existing?.source_file ?? "manual"); @@ -1602,12 +1622,13 @@ async function upsertDiaryEntryByKey(key: string, input: Record const rows = existing === undefined ? await withDatabaseRecovery("create_diary_entry_by_date", () => sql` INSERT INTO decision_center_diary_entries ( - id, entry_date, month, title, body, source_file, markdown_path, tags, content_hash, imported_at, updated_at + id, entry_date, month, title, summary, body, source_file, markdown_path, tags, content_hash, imported_at, updated_at ) VALUES ( ${`diary_${randomUUID()}`}, ${date}::date, ${month}, ${title}, + ${summary}, ${body}, ${finalSourceFile}, ${markdownPath}, @@ -1622,6 +1643,7 @@ async function upsertDiaryEntryByKey(key: string, input: Record UPDATE decision_center_diary_entries SET title = ${title}, + summary = ${summary}, body = ${body}, source_file = ${finalSourceFile}, markdown_path = ${markdownPath}, @@ -1631,6 +1653,7 @@ async function upsertDiaryEntryByKey(key: string, input: Record updated_at = CASE WHEN content_hash IS DISTINCT FROM ${hash} OR title IS DISTINCT FROM ${title} + OR summary IS DISTINCT FROM ${summary} OR source_file IS DISTINCT FROM ${finalSourceFile} OR tags IS DISTINCT FROM ${sql.json(tags)}::jsonb THEN now()