@zgeoff/atc 2.15.1 → 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 +1 -1
- package/src/daemon/build-fleet-events.ts +10 -4
- package/src/daemon/build-grant-from-fleet-entry.ts +14 -0
- package/src/daemon/build-report-view.ts +10 -4
- package/src/daemon/build-scoped-context.ts +63 -76
- package/src/daemon/daemon-connection.ts +202 -51
- package/src/daemon/daemon-context.ts +63 -12
- package/src/daemon/daemon.ts +241 -89
- package/src/daemon/drain-inbox.ts +85 -0
- package/src/daemon/is-tree-in-reach.ts +32 -0
- package/src/daemon/sessions.ts +62 -22
- package/src/daemon/tap-registry.ts +43 -5
- package/src/store/run-migrations.ts +55 -4
- package/src/store/state-store.ts +44 -9
package/src/daemon/daemon.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { buildConfigRevision } from './build-config-revision';
|
|
|
33
33
|
import { buildExecutionTargets } from './build-execution-targets';
|
|
34
34
|
import type { ExecutionTarget } from './build-execution-targets';
|
|
35
35
|
import { buildFleetEvents } from './build-fleet-events';
|
|
36
|
+
import { buildGrantFromFleetEntry } from './build-grant-from-fleet-entry';
|
|
36
37
|
import { buildMessageTrailEntry } from './build-message-trail-entry';
|
|
37
38
|
import { buildReportTrailEntry } from './build-report-trail-entry';
|
|
38
39
|
import { buildReportView } from './build-report-view';
|
|
@@ -51,10 +52,13 @@ import type {
|
|
|
51
52
|
SpawnParams,
|
|
52
53
|
TapClient,
|
|
53
54
|
} from './daemon-context';
|
|
55
|
+
import { drainInbox } from './drain-inbox';
|
|
56
|
+
import type { InboxSource } from './drain-inbox';
|
|
54
57
|
import { EffectRemainsError } from './effect-remains-error';
|
|
55
58
|
import { EventSignal } from './event-signal';
|
|
56
59
|
import { startHookServer } from './hooks';
|
|
57
60
|
import { IdempotencyLedger } from './idempotency-ledger';
|
|
61
|
+
import { isTreeInReach } from './is-tree-in-reach';
|
|
58
62
|
import { loadTranscriptPage } from './load-transcript-page';
|
|
59
63
|
import { makeHookRunner } from './make-hook-runner';
|
|
60
64
|
import type { HookScope } from './make-hook-runner';
|
|
@@ -321,8 +325,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
321
325
|
|
|
322
326
|
// A live session's scope also matches the rows it wrote under an earlier
|
|
323
327
|
// atc id, through the agent session id a restore carries on. Under an
|
|
324
|
-
// access, the scope holds only the
|
|
325
|
-
//
|
|
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.
|
|
326
333
|
const buildEventScope = (
|
|
327
334
|
sessionID: SessionID | null,
|
|
328
335
|
access: TargetAccess | null,
|
|
@@ -341,19 +348,27 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
341
348
|
}
|
|
342
349
|
|
|
343
350
|
const visible = mgr.sessions.filter(
|
|
344
|
-
(x) =>
|
|
351
|
+
(x) =>
|
|
352
|
+
(sessionID === null || x.id === sessionID) && isTreeInReach(mgr.sessions, x.id, access),
|
|
345
353
|
);
|
|
346
354
|
|
|
347
|
-
return {
|
|
348
|
-
atcIDs: visible.map((x) => x.id),
|
|
349
|
-
agentSessionIDs: visible.flatMap((x) =>
|
|
350
|
-
x.agentSessionID === undefined ? [] : [x.agentSessionID],
|
|
351
|
-
),
|
|
352
|
-
};
|
|
355
|
+
return { atcIDs: visible.map((x) => x.id), agentSessionIDs: [] };
|
|
353
356
|
};
|
|
354
357
|
|
|
355
|
-
//
|
|
356
|
-
//
|
|
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.
|
|
357
372
|
const readMessageView = async (messageID: MessageID) => {
|
|
358
373
|
const record = await store.findMessageByID(messageID);
|
|
359
374
|
|
|
@@ -361,11 +376,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
361
376
|
return null;
|
|
362
377
|
}
|
|
363
378
|
|
|
364
|
-
const owner =
|
|
365
|
-
(x) =>
|
|
366
|
-
|
|
367
|
-
(record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID
|
|
368
|
-
|
|
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
|
+
);
|
|
369
384
|
|
|
370
385
|
return {
|
|
371
386
|
session: owner?.id ?? record.atcID,
|
|
@@ -408,50 +423,17 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
408
423
|
// the order of their sent times, which the inbox drains by.
|
|
409
424
|
let lastMessageWrite: Promise<void> = Promise.resolve();
|
|
410
425
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const tap = taps.claimDelivery(sessionID, record.id);
|
|
416
|
-
|
|
417
|
-
if (tap === null) {
|
|
418
|
-
return false;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
tap.sendEvent({
|
|
422
|
-
v: PROTOCOL_V,
|
|
423
|
-
ev: 'InboxMessage',
|
|
424
|
-
s: sessionID,
|
|
425
|
-
message: record.id,
|
|
426
|
-
from: record.from,
|
|
427
|
-
text: record.text,
|
|
428
|
-
sentAt: record.sentAt,
|
|
429
|
-
});
|
|
426
|
+
const inboxSource: InboxSource = {
|
|
427
|
+
taps,
|
|
428
|
+
findLinkedOwner: (sessionID) => {
|
|
429
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
430
430
|
|
|
431
|
-
|
|
431
|
+
return s === undefined ? null : buildMessageOwner(s);
|
|
432
|
+
},
|
|
433
|
+
collectPendingMessages: (owner) => store.collectPendingMessages(owner),
|
|
432
434
|
};
|
|
433
435
|
|
|
434
|
-
|
|
435
|
-
// ok response is always queued ahead of its first message. It sends one
|
|
436
|
-
// unclaimed message per call and the tap's ack calls it again, so the
|
|
437
|
-
// backlog never outgrows the connection's outbound queue.
|
|
438
|
-
const drainInbox = async (sessionID: SessionID) => {
|
|
439
|
-
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
440
|
-
|
|
441
|
-
if (s === undefined) {
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
try {
|
|
446
|
-
const pending = await store.collectPendingMessages(buildMessageOwner(s));
|
|
447
|
-
|
|
448
|
-
for (const record of pending) {
|
|
449
|
-
if (sendInboxMessage(sessionID, record)) {
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
} catch {}
|
|
454
|
-
};
|
|
436
|
+
const drainSessionInbox = (sessionID: SessionID) => drainInbox(sessionID, inboxSource);
|
|
455
437
|
|
|
456
438
|
// A report id, which a remote session's reporter gives each report,
|
|
457
439
|
// makes a resent note land once; a resent answer changes nothing, since
|
|
@@ -841,8 +823,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
841
823
|
|
|
842
824
|
return true;
|
|
843
825
|
},
|
|
844
|
-
attachTap: (client, sessionID) => ctx.attachTap(client, sessionID),
|
|
845
|
-
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),
|
|
846
829
|
detachTap: (client) => {
|
|
847
830
|
taps.detachAll(client);
|
|
848
831
|
},
|
|
@@ -858,6 +841,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
858
841
|
const startSpawn = async (
|
|
859
842
|
p: SpawnParams,
|
|
860
843
|
id: SessionID,
|
|
844
|
+
requireInReach: () => void = () => {},
|
|
861
845
|
): Promise<Readonly<Record<string, unknown>>> => {
|
|
862
846
|
const prepared =
|
|
863
847
|
p.workspace === null ? null : await materializeSpawnWorkspace(p, id, p.workspace);
|
|
@@ -866,7 +850,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
866
850
|
const warnings = materialized === null ? [] : materialized.warnings;
|
|
867
851
|
|
|
868
852
|
try {
|
|
869
|
-
const session = await startSpawnedSession(p, id, materialized);
|
|
853
|
+
const session = await startSpawnedSession(p, id, materialized, requireInReach);
|
|
870
854
|
|
|
871
855
|
return warnings.length === 0 ? { session } : { session, warnings };
|
|
872
856
|
} catch (error) {
|
|
@@ -922,6 +906,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
922
906
|
workspace: SessionWorkspace;
|
|
923
907
|
withheldEnv: readonly string[];
|
|
924
908
|
}> | null,
|
|
909
|
+
requireInReach: () => void,
|
|
925
910
|
): Promise<SessionDescriptor> => {
|
|
926
911
|
const s = await mgr.spawn(
|
|
927
912
|
p.cwd,
|
|
@@ -937,6 +922,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
937
922
|
id,
|
|
938
923
|
p.target,
|
|
939
924
|
materialized,
|
|
925
|
+
requireInReach,
|
|
940
926
|
);
|
|
941
927
|
|
|
942
928
|
const runtime = runtimes.get(s.id);
|
|
@@ -945,7 +931,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
945
931
|
runtime.pendingLastUsed = true;
|
|
946
932
|
}
|
|
947
933
|
|
|
948
|
-
void store.recordSpawnDir(p.cwd);
|
|
934
|
+
void store.recordSpawnDir(p.cwd, { target: s.target, targetIdentity: s.targetIdentity });
|
|
949
935
|
|
|
950
936
|
return getDescriptor(mgr, s.id);
|
|
951
937
|
};
|
|
@@ -981,7 +967,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
981
967
|
// Throws the refusal a fresh spawn to the target gets when the access
|
|
982
968
|
// does not reach the target, and the identity, a held spawn key recorded
|
|
983
969
|
// for its session. A key that records none is refused: nothing it holds
|
|
984
|
-
// 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.
|
|
985
972
|
const requireReplayInReach = (record: IdempotencyRecord, access: TargetAccess): void => {
|
|
986
973
|
const bound = record.effectTarget;
|
|
987
974
|
|
|
@@ -989,11 +976,48 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
989
976
|
throw buildTargetForbiddenError(findReplayTarget(record));
|
|
990
977
|
}
|
|
991
978
|
|
|
992
|
-
|
|
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
|
+
) {
|
|
993
985
|
throw buildTargetForbiddenError(bound.target);
|
|
994
986
|
}
|
|
995
987
|
};
|
|
996
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
|
+
|
|
997
1021
|
// Each confirm token a forget handed out, by token: the session it
|
|
998
1022
|
// forgets, when it stops being taken, and whether a forget took it. A
|
|
999
1023
|
// token stays known for one more lifetime after it expires, so a late
|
|
@@ -1071,11 +1095,14 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1071
1095
|
// it to the session's tap when one is attached. Only the write itself can
|
|
1072
1096
|
// reject: every later step swallows its own failure, so a rejection means
|
|
1073
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.
|
|
1074
1100
|
const writeAcceptedMessage = async (
|
|
1075
1101
|
sessionID: SessionID,
|
|
1076
1102
|
from: string,
|
|
1077
1103
|
text: string,
|
|
1078
1104
|
id: MessageID,
|
|
1105
|
+
requireWriteInReach: () => void,
|
|
1079
1106
|
): Promise<MessageRecord> => {
|
|
1080
1107
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
1081
1108
|
const previousWrite = lastMessageWrite;
|
|
@@ -1096,6 +1123,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1096
1123
|
};
|
|
1097
1124
|
|
|
1098
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
|
+
|
|
1099
1131
|
await store.writeMessage(record);
|
|
1100
1132
|
|
|
1101
1133
|
await recordMessageStatus(sessionID, record);
|
|
@@ -1103,7 +1135,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1103
1135
|
written.resolve();
|
|
1104
1136
|
}
|
|
1105
1137
|
|
|
1106
|
-
await
|
|
1138
|
+
await drainSessionInbox(sessionID);
|
|
1107
1139
|
|
|
1108
1140
|
return record;
|
|
1109
1141
|
};
|
|
@@ -1148,7 +1180,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1148
1180
|
build: opts.build,
|
|
1149
1181
|
daemonID: store.daemonID,
|
|
1150
1182
|
collectSessions: () => mgr.collectDescriptors(),
|
|
1151
|
-
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
|
+
},
|
|
1152
1190
|
collectAgents: () => ({
|
|
1153
1191
|
daemon: {
|
|
1154
1192
|
hostname: hostname(),
|
|
@@ -1172,8 +1210,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1172
1210
|
return s === undefined ? null : { target: s.target, targetIdentity: s.targetIdentity };
|
|
1173
1211
|
},
|
|
1174
1212
|
findTargetIdentity: (target) => targets.find((x) => x.id === target)?.identity ?? null,
|
|
1175
|
-
|
|
1213
|
+
canSeeSession: (id, access) => isTreeInReach(mgr.sessions, id, access),
|
|
1214
|
+
isSessionVisible: () => true,
|
|
1176
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
|
+
},
|
|
1177
1225
|
resolveSpawnTarget: (requested) => {
|
|
1178
1226
|
const target = requested ?? defaultTarget;
|
|
1179
1227
|
const refusal = mgr.findExecutionRefusal({ target, targetIdentity: null }, 'spawn');
|
|
@@ -1193,8 +1241,22 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1193
1241
|
mgr.requireExecution({ target, targetIdentity: null }, 'run');
|
|
1194
1242
|
},
|
|
1195
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
|
+
|
|
1196
1258
|
if (keyed === null) {
|
|
1197
|
-
return
|
|
1259
|
+
return startInReach(plan(), mintSessionID());
|
|
1198
1260
|
}
|
|
1199
1261
|
|
|
1200
1262
|
const effectRef = mintSessionID();
|
|
@@ -1203,13 +1265,21 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1203
1265
|
operation: 'session.spawn',
|
|
1204
1266
|
keyed,
|
|
1205
1267
|
effectRef,
|
|
1206
|
-
start: () =>
|
|
1268
|
+
start: () => startInReach(plan(), effectRef),
|
|
1207
1269
|
settle: () => mgr.writeFleet(),
|
|
1208
|
-
replay: (record) => {
|
|
1209
|
-
if (access
|
|
1210
|
-
|
|
1270
|
+
replay: async (record) => {
|
|
1271
|
+
if (access === null) {
|
|
1272
|
+
return loadSpawnReplay(record);
|
|
1211
1273
|
}
|
|
1212
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
|
+
|
|
1213
1283
|
return loadSpawnReplay(record);
|
|
1214
1284
|
},
|
|
1215
1285
|
findEffectTarget: () => {
|
|
@@ -1329,7 +1399,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1329
1399
|
|
|
1330
1400
|
return 'ok';
|
|
1331
1401
|
},
|
|
1332
|
-
adoptSession: async (id, cols, rows) => {
|
|
1402
|
+
adoptSession: async (id, cols, rows, access) => {
|
|
1333
1403
|
if (!hasResumableTranscript(mgr, id)) {
|
|
1334
1404
|
return 'no_transcript';
|
|
1335
1405
|
}
|
|
@@ -1338,7 +1408,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1338
1408
|
|
|
1339
1409
|
runtime?.stopHeadlessRun();
|
|
1340
1410
|
|
|
1341
|
-
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
|
+
);
|
|
1342
1417
|
|
|
1343
1418
|
if (adopted === null) {
|
|
1344
1419
|
return 'missing';
|
|
@@ -1496,7 +1571,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1496
1571
|
runtimes.get(sessionID)?.dims ?? { cols: 80, rows: 24 },
|
|
1497
1572
|
restoreFleet: (cols, rows) =>
|
|
1498
1573
|
restoreFleet({ mgr, store, findRuntime, cols, rows, capMs: opts.restoreBootTimeoutMs ?? 0 }),
|
|
1499
|
-
readSessionRecord: async (id) => {
|
|
1574
|
+
readSessionRecord: async (id, access) => {
|
|
1500
1575
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
1501
1576
|
|
|
1502
1577
|
if (s === undefined) {
|
|
@@ -1511,7 +1586,19 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1511
1586
|
const result = s.result ?? null;
|
|
1512
1587
|
const createdAt = s.createdAt;
|
|
1513
1588
|
|
|
1514
|
-
|
|
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);
|
|
1515
1602
|
|
|
1516
1603
|
return {
|
|
1517
1604
|
session,
|
|
@@ -1547,13 +1634,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1547
1634
|
},
|
|
1548
1635
|
readEvents: async (afterID, limit, waitMs, sessionID, access) => {
|
|
1549
1636
|
const deadline = Date.now() + waitMs;
|
|
1550
|
-
const scope = buildEventScope(sessionID, access);
|
|
1551
1637
|
|
|
1552
1638
|
// A wake for an event the read leaves out (a heartbeat, or another
|
|
1553
1639
|
// session's event under a session filter) loops back to wait out the
|
|
1554
|
-
// 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.
|
|
1555
1643
|
for (;;) {
|
|
1556
1644
|
const generation = eventSignal.generation;
|
|
1645
|
+
const scope = buildEventScope(sessionID, access);
|
|
1557
1646
|
|
|
1558
1647
|
// A read from a cursor takes one row past the limit to learn whether
|
|
1559
1648
|
// more follow; the latest events have nothing after them.
|
|
@@ -1562,11 +1651,24 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1562
1651
|
? await store.collectLatestEvents(limit, scope)
|
|
1563
1652
|
: await store.collectEventsAfter(afterID, limit + 1, scope);
|
|
1564
1653
|
|
|
1654
|
+
if (
|
|
1655
|
+
access !== null &&
|
|
1656
|
+
JSON.stringify(buildEventScope(sessionID, access)) !== JSON.stringify(scope)
|
|
1657
|
+
) {
|
|
1658
|
+
continue;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1565
1661
|
const remaining = deadline - Date.now();
|
|
1566
1662
|
|
|
1567
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
|
+
|
|
1568
1670
|
return {
|
|
1569
|
-
events: buildFleetEvents(rows.slice(0, limit),
|
|
1671
|
+
events: buildFleetEvents(rows.slice(0, limit), naming, aliases),
|
|
1570
1672
|
more: rows.length > limit,
|
|
1571
1673
|
};
|
|
1572
1674
|
}
|
|
@@ -1577,9 +1679,30 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1577
1679
|
readReport: async (id, access) => {
|
|
1578
1680
|
const stored = await store.findReport(id, buildEventScope(null, access));
|
|
1579
1681
|
|
|
1580
|
-
|
|
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
|
+
};
|
|
1581
1696
|
},
|
|
1582
|
-
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
|
+
|
|
1583
1706
|
if (keyed === null) {
|
|
1584
1707
|
const refusal = findMessageRefusal(sessionID);
|
|
1585
1708
|
|
|
@@ -1587,9 +1710,23 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1587
1710
|
return refusal;
|
|
1588
1711
|
}
|
|
1589
1712
|
|
|
1590
|
-
|
|
1713
|
+
try {
|
|
1714
|
+
const record = await writeAcceptedMessage(
|
|
1715
|
+
sessionID,
|
|
1716
|
+
from,
|
|
1717
|
+
text,
|
|
1718
|
+
mintMessageID(),
|
|
1719
|
+
requireInReach,
|
|
1720
|
+
);
|
|
1721
|
+
|
|
1722
|
+
return { message: record.id, status: record.status };
|
|
1723
|
+
} catch (error) {
|
|
1724
|
+
if (error instanceof MessageRefusedError) {
|
|
1725
|
+
return error.refusal;
|
|
1726
|
+
}
|
|
1591
1727
|
|
|
1592
|
-
|
|
1728
|
+
throw error;
|
|
1729
|
+
}
|
|
1593
1730
|
}
|
|
1594
1731
|
|
|
1595
1732
|
const effectRef = mintMessageID();
|
|
@@ -1602,13 +1739,21 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1602
1739
|
|
|
1603
1740
|
// A refusal throws, so the claim drops and a retry runs fresh.
|
|
1604
1741
|
start: async () => {
|
|
1742
|
+
requireInReach();
|
|
1743
|
+
|
|
1605
1744
|
const refusal = findMessageRefusal(sessionID);
|
|
1606
1745
|
|
|
1607
1746
|
if (refusal !== null) {
|
|
1608
1747
|
throw new MessageRefusedError(refusal);
|
|
1609
1748
|
}
|
|
1610
1749
|
|
|
1611
|
-
const record = await writeAcceptedMessage(
|
|
1750
|
+
const record = await writeAcceptedMessage(
|
|
1751
|
+
sessionID,
|
|
1752
|
+
from,
|
|
1753
|
+
text,
|
|
1754
|
+
effectRef,
|
|
1755
|
+
requireInReach,
|
|
1756
|
+
);
|
|
1612
1757
|
|
|
1613
1758
|
return { message: record.id, status: record.status };
|
|
1614
1759
|
},
|
|
@@ -1625,7 +1770,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1625
1770
|
throw error;
|
|
1626
1771
|
}
|
|
1627
1772
|
},
|
|
1628
|
-
attachTap: (client, sessionID) => {
|
|
1773
|
+
attachTap: (client, sessionID, access) => {
|
|
1629
1774
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
1630
1775
|
|
|
1631
1776
|
if (s === undefined) {
|
|
@@ -1636,7 +1781,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1636
1781
|
return 'unsupported';
|
|
1637
1782
|
}
|
|
1638
1783
|
|
|
1639
|
-
emitInboxClosed(taps.attach(sessionID, client), sessionID, 'replaced');
|
|
1784
|
+
emitInboxClosed(taps.attach(sessionID, client, access === null), sessionID, 'replaced');
|
|
1640
1785
|
|
|
1641
1786
|
const runtime = runtimes.get(sessionID);
|
|
1642
1787
|
|
|
@@ -1644,10 +1789,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1644
1789
|
runtime.tapAttached = true;
|
|
1645
1790
|
}
|
|
1646
1791
|
|
|
1647
|
-
void
|
|
1792
|
+
void drainSessionInbox(sessionID);
|
|
1648
1793
|
|
|
1649
1794
|
return 'ok';
|
|
1650
1795
|
},
|
|
1796
|
+
detachTap: (client, sessionID) => {
|
|
1797
|
+
if (taps.isTap(sessionID, client)) {
|
|
1798
|
+
taps.removeSession(sessionID);
|
|
1799
|
+
}
|
|
1800
|
+
},
|
|
1651
1801
|
readMessage: async (messageID, waitMs) => {
|
|
1652
1802
|
const deadline = Date.now() + waitMs;
|
|
1653
1803
|
let initialStatus: MessageStatus | null = null;
|
|
@@ -1681,18 +1831,20 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1681
1831
|
await eventSignal.waitForNext(generation, remaining);
|
|
1682
1832
|
}
|
|
1683
1833
|
},
|
|
1684
|
-
ackMessage: async (client, sessionID, messageID) => {
|
|
1834
|
+
ackMessage: async (client, sessionID, messageID, access) => {
|
|
1685
1835
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
1686
1836
|
|
|
1687
1837
|
if (s === undefined || !taps.isTap(sessionID, client)) {
|
|
1688
1838
|
return 'not_tapping';
|
|
1689
1839
|
}
|
|
1690
1840
|
|
|
1691
|
-
|
|
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 };
|
|
1692
1844
|
|
|
1693
1845
|
const delivered = await store.updateMessageDelivered(messageID, owner, Date.now());
|
|
1694
1846
|
|
|
1695
|
-
void
|
|
1847
|
+
void drainSessionInbox(sessionID);
|
|
1696
1848
|
|
|
1697
1849
|
if (delivered !== null) {
|
|
1698
1850
|
await recordMessageStatus(sessionID, delivered);
|