@openpalm/lib 0.9.8 → 0.10.1
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 +31 -71
- package/package.json +1 -1
- package/src/control-plane/audit.ts +4 -4
- package/src/control-plane/backup.ts +31 -0
- package/src/control-plane/channels.ts +88 -156
- package/src/control-plane/cleanup-guardrails.test.ts +289 -0
- package/src/control-plane/compose-args.test.ts +170 -0
- package/src/control-plane/compose-args.ts +57 -0
- package/src/control-plane/config-persistence.ts +270 -0
- package/src/control-plane/core-assets.ts +58 -234
- package/src/control-plane/crypto.ts +14 -0
- package/src/control-plane/docker.ts +94 -204
- package/src/control-plane/env-schema-validation.test.ts +118 -0
- package/src/control-plane/extends-support.test.ts +105 -0
- package/src/control-plane/home.ts +133 -0
- package/src/control-plane/install-edge-cases.test.ts +314 -717
- package/src/control-plane/lifecycle.ts +215 -233
- package/src/control-plane/lock.test.ts +194 -0
- package/src/control-plane/lock.ts +176 -0
- package/src/control-plane/memory-config.ts +34 -160
- package/src/control-plane/opencode-client.test.ts +154 -0
- package/src/control-plane/opencode-client.ts +113 -0
- package/src/control-plane/provider-config.ts +34 -0
- package/src/control-plane/redact-schema.ts +50 -0
- package/src/control-plane/registry-components.test.ts +313 -0
- package/src/control-plane/registry.test.ts +414 -0
- package/src/control-plane/registry.ts +418 -0
- package/src/control-plane/rollback.ts +128 -0
- package/src/control-plane/scheduler.ts +18 -190
- package/src/control-plane/secret-backend.test.ts +359 -0
- package/src/control-plane/secret-backend.ts +322 -0
- package/src/control-plane/secret-mappings.ts +185 -0
- package/src/control-plane/secrets.ts +186 -112
- package/src/control-plane/setup-config.schema.json +306 -0
- package/src/control-plane/setup-status.ts +15 -8
- package/src/control-plane/setup-validation.ts +90 -0
- package/src/control-plane/setup.test.ts +336 -929
- package/src/control-plane/setup.ts +159 -849
- package/src/control-plane/spec-to-env.test.ts +100 -0
- package/src/control-plane/spec-to-env.ts +195 -0
- package/src/control-plane/spec-validator.ts +159 -0
- package/src/control-plane/stack-spec.test.ts +150 -0
- package/src/control-plane/stack-spec.ts +101 -22
- package/src/control-plane/types.ts +6 -99
- package/src/control-plane/validate.ts +107 -0
- package/src/index.ts +101 -159
- package/src/provider-constants.ts +2 -31
- package/src/control-plane/connection-mapping.ts +0 -191
- package/src/control-plane/connection-migration-flags.ts +0 -40
- package/src/control-plane/connection-profiles.ts +0 -317
- package/src/control-plane/core-asset-provider.ts +0 -21
- package/src/control-plane/fs-asset-provider.ts +0 -65
- package/src/control-plane/fs-registry-provider.ts +0 -46
- package/src/control-plane/paths.ts +0 -77
- package/src/control-plane/registry-provider.ts +0 -19
- package/src/control-plane/staging.ts +0 -399
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for orchestrator lock — acquisition, contention, stale cleanup,
|
|
3
|
+
* corrupt file handling, release, and idempotent release.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
6
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import {
|
|
10
|
+
acquireLock,
|
|
11
|
+
releaseLock,
|
|
12
|
+
lockPath,
|
|
13
|
+
LockAcquisitionError,
|
|
14
|
+
} from "./lock.js";
|
|
15
|
+
import type { LockHandle, LockInfo } from "./lock.js";
|
|
16
|
+
|
|
17
|
+
let opHome: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
opHome = mkdtempSync(join(tmpdir(), "lock-test-"));
|
|
21
|
+
mkdirSync(join(opHome, "data"), { recursive: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
rmSync(opHome, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ── Acquisition ──────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
describe("acquireLock", () => {
|
|
31
|
+
it("creates a lock file with correct JSON content", () => {
|
|
32
|
+
const handle = acquireLock(opHome, "install");
|
|
33
|
+
expect(existsSync(handle.path)).toBe(true);
|
|
34
|
+
|
|
35
|
+
const content = JSON.parse(readFileSync(handle.path, "utf-8"));
|
|
36
|
+
expect(content.pid).toBe(process.pid);
|
|
37
|
+
expect(content.operation).toBe("install");
|
|
38
|
+
expect(typeof content.acquiredAt).toBe("string");
|
|
39
|
+
|
|
40
|
+
releaseLock(handle);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns a handle with correct info", () => {
|
|
44
|
+
const handle = acquireLock(opHome, "update");
|
|
45
|
+
expect(handle.info.pid).toBe(process.pid);
|
|
46
|
+
expect(handle.info.operation).toBe("update");
|
|
47
|
+
expect(handle.path).toBe(lockPath(opHome));
|
|
48
|
+
|
|
49
|
+
releaseLock(handle);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("places lock at {opHome}/data/.openpalm.lock", () => {
|
|
53
|
+
const handle = acquireLock(opHome, "test");
|
|
54
|
+
expect(handle.path).toBe(join(opHome, "data", ".openpalm.lock"));
|
|
55
|
+
releaseLock(handle);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ── Contention ───────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
describe("contention", () => {
|
|
62
|
+
it("throws LockAcquisitionError when lock is already held by this process", () => {
|
|
63
|
+
const handle = acquireLock(opHome, "install");
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
expect(() => acquireLock(opHome, "update")).toThrow(LockAcquisitionError);
|
|
67
|
+
} finally {
|
|
68
|
+
releaseLock(handle);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("error includes holder details", () => {
|
|
73
|
+
const handle = acquireLock(opHome, "install");
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
acquireLock(opHome, "update");
|
|
77
|
+
expect.unreachable("should have thrown");
|
|
78
|
+
} catch (err) {
|
|
79
|
+
expect(err).toBeInstanceOf(LockAcquisitionError);
|
|
80
|
+
const lockErr = err as LockAcquisitionError;
|
|
81
|
+
expect(lockErr.holder.pid).toBe(process.pid);
|
|
82
|
+
expect(lockErr.holder.operation).toBe("install");
|
|
83
|
+
} finally {
|
|
84
|
+
releaseLock(handle);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ── Stale PID cleanup ────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
describe("stale PID cleanup", () => {
|
|
92
|
+
it("cleans up stale lock from a dead PID and acquires", () => {
|
|
93
|
+
// Write a lock file with a PID that does not exist
|
|
94
|
+
const stalePid = 99999999; // Very unlikely to be a real process
|
|
95
|
+
const staleInfo: LockInfo = {
|
|
96
|
+
pid: stalePid,
|
|
97
|
+
operation: "old-install",
|
|
98
|
+
acquiredAt: "2020-01-01T00:00:00.000Z",
|
|
99
|
+
};
|
|
100
|
+
writeFileSync(lockPath(opHome), JSON.stringify(staleInfo) + "\n");
|
|
101
|
+
|
|
102
|
+
// Should succeed because the PID is dead
|
|
103
|
+
const handle = acquireLock(opHome, "new-install");
|
|
104
|
+
expect(handle.info.pid).toBe(process.pid);
|
|
105
|
+
expect(handle.info.operation).toBe("new-install");
|
|
106
|
+
|
|
107
|
+
releaseLock(handle);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── Corrupt file handling ────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
describe("corrupt lock file", () => {
|
|
114
|
+
it("recovers from a corrupt lock file", () => {
|
|
115
|
+
writeFileSync(lockPath(opHome), "not valid json{{{");
|
|
116
|
+
|
|
117
|
+
const handle = acquireLock(opHome, "install");
|
|
118
|
+
expect(handle.info.pid).toBe(process.pid);
|
|
119
|
+
|
|
120
|
+
releaseLock(handle);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("recovers from an empty lock file", () => {
|
|
124
|
+
writeFileSync(lockPath(opHome), "");
|
|
125
|
+
|
|
126
|
+
const handle = acquireLock(opHome, "install");
|
|
127
|
+
expect(handle.info.pid).toBe(process.pid);
|
|
128
|
+
|
|
129
|
+
releaseLock(handle);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("recovers from a lock file with missing fields", () => {
|
|
133
|
+
writeFileSync(lockPath(opHome), JSON.stringify({ pid: 1 }));
|
|
134
|
+
|
|
135
|
+
const handle = acquireLock(opHome, "install");
|
|
136
|
+
expect(handle.info.pid).toBe(process.pid);
|
|
137
|
+
|
|
138
|
+
releaseLock(handle);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── Release ──────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
describe("releaseLock", () => {
|
|
145
|
+
it("removes the lock file", () => {
|
|
146
|
+
const handle = acquireLock(opHome, "install");
|
|
147
|
+
expect(existsSync(handle.path)).toBe(true);
|
|
148
|
+
|
|
149
|
+
releaseLock(handle);
|
|
150
|
+
expect(existsSync(handle.path)).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("is idempotent — second release is a no-op", () => {
|
|
154
|
+
const handle = acquireLock(opHome, "install");
|
|
155
|
+
releaseLock(handle);
|
|
156
|
+
expect(existsSync(handle.path)).toBe(false);
|
|
157
|
+
|
|
158
|
+
// Second release should not throw
|
|
159
|
+
releaseLock(handle);
|
|
160
|
+
expect(existsSync(handle.path)).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("does not remove lock owned by a different PID", () => {
|
|
164
|
+
// Simulate a lock file owned by someone else
|
|
165
|
+
const otherInfo: LockInfo = {
|
|
166
|
+
pid: 99999999,
|
|
167
|
+
operation: "other",
|
|
168
|
+
acquiredAt: new Date().toISOString(),
|
|
169
|
+
};
|
|
170
|
+
writeFileSync(lockPath(opHome), JSON.stringify(otherInfo) + "\n");
|
|
171
|
+
|
|
172
|
+
// Create a handle that claims to own the lock
|
|
173
|
+
const fakeHandle: LockHandle = {
|
|
174
|
+
path: lockPath(opHome),
|
|
175
|
+
info: {
|
|
176
|
+
pid: process.pid, // Different from file content
|
|
177
|
+
operation: "mine",
|
|
178
|
+
acquiredAt: new Date().toISOString(),
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
releaseLock(fakeHandle);
|
|
183
|
+
// Lock file should still exist because PID doesn't match
|
|
184
|
+
expect(existsSync(lockPath(opHome))).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── lockPath ─────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
describe("lockPath", () => {
|
|
191
|
+
it("returns the correct path", () => {
|
|
192
|
+
expect(lockPath("/home/user/.openpalm")).toBe("/home/user/.openpalm/data/.openpalm.lock");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator lock — prevents concurrent mutating operations.
|
|
3
|
+
*
|
|
4
|
+
* Uses O_CREAT | O_EXCL for atomic exclusive file creation.
|
|
5
|
+
* Lock file lives at {dataDir}/.openpalm.lock containing JSON
|
|
6
|
+
* with { pid, operation, acquiredAt }.
|
|
7
|
+
*
|
|
8
|
+
* Uses node:fs (not Bun) since lib must be Node-compatible for SvelteKit admin.
|
|
9
|
+
*/
|
|
10
|
+
import { openSync, writeSync, closeSync, readFileSync, unlinkSync, mkdirSync, constants } from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
|
|
13
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export type LockInfo = {
|
|
16
|
+
pid: number;
|
|
17
|
+
operation: string;
|
|
18
|
+
acquiredAt: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type LockHandle = {
|
|
22
|
+
path: string;
|
|
23
|
+
info: LockInfo;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ── Error ────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export class LockAcquisitionError extends Error {
|
|
29
|
+
public readonly holder: LockInfo;
|
|
30
|
+
|
|
31
|
+
constructor(holder: LockInfo) {
|
|
32
|
+
super(
|
|
33
|
+
`Cannot acquire lock: already held by PID ${holder.pid} ` +
|
|
34
|
+
`for "${holder.operation}" since ${holder.acquiredAt}`
|
|
35
|
+
);
|
|
36
|
+
this.name = "LockAcquisitionError";
|
|
37
|
+
this.holder = holder;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Path ─────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export function lockPath(opHome: string): string {
|
|
44
|
+
return `${opHome}/data/.openpalm.lock`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Stale PID Detection ──────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
function isProcessAlive(pid: number): boolean {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(pid, 0);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Read existing lock info ──────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function readLockInfo(path: string): LockInfo | null {
|
|
61
|
+
try {
|
|
62
|
+
const content = readFileSync(path, "utf-8");
|
|
63
|
+
const parsed = JSON.parse(content);
|
|
64
|
+
if (
|
|
65
|
+
typeof parsed.pid === "number" &&
|
|
66
|
+
typeof parsed.operation === "string" &&
|
|
67
|
+
typeof parsed.acquiredAt === "string"
|
|
68
|
+
) {
|
|
69
|
+
return parsed as LockInfo;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Acquire / Release ────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
export function acquireLock(opHome: string, operation: string): LockHandle {
|
|
80
|
+
const path = lockPath(opHome);
|
|
81
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
82
|
+
const info: LockInfo = {
|
|
83
|
+
pid: process.pid,
|
|
84
|
+
operation,
|
|
85
|
+
acquiredAt: new Date().toISOString(),
|
|
86
|
+
};
|
|
87
|
+
const content = JSON.stringify(info) + "\n";
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// Atomic exclusive create — fails if file already exists
|
|
91
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
|
|
92
|
+
try {
|
|
93
|
+
writeSync(fd, content);
|
|
94
|
+
} finally {
|
|
95
|
+
closeSync(fd);
|
|
96
|
+
}
|
|
97
|
+
return { path, info };
|
|
98
|
+
} catch (err: unknown) {
|
|
99
|
+
// File already exists — check if it's stale
|
|
100
|
+
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
|
101
|
+
const existing = readLockInfo(path);
|
|
102
|
+
|
|
103
|
+
if (existing && !isProcessAlive(existing.pid)) {
|
|
104
|
+
// Stale lock — remove and retry once
|
|
105
|
+
try {
|
|
106
|
+
unlinkSync(path);
|
|
107
|
+
} catch {
|
|
108
|
+
// Race: another process already removed it; fall through to retry
|
|
109
|
+
}
|
|
110
|
+
// Retry acquisition
|
|
111
|
+
try {
|
|
112
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
|
|
113
|
+
try {
|
|
114
|
+
writeSync(fd, content);
|
|
115
|
+
} finally {
|
|
116
|
+
closeSync(fd);
|
|
117
|
+
}
|
|
118
|
+
return { path, info };
|
|
119
|
+
} catch (retryErr: unknown) {
|
|
120
|
+
if ((retryErr as NodeJS.ErrnoException).code === "EEXIST") {
|
|
121
|
+
// Another process won the race — read the new holder
|
|
122
|
+
const newHolder = readLockInfo(path);
|
|
123
|
+
throw new LockAcquisitionError(
|
|
124
|
+
newHolder ?? { pid: 0, operation: "unknown", acquiredAt: "unknown" }
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
throw retryErr;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Lock is held by a live process (or corrupt file — treat as held)
|
|
132
|
+
if (existing) {
|
|
133
|
+
throw new LockAcquisitionError(existing);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Corrupt lock file — remove and retry
|
|
137
|
+
try {
|
|
138
|
+
unlinkSync(path);
|
|
139
|
+
} catch {
|
|
140
|
+
// ignore
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
|
|
144
|
+
try {
|
|
145
|
+
writeSync(fd, content);
|
|
146
|
+
} finally {
|
|
147
|
+
closeSync(fd);
|
|
148
|
+
}
|
|
149
|
+
return { path, info };
|
|
150
|
+
} catch (retryErr: unknown) {
|
|
151
|
+
if ((retryErr as NodeJS.ErrnoException).code === "EEXIST") {
|
|
152
|
+
const newHolder = readLockInfo(path);
|
|
153
|
+
throw new LockAcquisitionError(
|
|
154
|
+
newHolder ?? { pid: 0, operation: "unknown", acquiredAt: "unknown" }
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
throw retryErr;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function releaseLock(handle: LockHandle): void {
|
|
166
|
+
// Verify ownership before deleting — only remove if we still own it
|
|
167
|
+
const existing = readLockInfo(handle.path);
|
|
168
|
+
if (!existing) return; // Already gone — idempotent
|
|
169
|
+
if (existing.pid !== handle.info.pid) return; // Not ours — don't touch
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
unlinkSync(handle.path);
|
|
173
|
+
} catch {
|
|
174
|
+
// Already removed — idempotent
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -1,18 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Memory LLM & Embedding configuration management.
|
|
3
|
-
*/
|
|
1
|
+
/** Memory LLM & Embedding configuration management. */
|
|
4
2
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
LLM_PROVIDERS,
|
|
8
|
-
EMBEDDING_DIMS,
|
|
9
|
-
PROVIDER_DEFAULT_URLS,
|
|
10
|
-
} from "../provider-constants.js";
|
|
3
|
+
import { readStackEnv } from "./secrets.js";
|
|
4
|
+
import { EMBEDDING_DIMS, PROVIDER_DEFAULT_URLS } from "../provider-constants.js";
|
|
11
5
|
|
|
12
|
-
// Re-export shared constants for barrel compatibility
|
|
13
|
-
export { LLM_PROVIDERS, EMBEDDING_DIMS, PROVIDER_DEFAULT_URLS };
|
|
14
|
-
|
|
15
|
-
// ── Types ────────────────────────────────────────────────────────────────
|
|
16
6
|
|
|
17
7
|
export type MemoryConfig = {
|
|
18
8
|
mem0: {
|
|
@@ -31,7 +21,6 @@ export type MemoryConfig = {
|
|
|
31
21
|
memory: { custom_instructions: string };
|
|
32
22
|
};
|
|
33
23
|
|
|
34
|
-
// ── Constants (module-specific) ─────────────────────────────────────────
|
|
35
24
|
|
|
36
25
|
export const EMBED_PROVIDERS = [
|
|
37
26
|
"openai", "ollama", "huggingface", "lmstudio"
|
|
@@ -48,7 +37,6 @@ const ANTHROPIC_MODELS = [
|
|
|
48
37
|
"claude-3-5-haiku-20241022",
|
|
49
38
|
];
|
|
50
39
|
|
|
51
|
-
// ── API Key Resolution ──────────────────────────────────────────────────
|
|
52
40
|
|
|
53
41
|
export function resolveApiKey(apiKeyRef: string, configDir: string): string {
|
|
54
42
|
if (!apiKeyRef) return "";
|
|
@@ -57,11 +45,10 @@ export function resolveApiKey(apiKeyRef: string, configDir: string): string {
|
|
|
57
45
|
const varName = apiKeyRef.slice(4);
|
|
58
46
|
if (process.env[varName]) return process.env[varName]!;
|
|
59
47
|
|
|
60
|
-
const secrets =
|
|
48
|
+
const secrets = readStackEnv(configDir);
|
|
61
49
|
return secrets[varName] ?? "";
|
|
62
50
|
}
|
|
63
51
|
|
|
64
|
-
// ── Provider Model Listing ──────────────────────────────────────────────
|
|
65
52
|
|
|
66
53
|
export type ModelDiscoveryReason =
|
|
67
54
|
| 'none'
|
|
@@ -78,37 +65,15 @@ export type ProviderModelsResult = {
|
|
|
78
65
|
error?: string;
|
|
79
66
|
};
|
|
80
67
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
default: return `HTTP ${status}`;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async function extractProviderErrorDetail(res: Response): Promise<string> {
|
|
95
|
-
try {
|
|
96
|
-
const text = await res.text();
|
|
97
|
-
const json = JSON.parse(text) as Record<string, unknown>;
|
|
98
|
-
if (
|
|
99
|
-
typeof json.error === 'object' && json.error !== null &&
|
|
100
|
-
typeof (json.error as Record<string, unknown>).message === 'string'
|
|
101
|
-
) {
|
|
102
|
-
return (json.error as Record<string, unknown>).message as string;
|
|
103
|
-
}
|
|
104
|
-
if (typeof json.error === 'string') return json.error;
|
|
105
|
-
if (typeof json.message === 'string') return json.message;
|
|
106
|
-
if (typeof json.detail === 'string') return json.detail;
|
|
107
|
-
return '';
|
|
108
|
-
} catch {
|
|
109
|
-
return '';
|
|
110
|
-
}
|
|
111
|
-
}
|
|
68
|
+
const HTTP_STATUS_LABELS: Record<number, string> = {
|
|
69
|
+
401: 'Invalid or missing API key',
|
|
70
|
+
403: 'Access denied — check API key permissions',
|
|
71
|
+
404: 'Endpoint not found — verify the base URL',
|
|
72
|
+
429: 'Rate limited — try again shortly',
|
|
73
|
+
500: 'Provider internal error',
|
|
74
|
+
502: 'Provider returned a bad gateway error',
|
|
75
|
+
503: 'Provider is temporarily unavailable',
|
|
76
|
+
};
|
|
112
77
|
|
|
113
78
|
export async function fetchProviderModels(
|
|
114
79
|
provider: string,
|
|
@@ -132,7 +97,7 @@ export async function fetchProviderModels(
|
|
|
132
97
|
models: [],
|
|
133
98
|
status: 'recoverable_error',
|
|
134
99
|
reason: 'provider_http',
|
|
135
|
-
error: `Ollama API returned ${res.status}: ${
|
|
100
|
+
error: `Ollama API returned ${res.status}: ${(HTTP_STATUS_LABELS[res.status] ?? `HTTP ${res.status}`)}`,
|
|
136
101
|
};
|
|
137
102
|
}
|
|
138
103
|
const data = (await res.json()) as { models?: { name: string }[] };
|
|
@@ -158,14 +123,22 @@ export async function fetchProviderModels(
|
|
|
158
123
|
|
|
159
124
|
const res = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
|
|
160
125
|
if (!res.ok) {
|
|
161
|
-
|
|
126
|
+
let detail = '';
|
|
127
|
+
try {
|
|
128
|
+
const json = JSON.parse(await res.text()) as Record<string, unknown>;
|
|
129
|
+
const errObj = json.error as Record<string, unknown> | string | undefined;
|
|
130
|
+
detail = (typeof errObj === 'object' && errObj !== null && typeof errObj.message === 'string') ? errObj.message
|
|
131
|
+
: typeof errObj === 'string' ? errObj
|
|
132
|
+
: typeof json.message === 'string' ? json.message
|
|
133
|
+
: typeof json.detail === 'string' ? json.detail : '';
|
|
134
|
+
} catch { /* ignore parse errors */ }
|
|
162
135
|
return {
|
|
163
136
|
models: [],
|
|
164
137
|
status: 'recoverable_error',
|
|
165
138
|
reason: 'provider_http',
|
|
166
139
|
error: detail
|
|
167
140
|
? `Provider API returned ${res.status}: ${detail}`
|
|
168
|
-
: `Provider API returned ${res.status}: ${
|
|
141
|
+
: `Provider API returned ${res.status}: ${(HTTP_STATUS_LABELS[res.status] ?? `HTTP ${res.status}`)}`,
|
|
169
142
|
};
|
|
170
143
|
}
|
|
171
144
|
const data = (await res.json()) as { data?: { id: string }[] };
|
|
@@ -185,7 +158,6 @@ export async function fetchProviderModels(
|
|
|
185
158
|
}
|
|
186
159
|
}
|
|
187
160
|
|
|
188
|
-
// ── Default Config ───────────────────────────────────────────────────────
|
|
189
161
|
|
|
190
162
|
export function getDefaultConfig(): MemoryConfig {
|
|
191
163
|
return {
|
|
@@ -219,66 +191,27 @@ export function getDefaultConfig(): MemoryConfig {
|
|
|
219
191
|
};
|
|
220
192
|
}
|
|
221
193
|
|
|
222
|
-
// ── File I/O ─────────────────────────────────────────────────────────────
|
|
223
|
-
|
|
224
|
-
const CONFIG_FILENAME = "memory/default_config.json";
|
|
225
|
-
|
|
226
|
-
function configPath(dataDir: string): string {
|
|
227
|
-
return `${dataDir}/${CONFIG_FILENAME}`;
|
|
228
|
-
}
|
|
229
194
|
|
|
230
195
|
export function readMemoryConfig(dataDir: string): MemoryConfig {
|
|
231
|
-
const path =
|
|
196
|
+
const path = `${dataDir}/memory/default_config.json`;
|
|
232
197
|
if (!existsSync(path)) return getDefaultConfig();
|
|
233
198
|
try {
|
|
234
|
-
|
|
235
|
-
return JSON.parse(raw) as MemoryConfig;
|
|
199
|
+
return JSON.parse(readFileSync(path, "utf-8")) as MemoryConfig;
|
|
236
200
|
} catch {
|
|
237
201
|
return getDefaultConfig();
|
|
238
202
|
}
|
|
239
203
|
}
|
|
240
204
|
|
|
241
|
-
export function writeMemoryConfig(
|
|
242
|
-
dataDir: string,
|
|
243
|
-
config: MemoryConfig
|
|
244
|
-
): void {
|
|
245
|
-
const path = configPath(dataDir);
|
|
205
|
+
export function writeMemoryConfig(dataDir: string, config: MemoryConfig): void {
|
|
246
206
|
mkdirSync(`${dataDir}/memory`, { recursive: true });
|
|
247
|
-
writeFileSync(
|
|
207
|
+
writeFileSync(`${dataDir}/memory/default_config.json`, JSON.stringify(config, null, 2) + "\n");
|
|
248
208
|
}
|
|
249
209
|
|
|
250
210
|
export function ensureMemoryConfig(dataDir: string): void {
|
|
251
|
-
|
|
252
|
-
if (existsSync(path)) return;
|
|
211
|
+
if (existsSync(`${dataDir}/memory/default_config.json`)) return;
|
|
253
212
|
writeMemoryConfig(dataDir, getDefaultConfig());
|
|
254
213
|
}
|
|
255
214
|
|
|
256
|
-
// ── Config Resolution ────────────────────────────────────────────────
|
|
257
|
-
|
|
258
|
-
export function resolveConfigForPush(
|
|
259
|
-
config: MemoryConfig,
|
|
260
|
-
configDir: string
|
|
261
|
-
): MemoryConfig {
|
|
262
|
-
const resolved = structuredClone(config);
|
|
263
|
-
|
|
264
|
-
if (typeof resolved.mem0.llm.config.api_key === "string") {
|
|
265
|
-
resolved.mem0.llm.config.api_key = resolveApiKey(
|
|
266
|
-
resolved.mem0.llm.config.api_key as string,
|
|
267
|
-
configDir
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (typeof resolved.mem0.embedder.config.api_key === "string") {
|
|
272
|
-
resolved.mem0.embedder.config.api_key = resolveApiKey(
|
|
273
|
-
resolved.mem0.embedder.config.api_key as string,
|
|
274
|
-
configDir
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
return resolved;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// ── Dimension Checking ──────────────────────────────────────────────
|
|
282
215
|
|
|
283
216
|
export type VectorDimensionResult = {
|
|
284
217
|
match: boolean;
|
|
@@ -286,9 +219,6 @@ export type VectorDimensionResult = {
|
|
|
286
219
|
expectedDims: number;
|
|
287
220
|
};
|
|
288
221
|
|
|
289
|
-
/** @deprecated Use checkVectorDimensions instead */
|
|
290
|
-
export type QdrantDimensionResult = VectorDimensionResult;
|
|
291
|
-
|
|
292
222
|
export function checkVectorDimensions(
|
|
293
223
|
dataDir: string,
|
|
294
224
|
newConfig: MemoryConfig
|
|
@@ -299,9 +229,6 @@ export function checkVectorDimensions(
|
|
|
299
229
|
return { match: currentDims === expectedDims, currentDims, expectedDims };
|
|
300
230
|
}
|
|
301
231
|
|
|
302
|
-
/** @deprecated Use checkVectorDimensions instead */
|
|
303
|
-
export const checkQdrantDimensions = checkVectorDimensions;
|
|
304
|
-
|
|
305
232
|
export function resetVectorStore(
|
|
306
233
|
dataDir: string
|
|
307
234
|
): { ok: boolean; error?: string } {
|
|
@@ -334,79 +261,26 @@ export function resetVectorStore(
|
|
|
334
261
|
}
|
|
335
262
|
}
|
|
336
263
|
|
|
337
|
-
/** @deprecated Use resetVectorStore instead */
|
|
338
|
-
export const resetQdrantCollection = resetVectorStore;
|
|
339
|
-
|
|
340
|
-
// ── Runtime API ──────────────────────────────────────────────────────────
|
|
341
264
|
|
|
342
|
-
function
|
|
343
|
-
const configured =
|
|
344
|
-
|
|
345
|
-
process.env.OPENPALM_MEMORY_API_URL?.trim();
|
|
346
|
-
|
|
347
|
-
const bases = configured
|
|
348
|
-
? [configured]
|
|
349
|
-
: ["http://memory:8765", "http://127.0.0.1:8765"];
|
|
350
|
-
|
|
351
|
-
return Array.from(new Set(bases.map((base) => base.replace(/\/+$/, ""))));
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function getMemoryAuthHeaders(): Record<string, string> {
|
|
265
|
+
async function callMemoryApi(path: string, init?: RequestInit): Promise<Response> {
|
|
266
|
+
const configured = process.env.MEMORY_API_URL?.trim() || process.env.OP_MEMORY_API_URL?.trim();
|
|
267
|
+
const bases = configured ? [configured.replace(/\/+$/, "")] : ["http://memory:8765", "http://127.0.0.1:8765"];
|
|
355
268
|
const token = process.env.MEMORY_AUTH_TOKEN?.trim();
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
async function callMemoryApi(
|
|
360
|
-
path: string,
|
|
361
|
-
init?: RequestInit,
|
|
362
|
-
): Promise<Response> {
|
|
363
|
-
const bases = getMemoryApiBases();
|
|
364
|
-
const authHeaders = getMemoryAuthHeaders();
|
|
269
|
+
const authHeaders: Record<string, string> = token ? { authorization: `Bearer ${token}` } : {};
|
|
365
270
|
let lastError: unknown;
|
|
366
271
|
|
|
367
272
|
for (let i = 0; i < bases.length; i++) {
|
|
368
|
-
const url = `${bases[i]}${path}`;
|
|
369
273
|
try {
|
|
370
274
|
const headers = { ...authHeaders, ...(init?.headers as Record<string, string>) };
|
|
371
|
-
return await fetch(
|
|
275
|
+
return await fetch(`${bases[i]}${path}`, { ...init, headers });
|
|
372
276
|
} catch (err) {
|
|
373
277
|
lastError = err;
|
|
374
278
|
if (i === bases.length - 1) throw err;
|
|
375
279
|
}
|
|
376
280
|
}
|
|
377
|
-
|
|
378
281
|
throw lastError ?? new Error("Memory API request failed");
|
|
379
282
|
}
|
|
380
283
|
|
|
381
|
-
export async function pushConfigToMemory(
|
|
382
|
-
config: MemoryConfig
|
|
383
|
-
): Promise<{ ok: boolean; error?: string }> {
|
|
384
|
-
try {
|
|
385
|
-
const res = await callMemoryApi("/api/v1/config/", {
|
|
386
|
-
method: "PUT",
|
|
387
|
-
headers: { "content-type": "application/json" },
|
|
388
|
-
body: JSON.stringify(config),
|
|
389
|
-
});
|
|
390
|
-
if (!res.ok) {
|
|
391
|
-
const text = await res.text().catch(() => "");
|
|
392
|
-
return { ok: false, error: `HTTP ${res.status}: ${text}` };
|
|
393
|
-
}
|
|
394
|
-
return { ok: true };
|
|
395
|
-
} catch (err) {
|
|
396
|
-
return { ok: false, error: String(err) };
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
export async function fetchConfigFromMemory(): Promise<MemoryConfig | null> {
|
|
401
|
-
try {
|
|
402
|
-
const res = await callMemoryApi("/api/v1/config/");
|
|
403
|
-
if (!res.ok) return null;
|
|
404
|
-
return (await res.json()) as MemoryConfig;
|
|
405
|
-
} catch {
|
|
406
|
-
return null;
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
284
|
export async function provisionMemoryUser(
|
|
411
285
|
userId: string,
|
|
412
286
|
): Promise<{ ok: boolean; error?: string }> {
|