gestalt-mobile 0.20.1 → 0.22.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.
Files changed (35) hide show
  1. package/dist/client/assets/index-Db9p-xm2.css +1 -0
  2. package/dist/client/assets/index-NHzJXg1w.js +29 -0
  3. package/dist/client/index.html +2 -2
  4. package/dist/server/server/app.js +2 -0
  5. package/dist/server/server/composition.contracts.js +142 -6
  6. package/dist/server/server/composition.js +39 -8
  7. package/dist/server/server/features/agent-activity/model.js +5 -0
  8. package/dist/server/server/features/agent-activity/registry.js +12 -1
  9. package/dist/server/server/features/autopilot/application/service.js +53 -1
  10. package/dist/server/server/features/files/application/ports.js +6 -0
  11. package/dist/server/server/features/files/copy-entry/endpoint.js +48 -0
  12. package/dist/server/server/features/files/delete-entry/endpoint.js +23 -0
  13. package/dist/server/server/features/files/domain/relative-directory.js +15 -0
  14. package/dist/server/server/features/files/domain/workspace-directory.js +6 -0
  15. package/dist/server/server/features/files/list-directory/endpoint.js +49 -0
  16. package/dist/server/server/features/files/move-entry/endpoint.js +4 -0
  17. package/dist/server/server/features/files/register-routes.js +19 -0
  18. package/dist/server/server/features/files/upload-file/endpoint.js +35 -0
  19. package/dist/server/server/features/sessions/list-sessions/endpoint.js +10 -6
  20. package/dist/server/server/features/sessions/model/relay-session.js +19 -0
  21. package/dist/server/server/features/sessions/promote-recent-thread/use-case.js +13 -3
  22. package/dist/server/server/features/sessions/register-routes.js +1 -0
  23. package/dist/server/server/features/sessions/start-session/use-case.js +3 -5
  24. package/dist/server/server/platform/auth/sqlite-authorization-store.js +98 -205
  25. package/dist/server/server/platform/auth/sqlite.js +54 -0
  26. package/dist/server/server/platform/codex/activity-facts.js +4 -0
  27. package/dist/server/server/platform/codex/recent-thread-lister.js +37 -11
  28. package/dist/server/server/platform/codex/rollout-execution-policy.js +68 -0
  29. package/dist/server/server/platform/codex/session-runtime.js +76 -17
  30. package/dist/server/server/platform/filesystem/filesystem-workspace-files.js +559 -0
  31. package/dist/server/server/platform/persistence/migrate.js +5 -1
  32. package/dist/server/server/platform/persistence/sqlite-session-repository.js +11 -3
  33. package/package.json +1 -1
  34. package/dist/client/assets/index-CnXbn39K.js +0 -29
  35. package/dist/client/assets/index-DYERmevO.css +0 -1
