@coreplane/switchboard 0.0.0 → 1.18.1
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/LICENSE +201 -0
- package/README.md +17 -1
- package/dist/assets/.dockerignore +27 -0
- package/dist/assets/.env.example +33 -0
- package/dist/assets/Dockerfile +111 -0
- package/dist/assets/config/config.example.yaml +359 -0
- package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
- package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
- package/dist/assets/deploy/bin/cf-logs +32 -0
- package/dist/assets/deploy/cloudflare/package.json +29 -0
- package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
- package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
- package/dist/assets/deploy/cloudflare/worker.ts +382 -0
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
- package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
- package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
- package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
- package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
- package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
- package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
- package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
- package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
- package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
- package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
- package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
- package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
- package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
- package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
- package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/profile.example.json +13 -0
- package/dist/assets/deploy/secrets.manifest.json +108 -0
- package/dist/assets/docker-entrypoint.sh +15 -0
- package/dist/assets/package-lock.json +18407 -0
- package/dist/assets/package.json +104 -0
- package/dist/assets/project.json +219 -0
- package/dist/assets/source.json +5 -0
- package/dist/assets/src/core/authz/actor.ts +100 -0
- package/dist/assets/src/core/authz/authorize.ts +169 -0
- package/dist/assets/src/core/authz/grants.ts +347 -0
- package/dist/assets/src/core/authz/policy.ts +281 -0
- package/dist/assets/src/core/authz/resource.ts +147 -0
- package/dist/assets/src/core/authz/types.ts +164 -0
- package/dist/assets/src/core/drain.ts +54 -0
- package/dist/assets/src/core/ingressTokens.ts +64 -0
- package/dist/assets/src/core/memory/engine.ts +115 -0
- package/dist/assets/src/core/memory/scorer.ts +147 -0
- package/dist/assets/src/core/memory/types.ts +120 -0
- package/dist/assets/src/core/normalizeSpans.ts +299 -0
- package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
- package/dist/assets/src/core/redact.ts +113 -0
- package/dist/assets/src/core/runEvents.ts +537 -0
- package/dist/assets/src/core/runFriction.ts +665 -0
- package/dist/assets/src/core/runLedger/decisions.ts +126 -0
- package/dist/assets/src/core/runLedger/types.ts +177 -0
- package/dist/assets/src/core/runRecord.ts +627 -0
- package/dist/assets/src/core/runShape.ts +61 -0
- package/dist/assets/src/core/schedules.ts +452 -0
- package/dist/assets/src/core/time/formatDuration.ts +61 -0
- package/dist/assets/src/core/trace/attrs.ts +203 -0
- package/dist/assets/src/core/trace/classify.ts +49 -0
- package/dist/assets/src/core/trace/clock.ts +6 -0
- package/dist/assets/src/core/trace/context.ts +9 -0
- package/dist/assets/src/core/trace/ids.ts +23 -0
- package/dist/assets/src/core/trace/partition.ts +235 -0
- package/dist/assets/src/core/trace/sinks.ts +68 -0
- package/dist/assets/src/core/trace/streamSpans.ts +163 -0
- package/dist/assets/src/core/trace/traceparent.ts +29 -0
- package/dist/assets/src/core/trace/tracer.ts +247 -0
- package/dist/assets/src/core/trace/types.ts +125 -0
- package/dist/assets/src/core/trace/workerTrace.ts +97 -0
- package/dist/assets/src/deploy/buildStamp.ts +93 -0
- package/dist/assets/src/deploy/liveGate.ts +203 -0
- package/dist/assets/src/deploy/profile.ts +162 -0
- package/dist/assets/src/deploy/restart.ts +393 -0
- package/dist/assets/src/effort.ts +17 -0
- package/dist/assets/src/execution/bashTimeout.ts +78 -0
- package/dist/assets/src/execution/bindingPurge.ts +43 -0
- package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
- package/dist/assets/src/execution/residentCleanliness.ts +95 -0
- package/dist/assets/src/execution/residentCredentials.ts +81 -0
- package/dist/assets/src/execution/residentDepCache.ts +321 -0
- package/dist/assets/src/execution/residentDepsStore.ts +326 -0
- package/dist/assets/src/execution/residentDetach.ts +48 -0
- package/dist/assets/src/execution/residentDisk.ts +107 -0
- package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
- package/dist/assets/src/execution/residentExecWrap.ts +100 -0
- package/dist/assets/src/execution/residentHead.ts +85 -0
- package/dist/assets/src/execution/residentReadonly.ts +72 -0
- package/dist/assets/src/execution/residentRefresh.ts +429 -0
- package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
- package/dist/assets/src/execution/residentState.ts +47 -0
- package/dist/assets/src/execution/residentStepReport.ts +98 -0
- package/dist/assets/src/execution/residentStepTrace.ts +97 -0
- package/dist/assets/src/execution/residentSteps.ts +99 -0
- package/dist/assets/src/execution/residentText.ts +83 -0
- package/dist/assets/src/execution/residentTrace.ts +119 -0
- package/dist/assets/src/execution/sandboxEnv.ts +42 -0
- package/dist/assets/src/execution/sandboxErrors.ts +159 -0
- package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
- package/dist/assets/src/execution/shellQuote.ts +8 -0
- package/dist/assets/src/mcp/registry.ts +242 -0
- package/dist/assets/src/providers/types.ts +152 -0
- package/dist/assets/web/dist/.vite/manifest.json +176 -0
- package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
- package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
- package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
- package/dist/assets/web/dist/assets/ResidentDetailPage-DvQ05AGa.js +1 -0
- package/dist/assets/web/dist/assets/ResidentsIndexPage-B3uxKUne.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-ty94olNM.js +126 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-CM-qxyQm.js +1 -0
- package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
- package/dist/assets/web/dist/assets/ScheduledPage-C1psvLD4.js +1 -0
- package/dist/assets/web/dist/assets/StatusDot-DuoQnQeU.js +1 -0
- package/dist/assets/web/dist/assets/Tooltip-BfLPyxQy.js +1 -0
- package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
- package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
- package/dist/assets/web/dist/assets/main-Bnbk_Rsg.js +28 -0
- package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
- package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
- package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
- package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
- package/dist/cli.js +34494 -0
- package/package.json +43 -10
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// What a condition may read off a resource.
|
|
2
|
+
//
|
|
3
|
+
// Every condition in the closed vocabulary relates the actor to ONE resource
|
|
4
|
+
// attribute: `member-of` → channelId, `is-self` → userId, `owner-of` → repo,
|
|
5
|
+
// a `has-grant` placeholder → the named attribute. `TARGET_ATTRIBUTES` states
|
|
6
|
+
// which attributes each resource target (type, or type/kind for the kinded
|
|
7
|
+
// resources) can carry, so `validatePolicy` can refuse a row that reads an
|
|
8
|
+
// attribute its resource never has — at module load, not at request time.
|
|
9
|
+
|
|
10
|
+
import type { ChannelVisibility, KindOf, Resource, ResourceType } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export interface ResourceAttributes {
|
|
13
|
+
readonly channelId?: string;
|
|
14
|
+
readonly userId?: string;
|
|
15
|
+
/** `owner/name`. */
|
|
16
|
+
readonly repo?: string;
|
|
17
|
+
/** Agent name. */
|
|
18
|
+
readonly name?: string;
|
|
19
|
+
/** Visibility of the channel the resource originated in; absent → `unknown` (fail-closed).
|
|
20
|
+
* Read by the `originVisibility` row selector. */
|
|
21
|
+
readonly visibility: ChannelVisibility;
|
|
22
|
+
/** Visibility of the channel the resource IS or LIVES IN — a `run`'s stamped
|
|
23
|
+
* `channelVisibility`, a `channel`'s own — read by `member-of`'s public half.
|
|
24
|
+
* Absent on every other target: a memory scope's origin visibility says
|
|
25
|
+
* where a fact came from, never that its scope is public. */
|
|
26
|
+
readonly channelVisibility?: ChannelVisibility;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type AttributeName = Exclude<keyof ResourceAttributes, "visibility">;
|
|
30
|
+
|
|
31
|
+
/** The kinds each kinded resource type takes. Types absent here are not kinded. */
|
|
32
|
+
export const RESOURCE_KINDS: { readonly [T in ResourceType]?: readonly KindOf<T>[] } = {
|
|
33
|
+
"memory-scope": ["org", "user", "repo", "channel"],
|
|
34
|
+
"config-scope": ["channel", "user", "org"],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** A resource type, or `type/kind` for the kinded ones — the unit a rule row targets. */
|
|
38
|
+
export type Target =
|
|
39
|
+
| Exclude<ResourceType, "memory-scope" | "config-scope">
|
|
40
|
+
| `memory-scope/${"org" | "user" | "repo" | "channel"}`
|
|
41
|
+
| `config-scope/${"channel" | "user" | "org"}`;
|
|
42
|
+
|
|
43
|
+
export const TARGET_ATTRIBUTES: Readonly<Record<Target, readonly AttributeName[]>> = {
|
|
44
|
+
run: ["channelId", "userId", "repo"],
|
|
45
|
+
channel: ["channelId"],
|
|
46
|
+
"memory-scope/org": [],
|
|
47
|
+
"memory-scope/user": ["userId"],
|
|
48
|
+
"memory-scope/channel": ["channelId"],
|
|
49
|
+
"memory-scope/repo": ["repo"],
|
|
50
|
+
repo: ["repo"],
|
|
51
|
+
"config-scope/channel": ["channelId"],
|
|
52
|
+
"config-scope/user": ["userId"],
|
|
53
|
+
"config-scope/org": [],
|
|
54
|
+
agent: ["name"],
|
|
55
|
+
command: [],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const RESOURCE_TYPES: readonly ResourceType[] = [
|
|
59
|
+
"run",
|
|
60
|
+
"channel",
|
|
61
|
+
"memory-scope",
|
|
62
|
+
"repo",
|
|
63
|
+
"config-scope",
|
|
64
|
+
"agent",
|
|
65
|
+
"command",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export const CHANNEL_VISIBILITIES: readonly ChannelVisibility[] = ["public", "private", "dm", "machine", "unknown"];
|
|
69
|
+
|
|
70
|
+
/** The target a rule row names; `undefined` when the (type, kind) pair is not a valid target. */
|
|
71
|
+
export function targetOf(type: string, kind?: string): Target | undefined {
|
|
72
|
+
const kinds: readonly string[] | undefined = RESOURCE_KINDS[type as ResourceType];
|
|
73
|
+
if (kinds) {
|
|
74
|
+
if (kind === undefined || !kinds.includes(kind)) return undefined;
|
|
75
|
+
return `${type}/${kind}` as Target;
|
|
76
|
+
}
|
|
77
|
+
if (kind !== undefined) return undefined;
|
|
78
|
+
return type in TARGET_ATTRIBUTES ? (type as Target) : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function targetOfResource(resource: Resource): Target {
|
|
82
|
+
const kind = "kind" in resource ? resource.kind : undefined;
|
|
83
|
+
const target = targetOf(resource.type, kind);
|
|
84
|
+
if (!target) throw new TypeError(`authz: no target for resource type ${resource.type}`);
|
|
85
|
+
return target;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Strip `<prefix>:` from a memory-scope key; `undefined` when the key is not of that prefix
|
|
89
|
+
* (the condition then fails — a malformed key never widens access). */
|
|
90
|
+
function scopeId(key: string, prefix: "user" | "channel" | "repo"): string | undefined {
|
|
91
|
+
const head = `${prefix}:`;
|
|
92
|
+
return key.startsWith(head) && key.length > head.length ? key.slice(head.length) : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function attributesOf(resource: Resource): ResourceAttributes {
|
|
96
|
+
switch (resource.type) {
|
|
97
|
+
case "run": {
|
|
98
|
+
const visibility = resource.channelVisibility ?? "unknown";
|
|
99
|
+
return {
|
|
100
|
+
channelId: resource.channelId,
|
|
101
|
+
userId: resource.userId,
|
|
102
|
+
...(resource.repo !== undefined ? { repo: resource.repo } : {}),
|
|
103
|
+
visibility,
|
|
104
|
+
channelVisibility: visibility,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
case "channel":
|
|
108
|
+
return { channelId: resource.id, visibility: resource.visibility, channelVisibility: resource.visibility };
|
|
109
|
+
case "memory-scope": {
|
|
110
|
+
const visibility = resource.originChannelVisibility ?? "unknown";
|
|
111
|
+
switch (resource.kind) {
|
|
112
|
+
case "org":
|
|
113
|
+
return { visibility };
|
|
114
|
+
case "user": {
|
|
115
|
+
const userId = scopeId(resource.key, "user");
|
|
116
|
+
return userId === undefined ? { visibility } : { userId, visibility };
|
|
117
|
+
}
|
|
118
|
+
case "channel": {
|
|
119
|
+
const channelId = scopeId(resource.key, "channel");
|
|
120
|
+
return channelId === undefined ? { visibility } : { channelId, visibility };
|
|
121
|
+
}
|
|
122
|
+
case "repo": {
|
|
123
|
+
const repo = scopeId(resource.key, "repo");
|
|
124
|
+
return repo === undefined ? { visibility } : { repo, visibility };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case "repo":
|
|
130
|
+
return { repo: `${resource.owner}/${resource.name}`, visibility: "unknown" };
|
|
131
|
+
case "config-scope":
|
|
132
|
+
switch (resource.kind) {
|
|
133
|
+
case "channel":
|
|
134
|
+
return { channelId: resource.id, visibility: "unknown" };
|
|
135
|
+
case "user":
|
|
136
|
+
return { userId: resource.id, visibility: "unknown" };
|
|
137
|
+
case "org":
|
|
138
|
+
return { visibility: "unknown" };
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
case "agent":
|
|
142
|
+
return { name: resource.name, visibility: "unknown" };
|
|
143
|
+
case "command":
|
|
144
|
+
return { visibility: "unknown" };
|
|
145
|
+
}
|
|
146
|
+
return { visibility: "unknown" };
|
|
147
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// One authorization model — the shared contract
|
|
2
|
+
// (docs/decisions/0007-authorization-policy-table.md).
|
|
3
|
+
//
|
|
4
|
+
// Every surface resolves WHO is asking into an `Actor`; every command names
|
|
5
|
+
// WHAT it does as an `Action`; every thing acted on is a typed `Resource`.
|
|
6
|
+
// `authorize(actor, action, resource)` is the only decision; the same policy
|
|
7
|
+
// rules compile into store predicates for list-shaped reads.
|
|
8
|
+
// This file is types only — no logic, no I/O, node-free (importable by the
|
|
9
|
+
// state Worker like `runRecord.ts`).
|
|
10
|
+
|
|
11
|
+
/** Who is asking. `agent` acts on behalf of a principal and never exceeds it. */
|
|
12
|
+
export type ActorKind = "user" | "service" | "schedule" | "agent";
|
|
13
|
+
|
|
14
|
+
/** A set of names, or everything. `"all"` is explicit, never a default. */
|
|
15
|
+
export type GrantSet = ReadonlySet<string> | "all";
|
|
16
|
+
|
|
17
|
+
/** What an actor may do. One shape for humans, ingress
|
|
18
|
+
* tokens, Access identities, schedule actors, and agents. */
|
|
19
|
+
export interface Grants {
|
|
20
|
+
/** Action ids: `runs:read`, `friction:write`, `repo:exec`, `agent:run:<name>`, `memory:write`, … */
|
|
21
|
+
readonly actions: GrantSet;
|
|
22
|
+
/** Platform-namespaced channel ids the actor is a member of (`slack:C…`, `http:ops`), or every channel. */
|
|
23
|
+
readonly channels: GrantSet;
|
|
24
|
+
/** `owner/name` repos the actor may use, or every repo (open-when-absent today → `"all"`). */
|
|
25
|
+
readonly repos: GrantSet;
|
|
26
|
+
}
|
|
27
|
+
// Which agents an actor may run is NOT a separate axis: it is the action
|
|
28
|
+
// `agent:run:<name>` (or `agent:run:*`) in `actions`, so `has-grant` covers it
|
|
29
|
+
// and the condition vocabulary stays closed.
|
|
30
|
+
|
|
31
|
+
/** The empty grant. `Object.freeze` is shallow and a frozen `Set` still
|
|
32
|
+
* accepts `add`, so the inner sets are protected by the `ReadonlySet` type
|
|
33
|
+
* alone — never hand this object to code that takes a mutable `Set`. */
|
|
34
|
+
export const NO_GRANTS: Grants = Object.freeze({
|
|
35
|
+
actions: new Set<string>(),
|
|
36
|
+
channels: new Set<string>(),
|
|
37
|
+
repos: new Set<string>(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export interface Actor {
|
|
41
|
+
readonly kind: ActorKind;
|
|
42
|
+
/** Platform-namespaced (invariant 4): `slack:U…`, `http:<subject>`, `mcp:<subject>`,
|
|
43
|
+
* `access:<sub>`, `access:svc:<common_name>`, `cli:local`, `schedule:<name>`, `agent:<name>`. */
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly grants: Grants;
|
|
46
|
+
/** For `agent` actors: the principal the run acts for. Effective grants are the intersection. */
|
|
47
|
+
readonly onBehalfOf?: Actor;
|
|
48
|
+
/** Where a chat actor is speaking from — context, never authority. */
|
|
49
|
+
readonly origin?: { readonly channelId: string; readonly threadKey: string };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `<group>:<read|write|exec>` plus the non-command actions. A plain
|
|
53
|
+
* string on purpose: command ids are derived from the registry at runtime, so
|
|
54
|
+
* the closed set lives in the policy table, which validates every action it
|
|
55
|
+
* names against the registry at module load rather than in the type. */
|
|
56
|
+
export type Action = string;
|
|
57
|
+
|
|
58
|
+
/** How a channel's content may travel. `machine` = `http:*` / `mcp:*`. */
|
|
59
|
+
export type ChannelVisibility = "public" | "private" | "dm" | "machine" | "unknown";
|
|
60
|
+
|
|
61
|
+
export type Resource =
|
|
62
|
+
| {
|
|
63
|
+
readonly type: "run";
|
|
64
|
+
readonly id: string;
|
|
65
|
+
readonly channelId: string;
|
|
66
|
+
readonly userId: string;
|
|
67
|
+
readonly repo?: string;
|
|
68
|
+
readonly channelVisibility?: ChannelVisibility;
|
|
69
|
+
}
|
|
70
|
+
| { readonly type: "channel"; readonly id: string; readonly visibility: ChannelVisibility }
|
|
71
|
+
| {
|
|
72
|
+
readonly type: "memory-scope";
|
|
73
|
+
readonly key: string;
|
|
74
|
+
readonly kind: "org" | "user" | "repo" | "channel";
|
|
75
|
+
readonly originChannelVisibility?: ChannelVisibility;
|
|
76
|
+
}
|
|
77
|
+
| { readonly type: "repo"; readonly owner: string; readonly name: string }
|
|
78
|
+
/** A config tier (routing-and-config: a channel's or a user's scope, or the
|
|
79
|
+
* org-wide defaults — the three tiers MCP servers live in as well). */
|
|
80
|
+
| { readonly type: "config-scope"; readonly kind: "channel" | "user"; readonly id: string }
|
|
81
|
+
| { readonly type: "config-scope"; readonly kind: "org" }
|
|
82
|
+
| { readonly type: "agent"; readonly name: string }
|
|
83
|
+
/** List-shaped actions with no single resource (`runs.list`, `friction.report`). */
|
|
84
|
+
| { readonly type: "command"; readonly id: string };
|
|
85
|
+
|
|
86
|
+
export type ResourceType = Resource["type"];
|
|
87
|
+
|
|
88
|
+
/** The CLOSED condition vocabulary. Every condition is both evaluable
|
|
89
|
+
* against one resource and compilable to a store predicate. Adding a member
|
|
90
|
+
* is a decision-record-level change, never a local convenience. */
|
|
91
|
+
export type Condition =
|
|
92
|
+
| { readonly kind: "has-grant"; readonly grant: string }
|
|
93
|
+
/** actor.grants.channels contains resource.channelId (or is "all"), OR the
|
|
94
|
+
* resource's channel is `public` (a run's stamped `channelVisibility`;
|
|
95
|
+
* `unknown` is never public). One definition for both evaluators. */
|
|
96
|
+
| { readonly kind: "member-of" }
|
|
97
|
+
/** resource.userId === actor.id (or the on-behalf-of principal's id). */
|
|
98
|
+
| { readonly kind: "is-self" }
|
|
99
|
+
/** actor.grants.repos contains the resource's repo (or is "all"). */
|
|
100
|
+
| { readonly kind: "owner-of" }
|
|
101
|
+
/** actor.grants.channels === "all". */
|
|
102
|
+
| { readonly kind: "all-channels" };
|
|
103
|
+
|
|
104
|
+
/** The `kind` discriminator of a kinded resource type (`memory-scope`,
|
|
105
|
+
* `config-scope`); `never` for the others, so a row cannot carry a kind its
|
|
106
|
+
* resource does not have. */
|
|
107
|
+
export type KindOf<T extends ResourceType> =
|
|
108
|
+
Extract<Resource, { readonly type: T }> extends { readonly kind: infer K } ? K : never;
|
|
109
|
+
|
|
110
|
+
/** Every kind any kinded resource has (distributed per type — a conditional over the whole union would be `never`). */
|
|
111
|
+
export type ResourceKind = { [T in ResourceType]: KindOf<T> }[ResourceType];
|
|
112
|
+
|
|
113
|
+
/** `resourceKind` is REQUIRED for a kinded resource type and FORBIDDEN for the
|
|
114
|
+
* others — enforced by the type, not by a comment. */
|
|
115
|
+
type KindField<T extends ResourceType> = [KindOf<T>] extends [never]
|
|
116
|
+
? { readonly resourceKind?: never }
|
|
117
|
+
: { readonly resourceKind: KindOf<T> };
|
|
118
|
+
|
|
119
|
+
/** One policy row: `when` conditions are ANDed; rows for the same
|
|
120
|
+
* (action, resource type[/kind]) are ORed. No row → deny (fail-closed).
|
|
121
|
+
* `actorKinds`, `resourceKind`, and `originVisibility` SELECT which rows
|
|
122
|
+
* apply; `when` decides. Selectors read one side only (the actor's kind or a
|
|
123
|
+
* resource attribute); conditions relate the two — so selectors never widen
|
|
124
|
+
* the closed condition vocabulary. */
|
|
125
|
+
export type RuleFor<T extends ResourceType> = {
|
|
126
|
+
readonly action: Action;
|
|
127
|
+
readonly resource: T;
|
|
128
|
+
/** Restrict the row to these actor kinds; absent = any kind. */
|
|
129
|
+
readonly actorKinds?: readonly ActorKind[];
|
|
130
|
+
/** Restrict the row to resources whose origin channel has one of these
|
|
131
|
+
* visibilities (an `org` memory write from a `private`/`dm`/`unknown`
|
|
132
|
+
* origin has no row). A row carrying it is point-check only — `predicateFor`
|
|
133
|
+
* compiles it to `none`, since a store predicate cannot see the origin. */
|
|
134
|
+
readonly originVisibility?: readonly ChannelVisibility[];
|
|
135
|
+
readonly when: readonly Condition[];
|
|
136
|
+
} & KindField<T>;
|
|
137
|
+
|
|
138
|
+
/** A row for any resource type — distributed, so the kind stays tied to the type. */
|
|
139
|
+
export type Rule = { [T in ResourceType]: RuleFor<T> }[ResourceType];
|
|
140
|
+
|
|
141
|
+
export type Decision = { readonly allow: true } | { readonly allow: false; readonly reason: string };
|
|
142
|
+
|
|
143
|
+
/** What a list-shaped read may return, derived from the rules.
|
|
144
|
+
* Stores translate it to their own filter; handlers never see it. */
|
|
145
|
+
export type Predicate =
|
|
146
|
+
| { readonly kind: "none" } // nothing is visible
|
|
147
|
+
| { readonly kind: "all" }
|
|
148
|
+
| { readonly kind: "channels-in"; readonly channelIds: ReadonlySet<string> }
|
|
149
|
+
| { readonly kind: "user-is"; readonly userId: string }
|
|
150
|
+
| { readonly kind: "repos-in"; readonly repos: ReadonlySet<string> }
|
|
151
|
+
/** The record's stamped `channelVisibility` is one of these (`member-of`'s
|
|
152
|
+
* public half). A record without the stamp is `unknown` and never matches
|
|
153
|
+
* `visibility-in(["public"])`. */
|
|
154
|
+
| { readonly kind: "visibility-in"; readonly visibilities: ReadonlySet<ChannelVisibility> }
|
|
155
|
+
/** Rows for one (action, resource type) OR together. */
|
|
156
|
+
| { readonly kind: "or"; readonly of: readonly Predicate[] }
|
|
157
|
+
/** The compilable conditions of ONE row AND together (e.g. member-of ∧ is-self). */
|
|
158
|
+
| { readonly kind: "and"; readonly of: readonly Predicate[] };
|
|
159
|
+
|
|
160
|
+
/** Adapter-supplied channel facts. `unknown` is never a member (fail-closed). */
|
|
161
|
+
export interface ChannelDirectory {
|
|
162
|
+
info(channelId: string): Promise<{ visibility: ChannelVisibility }>;
|
|
163
|
+
isMember(actorId: string, channelId: string): Promise<boolean | "unknown">;
|
|
164
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Graceful-drain timing facts, shared by the drain itself (`src/index.ts`),
|
|
2
|
+
// the `/healthz` body (`src/channels/health.ts`) and the Slack reconnect
|
|
3
|
+
// catch-up (`src/channels/slackCatchUp.ts`). Node-free and Bolt-free on purpose
|
|
4
|
+
// so tests can pin the relationship between them without loading the adapter.
|
|
5
|
+
//
|
|
6
|
+
// Why they are one module: on SIGTERM the drain closes the Slack socket
|
|
7
|
+
// at once and holds the container until in-flight runs finish, up to
|
|
8
|
+
// DRAIN_DEADLINE_MS. Cloudflare starts the replacement container only after
|
|
9
|
+
// this one exits, so a deploy that lands on a run in flight blacks Slack out
|
|
10
|
+
// for the run's remaining duration (minutes, in practice). Mentions in
|
|
11
|
+
// that gap are recovered ONLY by the catch-up scan on the next connect, whose
|
|
12
|
+
// window must therefore cover the worst blackout: the full drain deadline plus
|
|
13
|
+
// the new container's cold start. Keeping the socket open during the drain was
|
|
14
|
+
// rejected — see the drain in `src/index.ts`.
|
|
15
|
+
|
|
16
|
+
/** How long the drain waits for in-flight work after SIGTERM before exiting —
|
|
17
|
+
* the grace Cloudflare's rollout allows before SIGKILL. With the run ledger on,
|
|
18
|
+
* the runs a resume can continue are handed off instead of waited for
|
|
19
|
+
* (docs/reference/specs/run-history.md item 39); this is the wait for the rest. */
|
|
20
|
+
export const DRAIN_DEADLINE_MS = 15 * 60_000;
|
|
21
|
+
|
|
22
|
+
/** The handoff's own budget (plan D8): after every resumable run is marked
|
|
23
|
+
* `handoff`, the drain waits this long for pending history writes and
|
|
24
|
+
* reflections, then exits — the next generation takes the runs. */
|
|
25
|
+
export const HANDOFF_BUDGET_MS = 6_000;
|
|
26
|
+
|
|
27
|
+
/** Time budgeted for the replacement container to boot and reach Socket Mode
|
|
28
|
+
* `connected` (image pull + Node start + Bolt handshake), when the catch-up
|
|
29
|
+
* scan runs. */
|
|
30
|
+
export const COLD_START_ALLOWANCE_MS = 5 * 60_000;
|
|
31
|
+
|
|
32
|
+
/** The smallest catch-up window that still covers a full-length drain. A
|
|
33
|
+
* smaller window leaves mentions posted early in a long drain un-run forever. */
|
|
34
|
+
export const MIN_CATCH_UP_WINDOW_MS = DRAIN_DEADLINE_MS + COLD_START_ALLOWANCE_MS;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Startup validation for `slack.catchUp.windowMinutes`. Returns a warning to
|
|
38
|
+
* log when the configured window cannot cover a full drain, or is not a usable
|
|
39
|
+
* duration; `undefined` when unset (the default applies) or safe. Never clamps:
|
|
40
|
+
* the operator's value stands — the warning names what it costs.
|
|
41
|
+
*/
|
|
42
|
+
export function catchUpWindowWarning(windowMinutes: number | undefined): string | undefined {
|
|
43
|
+
if (windowMinutes === undefined) return undefined;
|
|
44
|
+
if (!Number.isFinite(windowMinutes) || windowMinutes <= 0) {
|
|
45
|
+
return `slack.catchUp.windowMinutes must be a positive number of minutes (got ${String(windowMinutes)}); no catch-up window is usable`;
|
|
46
|
+
}
|
|
47
|
+
const windowMs = windowMinutes * 60_000;
|
|
48
|
+
if (windowMs >= MIN_CATCH_UP_WINDOW_MS) return undefined;
|
|
49
|
+
return (
|
|
50
|
+
`slack.catchUp.windowMinutes=${windowMinutes} (${windowMinutes} min) is below the safe minimum of ${MIN_CATCH_UP_WINDOW_MS / 60_000} min ` +
|
|
51
|
+
`(the ${DRAIN_DEADLINE_MS / 60_000} min drain deadline + ${COLD_START_ALLOWANCE_MS / 60_000} min cold start): ` +
|
|
52
|
+
`mentions posted early in a deploy-time drain longer than ${windowMinutes} min will never be caught up. Keeping the configured value.`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The ingress token map, parsed without any Node dependency so BOTH the bot
|
|
2
|
+
// (src/channels/http.ts, src/channels/mcp.ts) and the Cloudflare Worker shim
|
|
3
|
+
// (deploy/cloudflare/worker.ts, which fires scheduled runs through /ingress)
|
|
4
|
+
// read `SWITCHBOARD_INGRESS_TOKENS` with ONE parser. The shape is
|
|
5
|
+
// {"<raw bearer token>": {"subject": "alice", "channel": "ops"}, ...}
|
|
6
|
+
// A token is a CREDENTIAL, nothing more: what its bearer may do — start a run
|
|
7
|
+
// (`dispatch`), read runs, anything else — is the `grants` entry for
|
|
8
|
+
// `http:<subject>` / `mcp:<subject>` in config.yaml (docs/reference/specs/authorization.md
|
|
9
|
+
// item 9). Absent, empty, or malformed => an empty map. Callers treat an empty
|
|
10
|
+
// map as "ingress disabled" (fail-closed) — this module never decides that, it
|
|
11
|
+
// only parses. Token material is never logged here; the caller may log the reason.
|
|
12
|
+
|
|
13
|
+
/** The identity a token maps to. `subject` becomes the platform-namespaced user
|
|
14
|
+
* id (`http:<subject>`); an optional `channel` names the channel a dispatch
|
|
15
|
+
* through this token is recorded under (`http:<channel>`) — routing, not a
|
|
16
|
+
* grant. These two are the whole identity: any other field in an entry is
|
|
17
|
+
* ignored, so nothing in the token map can widen what the grants entry says. */
|
|
18
|
+
export interface IngressIdentity {
|
|
19
|
+
subject: string;
|
|
20
|
+
channel?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type IngressTokenMap = Record<string, IngressIdentity>;
|
|
24
|
+
|
|
25
|
+
export type ParsedIngressTokens =
|
|
26
|
+
| { ok: true; tokens: IngressTokenMap }
|
|
27
|
+
/** `reason` names the shape problem (never the token material). */
|
|
28
|
+
| { ok: false; reason: string; tokens: IngressTokenMap };
|
|
29
|
+
|
|
30
|
+
/** Parse the raw env value. Entries with an empty token, a non-object value, a
|
|
31
|
+
* missing/empty `subject`, or a non-string `channel` are skipped; the rest are
|
|
32
|
+
* kept, each reduced to `{ subject, channel? }`. `ok: false` only for a value
|
|
33
|
+
* that is not a JSON object at all. */
|
|
34
|
+
export function parseIngressTokenMap(raw: string | undefined): ParsedIngressTokens {
|
|
35
|
+
if (!raw || raw.trim() === "") return { ok: true, tokens: {} };
|
|
36
|
+
let parsed: unknown;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(raw);
|
|
39
|
+
} catch {
|
|
40
|
+
return { ok: false, reason: "not valid JSON", tokens: {} };
|
|
41
|
+
}
|
|
42
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
43
|
+
return { ok: false, reason: "must be a JSON object", tokens: {} };
|
|
44
|
+
}
|
|
45
|
+
const tokens: IngressTokenMap = {};
|
|
46
|
+
for (const [token, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
47
|
+
if (token === "") continue;
|
|
48
|
+
if (typeof value !== "object" || value === null) continue;
|
|
49
|
+
const v = value as Record<string, unknown>;
|
|
50
|
+
const subject = v.subject;
|
|
51
|
+
const channel = v.channel;
|
|
52
|
+
if (typeof subject !== "string" || subject === "") continue;
|
|
53
|
+
if (channel !== undefined && typeof channel !== "string") continue;
|
|
54
|
+
tokens[token] = typeof channel === "string" ? { subject, channel } : { subject };
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, tokens };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The raw token that maps to `subject`, or undefined when no entry does (or
|
|
60
|
+
* more than one does — an ambiguous identity is refused, never guessed). */
|
|
61
|
+
export function tokenForSubject(tokens: IngressTokenMap, subject: string): string | undefined {
|
|
62
|
+
const matches = Object.entries(tokens).filter(([, id]) => id.subject === subject);
|
|
63
|
+
return matches.length === 1 ? matches[0][0] : undefined;
|
|
64
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { MemoryCandidate, MemoryRecord } from "./types.js";
|
|
2
|
+
import { keywordMatch, scoreRecord, tokenize } from "./scorer.js";
|
|
3
|
+
|
|
4
|
+
// The store-agnostic memory algorithms: retrieval ranking and the write plan
|
|
5
|
+
// (dedup / supersede). Pure functions over plain records, no I/O and no clock
|
|
6
|
+
// of their own, so BOTH MemoryStore implementations — the in-process
|
|
7
|
+
// `InMemoryMemoryStore` and the Durable Object behind `WorkerMemoryStore`
|
|
8
|
+
// (deploy/cloudflare-memory/worker.ts imports this file by relative path and
|
|
9
|
+
// bundles it) — run the exact same rules from one source. A store only owns
|
|
10
|
+
// persistence: fetching the active rows of a scope and applying the plan.
|
|
11
|
+
|
|
12
|
+
/** Normalized text key for write-time dedup (trim + lowercase + collapse
|
|
13
|
+
* internal whitespace). Stored alongside the text by durable backends so the
|
|
14
|
+
* dedup lookup is an index hit, not a scan. */
|
|
15
|
+
export function normalizeText(text: string): string {
|
|
16
|
+
return text.trim().toLowerCase().replace(/\s+/g, " ");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Rank a scope's ACTIVE records for a query: relevance-gated (a record the
|
|
20
|
+
* query does not touch is dropped), scored by the pure scorer, best first,
|
|
21
|
+
* cut at `limit`. Does NOT bump usage — the caller persists that. */
|
|
22
|
+
export function rankRecords(active: MemoryRecord[], query: string, now: number, limit: number): MemoryRecord[] {
|
|
23
|
+
// The query (up to ~4k chars) is tokenized ONCE here, not once per record per
|
|
24
|
+
// pass: the Memory Worker runs this over its FTS candidate pool (up to
|
|
25
|
+
// max(50, 5×limit) bm25-ordered rows) on a single Durable Object
|
|
26
|
+
// thread, and each record's match is computed exactly once — the relevance
|
|
27
|
+
// gate and the score share it.
|
|
28
|
+
const queryTokens = tokenize(query);
|
|
29
|
+
if (queryTokens.length === 0) return [];
|
|
30
|
+
const scored: Array<{ r: MemoryRecord; score: number }> = [];
|
|
31
|
+
for (const r of active) {
|
|
32
|
+
if (r.status !== "active") continue;
|
|
33
|
+
const match = keywordMatch(r, queryTokens);
|
|
34
|
+
if (match <= 0) continue;
|
|
35
|
+
scored.push({ r, score: scoreRecord(r, queryTokens, now) });
|
|
36
|
+
}
|
|
37
|
+
return scored
|
|
38
|
+
.sort((a, b) => b.score - a.score)
|
|
39
|
+
.slice(0, limit)
|
|
40
|
+
.map(({ r }) => r);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Default per-scope cap on ACTIVE records when config does not set one. */
|
|
44
|
+
export const DEFAULT_SCOPE_CAP = 500;
|
|
45
|
+
|
|
46
|
+
/** Per-scope cap: which ACTIVE records a store must evict so that at
|
|
47
|
+
* most `cap` remain — the least recently USED first (`lastUsedAt ??
|
|
48
|
+
* createdAt` ascending; ties broken by lower `createdAt`, so the older record
|
|
49
|
+
* goes first), exactly `active.length - cap` of them, none at or under the
|
|
50
|
+
* cap. Non-active rows are ignored (they neither count nor get evicted).
|
|
51
|
+
* Pure: the caller flips status (soft delete — provenance stays). */
|
|
52
|
+
export function planEviction(active: MemoryRecord[], cap: number): MemoryRecord[] {
|
|
53
|
+
const live = active.filter((r) => r.status === "active");
|
|
54
|
+
const excess = live.length - cap;
|
|
55
|
+
if (excess <= 0) return [];
|
|
56
|
+
return live
|
|
57
|
+
.slice()
|
|
58
|
+
.sort((a, b) => (a.lastUsedAt ?? a.createdAt) - (b.lastUsedAt ?? b.createdAt) || a.createdAt - b.createdAt)
|
|
59
|
+
.slice(0, excess);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What a store must do for one candidate. `dedup`: bump `target.useCount`,
|
|
63
|
+
* insert nothing. `insert`: append `record` (already minted) and, when
|
|
64
|
+
* `supersede` is set, flip that record to `superseded`. */
|
|
65
|
+
export type WritePlan =
|
|
66
|
+
{ action: "dedup"; target: MemoryRecord } | { action: "insert"; record: MemoryRecord; supersede?: MemoryRecord };
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Decide how one candidate lands among a scope's ACTIVE records (docs/reference/specs/
|
|
70
|
+
* memory.md §8):
|
|
71
|
+
* - **Supersede target** = the active same-scope record whose id equals
|
|
72
|
+
* `cand.supersedes`; an unknown/foreign id resolves to nothing (the new
|
|
73
|
+
* record still lands, superseding nothing).
|
|
74
|
+
* - **Dedup**: a candidate WITHOUT `supersedes` dedups against every active
|
|
75
|
+
* record (normalized-text equality → bump, no insert). A candidate WITH
|
|
76
|
+
* `supersedes` is an explicit correction and dedups ONLY against its own
|
|
77
|
+
* target — and against nothing when the id doesn't resolve — so a text
|
|
78
|
+
* collision with an unrelated record can never swallow the correction (the
|
|
79
|
+
* extractor sees existing text verbatim; a poisoned transcript could induce
|
|
80
|
+
* such a collision to keep a stale record alive).
|
|
81
|
+
* - Otherwise **insert** the minted record (`mint` assigns id/timestamps),
|
|
82
|
+
* soft-deleting the target if there is one.
|
|
83
|
+
*/
|
|
84
|
+
export function planWrite(
|
|
85
|
+
active: MemoryRecord[],
|
|
86
|
+
cand: MemoryCandidate,
|
|
87
|
+
mint: (cand: MemoryCandidate) => MemoryRecord,
|
|
88
|
+
): WritePlan {
|
|
89
|
+
const norm = normalizeText(cand.text);
|
|
90
|
+
const target = cand.supersedes ? active.find((r) => r.status === "active" && r.id === cand.supersedes) : undefined;
|
|
91
|
+
const dedupPool = cand.supersedes ? (target ? [target] : []) : active.filter((r) => r.status === "active");
|
|
92
|
+
const existing = dedupPool.find((r) => normalizeText(r.text) === norm);
|
|
93
|
+
if (existing) return { action: "dedup", target: existing };
|
|
94
|
+
return { action: "insert", record: mint(cand), ...(target ? { supersede: target } : {}) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Build the record a store persists for a candidate. Ids are namespaced per
|
|
98
|
+
* AGENTS.md invariant 4 (`mem:<scopeKey>:<seq>`); keywords default to the
|
|
99
|
+
* text's tokens so keyword retrieval always has something to hit. */
|
|
100
|
+
export function mintRecord(scopeKey: string, seq: number, now: number, cand: MemoryCandidate): MemoryRecord {
|
|
101
|
+
return {
|
|
102
|
+
id: `mem:${scopeKey}:${seq}`,
|
|
103
|
+
scopeKey,
|
|
104
|
+
kind: cand.kind,
|
|
105
|
+
text: cand.text,
|
|
106
|
+
keywords: cand.keywords ?? tokenize(cand.text),
|
|
107
|
+
sourceThreadKey: cand.sourceThreadKey,
|
|
108
|
+
sourceRunId: cand.sourceRunId,
|
|
109
|
+
createdAt: now,
|
|
110
|
+
useCount: 0,
|
|
111
|
+
confidence: cand.confidence,
|
|
112
|
+
supersedes: cand.supersedes,
|
|
113
|
+
status: "active",
|
|
114
|
+
};
|
|
115
|
+
}
|