Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
Expand Down
33 changes: 29 additions & 4 deletions src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,29 @@
return JSON.parse(JSON.stringify(state)) as State
}

const warnedEmptyStatePaths = new Set<string>()

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),
Expand All @@ -263,13 +286,14 @@
}

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 }),
}),
),
Expand Down Expand Up @@ -297,7 +321,7 @@
// leaves either the old or the new valid state, never a torn file.
await atomicWriteFile(file, JSON.stringify(state, null, 2) + "\n")
},
catch: (cause) => new StateWriteError({ cause }),

Check failure on line 324 in src/state.ts

View workflow job for this annotation

GitHub Actions / Tests & Coverage

(FiberFailure) StateWriteError: An error has occurred

at catch (/home/runner/work/opencode-goal-plugin/opencode-goal-plugin/src/state.ts:324:27)
})
}

Expand All @@ -307,8 +331,9 @@

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
Expand Down
15 changes: 14 additions & 1 deletion test/server-v2.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
if (predicate()) return
await new Promise((resolve) => setTimeout(resolve, 5))
}
expect(predicate()).toBe(true)

Check failure on line 24 in test/server.test.ts

View workflow job for this annotation

GitHub Actions / Tests & Coverage

error: expect(received).toBe(expected)

Expected: true Received: false at waitFor (/home/runner/work/opencode-goal-plugin/opencode-goal-plugin/test/server.test.ts:24:23) at async <anonymous> (/home/runner/work/opencode-goal-plugin/opencode-goal-plugin/test/server.test.ts:2203:9)
}

async function waitForLong(predicate: () => boolean | Promise<boolean>, deadlineMs = 3000) {
Expand Down Expand Up @@ -504,6 +504,24 @@
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(
{
Expand Down
59 changes: 58 additions & 1 deletion test/state.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -11,6 +11,7 @@ import {
recordAssistantProgress,
getGoal,
getGoalInternal,
getGoalSync,
markGoalUnmet,
pauseGoalForPlanMode,
recordContinuationResult,
Expand Down Expand Up @@ -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)
Expand Down