@stechquick/studio-cli 4.1.1-0 → 4.1.2-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/dist/studio-cli/src/control/controlClient.js +22 -15
- package/dist/studio-cli/src/control/controlClient.js.map +1 -1
- package/dist/studio-cli/src/control/convertToBlock.js +1 -1
- package/dist/studio-cli/src/control/convertToBlock.js.map +1 -1
- package/dist/studio-cli/src/control/pull.js +17 -8
- package/dist/studio-cli/src/control/pull.js.map +1 -1
- package/dist/studio-cli/src/control/startWorking.js +1 -1
- package/dist/studio-cli/src/control/startWorking.js.map +1 -1
- package/dist/studio-cli/src/server/bridgeServer.js +155 -122
- package/dist/studio-cli/src/server/bridgeServer.js.map +1 -1
- package/dist/studio-cli/src/server/serverInfo.js +26 -14
- package/dist/studio-cli/src/server/serverInfo.js.map +1 -1
- package/dist/studio-cli/src/util/trash.js +137 -0
- package/dist/studio-cli/src/util/trash.js.map +1 -0
- package/dist/studio-cli/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -12,6 +12,7 @@ const qjsonCompiled_1 = require("../../../common/agentic/modelFiles/qjsonCompile
|
|
|
12
12
|
const pull_1 = require("../control/pull");
|
|
13
13
|
const push_1 = require("../control/push");
|
|
14
14
|
const state_1 = require("../util/state");
|
|
15
|
+
const trash_1 = require("../util/trash");
|
|
15
16
|
const frontmatter_1 = require("../../../common/agentic/modelFiles/frontmatter");
|
|
16
17
|
const modelRefs_1 = require("../../../common/agentic/modelFiles/modelRefs");
|
|
17
18
|
const version_1 = require("../version");
|
|
@@ -20,36 +21,37 @@ const PING_INTERVAL_MS = 20000;
|
|
|
20
21
|
const IDLE_EXIT_MS = 120000;
|
|
21
22
|
const RENDEZVOUS_BIND_RETRY_MS = 2000;
|
|
22
23
|
/**
|
|
23
|
-
*
|
|
24
|
-
* control
|
|
24
|
+
* Local WebSocket server (hub). One bridge client (browser) + short-lived
|
|
25
|
+
* control clients later. Port-range discovery + `.studio/server.json` claim + typed dispatch.
|
|
25
26
|
*/
|
|
26
27
|
class BridgeServer {
|
|
27
28
|
wss;
|
|
28
|
-
//
|
|
29
|
+
// Fixed rendezvous port listener — when the web connects here it announces the ephemeral port, then it is released.
|
|
29
30
|
rendezvousWss;
|
|
30
31
|
bridgeClient;
|
|
31
|
-
// AI test
|
|
32
|
+
// AI test browser (?cliRole=test) — SINK: sends no events, only receives syncModels.
|
|
32
33
|
testClient;
|
|
33
|
-
//
|
|
34
|
-
//
|
|
34
|
+
// Bodies pushed in this session (id → item, merged across pushes). Replayed when the test client
|
|
35
|
+
// connects later / on F5 — sync works regardless of connection order.
|
|
35
36
|
lastSync = new Map();
|
|
36
|
-
// GET /render
|
|
37
|
-
// workspace
|
|
37
|
+
// GET /render target — comes from the primary hello, kept IN MEMORY ONLY (not written to server.json:
|
|
38
|
+
// workspace files are exposed to the agent/git, session info must not hit disk).
|
|
38
39
|
renderTarget;
|
|
39
40
|
claimedAppId;
|
|
40
41
|
pingTimer;
|
|
41
42
|
idleTimer;
|
|
42
43
|
rendezvousRetryTimer;
|
|
43
44
|
_pushInProgress = false;
|
|
44
|
-
//
|
|
45
|
-
// echo
|
|
46
|
-
// id
|
|
45
|
+
// Model ids checked out by the push (newly created). Because the studio.checkout → checkoutChangedHook
|
|
46
|
+
// echo can also arrive AFTER the reply (_pushInProgress isn't enough); the checkoutChanged for these ids is
|
|
47
|
+
// suppressed per-id, consume-once → timing-independent, manual checkouts are unaffected.
|
|
47
48
|
_expectedCheckoutEchoes = new Set();
|
|
48
49
|
_deferredModelCreated = [];
|
|
49
50
|
_deferredModelChanged = [];
|
|
50
51
|
_pickerOpen = false;
|
|
52
|
+
trashQueue = new trash_1.TrashQueue();
|
|
51
53
|
host;
|
|
52
|
-
port = 0; //
|
|
54
|
+
port = 0; // ephemeral port assigned by the OS (bind() fills it in)
|
|
53
55
|
rootDir;
|
|
54
56
|
constructor(options = {}) {
|
|
55
57
|
this.host = options.host ?? "127.0.0.1";
|
|
@@ -61,27 +63,28 @@ class BridgeServer {
|
|
|
61
63
|
return this.rootDir;
|
|
62
64
|
}
|
|
63
65
|
async start() {
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
+
// Clear the runtime claim (port/pid) left over from a previous (crashed) server — this is a fresh
|
|
67
|
+
// server. The appId binding is kept.
|
|
68
|
+
//TODO: think about the side effects of doing this here.
|
|
66
69
|
if (this.rootDir !== undefined)
|
|
67
|
-
(0, serverInfo_1.
|
|
70
|
+
(0, serverInfo_1.clearServerInfoRuntimeSync)(this.rootDir);
|
|
68
71
|
await this.bind();
|
|
69
72
|
logger_1.logger.info(`bridge listening on ws://${this.host}:${this.port}`);
|
|
70
73
|
await this.bindRendezvous();
|
|
71
74
|
logger_1.logger.info("waiting for Studio web to connect…");
|
|
72
75
|
this.armIdleExit();
|
|
73
76
|
}
|
|
74
|
-
//
|
|
75
|
-
//
|
|
77
|
+
// Listen on the fixed rendezvous port. When the web connects it announces the ephemeral bridge port, then we
|
|
78
|
+
// release the port (another CLI can use it). If the port is in use (another CLI holds it) silently skip.
|
|
76
79
|
async bindRendezvous() {
|
|
77
80
|
const wss = new wsRaw_1.RawWebSocketServer({ host: this.host, port: protocolVersion_1.RENDEZVOUS_PORT });
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
+
// Announce the ephemeral port when the web connects. Do NOT close the rendezvous HERE — it is closed once the
|
|
82
|
+
// web connects to the ephemeral port and becomes a live client via hello (onHello → closeRendezvous). This way
|
|
83
|
+
// if the web can't reach the ephemeral port it can retry via the rendezvous.
|
|
81
84
|
wss.onConnection((conn) => {
|
|
82
85
|
new peer_1.Peer(conn).send("rendezvous", { port: this.port });
|
|
83
86
|
logger_1.logger.info(`rendezvous → announced port ${this.port}`);
|
|
84
|
-
setTimeout(() => conn.close(1000, "rendezvous done"), 500); // frame flush
|
|
87
|
+
setTimeout(() => conn.close(1000, "rendezvous done"), 500); // let the frame flush
|
|
85
88
|
});
|
|
86
89
|
try {
|
|
87
90
|
await wss.listen();
|
|
@@ -91,9 +94,9 @@ class BridgeServer {
|
|
|
91
94
|
catch (err) {
|
|
92
95
|
wss.close();
|
|
93
96
|
if (err.code === "EADDRINUSE") {
|
|
94
|
-
//
|
|
95
|
-
// web
|
|
96
|
-
//
|
|
97
|
+
// Another CLI is already the discovery point. If this is a protocol-launch (no folder) this instance is
|
|
98
|
+
// redundant — the web finds the rendezvous holder, not this one → exit so it doesn't stay orphaned. If
|
|
99
|
+
// launched manually with `--dir <path>` (rootDir set) don't exit; the user opened it deliberately, reachable via savedPort.
|
|
97
100
|
if (this.rootDir === undefined) {
|
|
98
101
|
logger_1.logger.info(`rendezvous ${protocolVersion_1.RENDEZVOUS_PORT} held by another CLI — redundant launch, exiting`);
|
|
99
102
|
this.close();
|
|
@@ -118,15 +121,16 @@ class BridgeServer {
|
|
|
118
121
|
close() {
|
|
119
122
|
this.stopPing();
|
|
120
123
|
this.cancelIdleExit();
|
|
124
|
+
this.trashQueue.dispose();
|
|
121
125
|
(0, pickWorkspaceDir_1.killActivePicker)();
|
|
122
126
|
this.closeRendezvous();
|
|
123
127
|
this.bridgeClient?.close(1001, "cli shutting down");
|
|
124
128
|
this.testClient?.close(1001, "cli shutting down");
|
|
125
129
|
this.wss?.close();
|
|
126
130
|
if (this.rootDir !== undefined)
|
|
127
|
-
(0, serverInfo_1.
|
|
131
|
+
(0, serverInfo_1.clearServerInfoRuntimeSync)(this.rootDir);
|
|
128
132
|
}
|
|
129
|
-
/**
|
|
133
|
+
/** Bind to the ephemeral port assigned by the OS (port:0 → the OS picks a free port). The web learns the port via the rendezvous. */
|
|
130
134
|
async bind() {
|
|
131
135
|
const wss = new wsRaw_1.RawWebSocketServer({ host: this.host, port: 0 });
|
|
132
136
|
wss.onConnection((conn) => this.onConnection(conn));
|
|
@@ -135,7 +139,7 @@ class BridgeServer {
|
|
|
135
139
|
this.wss = wss;
|
|
136
140
|
this.port = wss.boundPort;
|
|
137
141
|
}
|
|
138
|
-
// ---
|
|
142
|
+
// --- connection lifecycle ---
|
|
139
143
|
onConnection(conn) {
|
|
140
144
|
const peer = new peer_1.Peer(conn);
|
|
141
145
|
peer.onEnvelope((env) => this.dispatch(peer, env));
|
|
@@ -144,8 +148,8 @@ class BridgeServer {
|
|
|
144
148
|
}
|
|
145
149
|
onPeerClose(peer) {
|
|
146
150
|
if (peer === this.bridgeClient) {
|
|
147
|
-
// F5/
|
|
148
|
-
//
|
|
151
|
+
// F5/page close: the client goes away but the claim + server.json REMAIN (for reconnect).
|
|
152
|
+
// If the tab closed while the picker was open, close the orphaned window (its result will be dropped anyway).
|
|
149
153
|
if (this._pickerOpen)
|
|
150
154
|
(0, pickWorkspaceDir_1.killActivePicker)();
|
|
151
155
|
this.bridgeClient = undefined;
|
|
@@ -164,8 +168,8 @@ class BridgeServer {
|
|
|
164
168
|
dispatch(peer, env) {
|
|
165
169
|
switch (env.type) {
|
|
166
170
|
case "whoAreYou":
|
|
167
|
-
/** Probe —
|
|
168
|
-
*
|
|
171
|
+
/** Probe — NO side effects; reports the current state + protocol/version (does not claim).
|
|
172
|
+
* The compatibility check is done in the UI based on this reply (see docs/PROTOCOL-VERSION-CHECK.md). */
|
|
169
173
|
peer.reply(env, "whoAreYouResult", { hasClient: this.bridgeClient?.isOpen ?? false, hasTestClient: this.testClient?.isOpen ?? false, appId: this.claimedAppId ?? null, protocol: protocolVersion_1.GENERATION, cliVersion: version_1.VERSION });
|
|
170
174
|
return;
|
|
171
175
|
case "hello":
|
|
@@ -180,7 +184,7 @@ class BridgeServer {
|
|
|
180
184
|
case "pong":
|
|
181
185
|
return;
|
|
182
186
|
case "bye":
|
|
183
|
-
//
|
|
187
|
+
// Only the PRIMARY's bye shuts down the server; the test client leaving just frees the slot.
|
|
184
188
|
if (peer === this.testClient) {
|
|
185
189
|
logger_1.logger.info(`test client said bye (${env.payload.reason})`);
|
|
186
190
|
this.testClient = undefined;
|
|
@@ -220,8 +224,8 @@ class BridgeServer {
|
|
|
220
224
|
logger_1.logger.warn(`unhandled message type: ${env.type}`);
|
|
221
225
|
}
|
|
222
226
|
}
|
|
223
|
-
/**
|
|
224
|
-
*
|
|
227
|
+
/** File-writing events are only accepted from the primary (test is a SINK; the web side doesn't send
|
|
228
|
+
* them anyway — this is a second safeguard). */
|
|
225
229
|
isPrimary(peer) {
|
|
226
230
|
if (peer === this.bridgeClient)
|
|
227
231
|
return true;
|
|
@@ -230,20 +234,20 @@ class BridgeServer {
|
|
|
230
234
|
}
|
|
231
235
|
onHello(peer, env) {
|
|
232
236
|
const p = env.payload;
|
|
233
|
-
//
|
|
234
|
-
//
|
|
237
|
+
// Protocol compatibility is resolved on the UI side in the whoAreYou probe; if we got to hello the parties
|
|
238
|
+
// are already compatible (see docs/PROTOCOL-VERSION-CHECK.md). NO protocol check here.
|
|
235
239
|
if (p.role === "test") {
|
|
236
240
|
this.onHelloTest(peer, env);
|
|
237
241
|
return;
|
|
238
242
|
}
|
|
239
|
-
//
|
|
243
|
+
// If there's already a live bridge client (another tab) → busy.
|
|
240
244
|
if (this.bridgeClient?.isOpen && this.bridgeClient !== peer) {
|
|
241
245
|
peer.reply(env, "helloAck", { cliVersion: version_1.VERSION, protocol: protocolVersion_1.GENERATION, rootDir: this.rootDir ?? null }, { message: "bridge already in use", title: "Bridge busy" });
|
|
242
246
|
logger_1.logger.warn(`hello rejected — bridge already in use (app ${this.claimedAppId})`);
|
|
243
247
|
peer.close(1013, "bridge already in use");
|
|
244
248
|
return;
|
|
245
249
|
}
|
|
246
|
-
//
|
|
250
|
+
// Claimed and a different app → this server belongs to another workspace.
|
|
247
251
|
if (this.claimedAppId !== undefined && this.claimedAppId !== p.appId) {
|
|
248
252
|
peer.reply(env, "helloAck", { cliVersion: version_1.VERSION, protocol: protocolVersion_1.GENERATION, rootDir: this.rootDir ?? null }, { message: `server claimed by another app (${this.claimedAppId})`, title: "Wrong workspace" });
|
|
249
253
|
logger_1.logger.warn(`hello rejected — claimed by ${this.claimedAppId}, got ${p.appId}`);
|
|
@@ -253,9 +257,9 @@ class BridgeServer {
|
|
|
253
257
|
const fresh = this.claimedAppId === undefined;
|
|
254
258
|
this.claimedAppId = p.appId;
|
|
255
259
|
this.bridgeClient = peer;
|
|
256
|
-
//
|
|
260
|
+
// Refresh on every primary hello (origin/org may have changed on reconnect).
|
|
257
261
|
this.renderTarget = (p.organizationId && p.origin) ? { organizationId: p.organizationId, origin: p.origin } : undefined;
|
|
258
|
-
//
|
|
262
|
+
// Diagnostic: make it visible at connect time whether /render will work (an old UI build doesn't send org/origin).
|
|
259
263
|
if (this.renderTarget)
|
|
260
264
|
logger_1.logger.info(`render check ready — ${this.renderTarget.origin}/studio/${this.renderTarget.organizationId}/${p.appId}`);
|
|
261
265
|
else
|
|
@@ -270,12 +274,12 @@ class BridgeServer {
|
|
|
270
274
|
peer.reply(env, "helloAck", { cliVersion: version_1.VERSION, protocol: protocolVersion_1.GENERATION, rootDir: this.rootDir ?? null });
|
|
271
275
|
this.startPing();
|
|
272
276
|
this.cancelIdleExit();
|
|
273
|
-
//
|
|
277
|
+
// The web connected to the ephemeral port and handshaked → discovery done, release the rendezvous.
|
|
274
278
|
this.closeRendezvous();
|
|
275
279
|
}
|
|
276
|
-
/** Test
|
|
277
|
-
*
|
|
278
|
-
*
|
|
280
|
+
/** Test-role hello. The test client does NOT claim, doesn't touch server.json; it just attaches to the
|
|
281
|
+
* existing claim. A live primary is NOT required — the claim survives the primary's F5 (onPeerClose) and
|
|
282
|
+
* a test client without a primary is harmless (control commands return 409 anyway). */
|
|
279
283
|
onHelloTest(peer, env) {
|
|
280
284
|
const p = env.payload;
|
|
281
285
|
if (this.claimedAppId === undefined) {
|
|
@@ -301,7 +305,7 @@ class BridgeServer {
|
|
|
301
305
|
peer.reply(env, "helloAck", { cliVersion: version_1.VERSION, protocol: protocolVersion_1.GENERATION, rootDir: this.rootDir ?? null });
|
|
302
306
|
this.startPing();
|
|
303
307
|
this.cancelIdleExit();
|
|
304
|
-
//
|
|
308
|
+
// The test client may have connected/F5'd AFTER the pushes → send the session's accumulated bodies.
|
|
305
309
|
if (this.lastSync.size > 0)
|
|
306
310
|
void this.sendSyncModels("replay");
|
|
307
311
|
}
|
|
@@ -322,9 +326,9 @@ class BridgeServer {
|
|
|
322
326
|
logger_1.logger.error(`failed to write server.json: ${err.message}`);
|
|
323
327
|
}
|
|
324
328
|
}
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
329
|
+
// Folderless server: the web sees helloAck.rootDir === null and asks for pickFolder. The native picker opens;
|
|
330
|
+
// if an occupied folder (another live server's claim) is picked, folderInUse is sent and the picker reopens.
|
|
331
|
+
// On selection rootDir is set + (if claimed) server.json is written. Cancel → path:null.
|
|
328
332
|
async onPickFolder(peer, env) {
|
|
329
333
|
if (peer !== this.bridgeClient) {
|
|
330
334
|
peer.reply(env, "pickFolderResult", { path: null }, { message: "only the primary client can pick a folder" });
|
|
@@ -343,7 +347,7 @@ class BridgeServer {
|
|
|
343
347
|
while (peer.isOpen) {
|
|
344
348
|
const picked = await (0, pickWorkspaceDir_1.pickWorkspaceDir)();
|
|
345
349
|
if (!peer.isOpen)
|
|
346
|
-
return; // picker
|
|
350
|
+
return; // tab closed while the picker was open → drop the result
|
|
347
351
|
if (!picked) {
|
|
348
352
|
peer.reply(env, "pickFolderResult", { path: null });
|
|
349
353
|
return;
|
|
@@ -353,6 +357,13 @@ class BridgeServer {
|
|
|
353
357
|
peer.send("folderInUse", { path: picked });
|
|
354
358
|
continue;
|
|
355
359
|
}
|
|
360
|
+
// The folder carries a persistent appId binding; if it belongs to a different app, don't let this
|
|
361
|
+
// app's files mix into it — reopen the picker (same warning as a live in-use folder).
|
|
362
|
+
if (this.claimedAppId !== undefined && belongsToAnotherApp(picked, this.claimedAppId)) {
|
|
363
|
+
logger_1.logger.warn(`workspace belongs to another app — asking again: ${picked}`);
|
|
364
|
+
peer.send("folderInUse", { path: picked });
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
356
367
|
this.rootDir = picked;
|
|
357
368
|
if (this.claimedAppId !== undefined)
|
|
358
369
|
await this.claim(this.claimedAppId);
|
|
@@ -365,10 +376,10 @@ class BridgeServer {
|
|
|
365
376
|
this._pickerOpen = false;
|
|
366
377
|
}
|
|
367
378
|
}
|
|
368
|
-
// --- control
|
|
369
|
-
//
|
|
379
|
+
// --- control channel (HTTP /control/<cmd>) — hub relay ---
|
|
380
|
+
// Non-WS HTTP coming from wsRaw. Own the control request (true); everything else false → 426.
|
|
370
381
|
routeRequest(req, res) {
|
|
371
|
-
// CORS preflight — browser'
|
|
382
|
+
// CORS preflight — handle the browser's OPTIONS request.
|
|
372
383
|
if (req.method === "OPTIONS") {
|
|
373
384
|
res.writeHead(204, this.corsHeaders());
|
|
374
385
|
res.end();
|
|
@@ -379,20 +390,20 @@ class BridgeServer {
|
|
|
379
390
|
void this.handleControl(cmd, req, res);
|
|
380
391
|
return true;
|
|
381
392
|
}
|
|
382
|
-
// Render-check: agent
|
|
383
|
-
//
|
|
393
|
+
// Render-check: the agent opens this fixed local URL with Playwright; the CLI requests a FRESH token from
|
|
394
|
+
// the primary tab and 302-redirects to the Studio editor URL. The agent never sees/handles the token.
|
|
384
395
|
if (req.method === "GET" && req.url && req.url.split("?")[0] === "/render") {
|
|
385
396
|
void this.handleRenderRedirect(res);
|
|
386
397
|
return true;
|
|
387
398
|
}
|
|
388
399
|
return false;
|
|
389
400
|
}
|
|
390
|
-
/** `<origin>/studio/<org>/<app>?cliRole=test#token=<
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
* `?cliRole=test`
|
|
401
|
+
/** 302 redirect to `<origin>/studio/<org>/<app>?cliRole=test#token=<fresh token>`.
|
|
402
|
+
* The token is requested ON DEMAND from the primary on every request (the hello-time token goes stale in ~1 hour;
|
|
403
|
+
* api.getToken() silently refreshes it from MSAL) and is carried ONLY in the fragment (not the query → doesn't leak
|
|
404
|
+
* to access logs/Referer; never reaches the server). Never written to disk. The response DELIBERATELY has NO CORS
|
|
405
|
+
* header — so a foreign page can't read the redirect chain via fetch; the endpoint is for direct navigation only.
|
|
406
|
+
* Thanks to `?cliRole=test` the tab auto-connects as a test client as soon as it opens. */
|
|
396
407
|
async handleRenderRedirect(res) {
|
|
397
408
|
if (!this.bridgeClient?.isOpen || this.claimedAppId === undefined) {
|
|
398
409
|
this.respondNoCors(res, 503, { error: "no authenticated Studio session — open Studio and connect the CLI first" });
|
|
@@ -439,7 +450,7 @@ class BridgeServer {
|
|
|
439
450
|
req.on("error", reject);
|
|
440
451
|
});
|
|
441
452
|
}
|
|
442
|
-
//
|
|
453
|
+
// Resolve the control command: relay the needed message to the bridgeClient, return the reply over HTTP.
|
|
443
454
|
async handleControl(cmd, req, res) {
|
|
444
455
|
if (!this.bridgeClient?.isOpen) {
|
|
445
456
|
this.respond(res, 409, { error: "studio not connected" });
|
|
@@ -452,16 +463,18 @@ class BridgeServer {
|
|
|
452
463
|
try {
|
|
453
464
|
switch (cmd) {
|
|
454
465
|
case "pull": {
|
|
455
|
-
// browser pullResult (snapshot)
|
|
456
|
-
//
|
|
466
|
+
// the browser replies with pullResult (snapshot) → the SERVER writes the files (+ state).
|
|
467
|
+
// Response: NDJSON stream — {"progress":x,"total":y} after each model, {"message":...} at the end
|
|
457
468
|
const snapshot = await this.bridgeClient.request("pull", {}, 300000);
|
|
458
469
|
const modelTotal = snapshot.models.length;
|
|
459
470
|
res.writeHead(200, { "Content-Type": "application/x-ndjson", ...this.corsHeaders() });
|
|
460
471
|
try {
|
|
461
|
-
const { total, readOnly } = await (0, pull_1.pull)(this.workspaceDir, snapshot, (written) => {
|
|
472
|
+
const { total, readOnly, failed } = await (0, pull_1.pull)(this.workspaceDir, snapshot, (written) => {
|
|
462
473
|
res.write(JSON.stringify({ progress: written, total: modelTotal }) + "\n");
|
|
463
474
|
});
|
|
464
|
-
|
|
475
|
+
for (const f of failed)
|
|
476
|
+
res.write(JSON.stringify({ skipped: `${f.name} — ${f.error}` }) + "\n");
|
|
477
|
+
res.end(JSON.stringify({ message: `${total} model(s)${readOnly > 0 ? ` (${readOnly} read-only)` : ""}${failed.length > 0 ? `, ${failed.length} failed` : ""} and .ai-kit pulled` }) + "\n");
|
|
465
478
|
}
|
|
466
479
|
catch (err) {
|
|
467
480
|
res.end(JSON.stringify({ error: err.message }) + "\n");
|
|
@@ -469,12 +482,12 @@ class BridgeServer {
|
|
|
469
482
|
return;
|
|
470
483
|
}
|
|
471
484
|
case "push": {
|
|
472
|
-
// rootDir
|
|
485
|
+
// scan rootDir → collect existing (models) + new (newModels) files → bulk relay → update state.
|
|
473
486
|
const state = (0, state_1.readState)(this.workspaceDir);
|
|
474
487
|
const { models, newModels, skipped, deletedIds } = await (0, push_1.push)(this.workspaceDir, state);
|
|
475
|
-
// NDJSON stream
|
|
488
|
+
// open an NDJSON stream — the client draws a progress bar (controlClient.ts readNdjsonStream).
|
|
476
489
|
res.writeHead(200, { "Content-Type": "application/x-ndjson", ...this.corsHeaders() });
|
|
477
|
-
//
|
|
490
|
+
// Write an individual line for each skipped file.
|
|
478
491
|
for (const s of skipped) {
|
|
479
492
|
const hint = s.reason === "readonly" ? "read-only (use startWorking to check out)" : "unsupported type";
|
|
480
493
|
res.write(JSON.stringify({ skipped: `${s.localKey} — ${hint}` }) + "\n");
|
|
@@ -484,20 +497,28 @@ class BridgeServer {
|
|
|
484
497
|
return;
|
|
485
498
|
}
|
|
486
499
|
const total = models.length + newModels.length + deletedIds.length;
|
|
487
|
-
//
|
|
488
|
-
//
|
|
500
|
+
// modelChanged/modelCreated events that arrive during the push come from the push's own updateModel
|
|
501
|
+
// calls (echo). Put them in the deferred queue; process when the push finishes.
|
|
489
502
|
this._pushInProgress = true;
|
|
490
|
-
this._expectedCheckoutEchoes.clear(); //
|
|
503
|
+
this._expectedCheckoutEchoes.clear(); // clear ids left over from a previous push (whose echo never arrived)
|
|
491
504
|
try {
|
|
492
505
|
res.write(JSON.stringify({ progress: 0, total }) + "\n");
|
|
493
506
|
const pushTimeoutMs = Math.max(300000, total * 60000);
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
507
|
+
const heartbeat = setInterval(() => { if (!res.writableEnded)
|
|
508
|
+
res.write(JSON.stringify({ keepalive: 1 }) + "\n"); }, 20000);
|
|
509
|
+
let result;
|
|
510
|
+
try {
|
|
511
|
+
result = await this.bridgeClient.request("push", { models, newModels, deletedIds }, pushTimeoutMs);
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
clearInterval(heartbeat);
|
|
515
|
+
}
|
|
516
|
+
// Newly created models are checked out by the push → a checkoutChanged echo is expected.
|
|
517
|
+
// (Existing models don't check out on rename/move/body.) Even if the echo arrives late it's suppressed by id.
|
|
497
518
|
for (const r of result.newModelResults ?? [])
|
|
498
519
|
if (r.ok && r.id)
|
|
499
520
|
this._expectedCheckoutEchoes.add(r.id);
|
|
500
|
-
// Progress:
|
|
521
|
+
// Progress: one line per applied item (existing + new + deleted).
|
|
501
522
|
let done = 0;
|
|
502
523
|
const tick = () => res.write(JSON.stringify({ progress: ++done, total }) + "\n");
|
|
503
524
|
const counts = { okBody: 0, okRenamed: 0, okMoved: 0, okBoth: 0, okDeleted: 0, okRefFiles: 0 };
|
|
@@ -507,8 +528,8 @@ class BridgeServer {
|
|
|
507
528
|
counts.okRefFiles = await this.syncReferences(result.results ?? [], result.deletedResults ?? [], state, res);
|
|
508
529
|
await (0, state_1.writeState)(this.workspaceDir, state);
|
|
509
530
|
res.end(JSON.stringify({ message: this.buildPushSummary(counts, models, newModels, skipped) }) + "\n");
|
|
510
|
-
//
|
|
511
|
-
//
|
|
531
|
+
// Reflect to the test tab — after the HTTP response is closed, best-effort (never blocks the push;
|
|
532
|
+
// errors don't leak into the push either — res is already closed, must not fall into catch).
|
|
512
533
|
try {
|
|
513
534
|
this.afterPushSync(models, newModels, result);
|
|
514
535
|
}
|
|
@@ -521,8 +542,8 @@ class BridgeServer {
|
|
|
521
542
|
}
|
|
522
543
|
finally {
|
|
523
544
|
this._pushInProgress = false;
|
|
524
|
-
//
|
|
525
|
-
//
|
|
545
|
+
// A checkout echo that may arrive after the reply is suppressed (per-id) via _expectedCheckoutEchoes.
|
|
546
|
+
// Process the deferred echo events (those originating from the push itself become no-ops).
|
|
526
547
|
const deferredChanged = this._deferredModelChanged.splice(0);
|
|
527
548
|
for (const d of deferredChanged)
|
|
528
549
|
void this.handleModelChanged(d);
|
|
@@ -586,9 +607,9 @@ class BridgeServer {
|
|
|
586
607
|
res.writeHead(status, { "Content-Type": "application/json", ...this.corsHeaders() });
|
|
587
608
|
res.end(JSON.stringify(body));
|
|
588
609
|
}
|
|
589
|
-
// --- push
|
|
590
|
-
/**
|
|
591
|
-
*
|
|
610
|
+
// --- push result processing (handleControl "push" helpers) ---
|
|
611
|
+
/** Existing models: update the state hash on bodyChanged; rename/move counters; progress per item.
|
|
612
|
+
* If the move changed the owner, the ownerName in the local frontmatter is updated too. */
|
|
592
613
|
async applyExistingResults(results, models, state, counts, tick) {
|
|
593
614
|
for (const r of results) {
|
|
594
615
|
if (!r.ok) {
|
|
@@ -612,7 +633,7 @@ class BridgeServer {
|
|
|
612
633
|
tick();
|
|
613
634
|
}
|
|
614
635
|
}
|
|
615
|
-
/**
|
|
636
|
+
/** After a move, updates the file's frontmatter ownerName to the new owner (body/hash unchanged). */
|
|
616
637
|
async rewriteOwnerName(pm, ownerName) {
|
|
617
638
|
const abs = (0, node_path_1.join)(this.workspaceDir, ...pm.relDir.split("/").filter(Boolean), `${pm.newName}.${pm.modelType}`);
|
|
618
639
|
try {
|
|
@@ -626,7 +647,7 @@ class BridgeServer {
|
|
|
626
647
|
logger_1.logger.warn(`frontmatter ownerName update failed for ${pm.newName}: ${err.message}`);
|
|
627
648
|
}
|
|
628
649
|
}
|
|
629
|
-
/**
|
|
650
|
+
/** New files: write frontmatter to the newly created one, compute the state hash; progress per item. */
|
|
630
651
|
async applyNewModelResults(results, newModels, state, counts, tick, res) {
|
|
631
652
|
for (const r of results) {
|
|
632
653
|
if (!r.ok || !r.id || !r.key) {
|
|
@@ -657,7 +678,7 @@ class BridgeServer {
|
|
|
657
678
|
tick();
|
|
658
679
|
}
|
|
659
680
|
}
|
|
660
|
-
/**
|
|
681
|
+
/** Deleted models: remove from state; progress per item. */
|
|
661
682
|
applyDeletedResults(results, state, counts, tick) {
|
|
662
683
|
for (const r of results) {
|
|
663
684
|
if (!r.ok) {
|
|
@@ -670,9 +691,9 @@ class BridgeServer {
|
|
|
670
691
|
tick();
|
|
671
692
|
}
|
|
672
693
|
}
|
|
673
|
-
/** EPIC 13b — rename/move
|
|
674
|
-
* delete
|
|
675
|
-
* (Studio
|
|
694
|
+
/** EPIC 13b — after rename/move, fix <<type:oldPath>> → <<type:newPath>> in other files;
|
|
695
|
+
* on delete, warn about dangling references with a `skipped` line. Returns the number of fixed files.
|
|
696
|
+
* (In Studio the link is fixed by id; locally it's by path, so the REFERENCING files go stale.) */
|
|
676
697
|
async syncReferences(results, deletedResults, state, res) {
|
|
677
698
|
const rootDir = this.workspaceDir;
|
|
678
699
|
const renames = results.flatMap(r => (r.ok && r.oldPath && r.newPath && r.oldPath !== r.newPath) ? [{ oldPath: r.oldPath, newPath: r.newPath }] : []);
|
|
@@ -691,7 +712,7 @@ class BridgeServer {
|
|
|
691
712
|
}
|
|
692
713
|
}
|
|
693
714
|
if (changed) {
|
|
694
|
-
//
|
|
715
|
+
// If the referencing file isn't checked out (0444) it can't be written → make it temporarily writable, then revert.
|
|
695
716
|
const wasWritable = !!((await (0, promises_1.stat)(abs)).mode & 0o200);
|
|
696
717
|
if (!wasWritable)
|
|
697
718
|
await (0, promises_1.chmod)(abs, 0o644);
|
|
@@ -712,7 +733,7 @@ class BridgeServer {
|
|
|
712
733
|
}
|
|
713
734
|
return okRefFiles;
|
|
714
735
|
}
|
|
715
|
-
/** push
|
|
736
|
+
/** push summary message (last NDJSON line). */
|
|
716
737
|
buildPushSummary(counts, models, newModels, skipped) {
|
|
717
738
|
const parts = [];
|
|
718
739
|
const bodyTotal = models.filter(m => m.bodyChanged).length + newModels.length;
|
|
@@ -738,10 +759,10 @@ class BridgeServer {
|
|
|
738
759
|
parts.push(`${unsupportedCount} unsupported type skipped`);
|
|
739
760
|
return parts.join(", ");
|
|
740
761
|
}
|
|
741
|
-
// --- test client sync (
|
|
742
|
-
/**
|
|
743
|
-
* client
|
|
744
|
-
*
|
|
762
|
+
// --- test client sync (reflecting pushed bodies to the test tab) ---
|
|
763
|
+
/** Extracts sync items from the push results, merges them into lastSync and (if connected) sends them to the
|
|
764
|
+
* test client. The primary push payload is NEVER replayed — applyNewModel creates a model on the backend,
|
|
765
|
+
* replaying would double-create; so this lightweight message is sent with the FINAL ids. */
|
|
745
766
|
afterPushSync(models, newModels, result) {
|
|
746
767
|
const items = [];
|
|
747
768
|
for (const r of result.results ?? []) {
|
|
@@ -754,7 +775,7 @@ class BridgeServer {
|
|
|
754
775
|
}
|
|
755
776
|
for (const r of result.newModelResults ?? []) {
|
|
756
777
|
if (!r.ok || !r.id)
|
|
757
|
-
continue; //
|
|
778
|
+
continue; // if create failed on the primary it does NOT go to the test at all (no ghost model)
|
|
758
779
|
const nm = newModels.find(m => m.localKey === r.localKey);
|
|
759
780
|
if (!nm)
|
|
760
781
|
continue;
|
|
@@ -765,7 +786,7 @@ class BridgeServer {
|
|
|
765
786
|
for (const d of result.deletedResults ?? [])
|
|
766
787
|
if (d.ok)
|
|
767
788
|
this.lastSync.delete(d.id);
|
|
768
|
-
//
|
|
789
|
+
// bodyless rename/move/create/delete also require refreshItems in the test tab.
|
|
769
790
|
const treeChanged = (result.results ?? []).some(r => r.ok && (r.renamed === true || r.moved === true))
|
|
770
791
|
|| (result.newModelResults ?? []).some(r => r.ok)
|
|
771
792
|
|| (result.deletedResults ?? []).some(r => r.ok);
|
|
@@ -773,8 +794,8 @@ class BridgeServer {
|
|
|
773
794
|
void this.sendSyncModels("push", items, treeChanged);
|
|
774
795
|
}
|
|
775
796
|
}
|
|
776
|
-
/** syncModels
|
|
777
|
-
* Best-effort:
|
|
797
|
+
/** Sends syncModels to the test client. replay → the entire lastSync (after test hello/F5).
|
|
798
|
+
* Best-effort: errors are only logged; timeout 60s (the handler may refreshItems + open the editor). */
|
|
778
799
|
async sendSyncModels(reason, items, treeChanged = false) {
|
|
779
800
|
if (!this.testClient?.isOpen || this.claimedAppId === undefined)
|
|
780
801
|
return;
|
|
@@ -791,7 +812,7 @@ class BridgeServer {
|
|
|
791
812
|
logger_1.logger.warn(`test sync (${reason}) failed: ${err.message}`);
|
|
792
813
|
}
|
|
793
814
|
}
|
|
794
|
-
// --- web → local
|
|
815
|
+
// --- web → local auto sync (modelChanged) ---
|
|
795
816
|
async handleModelChanged(payload) {
|
|
796
817
|
if (this.rootDir === undefined) {
|
|
797
818
|
logger_1.logger.debug(`modelChanged: no workspace — dropping id=${payload.modelId}`);
|
|
@@ -812,28 +833,28 @@ class BridgeServer {
|
|
|
812
833
|
const fm = (0, frontmatter_1.parseFrontmatter)(existing);
|
|
813
834
|
if (!fm)
|
|
814
835
|
return;
|
|
815
|
-
// model="" → rename-only
|
|
836
|
+
// model="" → rename-only message (the body wasn't loaded in the VMM); skip writing the body
|
|
816
837
|
if (payload.model !== "") {
|
|
817
838
|
const ext = (0, node_path_1.extname)(abs).slice(1).toLowerCase();
|
|
818
839
|
const body = ext === "qjson" ? (0, qjsonCompiled_1.stripQJsonCompiledFields)(payload.model) : payload.model;
|
|
819
|
-
//
|
|
840
|
+
// If the user saved in Studio, the test replay (F5) must not revert to the stale push body.
|
|
820
841
|
const prev = this.lastSync.get(payload.modelId);
|
|
821
842
|
if (prev)
|
|
822
843
|
this.lastSync.set(payload.modelId, { ...prev, body });
|
|
823
844
|
const state = (0, state_1.readState)(rootDir);
|
|
824
|
-
// Push echo guard: hash state
|
|
845
|
+
// Push echo guard: if the hash equals state (we just pushed) skip writing the body
|
|
825
846
|
if ((0, state_1.sha)(body) !== state[payload.modelId]) {
|
|
826
847
|
try {
|
|
827
848
|
await (0, promises_1.chmod)(abs, 0o644);
|
|
828
849
|
}
|
|
829
|
-
catch { /*
|
|
850
|
+
catch { /* already writable or the file doesn't exist */ }
|
|
830
851
|
await (0, promises_1.writeFile)(abs, (0, frontmatter_1.writeWithFrontmatter)(fm.meta, body), "utf8");
|
|
831
852
|
state[payload.modelId] = (0, state_1.sha)(body);
|
|
832
853
|
await (0, state_1.writeState)(rootDir, state);
|
|
833
854
|
logger_1.logger.info(`auto-pulled: ${(0, node_path_1.relative)(rootDir, abs)}`);
|
|
834
855
|
}
|
|
835
856
|
}
|
|
836
|
-
// Rename
|
|
857
|
+
// Rename and move are done in order; currentAbs tracks the current path.
|
|
837
858
|
let currentAbs = abs;
|
|
838
859
|
if (payload.newName) {
|
|
839
860
|
const newAbs = (0, node_path_1.join)((0, node_path_1.dirname)(currentAbs), payload.newName + (0, node_path_1.extname)(currentAbs));
|
|
@@ -857,9 +878,9 @@ class BridgeServer {
|
|
|
857
878
|
}
|
|
858
879
|
}
|
|
859
880
|
}
|
|
860
|
-
//
|
|
861
|
-
// Echo guard:
|
|
862
|
-
//
|
|
881
|
+
// A new model was created in Studio → write a single file locally (same writeModel logic as pull).
|
|
882
|
+
// Echo guard: if a file with the same ID already exists (created by the push or already pulled) skip.
|
|
883
|
+
// Events arriving while a push is in progress go into the deferred queue (frontmatter not written yet).
|
|
863
884
|
async handleModelCreated(payload) {
|
|
864
885
|
if (this.rootDir === undefined) {
|
|
865
886
|
logger_1.logger.debug(`modelCreated: no workspace — dropping id=${payload.model.ID}`);
|
|
@@ -888,28 +909,27 @@ class BridgeServer {
|
|
|
888
909
|
logger_1.logger.debug(`modelDeleted: no workspace — dropping id=${payload.modelId}`);
|
|
889
910
|
return;
|
|
890
911
|
}
|
|
891
|
-
this.lastSync.delete(payload.modelId); // replay
|
|
912
|
+
this.lastSync.delete(payload.modelId); // so replay doesn't resurrect the deleted model
|
|
892
913
|
const abs = await this.findFileById(payload.modelId);
|
|
893
914
|
if (!abs) {
|
|
894
915
|
logger_1.logger.debug(`modelDeleted: no local file for id=${payload.modelId}`);
|
|
895
916
|
return;
|
|
896
917
|
}
|
|
897
|
-
await (0, promises_1.unlink)(abs);
|
|
898
918
|
const state = (0, state_1.readState)(this.workspaceDir);
|
|
899
919
|
delete state[payload.modelId];
|
|
900
920
|
await (0, state_1.writeState)(this.workspaceDir, state);
|
|
901
|
-
|
|
921
|
+
this.trashQueue.enqueue(abs, this.workspaceDir);
|
|
902
922
|
}
|
|
903
923
|
async handleCheckoutChanged(payload) {
|
|
904
924
|
if (this.rootDir === undefined) {
|
|
905
925
|
logger_1.logger.debug(`checkoutChanged: no workspace — dropping id=${payload.modelId}`);
|
|
906
926
|
return;
|
|
907
927
|
}
|
|
908
|
-
//
|
|
909
|
-
//
|
|
910
|
-
// -
|
|
911
|
-
// -
|
|
912
|
-
//
|
|
928
|
+
// Echo caused by the push's own checkout (studio.checkout when a new model is created).
|
|
929
|
+
// The file is already written in the correct mode by the push → log/chmod unnecessary, drop it.
|
|
930
|
+
// - While the push is running: suppress all.
|
|
931
|
+
// - Echo arriving late after the reply: only ids the push checked out, consume-once.
|
|
932
|
+
// Only manual start/stop working in Studio is logged.
|
|
913
933
|
if (this._pushInProgress) {
|
|
914
934
|
logger_1.logger.debug(`checkoutChanged: push in progress — ignoring (echo) for id=${payload.modelId}`);
|
|
915
935
|
return;
|
|
@@ -936,7 +956,7 @@ class BridgeServer {
|
|
|
936
956
|
}
|
|
937
957
|
return null;
|
|
938
958
|
}
|
|
939
|
-
// --- keepalive (bridge client
|
|
959
|
+
// --- keepalive (to the bridge client) ---
|
|
940
960
|
startPing() {
|
|
941
961
|
this.stopPing();
|
|
942
962
|
this.pingTimer = setInterval(() => {
|
|
@@ -951,8 +971,8 @@ class BridgeServer {
|
|
|
951
971
|
this.pingTimer = undefined;
|
|
952
972
|
}
|
|
953
973
|
}
|
|
954
|
-
//
|
|
955
|
-
// process
|
|
974
|
+
// If the time spent without a client (browser/tab closed or never connected) exceeds IDLE_EXIT_MS
|
|
975
|
+
// shut the process down → so claimless ports don't pile up in the background. Canceled when a client connects.
|
|
956
976
|
armIdleExit() {
|
|
957
977
|
this.cancelIdleExit();
|
|
958
978
|
this.idleTimer = setTimeout(() => {
|
|
@@ -969,8 +989,9 @@ class BridgeServer {
|
|
|
969
989
|
}
|
|
970
990
|
}
|
|
971
991
|
exports.BridgeServer = BridgeServer;
|
|
972
|
-
//
|
|
973
|
-
//
|
|
992
|
+
// Is there another live studio server in the folder? Checks whether the pid in server.json is alive.
|
|
993
|
+
// No pid (disconnected — only the appId binding remains) or a dead pid isn't treated as a lock — that
|
|
994
|
+
// folder can be re-claimed.
|
|
974
995
|
function isWorkspaceLocked(dir) {
|
|
975
996
|
let info;
|
|
976
997
|
try {
|
|
@@ -979,16 +1000,28 @@ function isWorkspaceLocked(dir) {
|
|
|
979
1000
|
catch {
|
|
980
1001
|
return false;
|
|
981
1002
|
}
|
|
982
|
-
|
|
1003
|
+
const pid = info?.pid;
|
|
1004
|
+
if (pid === undefined)
|
|
983
1005
|
return false;
|
|
984
1006
|
try {
|
|
985
|
-
process.kill(
|
|
1007
|
+
process.kill(pid, 0);
|
|
986
1008
|
return true;
|
|
987
1009
|
}
|
|
988
1010
|
catch (err) {
|
|
989
1011
|
return err.code === "EPERM";
|
|
990
1012
|
}
|
|
991
1013
|
}
|
|
1014
|
+
// Does the folder's persistent server.json appId differ from the connecting app? → it belongs to another
|
|
1015
|
+
// app (its files must not be mixed with this app's). No binding (empty folder) or the same app → false.
|
|
1016
|
+
function belongsToAnotherApp(dir, appId) {
|
|
1017
|
+
try {
|
|
1018
|
+
const info = (0, serverInfo_1.readServerInfo)(dir);
|
|
1019
|
+
return info?.appId !== undefined && info.appId !== appId;
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
992
1025
|
async function walkDir(dir) {
|
|
993
1026
|
const out = [];
|
|
994
1027
|
for (const entry of await (0, promises_1.readdir)(dir, { withFileTypes: true })) {
|