@coreplane/switchboard 1.249.1 → 1.251.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/assets/config/config.example.yaml +46 -4
- package/dist/assets/deploy/cloudflare-memory/worker.ts +260 -1
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +12 -3
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +7 -0
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +3 -2
- package/dist/assets/project.json +4 -0
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +1 -1
- package/dist/assets/src/core/authz/policy.ts +10 -0
- package/dist/assets/src/core/authz/resource.ts +6 -2
- package/dist/assets/src/core/authz/types.ts +2 -0
- package/dist/assets/src/core/budgets.ts +13 -0
- package/dist/assets/src/core/coordinator/contract.ts +120 -0
- package/dist/assets/src/core/coordinator/driver.ts +17 -3
- package/dist/assets/src/core/modelCard.ts +19 -3
- package/dist/assets/src/core/prDescriptionTypes.ts +35 -0
- package/dist/assets/src/core/provider.ts +57 -3
- package/dist/assets/src/core/refusal.ts +14 -0
- package/dist/assets/src/core/runEvents.ts +49 -2
- package/dist/assets/src/core/runFriction.ts +2 -0
- package/dist/assets/src/core/runLedger/decisions.ts +9 -1
- package/dist/assets/src/core/runLedger/sessionLog.ts +10 -0
- package/dist/assets/src/core/runLedger/transcript.ts +13 -3
- package/dist/assets/src/core/runLedger/types.ts +72 -6
- package/dist/assets/src/core/runRecord.ts +34 -0
- package/dist/assets/src/core/ship/contract.ts +17 -4
- package/dist/assets/src/core/ship/coordinator.ts +70 -4
- package/dist/assets/src/core/trace/attrs.ts +13 -2
- package/dist/assets/src/core/trace/workerTrace.ts +3 -2
- package/dist/assets/src/core/types.ts +3 -0
- package/dist/assets/web/dist/.vite/manifest.json +58 -52
- package/dist/assets/web/dist/assets/DeliveryPage-DUXd-Sl-.js +1 -0
- package/dist/assets/web/dist/assets/HomePage-mSiqEEcN.js +2 -0
- package/dist/assets/web/dist/assets/{PendingTurnRow-BuRre8it.js → PendingTurnRow-DDhMhrI7.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-BIUXyz6K.js → ResidentDetailPage-BnEoOnGQ.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-BZymgSAb.js → ResidentsIndexPage-Dxpgf-l-.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-CSo4-vld.js +1 -0
- package/dist/assets/web/dist/assets/{RunRoutePage-bgkjkA0p.js → RunRoutePage-9klVWhSF.js} +6 -6
- package/dist/assets/web/dist/assets/{RunsIndexPage-8S944AzB.js → RunsIndexPage-BplMIgaw.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-8bBtG9y3.js → ScheduledPage-B_GgeJrb.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-DQeNvfaV.js → SettingsPage-BXX4R113.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-BPE5syBa.js → StatusDot-BOaw8le9.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DkoeZfTs.js → Tooltip-DYZZ4l4V.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-BUzw--Ii.js → UnitRoutePage-BaSW5Odq.js} +1 -1
- package/dist/assets/web/dist/assets/budgets-BvWYKPsY.js +1 -0
- package/dist/assets/web/dist/assets/{dist-D11y9ZJ4.js → dist-BCVXeBJ9.js} +1 -1
- package/dist/assets/web/dist/assets/indexRow-Bde9OZxG.js +1 -0
- package/dist/assets/web/dist/assets/{main-B6LcgNM6.js → main-Dkcbtu3u.js} +2 -2
- package/dist/assets/web/dist/assets/sseReplay-DPwdsaok.js +9 -0
- package/dist/cli.js +4594 -2743
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/DeliveryPage-DF4aQypG.js +0 -1
- package/dist/assets/web/dist/assets/HomePage-BpQRky8B.js +0 -2
- package/dist/assets/web/dist/assets/RunFoldRow-3m4CPRI4.js +0 -1
- package/dist/assets/web/dist/assets/indexRow-BmK74Vp1.js +0 -1
- package/dist/assets/web/dist/assets/sseReplay-DXC7kGbN.js +0 -9
|
@@ -14,10 +14,9 @@ providers:
|
|
|
14
14
|
# OpenAI itself runs on its Responses API (record 0052): every first-party
|
|
15
15
|
# OpenAI card in pi's registry is `openai-responses`, OpenAI recommends the
|
|
16
16
|
# wire for new projects, and its chat route loses tool calling with any
|
|
17
|
-
# effort above `none` from GPT-5.4. The
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# loading as `wire: openai-chat` for one release.
|
|
17
|
+
# effort above `none` from GPT-5.4. The proxy serves the wire at
|
|
18
|
+
# `/v1/responses` — a run on this block is pinned and metered like any other.
|
|
19
|
+
# `type: openai-compatible` keeps loading as `wire: openai-chat` for one release.
|
|
21
20
|
openai:
|
|
22
21
|
wire: openai-responses
|
|
23
22
|
baseUrl: https://api.openai.com/v1
|
|
@@ -57,6 +56,10 @@ providers:
|
|
|
57
56
|
# inputs:
|
|
58
57
|
# image: true
|
|
59
58
|
# cache: automatic
|
|
59
|
+
# # The answer shapes the card takes — a forced tool call, the one-JSON-
|
|
60
|
+
# # object text contract, or both (absent assumes both). The intake gate
|
|
61
|
+
# # refuses at load a card declaring neither under its classify default.
|
|
62
|
+
# answers: ["tool", "text"]
|
|
60
63
|
# A passthrough is merged into the request body on the wires whose adapter
|
|
61
64
|
# takes one — a vendor-only feature such as OpenRouter's routing, never a
|
|
62
65
|
# control: it is not decided, not noted and not in the matrix, and a wrong
|
|
@@ -84,6 +87,14 @@ defaults:
|
|
|
84
87
|
# provider default. Skipped for models without effort support.
|
|
85
88
|
# efforts:
|
|
86
89
|
# coding: medium
|
|
90
|
+
# How much the bot says about its own doing (docs/reference/specs/routing-and-config.md
|
|
91
|
+
# item 28): quiet (default) — only what needs the person: answers, verdicts,
|
|
92
|
+
# refusals, questions, results, the card's progress; verbose — plus every
|
|
93
|
+
# acknowledgement (a follow-up folded into a live run, a plan handed to the
|
|
94
|
+
# runner, the workspace and budget on the card); debug — plus the router's
|
|
95
|
+
# reason and the ledger's word. Same layering as effort: per-channel/user
|
|
96
|
+
# `verbosity` and a per-request `verbosity:<level>` directive override it.
|
|
97
|
+
# verbosity: quiet
|
|
87
98
|
# A boundary caps what ANY run in a scope may have — never grants — on the
|
|
88
99
|
# three axes of a run's profile (docs/reference/specs/routing-and-config.md item 2):
|
|
89
100
|
# maxMinutes (the wall-clock budget; at least 2), maxIdentity (the credential
|
|
@@ -153,6 +164,7 @@ defaults:
|
|
|
153
164
|
# users:
|
|
154
165
|
# slack:U012345:
|
|
155
166
|
# model: openai/gpt-5
|
|
167
|
+
# verbosity: verbose # hear every acknowledgement of what the bot does for you
|
|
156
168
|
# harness:
|
|
157
169
|
# coding: opencode
|
|
158
170
|
|
|
@@ -445,6 +457,26 @@ workspaceDir: ./workspaces
|
|
|
445
457
|
# model: anthropic/claude-haiku-4-5
|
|
446
458
|
# answer: text
|
|
447
459
|
|
|
460
|
+
# The thread-reply intake gate (docs/decisions/0058-a-thread-reply-is-read-before-it-is-answered-intake-decides-whether-the-bot-was-addressed.md;
|
|
461
|
+
# docs/reference/specs/routing-and-config.md item 27). How an unmentioned reply
|
|
462
|
+
# in a channel thread the bot is part of is treated — NOTHING CALLS THE GATE
|
|
463
|
+
# YET; the Slack wiring lands in a later release, and until then behavior is
|
|
464
|
+
# unchanged whatever this block says. `threadReplies`: `classify` (the code's
|
|
465
|
+
# default — one cheap model turn decides whether the bot was addressed; a
|
|
466
|
+
# silent reply then gets no reaction, no card and no run), `mention` (only a
|
|
467
|
+
# mention is answered; an unmentioned reply is silent with a receipt and no
|
|
468
|
+
# model call) or `always` (today's answer-every-reply path, byte for byte).
|
|
469
|
+
# Also settable per channel and per user: `intake: { threadReplies: <mode> }`
|
|
470
|
+
# on a `channels.<id>` or `users.<id>` scope, user over channel over here.
|
|
471
|
+
# `model` is the verdict's model; default `routing.model`, else
|
|
472
|
+
# `defaults.models.general`. A mention, a DM and a top-level post never pass
|
|
473
|
+
# through the gate in any mode. A model whose operator card (`providers.<p>.
|
|
474
|
+
# models.<id>.answers`) declares it answers neither a forced tool call nor the
|
|
475
|
+
# text contract is refused at load when the default mode is `classify`.
|
|
476
|
+
# intake:
|
|
477
|
+
# threadReplies: classify
|
|
478
|
+
# model: anthropic/claude-haiku-4-5
|
|
479
|
+
|
|
448
480
|
# Linked threads (docs/decisions/0037-a-linked-thread-is-quoted-not-joined.md;
|
|
449
481
|
# docs/reference/specs/routing-and-config.md item 22). Off by default. On, a
|
|
450
482
|
# permalink in a request to a thread in another PUBLIC channel the bot is a
|
|
@@ -535,6 +567,16 @@ workspaceDir: ./workspaces
|
|
|
535
567
|
# catchUp:
|
|
536
568
|
# enabled: true
|
|
537
569
|
# windowMinutes: 30
|
|
570
|
+
# # The apps whose relay footer — `Sent by Claude in <#C…> on behalf of <@U…> ·
|
|
571
|
+
# # <permalink>`, appended by the Claude Slack app to a post it makes from a
|
|
572
|
+
# # Claude Code session — names the person the run is for and billed to
|
|
573
|
+
# # (docs/reference/specs/slack-channel.md item 13). By Slack bot id: the
|
|
574
|
+
# # `bot_id` on the app's messages, the `B…` a run record's `postedBy:
|
|
575
|
+
# # slack:bot:B…` carries. The footer is message text any app can write, so
|
|
576
|
+
# # it is read only from an app listed here; from any other app the app itself
|
|
577
|
+
# # is the requester, footer or not. Absent or empty: no footer is honoured.
|
|
578
|
+
# relayApps:
|
|
579
|
+
# - B0123ABCDE
|
|
538
580
|
|
|
539
581
|
# Where chat-set runtime overrides (`config set`, `config instructions`, `config clear`)
|
|
540
582
|
# persist (docs/reference/specs/routing-and-config.md item 12). Without this block they go to
|
|
@@ -71,25 +71,35 @@ import {
|
|
|
71
71
|
checkFence,
|
|
72
72
|
decideClaim,
|
|
73
73
|
decideClaimWrite,
|
|
74
|
+
decideIntakeInsert,
|
|
74
75
|
phaseTransition,
|
|
75
76
|
reclaimPhase,
|
|
76
77
|
selectReclaim,
|
|
77
78
|
} from "../../src/core/runLedger/decisions.ts";
|
|
79
|
+
import { intakeReceiptRetentionMs } from "../../src/core/budgets.ts";
|
|
78
80
|
import {
|
|
79
81
|
IDEMPOTENCY_KEY_PATTERN,
|
|
82
|
+
capThreadEvent,
|
|
80
83
|
INSTANCE_ID_PATTERN,
|
|
81
84
|
isCoordinatorInstance,
|
|
82
85
|
isCoordinatorUnit,
|
|
86
|
+
isThreadEvent,
|
|
83
87
|
sendRunFinished,
|
|
88
|
+
UNIT_PATTERN,
|
|
84
89
|
type CoordinatorInstance,
|
|
85
90
|
type CoordinatorUnit,
|
|
86
91
|
type RunFinishedSend,
|
|
92
|
+
type ThreadEvent,
|
|
87
93
|
} from "../../src/core/coordinator/contract.ts";
|
|
88
94
|
import {
|
|
89
95
|
GEN_PATTERN,
|
|
96
|
+
isIntakeReceipt,
|
|
90
97
|
type ClaimRequest,
|
|
91
98
|
type ClaimResult,
|
|
92
99
|
type FenceResult,
|
|
100
|
+
type IntakeQuery,
|
|
101
|
+
type IntakeReceipt,
|
|
102
|
+
type IntakeWriteResult,
|
|
93
103
|
type LivePhase,
|
|
94
104
|
type LiveRunRow,
|
|
95
105
|
type ReclaimedRun,
|
|
@@ -769,6 +779,27 @@ export class ConfigDO extends DurableObject<Env> {
|
|
|
769
779
|
});
|
|
770
780
|
}
|
|
771
781
|
|
|
782
|
+
/** The thread's pending row when one exists and is inside its ttl, else
|
|
783
|
+
* null. A pure read: expiry is checked here on this object's clock and
|
|
784
|
+
* nothing is deleted — nothing sweeps, and a consume still finds the
|
|
785
|
+
* expired row to name `expired`. */
|
|
786
|
+
async pendingConfirmationByThread(threadKey: string, now: number): Promise<ConfirmationRow | null> {
|
|
787
|
+
const row = this.sql
|
|
788
|
+
.exec<{ id: string; requester: string; expires_at: number; body: string }>(
|
|
789
|
+
`SELECT id, requester, expires_at, body FROM confirmations WHERE thread_key = ?`,
|
|
790
|
+
threadKey,
|
|
791
|
+
)
|
|
792
|
+
.toArray()[0];
|
|
793
|
+
if (!row || row.expires_at <= now) return null;
|
|
794
|
+
return {
|
|
795
|
+
id: row.id,
|
|
796
|
+
threadKey,
|
|
797
|
+
requester: row.requester,
|
|
798
|
+
expiresAt: row.expires_at,
|
|
799
|
+
body: parseStored(row.body, isJsonObject) ?? {},
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
772
803
|
private readConfirmation(id: string): ConfirmationRow | undefined {
|
|
773
804
|
const row = this.sql
|
|
774
805
|
.exec<{ thread_key: string; requester: string; expires_at: number; body: string }>(
|
|
@@ -1189,6 +1220,7 @@ const CONFIG_ROUTES = new Set([
|
|
|
1189
1220
|
"/config/confirmations/consume",
|
|
1190
1221
|
"/config/confirmations/cancel",
|
|
1191
1222
|
"/config/confirmations/cancel-by-thread",
|
|
1223
|
+
"/config/confirmations/pending-by-thread",
|
|
1192
1224
|
]);
|
|
1193
1225
|
const TICKET_STATES: ReadonlySet<string> = new Set<McpTicketState>(MCP_TICKET_STATES);
|
|
1194
1226
|
/** A confirmation id as the bot mints it (a UUID) — one token, no whitespace, bounded. */
|
|
@@ -1274,6 +1306,15 @@ async function handleConfig(pathname: string, body: unknown, env: Env): Promise<
|
|
|
1274
1306
|
console.log(`[config/confirmations/cancel] ${click.id} ${"ok" in outcome ? "cancelled" : outcome.refused}`);
|
|
1275
1307
|
return json(outcome);
|
|
1276
1308
|
}
|
|
1309
|
+
case "/config/confirmations/pending-by-thread": {
|
|
1310
|
+
if (typeof b.threadKey !== "string" || !b.threadKey) return json({ error: "threadKey required" }, 400);
|
|
1311
|
+
// The stub types this result `never`: workers-types' Serializable rejects
|
|
1312
|
+
// the row's opaque `Record<string, unknown>` body. What arrives is the
|
|
1313
|
+
// object's declared result, so the boundary restates it.
|
|
1314
|
+
const row = (await dO.pendingConfirmationByThread(b.threadKey, systemClock())) as ConfirmationRow | null;
|
|
1315
|
+
console.log(`[config/confirmations/pending-by-thread] ${b.threadKey} ${row === null ? "none" : row.id}`);
|
|
1316
|
+
return json({ row });
|
|
1317
|
+
}
|
|
1277
1318
|
case "/config/confirmations/cancel-by-thread": {
|
|
1278
1319
|
if (typeof b.threadKey !== "string" || !b.threadKey) return json({ error: "threadKey required" }, 400);
|
|
1279
1320
|
if (!Array.isArray(b.actorIds) || !b.actorIds.every((a): a is string => typeof a === "string"))
|
|
@@ -1515,6 +1556,22 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1515
1556
|
PRIMARY KEY (run_id, kind)
|
|
1516
1557
|
);
|
|
1517
1558
|
`);
|
|
1559
|
+
// The intake receipts (run-history item 59): one verdict per message key,
|
|
1560
|
+
// first writer wins, beside the live rows because the reconnect catch-up
|
|
1561
|
+
// reads them through the same store key. `prune_after` is stamped at the
|
|
1562
|
+
// insert (the bound is the writer's window through
|
|
1563
|
+
// `intakeReceiptRetentionMs`) and the alarm sweeps by it.
|
|
1564
|
+
this.sql.exec(`
|
|
1565
|
+
CREATE TABLE IF NOT EXISTS intake_receipts (
|
|
1566
|
+
key TEXT PRIMARY KEY,
|
|
1567
|
+
thread_key TEXT NOT NULL,
|
|
1568
|
+
decided_at INTEGER NOT NULL,
|
|
1569
|
+
prune_after INTEGER NOT NULL,
|
|
1570
|
+
json TEXT NOT NULL
|
|
1571
|
+
);
|
|
1572
|
+
CREATE INDEX IF NOT EXISTS intake_thread ON intake_receipts(thread_key, decided_at);
|
|
1573
|
+
CREATE INDEX IF NOT EXISTS intake_prune ON intake_receipts(prune_after);
|
|
1574
|
+
`);
|
|
1518
1575
|
// The coordinator's parent records (run-history item 49): one row per
|
|
1519
1576
|
// instance, written by the bot at the instance's creation and read by the
|
|
1520
1577
|
// spawn route for the requester, channel and thread every child acts as.
|
|
@@ -1537,6 +1594,20 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1537
1594
|
PRIMARY KEY (instance_id, unit)
|
|
1538
1595
|
);
|
|
1539
1596
|
`);
|
|
1597
|
+
// The thread events of a unit-owned thread (record 0051's reply-as-event rule): a
|
|
1598
|
+
// sibling table of the unit rows, never a field on them — `putUnits`
|
|
1599
|
+
// replaces a row whole, so an append landing between a route's read and
|
|
1600
|
+
// its put would be lost. Consumption is a column of its own, set once.
|
|
1601
|
+
this.sql.exec(`
|
|
1602
|
+
CREATE TABLE IF NOT EXISTS coordinator_unit_events (
|
|
1603
|
+
instance_id TEXT NOT NULL,
|
|
1604
|
+
unit TEXT NOT NULL,
|
|
1605
|
+
seq INTEGER NOT NULL,
|
|
1606
|
+
json TEXT NOT NULL,
|
|
1607
|
+
consumed_by TEXT,
|
|
1608
|
+
PRIMARY KEY (instance_id, unit, seq)
|
|
1609
|
+
);
|
|
1610
|
+
`);
|
|
1540
1611
|
}
|
|
1541
1612
|
|
|
1542
1613
|
// ---- the coordinator's parent records (run-history item 49) -----------------
|
|
@@ -1613,6 +1684,68 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1613
1684
|
.map((r) => JSON.parse(r.json) as CoordinatorUnit);
|
|
1614
1685
|
}
|
|
1615
1686
|
|
|
1687
|
+
// ---- the thread events of a unit-owned thread (record 0051's reply-as-event rule) --------------
|
|
1688
|
+
|
|
1689
|
+
/** The next sequence assigned in one transaction, the per-event cap applied
|
|
1690
|
+
* (attachments dropped whole, the row saying how many). */
|
|
1691
|
+
async appendUnitEvent(
|
|
1692
|
+
instanceId: string,
|
|
1693
|
+
unit: string,
|
|
1694
|
+
event: Omit<ThreadEvent, "seq">,
|
|
1695
|
+
): Promise<{ ok: true; seq: number }> {
|
|
1696
|
+
let seq = 1;
|
|
1697
|
+
this.ctx.storage.transactionSync(() => {
|
|
1698
|
+
const max = this.sql
|
|
1699
|
+
.exec<{
|
|
1700
|
+
m: number | null;
|
|
1701
|
+
}>(`SELECT MAX(seq) AS m FROM coordinator_unit_events WHERE instance_id = ? AND unit = ?`, instanceId, unit)
|
|
1702
|
+
.toArray()[0];
|
|
1703
|
+
seq = (max?.m ?? 0) + 1;
|
|
1704
|
+
const capped = capThreadEvent({ ...event, seq });
|
|
1705
|
+
this.sql.exec(
|
|
1706
|
+
`INSERT INTO coordinator_unit_events (instance_id, unit, seq, json) VALUES (?, ?, ?, ?)`,
|
|
1707
|
+
instanceId,
|
|
1708
|
+
unit,
|
|
1709
|
+
seq,
|
|
1710
|
+
JSON.stringify(capped),
|
|
1711
|
+
);
|
|
1712
|
+
});
|
|
1713
|
+
return { ok: true, seq };
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/** The unit's events in sequence order; `unconsumedOnly` filters to the rows nothing has consumed. */
|
|
1717
|
+
async listUnitEvents(instanceId: string, unit: string, unconsumedOnly: boolean): Promise<ThreadEvent[]> {
|
|
1718
|
+
return this.sql
|
|
1719
|
+
.exec<{ json: string; consumed_by: string | null }>(
|
|
1720
|
+
`SELECT json, consumed_by FROM coordinator_unit_events WHERE instance_id = ? AND unit = ?${
|
|
1721
|
+
unconsumedOnly ? " AND consumed_by IS NULL" : ""
|
|
1722
|
+
} ORDER BY seq ASC`,
|
|
1723
|
+
instanceId,
|
|
1724
|
+
unit,
|
|
1725
|
+
)
|
|
1726
|
+
.toArray()
|
|
1727
|
+
.map((r) => {
|
|
1728
|
+
const e = JSON.parse(r.json) as ThreadEvent;
|
|
1729
|
+
return r.consumed_by !== null ? { ...e, consumedBy: r.consumed_by } : e;
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/** Consumption set once — idempotent: a row already consumed keeps its first consumer. */
|
|
1734
|
+
async markUnitEventsConsumed(instanceId: string, unit: string, seqs: number[], by: string): Promise<{ ok: true }> {
|
|
1735
|
+
this.ctx.storage.transactionSync(() => {
|
|
1736
|
+
for (const seq of seqs) {
|
|
1737
|
+
this.sql.exec(
|
|
1738
|
+
`UPDATE coordinator_unit_events SET consumed_by = ? WHERE instance_id = ? AND unit = ? AND seq = ? AND consumed_by IS NULL`,
|
|
1739
|
+
by,
|
|
1740
|
+
instanceId,
|
|
1741
|
+
unit,
|
|
1742
|
+
seq,
|
|
1743
|
+
);
|
|
1744
|
+
}
|
|
1745
|
+
});
|
|
1746
|
+
return { ok: true };
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1616
1749
|
// ---- the live-run ledger (run-history items 28–34) --------------------------
|
|
1617
1750
|
|
|
1618
1751
|
private liveRow(runId: string): LiveRunRow | undefined {
|
|
@@ -1993,6 +2126,59 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1993
2126
|
return parseEventRows(this.eventRows(runId, 0, Number.MAX_SAFE_INTEGER));
|
|
1994
2127
|
}
|
|
1995
2128
|
|
|
2129
|
+
// ---- the intake receipts (run-history item 59) -------------------------------
|
|
2130
|
+
|
|
2131
|
+
private intakeRow(key: string): IntakeReceipt | undefined {
|
|
2132
|
+
const r = this.sql.exec<{ json: string }>(`SELECT json FROM intake_receipts WHERE key = ?`, key).toArray()[0];
|
|
2133
|
+
return r ? (JSON.parse(r.json) as IntakeReceipt) : undefined;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
/** Insert-if-absent inside one transaction: the first writer's row stands
|
|
2137
|
+
* and every caller acts on `stored` (`decideIntakeInsert`). `windowMs` is
|
|
2138
|
+
* the writer's reconnect catch-up window; the retention bound is stamped on
|
|
2139
|
+
* the row so the alarm's sweep is one indexed delete. */
|
|
2140
|
+
async recordIntake(key: string, receipt: IntakeReceipt, windowMs: number): Promise<IntakeWriteResult> {
|
|
2141
|
+
let out: IntakeWriteResult = { inserted: false, stored: receipt };
|
|
2142
|
+
this.ctx.storage.transactionSync(() => {
|
|
2143
|
+
out = decideIntakeInsert(this.intakeRow(key), receipt);
|
|
2144
|
+
if (!out.inserted) return;
|
|
2145
|
+
this.sql.exec(
|
|
2146
|
+
`INSERT INTO intake_receipts (key, thread_key, decided_at, prune_after, json) VALUES (?, ?, ?, ?, ?)`,
|
|
2147
|
+
key,
|
|
2148
|
+
receipt.threadKey,
|
|
2149
|
+
receipt.decidedAt,
|
|
2150
|
+
receipt.decidedAt + intakeReceiptRetentionMs(windowMs),
|
|
2151
|
+
JSON.stringify(receipt),
|
|
2152
|
+
);
|
|
2153
|
+
});
|
|
2154
|
+
if ((await this.ctx.storage.getAlarm()) === null)
|
|
2155
|
+
await this.ctx.storage.setAlarm(systemClock() + RUN_SWEEP_INTERVAL_MS);
|
|
2156
|
+
return out;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
async readIntake(key: string): Promise<IntakeReceipt | null> {
|
|
2160
|
+
return this.intakeRow(key) ?? null;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
/** A thread's receipts, or the receipts since an instant, oldest first. */
|
|
2164
|
+
async listIntake(query: IntakeQuery): Promise<IntakeReceipt[]> {
|
|
2165
|
+
const clauses: string[] = [];
|
|
2166
|
+
const params: (string | number)[] = [];
|
|
2167
|
+
if (query.threadKey !== undefined) {
|
|
2168
|
+
clauses.push("thread_key = ?");
|
|
2169
|
+
params.push(query.threadKey);
|
|
2170
|
+
}
|
|
2171
|
+
if (query.since !== undefined) {
|
|
2172
|
+
clauses.push("decided_at >= ?");
|
|
2173
|
+
params.push(query.since);
|
|
2174
|
+
}
|
|
2175
|
+
const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
2176
|
+
return this.sql
|
|
2177
|
+
.exec<{ json: string }>(`SELECT json FROM intake_receipts${where} ORDER BY decided_at ASC`, ...params)
|
|
2178
|
+
.toArray()
|
|
2179
|
+
.map((r) => JSON.parse(r.json) as IntakeReceipt);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
1996
2182
|
// ---- policy ---------------------------------------------------------------
|
|
1997
2183
|
|
|
1998
2184
|
/** The persisted policy (defaults until the first proposal lands). */
|
|
@@ -2274,12 +2460,19 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2274
2460
|
const now = systemClock();
|
|
2275
2461
|
const { policy } = this.policyState();
|
|
2276
2462
|
let deleted = 0;
|
|
2463
|
+
let receipts = 0;
|
|
2277
2464
|
let candidates: { key: string; threadKey: string }[] = [];
|
|
2278
2465
|
this.ctx.storage.transactionSync(() => {
|
|
2279
2466
|
deleted = this.trim(policy, now, undefined).deleted;
|
|
2280
2467
|
// Orphan sweep: events whose run is gone (defensive — `deleteRuns` pairs
|
|
2281
2468
|
// the two deletes, so this is a periodic check, not a per-put cost).
|
|
2282
2469
|
this.sql.exec(`DELETE FROM run_events WHERE run_id NOT IN (SELECT run_id FROM runs)`);
|
|
2470
|
+
// Intake receipts past their bound (item 59): each row carries its own
|
|
2471
|
+
// `prune_after`, stamped at the insert from the writer's window.
|
|
2472
|
+
receipts = this.sql
|
|
2473
|
+
.exec<{ n: number }>(`SELECT COUNT(*) AS n FROM intake_receipts WHERE prune_after <= ?`, now)
|
|
2474
|
+
.one().n;
|
|
2475
|
+
this.sql.exec(`DELETE FROM intake_receipts WHERE prune_after <= ?`, now);
|
|
2283
2476
|
// The sessions no kept run names any more (session-log item 7): decided
|
|
2284
2477
|
// here, on the rows this transaction leaves; dropped after it.
|
|
2285
2478
|
candidates = this.sql
|
|
@@ -2291,7 +2484,9 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2291
2484
|
.map((r) => ({ key: r.key, threadKey: r.thread_key }));
|
|
2292
2485
|
});
|
|
2293
2486
|
const dropped = await this.sweepSessions(candidates);
|
|
2294
|
-
console.log(
|
|
2487
|
+
console.log(
|
|
2488
|
+
`[runs/alarm] swept ${deleted} rows outside policy, pruned ${receipts} intake receipt(s), dropped ${dropped} session log(s)`,
|
|
2489
|
+
);
|
|
2295
2490
|
await this.ctx.storage.setAlarm(now + RUN_SWEEP_INTERVAL_MS);
|
|
2296
2491
|
root.end("ok", { swept: deleted });
|
|
2297
2492
|
} catch (err) {
|
|
@@ -3538,6 +3733,9 @@ const LEDGER_ROUTES = new Set([
|
|
|
3538
3733
|
"/runs/coordinator/get",
|
|
3539
3734
|
"/runs/coordinator/units/put",
|
|
3540
3735
|
"/runs/coordinator/units/list",
|
|
3736
|
+
"/runs/coordinator/events/append",
|
|
3737
|
+
"/runs/coordinator/events/list",
|
|
3738
|
+
"/runs/coordinator/events/mark-consumed",
|
|
3541
3739
|
"/runs/claim",
|
|
3542
3740
|
"/runs/heartbeat",
|
|
3543
3741
|
"/runs/append",
|
|
@@ -3553,6 +3751,9 @@ const LEDGER_ROUTES = new Set([
|
|
|
3553
3751
|
"/runs/reclaim",
|
|
3554
3752
|
"/runs/live",
|
|
3555
3753
|
"/runs/live-events",
|
|
3754
|
+
"/runs/intake",
|
|
3755
|
+
"/runs/intake/read",
|
|
3756
|
+
"/runs/intake/list",
|
|
3556
3757
|
"/runs/transcript/owner",
|
|
3557
3758
|
"/runs/transcript/write",
|
|
3558
3759
|
"/runs/transcript/read",
|
|
@@ -3926,6 +4127,64 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
|
|
|
3926
4127
|
return json({ error: "instanceId must be a Workflow instance id" }, 400);
|
|
3927
4128
|
return json({ units: await stub.listUnits(b.instanceId) });
|
|
3928
4129
|
}
|
|
4130
|
+
// The thread events of a unit-owned thread (record 0051's reply-as-event rule): append assigns
|
|
4131
|
+
// the sequence, list filters unconsumed, mark-consumed is idempotent.
|
|
4132
|
+
if (pathname.startsWith("/runs/coordinator/events/")) {
|
|
4133
|
+
if (typeof b.instanceId !== "string" || !INSTANCE_ID_PATTERN.test(b.instanceId))
|
|
4134
|
+
return json({ error: "instanceId must be a Workflow instance id" }, 400);
|
|
4135
|
+
if (typeof b.unit !== "string" || !UNIT_PATTERN.test(b.unit)) return json({ error: "unit must be a unit id" }, 400);
|
|
4136
|
+
if (pathname === "/runs/coordinator/events/append") {
|
|
4137
|
+
if (!isThreadEvent({ ...(b.event as Record<string, unknown>), seq: 1 }))
|
|
4138
|
+
return json({ error: "event must be a thread event (without its seq)" }, 400);
|
|
4139
|
+
// The store assigns the sequence and the consumer: a caller's `seq` or
|
|
4140
|
+
// `consumedBy` is dropped, so no row is born consumed in its JSON while
|
|
4141
|
+
// its column still lists it unconsumed.
|
|
4142
|
+
const { seq: _ignored, consumedBy: _fresh, ...event } = b.event as ThreadEvent;
|
|
4143
|
+
const r = await stub.appendUnitEvent(b.instanceId, b.unit, event as Omit<ThreadEvent, "seq" | "consumedBy">);
|
|
4144
|
+
console.log(`[runs/coordinator/events/append] ${key.value} ${b.instanceId}:${b.unit} seq ${r.seq}`);
|
|
4145
|
+
return json(r);
|
|
4146
|
+
}
|
|
4147
|
+
if (pathname === "/runs/coordinator/events/list") {
|
|
4148
|
+
return json({ events: await stub.listUnitEvents(b.instanceId, b.unit, b.unconsumedOnly === true) });
|
|
4149
|
+
}
|
|
4150
|
+
if (pathname === "/runs/coordinator/events/mark-consumed") {
|
|
4151
|
+
if (!Array.isArray(b.seqs) || !b.seqs.every((s) => typeof s === "number" && Number.isInteger(s) && s >= 1))
|
|
4152
|
+
return json({ error: "seqs must be an array of sequence numbers" }, 400);
|
|
4153
|
+
if (typeof b.by !== "string" || b.by.length === 0 || b.by.length > 200)
|
|
4154
|
+
return json({ error: "by must name the consumer" }, 400);
|
|
4155
|
+
const r = await stub.markUnitEventsConsumed(b.instanceId, b.unit, b.seqs as number[], b.by);
|
|
4156
|
+
console.log(
|
|
4157
|
+
`[runs/coordinator/events/mark-consumed] ${key.value} ${b.instanceId}:${b.unit} ${b.seqs.length} row(s) by ${b.by}`,
|
|
4158
|
+
);
|
|
4159
|
+
return json(r);
|
|
4160
|
+
}
|
|
4161
|
+
}
|
|
4162
|
+
|
|
4163
|
+
// The intake receipts (run-history item 59): keyed by the message, not a run.
|
|
4164
|
+
if (pathname === "/runs/intake" || pathname === "/runs/intake/read") {
|
|
4165
|
+
const receiptKey = b.key;
|
|
4166
|
+
if (typeof receiptKey !== "string" || receiptKey.length === 0 || receiptKey.length > 256)
|
|
4167
|
+
return json({ error: "key must be a non-empty string of at most 256 characters" }, 400);
|
|
4168
|
+
if (pathname === "/runs/intake/read") return json({ receipt: await stub.readIntake(receiptKey) });
|
|
4169
|
+
if (!isIntakeReceipt(b.receipt)) return json({ error: "receipt must be an intake receipt" }, 400);
|
|
4170
|
+
if (b.windowMs !== undefined && (typeof b.windowMs !== "number" || !Number.isFinite(b.windowMs) || b.windowMs < 0))
|
|
4171
|
+
return json({ error: "windowMs must be a non-negative number" }, 400);
|
|
4172
|
+
const r = await stub.recordIntake(receiptKey, b.receipt, typeof b.windowMs === "number" ? b.windowMs : 0);
|
|
4173
|
+
console.log(`[runs/intake] ${key.value} ${receiptKey} → ${r.inserted ? "inserted" : "existing"}`);
|
|
4174
|
+
return json(r);
|
|
4175
|
+
}
|
|
4176
|
+
if (pathname === "/runs/intake/list") {
|
|
4177
|
+
if (b.threadKey !== undefined && (typeof b.threadKey !== "string" || b.threadKey.length === 0))
|
|
4178
|
+
return json({ error: "threadKey must be a non-empty string" }, 400);
|
|
4179
|
+
if (b.since !== undefined && (typeof b.since !== "number" || !Number.isFinite(b.since)))
|
|
4180
|
+
return json({ error: "since must be a number" }, 400);
|
|
4181
|
+
return json({
|
|
4182
|
+
receipts: await stub.listIntake({
|
|
4183
|
+
...(b.threadKey !== undefined ? { threadKey: b.threadKey } : {}),
|
|
4184
|
+
...(b.since !== undefined ? { since: b.since } : {}),
|
|
4185
|
+
}),
|
|
4186
|
+
});
|
|
4187
|
+
}
|
|
3929
4188
|
|
|
3930
4189
|
const runId = parseRunId(b.runId);
|
|
3931
4190
|
if (!runId.ok) return json({ error: runId.error }, 400);
|
|
@@ -36,12 +36,21 @@ RUN git config --system user.name "switchboard-resident" \
|
|
|
36
36
|
# cores once several threads run). These defaults inherit into every thread's
|
|
37
37
|
# `su … -c` shell (su without `-` keeps the environment). CI=1 makes runners
|
|
38
38
|
# non-interactive (no watch mode, no TTY progress spinners in captured
|
|
39
|
-
# output).
|
|
40
|
-
#
|
|
39
|
+
# output). NODE_OPTIONS lets one Node process take 8 GiB of the 12 for its
|
|
40
|
+
# heap. V8's own default is a quarter of physical memory (~3 GiB here), and
|
|
41
|
+
# this image once pinned it lower still, at 1,536 MB — an eighth, meant as a
|
|
42
|
+
# fair share among the 16 seats — under which this repository's own two `tsc`
|
|
43
|
+
# passes die with "heap out of memory" (they pass at 3,072 MB with ~2 GB of
|
|
44
|
+
# RSS each; measured), and which is a quarter of the large-monorepo typecheck
|
|
45
|
+
# the instance size exists for (docs/reference/specs/execution.md item 16). A
|
|
46
|
+
# per-process heap limit never shared memory between processes (16 × 1,536 MB
|
|
47
|
+
# already exceeded the instance); the cgroup does that. So the cap is a
|
|
48
|
+
# runaway guard, sized for the largest legitimate command with room, and the
|
|
49
|
+
# same as the sandbox image's; src/deploy/imageNode.test.ts holds the two equal.
|
|
41
50
|
ENV CI=1 \
|
|
42
51
|
VITEST_MAX_WORKERS=1 VITEST_MAX_THREADS=1 VITEST_MAX_FORKS=1 VITEST_MIN_THREADS=1 VITEST_MIN_FORKS=1 \
|
|
43
52
|
UV_THREADPOOL_SIZE=4 \
|
|
44
|
-
NODE_OPTIONS=--max-old-space-size=
|
|
53
|
+
NODE_OPTIONS=--max-old-space-size=8192
|
|
45
54
|
|
|
46
55
|
# Node 24, not the base's. The 0.13.0-next base ships Node 22.23.2 like the
|
|
47
56
|
# 0.12.9 one (copied from node:22-slim: the binary at /usr/local/bin/node, npm
|
|
@@ -31,6 +31,13 @@ USER root
|
|
|
31
31
|
# (src/execution/bashTimeout.ts); changing it requires a container rebuild +
|
|
32
32
|
# redeploy.
|
|
33
33
|
ENV COMMAND_TIMEOUT_MS=1240000
|
|
34
|
+
# One Node process may take 8 GiB of the 12 for its heap. V8's own default is
|
|
35
|
+
# a quarter of physical memory (~3 GiB on this instance), which would refuse
|
|
36
|
+
# the very command the instance size was chosen for — a large monorepo's
|
|
37
|
+
# typecheck near 8 GB (docs/reference/specs/execution.md item 16) — while the
|
|
38
|
+
# box sat three-quarters empty. The same cap as the resident image's;
|
|
39
|
+
# src/deploy/imageNode.test.ts holds the two equal.
|
|
40
|
+
ENV NODE_OPTIONS=--max-old-space-size=8192
|
|
34
41
|
# docker.io + iptables: a Docker engine for runs whose tests need containers
|
|
35
42
|
# (act's default job mode, testcontainers, compose, image builds). Nothing
|
|
36
43
|
# starts the daemon at boot — the wrapper below does, on the first `docker`
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.251.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.251.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"web",
|
|
@@ -20445,7 +20445,7 @@
|
|
|
20445
20445
|
},
|
|
20446
20446
|
"packages/switchboard": {
|
|
20447
20447
|
"name": "@coreplane/switchboard",
|
|
20448
|
-
"version": "1.
|
|
20448
|
+
"version": "1.251.0",
|
|
20449
20449
|
"license": "Apache-2.0",
|
|
20450
20450
|
"dependencies": {
|
|
20451
20451
|
"@earendil-works/pi-ai": "0.85.1",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.251.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"cli": "tsx src/cli.ts",
|
|
40
40
|
"verify": "npm run verify:root && npm run verify --workspaces --if-present && npm run check:site",
|
|
41
41
|
"verify:root": "npm run check:consistency && npm run typecheck && npm run lint && npm run format:check && npm test && npm run check:dist",
|
|
42
|
-
"check:consistency": "npm run check:lockfile && npm run check:sandbox-pair && npm run skills:check && npm run licenses:check && npm run docs:check && npm run pr-title:check && npm run specs:check && npm run decisions:check && npm run hygiene:check && npm run check:project-facts && npm run agents:check && npm run clock:check && npm run screenshots:check",
|
|
42
|
+
"check:consistency": "npm run check:lockfile && npm run check:sandbox-pair && npm run skills:check && npm run licenses:check && npm run docs:check && npm run pr-title:check && npm run specs:check && npm run decisions:check && npm run hygiene:check && npm run check:project-facts && npm run check:registry-drift && npm run agents:check && npm run clock:check && npm run screenshots:check",
|
|
43
43
|
"ci:gate": "node scripts/ci-gate.mjs",
|
|
44
44
|
"fix": "npm run docs:gen && npm run pr-title:gen && npm run agents:gen && npm run clock:gen && npm run hygiene:gen && npm run deploy:gen && npm run skills:sync && npm run lint:fix && npm run format",
|
|
45
45
|
"deploy:gen": "npm run --silent cli -- deploy init",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"pr-title:gen": "node scripts/pr-title-vocabulary.mjs --write",
|
|
51
51
|
"pr-title:check": "node scripts/pr-title-vocabulary.mjs",
|
|
52
52
|
"check:project-facts": "node scripts/check-project-facts.mjs",
|
|
53
|
+
"check:registry-drift": "tsx scripts/registry-drift.ts",
|
|
53
54
|
"agents:gen": "tsx scripts/agents-gen.ts",
|
|
54
55
|
"clock:gen": "tsx scripts/clock-allowlist.mts --write",
|
|
55
56
|
"clock:check": "tsx scripts/clock-allowlist.mts",
|
package/dist/assets/project.json
CHANGED
|
@@ -127,6 +127,10 @@
|
|
|
127
127
|
"does": "Every copy of the project's names, repository, docs URL and contact address equals `project.json`; its description, topics and npm package fit their rules.",
|
|
128
128
|
"when": "After editing `project.json` or a community file; part of `check:consistency`."
|
|
129
129
|
},
|
|
130
|
+
"check:registry-drift": {
|
|
131
|
+
"does": "Every example-config model ref still resolves against the pinned pi registry.",
|
|
132
|
+
"when": "After a pi bump; part of `check:consistency`."
|
|
133
|
+
},
|
|
130
134
|
"skills:sync": {
|
|
131
135
|
"does": "Vendors the skills listed in `skills/manifest.yaml`.",
|
|
132
136
|
"when": "After changing the manifest; part of `fix`."
|
package/dist/assets/source.json
CHANGED
|
@@ -148,7 +148,7 @@ export function statusCardRule(examples = '"Implement the fix", "Run the test su
|
|
|
148
148
|
);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
const PR_DESCRIPTION_TEMPLATE = `PR description — submit it with the submit_pr_description tool for EVERY PR (this is the default, not something to wait to be asked for). Switchboard renders the GitHub body from the object you submit, so never author PR-body markdown yourself. Before submitting, judge your title with the ${PR_TITLE_GUARD} gate — \`npm run check:pr-title -- "<title>"\` — and submit only a title it accepts; the same gate refuses the PR in CI, and on Switchboard's own repository the tool refuses the same titles with the gate's own sentence, so fix the title and resubmit rather than pushing on. The body is a fixed-size MAP for the reader with everything for agents collapsed under it; every field is capped in visible characters (a link's URL is not counted) and the tool refuses an object over a cap naming
|
|
151
|
+
const PR_DESCRIPTION_TEMPLATE = `PR description — submit it with the submit_pr_description tool for EVERY PR (this is the default, not something to wait to be asked for). Switchboard renders the GitHub body from the object you submit, so never author PR-body markdown yourself. Before submitting, judge your title with the ${PR_TITLE_GUARD} gate — \`npm run check:pr-title -- "<title>"\` — and submit only a title it accepts; the same gate refuses the PR in CI, and on Switchboard's own repository the tool refuses the same titles with the gate's own sentence, so fix the title and resubmit rather than pushing on. The body is a fixed-size MAP for the reader with everything for agents collapsed under it; every field is capped in visible characters (a link's URL is not counted) and the tool refuses an object over a cap naming every field over it with the count, how many visible characters to remove and a prefix that fits — take the prefix as is, or remove at least that many visible characters (shortening a URL removes nothing), and resubmit once. Prose is unwrapped — no hard line breaks inside a paragraph. Always hyperlink the triggering issue/request. Never fabricate validation — state exactly what you ran and the real result. BEFORE authoring the pointers, load the \`pr-description\` skill with use_skill — it defines how to choose at most seven pointers, the mechanical anchor rules and what goes below the fold; follow it for every PR.
|
|
152
152
|
EVERY PR includes one that already exists when you push — opened by a person, by dependabot, or by an earlier run. After EVERY push to such a PR: read its current title and body (\`github_issue_get\` with the PR number works for pull requests; \`gh pr view\` where gh exists), judge them against the change as it now stands at the pushed head, and submit the object that describes the PR as it is NOW — carry forward what the existing body says that is still true (a dependency bump's release notes belong in why), add what you changed, and anchor the pointers at the new head. Switchboard replaces the PR's title and body with your rendering. A description that describes an earlier state of its branch is a bug; "it is someone else's PR" is never a reason to leave it.
|
|
153
153
|
- **title** (≤72 characters in all): the changelog line — \`type(scope): what a reader can now do or expect\`, one change, one clause, present tense. The type and the scope spend the same budget as the description, so the description is short; the body carries the rest. The tool refuses a longer title naming the count, and so does the gate in CI.
|
|
154
154
|
- **TL;DR** (\`tldr\`, rendered first, ≤300): two sentences for a reader with zero context — what this PR does and why it matters.
|
|
@@ -91,6 +91,12 @@ export const POLICY: readonly Rule[] = [
|
|
|
91
91
|
// `all` and a named `grants` entry hold it).
|
|
92
92
|
{ action: "costs:write", resource: "command", when: [grant("costs:write")] },
|
|
93
93
|
|
|
94
|
+
// ── providers ────────────────────────────────────────────────────────────
|
|
95
|
+
// `providers check` reads the provider's own endpoints on request: a browser
|
|
96
|
+
// session's read baseline, a grant everywhere else — an on-request provider
|
|
97
|
+
// read is never a chat baseline, like the costs reads.
|
|
98
|
+
{ action: "providers:read", resource: "command", when: [grant("providers:read")] },
|
|
99
|
+
|
|
94
100
|
// ── repos ────────────────────────────────────────────────────────────────
|
|
95
101
|
{ action: "repo:read", resource: "command", when: [grant("repo:read")] },
|
|
96
102
|
{ action: "repo:write", resource: "repo", when: [grant("repo:write")] },
|
|
@@ -129,6 +135,10 @@ export const POLICY: readonly Rule[] = [
|
|
|
129
135
|
// scope from anywhere, a private one only from inside it, `unknown` never.
|
|
130
136
|
{ action: "config:read", resource: "config-scope", resourceKind: "channel", when: [grant("config:write")] },
|
|
131
137
|
{ action: "config:read", resource: "config-scope", resourceKind: "channel", when: [MEMBER_OF] },
|
|
138
|
+
// A THREAD's scope (`config set thread`, routing-and-config item 27): the
|
|
139
|
+
// channel-config right — whoever may set the channel may set a thread in it;
|
|
140
|
+
// never a baseline, so membership alone admits nobody.
|
|
141
|
+
{ action: "config:write", resource: "config-scope", resourceKind: "thread", when: [grant("config:write")] },
|
|
132
142
|
// A user edits only their own scope.
|
|
133
143
|
{ action: "config:write", resource: "config-scope", resourceKind: "user", when: [IS_SELF] },
|
|
134
144
|
|
|
@@ -31,14 +31,14 @@ export type AttributeName = Exclude<keyof ResourceAttributes, "visibility">;
|
|
|
31
31
|
/** The kinds each kinded resource type takes. Types absent here are not kinded. */
|
|
32
32
|
export const RESOURCE_KINDS: { readonly [T in ResourceType]?: readonly KindOf<T>[] } = {
|
|
33
33
|
"memory-scope": ["org", "user", "repo", "channel"],
|
|
34
|
-
"config-scope": ["channel", "user", "org"],
|
|
34
|
+
"config-scope": ["channel", "user", "thread", "org"],
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
/** A resource type, or `type/kind` for the kinded ones — the unit a rule row targets. */
|
|
38
38
|
export type Target =
|
|
39
39
|
| Exclude<ResourceType, "memory-scope" | "config-scope">
|
|
40
40
|
| `memory-scope/${"org" | "user" | "repo" | "channel"}`
|
|
41
|
-
| `config-scope/${"channel" | "user" | "org"}`;
|
|
41
|
+
| `config-scope/${"channel" | "user" | "thread" | "org"}`;
|
|
42
42
|
|
|
43
43
|
export const TARGET_ATTRIBUTES: Readonly<Record<Target, readonly AttributeName[]>> = {
|
|
44
44
|
run: ["channelId", "userId", "repo"],
|
|
@@ -50,6 +50,9 @@ export const TARGET_ATTRIBUTES: Readonly<Record<Target, readonly AttributeName[]
|
|
|
50
50
|
repo: ["repo"],
|
|
51
51
|
"config-scope/channel": ["channelId"],
|
|
52
52
|
"config-scope/user": ["userId"],
|
|
53
|
+
// A thread key carries no attribute a condition reads: the one row on it is
|
|
54
|
+
// a bare grant check (the channel-config right, `config set thread`).
|
|
55
|
+
"config-scope/thread": [],
|
|
53
56
|
"config-scope/org": [],
|
|
54
57
|
agent: ["name"],
|
|
55
58
|
command: [],
|
|
@@ -136,6 +139,7 @@ export function attributesOf(resource: Resource): ResourceAttributes {
|
|
|
136
139
|
return { channelId: resource.id, visibility: "unknown", channelVisibility: resource.visibility ?? "unknown" };
|
|
137
140
|
case "user":
|
|
138
141
|
return { userId: resource.id, visibility: "unknown" };
|
|
142
|
+
case "thread":
|
|
139
143
|
case "org":
|
|
140
144
|
return { visibility: "unknown" };
|
|
141
145
|
}
|