@zgeoff/atc 2.15.0 → 2.15.2
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 +2 -1
- package/src/agents/agent-adapter.ts +3 -38
- package/src/agents/build-hook-settings.ts +1 -1
- package/src/agents/claude-adapter.ts +3 -3
- package/src/agents/codex-adapter.ts +3 -3
- package/src/agents/gateway-adapter.ts +3 -3
- package/src/agents/grok-adapter.ts +3 -3
- package/src/agents/start-claude-headless-run.ts +1 -1
- package/src/client/boot-daemon.ts +2 -2
- package/src/client/collect-agent-picks.ts +1 -1
- package/src/client/daemon-client.ts +2 -1
- package/src/client/format-overlay-agent-mark.ts +1 -1
- package/src/client/index.ts +4 -2
- package/src/client/pick-tab-target.ts +2 -2
- package/src/client/spawn-picker.ts +1 -1
- package/src/client/to-mirror-session.ts +3 -3
- package/src/client/ui.ts +3 -3
- package/src/daemon/build-fleet-events.ts +10 -4
- package/src/daemon/build-grant-from-fleet-entry.ts +14 -0
- package/src/daemon/build-message-trail-entry.ts +1 -1
- package/src/daemon/build-report-trail-entry.ts +1 -1
- package/src/daemon/build-report-view.ts +10 -4
- package/src/daemon/build-scoped-context.ts +64 -77
- package/src/daemon/build-session-message-event.ts +1 -1
- package/src/daemon/daemon-connection.ts +206 -288
- package/src/daemon/daemon-context.ts +291 -0
- package/src/daemon/daemon.ts +250 -94
- package/src/daemon/drain-inbox.ts +85 -0
- package/src/daemon/execution-provider.ts +12 -0
- package/src/daemon/hooks.ts +1 -7
- package/src/daemon/idempotency-ledger.ts +78 -18
- package/src/daemon/imp-harness.ts +41 -0
- package/src/daemon/is-tree-in-reach.ts +32 -0
- package/src/daemon/local-pty-provider.ts +45 -0
- package/src/daemon/parse-hook-line.ts +1 -1
- package/src/daemon/sessions.ts +175 -123
- package/src/daemon/start-headless-turn.ts +3 -3
- package/src/daemon/start-session-bridge.ts +2 -2
- package/src/daemon/tap-registry.ts +43 -5
- package/src/mcp/collect-clients.ts +1 -1
- package/src/mcp/collect-grants.ts +1 -1
- package/src/mcp/find-client-name.ts +1 -1
- package/src/mcp/mcp-auth-schema.ts +37 -0
- package/src/mcp/open-mcp-auth.ts +2 -2
- package/src/mcp/reconnecting-caller.ts +15 -6
- package/src/mcp/remove-client.ts +1 -1
- package/src/mcp/revoke-grant.ts +1 -1
- package/src/mcp/types.ts +0 -38
- package/src/mcp-http-server.ts +2 -1
- package/src/protocol/adapter-event.ts +25 -0
- package/src/protocol/count-session-states.ts +16 -0
- package/src/protocol/daemon-channel.ts +17 -0
- package/src/protocol/hook-event.ts +11 -0
- package/src/protocol/pinned-group-key.ts +2 -0
- package/src/protocol/session-state.ts +5 -0
- package/src/protocol/sort-grouped-session-views.ts +29 -0
- package/src/protocol/sort-session-views.ts +41 -0
- package/src/protocol/sortable-session-view.ts +13 -0
- package/src/shared/agent-id.ts +6 -0
- package/src/shared/collect-gateways.ts +1 -1
- package/src/shared/to-agent-id.ts +11 -0
- package/src/store/fleet-entry.ts +2 -2
- package/src/store/run-migrations.ts +55 -4
- package/src/store/state-store.ts +49 -13
- /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
- /package/src/{agents → shared}/truncate-detail.ts +0 -0
- /package/src/{daemon → shared}/truncate-summary.ts +0 -0
package/src/daemon/daemon.ts
CHANGED
|
@@ -2,12 +2,15 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { hostname, tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
|
-
import type {
|
|
5
|
+
import type { AgentAdapter } from '../agents/agent-adapter';
|
|
6
6
|
import { planTypedLineInput } from '../agents/plan-typed-line-input';
|
|
7
|
+
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
7
8
|
import { DaemonError } from '../protocol/daemon-error';
|
|
9
|
+
import type { HookEvent } from '../protocol/hook-event';
|
|
8
10
|
import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
|
|
9
11
|
import type { EventMsg } from '../protocol/protocol';
|
|
10
12
|
import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
|
|
13
|
+
import type { SessionState } from '../protocol/session-state';
|
|
11
14
|
import type { HooksConfig } from '../shared/collect-hooks';
|
|
12
15
|
import type { TargetConfigError } from '../shared/collect-targets';
|
|
13
16
|
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
@@ -30,6 +33,7 @@ import { buildConfigRevision } from './build-config-revision';
|
|
|
30
33
|
import { buildExecutionTargets } from './build-execution-targets';
|
|
31
34
|
import type { ExecutionTarget } from './build-execution-targets';
|
|
32
35
|
import { buildFleetEvents } from './build-fleet-events';
|
|
36
|
+
import { buildGrantFromFleetEntry } from './build-grant-from-fleet-entry';
|
|
33
37
|
import { buildMessageTrailEntry } from './build-message-trail-entry';
|
|
34
38
|
import { buildReportTrailEntry } from './build-report-trail-entry';
|
|
35
39
|
import { buildReportView } from './build-report-view';
|
|
@@ -47,12 +51,14 @@ import type {
|
|
|
47
51
|
OutputClient,
|
|
48
52
|
SpawnParams,
|
|
49
53
|
TapClient,
|
|
50
|
-
} from './daemon-
|
|
54
|
+
} from './daemon-context';
|
|
55
|
+
import { drainInbox } from './drain-inbox';
|
|
56
|
+
import type { InboxSource } from './drain-inbox';
|
|
51
57
|
import { EffectRemainsError } from './effect-remains-error';
|
|
52
58
|
import { EventSignal } from './event-signal';
|
|
53
59
|
import { startHookServer } from './hooks';
|
|
54
|
-
import type { HookEvent } from './hooks';
|
|
55
60
|
import { IdempotencyLedger } from './idempotency-ledger';
|
|
61
|
+
import { isTreeInReach } from './is-tree-in-reach';
|
|
56
62
|
import { loadTranscriptPage } from './load-transcript-page';
|
|
57
63
|
import { makeHookRunner } from './make-hook-runner';
|
|
58
64
|
import type { HookScope } from './make-hook-runner';
|
|
@@ -66,7 +72,7 @@ import { runEjectHandoff } from './run-eject-handoff';
|
|
|
66
72
|
import { ScreenModel } from './screen-model';
|
|
67
73
|
import { SessionRuntime } from './session-runtime';
|
|
68
74
|
import { SessionManager } from './sessions';
|
|
69
|
-
import type { Session, SessionDescriptor
|
|
75
|
+
import type { Session, SessionDescriptor } from './sessions';
|
|
70
76
|
import { startEventsServer } from './start-events-server';
|
|
71
77
|
import { startHeadlessTurn } from './start-headless-turn';
|
|
72
78
|
import { startSessionBridge } from './start-session-bridge';
|
|
@@ -319,8 +325,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
319
325
|
|
|
320
326
|
// A live session's scope also matches the rows it wrote under an earlier
|
|
321
327
|
// atc id, through the agent session id a restore carries on. Under an
|
|
322
|
-
// access, the scope holds only the
|
|
323
|
-
//
|
|
328
|
+
// access, the scope holds only the atc ids of the sessions whose whole
|
|
329
|
+
// tree is on targets the access holds, so a session outside it matches
|
|
330
|
+
// nothing, as a session never seen does. It holds no agent session id there: a session on
|
|
331
|
+
// another target can resume the same agent session, and its rows would
|
|
332
|
+
// match.
|
|
324
333
|
const buildEventScope = (
|
|
325
334
|
sessionID: SessionID | null,
|
|
326
335
|
access: TargetAccess | null,
|
|
@@ -339,19 +348,27 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
339
348
|
}
|
|
340
349
|
|
|
341
350
|
const visible = mgr.sessions.filter(
|
|
342
|
-
(x) =>
|
|
351
|
+
(x) =>
|
|
352
|
+
(sessionID === null || x.id === sessionID) && isTreeInReach(mgr.sessions, x.id, access),
|
|
343
353
|
);
|
|
344
354
|
|
|
345
|
-
return {
|
|
346
|
-
atcIDs: visible.map((x) => x.id),
|
|
347
|
-
agentSessionIDs: visible.flatMap((x) =>
|
|
348
|
-
x.agentSessionID === undefined ? [] : [x.agentSessionID],
|
|
349
|
-
),
|
|
350
|
-
};
|
|
355
|
+
return { atcIDs: visible.map((x) => x.id), agentSessionIDs: [] };
|
|
351
356
|
};
|
|
352
357
|
|
|
353
|
-
//
|
|
354
|
-
//
|
|
358
|
+
// The sessions that may name a trail row: under an access, only those
|
|
359
|
+
// whose whole tree is on targets it holds.
|
|
360
|
+
const collectNamingDescriptors = (access: TargetAccess | null): SessionDescriptor[] => {
|
|
361
|
+
const reached = new Set(
|
|
362
|
+
mgr.sessions
|
|
363
|
+
.filter((x) => access === null || isTreeInReach(mgr.sessions, x.id, access))
|
|
364
|
+
.map((x) => x.id),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
return mgr.collectDescriptors().filter((d) => reached.has(d.id));
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// A message belongs to the live session holding its atc id, else to the
|
|
371
|
+
// one holding its agent session id, else to the atc id it was sent to.
|
|
355
372
|
const readMessageView = async (messageID: MessageID) => {
|
|
356
373
|
const record = await store.findMessageByID(messageID);
|
|
357
374
|
|
|
@@ -359,11 +376,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
359
376
|
return null;
|
|
360
377
|
}
|
|
361
378
|
|
|
362
|
-
const owner =
|
|
363
|
-
(x) =>
|
|
364
|
-
|
|
365
|
-
(record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID
|
|
366
|
-
|
|
379
|
+
const owner =
|
|
380
|
+
mgr.sessions.find((x) => x.id === record.atcID) ??
|
|
381
|
+
mgr.sessions.find(
|
|
382
|
+
(x) => record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID,
|
|
383
|
+
);
|
|
367
384
|
|
|
368
385
|
return {
|
|
369
386
|
session: owner?.id ?? record.atcID,
|
|
@@ -406,50 +423,17 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
406
423
|
// the order of their sent times, which the inbox drains by.
|
|
407
424
|
let lastMessageWrite: Promise<void> = Promise.resolve();
|
|
408
425
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const tap = taps.claimDelivery(sessionID, record.id);
|
|
414
|
-
|
|
415
|
-
if (tap === null) {
|
|
416
|
-
return false;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
tap.sendEvent({
|
|
420
|
-
v: PROTOCOL_V,
|
|
421
|
-
ev: 'InboxMessage',
|
|
422
|
-
s: sessionID,
|
|
423
|
-
message: record.id,
|
|
424
|
-
from: record.from,
|
|
425
|
-
text: record.text,
|
|
426
|
-
sentAt: record.sentAt,
|
|
427
|
-
});
|
|
426
|
+
const inboxSource: InboxSource = {
|
|
427
|
+
taps,
|
|
428
|
+
findLinkedOwner: (sessionID) => {
|
|
429
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
428
430
|
|
|
429
|
-
|
|
431
|
+
return s === undefined ? null : buildMessageOwner(s);
|
|
432
|
+
},
|
|
433
|
+
collectPendingMessages: (owner) => store.collectPendingMessages(owner),
|
|
430
434
|
};
|
|
431
435
|
|
|
432
|
-
|
|
433
|
-
// ok response is always queued ahead of its first message. It sends one
|
|
434
|
-
// unclaimed message per call and the tap's ack calls it again, so the
|
|
435
|
-
// backlog never outgrows the connection's outbound queue.
|
|
436
|
-
const drainInbox = async (sessionID: SessionID) => {
|
|
437
|
-
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
438
|
-
|
|
439
|
-
if (s === undefined) {
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
try {
|
|
444
|
-
const pending = await store.collectPendingMessages(buildMessageOwner(s));
|
|
445
|
-
|
|
446
|
-
for (const record of pending) {
|
|
447
|
-
if (sendInboxMessage(sessionID, record)) {
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
} catch {}
|
|
452
|
-
};
|
|
436
|
+
const drainSessionInbox = (sessionID: SessionID) => drainInbox(sessionID, inboxSource);
|
|
453
437
|
|
|
454
438
|
// A report id, which a remote session's reporter gives each report,
|
|
455
439
|
// makes a resent note land once; a resent answer changes nothing, since
|
|
@@ -839,8 +823,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
839
823
|
|
|
840
824
|
return true;
|
|
841
825
|
},
|
|
842
|
-
attachTap: (client, sessionID) => ctx.attachTap(client, sessionID),
|
|
843
|
-
ackMessage: (client, sessionID, messageID) =>
|
|
826
|
+
attachTap: (client, sessionID) => ctx.attachTap(client, sessionID, null),
|
|
827
|
+
ackMessage: (client, sessionID, messageID) =>
|
|
828
|
+
ctx.ackMessage(client, sessionID, messageID, null),
|
|
844
829
|
detachTap: (client) => {
|
|
845
830
|
taps.detachAll(client);
|
|
846
831
|
},
|
|
@@ -856,6 +841,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
856
841
|
const startSpawn = async (
|
|
857
842
|
p: SpawnParams,
|
|
858
843
|
id: SessionID,
|
|
844
|
+
requireInReach: () => void = () => {},
|
|
859
845
|
): Promise<Readonly<Record<string, unknown>>> => {
|
|
860
846
|
const prepared =
|
|
861
847
|
p.workspace === null ? null : await materializeSpawnWorkspace(p, id, p.workspace);
|
|
@@ -864,7 +850,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
864
850
|
const warnings = materialized === null ? [] : materialized.warnings;
|
|
865
851
|
|
|
866
852
|
try {
|
|
867
|
-
const session = await startSpawnedSession(p, id, materialized);
|
|
853
|
+
const session = await startSpawnedSession(p, id, materialized, requireInReach);
|
|
868
854
|
|
|
869
855
|
return warnings.length === 0 ? { session } : { session, warnings };
|
|
870
856
|
} catch (error) {
|
|
@@ -920,6 +906,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
920
906
|
workspace: SessionWorkspace;
|
|
921
907
|
withheldEnv: readonly string[];
|
|
922
908
|
}> | null,
|
|
909
|
+
requireInReach: () => void,
|
|
923
910
|
): Promise<SessionDescriptor> => {
|
|
924
911
|
const s = await mgr.spawn(
|
|
925
912
|
p.cwd,
|
|
@@ -935,6 +922,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
935
922
|
id,
|
|
936
923
|
p.target,
|
|
937
924
|
materialized,
|
|
925
|
+
requireInReach,
|
|
938
926
|
);
|
|
939
927
|
|
|
940
928
|
const runtime = runtimes.get(s.id);
|
|
@@ -943,7 +931,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
943
931
|
runtime.pendingLastUsed = true;
|
|
944
932
|
}
|
|
945
933
|
|
|
946
|
-
void store.recordSpawnDir(p.cwd);
|
|
934
|
+
void store.recordSpawnDir(p.cwd, { target: s.target, targetIdentity: s.targetIdentity });
|
|
947
935
|
|
|
948
936
|
return getDescriptor(mgr, s.id);
|
|
949
937
|
};
|
|
@@ -972,12 +960,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
972
960
|
);
|
|
973
961
|
};
|
|
974
962
|
|
|
975
|
-
const ledger = new IdempotencyLedger(store, LOCAL_PRINCIPAL)
|
|
963
|
+
const ledger = new IdempotencyLedger(store, LOCAL_PRINCIPAL, (line) => {
|
|
964
|
+
mgr.log(line);
|
|
965
|
+
});
|
|
976
966
|
|
|
977
967
|
// Throws the refusal a fresh spawn to the target gets when the access
|
|
978
968
|
// does not reach the target, and the identity, a held spawn key recorded
|
|
979
969
|
// for its session. A key that records none is refused: nothing it holds
|
|
980
|
-
// shows where its session ran.
|
|
970
|
+
// shows where its session ran. A session the daemon still holds is
|
|
971
|
+
// refused the same way when the access does not reach its whole tree.
|
|
981
972
|
const requireReplayInReach = (record: IdempotencyRecord, access: TargetAccess): void => {
|
|
982
973
|
const bound = record.effectTarget;
|
|
983
974
|
|
|
@@ -985,11 +976,48 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
985
976
|
throw buildTargetForbiddenError(findReplayTarget(record));
|
|
986
977
|
}
|
|
987
978
|
|
|
988
|
-
|
|
979
|
+
const session = mgr.sessions.find((x) => x.id === record.effectRef);
|
|
980
|
+
|
|
981
|
+
if (
|
|
982
|
+
!access.canUse(bound) ||
|
|
983
|
+
(session !== undefined && !isTreeInReach(mgr.sessions, session.id, access))
|
|
984
|
+
) {
|
|
989
985
|
throw buildTargetForbiddenError(bound.target);
|
|
990
986
|
}
|
|
991
987
|
};
|
|
992
988
|
|
|
989
|
+
// Throws the refusal of a replay out of reach when a held spawn key's
|
|
990
|
+
// session is no longer live and the access does not reach the whole tree
|
|
991
|
+
// its fleet rows hold, as the live check refuses a live tree. A session
|
|
992
|
+
// with no fleet row has no tree left to check.
|
|
993
|
+
const requireStoredTreeInReach = async (
|
|
994
|
+
record: IdempotencyRecord,
|
|
995
|
+
access: TargetAccess,
|
|
996
|
+
): Promise<void> => {
|
|
997
|
+
if (mgr.sessions.some((x) => x.id === record.effectRef)) {
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const fleet = await store.loadFleet();
|
|
1002
|
+
|
|
1003
|
+
const members = fleet.map((entry) => {
|
|
1004
|
+
const grant = buildGrantFromFleetEntry(entry);
|
|
1005
|
+
|
|
1006
|
+
return {
|
|
1007
|
+
id: entry.sessionID,
|
|
1008
|
+
parent: entry.parent ?? null,
|
|
1009
|
+
target: grant.target,
|
|
1010
|
+
targetIdentity: grant.targetIdentity,
|
|
1011
|
+
};
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
const own = members.find((member) => member.id === record.effectRef);
|
|
1015
|
+
|
|
1016
|
+
if (own !== undefined && !isTreeInReach(members, own.id, access)) {
|
|
1017
|
+
throw buildTargetForbiddenError(record.effectTarget?.target ?? findReplayTarget(record));
|
|
1018
|
+
}
|
|
1019
|
+
};
|
|
1020
|
+
|
|
993
1021
|
// Each confirm token a forget handed out, by token: the session it
|
|
994
1022
|
// forgets, when it stops being taken, and whether a forget took it. A
|
|
995
1023
|
// token stays known for one more lifetime after it expires, so a late
|
|
@@ -1067,11 +1095,14 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1067
1095
|
// it to the session's tap when one is attached. Only the write itself can
|
|
1068
1096
|
// reject: every later step swallows its own failure, so a rejection means
|
|
1069
1097
|
// no message was written.
|
|
1098
|
+
// The caller's check runs again once the writes ahead of this one land,
|
|
1099
|
+
// before anything is written; a failed check writes nothing.
|
|
1070
1100
|
const writeAcceptedMessage = async (
|
|
1071
1101
|
sessionID: SessionID,
|
|
1072
1102
|
from: string,
|
|
1073
1103
|
text: string,
|
|
1074
1104
|
id: MessageID,
|
|
1105
|
+
requireWriteInReach: () => void,
|
|
1075
1106
|
): Promise<MessageRecord> => {
|
|
1076
1107
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
1077
1108
|
const previousWrite = lastMessageWrite;
|
|
@@ -1092,6 +1123,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1092
1123
|
};
|
|
1093
1124
|
|
|
1094
1125
|
try {
|
|
1126
|
+
// The write boundary: the reach check and the start of the row write
|
|
1127
|
+
// run in one synchronous step, so no wait lets the session's tree
|
|
1128
|
+
// leave the access between the check and the write it allows.
|
|
1129
|
+
requireWriteInReach();
|
|
1130
|
+
|
|
1095
1131
|
await store.writeMessage(record);
|
|
1096
1132
|
|
|
1097
1133
|
await recordMessageStatus(sessionID, record);
|
|
@@ -1099,7 +1135,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1099
1135
|
written.resolve();
|
|
1100
1136
|
}
|
|
1101
1137
|
|
|
1102
|
-
await
|
|
1138
|
+
await drainSessionInbox(sessionID);
|
|
1103
1139
|
|
|
1104
1140
|
return record;
|
|
1105
1141
|
};
|
|
@@ -1144,7 +1180,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1144
1180
|
build: opts.build,
|
|
1145
1181
|
daemonID: store.daemonID,
|
|
1146
1182
|
collectSessions: () => mgr.collectDescriptors(),
|
|
1147
|
-
collectSpawnDirs: () =>
|
|
1183
|
+
collectSpawnDirs: async (access) => {
|
|
1184
|
+
const dirs = await store.collectSpawnDirs();
|
|
1185
|
+
|
|
1186
|
+
const reached = dirs.filter((dir) => access === null || access.canUse(dir.grant));
|
|
1187
|
+
|
|
1188
|
+
return [...new Set(reached.map((dir) => dir.cwd))];
|
|
1189
|
+
},
|
|
1148
1190
|
collectAgents: () => ({
|
|
1149
1191
|
daemon: {
|
|
1150
1192
|
hostname: hostname(),
|
|
@@ -1168,8 +1210,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1168
1210
|
return s === undefined ? null : { target: s.target, targetIdentity: s.targetIdentity };
|
|
1169
1211
|
},
|
|
1170
1212
|
findTargetIdentity: (target) => targets.find((x) => x.id === target)?.identity ?? null,
|
|
1171
|
-
|
|
1213
|
+
canSeeSession: (id, access) => isTreeInReach(mgr.sessions, id, access),
|
|
1214
|
+
isSessionVisible: () => true,
|
|
1172
1215
|
findPermissionSession: (request) => registry.findSessionID(request),
|
|
1216
|
+
resolveSpawnParent: (id) => {
|
|
1217
|
+
const owner = mgr.sessions.find((x) => x.id === id);
|
|
1218
|
+
|
|
1219
|
+
if (owner === undefined) {
|
|
1220
|
+
return 'missing';
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
return owner.parent ?? owner.id;
|
|
1224
|
+
},
|
|
1173
1225
|
resolveSpawnTarget: (requested) => {
|
|
1174
1226
|
const target = requested ?? defaultTarget;
|
|
1175
1227
|
const refusal = mgr.findExecutionRefusal({ target, targetIdentity: null }, 'spawn');
|
|
@@ -1189,8 +1241,22 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1189
1241
|
mgr.requireExecution({ target, targetIdentity: null }, 'run');
|
|
1190
1242
|
},
|
|
1191
1243
|
spawnSession: (plan, keyed, access) => {
|
|
1244
|
+
// Under an access, a spawn under a parent whose tree leaves the access
|
|
1245
|
+
// before the harness starts is refused as a spawn under an unknown
|
|
1246
|
+
// parent.
|
|
1247
|
+
const startInReach = (p: SpawnParams, id: SessionID) =>
|
|
1248
|
+
startSpawn(p, id, () => {
|
|
1249
|
+
if (
|
|
1250
|
+
access !== null &&
|
|
1251
|
+
p.parent !== null &&
|
|
1252
|
+
!isTreeInReach(mgr.sessions, p.parent, access)
|
|
1253
|
+
) {
|
|
1254
|
+
throw new DaemonError('no_such_session', `no session '${p.parent}'`);
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1192
1258
|
if (keyed === null) {
|
|
1193
|
-
return
|
|
1259
|
+
return startInReach(plan(), mintSessionID());
|
|
1194
1260
|
}
|
|
1195
1261
|
|
|
1196
1262
|
const effectRef = mintSessionID();
|
|
@@ -1199,13 +1265,21 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1199
1265
|
operation: 'session.spawn',
|
|
1200
1266
|
keyed,
|
|
1201
1267
|
effectRef,
|
|
1202
|
-
start: () =>
|
|
1268
|
+
start: () => startInReach(plan(), effectRef),
|
|
1203
1269
|
settle: () => mgr.writeFleet(),
|
|
1204
|
-
replay: (record) => {
|
|
1205
|
-
if (access
|
|
1206
|
-
|
|
1270
|
+
replay: async (record) => {
|
|
1271
|
+
if (access === null) {
|
|
1272
|
+
return loadSpawnReplay(record);
|
|
1207
1273
|
}
|
|
1208
1274
|
|
|
1275
|
+
requireReplayInReach(record, access);
|
|
1276
|
+
|
|
1277
|
+
await requireStoredTreeInReach(record, access);
|
|
1278
|
+
|
|
1279
|
+
// The tree may change while the fleet is read, so the live check
|
|
1280
|
+
// runs again in the step that answers.
|
|
1281
|
+
requireReplayInReach(record, access);
|
|
1282
|
+
|
|
1209
1283
|
return loadSpawnReplay(record);
|
|
1210
1284
|
},
|
|
1211
1285
|
findEffectTarget: () => {
|
|
@@ -1325,7 +1399,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1325
1399
|
|
|
1326
1400
|
return 'ok';
|
|
1327
1401
|
},
|
|
1328
|
-
adoptSession: async (id, cols, rows) => {
|
|
1402
|
+
adoptSession: async (id, cols, rows, access) => {
|
|
1329
1403
|
if (!hasResumableTranscript(mgr, id)) {
|
|
1330
1404
|
return 'no_transcript';
|
|
1331
1405
|
}
|
|
@@ -1334,7 +1408,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1334
1408
|
|
|
1335
1409
|
runtime?.stopHeadlessRun();
|
|
1336
1410
|
|
|
1337
|
-
const adopted = await mgr.adoptTerminal(
|
|
1411
|
+
const adopted = await mgr.adoptTerminal(
|
|
1412
|
+
id,
|
|
1413
|
+
cols,
|
|
1414
|
+
rows,
|
|
1415
|
+
() => access === null || isTreeInReach(mgr.sessions, id, access),
|
|
1416
|
+
);
|
|
1338
1417
|
|
|
1339
1418
|
if (adopted === null) {
|
|
1340
1419
|
return 'missing';
|
|
@@ -1492,7 +1571,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1492
1571
|
runtimes.get(sessionID)?.dims ?? { cols: 80, rows: 24 },
|
|
1493
1572
|
restoreFleet: (cols, rows) =>
|
|
1494
1573
|
restoreFleet({ mgr, store, findRuntime, cols, rows, capMs: opts.restoreBootTimeoutMs ?? 0 }),
|
|
1495
|
-
readSessionRecord: async (id) => {
|
|
1574
|
+
readSessionRecord: async (id, access) => {
|
|
1496
1575
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
1497
1576
|
|
|
1498
1577
|
if (s === undefined) {
|
|
@@ -1507,7 +1586,19 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1507
1586
|
const result = s.result ?? null;
|
|
1508
1587
|
const createdAt = s.createdAt;
|
|
1509
1588
|
|
|
1510
|
-
|
|
1589
|
+
// The agent session id links rows from before atc ids stayed stable,
|
|
1590
|
+
// unless a session on another target resumed the same agent session,
|
|
1591
|
+
// whose rows it would match too. Under an access it links nothing: a
|
|
1592
|
+
// session that shared it may be gone, its rows still in the trail.
|
|
1593
|
+
const shared = mgr.sessions.some(
|
|
1594
|
+
(x) =>
|
|
1595
|
+
x.agentSessionID === s.agentSessionID &&
|
|
1596
|
+
(x.target !== s.target || x.targetIdentity !== s.targetIdentity),
|
|
1597
|
+
);
|
|
1598
|
+
|
|
1599
|
+
const linked = access !== null || shared ? undefined : s.agentSessionID;
|
|
1600
|
+
|
|
1601
|
+
const lastEventAt = await store.loadLastActivityAt(s.id, linked);
|
|
1511
1602
|
|
|
1512
1603
|
return {
|
|
1513
1604
|
session,
|
|
@@ -1543,13 +1634,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1543
1634
|
},
|
|
1544
1635
|
readEvents: async (afterID, limit, waitMs, sessionID, access) => {
|
|
1545
1636
|
const deadline = Date.now() + waitMs;
|
|
1546
|
-
const scope = buildEventScope(sessionID, access);
|
|
1547
1637
|
|
|
1548
1638
|
// A wake for an event the read leaves out (a heartbeat, or another
|
|
1549
1639
|
// session's event under a session filter) loops back to wait out the
|
|
1550
|
-
// rest of the window.
|
|
1640
|
+
// rest of the window. The scope is built again after every await, so
|
|
1641
|
+
// a session whose tree left the access while the read waited matches
|
|
1642
|
+
// nothing, and a read whose scope changed under its query runs again.
|
|
1551
1643
|
for (;;) {
|
|
1552
1644
|
const generation = eventSignal.generation;
|
|
1645
|
+
const scope = buildEventScope(sessionID, access);
|
|
1553
1646
|
|
|
1554
1647
|
// A read from a cursor takes one row past the limit to learn whether
|
|
1555
1648
|
// more follow; the latest events have nothing after them.
|
|
@@ -1558,11 +1651,24 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1558
1651
|
? await store.collectLatestEvents(limit, scope)
|
|
1559
1652
|
: await store.collectEventsAfter(afterID, limit + 1, scope);
|
|
1560
1653
|
|
|
1654
|
+
if (
|
|
1655
|
+
access !== null &&
|
|
1656
|
+
JSON.stringify(buildEventScope(sessionID, access)) !== JSON.stringify(scope)
|
|
1657
|
+
) {
|
|
1658
|
+
continue;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1561
1661
|
const remaining = deadline - Date.now();
|
|
1562
1662
|
|
|
1563
1663
|
if (rows.length > 0 || remaining <= 0 || eventSignal.disposed) {
|
|
1664
|
+
const naming = collectNamingDescriptors(access);
|
|
1665
|
+
|
|
1666
|
+
// Under an access an event takes no alias, so each event keeps the
|
|
1667
|
+
// atc id it was recorded under for the check that sends it.
|
|
1668
|
+
const aliases = access === null ? naming : [];
|
|
1669
|
+
|
|
1564
1670
|
return {
|
|
1565
|
-
events: buildFleetEvents(rows.slice(0, limit),
|
|
1671
|
+
events: buildFleetEvents(rows.slice(0, limit), naming, aliases),
|
|
1566
1672
|
more: rows.length > limit,
|
|
1567
1673
|
};
|
|
1568
1674
|
}
|
|
@@ -1573,9 +1679,30 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1573
1679
|
readReport: async (id, access) => {
|
|
1574
1680
|
const stored = await store.findReport(id, buildEventScope(null, access));
|
|
1575
1681
|
|
|
1576
|
-
|
|
1682
|
+
if (stored === null) {
|
|
1683
|
+
return null;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
const naming = collectNamingDescriptors(access);
|
|
1687
|
+
const aliases = access === null ? naming : [];
|
|
1688
|
+
|
|
1689
|
+
// Under an access a report takes no alias, so a session in reach
|
|
1690
|
+
// never names a report whose own session left the access while the
|
|
1691
|
+
// query waited.
|
|
1692
|
+
return {
|
|
1693
|
+
owner: stored.atcID,
|
|
1694
|
+
view: buildReportView(stored, naming, aliases),
|
|
1695
|
+
};
|
|
1577
1696
|
},
|
|
1578
|
-
writeSessionMessage: async (sessionID, from, text, keyed) => {
|
|
1697
|
+
writeSessionMessage: async (sessionID, from, text, keyed, access) => {
|
|
1698
|
+
// Under an access, a session whose tree leaves the access before the
|
|
1699
|
+
// message is written refuses it as an unknown session does.
|
|
1700
|
+
const requireInReach = () => {
|
|
1701
|
+
if (access !== null && !isTreeInReach(mgr.sessions, sessionID, access)) {
|
|
1702
|
+
throw new MessageRefusedError('missing');
|
|
1703
|
+
}
|
|
1704
|
+
};
|
|
1705
|
+
|
|
1579
1706
|
if (keyed === null) {
|
|
1580
1707
|
const refusal = findMessageRefusal(sessionID);
|
|
1581
1708
|
|
|
@@ -1583,9 +1710,23 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1583
1710
|
return refusal;
|
|
1584
1711
|
}
|
|
1585
1712
|
|
|
1586
|
-
|
|
1713
|
+
try {
|
|
1714
|
+
const record = await writeAcceptedMessage(
|
|
1715
|
+
sessionID,
|
|
1716
|
+
from,
|
|
1717
|
+
text,
|
|
1718
|
+
mintMessageID(),
|
|
1719
|
+
requireInReach,
|
|
1720
|
+
);
|
|
1587
1721
|
|
|
1588
|
-
|
|
1722
|
+
return { message: record.id, status: record.status };
|
|
1723
|
+
} catch (error) {
|
|
1724
|
+
if (error instanceof MessageRefusedError) {
|
|
1725
|
+
return error.refusal;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
throw error;
|
|
1729
|
+
}
|
|
1589
1730
|
}
|
|
1590
1731
|
|
|
1591
1732
|
const effectRef = mintMessageID();
|
|
@@ -1598,13 +1739,21 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1598
1739
|
|
|
1599
1740
|
// A refusal throws, so the claim drops and a retry runs fresh.
|
|
1600
1741
|
start: async () => {
|
|
1742
|
+
requireInReach();
|
|
1743
|
+
|
|
1601
1744
|
const refusal = findMessageRefusal(sessionID);
|
|
1602
1745
|
|
|
1603
1746
|
if (refusal !== null) {
|
|
1604
1747
|
throw new MessageRefusedError(refusal);
|
|
1605
1748
|
}
|
|
1606
1749
|
|
|
1607
|
-
const record = await writeAcceptedMessage(
|
|
1750
|
+
const record = await writeAcceptedMessage(
|
|
1751
|
+
sessionID,
|
|
1752
|
+
from,
|
|
1753
|
+
text,
|
|
1754
|
+
effectRef,
|
|
1755
|
+
requireInReach,
|
|
1756
|
+
);
|
|
1608
1757
|
|
|
1609
1758
|
return { message: record.id, status: record.status };
|
|
1610
1759
|
},
|
|
@@ -1621,7 +1770,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1621
1770
|
throw error;
|
|
1622
1771
|
}
|
|
1623
1772
|
},
|
|
1624
|
-
attachTap: (client, sessionID) => {
|
|
1773
|
+
attachTap: (client, sessionID, access) => {
|
|
1625
1774
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
1626
1775
|
|
|
1627
1776
|
if (s === undefined) {
|
|
@@ -1632,7 +1781,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1632
1781
|
return 'unsupported';
|
|
1633
1782
|
}
|
|
1634
1783
|
|
|
1635
|
-
emitInboxClosed(taps.attach(sessionID, client), sessionID, 'replaced');
|
|
1784
|
+
emitInboxClosed(taps.attach(sessionID, client, access === null), sessionID, 'replaced');
|
|
1636
1785
|
|
|
1637
1786
|
const runtime = runtimes.get(sessionID);
|
|
1638
1787
|
|
|
@@ -1640,10 +1789,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1640
1789
|
runtime.tapAttached = true;
|
|
1641
1790
|
}
|
|
1642
1791
|
|
|
1643
|
-
void
|
|
1792
|
+
void drainSessionInbox(sessionID);
|
|
1644
1793
|
|
|
1645
1794
|
return 'ok';
|
|
1646
1795
|
},
|
|
1796
|
+
detachTap: (client, sessionID) => {
|
|
1797
|
+
if (taps.isTap(sessionID, client)) {
|
|
1798
|
+
taps.removeSession(sessionID);
|
|
1799
|
+
}
|
|
1800
|
+
},
|
|
1647
1801
|
readMessage: async (messageID, waitMs) => {
|
|
1648
1802
|
const deadline = Date.now() + waitMs;
|
|
1649
1803
|
let initialStatus: MessageStatus | null = null;
|
|
@@ -1677,18 +1831,20 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1677
1831
|
await eventSignal.waitForNext(generation, remaining);
|
|
1678
1832
|
}
|
|
1679
1833
|
},
|
|
1680
|
-
ackMessage: async (client, sessionID, messageID) => {
|
|
1834
|
+
ackMessage: async (client, sessionID, messageID, access) => {
|
|
1681
1835
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
1682
1836
|
|
|
1683
1837
|
if (s === undefined || !taps.isTap(sessionID, client)) {
|
|
1684
1838
|
return 'not_tapping';
|
|
1685
1839
|
}
|
|
1686
1840
|
|
|
1687
|
-
|
|
1841
|
+
// Under an access, only a message sent to this atc id is the
|
|
1842
|
+
// session's to ack, as with the inbox drain.
|
|
1843
|
+
const owner = access === null ? buildMessageOwner(s) : { atcID: s.id };
|
|
1688
1844
|
|
|
1689
1845
|
const delivered = await store.updateMessageDelivered(messageID, owner, Date.now());
|
|
1690
1846
|
|
|
1691
|
-
void
|
|
1847
|
+
void drainSessionInbox(sessionID);
|
|
1692
1848
|
|
|
1693
1849
|
if (delivered !== null) {
|
|
1694
1850
|
await recordMessageStatus(sessionID, delivered);
|