@@ -0,0 +1,559 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ import { copyFile, lstat, mkdir, open, readdir, realpath, rename, rm, stat, writeFile, } from 'node:fs/promises';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { constants } from 'node:fs';
9
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
10
+ /** Lists exactly one directory; symlinks are represented but never followed. */
11
+ export class FilesystemWorkspaceFiles {
12
+ filesystem;
13
+ mutationHooks;
14
+ cursors = new Map();
15
+ constructor(filesystem = { lstat, readdir, realpath, stat }, mutationHooks = {}) {
16
+ this.filesystem = filesystem;
17
+ this.mutationHooks = mutationHooks;
18
+ }
19
+ async list(workspaceRoot, input) {
20
+ const root = await this.canonicalDirectory(workspaceRoot);
21
+ if (!root)
22
+ return { kind: 'unreadable' };
23
+ const target = await this.targetDirectory(root, input.directory);
24
+ if (target.kind !== 'directory')
25
+ return target;
26
+ const cursor = input.cursor === undefined ? undefined : this.cursors.get(input.cursor);
27
+ if (input.cursor !== undefined && !cursor)
28
+ return { kind: 'invalid-cursor' };
29
+ if (cursor &&
30
+ (cursor.d !== input.directory ||
31
+ cursor.i !== Number(target.metadata.ino) ||
32
+ cursor.m !== Number(target.metadata.mtimeMs)))
33
+ return { kind: 'stale-cursor' };
34
+ try {
35
+ const names = await this.filesystem.readdir(target.path, { encoding: 'utf8' });
36
+ const entries = [];
37
+ for (const name of names) {
38
+ if (name === '.git')
39
+ continue;
40
+ const child = join(target.path, name);
41
+ try {
42
+ const metadata = await this.filesystem.lstat(child);
43
+ const kind = metadata.isSymbolicLink()
44
+ ? 'symlink'
45
+ : metadata.isDirectory()
46
+ ? 'directory'
47
+ : metadata.isFile()
48
+ ? 'file'
49
+ : null;
50
+ if (!kind)
51
+ continue;
52
+ const path = input.directory === '' ? name : `${input.directory}/${name}`;
53
+ entries.push({
54
+ name,
55
+ path,
56
+ kind,
57
+ ...(kind === 'file'
58
+ ? { size: metadata.size, modifiedAt: metadata.mtime.toISOString() }
59
+ : {}),
60
+ });
61
+ }
62
+ catch (error) {
63
+ if (error.code !== 'ENOENT')
64
+ return { kind: 'unreadable' };
65
+ }
66
+ }
67
+ entries.sort(compareEntries);
68
+ if (cursor && cursor.s !== snapshot(entries))
69
+ return { kind: 'stale-cursor' };
70
+ const start = cursor ? entries.findIndex((entry) => entry.name === cursor.a) + 1 : 0;
71
+ if (cursor && start === 0)
72
+ return { kind: 'stale-cursor' };
73
+ const pageEntries = entries.slice(start, start + input.limit);
74
+ const last = pageEntries.at(-1);
75
+ const nextCursor = start + pageEntries.length < entries.length && last
76
+ ? this.rememberCursor({
77
+ d: input.directory,
78
+ i: Number(target.metadata.ino),
79
+ m: Number(target.metadata.mtimeMs),
80
+ a: last.name,
81
+ s: snapshot(entries),
82
+ })
83
+ : undefined;
84
+ const page = {
85
+ directory: input.directory,
86
+ entries: pageEntries,
87
+ ...(nextCursor ? { nextCursor } : {}),
88
+ };
89
+ return { kind: 'available', page };
90
+ }
91
+ catch (error) {
92
+ return error.code === 'ENOENT'
93
+ ? { kind: 'missing' }
94
+ : { kind: 'unreadable' };
95
+ }
96
+ }
97
+ async copy(rootPath, input) {
98
+ return this.transfer(rootPath, input, false);
99
+ }
100
+ async move(rootPath, input) {
101
+ return this.transfer(rootPath, input, true);
102
+ }
103
+ async upload(rootPath, input) {
104
+ if (!validName(input.filename))
105
+ return { kind: 'invalid-destination' };
106
+ const root = await this.canonicalDirectory(rootPath);
107
+ if (!root)
108
+ return { kind: 'unreadable' };
109
+ const parent = await anchoredDirectory(root, input.directory);
110
+ if (!parent)
111
+ return { kind: 'invalid-destination' };
112
+ try {
113
+ await this.mutationHooks.afterParentCheck?.('upload');
114
+ if (!(await stableAnchor(root, input.directory, parent.path)))
115
+ return { kind: 'invalid-destination' };
116
+ const target = await this.targetName(parent.path, input.filename, input.conflict, 'file');
117
+ if (typeof target !== 'string')
118
+ return target;
119
+ const temp = join(parent.path, `.${randomUUID()}.upload`);
120
+ await writeFile(temp, input.content, { flag: 'wx' });
121
+ try {
122
+ await this.mutationHooks.beforePublish?.('upload');
123
+ await this.publishFile(temp, target, input.conflict);
124
+ }
125
+ finally {
126
+ await rm(temp, { force: true }).catch(() => undefined);
127
+ }
128
+ return {
129
+ kind: 'available',
130
+ source: '',
131
+ path: input.directory ? `${input.directory}/${basename(target)}` : basename(target),
132
+ entryKind: 'file',
133
+ conflict: input.conflict,
134
+ };
135
+ }
136
+ catch {
137
+ return { kind: 'unreadable' };
138
+ }
139
+ finally {
140
+ await parent.close();
141
+ }
142
+ }
143
+ async delete(rootPath, input) {
144
+ if (!input.recursive)
145
+ return { kind: 'invalid-destination' };
146
+ if (input.path === '.git' || input.path.startsWith('.git/'))
147
+ return { kind: 'protected' };
148
+ if (!validRelative(input.path))
149
+ return { kind: 'invalid-destination' };
150
+ const root = await this.canonicalDirectory(rootPath);
151
+ if (!root)
152
+ return { kind: 'unreadable' };
153
+ const parent = await anchoredDirectory(root, dirnameRelative(input.path));
154
+ if (!parent)
155
+ return { kind: 'missing' };
156
+ try {
157
+ await this.mutationHooks.afterParentCheck?.('delete');
158
+ if (!(await stableAnchor(root, dirnameRelative(input.path), parent.path)))
159
+ return { kind: 'missing' };
160
+ const target = join(parent.path, basename(input.path));
161
+ const metadata = await safeLstat(target);
162
+ if (!metadata || metadata.isSymbolicLink())
163
+ return { kind: 'missing' };
164
+ await rm(target, { recursive: metadata.isDirectory(), force: false });
165
+ return {
166
+ kind: 'available',
167
+ source: input.path,
168
+ path: input.path,
169
+ entryKind: metadata.isDirectory() ? 'directory' : 'file',
170
+ conflict: 'reject',
171
+ };
172
+ }
173
+ catch {
174
+ return { kind: 'unreadable' };
175
+ }
176
+ finally {
177
+ await parent.close();
178
+ }
179
+ }
180
+ async transfer(rootPath, input, moving) {
181
+ if (!validRelative(input.source) ||
182
+ (input.destinationDirectory !== '' && !validRelative(input.destinationDirectory)))
183
+ return { kind: 'invalid-destination' };
184
+ const root = await this.canonicalDirectory(rootPath);
185
+ if (!root)
186
+ return { kind: 'unreadable' };
187
+ if (input.source === '.git' || input.source.startsWith('.git/'))
188
+ return { kind: 'protected' };
189
+ const sourceParent = await anchoredDirectory(root, dirnameRelative(input.source));
190
+ if (!sourceParent)
191
+ return { kind: 'missing' };
192
+ const parent = await anchoredDirectory(root, input.destinationDirectory);
193
+ if (!parent) {
194
+ await sourceParent.close();
195
+ return { kind: 'invalid-destination' };
196
+ }
197
+ const source = join(sourceParent.path, basename(input.source));
198
+ const sourceMetadata = await safeLstat(source);
199
+ if (!sourceMetadata || sourceMetadata.isSymbolicLink()) {
200
+ await Promise.all([sourceParent.close(), parent.close()]);
201
+ return { kind: 'missing' };
202
+ }
203
+ if (dirnameRelative(input.source) === input.destinationDirectory && moving) {
204
+ await Promise.all([sourceParent.close(), parent.close()]);
205
+ return { kind: 'same-parent' };
206
+ }
207
+ if (sourceMetadata.isDirectory() &&
208
+ (input.destinationDirectory === input.source ||
209
+ input.destinationDirectory.startsWith(`${input.source}/`))) {
210
+ await Promise.all([sourceParent.close(), parent.close()]);
211
+ return { kind: 'source-inside-destination' };
212
+ }
213
+ const target = await this.targetName(parent.path, basename(source), input.conflict, sourceMetadata.isFile() ? 'file' : 'directory');
214
+ if (typeof target !== 'string') {
215
+ await Promise.all([sourceParent.close(), parent.close()]);
216
+ return target;
217
+ }
218
+ try {
219
+ await this.mutationHooks.afterParentCheck?.(moving ? 'move' : 'copy');
220
+ if (!(await stableAnchor(root, dirnameRelative(input.source), sourceParent.path)) ||
221
+ !(await stableAnchor(root, input.destinationDirectory, parent.path)))
222
+ return { kind: 'invalid-destination' };
223
+ await this.mutationHooks.beforePublish?.(moving ? 'move' : 'copy');
224
+ if (moving) {
225
+ await this.publishMove(source, target, input.conflict, sourceMetadata.isDirectory());
226
+ }
227
+ else if (sourceMetadata.isFile()) {
228
+ const temp = join(parent.path, `.${randomUUID()}.copy`);
229
+ try {
230
+ const sourceHandle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
231
+ try {
232
+ await copyFile(procPath(sourceHandle.fd), temp, 0);
233
+ }
234
+ finally {
235
+ await sourceHandle.close();
236
+ }
237
+ await this.publishFile(temp, target, input.conflict);
238
+ }
239
+ finally {
240
+ await rm(temp, { force: true }).catch(() => undefined);
241
+ }
242
+ }
243
+ else {
244
+ const temp = join(parent.path, `.${randomUUID()}.copy-directory`);
245
+ try {
246
+ const sourceDirectory = await openDirectory(source);
247
+ if (!sourceDirectory)
248
+ throw new Error('source changed');
249
+ try {
250
+ await copyTree(sourceDirectory.path, temp, true);
251
+ }
252
+ finally {
253
+ await sourceDirectory.close();
254
+ }
255
+ await this.publishDirectory(temp, target, input.conflict);
256
+ }
257
+ finally {
258
+ await rm(temp, { recursive: true, force: true }).catch(() => undefined);
259
+ }
260
+ }
261
+ return {
262
+ kind: 'available',
263
+ source: input.source,
264
+ path: input.destinationDirectory
265
+ ? `${input.destinationDirectory}/${basename(target)}`
266
+ : basename(target),
267
+ entryKind: sourceMetadata.isDirectory() ? 'directory' : 'file',
268
+ conflict: input.conflict,
269
+ };
270
+ }
271
+ catch {
272
+ return { kind: 'unreadable' };
273
+ }
274
+ finally {
275
+ await Promise.all([sourceParent.close(), parent.close()]);
276
+ }
277
+ }
278
+ async safeDirectory(root, directory) {
279
+ if (directory !== '' && !validRelative(directory))
280
+ return null;
281
+ const entry = await this.safeEntry(root, directory, true);
282
+ return entry?.metadata.isDirectory() ? entry.path : null;
283
+ }
284
+ async targetName(parent, name, conflict, sourceKind) {
285
+ let target = join(parent, name);
286
+ const existing = await safeLstat(target);
287
+ if (!existing)
288
+ return target;
289
+ if (existing.isSymbolicLink())
290
+ return { kind: 'symlink' };
291
+ if (conflict === 'reject')
292
+ return { kind: 'conflict', replaceAllowed: sourceKind === 'file' && existing.isFile() };
293
+ if (conflict === 'replace') {
294
+ if (sourceKind !== 'file' || !existing.isFile())
295
+ return { kind: 'replace-unsupported' };
296
+ return target;
297
+ }
298
+ const extension = extname(name);
299
+ const stem = extension ? name.slice(0, -extension.length) : name;
300
+ for (let index = 1; index < 10000; index += 1) {
301
+ target = join(parent, `${stem} (${index === 1 ? 'copy' : index})${extension}`);
302
+ if (!(await safeLstat(target)))
303
+ return target;
304
+ }
305
+ return { kind: 'unreadable' };
306
+ }
307
+ async safeEntry(root, value, allowRoot = false) {
308
+ if (value === '' && !allowRoot)
309
+ return null;
310
+ let path = root;
311
+ for (const segment of value === '' ? [] : value.split('/')) {
312
+ if (!validName(segment))
313
+ return null;
314
+ path = join(path, segment);
315
+ const metadata = await safeLstat(path);
316
+ if (!metadata || metadata.isSymbolicLink())
317
+ return null;
318
+ try {
319
+ if (!within(root, await realpath(path)))
320
+ return null;
321
+ }
322
+ catch {
323
+ return null;
324
+ }
325
+ }
326
+ const metadata = await safeLstat(path);
327
+ return metadata && !metadata.isSymbolicLink() ? { path, metadata } : null;
328
+ }
329
+ async publishFile(temp, target, conflict) {
330
+ const reservation = conflict === 'replace' ? null : await reserveFile(target);
331
+ try {
332
+ await rename(temp, target);
333
+ }
334
+ catch (error) {
335
+ if (reservation)
336
+ await removeReservation(target, reservation, false);
337
+ throw error;
338
+ }
339
+ }
340
+ async publishDirectory(temp, target, conflict) {
341
+ const reservation = conflict === 'replace' ? null : await reserveDirectory(target);
342
+ try {
343
+ await rename(temp, target);
344
+ }
345
+ catch (error) {
346
+ if (reservation)
347
+ await removeReservation(target, reservation, true);
348
+ throw error;
349
+ }
350
+ }
351
+ async publishMove(source, target, conflict, directory) {
352
+ const reservation = conflict === 'replace'
353
+ ? null
354
+ : directory
355
+ ? await reserveDirectory(target)
356
+ : await reserveFile(target);
357
+ try {
358
+ await rename(source, target);
359
+ }
360
+ catch (error) {
361
+ if (reservation)
362
+ await removeReservation(target, reservation, directory);
363
+ throw error;
364
+ }
365
+ }
366
+ rememberCursor(cursor) {
367
+ const token = randomUUID();
368
+ this.cursors.set(token, cursor);
369
+ if (this.cursors.size > 2_000)
370
+ this.cursors.delete(this.cursors.keys().next().value);
371
+ return token;
372
+ }
373
+ async canonicalDirectory(path) {
374
+ if (!isAbsolute(path))
375
+ return null;
376
+ try {
377
+ const canonical = await this.filesystem.realpath(resolve(path));
378
+ return (await this.filesystem.stat(canonical)).isDirectory() ? canonical : null;
379
+ }
380
+ catch {
381
+ return null;
382
+ }
383
+ }
384
+ async targetDirectory(root, directory) {
385
+ let path = root;
386
+ for (const segment of directory === '' ? [] : directory.split('/')) {
387
+ path = join(path, segment);
388
+ try {
389
+ const metadata = await this.filesystem.lstat(path);
390
+ if (metadata.isSymbolicLink())
391
+ return { kind: 'missing' };
392
+ const canonical = await this.filesystem.realpath(path);
393
+ if (!within(root, canonical))
394
+ return { kind: 'missing' };
395
+ }
396
+ catch (error) {
397
+ return error.code === 'ENOENT'
398
+ ? { kind: 'missing' }
399
+ : { kind: 'unreadable' };
400
+ }
401
+ }
402
+ try {
403
+ const metadata = await this.filesystem.lstat(path);
404
+ if (metadata.isSymbolicLink())
405
+ return { kind: 'missing' };
406
+ if (!metadata.isDirectory())
407
+ return { kind: 'not-directory' };
408
+ const canonical = await this.filesystem.realpath(path);
409
+ if (!within(root, canonical) || !(await this.filesystem.stat(canonical)).isDirectory())
410
+ return { kind: 'missing' };
411
+ return { kind: 'directory', path: canonical, metadata };
412
+ }
413
+ catch (error) {
414
+ return error.code === 'ENOENT'
415
+ ? { kind: 'missing' }
416
+ : { kind: 'unreadable' };
417
+ }
418
+ }
419
+ }
420
+ function within(root, candidate) {
421
+ const path = relative(root, candidate);
422
+ return path === '' || (!path.startsWith(`..${sep}`) && path !== '..' && !isAbsolute(path));
423
+ }
424
+ function compareEntries(left, right) {
425
+ const rank = { directory: 0, file: 1, symlink: 2 };
426
+ return rank[left.kind] - rank[right.kind] || compareCodePoints(left.name, right.name);
427
+ }
428
+ /** Unicode scalar-value ordering, independent of host locale and UTF-16 code units. */
429
+ function compareCodePoints(left, right) {
430
+ let leftIndex = 0;
431
+ let rightIndex = 0;
432
+ while (leftIndex < left.length && rightIndex < right.length) {
433
+ const leftPoint = left.codePointAt(leftIndex);
434
+ const rightPoint = right.codePointAt(rightIndex);
435
+ if (leftPoint !== rightPoint)
436
+ return leftPoint < rightPoint ? -1 : 1;
437
+ leftIndex += leftPoint > 0xffff ? 2 : 1;
438
+ rightIndex += rightPoint > 0xffff ? 2 : 1;
439
+ }
440
+ return leftIndex === left.length ? (rightIndex === right.length ? 0 : -1) : 1;
441
+ }
442
+ function snapshot(entries) {
443
+ return entries.map((entry) => `${entry.kind}\0${entry.name}`).join('\0');
444
+ }
445
+ function validRelative(value) {
446
+ return (value !== '' &&
447
+ !value.includes('\\') &&
448
+ !value.includes('\0') &&
449
+ value.split('/').every(validName));
450
+ }
451
+ function validName(value) {
452
+ return (value !== '' &&
453
+ value !== '.' &&
454
+ value !== '..' &&
455
+ value !== '.git' &&
456
+ !value.includes('/') &&
457
+ !value.includes('\\') &&
458
+ !value.includes('\0'));
459
+ }
460
+ async function safeLstat(path) {
461
+ try {
462
+ return await lstat(path);
463
+ }
464
+ catch {
465
+ return null;
466
+ }
467
+ }
468
+ function dirnameRelative(path) {
469
+ const slash = path.lastIndexOf('/');
470
+ return slash < 0 ? '' : path.slice(0, slash);
471
+ }
472
+ function procPath(fd) {
473
+ return `/proc/self/fd/${fd}`;
474
+ }
475
+ async function openDirectory(path) {
476
+ try {
477
+ const handle = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
478
+ return { path: procPath(handle.fd), close: () => handle.close() };
479
+ }
480
+ catch {
481
+ return null;
482
+ }
483
+ }
484
+ async function anchoredDirectory(root, directory) {
485
+ if (directory !== '' && !validRelative(directory))
486
+ return null;
487
+ let current = await openDirectory(root);
488
+ if (!current)
489
+ return null;
490
+ for (const segment of directory === '' ? [] : directory.split('/')) {
491
+ const next = await openDirectory(join(current.path, segment));
492
+ await current.close();
493
+ if (!next)
494
+ return null;
495
+ current = next;
496
+ }
497
+ return current;
498
+ }
499
+ async function reserveFile(path) {
500
+ const reservation = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
501
+ try {
502
+ return await reservation.stat();
503
+ }
504
+ finally {
505
+ await reservation.close();
506
+ }
507
+ }
508
+ async function reserveDirectory(path) {
509
+ await mkdir(path);
510
+ return lstat(path);
511
+ }
512
+ async function removeReservation(path, reservation, directory) {
513
+ const current = await safeLstat(path);
514
+ if (current?.dev === reservation.dev && current.ino === reservation.ino)
515
+ await rm(path, { recursive: directory, force: true }).catch(() => undefined);
516
+ }
517
+ async function stableAnchor(root, directory, anchor) {
518
+ try {
519
+ const current = await realpath(directory ? join(root, ...directory.split('/')) : root);
520
+ const anchored = await realpath(anchor);
521
+ return current === anchored && within(root, anchored);
522
+ }
523
+ catch {
524
+ return false;
525
+ }
526
+ }
527
+ async function copyTree(source, target, directory) {
528
+ if (!directory) {
529
+ const sourceHandle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
530
+ try {
531
+ await copyFile(procPath(sourceHandle.fd), target, 0);
532
+ }
533
+ finally {
534
+ await sourceHandle.close();
535
+ }
536
+ return;
537
+ }
538
+ await mkdir(target);
539
+ for (const name of await readdir(source)) {
540
+ const childSource = join(source, name);
541
+ const metadata = await lstat(childSource);
542
+ if (metadata.isSymbolicLink() || name === '.git')
543
+ throw new Error('unsupported source');
544
+ if (metadata.isDirectory()) {
545
+ const child = await openDirectory(childSource);
546
+ if (!child)
547
+ throw new Error('source changed');
548
+ try {
549
+ await copyTree(child.path, join(target, name), true);
550
+ }
551
+ finally {
552
+ await child.close();
553
+ }
554
+ }
555
+ else {
556
+ await copyTree(childSource, join(target, name), false);
557
+ }
558
+ }
559
+ }
@@ -3,7 +3,7 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- const schema = `CREATE TABLE IF NOT EXISTS relay_sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workspace_path TEXT NOT NULL, profile TEXT NOT NULL, model TEXT, branch TEXT, thread_id TEXT, state TEXT NOT NULL, desired_state TEXT NOT NULL, active_turn_id TEXT, protocol_version TEXT, failure_count INTEGER NOT NULL DEFAULT 0, effective_skill_selection_json TEXT, last_org_plan_json TEXT, next_sequence INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pending_interactions (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, request_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, turn_id TEXT, requested_at TEXT, resolved_at TEXT, outcome TEXT, operation_key TEXT, resolution_state TEXT NOT NULL DEFAULT 'active', PRIMARY KEY (session_id, request_id)); CREATE TABLE IF NOT EXISTS session_events (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, sequence INTEGER NOT NULL, occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, autopilot_outbox_id INTEGER, PRIMARY KEY (session_id, sequence), UNIQUE(session_id, autopilot_outbox_id)); CREATE TABLE IF NOT EXISTS idempotency_results (scope TEXT NOT NULL, key TEXT NOT NULL, status_code INTEGER NOT NULL, body_json TEXT NOT NULL, PRIMARY KEY (scope, key)); CREATE TABLE IF NOT EXISTS autopilot_sessions (session_id TEXT PRIMARY KEY REFERENCES relay_sessions(id) ON DELETE CASCADE, state TEXT NOT NULL, requested_enabled INTEGER NOT NULL, plan_identity TEXT, plan_fingerprint TEXT, generation INTEGER NOT NULL, no_progress_count INTEGER NOT NULL, next_evaluation_at TEXT, last_control_id TEXT, stop_reason TEXT, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS autopilot_controls (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, control_id TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, failure_code TEXT, turn_id TEXT, PRIMARY KEY (session_id, control_id)); CREATE TABLE IF NOT EXISTS autopilot_outbox (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, type TEXT NOT NULL, payload_json TEXT NOT NULL, occurred_at TEXT NOT NULL);`;
6
+ const schema = `CREATE TABLE IF NOT EXISTS relay_sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workspace_path TEXT NOT NULL, profile TEXT NOT NULL, model TEXT, branch TEXT, sandbox TEXT, approval_policy TEXT, thread_id TEXT, state TEXT NOT NULL, desired_state TEXT NOT NULL, active_turn_id TEXT, protocol_version TEXT, failure_count INTEGER NOT NULL DEFAULT 0, effective_skill_selection_json TEXT, last_org_plan_json TEXT, next_sequence INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pending_interactions (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, request_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, turn_id TEXT, requested_at TEXT, resolved_at TEXT, outcome TEXT, operation_key TEXT, resolution_state TEXT NOT NULL DEFAULT 'active', PRIMARY KEY (session_id, request_id)); CREATE TABLE IF NOT EXISTS session_events (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, sequence INTEGER NOT NULL, occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, autopilot_outbox_id INTEGER, PRIMARY KEY (session_id, sequence), UNIQUE(session_id, autopilot_outbox_id)); CREATE TABLE IF NOT EXISTS idempotency_results (scope TEXT NOT NULL, key TEXT NOT NULL, status_code INTEGER NOT NULL, body_json TEXT NOT NULL, PRIMARY KEY (scope, key)); CREATE TABLE IF NOT EXISTS autopilot_sessions (session_id TEXT PRIMARY KEY REFERENCES relay_sessions(id) ON DELETE CASCADE, state TEXT NOT NULL, requested_enabled INTEGER NOT NULL, plan_identity TEXT, plan_fingerprint TEXT, generation INTEGER NOT NULL, no_progress_count INTEGER NOT NULL, next_evaluation_at TEXT, last_control_id TEXT, stop_reason TEXT, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS autopilot_controls (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, control_id TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, failure_code TEXT, turn_id TEXT, PRIMARY KEY (session_id, control_id)); CREATE TABLE IF NOT EXISTS autopilot_outbox (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, type TEXT NOT NULL, payload_json TEXT NOT NULL, occurred_at TEXT NOT NULL);`;
7
7
  export function migrate(database) {
8
8
  database.exec(schema);
9
9
  database.exec("CREATE INDEX IF NOT EXISTS session_events_autopilot_audit_tail_v2 ON session_events(session_id, type, sequence DESC) WHERE type IN ('autopilot.continuation-scheduled','autopilot.control-issued','autopilot.turn-started','autopilot.turn-failed','autopilot.progress-reset','autopilot.updated','org-plan.attention-required','org-plan.attention-resolved')");
@@ -18,6 +18,10 @@ export function migrate(database) {
18
18
  database.exec('ALTER TABLE relay_sessions ADD COLUMN branch TEXT');
19
19
  if (!columns.some((column) => column.name === 'attention_tool_capability'))
20
20
  database.exec('ALTER TABLE relay_sessions ADD COLUMN attention_tool_capability TEXT');
21
+ if (!columns.some((column) => column.name === 'sandbox'))
22
+ database.exec('ALTER TABLE relay_sessions ADD COLUMN sandbox TEXT');
23
+ if (!columns.some((column) => column.name === 'approval_policy'))
24
+ database.exec('ALTER TABLE relay_sessions ADD COLUMN approval_policy TEXT');
21
25
  const interactionColumns = database
22
26
  .prepare('PRAGMA table_info(pending_interactions)')
23
27
  .all();
@@ -3,7 +3,7 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- import { createEffectiveSkillSelection, } from '../../features/sessions/model/relay-session.js';
6
+ import { createEffectiveSkillSelection, createSessionExecutionPolicy, } from '../../features/sessions/model/relay-session.js';
7
7
  export class SqliteSessionRepository {
8
8
  db;
9
9
  constructor(db) {
@@ -11,8 +11,8 @@ export class SqliteSessionRepository {
11
11
  }
12
12
  save(session) {
13
13
  this.db
14
- .prepare('INSERT INTO relay_sessions (id,workspace_id,workspace_path,profile,model,branch,thread_id,state,desired_state,active_turn_id,protocol_version,attention_tool_capability,failure_count,effective_skill_selection_json,last_org_plan_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,workspace_path=excluded.workspace_path,profile=excluded.profile,model=excluded.model,branch=excluded.branch,thread_id=excluded.thread_id,state=excluded.state,desired_state=excluded.desired_state,active_turn_id=excluded.active_turn_id,protocol_version=excluded.protocol_version,attention_tool_capability=excluded.attention_tool_capability,failure_count=excluded.failure_count,effective_skill_selection_json=excluded.effective_skill_selection_json,last_org_plan_json=excluded.last_org_plan_json,updated_at=excluded.updated_at')
15
- .run(session.id, session.workspaceId, session.workspacePath, session.profile, session.model ?? null, session.branch ?? null, session.threadId, session.state, session.desiredState, session.activeTurnId, session.protocolVersion, session.attentionToolCapability ?? null, session.failureCount, session.effectiveSkillSelection === undefined
14
+ .prepare('INSERT INTO relay_sessions (id,workspace_id,workspace_path,profile,model,branch,sandbox,approval_policy,thread_id,state,desired_state,active_turn_id,protocol_version,attention_tool_capability,failure_count,effective_skill_selection_json,last_org_plan_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,workspace_path=excluded.workspace_path,profile=excluded.profile,model=excluded.model,branch=excluded.branch,sandbox=excluded.sandbox,approval_policy=excluded.approval_policy,thread_id=excluded.thread_id,state=excluded.state,desired_state=excluded.desired_state,active_turn_id=excluded.active_turn_id,protocol_version=excluded.protocol_version,attention_tool_capability=excluded.attention_tool_capability,failure_count=excluded.failure_count,effective_skill_selection_json=excluded.effective_skill_selection_json,last_org_plan_json=excluded.last_org_plan_json,updated_at=excluded.updated_at')
15
+ .run(session.id, session.workspaceId, session.workspacePath, session.profile, session.model ?? null, session.branch ?? null, session.executionPolicy?.sandbox ?? null, session.executionPolicy?.approvalPolicy ?? null, session.threadId, session.state, session.desiredState, session.activeTurnId, session.protocolVersion, session.attentionToolCapability ?? null, session.failureCount, session.effectiveSkillSelection === undefined
16
16
  ? null
17
17
  : JSON.stringify(session.effectiveSkillSelection), session.lastOrgPlan === undefined ? null : JSON.stringify(session.lastOrgPlan), session.createdAt, session.updatedAt);
18
18
  }
@@ -28,6 +28,13 @@ export class SqliteSessionRepository {
28
28
  }
29
29
  }
30
30
  function map(row) {
31
+ const sandbox = row.sandbox ?? null;
32
+ const approvalPolicy = row.approval_policy ?? null;
33
+ if (approvalPolicy === null && sandbox !== null)
34
+ throw new Error('SESSION_EXECUTION_POLICY_INVALID');
35
+ const executionPolicy = approvalPolicy === null
36
+ ? undefined
37
+ : createSessionExecutionPolicy({ sandbox: sandbox ?? undefined, approvalPolicy });
31
38
  const effectiveSkillSelection = row.effective_skill_selection_json
32
39
  ? createEffectiveSkillSelection(JSON.parse(row.effective_skill_selection_json))
33
40
  : undefined;
@@ -41,6 +48,7 @@ function map(row) {
41
48
  profile: row.profile,
42
49
  ...(row.model === null ? {} : { model: row.model }),
43
50
  ...(row.branch === null ? {} : { branch: row.branch }),
51
+ ...(executionPolicy === undefined ? {} : { executionPolicy }),
44
52
  threadId: row.thread_id,
45
53
  state: row.state,
46
54
  desiredState: row.desired_state,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.20.1",
3
+ "version": "0.22.0",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",