@zgeoff/atc 2.10.4 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +72 -0
- package/src/agents/atc-bridge-files.ts +1 -1
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/claude-adapter.ts +52 -1
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +46 -1
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +75 -1
- package/src/agents/grok-adapter.ts +22 -0
- package/src/cli.ts +22 -0
- package/src/client/boot-daemon.ts +6 -0
- package/src/client/daemon-client.ts +14 -2
- package/src/client/spawn-picker.ts +27 -4
- package/src/daemon/build-agent-list.ts +94 -0
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +34 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-scoped-context.ts +232 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +371 -99
- package/src/daemon/daemon.ts +537 -90
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +103 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/local-pty-provider.ts +83 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-report.ts +29 -3
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/restore-fleet.ts +66 -19
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +287 -65
- package/src/daemon/start-headless-run.ts +11 -0
- package/src/daemon/start-headless-turn.ts +11 -2
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +40 -6
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +88 -7
- package/src/mcp/mcp-tools.ts +245 -24
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +77 -15
- package/src/mcp/require-daemon-features.ts +40 -0
- package/src/mcp/run-tool.ts +139 -35
- package/src/mcp/start-mcp-http-server.ts +130 -24
- package/src/mcp/types.ts +9 -1
- package/src/mcp-http-server.ts +6 -1
- package/src/mcp-server.ts +13 -1
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +47 -0
- package/src/protocol/parse-daemon-features.ts +19 -0
- package/src/protocol/protocol.ts +44 -11
- package/src/protocol/request-param-schemas.ts +78 -12
- package/src/report.ts +20 -3
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/store/fleet-entry.ts +42 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/message-record.ts +3 -0
- package/src/store/run-migrations.ts +272 -7
- package/src/store/state-store.ts +503 -34
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +239 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +103 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
package/src/daemon/daemon.ts
CHANGED
|
@@ -1,34 +1,58 @@
|
|
|
1
1
|
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
2
3
|
import { dirname, join } from 'node:path';
|
|
3
4
|
import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
|
|
5
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
4
6
|
import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
|
|
5
7
|
import type { EventMsg } from '../protocol/protocol';
|
|
6
8
|
import type { HooksConfig } from '../shared/collect-hooks';
|
|
9
|
+
import type { TargetConfigError } from '../shared/collect-targets';
|
|
7
10
|
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
11
|
+
import type { MessageID } from '../shared/message-id';
|
|
12
|
+
import { isRecord } from '../shared/report';
|
|
8
13
|
import type { SessionID } from '../shared/session-id';
|
|
14
|
+
import { toMessageID } from '../shared/to-message-id';
|
|
9
15
|
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
16
|
+
import type { IdempotencyRecord } from '../store/idempotency-record';
|
|
10
17
|
import type { MessageOwner } from '../store/message-owner';
|
|
11
|
-
import type { MessageRecord } from '../store/message-record';
|
|
18
|
+
import type { MessageRecord, MessageStatus } from '../store/message-record';
|
|
12
19
|
import { StateStore } from '../store/state-store';
|
|
20
|
+
import type { EventScope } from '../store/state-store';
|
|
13
21
|
import type { TrailEntry } from '../store/trail-entry';
|
|
14
22
|
import { ANSWER_BYTE_CAP } from './answer-byte-cap';
|
|
15
23
|
import { AttachRegistry } from './attach-registry';
|
|
24
|
+
import { buildAgentList } from './build-agent-list';
|
|
25
|
+
import { buildConfigRevision } from './build-config-revision';
|
|
26
|
+
import { buildExecutionTargets } from './build-execution-targets';
|
|
27
|
+
import type { ExecutionTarget } from './build-execution-targets';
|
|
16
28
|
import { buildFleetEvents } from './build-fleet-events';
|
|
17
29
|
import { buildMessageTrailEntry } from './build-message-trail-entry';
|
|
18
30
|
import { buildReportTrailEntry } from './build-report-trail-entry';
|
|
19
31
|
import { buildSessionEvent } from './build-session-event';
|
|
20
32
|
import { buildSessionMessageEvent } from './build-session-message-event';
|
|
21
33
|
import { buildSessionReportEvent } from './build-session-report-event';
|
|
34
|
+
import { buildTargetAccess } from './build-target-access';
|
|
35
|
+
import { buildTargetForbiddenError } from './build-target-forbidden-error';
|
|
36
|
+
import { buildTargetList } from './build-target-list';
|
|
22
37
|
import { claimDaemonLock } from './claim-daemon-lock';
|
|
23
38
|
import { DaemonConnection } from './daemon-connection';
|
|
24
|
-
import type {
|
|
39
|
+
import type {
|
|
40
|
+
DaemonContext,
|
|
41
|
+
MessageRefusal,
|
|
42
|
+
OutputClient,
|
|
43
|
+
SpawnParams,
|
|
44
|
+
TapClient,
|
|
45
|
+
} from './daemon-connection';
|
|
46
|
+
import { EffectRemainsError } from './effect-remains-error';
|
|
25
47
|
import { EventSignal } from './event-signal';
|
|
26
48
|
import { startHookServer } from './hooks';
|
|
27
49
|
import type { HookEvent } from './hooks';
|
|
50
|
+
import { IdempotencyLedger } from './idempotency-ledger';
|
|
28
51
|
import { loadTranscriptPage } from './load-transcript-page';
|
|
29
52
|
import { makeHookRunner } from './make-hook-runner';
|
|
30
53
|
import type { HookScope } from './make-hook-runner';
|
|
31
54
|
import { mintMessageID } from './mint-message-id';
|
|
55
|
+
import { mintSessionID } from './mint-session-id';
|
|
32
56
|
import { parseReport } from './parse-report';
|
|
33
57
|
import { PermissionRegistry } from './permission-registry';
|
|
34
58
|
import { restoreFleet } from './restore-fleet';
|
|
@@ -40,6 +64,7 @@ import type { Session, SessionDescriptor, SessionState } from './sessions';
|
|
|
40
64
|
import { startEventsServer } from './start-events-server';
|
|
41
65
|
import { startHeadlessTurn } from './start-headless-turn';
|
|
42
66
|
import { TapRegistry } from './tap-registry';
|
|
67
|
+
import type { TargetAccess } from './target-access';
|
|
43
68
|
import { writeDaemonRecord } from './write-daemon-record';
|
|
44
69
|
|
|
45
70
|
export interface DaemonOptions {
|
|
@@ -55,6 +80,21 @@ export interface DaemonOptions {
|
|
|
55
80
|
// grok adapter is unsupported, not a Claude spawn.
|
|
56
81
|
readonly adapters?: readonly AgentAdapter[];
|
|
57
82
|
|
|
83
|
+
// Where sessions run, in config order; one `local` target on the local
|
|
84
|
+
// pseudo-terminal provider when unset. A spawn without a target runs on
|
|
85
|
+
// the default one: `local` when unset and the targets hold it, and none
|
|
86
|
+
// otherwise, which refuses such a spawn.
|
|
87
|
+
readonly targets?: readonly ExecutionTarget[];
|
|
88
|
+
readonly defaultTarget?: string | null;
|
|
89
|
+
|
|
90
|
+
// The target config problems the daemon started with. A target they cover
|
|
91
|
+
// refuses every session, and `agents.list` returns them.
|
|
92
|
+
readonly targetErrors?: readonly TargetConfigError[];
|
|
93
|
+
|
|
94
|
+
// The targets each principal may use; unset or null when the config has
|
|
95
|
+
// no principals, which leaves every principal the implicit local target.
|
|
96
|
+
readonly principals?: ReadonlyMap<string, readonly string[]> | null;
|
|
97
|
+
|
|
58
98
|
// SQLite path for daemon state; a fleet.json at legacyFleetPath seeds the
|
|
59
99
|
// fleet table once so upgrading keeps the restorable fleet.
|
|
60
100
|
readonly dbPath: string;
|
|
@@ -105,6 +145,14 @@ export interface DaemonHandle {
|
|
|
105
145
|
// it is refused.
|
|
106
146
|
const TAP_GRACE_MS = 15_000;
|
|
107
147
|
|
|
148
|
+
// The principal every request on the local socket acts as.
|
|
149
|
+
const LOCAL_PRINCIPAL = 'local';
|
|
150
|
+
|
|
151
|
+
// A completed idempotency key is kept this long after it completed, and the
|
|
152
|
+
// sweep that drops older ones runs this often.
|
|
153
|
+
const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
154
|
+
const IDEMPOTENCY_SWEEP_MS = 60 * 60 * 1000;
|
|
155
|
+
|
|
108
156
|
// How long startup waits for a daemon that is shutting down to release the
|
|
109
157
|
// state lock before refusing to start.
|
|
110
158
|
const LOCK_WAIT_MS = 2000;
|
|
@@ -144,7 +192,42 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
144
192
|
|
|
145
193
|
const store = await StateStore.open(opts.dbPath, opts.legacyFleetPath);
|
|
146
194
|
|
|
147
|
-
|
|
195
|
+
// Before any request is served: a key left in progress by a daemon that
|
|
196
|
+
// stopped mid-effect is settled first, so no retry can race it.
|
|
197
|
+
await store.reconcileIdempotencyKeys(Date.now());
|
|
198
|
+
|
|
199
|
+
await tryRemoveExpiredIdempotencyKeys(store);
|
|
200
|
+
|
|
201
|
+
const idempotencySweep = setInterval(() => {
|
|
202
|
+
void tryRemoveExpiredIdempotencyKeys(store);
|
|
203
|
+
}, IDEMPOTENCY_SWEEP_MS);
|
|
204
|
+
|
|
205
|
+
idempotencySweep.unref();
|
|
206
|
+
|
|
207
|
+
const targets =
|
|
208
|
+
opts.targets ?? buildExecutionTargets([{ id: 'local', provider: 'local-pty', options: {} }]);
|
|
209
|
+
|
|
210
|
+
const targetErrors = opts.targetErrors ?? [];
|
|
211
|
+
const principals = opts.principals ?? null;
|
|
212
|
+
|
|
213
|
+
const targetsByID = new Map(targets.map((target) => [target.id, target]));
|
|
214
|
+
|
|
215
|
+
const defaultTarget =
|
|
216
|
+
opts.defaultTarget === undefined
|
|
217
|
+
? (targets.find((target) => target.id === 'local')?.id ?? null)
|
|
218
|
+
: opts.defaultTarget;
|
|
219
|
+
|
|
220
|
+
const configRevision = buildConfigRevision(targets, defaultTarget, targetErrors);
|
|
221
|
+
|
|
222
|
+
const mgr = new SessionManager(
|
|
223
|
+
opts.adapter,
|
|
224
|
+
store,
|
|
225
|
+
opts.statusPath,
|
|
226
|
+
opts.adapters ?? [],
|
|
227
|
+
targets,
|
|
228
|
+
targetErrors,
|
|
229
|
+
);
|
|
230
|
+
|
|
148
231
|
const clients = new Set<DaemonConnection>();
|
|
149
232
|
|
|
150
233
|
const runHooks = makeHookRunner(opts.hooks ?? {});
|
|
@@ -202,6 +285,61 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
202
285
|
emitEvent(buildSessionMessageEvent(sessionID, record), findHookScope(sessionID));
|
|
203
286
|
};
|
|
204
287
|
|
|
288
|
+
// A live session's scope also matches the rows it wrote under an earlier
|
|
289
|
+
// atc id, through the agent session id a restore carries on. Under an
|
|
290
|
+
// access, the scope holds only the sessions on targets the access holds,
|
|
291
|
+
// so a session outside it matches nothing, as a session never seen does.
|
|
292
|
+
const buildEventScope = (
|
|
293
|
+
sessionID: SessionID | null,
|
|
294
|
+
access: TargetAccess | null,
|
|
295
|
+
): EventScope | null => {
|
|
296
|
+
if (access === null) {
|
|
297
|
+
if (sessionID === null) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const agentSessionID = mgr.sessions.find((x) => x.id === sessionID)?.agentSessionID;
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
atcIDs: [sessionID],
|
|
305
|
+
agentSessionIDs: agentSessionID === undefined ? [] : [agentSessionID],
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const visible = mgr.sessions.filter(
|
|
310
|
+
(x) => access.canUse(x) && (sessionID === null || x.id === sessionID),
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
atcIDs: visible.map((x) => x.id),
|
|
315
|
+
agentSessionIDs: visible.flatMap((x) =>
|
|
316
|
+
x.agentSessionID === undefined ? [] : [x.agentSessionID],
|
|
317
|
+
),
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// A message belongs to the live session holding its atc id or its agent
|
|
322
|
+
// session id, else to the atc id it was sent to.
|
|
323
|
+
const readMessageView = async (messageID: MessageID) => {
|
|
324
|
+
const record = await store.findMessageByID(messageID);
|
|
325
|
+
|
|
326
|
+
if (record === null) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const owner = mgr.sessions.find(
|
|
331
|
+
(x) =>
|
|
332
|
+
x.id === record.atcID ||
|
|
333
|
+
(record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
session: owner?.id ?? record.atcID,
|
|
338
|
+
record,
|
|
339
|
+
answeredWith: await store.collectTurnSiblings(record),
|
|
340
|
+
};
|
|
341
|
+
};
|
|
342
|
+
|
|
205
343
|
registry.onRequested = (req) => {
|
|
206
344
|
emitEvent(
|
|
207
345
|
{
|
|
@@ -309,15 +447,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
309
447
|
const owner = s === undefined ? { atcID: e.atcId } : buildMessageOwner(s);
|
|
310
448
|
|
|
311
449
|
try {
|
|
312
|
-
|
|
313
|
-
|
|
450
|
+
// One statement answers the whole turn, so a reader that sees any
|
|
451
|
+
// member answered already finds every sibling answered beside it.
|
|
452
|
+
const answered = await store.updateMessagesAnswered(
|
|
453
|
+
report.messages,
|
|
314
454
|
owner,
|
|
315
455
|
truncateToBytes(report.answer, ANSWER_BYTE_CAP),
|
|
316
456
|
Date.now(),
|
|
457
|
+
report.turn,
|
|
317
458
|
);
|
|
318
459
|
|
|
319
|
-
|
|
320
|
-
await recordMessageStatus(e.atcId,
|
|
460
|
+
for (const record of answered) {
|
|
461
|
+
await recordMessageStatus(e.atcId, record);
|
|
321
462
|
}
|
|
322
463
|
} catch {}
|
|
323
464
|
};
|
|
@@ -385,7 +526,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
385
526
|
|
|
386
527
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
387
528
|
|
|
388
|
-
|
|
529
|
+
// A host that cannot resize keeps its terminal at the size it started
|
|
530
|
+
// with; the screen model still follows the clients.
|
|
531
|
+
if (s !== undefined && (mgr.findProvider(s)?.capabilities.resize ?? false)) {
|
|
532
|
+
s.pty?.resize(dims.cols, dims.rows);
|
|
533
|
+
}
|
|
534
|
+
|
|
389
535
|
runtime?.screen?.updateDims(dims.cols, dims.rows);
|
|
390
536
|
|
|
391
537
|
if (runtime !== undefined) {
|
|
@@ -616,38 +762,266 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
616
762
|
}
|
|
617
763
|
}, opts.reporterSocketPath);
|
|
618
764
|
|
|
765
|
+
// A spawn that throws once its process has started takes the session back
|
|
766
|
+
// before it throws, so a failed start leaves nothing running and a keyed
|
|
767
|
+
// retry spawns once. When taking it back fails too, the session may still
|
|
768
|
+
// stand, and the throw says so.
|
|
769
|
+
const startSpawn = async (p: SpawnParams, id: SessionID): Promise<SessionDescriptor> => {
|
|
770
|
+
try {
|
|
771
|
+
return startSpawnedSession(p, id);
|
|
772
|
+
} catch (error) {
|
|
773
|
+
try {
|
|
774
|
+
await mgr.removeFailedSpawn(id);
|
|
775
|
+
} catch (cleanupError) {
|
|
776
|
+
throw new EffectRemainsError(
|
|
777
|
+
`spawn of session ${id} failed and taking it back failed too`,
|
|
778
|
+
{
|
|
779
|
+
cause: cleanupError,
|
|
780
|
+
},
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
throw error;
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
const startSpawnedSession = (p: SpawnParams, id: SessionID): SessionDescriptor => {
|
|
789
|
+
const s = mgr.spawn(
|
|
790
|
+
p.cwd,
|
|
791
|
+
p.name,
|
|
792
|
+
p.prompt,
|
|
793
|
+
p.cols,
|
|
794
|
+
p.rows,
|
|
795
|
+
p.resume,
|
|
796
|
+
p.namedBy,
|
|
797
|
+
p.agent,
|
|
798
|
+
p.parent,
|
|
799
|
+
p.overrides,
|
|
800
|
+
id,
|
|
801
|
+
p.target,
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
const runtime = runtimes.get(s.id);
|
|
805
|
+
|
|
806
|
+
if (runtime !== undefined) {
|
|
807
|
+
runtime.pendingLastUsed = true;
|
|
808
|
+
runtime.dims = { cols: p.cols, rows: p.rows };
|
|
809
|
+
|
|
810
|
+
runtime.screen = new ScreenModel(p.cols, p.rows);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
void store.recordSpawnDir(p.cwd);
|
|
814
|
+
|
|
815
|
+
return getDescriptor(mgr, s.id);
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
// A retried spawn answers with the session as it stands now when it is
|
|
819
|
+
// listed, else with the descriptor the first spawn answered. A key that
|
|
820
|
+
// start-up reconciliation completed has no stored answer, and its session
|
|
821
|
+
// lists only once the fleet is restored.
|
|
822
|
+
const loadSpawnReplay = (record: IdempotencyRecord): Readonly<Record<string, unknown>> => {
|
|
823
|
+
const listed = mgr.collectDescriptors().find((d) => d.id === record.effectRef);
|
|
824
|
+
|
|
825
|
+
if (listed !== undefined) {
|
|
826
|
+
return { session: listed };
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const stored: unknown = record.result === null ? null : JSON.parse(record.result);
|
|
830
|
+
|
|
831
|
+
if (isRecord(stored)) {
|
|
832
|
+
return stored;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
throw new DaemonError(
|
|
836
|
+
'no_such_session',
|
|
837
|
+
`the spawn under idempotency key '${record.key}' created session '${record.effectRef}', which is not listed; restore the fleet to list it`,
|
|
838
|
+
{ effectRef: record.effectRef },
|
|
839
|
+
);
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
const ledger = new IdempotencyLedger(store, LOCAL_PRINCIPAL);
|
|
843
|
+
|
|
844
|
+
// Throws the refusal a fresh spawn to the target gets when the access
|
|
845
|
+
// does not reach the target, and the identity, a held spawn key recorded
|
|
846
|
+
// for its session. A key that records none is refused: nothing it holds
|
|
847
|
+
// shows where its session ran.
|
|
848
|
+
const requireReplayInReach = (record: IdempotencyRecord, access: TargetAccess): void => {
|
|
849
|
+
const bound = record.effectTarget;
|
|
850
|
+
|
|
851
|
+
if (bound === null) {
|
|
852
|
+
throw buildTargetForbiddenError(findReplayTarget(record));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (!access.canUse(bound)) {
|
|
856
|
+
throw buildTargetForbiddenError(bound.target);
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
// Why the session refuses a message right now, or null when it takes one.
|
|
861
|
+
const findMessageRefusal = (sessionID: SessionID): MessageRefusal | null => {
|
|
862
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
863
|
+
|
|
864
|
+
if (s === undefined) {
|
|
865
|
+
return 'missing';
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (!(s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'))) {
|
|
869
|
+
return 'dead';
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (mgr.findAdapter(s.agent)?.takesMessages !== true) {
|
|
873
|
+
return 'unsupported';
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const runtime = runtimes.get(sessionID);
|
|
877
|
+
|
|
878
|
+
if (
|
|
879
|
+
runtime !== undefined &&
|
|
880
|
+
runtime.startedAt !== null &&
|
|
881
|
+
!runtime.tapAttached &&
|
|
882
|
+
Date.now() - runtime.startedAt >= (opts.tapGraceMs ?? TAP_GRACE_MS)
|
|
883
|
+
) {
|
|
884
|
+
return 'no_tap';
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return null;
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
// Writes the message under the id given, notes it in the trail, and hands
|
|
891
|
+
// it to the session's tap when one is attached. Only the write itself can
|
|
892
|
+
// reject: every later step swallows its own failure, so a rejection means
|
|
893
|
+
// no message was written.
|
|
894
|
+
const writeAcceptedMessage = async (
|
|
895
|
+
sessionID: SessionID,
|
|
896
|
+
from: string,
|
|
897
|
+
text: string,
|
|
898
|
+
id: MessageID,
|
|
899
|
+
): Promise<MessageRecord> => {
|
|
900
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
901
|
+
const previousWrite = lastMessageWrite;
|
|
902
|
+
const written = Promise.withResolvers<void>();
|
|
903
|
+
|
|
904
|
+
lastMessageWrite = written.promise;
|
|
905
|
+
|
|
906
|
+
await previousWrite;
|
|
907
|
+
|
|
908
|
+
const record: MessageRecord = {
|
|
909
|
+
id,
|
|
910
|
+
atcID: sessionID,
|
|
911
|
+
...(s?.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
912
|
+
from,
|
|
913
|
+
text,
|
|
914
|
+
status: 'accepted',
|
|
915
|
+
sentAt: Date.now(),
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
try {
|
|
919
|
+
await store.writeMessage(record);
|
|
920
|
+
|
|
921
|
+
await recordMessageStatus(sessionID, record);
|
|
922
|
+
} finally {
|
|
923
|
+
written.resolve();
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
await drainInbox(sessionID);
|
|
927
|
+
|
|
928
|
+
return record;
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
// A retried send answers with the message's current status, so a caller
|
|
932
|
+
// that lost the first answer learns where its message stands now.
|
|
933
|
+
const loadMessageReplay = async (
|
|
934
|
+
record: IdempotencyRecord,
|
|
935
|
+
): Promise<Readonly<Record<string, unknown>>> => {
|
|
936
|
+
const current = await store.findMessageByID(toMessageID(record.effectRef));
|
|
937
|
+
|
|
938
|
+
if (current !== null) {
|
|
939
|
+
return { message: current.id, status: current.status };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const stored: unknown = record.result === null ? null : JSON.parse(record.result);
|
|
943
|
+
|
|
944
|
+
if (isRecord(stored)) {
|
|
945
|
+
return stored;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
throw new DaemonError(
|
|
949
|
+
'internal',
|
|
950
|
+
`the message under idempotency key '${record.key}' has no stored row`,
|
|
951
|
+
{ effectRef: record.effectRef },
|
|
952
|
+
);
|
|
953
|
+
};
|
|
954
|
+
|
|
619
955
|
const ctx: DaemonContext = {
|
|
620
956
|
build: opts.build,
|
|
957
|
+
daemonID: store.daemonID,
|
|
621
958
|
collectSessions: () => mgr.collectDescriptors(),
|
|
622
959
|
collectSpawnDirs: () => store.collectSpawnDirs(),
|
|
960
|
+
collectAgents: () => ({
|
|
961
|
+
daemon: {
|
|
962
|
+
hostname: hostname(),
|
|
963
|
+
platform: process.platform,
|
|
964
|
+
arch: process.arch,
|
|
965
|
+
build: opts.build,
|
|
966
|
+
},
|
|
967
|
+
agents: buildAgentList(mgr.collectAdapters(), (bin) => Bun.which(bin) !== null),
|
|
968
|
+
targets: buildTargetList(targets, defaultTarget),
|
|
969
|
+
spawnDefaults: { agent: 'claude', target: defaultTarget },
|
|
970
|
+
configRevision,
|
|
971
|
+
targetErrors,
|
|
972
|
+
}),
|
|
623
973
|
collectFleet: () => store.loadFleet(),
|
|
624
974
|
loadLastUsedAgent: () => store.loadLastUsedAgent(),
|
|
625
975
|
findAdapter: (kind) => mgr.findAdapter(kind),
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
p.name,
|
|
630
|
-
p.prompt,
|
|
631
|
-
p.cols,
|
|
632
|
-
p.rows,
|
|
633
|
-
p.resume,
|
|
634
|
-
p.namedBy,
|
|
635
|
-
p.agent,
|
|
636
|
-
p.parent,
|
|
637
|
-
);
|
|
638
|
-
|
|
639
|
-
const runtime = runtimes.get(s.id);
|
|
976
|
+
buildTargetAccess: (principal) => buildTargetAccess(principals, targetsByID, principal),
|
|
977
|
+
findSessionGrant: (id) => {
|
|
978
|
+
const s = mgr.sessions.find((x) => x.id === id);
|
|
640
979
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
980
|
+
return s === undefined ? null : { target: s.target, targetIdentity: s.targetIdentity };
|
|
981
|
+
},
|
|
982
|
+
findTargetIdentity: (target) => targets.find((x) => x.id === target)?.identity ?? null,
|
|
983
|
+
collectChildIDs: (id) => mgr.collectChildren(id).map((child) => child.id),
|
|
984
|
+
findPermissionSession: (request) => registry.findSessionID(request),
|
|
985
|
+
resolveSpawnTarget: (requested) => {
|
|
986
|
+
const target = requested ?? defaultTarget;
|
|
987
|
+
const refusal = mgr.findExecutionRefusal({ target, targetIdentity: null }, 'spawn');
|
|
988
|
+
|
|
989
|
+
if (refusal !== null) {
|
|
990
|
+
throw refusal;
|
|
991
|
+
}
|
|
644
992
|
|
|
645
|
-
|
|
993
|
+
if (target === null) {
|
|
994
|
+
throw new Error('a spawn without a target passed its checks');
|
|
646
995
|
}
|
|
647
996
|
|
|
648
|
-
|
|
997
|
+
return target;
|
|
998
|
+
},
|
|
999
|
+
spawnSession: (plan, keyed, access) => {
|
|
1000
|
+
if (keyed === null) {
|
|
1001
|
+
return startSpawn(plan(), mintSessionID()).then((session) => ({ session }));
|
|
1002
|
+
}
|
|
649
1003
|
|
|
650
|
-
|
|
1004
|
+
const effectRef = mintSessionID();
|
|
1005
|
+
|
|
1006
|
+
return ledger.run({
|
|
1007
|
+
operation: 'session.spawn',
|
|
1008
|
+
keyed,
|
|
1009
|
+
effectRef,
|
|
1010
|
+
start: async () => ({ session: await startSpawn(plan(), effectRef) }),
|
|
1011
|
+
settle: () => mgr.writeFleet(),
|
|
1012
|
+
replay: (record) => {
|
|
1013
|
+
if (access !== null) {
|
|
1014
|
+
requireReplayInReach(record, access);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
return loadSpawnReplay(record);
|
|
1018
|
+
},
|
|
1019
|
+
findEffectTarget: () => {
|
|
1020
|
+
const s = mgr.sessions.find((x) => x.id === effectRef);
|
|
1021
|
+
|
|
1022
|
+
return s === undefined ? null : { target: s.target, targetIdentity: s.targetIdentity };
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
651
1025
|
},
|
|
652
1026
|
updateSession: (id, name, pinned) => mgr.updateSession(id, name, pinned),
|
|
653
1027
|
quitDaemon: () => {
|
|
@@ -662,10 +1036,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
662
1036
|
}, 80);
|
|
663
1037
|
},
|
|
664
1038
|
killSession: async (id) => {
|
|
665
|
-
|
|
1039
|
+
const s = mgr.sessions.find((x) => x.id === id);
|
|
1040
|
+
|
|
1041
|
+
if (s === undefined) {
|
|
666
1042
|
return false;
|
|
667
1043
|
}
|
|
668
1044
|
|
|
1045
|
+
for (const live of [s, ...mgr.collectChildren(id)]) {
|
|
1046
|
+
if (live.pty !== null) {
|
|
1047
|
+
mgr.requireExecution(live, 'kill');
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
669
1051
|
for (const child of mgr.collectChildren(id)) {
|
|
670
1052
|
runtimes.get(child.id)?.stopHeadlessRun();
|
|
671
1053
|
}
|
|
@@ -693,6 +1075,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
693
1075
|
return 'no_transcript';
|
|
694
1076
|
}
|
|
695
1077
|
|
|
1078
|
+
mgr.requireExecution(s, 'kill');
|
|
1079
|
+
mgr.requireExecution(s, 'headless');
|
|
1080
|
+
|
|
696
1081
|
const yanked = mgr.yankHeadless(id);
|
|
697
1082
|
|
|
698
1083
|
if (yanked === null) {
|
|
@@ -731,9 +1116,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
731
1116
|
}
|
|
732
1117
|
|
|
733
1118
|
if (runtime !== undefined) {
|
|
734
|
-
runtime.
|
|
735
|
-
runtime.startedAt = null;
|
|
736
|
-
runtime.tapAttached = false;
|
|
1119
|
+
runtime.resetBoot({ cols, rows });
|
|
737
1120
|
|
|
738
1121
|
runtime.screen ??= new ScreenModel(cols, rows);
|
|
739
1122
|
}
|
|
@@ -776,6 +1159,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
776
1159
|
return 'dead';
|
|
777
1160
|
}
|
|
778
1161
|
|
|
1162
|
+
mgr.requireExecution(s, 'attach');
|
|
779
1163
|
attachments.attach(sessionID, client, dims);
|
|
780
1164
|
mgr.attach(sessionID);
|
|
781
1165
|
|
|
@@ -816,7 +1200,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
816
1200
|
return 'missing';
|
|
817
1201
|
}
|
|
818
1202
|
|
|
1203
|
+
// A headless turn runs only through the session's own target, never
|
|
1204
|
+
// anywhere else, and a dead session takes no input.
|
|
819
1205
|
if (s.kind === 'headless') {
|
|
1206
|
+
mgr.requireExecution(s, 'headless');
|
|
1207
|
+
|
|
1208
|
+
if (s.state === 'exited') {
|
|
1209
|
+
return 'dead';
|
|
1210
|
+
}
|
|
1211
|
+
|
|
820
1212
|
if ((runtimes.get(sessionID)?.headlessRun ?? null) !== null) {
|
|
821
1213
|
return 'busy';
|
|
822
1214
|
}
|
|
@@ -836,6 +1228,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
836
1228
|
return 'dead';
|
|
837
1229
|
}
|
|
838
1230
|
|
|
1231
|
+
mgr.requireExecution(s, 'input');
|
|
839
1232
|
s.pty.write(data);
|
|
840
1233
|
|
|
841
1234
|
return 'ok';
|
|
@@ -905,82 +1298,80 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
905
1298
|
|
|
906
1299
|
return { path, page };
|
|
907
1300
|
},
|
|
908
|
-
readEvents: async (afterID, limit, waitMs) => {
|
|
1301
|
+
readEvents: async (afterID, limit, waitMs, sessionID, access) => {
|
|
909
1302
|
const deadline = Date.now() + waitMs;
|
|
1303
|
+
const scope = buildEventScope(sessionID, access);
|
|
910
1304
|
|
|
911
|
-
// A wake for an event the read leaves out (a heartbeat
|
|
912
|
-
//
|
|
1305
|
+
// A wake for an event the read leaves out (a heartbeat, or another
|
|
1306
|
+
// session's event under a session filter) loops back to wait out the
|
|
1307
|
+
// rest of the window.
|
|
913
1308
|
for (;;) {
|
|
914
1309
|
const generation = eventSignal.generation;
|
|
915
1310
|
|
|
1311
|
+
// A read from a cursor takes one row past the limit to learn whether
|
|
1312
|
+
// more follow; the latest events have nothing after them.
|
|
916
1313
|
const rows =
|
|
917
1314
|
afterID === null
|
|
918
|
-
? await store.collectLatestEvents(limit)
|
|
919
|
-
: await store.collectEventsAfter(afterID, limit);
|
|
1315
|
+
? await store.collectLatestEvents(limit, scope)
|
|
1316
|
+
: await store.collectEventsAfter(afterID, limit + 1, scope);
|
|
920
1317
|
|
|
921
1318
|
const remaining = deadline - Date.now();
|
|
922
1319
|
|
|
923
1320
|
if (rows.length > 0 || remaining <= 0 || eventSignal.disposed) {
|
|
924
|
-
return
|
|
1321
|
+
return {
|
|
1322
|
+
events: buildFleetEvents(rows.slice(0, limit), mgr.collectDescriptors()),
|
|
1323
|
+
more: rows.length > limit,
|
|
1324
|
+
};
|
|
925
1325
|
}
|
|
926
1326
|
|
|
927
1327
|
await eventSignal.waitForNext(generation, remaining);
|
|
928
1328
|
}
|
|
929
1329
|
},
|
|
930
|
-
writeSessionMessage: async (sessionID, from, text) => {
|
|
931
|
-
|
|
1330
|
+
writeSessionMessage: async (sessionID, from, text, keyed) => {
|
|
1331
|
+
if (keyed === null) {
|
|
1332
|
+
const refusal = findMessageRefusal(sessionID);
|
|
932
1333
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1334
|
+
if (refusal !== null) {
|
|
1335
|
+
return refusal;
|
|
1336
|
+
}
|
|
936
1337
|
|
|
937
|
-
|
|
938
|
-
return 'dead';
|
|
939
|
-
}
|
|
1338
|
+
const record = await writeAcceptedMessage(sessionID, from, text, mintMessageID());
|
|
940
1339
|
|
|
941
|
-
|
|
942
|
-
return 'unsupported';
|
|
1340
|
+
return { message: record.id, status: record.status };
|
|
943
1341
|
}
|
|
944
1342
|
|
|
945
|
-
const
|
|
1343
|
+
const effectRef = mintMessageID();
|
|
946
1344
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
) {
|
|
953
|
-
return 'no_tap';
|
|
954
|
-
}
|
|
1345
|
+
try {
|
|
1346
|
+
return await ledger.run({
|
|
1347
|
+
operation: 'session.message',
|
|
1348
|
+
keyed,
|
|
1349
|
+
effectRef,
|
|
955
1350
|
|
|
956
|
-
|
|
957
|
-
|
|
1351
|
+
// A refusal throws, so the claim drops and a retry runs fresh.
|
|
1352
|
+
start: async () => {
|
|
1353
|
+
const refusal = findMessageRefusal(sessionID);
|
|
958
1354
|
|
|
959
|
-
|
|
1355
|
+
if (refusal !== null) {
|
|
1356
|
+
throw new MessageRefusedError(refusal);
|
|
1357
|
+
}
|
|
960
1358
|
|
|
961
|
-
|
|
1359
|
+
const record = await writeAcceptedMessage(sessionID, from, text, effectRef);
|
|
962
1360
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
atcID: sessionID,
|
|
966
|
-
...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
967
|
-
from,
|
|
968
|
-
text,
|
|
969
|
-
status: 'accepted',
|
|
970
|
-
sentAt: Date.now(),
|
|
971
|
-
};
|
|
1361
|
+
return { message: record.id, status: record.status };
|
|
1362
|
+
},
|
|
972
1363
|
|
|
973
|
-
|
|
974
|
-
|
|
1364
|
+
// The message row is the effect, and the start already wrote it.
|
|
1365
|
+
settle: () => Promise.resolve(),
|
|
1366
|
+
replay: (record) => loadMessageReplay(record),
|
|
1367
|
+
});
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
if (error instanceof MessageRefusedError) {
|
|
1370
|
+
return error.refusal;
|
|
1371
|
+
}
|
|
975
1372
|
|
|
976
|
-
|
|
977
|
-
} finally {
|
|
978
|
-
written.resolve();
|
|
1373
|
+
throw error;
|
|
979
1374
|
}
|
|
980
|
-
|
|
981
|
-
await drainInbox(sessionID);
|
|
982
|
-
|
|
983
|
-
return record;
|
|
984
1375
|
},
|
|
985
1376
|
attachTap: (client, sessionID) => {
|
|
986
1377
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
@@ -1005,20 +1396,38 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1005
1396
|
|
|
1006
1397
|
return 'ok';
|
|
1007
1398
|
},
|
|
1008
|
-
readMessage: async (messageID) => {
|
|
1009
|
-
const
|
|
1399
|
+
readMessage: async (messageID, waitMs) => {
|
|
1400
|
+
const deadline = Date.now() + waitMs;
|
|
1401
|
+
let initialStatus: MessageStatus | null = null;
|
|
1010
1402
|
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1403
|
+
// Every message status change writes a trail entry and wakes the
|
|
1404
|
+
// signal, so a wake re-reads the message and returns once its status
|
|
1405
|
+
// moved from the status at call time. An answered message has no
|
|
1406
|
+
// further status to wait for.
|
|
1407
|
+
for (;;) {
|
|
1408
|
+
const generation = eventSignal.generation;
|
|
1014
1409
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1410
|
+
const view = await readMessageView(messageID);
|
|
1411
|
+
|
|
1412
|
+
if (view === null) {
|
|
1413
|
+
return null;
|
|
1414
|
+
}
|
|
1020
1415
|
|
|
1021
|
-
|
|
1416
|
+
initialStatus ??= view.record.status;
|
|
1417
|
+
|
|
1418
|
+
const remaining = deadline - Date.now();
|
|
1419
|
+
|
|
1420
|
+
if (
|
|
1421
|
+
view.record.status !== initialStatus ||
|
|
1422
|
+
view.record.status === 'answered' ||
|
|
1423
|
+
remaining <= 0 ||
|
|
1424
|
+
eventSignal.disposed
|
|
1425
|
+
) {
|
|
1426
|
+
return view;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
await eventSignal.waitForNext(generation, remaining);
|
|
1430
|
+
}
|
|
1022
1431
|
},
|
|
1023
1432
|
ackMessage: async (client, sessionID, messageID) => {
|
|
1024
1433
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
@@ -1079,6 +1488,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1079
1488
|
client.dispose();
|
|
1080
1489
|
}
|
|
1081
1490
|
|
|
1491
|
+
clearInterval(idempotencySweep);
|
|
1492
|
+
|
|
1082
1493
|
server.stop(true);
|
|
1083
1494
|
eventsServer?.stop();
|
|
1084
1495
|
reporter.stop(true);
|
|
@@ -1161,3 +1572,39 @@ function getDescriptor(mgr: SessionManager, id: SessionID): SessionDescriptor {
|
|
|
1161
1572
|
|
|
1162
1573
|
return d;
|
|
1163
1574
|
}
|
|
1575
|
+
|
|
1576
|
+
// A sweep that fails leaves the keys for the next one.
|
|
1577
|
+
async function tryRemoveExpiredIdempotencyKeys(store: StateStore): Promise<boolean> {
|
|
1578
|
+
try {
|
|
1579
|
+
await store.removeExpiredIdempotencyKeys(Date.now() - IDEMPOTENCY_TTL_MS);
|
|
1580
|
+
} catch {
|
|
1581
|
+
return false;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
return true;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// Carries a message refusal out of a keyed send's start, so the claim drops
|
|
1588
|
+
// and the refusal still answers the request.
|
|
1589
|
+
class MessageRefusedError extends Error {
|
|
1590
|
+
readonly refusal: MessageRefusal;
|
|
1591
|
+
|
|
1592
|
+
constructor(refusal: MessageRefusal) {
|
|
1593
|
+
super(`message refused: ${refusal}`);
|
|
1594
|
+
|
|
1595
|
+
this.refusal = refusal;
|
|
1596
|
+
this.name = 'MessageRefusedError';
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// The target a held spawn key's stored answer holds its session on, for a
|
|
1601
|
+
// refusal of its replay; the answer holds no other target to name.
|
|
1602
|
+
function findReplayTarget(record: IdempotencyRecord): string {
|
|
1603
|
+
const stored: unknown = record.result === null ? null : JSON.parse(record.result);
|
|
1604
|
+
const session = isRecord(stored) ? stored['session'] : null;
|
|
1605
|
+
const locator = isRecord(session) ? session['locator'] : null;
|
|
1606
|
+
|
|
1607
|
+
return isRecord(locator) && typeof locator['targetID'] === 'string'
|
|
1608
|
+
? locator['targetID']
|
|
1609
|
+
: 'unknown';
|
|
1610
|
+
}
|