@prevalentware/opencode-goal-plugin 0.1.33 → 0.1.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/server.js +137 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -225,7 +225,11 @@ If `XDG_DATA_HOME` is not set, the default is:
|
|
|
225
225
|
|
|
226
226
|
Set `OPENCODE_GOAL_STATE_PATH` to use a custom file.
|
|
227
227
|
|
|
228
|
-
The state file is written atomically with owner-only permissions
|
|
228
|
+
The state file is written atomically through a same-directory temp file: the final path is only ever replaced by a fully-flushed file, so after a crash the state is the previous or the new valid version, never a torn one. The file is created with owner-only permissions where the host filesystem supports them, and the temp name is a random UUID opened exclusively so concurrent writers cannot collide.
|
|
229
|
+
|
|
230
|
+
Ordinary fsync improves crash consistency but is not `F_FULLFSYNC`, so sudden power loss on macOS/APFS is not an absolute durability guarantee; where the platform cannot fsync the parent directory, a crash may leave the old or the new state file (both valid), never a partially-written one. Existing active goals recover from disk with their full objective, budget, history, and checkpoint metadata.
|
|
231
|
+
|
|
232
|
+
If the rename succeeds but syncing the parent directory reports a genuine I/O error, the mutation reports a write failure even though the new valid state may already be present. This avoids claiming durability that the filesystem did not confirm.
|
|
229
233
|
|
|
230
234
|
## Credits
|
|
231
235
|
|
package/dist/server.js
CHANGED
|
@@ -3,11 +3,120 @@
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
|
|
5
5
|
// src/state.ts
|
|
6
|
-
import {
|
|
6
|
+
import { mkdir, readFile } from "fs/promises";
|
|
7
7
|
import { homedir } from "os";
|
|
8
|
-
import { dirname, join } from "path";
|
|
8
|
+
import { dirname as dirname2, join } from "path";
|
|
9
9
|
import { Data, Effect, Schema } from "effect";
|
|
10
10
|
|
|
11
|
+
// src/atomic-write.ts
|
|
12
|
+
import { randomUUID } from "crypto";
|
|
13
|
+
import { chmod, open, rename, unlink } from "fs/promises";
|
|
14
|
+
import { dirname } from "path";
|
|
15
|
+
function isUnsupportedSyncDirError(error, platform) {
|
|
16
|
+
const code = error?.code;
|
|
17
|
+
return code === "EINVAL" || code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EISDIR" || platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBADF");
|
|
18
|
+
}
|
|
19
|
+
function isTransientRenameError(error, platform) {
|
|
20
|
+
if (platform !== "win32")
|
|
21
|
+
return false;
|
|
22
|
+
const code = error?.code;
|
|
23
|
+
return code === "EPERM" || code === "EACCES" || code === "EBUSY";
|
|
24
|
+
}
|
|
25
|
+
async function bestEffort(action) {
|
|
26
|
+
try {
|
|
27
|
+
await action();
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
|
+
var defaultDirOpenOps = {
|
|
32
|
+
async open(dir, flags) {
|
|
33
|
+
const handle = await open(dir, flags);
|
|
34
|
+
return {
|
|
35
|
+
sync: () => handle.sync(),
|
|
36
|
+
close: () => handle.close()
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
async function syncDirectory(dir, ops = defaultDirOpenOps, platform = process.platform) {
|
|
41
|
+
let fsHandle = null;
|
|
42
|
+
try {
|
|
43
|
+
const handle = await ops.open(dir, "r");
|
|
44
|
+
fsHandle = handle;
|
|
45
|
+
await handle.sync();
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (!isUnsupportedSyncDirError(error, platform))
|
|
48
|
+
throw error;
|
|
49
|
+
} finally {
|
|
50
|
+
const cleanupHandle = fsHandle;
|
|
51
|
+
if (cleanupHandle)
|
|
52
|
+
await bestEffort(() => cleanupHandle.close());
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
var defaultAtomicWriteOps = {
|
|
56
|
+
platform: process.platform,
|
|
57
|
+
async open(path, flags, mode) {
|
|
58
|
+
const handle = await open(path, flags, mode);
|
|
59
|
+
return {
|
|
60
|
+
write: (data) => handle.writeFile(data),
|
|
61
|
+
sync: () => handle.sync(),
|
|
62
|
+
close: () => handle.close()
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
rename,
|
|
66
|
+
chmod,
|
|
67
|
+
unlink,
|
|
68
|
+
syncDir: (dir, platform) => syncDirectory(dir, defaultDirOpenOps, platform),
|
|
69
|
+
sleep: defaultSleep
|
|
70
|
+
};
|
|
71
|
+
var RENAME_ATTEMPTS = 3;
|
|
72
|
+
var RENAME_RETRY_DELAY_MS = 20;
|
|
73
|
+
async function renameWithRetry(ops, from, to) {
|
|
74
|
+
let attempt = 0;
|
|
75
|
+
for (;; ) {
|
|
76
|
+
try {
|
|
77
|
+
await ops.rename(from, to);
|
|
78
|
+
return;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
attempt += 1;
|
|
81
|
+
if (!isTransientRenameError(error, ops.platform) || attempt >= RENAME_ATTEMPTS)
|
|
82
|
+
throw error;
|
|
83
|
+
const delay = RENAME_RETRY_DELAY_MS * attempt;
|
|
84
|
+
await ops.sleep(delay);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function atomicWriteFile(file, data, ops = defaultAtomicWriteOps) {
|
|
89
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
90
|
+
let handle = null;
|
|
91
|
+
let created = false;
|
|
92
|
+
let renamed = false;
|
|
93
|
+
try {
|
|
94
|
+
handle = await ops.open(tmp, "wx", 384);
|
|
95
|
+
created = true;
|
|
96
|
+
await handle.write(data);
|
|
97
|
+
await handle.sync();
|
|
98
|
+
await handle.close();
|
|
99
|
+
handle = null;
|
|
100
|
+
await renameWithRetry(ops, tmp, file);
|
|
101
|
+
renamed = true;
|
|
102
|
+
await bestEffort(() => ops.chmod(file, 384));
|
|
103
|
+
try {
|
|
104
|
+
await ops.syncDir(dirname(file), ops.platform);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (!isUnsupportedSyncDirError(error, ops.platform))
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const cleanupHandle = handle;
|
|
111
|
+
if (cleanupHandle)
|
|
112
|
+
await bestEffort(() => cleanupHandle.close());
|
|
113
|
+
if (created && !renamed)
|
|
114
|
+
await bestEffort(() => ops.unlink(tmp));
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/state.ts
|
|
11
120
|
class StateReadError extends Data.TaggedError("StateReadError") {
|
|
12
121
|
}
|
|
13
122
|
|
|
@@ -101,15 +210,36 @@ function isMissingStateFile(error) {
|
|
|
101
210
|
function mutableState(state) {
|
|
102
211
|
return JSON.parse(JSON.stringify(state));
|
|
103
212
|
}
|
|
213
|
+
var warnedEmptyStatePaths = new Set;
|
|
214
|
+
function isStatePadding(character) {
|
|
215
|
+
return character === "\x00" || character.trim() === "";
|
|
216
|
+
}
|
|
217
|
+
function parseStateText(raw, file) {
|
|
218
|
+
let start = 0;
|
|
219
|
+
let end = raw.length;
|
|
220
|
+
while (start < end && isStatePadding(raw[start]))
|
|
221
|
+
start += 1;
|
|
222
|
+
while (end > start && isStatePadding(raw[end - 1]))
|
|
223
|
+
end -= 1;
|
|
224
|
+
const content = raw.slice(start, end);
|
|
225
|
+
if (content)
|
|
226
|
+
return JSON.parse(content);
|
|
227
|
+
if (!warnedEmptyStatePaths.has(file)) {
|
|
228
|
+
warnedEmptyStatePaths.add(file);
|
|
229
|
+
console.warn(`[opencode-goal-plugin] Empty or zero-filled state file at ${file}; recovering with empty state.`);
|
|
230
|
+
}
|
|
231
|
+
return emptyState();
|
|
232
|
+
}
|
|
104
233
|
function decodeState(value) {
|
|
105
234
|
return Schema.decodeUnknown(StateSchema)(value).pipe(Effect.map(mutableState), Effect.map(normalizeState), Effect.mapError((cause) => new StateDecodeError({ cause })));
|
|
106
235
|
}
|
|
107
236
|
function readStateEffect() {
|
|
237
|
+
const file = statePath();
|
|
108
238
|
return Effect.tryPromise({
|
|
109
|
-
try: () => readFile(
|
|
239
|
+
try: () => readFile(file, "utf8"),
|
|
110
240
|
catch: (cause) => new StateReadError({ cause })
|
|
111
241
|
}).pipe(Effect.flatMap((raw) => Effect.try({
|
|
112
|
-
try: () =>
|
|
242
|
+
try: () => parseStateText(raw, file),
|
|
113
243
|
catch: (cause) => new StateDecodeError({ cause })
|
|
114
244
|
})), Effect.flatMap(decodeState), Effect.catchAll((error) => error._tag === "StateReadError" && isMissingStateFile(error.cause) ? Effect.succeed(emptyState()) : Effect.fail(error)));
|
|
115
245
|
}
|
|
@@ -117,14 +247,9 @@ function writeStateEffect(state) {
|
|
|
117
247
|
return Effect.tryPromise({
|
|
118
248
|
try: async () => {
|
|
119
249
|
const file = statePath();
|
|
120
|
-
await mkdir(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
`, { mode: 384 });
|
|
124
|
-
await rename(tmp, file);
|
|
125
|
-
await chmod(file, 384).catch(() => {
|
|
126
|
-
return;
|
|
127
|
-
});
|
|
250
|
+
await mkdir(dirname2(file), { recursive: true, mode: 448 });
|
|
251
|
+
await atomicWriteFile(file, JSON.stringify(state, null, 2) + `
|
|
252
|
+
`);
|
|
128
253
|
},
|
|
129
254
|
catch: (cause) => new StateWriteError({ cause })
|
|
130
255
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prevalentware/opencode-goal-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.35",
|
|
4
4
|
"description": "OpenCode goal plugin that adds Codex-style long-running goal mode, /goal commands, persistence, and TUI status for AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|