@openpalm/lib 0.10.2 → 0.11.0-beta.10

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.
Files changed (63) hide show
  1. package/README.md +4 -2
  2. package/package.json +11 -3
  3. package/src/control-plane/akm-vault.test.ts +105 -0
  4. package/src/control-plane/akm-vault.ts +311 -0
  5. package/src/control-plane/channels.ts +11 -9
  6. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  7. package/src/control-plane/compose-args.test.ts +25 -33
  8. package/src/control-plane/compose-args.ts +0 -4
  9. package/src/control-plane/compose-errors.test.ts +106 -0
  10. package/src/control-plane/compose-errors.ts +117 -0
  11. package/src/control-plane/config-persistence.ts +148 -73
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +111 -58
  14. package/src/control-plane/docker.ts +70 -25
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +84 -1
  17. package/src/control-plane/home.ts +66 -69
  18. package/src/control-plane/host-opencode.test.ts +260 -0
  19. package/src/control-plane/host-opencode.ts +229 -0
  20. package/src/control-plane/install-edge-cases.test.ts +190 -292
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +65 -75
  23. package/src/control-plane/markdown-task.ts +200 -0
  24. package/src/control-plane/migrate-0110.test.ts +177 -0
  25. package/src/control-plane/migrate-0110.ts +99 -0
  26. package/src/control-plane/operator-ids.test.ts +130 -0
  27. package/src/control-plane/operator-ids.ts +89 -0
  28. package/src/control-plane/paths.ts +80 -0
  29. package/src/control-plane/provider-models.ts +154 -0
  30. package/src/control-plane/registry-components.test.ts +105 -27
  31. package/src/control-plane/registry.test.ts +247 -51
  32. package/src/control-plane/registry.ts +404 -54
  33. package/src/control-plane/rollback.ts +17 -16
  34. package/src/control-plane/scheduler.ts +75 -262
  35. package/src/control-plane/secret-mappings.ts +4 -8
  36. package/src/control-plane/secrets.ts +97 -55
  37. package/src/control-plane/setup-config.schema.json +5 -17
  38. package/src/control-plane/setup-status.ts +9 -29
  39. package/src/control-plane/setup-validation.ts +23 -23
  40. package/src/control-plane/setup.test.ts +143 -244
  41. package/src/control-plane/setup.ts +216 -133
  42. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  43. package/src/control-plane/spec-to-env.test.ts +75 -60
  44. package/src/control-plane/spec-to-env.ts +68 -153
  45. package/src/control-plane/stack-spec.test.ts +22 -84
  46. package/src/control-plane/stack-spec.ts +9 -89
  47. package/src/control-plane/types.ts +9 -29
  48. package/src/control-plane/ui-assets.ts +385 -0
  49. package/src/control-plane/validate.ts +44 -79
  50. package/src/index.ts +102 -56
  51. package/src/logger.test.ts +228 -0
  52. package/src/logger.ts +71 -1
  53. package/src/provider-constants.ts +22 -1
  54. package/src/control-plane/audit.ts +0 -40
  55. package/src/control-plane/env-schema-validation.test.ts +0 -118
  56. package/src/control-plane/lock.test.ts +0 -194
  57. package/src/control-plane/lock.ts +0 -176
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/provider-config.ts +0 -34
  60. package/src/control-plane/redact-schema.ts +0 -50
  61. package/src/control-plane/secret-backend.test.ts +0 -359
  62. package/src/control-plane/secret-backend.ts +0 -322
  63. package/src/control-plane/spec-validator.ts +0 -159
package/src/logger.ts CHANGED
@@ -1,8 +1,78 @@
1
1
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
2
 
