@coreplane/switchboard 1.236.0 → 1.237.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/deploy/cloudflare-resident/worker.ts +175 -62
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +36 -10
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/core/authz/authorize.ts +25 -6
- package/dist/assets/src/core/authz/policy.ts +9 -0
- package/dist/assets/src/core/authz/types.ts +15 -1
- package/dist/assets/src/core/coordinator/driver.ts +5 -1
- package/dist/assets/src/core/runEvents.ts +10 -6
- package/dist/assets/src/core/ship/coordinator.ts +23 -1
- package/dist/assets/src/execution/residentDepsStore.ts +14 -2
- package/dist/assets/src/execution/residentDiskBudget.ts +45 -11
- package/dist/assets/src/execution/residentHead.ts +34 -8
- package/dist/assets/src/execution/residentRebind.ts +88 -31
- package/dist/assets/src/execution/sandboxLifecycle.ts +33 -0
- package/dist/assets/web/dist/.vite/manifest.json +408 -408
- package/dist/assets/web/dist/assets/AppShell-MYHNxJks.js +1 -0
- package/dist/assets/web/dist/assets/{CostsPage-BfJsol9y.js → CostsPage-DKk9OjB0.js} +1 -1
- package/dist/assets/web/dist/assets/{DeliveryPage-CvInfu7y.js → DeliveryPage-Kz6js49i.js} +1 -1
- package/dist/assets/web/dist/assets/{NotFoundPage-d37EP8hS.js → NotFoundPage-BXLrQQzy.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-DQSD0SZD.js → ResidentDetailPage-Dc23v_8v.js} +1 -1
- package/dist/assets/web/dist/assets/ResidentsIndexPage-TR5D1TRR.js +1 -0
- package/dist/assets/web/dist/assets/{RunFoldRow-Bngn4GjF.js → RunFoldRow-mkUj7WEN.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-a-tMrirB.js → RunRoutePage-CQI17__g.js} +3 -3
- package/dist/assets/web/dist/assets/RunsIndexPage-yIP4PlKP.js +1 -0
- package/dist/assets/web/dist/assets/{RunsTabs-CyCdOhJ2.js → RunsTabs-XpCqeWu4.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-ByCmyrDs.js → ScheduledPage-CdCVyNbh.js} +1 -1
- package/dist/assets/web/dist/assets/SettingsPage-DuA8JpLU.js +1 -0
- package/dist/assets/web/dist/assets/{StatusDot-D-BPSPpw.js → StatusDot-DmNHX6am.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DPDx5tXT.js → Tooltip-qVnJTgSt.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-0eiekTHQ.js → UnitRoutePage-BcTeH7m2.js} +1 -1
- package/dist/assets/web/dist/assets/{angular-html-Ni1Xil2G.js → angular-html-DYtV9uIN.js} +1 -1
- package/dist/assets/web/dist/assets/{angular-ts-oCgo6p05.js → angular-ts-DF2P25Zp.js} +1 -1
- package/dist/assets/web/dist/assets/{apl-CiWYl0gh.js → apl-Ff96wxko.js} +1 -1
- package/dist/assets/web/dist/assets/{astro-B-VeKZ_J.js → astro-LbBnZna-.js} +1 -1
- package/dist/assets/web/dist/assets/{blade-BIWEISKd.js → blade-DltuxeBW.js} +1 -1
- package/dist/assets/web/dist/assets/{c-DHxA5Byp.js → c-DBg7yuPx.js} +1 -1
- package/dist/assets/web/dist/assets/{chapel-D4qGLw-Q.js → chapel-C83hUAds.js} +1 -1
- package/dist/assets/web/dist/assets/{cobol-BznX5Q-u.js → cobol-DB0yV_2D.js} +1 -1
- package/dist/assets/web/dist/assets/{coffee-CVr8Idlj.js → coffee-Bpih6Kzl.js} +1 -1
- package/dist/assets/web/dist/assets/{cpp-wrk6NfW4.js → cpp-CU00ZeRn.js} +1 -1
- package/dist/assets/web/dist/assets/{crystal-CGYWMeH1.js → crystal-f8ImJkux.js} +1 -1
- package/dist/assets/web/dist/assets/{css-BgGytMsy.js → css-4VrC4ISo.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-DSkVB16z.js → dist-TAGawIfM.js} +2 -2
- package/dist/assets/web/dist/assets/{durationTone-DYogEPEk.js → durationTone-CYMux25J.js} +1 -1
- package/dist/assets/web/dist/assets/{edge-CdR6AU9v.js → edge-BwFDzfS_.js} +1 -1
- package/dist/assets/web/dist/assets/{elixir-CQRb3PZx.js → elixir-wwIYIpJm.js} +1 -1
- package/dist/assets/web/dist/assets/{elm-CpuA6W8B.js → elm-DyDuM3v3.js} +1 -1
- package/dist/assets/web/dist/assets/{erb-CvlrblqH.js → erb-DFJBEVrh.js} +1 -1
- package/dist/assets/web/dist/assets/{git-rebase-COnovCVc.js → git-rebase-CuDv-44X.js} +1 -1
- package/dist/assets/web/dist/assets/{glimmer-js-C-DS4g_J.js → glimmer-js-ZFnrm4J3.js} +1 -1
- package/dist/assets/web/dist/assets/{glimmer-ts-DTkorWRI.js → glimmer-ts-XY4Yqshp.js} +1 -1
- package/dist/assets/web/dist/assets/{glsl-CH51-JW7.js → glsl-xnR8efx0.js} +1 -1
- package/dist/assets/web/dist/assets/{graphql-BO-XymNv.js → graphql-D-gO5tnP.js} +1 -1
- package/dist/assets/web/dist/assets/{hack-C-N0oicf.js → hack-CSxbW4ax.js} +1 -1
- package/dist/assets/web/dist/assets/{haml-BfmdtWoP.js → haml-D4ar42QT.js} +1 -1
- package/dist/assets/web/dist/assets/{handlebars-BqhO_te-.js → handlebars-CMLbDE7o.js} +1 -1
- package/dist/assets/web/dist/assets/{html-DUEZlTKV.js → html-CVekU4Fs.js} +1 -1
- package/dist/assets/web/dist/assets/{html-derivative-DOUJTS8A.js → html-derivative-St3HHw_N.js} +1 -1
- package/dist/assets/web/dist/assets/{http-j7UH0Cqv.js → http-Mon0DWHS.js} +1 -1
- package/dist/assets/web/dist/assets/{hurl-D6ZaBLJk.js → hurl-Cbl5IvBs.js} +1 -1
- package/dist/assets/web/dist/assets/{indexRow-BjXBzI5Z.js → indexRow-FLJu5mR9.js} +1 -1
- package/dist/assets/web/dist/assets/{java-dugWx3wi.js → java-Bprck1l-.js} +1 -1
- package/dist/assets/web/dist/assets/{javascript-CK4GC4JO.js → javascript-C73G1-UR.js} +1 -1
- package/dist/assets/web/dist/assets/{jinja-DrLKIyKG.js → jinja-C4DuRxYJ.js} +1 -1
- package/dist/assets/web/dist/assets/{jison-CS1Th-TO.js → jison-B2U03GEC.js} +1 -1
- package/dist/assets/web/dist/assets/{json-CkBqdVys.js → json-CvTZUDzx.js} +1 -1
- package/dist/assets/web/dist/assets/{jsx-CCxdg7n3.js → jsx-DMWKCzwo.js} +1 -1
- package/dist/assets/web/dist/assets/{julia-BalKW2hA.js → julia-Czn7bV_3.js} +1 -1
- package/dist/assets/web/dist/assets/{just-BKw1G40D.js → just-BBxpNfvC.js} +1 -1
- package/dist/assets/web/dist/assets/{latex-Dfad6ZN-.js → latex-Bw9yg5mr.js} +1 -1
- package/dist/assets/web/dist/assets/{liquid-f_CMGBvd.js → liquid-BRCjaydE.js} +1 -1
- package/dist/assets/web/dist/assets/{lua-BcMK_uKM.js → lua-DUu2KIC0.js} +1 -1
- package/dist/assets/web/dist/assets/main-csihqEj5.css +1 -0
- package/dist/assets/web/dist/assets/{main-DR8iKpNt.js → main-mUd_TJjH.js} +2 -2
- package/dist/assets/web/dist/assets/{marko-B0oNLx2i.js → marko-CZJZIlSQ.js} +1 -1
- package/dist/assets/web/dist/assets/{mdc-Bc_MmIyq.js → mdc-BmClq6fd.js} +1 -1
- package/dist/assets/web/dist/assets/{nginx-Cmxv8RIt.js → nginx-Bmt3xSEm.js} +1 -1
- package/dist/assets/web/dist/assets/{nim-BtgRfaY0.js → nim-Da-i0utK.js} +1 -1
- package/dist/assets/web/dist/assets/{org-Dl9EzlHh.js → org-BvAre2qP.js} +1 -1
- package/dist/assets/web/dist/assets/{perl-BmF7x7BB.js → perl-v9WwFFtB.js} +1 -1
- package/dist/assets/web/dist/assets/{php-rH6CVptQ.js → php-DTU4Zyze.js} +1 -1
- package/dist/assets/web/dist/assets/{pug-CIN5Ccck.js → pug-DhTwEqTO.js} +1 -1
- package/dist/assets/web/dist/assets/{qml-BpLS3RFr.js → qml-BUD9AC_c.js} +1 -1
- package/dist/assets/web/dist/assets/{r-BUMF3B-H.js → r-CAnu56n7.js} +1 -1
- package/dist/assets/web/dist/assets/{razor-B7sTBPdT.js → razor-Dn0P4gAq.js} +1 -1
- package/dist/assets/web/dist/assets/{regexp-h2SvwrsJ.js → regexp-CZyBRaVW.js} +1 -1
- package/dist/assets/web/dist/assets/residentsModel-By1fThvk.js +1 -0
- package/dist/assets/web/dist/assets/{rst-DLOWEsD2.js → rst-Ckyd5UsY.js} +1 -1
- package/dist/assets/web/dist/assets/{ruby-Cl_-I4k3.js → ruby-BBQ62fsx.js} +1 -1
- package/dist/assets/web/dist/assets/{sas-Cm4M-BvV.js → sas-CW35hMxG.js} +1 -1
- package/dist/assets/web/dist/assets/{scss-CEFCEQUF.js → scss-CpV8pF4J.js} +1 -1
- package/dist/assets/web/dist/assets/{shellscript-CE6zb5eS.js → shellscript-CRJvsmE1.js} +1 -1
- package/dist/assets/web/dist/assets/{shellsession-B4fDVzrM.js → shellsession-CyriUZxf.js} +1 -1
- package/dist/assets/web/dist/assets/{soy-DwXCMpjE.js → soy-D5QzYUeg.js} +1 -1
- package/dist/assets/web/dist/assets/{sql-DrnAKnyD.js → sql-CUBf_Mse.js} +1 -1
- package/dist/assets/web/dist/assets/{stata-BzlwPWD6.js → stata-B-4n4ITz.js} +1 -1
- package/dist/assets/web/dist/assets/{surrealql-CeUieZXd.js → surrealql-RuWZBLtr.js} +1 -1
- package/dist/assets/web/dist/assets/{svelte-BERIpIKR.js → svelte-CO5YzCsK.js} +1 -1
- package/dist/assets/web/dist/assets/{templ-6FSuUDwg.js → templ-DbkWVaBF.js} +1 -1
- package/dist/assets/web/dist/assets/{tex-SWCyMwNX.js → tex-IuxoEcId.js} +1 -1
- package/dist/assets/web/dist/assets/{ts-tags-VWj7gWgY.js → ts-tags-g6dGQPjd.js} +1 -1
- package/dist/assets/web/dist/assets/{tsx-B6dzfL8L.js → tsx-WnXwiyH2.js} +1 -1
- package/dist/assets/web/dist/assets/{twig-BDXsr482.js → twig-Lx1G7PnY.js} +1 -1
- package/dist/assets/web/dist/assets/{typescript-s100Dt8y.js → typescript-BQd7biLF.js} +1 -1
- package/dist/assets/web/dist/assets/{typst-WfyEAUdt.js → typst-D2lXPHK7.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-CkXOOJ-6.js → vue-4wVQD9y9.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-html-CEpzdcau.js → vue-html-DZPaRte4.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-vine-D9dsfiFA.js → vue-vine-DbmZCmu-.js} +1 -1
- package/dist/assets/web/dist/assets/{xml-iBDofbJq.js → xml-DV16szyo.js} +1 -1
- package/dist/assets/web/dist/assets/{xsl-CqbJF4hD.js → xsl-eo5-1sk0.js} +1 -1
- package/dist/assets/web/dist/assets/{yaml-D9i6OxYe.js → yaml-BXdX8HZf.js} +1 -1
- package/dist/cli.js +960 -520
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/AppShell-DZdz_sl7.js +0 -1
- package/dist/assets/web/dist/assets/ResidentsIndexPage-CZUGLen6.js +0 -1
- package/dist/assets/web/dist/assets/RunsIndexPage-s0VKmRda.js +0 -1
- package/dist/assets/web/dist/assets/SettingsPage-DhKEokGK.js +0 -1
- package/dist/assets/web/dist/assets/main-B77vAZVg.css +0 -1
- package/dist/assets/web/dist/assets/residentsModel-VSJEepL1.js +0 -1
|
@@ -252,7 +252,13 @@ import {
|
|
|
252
252
|
type DiskSample,
|
|
253
253
|
type ThreadCostKind,
|
|
254
254
|
} from "../../src/execution/residentDiskBudget.js";
|
|
255
|
-
import {
|
|
255
|
+
import {
|
|
256
|
+
attachTarget,
|
|
257
|
+
type FetchReason,
|
|
258
|
+
mirrorFetchReason,
|
|
259
|
+
parseWantSha,
|
|
260
|
+
wantShaForBinding,
|
|
261
|
+
} from "../../src/execution/residentHead.js";
|
|
256
262
|
import { residentText, sanitizeResidentBody } from "../../src/execution/residentText.js";
|
|
257
263
|
import {
|
|
258
264
|
abandonedWaitStepResult,
|
|
@@ -297,6 +303,9 @@ import {
|
|
|
297
303
|
parseDepsStoreListing,
|
|
298
304
|
planDepsEviction,
|
|
299
305
|
planDepsMaterialization,
|
|
306
|
+
DEPS_STORE_MAX_UNREFERENCED_UNDER_PRESSURE,
|
|
307
|
+
type DepsEvictionPlan,
|
|
308
|
+
type DepsStoreListing,
|
|
300
309
|
} from "../../src/execution/residentDepsStore.js";
|
|
301
310
|
import { buildId, injectedBuildStamp } from "../../src/deploy/buildStamp.js";
|
|
302
311
|
import { createRefreshInstance, createRefreshInstanceNow, type RefreshInstanceParams } from "./refresh";
|
|
@@ -1002,10 +1011,11 @@ interface ResidentRecord {
|
|
|
1002
1011
|
interface ThreadBinding {
|
|
1003
1012
|
threadKey: string;
|
|
1004
1013
|
ref: string;
|
|
1005
|
-
/** How `ref` was chosen
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1008
|
-
*
|
|
1014
|
+
/** How `ref` was chosen: the repo default for want of a named branch — when
|
|
1015
|
+
* the binding was made (item 30), or when a return put it there because the
|
|
1016
|
+
* branch it was on is gone (`returned`) — or a branch someone named. Absent
|
|
1017
|
+
* on a binding made before the field: read as `default` iff `ref` is the
|
|
1018
|
+
* default branch. Only a `default` binding may ever move onto a branch. */
|
|
1009
1019
|
boundBy?: BoundBy;
|
|
1010
1020
|
/** The move a binding may make (item 16): from the default it was bound to,
|
|
1011
1021
|
* onto the branch the thread's own run opened a pull request on. Set, the
|
|
@@ -1013,6 +1023,12 @@ interface ThreadBinding {
|
|
|
1013
1023
|
* branch was gone from the mirror and the binding went back to the
|
|
1014
1024
|
* default), and then for the next own pull request. */
|
|
1015
1025
|
rebound?: Rebound;
|
|
1026
|
+
/** The binding's last move back to the default (item 16's second movement):
|
|
1027
|
+
* the branch it was on — one a rebind moved it onto, or one this thread's
|
|
1028
|
+
* own runs pushed (`ownBranches`) and bound by name, a ship unit's — was
|
|
1029
|
+
* gone from the mirror at an attach. From where, to where, which pull
|
|
1030
|
+
* request's branch it was, when. */
|
|
1031
|
+
returned?: Returned;
|
|
1016
1032
|
/** The branches the thread's own runs pushed and the pull requests they
|
|
1017
1033
|
* head, as each run's release handed them over (`/detach` `pushed`, item
|
|
1018
1034
|
* 16a) — written before any eviction decision, so the fact outlives the
|
|
@@ -1122,9 +1138,10 @@ interface AttachOk {
|
|
|
1122
1138
|
* and why — so the bot can say where the follow-up runs and why. */
|
|
1123
1139
|
rebindRefused?: RebindRefused;
|
|
1124
1140
|
/** This attach moved the binding BACK to the default branch (item 16's
|
|
1125
|
-
* second movement): the branch
|
|
1126
|
-
* mirror, so the tree was provisioned on
|
|
1127
|
-
* failing `unknown-ref`. From where, to
|
|
1141
|
+
* second movement): the branch it was on — a rebind's, or one this thread
|
|
1142
|
+
* itself pushed — is gone from the mirror, so the tree was provisioned on
|
|
1143
|
+
* the default instead of the attach failing `unknown-ref`. From where, to
|
|
1144
|
+
* where, which PR's branch it was. */
|
|
1128
1145
|
returned?: Returned;
|
|
1129
1146
|
}
|
|
1130
1147
|
|
|
@@ -2094,13 +2111,20 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
2094
2111
|
throw new StepError("cat-file", `git cat-file exited ${r.exitCode}: ${r.stderr.trim() || "(no output)"}`);
|
|
2095
2112
|
}
|
|
2096
2113
|
|
|
2097
|
-
/** Attach's fetch decision (item 51) over the mirror's actual state
|
|
2098
|
-
* is
|
|
2099
|
-
*
|
|
2100
|
-
|
|
2114
|
+
/** Attach's fetch decision (item 51) over the mirror's actual state — why
|
|
2115
|
+
* the mirror is fetched before the tree is cloned, or null: the ref is
|
|
2116
|
+
* missing, `wantSha` names a commit the ref's tip is not at, or the ref is
|
|
2117
|
+
* one the binding could return from (item 16's second movement) and is
|
|
2118
|
+
* verified against the origin before the mirror's word on it is trusted.
|
|
2119
|
+
* The decision itself is the pure, tested `mirrorFetchReason`. */
|
|
2120
|
+
private async mirrorFetchReasonFor(
|
|
2121
|
+
ref: string,
|
|
2122
|
+
wantSha: string | null,
|
|
2123
|
+
returnable: boolean,
|
|
2124
|
+
): Promise<FetchReason | null> {
|
|
2101
2125
|
const refExists = await this.refExists(ref);
|
|
2102
2126
|
const mirrorSha = refExists && wantSha !== null ? await this.readMirrorSha(ref) : undefined;
|
|
2103
|
-
return
|
|
2127
|
+
return mirrorFetchReason({ refExists, mirrorSha, wantSha, returnable });
|
|
2104
2128
|
}
|
|
2105
2129
|
|
|
2106
2130
|
/** Dependency/build cache key: sha256 over the ls-tree lines (mode,
|
|
@@ -4067,12 +4091,36 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4067
4091
|
committedKiB: this.diskCommittedKiB,
|
|
4068
4092
|
diskBudgetMb: input.record.diskBudgetMb,
|
|
4069
4093
|
kind,
|
|
4094
|
+
// A deps-store entry backup stages its archive from the deps (item
|
|
4095
|
+
// 61); the reserve holds their share only while one runs.
|
|
4096
|
+
depsBackupInFlight: this.depsBackupsInFlight.size > 0,
|
|
4070
4097
|
});
|
|
4071
4098
|
let rawFree = df.freeKiB;
|
|
4072
4099
|
let verdict = decide(rawFree);
|
|
4073
4100
|
const evicted: Array<{ threadKey: string; freedKiB: number | null }> = [];
|
|
4074
4101
|
// Item 62: keep decisions travel as tokens; the key stays for the log line.
|
|
4075
4102
|
const kept: Array<{ threadKey: string; why: DiskKeepWhy }> = [];
|
|
4103
|
+
// The deps store's spares go first (item 55): a warm cache no live tree
|
|
4104
|
+
// references, worth minutes on a future attach, against a tree refused
|
|
4105
|
+
// now. Their bytes come back before any idle tree is considered.
|
|
4106
|
+
let spares: Array<{ key: string; freedKiB: number }> = [];
|
|
4107
|
+
if (!verdict.fits) {
|
|
4108
|
+
spares = await this.evictDepsSparesUnderPressure().catch((err) => {
|
|
4109
|
+
console.log(`disk-pressure: deps spare eviction failed: ${errMsg(err)}`);
|
|
4110
|
+
return [];
|
|
4111
|
+
});
|
|
4112
|
+
if (spares.length > 0) {
|
|
4113
|
+
console.log(
|
|
4114
|
+
`disk-pressure: evicted ${spares.length} deps-store spare(s) (${formatGiB(spares.reduce((a, e) => a + e.freedKiB, 0))} back) to make room for ${input.threadKey}`,
|
|
4115
|
+
);
|
|
4116
|
+
rawFree = rawFreeAfterEviction(
|
|
4117
|
+
rawFree,
|
|
4118
|
+
await this.dfSample(),
|
|
4119
|
+
spares.reduce((a, e) => a + e.freedKiB, 0),
|
|
4120
|
+
);
|
|
4121
|
+
verdict = decide(rawFree);
|
|
4122
|
+
}
|
|
4123
|
+
}
|
|
4076
4124
|
if (!verdict.fits) {
|
|
4077
4125
|
const live = await this.liveBindings();
|
|
4078
4126
|
const candidates: DiskEvictionCandidate[] = live.map((b) => ({
|
|
@@ -4119,7 +4167,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4119
4167
|
// truthiness does not narrow a boolean-literal discriminant.
|
|
4120
4168
|
const final = verdict;
|
|
4121
4169
|
if (final.fits === false) {
|
|
4122
|
-
const reason = diskPressureReason({ verdict: final, evicted, kept });
|
|
4170
|
+
const reason = diskPressureReason({ verdict: final, evicted, kept, spares });
|
|
4123
4171
|
console.log(`attach ${input.threadKey}: ${reason}`);
|
|
4124
4172
|
const s = await this.getStatus();
|
|
4125
4173
|
return { error: reason, status: 503, state: s.state, reason: DISK_PRESSURE_REASON };
|
|
@@ -4127,7 +4175,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4127
4175
|
const m = final.math;
|
|
4128
4176
|
console.log(
|
|
4129
4177
|
`attach ${input.threadKey}: disk admitted — ${kind} ${formatGiB(m.projectedKiB)} projected, ${formatGiB(m.freeKiB)} free, ` +
|
|
4130
|
-
`reserve ${formatGiB(m.reserve.totalKiB)}, headroom ${formatGiB(m.headroomKiB)}${evicted.length > 0 ? `, evicted ${evicted.map((e) => e.threadKey).join(", ")}` : ""}`,
|
|
4178
|
+
`reserve ${formatGiB(m.reserve.totalKiB)}, headroom ${formatGiB(m.headroomKiB)}${spares.length > 0 ? `, evicted ${spares.length} deps spare(s)` : ""}${evicted.length > 0 ? `, evicted ${evicted.map((e) => e.threadKey).join(", ")}` : ""}`,
|
|
4131
4179
|
);
|
|
4132
4180
|
const committedKiB = m.projectedKiB ?? 0;
|
|
4133
4181
|
this.diskCommittedKiB += committedKiB;
|
|
@@ -4461,12 +4509,13 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4461
4509
|
lastAttachAt: now,
|
|
4462
4510
|
evicted: false,
|
|
4463
4511
|
// An evicted binding's own history: how its ref was chosen (absent on
|
|
4464
|
-
// one made before the field — never overwritten by this attach's flag)
|
|
4465
|
-
//
|
|
4512
|
+
// one made before the field — never overwritten by this attach's flag),
|
|
4513
|
+
// the one move it may have made and its last move back.
|
|
4466
4514
|
...(existing
|
|
4467
4515
|
? {
|
|
4468
4516
|
...(existing.boundBy !== undefined ? { boundBy: existing.boundBy } : {}),
|
|
4469
4517
|
...(existing.rebound !== undefined ? { rebound: existing.rebound } : {}),
|
|
4518
|
+
...(existing.returned !== undefined ? { returned: existing.returned } : {}),
|
|
4470
4519
|
...(existing.ownBranches !== undefined ? { ownBranches: existing.ownBranches } : {}),
|
|
4471
4520
|
}
|
|
4472
4521
|
: { boundBy }),
|
|
@@ -4819,27 +4868,27 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4819
4868
|
|
|
4820
4869
|
/** Item 16's second movement, decided where the fact is established — under
|
|
4821
4870
|
* the mirror mutex, after the attach's fetch found the bound ref gone: a
|
|
4822
|
-
* binding a
|
|
4823
|
-
*
|
|
4824
|
-
*
|
|
4825
|
-
*
|
|
4826
|
-
*
|
|
4827
|
-
*
|
|
4828
|
-
*
|
|
4829
|
-
*
|
|
4830
|
-
*
|
|
4871
|
+
* binding on a branch that is now deleted (its pull request merged) goes
|
|
4872
|
+
* back to the default, so the run starts clean there (item 17) instead of
|
|
4873
|
+
* the thread failing `unknown-ref` for the rest of its life — when the
|
|
4874
|
+
* branch is one a rebind moved it onto, or one this thread's own runs
|
|
4875
|
+
* pushed as the binding remembers it (`ownBranches`): a ship unit's coding
|
|
4876
|
+
* child binds its unit branch by name and pushes it, and the merge deletes
|
|
4877
|
+
* it. The row as it stands is re-read and re-judged (the pure
|
|
4878
|
+
* `returnToDefault`, which writes nothing for a row `canReturnToDefault`
|
|
4879
|
+
* does not admit: a ref a person named that the thread never pushed never
|
|
4880
|
+
* returns — that branch is theirs to sort out), the move recorded so the
|
|
4881
|
+
* thread may follow its next pull request, and the answer carries the move
|
|
4882
|
+
* back for the card. A row that no longer qualifies — another attach moved
|
|
4883
|
+
* it meanwhile — is answered as it stands, nothing written. */
|
|
4831
4884
|
private async returnBindingToDefault(
|
|
4832
4885
|
binding: ThreadBinding,
|
|
4833
4886
|
defaultRef: string,
|
|
4834
4887
|
): Promise<{ binding: ThreadBinding; returned?: Returned }> {
|
|
4835
4888
|
const key = threadBindingKey(binding.threadKey);
|
|
4836
4889
|
const current = (await this.ctx.storage.get<ThreadBinding>(key)) ?? binding;
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
{ ...current, rebound: current.rebound },
|
|
4840
|
-
defaultRef,
|
|
4841
|
-
new Date(systemClock()).toISOString(),
|
|
4842
|
-
);
|
|
4890
|
+
const back = returnToDefault(current, defaultRef, new Date(systemClock()).toISOString());
|
|
4891
|
+
if (back === undefined) return { binding: current };
|
|
4843
4892
|
await this.ctx.storage.put(key, back.binding);
|
|
4844
4893
|
console.log(
|
|
4845
4894
|
`attach ${binding.threadKey}: ${back.returned.from} is gone from the mirror (the thread's own pull request #${back.returned.pr}) — returned to ${back.returned.to}; the tree is provisioned there`,
|
|
@@ -4921,9 +4970,22 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4921
4970
|
// The expected head applies to the ref it was resolved for; a sticky
|
|
4922
4971
|
// binding on another branch drops it (item 51) rather than fetching on
|
|
4923
4972
|
// every attach of a thread that can never be at that commit.
|
|
4973
|
+
// A ref the binding could return from — a branch a rebind moved it onto,
|
|
4974
|
+
// or one its own runs pushed (item 16's second movement) — is fetched for
|
|
4975
|
+
// whether or not the mirror holds it: the branch dies when its pull
|
|
4976
|
+
// request merges, the deletion reaches the mirror only through a
|
|
4977
|
+
// `fetch --prune`, and a mirror trusted for still holding the ref would
|
|
4978
|
+
// provision a tree at the deleted branch's stale tip until the refresh
|
|
4979
|
+
// cycle's prune caught up. One predicate, read once here, drives the mint
|
|
4980
|
+
// pre-check, the fetch under the lock and the return gate alike.
|
|
4924
4981
|
let want = wantShaForBinding({ boundRef: binding.ref, refHint, wantSha });
|
|
4982
|
+
const returnable = canReturnToDefault(binding, facts.defaultRef);
|
|
4925
4983
|
let fetchToken: string | null = token;
|
|
4926
|
-
if (
|
|
4984
|
+
if (
|
|
4985
|
+
!fetchToken &&
|
|
4986
|
+
githubAppConfigured(this.env) &&
|
|
4987
|
+
(await this.mirrorFetchReasonFor(binding.ref, want, returnable)) !== null
|
|
4988
|
+
) {
|
|
4927
4989
|
fetchToken = (await mintRepoScopedToken(this.env, slug).catch(() => null))?.token ?? null;
|
|
4928
4990
|
}
|
|
4929
4991
|
|
|
@@ -4931,14 +4993,27 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4931
4993
|
try {
|
|
4932
4994
|
locked = await this.withMirrorLock(async () => {
|
|
4933
4995
|
await this.ensureGitSetup();
|
|
4934
|
-
const
|
|
4935
|
-
if (
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4996
|
+
const why = await this.mirrorFetchReasonFor(binding.ref, want, returnable);
|
|
4997
|
+
if (why !== null) {
|
|
4998
|
+
try {
|
|
4999
|
+
await this.gitWithCred(
|
|
5000
|
+
fetchToken,
|
|
5001
|
+
["-C", MIRROR_DIR, "fetch", "--prune", "origin"],
|
|
5002
|
+
"fetch",
|
|
5003
|
+
GIT_NETWORK_TIMEOUT_MS,
|
|
5004
|
+
);
|
|
5005
|
+
} catch (err) {
|
|
5006
|
+
// A fetch for a missing or stale ref fails the attach as it always
|
|
5007
|
+
// did. One that only verifies a returnable ref the mirror holds is
|
|
5008
|
+
// not the attach's failure — the origin unreachable, no token for
|
|
5009
|
+
// a private repo — so the attach goes on with the mirror's ref, as
|
|
5010
|
+
// it did before the verification existed; the return is only
|
|
5011
|
+
// ever decided on a fetch that ran.
|
|
5012
|
+
if (why !== "returnable-ref") throw err;
|
|
5013
|
+
console.log(
|
|
5014
|
+
`attach ${threadKey}: the fetch verifying ${binding.ref} at the origin failed (${errMsg(err)}) — the attach goes on with the mirror's ref`,
|
|
5015
|
+
);
|
|
5016
|
+
}
|
|
4942
5017
|
}
|
|
4943
5018
|
// What to check out, now that the mirror is as fresh as it will get
|
|
4944
5019
|
// (item 51, the pure `attachTarget`): the ref's tip when the ref is
|
|
@@ -4947,18 +5022,21 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4947
5022
|
// `refs/pull/N/head` still names its head, and the caller told us that
|
|
4948
5023
|
// head — so a review of a merged PR stays on the warm resident instead
|
|
4949
5024
|
// of falling back to a cold sandbox that clones the same commit itself.
|
|
4950
|
-
// No fetch
|
|
4951
|
-
// for every missing ref, so only a fetched mirror needs the
|
|
4952
|
-
//
|
|
4953
|
-
|
|
5025
|
+
// No fetch was due ⇒ the ref existed at the check: `mirrorFetchReason`
|
|
5026
|
+
// fetches for every missing ref, so only a fetched mirror needs the
|
|
5027
|
+
// re-read — and a returnable ref is re-read for real, the fact the
|
|
5028
|
+
// second movement is decided on. (Under the mirror mutex — the mirror
|
|
5029
|
+
// cannot change in between.)
|
|
5030
|
+
const refExists = why === null || (await this.refExists(binding.ref));
|
|
4954
5031
|
let target = attachTarget({
|
|
4955
5032
|
refExists,
|
|
4956
5033
|
wantSha: want,
|
|
4957
5034
|
commitInMirror: !refExists && want !== null && (await this.commitInMirror(want)),
|
|
4958
5035
|
});
|
|
4959
|
-
if (target.kind === "unknown-ref" &&
|
|
4960
|
-
// The branch a rebind moved
|
|
4961
|
-
//
|
|
5036
|
+
if (target.kind === "unknown-ref" && returnable) {
|
|
5037
|
+
// The thread's branch — one a rebind moved it onto, or one its own
|
|
5038
|
+
// runs pushed and it was bound to by name — is gone even after the
|
|
5039
|
+
// fetch: deleted once its pull request merged. The binding goes
|
|
4962
5040
|
// back to the default (item 16's second movement) and the attach
|
|
4963
5041
|
// goes on there: the default is always in the mirror, and the
|
|
4964
5042
|
// worktree step below provisions the tree at its tip.
|
|
@@ -5713,27 +5791,24 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5713
5791
|
* in flight, and remove what `planDepsEviction` names — debris first, then
|
|
5714
5792
|
* the coldest spares beyond DEPS_STORE_MAX_UNREFERENCED. Housekeeping:
|
|
5715
5793
|
* a failure is a log line, never a lifecycle flip. */
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
const listing = parseDepsStoreListing(listed.stdout);
|
|
5720
|
-
if (listing.entries.length === 0 && listing.leftovers.length === 0) return;
|
|
5794
|
+
/** The store entries no eviction may touch: the checkout's key, every live
|
|
5795
|
+
* binding's, every install and every entry backup in flight. */
|
|
5796
|
+
private async depsProtectedKeys(): Promise<Set<string>> {
|
|
5721
5797
|
const facts = await this.ctx.storage.get<RepoFacts>(FACTS_KEY);
|
|
5722
5798
|
const protectedKeys = new Set<string>([...this.depsInFlight.keys(), ...this.depsBackupsInFlight]);
|
|
5723
5799
|
if (facts?.lockfileHash) protectedKeys.add(facts.lockfileHash);
|
|
5724
5800
|
for (const b of await this.liveBindings()) if (b.depsKey) protectedKeys.add(b.depsKey);
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5801
|
+
return protectedKeys;
|
|
5802
|
+
}
|
|
5803
|
+
|
|
5804
|
+
/** Remove the store entries a plan names and drop their entry backups with
|
|
5805
|
+
* them (item 61 PR B: a spare nothing references on disk is a spare nothing
|
|
5806
|
+
* will wake into). Shared by the sweep and the attach's pressure path. */
|
|
5807
|
+
private async applyDepsEviction(plan: DepsEvictionPlan, listing: DepsStoreListing, who: string): Promise<void> {
|
|
5731
5808
|
await this.runOk(["rm", "-rf", ...plan.remove], "deps-evict", { timeoutMs: DU_TIMEOUT_MS });
|
|
5732
5809
|
console.log(
|
|
5733
|
-
`deps: evicted ${plan.remove.length} path(s) — ${plan.remove.map((p) => p.slice(DEPS_STORE_DIR.length + 1, DEPS_STORE_DIR.length + 9)).join(", ")}; kept ${plan.keep.map((k) => k.slice(0, 8)).join(", ")}`,
|
|
5810
|
+
`deps: ${who} evicted ${plan.remove.length} path(s) — ${plan.remove.map((p) => p.slice(DEPS_STORE_DIR.length + 1, DEPS_STORE_DIR.length + 9)).join(", ")}; kept ${plan.keep.map((k) => k.slice(0, 8)).join(", ")}`,
|
|
5734
5811
|
);
|
|
5735
|
-
// The evicted entries' archives go with them (item 61 PR B): a spare
|
|
5736
|
-
// nothing references on disk is a spare nothing will wake into.
|
|
5737
5812
|
const kept = new Set(plan.keep);
|
|
5738
5813
|
const evictedKeys = listing.entries.map((e) => e.key).filter((k) => !kept.has(k));
|
|
5739
5814
|
const drop = depsBackupsToDrop({
|
|
@@ -5748,6 +5823,44 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5748
5823
|
}
|
|
5749
5824
|
}
|
|
5750
5825
|
|
|
5826
|
+
/** Under disk pressure (item 55) the store keeps no spare: every complete
|
|
5827
|
+
* entry no live tree references goes, coldest first, before any idle tree
|
|
5828
|
+
* is considered — a spare is a cache worth minutes on a future attach, a
|
|
5829
|
+
* refused tree is a run falling to a cold sandbox now. Leftovers are the
|
|
5830
|
+
* sweep's business (they may be live installs' scratch). Answers what it
|
|
5831
|
+
* removed with the bytes each measured at; a listing that fails answers
|
|
5832
|
+
* nothing and the tree path decides as before. */
|
|
5833
|
+
private async evictDepsSparesUnderPressure(): Promise<Array<{ key: string; freedKiB: number }>> {
|
|
5834
|
+
const listed = await this.run(["sh", "-c", depsStoreListScript()], { timeoutMs: DU_TIMEOUT_MS });
|
|
5835
|
+
if (listed.exitCode !== 0) return [];
|
|
5836
|
+
const listing = parseDepsStoreListing(listed.stdout);
|
|
5837
|
+
if (listing.entries.length === 0) return [];
|
|
5838
|
+
const plan = planDepsEviction({
|
|
5839
|
+
entries: listing.entries,
|
|
5840
|
+
leftovers: [],
|
|
5841
|
+
protectedKeys: await this.depsProtectedKeys(),
|
|
5842
|
+
maxUnreferenced: DEPS_STORE_MAX_UNREFERENCED_UNDER_PRESSURE,
|
|
5843
|
+
});
|
|
5844
|
+
if (plan.remove.length === 0) return [];
|
|
5845
|
+
await this.applyDepsEviction(plan, listing, "disk-pressure");
|
|
5846
|
+
return plan.evicted.map((e) => ({ key: e.key, freedKiB: e.kib }));
|
|
5847
|
+
}
|
|
5848
|
+
|
|
5849
|
+
private async sweepDepsStore(): Promise<void> {
|
|
5850
|
+
const listed = await this.run(["sh", "-c", depsStoreListScript()], { timeoutMs: DU_TIMEOUT_MS });
|
|
5851
|
+
if (listed.exitCode !== 0) return;
|
|
5852
|
+
const listing = parseDepsStoreListing(listed.stdout);
|
|
5853
|
+
if (listing.entries.length === 0 && listing.leftovers.length === 0) return;
|
|
5854
|
+
const protectedKeys = await this.depsProtectedKeys();
|
|
5855
|
+
// A scratch/staging dir of an install still running in this incarnation
|
|
5856
|
+
// is live work, not debris; the listing cannot tell them apart, so
|
|
5857
|
+
// leftovers wait for a sweep with nothing in flight.
|
|
5858
|
+
const leftovers = this.depsInFlight.size > 0 ? [] : listing.leftovers;
|
|
5859
|
+
const plan = planDepsEviction({ entries: listing.entries, leftovers, protectedKeys });
|
|
5860
|
+
if (plan.remove.length === 0) return;
|
|
5861
|
+
await this.applyDepsEviction(plan, listing, "sweep");
|
|
5862
|
+
}
|
|
5863
|
+
|
|
5751
5864
|
/** The per-user 700 staging dir (`install -d` is idempotent), memoized per
|
|
5752
5865
|
* incarnation — the write/credential paths used to fork for it on every
|
|
5753
5866
|
* call. Cleared with the other memos; a fresh disk simply re-creates it. */
|
|
@@ -41,6 +41,9 @@ import {
|
|
|
41
41
|
SANDBOX_SLEEP_AFTER,
|
|
42
42
|
isRecycleError,
|
|
43
43
|
recycledMidCommandMessage,
|
|
44
|
+
DETACH_HINT,
|
|
45
|
+
OUTPUT_AFTER_EXIT_MS,
|
|
46
|
+
heldOutputNote,
|
|
44
47
|
} from "../../src/execution/sandboxLifecycle.js";
|
|
45
48
|
import { envFromRequest } from "../../src/execution/sandboxEnv.js";
|
|
46
49
|
import { IdleGuard, type IdleGuardHost } from "../../src/execution/sandboxIdle.js";
|
|
@@ -88,10 +91,17 @@ const EXEC_TIMEOUT_SECS = 280;
|
|
|
88
91
|
* the shell-level 124 is always the deadline a command meets first. */
|
|
89
92
|
const SDK_BACKSTOP_MARGIN_MS = 40_000;
|
|
90
93
|
|
|
91
|
-
/** How long the Durable Object waits for the process's
|
|
92
|
-
* own
|
|
93
|
-
*
|
|
94
|
-
|
|
94
|
+
/** How long the Durable Object waits for the process's output to END past
|
|
95
|
+
* the command's own deadline before it asks the runtime what became of the
|
|
96
|
+
* process (docs/reference/specs/execution.md item 24). The runtime ends the
|
|
97
|
+
* output stream only when every holder of the command's stdout and stderr is
|
|
98
|
+
* gone; a process that exited while a detached child holds them is answered
|
|
99
|
+
* with its exit code and a note, a process still running is killed and
|
|
100
|
+
* answered as the shell-level timeout. The wait is `OUTPUT_AFTER_EXIT_MS`
|
|
101
|
+
* (20 s), inside the executor's per-send margin, so this answer always
|
|
102
|
+
* reaches the bot before it gives the command up — the SDK's own limit
|
|
103
|
+
* (`SDK_BACKSTOP_MARGIN_MS`) stays above it as the last resort. */
|
|
104
|
+
const OUTPUT_WAIT_AFTER_DEADLINE_MS = OUTPUT_AFTER_EXIT_MS;
|
|
95
105
|
|
|
96
106
|
/** The interruption reasons that mean the container's runtime is not the one
|
|
97
107
|
* the command started on: the process, if it started, is gone with its
|
|
@@ -303,7 +313,10 @@ export class SwitchboardSandbox extends Sandbox<Env> {
|
|
|
303
313
|
return this.execFailure(err, startedAt);
|
|
304
314
|
}
|
|
305
315
|
try {
|
|
306
|
-
const out = await proc.output({
|
|
316
|
+
const out = await proc.output({
|
|
317
|
+
encoding: "utf8",
|
|
318
|
+
timeout: execTimeoutSecs * 1000 + OUTPUT_WAIT_AFTER_DEADLINE_MS,
|
|
319
|
+
});
|
|
307
320
|
// coreutils `timeout` exits 124 when the deadline killed the command
|
|
308
321
|
// (137 when the follow-up SIGKILL had to); the SDK's own limit, if it
|
|
309
322
|
// ever wins, reports `timedOut`. All three are the one story.
|
|
@@ -321,9 +334,21 @@ export class SwitchboardSandbox extends Sandbox<Env> {
|
|
|
321
334
|
};
|
|
322
335
|
} catch (err) {
|
|
323
336
|
if (err instanceof ProcessWaitTimeoutError) {
|
|
324
|
-
// The
|
|
325
|
-
//
|
|
326
|
-
//
|
|
337
|
+
// The output never ended inside the command's deadline plus the
|
|
338
|
+
// margin. Two stories, told apart by asking the runtime (item 24):
|
|
339
|
+
// the process EXITED and a detached child holds its output open —
|
|
340
|
+
// the answer is its exit code and the note, the child runs on; or
|
|
341
|
+
// the process is still RUNNING past its own `timeout` — end it, and
|
|
342
|
+
// report the shell-level timeout it exceeded.
|
|
343
|
+
const status = await proc.status().catch(() => null);
|
|
344
|
+
if (status?.state === "exited") {
|
|
345
|
+
return {
|
|
346
|
+
stdout: "",
|
|
347
|
+
stderr: heldOutputNote(status.exit.code),
|
|
348
|
+
exitCode: status.exit.code,
|
|
349
|
+
durationMs: systemClock() - startedAt,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
327
352
|
await proc.kill(9).catch(() => {});
|
|
328
353
|
return {
|
|
329
354
|
stdout: "",
|
|
@@ -449,11 +474,12 @@ export class SwitchboardSandbox extends Sandbox<Env> {
|
|
|
449
474
|
/** The stderr line an exit 124 carries: the limit, the knob, and the way to
|
|
450
475
|
* outlive a command (`setsid -f`: every /exec runs under `timeout … bash -c`,
|
|
451
476
|
* whose process group is reaped when the command returns, so a plain
|
|
452
|
-
* background job dies with it
|
|
477
|
+
* background job dies with it — and with the wrapper's stdio redirected, so
|
|
478
|
+
* the command's output ends when the command does, item 24). */
|
|
453
479
|
function timeoutNote(execTimeoutSecs: number): string {
|
|
454
480
|
return (
|
|
455
481
|
`command timed out in the sandbox after ${execTimeoutSecs}s (pass the bash tool's timeoutMs for longer commands, max ${BASH_TIMEOUT_MAX_MS} ms); ` +
|
|
456
|
-
|
|
482
|
+
`re-run as smaller/faster steps, or start it detached with \`${DETACH_HINT}\` and poll the log on later calls`
|
|
457
483
|
);
|
|
458
484
|
}
|
|
459
485
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.237.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.237.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"web",
|
|
@@ -20241,7 +20241,7 @@
|
|
|
20241
20241
|
},
|
|
20242
20242
|
"packages/switchboard": {
|
|
20243
20243
|
"name": "@coreplane/switchboard",
|
|
20244
|
-
"version": "1.
|
|
20244
|
+
"version": "1.237.0",
|
|
20245
20245
|
"license": "Apache-2.0",
|
|
20246
20246
|
"dependencies": {
|
|
20247
20247
|
"@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.237.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",
|
package/dist/assets/source.json
CHANGED
|
@@ -63,6 +63,21 @@ export function principalOf(actor: Actor): Actor {
|
|
|
63
63
|
return current;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/** The ids that mean "me" for a decision (record 0042): the root principal's
|
|
67
|
+
* `self` when it carries one — its own id and the person a dashboard session
|
|
68
|
+
* is linked to — else its id alone. Never read by a grant check. */
|
|
69
|
+
export function selfIdsOf(actor: Actor): readonly string[] {
|
|
70
|
+
const principal = principalOf(actor);
|
|
71
|
+
return principal.self && principal.self.length > 0 ? principal.self : [principal.id];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether a self set names a chat identity — the `acts-as-person` condition:
|
|
75
|
+
* every chat actor, a dashboard session linked to its person; never an
|
|
76
|
+
* unlinked browser session or a credential. */
|
|
77
|
+
export function actsAsPerson(selfIds: readonly string[]): boolean {
|
|
78
|
+
return selfIds.some((id) => id.startsWith("slack:"));
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
// ── decision ─────────────────────────────────────────────────────────────────
|
|
67
82
|
|
|
68
83
|
const ALLOW: Decision = Object.freeze({ allow: true });
|
|
@@ -76,11 +91,13 @@ export type DenyReason =
|
|
|
76
91
|
| "missing-grant"
|
|
77
92
|
| "not-member"
|
|
78
93
|
| "not-self"
|
|
94
|
+
| "not-person"
|
|
79
95
|
| "not-owner"
|
|
80
96
|
| "not-all-channels";
|
|
81
97
|
|
|
82
98
|
const FAILURE_REASON: Readonly<Record<Condition["kind"], DenyReason>> = {
|
|
83
99
|
"has-grant": "missing-grant",
|
|
100
|
+
"acts-as-person": "not-person",
|
|
84
101
|
"member-of": "not-member",
|
|
85
102
|
"is-self": "not-self",
|
|
86
103
|
"owner-of": "not-owner",
|
|
@@ -98,7 +115,7 @@ export function isKnownActorKind(kind: string): boolean {
|
|
|
98
115
|
export function evaluateCondition(
|
|
99
116
|
condition: Condition,
|
|
100
117
|
grants: Grants,
|
|
101
|
-
|
|
118
|
+
selfIds: readonly string[],
|
|
102
119
|
attributes: ResourceAttributes,
|
|
103
120
|
): boolean {
|
|
104
121
|
switch (condition.kind) {
|
|
@@ -115,7 +132,9 @@ export function evaluateCondition(
|
|
|
115
132
|
attributes.channelVisibility === "public"
|
|
116
133
|
);
|
|
117
134
|
case "is-self":
|
|
118
|
-
return attributes.userId !== undefined && attributes.userId
|
|
135
|
+
return attributes.userId !== undefined && selfIds.includes(attributes.userId);
|
|
136
|
+
case "acts-as-person":
|
|
137
|
+
return actsAsPerson(selfIds);
|
|
119
138
|
case "owner-of":
|
|
120
139
|
return attributes.repo !== undefined && holds(grants.repos, attributes.repo);
|
|
121
140
|
case "all-channels":
|
|
@@ -134,8 +153,8 @@ export function evaluateRule(rule: Rule, actor: Actor, resource: Resource): bool
|
|
|
134
153
|
const attributes = attributesOf(resource);
|
|
135
154
|
if (rule.originVisibility && !rule.originVisibility.includes(attributes.visibility)) return false;
|
|
136
155
|
const grants = effectiveGrants(actor);
|
|
137
|
-
const
|
|
138
|
-
return rule.when.every((condition) => evaluateCondition(condition, grants,
|
|
156
|
+
const selfIds = selfIdsOf(actor);
|
|
157
|
+
return rule.when.every((condition) => evaluateCondition(condition, grants, selfIds, attributes));
|
|
139
158
|
}
|
|
140
159
|
|
|
141
160
|
/** `authorize` over an explicit (validated) table. Tests use it to drive
|
|
@@ -153,10 +172,10 @@ export function authorizeWith(rules: readonly Rule[], actor: Actor, action: Acti
|
|
|
153
172
|
);
|
|
154
173
|
if (forOrigin.length === 0) return deny("origin-visibility");
|
|
155
174
|
const grants = effectiveGrants(actor);
|
|
156
|
-
const
|
|
175
|
+
const selfIds = selfIdsOf(actor);
|
|
157
176
|
let reason: DenyReason | undefined;
|
|
158
177
|
for (const rule of forOrigin) {
|
|
159
|
-
const failed = rule.when.find((condition) => !evaluateCondition(condition, grants,
|
|
178
|
+
const failed = rule.when.find((condition) => !evaluateCondition(condition, grants, selfIds, attributes));
|
|
160
179
|
if (!failed) return ALLOW;
|
|
161
180
|
reason ??= FAILURE_REASON[failed.kind];
|
|
162
181
|
}
|
|
@@ -41,6 +41,7 @@ import type { ActorKind, Condition, Rule } from "./types.js";
|
|
|
41
41
|
const grant = (g: string): Condition => ({ kind: "has-grant", grant: g });
|
|
42
42
|
const MEMBER_OF: Condition = { kind: "member-of" };
|
|
43
43
|
const IS_SELF: Condition = { kind: "is-self" };
|
|
44
|
+
const ACTS_AS_PERSON: Condition = { kind: "acts-as-person" };
|
|
44
45
|
const OWNER_OF: Condition = { kind: "owner-of" };
|
|
45
46
|
const ALL_CHANNELS: Condition = { kind: "all-channels" };
|
|
46
47
|
|
|
@@ -144,6 +145,13 @@ export const POLICY: readonly Rule[] = [
|
|
|
144
145
|
// ── mcp (external MCP servers live in the three config tiers) ────────────
|
|
145
146
|
{ action: "mcp:read", resource: "command", when: [grant("mcp:read")] },
|
|
146
147
|
{ action: "mcp:write", resource: "command", when: [grant("mcp:write")] },
|
|
148
|
+
// A person always has their own tier to write (`mcp add|connect|remove`
|
|
149
|
+
// for themselves), as `config set me` already is theirs: the row admits an
|
|
150
|
+
// actor whose `self` names a chat identity — every chat user (who holds
|
|
151
|
+
// `mcp:write` by baseline anyway) and a dashboard session linked to its
|
|
152
|
+
// person (record 0042) — never an unlinked browser session or a credential.
|
|
153
|
+
// Which tier the write reaches stays the handler's question below.
|
|
154
|
+
{ action: "mcp:write", resource: "command", actorKinds: ["user"], when: [ACTS_AS_PERSON] },
|
|
147
155
|
// A CHANNEL's servers: the channel-config right for a person; a credential
|
|
148
156
|
// an admin minted with `mcp:write` manages any tier it can name.
|
|
149
157
|
{ action: "mcp:write", resource: "config-scope", resourceKind: "channel", when: [grant("config:write")] },
|
|
@@ -214,6 +222,7 @@ export const CONDITION_KINDS: readonly Condition["kind"][] = [
|
|
|
214
222
|
"has-grant",
|
|
215
223
|
"member-of",
|
|
216
224
|
"is-self",
|
|
225
|
+
"acts-as-person",
|
|
217
226
|
"owner-of",
|
|
218
227
|
"all-channels",
|
|
219
228
|
];
|