gestalt-mobile 0.42.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/dist/client/assets/index-5-CmBBPG.css +1 -0
- package/dist/client/assets/index-BLgr7z1T.js +107 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +2 -3
- package/dist/server/server/composition.contracts.js +27 -0
- package/dist/server/server/composition.js +10 -2
- package/dist/server/server/features/agent-activity/registry.js +4 -0
- package/dist/server/server/features/autopilot/application/policy.js +42 -1
- package/dist/server/server/features/catalog/get-bootstrap/use-case.js +11 -0
- package/dist/server/server/features/catalog/register-routes.js +13 -0
- package/dist/server/server/features/catalog/session-defaults/endpoint.js +16 -0
- package/dist/server/server/features/files/check-references/endpoint.js +36 -0
- package/dist/server/server/features/files/read-entry/endpoint.js +28 -0
- package/dist/server/server/features/files/register-routes.js +4 -0
- package/dist/server/server/features/plans/application/parse-supervised-plan.js +11 -0
- package/dist/server/server/features/plans/archive-workspace-plan/endpoint.js +26 -0
- package/dist/server/server/features/plans/list-workspace-plans/endpoint.js +1 -1
- package/dist/server/server/features/plans/register-routes.js +6 -0
- package/dist/server/server/features/sessions/model/relay-session.js +2 -0
- package/dist/server/server/features/sessions/start-session/endpoint.js +6 -0
- package/dist/server/server/features/sessions/start-session/request.js +4 -0
- package/dist/server/server/features/sessions/start-session/use-case.js +12 -0
- package/dist/server/server/platform/catalog/filesystem-workspace-catalog.js +3 -0
- package/dist/server/server/platform/codex/recent-thread-lister.js +2 -1
- package/dist/server/server/platform/codex/session-model-config.js +104 -0
- package/dist/server/server/platform/codex/session-runtime.js +33 -13
- package/dist/server/server/platform/filesystem/filesystem-workspace-files.js +99 -0
- package/dist/server/server/platform/persistence/filesystem-session-defaults.js +42 -0
- package/dist/server/server/platform/persistence/migrate.js +2 -0
- package/dist/server/server/platform/persistence/sqlite-session-repository.js +7 -2
- package/dist/server/server/platform/plans/filesystem-plan-archiver.js +109 -0
- package/dist/server/server/platform/plans/filesystem-workspace-plan-catalog.js +96 -68
- package/dist/server/server/platform/plans/org-plan-archive-tag.js +17 -0
- package/dist/server/server/platform/plans/org-plan-discovery.js +53 -0
- package/dist/server/shared/contracts/session-defaults.js +20 -0
- package/dist/server/shared/contracts/session-model-settings.js +6 -0
- package/package.json +3 -1
- package/dist/client/assets/index-BRly-q56.js +0 -38
- package/dist/client/assets/index-D57mr3B1.css +0 -1
|
@@ -18,14 +18,22 @@ import { WriterAcquisitionError, } from '../../features/sessions/application/wri
|
|
|
18
18
|
import { isCodexThreadWriterBusy, isMissingCodexThreadRollout } from './json-rpc-client.js';
|
|
19
19
|
import { resolvedServerRequestId } from './server-request.js';
|
|
20
20
|
/** Sticky thread config keeps skill paths out of the app-server process argv. */
|
|
21
|
-
export function threadSkillConfig(skillsConfig) {
|
|
22
|
-
return skillsConfig === undefined
|
|
21
|
+
export function threadSkillConfig(skillsConfig, modelConfig = {}) {
|
|
22
|
+
return skillsConfig === undefined && !Object.keys(modelConfig).length
|
|
23
|
+
? {}
|
|
24
|
+
: {
|
|
25
|
+
config: {
|
|
26
|
+
...modelConfig,
|
|
27
|
+
...(skillsConfig === undefined ? {} : { skills: { config: skillsConfig } }),
|
|
28
|
+
},
|
|
29
|
+
};
|
|
23
30
|
}
|
|
24
31
|
/** One private owner for every resource acquired for a live Codex child. */
|
|
25
32
|
class SessionResource {
|
|
26
33
|
sessionId;
|
|
27
34
|
process;
|
|
28
35
|
skillsConfig;
|
|
36
|
+
modelConfig;
|
|
29
37
|
planStatusLease;
|
|
30
38
|
planMeasurementToken;
|
|
31
39
|
unregister;
|
|
@@ -44,10 +52,11 @@ class SessionResource {
|
|
|
44
52
|
completedProcessActions = new Map();
|
|
45
53
|
turnThreads = new Map();
|
|
46
54
|
childThreads = new Set();
|
|
47
|
-
constructor(sessionId, process, skillsConfig, planStatusLease, planMeasurementToken, unregister, onDisposed) {
|
|
55
|
+
constructor(sessionId, process, skillsConfig, modelConfig, planStatusLease, planMeasurementToken, unregister, onDisposed) {
|
|
48
56
|
this.sessionId = sessionId;
|
|
49
57
|
this.process = process;
|
|
50
58
|
this.skillsConfig = skillsConfig;
|
|
59
|
+
this.modelConfig = modelConfig;
|
|
51
60
|
this.planStatusLease = planStatusLease;
|
|
52
61
|
this.planMeasurementToken = planMeasurementToken;
|
|
53
62
|
this.unregister = unregister;
|
|
@@ -92,13 +101,14 @@ export class CodexSessionRuntime {
|
|
|
92
101
|
planMeasurementBaseUrl;
|
|
93
102
|
maxPendingRequests;
|
|
94
103
|
readerCwd;
|
|
104
|
+
resolveModelConfig;
|
|
95
105
|
serverResponseListeners = new Set();
|
|
96
106
|
constructor(launch,
|
|
97
107
|
// Kept as an ignored compatibility slot while callers migrate from the old
|
|
98
108
|
// correlated-map constructor shape. Live ownership is `sessions` only.
|
|
99
109
|
_legacyProcesses = undefined, onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl,
|
|
100
110
|
// Compatibility slot: blocking app-server requests wait for explicit input.
|
|
101
|
-
_legacyRequestTimeoutMs = undefined, maxPendingRequests = 64, readerCwd) {
|
|
111
|
+
_legacyRequestTimeoutMs = undefined, maxPendingRequests = 64, readerCwd, resolveModelConfig) {
|
|
102
112
|
this.launch = launch;
|
|
103
113
|
this.onNotification = onNotification;
|
|
104
114
|
this.onServerRequest = onServerRequest;
|
|
@@ -109,6 +119,7 @@ export class CodexSessionRuntime {
|
|
|
109
119
|
this.planMeasurementBaseUrl = planMeasurementBaseUrl;
|
|
110
120
|
this.maxPendingRequests = maxPendingRequests;
|
|
111
121
|
this.readerCwd = readerCwd;
|
|
122
|
+
this.resolveModelConfig = resolveModelConfig;
|
|
112
123
|
void _legacyProcesses;
|
|
113
124
|
void _legacyRequestTimeoutMs;
|
|
114
125
|
}
|
|
@@ -123,7 +134,7 @@ export class CodexSessionRuntime {
|
|
|
123
134
|
clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
|
|
124
135
|
capabilities: { experimentalApi: true },
|
|
125
136
|
});
|
|
126
|
-
const startedThreadId = await this.startThread(resource.process, session, resource.skillsConfig);
|
|
137
|
+
const startedThreadId = await this.startThread(resource.process, session, resource.skillsConfig, resource.modelConfig);
|
|
127
138
|
resource.threadId = startedThreadId;
|
|
128
139
|
this.sessions.set(session.id, resource);
|
|
129
140
|
await this.writePendingThreadName(session.id);
|
|
@@ -224,6 +235,9 @@ export class CodexSessionRuntime {
|
|
|
224
235
|
input: [{ type: 'text', text, text_elements: [] }],
|
|
225
236
|
...(clientUserMessageId ? { clientUserMessageId } : {}),
|
|
226
237
|
...(session.model ? { model: session.model } : {}),
|
|
238
|
+
...(session.modelSettings?.reasoningEffort
|
|
239
|
+
? { effort: session.modelSettings.reasoningEffort }
|
|
240
|
+
: {}),
|
|
227
241
|
}));
|
|
228
242
|
resource.turnThreads.set(result, session.threadId);
|
|
229
243
|
return RelaySession.rehydrate(session).startTurn(result, now).snapshot;
|
|
@@ -236,7 +250,12 @@ export class CodexSessionRuntime {
|
|
|
236
250
|
threadId: childThreadId,
|
|
237
251
|
input: [{ type: 'text', text, text_elements: [] }],
|
|
238
252
|
clientUserMessageId,
|
|
239
|
-
...(session.
|
|
253
|
+
...(session.modelSettings?.executorModel
|
|
254
|
+
? { model: session.modelSettings.executorModel }
|
|
255
|
+
: {}),
|
|
256
|
+
...(session.modelSettings?.executorReasoningEffort
|
|
257
|
+
? { effort: session.modelSettings.executorReasoningEffort }
|
|
258
|
+
: {}),
|
|
240
259
|
}));
|
|
241
260
|
if (resource.turnThreads.has(result) || resource.turnThreads.size < 256)
|
|
242
261
|
resource.turnThreads.set(result, childThreadId);
|
|
@@ -677,7 +696,7 @@ export class CodexSessionRuntime {
|
|
|
677
696
|
await resource.process.rpc.request('thread/resume', {
|
|
678
697
|
threadId: session.threadId,
|
|
679
698
|
cwd: session.workspacePath,
|
|
680
|
-
...threadSkillConfig(resource.skillsConfig),
|
|
699
|
+
...threadSkillConfig(resource.skillsConfig, resource.modelConfig),
|
|
681
700
|
...(session.executionPolicy?.approvalPolicy
|
|
682
701
|
? { approvalPolicy: session.executionPolicy.approvalPolicy }
|
|
683
702
|
: {}),
|
|
@@ -699,7 +718,7 @@ export class CodexSessionRuntime {
|
|
|
699
718
|
catch (error) {
|
|
700
719
|
if (!canRebindMissingRollout(session, error))
|
|
701
720
|
throw error;
|
|
702
|
-
const replacementThreadId = await this.startThread(resource.process, session, resource.skillsConfig);
|
|
721
|
+
const replacementThreadId = await this.startThread(resource.process, session, resource.skillsConfig, resource.modelConfig);
|
|
703
722
|
result = rebindMissingRollout(session, error, replacementThreadId, now);
|
|
704
723
|
}
|
|
705
724
|
resource.threadId = result.session.threadId;
|
|
@@ -767,13 +786,13 @@ export class CodexSessionRuntime {
|
|
|
767
786
|
reject(new Error('CODEX_SERVER_REQUEST_UNSUPPORTED'));
|
|
768
787
|
});
|
|
769
788
|
}
|
|
770
|
-
async startThread(process, session, skillsConfig) {
|
|
771
|
-
return decodeThreadStart(await process.rpc.request('thread/start', this.threadStartParams(session, skillsConfig)));
|
|
789
|
+
async startThread(process, session, skillsConfig, modelConfig = {}) {
|
|
790
|
+
return decodeThreadStart(await process.rpc.request('thread/start', this.threadStartParams(session, skillsConfig, modelConfig)));
|
|
772
791
|
}
|
|
773
|
-
threadStartParams(session, skillsConfig) {
|
|
792
|
+
threadStartParams(session, skillsConfig, modelConfig = {}) {
|
|
774
793
|
return {
|
|
775
794
|
cwd: session.workspacePath,
|
|
776
|
-
...threadSkillConfig(skillsConfig),
|
|
795
|
+
...threadSkillConfig(skillsConfig, modelConfig),
|
|
777
796
|
approvalPolicy: session.executionPolicy?.approvalPolicy ?? 'on-request',
|
|
778
797
|
dynamicTools: [
|
|
779
798
|
gestaltQuizDynamicTool,
|
|
@@ -801,6 +820,7 @@ export class CodexSessionRuntime {
|
|
|
801
820
|
try {
|
|
802
821
|
const token = randomUUID();
|
|
803
822
|
const skillsConfig = await this.resolveSkills?.(session);
|
|
823
|
+
const modelConfig = (await this.resolveModelConfig?.(session)) ?? {};
|
|
804
824
|
const process = this.launch({
|
|
805
825
|
profile: session.profile,
|
|
806
826
|
cwd: session.workspacePath,
|
|
@@ -820,7 +840,7 @@ export class CodexSessionRuntime {
|
|
|
820
840
|
}
|
|
821
841
|
: {}),
|
|
822
842
|
});
|
|
823
|
-
const resource = new SessionResource(session.id, process, skillsConfig, lease, token, [], () => {
|
|
843
|
+
const resource = new SessionResource(session.id, process, skillsConfig, modelConfig, lease, token, [], () => {
|
|
824
844
|
if (this.sessions.get(session.id) === resource)
|
|
825
845
|
this.sessions.delete(session.id);
|
|
826
846
|
});
|
|
@@ -94,6 +94,105 @@ export class FilesystemWorkspaceFiles {
|
|
|
94
94
|
: { kind: 'unreadable' };
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
+
async exists(rootPath, requestedPath) {
|
|
98
|
+
const root = await this.canonicalDirectory(rootPath);
|
|
99
|
+
if (!root)
|
|
100
|
+
return false;
|
|
101
|
+
const target = resolve(root, requestedPath);
|
|
102
|
+
if (!within(root, target))
|
|
103
|
+
return false;
|
|
104
|
+
const path = relative(root, target).split(sep).join('/');
|
|
105
|
+
if (path !== '' && !validRelative(path))
|
|
106
|
+
return false;
|
|
107
|
+
const parent = await anchoredDirectory(root, dirnameRelative(path));
|
|
108
|
+
if (!parent)
|
|
109
|
+
return false;
|
|
110
|
+
try {
|
|
111
|
+
if (!(await stableAnchor(root, dirnameRelative(path), parent.path)))
|
|
112
|
+
return false;
|
|
113
|
+
const handle = await open(path ? join(parent.path, basename(path)) : `${parent.path}/.`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
114
|
+
try {
|
|
115
|
+
const metadata = await handle.stat();
|
|
116
|
+
return ((metadata.isFile() || metadata.isDirectory()) &&
|
|
117
|
+
within(root, await realpath(procPath(handle.fd))));
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await handle.close();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await parent.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async read(rootPath, requestedPath) {
|
|
131
|
+
const root = await this.canonicalDirectory(rootPath);
|
|
132
|
+
if (!root)
|
|
133
|
+
return { kind: 'unreadable' };
|
|
134
|
+
const target = resolve(root, requestedPath);
|
|
135
|
+
if (!within(root, target))
|
|
136
|
+
return { kind: 'unreadable' };
|
|
137
|
+
const path = relative(root, target).split(sep).join('/');
|
|
138
|
+
if (path !== '' && !validRelative(path))
|
|
139
|
+
return { kind: 'unreadable' };
|
|
140
|
+
const parentPath = dirnameRelative(path);
|
|
141
|
+
const parent = await anchoredDirectory(root, parentPath);
|
|
142
|
+
if (!parent)
|
|
143
|
+
return { kind: 'unreadable' };
|
|
144
|
+
try {
|
|
145
|
+
if (!(await stableAnchor(root, parentPath, parent.path)))
|
|
146
|
+
return { kind: 'unreadable' };
|
|
147
|
+
const handle = await open(path ? join(parent.path, basename(path)) : `${parent.path}/.`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
148
|
+
try {
|
|
149
|
+
const metadata = await handle.stat();
|
|
150
|
+
if (!within(root, await realpath(procPath(handle.fd))))
|
|
151
|
+
return { kind: 'unreadable' };
|
|
152
|
+
if (metadata.isDirectory())
|
|
153
|
+
return { kind: 'available', preview: { kind: 'directory', path } };
|
|
154
|
+
if (!metadata.isFile())
|
|
155
|
+
return { kind: 'unsupported' };
|
|
156
|
+
const limit = 1024 * 1024;
|
|
157
|
+
if (metadata.size > limit)
|
|
158
|
+
return { kind: 'too-large' };
|
|
159
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
160
|
+
let size = 0;
|
|
161
|
+
while (size < buffer.length) {
|
|
162
|
+
const read = await handle.read(buffer, size, buffer.length - size, size);
|
|
163
|
+
if (!read.bytesRead)
|
|
164
|
+
break;
|
|
165
|
+
size += read.bytesRead;
|
|
166
|
+
}
|
|
167
|
+
if (size > limit)
|
|
168
|
+
return { kind: 'too-large' };
|
|
169
|
+
if (!(await stableAnchor(root, parentPath, parent.path)) ||
|
|
170
|
+
!within(root, await realpath(procPath(handle.fd))))
|
|
171
|
+
return { kind: 'unreadable' };
|
|
172
|
+
const bytes = buffer.subarray(0, size);
|
|
173
|
+
if (bytes.includes(0))
|
|
174
|
+
return { kind: 'unsupported' };
|
|
175
|
+
try {
|
|
176
|
+
const content = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
177
|
+
return { kind: 'available', preview: { kind: 'file', path, content, size } };
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return { kind: 'unsupported' };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
await handle.close();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
return {
|
|
189
|
+
kind: error.code === 'ENOENT' ? 'missing' : 'unreadable',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
await parent.close();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
97
196
|
async copy(rootPath, input) {
|
|
98
197
|
return this.transfer(rootPath, input, false);
|
|
99
198
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
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 { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { sessionDefaultsSchema, } from '../../../shared/contracts/session-defaults.js';
|
|
10
|
+
export class FilesystemSessionDefaults {
|
|
11
|
+
directory;
|
|
12
|
+
path;
|
|
13
|
+
constructor(homeDirectory) {
|
|
14
|
+
this.directory = join(homeDirectory, '.gestalt');
|
|
15
|
+
this.path = join(this.directory, 'session-defaults.json');
|
|
16
|
+
}
|
|
17
|
+
async read() {
|
|
18
|
+
try {
|
|
19
|
+
return sessionDefaultsSchema.parse(JSON.parse(await readFile(this.path, 'utf8')));
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === 'ENOENT')
|
|
23
|
+
return null;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async save(defaults) {
|
|
28
|
+
const validated = sessionDefaultsSchema.parse(defaults);
|
|
29
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
30
|
+
const temporary = `${this.path}.${randomUUID()}.tmp`;
|
|
31
|
+
try {
|
|
32
|
+
await writeFile(temporary, `${JSON.stringify(validated, null, 2)}\n`, {
|
|
33
|
+
mode: 0o600,
|
|
34
|
+
flag: 'wx',
|
|
35
|
+
});
|
|
36
|
+
await rename(temporary, this.path);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await rm(temporary, { force: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -34,6 +34,8 @@ export function migrate(database) {
|
|
|
34
34
|
database.exec('ALTER TABLE relay_sessions ADD COLUMN effective_skill_selection_json TEXT');
|
|
35
35
|
if (!columns.some((column) => column.name === 'last_org_plan_json'))
|
|
36
36
|
database.exec('ALTER TABLE relay_sessions ADD COLUMN last_org_plan_json TEXT');
|
|
37
|
+
if (!columns.some((column) => column.name === 'model_settings_json'))
|
|
38
|
+
database.exec('ALTER TABLE relay_sessions ADD COLUMN model_settings_json TEXT');
|
|
37
39
|
if (!columns.some((column) => column.name === 'model'))
|
|
38
40
|
database.exec('ALTER TABLE relay_sessions ADD COLUMN model TEXT');
|
|
39
41
|
if (!columns.some((column) => column.name === 'branch'))
|
|
@@ -11,10 +11,10 @@ export class SqliteSessionRepository {
|
|
|
11
11
|
}
|
|
12
12
|
save(session) {
|
|
13
13
|
this.db
|
|
14
|
-
.prepare('INSERT INTO relay_sessions (id,workspace_id,workspace_path,provider,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 (
|
|
14
|
+
.prepare('INSERT INTO relay_sessions (id,workspace_id,workspace_path,provider,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,model_settings_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,workspace_path=excluded.workspace_path,provider=excluded.provider,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,model_settings_json=excluded.model_settings_json,updated_at=excluded.updated_at')
|
|
15
15
|
.run(session.id, session.workspaceId, session.workspacePath, session.provider, 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
|
-
: JSON.stringify(session.effectiveSkillSelection), session.lastOrgPlan === undefined ? null : JSON.stringify(session.lastOrgPlan), session.createdAt, session.updatedAt);
|
|
17
|
+
: JSON.stringify(session.effectiveSkillSelection), session.lastOrgPlan === undefined ? null : JSON.stringify(session.lastOrgPlan), session.modelSettings === undefined ? null : JSON.stringify(session.modelSettings), session.createdAt, session.updatedAt);
|
|
18
18
|
}
|
|
19
19
|
find(id) {
|
|
20
20
|
const row = this.db.prepare('SELECT * FROM relay_sessions WHERE id = ?').get(id);
|
|
@@ -48,6 +48,11 @@ function map(row) {
|
|
|
48
48
|
provider: row.provider === 'kimi' ? 'kimi' : 'codex',
|
|
49
49
|
profile: row.profile,
|
|
50
50
|
...(row.model === null ? {} : { model: row.model }),
|
|
51
|
+
...(row.model_settings_json
|
|
52
|
+
? {
|
|
53
|
+
modelSettings: JSON.parse(row.model_settings_json),
|
|
54
|
+
}
|
|
55
|
+
: {}),
|
|
51
56
|
...(row.branch === null ? {} : { branch: row.branch }),
|
|
52
57
|
...(executionPolicy === undefined ? {} : { executionPolicy }),
|
|
53
58
|
threadId: row.thread_id,
|
|
@@ -0,0 +1,109 @@
|
|
|
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 { constants } from 'node:fs';
|
|
7
|
+
import { lstat, open, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
10
|
+
import { archiveOrgPlan } from './org-plan-archive-tag.js';
|
|
11
|
+
/** Adds a standard Org file tag without moving the plan or changing its tasks. */
|
|
12
|
+
export class FilesystemPlanArchiver {
|
|
13
|
+
beforePublish;
|
|
14
|
+
pending = new Map();
|
|
15
|
+
constructor(beforePublish = async () => { }) {
|
|
16
|
+
this.beforePublish = beforePublish;
|
|
17
|
+
}
|
|
18
|
+
async archive(workspacePath, planName) {
|
|
19
|
+
if (!isAbsolute(workspacePath) || !validPlanName(planName))
|
|
20
|
+
return { kind: 'missing' };
|
|
21
|
+
const key = `${workspacePath}\0${planName}`;
|
|
22
|
+
const active = this.pending.get(key);
|
|
23
|
+
if (active)
|
|
24
|
+
return active;
|
|
25
|
+
const request = this.write(workspacePath, planName).finally(() => this.pending.delete(key));
|
|
26
|
+
this.pending.set(key, request);
|
|
27
|
+
return request;
|
|
28
|
+
}
|
|
29
|
+
async write(workspacePath, planName) {
|
|
30
|
+
try {
|
|
31
|
+
const workspace = await realpath(workspacePath);
|
|
32
|
+
const path = resolve(workspace, ...planName.split('/'));
|
|
33
|
+
if ((await realpath(path)) !== path)
|
|
34
|
+
return { kind: 'unavailable' };
|
|
35
|
+
const parentPath = dirname(path);
|
|
36
|
+
const parent = await open(parentPath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
37
|
+
try {
|
|
38
|
+
// Anchor publishing to the checked directory, even if a parent is renamed.
|
|
39
|
+
const anchor = `/proc/self/fd/${parent.fd}`;
|
|
40
|
+
if ((await realpath(anchor)) !== parentPath)
|
|
41
|
+
return { kind: 'unavailable' };
|
|
42
|
+
const target = join(anchor, basename(path));
|
|
43
|
+
const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
44
|
+
let metadata;
|
|
45
|
+
let source;
|
|
46
|
+
try {
|
|
47
|
+
metadata = await file.stat();
|
|
48
|
+
if (!metadata.isFile() || metadata.size > 1_048_576)
|
|
49
|
+
return { kind: 'unavailable' };
|
|
50
|
+
source = await file.readFile('utf8');
|
|
51
|
+
if (!sameFile(metadata, await file.stat()))
|
|
52
|
+
return { kind: 'conflict' };
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
await file.close();
|
|
56
|
+
}
|
|
57
|
+
const content = archiveOrgPlan(source);
|
|
58
|
+
if (content === source)
|
|
59
|
+
return { kind: 'archived' };
|
|
60
|
+
const temporary = join(anchor, `.${randomUUID()}.archive`);
|
|
61
|
+
try {
|
|
62
|
+
await writeFile(temporary, content, { flag: 'wx', mode: metadata.mode & 0o777 });
|
|
63
|
+
await this.beforePublish();
|
|
64
|
+
if ((await realpath(parentPath)) !== parentPath ||
|
|
65
|
+
(await realpath(anchor)) !== parentPath ||
|
|
66
|
+
!within(workspace, await realpath(anchor)))
|
|
67
|
+
return { kind: 'unavailable' };
|
|
68
|
+
if (!sameFile(metadata, await lstat(target)))
|
|
69
|
+
return { kind: 'conflict' };
|
|
70
|
+
await rename(temporary, target);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
await rm(temporary, { force: true });
|
|
74
|
+
}
|
|
75
|
+
return { kind: 'archived' };
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await parent.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return {
|
|
83
|
+
kind: error.code === 'ENOENT' ? 'missing' : 'unavailable',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function sameFile(before, after) {
|
|
89
|
+
return (after.isFile() &&
|
|
90
|
+
!after.isSymbolicLink() &&
|
|
91
|
+
before.dev === after.dev &&
|
|
92
|
+
before.ino === after.ino &&
|
|
93
|
+
before.size === after.size &&
|
|
94
|
+
before.mtimeMs === after.mtimeMs &&
|
|
95
|
+
before.ctimeMs === after.ctimeMs);
|
|
96
|
+
}
|
|
97
|
+
function within(root, path) {
|
|
98
|
+
const name = relative(root, path);
|
|
99
|
+
return name === '' || (!isAbsolute(name) && name !== '..' && !name.startsWith(`..${sep}`));
|
|
100
|
+
}
|
|
101
|
+
function validPlanName(name) {
|
|
102
|
+
const parts = name.split('/');
|
|
103
|
+
return (name.endsWith('.org') &&
|
|
104
|
+
!isAbsolute(name) &&
|
|
105
|
+
!name.includes('\\') &&
|
|
106
|
+
!name.includes('\0') &&
|
|
107
|
+
parts.slice(0, -1).includes('.gestalt') &&
|
|
108
|
+
parts.every((part) => part !== '' && part !== '.' && part !== '..' && part !== '.git'));
|
|
109
|
+
}
|
|
@@ -3,44 +3,112 @@
|
|
|
3
3
|
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
|
-
import { lstat,
|
|
7
|
-
import { isAbsolute,
|
|
6
|
+
import { lstat, readFile, realpath, stat } from 'node:fs/promises';
|
|
7
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
8
8
|
import { parseSupervisedPlan } from '../../features/plans/application/parse-supervised-plan.js';
|
|
9
|
+
import { OrgPlanDiscovery } from './org-plan-discovery.js';
|
|
10
|
+
import { isArchivedOrgPlan } from './org-plan-archive-tag.js';
|
|
9
11
|
const maximumBytes = 1_048_576;
|
|
10
|
-
/**
|
|
11
|
-
* Passive access to every regular `.org` file below a workspace. Symlinks are
|
|
12
|
-
* never followed. Supported supervised plans include a browser preview; other
|
|
13
|
-
* Org files remain visible in the catalog without one.
|
|
14
|
-
*/
|
|
12
|
+
/** Cached `.gestalt` Org discovery, with bounded parallel metadata checks. */
|
|
15
13
|
export class FilesystemWorkspacePlanCatalog {
|
|
16
14
|
filesystem;
|
|
17
|
-
|
|
15
|
+
discovery;
|
|
16
|
+
now;
|
|
17
|
+
caches = new Map();
|
|
18
|
+
constructor(filesystem = { lstat, readFile, realpath, stat }, discovery = new OrgPlanDiscovery(), now = Date.now) {
|
|
18
19
|
this.filesystem = filesystem;
|
|
20
|
+
this.discovery = discovery;
|
|
21
|
+
this.now = now;
|
|
19
22
|
}
|
|
20
|
-
async list(workspacePath) {
|
|
23
|
+
async list(workspacePath, refresh = false) {
|
|
21
24
|
const workspace = await this.workspace(workspacePath);
|
|
22
25
|
if (!workspace)
|
|
23
26
|
return [];
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (candidate.kind === 'missing')
|
|
29
|
-
continue;
|
|
30
|
-
if (candidate.kind === 'unavailable') {
|
|
31
|
-
entries.push(toFallbackEntry(planName));
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
const parsed = parseSupervisedPlan({
|
|
35
|
-
source: candidate.source,
|
|
36
|
-
planPath: candidate.canonicalPath,
|
|
37
|
-
workspacePath: workspace,
|
|
38
|
-
});
|
|
39
|
-
entries.push(parsed.kind === 'available'
|
|
40
|
-
? toEntry(planName, parsed.plan)
|
|
41
|
-
: toFallbackEntry(planName, candidate.source));
|
|
27
|
+
let cache = this.caches.get(workspace);
|
|
28
|
+
if (!cache) {
|
|
29
|
+
cache = { entries: null, files: new Map(), refreshedAt: 0, request: null };
|
|
30
|
+
this.caches.set(workspace, cache);
|
|
42
31
|
}
|
|
43
|
-
|
|
32
|
+
if (cache.request)
|
|
33
|
+
return cache.request;
|
|
34
|
+
if (!refresh && cache.entries && this.now() - cache.refreshedAt < 1_000)
|
|
35
|
+
return cache.entries;
|
|
36
|
+
const current = cache;
|
|
37
|
+
current.request = this.refresh(workspace, current).finally(() => {
|
|
38
|
+
current.request = null;
|
|
39
|
+
});
|
|
40
|
+
return current.request;
|
|
41
|
+
}
|
|
42
|
+
async refresh(workspace, cache) {
|
|
43
|
+
const planNames = [...new Set(await this.discovery.list(workspace))]
|
|
44
|
+
.filter((name) => isPlanPath(name) && name.split('/').slice(0, -1).includes('.gestalt'))
|
|
45
|
+
.sort((left, right) => left.localeCompare(right));
|
|
46
|
+
const entries = new Array(planNames.length).fill(null);
|
|
47
|
+
const files = new Map();
|
|
48
|
+
let next = 0;
|
|
49
|
+
await Promise.all(Array.from({ length: Math.min(8, planNames.length) }, async () => {
|
|
50
|
+
while (next < planNames.length) {
|
|
51
|
+
const index = next++;
|
|
52
|
+
const name = planNames[index];
|
|
53
|
+
let signature;
|
|
54
|
+
try {
|
|
55
|
+
const metadata = await this.filesystem.lstat(resolve(workspace, ...name.split('/')));
|
|
56
|
+
if (!metadata.isFile() || metadata.isSymbolicLink())
|
|
57
|
+
continue;
|
|
58
|
+
signature = [
|
|
59
|
+
metadata.dev,
|
|
60
|
+
metadata.ino,
|
|
61
|
+
metadata.size,
|
|
62
|
+
metadata.mtimeMs,
|
|
63
|
+
metadata.ctimeMs,
|
|
64
|
+
].join(':');
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const previous = cache.files.get(name);
|
|
70
|
+
if (previous?.signature === signature) {
|
|
71
|
+
try {
|
|
72
|
+
const canonical = await this.filesystem.realpath(resolve(workspace, ...name.split('/')));
|
|
73
|
+
if (!isWithin(workspace, canonical) ||
|
|
74
|
+
toPlanName(relative(workspace, canonical)) !== name)
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
entries[index] = previous.entry;
|
|
81
|
+
files.set(name, previous);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const candidate = await this.readCandidate(workspace, name);
|
|
85
|
+
if (candidate.kind === 'missing')
|
|
86
|
+
continue;
|
|
87
|
+
let entry;
|
|
88
|
+
if (candidate.kind === 'unavailable') {
|
|
89
|
+
entry = toFallbackEntry(name);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const parsed = parseSupervisedPlan({
|
|
93
|
+
source: candidate.source,
|
|
94
|
+
planPath: candidate.canonicalPath,
|
|
95
|
+
workspacePath: workspace,
|
|
96
|
+
});
|
|
97
|
+
entry =
|
|
98
|
+
parsed.kind === 'available'
|
|
99
|
+
? toEntry(name, parsed.plan)
|
|
100
|
+
: toFallbackEntry(name, candidate.source);
|
|
101
|
+
if (isArchivedOrgPlan(candidate.source))
|
|
102
|
+
entry = { ...entry, archived: true };
|
|
103
|
+
files.set(name, { signature, entry });
|
|
104
|
+
}
|
|
105
|
+
entries[index] = entry;
|
|
106
|
+
}
|
|
107
|
+
}));
|
|
108
|
+
cache.files = files;
|
|
109
|
+
cache.entries = entries.filter((entry) => entry !== null);
|
|
110
|
+
cache.refreshedAt = this.now();
|
|
111
|
+
return cache.entries;
|
|
44
112
|
}
|
|
45
113
|
async read(workspacePath, planName) {
|
|
46
114
|
if (!isPlanPath(planName))
|
|
@@ -62,46 +130,6 @@ export class FilesystemWorkspacePlanCatalog {
|
|
|
62
130
|
return null;
|
|
63
131
|
}
|
|
64
132
|
}
|
|
65
|
-
async discover(workspace) {
|
|
66
|
-
const pending = [workspace];
|
|
67
|
-
const visited = new Set();
|
|
68
|
-
const plans = [];
|
|
69
|
-
while (pending.length > 0) {
|
|
70
|
-
const directory = pending.pop();
|
|
71
|
-
try {
|
|
72
|
-
const before = await this.filesystem.lstat(directory);
|
|
73
|
-
if (!before.isDirectory() || before.isSymbolicLink())
|
|
74
|
-
continue;
|
|
75
|
-
const canonical = await this.filesystem.realpath(directory);
|
|
76
|
-
if (!isWithin(workspace, canonical))
|
|
77
|
-
continue;
|
|
78
|
-
const metadata = await this.filesystem.stat(canonical);
|
|
79
|
-
if (!metadata.isDirectory() || visited.has(canonical))
|
|
80
|
-
continue;
|
|
81
|
-
visited.add(canonical);
|
|
82
|
-
const children = await this.filesystem.readdir(canonical, {
|
|
83
|
-
encoding: 'utf8',
|
|
84
|
-
withFileTypes: true,
|
|
85
|
-
});
|
|
86
|
-
for (const child of children) {
|
|
87
|
-
if (child.isSymbolicLink())
|
|
88
|
-
continue;
|
|
89
|
-
const childPath = join(canonical, child.name);
|
|
90
|
-
if (child.isDirectory())
|
|
91
|
-
pending.push(childPath);
|
|
92
|
-
else if (child.isFile() && isPlanFilename(child.name)) {
|
|
93
|
-
const planName = toPlanName(relative(workspace, childPath));
|
|
94
|
-
if (isPlanPath(planName))
|
|
95
|
-
plans.push(planName);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
// A disappearing or unreadable branch does not hide plans elsewhere.
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return plans.sort((left, right) => left.localeCompare(right));
|
|
104
|
-
}
|
|
105
133
|
async readFromWorkspace(workspace, planName) {
|
|
106
134
|
const candidate = await this.readCandidate(workspace, planName);
|
|
107
135
|
if (candidate.kind !== 'readable')
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
export function isArchivedOrgPlan(source) {
|
|
7
|
+
return [...source.matchAll(/^#\+FILETAGS:[ \t]*(.*)$/gim)].some((match) => match[1].split(/[:\s]+/).some((tag) => tag.toUpperCase() === 'ARCHIVE'));
|
|
8
|
+
}
|
|
9
|
+
export function archiveOrgPlan(source) {
|
|
10
|
+
if (isArchivedOrgPlan(source))
|
|
11
|
+
return source;
|
|
12
|
+
const existing = /^(#\+FILETAGS:[ \t]*)([^\r\n]*)(\r?)$/im;
|
|
13
|
+
if (existing.test(source))
|
|
14
|
+
return source.replace(existing, (_line, prefix, tags, carriage) => `${prefix}${tags.trimEnd()}${tags.trimEnd().endsWith(':') ? '' : ':'}ARCHIVE:${carriage}`);
|
|
15
|
+
const newline = source.includes('\r\n') ? '\r\n' : '\n';
|
|
16
|
+
return `#+FILETAGS: :ARCHIVE:${newline}${source}`;
|
|
17
|
+
}
|