@codemem/opencode-plugin 0.46.0 → 0.46.2
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.
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
1
2
|
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
import {
|
|
3
5
|
DEFAULT_DRAIN_LIMIT,
|
|
4
6
|
DEFAULT_MAX_ENTRIES,
|
|
5
7
|
loadRawEventSpoolEntries,
|
|
8
|
+
RAW_EVENT_SPOOL_CONFLICT_CODE,
|
|
6
9
|
RAW_EVENT_SPOOL_FULL_CODE,
|
|
7
10
|
removeRawEventSpoolEntry,
|
|
8
11
|
resolveSpoolDirectory,
|
|
@@ -95,6 +98,24 @@ const warnSpoolPersistenceFailure = async (state, sessionID, reason) => {
|
|
|
95
98
|
await bestEffortNotify(state, { message: `codemem: ${message}`, variant: "error" });
|
|
96
99
|
};
|
|
97
100
|
|
|
101
|
+
const SPOOL_STAGES = new Set(["directory", "existing_entry", "capacity", "temporary_write", "publish", "directory_sync"]);
|
|
102
|
+
const SPOOL_ERRNOS = new Set(["EACCES", "EPERM", "ENOSPC", "EROFS", "ENOTDIR", "EEXIST", "EIO", "EINVAL", "EMFILE", "ENFILE"]);
|
|
103
|
+
|
|
104
|
+
const spoolFailureDiagnostic = (error) => {
|
|
105
|
+
const stage = SPOOL_STAGES.has(error?.spoolStage) ? error.spoolStage : "unknown";
|
|
106
|
+
let code = "other";
|
|
107
|
+
if (error?.code === RAW_EVENT_SPOOL_CONFLICT_CODE) code = "conflict";
|
|
108
|
+
else if (error?.code === RAW_EVENT_SPOOL_FULL_CODE) code = "full";
|
|
109
|
+
else if (SPOOL_ERRNOS.has(error?.code)) code = error.code;
|
|
110
|
+
return `raw_events.spool.failure stage=${stage} code=${code} category=persistence`;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const persistSafeSpoolDiagnostic = async (state, line) => {
|
|
114
|
+
const directory = join(state.options.spoolHome, ".codemem");
|
|
115
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
116
|
+
await appendFile(join(directory, "plugin.log"), `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
|
|
117
|
+
};
|
|
118
|
+
|
|
98
119
|
const persistForRetry = async (state, { body, serialized, payload, sessionID }) => {
|
|
99
120
|
try {
|
|
100
121
|
await writeRawEventSpoolEntry({
|
|
@@ -109,6 +130,14 @@ const persistForRetry = async (state, { body, serialized, payload, sessionID })
|
|
|
109
130
|
} catch (error) {
|
|
110
131
|
const reason = error?.code === RAW_EVENT_SPOOL_FULL_CODE ? "spool_full" : "write_failed";
|
|
111
132
|
await state.options.logLine(`raw_events.spool.${reason} category=persistence`);
|
|
133
|
+
const diagnostic = spoolFailureDiagnostic(error);
|
|
134
|
+
try {
|
|
135
|
+
await persistSafeSpoolDiagnostic(state, diagnostic);
|
|
136
|
+
} catch {
|
|
137
|
+
// The same disk/permission failure can block the local log. Keep the
|
|
138
|
+
// category visible in the host's stderr without exposing event content.
|
|
139
|
+
console.error(`codemem: ${diagnostic}`);
|
|
140
|
+
}
|
|
112
141
|
await warnSpoolPersistenceFailure(state, sessionID, reason);
|
|
113
142
|
return false;
|
|
114
143
|
}
|
|
@@ -11,10 +11,12 @@ import {
|
|
|
11
11
|
} from "node:fs/promises";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
|
+
import { isDeepStrictEqual } from "node:util";
|
|
14
15
|
|
|
15
16
|
const DEFAULT_DRAIN_LIMIT = 20;
|
|
16
17
|
const DEFAULT_MAX_ENTRIES = 2000;
|
|
17
18
|
const RAW_EVENT_SPOOL_FULL_CODE = "raw_event_spool_full";
|
|
19
|
+
const RAW_EVENT_SPOOL_CONFLICT_CODE = "raw_event_spool_conflict";
|
|
18
20
|
const SPOOL_DIRECTORY_NAME = "opencode-raw-event-spool";
|
|
19
21
|
const spoolWritesInFlight = new Map();
|
|
20
22
|
|
|
@@ -36,6 +38,28 @@ const requireEventId = (envelope) => {
|
|
|
36
38
|
return eventId;
|
|
37
39
|
};
|
|
38
40
|
|
|
41
|
+
/** Delivery timestamps can change when the same host event is captured again. */
|
|
42
|
+
const sameEventApartFromDeliveryTime = (existingBytes, incomingBytes) => {
|
|
43
|
+
if (existingBytes === incomingBytes) return true;
|
|
44
|
+
try {
|
|
45
|
+
const existing = JSON.parse(existingBytes);
|
|
46
|
+
const incoming = JSON.parse(incomingBytes);
|
|
47
|
+
if (!existing || typeof existing !== "object" || Array.isArray(existing)) return false;
|
|
48
|
+
if (!incoming || typeof incoming !== "object" || Array.isArray(incoming)) return false;
|
|
49
|
+
const withoutDeliveryTime = ({ ts_wall_ms: _wall, ts_mono_ms: _mono, ...event }) => event;
|
|
50
|
+
return isDeepStrictEqual(withoutDeliveryTime(existing), withoutDeliveryTime(incoming));
|
|
51
|
+
} catch {
|
|
52
|
+
// Malformed retained data is not proof that the new event is already durable.
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const conflictingEntry = () => {
|
|
58
|
+
const error = new Error("raw event spool entry conflicts with existing event_id");
|
|
59
|
+
error.code = RAW_EVENT_SPOOL_CONFLICT_CODE;
|
|
60
|
+
return error;
|
|
61
|
+
};
|
|
62
|
+
|
|
39
63
|
const ensurePrivateDirectory = async (directory) => {
|
|
40
64
|
const firstCreated = await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
41
65
|
await chmod(directory, 0o700);
|
|
@@ -75,6 +99,61 @@ const syncDirectory = async (path) => {
|
|
|
75
99
|
}
|
|
76
100
|
};
|
|
77
101
|
|
|
102
|
+
const atSpoolStage = async (stage, action) => {
|
|
103
|
+
try {
|
|
104
|
+
return await action();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error && typeof error === "object") error.spoolStage = stage;
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const readExistingEntry = async (directory, destination, bytes, eventId) => {
|
|
112
|
+
try {
|
|
113
|
+
return await atSpoolStage("existing_entry", async () => {
|
|
114
|
+
const existing = await readFile(destination, "utf8");
|
|
115
|
+
if (!sameEventApartFromDeliveryTime(existing, bytes)) throw conflictingEntry();
|
|
116
|
+
await chmod(destination, 0o600);
|
|
117
|
+
await syncDirectory(directory);
|
|
118
|
+
return { eventId, serialized: existing };
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error?.code !== "ENOENT") throw error;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const requireSpoolCapacity = async (directory, maxEntries) =>
|
|
127
|
+
atSpoolStage("capacity", async () => {
|
|
128
|
+
const directoryEntries = await readdir(directory, { withFileTypes: true });
|
|
129
|
+
const entryCount = directoryEntries.filter(
|
|
130
|
+
(entry) => entry.isFile() && entry.name.endsWith(".json"),
|
|
131
|
+
).length;
|
|
132
|
+
if (entryCount < maxEntries) return;
|
|
133
|
+
const error = new Error("raw event spool is full");
|
|
134
|
+
error.code = RAW_EVENT_SPOOL_FULL_CODE;
|
|
135
|
+
throw error;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const publishSpoolEntry = async (temporary, destination, bytes) => {
|
|
139
|
+
try {
|
|
140
|
+
await atSpoolStage("temporary_write", () => writeDurableFile(temporary, bytes));
|
|
141
|
+
await atSpoolStage("publish", async () => {
|
|
142
|
+
try {
|
|
143
|
+
await link(temporary, destination);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error?.code !== "EEXIST") throw error;
|
|
146
|
+
const existing = await readFile(destination, "utf8");
|
|
147
|
+
if (!sameEventApartFromDeliveryTime(existing, bytes)) throw conflictingEntry();
|
|
148
|
+
await chmod(destination, 0o600);
|
|
149
|
+
}
|
|
150
|
+
await chmod(destination, 0o600);
|
|
151
|
+
});
|
|
152
|
+
} finally {
|
|
153
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
78
157
|
const writeRawEventSpoolEntryUnlocked = async ({
|
|
79
158
|
envelope,
|
|
80
159
|
serialized,
|
|
@@ -93,50 +172,12 @@ const writeRawEventSpoolEntryUnlocked = async ({
|
|
|
93
172
|
`.${spoolFilename(eventId)}.${process.pid}.${randomUUID()}.tmp`,
|
|
94
173
|
);
|
|
95
174
|
|
|
96
|
-
await ensurePrivateDirectory(directory);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
await chmod(destination, 0o600);
|
|
103
|
-
await syncDirectory(directory);
|
|
104
|
-
return { eventId, serialized: existing };
|
|
105
|
-
} catch (error) {
|
|
106
|
-
if (error?.code !== "ENOENT") {
|
|
107
|
-
throw error;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const directoryEntries = await readdir(directory, { withFileTypes: true });
|
|
112
|
-
const entryCount = directoryEntries.filter(
|
|
113
|
-
(entry) => entry.isFile() && entry.name.endsWith(".json"),
|
|
114
|
-
).length;
|
|
115
|
-
if (entryCount >= normalizedMaxEntries) {
|
|
116
|
-
const error = new Error("raw event spool is full");
|
|
117
|
-
error.code = RAW_EVENT_SPOOL_FULL_CODE;
|
|
118
|
-
throw error;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
try {
|
|
122
|
-
await writeDurableFile(temporary, bytes);
|
|
123
|
-
try {
|
|
124
|
-
await link(temporary, destination);
|
|
125
|
-
} catch (error) {
|
|
126
|
-
if (error?.code !== "EEXIST") {
|
|
127
|
-
throw error;
|
|
128
|
-
}
|
|
129
|
-
const existing = await readFile(destination, "utf8");
|
|
130
|
-
if (existing !== bytes) {
|
|
131
|
-
throw new Error("raw event spool entry conflicts with existing event_id");
|
|
132
|
-
}
|
|
133
|
-
await chmod(destination, 0o600);
|
|
134
|
-
}
|
|
135
|
-
await chmod(destination, 0o600);
|
|
136
|
-
} finally {
|
|
137
|
-
await rm(temporary, { force: true }).catch(() => {});
|
|
138
|
-
}
|
|
139
|
-
await syncDirectory(directory);
|
|
175
|
+
await atSpoolStage("directory", () => ensurePrivateDirectory(directory));
|
|
176
|
+
const existing = await readExistingEntry(directory, destination, bytes, eventId);
|
|
177
|
+
if (existing) return existing;
|
|
178
|
+
await requireSpoolCapacity(directory, normalizedMaxEntries);
|
|
179
|
+
await publishSpoolEntry(temporary, destination, bytes);
|
|
180
|
+
await atSpoolStage("directory_sync", () => syncDirectory(directory));
|
|
140
181
|
|
|
141
182
|
return { eventId, serialized: bytes };
|
|
142
183
|
};
|
|
@@ -225,6 +266,7 @@ export {
|
|
|
225
266
|
DEFAULT_DRAIN_LIMIT,
|
|
226
267
|
DEFAULT_MAX_ENTRIES,
|
|
227
268
|
loadRawEventSpoolEntries,
|
|
269
|
+
RAW_EVENT_SPOOL_CONFLICT_CODE,
|
|
228
270
|
RAW_EVENT_SPOOL_FULL_CODE,
|
|
229
271
|
removeRawEventSpoolEntry,
|
|
230
272
|
resolveSpoolDirectory,
|
package/.opencode/lib/runtime.js
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
|
|
31
31
|
const TRUTHY_VALUES = ["1", "true", "yes"];
|
|
32
32
|
const DISABLED_VALUES = ["0", "false", "off"];
|
|
33
|
-
const PINNED_BACKEND_VERSION = "0.46.
|
|
33
|
+
const PINNED_BACKEND_VERSION = "0.46.2";
|
|
34
34
|
const COMPAT_CHECK_DELAY_MS = 1500;
|
|
35
35
|
const COMPAT_CHECK_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
36
36
|
const MAX_UPDATE_STATUS_BYTES = 16 * 1024;
|
|
@@ -1948,6 +1948,7 @@ const buildRawEventEnvelope = ({
|
|
|
1948
1948
|
nowMono,
|
|
1949
1949
|
nextEventId,
|
|
1950
1950
|
captureContext,
|
|
1951
|
+
hostGeneration,
|
|
1951
1952
|
}) => ({
|
|
1952
1953
|
session_stream_id: sessionID,
|
|
1953
1954
|
session_id: sessionID,
|
|
@@ -1960,6 +1961,7 @@ const buildRawEventEnvelope = ({
|
|
|
1960
1961
|
cwd,
|
|
1961
1962
|
project,
|
|
1962
1963
|
started_at: startedAt,
|
|
1964
|
+
...(hostGeneration === "v2" ? { host_generation: "v2" } : {}),
|
|
1963
1965
|
...(captureContext ? { capture_context: captureContext } : {}),
|
|
1964
1966
|
});
|
|
1965
1967
|
|
|
@@ -2169,7 +2171,7 @@ const buildRunnerArgs = ({ runner, runnerFrom, runnerFromExplicit }) => {
|
|
|
2169
2171
|
return runnerFromExplicit ? [runnerFrom] : [];
|
|
2170
2172
|
};
|
|
2171
2173
|
|
|
2172
|
-
export const createCodememRuntime = async ({ location, host }) => {
|
|
2174
|
+
export const createCodememRuntime = async ({ location, host, hostGeneration = null }) => {
|
|
2173
2175
|
const { project, directory, worktree } = location;
|
|
2174
2176
|
const hostLog = typeof host?.log === "function" ? host.log : async () => {};
|
|
2175
2177
|
const hostNotify = typeof host?.notify === "function" ? host.notify : null;
|
|
@@ -2803,7 +2805,7 @@ export const createCodememRuntime = async ({ location, host }) => {
|
|
|
2803
2805
|
|
|
2804
2806
|
rawEventDelivery = createRawEventDelivery({
|
|
2805
2807
|
backoffMs: rawEventsBackoffMs,
|
|
2806
|
-
buildEnvelope: buildRawEventEnvelope,
|
|
2808
|
+
buildEnvelope: (input) => buildRawEventEnvelope({ ...input, hostGeneration }),
|
|
2807
2809
|
classifyViewerFailure: classifyRawEventViewerFailure,
|
|
2808
2810
|
cwd,
|
|
2809
2811
|
discardResponseBody,
|
package/index.js
CHANGED
|
@@ -2,12 +2,15 @@ import {
|
|
|
2
2
|
CodememPlugin,
|
|
3
3
|
OpencodeMemPlugin,
|
|
4
4
|
} from "./.opencode/plugins/codemem.js";
|
|
5
|
-
import {
|
|
5
|
+
import { createOpenCodeV2Adapter } from "./.opencode/lib/opencode-v2-adapter.js";
|
|
6
|
+
import { createCodememRuntime } from "./.opencode/lib/runtime.js";
|
|
6
7
|
|
|
7
8
|
const CodememDualPlugin = {
|
|
8
9
|
id: "codemem",
|
|
9
10
|
server: CodememPlugin,
|
|
10
|
-
setup:
|
|
11
|
+
setup: createOpenCodeV2Adapter({
|
|
12
|
+
createRuntime: (options) => createCodememRuntime({ ...options, hostGeneration: "v2" }),
|
|
13
|
+
}),
|
|
11
14
|
};
|
|
12
15
|
|
|
13
16
|
export default CodememDualPlugin;
|