@corenel/harness 0.4.1 → 0.5.1
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/api/backend.d.ts +24 -0
- package/dist/api/backend.d.ts.map +1 -1
- package/dist/api/backend.js +32 -8
- package/dist/api/backend.js.map +1 -1
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +10 -1
- package/dist/api/client.js.map +1 -1
- package/dist/core/budget.d.ts +6 -0
- package/dist/core/budget.d.ts.map +1 -1
- package/dist/core/budget.js +16 -0
- package/dist/core/budget.js.map +1 -1
- package/dist/core/factFile.d.ts +25 -0
- package/dist/core/factFile.d.ts.map +1 -0
- package/dist/core/factFile.js +59 -0
- package/dist/core/factFile.js.map +1 -0
- package/dist/core/loop.d.ts +18 -0
- package/dist/core/loop.d.ts.map +1 -1
- package/dist/core/loop.js +50 -2
- package/dist/core/loop.js.map +1 -1
- package/dist/core/recovery.d.ts +4 -3
- package/dist/core/recovery.d.ts.map +1 -1
- package/dist/core/recovery.js +20 -0
- package/dist/core/recovery.js.map +1 -1
- package/dist/prompts/config.d.ts +14 -0
- package/dist/prompts/config.d.ts.map +1 -1
- package/dist/prompts/config.js +25 -0
- package/dist/prompts/config.js.map +1 -1
- package/dist/prompts/library/defaults.generated.js +4 -4
- package/dist/prompts/library/defaults.generated.js.map +1 -1
- package/dist/providers/gateway.d.ts.map +1 -1
- package/dist/providers/gateway.js +11 -1
- package/dist/providers/gateway.js.map +1 -1
- package/dist/redact.d.ts +10 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +53 -0
- package/dist/redact.js.map +1 -0
- package/dist/rooms/findings.d.ts +134 -0
- package/dist/rooms/findings.d.ts.map +1 -0
- package/dist/rooms/findings.js +262 -0
- package/dist/rooms/findings.js.map +1 -0
- package/dist/sidecar/host.d.ts +192 -2
- package/dist/sidecar/host.d.ts.map +1 -1
- package/dist/sidecar/host.js +407 -0
- package/dist/sidecar/host.js.map +1 -1
- package/dist/sidecar/server.d.ts +185 -1
- package/dist/sidecar/server.d.ts.map +1 -1
- package/dist/sidecar/server.js +515 -3
- package/dist/sidecar/server.js.map +1 -1
- package/dist/tools/builtins.d.ts.map +1 -1
- package/dist/tools/builtins.js +8 -0
- package/dist/tools/builtins.js.map +1 -1
- package/dist/tools/files.d.ts.map +1 -1
- package/dist/tools/files.js +8 -0
- package/dist/tools/files.js.map +1 -1
- package/dist/tools/roomTools.d.ts +95 -0
- package/dist/tools/roomTools.d.ts.map +1 -0
- package/dist/tools/roomTools.js +279 -0
- package/dist/tools/roomTools.js.map +1 -0
- package/dist/tools/sessions.d.ts.map +1 -1
- package/dist/tools/sessions.js +1 -0
- package/dist/tools/sessions.js.map +1 -1
- package/dist/tools/web.d.ts.map +1 -1
- package/dist/tools/web.js +3 -0
- package/dist/tools/web.js.map +1 -1
- package/package.json +7 -2
package/dist/sidecar/server.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { SIDECAR_PROTOCOL, errMessage, isLoopbackHost, } from '@corenel/protocol';
|
|
2
|
+
/** How long the welcome waits on `clientDirName` before going out without it. A
|
|
3
|
+
* manifest write is a few milliseconds; anything near this is a wedged disk, and
|
|
4
|
+
* a client left with no handshake at all is worse than one that has to fall back
|
|
5
|
+
* to deducing its own directory. */
|
|
6
|
+
const DIR_NAME_TIMEOUT_MS = 3000;
|
|
2
7
|
/** True for anything with a callable `.then` -- covers native Promises and any
|
|
3
8
|
* other thenable a caller's `onClient` might return, without assuming it's a
|
|
4
9
|
* real Promise instance (the declared type is `() => void`; an async function
|
|
@@ -91,6 +96,14 @@ export class SidecarServer {
|
|
|
91
96
|
writable: true,
|
|
92
97
|
value: void 0
|
|
93
98
|
});
|
|
99
|
+
/** The friendly label from the same `hello`, kept beside `clientId` so the
|
|
100
|
+
* dirName resolver is handed the identity the manifest keys on. */
|
|
101
|
+
Object.defineProperty(this, "clientName", {
|
|
102
|
+
enumerable: true,
|
|
103
|
+
configurable: true,
|
|
104
|
+
writable: true,
|
|
105
|
+
value: void 0
|
|
106
|
+
});
|
|
94
107
|
/** Detach function for this connection's attendance attachment, set on a
|
|
95
108
|
* successful `hello` when `opts.attendance` is wired. Called once, on channel
|
|
96
109
|
* close, so a dead connection stops receiving/counting toward asks. */
|
|
@@ -100,6 +113,23 @@ export class SidecarServer {
|
|
|
100
113
|
writable: true,
|
|
101
114
|
value: void 0
|
|
102
115
|
});
|
|
116
|
+
/** The synced directory THIS connection belongs to, once resolved -- the same
|
|
117
|
+
* value echoed in `welcome.dirName`. Retained so `hosts-list` can stamp
|
|
118
|
+
* `self` and `hosts-forget` can refuse the caller's own tree from the
|
|
119
|
+
* daemon's own knowledge rather than from anything the client claimed. */
|
|
120
|
+
Object.defineProperty(this, "dirName", {
|
|
121
|
+
enumerable: true,
|
|
122
|
+
configurable: true,
|
|
123
|
+
writable: true,
|
|
124
|
+
value: void 0
|
|
125
|
+
});
|
|
126
|
+
/** Detach for this connection's entry in the shared hosts live set. */
|
|
127
|
+
Object.defineProperty(this, "hostsDetach", {
|
|
128
|
+
enumerable: true,
|
|
129
|
+
configurable: true,
|
|
130
|
+
writable: true,
|
|
131
|
+
value: void 0
|
|
132
|
+
});
|
|
103
133
|
/** Detach callbacks for runs this connection is watching, so a dropped socket
|
|
104
134
|
* stops the hub fanning out into a dead channel. */
|
|
105
135
|
Object.defineProperty(this, "runDetachers", {
|
|
@@ -222,16 +252,42 @@ export class SidecarServer {
|
|
|
222
252
|
// Every connected server subscribes, which is what makes a root added by one
|
|
223
253
|
// paired client reach ALL of them: two browsers on one daemon must never
|
|
224
254
|
// disagree about what is reachable. The payload is the COMPLETE set.
|
|
225
|
-
|
|
255
|
+
/* Both subscriptions are made HERE, in the constructor, and both therefore
|
|
256
|
+
* have to check `authed` themselves. Nothing else does it for them: every
|
|
257
|
+
* `case` in onMessage guards its own handler, but a broadcast is not a
|
|
258
|
+
* reply to a request -- it arrives on its own, and at construction time
|
|
259
|
+
* `authed` is necessarily still false. Unguarded, a process that opens the
|
|
260
|
+
* socket and never sends `hello` is fed the daemon's working directories,
|
|
261
|
+
* and every room's participant names, spend, parked state and close
|
|
262
|
+
* reasons. The subscription itself is kept (not deferred to the hello) so
|
|
263
|
+
* there is exactly one place it is set up and torn down. */
|
|
264
|
+
const offRoots = opts.roots?.subscribe((s) => {
|
|
265
|
+
if (!this.authed)
|
|
266
|
+
return;
|
|
267
|
+
this.ch.send({ t: 'roots', root: s.root, roots: s.roots });
|
|
268
|
+
});
|
|
269
|
+
// Same reasoning as roots: every connected client hears every room move,
|
|
270
|
+
// not just the one that started it -- ONE room service is shared across
|
|
271
|
+
// every per-connection SidecarServer.
|
|
272
|
+
const offRooms = opts.rooms?.subscribe((room) => {
|
|
273
|
+
if (!this.authed)
|
|
274
|
+
return;
|
|
275
|
+
this.ch.send({ t: 'room-progress', room });
|
|
276
|
+
});
|
|
226
277
|
this.ch.onClose?.(() => {
|
|
227
278
|
this.attendedDetach?.();
|
|
228
279
|
this.attendedDetach = undefined;
|
|
280
|
+
// Leave the live set, or this directory reads as connected for ever and
|
|
281
|
+
// could never be reclaimed -- the refusal would outlive the connection.
|
|
282
|
+
this.hostsDetach?.();
|
|
283
|
+
this.hostsDetach = undefined;
|
|
229
284
|
// A dropped connection must stop receiving run events, or the hub keeps
|
|
230
285
|
// fanning out into a dead channel for the rest of the run.
|
|
231
286
|
for (const detach of this.runDetachers)
|
|
232
287
|
detach();
|
|
233
288
|
this.runDetachers.clear();
|
|
234
289
|
offRoots?.();
|
|
290
|
+
offRooms?.();
|
|
235
291
|
// Stop streaming process output into a dead socket. The PROCESSES keep
|
|
236
292
|
// running - that is the point of the table living on the daemon - but this
|
|
237
293
|
// connection stops hearing about them until something re-attaches.
|
|
@@ -417,12 +473,121 @@ export class SidecarServer {
|
|
|
417
473
|
}
|
|
418
474
|
void this.onRunTranscript(m);
|
|
419
475
|
return;
|
|
476
|
+
case 'crew-memory':
|
|
477
|
+
if (!this.authed) {
|
|
478
|
+
this.ch.send({ t: 'crew-memory-result', id: m.id, memories: [] });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
void this.onCrewMemory(m);
|
|
482
|
+
return;
|
|
420
483
|
case 'host-sync-put':
|
|
421
484
|
void this.onHostSyncPut(m);
|
|
422
485
|
return;
|
|
423
486
|
case 'host-sync-delete':
|
|
424
487
|
void this.onHostSyncDelete(m);
|
|
425
488
|
return;
|
|
489
|
+
case 'agent-pause-list':
|
|
490
|
+
// `ok: false`, not an empty list: telling an unauthenticated caller
|
|
491
|
+
// "this daemon is holding no pauses" is a claim it has not earned, the
|
|
492
|
+
// same reasoning as room-list below.
|
|
493
|
+
if (!this.authed) {
|
|
494
|
+
this.ch.send({ t: 'agent-pause-list-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
this.onAgentPauseList(m);
|
|
498
|
+
return;
|
|
499
|
+
case 'agent-pause-set':
|
|
500
|
+
if (!this.authed) {
|
|
501
|
+
this.ch.send({ t: 'agent-pause-set-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
void this.onAgentPauseSet(m);
|
|
505
|
+
return;
|
|
506
|
+
case 'hosts-list':
|
|
507
|
+
// `ok: false`, not an empty estate: telling an unauthenticated caller
|
|
508
|
+
// "this daemon holds no synced hosts" is a claim it has not earned --
|
|
509
|
+
// the same reasoning as agent-pause-list above.
|
|
510
|
+
if (!this.authed) {
|
|
511
|
+
this.ch.send({ t: 'hosts-list-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
void this.onHostsList(m);
|
|
515
|
+
return;
|
|
516
|
+
case 'hosts-forget':
|
|
517
|
+
if (!this.authed) {
|
|
518
|
+
this.ch.send({ t: 'hosts-forget-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
void this.onHostsForget(m);
|
|
522
|
+
return;
|
|
523
|
+
case 'room-open':
|
|
524
|
+
if (!this.authed) {
|
|
525
|
+
this.ch.send({ t: 'room-open-result', id: m.id, ok: false, problems: ['not authenticated'] });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
void this.onRoomOpen(m);
|
|
529
|
+
return;
|
|
530
|
+
case 'room-list':
|
|
531
|
+
// `ok: false`, not an empty list: telling an unauthenticated caller
|
|
532
|
+
// "this daemon has no rooms" is a lie, and the protocol now has an
|
|
533
|
+
// arm that says what actually happened.
|
|
534
|
+
if (!this.authed) {
|
|
535
|
+
this.ch.send({ t: 'room-list-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
void this.onRoomList(m);
|
|
539
|
+
return;
|
|
540
|
+
case 'room-read':
|
|
541
|
+
// Same as room-list: "no such room" is a considered answer this
|
|
542
|
+
// caller has not earned. Say it was refused instead.
|
|
543
|
+
if (!this.authed) {
|
|
544
|
+
this.ch.send({ t: 'room-read-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
void this.onRoomRead(m);
|
|
548
|
+
return;
|
|
549
|
+
case 'room-answer':
|
|
550
|
+
if (!this.authed) {
|
|
551
|
+
this.ch.send({ t: 'room-answer-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
void this.onRoomAnswer(m);
|
|
555
|
+
return;
|
|
556
|
+
case 'room-resume':
|
|
557
|
+
if (!this.authed) {
|
|
558
|
+
this.ch.send({ t: 'room-resume-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
void this.onRoomResume(m);
|
|
562
|
+
return;
|
|
563
|
+
case 'room-interject':
|
|
564
|
+
if (!this.authed) {
|
|
565
|
+
this.ch.send({ t: 'room-interject-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
void this.onRoomInterject(m);
|
|
569
|
+
return;
|
|
570
|
+
case 'room-findings':
|
|
571
|
+
if (!this.authed) {
|
|
572
|
+
this.ch.send({ t: 'room-findings-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
void this.onRoomFindings(m);
|
|
576
|
+
return;
|
|
577
|
+
case 'room-finding-edit':
|
|
578
|
+
if (!this.authed) {
|
|
579
|
+
this.ch.send({ t: 'room-finding-edit-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
void this.onRoomFindingEdit(m);
|
|
583
|
+
return;
|
|
584
|
+
case 'room-close':
|
|
585
|
+
if (!this.authed) {
|
|
586
|
+
this.ch.send({ t: 'room-close-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
void this.onRoomClose(m);
|
|
590
|
+
return;
|
|
426
591
|
default:
|
|
427
592
|
// welcome / denied / invoke-result / host-call / event / run-accepted /
|
|
428
593
|
// run-done / permission-ask / permission-resolved are host-side.
|
|
@@ -454,6 +619,7 @@ export class SidecarServer {
|
|
|
454
619
|
const name = typeof m.clientName === 'string' && m.clientName
|
|
455
620
|
? m.clientName.slice(0, MAX_IDENTITY_FIELD_LEN)
|
|
456
621
|
: clientId;
|
|
622
|
+
this.clientName = name;
|
|
457
623
|
// Best-effort, fire-and-forget: a throwing/rejecting `onClient` must never
|
|
458
624
|
// deny/break an otherwise valid, authenticated handshake -- identity
|
|
459
625
|
// recording is informational, not part of the auth decision. Covers BOTH
|
|
@@ -483,6 +649,52 @@ export class SidecarServer {
|
|
|
483
649
|
});
|
|
484
650
|
}
|
|
485
651
|
this.opts.log?.(`paired — ${this.tools.size} ${this.tools.size === 1 ? 'tool' : 'tools'} advertised (host dir: ${this.hostStateDir})`);
|
|
652
|
+
/* THE ONE THING THAT WAITS. The welcome carries the synced directory name
|
|
653
|
+
* this daemon resolved for this connection, and the client has no other way
|
|
654
|
+
* to learn it -- it could previously only deduce it, and got null whenever
|
|
655
|
+
* no workspace was open or two hosts held the same workspace id, leaving a
|
|
656
|
+
* management surface unable to say which registrations were its own.
|
|
657
|
+
*
|
|
658
|
+
* Bounded, and every failure ends the same way as an older daemon: no
|
|
659
|
+
* `dirName` on the welcome. A handshake that never arrives would be a far
|
|
660
|
+
* worse trade than a client that falls back to deducing. */
|
|
661
|
+
const identity = this.clientId === undefined
|
|
662
|
+
? undefined
|
|
663
|
+
: { clientId: this.clientId, name: this.clientName ?? this.clientId };
|
|
664
|
+
if (this.opts.clientDirName && identity) {
|
|
665
|
+
void this.resolveDirName(this.opts.clientDirName, identity).then((dirName) => {
|
|
666
|
+
this.sendWelcome(dirName);
|
|
667
|
+
});
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
this.sendWelcome(null);
|
|
671
|
+
}
|
|
672
|
+
/** Resolve the connection's synced directory name, or null for every failure
|
|
673
|
+
* mode there is (throw, rejection, non-string, timeout). */
|
|
674
|
+
async resolveDirName(resolve, identity) {
|
|
675
|
+
try {
|
|
676
|
+
const timed = new Promise((res) => setTimeout(() => res(null), DIR_NAME_TIMEOUT_MS));
|
|
677
|
+
const got = await Promise.race([Promise.resolve(resolve(identity)), timed]);
|
|
678
|
+
return typeof got === 'string' && got ? got : null;
|
|
679
|
+
}
|
|
680
|
+
catch (err) {
|
|
681
|
+
this.opts.log?.(`clientDirName failed: ${errMessage(err)}`);
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
sendWelcome(dirName) {
|
|
686
|
+
/* Remembered BEFORE the welcome goes out, because `self` and the
|
|
687
|
+
* forget-your-own-tree refusal are both answered from it. A daemon too old
|
|
688
|
+
* to resolve one, or a client with no identity, leaves it undefined -- and
|
|
689
|
+
* then every entry reads `self: false` and nothing is refused on that
|
|
690
|
+
* ground, which is correct: this connection genuinely has no directory. */
|
|
691
|
+
if (dirName) {
|
|
692
|
+
this.dirName = dirName;
|
|
693
|
+
// Only a resolved directory can be attached: an unidentified connection
|
|
694
|
+
// is not "connected as" anything, and marking it would pin a tree it
|
|
695
|
+
// has no claim to.
|
|
696
|
+
this.hostsDetach = this.opts.hosts?.attach(dirName);
|
|
697
|
+
}
|
|
486
698
|
const tools = [...this.tools.values()].map(({ run: _run, ...decl }) => decl);
|
|
487
699
|
// Advertise the loopback HTTP proxy so the host can route local-LLM calls
|
|
488
700
|
// through us (server-side fetch, no browser CORS) instead of failing, and
|
|
@@ -494,8 +706,14 @@ export class SidecarServer {
|
|
|
494
706
|
const features = [
|
|
495
707
|
'http-proxy',
|
|
496
708
|
...(this.opts.runner ? ['runner'] : []),
|
|
709
|
+
// The daemon can resolve a model for an agent that sets none. Advertised
|
|
710
|
+
// so a client can warn before deploying an agent left on the workspace
|
|
711
|
+
// default, whose scheduled runs would otherwise fail one by one with
|
|
712
|
+
// nothing on screen to say why.
|
|
713
|
+
...(this.opts.runner?.hasDefaultModel ? ['default-model'] : []),
|
|
497
714
|
...(this.opts.acp ? ['acp'] : []),
|
|
498
715
|
...(this.opts.roots?.areas.length ? ['add-roots'] : []),
|
|
716
|
+
...(this.opts.rooms ? ['rooms'] : []),
|
|
499
717
|
...(this.opts.procs ? ['procs'] : []),
|
|
500
718
|
...(this.opts.sessions ? ['sessions'] : []),
|
|
501
719
|
// The operator's transcript CEILING, advertised so a client offers the
|
|
@@ -508,8 +726,21 @@ export class SidecarServer {
|
|
|
508
726
|
// operator declared somewhere a checkout may be written, so a client only
|
|
509
727
|
// offers "start from a repository" where it would actually work.
|
|
510
728
|
...(this.opts.sessions?.canClone ? ['session-clone'] : []),
|
|
729
|
+
// Whether this daemon HOLDS pauses. Advertised only with a real store
|
|
730
|
+
// behind it, so a client offers "Pause" only where pausing would actually
|
|
731
|
+
// stop the agent -- the alternative is a button that reports success on a
|
|
732
|
+
// daemon that keeps firing.
|
|
733
|
+
...(this.opts.pauses ? ['agent-pause'] : []),
|
|
734
|
+
// Whether this daemon can report and reclaim its synced host directories.
|
|
735
|
+
// Advertised only with a real manifest behind it, so a client never
|
|
736
|
+
// offers a destructive control to a daemon that would refuse it.
|
|
737
|
+
...(this.opts.hosts ? ['hosts-admin'] : []),
|
|
511
738
|
];
|
|
512
|
-
this.ch.send({
|
|
739
|
+
this.ch.send({
|
|
740
|
+
t: 'welcome', tools, features,
|
|
741
|
+
...(this.opts.info ? { info: this.opts.info } : {}),
|
|
742
|
+
...(dirName ? { dirName } : {}),
|
|
743
|
+
});
|
|
513
744
|
}
|
|
514
745
|
/** Proxy a host HTTP request server-side. Restricted to LOOPBACK targets — the
|
|
515
746
|
* sidecar must never be usable as an SSRF pivot to internal/metadata hosts. The
|
|
@@ -772,7 +1003,28 @@ export class SidecarServer {
|
|
|
772
1003
|
this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: `unknown tool ${m.name}` });
|
|
773
1004
|
return;
|
|
774
1005
|
}
|
|
775
|
-
|
|
1006
|
+
/* MUTATIONS ARE LOUD, READS ARE NOT.
|
|
1007
|
+
*
|
|
1008
|
+
* A single agent run issues hundreds of `fs_read_file` / `fs_list_dir`
|
|
1009
|
+
* calls, and one line each buries everything else in the daemon's
|
|
1010
|
+
* scrollback -- the pairing line, a policy refusal, a failing write. What
|
|
1011
|
+
* an operator is actually watching for is what the agent CHANGED, so that
|
|
1012
|
+
* is what still prints.
|
|
1013
|
+
*
|
|
1014
|
+
* `=== true`, deliberately, because in this codebase an OMITTED `mutates`
|
|
1015
|
+
* means read-only: writes and shell declare `mutates: true`
|
|
1016
|
+
* (sidecar/src/tools.ts), reads say nothing, and the permission service
|
|
1017
|
+
* reads it the same way (`if (tool.mutates && ...)`). Logging on "not
|
|
1018
|
+
* explicitly false" would therefore have logged every read and changed
|
|
1019
|
+
* nothing. The log must agree with the layer that decides permissions --
|
|
1020
|
+
* a tool that mutates without declaring it is already escaping the policy's
|
|
1021
|
+
* mutating backstop, and that is a policy bug to fix at the declaration,
|
|
1022
|
+
* not something a divergent logging rule should paper over.
|
|
1023
|
+
*
|
|
1024
|
+
* Failures below are logged whatever the kind: a read that throws is
|
|
1025
|
+
* exactly the line someone is scrolling back to find. */
|
|
1026
|
+
if (tool.mutates === true)
|
|
1027
|
+
this.opts.log?.(`invoke ${m.name}`);
|
|
776
1028
|
try {
|
|
777
1029
|
const result = await tool.run(m.args, this.makeContext());
|
|
778
1030
|
this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result });
|
|
@@ -935,6 +1187,25 @@ export class SidecarServer {
|
|
|
935
1187
|
this.ch.send({ t: 'run-transcript-result', id: m.id, lines: [] });
|
|
936
1188
|
}
|
|
937
1189
|
}
|
|
1190
|
+
/** What one of this caller's crew agents has LEARNED, from the daemon's own
|
|
1191
|
+
* copy. Same owner-scoping shape as `onRunHistory`: the identity handed down
|
|
1192
|
+
* is the untrusted `{ clientId }` this connection authenticated with, and
|
|
1193
|
+
* the runner resolves it through the hosts manifest before it ever touches
|
|
1194
|
+
* a path. */
|
|
1195
|
+
async onCrewMemory(m) {
|
|
1196
|
+
try {
|
|
1197
|
+
const memories = this.opts.runner?.memories
|
|
1198
|
+
? await this.opts.runner.memories({ clientId: this.clientId }, m.agent)
|
|
1199
|
+
: [];
|
|
1200
|
+
this.ch.send({ t: 'crew-memory-result', id: m.id, memories });
|
|
1201
|
+
}
|
|
1202
|
+
catch {
|
|
1203
|
+
// A memory folder that cannot be read is not a reason to break the
|
|
1204
|
+
// caller's page. The client distinguishes "none" from "unreachable" by
|
|
1205
|
+
// whether the call resolved at all.
|
|
1206
|
+
this.ch.send({ t: 'crew-memory-result', id: m.id, memories: [] });
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
938
1209
|
async onRun(m) {
|
|
939
1210
|
/* WHERE the run happens, decided once, up front.
|
|
940
1211
|
*
|
|
@@ -1044,6 +1315,247 @@ export class SidecarServer {
|
|
|
1044
1315
|
this.ch.send({ t: 'roots-add-result', id: m.id, ok: false, error: errMessage(e) });
|
|
1045
1316
|
}
|
|
1046
1317
|
}
|
|
1318
|
+
/** Every synced host directory, with `self` stamped from THIS connection's
|
|
1319
|
+
* own resolved directory -- never from anything the client sent. */
|
|
1320
|
+
async onHostsList(m) {
|
|
1321
|
+
if (!this.opts.hosts) {
|
|
1322
|
+
this.ch.send({ t: 'hosts-list-result', id: m.id, ok: false, error: 'this daemon does not administer synced hosts' });
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
try {
|
|
1326
|
+
this.ch.send({ t: 'hosts-list-result', id: m.id, ok: true, hosts: this.stampSelf(await this.opts.hosts.list()) });
|
|
1327
|
+
}
|
|
1328
|
+
catch (err) {
|
|
1329
|
+
this.ch.send({ t: 'hosts-list-result', id: m.id, ok: false, error: errMessage(err) });
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
stampSelf(hosts) {
|
|
1333
|
+
return hosts.map((h) => ({ ...h, self: this.dirName !== undefined && h.dirName === this.dirName }));
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Delete one synced tree and its manifest entry.
|
|
1337
|
+
*
|
|
1338
|
+
* THE REFUSALS ARE THE FEATURE. A client's UI is not a guard -- this message
|
|
1339
|
+
* carries a `dirName` the client chose, so both rules that make the operation
|
|
1340
|
+
* safe are enforced here: the caller's OWN directory is refused outright, and
|
|
1341
|
+
* the port refuses any directory a client is attached as. The first is
|
|
1342
|
+
* checked here rather than left to the second because it is answerable from
|
|
1343
|
+
* this connection's own identity and yields the more useful sentence.
|
|
1344
|
+
*/
|
|
1345
|
+
async onHostsForget(m) {
|
|
1346
|
+
if (!this.opts.hosts) {
|
|
1347
|
+
this.ch.send({ t: 'hosts-forget-result', id: m.id, ok: false, error: 'this daemon does not administer synced hosts' });
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
if (typeof m.dirName !== 'string' || !m.dirName) {
|
|
1351
|
+
this.ch.send({ t: 'hosts-forget-result', id: m.id, ok: false, error: 'dirName must be a non-empty string' });
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
if (this.dirName !== undefined && m.dirName === this.dirName) {
|
|
1355
|
+
this.ch.send({
|
|
1356
|
+
t: 'hosts-forget-result', id: m.id, ok: false,
|
|
1357
|
+
error: 'That is this connection’s own synced directory. Disconnect this client first, then remove it from another one.',
|
|
1358
|
+
});
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
try {
|
|
1362
|
+
const outcome = await this.opts.hosts.forget(m.dirName);
|
|
1363
|
+
if (!outcome.ok) {
|
|
1364
|
+
this.ch.send({ t: 'hosts-forget-result', id: m.id, ok: false, error: outcome.reason });
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
this.ch.send({ t: 'hosts-forget-result', id: m.id, ok: true, hosts: this.stampSelf(await this.opts.hosts.list()) });
|
|
1368
|
+
}
|
|
1369
|
+
catch (err) {
|
|
1370
|
+
this.ch.send({ t: 'hosts-forget-result', id: m.id, ok: false, error: errMessage(err) });
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
onAgentPauseList(m) {
|
|
1374
|
+
if (!this.opts.pauses) {
|
|
1375
|
+
// An honest empty answer for a daemon without the capability: it is
|
|
1376
|
+
// holding no pauses, which is true. The feature gate is what tells a
|
|
1377
|
+
// client whether it may OFFER the control.
|
|
1378
|
+
this.ch.send({ t: 'agent-pause-list-result', id: m.id, ok: true, pauses: [] });
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
try {
|
|
1382
|
+
this.ch.send({ t: 'agent-pause-list-result', id: m.id, ok: true, pauses: this.opts.pauses.list() });
|
|
1383
|
+
}
|
|
1384
|
+
catch (err) {
|
|
1385
|
+
this.ch.send({ t: 'agent-pause-list-result', id: m.id, ok: false, error: errMessage(err) });
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
async onAgentPauseSet(m) {
|
|
1389
|
+
if (!this.opts.pauses) {
|
|
1390
|
+
// NOT an empty success. Accepting a pause this daemon cannot hold would
|
|
1391
|
+
// leave the operator believing an agent had been stopped while it kept
|
|
1392
|
+
// firing on the hour -- the exact failure the feature exists to end.
|
|
1393
|
+
this.ch.send({ t: 'agent-pause-set-result', id: m.id, ok: false, error: 'this daemon does not hold agent pauses' });
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
// Runtime-validate: the message arrived via `JSON.parse(...) as SidecarMsg`,
|
|
1397
|
+
// so the declared types guarantee nothing. Both fields become part of a
|
|
1398
|
+
// persisted key.
|
|
1399
|
+
if (typeof m.owner !== 'string' || typeof m.agent !== 'string' || !m.agent) {
|
|
1400
|
+
this.ch.send({ t: 'agent-pause-set-result', id: m.id, ok: false, error: 'owner must be a string and agent a non-empty string' });
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
try {
|
|
1404
|
+
const pauses = await this.opts.pauses.set(m.owner, m.agent, m.paused === true);
|
|
1405
|
+
// Republish before replying: the caller's own row and every other
|
|
1406
|
+
// connected client's must change together, or a second browser keeps
|
|
1407
|
+
// offering "Pause" on an agent already paused.
|
|
1408
|
+
this.opts.pauses.refresh();
|
|
1409
|
+
this.ch.send({ t: 'agent-pause-set-result', id: m.id, ok: true, pauses });
|
|
1410
|
+
}
|
|
1411
|
+
catch (err) {
|
|
1412
|
+
this.ch.send({ t: 'agent-pause-set-result', id: m.id, ok: false, error: errMessage(err) });
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
async onRoomOpen(m) {
|
|
1416
|
+
if (!this.opts.rooms) {
|
|
1417
|
+
this.ch.send({ t: 'room-open-result', id: m.id, ok: false, problems: ['this sidecar does not host rooms'] });
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
try {
|
|
1421
|
+
const r = await this.opts.rooms.open(m.req);
|
|
1422
|
+
this.ch.send(r.ok
|
|
1423
|
+
? { t: 'room-open-result', id: m.id, ok: true, room: r.room }
|
|
1424
|
+
: { t: 'room-open-result', id: m.id, ok: false, problems: r.problems });
|
|
1425
|
+
}
|
|
1426
|
+
catch (err) {
|
|
1427
|
+
// A room failure must never kill the connection; the client gets a
|
|
1428
|
+
// refusal it can render instead of a socket that went quiet.
|
|
1429
|
+
this.ch.send({ t: 'room-open-result', id: m.id, ok: false, problems: [errMessage(err)] });
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
async onRoomList(m) {
|
|
1433
|
+
if (!this.opts.rooms) {
|
|
1434
|
+
// Honest empty answer for a daemon without the capability -- it is
|
|
1435
|
+
// running no rooms, same reasoning as proc-list/session-list. Distinct
|
|
1436
|
+
// from the catch below: that is a STORE FAILURE, not "there are none".
|
|
1437
|
+
this.ch.send({ t: 'room-list-result', id: m.id, ok: true, rooms: [] });
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
try {
|
|
1441
|
+
this.ch.send({ t: 'room-list-result', id: m.id, ok: true, rooms: await this.opts.rooms.list() });
|
|
1442
|
+
}
|
|
1443
|
+
catch (err) {
|
|
1444
|
+
// A room failure must never kill the connection, and it must not read
|
|
1445
|
+
// as "this daemon has no rooms" either -- an empty list here would be
|
|
1446
|
+
// exactly the wrong answer to "the store could not be read".
|
|
1447
|
+
this.ch.send({ t: 'room-list-result', id: m.id, ok: false, error: errMessage(err) });
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
async onRoomRead(m) {
|
|
1451
|
+
if (!this.opts.rooms) {
|
|
1452
|
+
this.ch.send({ t: 'room-read-result', id: m.id, ok: true, room: null, messages: [], turnCosts: [] });
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
try {
|
|
1456
|
+
const found = await this.opts.rooms.read(m.roomId);
|
|
1457
|
+
// `found === null` here is Task 5's considered "no such room" -- keep
|
|
1458
|
+
// it a successful `ok: true, room: null`, never conflated with the
|
|
1459
|
+
// catch below's "could not read it" failure.
|
|
1460
|
+
this.ch.send(found
|
|
1461
|
+
? { t: 'room-read-result', id: m.id, ok: true, room: found.room, messages: found.messages, turnCosts: found.turnCosts }
|
|
1462
|
+
: { t: 'room-read-result', id: m.id, ok: true, room: null, messages: [], turnCosts: [] });
|
|
1463
|
+
}
|
|
1464
|
+
catch (err) {
|
|
1465
|
+
// A thrown read is a STORE FAILURE, not "the room does not exist" --
|
|
1466
|
+
// collapsing it into `room: null` would train a client to stop
|
|
1467
|
+
// retrying on what was really transient.
|
|
1468
|
+
this.ch.send({ t: 'room-read-result', id: m.id, ok: false, error: errMessage(err) });
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
async onRoomResume(m) {
|
|
1472
|
+
if (!this.opts.rooms) {
|
|
1473
|
+
this.ch.send({ t: 'room-resume-result', id: m.id, ok: false, error: 'this sidecar does not host rooms' });
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
try {
|
|
1477
|
+
const room = await this.opts.rooms.resume(m.roomId);
|
|
1478
|
+
this.ch.send({ t: 'room-resume-result', id: m.id, ok: true, room });
|
|
1479
|
+
}
|
|
1480
|
+
catch (err) {
|
|
1481
|
+
// A room failure must never kill the connection; the client gets a
|
|
1482
|
+
// refusal it can show.
|
|
1483
|
+
this.ch.send({ t: 'room-resume-result', id: m.id, ok: false, error: errMessage(err) });
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
async onRoomAnswer(m) {
|
|
1487
|
+
if (!this.opts.rooms) {
|
|
1488
|
+
this.ch.send({ t: 'room-answer-result', id: m.id, ok: false, error: 'this sidecar does not host rooms' });
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
try {
|
|
1492
|
+
const room = await this.opts.rooms.answer(m.roomId, m.participantId, m.text, m.addressed);
|
|
1493
|
+
this.ch.send({ t: 'room-answer-result', id: m.id, ok: true, room });
|
|
1494
|
+
}
|
|
1495
|
+
catch (err) {
|
|
1496
|
+
// A room failure must never kill the connection; the client gets a
|
|
1497
|
+
// refusal it can render instead of a socket that went quiet.
|
|
1498
|
+
this.ch.send({ t: 'room-answer-result', id: m.id, ok: false, error: errMessage(err) });
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
async onRoomInterject(m) {
|
|
1502
|
+
if (!this.opts.rooms) {
|
|
1503
|
+
this.ch.send({ t: 'room-interject-result', id: m.id, ok: false, error: 'this sidecar does not host rooms' });
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
try {
|
|
1507
|
+
const room = await this.opts.rooms.interject(m.roomId, m.participantId, m.text);
|
|
1508
|
+
this.ch.send({ t: 'room-interject-result', id: m.id, ok: true, room });
|
|
1509
|
+
}
|
|
1510
|
+
catch (err) {
|
|
1511
|
+
// A room failure must never kill the connection; the client gets a
|
|
1512
|
+
// refusal it can render instead of a socket that went quiet.
|
|
1513
|
+
this.ch.send({ t: 'room-interject-result', id: m.id, ok: false, error: errMessage(err) });
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
async onRoomClose(m) {
|
|
1517
|
+
if (!this.opts.rooms) {
|
|
1518
|
+
this.ch.send({ t: 'room-close-result', id: m.id, ok: false, error: 'this sidecar does not host rooms' });
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
try {
|
|
1522
|
+
const room = await this.opts.rooms.close(m.roomId, m.participantId, m.reason);
|
|
1523
|
+
this.ch.send({ t: 'room-close-result', id: m.id, ok: true, room });
|
|
1524
|
+
}
|
|
1525
|
+
catch (err) {
|
|
1526
|
+
this.ch.send({ t: 'room-close-result', id: m.id, ok: false, error: errMessage(err) });
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
async onRoomFindings(m) {
|
|
1530
|
+
if (!this.opts.rooms) {
|
|
1531
|
+
// A daemon without the capability hosts no rooms, so it holds no
|
|
1532
|
+
// findings -- an honest empty document, the same answer `room-list`
|
|
1533
|
+
// gives. The catch below is the different thing: a real read that failed.
|
|
1534
|
+
this.ch.send({ t: 'room-findings-result', id: m.id, ok: true, findings: [] });
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
try {
|
|
1538
|
+
this.ch.send({ t: 'room-findings-result', id: m.id, ok: true, findings: await this.opts.rooms.findings(m.roomId) });
|
|
1539
|
+
}
|
|
1540
|
+
catch (err) {
|
|
1541
|
+
this.ch.send({ t: 'room-findings-result', id: m.id, ok: false, error: errMessage(err) });
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
async onRoomFindingEdit(m) {
|
|
1545
|
+
if (!this.opts.rooms) {
|
|
1546
|
+
this.ch.send({ t: 'room-finding-edit-result', id: m.id, ok: false, error: 'this sidecar does not host rooms' });
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
try {
|
|
1550
|
+
const findings = await this.opts.rooms.editFinding(m.roomId, m.participantId, m.edit);
|
|
1551
|
+
this.ch.send({ t: 'room-finding-edit-result', id: m.id, ok: true, findings });
|
|
1552
|
+
}
|
|
1553
|
+
catch (err) {
|
|
1554
|
+
// A refused edit must never kill the connection; the person gets the
|
|
1555
|
+
// reason their edit did not land.
|
|
1556
|
+
this.ch.send({ t: 'room-finding-edit-result', id: m.id, ok: false, error: errMessage(err) });
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1047
1559
|
async onHostSyncPut(m) {
|
|
1048
1560
|
const identity = this.requireHostSyncIdentity();
|
|
1049
1561
|
if ('error' in identity) {
|