pi-microsandbox 0.2.0 → 0.3.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.
@@ -5,317 +5,132 @@ import {
5
5
  type ManagedSandboxRecord,
6
6
  type PersistedSandboxState,
7
7
  type SandboxLabelInput,
8
- type StorageMode,
9
8
  type ValidatedManagedSandbox,
10
- type VolumeLabelInput,
11
- type VolumeRecord,
12
9
  } from "./types.ts";
13
10
 
14
- const STORAGE_MODES: readonly StorageMode[] = ["git", "direct", "none"];
15
-
16
11
  function isRecord(value: unknown): value is Record<string, unknown> {
17
12
  return typeof value === "object" && value !== null && !Array.isArray(value);
18
13
  }
19
-
20
- function isStorageMode(value: unknown): value is StorageMode {
21
- return typeof value === "string" && STORAGE_MODES.includes(value as StorageMode);
22
- }
23
-
24
14
  function nonEmptyString(value: unknown): value is string {
25
15
  return typeof value === "string" && value.length > 0;
26
16
  }
27
-
28
17
  function finiteNumber(value: unknown): value is number {
29
18
  return typeof value === "number" && Number.isFinite(value);
30
19
  }
31
20
 
32
- /**
33
- * SDK versions have returned labels both as maps and as entry/tuple arrays.
34
- * Keep that tolerance in this module so callers never need to know the SDK shape.
35
- */
21
+ /** SDK versions have returned labels both as maps and as entry/tuple arrays. */
36
22
  function normalizeLabels(value: unknown): Record<string, string> | null {
37
23
  const labels: Record<string, string> = {};
38
-
39
24
  if (isRecord(value)) {
40
- for (const [key, labelValue] of Object.entries(value)) {
41
- if (!key || labelValue === null || labelValue === undefined) continue;
42
- if (
43
- typeof labelValue === "string" ||
44
- typeof labelValue === "number" ||
45
- typeof labelValue === "boolean"
46
- ) {
47
- labels[key] = String(labelValue);
25
+ for (const [key, item] of Object.entries(value)) {
26
+ if (key && (typeof item === "string" || typeof item === "number" || typeof item === "boolean")) {
27
+ labels[key] = String(item);
48
28
  }
49
29
  }
50
30
  return labels;
51
31
  }
52
-
53
32
  if (Array.isArray(value)) {
54
33
  for (const entry of value) {
55
- let key: unknown;
56
- let labelValue: unknown;
57
-
58
- if (Array.isArray(entry)) {
59
- [key, labelValue] = entry;
60
- } else if (isRecord(entry)) {
61
- key = entry.key ?? entry.name;
62
- labelValue = entry.value;
34
+ const key = Array.isArray(entry) ? entry[0] : isRecord(entry) ? entry.key ?? entry.name : undefined;
35
+ const item = Array.isArray(entry) ? entry[1] : isRecord(entry) ? entry.value : undefined;
36
+ if (typeof key === "string" && key && (typeof item === "string" || typeof item === "number" || typeof item === "boolean")) {
37
+ labels[key] = String(item);
63
38
  }
64
-
65
- if (
66
- typeof key !== "string" ||
67
- key.length === 0 ||
68
- (typeof labelValue !== "string" &&
69
- typeof labelValue !== "number" &&
70
- typeof labelValue !== "boolean")
71
- ) {
72
- // A bad label entry must not make otherwise useful SDK output unusable.
73
- continue;
74
- }
75
- labels[key] = String(labelValue);
76
39
  }
77
40
  return labels;
78
41
  }
79
-
80
42
  return null;
81
43
  }
82
44
 
83
45
  function rawRecord(value: unknown): Record<string, unknown> | null {
84
46
  if (isRecord(value)) return value;
85
-
86
- // Some CLI/SDK adapters expose a record as [name, labels]. Accept this only
87
- // at the normalization boundary; all public functions return object records.
88
- if (
89
- Array.isArray(value) &&
90
- value.length >= 2 &&
91
- typeof value[0] === "string"
92
- ) {
47
+ if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "string") {
93
48
  return { name: value[0], labels: value[1] };
94
49
  }
95
-
96
50
  return null;
97
51
  }
98
52
 
99
53
  function labelsFromRaw(raw: Record<string, unknown>): Record<string, string> | null {
100
- const direct = raw.labels;
101
- if (direct !== undefined) return normalizeLabels(direct);
102
-
103
- const metadata = raw.metadata;
104
- if (isRecord(metadata) && metadata.labels !== undefined) {
105
- return normalizeLabels(metadata.labels);
106
- }
107
-
108
- return null;
109
- }
110
-
111
- function optionalString(
112
- target: Record<string, unknown>,
113
- key: string,
114
- value: unknown,
115
- ): void {
116
- if (typeof value === "string") target[key] = value;
117
- else delete target[key];
118
- }
119
-
120
- function optionalNumber(
121
- target: Record<string, unknown>,
122
- key: string,
123
- value: unknown,
124
- ): void {
125
- if (finiteNumber(value)) target[key] = value;
126
- else delete target[key];
54
+ if (raw.labels !== undefined) return normalizeLabels(raw.labels);
55
+ return isRecord(raw.metadata) ? normalizeLabels(raw.metadata.labels) : null;
127
56
  }
128
57
 
129
58
  export function buildSandboxLabels(input: SandboxLabelInput): Record<string, string> {
130
- const labels: Record<string, string> = {
59
+ return {
131
60
  [LABEL_KEYS.managed]: "true",
132
61
  [LABEL_KEYS.schema]: String(STATE_SCHEMA_VERSION),
133
62
  [LABEL_KEYS.session]: input.sessionId,
134
- [LABEL_KEYS.mode]: input.mode,
135
63
  [LABEL_KEYS.cwd]: input.cwd,
64
+ [LABEL_KEYS.root]: input.root,
65
+ [LABEL_KEYS.guestRoot]: input.guestRoot,
136
66
  [LABEL_KEYS.pid]: String(input.pid),
137
67
  [LABEL_KEYS.image]: input.image,
138
68
  [LABEL_KEYS.keep]: "true",
139
69
  };
140
-
141
- if (input.mode === "git") {
142
- if (!nonEmptyString(input.volumeName)) {
143
- throw new Error("git sandbox labels require a volume name");
144
- }
145
- labels[LABEL_KEYS.volume] = input.volumeName;
146
- if (input.seedBranch !== undefined && input.seedBranch !== null) {
147
- labels[LABEL_KEYS.seedBranch] = input.seedBranch;
148
- }
149
- if (input.seedSha !== undefined && input.seedSha !== null) {
150
- labels[LABEL_KEYS.seedSha] = input.seedSha;
151
- }
152
- }
153
-
154
- return labels;
155
- }
156
-
157
- export function buildVolumeLabels(input: VolumeLabelInput): Record<string, string> {
158
- const labels: Record<string, string> = {
159
- [LABEL_KEYS.managed]: "true",
160
- [LABEL_KEYS.schema]: String(STATE_SCHEMA_VERSION),
161
- [LABEL_KEYS.session]: input.sessionId,
162
- [LABEL_KEYS.cwd]: input.cwd,
163
- [LABEL_KEYS.keep]: "true",
164
- };
165
-
166
- if (input.seedBranch !== undefined && input.seedBranch !== null) {
167
- labels[LABEL_KEYS.seedBranch] = input.seedBranch;
168
- }
169
- if (input.seedSha !== undefined && input.seedSha !== null) {
170
- labels[LABEL_KEYS.seedSha] = input.seedSha;
171
- }
172
-
173
- return labels;
174
70
  }
175
71
 
176
72
  export function normalizeSandboxRecord(value: unknown): ManagedSandboxRecord | null {
177
73
  const raw = rawRecord(value);
178
74
  if (!raw) return null;
179
-
180
75
  const labels = labelsFromRaw(raw);
181
76
  const name = raw.name ?? raw.id ?? raw.sandboxName;
182
77
  if (!labels || !nonEmptyString(name)) return null;
183
-
184
- const normalized: Record<string, unknown> = { ...raw, name, labels };
185
- optionalString(normalized, "status", raw.status ?? raw.state);
186
- optionalNumber(normalized, "createdAt", raw.createdAt ?? raw.created_at);
187
- return normalized as unknown as ManagedSandboxRecord;
188
- }
189
-
190
- export function normalizeVolumeRecord(value: unknown): VolumeRecord | null {
191
- const raw = rawRecord(value);
192
- if (!raw) return null;
193
-
194
- const labels = labelsFromRaw(raw);
195
- const name = raw.name ?? raw.id ?? raw.volumeName;
196
- const hostPath = raw.hostPath ?? raw.path ?? raw.host_path;
197
- if (!labels || !nonEmptyString(name) || !nonEmptyString(hostPath)) return null;
198
-
199
- const normalized: Record<string, unknown> = { ...raw, name, hostPath, labels };
200
- optionalString(normalized, "kind", raw.kind ?? raw.type);
201
- optionalNumber(normalized, "usedBytes", raw.usedBytes ?? raw.used_bytes);
202
- optionalNumber(normalized, "createdAt", raw.createdAt ?? raw.created_at);
203
- return normalized as unknown as VolumeRecord;
78
+ const status = raw.status ?? raw.state;
79
+ const createdAt = raw.createdAt ?? raw.created_at;
80
+ return {
81
+ name,
82
+ labels,
83
+ ...(typeof status === "string" ? { status } : {}),
84
+ ...(finiteNumber(createdAt) ? { createdAt } : {}),
85
+ };
204
86
  }
205
87
 
206
88
  export function parseCliSandboxList(json: string): ManagedSandboxRecord[] {
207
89
  let parsed: unknown;
208
- try {
209
- parsed = JSON.parse(json);
210
- } catch (error) {
90
+ try { parsed = JSON.parse(json); } catch (error) {
211
91
  throw new Error("invalid sandbox list JSON", { cause: error });
212
92
  }
213
-
214
- let rows: unknown[];
215
- if (Array.isArray(parsed)) {
216
- rows = parsed;
217
- } else if (isRecord(parsed) && Array.isArray(parsed.sandboxes)) {
218
- rows = parsed.sandboxes;
219
- } else {
220
- throw new Error("invalid sandbox list shape: expected an array or {sandboxes: []}");
221
- }
222
-
223
- return rows
224
- .map((row) => normalizeSandboxRecord(row))
225
- .filter((row): row is ManagedSandboxRecord => row !== null);
93
+ const rows = Array.isArray(parsed)
94
+ ? parsed
95
+ : isRecord(parsed) && Array.isArray(parsed.sandboxes)
96
+ ? parsed.sandboxes
97
+ : null;
98
+ if (!rows) throw new Error("invalid sandbox list shape: expected an array or {sandboxes: []}");
99
+ return rows.map(normalizeSandboxRecord).filter((row): row is ManagedSandboxRecord => row !== null);
226
100
  }
227
101
 
228
- export function validateManagedSandbox(
229
- record: ManagedSandboxRecord,
230
- ): ValidatedManagedSandbox | null {
102
+ export function validateManagedSandbox(record: ManagedSandboxRecord): ValidatedManagedSandbox | null {
231
103
  if (!isRecord(record) || !nonEmptyString(record.name)) return null;
232
-
233
104
  const labels = normalizeLabels(record.labels);
234
105
  if (!labels) return null;
235
-
236
106
  const sessionId = labels[LABEL_KEYS.session];
237
- const mode = labels[LABEL_KEYS.mode];
238
107
  const cwd = labels[LABEL_KEYS.cwd];
239
- const image = labels[LABEL_KEYS.image];
240
- const pidText = labels[LABEL_KEYS.pid];
241
- const volumeName = labels[LABEL_KEYS.volume];
242
-
108
+ const root = labels[LABEL_KEYS.root];
109
+ const guestRoot = labels[LABEL_KEYS.guestRoot];
110
+ const pid = labels[LABEL_KEYS.pid];
243
111
  if (
244
112
  labels[LABEL_KEYS.managed] !== "true" ||
245
113
  labels[LABEL_KEYS.schema] !== String(STATE_SCHEMA_VERSION) ||
246
114
  labels[LABEL_KEYS.keep] !== "true" ||
247
- !nonEmptyString(sessionId) ||
248
- !isStorageMode(mode) ||
249
- !nonEmptyString(cwd) ||
250
- !nonEmptyString(image) ||
251
- !pidText ||
252
- !/^\d+$/.test(pidText) ||
253
- !Number.isSafeInteger(Number(pidText))
254
- ) {
255
- return null;
256
- }
257
-
258
- if (mode === "git" && !nonEmptyString(volumeName)) return null;
259
- if (mode !== "git" && volumeName !== undefined) return null;
260
-
261
- const normalized: Record<string, unknown> = {
262
- ...record,
263
- labels,
264
- name: record.name,
265
- sessionId,
266
- mode,
267
- cwd,
268
- };
269
- if (volumeName !== undefined && volumeName.length > 0) {
270
- normalized.volumeName = volumeName;
271
- } else {
272
- delete normalized.volumeName;
273
- }
274
- return normalized as unknown as ValidatedManagedSandbox;
275
- }
276
-
277
- function validOptionalString(value: unknown): boolean {
278
- return value === undefined || typeof value === "string";
279
- }
280
-
281
- function validOptionalNullableString(value: unknown): boolean {
282
- return value === undefined || value === null || typeof value === "string";
115
+ !nonEmptyString(sessionId) || !nonEmptyString(cwd) || !nonEmptyString(root) ||
116
+ !nonEmptyString(guestRoot) || !nonEmptyString(labels[LABEL_KEYS.image]) || !pid || !/^\d+$/.test(pid) ||
117
+ !Number.isSafeInteger(Number(pid))
118
+ ) return null;
119
+ return { ...record, labels, sessionId, cwd, root, guestRoot };
283
120
  }
284
121
 
285
122
  function validPersistedState(value: Record<string, unknown>): boolean {
286
- return (
287
- value.version === STATE_SCHEMA_VERSION &&
288
- nonEmptyString(value.sessionId) &&
289
- nonEmptyString(value.sandboxName) &&
290
- isStorageMode(value.mode) &&
291
- nonEmptyString(value.cwd) &&
292
- nonEmptyString(value.image) &&
293
- typeof value.enabled === "boolean" &&
294
- finiteNumber(value.createdAt) &&
295
- validOptionalString(value.volumeName) &&
296
- validOptionalString(value.volumeHostPath) &&
297
- validOptionalNullableString(value.seedBranch) &&
298
- validOptionalNullableString(value.seedSha)
299
- );
123
+ return value.version === STATE_SCHEMA_VERSION &&
124
+ nonEmptyString(value.sessionId) && nonEmptyString(value.sandboxName) &&
125
+ nonEmptyString(value.cwd) && nonEmptyString(value.root) && nonEmptyString(value.guestRoot) &&
126
+ nonEmptyString(value.image) && typeof value.enabled === "boolean" && finiteNumber(value.createdAt);
300
127
  }
301
128
 
302
- export function encodeSessionState(
303
- state: PersistedSandboxState,
304
- ): PersistedSandboxState {
305
- // Do not mutate a caller-owned object. Encoding always stamps the current
306
- // schema so a state written by this module can never be mistaken for an old
307
- // version after a future schema change.
129
+ export function encodeSessionState(state: PersistedSandboxState): PersistedSandboxState {
308
130
  return { ...state, version: STATE_SCHEMA_VERSION };
309
131
  }
310
132
 
311
- export function decodeSessionState(
312
- value: unknown,
313
- currentSessionId: string,
314
- ): PersistedSandboxState | null {
315
- if (!isRecord(value) || value.sessionId !== currentSessionId) return null;
316
- if (!validPersistedState(value)) return null;
317
-
318
- // Preserve forward-compatible, unknown JSON fields while replacing the
319
- // validated known fields with their exact runtime values.
133
+ export function decodeSessionState(value: unknown, currentSessionId: string): PersistedSandboxState | null {
134
+ if (!isRecord(value) || value.sessionId !== currentSessionId || !validPersistedState(value)) return null;
320
135
  return { ...value, version: STATE_SCHEMA_VERSION } as PersistedSandboxState;
321
136
  }
@@ -47,18 +47,17 @@ async function lockOperations(opts: LocksOptions): Promise<LockOperations> {
47
47
  function normalizeLockInfo(value: unknown): LockInfo | null {
48
48
  if (typeof value !== "object" || value === null) return null;
49
49
  const candidate = value as Partial<LockInfo>;
50
- const { version, sessionId, sandboxName, volumeName, mode, cwd, pid, createdAt } = candidate;
50
+ const { version, sessionId, sandboxName, cwd, root, pid, createdAt } = candidate;
51
51
  if (version !== LOCKFILE_VERSION
52
52
  || typeof sessionId !== "string" || sessionId.length === 0
53
53
  || typeof sandboxName !== "string" || sandboxName.length === 0
54
- || (volumeName !== undefined && (typeof volumeName !== "string" || volumeName.length === 0))
55
- || (mode !== "git" && mode !== "direct" && mode !== "none")
56
54
  || typeof cwd !== "string" || !isAbsolute(cwd)
55
+ || typeof root !== "string" || !isAbsolute(root)
57
56
  || typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0
58
57
  || typeof createdAt !== "number" || !Number.isFinite(createdAt)) {
59
58
  return null;
60
59
  }
61
- return { version, sessionId, sandboxName, volumeName, mode, cwd, pid, createdAt };
60
+ return { version, sessionId, sandboxName, cwd, root, pid, createdAt };
62
61
  }
63
62
 
64
63
  function validLockInfo(value: unknown): value is LockInfo {
@@ -72,12 +71,11 @@ function lockInfoJson(info: LockInfo): string {
72
71
  version: LOCKFILE_VERSION,
73
72
  sessionId: info.sessionId,
74
73
  sandboxName: info.sandboxName,
75
- mode: info.mode,
76
74
  cwd: info.cwd,
75
+ root: info.root,
77
76
  pid: info.pid,
78
77
  createdAt: info.createdAt,
79
78
  };
80
- if (info.volumeName !== undefined) value.volumeName = info.volumeName;
81
79
  return `${JSON.stringify(value)}\n`;
82
80
  }
83
81
 
@@ -26,12 +26,13 @@ const MANAGED_LABELS: Readonly<Record<string, string>> = Object.freeze({
26
26
  [LABEL_KEYS.managed]: "true",
27
27
  });
28
28
 
29
- const REQUIRED_LABELS = [
29
+ const CURRENT_REQUIRED_LABELS = [
30
30
  LABEL_KEYS.managed,
31
31
  LABEL_KEYS.schema,
32
32
  LABEL_KEYS.session,
33
- LABEL_KEYS.mode,
34
33
  LABEL_KEYS.cwd,
34
+ LABEL_KEYS.root,
35
+ LABEL_KEYS.guestRoot,
35
36
  LABEL_KEYS.pid,
36
37
  LABEL_KEYS.image,
37
38
  LABEL_KEYS.keep,
@@ -62,17 +63,20 @@ function validateRecord(value: unknown): ManagedSandboxRecord | null {
62
63
  labels[key] = label;
63
64
  }
64
65
 
65
- for (const key of REQUIRED_LABELS) {
66
- if (typeof labels[key] !== "string" || labels[key].length === 0) return null;
67
- }
68
66
  if (labels[LABEL_KEYS.managed] !== "true") return null;
69
- if (labels[LABEL_KEYS.schema] !== String(STATE_SCHEMA_VERSION)) return null;
70
- if (!/^\d+$/.test(labels[LABEL_KEYS.pid])) return null;
71
- if (!Number.isSafeInteger(Number(labels[LABEL_KEYS.pid]))) return null;
72
- if (labels[LABEL_KEYS.keep] !== "true") return null;
73
- if (!["git", "direct", "none"].includes(labels[LABEL_KEYS.mode])) return null;
74
- if (labels[LABEL_KEYS.mode] === "git" && !labels[LABEL_KEYS.volume]) return null;
75
- if (labels[LABEL_KEYS.mode] !== "git" && labels[LABEL_KEYS.volume] !== undefined) return null;
67
+ if (!labels[LABEL_KEYS.session] || !/^\d+$/.test(labels[LABEL_KEYS.schema] ?? "")) return null;
68
+ if (Number(labels[LABEL_KEYS.schema]) > STATE_SCHEMA_VERSION) return null;
69
+
70
+ // Old managed schemas are removable after their session owner lock is
71
+ // acquired. Their storage labels are deliberately ignored: prune has no
72
+ // volume capability and legacy volumes must remain untouched.
73
+ if (labels[LABEL_KEYS.schema] === String(STATE_SCHEMA_VERSION)) {
74
+ for (const key of CURRENT_REQUIRED_LABELS) {
75
+ if (typeof labels[key] !== "string" || labels[key].length === 0) return null;
76
+ }
77
+ if (!/^\d+$/.test(labels[LABEL_KEYS.pid]) || !Number.isSafeInteger(Number(labels[LABEL_KEYS.pid]))) return null;
78
+ if (labels[LABEL_KEYS.keep] !== "true") return null;
79
+ }
76
80
 
77
81
  return {
78
82
  name: value.name,