3
+ /**
4
+ * In-house redactor. Returns `'***REDACTED***'` when `key` names something
5
+ * that looks like a secret (token, key, secret, password, hmac). Replaces
6
+ * the value-masking that varlock used to do for log output.
7
+ *
8
+ * The pattern matches the bare word at the start or end of the key, using
9
+ * underscore as a word boundary. This avoids substring false positives
10
+ * like `MONKEY` (contains `_KEY`? no, but the un-anchored pattern used
11
+ * to match the substring `KEY` even without an underscore) and
12
+ * `PACKET_SIZE` (does not actually contain `_KEY`, but the regex engine
13
+ * with un-anchored alternations was sloppy enough to invite future bugs).
14
+ *
15
+ * Examples:
16
+ * OP_UI_LOGIN_PASSWORD → sensitive (suffix _PASSWORD)
17
+ * CHANNEL_API_KEY → sensitive (suffix _KEY)
18
+ * CHANNEL_FOO_HMAC → sensitive (suffix _HMAC)
19
+ * HMAC_KEY → sensitive (prefix HMAC_, suffix _KEY)
20
+ * TOKEN → sensitive (bare word)
21
+ * MONKEY → NOT sensitive
22
+ * PACKET_SIZE → NOT sensitive
23
+ *
24
+ * The same predicate is exported as {@link isSensitiveEnvKey} so callers
25
+ * that need to mask only part of a larger payload can short-circuit.
26
+ */
27
+ const REDACT_PATTERN = /(?:^|_)(?:TOKEN|SECRET|KEY|PASSWORD|HMAC)(?:_|$)/i;
28
+
29
+ export function isSensitiveEnvKey(key: string): boolean {
30
+ return REDACT_PATTERN.test(key);
31
+ }
32
+
33
+ export function redactValue(key: string, value: string): string {
34
+ return isSensitiveEnvKey(key) ? '***REDACTED***' : value;
35
+ }
36
+
37
+ /**
38
+ * Recursively walk a structured `extra` payload and mask every value whose
39
+ * own key (or the nearest enclosing object key) matches the sensitivity
40
+ * pattern. The original object is not mutated. Sensitive values of any
41
+ * primitive type (string, number, boolean) are replaced wholesale; nested
42
+ * objects under a sensitive key are still walked so that callers can mix
43
+ * structured payloads with redacted leaves.
44
+ */
45
+ export function redactExtra<T>(extra: T): T {
46
+ if (extra == null || typeof extra !== 'object') return extra;
47
+ if (Array.isArray(extra)) {
48
+ return extra.map((v) => (v && typeof v === 'object' ? redactExtra(v) : v)) as unknown as T;
49
+ }
50
+ const out: Record<string, unknown> = {};
51
+ for (const [k, v] of Object.entries(extra as Record<string, unknown>)) {
52
+ if (isSensitiveEnvKey(k)) {
53
+ // Redact any non-null primitive (string/number/boolean) under a
54
+ // sensitive key. Nested objects keep being walked so a structured
55
+ // payload like { credentials: { ... } } still gets per-field masking.
56
+ if (v && typeof v === 'object') {
57
+ out[k] = redactExtra(v);
58
+ } else if (v == null) {
59
+ out[k] = v;
60
+ } else {
61
+ out[k] = '***REDACTED***';
62
+ }
63
+ } else if (v && typeof v === 'object') {
64
+ out[k] = redactExtra(v);
65
+ } else {
66
+ out[k] = v;
67
+ }
68
+ }
69
+ return out as T;
70
+ }
71
+
3
72
  export function createLogger(service: string) {
4
73
  function log(level: LogLevel, msg: string, extra?: Record<string, unknown>): void {
5
- const entry = { ts: new Date().toISOString(), level, service, msg, ...(extra ? { extra } : {}) };
74
+ const safeExtra = extra ? redactExtra(extra) : undefined;
75
+ const entry = { ts: new Date().toISOString(), level, service, msg, ...(safeExtra ? { extra: safeExtra } : {}) };
6
76
  (level === 'error' || level === 'warn' ? console.error : console.log)(JSON.stringify(entry));
7
77
  }
8
78
  return {
@@ -40,19 +40,40 @@ export const PROVIDER_KEY_MAP: Record<string, string> = {
40
40
  huggingface: "HF_TOKEN",
41
41
  };
42
42
 
43
- /** Known embedding model dimensions (cloud providers). */
43
+ /** Known embedding model dimensions. Keyed by `provider/model`. */
44
44
  export const EMBEDDING_DIMS: Record<string, number> = {
45
45
  "openai/text-embedding-3-small": 1536,
46
46
  "openai/text-embedding-3-large": 3072,
47
47
  "openai/text-embedding-ada-002": 1536,
48
48
  "ollama/nomic-embed-text": 768,
49
49
  "ollama/mxbai-embed-large": 1024,
50
+ "ollama/mxbai-embed-large-v1": 1024,
50
51
  "ollama/all-minilm": 384,
51
52
  "ollama/snowflake-arctic-embed": 1024,
53
+ "model-runner/ai/mxbai-embed-large-v1": 1024,
54
+ "mistral/mistral-embed": 1024,
52
55
  "google/text-embedding-004": 768,
53
56
  "huggingface/sentence-transformers/all-MiniLM-L6-v2": 384,
57
+ "huggingface/intfloat/multilingual-e5-large": 1024,
54
58
  };
55
59
 
60
+ /**
61
+ * Look up embedding model dimensions. Tries the full key first, then strips
62
+ * any trailing `:tag` from the model name (Ollama-style versions).
63
+ * Returns 0 when no match is found.
64
+ */
65
+ export function lookupEmbeddingDims(provider: string, model: string): number {
66
+ if (!provider || !model) return 0;
67
+ const key = `${provider}/${model}`;
68
+ if (EMBEDDING_DIMS[key]) return EMBEDDING_DIMS[key];
69
+ const colon = model.lastIndexOf(":");
70
+ if (colon > 0) {
71
+ const bare = `${provider}/${model.slice(0, colon)}`;
72
+ if (EMBEDDING_DIMS[bare]) return EMBEDDING_DIMS[bare];
73
+ }
74
+ return 0;
75
+ }
76
+
56
77
  /** Provider display labels for UI. */
57
78
  export const PROVIDER_LABELS: Record<string, string> = {
58
79
  openai: "OpenAI",
@@ -1,40 +0,0 @@
1
- /**
2
- * Audit logging for the OpenPalm control plane.
3
- */
4
- import { mkdirSync, appendFileSync } from "node:fs";
5
- import type { ControlPlaneState, AuditEntry, CallerType } from "./types.js";
6
-
7
- const MAX_AUDIT_MEMORY = 1000;
8
-
9
- export function appendAudit(
10
- state: ControlPlaneState,
11
- actor: string,
12
- action: string,
13
- args: Record<string, unknown>,
14
- ok: boolean,
15
- requestId = "",
16
- callerType: CallerType = "unknown"
17
- ): void {
18
- const entry: AuditEntry = {
19
- at: new Date().toISOString(),
20
- requestId,
21
- actor,
22
- callerType,
23
- action,
24
- args,
25
- ok
26
- };
27
- state.audit.push(entry);
28
- if (state.audit.length > MAX_AUDIT_MEMORY) {
29
- state.audit = state.audit.slice(-MAX_AUDIT_MEMORY);
30
- }
31
- try {
32
- mkdirSync(state.logsDir, { recursive: true });
33
- appendFileSync(
34
- `${state.logsDir}/admin-audit.jsonl`,
35
- JSON.stringify(entry) + "\n"
36
- );
37
- } catch {
38
- // best-effort persistence
39
- }
40
- }
@@ -1,118 +0,0 @@
1
- /**
2
- * Test that env schema validation uses the correct nested vault paths.
3
- */
4
- import { describe, test, expect, beforeAll, afterAll } from "bun:test";
5
- import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
6
- import { join } from "node:path";
7
- import { tmpdir } from "node:os";
8
- import type { ControlPlaneState } from "./types.js";
9
-
10
- describe("env schema validation paths", () => {
11
- let tmpDir: string;
12
- let state: ControlPlaneState;
13
-
14
- beforeAll(() => {
15
- tmpDir = join(tmpdir(), `openpalm-schema-test-${Date.now()}`);
16
- mkdirSync(join(tmpDir, "vault/user"), { recursive: true });
17
- mkdirSync(join(tmpDir, "vault/stack"), { recursive: true });
18
- mkdirSync(join(tmpDir, "data"), { recursive: true });
19
- mkdirSync(join(tmpDir, "logs"), { recursive: true });
20
- mkdirSync(join(tmpDir, "config"), { recursive: true });
21
-
22
- state = {
23
- adminToken: "test-token",
24
- assistantToken: "test-assistant",
25
- setupToken: "test-setup",
26
- homeDir: tmpDir,
27
- configDir: join(tmpDir, "config"),
28
- vaultDir: join(tmpDir, "vault"),
29
- dataDir: join(tmpDir, "data"),
30
- logsDir: join(tmpDir, "logs"),
31
- cacheDir: join(tmpDir, "cache"),
32
- services: {},
33
- artifacts: { compose: "" },
34
- artifactMeta: [],
35
- audit: [],
36
- };
37
- });
38
-
39
- afterAll(() => {
40
- if (tmpDir && existsSync(tmpDir)) {
41
- rmSync(tmpDir, { recursive: true, force: true });
42
- }
43
- });
44
-
45
- test("validation succeeds when no schema files exist (skip mode)", async () => {
46
- const { validateProposedState } = await import("./validate.js");
47
- const result = await validateProposedState(state);
48
- // When schema files don't exist, validation is skipped (no errors)
49
- expect(result.ok).toBe(true);
50
- expect(result.errors).toEqual([]);
51
- });
52
-
53
- test("schema paths match canonical vault layout", () => {
54
- const expectedUserSchema = join(tmpDir, "vault/user/user.env.schema");
55
- const expectedStackSchema = join(tmpDir, "vault/stack/stack.env.schema");
56
-
57
- writeFileSync(expectedUserSchema, "# test schema\n");
58
- writeFileSync(expectedStackSchema, "# test schema\n");
59
-
60
- expect(existsSync(expectedUserSchema)).toBe(true);
61
- expect(existsSync(expectedStackSchema)).toBe(true);
62
-
63
- // Old flat paths must NOT exist
64
- expect(existsSync(join(tmpDir, "vault/user.env.schema"))).toBe(false);
65
- expect(existsSync(join(tmpDir, "vault/system.env.schema"))).toBe(false);
66
- });
67
-
68
- test("validate.ts reads from nested paths, not flat paths", async () => {
69
- // Write schemas at OLD flat paths — should be ignored
70
- writeFileSync(join(tmpDir, "vault/user.env.schema"), "OPENAI_API_KEY\n");
71
- writeFileSync(join(tmpDir, "vault/system.env.schema"), "OP_ADMIN_TOKEN\n");
72
- // Write env files
73
- writeFileSync(join(tmpDir, "vault/user/user.env"), "# empty\n");
74
- writeFileSync(join(tmpDir, "vault/stack/stack.env"), "# empty\n");
75
- // Delete nested schemas to prove flat paths are ignored
76
- try { rmSync(join(tmpDir, "vault/user/user.env.schema")); } catch { /* may not exist */ }
77
- try { rmSync(join(tmpDir, "vault/stack/stack.env.schema")); } catch { /* may not exist */ }
78
-
79
- const { validateProposedState } = await import("./validate.js");
80
- const result = await validateProposedState(state);
81
- // Should pass because nested schemas don't exist (skipped), not because flat schemas were read
82
- expect(result.ok).toBe(true);
83
- });
84
-
85
- test("validation reports warnings for missing required schema keys", async () => {
86
- // Seed a schema that requires OPENAI_API_KEY
87
- writeFileSync(join(tmpDir, "vault/user/user.env.schema"), "OPENAI_API_KEY=string\nOWNER_NAME=string\n");
88
- // Seed an env file that is missing those keys
89
- writeFileSync(join(tmpDir, "vault/user/user.env"), "# empty env\nSOME_OTHER_KEY=value\n");
90
-
91
- const { validateProposedState } = await import("./validate.js");
92
- const result = await validateProposedState(state);
93
- // The validator should report warnings for missing keys (not errors — env validation is advisory)
94
- expect(result.warnings.length).toBeGreaterThanOrEqual(0);
95
- });
96
-
97
- test("validation handles malformed env file gracefully", async () => {
98
- writeFileSync(join(tmpDir, "vault/user/user.env.schema"), "OPENAI_API_KEY=string\n");
99
- // Malformed: no = sign, just random text
100
- writeFileSync(join(tmpDir, "vault/user/user.env"), "this is not a valid env file\n===\n");
101
-
102
- const { validateProposedState } = await import("./validate.js");
103
- const result = await validateProposedState(state);
104
- // Should not throw — graceful handling
105
- expect(typeof result.ok).toBe("boolean");
106
- });
107
-
108
- test("validation handles empty schema file gracefully", async () => {
109
- writeFileSync(join(tmpDir, "vault/user/user.env.schema"), "");
110
- writeFileSync(join(tmpDir, "vault/user/user.env"), "OPENAI_API_KEY=sk-test\n");
111
-
112
- const { validateProposedState } = await import("./validate.js");
113
- const result = await validateProposedState(state);
114
- // Empty schema may cause varlock to report an error — that's fine,
115
- // the important thing is it doesn't throw/crash
116
- expect(typeof result.ok).toBe("boolean");
117
- });
118
- });
@@ -1,194 +0,0 @@
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
- });
@@ -1,176 +0,0 @@
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
- }