@venturewild/workspace 0.6.24 → 0.6.27
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/package.json
CHANGED
|
@@ -26,12 +26,38 @@ import {
|
|
|
26
26
|
writeFileSync,
|
|
27
27
|
unlinkSync,
|
|
28
28
|
} from 'node:fs';
|
|
29
|
+
import net from 'node:net';
|
|
29
30
|
import path from 'node:path';
|
|
30
31
|
import os from 'node:os';
|
|
31
32
|
import { resolveDaemonBinary } from './daemon-bin.mjs';
|
|
32
33
|
|
|
33
34
|
const DEFAULT_HTTP_BASE = 'http://127.0.0.1:8320';
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Is something LISTENING on host:port? A successful TCP connect means the socket
|
|
38
|
+
* is still held (a new daemon would hit EADDRINUSE); ECONNREFUSED means nothing
|
|
39
|
+
* is listening — the port is FREE to bind. Loopback connects resolve instantly,
|
|
40
|
+
* so this is fast. Conservative on ambiguous outcomes (timeout / odd error):
|
|
41
|
+
* report "in use" rather than risk spawning into a half-released socket.
|
|
42
|
+
* @returns {Promise<boolean>} true = in use, false = free to bind.
|
|
43
|
+
*/
|
|
44
|
+
function defaultProbePort(host, port, timeoutMs = 1000) {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const sock = net.connect({ host, port });
|
|
47
|
+
let done = false;
|
|
48
|
+
const finish = (inUse) => {
|
|
49
|
+
if (done) return;
|
|
50
|
+
done = true;
|
|
51
|
+
try { sock.destroy(); } catch { /* already closed */ }
|
|
52
|
+
resolve(inUse);
|
|
53
|
+
};
|
|
54
|
+
sock.setTimeout(timeoutMs);
|
|
55
|
+
sock.once('connect', () => finish(true)); // someone is listening
|
|
56
|
+
sock.once('timeout', () => finish(true)); // filtered/hung — assume held
|
|
57
|
+
sock.once('error', (e) => finish(e?.code !== 'ECONNREFUSED')); // refused = free
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
35
61
|
export class DaemonSupervisor {
|
|
36
62
|
/**
|
|
37
63
|
* @param {object} [opts]
|
|
@@ -53,6 +79,7 @@ export class DaemonSupervisor {
|
|
|
53
79
|
spawnImpl = spawn,
|
|
54
80
|
fetchImpl = globalThis.fetch,
|
|
55
81
|
killImpl = (pid, sig) => process.kill(pid, sig),
|
|
82
|
+
probePortImpl = defaultProbePort, // (host, port, timeoutMs) -> Promise<bool>; test seam
|
|
56
83
|
env = process.env,
|
|
57
84
|
// b-ii: when the install is logged in (account.json present), these are
|
|
58
85
|
// injected into the daemon's spawn env so it opens the proxy link
|
|
@@ -72,6 +99,7 @@ export class DaemonSupervisor {
|
|
|
72
99
|
this.spawnImpl = spawnImpl;
|
|
73
100
|
this.fetchImpl = fetchImpl;
|
|
74
101
|
this.killImpl = killImpl;
|
|
102
|
+
this.probePortImpl = probePortImpl;
|
|
75
103
|
this.env = env;
|
|
76
104
|
this.accountToken = accountToken;
|
|
77
105
|
this.serverUrl = serverUrl;
|
|
@@ -246,18 +274,52 @@ export class DaemonSupervisor {
|
|
|
246
274
|
return { stopped: true, pid };
|
|
247
275
|
}
|
|
248
276
|
|
|
277
|
+
/** The daemon's API host+port, parsed from httpBase (for raw socket checks). */
|
|
278
|
+
get apiHostPort() {
|
|
279
|
+
try {
|
|
280
|
+
const u = new URL(this.httpBase);
|
|
281
|
+
return { host: u.hostname || '127.0.0.1', port: Number(u.port) || 8320 };
|
|
282
|
+
} catch {
|
|
283
|
+
return { host: '127.0.0.1', port: 8320 };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Is the daemon's API port still held by a listener? (true = can't bind yet) */
|
|
288
|
+
portInUse(timeoutMs = 1000) {
|
|
289
|
+
const { host, port } = this.apiHostPort;
|
|
290
|
+
return this.probePortImpl(host, port, timeoutMs);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Block until the daemon's API port is FREE to bind, or the deadline passes.
|
|
295
|
+
* Returns true if it became free, false on timeout (caller proceeds anyway —
|
|
296
|
+
* best-effort, like the old health-poll).
|
|
297
|
+
*/
|
|
298
|
+
async waitForPortFree(timeoutMs = 8000, pollMs = 150) {
|
|
299
|
+
const deadline = Date.now() + timeoutMs;
|
|
300
|
+
for (;;) {
|
|
301
|
+
if (!(await this.portInUse())) return true;
|
|
302
|
+
if (Date.now() >= deadline) return false;
|
|
303
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
249
307
|
/**
|
|
250
308
|
* Recycle the daemon so it loads a freshly-resolved binary (after an
|
|
251
|
-
* auto-update). Stop the running process, wait for it to release its API
|
|
252
|
-
* then spawn again. Returns the `spawnDaemon` result.
|
|
309
|
+
* auto-update). Stop the running process, wait for it to release its API
|
|
310
|
+
* SOCKET, then spawn again. Returns the `spawnDaemon` result.
|
|
311
|
+
*
|
|
312
|
+
* Why wait on the socket, not health(): health() going false only means the
|
|
313
|
+
* daemon stopped answering HTTP — the OS can hold the :8320 listening socket a
|
|
314
|
+
* beat longer (graceful-shutdown drain / close). The daemon does NOT retry its
|
|
315
|
+
* bind: it logs "Address already in use" once and gives up, so the b-ii proxy
|
|
316
|
+
* link never forms and the public URL stays 502 until a reboot
|
|
317
|
+
* (docs/onboarding-hardening-2026-06-23.md). Spawning only once the socket is
|
|
318
|
+
* actually free closes that race.
|
|
253
319
|
*/
|
|
254
320
|
async recycle() {
|
|
255
321
|
await this.stop();
|
|
256
|
-
|
|
257
|
-
const deadline = Date.now() + 5000;
|
|
258
|
-
while (Date.now() < deadline && (await this.health()).running) {
|
|
259
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
260
|
-
}
|
|
322
|
+
await this.waitForPortFree();
|
|
261
323
|
return this.spawnDaemon();
|
|
262
324
|
}
|
|
263
325
|
|
package/server/src/index.mjs
CHANGED
|
@@ -87,6 +87,7 @@ import { runDoctor } from './doctor.mjs';
|
|
|
87
87
|
import { appendLine, tailFile, logFile, listLogs, TAILABLE, globalDir } from './logpaths.mjs';
|
|
88
88
|
import { createBackgroundTasks } from './background-tasks.mjs';
|
|
89
89
|
import { createTickets } from './support/tickets.mjs';
|
|
90
|
+
import { createTicketSyncer } from './support/ticket-sync.mjs';
|
|
90
91
|
import { SessionReporter } from './session-reporter.mjs';
|
|
91
92
|
import { TranscriptRecorder } from './transcript.mjs';
|
|
92
93
|
import { loadObservabilityConsent, setObservabilityConsent } from './observability.mjs';
|
|
@@ -648,6 +649,14 @@ export async function createServer(overrides = {}) {
|
|
|
648
649
|
.finally(() => clearTimeout(t));
|
|
649
650
|
}
|
|
650
651
|
|
|
652
|
+
// Dedup'd push to the team's rails. Swept at every turn end AND by the Requests-block
|
|
653
|
+
// poll — so a ticket the agent files via mcp__tickets__file_ticket (a separate process
|
|
654
|
+
// that only writes the local store, never calls syncTicket) still reaches the team
|
|
655
|
+
// without the user having the Requests block open. See support/ticket-sync.mjs.
|
|
656
|
+
const ticketSync = createTicketSyncer({ send: syncTicket, list: () => tickets.list() });
|
|
657
|
+
const syncTicketOnce = (rec) => ticketSync.syncOnce(rec);
|
|
658
|
+
const syncUnsyncedTickets = () => ticketSync.syncAll();
|
|
659
|
+
|
|
651
660
|
/**
|
|
652
661
|
* Run one chat turn: spawn the agent, stream every chunk to every chat
|
|
653
662
|
* client, and persist the resulting session id so the next turn resumes it.
|
|
@@ -810,6 +819,10 @@ export async function createServer(overrides = {}) {
|
|
|
810
819
|
} catch { /* best-effort — never break a turn on a registry hiccup */ }
|
|
811
820
|
broadcastChat({ type: 'end', messageId: id, code }, workspace.id, threadId);
|
|
812
821
|
activityBus.publish({ type: 'chat-end', messageId: id, code });
|
|
822
|
+
// A user turn may have filed/updated a ticket via mcp__tickets__file_ticket
|
|
823
|
+
// (a separate process that only writes the local store). Sweep + push them to
|
|
824
|
+
// the team now, so agent-filed tickets sync without the user opening Requests.
|
|
825
|
+
if (!auto) syncUnsyncedTickets();
|
|
813
826
|
});
|
|
814
827
|
session.send(prompt, {
|
|
815
828
|
cwd: workspace.dir,
|
|
@@ -2813,15 +2826,13 @@ export async function createServer(overrides = {}) {
|
|
|
2813
2826
|
// --- Feature/bug tickets (item 7) — the Requests block + the agent's file_ticket --
|
|
2814
2827
|
// The agent files via the support MCP (mcp__tickets__file_ticket) into the same
|
|
2815
2828
|
// store; the user files/updates here. Owner-gated. Tickets sync to the team's tracker
|
|
2816
|
-
// best-effort, once each
|
|
2817
|
-
|
|
2829
|
+
// best-effort, once each via syncTicketOnce/syncUnsyncedTickets (defined up top, also
|
|
2830
|
+
// swept at every turn end so agent-filed tickets sync without opening this block).
|
|
2818
2831
|
app.get('/api/workspace/tickets', (c) => {
|
|
2819
2832
|
const forbidden = require(c, 'chatWrite');
|
|
2820
2833
|
if (forbidden) return forbidden;
|
|
2821
2834
|
const list = tickets.list();
|
|
2822
|
-
for (const t of list)
|
|
2823
|
-
if (!syncedTicketIds.has(t.id)) { syncedTicketIds.add(t.id); syncTicket(t); }
|
|
2824
|
-
}
|
|
2835
|
+
for (const t of list) syncTicketOnce(t);
|
|
2825
2836
|
return c.json({ tickets: list });
|
|
2826
2837
|
});
|
|
2827
2838
|
app.post('/api/workspace/tickets', async (c) => {
|
|
@@ -2833,7 +2844,7 @@ export async function createServer(overrides = {}) {
|
|
|
2833
2844
|
title: body.title, desc: body.desc, category: body.category, severity: body.severity,
|
|
2834
2845
|
workspaceId: workspaceFor(c).id, source: 'user',
|
|
2835
2846
|
});
|
|
2836
|
-
|
|
2847
|
+
syncTicketOnce(rec);
|
|
2837
2848
|
auditAction(c, 'tickets.file', `id=${rec.id} cat=${rec.category}`);
|
|
2838
2849
|
return c.json({ ticket: rec });
|
|
2839
2850
|
});
|
|
@@ -2843,7 +2854,7 @@ export async function createServer(overrides = {}) {
|
|
|
2843
2854
|
const body = await c.req.json().catch(() => ({}));
|
|
2844
2855
|
const rec = tickets.update(c.req.param('id'), { status: body.status, note: body.note });
|
|
2845
2856
|
if (!rec) return c.json({ error: 'not_found' }, 404);
|
|
2846
|
-
|
|
2857
|
+
syncTicketOnce(rec);
|
|
2847
2858
|
auditAction(c, 'tickets.update', `id=${rec.id} status=${rec.status}`);
|
|
2848
2859
|
return c.json({ ticket: rec });
|
|
2849
2860
|
});
|
|
@@ -80,7 +80,7 @@ async function callTool(name, args = {}) {
|
|
|
80
80
|
});
|
|
81
81
|
return textContent({
|
|
82
82
|
kind: 'ticket', op: 'file', ticket: rec,
|
|
83
|
-
note: "Request filed
|
|
83
|
+
note: "Request filed to the user's Requests block; the server reports it to the VentureWild team when this turn ends. Tell them in one short line what you logged.",
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
86
|
case 'list_tickets': {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Dedup wrapper around the best-effort "push a ticket to the team's rails" send.
|
|
2
|
+
//
|
|
3
|
+
// The bug this fixes: a ticket the agent files via mcp__tickets__file_ticket lands in
|
|
4
|
+
// the local store from a SEPARATE process that never calls the server's syncTicket — so
|
|
5
|
+
// it only reached the rails when the web Requests block happened to poll. This syncer is
|
|
6
|
+
// swept at the end of every chat turn (and from the poll), so agent-filed tickets sync
|
|
7
|
+
// without the user opening anything.
|
|
8
|
+
//
|
|
9
|
+
// Dedup key = id + updatedAt + status + note-count, so each ticket VERSION is sent at
|
|
10
|
+
// most once, but a later change re-sends. We include status/note-count (not just
|
|
11
|
+
// updatedAt) because two writes can land in the SAME millisecond — an agent that files
|
|
12
|
+
// then immediately updates a ticket would otherwise collide on updatedAt and the change
|
|
13
|
+
// would never sync. Re-sending is safe: the rails ingest upserts by ticket_id. The
|
|
14
|
+
// `sent` set is in-memory — a server restart re-syncs the store on the next sweep.
|
|
15
|
+
|
|
16
|
+
export function createTicketSyncer({ send, list }) {
|
|
17
|
+
const sent = new Set();
|
|
18
|
+
|
|
19
|
+
function sig(rec) {
|
|
20
|
+
const notes = Array.isArray(rec.notes) ? rec.notes.length : 0;
|
|
21
|
+
return `${rec.id}:${rec.updatedAt || rec.createdAt || 0}:${rec.status || ''}:${notes}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Send one ticket if this version hasn't been sent yet. Returns true if it sent.
|
|
25
|
+
function syncOnce(rec) {
|
|
26
|
+
if (!rec || typeof rec.id !== 'string') return false;
|
|
27
|
+
const key = sig(rec);
|
|
28
|
+
if (sent.has(key)) return false;
|
|
29
|
+
sent.add(key);
|
|
30
|
+
try { send(rec); } catch { /* best-effort — the local store is the source of truth */ }
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Sweep the whole store, sending every not-yet-sent version. Returns how many sent.
|
|
35
|
+
function syncAll() {
|
|
36
|
+
let recs;
|
|
37
|
+
try { recs = list() || []; } catch { return 0; }
|
|
38
|
+
let n = 0;
|
|
39
|
+
for (const r of recs) { if (syncOnce(r)) n += 1; }
|
|
40
|
+
return n;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { syncOnce, syncAll };
|
|
44
|
+
}
|