diff --git a/dist/server.js b/dist/server.js index 85ab9d2..cefe93e 100644 --- a/dist/server.js +++ b/dist/server.js @@ -210,15 +210,36 @@ function isMissingStateFile(error) { function mutableState(state) { return JSON.parse(JSON.stringify(state)); } +var warnedEmptyStatePaths = new Set; +function isStatePadding(character) { + return character === "\x00" || character.trim() === ""; +} +function parseStateText(raw, file) { + let start = 0; + let end = raw.length; + while (start < end && isStatePadding(raw[start])) + start += 1; + while (end > start && isStatePadding(raw[end - 1])) + end -= 1; + const content = raw.slice(start, end); + if (content) + return JSON.parse(content); + if (!warnedEmptyStatePaths.has(file)) { + warnedEmptyStatePaths.add(file); + console.warn(`[opencode-goal-plugin] Empty or zero-filled state file at ${file}; recovering with empty state.`); + } + return emptyState(); +} function decodeState(value) { return Schema.decodeUnknown(StateSchema)(value).pipe(Effect.map(mutableState), Effect.map(normalizeState), Effect.mapError((cause) => new StateDecodeError({ cause }))); } function readStateEffect() { + const file = statePath(); return Effect.tryPromise({ - try: () => readFile(statePath(), "utf8"), + try: () => readFile(file, "utf8"), catch: (cause) => new StateReadError({ cause }) }).pipe(Effect.flatMap((raw) => Effect.try({ - try: () => JSON.parse(raw), + try: () => parseStateText(raw, file), catch: (cause) => new StateDecodeError({ cause }) })), Effect.flatMap(decodeState), Effect.catchAll((error) => error._tag === "StateReadError" && isMissingStateFile(error.cause) ? Effect.succeed(emptyState()) : Effect.fail(error))); } diff --git a/src/state.ts b/src/state.ts index 6b6d59c..6499c67 100644 --- a/src/state.ts +++ b/src/state.ts @@ -254,6 +254,29 @@ function mutableState(state: Schema.Schema.Type): State { return JSON.parse(JSON.stringify(state)) as State } +const warnedEmptyStatePaths = new Set() + +function isStatePadding(character: string) { + return character === "\0" || character.trim() === "" +} + +function parseStateText(raw: string, file: string): unknown { + // trim handles whitespace and UTF-8 BOMs. NUL padding can remain after an + // interrupted filesystem write, so tolerate it only at the file boundaries. + let start = 0 + let end = raw.length + while (start < end && isStatePadding(raw[start]!)) start += 1 + while (end > start && isStatePadding(raw[end - 1]!)) end -= 1 + const content = raw.slice(start, end) + if (content) return JSON.parse(content) as unknown + + if (!warnedEmptyStatePaths.has(file)) { + warnedEmptyStatePaths.add(file) + console.warn(`[opencode-goal-plugin] Empty or zero-filled state file at ${file}; recovering with empty state.`) + } + return emptyState() +} + function decodeState(value: unknown) { return Schema.decodeUnknown(StateSchema)(value).pipe( Effect.map(mutableState), @@ -263,13 +286,14 @@ function decodeState(value: unknown) { } function readStateEffect() { + const file = statePath() return Effect.tryPromise({ - try: () => readFile(statePath(), "utf8"), + try: () => readFile(file, "utf8"), catch: (cause) => new StateReadError({ cause }), }).pipe( Effect.flatMap((raw) => Effect.try({ - try: () => JSON.parse(raw) as unknown, + try: () => parseStateText(raw, file), catch: (cause) => new StateDecodeError({ cause }), }), ), @@ -307,8 +331,9 @@ async function readState(): Promise { function readStateSync(): State { try { - const raw = readFileSync(statePath(), "utf8") - return normalizeState(mutableState(Schema.decodeUnknownSync(StateSchema)(JSON.parse(raw) as unknown))) + const file = statePath() + const raw = readFileSync(file, "utf8") + return normalizeState(mutableState(Schema.decodeUnknownSync(StateSchema)(parseStateText(raw, file)))) } catch (error) { if (isMissingStateFile(error)) return emptyState() throw error diff --git a/test/server-v2.test.ts b/test/server-v2.test.ts index 928cd78..3a27878 100644 --- a/test/server-v2.test.ts +++ b/test/server-v2.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test" -import { mkdtemp, rm } from "node:fs/promises" +import { mkdtemp, rm, writeFile } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" import plugin from "../src/server" @@ -227,6 +227,19 @@ test("V2 setup registers goal tools with JSON Schema inputs, codemode:false, and expect(mock.promptCalls).toHaveLength(0) }) +test("V2 create_goal recovers from a zero-filled state file", async () => { + await writeFile(process.env.OPENCODE_GOAL_STATE_PATH!, "\u0000\u0000", "utf8") + const mock = makeMockContext({ auto_continue: false }) + const cleanup = await plugin.setup(mock as never) + + const created = await createGoalViaV2Tool(mock, "recover V2 state") + + expect(contentOf(created)).toContain('"objective": "recover V2 state"') + expect((await getGoal("ses_v2"))?.objective).toBe("recover V2 state") + mock.stream.end() + await cleanup() +}) + test("V2 setup registers the /goal command via command transform", async () => { const mock = makeMockContext({ auto_continue: false }) const cleanup = await plugin.setup(mock as never) diff --git a/test/server.test.ts b/test/server.test.ts index 6cc7792..7329d43 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -504,6 +504,24 @@ test("message transform prefers exact step token usage", async () => { expect(String(read)).toContain('"tokensUsed": 24') }) +test("per-prompt chat hook recovers from an empty state file", async () => { + await writeFile(process.env.OPENCODE_GOAL_STATE_PATH!, "", "utf8") + const hooks = await plugin.server( + { + client: { + session: { + promptAsync: async () => {}, + }, + }, + } as never, + { auto_continue: false }, + ) + + await hooks["chat.message"]!({ sessionID: "ses_1", agent: "build" } as never, { message: {} } as never) + + expect(JSON.parse(await readFile(process.env.OPENCODE_GOAL_STATE_PATH!, "utf8"))).toEqual({ version: 1, goals: {} }) +}) + test("message transform records assistant checkpoints", async () => { const hooks = await plugin.server( { diff --git a/test/state.test.ts b/test/state.test.ts index f4d74d1..9083396 100644 --- a/test/state.test.ts +++ b/test/state.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from "bun:test" +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test" import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" @@ -11,6 +11,7 @@ import { recordAssistantProgress, getGoal, getGoalInternal, + getGoalSync, markGoalUnmet, pauseGoalForPlanMode, recordContinuationResult, @@ -286,11 +287,67 @@ test("writes state with owner-only file permissions", async () => { test("does not overwrite corrupt persisted state", async () => { await writeFile(process.env.OPENCODE_GOAL_STATE_PATH!, "{not valid json", "utf8") + expect(() => getGoalSync("ses_1")).toThrow() await expect(createGoal("ses_1", "ship the plugin", null)).rejects.toThrow() expect(await readFile(process.env.OPENCODE_GOAL_STATE_PATH!, "utf8")).toBe("{not valid json") }) +test("treats empty and zero-filled state files as missing for async and sync reads", async () => { + for (const content of ["", " \n\t", "\uFEFF", "\u0000\u0000"]) { + await writeFile(process.env.OPENCODE_GOAL_STATE_PATH!, content, "utf8") + + expect(await getGoal("ses_1")).toBeNull() + expect(getGoalSync("ses_1")).toBeNull() + expect(await readFile(process.env.OPENCODE_GOAL_STATE_PATH!, "utf8")).toBe(content) + } +}) + +test("loads valid state prefixed by a UTF-8 BOM", async () => { + const content = `\uFEFF${JSON.stringify({ version: 1, goals: {} })}` + await writeFile(process.env.OPENCODE_GOAL_STATE_PATH!, content, "utf8") + + expect(await getGoal("ses_1")).toBeNull() + expect(getGoalSync("ses_1")).toBeNull() + expect(await readFile(process.env.OPENCODE_GOAL_STATE_PATH!, "utf8")).toBe(content) +}) + +test("creates and persists a goal from an empty state file", async () => { + await writeFile(process.env.OPENCODE_GOAL_STATE_PATH!, "", "utf8") + + const created = await createGoal("ses_1", "recover safely", null) + + expect(created.objective).toBe("recover safely") + expect((await getGoal("ses_1"))?.objective).toBe("recover safely") + expect(JSON.parse(await readFile(process.env.OPENCODE_GOAL_STATE_PATH!, "utf8"))).toMatchObject({ + version: 1, + goals: { ses_1: { objective: "recover safely" } }, + }) +}) + +test("warns once for each empty state file path", async () => { + const first = process.env.OPENCODE_GOAL_STATE_PATH! + const second = join(dir, "other-goals.json") + await writeFile(first, "", "utf8") + await writeFile(second, "", "utf8") + const warnings: string[] = [] + const warn = spyOn(console, "warn").mockImplementation((message) => { + warnings.push(String(message)) + }) + + try { + expect(await getGoal("ses_1")).toBeNull() + expect(getGoalSync("ses_1")).toBeNull() + process.env.OPENCODE_GOAL_STATE_PATH = second + expect(await getGoal("ses_1")).toBeNull() + } finally { + warn.mockRestore() + } + + expect(warnings.filter((message) => message.includes(first))).toHaveLength(1) + expect(warnings.filter((message) => message.includes(second))).toHaveLength(1) +}) + test("prompt delivery arms the pending window but never resets the failure count", async () => { await createGoal("ses_1", "keep going", null) await reserveContinuation("ses_1", 10, 0)