@cortexkit/opencode-magic-context 0.18.0 → 0.19.0
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 +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/features/magic-context/compaction-marker.d.ts +17 -0
- package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
- package/dist/features/magic-context/compartment-storage.d.ts +11 -0
- package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/queue.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-identity.d.ts +11 -0
- package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -0
- package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +56 -0
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta.d.ts +1 -1
- package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
- package/dist/features/magic-context/storage.d.ts +1 -1
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
- package/dist/hooks/magic-context/cache-busting-signals.d.ts +10 -0
- package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -0
- package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/compaction-marker-manager.d.ts +50 -0
- package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-incremental.d.ts +1 -1
- package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts +16 -7
- package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner.d.ts +7 -2
- package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
- package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/historian-state-file.d.ts +25 -11
- package/dist/hooks/magic-context/historian-state-file.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook-handlers.d.ts +11 -4
- package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +2 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/live-session-state.d.ts +3 -1
- package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts +10 -4
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +15 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +2 -0
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.js +1177 -547
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/plugin/sidebar-snapshot-cache.d.ts.map +1 -1
- package/dist/shared/conflict-detector.d.ts +49 -0
- package/dist/shared/conflict-detector.d.ts.map +1 -1
- package/dist/shared/conflict-fixer.d.ts +1 -1
- package/dist/shared/conflict-fixer.d.ts.map +1 -1
- package/dist/shared/data-path.d.ts +84 -0
- package/dist/shared/data-path.d.ts.map +1 -1
- package/dist/shared/logger.d.ts +6 -0
- package/dist/shared/logger.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts +2 -1
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts +3 -2
- package/dist/shared/rpc-notifications.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +3 -0
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/rpc-types.d.ts +1 -0
- package/dist/shared/rpc-types.d.ts.map +1 -1
- package/dist/shared/rpc-utils.d.ts +13 -2
- package/dist/shared/rpc-utils.d.ts.map +1 -1
- package/dist/shared/stable-json.d.ts +21 -0
- package/dist/shared/stable-json.d.ts.map +1 -0
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/shared/conflict-detector.ts +4 -4
- package/src/shared/conflict-fixer.test.ts +124 -0
- package/src/shared/conflict-fixer.ts +34 -28
- package/src/shared/data-path.test.ts +38 -0
- package/src/shared/data-path.ts +99 -0
- package/src/shared/logger.ts +29 -3
- package/src/shared/rpc-client.test.ts +161 -0
- package/src/shared/rpc-client.ts +82 -22
- package/src/shared/rpc-notifications.test.ts +20 -0
- package/src/shared/rpc-notifications.ts +9 -6
- package/src/shared/rpc-server.ts +42 -4
- package/src/shared/rpc-types.ts +1 -0
- package/src/shared/rpc-utils.ts +59 -3
- package/src/shared/stable-json.test.ts +87 -0
- package/src/shared/stable-json.ts +37 -0
- package/src/tui/data/context-db.ts +20 -1
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse, stringify } from "comment-json";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type ConflictResult,
|
|
7
|
+
DCP_PACKAGE_NAMES,
|
|
8
|
+
extractPluginName,
|
|
9
|
+
matchesPackageName,
|
|
10
|
+
} from "./conflict-detector";
|
|
6
11
|
import { getOpenCodeConfigPaths } from "./opencode-config-dir";
|
|
7
12
|
|
|
8
13
|
type JsonObject = Record<string, unknown>;
|
|
@@ -20,10 +25,6 @@ const OMO_CONFIG_NAMES = [
|
|
|
20
25
|
"oh-my-opencode.json",
|
|
21
26
|
] as const;
|
|
22
27
|
|
|
23
|
-
function ensureParentDir(filePath: string): void {
|
|
24
|
-
mkdirSync(dirname(filePath), { recursive: true });
|
|
25
|
-
}
|
|
26
|
-
|
|
27
28
|
function isRecord(value: unknown): value is JsonObject {
|
|
28
29
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
30
|
}
|
|
@@ -36,18 +37,19 @@ function asStringArray(value: unknown): string[] {
|
|
|
36
37
|
|
|
37
38
|
function readConfig(filePath: string): JsonObject | null {
|
|
38
39
|
if (!existsSync(filePath)) {
|
|
39
|
-
return
|
|
40
|
+
return null;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
try {
|
|
44
|
+
const parsed = parse(readFileSync(filePath, "utf-8"));
|
|
45
|
+
return isRecord(parsed) ? parsed : null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
// Intentional: conflict-fixer uses JSON.stringify (not comment-json) because this module
|
|
46
|
-
// is imported by the TUI process which loads raw source and cannot resolve npm dependencies.
|
|
47
|
-
// Comment preservation for config writes is handled by the CLI paths (doctor.ts, setup.ts).
|
|
48
51
|
function writeConfig(filePath: string, config: JsonObject): void {
|
|
49
|
-
|
|
50
|
-
writeFileSync(filePath, `${JSON.stringify(config, null, 2)}\n`);
|
|
52
|
+
writeFileSync(filePath, `${stringify(config, null, 2)}\n`);
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
function resolveUserOpenCodeConfigPath(): string {
|
|
@@ -60,7 +62,9 @@ function collectOpenCodeConfigPaths(directory: string): string[] {
|
|
|
60
62
|
const paths = new Set<string>();
|
|
61
63
|
const userConfig = resolveUserOpenCodeConfigPath();
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
if (existsSync(userConfig)) {
|
|
66
|
+
paths.add(userConfig);
|
|
67
|
+
}
|
|
64
68
|
|
|
65
69
|
for (const filePath of [
|
|
66
70
|
join(directory, ".opencode", "opencode.jsonc"),
|
|
@@ -93,13 +97,17 @@ function collectOmoConfigPaths(directory: string): string[] {
|
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
if (paths.size === 0) {
|
|
97
|
-
paths.add(join(configDir, "oh-my-openagent.json"));
|
|
98
|
-
}
|
|
99
|
-
|
|
100
100
|
return [...paths];
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
function filterDcpPluginEntries(entries: unknown[]): { plugins: unknown[]; removed: boolean } {
|
|
104
|
+
const plugins = entries.filter((entry) => {
|
|
105
|
+
const name = extractPluginName(entry);
|
|
106
|
+
return name ? !matchesPackageName(name, DCP_PACKAGE_NAMES) : true;
|
|
107
|
+
});
|
|
108
|
+
return { plugins, removed: plugins.length !== entries.length };
|
|
109
|
+
}
|
|
110
|
+
|
|
103
111
|
export function fixConflicts(directory: string, conflicts: ConflictResult["conflicts"]): string[] {
|
|
104
112
|
const actions: string[] = [];
|
|
105
113
|
let updatedCompaction = false;
|
|
@@ -116,7 +124,7 @@ export function fixConflicts(directory: string, conflicts: ConflictResult["confl
|
|
|
116
124
|
let changed = false;
|
|
117
125
|
|
|
118
126
|
if (conflicts.compactionAuto || conflicts.compactionPrune) {
|
|
119
|
-
const compaction = isRecord(config.compaction) ?
|
|
127
|
+
const compaction = isRecord(config.compaction) ? config.compaction : {};
|
|
120
128
|
|
|
121
129
|
if (compaction.auto !== false) {
|
|
122
130
|
compaction.auto = false;
|
|
@@ -134,13 +142,11 @@ export function fixConflicts(directory: string, conflicts: ConflictResult["confl
|
|
|
134
142
|
}
|
|
135
143
|
|
|
136
144
|
if (conflicts.dcpPlugin) {
|
|
137
|
-
const plugins =
|
|
138
|
-
const
|
|
139
|
-
(plugin) => !plugin.includes("opencode-dcp"),
|
|
140
|
-
);
|
|
145
|
+
const plugins = Array.isArray(config.plugin) ? config.plugin : [];
|
|
146
|
+
const filtered = filterDcpPluginEntries(plugins);
|
|
141
147
|
|
|
142
|
-
if (
|
|
143
|
-
config.plugin =
|
|
148
|
+
if (filtered.removed) {
|
|
149
|
+
config.plugin = filtered.plugins;
|
|
144
150
|
changed = true;
|
|
145
151
|
removedDcpPlugin = true;
|
|
146
152
|
}
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
getMagicContextStorageDir,
|
|
9
9
|
getOpenCodeCacheDir,
|
|
10
10
|
getOpenCodeStorageDir,
|
|
11
|
+
getProjectMagicContextDir,
|
|
12
|
+
getProjectMagicContextHistorianDir,
|
|
11
13
|
} from "./data-path";
|
|
12
14
|
|
|
13
15
|
const savedEnv = {
|
|
@@ -118,4 +120,40 @@ describe("data-path", () => {
|
|
|
118
120
|
expect(legacy).toContain("opencode");
|
|
119
121
|
expect(shared).toContain("cortexkit");
|
|
120
122
|
});
|
|
123
|
+
|
|
124
|
+
test("getProjectMagicContextDir composes <project>/.opencode/magic-context", () => {
|
|
125
|
+
// Project-local artifacts (historian state file, failure dumps) live
|
|
126
|
+
// inside the project so OpenCode's external_directory permission system
|
|
127
|
+
// treats them as project-internal. Without this, historian's Read tool
|
|
128
|
+
// would trigger a permission prompt on every run when artifacts lived
|
|
129
|
+
// under os.tmpdir().
|
|
130
|
+
expect(getProjectMagicContextDir("/Users/me/Work/proj")).toBe(
|
|
131
|
+
path.join("/Users/me/Work/proj", ".opencode", "magic-context"),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("getProjectMagicContextHistorianDir appends historian/", () => {
|
|
136
|
+
expect(getProjectMagicContextHistorianDir("/Users/me/Work/proj")).toBe(
|
|
137
|
+
path.join("/Users/me/Work/proj", ".opencode", "magic-context", "historian"),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("getProjectMagicContextDir is unaffected by XDG_DATA_HOME", () => {
|
|
142
|
+
// Project-local paths anchor to the project directory the caller
|
|
143
|
+
// passes in, NOT to any user-config env var. Setting XDG_DATA_HOME
|
|
144
|
+
// (which changes the shared storage dir) must not change the
|
|
145
|
+
// project-local historian dir.
|
|
146
|
+
process.env.XDG_DATA_HOME = "/tmp/custom-data";
|
|
147
|
+
expect(getProjectMagicContextDir("/some/project")).toBe(
|
|
148
|
+
path.join("/some/project", ".opencode", "magic-context"),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("getProjectMagicContextDir handles trailing slashes via path.join", () => {
|
|
153
|
+
// path.join normalizes redundant separators so callers don't need to
|
|
154
|
+
// worry about how the project directory was constructed.
|
|
155
|
+
expect(getProjectMagicContextDir("/some/project/")).toBe(
|
|
156
|
+
path.join("/some/project/", ".opencode", "magic-context"),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
121
159
|
});
|
package/src/shared/data-path.ts
CHANGED
|
@@ -1,10 +1,109 @@
|
|
|
1
1
|
import * as os from "node:os";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { getHarness, type HarnessId } from "./harness";
|
|
3
4
|
|
|
4
5
|
export function getDataDir(): string {
|
|
5
6
|
return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
|
|
6
7
|
}
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Per-harness scratch directory under the OS temp dir.
|
|
11
|
+
*
|
|
12
|
+
* Layout:
|
|
13
|
+
* - OpenCode: `${os.tmpdir()}/opencode/magic-context/`
|
|
14
|
+
* - Pi: `${os.tmpdir()}/pi/magic-context/`
|
|
15
|
+
*
|
|
16
|
+
* Why a per-harness subtree of `os.tmpdir()`:
|
|
17
|
+
* 1. OpenCode Desktop runs as an Electron app with a permission sandbox.
|
|
18
|
+
* Writing to arbitrary tmp paths can trigger user-visible permission
|
|
19
|
+
* prompts; the `${tmpdir}/opencode/` subtree is allow-listed by
|
|
20
|
+
* OpenCode, so anything we put under it never asks for permission.
|
|
21
|
+
* 2. Splitting OpenCode from Pi keeps their logs and historian dump
|
|
22
|
+
* directories cleanly separated. `doctor --issue` for each harness
|
|
23
|
+
* reports diagnostics from the matching subtree, so an OpenCode
|
|
24
|
+
* issue report never includes Pi log noise (and vice versa).
|
|
25
|
+
* 3. Pi has no permission sandbox, so the path choice is purely
|
|
26
|
+
* cosmetic for Pi — it just keeps the layout symmetric.
|
|
27
|
+
*
|
|
28
|
+
* Pass an explicit `harness` only when the caller already knows the
|
|
29
|
+
* harness without relying on the global `setHarness()` state (e.g. the
|
|
30
|
+
* CLI's doctor commands, which target a specific harness regardless of
|
|
31
|
+
* which plugin is loaded). Production runtime callers should omit it so
|
|
32
|
+
* the helper picks up the boot-time harness automatically.
|
|
33
|
+
*/
|
|
34
|
+
export function getMagicContextTempDir(harness: HarnessId = getHarness()): string {
|
|
35
|
+
return path.join(os.tmpdir(), harness, "magic-context");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Standard log file path the plugin writes to. Pi and OpenCode write to
|
|
40
|
+
* SEPARATE logs under their respective harness subtrees so a single
|
|
41
|
+
* machine running both harnesses doesn't interleave session traces.
|
|
42
|
+
*
|
|
43
|
+
* The plugin's buffered logger calls this on every flush rather than
|
|
44
|
+
* caching, so `setHarness("pi")` taking effect after module load is
|
|
45
|
+
* reflected in the next flush.
|
|
46
|
+
*/
|
|
47
|
+
export function getMagicContextLogPath(harness: HarnessId = getHarness()): string {
|
|
48
|
+
return path.join(getMagicContextTempDir(harness), "magic-context.log");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Directory used for both historian validation-failure dumps and the
|
|
53
|
+
* existing-state offload XMLs that large historian/recomp passes write
|
|
54
|
+
* before invoking the model. Per-harness so dumps from different
|
|
55
|
+
* harnesses don't collide on filename and so `doctor --issue` for each
|
|
56
|
+
* harness reports only its own historian artifacts.
|
|
57
|
+
*/
|
|
58
|
+
export function getMagicContextHistorianDir(harness: HarnessId = getHarness()): string {
|
|
59
|
+
return path.join(getMagicContextTempDir(harness), "historian");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Project-local magic-context artifact directory.
|
|
64
|
+
*
|
|
65
|
+
* Layout: `<project-directory>/.opencode/magic-context/`
|
|
66
|
+
*
|
|
67
|
+
* Used for artifacts that the historian/recomp pipeline writes during a run
|
|
68
|
+
* and that the model is asked to read via its native Read tool. OpenCode's
|
|
69
|
+
* `external_directory` permission system asks the user before reading any
|
|
70
|
+
* file outside the project directory or its worktree, which interrupts every
|
|
71
|
+
* historian run when artifacts live under `os.tmpdir()`. Writing under the
|
|
72
|
+
* project's own `.opencode/` subtree falls inside the project boundary and
|
|
73
|
+
* never triggers a permission prompt.
|
|
74
|
+
*
|
|
75
|
+
* The `.opencode/` parent dir is OpenCode's own per-project convention (used
|
|
76
|
+
* for project-local config, plans, dumps, plugin installs). Anchoring
|
|
77
|
+
* magic-context artifacts under `.opencode/magic-context/` keeps them
|
|
78
|
+
* co-located with related OpenCode metadata and makes them easy for users to
|
|
79
|
+
* locate when debugging.
|
|
80
|
+
*
|
|
81
|
+
* Logger does NOT use this — log files stay in the per-harness tmp subtree
|
|
82
|
+
* because they are written by the plugin process itself (no model-side Read
|
|
83
|
+
* tool call, no permission prompt) and span sessions/projects.
|
|
84
|
+
*/
|
|
85
|
+
export function getProjectMagicContextDir(directory: string): string {
|
|
86
|
+
return path.join(directory, ".opencode", "magic-context");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Project-local historian artifact directory.
|
|
91
|
+
*
|
|
92
|
+
* Layout: `<project-directory>/.opencode/magic-context/historian/`
|
|
93
|
+
*
|
|
94
|
+
* Used for:
|
|
95
|
+
* - existing-state offload XMLs that long historian/recomp passes write
|
|
96
|
+
* before invoking the model (the model reads the file via Read tool)
|
|
97
|
+
* - validation-failure dump XMLs preserved for debugging
|
|
98
|
+
*
|
|
99
|
+
* Callers must `mkdirSync(dir, { recursive: true })` before writing — the
|
|
100
|
+
* `.opencode/` parent may not exist on a fresh project, and write failures
|
|
101
|
+
* here must degrade gracefully (e.g. historian falls back to inline state).
|
|
102
|
+
*/
|
|
103
|
+
export function getProjectMagicContextHistorianDir(directory: string): string {
|
|
104
|
+
return path.join(getProjectMagicContextDir(directory), "historian");
|
|
105
|
+
}
|
|
106
|
+
|
|
8
107
|
export function getOpenCodeStorageDir(): string {
|
|
9
108
|
return path.join(getDataDir(), "opencode", "storage");
|
|
10
109
|
}
|
package/src/shared/logger.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
-
import * as os from "node:os";
|
|
3
2
|
import * as path from "node:path";
|
|
3
|
+
import { getMagicContextLogPath } from "./data-path";
|
|
4
4
|
|
|
5
|
-
const logFile = path.join(os.tmpdir(), "magic-context.log");
|
|
6
5
|
const isTestEnv = process.env.NODE_ENV === "test";
|
|
7
6
|
|
|
8
7
|
let buffer: string[] = [];
|
|
@@ -10,6 +9,25 @@ let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
10
9
|
const FLUSH_INTERVAL_MS = 500;
|
|
11
10
|
const BUFFER_SIZE_LIMIT = 50;
|
|
12
11
|
|
|
12
|
+
// Cache the last log directory we mkdir'd successfully so we only retry the
|
|
13
|
+
// filesystem call when the resolved path actually changes. The path is
|
|
14
|
+
// re-evaluated on every flush because `setHarness("pi")` runs after module
|
|
15
|
+
// load on Pi; we MUST NOT freeze it at import time, or Pi's first flush
|
|
16
|
+
// could land in the OpenCode subtree.
|
|
17
|
+
let lastEnsuredDir: string | null = null;
|
|
18
|
+
|
|
19
|
+
function ensureDir(filePath: string): void {
|
|
20
|
+
const dir = path.dirname(filePath);
|
|
21
|
+
if (dir === lastEnsuredDir) return;
|
|
22
|
+
try {
|
|
23
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
24
|
+
lastEnsuredDir = dir;
|
|
25
|
+
} catch {
|
|
26
|
+
// Intentional: logging must never throw. If mkdir fails we still
|
|
27
|
+
// try the append; failure there is also swallowed.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
function flush(): void {
|
|
14
32
|
if (flushTimer) {
|
|
15
33
|
clearTimeout(flushTimer);
|
|
@@ -19,6 +37,8 @@ function flush(): void {
|
|
|
19
37
|
const data = buffer.join("");
|
|
20
38
|
buffer = [];
|
|
21
39
|
try {
|
|
40
|
+
const logFile = getMagicContextLogPath();
|
|
41
|
+
ensureDir(logFile);
|
|
22
42
|
fs.appendFileSync(logFile, data);
|
|
23
43
|
} catch {
|
|
24
44
|
// Intentional: logging must never throw
|
|
@@ -58,8 +78,14 @@ export function sessionLog(sessionId: string, message: string, data?: unknown):
|
|
|
58
78
|
log(`[magic-context][${sessionId}] ${message}`, data);
|
|
59
79
|
}
|
|
60
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the current log file path. The path is harness-aware (see
|
|
83
|
+
* {@link getMagicContextLogPath}) and re-evaluated on every call, so callers
|
|
84
|
+
* who format diagnostic output with this value always see the path the next
|
|
85
|
+
* flush will actually use.
|
|
86
|
+
*/
|
|
61
87
|
export function getLogFilePath(): string {
|
|
62
|
-
return
|
|
88
|
+
return getMagicContextLogPath();
|
|
63
89
|
}
|
|
64
90
|
|
|
65
91
|
// Flush remaining buffer on process exit
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { MagicContextRpcClient } from "./rpc-client";
|
|
7
|
+
import { rpcPortFilePath } from "./rpc-utils";
|
|
8
|
+
|
|
9
|
+
interface TestServer {
|
|
10
|
+
port: number;
|
|
11
|
+
close: () => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const tempDirs: string[] = [];
|
|
15
|
+
let servers: TestServer[] = [];
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
for (const server of servers.splice(0)) {
|
|
19
|
+
await server.close();
|
|
20
|
+
}
|
|
21
|
+
for (const dir of tempDirs.splice(0)) {
|
|
22
|
+
rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function makeTempDir(): string {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), "mc-rpc-client-"));
|
|
28
|
+
tempDirs.push(dir);
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writePortFile(storageDir: string, directory: string, port: number): void {
|
|
33
|
+
const portFile = rpcPortFilePath(storageDir, directory);
|
|
34
|
+
mkdirSync(dirname(portFile), { recursive: true });
|
|
35
|
+
writeFileSync(
|
|
36
|
+
portFile,
|
|
37
|
+
JSON.stringify({ port, pid: process.pid, started_at: Date.now() }),
|
|
38
|
+
"utf-8",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writePortFileForPid(
|
|
43
|
+
storageDir: string,
|
|
44
|
+
directory: string,
|
|
45
|
+
port: number,
|
|
46
|
+
pid: number,
|
|
47
|
+
startedAt: number,
|
|
48
|
+
): void {
|
|
49
|
+
const portFile = rpcPortFilePath(storageDir, directory, pid);
|
|
50
|
+
mkdirSync(dirname(portFile), { recursive: true });
|
|
51
|
+
writeFileSync(portFile, JSON.stringify({ port, pid, started_at: startedAt }), "utf-8");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function startRpcServer(handler: (method: string) => Response | object): Promise<TestServer> {
|
|
55
|
+
const server = createServer(async (req, res) => {
|
|
56
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
57
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
58
|
+
res.end(JSON.stringify({ ok: true }));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (req.method === "POST" && req.url?.startsWith("/rpc/")) {
|
|
63
|
+
const method = req.url.slice("/rpc/".length);
|
|
64
|
+
const result = handler(method);
|
|
65
|
+
if (result instanceof Response) {
|
|
66
|
+
res.writeHead(result.status, { "Content-Type": "application/json" });
|
|
67
|
+
res.end(await result.text());
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
71
|
+
res.end(JSON.stringify(result));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
res.writeHead(404);
|
|
76
|
+
res.end("Not Found");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await new Promise<void>((resolve, reject) => {
|
|
80
|
+
server.once("error", reject);
|
|
81
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
82
|
+
});
|
|
83
|
+
const addr = server.address();
|
|
84
|
+
if (!addr || typeof addr === "string") throw new Error("failed to bind test server");
|
|
85
|
+
|
|
86
|
+
const testServer = {
|
|
87
|
+
port: addr.port,
|
|
88
|
+
close: () =>
|
|
89
|
+
new Promise<void>((resolve, reject) => {
|
|
90
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
91
|
+
}),
|
|
92
|
+
};
|
|
93
|
+
servers.push(testServer);
|
|
94
|
+
return testServer;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function closeServer(server: TestServer): Promise<void> {
|
|
98
|
+
servers = servers.filter((s) => s !== server);
|
|
99
|
+
await server.close();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe("MagicContextRpcClient", () => {
|
|
103
|
+
test("re-reads the port file after the cached server restarts on a new port", async () => {
|
|
104
|
+
const storageDir = makeTempDir();
|
|
105
|
+
const directory = "/repo";
|
|
106
|
+
const client = new MagicContextRpcClient(storageDir, directory);
|
|
107
|
+
|
|
108
|
+
const first = await startRpcServer(() => ({ value: "first" }));
|
|
109
|
+
writePortFile(storageDir, directory, first.port);
|
|
110
|
+
expect(await client.call<{ value: string }>("value")).toEqual({ value: "first" });
|
|
111
|
+
|
|
112
|
+
await closeServer(first);
|
|
113
|
+
const second = await startRpcServer(() => ({ value: "second" }));
|
|
114
|
+
writePortFile(storageDir, directory, second.port);
|
|
115
|
+
|
|
116
|
+
expect(await client.call<{ value: string }>("value")).toEqual({ value: "second" });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("gives up when the port file points at a dead server", async () => {
|
|
120
|
+
const storageDir = makeTempDir();
|
|
121
|
+
const directory = "/repo";
|
|
122
|
+
const dead = await startRpcServer(() => ({ ok: true }));
|
|
123
|
+
const port = dead.port;
|
|
124
|
+
await closeServer(dead);
|
|
125
|
+
writePortFile(storageDir, directory, port);
|
|
126
|
+
|
|
127
|
+
const client = new MagicContextRpcClient(storageDir, directory);
|
|
128
|
+
await expect(client.call("value")).rejects.toThrow(
|
|
129
|
+
"Magic Context RPC server not available",
|
|
130
|
+
);
|
|
131
|
+
}, 20_000);
|
|
132
|
+
|
|
133
|
+
test("re-resolves and retries transient 5xx responses", async () => {
|
|
134
|
+
const storageDir = makeTempDir();
|
|
135
|
+
const directory = "/repo";
|
|
136
|
+
let calls = 0;
|
|
137
|
+
const server = await startRpcServer(() => {
|
|
138
|
+
calls++;
|
|
139
|
+
if (calls === 1) {
|
|
140
|
+
return new Response(JSON.stringify({ error: "warming up" }), { status: 503 });
|
|
141
|
+
}
|
|
142
|
+
return { value: "ok" };
|
|
143
|
+
});
|
|
144
|
+
writePortFile(storageDir, directory, server.port);
|
|
145
|
+
|
|
146
|
+
const client = new MagicContextRpcClient(storageDir, directory);
|
|
147
|
+
expect(await client.call<{ value: string }>("value")).toEqual({ value: "ok" });
|
|
148
|
+
expect(calls).toBe(2);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("ignores newer stale pid files and discovers the latest live instance", async () => {
|
|
152
|
+
const storageDir = makeTempDir();
|
|
153
|
+
const directory = "/repo";
|
|
154
|
+
const live = await startRpcServer(() => ({ value: "live" }));
|
|
155
|
+
writePortFileForPid(storageDir, directory, 65535, 999_999_999, Date.now() + 10_000);
|
|
156
|
+
writePortFileForPid(storageDir, directory, live.port, process.pid, Date.now());
|
|
157
|
+
|
|
158
|
+
const client = new MagicContextRpcClient(storageDir, directory);
|
|
159
|
+
expect(await client.call<{ value: string }>("value")).toEqual({ value: "live" });
|
|
160
|
+
});
|
|
161
|
+
});
|
package/src/shared/rpc-client.ts
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
isPidAlive,
|
|
5
|
+
legacyRpcPortFilePath,
|
|
6
|
+
parseRpcPortFile,
|
|
7
|
+
type RpcPortFileRecord,
|
|
8
|
+
rpcPortDir,
|
|
9
|
+
} from "./rpc-utils";
|
|
3
10
|
|
|
4
11
|
const MAX_RETRIES = 10;
|
|
5
12
|
const RETRY_DELAY_MS = 500;
|
|
6
13
|
const REQUEST_TIMEOUT_MS = 5000;
|
|
14
|
+
const MAX_RERESOLVE_ATTEMPTS = 3;
|
|
15
|
+
const NON_RETRYABLE_RPC_ERROR = Symbol("nonRetryableRpcError");
|
|
16
|
+
type NonRetryableRpcError = Error & { [NON_RETRYABLE_RPC_ERROR]: true };
|
|
7
17
|
|
|
8
18
|
export class MagicContextRpcClient {
|
|
9
19
|
private port: number | null = null;
|
|
10
|
-
private
|
|
20
|
+
private portDir: string;
|
|
21
|
+
private legacyPortFilePath: string;
|
|
11
22
|
private healthChecked = false;
|
|
12
23
|
|
|
13
24
|
constructor(storageDir: string, directory: string) {
|
|
14
|
-
this.
|
|
25
|
+
this.portDir = rpcPortDir(storageDir, directory);
|
|
26
|
+
this.legacyPortFilePath = legacyRpcPortFilePath(storageDir, directory);
|
|
15
27
|
}
|
|
16
28
|
|
|
17
29
|
/** Call an RPC method. Retries port resolution if the server isn't ready yet. */
|
|
@@ -19,23 +31,52 @@ export class MagicContextRpcClient {
|
|
|
19
31
|
method: string,
|
|
20
32
|
params: Record<string, unknown> = {},
|
|
21
33
|
): Promise<T> {
|
|
22
|
-
|
|
23
|
-
if (!port) {
|
|
24
|
-
throw new Error("Magic Context RPC server not available");
|
|
25
|
-
}
|
|
34
|
+
let lastError: unknown = null;
|
|
26
35
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
for (let attempt = 0; attempt < MAX_RERESOLVE_ATTEMPTS; attempt++) {
|
|
37
|
+
const port = await this.resolvePort();
|
|
38
|
+
if (!port) {
|
|
39
|
+
lastError = new Error("Magic Context RPC server not available");
|
|
40
|
+
this.reset();
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const response = await this.fetchWithTimeout(
|
|
46
|
+
`http://127.0.0.1:${port}/rpc/${method}`,
|
|
47
|
+
{
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { "Content-Type": "application/json" },
|
|
50
|
+
body: JSON.stringify(params),
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
const text = await response.text();
|
|
56
|
+
const error = new Error(`RPC ${method} failed (${response.status}): ${text}`);
|
|
57
|
+
if (response.status >= 500) {
|
|
58
|
+
lastError = error;
|
|
59
|
+
this.reset();
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
(error as NonRetryableRpcError)[NON_RETRYABLE_RPC_ERROR] = true;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
32
65
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
66
|
+
return (await response.json()) as T;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (isNonRetryableRpcError(err)) {
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
lastError = err;
|
|
72
|
+
this.reset();
|
|
73
|
+
}
|
|
36
74
|
}
|
|
37
75
|
|
|
38
|
-
|
|
76
|
+
if (lastError instanceof Error) {
|
|
77
|
+
throw lastError;
|
|
78
|
+
}
|
|
79
|
+
throw new Error("Magic Context RPC server not available");
|
|
39
80
|
}
|
|
40
81
|
|
|
41
82
|
/** Check if the RPC server is reachable. */
|
|
@@ -83,13 +124,28 @@ export class MagicContextRpcClient {
|
|
|
83
124
|
}
|
|
84
125
|
|
|
85
126
|
private readPortFile(): number | null {
|
|
127
|
+
const records: RpcPortFileRecord[] = [];
|
|
128
|
+
|
|
86
129
|
try {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
130
|
+
for (const entry of readdirSync(this.portDir)) {
|
|
131
|
+
if (!entry.startsWith("port-") || !entry.endsWith(".json")) continue;
|
|
132
|
+
const record = parseRpcPortFile(readFileSync(join(this.portDir, entry), "utf-8"));
|
|
133
|
+
if (!record || !isPidAlive(record.pid)) continue;
|
|
134
|
+
records.push(record);
|
|
91
135
|
}
|
|
92
|
-
|
|
136
|
+
} catch {
|
|
137
|
+
// Directory may not exist yet. Fall back to the legacy file below.
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (records.length > 0) {
|
|
141
|
+
records.sort((a, b) => b.started_at - a.started_at);
|
|
142
|
+
return records[0].port;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const record = parseRpcPortFile(readFileSync(this.legacyPortFilePath, "utf-8"));
|
|
147
|
+
if (record?.pid && !isPidAlive(record.pid)) return null;
|
|
148
|
+
return record?.port ?? null;
|
|
93
149
|
} catch {
|
|
94
150
|
return null;
|
|
95
151
|
}
|
|
@@ -121,3 +177,7 @@ export class MagicContextRpcClient {
|
|
|
121
177
|
this.healthChecked = false;
|
|
122
178
|
}
|
|
123
179
|
}
|
|
180
|
+
|
|
181
|
+
function isNonRetryableRpcError(err: unknown): err is NonRetryableRpcError {
|
|
182
|
+
return typeof err === "object" && err !== null && NON_RETRYABLE_RPC_ERROR in err;
|
|
183
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { drainNotifications, pushNotification } from "./rpc-notifications";
|
|
3
|
+
|
|
4
|
+
describe("rpc notifications", () => {
|
|
5
|
+
test("keeps messages queued until the client acks their id", () => {
|
|
6
|
+
const initial = drainNotifications(Number.MAX_SAFE_INTEGER);
|
|
7
|
+
expect(initial).toEqual([]);
|
|
8
|
+
|
|
9
|
+
pushNotification("one", { ok: true }, "ses_1");
|
|
10
|
+
const firstPoll = drainNotifications();
|
|
11
|
+
expect(firstPoll).toHaveLength(1);
|
|
12
|
+
expect(firstPoll[0].type).toBe("one");
|
|
13
|
+
|
|
14
|
+
const retryPoll = drainNotifications();
|
|
15
|
+
expect(retryPoll.map((m) => m.id)).toEqual(firstPoll.map((m) => m.id));
|
|
16
|
+
|
|
17
|
+
const lastReceivedId = Math.max(...firstPoll.map((m) => m.id));
|
|
18
|
+
expect(drainNotifications(lastReceivedId)).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
});
|