mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 20:50:01 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bae7a954a8 | |||
| d31a994c27 | |||
| 76640a5c9c | |||
| 76dbaf20ad |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"assets": {
|
||||
"directory": "./dist",
|
||||
"binding": "ASSETS",
|
||||
"html_handling": "none",
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &<br/>Platform
|
||||
|
||||
section Foundation
|
||||
2024 : Prototype : First release
|
||||
: Public beta
|
||||
section Growth
|
||||
2025 : "Scale: ≥ 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 &<br/>Platform
|
||||
section Foundation<br/>phase
|
||||
2024 : Prototype<br/>ready : First release
|
||||
: Scale ≥ 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",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user