@scp-dev/core 0.1.5 → 0.2.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/dist/index.d.ts +39 -3
- package/dist/index.js +136 -25
- package/package.json +1 -1
- package/src/core.ts +154 -25
- package/src/scopes.ts +3 -2
package/dist/index.d.ts
CHANGED
|
@@ -998,11 +998,12 @@ declare function relationText(peer: PolicySubject, me: PolicySubject): string;
|
|
|
998
998
|
/**
|
|
999
999
|
* The scope check for a message from `a` to `b`: `out(a)` admits `b` and `in(b)` admits `a`. Either side the account's human (with `humanAlways`)
|
|
1000
1000
|
* or the system endpoint passes, except an `unverified` human sender (one without a terminal), which is checked like any unplaced sender; an
|
|
1001
|
-
* ancestor of `b` (unless `strict`)
|
|
1001
|
+
* ancestor of `b` (unless `strict`), a solicited reply and a parent and child `linked` across machines pass both.
|
|
1002
1002
|
*/
|
|
1003
1003
|
declare function scopeDecision(world: ScopeWorld, a: ScopeNode, b: ScopeNode, options: {
|
|
1004
1004
|
humanAlways: boolean;
|
|
1005
1005
|
solicited?: boolean;
|
|
1006
|
+
linked?: boolean;
|
|
1006
1007
|
unverified?: boolean;
|
|
1007
1008
|
strict?: boolean;
|
|
1008
1009
|
}): ScopeDecision;
|
|
@@ -1085,6 +1086,8 @@ interface EndpointRow {
|
|
|
1085
1086
|
/** the root endpoint's id; null for a root */
|
|
1086
1087
|
tree: string | null;
|
|
1087
1088
|
parent_ep: string | null;
|
|
1089
|
+
/** a parent on another machine of the account, `<machine>/<handle>`: the row stays a root of its own tree */
|
|
1090
|
+
parent_addr: string | null;
|
|
1088
1091
|
depth: number | null;
|
|
1089
1092
|
/** JSON {@link ScopeRules} */
|
|
1090
1093
|
rules: string | null;
|
|
@@ -1109,6 +1112,10 @@ interface EndpointRow {
|
|
|
1109
1112
|
linked: number | null;
|
|
1110
1113
|
/** how the session that holds the endpoint wakes it: a {@link Wake} other than none, or null */
|
|
1111
1114
|
wake: string | null;
|
|
1115
|
+
/** the live connections that set the wake */
|
|
1116
|
+
wakers: number;
|
|
1117
|
+
/** when the last hook connection that set the wake closed */
|
|
1118
|
+
waker_left: number | null;
|
|
1112
1119
|
}
|
|
1113
1120
|
interface MessageRow {
|
|
1114
1121
|
id: string;
|
|
@@ -1171,6 +1178,8 @@ declare class RelayCore {
|
|
|
1171
1178
|
constructor(sql: Sql, options: CoreOptions);
|
|
1172
1179
|
/** a `machine` label naming this host by its hostname, as clients default it, names it by its machine name when one is set */
|
|
1173
1180
|
private machineLabel;
|
|
1181
|
+
/** this host's own agent rows labelled with another machine (its name before a rename, or its hostname) take its machine name */
|
|
1182
|
+
relabelMachine(): number;
|
|
1174
1183
|
migrate(): void;
|
|
1175
1184
|
/** every stored list that is exactly `tree:self`, the tree preset's old list, narrows once to the preset's list */
|
|
1176
1185
|
private migrateTreePreset;
|
|
@@ -1256,6 +1265,8 @@ declare class RelayCore {
|
|
|
1256
1265
|
* whether a claim placed under `parent` restarts `row`: it lands where `row` sits (under `row`'s parent, or a root with no placement), or on
|
|
1257
1266
|
* `row` itself from `row`'s own process; never under `row` or elsewhere in its tree
|
|
1258
1267
|
*/
|
|
1268
|
+
/** a local row held only by connections whose harness process exited, as a shim that outlived its harness leaves it */
|
|
1269
|
+
private outlived;
|
|
1259
1270
|
private restarts;
|
|
1260
1271
|
/**
|
|
1261
1272
|
* whether the claim re-claims `row` with the grant `row` spent: from `row`'s own process or one of its ancestors, or with no lineage from
|
|
@@ -1344,6 +1355,15 @@ declare class RelayCore {
|
|
|
1344
1355
|
transient?: boolean;
|
|
1345
1356
|
process?: string;
|
|
1346
1357
|
}): boolean;
|
|
1358
|
+
/**
|
|
1359
|
+
* a connection that set the endpoint's wake starts (`live`) or stops counting as its waker. A hook's (`grace`) still counts for
|
|
1360
|
+
* LIMITS.restartGraceS after it closes, while its harness starts the next one
|
|
1361
|
+
*/
|
|
1362
|
+
waker(endpoint: string, live: boolean, options?: {
|
|
1363
|
+
grace?: boolean;
|
|
1364
|
+
}): void;
|
|
1365
|
+
/** clears a wake no waker holds once a hook waker's grace ran out too: nothing is left to wake the endpoint */
|
|
1366
|
+
private lapseWake;
|
|
1347
1367
|
/**
|
|
1348
1368
|
* a title for the caller's in-process subagent `id` from the description its parent gave it; the subagent's endpoint id when it changed one.
|
|
1349
1369
|
* It fills only a title nobody set, as an inferred one, and waits for the subagent's claim when it has no endpoint yet
|
|
@@ -1388,8 +1408,22 @@ declare class RelayCore {
|
|
|
1388
1408
|
endpoint: EndpointView;
|
|
1389
1409
|
parent: string;
|
|
1390
1410
|
};
|
|
1391
|
-
/**
|
|
1411
|
+
/**
|
|
1412
|
+
* `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants; a parent on another
|
|
1413
|
+
* machine goes to {@link linkAcross}
|
|
1414
|
+
*/
|
|
1392
1415
|
private link;
|
|
1416
|
+
/** a link of `row`, which already sits under a parent, to another one */
|
|
1417
|
+
private alreadyLinked;
|
|
1418
|
+
/**
|
|
1419
|
+
* the key `<machine>/<handle>` that links `row`, a local agent, under `parent`, an agent on another machine, once the parent is checked; no
|
|
1420
|
+
* inherited or child-default caps come with it. A relay of one machine has no such parent
|
|
1421
|
+
*/
|
|
1422
|
+
protected linkAcross(_row: EndpointRow, _parent: EndpointRow): string;
|
|
1423
|
+
/** whether `a` and `b` are a parent and its child on different machines */
|
|
1424
|
+
protected linkedAcross(_a: EndpointRow, _b: EndpointRow): boolean;
|
|
1425
|
+
/** the address of `row`'s parent on another machine, null when it has none; with `shown`, null for a parent outside it */
|
|
1426
|
+
protected linkedParent(_row: EndpointRow, _shown?: Set<string>): string | null;
|
|
1393
1427
|
/**
|
|
1394
1428
|
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
1395
1429
|
* the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
|
|
@@ -1404,6 +1438,8 @@ declare class RelayCore {
|
|
|
1404
1438
|
};
|
|
1405
1439
|
/** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
|
|
1406
1440
|
private renameRow;
|
|
1441
|
+
/** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
|
|
1442
|
+
protected renamedHandle(_from: string, _to: string): void;
|
|
1407
1443
|
/**
|
|
1408
1444
|
* a session leaves: it lets go of its session and token and shows offline, its mail kept; a later join takes it back. An agent leaves only
|
|
1409
1445
|
* itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
|
|
@@ -1685,7 +1721,7 @@ declare class RelayCore {
|
|
|
1685
1721
|
}): AuditRow[];
|
|
1686
1722
|
/** drops what expired: deliveries, message bodies past their grace, old guard state and old audit rows */
|
|
1687
1723
|
sweep(): void;
|
|
1688
|
-
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
|
|
1724
|
+
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would, and lapses wakes whose hook waker never came back */
|
|
1689
1725
|
endExited(now?: number): void;
|
|
1690
1726
|
/** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
|
|
1691
1727
|
private settleOffline;
|
package/dist/index.js
CHANGED
|
@@ -558,6 +558,7 @@ function scopeDecision(world, a, b, options) {
|
|
|
558
558
|
if (options.humanAlways && (b.human || a.human && !options.unverified)) return { allowed: true, reasons: ["the human is always reachable"] };
|
|
559
559
|
if (!options.strict && isAncestor(a, b)) return { allowed: true, reasons: [`${a.address} is an ancestor of ${b.address}: scopes never stop it`] };
|
|
560
560
|
if (options.solicited) return { allowed: true, reasons: [`a reply to ${b.address}'s message in its thread`] };
|
|
561
|
+
if (options.linked) return { allowed: true, reasons: [`${a.address} and ${b.address} are parent and child across machines`] };
|
|
561
562
|
const memo = /* @__PURE__ */ new Map();
|
|
562
563
|
const out = admits(world, a, "out", b, memo);
|
|
563
564
|
if (!out.ok) return { allowed: false, dir: "out", reasons: [out.why] };
|
|
@@ -1306,6 +1307,19 @@ var RelayCore = class {
|
|
|
1306
1307
|
if (this.namedMachine !== null && labels.machine !== void 0 && labels.machine === this.hostname) labels.machine = this.namedMachine;
|
|
1307
1308
|
return labels;
|
|
1308
1309
|
}
|
|
1310
|
+
/** this host's own agent rows labelled with another machine (its name before a rename, or its hostname) take its machine name */
|
|
1311
|
+
relabelMachine() {
|
|
1312
|
+
const machine = this.namedMachine;
|
|
1313
|
+
if (machine === null) return 0;
|
|
1314
|
+
let changed = 0;
|
|
1315
|
+
for (const row of this.sql.all(`SELECT * FROM endpoints WHERE kind = 'agent' AND labels LIKE '%"machine"%'`)) {
|
|
1316
|
+
const labels = json(row.labels, {});
|
|
1317
|
+
if (labels.machine === void 0 || labels.machine === machine || !this.localRow(row)) continue;
|
|
1318
|
+
this.sql.all(`UPDATE endpoints SET labels = ? WHERE id = ?`, JSON.stringify({ ...labels, machine }), row.id);
|
|
1319
|
+
changed++;
|
|
1320
|
+
}
|
|
1321
|
+
return changed;
|
|
1322
|
+
}
|
|
1309
1323
|
migrate() {
|
|
1310
1324
|
for (const statement of MIGRATION) this.sql.all(statement);
|
|
1311
1325
|
const columns = this.sql.all(`PRAGMA table_info(idempotency)`).map((c) => c.name);
|
|
@@ -1325,6 +1339,7 @@ var RelayCore = class {
|
|
|
1325
1339
|
for (const [column, type] of [
|
|
1326
1340
|
["tree", "TEXT"],
|
|
1327
1341
|
["parent_ep", "TEXT"],
|
|
1342
|
+
["parent_addr", "TEXT"],
|
|
1328
1343
|
["depth", "INTEGER"],
|
|
1329
1344
|
["rules", "TEXT"],
|
|
1330
1345
|
["caps", "TEXT"],
|
|
@@ -1344,7 +1359,9 @@ var RelayCore = class {
|
|
|
1344
1359
|
["tldr_turn", "INTEGER"],
|
|
1345
1360
|
["named", "INTEGER"],
|
|
1346
1361
|
["linked", "INTEGER"],
|
|
1347
|
-
["wake", "TEXT"]
|
|
1362
|
+
["wake", "TEXT"],
|
|
1363
|
+
["wakers", "INTEGER NOT NULL DEFAULT 0"],
|
|
1364
|
+
["waker_left", "INTEGER"]
|
|
1348
1365
|
]) {
|
|
1349
1366
|
if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`);
|
|
1350
1367
|
}
|
|
@@ -1621,7 +1638,7 @@ var RelayCore = class {
|
|
|
1621
1638
|
if (row && presenceOf(row, now) === "offline") {
|
|
1622
1639
|
this.sql.all(`UPDATE endpoints SET session = NULL WHERE id = ?`, row.id);
|
|
1623
1640
|
row = void 0;
|
|
1624
|
-
} else if (row && !transient && !sameProcess(row, fields.process)) {
|
|
1641
|
+
} else if (row && !transient && !sameProcess(row, fields.process) && !(row.handle === wanted && reclaims(row, fields.process))) {
|
|
1625
1642
|
row = void 0;
|
|
1626
1643
|
session = null;
|
|
1627
1644
|
} else if (row && !transient && row.token_hash === null && row.named === null && row.handle !== wanted && !row.handle.startsWith(`${wanted.slice(0, 58)}-`)) {
|
|
@@ -1663,7 +1680,7 @@ var RelayCore = class {
|
|
|
1663
1680
|
this.sql.all(`INSERT INTO endpoints (id, handle, kind, harness, labels, created_at) VALUES (?, ?, 'agent', ?, '{}', ?)`, id, handle, harness, now);
|
|
1664
1681
|
row = this.byId(id);
|
|
1665
1682
|
created = true;
|
|
1666
|
-
} else if (held.kind === "agent" && held.connections === 0 && presenceOf(held, now) === "offline" && harnessFits(held.harness, harness) && held.id !== scope.subagent?.root && !(scope.subagent && held.session?.startsWith("sub:") && held.session !== fields.session)) {
|
|
1683
|
+
} else if (held.kind === "agent" && (held.connections === 0 && presenceOf(held, now) === "offline" || !transient && this.outlived(held)) && harnessFits(held.harness, harness) && held.id !== scope.subagent?.root && !(scope.subagent && held.session?.startsWith("sub:") && held.session !== fields.session)) {
|
|
1667
1684
|
own = transient && held.process !== null && fields.process !== null && sameHarness(held.process, fields.process);
|
|
1668
1685
|
const ended = held.hook_ended !== null || (held.last_seen ?? held.created_at) < now - LIMITS.restartGraceS * 1e3;
|
|
1669
1686
|
if ((!transient || own || ended) && (this.restarts(held, this.placement(scope, now).parent, fields.process) || this.redeems(held, scope, { harness, ...fields })))
|
|
@@ -1872,6 +1889,11 @@ var RelayCore = class {
|
|
|
1872
1889
|
* whether a claim placed under `parent` restarts `row`: it lands where `row` sits (under `row`'s parent, or a root with no placement), or on
|
|
1873
1890
|
* `row` itself from `row`'s own process; never under `row` or elsewhere in its tree
|
|
1874
1891
|
*/
|
|
1892
|
+
/** a local row held only by connections whose harness process exited, as a shim that outlived its harness leaves it */
|
|
1893
|
+
outlived(row) {
|
|
1894
|
+
const pid = Number(/^pid:(\d+):/.exec(row.process ?? "")?.[1]);
|
|
1895
|
+
return row.connections > 0 && pid > 0 && this.localRow(row) && !this.alive(pid);
|
|
1896
|
+
}
|
|
1875
1897
|
restarts(row, parent, process) {
|
|
1876
1898
|
if (parent?.id === row.id) return process !== null && row.process !== null && sameHarness(row.process, process);
|
|
1877
1899
|
return (parent?.id ?? null) === row.parent_ep || parent === null && row.linked !== null;
|
|
@@ -1922,7 +1944,7 @@ var RelayCore = class {
|
|
|
1922
1944
|
}
|
|
1923
1945
|
caps.caps.push(...this.capsOf(row).caps);
|
|
1924
1946
|
this.sql.all(
|
|
1925
|
-
`UPDATE endpoints SET tree = ?, parent_ep = ?, depth = ?, rules = ?, caps = ? WHERE id = ?`,
|
|
1947
|
+
`UPDATE endpoints SET tree = ?, parent_ep = ?, parent_addr = NULL, depth = ?, rules = ?, caps = ? WHERE id = ?`,
|
|
1926
1948
|
parent ? parent.tree ?? parent.id : null,
|
|
1927
1949
|
parent?.id ?? null,
|
|
1928
1950
|
depth,
|
|
@@ -2036,7 +2058,7 @@ var RelayCore = class {
|
|
|
2036
2058
|
endpoint
|
|
2037
2059
|
);
|
|
2038
2060
|
const row = this.mustEndpoint(endpoint);
|
|
2039
|
-
if (!options.settle ||
|
|
2061
|
+
if (!options.settle || shownPresence(row, now) !== "offline" || row.status_state !== "working" && row.status_state !== "waiting") return false;
|
|
2040
2062
|
this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, endpoint);
|
|
2041
2063
|
return true;
|
|
2042
2064
|
}
|
|
@@ -2051,6 +2073,7 @@ var RelayCore = class {
|
|
|
2051
2073
|
`UPDATE endpoints SET connections = 0, hook_seen = CASE WHEN kind = 'agent' AND hook_ended IS NULL THEN MAX(COALESCE(hook_seen, 0), ?) ELSE hook_seen END WHERE connections > 0`,
|
|
2052
2074
|
this.now() - (LIMITS.hookIdleS - LIMITS.restartGraceS) * 1e3
|
|
2053
2075
|
);
|
|
2076
|
+
this.sql.all(`UPDATE endpoints SET wakers = 0, waker_left = ? WHERE wakers > 0`, this.now());
|
|
2054
2077
|
}
|
|
2055
2078
|
whoami(endpoint) {
|
|
2056
2079
|
return this.view(this.mustEndpoint(endpoint));
|
|
@@ -2068,7 +2091,7 @@ var RelayCore = class {
|
|
|
2068
2091
|
const viewer = filters.viewer === void 0 ? void 0 : this.mustEndpoint(filters.viewer);
|
|
2069
2092
|
const shown = viewer ? new Set(this.visibleRows(viewer).map((row) => row.id)) : void 0;
|
|
2070
2093
|
const peeker = viewer ?? (filters.peekable === true ? this.byHandle(HUMAN_HANDLE) : void 0);
|
|
2071
|
-
return this.listed().filter((row) => filters.all || row.connections > 0 || row.kind === "human" || (row.last_seen ?? 0) >= cutoff).filter((row) => !filters.harness || row.harness === filters.harness).filter((row) => !filters.kind || row.kind === filters.kind).filter((row) => !viewer || row.id === viewer.id || this.mayMessage(viewer, row, viewer.kind === "human")).map((row) => {
|
|
2094
|
+
return this.listed().filter((row) => filters.all || row.connections > 0 || row.kind === "human" || (row.last_seen ?? 0) >= cutoff || waking(row, now)).filter((row) => !filters.harness || row.harness === filters.harness).filter((row) => !filters.kind || row.kind === filters.kind).filter((row) => !viewer || row.id === viewer.id || this.mayMessage(viewer, row, viewer.kind === "human")).map((row) => {
|
|
2072
2095
|
const view = this.view(row, shown);
|
|
2073
2096
|
return peeker && this.peekableBy(peeker, row, viewer?.kind === "human") ? { ...view, peekable: row.peek } : view;
|
|
2074
2097
|
}).filter((view) => !filters.presence || view.presence === filters.presence);
|
|
@@ -2168,6 +2191,27 @@ var RelayCore = class {
|
|
|
2168
2191
|
this.sql.all(`UPDATE endpoints SET wake = ? WHERE id = ?`, wake, endpoint);
|
|
2169
2192
|
return true;
|
|
2170
2193
|
}
|
|
2194
|
+
/**
|
|
2195
|
+
* a connection that set the endpoint's wake starts (`live`) or stops counting as its waker. A hook's (`grace`) still counts for
|
|
2196
|
+
* LIMITS.restartGraceS after it closes, while its harness starts the next one
|
|
2197
|
+
*/
|
|
2198
|
+
waker(endpoint, live, options = {}) {
|
|
2199
|
+
if (live) this.sql.all(`UPDATE endpoints SET wakers = wakers + 1 WHERE id = ?`, endpoint);
|
|
2200
|
+
else {
|
|
2201
|
+
const now = this.now();
|
|
2202
|
+
this.sql.all(
|
|
2203
|
+
`UPDATE endpoints SET wakers = MAX(wakers - 1, 0), waker_left = CASE WHEN ? THEN ? ELSE waker_left END WHERE id = ?`,
|
|
2204
|
+
options.grace ? 1 : 0,
|
|
2205
|
+
now,
|
|
2206
|
+
endpoint
|
|
2207
|
+
);
|
|
2208
|
+
if (!options.grace) this.lapseWake(endpoint, now);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
/** clears a wake no waker holds once a hook waker's grace ran out too: nothing is left to wake the endpoint */
|
|
2212
|
+
lapseWake(endpoint, now) {
|
|
2213
|
+
this.sql.all(`UPDATE endpoints SET wake = NULL WHERE id = ? AND wakers = 0 AND COALESCE(waker_left, 0) < ?`, endpoint, now - LIMITS.restartGraceS * 1e3);
|
|
2214
|
+
}
|
|
2171
2215
|
/**
|
|
2172
2216
|
* a title for the caller's in-process subagent `id` from the description its parent gave it; the subagent's endpoint id when it changed one.
|
|
2173
2217
|
* It fills only a title nobody set, as an inferred one, and waits for the subagent's claim when it has no endpoint yet
|
|
@@ -2272,7 +2316,8 @@ var RelayCore = class {
|
|
|
2272
2316
|
const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
|
|
2273
2317
|
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself");
|
|
2274
2318
|
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be unlinked");
|
|
2275
|
-
if (target.parent_ep === null
|
|
2319
|
+
if (target.parent_ep === null && target.parent_addr === null)
|
|
2320
|
+
throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`);
|
|
2276
2321
|
if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
|
|
2277
2322
|
throw this.scopeRefused(
|
|
2278
2323
|
actor,
|
|
@@ -2280,6 +2325,14 @@ var RelayCore = class {
|
|
|
2280
2325
|
{ target: this.address(target), reason: "unlink" },
|
|
2281
2326
|
"unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)"
|
|
2282
2327
|
);
|
|
2328
|
+
if (target.parent_ep === null) {
|
|
2329
|
+
const from2 = this.linkedParent(target) ?? target.parent_addr;
|
|
2330
|
+
this.sql.all(`UPDATE endpoints SET parent_addr = NULL, linked = NULL WHERE id = ?`, target.id);
|
|
2331
|
+
this.scopeMemo?.ancestors.clear();
|
|
2332
|
+
this.scopeMemo?.limited.clear();
|
|
2333
|
+
this.audit("unlink", target.id, null, { ...this.actorDetail(actor, me), target: this.address(target), parent: from2 });
|
|
2334
|
+
return { endpoint: this.whoami(target.id), parent: from2 };
|
|
2335
|
+
}
|
|
2283
2336
|
const parent = this.byId(target.parent_ep);
|
|
2284
2337
|
const from = parent ? this.address(parent) : target.parent_ep;
|
|
2285
2338
|
const shift = target.depth ?? 0;
|
|
@@ -2303,16 +2356,26 @@ var RelayCore = class {
|
|
|
2303
2356
|
return { endpoint: this.whoami(target.id), parent: from };
|
|
2304
2357
|
});
|
|
2305
2358
|
}
|
|
2306
|
-
/**
|
|
2359
|
+
/**
|
|
2360
|
+
* `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants; a parent on another
|
|
2361
|
+
* machine goes to {@link linkAcross}
|
|
2362
|
+
*/
|
|
2307
2363
|
link(row, parent, by) {
|
|
2308
|
-
if (
|
|
2364
|
+
if (!this.localRow(parent)) {
|
|
2365
|
+
const key = this.linkAcross(row, parent);
|
|
2366
|
+
if (row.parent_addr === key) return;
|
|
2367
|
+
if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row);
|
|
2368
|
+
this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, key, this.now(), row.id);
|
|
2369
|
+
this.scopeMemo?.ancestors.clear();
|
|
2370
|
+
this.scopeMemo?.limited.clear();
|
|
2371
|
+
this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) });
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
if (parent.kind !== "agent") throw new RelayError("invalid_params", "the parent is an agent endpoint");
|
|
2309
2375
|
if (row.parent_ep === parent.id) return;
|
|
2310
2376
|
if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
|
|
2311
2377
|
throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`);
|
|
2312
|
-
if (row.parent_ep !== null)
|
|
2313
|
-
const now = this.byId(row.parent_ep);
|
|
2314
|
-
throw new RelayError("scope_refused", `${this.address(row)} already sits under ${now ? this.address(now) : "another agent"}; unlink it first`);
|
|
2315
|
-
}
|
|
2378
|
+
if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row);
|
|
2316
2379
|
const depth = (parent.depth ?? 0) + 1;
|
|
2317
2380
|
const deepest = one(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0;
|
|
2318
2381
|
if (deepest + depth > LIMITS.depthMax)
|
|
@@ -2336,6 +2399,27 @@ var RelayCore = class {
|
|
|
2336
2399
|
this.scopeMemo?.limited.clear();
|
|
2337
2400
|
this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) });
|
|
2338
2401
|
}
|
|
2402
|
+
/** a link of `row`, which already sits under a parent, to another one */
|
|
2403
|
+
alreadyLinked(row) {
|
|
2404
|
+
const local = row.parent_ep ? this.byId(row.parent_ep) : void 0;
|
|
2405
|
+
const now = local ? this.address(local) : row.parent_addr !== null ? this.linkedParent(row) ?? row.parent_addr : "another agent";
|
|
2406
|
+
return new RelayError("scope_refused", `${this.address(row)} already sits under ${now}; unlink it first`);
|
|
2407
|
+
}
|
|
2408
|
+
/**
|
|
2409
|
+
* the key `<machine>/<handle>` that links `row`, a local agent, under `parent`, an agent on another machine, once the parent is checked; no
|
|
2410
|
+
* inherited or child-default caps come with it. A relay of one machine has no such parent
|
|
2411
|
+
*/
|
|
2412
|
+
linkAcross(_row, _parent) {
|
|
2413
|
+
throw new RelayError("invalid_params", "the parent is a local agent endpoint");
|
|
2414
|
+
}
|
|
2415
|
+
/** whether `a` and `b` are a parent and its child on different machines */
|
|
2416
|
+
linkedAcross(_a, _b) {
|
|
2417
|
+
return false;
|
|
2418
|
+
}
|
|
2419
|
+
/** the address of `row`'s parent on another machine, null when it has none; with `shown`, null for a parent outside it */
|
|
2420
|
+
linkedParent(_row, _shown) {
|
|
2421
|
+
return null;
|
|
2422
|
+
}
|
|
2339
2423
|
/**
|
|
2340
2424
|
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
2341
2425
|
* the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
|
|
@@ -2383,8 +2467,12 @@ var RelayCore = class {
|
|
|
2383
2467
|
])
|
|
2384
2468
|
this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a);
|
|
2385
2469
|
}
|
|
2470
|
+
this.renamedHandle(from, handle);
|
|
2386
2471
|
this.audit("rename", row.id, null, { from, to: handle });
|
|
2387
2472
|
}
|
|
2473
|
+
/** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
|
|
2474
|
+
renamedHandle(_from, _to) {
|
|
2475
|
+
}
|
|
2388
2476
|
/**
|
|
2389
2477
|
* a session leaves: it lets go of its session and token and shows offline, its mail kept; a later join takes it back. An agent leaves only
|
|
2390
2478
|
* itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
|
|
@@ -2469,7 +2557,7 @@ var RelayCore = class {
|
|
|
2469
2557
|
const recipients = [...new Map(resolved.map((row) => [row.id, row])).values()];
|
|
2470
2558
|
if (params.live !== void 0 && typeof params.live !== "boolean") throw new RelayError("invalid_params", "live must be true or false");
|
|
2471
2559
|
const dark = recipients.filter(
|
|
2472
|
-
(r) => r.kind === "agent" &&
|
|
2560
|
+
(r) => r.kind === "agent" && shownPresence(r, now) === "offline" && (now - Math.max(r.last_seen ?? 0, r.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS || r.session === null && r.token_hash === null && r.hook_ended !== null) && !this.unjoinedSpawn(sender, r.handle, now)
|
|
2473
2561
|
);
|
|
2474
2562
|
if (params.live === true && dark.length)
|
|
2475
2563
|
throw new RelayError("not_live", `no live session holds ${dark.map((r) => this.address(r)).join(", ")}: nothing was queued`);
|
|
@@ -2714,7 +2802,7 @@ var RelayCore = class {
|
|
|
2714
2802
|
reach(sender, recipient, offline, now) {
|
|
2715
2803
|
if (recipient.kind !== "agent" || !this.localRow(recipient)) return void 0;
|
|
2716
2804
|
const done = recipient.status_state === "done";
|
|
2717
|
-
if ((done ||
|
|
2805
|
+
if ((done || shownPresence(recipient, now) === "offline") && this.unjoinedSpawn(sender, recipient.handle, now)) return "starting";
|
|
2718
2806
|
if (done) return "ended";
|
|
2719
2807
|
return offline ? "offline" : "running";
|
|
2720
2808
|
}
|
|
@@ -3113,7 +3201,7 @@ var RelayCore = class {
|
|
|
3113
3201
|
continue;
|
|
3114
3202
|
}
|
|
3115
3203
|
const row = this.byId(grant.used_by);
|
|
3116
|
-
if (!row || row.status_state === "done" ||
|
|
3204
|
+
if (!row || row.status_state === "done" || shownPresence(row, now) === "offline" && (grant.handback !== null || !pid || !this.alive(pid))) continue;
|
|
3117
3205
|
workers.set(row.id, row);
|
|
3118
3206
|
if (grant.pushed_at !== null && grant.pushed_at > now - LIMITS.pushGapS * 1e3) paced.add(row.id);
|
|
3119
3207
|
}
|
|
@@ -3476,9 +3564,9 @@ var RelayCore = class {
|
|
|
3476
3564
|
request,
|
|
3477
3565
|
endpoint
|
|
3478
3566
|
).filter(
|
|
3479
|
-
(row) => present ? present.includes(row.id) :
|
|
3567
|
+
(row) => present ? present.includes(row.id) : shownPresence(row, now) !== "offline" || Math.max(row.last_seen ?? 0, row.hook_seen ?? 0, row.hook_ended ?? 0) >= asked.created_at
|
|
3480
3568
|
);
|
|
3481
|
-
const gone = (row) =>
|
|
3569
|
+
const gone = (row) => shownPresence(row, now) === "offline" && ((row.hook_ended ?? 0) >= asked.created_at || row.status_state === "done" || now - Math.max(row.last_seen ?? 0, row.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS);
|
|
3482
3570
|
if (!rows.length || !rows.every(gone)) return null;
|
|
3483
3571
|
return rows.map((row) => ({ address: this.address(row), state: row.status_state }));
|
|
3484
3572
|
}
|
|
@@ -3558,22 +3646,31 @@ var RelayCore = class {
|
|
|
3558
3646
|
if (!root || root.handle.startsWith(GONE_PREFIX) || !seen) this.dropGroup(group.name);
|
|
3559
3647
|
}
|
|
3560
3648
|
}
|
|
3561
|
-
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
|
|
3649
|
+
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would, and lapses wakes whose hook waker never came back */
|
|
3562
3650
|
endExited(now = this.now()) {
|
|
3563
3651
|
for (const row of this.sql.all(
|
|
3564
3652
|
`SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`
|
|
3565
3653
|
)) {
|
|
3566
3654
|
const pid = Number(/^pid:(\d+)/.exec(row.process)?.[1]);
|
|
3567
3655
|
if (!pid || !this.localRow(row) || this.alive(pid)) continue;
|
|
3568
|
-
this.sql.all(
|
|
3656
|
+
this.sql.all(
|
|
3657
|
+
`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = NULL, wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ? AND connections = 0`,
|
|
3658
|
+
now,
|
|
3659
|
+
row.id
|
|
3660
|
+
);
|
|
3569
3661
|
}
|
|
3662
|
+
for (const row of this.sql.all(
|
|
3663
|
+
`SELECT * FROM endpoints WHERE wake IS NOT NULL AND wakers = 0 AND waker_left < ?`,
|
|
3664
|
+
now - LIMITS.restartGraceS * 1e3
|
|
3665
|
+
))
|
|
3666
|
+
if (this.localRow(row)) this.lapseWake(row.id, now);
|
|
3570
3667
|
}
|
|
3571
3668
|
/** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
|
|
3572
3669
|
settleOffline(now) {
|
|
3573
3670
|
for (const row of this.sql.all(
|
|
3574
3671
|
`SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND status_state IN ('working', 'waiting')`
|
|
3575
3672
|
)) {
|
|
3576
|
-
if (this.localRow(row) &&
|
|
3673
|
+
if (this.localRow(row) && shownPresence(row, now) === "offline")
|
|
3577
3674
|
this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, row.id);
|
|
3578
3675
|
}
|
|
3579
3676
|
}
|
|
@@ -3616,7 +3713,8 @@ var RelayCore = class {
|
|
|
3616
3713
|
if (params.kind === "session_end") {
|
|
3617
3714
|
if (own)
|
|
3618
3715
|
this.sql.all(
|
|
3619
|
-
`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END
|
|
3716
|
+
`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
|
|
3717
|
+
wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
|
|
3620
3718
|
now,
|
|
3621
3719
|
endpoint
|
|
3622
3720
|
);
|
|
@@ -4245,7 +4343,8 @@ var RelayCore = class {
|
|
|
4245
4343
|
}
|
|
4246
4344
|
const note = [`exited ${p.code}`, spender.status_note].filter(Boolean).join("; ").slice(0, LIMITS.noteChars);
|
|
4247
4345
|
this.sql.all(
|
|
4248
|
-
`UPDATE endpoints SET status_state = 'done', status_note = ?, hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END
|
|
4346
|
+
`UPDATE endpoints SET status_state = 'done', status_note = ?, hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
|
|
4347
|
+
wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
|
|
4249
4348
|
note,
|
|
4250
4349
|
now,
|
|
4251
4350
|
spender.id
|
|
@@ -4670,6 +4769,7 @@ ${stderr}` : "";
|
|
|
4670
4769
|
return scopeDecision(world, world.node(a.id) ?? this.scopeNode(a), world.node(b.id) ?? this.scopeNode(b), {
|
|
4671
4770
|
humanAlways: this.defaults().humanAlways,
|
|
4672
4771
|
solicited,
|
|
4772
|
+
linked: a.id !== b.id && this.linkedAcross(a, b),
|
|
4673
4773
|
unverified,
|
|
4674
4774
|
strict
|
|
4675
4775
|
});
|
|
@@ -5382,7 +5482,7 @@ ${stderr}` : "";
|
|
|
5382
5482
|
}
|
|
5383
5483
|
/** with `shown`, the ids a viewer sees: a parent outside it is null */
|
|
5384
5484
|
view(row, shown) {
|
|
5385
|
-
const presence =
|
|
5485
|
+
const presence = shownPresence(row, this.now());
|
|
5386
5486
|
return {
|
|
5387
5487
|
id: row.id,
|
|
5388
5488
|
address: this.address(row),
|
|
@@ -5400,7 +5500,8 @@ ${stderr}` : "";
|
|
|
5400
5500
|
}
|
|
5401
5501
|
placeView(row, shown) {
|
|
5402
5502
|
const parent = row.parent_ep && (!shown || shown.has(row.parent_ep)) ? this.byId(row.parent_ep) : void 0;
|
|
5403
|
-
|
|
5503
|
+
const across = !parent && row.parent_ep === null && row.parent_addr ? this.linkedParent(row, shown) : null;
|
|
5504
|
+
return { parent: parent ? this.address(parent) : across, depth: row.depth ?? 0, scope: presetOf(this.scopeNode(row), this.defaults().symmetric) };
|
|
5404
5505
|
}
|
|
5405
5506
|
/** `recipient` names the delivery's endpoint when there is no `seq`: a send to several addresses shows each recipient only itself */
|
|
5406
5507
|
envelope(row, seq, recipient) {
|
|
@@ -5575,6 +5676,9 @@ function unreachable(address) {
|
|
|
5575
5676
|
var PROCESS_KEY = /^pid:\d+:[A-Za-z0-9:-]{1,64}$/;
|
|
5576
5677
|
var BARE_PID_KEY = /^pid:\d+$/;
|
|
5577
5678
|
var ANY_PROCESS_KEY = /^pid:\d+(:[A-Za-z0-9:-]{1,64})?$/;
|
|
5679
|
+
function reclaims(row, key) {
|
|
5680
|
+
return row.connections > 0 && key !== null && row.process === key && PROCESS_KEY.test(key);
|
|
5681
|
+
}
|
|
5578
5682
|
function sameProcess(row, key) {
|
|
5579
5683
|
if (row.connections > 0 || key === null || row.process === null) return false;
|
|
5580
5684
|
return row.token_hash === null ? sameHarness(row.process, key) : row.process === key && PROCESS_KEY.test(key);
|
|
@@ -5615,6 +5719,13 @@ function presenceOf(row, now) {
|
|
|
5615
5719
|
if (row.hook_seen >= now - LIMITS.hookOnlineS * 1e3) return "online";
|
|
5616
5720
|
return row.hook_seen >= now - LIMITS.hookIdleS * 1e3 ? "idle" : "offline";
|
|
5617
5721
|
}
|
|
5722
|
+
function waking(row, now) {
|
|
5723
|
+
return wakeOf(row) !== null && (row.wakers > 0 || (row.waker_left ?? 0) >= now - LIMITS.restartGraceS * 1e3);
|
|
5724
|
+
}
|
|
5725
|
+
function shownPresence(row, now) {
|
|
5726
|
+
const presence = presenceOf(row, now);
|
|
5727
|
+
return presence === "offline" && waking(row, now) ? "idle" : presence;
|
|
5728
|
+
}
|
|
5618
5729
|
function pairKey(a, b) {
|
|
5619
5730
|
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
5620
5731
|
}
|
package/package.json
CHANGED
package/src/core.ts
CHANGED
|
@@ -243,6 +243,8 @@ export interface EndpointRow {
|
|
|
243
243
|
/** the root endpoint's id; null for a root */
|
|
244
244
|
tree: string | null
|
|
245
245
|
parent_ep: string | null
|
|
246
|
+
/** a parent on another machine of the account, `<machine>/<handle>`: the row stays a root of its own tree */
|
|
247
|
+
parent_addr: string | null
|
|
246
248
|
depth: number | null
|
|
247
249
|
/** JSON {@link ScopeRules} */
|
|
248
250
|
rules: string | null
|
|
@@ -267,6 +269,10 @@ export interface EndpointRow {
|
|
|
267
269
|
linked: number | null
|
|
268
270
|
/** how the session that holds the endpoint wakes it: a {@link Wake} other than none, or null */
|
|
269
271
|
wake: string | null
|
|
272
|
+
/** the live connections that set the wake */
|
|
273
|
+
wakers: number
|
|
274
|
+
/** when the last hook connection that set the wake closed */
|
|
275
|
+
waker_left: number | null
|
|
270
276
|
}
|
|
271
277
|
|
|
272
278
|
export interface MessageRow {
|
|
@@ -496,6 +502,20 @@ export class RelayCore {
|
|
|
496
502
|
return labels
|
|
497
503
|
}
|
|
498
504
|
|
|
505
|
+
/** this host's own agent rows labelled with another machine (its name before a rename, or its hostname) take its machine name */
|
|
506
|
+
relabelMachine(): number {
|
|
507
|
+
const machine = this.namedMachine
|
|
508
|
+
if (machine === null) return 0
|
|
509
|
+
let changed = 0
|
|
510
|
+
for (const row of this.sql.all<EndpointRow>(`SELECT * FROM endpoints WHERE kind = 'agent' AND labels LIKE '%"machine"%'`)) {
|
|
511
|
+
const labels = json<Record<string, string>>(row.labels, {})
|
|
512
|
+
if (labels.machine === undefined || labels.machine === machine || !this.localRow(row)) continue
|
|
513
|
+
this.sql.all(`UPDATE endpoints SET labels = ? WHERE id = ?`, JSON.stringify({ ...labels, machine }), row.id)
|
|
514
|
+
changed++
|
|
515
|
+
}
|
|
516
|
+
return changed
|
|
517
|
+
}
|
|
518
|
+
|
|
499
519
|
migrate(): void {
|
|
500
520
|
for (const statement of MIGRATION) this.sql.all(statement)
|
|
501
521
|
const columns = this.sql.all<{ name: string }>(`PRAGMA table_info(idempotency)`).map((c) => c.name)
|
|
@@ -515,6 +535,7 @@ export class RelayCore {
|
|
|
515
535
|
for (const [column, type] of [
|
|
516
536
|
["tree", "TEXT"],
|
|
517
537
|
["parent_ep", "TEXT"],
|
|
538
|
+
["parent_addr", "TEXT"],
|
|
518
539
|
["depth", "INTEGER"],
|
|
519
540
|
["rules", "TEXT"],
|
|
520
541
|
["caps", "TEXT"],
|
|
@@ -535,6 +556,8 @@ export class RelayCore {
|
|
|
535
556
|
["named", "INTEGER"],
|
|
536
557
|
["linked", "INTEGER"],
|
|
537
558
|
["wake", "TEXT"],
|
|
559
|
+
["wakers", "INTEGER NOT NULL DEFAULT 0"],
|
|
560
|
+
["waker_left", "INTEGER"],
|
|
538
561
|
] as const) {
|
|
539
562
|
if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`)
|
|
540
563
|
}
|
|
@@ -834,7 +857,7 @@ export class RelayCore {
|
|
|
834
857
|
if (row && presenceOf(row, now) === "offline") {
|
|
835
858
|
this.sql.all(`UPDATE endpoints SET session = NULL WHERE id = ?`, row.id)
|
|
836
859
|
row = undefined
|
|
837
|
-
} else if (row && !transient && !sameProcess(row, fields.process)) {
|
|
860
|
+
} else if (row && !transient && !sameProcess(row, fields.process) && !(row.handle === wanted && reclaims(row, fields.process))) {
|
|
838
861
|
row = undefined
|
|
839
862
|
session = null
|
|
840
863
|
} else if (
|
|
@@ -884,8 +907,7 @@ export class RelayCore {
|
|
|
884
907
|
created = true
|
|
885
908
|
} else if (
|
|
886
909
|
held.kind === "agent" &&
|
|
887
|
-
held.connections === 0 &&
|
|
888
|
-
presenceOf(held, now) === "offline" &&
|
|
910
|
+
((held.connections === 0 && presenceOf(held, now) === "offline") || (!transient && this.outlived(held))) &&
|
|
889
911
|
harnessFits(held.harness, harness) &&
|
|
890
912
|
held.id !== scope.subagent?.root &&
|
|
891
913
|
!(scope.subagent && held.session?.startsWith("sub:") && held.session !== fields.session)
|
|
@@ -1124,6 +1146,12 @@ export class RelayCore {
|
|
|
1124
1146
|
* whether a claim placed under `parent` restarts `row`: it lands where `row` sits (under `row`'s parent, or a root with no placement), or on
|
|
1125
1147
|
* `row` itself from `row`'s own process; never under `row` or elsewhere in its tree
|
|
1126
1148
|
*/
|
|
1149
|
+
/** a local row held only by connections whose harness process exited, as a shim that outlived its harness leaves it */
|
|
1150
|
+
private outlived(row: EndpointRow): boolean {
|
|
1151
|
+
const pid = Number(/^pid:(\d+):/.exec(row.process ?? "")?.[1])
|
|
1152
|
+
return row.connections > 0 && pid > 0 && this.localRow(row) && !this.alive(pid)
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1127
1155
|
private restarts(row: EndpointRow, parent: EndpointRow | null, process: string | null): boolean {
|
|
1128
1156
|
if (parent?.id === row.id) return process !== null && row.process !== null && sameHarness(row.process, process)
|
|
1129
1157
|
return (parent?.id ?? null) === row.parent_ep || (parent === null && row.linked !== null)
|
|
@@ -1178,7 +1206,7 @@ export class RelayCore {
|
|
|
1178
1206
|
}
|
|
1179
1207
|
caps.caps.push(...this.capsOf(row).caps)
|
|
1180
1208
|
this.sql.all(
|
|
1181
|
-
`UPDATE endpoints SET tree = ?, parent_ep = ?, depth = ?, rules = ?, caps = ? WHERE id = ?`,
|
|
1209
|
+
`UPDATE endpoints SET tree = ?, parent_ep = ?, parent_addr = NULL, depth = ?, rules = ?, caps = ? WHERE id = ?`,
|
|
1182
1210
|
parent ? (parent.tree ?? parent.id) : null,
|
|
1183
1211
|
parent?.id ?? null,
|
|
1184
1212
|
depth,
|
|
@@ -1305,7 +1333,7 @@ export class RelayCore {
|
|
|
1305
1333
|
endpoint,
|
|
1306
1334
|
)
|
|
1307
1335
|
const row = this.mustEndpoint(endpoint)
|
|
1308
|
-
if (!options.settle ||
|
|
1336
|
+
if (!options.settle || shownPresence(row, now) !== "offline" || (row.status_state !== "working" && row.status_state !== "waiting")) return false
|
|
1309
1337
|
this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, endpoint)
|
|
1310
1338
|
return true
|
|
1311
1339
|
}
|
|
@@ -1322,6 +1350,7 @@ export class RelayCore {
|
|
|
1322
1350
|
`UPDATE endpoints SET connections = 0, hook_seen = CASE WHEN kind = 'agent' AND hook_ended IS NULL THEN MAX(COALESCE(hook_seen, 0), ?) ELSE hook_seen END WHERE connections > 0`,
|
|
1323
1351
|
this.now() - (LIMITS.hookIdleS - LIMITS.restartGraceS) * 1000,
|
|
1324
1352
|
)
|
|
1353
|
+
this.sql.all(`UPDATE endpoints SET wakers = 0, waker_left = ? WHERE wakers > 0`, this.now())
|
|
1325
1354
|
}
|
|
1326
1355
|
|
|
1327
1356
|
whoami(endpoint: string): EndpointView {
|
|
@@ -1351,7 +1380,7 @@ export class RelayCore {
|
|
|
1351
1380
|
const shown = viewer ? new Set(this.visibleRows(viewer).map((row) => row.id)) : undefined
|
|
1352
1381
|
const peeker = viewer ?? (filters.peekable === true ? this.byHandle(HUMAN_HANDLE)! : undefined)
|
|
1353
1382
|
return this.listed()
|
|
1354
|
-
.filter((row) => filters.all || row.connections > 0 || row.kind === "human" || (row.last_seen ?? 0) >= cutoff)
|
|
1383
|
+
.filter((row) => filters.all || row.connections > 0 || row.kind === "human" || (row.last_seen ?? 0) >= cutoff || waking(row, now))
|
|
1355
1384
|
.filter((row) => !filters.harness || row.harness === filters.harness)
|
|
1356
1385
|
.filter((row) => !filters.kind || row.kind === filters.kind)
|
|
1357
1386
|
.filter((row) => !viewer || row.id === viewer.id || this.mayMessage(viewer, row, viewer.kind === "human"))
|
|
@@ -1468,6 +1497,29 @@ export class RelayCore {
|
|
|
1468
1497
|
return true
|
|
1469
1498
|
}
|
|
1470
1499
|
|
|
1500
|
+
/**
|
|
1501
|
+
* a connection that set the endpoint's wake starts (`live`) or stops counting as its waker. A hook's (`grace`) still counts for
|
|
1502
|
+
* LIMITS.restartGraceS after it closes, while its harness starts the next one
|
|
1503
|
+
*/
|
|
1504
|
+
waker(endpoint: string, live: boolean, options: { grace?: boolean } = {}): void {
|
|
1505
|
+
if (live) this.sql.all(`UPDATE endpoints SET wakers = wakers + 1 WHERE id = ?`, endpoint)
|
|
1506
|
+
else {
|
|
1507
|
+
const now = this.now()
|
|
1508
|
+
this.sql.all(
|
|
1509
|
+
`UPDATE endpoints SET wakers = MAX(wakers - 1, 0), waker_left = CASE WHEN ? THEN ? ELSE waker_left END WHERE id = ?`,
|
|
1510
|
+
options.grace ? 1 : 0,
|
|
1511
|
+
now,
|
|
1512
|
+
endpoint,
|
|
1513
|
+
)
|
|
1514
|
+
if (!options.grace) this.lapseWake(endpoint, now)
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/** clears a wake no waker holds once a hook waker's grace ran out too: nothing is left to wake the endpoint */
|
|
1519
|
+
private lapseWake(endpoint: string, now: number): void {
|
|
1520
|
+
this.sql.all(`UPDATE endpoints SET wake = NULL WHERE id = ? AND wakers = 0 AND COALESCE(waker_left, 0) < ?`, endpoint, now - LIMITS.restartGraceS * 1000)
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1471
1523
|
/**
|
|
1472
1524
|
* a title for the caller's in-process subagent `id` from the description its parent gave it; the subagent's endpoint id when it changed one.
|
|
1473
1525
|
* It fills only a title nobody set, as an inferred one, and waits for the subagent's claim when it has no endpoint yet
|
|
@@ -1579,7 +1631,8 @@ export class RelayCore {
|
|
|
1579
1631
|
const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
|
|
1580
1632
|
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself")
|
|
1581
1633
|
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be unlinked")
|
|
1582
|
-
if (target.parent_ep === null
|
|
1634
|
+
if (target.parent_ep === null && target.parent_addr === null)
|
|
1635
|
+
throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`)
|
|
1583
1636
|
if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
|
|
1584
1637
|
throw this.scopeRefused(
|
|
1585
1638
|
actor,
|
|
@@ -1587,6 +1640,14 @@ export class RelayCore {
|
|
|
1587
1640
|
{ target: this.address(target), reason: "unlink" },
|
|
1588
1641
|
"unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)",
|
|
1589
1642
|
)
|
|
1643
|
+
if (target.parent_ep === null) {
|
|
1644
|
+
const from = this.linkedParent(target) ?? target.parent_addr!
|
|
1645
|
+
this.sql.all(`UPDATE endpoints SET parent_addr = NULL, linked = NULL WHERE id = ?`, target.id)
|
|
1646
|
+
this.scopeMemo?.ancestors.clear()
|
|
1647
|
+
this.scopeMemo?.limited.clear()
|
|
1648
|
+
this.audit("unlink", target.id, null, { ...this.actorDetail(actor, me), target: this.address(target), parent: from })
|
|
1649
|
+
return { endpoint: this.whoami(target.id), parent: from }
|
|
1650
|
+
}
|
|
1590
1651
|
const parent = this.byId(target.parent_ep)
|
|
1591
1652
|
const from = parent ? this.address(parent) : target.parent_ep
|
|
1592
1653
|
const shift = target.depth ?? 0
|
|
@@ -1611,16 +1672,26 @@ export class RelayCore {
|
|
|
1611
1672
|
})
|
|
1612
1673
|
}
|
|
1613
1674
|
|
|
1614
|
-
/**
|
|
1675
|
+
/**
|
|
1676
|
+
* `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants; a parent on another
|
|
1677
|
+
* machine goes to {@link linkAcross}
|
|
1678
|
+
*/
|
|
1615
1679
|
private link(row: EndpointRow, parent: EndpointRow, by: { by: string; auth: string }): void {
|
|
1616
|
-
if (
|
|
1680
|
+
if (!this.localRow(parent)) {
|
|
1681
|
+
const key = this.linkAcross(row, parent)
|
|
1682
|
+
if (row.parent_addr === key) return
|
|
1683
|
+
if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row)
|
|
1684
|
+
this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, key, this.now(), row.id)
|
|
1685
|
+
this.scopeMemo?.ancestors.clear()
|
|
1686
|
+
this.scopeMemo?.limited.clear()
|
|
1687
|
+
this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) })
|
|
1688
|
+
return
|
|
1689
|
+
}
|
|
1690
|
+
if (parent.kind !== "agent") throw new RelayError("invalid_params", "the parent is an agent endpoint")
|
|
1617
1691
|
if (row.parent_ep === parent.id) return
|
|
1618
1692
|
if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
|
|
1619
1693
|
throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`)
|
|
1620
|
-
if (row.parent_ep !== null)
|
|
1621
|
-
const now = this.byId(row.parent_ep)
|
|
1622
|
-
throw new RelayError("scope_refused", `${this.address(row)} already sits under ${now ? this.address(now) : "another agent"}; unlink it first`)
|
|
1623
|
-
}
|
|
1694
|
+
if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row)
|
|
1624
1695
|
const depth = (parent.depth ?? 0) + 1
|
|
1625
1696
|
const deepest = one<{ d: number | null }>(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0
|
|
1626
1697
|
if (deepest + depth > LIMITS.depthMax)
|
|
@@ -1645,6 +1716,31 @@ export class RelayCore {
|
|
|
1645
1716
|
this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) })
|
|
1646
1717
|
}
|
|
1647
1718
|
|
|
1719
|
+
/** a link of `row`, which already sits under a parent, to another one */
|
|
1720
|
+
private alreadyLinked(row: EndpointRow): RelayError {
|
|
1721
|
+
const local = row.parent_ep ? this.byId(row.parent_ep) : undefined
|
|
1722
|
+
const now = local ? this.address(local) : row.parent_addr !== null ? (this.linkedParent(row) ?? row.parent_addr) : "another agent"
|
|
1723
|
+
return new RelayError("scope_refused", `${this.address(row)} already sits under ${now}; unlink it first`)
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/**
|
|
1727
|
+
* the key `<machine>/<handle>` that links `row`, a local agent, under `parent`, an agent on another machine, once the parent is checked; no
|
|
1728
|
+
* inherited or child-default caps come with it. A relay of one machine has no such parent
|
|
1729
|
+
*/
|
|
1730
|
+
protected linkAcross(_row: EndpointRow, _parent: EndpointRow): string {
|
|
1731
|
+
throw new RelayError("invalid_params", "the parent is a local agent endpoint")
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/** whether `a` and `b` are a parent and its child on different machines */
|
|
1735
|
+
protected linkedAcross(_a: EndpointRow, _b: EndpointRow): boolean {
|
|
1736
|
+
return false
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/** the address of `row`'s parent on another machine, null when it has none; with `shown`, null for a parent outside it */
|
|
1740
|
+
protected linkedParent(_row: EndpointRow, _shown?: Set<string>): string | null {
|
|
1741
|
+
return null
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1648
1744
|
/**
|
|
1649
1745
|
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
1650
1746
|
* the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
|
|
@@ -1693,9 +1789,13 @@ export class RelayCore {
|
|
|
1693
1789
|
] as const)
|
|
1694
1790
|
this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a)
|
|
1695
1791
|
}
|
|
1792
|
+
this.renamedHandle(from, handle)
|
|
1696
1793
|
this.audit("rename", row.id, null, { from, to: handle })
|
|
1697
1794
|
}
|
|
1698
1795
|
|
|
1796
|
+
/** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
|
|
1797
|
+
protected renamedHandle(_from: string, _to: string): void {}
|
|
1798
|
+
|
|
1699
1799
|
/**
|
|
1700
1800
|
* a session leaves: it lets go of its session and token and shows offline, its mail kept; a later join takes it back. An agent leaves only
|
|
1701
1801
|
* itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
|
|
@@ -1787,7 +1887,7 @@ export class RelayCore {
|
|
|
1787
1887
|
const dark = recipients.filter(
|
|
1788
1888
|
(r) =>
|
|
1789
1889
|
r.kind === "agent" &&
|
|
1790
|
-
|
|
1890
|
+
shownPresence(r, now) === "offline" &&
|
|
1791
1891
|
(now - Math.max(r.last_seen ?? 0, r.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS || (r.session === null && r.token_hash === null && r.hook_ended !== null)) &&
|
|
1792
1892
|
!this.unjoinedSpawn(sender, r.handle, now),
|
|
1793
1893
|
)
|
|
@@ -2047,7 +2147,7 @@ export class RelayCore {
|
|
|
2047
2147
|
private reach(sender: EndpointRow, recipient: EndpointRow, offline: boolean, now: number): Reach | undefined {
|
|
2048
2148
|
if (recipient.kind !== "agent" || !this.localRow(recipient)) return undefined
|
|
2049
2149
|
const done = recipient.status_state === "done"
|
|
2050
|
-
if ((done ||
|
|
2150
|
+
if ((done || shownPresence(recipient, now) === "offline") && this.unjoinedSpawn(sender, recipient.handle, now)) return "starting"
|
|
2051
2151
|
if (done) return "ended"
|
|
2052
2152
|
return offline ? "offline" : "running"
|
|
2053
2153
|
}
|
|
@@ -2500,7 +2600,7 @@ export class RelayCore {
|
|
|
2500
2600
|
continue
|
|
2501
2601
|
}
|
|
2502
2602
|
const row = this.byId(grant.used_by)
|
|
2503
|
-
if (!row || row.status_state === "done" || (
|
|
2603
|
+
if (!row || row.status_state === "done" || (shownPresence(row, now) === "offline" && (grant.handback !== null || !pid || !this.alive(pid)))) continue
|
|
2504
2604
|
workers.set(row.id, row)
|
|
2505
2605
|
if (grant.pushed_at !== null && grant.pushed_at > now - LIMITS.pushGapS * 1000) paced.add(row.id)
|
|
2506
2606
|
}
|
|
@@ -2902,10 +3002,10 @@ export class RelayCore {
|
|
|
2902
3002
|
.filter((row) =>
|
|
2903
3003
|
present
|
|
2904
3004
|
? present.includes(row.id)
|
|
2905
|
-
:
|
|
3005
|
+
: shownPresence(row, now) !== "offline" || Math.max(row.last_seen ?? 0, row.hook_seen ?? 0, row.hook_ended ?? 0) >= asked.created_at,
|
|
2906
3006
|
)
|
|
2907
3007
|
const gone = (row: EndpointRow): boolean =>
|
|
2908
|
-
|
|
3008
|
+
shownPresence(row, now) === "offline" &&
|
|
2909
3009
|
((row.hook_ended ?? 0) >= asked.created_at || row.status_state === "done" || now - Math.max(row.last_seen ?? 0, row.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS)
|
|
2910
3010
|
if (!rows.length || !rows.every(gone)) return null
|
|
2911
3011
|
return rows.map((row) => ({ address: this.address(row), state: row.status_state as StatusState | null }))
|
|
@@ -3003,15 +3103,24 @@ export class RelayCore {
|
|
|
3003
3103
|
}
|
|
3004
3104
|
}
|
|
3005
3105
|
|
|
3006
|
-
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
|
|
3106
|
+
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would, and lapses wakes whose hook waker never came back */
|
|
3007
3107
|
endExited(now = this.now()): void {
|
|
3008
3108
|
for (const row of this.sql.all<EndpointRow>(
|
|
3009
3109
|
`SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`,
|
|
3010
3110
|
)) {
|
|
3011
3111
|
const pid = Number(/^pid:(\d+)/.exec(row.process!)?.[1])
|
|
3012
3112
|
if (!pid || !this.localRow(row) || this.alive(pid)) continue
|
|
3013
|
-
this.sql.all(
|
|
3113
|
+
this.sql.all(
|
|
3114
|
+
`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = NULL, wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ? AND connections = 0`,
|
|
3115
|
+
now,
|
|
3116
|
+
row.id,
|
|
3117
|
+
)
|
|
3014
3118
|
}
|
|
3119
|
+
for (const row of this.sql.all<EndpointRow>(
|
|
3120
|
+
`SELECT * FROM endpoints WHERE wake IS NOT NULL AND wakers = 0 AND waker_left < ?`,
|
|
3121
|
+
now - LIMITS.restartGraceS * 1000,
|
|
3122
|
+
))
|
|
3123
|
+
if (this.localRow(row)) this.lapseWake(row.id, now)
|
|
3015
3124
|
}
|
|
3016
3125
|
|
|
3017
3126
|
/** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
|
|
@@ -3019,7 +3128,7 @@ export class RelayCore {
|
|
|
3019
3128
|
for (const row of this.sql.all<EndpointRow>(
|
|
3020
3129
|
`SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND status_state IN ('working', 'waiting')`,
|
|
3021
3130
|
)) {
|
|
3022
|
-
if (this.localRow(row) &&
|
|
3131
|
+
if (this.localRow(row) && shownPresence(row, now) === "offline")
|
|
3023
3132
|
this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, row.id)
|
|
3024
3133
|
}
|
|
3025
3134
|
}
|
|
@@ -3068,7 +3177,8 @@ export class RelayCore {
|
|
|
3068
3177
|
if (params.kind === "session_end") {
|
|
3069
3178
|
if (own)
|
|
3070
3179
|
this.sql.all(
|
|
3071
|
-
`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END
|
|
3180
|
+
`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
|
|
3181
|
+
wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
|
|
3072
3182
|
now,
|
|
3073
3183
|
endpoint,
|
|
3074
3184
|
)
|
|
@@ -3780,7 +3890,8 @@ export class RelayCore {
|
|
|
3780
3890
|
}
|
|
3781
3891
|
const note = [`exited ${p.code}`, spender.status_note].filter(Boolean).join("; ").slice(0, LIMITS.noteChars)
|
|
3782
3892
|
this.sql.all(
|
|
3783
|
-
`UPDATE endpoints SET status_state = 'done', status_note = ?, hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END
|
|
3893
|
+
`UPDATE endpoints SET status_state = 'done', status_note = ?, hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
|
|
3894
|
+
wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
|
|
3784
3895
|
note,
|
|
3785
3896
|
now,
|
|
3786
3897
|
spender.id,
|
|
@@ -4269,6 +4380,7 @@ export class RelayCore {
|
|
|
4269
4380
|
return scopeDecision(world, world.node(a.id) ?? this.scopeNode(a), world.node(b.id) ?? this.scopeNode(b), {
|
|
4270
4381
|
humanAlways: this.defaults().humanAlways,
|
|
4271
4382
|
solicited,
|
|
4383
|
+
linked: a.id !== b.id && this.linkedAcross(a, b),
|
|
4272
4384
|
unverified,
|
|
4273
4385
|
strict,
|
|
4274
4386
|
})
|
|
@@ -5079,7 +5191,7 @@ export class RelayCore {
|
|
|
5079
5191
|
|
|
5080
5192
|
/** with `shown`, the ids a viewer sees: a parent outside it is null */
|
|
5081
5193
|
protected view(row: EndpointRow, shown?: Set<string>): EndpointView {
|
|
5082
|
-
const presence =
|
|
5194
|
+
const presence = shownPresence(row, this.now())
|
|
5083
5195
|
return {
|
|
5084
5196
|
id: row.id,
|
|
5085
5197
|
address: this.address(row),
|
|
@@ -5098,7 +5210,8 @@ export class RelayCore {
|
|
|
5098
5210
|
|
|
5099
5211
|
private placeView(row: EndpointRow, shown?: Set<string>): Pick<EndpointView, "parent" | "depth" | "scope"> {
|
|
5100
5212
|
const parent = row.parent_ep && (!shown || shown.has(row.parent_ep)) ? this.byId(row.parent_ep) : undefined
|
|
5101
|
-
|
|
5213
|
+
const across = !parent && row.parent_ep === null && row.parent_addr ? this.linkedParent(row, shown) : null
|
|
5214
|
+
return { parent: parent ? this.address(parent) : across, depth: row.depth ?? 0, scope: presetOf(this.scopeNode(row), this.defaults().symmetric) }
|
|
5102
5215
|
}
|
|
5103
5216
|
|
|
5104
5217
|
/** `recipient` names the delivery's endpoint when there is no `seq`: a send to several addresses shows each recipient only itself */
|
|
@@ -5315,6 +5428,11 @@ const ANY_PROCESS_KEY = /^pid:\d+(:[A-Za-z0-9:-]{1,64})?$/
|
|
|
5315
5428
|
* whether a connecting claim from the harness process `key` may join a session endpoint: no connection holds it, the harness process that last
|
|
5316
5429
|
* claimed it claims again, and, once a connection held it (a token was handed out), with the same full key, which a recycled pid cannot match
|
|
5317
5430
|
*/
|
|
5431
|
+
/** a claim from the very harness process that holds the live `row`, such as a session it restarted in process */
|
|
5432
|
+
function reclaims(row: EndpointRow, key: string | null): boolean {
|
|
5433
|
+
return row.connections > 0 && key !== null && row.process === key && PROCESS_KEY.test(key)
|
|
5434
|
+
}
|
|
5435
|
+
|
|
5318
5436
|
function sameProcess(row: EndpointRow, key: string | null): boolean {
|
|
5319
5437
|
if (row.connections > 0 || key === null || row.process === null) return false
|
|
5320
5438
|
return row.token_hash === null ? sameHarness(row.process, key) : row.process === key && PROCESS_KEY.test(key)
|
|
@@ -5379,6 +5497,17 @@ function presenceOf(row: EndpointRow, now: number): Presence {
|
|
|
5379
5497
|
return row.hook_seen >= now - LIMITS.hookIdleS * 1000 ? "idle" : "offline"
|
|
5380
5498
|
}
|
|
5381
5499
|
|
|
5500
|
+
/** a wake with a live waker, or a hook waker that left within LIMITS.restartGraceS */
|
|
5501
|
+
function waking(row: EndpointRow, now: number): boolean {
|
|
5502
|
+
return wakeOf(row) !== null && (row.wakers > 0 || (row.waker_left ?? 0) >= now - LIMITS.restartGraceS * 1000)
|
|
5503
|
+
}
|
|
5504
|
+
|
|
5505
|
+
/** the presence an endpoint shows: one its waker can wake is at least idle, though its session's own presence decayed */
|
|
5506
|
+
function shownPresence(row: EndpointRow, now: number): Presence {
|
|
5507
|
+
const presence = presenceOf(row, now)
|
|
5508
|
+
return presence === "offline" && waking(row, now) ? "idle" : presence
|
|
5509
|
+
}
|
|
5510
|
+
|
|
5382
5511
|
function pairKey(a: string, b: string): string {
|
|
5383
5512
|
return a < b ? `${a}|${b}` : `${b}|${a}`
|
|
5384
5513
|
}
|
package/src/scopes.ts
CHANGED
|
@@ -293,19 +293,20 @@ export function relationText(peer: PolicySubject, me: PolicySubject): string {
|
|
|
293
293
|
/**
|
|
294
294
|
* The scope check for a message from `a` to `b`: `out(a)` admits `b` and `in(b)` admits `a`. Either side the account's human (with `humanAlways`)
|
|
295
295
|
* or the system endpoint passes, except an `unverified` human sender (one without a terminal), which is checked like any unplaced sender; an
|
|
296
|
-
* ancestor of `b` (unless `strict`)
|
|
296
|
+
* ancestor of `b` (unless `strict`), a solicited reply and a parent and child `linked` across machines pass both.
|
|
297
297
|
*/
|
|
298
298
|
export function scopeDecision(
|
|
299
299
|
world: ScopeWorld,
|
|
300
300
|
a: ScopeNode,
|
|
301
301
|
b: ScopeNode,
|
|
302
|
-
options: { humanAlways: boolean; solicited?: boolean; unverified?: boolean; strict?: boolean },
|
|
302
|
+
options: { humanAlways: boolean; solicited?: boolean; linked?: boolean; unverified?: boolean; strict?: boolean },
|
|
303
303
|
): ScopeDecision {
|
|
304
304
|
const kinds = [a.subject.kind, b.subject.kind]
|
|
305
305
|
if (kinds.includes("system")) return { allowed: true, reasons: ["the relay itself"] }
|
|
306
306
|
if (options.humanAlways && (b.human || (a.human && !options.unverified))) return { allowed: true, reasons: ["the human is always reachable"] }
|
|
307
307
|
if (!options.strict && isAncestor(a, b)) return { allowed: true, reasons: [`${a.address} is an ancestor of ${b.address}: scopes never stop it`] }
|
|
308
308
|
if (options.solicited) return { allowed: true, reasons: [`a reply to ${b.address}'s message in its thread`] }
|
|
309
|
+
if (options.linked) return { allowed: true, reasons: [`${a.address} and ${b.address} are parent and child across machines`] }
|
|
309
310
|
const memo: Memo = new Map()
|
|
310
311
|
const out = admits(world, a, "out", b, memo)
|
|
311
312
|
if (!out.ok) return { allowed: false, dir: "out", reasons: [out.why] }
|