Compare commits

...

4 Commits

Author SHA1 Message Date
Kit Langton bae7a954a8 fix(client): require authenticated service stop 2026-08-12 20:04:49 -04:00
Kit Langton d31a994c27 feat(tui): render Mermaid timelines (#42130) 2026-08-12 19:55:39 -04:00
Kit Langton 76640a5c9c feat(sdk): re-export Event schema from sdk-next (#42175) 2026-08-12 19:44:33 -04:00
Kit Langton 76dbaf20ad fix(catalog): serve app shell at lab route (#42159) 2026-08-12 18:18:24 -04:00
21 changed files with 711 additions and 160 deletions
+15 -63
View File
@@ -56,7 +56,6 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
const timing = ensureTiming(options)
const contenders = new Set<ServiceContender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let lastSpawn = 0
let spawnDelay = timing.spawnDelay
@@ -80,18 +79,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
const registration = yield* registered(options.file, true, timing.requestTimeout)
const info = registration.info
const service = registration.service
if (registration.timedOut && info !== undefined) {
timeouts = {
info,
count: timeouts !== undefined && same(timeouts.info, info) ? timeouts.count + 1 : 1,
}
if (timeouts.count >= 3) {
yield* announce("missing")
yield* evict(info, options, timing)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
} else timeouts = undefined
if (service !== undefined) {
spawnDelay = timing.spawnDelay
const compatible = !service.legacy && matchesVersion(service.version, options)
@@ -99,8 +86,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
if (compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
yield* kill(service, timing)
yield* announce("version-mismatch", service.version)
yield* kill(service, options, timing).pipe(Effect.ignore)
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
@@ -133,8 +120,12 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
const existing = yield* find(options)
if (existing !== undefined) yield* kill(existing, options, defaultEnsureTiming)
const registration = yield* registered(options.file, true)
if (registration.service !== undefined) yield* kill(registration.service, defaultEnsureTiming)
if (registration.service === undefined && registration.info !== undefined)
return yield* Effect.fail(
new Error("Background service is not responding; stop its process manually and try again"),
)
})
function fallback() {
@@ -243,20 +234,10 @@ const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = fal
return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
})
// Health-checked lookup without the version gate: lifecycle operations must be
// able to see (and replace or stop) a server from a different version.
const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
return (yield* registered(options.file, true)).service
})
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
// discovery window.
// Poll until an authenticated stop exits, bounded by the configured stop window.
const poll = (timing: EnsureTiming) =>
Schedule.max([Schedule.spaced(timing.stopPollInterval), Schedule.recurs(timing.stopPollAttempts)])
const signal = (pid: number, name: NodeJS.Signals) =>
Effect.try({ try: () => process.kill(pid, name), catch: (cause) => cause }).pipe(Effect.ignore)
const stopped = Effect.fnUntraced(function* (pid: number) {
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
Effect.orElseSucceed(() => false),
@@ -265,44 +246,14 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
})
function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = yield* read(options.file)
if (current === undefined || !same(current, info)) return
yield* signal(info.pid, "SIGTERM")
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
if (Option.isSome(done)) return
const latest = yield* read(options.file)
if (latest === undefined || !same(latest, info)) return
yield* signal(info.pid, "SIGKILL")
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
})
const kill = Effect.fnUntraced(function* (
service: LocalService,
options: { readonly file?: string },
timing: EnsureTiming,
) {
const kill = Effect.fnUntraced(function* (service: LocalService, timing: EnsureTiming) {
const requested = yield* requestStop(service, timing.requestTimeout)
if (requested === "rejected") return
if (requested === "unsupported") {
// A stale registration may point at a reused PID. Authenticate again
// immediately before the legacy signal fallback.
const current = yield* find(options)
if (current === undefined || !same(current.info, service.info)) return
yield* signal(service.info.pid, "SIGTERM")
}
if (requested === "rejected") return yield* Effect.fail(new Error("Background service rejected the stop request"))
if (requested === "unsupported")
return yield* Effect.fail(new Error("Background service does not support authenticated stop requests"))
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
if (Option.isSome(done)) return
const latest = yield* find(options)
if (latest === undefined || !same(latest.info, service.info)) return
yield* signal(service.info.pid, "SIGKILL")
yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)))
return yield* Effect.fail(new Error("Background service accepted the stop request but did not exit"))
})
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
@@ -317,7 +268,8 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout
signal: AbortSignal.timeout(timeout),
}),
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
if (response === undefined) return "rejected" as const
if (response.status === 404 || response.status === 405) return "unsupported" as const
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
const decoded = decodeStopResponse(body)
if (!response.ok || Option.isNone(decoded) || !decoded.value.accepted) return "rejected" as const
+11 -57
View File
@@ -37,7 +37,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const timing = ensureTiming(options)
const deadline = Date.now() + timing.promiseTimeout
const contenders = new Set<ServiceContender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let lastSpawn = 0
let spawnDelay = timing.spawnDelay
@@ -61,19 +60,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
while (true) {
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
const registration = await registered(options.file, true, timing.requestTimeout)
if (registration.timedOut && registration.info !== undefined) {
timeouts = {
info: registration.info,
count: timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
}
if (timeouts.count >= 3) {
announce("missing")
await evict(registration.info, options, timing)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
} else timeouts = undefined
if (registration.service !== undefined) {
spawnDelay = timing.spawnDelay
const service = registration.service
@@ -81,8 +67,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (compatible && service.state === "ready") return service.endpoint
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
await kill(service, timing)
announce("version-mismatch", service.version)
await kill(service, options, timing).catch(() => undefined)
lastSpawn = 0
}
} else {
@@ -110,8 +96,10 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
const existing = await find(options)
if (existing !== undefined) await kill(existing, options, defaultEnsureTiming)
const registration = await registered(options.file, true)
if (registration.service !== undefined) await kill(registration.service, defaultEnsureTiming)
if (registration.service === undefined && registration.info !== undefined)
throw new Error("Background service is not responding; stop its process manually and try again")
}
function fallback() {
@@ -199,16 +187,6 @@ async function registered(file?: string, allowLegacy = false, timeout?: number)
return { info, ...(await probeResult(info, allowLegacy, timeout)) }
}
async function find(options: { readonly file?: string }) {
return (await registered(options.file, true)).service
}
function signal(pid: number, name: NodeJS.Signals) {
try {
process.kill(pid, name)
} catch {}
}
function stopped(pid: number) {
try {
process.kill(pid, 0)
@@ -226,37 +204,12 @@ async function waitUntilStopped(pid: number, timing: EnsureTiming) {
return false
}
function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
async function evict(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = await read(options.file)
if (current === undefined || !same(current, info)) return
signal(info.pid, "SIGTERM")
if (await waitUntilStopped(info.pid, timing)) return
const latest = await read(options.file)
if (latest === undefined || !same(latest, info)) return
signal(info.pid, "SIGKILL")
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
}
async function kill(service: LocalService, options: { readonly file?: string }, timing: EnsureTiming) {
async function kill(service: LocalService, timing: EnsureTiming) {
const requested = await requestStop(service, timing.requestTimeout)
if (requested === "rejected") return
if (requested === "unsupported") {
const current = await find(options)
if (current === undefined || !same(current.info, service.info)) return
signal(service.info.pid, "SIGTERM")
}
if (requested === "rejected") throw new Error("Background service rejected the stop request")
if (requested === "unsupported") throw new Error("Background service does not support authenticated stop requests")
if (await waitUntilStopped(service.info.pid, timing)) return
const latest = await find(options)
if (latest === undefined || !same(latest.info, service.info)) return
signal(service.info.pid, "SIGKILL")
if (!(await waitUntilStopped(service.info.pid, timing)))
throw new Error(`Server process ${service.info.pid} is still running`)
throw new Error("Background service accepted the stop request but did not exit")
}
async function requestStop(service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
@@ -267,7 +220,8 @@ async function requestStop(service: LocalService, timeout = defaultEnsureTiming.
body: JSON.stringify({ instanceID: service.info.id }),
signal: AbortSignal.timeout(timeout),
}).catch(() => undefined)
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
if (response === undefined) return "rejected" as const
if (response.status === 404 || response.status === 405) return "unsupported" as const
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined
if (!response.ok || body?.accepted !== true) return "rejected" as const
return "accepted" as const
+17 -3
View File
@@ -28,7 +28,7 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
let requests = 0
let version = "test"
if (mode === "old" || mode === "reject-stop") version = "old"
if (mode === "old" || mode === "reject-stop" || mode === "stop-hanging" || mode === "stop-accepted-hanging") version = "old"
if (mode === "incompatible") version = "1.9.0"
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
const id = crypto.randomUUID()
@@ -40,7 +40,15 @@ const server = Bun.serve({
await appendFile(registration + ".stop-attempts", process.pid + "\n")
return Response.json({ accepted: false })
}
if (pathname === "/api/service/stop" && mode === "graceful") {
if (pathname === "/api/service/stop" && mode === "stop-hanging") {
await appendFile(registration + ".stop-attempts", process.pid + "\n")
return new Promise<Response>(() => {})
}
if (pathname === "/api/service/stop" && mode === "stop-accepted-hanging") {
await appendFile(registration + ".stop-attempts", process.pid + "\n")
return Response.json({ accepted: true })
}
if (pathname === "/api/service/stop" && (mode === "graceful" || mode === "old" || mode === "incompatible")) {
const body = await request.json()
if (typeof body !== "object" || body === null || body.instanceID !== id) return Response.json({ accepted: false })
await writeFile(registration + ".stop", JSON.stringify(body))
@@ -63,7 +71,13 @@ const server = Bun.serve({
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
if (
mode === "starting" ||
mode === "graceful" ||
mode === "reject-stop" ||
mode === "stop-hanging" ||
mode === "stop-accepted-hanging"
)
return Response.json({ healthy: true, version, pid: process.pid })
return Response.json({ healthy: true, version, pid: process.pid })
},
+50 -10
View File
@@ -100,7 +100,7 @@ test("reports a bounded contender stderr tail with native promises", async () =>
expect(error.message.length).toBeLessThan(9_000)
}, 10_000)
test("evicts an unresponsive registered service before starting its replacement", async () => {
test("never evicts an unresponsive registered service automatically", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = Bun.spawn([process.execPath, fixture, registration, "hanging"], {
@@ -111,19 +111,25 @@ test("evicts an unresponsive registered service before starting its replacement"
await waitForFile(registration)
const original = await Bun.file(registration).json()
const endpoint = await ensure({
const result = ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "delayed", "10"],
command: [process.execPath, fixture, registration, "record-start"],
})
const replacement = await Bun.file(registration).json()
await waitForLines(registration + ".requests", 3)
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
expect(await existing.exited).toBe(0)
expect(replacement.pid).not.toBe(original.pid)
expect(endpoint.url).toBe(replacement.url)
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
expect(existing.exitCode).toBe(null)
expect(await Bun.file(registration).json()).toEqual(original)
await expect(result).rejects.toThrow()
})
test("explicit native stop refuses to signal an unidentified unresponsive PID", async () => {
const registration = await setup("hanging")
const info = await Bun.file(registration).json()
await expect(Service.stop({ file: registration })).rejects.toThrow("stop its process manually")
expect(process.kill(info.pid, 0)).toBe(true)
})
test("requests graceful stop of the exact service instance", async () => {
@@ -135,6 +141,29 @@ test("requests graceful stop of the exact service instance", async () => {
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
})
test.each([
["reject-stop", "rejected the stop request"],
["stop-hanging", "rejected the stop request"],
["stop-accepted-hanging", "accepted the stop request but did not exit"],
["legacy", "does not support authenticated stop requests"],
])("native replacement fails closed for %s service stop", async (mode, message) => {
const registration = await setup(mode)
const directory = await temp()
const contender = join(directory, "contender.json")
const info = await Bun.file(registration).json()
await expect(
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, contender, "record-start"],
}),
).rejects.toThrow(message)
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect(process.kill(info.pid, 0)).toBe(true)
})
async function setup(mode: string) {
const directory = await temp()
const registration = join(directory, "service.json")
@@ -156,3 +185,14 @@ async function waitForFile(file: string) {
}
throw new Error(`Timed out waiting for ${file}`)
}
async function waitForLines(file: string, count: number) {
for (let attempt = 0; attempt < 600; attempt++) {
const text = await Bun.file(file)
.text()
.catch(() => "")
if (text.trim().split("\n").length >= count) return
await Bun.sleep(5)
}
throw new Error(`Timed out waiting for ${count} lines in ${file}`)
}
+39 -26
View File
@@ -118,29 +118,39 @@ test("reports a failed registered service without spawning", async () => {
expect(process.exitCode).toBe(null)
})
test("evicts an unresponsive registered service before starting its replacement", async () => {
test("never evicts an unresponsive registered service automatically", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "hanging")
await waitForFile(registration)
const original = await Bun.file(registration).json()
const endpoint = await run(
const controller = new AbortController()
const result = Effect.runPromise(
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "delayed", "10"],
}),
command: [process.execPath, fixture, registration, "record-start"],
}).pipe(Effect.provide(NodeFileSystem.layer)),
{ signal: controller.signal },
)
const replacement = await Bun.file(registration).json()
await waitForLines(registration + ".requests", 3)
controller.abort()
await result.catch(() => undefined)
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
expect(await existing.exited).toBe(0)
expect(replacement.pid).not.toBe(original.pid)
expect(endpoint.url).toBe(replacement.url)
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: replacement.pid })
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
expect(existing.exitCode).toBe(null)
expect(await Bun.file(registration).json()).toEqual(original)
})
test("explicit stop refuses to signal an unidentified unresponsive PID", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "hanging")
await waitForFile(registration)
await expect(run(Service.stop({ file: registration }))).rejects.toThrow("stop its process manually")
expect(existing.exitCode).toBe(null)
})
test("requests graceful stop of the exact service instance", async () => {
@@ -161,36 +171,39 @@ test("does not spawn contenders while an incompatible service rejects replacemen
const contender = join(directory, "contender.json")
const existing = spawn(registration, "reject-stop")
await waitForFile(registration)
const controller = new AbortController()
const starting = Effect.runPromise(
const starting = run(
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, contender, "record-start"],
}).pipe(Effect.provide(NodeFileSystem.layer)),
{ signal: controller.signal },
}),
)
await waitForLines(registration + ".stop-attempts", 2)
controller.abort()
await starting.catch(() => undefined)
await expect(starting).rejects.toThrow("Background service rejected the stop request")
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect((await Bun.file(registration + ".stop-attempts").text()).trim().split("\n")).toHaveLength(1)
expect(existing.exitCode).toBe(null)
})
test("a legacy health response is still replaced", async () => {
test.each([
["stop-hanging", "rejected the stop request"],
["stop-accepted-hanging", "accepted the stop request but did not exit"],
["legacy", "does not support authenticated stop requests"],
])("replacement fails closed for %s service stop", async (mode, message) => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "legacy")
const contender = join(directory, "contender.json")
const existing = spawn(registration, mode)
await waitForFile(registration)
const starts: EnsureReason[] = []
const result = run(ensure({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
const result = run(
ensure({ file: registration, version: "test", command: [process.execPath, fixture, contender, "record-start"] }),
)
await expect(result).rejects.toThrow("Missing service command")
expect(starts).toEqual(["version-mismatch"])
await existing.exited
await expect(result).rejects.toThrow(message)
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect(existing.exitCode).toBe(null)
})
test("waits for a slow winner while bounding lock probes", async () => {
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import wrangler from "../wrangler.jsonc"
import { assetPath } from "../worker"
describe("catalog worker", () => {
@@ -12,4 +13,8 @@ describe("catalog worker", () => {
expect(assetPath("/lab/catalog/catalog.json")).toBe("/catalog.json")
expect(assetPath("/lab/catalog/captures/opencode/home.frame.json")).toBe("/captures/opencode/home.frame.json")
})
test("leaves HTML routing to the worker", () => {
expect(wrangler.assets.html_handling).toBe("none")
})
})
+1
View File
@@ -8,6 +8,7 @@
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"html_handling": "none",
},
"routes": [
{
+2
View File
@@ -2,10 +2,12 @@ import type { MermaidDiagramKind } from "./diagnostics.js"
import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
import { isMermaidSequenceDiagram } from "./sequence/parser.js"
import { isMermaidStateDiagram } from "./state/parser.js"
import { isMermaidTimelineDiagram } from "./timeline/parser.js"
export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
if (isMermaidFlowchartDiagram(content)) return "flowchart"
if (isMermaidSequenceDiagram(content)) return "sequence"
if (isMermaidStateDiagram(content)) return "state"
if (isMermaidTimelineDiagram(content)) return "timeline"
return undefined
}
+1 -1
View File
@@ -1,4 +1,4 @@
export type MermaidDiagramKind = "flowchart" | "sequence" | "state"
export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline"
/** An otherwise valid diagram contains syntax that this renderer does not support. */
export class MermaidSyntaxError extends Error {
+23
View File
@@ -25,6 +25,10 @@ import { drawStateDiagramGrid } from "./state/drawing.js"
import { parseMermaidStateDiagram } from "./state/parser.js"
import { renderStateGridStyledText } from "./state/render-grid.js"
import { resolveStateStyleColors } from "./state/style.js"
import { drawTimelineDiagramGrid } from "./timeline/drawing.js"
import { parseMermaidTimelineDiagram } from "./timeline/parser.js"
import { renderTimelineGridStyledText } from "./timeline/render-grid.js"
import { resolveTimelineStyleColors } from "./timeline/style.js"
type DiagramKind = NonNullable<ReturnType<typeof detectMermaidDiagram>>
@@ -180,6 +184,25 @@ function prepareDiagram(
height: size.height,
}
}
case "timeline": {
const grid = drawTimelineDiagramGrid(parseMermaidTimelineDiagram(source))
const size = grid.getTextSize({ trimBottom: true })
return {
kind,
source,
text: renderTimelineGridStyledText(
grid,
resolveTimelineStyleColors({
title: color(colors.text),
section: color(colors.secondary),
period: color(colors.warning),
spine: color(colors.muted),
event: color(colors.primary),
}),
),
height: size.height,
}
}
}
}
@@ -3,6 +3,7 @@ import { MermaidSyntaxError } from "../diagnostics.js"
import { parseMermaidFlowchartDiagram } from "../flowchart/parser.js"
import { parseMermaidSequenceDiagram } from "../sequence/parser.js"
import { parseMermaidStateDiagram } from "../state/parser.js"
import { renderTimelineDiagram } from "../timeline/diagram.js"
import { renderSequenceDiagram } from "../sequence/diagram.js"
describe("parser diagnostics", () => {
@@ -104,6 +105,12 @@ describe("parser diagnostics", () => {
).toThrow('Unexpected "end" without an open block in sequence diagram at line 2: "end"')
})
test("reports malformed timeline continuations with timeline diagnostics", () => {
expect(() => renderTimelineDiagram("timeline\n : orphan event")).toThrow(
'Timeline continuation requires a preceding period in timeline diagram at line 2: ": orphan event"',
)
})
test("does not attach else through an unclosed nested sequence block", () => {
expect(() =>
parseMermaidSequenceDiagram(`sequenceDiagram
+28
View File
@@ -333,3 +333,31 @@ stateDiagram-v2
expect(frame).toContain("Idle")
expect(frame).not.toContain("stateDiagram-v2")
})
test("renders a Mermaid timeline fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 18 })
renderer = testRenderer.renderer
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-timeline",
content: `\`\`\`mermaid
timeline
title Product history
section Foundation
2024 : Prototype
: First release
\`\`\``,
syntaxStyle,
treeSitterClient,
renderNode: createMermaidMarkdownRenderer(renderer),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, renderOnce)
const frame = captureCharFrame()
expect(frame).toContain("Product history")
expect(frame).toContain("Foundation")
expect(frame).toContain("First release")
expect(frame).not.toContain("timeline")
})
@@ -0,0 +1,188 @@
import { describe, expect, test } from "bun:test"
import { renderTimelineDiagram } from "./diagram.js"
import { drawTimelineDiagramGrid } from "./drawing.js"
import { parseMermaidTimelineDiagram } from "./parser.js"
import { renderTimelineGridText } from "./render-grid.js"
import { resolveTimelineStyleColors } from "./style.js"
describe("TimelineDiagram", () => {
test("detects and parses titles, sections, periods, inline events, and continuations", () => {
const diagram = parseMermaidTimelineDiagram(`
%% product history
timeline LR
title Product &amp;<br/>Platform
section Foundation
2024 : Prototype : First release
: Public beta
section Growth
2025 : "Scale: &#x2265; 10k"
`)
expect(diagram.direction).toBe("LR")
expect(diagram.title).toBe("Product &<br/>Platform")
expect(diagram.sections).toEqual([{ label: "Foundation" }, { label: "Growth" }])
expect(diagram.periods).toEqual([
{ period: "2024", events: ["Prototype", "First release", "Public beta"] },
{ period: "2025", events: ["Scale: ≥ 10k"] },
])
expect(diagram.entries.map((entry) => entry.type)).toEqual(["section", "period", "section", "period"])
})
test("renders a vertical spine with title, section, periods, events, entities, and line breaks", () => {
const output = renderTimelineDiagram(`timeline
title Product &amp;<br/>Platform
section Foundation<br/>phase
2024 : Prototype<br/>ready : First release
: Scale &#x2265; 10k`)
expect(output).toBe(
[
" Product &",
" Platform",
"",
"Foundation ───┐",
" phase │",
" │",
" 2024 ───● Prototype",
" │ ready",
" │ First release",
" │ Scale ≥ 10k",
" │",
].join("\n"),
)
})
test.each(["timeline", "timeline TD", "timeline LR"])("uses the vertical terminal layout for %s", (header) => {
const output = renderTimelineDiagram(`${header}\n 2024 : One\n 2025 : Two`)
const lines = output.split("\n")
expect(lines.findIndex((line) => line.includes("2024"))).toBeLessThan(
lines.findIndex((line) => line.includes("2025")),
)
expect(output).toContain("│")
expect(output).toContain("●")
})
test("preserves Mermaid direction semantics while using vertical terminal layout", () => {
expect(parseMermaidTimelineDiagram("timeline\n 2024 : One").direction).toBe("LR")
expect(parseMermaidTimelineDiagram("timeline TD\n 2024 : One").direction).toBe("TD")
})
test("keeps ordinary colons in event text", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
2024 : https://example.com : event:detail : next event`)
expect(diagram.periods[0]?.events).toEqual(["https://example.com", "event:detail", "next event"])
})
test("does not treat apostrophes in event prose as quotes", () => {
const diagram = parseMermaidTimelineDiagram("timeline\n 2024 : Kit's launch : Public beta")
expect(diagram.periods[0]?.events).toEqual(["Kit's launch", "Public beta"])
})
test("supports standalone periods followed by continuation events", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
2024
: First release
: Public beta`)
expect(diagram.periods).toEqual([{ period: "2024", events: ["First release", "Public beta"] }])
})
test("ignores timeline comments and accessibility directives", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
# product history
accTitle: Product timeline
accDescr Product release history
2024 : Prototype %% internal note`)
expect(diagram.periods).toEqual([{ period: "2024", events: ["Prototype"] }])
})
test("ignores multiline accessibility descriptions", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
accDescr {
Product milestones by year.
Includes launch and growth.
}
2024 : Ship`)
expect(diagram.periods).toEqual([{ period: "2024", events: ["Ship"] }])
})
test("rejects a continuation without a period with source diagnostics", () => {
expect(() => parseMermaidTimelineDiagram("timeline\n : orphan event")).toThrow(
'Timeline continuation requires a preceding period in timeline diagram at line 2: ": orphan event"',
)
})
test("rejects unsupported and empty syntax", () => {
expect(() => parseMermaidTimelineDiagram("timeline\n section")).toThrow("Timeline section cannot be empty")
expect(() => parseMermaidTimelineDiagram("timeline\n 2024 :")).toThrow("Timeline event cannot be empty")
expect(() => parseMermaidTimelineDiagram("timeline\n : unsupported")).toThrow("requires a preceding period")
})
test("draws semantic styles for every timeline role", () => {
const grid = drawTimelineDiagramGrid(
parseMermaidTimelineDiagram("timeline\n title Roadmap\n section Now\n 2026 : Ship"),
)
const styles = new Set(grid.rows.flatMap((row) => row.map((cell) => cell.style).filter(Boolean)))
expect(styles).toEqual(
new Set([
"title",
"section",
"sectionFade1",
"sectionFade2",
"sectionFade3",
"spine",
"period",
"periodFade1",
"periodFade2",
"periodFade3",
"event",
]),
)
expect(Object.keys(resolveTimelineStyleColors()).sort()).toEqual([
"event",
"period",
"periodFade1",
"periodFade2",
"periodFade3",
"section",
"sectionFade1",
"sectionFade2",
"sectionFade3",
"spine",
"title",
])
expect(renderTimelineGridText(grid)).toBe(
renderTimelineDiagram("timeline\n title Roadmap\n section Now\n 2026 : Ship"),
)
})
test("uses section starts and joins with ordered color ramps", () => {
const grid = drawTimelineDiagramGrid(
parseMermaidTimelineDiagram("timeline\n section Morning\n 09:00 : Start\n section Midday\n 12:00 : Continue"),
)
const text = renderTimelineGridText(grid)
expect(text).toContain("Morning ───┐")
expect(text).toContain("Midday ───┤")
expect(grid.rows[0]?.map((cell) => cell.style).filter(Boolean)).toEqual([
"section",
"section",
"section",
"section",
"section",
"section",
"section",
"sectionFade1",
"sectionFade2",
"sectionFade3",
"spine",
])
})
})
+8
View File
@@ -0,0 +1,8 @@
import { drawTimelineDiagramGrid } from "./drawing.js"
import { parseMermaidTimelineDiagram } from "./parser.js"
import { renderTimelineGridText } from "./render-grid.js"
import type { TimelineDiagramRenderOptions } from "./types.js"
export function renderTimelineDiagram(content: string, options: TimelineDiagramRenderOptions = {}): string {
return renderTimelineGridText(drawTimelineDiagramGrid(parseMermaidTimelineDiagram(content), options))
}
+109
View File
@@ -0,0 +1,109 @@
import { DiagramCanvas } from "../core/canvas.js"
import { splitDiagramLines } from "../core/text-lines.js"
import { diagramTextWidth } from "../core/text.js"
import type { TimelineGrid } from "./render-grid.js"
import { TIMELINE_PERIOD_FADE_STYLES, TIMELINE_SECTION_FADE_STYLES } from "./style.js"
import type { TimelineCellStyle, TimelineDiagram, TimelineDiagramRenderOptions, TimelinePeriod } from "./types.js"
interface PeriodLayout {
period: TimelinePeriod
periodLines: string[]
eventLines: string[][]
height: number
}
const JOIN_WIDTH = TIMELINE_SECTION_FADE_STYLES.length
const SPINE_OFFSET = JOIN_WIDTH + 1
const EVENT_OFFSET = 3
export function drawTimelineDiagramGrid(
diagram: TimelineDiagram,
_options: TimelineDiagramRenderOptions = {},
): TimelineGrid {
const periodLayouts = new Map<TimelinePeriod, PeriodLayout>()
let leftWidth = 0
let rightWidth = 0
let bodyHeight = 0
for (const entry of diagram.entries) {
if (entry.type === "section") {
const lines = splitDiagramLines(entry.section.label)
bodyHeight += lines.length + 1
for (const line of lines) leftWidth = Math.max(leftWidth, diagramTextWidth(line))
continue
}
const periodLines = splitDiagramLines(entry.period.period)
const eventLines = entry.period.events.map(splitDiagramLines)
const eventHeight = eventLines.reduce((height, lines) => height + lines.length, 0)
const height = Math.max(periodLines.length, eventHeight)
periodLayouts.set(entry.period, { period: entry.period, periodLines, eventLines, height })
for (const line of periodLines) leftWidth = Math.max(leftWidth, diagramTextWidth(line))
for (const lines of eventLines) {
for (const line of lines) rightWidth = Math.max(rightWidth, diagramTextWidth(line))
}
bodyHeight += height + 1
}
const titleLines = diagram.title ? splitDiagramLines(diagram.title) : []
const bodyWidth = diagram.entries.length === 0 ? 0 : leftWidth + SPINE_OFFSET + EVENT_OFFSET + rightWidth + 1
let titleWidth = 0
for (const line of titleLines) titleWidth = Math.max(titleWidth, diagramTextWidth(line))
const width = Math.max(bodyWidth, titleWidth)
const titleHeight = titleLines.length === 0 ? 0 : titleLines.length + (diagram.entries.length === 0 ? 0 : 1)
if (width === 0) return new DiagramCanvas(0, 0)
const grid: TimelineGrid = new DiagramCanvas(width, titleHeight + bodyHeight)
titleLines.forEach((line, index) =>
setText(grid, Math.floor((width - diagramTextWidth(line)) / 2), index, line, "title"),
)
if (diagram.entries.length === 0) return grid
const spineX = leftWidth + SPINE_OFFSET
let y = titleHeight
let railStarted = false
for (const entry of diagram.entries) {
if (entry.type === "section") {
const lines = splitDiagramLines(entry.section.label)
lines.forEach((line, index) => {
setText(grid, leftWidth - diagramTextWidth(line), y + index, line, "section")
if (index > 0) setCell(grid, spineX, y + index, "│", "spine")
})
drawJoin(grid, leftWidth, y, TIMELINE_SECTION_FADE_STYLES)
setCell(grid, spineX, y, railStarted ? "┤" : "┐", "spine")
setCell(grid, spineX, y + lines.length, "│", "spine")
railStarted = true
y += lines.length + 1
continue
}
const layout = periodLayouts.get(entry.period)!
for (let row = 0; row < layout.height + 1; row++) setCell(grid, spineX, y + row, "│", "spine")
railStarted = true
setCell(grid, spineX, y, "●", "spine")
layout.periodLines.forEach((line, index) => {
const lineWidth = diagramTextWidth(line)
setText(grid, leftWidth - lineWidth, y + index, line, "period")
})
drawJoin(grid, leftWidth, y, TIMELINE_PERIOD_FADE_STYLES)
let eventY = y
for (const lines of layout.eventLines) {
lines.forEach((line, index) => setText(grid, spineX + EVENT_OFFSET, eventY + index, line, "event"))
eventY += lines.length
}
y += layout.height + 1
}
return grid
}
function drawJoin(grid: TimelineGrid, x: number, y: number, styles: readonly TimelineCellStyle[]): void {
styles.forEach((style, index) => setCell(grid, x + index + 1, y, "─", style))
}
function setCell(grid: TimelineGrid, x: number, y: number, char: string, style: TimelineCellStyle): void {
grid.setCell(x, y, char, style)
}
function setText(grid: TimelineGrid, x: number, y: number, text: string, style: TimelineCellStyle): void {
grid.setText(x, y, text, style)
}
+119
View File
@@ -0,0 +1,119 @@
import { firstMeaningfulMermaidLine, meaningfulNumberedMermaidLines, stripMermaidQuotes } from "../core/mermaid.js"
import { MermaidSyntaxError } from "../diagnostics.js"
import type { TimelineDiagram, TimelineDirection, TimelineEntry, TimelinePeriod, TimelineSection } from "./types.js"
const HEADER_RE = /^timeline(?:\s+(TD|LR))?$/i
const TITLE_RE = /^title(?:\s+(.+))?$/i
const SECTION_RE = /^section(?:\s+(.+))?$/i
const ACCESSIBILITY_RE = /^acc(?:Title|Descr)(?::|\s|$)/i
export function isMermaidTimelineDiagram(content: string): boolean {
return HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
}
export function parseMermaidTimelineDiagram(content: string): TimelineDiagram {
const sections: TimelineSection[] = []
const periods: TimelinePeriod[] = []
const entries: TimelineEntry[] = []
let direction: TimelineDirection = "LR"
let title: string | undefined
let currentPeriod: TimelinePeriod | undefined
let inAccessibilityDescription = false
for (const source of meaningfulNumberedMermaidLines(content)) {
const line = stripTimelineComment(source.text)
if (inAccessibilityDescription) {
if (line === "}") inAccessibilityDescription = false
continue
}
if (/^accDescr\s*\{$/i.test(line)) {
inAccessibilityDescription = true
continue
}
if (!line || line.startsWith("#") || ACCESSIBILITY_RE.test(line)) continue
const header = line.match(HEADER_RE)
if (header) {
direction = (header[1]?.toUpperCase() as TimelineDirection | undefined) ?? "LR"
continue
}
const titleMatch = line.match(TITLE_RE)
if (titleMatch) {
if (!titleMatch[1]) throw syntaxError(source.lineNumber, line, "Timeline title cannot be empty")
title = stripMermaidQuotes(titleMatch[1])
continue
}
const sectionMatch = line.match(SECTION_RE)
if (sectionMatch) {
if (!sectionMatch[1]) throw syntaxError(source.lineNumber, line, "Timeline section cannot be empty")
const section = { label: stripMermaidQuotes(sectionMatch[1]) }
sections.push(section)
entries.push({ type: "section", section })
currentPeriod = undefined
continue
}
if (line.startsWith(":")) {
if (!currentPeriod) {
throw syntaxError(source.lineNumber, line, "Timeline continuation requires a preceding period")
}
currentPeriod.events.push(...parseEvents(line.slice(1), source.lineNumber, line))
continue
}
const fields = splitEventFields(line)
const periodLabel = stripMermaidQuotes(fields.shift()!)
if (!periodLabel) throw syntaxError(source.lineNumber, line, "Timeline period cannot be empty")
const period = {
period: periodLabel,
events: fields.length === 0 ? [] : parseEventFields(fields, source.lineNumber, line),
}
periods.push(period)
entries.push({ type: "period", period })
currentPeriod = period
}
return { direction, ...(title === undefined ? {} : { title }), sections, periods, entries }
}
function parseEvents(value: string, lineNumber: number, sourceLine: string): string[] {
return parseEventFields(splitEventFields(value), lineNumber, sourceLine)
}
function parseEventFields(fields: string[], lineNumber: number, sourceLine: string): string[] {
const events = fields.map(stripMermaidQuotes)
if (events.length === 0 || events.some((event) => event.length === 0)) {
throw syntaxError(lineNumber, sourceLine, "Timeline event cannot be empty")
}
return events
}
function splitEventFields(value: string): string[] {
const fields: string[] = []
let quote: '"' | "'" | undefined
let start = 0
for (let index = 0; index < value.length; index++) {
const char = value[index]
if (char === '"' || char === "'") {
if (quote === char) quote = undefined
else if (quote === undefined && value.slice(start, index).trim() === "") quote = char
continue
}
const next = value[index + 1]
if (char !== ":" || quote !== undefined || (next !== undefined && !/\s/.test(next))) continue
fields.push(value.slice(start, index))
start = index + 1
}
fields.push(value.slice(start))
return fields
}
function stripTimelineComment(value: string): string {
const comment = value.indexOf("%%")
return (comment < 0 ? value : value.slice(0, comment)).trim()
}
function syntaxError(lineNumber: number, sourceLine: string, reason?: string): MermaidSyntaxError {
return new MermaidSyntaxError("timeline", lineNumber, sourceLine, reason)
}
@@ -0,0 +1,17 @@
import type { StyledText } from "@opentui/core"
import type { DiagramCanvas } from "../core/canvas.js"
import { renderDiagramGridStyledText } from "../core/render-grid.js"
import type { TimelineStyleColors } from "./style.js"
import type { TimelineCellStyle } from "./types.js"
export type TimelineGrid = DiagramCanvas<TimelineCellStyle>
export function renderTimelineGridText(grid: TimelineGrid): string {
return grid.toString({ trimBottom: true })
}
export function renderTimelineGridStyledText(grid: TimelineGrid, colors: TimelineStyleColors): StyledText {
return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, {
trimBottom: true,
})
}
+36
View File
@@ -0,0 +1,36 @@
import { RGBA } from "@opentui/core"
import { blendColor, numberedStyleKeys, rgba, type DiagramRgb } from "../core/color/style.js"
import type { TimelineBaseCellStyle, TimelineCellStyle } from "./types.js"
const DEFAULT_THEME_RGB = {
title: [228, 239, 232],
section: [154, 184, 169],
period: [230, 177, 126],
spine: [111, 138, 126],
event: [134, 225, 200],
} as const satisfies Record<TimelineBaseCellStyle, DiagramRgb>
export type TimelineStyleColors = Required<Record<TimelineCellStyle, RGBA>>
export const TIMELINE_SECTION_FADE_STYLES = numberedStyleKeys("sectionFade", [1, 2, 3] as const)
export const TIMELINE_PERIOD_FADE_STYLES = numberedStyleKeys("periodFade", [1, 2, 3] as const)
export function resolveTimelineStyleColors(
colors: Partial<Record<TimelineBaseCellStyle, RGBA | undefined>> = {},
): TimelineStyleColors {
const section = colors.section ?? rgba(DEFAULT_THEME_RGB.section)
const period = colors.period ?? rgba(DEFAULT_THEME_RGB.period)
const spine = colors.spine ?? rgba(DEFAULT_THEME_RGB.spine)
return {
title: colors.title ?? rgba(DEFAULT_THEME_RGB.title),
section,
period,
spine,
event: colors.event ?? rgba(DEFAULT_THEME_RGB.event),
sectionFade1: blendColor(section, spine, 0.5),
sectionFade2: blendColor(section, spine, 0.67),
sectionFade3: blendColor(section, spine, 0.83),
periodFade1: blendColor(period, spine, 0.5),
periodFade2: blendColor(period, spine, 0.67),
periodFade3: blendColor(period, spine, 0.83),
}
}
+31
View File
@@ -0,0 +1,31 @@
export type TimelineDirection = "TD" | "LR"
export interface TimelineSection {
label: string
}
export interface TimelinePeriod {
period: string
events: string[]
}
export type TimelineEntry = { type: "section"; section: TimelineSection } | { type: "period"; period: TimelinePeriod }
export interface TimelineDiagram {
direction: TimelineDirection
title?: string
sections: TimelineSection[]
periods: TimelinePeriod[]
entries: TimelineEntry[]
}
export interface TimelineDiagramRenderOptions {
/** Parsed for Mermaid compatibility. Timeline diagrams always use a vertical terminal layout. */
direction?: TimelineDirection
}
export type TimelineBaseCellStyle = "title" | "section" | "period" | "spine" | "event"
export type TimelineFadeStep = 1 | 2 | 3
export type TimelineSectionFadeStyle = `sectionFade${TimelineFadeStep}`
export type TimelinePeriodFadeStyle = `periodFade${TimelineFadeStep}`
export type TimelineCellStyle = TimelineBaseCellStyle | TimelineSectionFadeStyle | TimelinePeriodFadeStyle
+1
View File
@@ -7,6 +7,7 @@ export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
export { Config } from "@opencode-ai/schema/config"
export { Credential } from "@opencode-ai/schema/credential"
export { Event } from "@opencode-ai/schema/event"
export { FileSystem } from "@opencode-ai/schema/filesystem"
export { Integration } from "@opencode-ai/schema/integration"
export { Location } from "@opencode-ai/schema/location"
@@ -4,6 +4,7 @@ import { SessionInbox as CoreSessionInbox } from "@opencode-ai/core/session/inbo
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
import { Agent } from "@opencode-ai/schema/agent"
import { Config } from "@opencode-ai/schema/config"
import { Event } from "@opencode-ai/schema/event"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Project } from "@opencode-ai/schema/project"
@@ -26,6 +27,7 @@ const CoreSession = await import("@opencode-ai/core/session")
test("re-exports canonical contracts directly from Schema", () => {
expect(SDK.Agent).toBe(Agent)
expect(SDK.Config).toBe(Config)
expect(SDK.Event).toBe(Event)
expect(SDK.Model).toBe(Model)
expect(SDK.WebSearch).toBe(WebSearch)
expect(SDK.Session).toBe(Session)
@@ -37,6 +39,7 @@ test("re-exports canonical contracts directly from Schema", () => {
"Command",
"Config",
"Credential",
"Event",
"FileSystem",
"Integration",
"Location",