@beremaran/godot-agent-loop 1.0.1 → 1.1.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 +59 -34
- package/addons/godot_agent_loop/README.md +26 -16
- package/addons/godot_agent_loop/plugin.cfg +3 -2
- package/addons/godot_agent_loop/plugin.gd +840 -25
- package/agent-plugin/.claude-plugin/plugin.json +1 -1
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/adapter-manifest.json +3 -3
- package/agent-plugin/pi/extension.ts +1 -1
- package/agent-plugin/skills/build-godot-game/SKILL.md +26 -5
- package/agent-plugin/skills/debug-godot-game/SKILL.md +20 -7
- package/agent-plugin/skills/ship-godot-game/SKILL.md +13 -3
- package/agent-plugin/skills/verify-godot-change/SKILL.md +17 -2
- package/build/authoring-session-manager.js +24 -1
- package/build/domain-tool-registries.js +5 -1
- package/build/editor-authoring-router.js +228 -0
- package/build/editor-bridge-protocol.js +2 -1
- package/build/editor-connection.js +29 -24
- package/build/editor-mutation-guard.js +3 -1
- package/build/editor-plugin-installer.js +2 -1
- package/build/editor-session-registry.js +392 -0
- package/build/editor-sync-queue.js +135 -0
- package/build/index.js +208 -29
- package/build/lifecycle-trace.js +80 -0
- package/build/scripts/mcp_editor_plugin.gd +840 -25
- package/build/scripts/mcp_interaction_server.gd +76 -2
- package/build/scripts/mcp_runtime/core_domain.gd +2 -1
- package/build/scripts/mcp_runtime/system_domain.gd +2 -0
- package/build/server-instructions.js +5 -5
- package/build/session-timing.js +17 -1
- package/build/tool-definitions.js +99 -6
- package/build/tool-handlers/game-tool-handlers.js +21 -4
- package/build/tool-handlers/lifecycle-tool-handlers.js +361 -27
- package/build/tool-handlers/project-handler-services.js +45 -6
- package/build/tool-handlers/project-tool-handlers.js +4 -4
- package/build/tool-manifest.js +29 -1
- package/build/tool-mutation-policy.js +1 -0
- package/build/tool-surface.js +4 -4
- package/build/utils.js +2 -2
- package/package.json +4 -3
- package/product.json +7 -5
|
@@ -154,9 +154,10 @@ export class EditorPluginInstaller {
|
|
|
154
154
|
'name="Godot Agent Loop Transient Bridge"',
|
|
155
155
|
'description="Session-owned authenticated editor bridge"',
|
|
156
156
|
'author="Godot Agent Loop"',
|
|
157
|
-
'version="1.0
|
|
157
|
+
'version="1.1.0"',
|
|
158
158
|
'script="plugin.gd"',
|
|
159
159
|
`protocol_version="${EDITOR_BRIDGE_PROTOCOL_VERSION}"`,
|
|
160
|
+
'minimum_godot_version="4.7"',
|
|
160
161
|
'',
|
|
161
162
|
].join('\n'));
|
|
162
163
|
const marker = {
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { existsSync, realpathSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { EditorBridgeCompatibilityError, EditorConnection, EditorRequestTimeoutError, } from './editor-connection.js';
|
|
4
|
+
import { EDITOR_BRIDGE_PROTOCOL_VERSION } from './editor-bridge-protocol.js';
|
|
5
|
+
export const EDITOR_SESSION_DIRECTORY = join('.godot', 'godot_agent_loop');
|
|
6
|
+
export const EDITOR_SESSION_FILE = join(EDITOR_SESSION_DIRECTORY, 'editor-session.json');
|
|
7
|
+
/** Secure, reconnectable editor bridges keyed by canonical Godot project path. */
|
|
8
|
+
export class EditorSessionRegistry {
|
|
9
|
+
options;
|
|
10
|
+
entries = new Map();
|
|
11
|
+
trackedProjects = new Set();
|
|
12
|
+
operationTails = new Map();
|
|
13
|
+
emittedSignatures = new Map();
|
|
14
|
+
retryDelaysMs;
|
|
15
|
+
watcher = null;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.options = options;
|
|
18
|
+
this.retryDelaysMs = options.retryDelaysMs ?? [0, 50, 100, 250, 500, 1_000, 2_000];
|
|
19
|
+
}
|
|
20
|
+
async ensure(projectPath, timeoutMs = 10_000) {
|
|
21
|
+
const canonical = canonicalProjectPath(projectPath);
|
|
22
|
+
return this.serialize(canonical, () => this.ensureDiscovered(canonical, timeoutMs));
|
|
23
|
+
}
|
|
24
|
+
async status(projectPath) {
|
|
25
|
+
const canonical = canonicalProjectPath(projectPath);
|
|
26
|
+
return this.serialize(canonical, async () => {
|
|
27
|
+
this.track(canonical);
|
|
28
|
+
return this.discover(canonical);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async ensureDiscovered(canonical, timeoutMs) {
|
|
32
|
+
this.track(canonical);
|
|
33
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
34
|
+
let last = await this.discover(canonical);
|
|
35
|
+
let attempt = 0;
|
|
36
|
+
while (!last.connected && last.state === 'no_editor' && Date.now() < deadline) {
|
|
37
|
+
const delay = this.retryDelaysMs[Math.min(attempt++, this.retryDelaysMs.length - 1)] ?? 250;
|
|
38
|
+
if (delay > 0)
|
|
39
|
+
await new Promise(resolveDelay => setTimeout(resolveDelay, Math.min(delay, Math.max(1, deadline - Date.now()))));
|
|
40
|
+
last = await this.discover(canonical);
|
|
41
|
+
}
|
|
42
|
+
return last;
|
|
43
|
+
}
|
|
44
|
+
async send(projectPath, command, params = {}, timeoutMs = 10_000) {
|
|
45
|
+
const canonical = canonicalProjectPath(projectPath);
|
|
46
|
+
return this.serialize(canonical, async () => {
|
|
47
|
+
const session = await this.ensureDiscovered(canonical, Math.min(timeoutMs, 2_000));
|
|
48
|
+
if (!session.connected)
|
|
49
|
+
throw new EditorSessionUnavailableError(session);
|
|
50
|
+
const entry = this.entries.get(canonical);
|
|
51
|
+
if (!entry)
|
|
52
|
+
throw new EditorSessionUnavailableError(session);
|
|
53
|
+
try {
|
|
54
|
+
const result = await entry.connection.send(command, params, timeoutMs);
|
|
55
|
+
entry.state = editorStateFromResult(result, entry.state);
|
|
56
|
+
this.emit(this.publicSession(canonical, entry, true));
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
// A request can time out while Godot's main thread is briefly busy
|
|
61
|
+
// importing or saving a resource. The socket remains usable, so keep
|
|
62
|
+
// the authenticated session instead of forcing a competing reconnect.
|
|
63
|
+
if (!(error instanceof EditorRequestTimeoutError)) {
|
|
64
|
+
entry.connection.disconnect();
|
|
65
|
+
this.entries.delete(canonical);
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
disconnect(projectPath) {
|
|
72
|
+
const canonical = canonicalProjectPath(projectPath);
|
|
73
|
+
const entry = this.entries.get(canonical);
|
|
74
|
+
entry?.connection.disconnect();
|
|
75
|
+
this.entries.delete(canonical);
|
|
76
|
+
this.trackedProjects.delete(canonical);
|
|
77
|
+
this.emittedSignatures.delete(canonical);
|
|
78
|
+
const session = emptySession(canonical, 'no_editor', 'Disconnected by caller');
|
|
79
|
+
this.emit(session);
|
|
80
|
+
this.stopWatcherIfIdle();
|
|
81
|
+
return session;
|
|
82
|
+
}
|
|
83
|
+
disconnectAll() {
|
|
84
|
+
for (const entry of this.entries.values())
|
|
85
|
+
entry.connection.disconnect();
|
|
86
|
+
this.entries.clear();
|
|
87
|
+
this.trackedProjects.clear();
|
|
88
|
+
this.emittedSignatures.clear();
|
|
89
|
+
if (this.watcher)
|
|
90
|
+
clearInterval(this.watcher);
|
|
91
|
+
this.watcher = null;
|
|
92
|
+
}
|
|
93
|
+
async discover(canonical) {
|
|
94
|
+
const recordResult = readDiscoveryRecord(canonical, this.options.processExists ?? processExists);
|
|
95
|
+
if ('state' in recordResult) {
|
|
96
|
+
const old = this.entries.get(canonical);
|
|
97
|
+
old?.connection.disconnect();
|
|
98
|
+
this.entries.delete(canonical);
|
|
99
|
+
this.emit(recordResult);
|
|
100
|
+
return recordResult;
|
|
101
|
+
}
|
|
102
|
+
const record = recordResult.record;
|
|
103
|
+
const existing = this.entries.get(canonical);
|
|
104
|
+
const sameRecord = existing
|
|
105
|
+
&& existing.record.editor_start_identity === record.editor_start_identity
|
|
106
|
+
&& existing.record.port === record.port;
|
|
107
|
+
if (sameRecord)
|
|
108
|
+
return this.publicSession(canonical, existing, true);
|
|
109
|
+
existing?.connection.disconnect();
|
|
110
|
+
const connection = this.options.connectionFactory?.(record) ?? new EditorConnection({
|
|
111
|
+
port: record.port,
|
|
112
|
+
secret: record.token,
|
|
113
|
+
protocolVersion: EDITOR_BRIDGE_PROTOCOL_VERSION,
|
|
114
|
+
serverVersion: this.options.serverVersion,
|
|
115
|
+
});
|
|
116
|
+
try {
|
|
117
|
+
const handshake = await connection.authenticate(2_000);
|
|
118
|
+
validateHandshake(record, handshake);
|
|
119
|
+
const state = handshake.paused === true ? 'paused' : 'connected';
|
|
120
|
+
const entry = { record, connection, state };
|
|
121
|
+
this.entries.set(canonical, entry);
|
|
122
|
+
const session = this.publicSession(canonical, entry, true);
|
|
123
|
+
this.emit(session);
|
|
124
|
+
return session;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
connection.disconnect();
|
|
128
|
+
if (error instanceof EditorBridgeCompatibilityError) {
|
|
129
|
+
const session = publicRecord(record, 'protocol_incompatible', false, error.message);
|
|
130
|
+
this.emit(session);
|
|
131
|
+
return session;
|
|
132
|
+
}
|
|
133
|
+
const pidExists = this.options.processExists ?? processExists;
|
|
134
|
+
const invalidIdentity = error instanceof EditorSessionIdentityError;
|
|
135
|
+
const processAlive = pidExists(record.editor_pid);
|
|
136
|
+
if (invalidIdentity || !processAlive)
|
|
137
|
+
cleanStaleRecord(canonical);
|
|
138
|
+
const session = invalidIdentity || !processAlive
|
|
139
|
+
? emptySession(canonical, 'no_editor', `Stale editor session removed: ${errorMessage(error)}`)
|
|
140
|
+
: publicRecord(record, 'no_editor', false, `Live editor session is temporarily unreachable: ${errorMessage(error)}`);
|
|
141
|
+
this.emit(session);
|
|
142
|
+
return session;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
publicSession(canonical, entry, reused) {
|
|
146
|
+
return { ...publicRecord(entry.record, entry.state, true), project_path: canonical, reused };
|
|
147
|
+
}
|
|
148
|
+
track(canonical) {
|
|
149
|
+
this.trackedProjects.add(canonical);
|
|
150
|
+
if (this.watcher)
|
|
151
|
+
return;
|
|
152
|
+
this.watcher = setInterval(() => {
|
|
153
|
+
for (const projectPath of this.trackedProjects) {
|
|
154
|
+
// An editor can accept only one agent connection. Do not let the
|
|
155
|
+
// watcher start a second authentication while an ensure/send is still
|
|
156
|
+
// establishing or using the project's connection.
|
|
157
|
+
if (this.operationTails.has(projectPath))
|
|
158
|
+
continue;
|
|
159
|
+
void this.status(projectPath).catch(error => {
|
|
160
|
+
this.options.log?.(`Editor discovery failed for ${projectPath}: ${errorMessage(error)}`);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}, 750);
|
|
164
|
+
this.watcher.unref();
|
|
165
|
+
}
|
|
166
|
+
stopWatcherIfIdle() {
|
|
167
|
+
if (this.trackedProjects.size > 0 || !this.watcher)
|
|
168
|
+
return;
|
|
169
|
+
clearInterval(this.watcher);
|
|
170
|
+
this.watcher = null;
|
|
171
|
+
}
|
|
172
|
+
emit(session) {
|
|
173
|
+
const signature = `${session.state}:${session.editor_start_identity ?? ''}:${session.connected}`;
|
|
174
|
+
if (this.emittedSignatures.get(session.project_path) === signature)
|
|
175
|
+
return;
|
|
176
|
+
this.emittedSignatures.set(session.project_path, signature);
|
|
177
|
+
this.options.onStateChange?.(session);
|
|
178
|
+
}
|
|
179
|
+
serialize(projectPath, operation) {
|
|
180
|
+
const previous = this.operationTails.get(projectPath) ?? Promise.resolve();
|
|
181
|
+
const result = previous.then(operation, operation);
|
|
182
|
+
const tail = result.then(() => undefined, () => undefined);
|
|
183
|
+
this.operationTails.set(projectPath, tail);
|
|
184
|
+
void tail.finally(() => {
|
|
185
|
+
if (this.operationTails.get(projectPath) === tail)
|
|
186
|
+
this.operationTails.delete(projectPath);
|
|
187
|
+
});
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
export class EditorSessionUnavailableError extends Error {
|
|
192
|
+
session;
|
|
193
|
+
constructor(session) {
|
|
194
|
+
super(`Editor session is unavailable (${session.state})${session.reason ? `: ${session.reason}` : ''}`);
|
|
195
|
+
this.session = session;
|
|
196
|
+
this.name = 'EditorSessionUnavailableError';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
export function readDiscoveryRecord(projectPath, pidExists = processExists) {
|
|
200
|
+
const canonical = canonicalProjectPath(projectPath);
|
|
201
|
+
const sessionPath = join(canonical, EDITOR_SESSION_FILE);
|
|
202
|
+
let raw;
|
|
203
|
+
try {
|
|
204
|
+
if (process.platform !== 'win32' && (statSync(sessionPath).mode & 0o077) !== 0) {
|
|
205
|
+
cleanStaleRecord(canonical);
|
|
206
|
+
return emptySession(canonical, 'no_editor', 'Discovery record permissions were not private');
|
|
207
|
+
}
|
|
208
|
+
raw = JSON.parse(readFileSync(sessionPath, 'utf8'));
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (isMissingFile(error))
|
|
212
|
+
return sessionWithoutDiscoveryRecord(canonical);
|
|
213
|
+
cleanStaleRecord(canonical);
|
|
214
|
+
return emptySession(canonical, 'no_editor', `Invalid discovery record: ${errorMessage(error)}`);
|
|
215
|
+
}
|
|
216
|
+
if (!isDiscoveryRecord(raw)) {
|
|
217
|
+
cleanStaleRecord(canonical);
|
|
218
|
+
return emptySession(canonical, 'no_editor', 'Invalid discovery record schema');
|
|
219
|
+
}
|
|
220
|
+
let recordProject;
|
|
221
|
+
try {
|
|
222
|
+
recordProject = canonicalProjectPath(raw.project_path);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
cleanStaleRecord(canonical);
|
|
226
|
+
return emptySession(canonical, 'no_editor', 'Discovery record project path is invalid');
|
|
227
|
+
}
|
|
228
|
+
if (recordProject !== canonical || !isLoopbackPort(raw.port) || !pidExists(raw.editor_pid)) {
|
|
229
|
+
cleanStaleRecord(canonical);
|
|
230
|
+
return {
|
|
231
|
+
...sessionWithoutDiscoveryRecord(canonical),
|
|
232
|
+
reason: 'Stale or cross-project discovery record removed',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (raw.protocol_version !== EDITOR_BRIDGE_PROTOCOL_VERSION) {
|
|
236
|
+
return publicRecord(raw, 'protocol_incompatible', false, `Server protocol ${EDITOR_BRIDGE_PROTOCOL_VERSION}, addon protocol ${raw.protocol_version}`);
|
|
237
|
+
}
|
|
238
|
+
return { record: { ...raw, project_path: canonical } };
|
|
239
|
+
}
|
|
240
|
+
function sessionWithoutDiscoveryRecord(projectPath) {
|
|
241
|
+
const candidates = [
|
|
242
|
+
{ name: 'godot_agent_loop', path: join(projectPath, 'addons', 'godot_agent_loop', 'plugin.cfg') },
|
|
243
|
+
{ name: 'godot_agent_loop_transient', path: join(projectPath, 'addons', 'godot_agent_loop_transient', 'plugin.cfg') },
|
|
244
|
+
];
|
|
245
|
+
const installed = candidates.find(candidate => existsSync(candidate.path));
|
|
246
|
+
if (!installed) {
|
|
247
|
+
return emptySession(projectPath, 'addon_missing_restart_required', 'Install and enable addons/godot_agent_loop, then restart Godot once');
|
|
248
|
+
}
|
|
249
|
+
let config;
|
|
250
|
+
try {
|
|
251
|
+
config = readFileSync(installed.path, 'utf8');
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
return emptySession(projectPath, 'addon_missing_restart_required', `Installed addon could not be read: ${errorMessage(error)}`);
|
|
255
|
+
}
|
|
256
|
+
const protocol = /^protocol_version\s*=\s*"([^"]+)"/m.exec(config)?.[1];
|
|
257
|
+
if (protocol !== EDITOR_BRIDGE_PROTOCOL_VERSION) {
|
|
258
|
+
return emptySession(projectPath, 'addon_upgrade_restart_required', `Installed addon protocol ${protocol ?? 'missing'} must be replaced with protocol ${EDITOR_BRIDGE_PROTOCOL_VERSION}, then Godot restarted`);
|
|
259
|
+
}
|
|
260
|
+
let projectSource = '';
|
|
261
|
+
try {
|
|
262
|
+
projectSource = readFileSync(join(projectPath, 'project.godot'), 'utf8');
|
|
263
|
+
}
|
|
264
|
+
catch { /* validation occurs at the tool boundary */ }
|
|
265
|
+
const enabledSection = /(?:^|\n)\[editor_plugins\]\s*\n([\s\S]*?)(?=\n\[|$)/.exec(projectSource)?.[1] ?? '';
|
|
266
|
+
const enabledLine = /^enabled\s*=\s*PackedStringArray\((.*)\)\s*$/m.exec(enabledSection)?.[1] ?? '';
|
|
267
|
+
const enabled = [...enabledLine.matchAll(/"(?:\\.|[^"])*"/g)]
|
|
268
|
+
.some(match => {
|
|
269
|
+
try {
|
|
270
|
+
const value = JSON.parse(match[0]);
|
|
271
|
+
return value === installed.name || value === `res://addons/${installed.name}/plugin.cfg`;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
if (!enabled) {
|
|
278
|
+
return emptySession(projectPath, 'addon_missing_restart_required', `The ${installed.name} addon is installed but disabled; enable it and restart Godot`);
|
|
279
|
+
}
|
|
280
|
+
return emptySession(projectPath, 'no_editor', 'A compatible addon is enabled, but no matching editor session is currently discoverable');
|
|
281
|
+
}
|
|
282
|
+
function validateHandshake(record, result) {
|
|
283
|
+
if (result.error) {
|
|
284
|
+
throw new Error(typeof result.error === 'string' ? result.error : JSON.stringify(result.error));
|
|
285
|
+
}
|
|
286
|
+
if (result.project_path !== record.project_path
|
|
287
|
+
|| result.editor_pid !== record.editor_pid
|
|
288
|
+
|| result.editor_start_identity !== record.editor_start_identity) {
|
|
289
|
+
throw new EditorSessionIdentityError('Editor handshake identity did not match its discovery record');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
class EditorSessionIdentityError extends Error {
|
|
293
|
+
}
|
|
294
|
+
function isDiscoveryRecord(value) {
|
|
295
|
+
if (!value || typeof value !== 'object')
|
|
296
|
+
return false;
|
|
297
|
+
const record = value;
|
|
298
|
+
return typeof record.project_path === 'string'
|
|
299
|
+
&& Number.isInteger(record.editor_pid) && Number(record.editor_pid) > 0
|
|
300
|
+
&& typeof record.editor_start_identity === 'string' && record.editor_start_identity.length > 0
|
|
301
|
+
&& Number.isInteger(record.port)
|
|
302
|
+
&& typeof record.token === 'string' && record.token.length >= 32
|
|
303
|
+
&& typeof record.protocol_version === 'string'
|
|
304
|
+
&& typeof record.addon_version === 'string'
|
|
305
|
+
&& typeof record.godot_version === 'string'
|
|
306
|
+
&& typeof record.created_at === 'string';
|
|
307
|
+
}
|
|
308
|
+
function publicRecord(record, state, connected, reason) {
|
|
309
|
+
const persistent = existsSync(join(record.project_path, 'addons', 'godot_agent_loop', 'plugin.cfg'));
|
|
310
|
+
const transient = existsSync(join(record.project_path, 'addons', 'godot_agent_loop_transient', 'plugin.cfg'));
|
|
311
|
+
return {
|
|
312
|
+
state,
|
|
313
|
+
project_path: record.project_path,
|
|
314
|
+
connected,
|
|
315
|
+
reused: connected,
|
|
316
|
+
spawned: false,
|
|
317
|
+
editor_pid: record.editor_pid,
|
|
318
|
+
editor_start_identity: record.editor_start_identity,
|
|
319
|
+
port: record.port,
|
|
320
|
+
protocol_version: record.protocol_version,
|
|
321
|
+
addon_version: record.addon_version,
|
|
322
|
+
godot_version: record.godot_version,
|
|
323
|
+
created_at: record.created_at,
|
|
324
|
+
plugin_owned: false,
|
|
325
|
+
plugin_distribution: persistent ? 'persistent' : transient ? 'transient' : 'unavailable',
|
|
326
|
+
...(reason ? { reason } : {}),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function emptySession(projectPath, state, reason) {
|
|
330
|
+
return {
|
|
331
|
+
state,
|
|
332
|
+
project_path: projectPath,
|
|
333
|
+
connected: false,
|
|
334
|
+
reused: false,
|
|
335
|
+
spawned: false,
|
|
336
|
+
editor_pid: null,
|
|
337
|
+
editor_start_identity: null,
|
|
338
|
+
port: null,
|
|
339
|
+
protocol_version: null,
|
|
340
|
+
addon_version: null,
|
|
341
|
+
godot_version: null,
|
|
342
|
+
created_at: null,
|
|
343
|
+
...(reason ? { reason } : {}),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function editorStateFromResult(result, fallback) {
|
|
347
|
+
const state = result.state;
|
|
348
|
+
if (typeof state === 'string' && EDITOR_STATES.has(state))
|
|
349
|
+
return state;
|
|
350
|
+
if (result.error === 'unsaved_conflict')
|
|
351
|
+
return 'unsaved_conflict';
|
|
352
|
+
if (result.paused === true)
|
|
353
|
+
return 'paused';
|
|
354
|
+
return fallback === 'paused' ? 'connected' : fallback;
|
|
355
|
+
}
|
|
356
|
+
const EDITOR_STATES = new Set([
|
|
357
|
+
'connected', 'no_editor', 'addon_missing_restart_required', 'addon_upgrade_restart_required',
|
|
358
|
+
'protocol_incompatible', 'paused', 'syncing', 'unsaved_conflict',
|
|
359
|
+
]);
|
|
360
|
+
export function canonicalProjectPath(projectPath) {
|
|
361
|
+
const absolute = resolve(projectPath);
|
|
362
|
+
try {
|
|
363
|
+
return realpathSync.native(absolute);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return absolute;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function processExists(pid) {
|
|
370
|
+
try {
|
|
371
|
+
process.kill(pid, 0);
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'EPERM');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function isLoopbackPort(port) {
|
|
379
|
+
return Number.isInteger(port) && port > 0 && port < 65_536;
|
|
380
|
+
}
|
|
381
|
+
function cleanStaleRecord(projectPath) {
|
|
382
|
+
try {
|
|
383
|
+
rmSync(join(projectPath, EDITOR_SESSION_FILE), { force: true });
|
|
384
|
+
}
|
|
385
|
+
catch { /* best effort stale cleanup */ }
|
|
386
|
+
}
|
|
387
|
+
function isMissingFile(error) {
|
|
388
|
+
return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT');
|
|
389
|
+
}
|
|
390
|
+
function errorMessage(error) {
|
|
391
|
+
return error instanceof Error ? error.message : String(error);
|
|
392
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { EditorSessionUnavailableError } from './editor-session-registry.js';
|
|
2
|
+
/** Debounces writes and serializes acknowledged filesystem synchronization per project. */
|
|
3
|
+
export class EditorSyncQueue {
|
|
4
|
+
options;
|
|
5
|
+
pending = new Map();
|
|
6
|
+
tails = new Map();
|
|
7
|
+
debounceMs;
|
|
8
|
+
timeoutMs;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
this.debounceMs = options.debounceMs ?? 40;
|
|
12
|
+
this.timeoutMs = options.timeoutMs ?? 15_000;
|
|
13
|
+
}
|
|
14
|
+
async enqueue(event) {
|
|
15
|
+
try {
|
|
16
|
+
const editorSession = await this.options.status(event.project_path);
|
|
17
|
+
if (!editorSession.connected)
|
|
18
|
+
return detachedResult(editorSession);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error instanceof EditorSessionUnavailableError)
|
|
22
|
+
return detachedResult(error.session);
|
|
23
|
+
return {
|
|
24
|
+
sync_status: 'failed', editor_session: null, fallback_reason: errorMessage(error),
|
|
25
|
+
observed_target_state: null, attempts: 1,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return new Promise(resolve => {
|
|
29
|
+
const existing = this.pending.get(event.project_path);
|
|
30
|
+
if (existing) {
|
|
31
|
+
existing.events.push(event);
|
|
32
|
+
existing.resolvers.push(resolve);
|
|
33
|
+
clearTimeout(existing.timer);
|
|
34
|
+
existing.timer = setTimeout(() => { this.flush(event.project_path); }, this.debounceMs);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const batch = {
|
|
38
|
+
events: [event],
|
|
39
|
+
resolvers: [resolve],
|
|
40
|
+
timer: setTimeout(() => { this.flush(event.project_path); }, this.debounceMs),
|
|
41
|
+
};
|
|
42
|
+
this.pending.set(event.project_path, batch);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
flush(projectPath) {
|
|
46
|
+
const batch = this.pending.get(projectPath);
|
|
47
|
+
if (!batch)
|
|
48
|
+
return;
|
|
49
|
+
this.pending.delete(projectPath);
|
|
50
|
+
const previous = this.tails.get(projectPath) ?? Promise.resolve();
|
|
51
|
+
const operation = previous.then(() => this.synchronize(projectPath, batch.events));
|
|
52
|
+
const settled = operation.then(result => {
|
|
53
|
+
for (const resolve of batch.resolvers)
|
|
54
|
+
resolve(result);
|
|
55
|
+
}, error => {
|
|
56
|
+
const result = {
|
|
57
|
+
sync_status: 'failed', editor_session: null, fallback_reason: errorMessage(error),
|
|
58
|
+
observed_target_state: null, attempts: 1,
|
|
59
|
+
};
|
|
60
|
+
for (const resolve of batch.resolvers)
|
|
61
|
+
resolve(result);
|
|
62
|
+
});
|
|
63
|
+
const tail = settled.then(() => undefined, () => undefined);
|
|
64
|
+
this.tails.set(projectPath, tail);
|
|
65
|
+
void tail.finally(() => {
|
|
66
|
+
if (this.tails.get(projectPath) === tail)
|
|
67
|
+
this.tails.delete(projectPath);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async synchronize(projectPath, events) {
|
|
71
|
+
const final = events.at(-1);
|
|
72
|
+
const params = {
|
|
73
|
+
project_path: projectPath,
|
|
74
|
+
command: final.command,
|
|
75
|
+
changes: events.map(event => ({
|
|
76
|
+
command: event.command,
|
|
77
|
+
scene_path: event.scene_path ?? null,
|
|
78
|
+
resource_path: event.resource_path ?? null,
|
|
79
|
+
focus_path: event.focus_path ?? null,
|
|
80
|
+
})),
|
|
81
|
+
...(final.scene_path ? { scene_path: final.scene_path } : {}),
|
|
82
|
+
...(final.resource_path ? { resource_path: final.resource_path } : {}),
|
|
83
|
+
...(final.focus_path ? { focus_path: final.focus_path } : {}),
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
const initialSession = await this.options.status(projectPath);
|
|
87
|
+
if (!initialSession.connected) {
|
|
88
|
+
return detachedResult(initialSession);
|
|
89
|
+
}
|
|
90
|
+
const response = await this.options.send(projectPath, params, this.timeoutMs);
|
|
91
|
+
const editorSession = await this.options.status(projectPath);
|
|
92
|
+
const conflict = response.error === 'unsaved_conflict' || response.state === 'unsaved_conflict';
|
|
93
|
+
return {
|
|
94
|
+
sync_status: conflict ? 'conflict' : response.success === true ? 'acknowledged' : 'failed',
|
|
95
|
+
editor_session: editorSession,
|
|
96
|
+
fallback_reason: conflict ? 'The open scene has unsaved human changes; disk state was not reloaded.'
|
|
97
|
+
: response.success === true ? null : errorMessage(response.error ?? 'Editor did not acknowledge synchronization'),
|
|
98
|
+
observed_target_state: asRecord(response.observed_target_state) ?? response,
|
|
99
|
+
attempts: 1,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error instanceof EditorSessionUnavailableError) {
|
|
104
|
+
return {
|
|
105
|
+
sync_status: 'detached', editor_session: error.session,
|
|
106
|
+
fallback_reason: 'No attached editor was available to acknowledge the persisted mutation.',
|
|
107
|
+
observed_target_state: null, attempts: 1,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const timeout = /timed out/i.test(errorMessage(error));
|
|
111
|
+
let session = null;
|
|
112
|
+
try {
|
|
113
|
+
session = await this.options.status(projectPath);
|
|
114
|
+
}
|
|
115
|
+
catch { /* status is supplemental */ }
|
|
116
|
+
return {
|
|
117
|
+
sync_status: timeout ? 'timeout' : 'failed', editor_session: session,
|
|
118
|
+
fallback_reason: errorMessage(error), observed_target_state: null, attempts: 1,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function detachedResult(editorSession) {
|
|
124
|
+
return {
|
|
125
|
+
sync_status: 'detached', editor_session: editorSession,
|
|
126
|
+
fallback_reason: `No attached editor was available to acknowledge the persisted mutation (${editorSession.state}).`,
|
|
127
|
+
observed_target_state: null, attempts: 1,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function asRecord(value) {
|
|
131
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
132
|
+
}
|
|
133
|
+
function errorMessage(error) {
|
|
134
|
+
return error instanceof Error ? error.message : String(error);
|
|
135
|
+
}
|