@prevalentware/opencode-goal-plugin 0.1.33 → 0.1.34
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 +114 -10
- 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
|
|
|
@@ -117,14 +226,9 @@ function writeStateEffect(state) {
|
|
|
117
226
|
return Effect.tryPromise({
|
|
118
227
|
try: async () => {
|
|
119
228
|
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
|
-
});
|
|
229
|
+
await mkdir(dirname2(file), { recursive: true, mode: 448 });
|
|
230
|
+
await atomicWriteFile(file, JSON.stringify(state, null, 2) + `
|
|
231
|
+
`);
|
|
128
232
|
},
|
|
129
233
|
catch: (cause) => new StateWriteError({ cause })
|
|
130
234
|
});
|
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.34",
|
|
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",
|