@scp-dev/core 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/dist/index.d.ts +138 -11
- package/dist/index.js +323 -37
- package/package.json +1 -1
- package/src/core.ts +330 -35
- package/src/handback.ts +7 -4
- package/src/index.ts +3 -0
- package/src/policy.ts +7 -1
- package/src/protocol.ts +29 -0
- package/src/render.ts +34 -6
- package/src/sql.ts +1 -1
package/src/core.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
LIMITS,
|
|
14
14
|
MESSAGE_KINDS,
|
|
15
15
|
type MessageKind,
|
|
16
|
+
type MessageReceipts,
|
|
16
17
|
type PeekLevel,
|
|
17
18
|
type PeekResult,
|
|
18
19
|
type PeekSeen,
|
|
@@ -42,7 +43,7 @@ import {
|
|
|
42
43
|
type WhyResult,
|
|
43
44
|
} from "./protocol"
|
|
44
45
|
import { digestText, PEEK, peekLevel, readText } from "./peek"
|
|
45
|
-
import { shortId, stripControl, wrapMessage } from "./render"
|
|
46
|
+
import { clock, shortId, stripControl, wrapMessage } from "./render"
|
|
46
47
|
import {
|
|
47
48
|
clipped,
|
|
48
49
|
expandRules,
|
|
@@ -68,6 +69,8 @@ import { one, type Sql, type SqlValue } from "./sql"
|
|
|
68
69
|
import { utf8Bytes } from "./utf8"
|
|
69
70
|
|
|
70
71
|
export const HUMAN_HANDLE = "_human"
|
|
72
|
+
/** an agent title's length cap */
|
|
73
|
+
export const TITLE_CHARS = 64
|
|
71
74
|
export const SYSTEM_HANDLE = "_relay"
|
|
72
75
|
export const GONE_PREFIX = "_gone_"
|
|
73
76
|
|
|
@@ -125,7 +128,9 @@ const MIGRATION = [
|
|
|
125
128
|
offered_until INTEGER,
|
|
126
129
|
offered_hold TEXT,
|
|
127
130
|
injected_at INTEGER,
|
|
128
|
-
nudged_at INTEGER
|
|
131
|
+
nudged_at INTEGER,
|
|
132
|
+
deferred_at INTEGER,
|
|
133
|
+
deferred_ack INTEGER
|
|
129
134
|
)`,
|
|
130
135
|
`CREATE INDEX IF NOT EXISTS deliveries_inbox ON deliveries (endpoint, acked_at, seq)`,
|
|
131
136
|
`CREATE INDEX IF NOT EXISTS deliveries_message ON deliveries (message_id)`,
|
|
@@ -237,12 +242,20 @@ export interface EndpointRow {
|
|
|
237
242
|
/** JSON {@link ScopeCaps} */
|
|
238
243
|
caps: string | null
|
|
239
244
|
title: string | null
|
|
245
|
+
/** the title a hook last inferred: the title is inferred while it equals this */
|
|
246
|
+
title_auto: string | null
|
|
247
|
+
/** the prompt count when a summary last set the inferred title; null when the first prompt set it */
|
|
248
|
+
title_turn: number | null
|
|
249
|
+
/** the prompt count when the status note last changed or a hook last reminded the agent to refresh it */
|
|
250
|
+
tldr_turn: number | null
|
|
240
251
|
/** when a new spawn last took the endpoint over: mail and messages from before it were the earlier holder's */
|
|
241
252
|
reset_at: number | null
|
|
242
253
|
/** when a new spawn last took the endpoint over: with its id, names the holder another machine sees */
|
|
243
254
|
spawned_at: number | null
|
|
244
255
|
/** who may peek: null is off */
|
|
245
256
|
peek: PeekLevel | null
|
|
257
|
+
/** when the session last set its handle itself (join or rename): a later claim with another handle does not rename it back */
|
|
258
|
+
named: number | null
|
|
246
259
|
}
|
|
247
260
|
|
|
248
261
|
export interface MessageRow {
|
|
@@ -269,7 +282,7 @@ export interface CoreOptions {
|
|
|
269
282
|
now?: () => number
|
|
270
283
|
/** a synchronous hash for resume tokens (sha256 hex) */
|
|
271
284
|
hash: (value: string) => string
|
|
272
|
-
/** runs `fn` atomically:
|
|
285
|
+
/** runs `fn` atomically: a local SQLite transaction, a Durable Object's transactionSync */
|
|
273
286
|
transaction?: <T>(fn: () => T) => T
|
|
274
287
|
/** count every sender but the relay itself, humans included, against LIMITS.userPerDay, and trust no client's terminal claim for a hop reset (default: agents and non-terminal humans count) */
|
|
275
288
|
capEverySender?: boolean
|
|
@@ -439,6 +452,8 @@ export class RelayCore {
|
|
|
439
452
|
private readonly capEverySender: boolean
|
|
440
453
|
private readonly alive: (pid: number) => boolean
|
|
441
454
|
private readonly hostMachine: string | null
|
|
455
|
+
private readonly namedMachine: string | null
|
|
456
|
+
private readonly hostname: string | null
|
|
442
457
|
private policy: Policy | null = null
|
|
443
458
|
/** the identity each policy `group:<name>` naming no policy group bound to when the policy's text first loaded: that group's then, or `none` */
|
|
444
459
|
private policyBinds = new Map<string, string>()
|
|
@@ -458,6 +473,14 @@ export class RelayCore {
|
|
|
458
473
|
this.capEverySender = options.capEverySender === true
|
|
459
474
|
this.alive = options.alive ?? (() => true)
|
|
460
475
|
this.hostMachine = options.machine ?? options.hostname ?? null
|
|
476
|
+
this.namedMachine = options.machine ?? null
|
|
477
|
+
this.hostname = options.hostname ?? null
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** a `machine` label naming this host by its hostname, as clients default it, names it by its machine name when one is set */
|
|
481
|
+
private machineLabel(labels: Record<string, string>): Record<string, string> {
|
|
482
|
+
if (this.namedMachine !== null && labels.machine !== undefined && labels.machine === this.hostname) labels.machine = this.namedMachine
|
|
483
|
+
return labels
|
|
461
484
|
}
|
|
462
485
|
|
|
463
486
|
migrate(): void {
|
|
@@ -493,6 +516,10 @@ export class RelayCore {
|
|
|
493
516
|
["directive_holds", "INTEGER"],
|
|
494
517
|
["harness_session", "TEXT"],
|
|
495
518
|
["harness_transcript", "TEXT"],
|
|
519
|
+
["title_auto", "TEXT"],
|
|
520
|
+
["title_turn", "INTEGER"],
|
|
521
|
+
["tldr_turn", "INTEGER"],
|
|
522
|
+
["named", "INTEGER"],
|
|
496
523
|
] as const) {
|
|
497
524
|
if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`)
|
|
498
525
|
}
|
|
@@ -521,6 +548,8 @@ export class RelayCore {
|
|
|
521
548
|
if (!deliveryColumns.includes("injected_at")) this.sql.all(`ALTER TABLE deliveries ADD COLUMN injected_at INTEGER`)
|
|
522
549
|
if (!deliveryColumns.includes("nudged_at")) this.sql.all(`ALTER TABLE deliveries ADD COLUMN nudged_at INTEGER`)
|
|
523
550
|
if (!deliveryColumns.includes("restated_at")) this.sql.all(`ALTER TABLE deliveries ADD COLUMN restated_at INTEGER`)
|
|
551
|
+
if (!deliveryColumns.includes("deferred_at")) this.sql.all(`ALTER TABLE deliveries ADD COLUMN deferred_at INTEGER`)
|
|
552
|
+
if (!deliveryColumns.includes("deferred_ack")) this.sql.all(`ALTER TABLE deliveries ADD COLUMN deferred_ack INTEGER`)
|
|
524
553
|
const activityColumns = this.sql.all<{ name: string }>(`PRAGMA table_info(activity)`).map((c) => c.name)
|
|
525
554
|
if (!activityColumns.includes("agent")) this.sql.all(`ALTER TABLE activity ADD COLUMN agent TEXT`)
|
|
526
555
|
if (!this.byHandle(SYSTEM_HANDLE)) {
|
|
@@ -653,7 +682,7 @@ export class RelayCore {
|
|
|
653
682
|
model: cleanText(claim.model, "model", 128),
|
|
654
683
|
cwd: cleanText(claim.cwd, "cwd", 1024),
|
|
655
684
|
parent: cleanText(claim.parent, "parent", 128),
|
|
656
|
-
labels: JSON.stringify(cleanLabels(claim.labels)),
|
|
685
|
+
labels: JSON.stringify(this.machineLabel(cleanLabels(claim.labels))),
|
|
657
686
|
session: cleanText(claim.session, "session", 128),
|
|
658
687
|
process: cleanText(claim.process, "process", 128),
|
|
659
688
|
}
|
|
@@ -768,6 +797,7 @@ export class RelayCore {
|
|
|
768
797
|
scope: ClaimScope = { handle: wanted, lineage: [], grant: null, rules: {}, groups: [], cwd: fields.cwd },
|
|
769
798
|
): { endpoint: EndpointView; token?: string; created?: true } {
|
|
770
799
|
const now = this.now()
|
|
800
|
+
this.endExited(now)
|
|
771
801
|
const bare = transient && fields.session !== null && BARE_PID_KEY.test(fields.session)
|
|
772
802
|
let row = fields.session
|
|
773
803
|
? one<EndpointRow>(
|
|
@@ -793,7 +823,14 @@ export class RelayCore {
|
|
|
793
823
|
} else if (row && !transient && !sameProcess(row, fields.process)) {
|
|
794
824
|
row = undefined
|
|
795
825
|
session = null
|
|
796
|
-
} else if (
|
|
826
|
+
} else if (
|
|
827
|
+
row &&
|
|
828
|
+
!transient &&
|
|
829
|
+
row.token_hash === null &&
|
|
830
|
+
row.named === null &&
|
|
831
|
+
row.handle !== wanted &&
|
|
832
|
+
!row.handle.startsWith(`${wanted.slice(0, 58)}-`)
|
|
833
|
+
) {
|
|
797
834
|
const hooks = row
|
|
798
835
|
let settled = false
|
|
799
836
|
for (let n = 1; n < (scope.exact ? 2 : 1000) && !settled; n++) {
|
|
@@ -1292,6 +1329,7 @@ export class RelayCore {
|
|
|
1292
1329
|
} = {},
|
|
1293
1330
|
): EndpointView[] {
|
|
1294
1331
|
const now = this.now()
|
|
1332
|
+
this.endExited(now)
|
|
1295
1333
|
this.settleOffline(now)
|
|
1296
1334
|
const cutoff = now - LIMITS.listOfflineWithinS * 1000
|
|
1297
1335
|
const viewer = filters.viewer === undefined ? undefined : this.mustEndpoint(filters.viewer)
|
|
@@ -1359,10 +1397,12 @@ export class RelayCore {
|
|
|
1359
1397
|
const text = oneLine(note, "note", LIMITS.noteChars)
|
|
1360
1398
|
if (text && findSecret(text)) throw this.refuse(endpoint, "secret_refused", "the status note looks like it carries a credential")
|
|
1361
1399
|
this.sql.all(
|
|
1362
|
-
`UPDATE endpoints SET status_state = COALESCE(?, status_state, 'working'), status_note = COALESCE(?, status_note, ''), last_seen =
|
|
1400
|
+
`UPDATE endpoints SET status_state = COALESCE(?, status_state, 'working'), status_note = COALESCE(?, status_note, ''), last_seen = ?,
|
|
1401
|
+
tldr_turn = CASE WHEN ? THEN COALESCE(prompts, 0) ELSE tldr_turn END WHERE id = ?`,
|
|
1363
1402
|
state ?? null,
|
|
1364
1403
|
text,
|
|
1365
1404
|
this.now(),
|
|
1405
|
+
text === null ? 0 : 1,
|
|
1366
1406
|
endpoint,
|
|
1367
1407
|
)
|
|
1368
1408
|
}
|
|
@@ -1373,11 +1413,126 @@ export class RelayCore {
|
|
|
1373
1413
|
if (row.kind !== "agent") throw new RelayError("invalid_params", "only an agent endpoint has a title")
|
|
1374
1414
|
const title = oneLine(value, "title", 64) || null
|
|
1375
1415
|
if (title && findSecret(title)) throw this.refuse(endpoint, "secret_refused", "the title looks like it carries a credential")
|
|
1376
|
-
this.sql.all(`UPDATE endpoints SET title =
|
|
1416
|
+
this.sql.all(`UPDATE endpoints SET title = ?, title_auto = NULL, title_turn = NULL WHERE id = ?`, title, endpoint)
|
|
1377
1417
|
this.audit("title", endpoint, null, { title })
|
|
1378
1418
|
return this.whoami(endpoint)
|
|
1379
1419
|
}
|
|
1380
1420
|
|
|
1421
|
+
/**
|
|
1422
|
+
* a hook's guess at the session's title, from the harness's own summary or the first prompt's first line; whether it changed the title.
|
|
1423
|
+
* Never over a title set explicitly; the first prompt fills only an empty title, and a summary replaces an inferred one at most every
|
|
1424
|
+
* ${LIMITS.titleRefreshTurns} prompts, or at once over the first prompt's
|
|
1425
|
+
*/
|
|
1426
|
+
inferTitle(endpoint: string, params: { text?: unknown; from?: unknown }, options: { transient?: boolean; process?: string } = {}): boolean {
|
|
1427
|
+
if (params.from !== "summary" && params.from !== "prompt") throw new RelayError("invalid_params", "from is summary or prompt")
|
|
1428
|
+
if (typeof params.text !== "string") throw new RelayError("invalid_params", "text must be a string")
|
|
1429
|
+
const row = this.mustEndpoint(endpoint)
|
|
1430
|
+
if (row.kind !== "agent" || !this.holds(row, options)) return false
|
|
1431
|
+
const title = stripControl(redactSecrets(params.text.slice(0, 4096).replace(/\s+/g, " ").trim()))
|
|
1432
|
+
.slice(0, TITLE_CHARS)
|
|
1433
|
+
.trim()
|
|
1434
|
+
if (!title || findSecret(title) || title === row.title) return false
|
|
1435
|
+
if (row.title !== null && row.title !== row.title_auto) return false
|
|
1436
|
+
const turn = row.prompts ?? 0
|
|
1437
|
+
if (params.from === "prompt" && row.title !== null) return false
|
|
1438
|
+
if (params.from === "summary" && row.title !== null && row.title_turn !== null && turn - row.title_turn < LIMITS.titleRefreshTurns) return false
|
|
1439
|
+
this.sql.all(`UPDATE endpoints SET title = ?, title_auto = ?, title_turn = ? WHERE id = ?`, title, title, params.from === "summary" ? turn : null, endpoint)
|
|
1440
|
+
return true
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
|
|
1445
|
+
* when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
|
|
1446
|
+
*/
|
|
1447
|
+
joinSession(claim: Claim, handle?: unknown): EndpointView {
|
|
1448
|
+
return this.scopeTx(() => {
|
|
1449
|
+
const { endpoint } = this.claim(claim, { transient: true })
|
|
1450
|
+
const row = this.mustEndpoint(endpoint.id)
|
|
1451
|
+
if (handle !== undefined && handle !== null && handle !== "") this.renameRow(row, handle, false)
|
|
1452
|
+
this.sql.all(`UPDATE endpoints SET hook_seen = ?, hook_ended = NULL WHERE id = ?`, this.now(), row.id)
|
|
1453
|
+
this.audit("join", row.id, null, { handle: this.byId(row.id)!.handle })
|
|
1454
|
+
return this.whoami(row.id)
|
|
1455
|
+
})
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
1460
|
+
* 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
|
|
1461
|
+
*/
|
|
1462
|
+
rename(actor: ScopeActor, params: { endpoint?: unknown; handle: unknown }): { endpoint: EndpointView; from: string; session: string | null } {
|
|
1463
|
+
return this.scopeTx(() => {
|
|
1464
|
+
const me = this.actorRow(actor)
|
|
1465
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
|
|
1466
|
+
else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself renames it: run this from the session's own shell")
|
|
1467
|
+
const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
|
|
1468
|
+
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent renames only itself")
|
|
1469
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be renamed")
|
|
1470
|
+
this.renameRow(target, params.handle, actor.kind === "human")
|
|
1471
|
+
return { endpoint: this.whoami(target.id), from: target.handle, session: target.session }
|
|
1472
|
+
})
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
|
|
1476
|
+
private renameRow(row: EndpointRow, value: unknown, byHuman: boolean): void {
|
|
1477
|
+
const handle = typeof value === "string" ? value.trim().replace(/^me\//, "") : ""
|
|
1478
|
+
if (!validHandle(handle)) throw new RelayError("invalid_params", `handle ${JSON.stringify(handle)} is not valid: lowercase letters, digits, . _ -`)
|
|
1479
|
+
if (findSecret(handle)) throw new RelayError("secret_refused", "the handle looks like it carries a credential")
|
|
1480
|
+
if (handle === row.handle) {
|
|
1481
|
+
this.sql.all(`UPDATE endpoints SET named = COALESCE(named, ?) WHERE id = ?`, this.now(), row.id)
|
|
1482
|
+
return
|
|
1483
|
+
}
|
|
1484
|
+
if (!byHuman && row.parent_ep !== null && this.policy?.defaults?.rename !== "self") {
|
|
1485
|
+
const parent = this.byId(row.parent_ep)
|
|
1486
|
+
throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; its name is set by the spawner`)
|
|
1487
|
+
}
|
|
1488
|
+
const held = this.byHandle(handle)
|
|
1489
|
+
if (held) {
|
|
1490
|
+
const how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint"
|
|
1491
|
+
throw new RelayError("invalid_params", `${handle} is ${how}: pick another name`)
|
|
1492
|
+
}
|
|
1493
|
+
const from = row.handle
|
|
1494
|
+
this.sql.all(`UPDATE endpoints SET handle = ?, named = ? WHERE id = ?`, handle, this.now(), row.id)
|
|
1495
|
+
this.sql.all(`UPDATE grants SET name = ? WHERE used_by = ? AND name = ?`, handle, row.id, from)
|
|
1496
|
+
for (const prefix of row.parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
|
|
1497
|
+
const [a, b] = [JSON.stringify(`${prefix}${from}`), JSON.stringify(`${prefix}${handle}`)]
|
|
1498
|
+
for (const [table, column] of [
|
|
1499
|
+
["endpoints", "rules"],
|
|
1500
|
+
["endpoints", "caps"],
|
|
1501
|
+
["groups", "rules"],
|
|
1502
|
+
["grants", "caps"],
|
|
1503
|
+
] as const)
|
|
1504
|
+
this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a)
|
|
1505
|
+
}
|
|
1506
|
+
this.audit("rename", row.id, null, { from, to: handle })
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* 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
|
|
1511
|
+
* itself, the human at a terminal any local root. A spawned worker stays joined while it runs
|
|
1512
|
+
*/
|
|
1513
|
+
leaveSession(actor: ScopeActor, params: { endpoint?: unknown } = {}): { endpoint: EndpointView; session: string | null } {
|
|
1514
|
+
return this.scopeTx(() => {
|
|
1515
|
+
const me = this.actorRow(actor)
|
|
1516
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
|
|
1517
|
+
else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself leaves: run this from the session's own shell")
|
|
1518
|
+
const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
|
|
1519
|
+
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent leaves only for itself")
|
|
1520
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can leave")
|
|
1521
|
+
if (target.parent_ep !== null) {
|
|
1522
|
+
const parent = this.byId(target.parent_ep)
|
|
1523
|
+
throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; a spawned worker stays joined while it runs`)
|
|
1524
|
+
}
|
|
1525
|
+
this.sql.all(`UPDATE endpoints SET session = NULL, token_hash = NULL, hook_seen = NULL, hook_ended = ? WHERE id = ?`, this.now(), target.id)
|
|
1526
|
+
this.audit("leave", target.id, null, { handle: target.handle })
|
|
1527
|
+
return { endpoint: this.whoami(target.id), session: target.session }
|
|
1528
|
+
})
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/** the harness session key an endpoint holds, if any */
|
|
1532
|
+
sessionOf(endpoint: string): string | null {
|
|
1533
|
+
return this.byId(endpoint)?.session ?? null
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1381
1536
|
// messages
|
|
1382
1537
|
|
|
1383
1538
|
/**
|
|
@@ -1438,6 +1593,17 @@ export class RelayCore {
|
|
|
1438
1593
|
resolved.push(row)
|
|
1439
1594
|
}
|
|
1440
1595
|
const recipients = [...new Map(resolved.map((row) => [row.id, row])).values()]
|
|
1596
|
+
if (params.live !== undefined && typeof params.live !== "boolean") throw new RelayError("invalid_params", "live must be true or false")
|
|
1597
|
+
const dark = recipients.filter(
|
|
1598
|
+
(r) =>
|
|
1599
|
+
r.kind === "agent" &&
|
|
1600
|
+
presenceOf(r, now) === "offline" &&
|
|
1601
|
+
(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)) &&
|
|
1602
|
+
!this.unjoinedSpawn(sender, r.handle, now),
|
|
1603
|
+
)
|
|
1604
|
+
if (params.live === true && dark.length)
|
|
1605
|
+
throw new RelayError("not_live", `no live session holds ${dark.map((r) => this.address(r)).join(", ")}: nothing was queued`)
|
|
1606
|
+
const warnings = dark.map((r) => `queued: no live session holds ${this.address(r)}`)
|
|
1441
1607
|
const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }))
|
|
1442
1608
|
const reached = recipients.length + pending.length
|
|
1443
1609
|
if (reached > LIMITS.fanOutTotal) throw new RelayError("too_large", `the send reaches ${reached} endpoints, more than ${LIMITS.fanOutTotal}`)
|
|
@@ -1667,7 +1833,7 @@ export class RelayCore {
|
|
|
1667
1833
|
deliveries.push({ endpoint: key, address, seq, offline: false, queued: true, last_seen: null, state: "starting" })
|
|
1668
1834
|
}
|
|
1669
1835
|
for (const name of fanOut) this.sql.all(`INSERT INTO tree_events (tree, at) VALUES (?, ?)`, `group:${name}`, now)
|
|
1670
|
-
const sent: SendResult = { id, thread, hop, deliveries }
|
|
1836
|
+
const sent: SendResult = { id, thread, hop, deliveries, ...(warnings.length ? { warnings } : {}) }
|
|
1671
1837
|
if (clientId) {
|
|
1672
1838
|
this.sql.all(
|
|
1673
1839
|
`INSERT OR REPLACE INTO idempotency (from_ep, client_id, payload_hash, result, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
@@ -1749,21 +1915,29 @@ export class RelayCore {
|
|
|
1749
1915
|
}
|
|
1750
1916
|
}
|
|
1751
1917
|
|
|
1752
|
-
/**
|
|
1918
|
+
/**
|
|
1919
|
+
* the oldest unacked deliveries after the cursor, and those a background hook showed in the last ${LIMITS.deferredShowS} s whose showing has
|
|
1920
|
+
* not settled ({@link injected}), with `shown_at`: the harness may hand the agent that hook's text only after this call; reading counts as
|
|
1921
|
+
* receiving for the causal hop
|
|
1922
|
+
*/
|
|
1753
1923
|
inbox(endpoint: string, options: { after?: number | undefined; limit?: number | undefined } = {}): Envelope[] {
|
|
1754
1924
|
const now = this.now()
|
|
1755
1925
|
const limit = Math.min(Math.max(1, Math.floor(options.limit ?? LIMITS.inboxPage)), LIMITS.inboxPage)
|
|
1756
|
-
const rows = this.sql.all<MessageRow & { seq: number }>(
|
|
1757
|
-
`SELECT m.*, d.seq AS seq FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
1758
|
-
WHERE d.endpoint = ? AND d.acked_at IS NULL AND d.expires_at > ? AND d.seq > ?
|
|
1926
|
+
const rows = this.sql.all<MessageRow & { seq: number; deferred_at: number | null; acked_at: number | null }>(
|
|
1927
|
+
`SELECT m.*, d.seq AS seq, d.deferred_at AS deferred_at, d.acked_at AS acked_at FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
1928
|
+
WHERE d.endpoint = ? AND (d.acked_at IS NULL OR d.deferred_at > ?) AND d.expires_at > ? AND d.seq > ?
|
|
1759
1929
|
ORDER BY d.seq LIMIT ?`,
|
|
1760
1930
|
endpoint,
|
|
1931
|
+
now - LIMITS.deferredShowS * 1000,
|
|
1761
1932
|
now,
|
|
1762
1933
|
Math.max(0, Math.floor(options.after ?? 0)),
|
|
1763
1934
|
limit,
|
|
1764
1935
|
)
|
|
1765
1936
|
this.received(endpoint, rows, now)
|
|
1766
|
-
return rows.map((row) =>
|
|
1937
|
+
return rows.map((row) => {
|
|
1938
|
+
const envelope = this.envelope(row, row.seq)
|
|
1939
|
+
return row.acked_at !== null && row.deferred_at !== null ? { ...envelope, shown_at: new Date(row.deferred_at).toISOString() } : envelope
|
|
1940
|
+
})
|
|
1767
1941
|
}
|
|
1768
1942
|
|
|
1769
1943
|
/**
|
|
@@ -1806,10 +1980,11 @@ export class RelayCore {
|
|
|
1806
1980
|
* `directive_seqs`. Any call says `brief` once the session was shown the full preamble ({@link briefed}), and one that is no stop names in
|
|
1807
1981
|
* `intro` the spawner of an endpoint a `swarm spawn` grant placed, with the grant's steer key in `intro_key`, until a hook showed it
|
|
1808
1982
|
* ({@link introduced}). A hook of another process than the one holding the endpoint (a child that inherited the session key) gets nothing.
|
|
1983
|
+
* A wake also offers live workers' messages.
|
|
1809
1984
|
*/
|
|
1810
1985
|
inject(
|
|
1811
1986
|
endpoint: string,
|
|
1812
|
-
options: { stop?: boolean; count?: boolean; holdUnread?: boolean; directives?: boolean; transient?: boolean; process?: string } = {},
|
|
1987
|
+
options: { stop?: boolean; count?: boolean; holdUnread?: boolean; directives?: boolean; transient?: boolean; process?: string; wake?: boolean } = {},
|
|
1813
1988
|
): {
|
|
1814
1989
|
messages: Envelope[]
|
|
1815
1990
|
offered: number[]
|
|
@@ -1899,7 +2074,7 @@ export class RelayCore {
|
|
|
1899
2074
|
now,
|
|
1900
2075
|
LIMITS.inboxPage,
|
|
1901
2076
|
)
|
|
1902
|
-
const { shown, offered } = this.budget(rows, LIMITS.injectBytes, () => this.pushable(me, now))
|
|
2077
|
+
const { shown, offered } = this.budget(rows, LIMITS.injectBytes, options.wake ? undefined : () => this.pushable(me, now))
|
|
1903
2078
|
const hold = this.hold(offered, now)
|
|
1904
2079
|
this.received(endpoint, shown, now)
|
|
1905
2080
|
return { messages: shown.map((row) => this.envelope(row, row.seq)), offered, hold, more: waiting - shown.length, unread, block: false, ...brief, ...intro }
|
|
@@ -1984,13 +2159,15 @@ export class RelayCore {
|
|
|
1984
2159
|
* messages no hook has shown or holds follow theirs ({@link pushOthers}), `others_shown` of them, held with them; `others` counts the other
|
|
1985
2160
|
* unread ones left. A live worker's messages wait ${LIMITS.pushGapS} s after a push showed one of its own ({@link pushed}), and with no
|
|
1986
2161
|
* push left since the last prompt nothing is shown. `prompted` says a prompt came since inject handed out the `prompts` mark. A hook of
|
|
1987
|
-
* another process than the one holding the endpoint gets nothing.
|
|
2162
|
+
* another process than the one holding the endpoint gets nothing. `peek`: the same answer, nothing held (`hold` null) or marked received.
|
|
1988
2163
|
*/
|
|
1989
|
-
stopWait(endpoint: string, options: { transient?: boolean; process?: string; prompts?: number } = {}): StopWaitResult {
|
|
2164
|
+
stopWait(endpoint: string, options: { transient?: boolean; process?: string; prompts?: number; peek?: boolean } = {}): StopWaitResult {
|
|
1990
2165
|
const now = this.now()
|
|
1991
2166
|
const me = this.mustEndpoint(endpoint)
|
|
1992
2167
|
if (!this.holds(me, options)) return { messages: [], offered: [], hold: null, more: 0, workers: [], starting: 0, others: 0, pushes_left: 0 }
|
|
1993
2168
|
const { workers, starting, paced } = this.liveWorkers(me, now)
|
|
2169
|
+
const live = this.liveWorkers(me, now, true)
|
|
2170
|
+
const background = [...live.workers.map((row) => this.address(row)), ...live.unjoined.filter(Boolean).map((name) => `me/${name}`)]
|
|
1994
2171
|
const left = this.pushesLeft(me)
|
|
1995
2172
|
const { where, params } = this.workerMessages(me, now, true, paced)
|
|
1996
2173
|
const rows = left ? this.sql.all<MessageRow & { seq: number }>(`SELECT m.*, d.seq AS seq ${where} ORDER BY d.seq LIMIT ?`, ...params, LIMITS.inboxPage) : []
|
|
@@ -2004,8 +2181,8 @@ export class RelayCore {
|
|
|
2004
2181
|
const { shown, offered } = this.budget(rows, LIMITS.pushBytes)
|
|
2005
2182
|
const used = shown.reduce((sum, row) => sum + utf8Bytes(wrapMessage(this.envelope(row, row.seq))), 0)
|
|
2006
2183
|
const others = rows.length ? this.pushOthers(me, now, LIMITS.pushBytes - used) : []
|
|
2007
|
-
const hold = this.hold([...offered, ...others.map((row) => row.seq)], now)
|
|
2008
|
-
this.received(endpoint, [...shown, ...others], now)
|
|
2184
|
+
const hold = options.peek ? null : this.hold([...offered, ...others.map((row) => row.seq)], now)
|
|
2185
|
+
if (!options.peek) this.received(endpoint, [...shown, ...others], now)
|
|
2009
2186
|
return {
|
|
2010
2187
|
messages: [...shown, ...others].map((row) => this.envelope(row, row.seq)),
|
|
2011
2188
|
offered: [...offered, ...others.map((row) => row.seq)],
|
|
@@ -2013,6 +2190,7 @@ export class RelayCore {
|
|
|
2013
2190
|
more: waiting - shown.length,
|
|
2014
2191
|
workers: workers.map((row) => this.address(row)),
|
|
2015
2192
|
starting,
|
|
2193
|
+
...(background.length ? { background } : {}),
|
|
2016
2194
|
others: unread - this.workerUnread(me, now, false) - others.length,
|
|
2017
2195
|
...(others.length ? { others_shown: others.length } : {}),
|
|
2018
2196
|
pushes_left: left,
|
|
@@ -2080,9 +2258,29 @@ export class RelayCore {
|
|
|
2080
2258
|
prompted(endpoint: string, options: { transient?: boolean; process?: string } = {}): boolean {
|
|
2081
2259
|
if (!this.holds(this.mustEndpoint(endpoint), options)) return false
|
|
2082
2260
|
this.sql.all(`UPDATE endpoints SET prompts = COALESCE(prompts, 0) + 1, pushes = 0 WHERE id = ?`, endpoint)
|
|
2261
|
+
this.settle(endpoint)
|
|
2083
2262
|
return true
|
|
2084
2263
|
}
|
|
2085
2264
|
|
|
2265
|
+
/** whether a prompt should remind the agent to refresh its status note: `every` prompts passed since the note or the last reminder; counts the reminder */
|
|
2266
|
+
tldrDue(endpoint: string, every: number): boolean {
|
|
2267
|
+
if (!Number.isInteger(every) || every <= 0) return false
|
|
2268
|
+
const row = this.mustEndpoint(endpoint)
|
|
2269
|
+
const turn = row.prompts ?? 0
|
|
2270
|
+
if (row.kind !== "agent" || turn - (row.tldr_turn ?? 0) < every) return false
|
|
2271
|
+
this.sql.all(`UPDATE endpoints SET tldr_turn = ? WHERE id = ?`, turn, endpoint)
|
|
2272
|
+
return true
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
/** the background hooks' showings ({@link injected}) older than ${LIMITS.deferredSettleMs} ms reached the agent: inbox no longer lists them */
|
|
2276
|
+
private settle(endpoint: string): void {
|
|
2277
|
+
this.sql.all(
|
|
2278
|
+
`UPDATE deliveries SET deferred_at = NULL WHERE endpoint = ? AND deferred_at IS NOT NULL AND deferred_at <= ?`,
|
|
2279
|
+
endpoint,
|
|
2280
|
+
this.now() - LIMITS.deferredSettleMs,
|
|
2281
|
+
)
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2086
2284
|
/**
|
|
2087
2285
|
* The endpoint's live spawned workers: its unrevoked spawn grants (minted by `swarm spawn` without --no-push, never by spawn-env) from its
|
|
2088
2286
|
* current spawn that are either unspent and unexpired (`starting`: the harness has not joined yet) or spent by an endpoint whose status is not
|
|
@@ -2091,22 +2289,22 @@ export class RelayCore {
|
|
|
2091
2289
|
* spender done and revokes an unspent grant, so a worker goes when its harness exits, else when its presence lapses or its grant expires; an
|
|
2092
2290
|
* unspent grant whose `swarm spawn` process is gone (killed before it could report the exit) is revoked here.
|
|
2093
2291
|
*/
|
|
2094
|
-
private liveWorkers(me: EndpointRow, now: number): { workers: EndpointRow[]; starting: number; paced: string[] } {
|
|
2095
|
-
const grants = this.sql.all<Pick<GrantRow, "token_hash" | "used_by" | "bound_process" | "pushed_at" | "handback">>(
|
|
2096
|
-
`SELECT token_hash, used_by, bound_process, pushed_at, handback FROM grants WHERE issuer = ? AND spawn = 1 AND no_push IS NULL AND revoked_at IS NULL AND created_at >= ? AND (used_by IS NOT NULL OR expires_at > ?) ORDER BY created_at`,
|
|
2292
|
+
private liveWorkers(me: EndpointRow, now: number, noPush = false): { workers: EndpointRow[]; starting: number; paced: string[]; unjoined: string[] } {
|
|
2293
|
+
const grants = this.sql.all<Pick<GrantRow, "token_hash" | "used_by" | "bound_process" | "pushed_at" | "handback" | "name">>(
|
|
2294
|
+
`SELECT token_hash, used_by, bound_process, pushed_at, handback, name FROM grants WHERE issuer = ? AND spawn = 1 AND no_push IS ${noPush ? "NOT NULL" : "NULL"} AND revoked_at IS NULL AND created_at >= ? AND (used_by IS NOT NULL OR expires_at > ?) ORDER BY created_at`,
|
|
2097
2295
|
me.id,
|
|
2098
2296
|
me.spawned_at ?? 0,
|
|
2099
2297
|
now,
|
|
2100
2298
|
)
|
|
2101
2299
|
const workers = new Map<string, EndpointRow>()
|
|
2102
2300
|
const paced = new Set<string>()
|
|
2103
|
-
|
|
2301
|
+
const unjoined: string[] = []
|
|
2104
2302
|
for (const grant of grants) {
|
|
2105
2303
|
const pid = Number(/^pid:(\d+):/.exec(grant.bound_process ?? "")?.[1])
|
|
2106
2304
|
if (grant.used_by === null) {
|
|
2107
2305
|
if (pid && !this.alive(pid))
|
|
2108
2306
|
this.sql.all(`UPDATE grants SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL AND used_by IS NULL`, now, grant.token_hash)
|
|
2109
|
-
else
|
|
2307
|
+
else unjoined.push(grant.name ?? "")
|
|
2110
2308
|
continue
|
|
2111
2309
|
}
|
|
2112
2310
|
const row = this.byId(grant.used_by)
|
|
@@ -2114,7 +2312,7 @@ export class RelayCore {
|
|
|
2114
2312
|
workers.set(row.id, row)
|
|
2115
2313
|
if (grant.pushed_at !== null && grant.pushed_at > now - LIMITS.pushGapS * 1000) paced.add(row.id)
|
|
2116
2314
|
}
|
|
2117
|
-
return { workers: [...workers.values()], starting, paced: [...paced] }
|
|
2315
|
+
return { workers: [...workers.values()], starting: unjoined.length, paced: [...paced], unjoined }
|
|
2118
2316
|
}
|
|
2119
2317
|
|
|
2120
2318
|
/**
|
|
@@ -2216,9 +2414,11 @@ export class RelayCore {
|
|
|
2216
2414
|
|
|
2217
2415
|
/**
|
|
2218
2416
|
* a hook or channel showed these deliveries, offered to it under `hold`: no hook offers them again; those of them it marked that are `read`
|
|
2219
|
-
* (printed in full) and delivered to `endpoint` are acked
|
|
2417
|
+
* (printed in full) and delivered to `endpoint` are acked, and with `deferred` (a background hook, whose text the harness hands the agent
|
|
2418
|
+
* later) still listed by {@link inbox} until that settles ({@link settle}) and marked delivered rather than read ({@link receipts}) until
|
|
2419
|
+
* the agent acks it itself; how many it marked
|
|
2220
2420
|
*/
|
|
2221
|
-
injected(seqs: unknown, hold: unknown, options: { endpoint?: string; read?: unknown } = {}): number {
|
|
2421
|
+
injected(seqs: unknown, hold: unknown, options: { endpoint?: string; read?: unknown; deferred?: boolean } = {}): number {
|
|
2222
2422
|
const list = seqList(seqs)
|
|
2223
2423
|
if (typeof hold !== "string") return 0
|
|
2224
2424
|
const read = new Set(options.read === undefined || options.endpoint === undefined ? [] : seqList(options.read))
|
|
@@ -2233,13 +2433,17 @@ export class RelayCore {
|
|
|
2233
2433
|
hold,
|
|
2234
2434
|
).length
|
|
2235
2435
|
marked += hit
|
|
2236
|
-
if (hit && read.has(seq))
|
|
2436
|
+
if (hit && read.has(seq)) {
|
|
2437
|
+
const deferred = options.deferred === true ? now : null
|
|
2237
2438
|
acked += this.sql.all(
|
|
2238
|
-
`UPDATE deliveries SET acked_at = ? WHERE seq = ? AND endpoint = ? AND acked_at IS NULL RETURNING seq`,
|
|
2439
|
+
`UPDATE deliveries SET acked_at = ?, deferred_at = ?, deferred_ack = ? WHERE seq = ? AND endpoint = ? AND acked_at IS NULL RETURNING seq`,
|
|
2239
2440
|
now,
|
|
2441
|
+
deferred,
|
|
2442
|
+
deferred && 1,
|
|
2240
2443
|
seq,
|
|
2241
2444
|
options.endpoint!,
|
|
2242
2445
|
).length
|
|
2446
|
+
}
|
|
2243
2447
|
}
|
|
2244
2448
|
if (acked) this.audit("ack", options.endpoint!, null, { count: acked, via: "inject" })
|
|
2245
2449
|
return marked
|
|
@@ -2305,6 +2509,51 @@ export class RelayCore {
|
|
|
2305
2509
|
return envelope(seq ?? rows[0])
|
|
2306
2510
|
}
|
|
2307
2511
|
|
|
2512
|
+
/**
|
|
2513
|
+
* a message `endpoint` sent, by its id, a unique suffix of at least ${MESSAGE_SUFFIX_MIN} characters such as its {@link shortId}, or `last`
|
|
2514
|
+
* (the newest it sent), with when each recipient read it (acked), or when a background hook delivered it (acked by that showing, the agent
|
|
2515
|
+
* gets it with its next request, `delivered_at` and no `read_at`), and when a hook or channel showed it; a recipient still waiting for a
|
|
2516
|
+
* `swarm spawn` to join is named by the spawn's address. not_found when it sent no such message; invalid_params when a suffix names more
|
|
2517
|
+
* than one
|
|
2518
|
+
*/
|
|
2519
|
+
receipts(endpoint: string, id: unknown): MessageReceipts {
|
|
2520
|
+
if (typeof id !== "string" || !id.trim()) throw new RelayError("invalid_params", "id is a message id")
|
|
2521
|
+
type Row = { id: string; thread: string }
|
|
2522
|
+
const sent = `SELECT id, thread FROM messages WHERE from_ep = ?`
|
|
2523
|
+
let rows =
|
|
2524
|
+
id === "last" ? this.sql.all<Row>(`${sent} ORDER BY created_at DESC, id DESC LIMIT 1`, endpoint) : this.sql.all<Row>(`${sent} AND id = ?`, endpoint, id)
|
|
2525
|
+
if (!rows.length && MESSAGE_SUFFIX.test(id))
|
|
2526
|
+
rows = this.sql.all<Row>(`${sent} AND substr(id, -?) = ? ORDER BY created_at DESC LIMIT 6`, endpoint, id.length, id)
|
|
2527
|
+
if (rows.length > 1) {
|
|
2528
|
+
const named = rows.slice(0, 5).map((row) => `${shortId(row.id)} (${row.id})`)
|
|
2529
|
+
throw new RelayError(
|
|
2530
|
+
"invalid_params",
|
|
2531
|
+
`${id} matches more than one message you sent, newest first: ${named.join(", ")}${rows.length > 5 ? ", ..." : ""}; give more of the id`,
|
|
2532
|
+
)
|
|
2533
|
+
}
|
|
2534
|
+
const message = rows[0]
|
|
2535
|
+
if (!message) throw new RelayError("not_found", `no message ${id} that you sent`)
|
|
2536
|
+
const deliveries = this.sql.all<{ endpoint: string; acked_at: number | null; injected_at: number | null; deferred_ack: number | null }>(
|
|
2537
|
+
`SELECT endpoint, acked_at, injected_at, deferred_ack FROM deliveries WHERE message_id = ? ORDER BY seq`,
|
|
2538
|
+
message.id,
|
|
2539
|
+
)
|
|
2540
|
+
const address = (key: string) => {
|
|
2541
|
+
const pending = key.startsWith(pendingKey(""))
|
|
2542
|
+
? one<{ name: string | null }>(this.sql, `SELECT name FROM grants WHERE token_hash = ?`, key.slice(pendingKey("").length))
|
|
2543
|
+
: undefined
|
|
2544
|
+
return pending?.name ? `${this.user}/${pending.name}` : this.addressOf(key)
|
|
2545
|
+
}
|
|
2546
|
+
return {
|
|
2547
|
+
id: message.id,
|
|
2548
|
+
thread: message.thread,
|
|
2549
|
+
recipients: deliveries.map((d) => ({
|
|
2550
|
+
address: address(d.endpoint),
|
|
2551
|
+
...(d.deferred_ack ? { read_at: null, delivered_at: iso(d.acked_at) } : { read_at: iso(d.acked_at) }),
|
|
2552
|
+
shown_at: iso(d.injected_at),
|
|
2553
|
+
})),
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2308
2557
|
/**
|
|
2309
2558
|
* another endpoint's message in a thread `endpoint` sent or got a message in, by its id or a unique suffix of at least ${MESSAGE_SUFFIX_MIN}
|
|
2310
2559
|
* characters: enough to reply to it, never its body. Null when there is none; invalid_params when the suffix names more than one
|
|
@@ -2399,6 +2648,7 @@ export class RelayCore {
|
|
|
2399
2648
|
)
|
|
2400
2649
|
}
|
|
2401
2650
|
|
|
2651
|
+
/** marks these deliveries to `endpoint` read, one a background hook's showing acked ({@link injected}) read now too; how many were unread */
|
|
2402
2652
|
ack(endpoint: string, seqs: unknown): number {
|
|
2403
2653
|
let acked = 0
|
|
2404
2654
|
for (const seq of seqList(seqs)) {
|
|
@@ -2408,6 +2658,13 @@ export class RelayCore {
|
|
|
2408
2658
|
seq,
|
|
2409
2659
|
endpoint,
|
|
2410
2660
|
).length
|
|
2661
|
+
this.sql.all(
|
|
2662
|
+
`UPDATE deliveries SET acked_at = ?, deferred_ack = NULL WHERE seq = ? AND endpoint = ? AND deferred_ack IS NOT NULL`,
|
|
2663
|
+
this.now(),
|
|
2664
|
+
seq,
|
|
2665
|
+
endpoint,
|
|
2666
|
+
)
|
|
2667
|
+
this.sql.all(`UPDATE deliveries SET deferred_at = NULL WHERE seq = ? AND endpoint = ? AND deferred_at IS NOT NULL`, seq, endpoint)
|
|
2411
2668
|
}
|
|
2412
2669
|
if (acked) this.audit("ack", endpoint, null, { count: acked })
|
|
2413
2670
|
return acked
|
|
@@ -2527,6 +2784,7 @@ export class RelayCore {
|
|
|
2527
2784
|
this.sql.all(`DELETE FROM idempotency WHERE created_at <= ?`, now - LIMITS.idempotencyS * 1000)
|
|
2528
2785
|
this.sql.all(`DELETE FROM pair_events WHERE at <= ?`, now - 3600 * 1000)
|
|
2529
2786
|
this.sql.all(`DELETE FROM blocks WHERE until <= ?`, now)
|
|
2787
|
+
this.endExited(now)
|
|
2530
2788
|
this.settleOffline(now)
|
|
2531
2789
|
this.sql.all(
|
|
2532
2790
|
`DELETE FROM endpoints WHERE kind = 'agent' AND connections = 0 AND COALESCE(last_seen, created_at) <= ?
|
|
@@ -2553,6 +2811,17 @@ export class RelayCore {
|
|
|
2553
2811
|
}
|
|
2554
2812
|
}
|
|
2555
2813
|
|
|
2814
|
+
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
|
|
2815
|
+
endExited(now = this.now()): void {
|
|
2816
|
+
for (const row of this.sql.all<EndpointRow>(
|
|
2817
|
+
`SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`,
|
|
2818
|
+
)) {
|
|
2819
|
+
const pid = Number(/^pid:(\d+)/.exec(row.process!)?.[1])
|
|
2820
|
+
if (!pid || !this.localRow(row) || this.alive(pid)) continue
|
|
2821
|
+
this.sql.all(`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = NULL WHERE id = ? AND connections = 0`, now, row.id)
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2556
2825
|
/** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
|
|
2557
2826
|
private settleOffline(now: number): void {
|
|
2558
2827
|
for (const row of this.sql.all<EndpointRow>(
|
|
@@ -2595,6 +2864,7 @@ export class RelayCore {
|
|
|
2595
2864
|
)!
|
|
2596
2865
|
this.sql.all(`DELETE FROM activity WHERE id <= ?`, row.id - LIMITS.activityKeep)
|
|
2597
2866
|
const own = this.holds(this.mustEndpoint(endpoint), options)
|
|
2867
|
+
if (own && !agent && params.kind === "tool") this.settle(endpoint)
|
|
2598
2868
|
if (own && !agent && typeof params.session_id === "string" && HARNESS_SESSION.test(params.session_id)) {
|
|
2599
2869
|
this.sql.all(
|
|
2600
2870
|
`UPDATE endpoints SET harness_session = ?, harness_transcript = ? WHERE id = ?`,
|
|
@@ -2611,15 +2881,19 @@ export class RelayCore {
|
|
|
2611
2881
|
endpoint,
|
|
2612
2882
|
)
|
|
2613
2883
|
} else if (params.kind === "session_start") {
|
|
2884
|
+
const fresh = own && summary !== "compact" && summary !== "resume"
|
|
2614
2885
|
this.sql.all(
|
|
2615
2886
|
`UPDATE endpoints SET hook_seen = COALESCE(?, hook_seen), hook_ended = NULL, stop_blocks = CASE WHEN ? THEN 0 ELSE stop_blocks END, pushes = CASE WHEN ? THEN 0 ELSE pushes END,
|
|
2616
|
-
briefed = CASE WHEN ? THEN NULL ELSE briefed END, introduced = CASE WHEN ? THEN NULL ELSE introduced END, directive_holds = CASE WHEN ? THEN NULL ELSE directive_holds END
|
|
2887
|
+
briefed = CASE WHEN ? THEN NULL ELSE briefed END, introduced = CASE WHEN ? THEN NULL ELSE introduced END, directive_holds = CASE WHEN ? THEN NULL ELSE directive_holds END,
|
|
2888
|
+
title = CASE WHEN ? AND title = title_auto THEN NULL ELSE title END, title_auto = CASE WHEN ? THEN NULL ELSE title_auto END WHERE id = ?`,
|
|
2617
2889
|
options.transient ? now : null,
|
|
2618
2890
|
own ? 1 : 0,
|
|
2619
2891
|
own ? 1 : 0,
|
|
2620
2892
|
own ? 1 : 0,
|
|
2621
2893
|
own ? 1 : 0,
|
|
2622
2894
|
own ? 1 : 0,
|
|
2895
|
+
fresh ? 1 : 0,
|
|
2896
|
+
fresh ? 1 : 0,
|
|
2623
2897
|
endpoint,
|
|
2624
2898
|
)
|
|
2625
2899
|
} else if (options.transient) {
|
|
@@ -2997,6 +3271,20 @@ export class RelayCore {
|
|
|
2997
3271
|
})
|
|
2998
3272
|
}
|
|
2999
3273
|
|
|
3274
|
+
/** the grant issuer's messages to the endpoint that spent it, sent since the grant, that it read, all but those among `unapplied`, oldest first */
|
|
3275
|
+
private readSteers(grant: GrantRow, spender: EndpointRow, unapplied: string[]): { id: string; acked_at: number; deferred_ack: number | null }[] {
|
|
3276
|
+
return this.sql.all<{ id: string; acked_at: number; deferred_ack: number | null }>(
|
|
3277
|
+
`SELECT m.id, d.acked_at, d.deferred_ack FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
3278
|
+
WHERE d.endpoint = ? AND m.from_ep = ? AND m.created_at >= ? AND d.acked_at IS NOT NULL
|
|
3279
|
+
${unapplied.length ? `AND m.id NOT IN (${unapplied.map(() => "?").join(", ")})` : ""} ORDER BY d.seq LIMIT ?`,
|
|
3280
|
+
spender.id,
|
|
3281
|
+
grant.issuer,
|
|
3282
|
+
grant.created_at,
|
|
3283
|
+
...unapplied,
|
|
3284
|
+
LIMITS.inboxPage,
|
|
3285
|
+
)
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3000
3288
|
/**
|
|
3001
3289
|
* a message needs both ends: for each `ep:` selector `widen` added on `target`, the other end's side is widened to admit `target` too when
|
|
3002
3290
|
* `me` has authority over it, audited as its own widening; when that side still refuses, the change names it and the command that would fix it.
|
|
@@ -3370,8 +3658,9 @@ export class RelayCore {
|
|
|
3370
3658
|
/**
|
|
3371
3659
|
* a spawn's handback to its spawner `me`, the grant's issuer, from the worker (the relay when it never joined): past scopes, pair limits and
|
|
3372
3660
|
* the judge, since the spawner minted the grant and is the recipient. One per grant (null once sent), in the thread the two last wrote in,
|
|
3373
|
-
* else one derived from the grant, naming by short id the spawner's messages the worker
|
|
3374
|
-
*
|
|
3661
|
+
* else one derived from the grant, naming by short id the spawner's messages the worker read, with when (one a background hook showed only
|
|
3662
|
+
* delivered, unless the worker then exited 0: its model had the next request), and those it left unread; its hop follows the worker's
|
|
3663
|
+
* causal hop, capped, and a full thread drops it (null). A worker that never joined or exited non-zero gets its `stderr` last, labelled, its
|
|
3375
3664
|
* output cut from the front so both stay within ${LIMITS.handbackBytes} bytes
|
|
3376
3665
|
*/
|
|
3377
3666
|
private handback(
|
|
@@ -3435,7 +3724,13 @@ export class RelayCore {
|
|
|
3435
3724
|
]
|
|
3436
3725
|
if (spender) {
|
|
3437
3726
|
const unapplied = this.steersOf(grant, spender, now, taken)
|
|
3438
|
-
|
|
3727
|
+
const steers = [
|
|
3728
|
+
...this.readSteers(grant, spender, taken).map(
|
|
3729
|
+
(row) => `your steer ${shortId(row.id)} was ${row.deferred_ack && code !== 0 ? "delivered" : "read"} at ${clock(row.acked_at)}`,
|
|
3730
|
+
),
|
|
3731
|
+
...(unapplied.length ? this.unappliedLines(spender, grant, unapplied, now, exits) : []),
|
|
3732
|
+
]
|
|
3733
|
+
if (steers.length) body.push(steers.join("\n"))
|
|
3439
3734
|
}
|
|
3440
3735
|
const causal = spender && spender.last_received_at !== null && spender.last_received_at >= now - LIMITS.causalWindowS * 1000
|
|
3441
3736
|
const hop = causal ? Math.min((spender.last_received_hop ?? 0) + 1, LIMITS.hopMax) : 0
|
|
@@ -4550,7 +4845,7 @@ export class RelayCore {
|
|
|
4550
4845
|
|
|
4551
4846
|
/** the user parts an address of this core may carry */
|
|
4552
4847
|
protected addressUsers(): string[] {
|
|
4553
|
-
return [this.user, "me"]
|
|
4848
|
+
return [this.user, "me", "local"]
|
|
4554
4849
|
}
|
|
4555
4850
|
|
|
4556
4851
|
/** whether the row is this host's own, the only rows with a tree, groups, rules and caps here */
|
|
@@ -4564,7 +4859,7 @@ export class RelayCore {
|
|
|
4564
4859
|
const slash = text.indexOf("/")
|
|
4565
4860
|
const user = slash === -1 ? null : text.slice(0, slash)
|
|
4566
4861
|
const handle = slash === -1 ? text : text.slice(slash + 1)
|
|
4567
|
-
if (user !== null && user !== "me" && user !== this.user) throw unreachable(text)
|
|
4862
|
+
if (user !== null && user !== "me" && user !== "local" && user !== this.user) throw unreachable(text)
|
|
4568
4863
|
const row = handle === "" ? this.byHandle(HUMAN_HANDLE) : validHandle(handle) ? this.byHandle(handle) : undefined
|
|
4569
4864
|
if (!row) throw unreachable(text)
|
|
4570
4865
|
return row
|