@cloudhal/mcp-server 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +18 -2
- package/dist/src/analytics-reports.d.ts +164 -0
- package/dist/src/analytics-reports.js +632 -0
- package/dist/src/analytics-tools.d.ts +4 -0
- package/dist/src/analytics-tools.js +597 -0
- package/dist/src/asset-tools.d.ts +5 -0
- package/dist/src/asset-tools.js +685 -0
- package/dist/src/body-tools.d.ts +46 -0
- package/dist/src/body-tools.js +708 -0
- package/dist/src/changes.d.ts +10 -0
- package/dist/src/changes.js +112 -0
- package/dist/src/chrome-tools.d.ts +8 -0
- package/dist/src/chrome-tools.js +207 -0
- package/dist/src/comment-tools.d.ts +9 -0
- package/dist/src/comment-tools.js +743 -0
- package/dist/src/comments.d.ts +184 -0
- package/dist/src/comments.js +408 -0
- package/dist/src/common.d.ts +258 -0
- package/dist/src/common.js +488 -0
- package/dist/src/content-shared.d.ts +50 -0
- package/dist/src/content-shared.js +102 -0
- package/dist/src/design-tools.d.ts +9 -0
- package/dist/src/design-tools.js +349 -0
- package/dist/src/elevation-tools.d.ts +3 -0
- package/dist/src/elevation-tools.js +331 -0
- package/dist/src/entry-terms.d.ts +14 -0
- package/dist/src/entry-terms.js +51 -0
- package/dist/src/entry-tools.d.ts +4 -0
- package/dist/src/entry-tools.js +442 -0
- package/dist/src/http.d.ts +48 -0
- package/dist/src/http.js +101 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +10 -0
- package/dist/src/menu-tools.d.ts +41 -0
- package/dist/src/menu-tools.js +1058 -0
- package/dist/src/page-tools.d.ts +5 -0
- package/dist/src/page-tools.js +311 -0
- package/dist/src/preview-doc.d.ts +9 -0
- package/dist/src/preview-doc.js +56 -0
- package/dist/src/preview-view.d.ts +19 -0
- package/dist/src/preview-view.js +71 -0
- package/dist/src/publish-tools.d.ts +10 -0
- package/dist/src/publish-tools.js +248 -0
- package/dist/src/render/cdp.d.ts +29 -0
- package/dist/src/render/cdp.js +190 -0
- package/dist/src/render/checks/components.d.ts +5 -0
- package/dist/src/render/checks/components.js +95 -0
- package/dist/src/render/checks/content.d.ts +5 -0
- package/dist/src/render/checks/content.js +63 -0
- package/dist/src/render/checks/contrast.d.ts +5 -0
- package/dist/src/render/checks/contrast.js +72 -0
- package/dist/src/render/checks/index.d.ts +1 -0
- package/dist/src/render/checks/index.js +29 -0
- package/dist/src/render/checks/layout.d.ts +5 -0
- package/dist/src/render/checks/layout.js +44 -0
- package/dist/src/render/checks/motion.d.ts +5 -0
- package/dist/src/render/checks/motion.js +66 -0
- package/dist/src/render/checks/navigation.d.ts +5 -0
- package/dist/src/render/checks/navigation.js +78 -0
- package/dist/src/render/checks/prelude.d.ts +1 -0
- package/dist/src/render/checks/prelude.js +64 -0
- package/dist/src/render/limiter.d.ts +11 -0
- package/dist/src/render/limiter.js +29 -0
- package/dist/src/render/page.d.ts +19 -0
- package/dist/src/render/page.js +135 -0
- package/dist/src/render/types.d.ts +89 -0
- package/dist/src/render/types.js +140 -0
- package/dist/src/render-tools.d.ts +14 -0
- package/dist/src/render-tools.js +314 -0
- package/dist/src/render.d.ts +14 -0
- package/dist/src/render.js +33 -0
- package/dist/src/schedule-tools.d.ts +2 -0
- package/dist/src/schedule-tools.js +1596 -0
- package/dist/src/schedules.d.ts +292 -0
- package/dist/src/schedules.js +890 -0
- package/dist/src/schema-transforms.d.ts +14 -0
- package/dist/src/schema-transforms.js +50 -0
- package/dist/src/search-tools.d.ts +2 -0
- package/dist/src/search-tools.js +112 -0
- package/dist/src/server-approvals.d.ts +51 -0
- package/dist/src/server-approvals.js +273 -0
- package/dist/src/server-call.d.ts +28 -0
- package/dist/src/server-call.js +259 -0
- package/dist/src/server-instructions.d.ts +4 -0
- package/dist/src/server-instructions.js +74 -0
- package/dist/src/server-resources.d.ts +4 -0
- package/dist/src/server-resources.js +83 -0
- package/dist/src/server-schema.d.ts +20 -0
- package/dist/src/server-schema.js +82 -0
- package/dist/src/server.d.ts +80 -0
- package/dist/src/server.js +135 -0
- package/dist/src/stdio.d.ts +9 -0
- package/dist/src/stdio.js +18 -0
- package/dist/src/surface-tools.d.ts +4 -0
- package/dist/src/surface-tools.js +357 -0
- package/dist/src/taxonomy-tools.d.ts +16 -0
- package/dist/src/taxonomy-tools.js +1433 -0
- package/dist/src/terms.d.ts +47 -0
- package/dist/src/terms.js +271 -0
- package/dist/src/tools.d.ts +39 -0
- package/dist/src/tools.js +511 -0
- package/dist/src/translation-tools.d.ts +3 -0
- package/dist/src/translation-tools.js +422 -0
- package/package.json +86 -3
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedules for agents and the REST API (RFC-0008 §10–§14): the deployment's schedule store, its
|
|
3
|
+
* ticker and core-ts's `ReleaseService` (pinning, scheduling, "Publish now"), "own" for releases, who may
|
|
4
|
+
* approve one, the views and the calendar, release requests and their carry-out, automation runs
|
|
5
|
+
* (the open run of an automation key, taint and budgets) and the scheduler's run-time re-check of
|
|
6
|
+
* approvals (`createReleaseApprovalCheck`). Shared by the MCP schedule tools (`schedule-tools.ts`)
|
|
7
|
+
* and `@cloudhal/api`'s schedule routes.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
autoReleaseWindowEnds,
|
|
15
|
+
checkAutoRelease,
|
|
16
|
+
clampMode,
|
|
17
|
+
effectiveMode,
|
|
18
|
+
holds,
|
|
19
|
+
|
|
20
|
+
ownsEntry,
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
roleScopes,
|
|
25
|
+
} from "@cloudhal/agent-runtime";
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
import { addedCorrections, nodeHash, sha256Hex } from "@cloudhal/core-ts";
|
|
42
|
+
import {
|
|
43
|
+
|
|
44
|
+
ReleaseService,
|
|
45
|
+
|
|
46
|
+
} from "@cloudhal/core-ts/releases";
|
|
47
|
+
import {
|
|
48
|
+
|
|
49
|
+
approvalArgs,
|
|
50
|
+
canonicalInstant,
|
|
51
|
+
|
|
52
|
+
isTerminalRelease,
|
|
53
|
+
occurrences,
|
|
54
|
+
REQUEST_HORIZON_DAYS,
|
|
55
|
+
|
|
56
|
+
releaseHash,
|
|
57
|
+
|
|
58
|
+
untrustedEffectiveScopes,
|
|
59
|
+
} from "@cloudhal/schedules";
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
/** What a deployment provides for schedules (RFC-0008 §16). */
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
/** True when the deployment serves schedules. */
|
|
82
|
+
export const schedulesAvailable = (deps ) =>
|
|
83
|
+
deps !== undefined && deps.available !== false;
|
|
84
|
+
|
|
85
|
+
/** core-ts's `ReleaseService` over this graph, with the deployment's ticker and purge. */
|
|
86
|
+
export function releaseService(
|
|
87
|
+
deps ,
|
|
88
|
+
graph ,
|
|
89
|
+
now ,
|
|
90
|
+
) {
|
|
91
|
+
return new ReleaseService({
|
|
92
|
+
graph,
|
|
93
|
+
...(now ? { now } : {}),
|
|
94
|
+
...(deps.scheduler ? { scheduler: deps.scheduler } : {}),
|
|
95
|
+
...(deps.purge ? { purge: deps.purge } : {}),
|
|
96
|
+
...(deps.maxNewBodies !== undefined ? { maxNewBodies: deps.maxNewBodies } : {}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Tells the ticker something is due at `at` (never throws, never awaited by callers' answers). */
|
|
101
|
+
export function poke(deps , at ) {
|
|
102
|
+
if (!at || !deps.scheduler) return;
|
|
103
|
+
deps.scheduler.poke(at).catch(() => undefined);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The person approving on a local server without people (stdio, `bypass`, a confirmed elicitation). */
|
|
107
|
+
export const LOCAL_PERSON = Object.freeze({ id: "user_localdev01", name: "Local developer" });
|
|
108
|
+
|
|
109
|
+
/** Metadata keys on releases (Release.metadata, strings only). */
|
|
110
|
+
export const RELEASE_META = {
|
|
111
|
+
/** The agent's note on a proposal (≤ 500, plain). */
|
|
112
|
+
note: "cloudhal.note",
|
|
113
|
+
/** A gate-only reduction (set_embargo on a live node): re-checked without the mode rule. */
|
|
114
|
+
reduce: "cloudhal.reduce",
|
|
115
|
+
/**
|
|
116
|
+
* The mode the agent worked in when it scheduled a gate-only reduction with its key's (`auto`)
|
|
117
|
+
* approval (`plan` … `auto`): such a release is not an `auto`-mode publish, so Studio labels it
|
|
118
|
+
* by this mode ("Hidden by an agent in EDITS mode"), not as "Automatic".
|
|
119
|
+
*/
|
|
120
|
+
mode: "cloudhal.mode",
|
|
121
|
+
/** The tool or route that made the release. */
|
|
122
|
+
source: "cloudhal.source",
|
|
123
|
+
} ;
|
|
124
|
+
|
|
125
|
+
// ───────────────────────────── nodes and pinning (§6.1) ─────────────────────────────
|
|
126
|
+
|
|
127
|
+
/** A node of the working copy by kind and id (undefined when it is gone). */
|
|
128
|
+
export async function workingNode(
|
|
129
|
+
graph ,
|
|
130
|
+
kind ,
|
|
131
|
+
id ,
|
|
132
|
+
) {
|
|
133
|
+
switch (kind) {
|
|
134
|
+
case "page":
|
|
135
|
+
return graph.getPage(id);
|
|
136
|
+
case "contentEntry":
|
|
137
|
+
return graph.getContentEntry(id);
|
|
138
|
+
case "taxonomy":
|
|
139
|
+
return graph.getTaxonomy(id);
|
|
140
|
+
case "term":
|
|
141
|
+
return graph.getTerm(id);
|
|
142
|
+
case "redirect":
|
|
143
|
+
return graph.getRedirect(id);
|
|
144
|
+
case "menu":
|
|
145
|
+
return graph.getMenu(id);
|
|
146
|
+
case "asset":
|
|
147
|
+
return graph.getAsset(id);
|
|
148
|
+
case "site":
|
|
149
|
+
return graph.getSite(id);
|
|
150
|
+
default:
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The live snapshot's node refs (empty when nothing is live yet). */
|
|
156
|
+
export async function liveRefs(graph , site ) {
|
|
157
|
+
if (!site.publishedSnapshotId) return [];
|
|
158
|
+
return (await graph.getSnapshot(site.publishedSnapshotId))?.nodes ?? [];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A node version's body: the working copy when it is that version, else the snapshot blob. */
|
|
162
|
+
export async function nodeVersion (
|
|
163
|
+
graph ,
|
|
164
|
+
ref ,
|
|
165
|
+
) {
|
|
166
|
+
const working = await workingNode(graph, ref.kind, ref.id);
|
|
167
|
+
if (working && (await nodeHash(working)) === ref.hash) return working ;
|
|
168
|
+
try {
|
|
169
|
+
return (await graph.readSnapshotNode({ ...ref } )) ;
|
|
170
|
+
} catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The `rl1:` a one-node request is bound to before any release exists (`schedule_unpublish`,
|
|
177
|
+
* `lift_embargo`): the node's working and live versions, so any change voids the request.
|
|
178
|
+
*/
|
|
179
|
+
export async function nodeBinding(
|
|
180
|
+
graph ,
|
|
181
|
+
site ,
|
|
182
|
+
nodeId ,
|
|
183
|
+
) {
|
|
184
|
+
const working = (await graph.currentNodeRefs(site.id)).find((r) => r.id === nodeId);
|
|
185
|
+
if (!working || working.kind === "site") throw new Error(`node ${nodeId} not found`);
|
|
186
|
+
const base = (await liveRefs(graph, site)).find((r) => r.id === nodeId);
|
|
187
|
+
const item = {
|
|
188
|
+
kind: "set",
|
|
189
|
+
nodeId,
|
|
190
|
+
nodeKind: working.kind ,
|
|
191
|
+
target: { rev: working.rev, hash: working.hash },
|
|
192
|
+
...(base ? { base: { rev: base.rev, hash: base.hash } } : {}),
|
|
193
|
+
};
|
|
194
|
+
return releaseHash(site.id, [item]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The items as the low-risk check sees them (§10.3). */
|
|
198
|
+
export function releaseRisk(
|
|
199
|
+
items ,
|
|
200
|
+
corrects = new Set(),
|
|
201
|
+
) {
|
|
202
|
+
return items.map(
|
|
203
|
+
(p) => ({
|
|
204
|
+
kind: p.item.kind,
|
|
205
|
+
nodeId: p.item.nodeId,
|
|
206
|
+
nodeKind: p.item.nodeKind,
|
|
207
|
+
live: p.item.base !== undefined,
|
|
208
|
+
deps: p.deps,
|
|
209
|
+
...(corrects.has(p.item.nodeId) ? { corrects: true } : {}),
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** RFC-0019 §2.4: the release's articles whose target adds a correction to the live version. */
|
|
215
|
+
export async function releaseCorrections(
|
|
216
|
+
graph ,
|
|
217
|
+
items ,
|
|
218
|
+
) {
|
|
219
|
+
const out = new Set ();
|
|
220
|
+
for (const { item } of items) {
|
|
221
|
+
if (item.kind !== "set" || item.nodeKind !== "contentEntry") continue;
|
|
222
|
+
const ref = { id: item.nodeId, kind: item.nodeKind };
|
|
223
|
+
const after = await nodeVersion (graph, { ...ref, ...item.target });
|
|
224
|
+
if (!after?.corrections?.length) continue;
|
|
225
|
+
const before = item.base
|
|
226
|
+
? await nodeVersion (graph, { ...ref, ...item.base })
|
|
227
|
+
: undefined;
|
|
228
|
+
if (addedCorrections(before, after).length > 0) out.add(item.nodeId);
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A release removes content or carries an expiry: canceling it keeps content up (§12 `cancel_schedule`). */
|
|
234
|
+
export async function removesContent(
|
|
235
|
+
graph ,
|
|
236
|
+
record ,
|
|
237
|
+
) {
|
|
238
|
+
for (const p of record.items) {
|
|
239
|
+
if (p.item.kind === "remove") return true;
|
|
240
|
+
const body = (await nodeVersion (graph, {
|
|
241
|
+
id: p.item.nodeId,
|
|
242
|
+
kind: p.item.nodeKind,
|
|
243
|
+
...p.item.target,
|
|
244
|
+
})) ;
|
|
245
|
+
if (body?.availability?.until) return true;
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ───────────────────────────── own and approval rights (§10.1, §10.6) ─────────────────────────────
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Whether every item is `person`'s own (RFC-0005 §4, `ownOnlyChangeSet`'s rule): entries whose
|
|
254
|
+
* author is the person or one of their agents, plus new assets those entries use. Undefined when
|
|
255
|
+
* own-only, else why not.
|
|
256
|
+
*/
|
|
257
|
+
export async function notOwnRelease(
|
|
258
|
+
graph ,
|
|
259
|
+
ownership ,
|
|
260
|
+
person ,
|
|
261
|
+
items ,
|
|
262
|
+
) {
|
|
263
|
+
if (!person) return "ownership cannot be established for this caller";
|
|
264
|
+
const riding = new Set ();
|
|
265
|
+
for (const p of items) {
|
|
266
|
+
const i = p.item;
|
|
267
|
+
if (i.nodeKind !== "contentEntry") continue;
|
|
268
|
+
const ref = i.kind === "set" ? i.target : i.base;
|
|
269
|
+
const entry = await nodeVersion (graph, {
|
|
270
|
+
id: i.nodeId,
|
|
271
|
+
kind: i.nodeKind,
|
|
272
|
+
...ref,
|
|
273
|
+
});
|
|
274
|
+
if (!entry || !(await ownsEntry(ownership, person, entry)))
|
|
275
|
+
return `${i.nodeId} is not your article`;
|
|
276
|
+
if (i.kind === "set") for (const d of p.deps) riding.add(d);
|
|
277
|
+
}
|
|
278
|
+
for (const p of items) {
|
|
279
|
+
const i = p.item;
|
|
280
|
+
if (i.nodeKind === "contentEntry") continue;
|
|
281
|
+
if (i.nodeKind === "asset" && i.kind === "set" && !i.base && riding.has(i.nodeId)) continue;
|
|
282
|
+
return `${i.nodeId} is a ${i.nodeKind}, not one of your articles`;
|
|
283
|
+
}
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* May this principal approve these items now (§10.4, §10.6)? `releases:publish`, or
|
|
289
|
+
* `releases:publish:own` with own-only items. Without a membership source a person may.
|
|
290
|
+
*/
|
|
291
|
+
export async function approvalProblem(
|
|
292
|
+
graph ,
|
|
293
|
+
ownership ,
|
|
294
|
+
principal ,
|
|
295
|
+
items ,
|
|
296
|
+
) {
|
|
297
|
+
if (holds(principal.scopes, "releases:publish")) return undefined;
|
|
298
|
+
if (!holds(principal.scopes, "releases:publish:own"))
|
|
299
|
+
return "your role cannot approve releases; an Editor approves them";
|
|
300
|
+
const why = await notOwnRelease(
|
|
301
|
+
graph,
|
|
302
|
+
ownership,
|
|
303
|
+
principal.person?.id ?? principal.actor.id,
|
|
304
|
+
items,
|
|
305
|
+
);
|
|
306
|
+
return why ? `your role approves only your own releases, but ${why}` : undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** A person's approval as the contract stores it (a staff person, never an agent). */
|
|
310
|
+
export function personApproval(
|
|
311
|
+
principal ,
|
|
312
|
+
at ,
|
|
313
|
+
extra = {},
|
|
314
|
+
) {
|
|
315
|
+
const id = principal.person?.id ?? principal.actor.id;
|
|
316
|
+
const by = /^user_[A-Za-z0-9]{6,32}$/.test(id)
|
|
317
|
+
? {
|
|
318
|
+
id,
|
|
319
|
+
...((principal.person?.name ?? principal.actor.name)
|
|
320
|
+
? { name: (principal.person?.name ?? principal.actor.name ?? "").slice(0, 200) }
|
|
321
|
+
: {}),
|
|
322
|
+
}
|
|
323
|
+
: { ...LOCAL_PERSON };
|
|
324
|
+
return {
|
|
325
|
+
kind: "person",
|
|
326
|
+
by,
|
|
327
|
+
at,
|
|
328
|
+
...(extra.executeAt ? { executeAt: extra.executeAt } : {}),
|
|
329
|
+
...(extra.approvalId ? { approvalId: extra.approvalId } : {}),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** When a scheduled release becomes due (`on-approval`: now). */
|
|
334
|
+
export function executeAtOf(when , now ) {
|
|
335
|
+
if (!when) return undefined;
|
|
336
|
+
return when.kind === "at" ? canonicalInstant(when.at) : now;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The `auto` path of a release (§10.3, §10.7): the key's effective mode is `auto` (never inside a
|
|
349
|
+
* tainted run — such calls never get here), the items are low risk, the key holds
|
|
350
|
+
* `releases:publish` (own-only items with `:own`), and the release runs before the window ends.
|
|
351
|
+
*/
|
|
352
|
+
export async function autoReleaseVerdict(
|
|
353
|
+
graph ,
|
|
354
|
+
ownership ,
|
|
355
|
+
principal ,
|
|
356
|
+
items ,
|
|
357
|
+
executeAt ,
|
|
358
|
+
now ,
|
|
359
|
+
) {
|
|
360
|
+
const windowEnds = autoReleaseWindowEnds(now, principal.elevation?.until);
|
|
361
|
+
const check = checkAutoRelease(releaseRisk(items, await releaseCorrections(graph, items)));
|
|
362
|
+
const no = (reason ) => ({ ok: false, reason, windowEnds, check });
|
|
363
|
+
if (effectiveMode(principal) !== "auto") return no("not in auto mode");
|
|
364
|
+
if (!principal.keyId) return no("auto-mode releases need an API key");
|
|
365
|
+
if (!check.lowRisk) return no(`not low risk: ${check.code}`);
|
|
366
|
+
if (Date.parse(executeAt) > Date.parse(windowEnds))
|
|
367
|
+
return {
|
|
368
|
+
...no("it would run after your autonomy ends (the elevation's end, or 24 hours)"),
|
|
369
|
+
check: { ...check, lowRisk: false, code: "outside-window" },
|
|
370
|
+
};
|
|
371
|
+
const problem = await approvalProblem(graph, ownership, principal, items);
|
|
372
|
+
if (problem) return no(problem);
|
|
373
|
+
return { ok: true, windowEnds, check };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ───────────────────────────── requests (§10.4, §10.5) ─────────────────────────────
|
|
377
|
+
|
|
378
|
+
/** How long an agent's release request may wait (§10.5): until the release's time, ≤ 30 days. */
|
|
379
|
+
export function requestExpiry(
|
|
380
|
+
when ,
|
|
381
|
+
now ,
|
|
382
|
+
agent ,
|
|
383
|
+
) {
|
|
384
|
+
if (!when || when.kind === "on-approval") return {};
|
|
385
|
+
const at = Date.parse(when.at);
|
|
386
|
+
if (agent && at > now.getTime() + REQUEST_HORIZON_DAYS * 86_400_000)
|
|
387
|
+
return {
|
|
388
|
+
problem: `too_far_for_request: agents ask for releases at most ${REQUEST_HORIZON_DAYS} days ahead; propose a draft (propose_release) and a person schedules it`,
|
|
389
|
+
};
|
|
390
|
+
return { expiresAt: canonicalInstant(at) };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The `input` of a release request: what the carry-out reads back (and Studio shows). */
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Moves a release to `awaiting_approval` and files one request bound to its `rl1:` hash and
|
|
403
|
+
* `when` (§10.4): `args_hash` covers `approvalArgs(releaseId, when)`, the request expires at the
|
|
404
|
+
* release's time. A release already awaiting approval keeps waiting under the new request (the old
|
|
405
|
+
* one is closed). Returns the request.
|
|
406
|
+
*/
|
|
407
|
+
export async function requestRelease(input
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
) {
|
|
421
|
+
const { deps, approvals, record } = input;
|
|
422
|
+
const r = record.release;
|
|
423
|
+
const expiry = requestExpiry(input.when, input.now, input.actor.kind === "agent");
|
|
424
|
+
if (expiry.problem) throw new ScheduleRequestError(expiry.problem);
|
|
425
|
+
let current = r;
|
|
426
|
+
if (current.state === "awaiting_approval" || current.state === "scheduled")
|
|
427
|
+
await approvals.closeForRelease(r.siteId, r.id, input.actor, "the release changed");
|
|
428
|
+
// A request replaces a request: back to a draft first (awaiting → awaiting is no transition).
|
|
429
|
+
if (current.state === "awaiting_approval")
|
|
430
|
+
current = await deps.store.transitionRelease(r.siteId, r.id, {
|
|
431
|
+
expectedRev: current.rev,
|
|
432
|
+
to: "draft",
|
|
433
|
+
approval: null,
|
|
434
|
+
when: input.when,
|
|
435
|
+
requestId: null,
|
|
436
|
+
});
|
|
437
|
+
const requestInput = {
|
|
438
|
+
siteId: r.siteId,
|
|
439
|
+
releaseId: r.id,
|
|
440
|
+
when: input.when,
|
|
441
|
+
...(input.extra ?? {}),
|
|
442
|
+
};
|
|
443
|
+
const request = await approvals.request({
|
|
444
|
+
siteId: r.siteId,
|
|
445
|
+
tool: input.tool,
|
|
446
|
+
argsHash: await sha256Hex(approvalArgs(r.id, input.when)),
|
|
447
|
+
input: requestInput,
|
|
448
|
+
actor: input.actor,
|
|
449
|
+
prompt: input.prompt,
|
|
450
|
+
stateHash: current.hash,
|
|
451
|
+
mode: input.mode,
|
|
452
|
+
cause: input.cause,
|
|
453
|
+
releaseId: r.id,
|
|
454
|
+
...(input.runId ? { runId: input.runId } : {}),
|
|
455
|
+
...(expiry.expiresAt ? { expiresAt: expiry.expiresAt } : {}),
|
|
456
|
+
});
|
|
457
|
+
const release = await deps.store.transitionRelease(r.siteId, r.id, {
|
|
458
|
+
expectedRev: current.rev,
|
|
459
|
+
to: "awaiting_approval",
|
|
460
|
+
when: input.when,
|
|
461
|
+
requestId: request.id,
|
|
462
|
+
approval: null,
|
|
463
|
+
});
|
|
464
|
+
return { release, request };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** A release request that cannot be filed (too far ahead for an agent). */
|
|
468
|
+
export class ScheduleRequestError extends Error {
|
|
469
|
+
name = "ScheduleRequestError";
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Whether a request's `when` is the release's current one (§10.4: the time is in `args_hash`). */
|
|
473
|
+
export function sameWhen(a , b ) {
|
|
474
|
+
return approvalArgs("x", a ?? undefined) === approvalArgs("x", b);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ───────────────────────────── views (§12) ─────────────────────────────
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
export function approvalState(r ) {
|
|
482
|
+
if (r.state === "awaiting_approval") return "pending";
|
|
483
|
+
if (r.state === "expired") return "expired";
|
|
484
|
+
if (r.approval?.kind === "auto") return "auto";
|
|
485
|
+
if (r.approval) return "approved";
|
|
486
|
+
return "none";
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
function titleOf(node ) {
|
|
522
|
+
const n = node
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
;
|
|
533
|
+
if (!n) return { title: null, path: null };
|
|
534
|
+
const name =
|
|
535
|
+
typeof n.name === "string"
|
|
536
|
+
? n.name
|
|
537
|
+
: n.name && typeof n.name === "object"
|
|
538
|
+
? (Object.values(n.name )[0] ?? null)
|
|
539
|
+
: null;
|
|
540
|
+
const title =
|
|
541
|
+
n.title ?? name ?? n.key ?? (n.from ? `${n.from} → ${n.to}` : null) ?? n.filename ?? null;
|
|
542
|
+
const path =
|
|
543
|
+
n.path ?? (n.collection && n.slug ? `/${n.collection}/${n.slug}` : null) ?? n.from ?? null;
|
|
544
|
+
return { title: title ? String(title).slice(0, 200) : null, path };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** A release with its items' titles and paths, overlaps, locks and the request (§12 `get_release`). */
|
|
548
|
+
export async function releaseView(
|
|
549
|
+
deps ,
|
|
550
|
+
graph ,
|
|
551
|
+
record ,
|
|
552
|
+
approvals ,
|
|
553
|
+
) {
|
|
554
|
+
const r = record.release;
|
|
555
|
+
const overlaps = await deps.store.overlaps(r.siteId);
|
|
556
|
+
// What the dependency closure added (§6.1 step 2): new nodes another item's target references.
|
|
557
|
+
const referenced = new Set(record.items.flatMap((p) => (p.item.kind === "set" ? p.deps : [])));
|
|
558
|
+
const closure = new Set(
|
|
559
|
+
record.items
|
|
560
|
+
.filter((p) => p.item.kind === "set" && !p.item.base && referenced.has(p.item.nodeId))
|
|
561
|
+
.map((p) => p.item.nodeId),
|
|
562
|
+
);
|
|
563
|
+
const items = [];
|
|
564
|
+
for (const p of record.items) {
|
|
565
|
+
const i = p.item;
|
|
566
|
+
const ref = i.kind === "set" ? i.target : i.base;
|
|
567
|
+
const node = await nodeVersion(graph, { id: i.nodeId, kind: i.nodeKind, ...ref });
|
|
568
|
+
items.push({
|
|
569
|
+
nodeId: i.nodeId,
|
|
570
|
+
nodeKind: i.nodeKind,
|
|
571
|
+
kind: i.kind,
|
|
572
|
+
...titleOf(node),
|
|
573
|
+
pinnedRev: i.kind === "set" ? i.target.rev : null,
|
|
574
|
+
liveRev: i.base?.rev ?? null,
|
|
575
|
+
closure: closure.has(i.nodeId),
|
|
576
|
+
overlaps: (overlaps.get(i.nodeId) ?? []).filter((id) => id !== r.id),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
const request = record.requestId ? await approvals?.get(record.requestId) : undefined;
|
|
580
|
+
return {
|
|
581
|
+
...r,
|
|
582
|
+
executeAt: record.executeAt ?? null,
|
|
583
|
+
approvalState: approvalState(r),
|
|
584
|
+
view: {
|
|
585
|
+
items,
|
|
586
|
+
closureAdded: [...closure].sort(),
|
|
587
|
+
lockedNodes: record.items.filter((p) => p.held).map((p) => p.item.nodeId),
|
|
588
|
+
...(request
|
|
589
|
+
? {
|
|
590
|
+
request: {
|
|
591
|
+
id: request.id,
|
|
592
|
+
status: request.status,
|
|
593
|
+
actor: request.actor,
|
|
594
|
+
expiresAt: request.expiresAt,
|
|
595
|
+
createdAt: request.createdAt,
|
|
596
|
+
},
|
|
597
|
+
}
|
|
598
|
+
: {}),
|
|
599
|
+
},
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
/** At most 62 days per calendar query (§12). */
|
|
631
|
+
export const CALENDAR_MAX_DAYS = 62;
|
|
632
|
+
export const CALENDAR_MAX_ITEMS = 200;
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Calendar items (§4.5, §12): releases, gates and automation occurrences between `from` and `to`,
|
|
636
|
+
* a view built from state (nothing stored). Sorted by time, then id.
|
|
637
|
+
*/
|
|
638
|
+
export async function calendarItems(
|
|
639
|
+
deps ,
|
|
640
|
+
graph ,
|
|
641
|
+
siteId ,
|
|
642
|
+
query ,
|
|
643
|
+
) {
|
|
644
|
+
const from = Date.parse(query.from);
|
|
645
|
+
const to = Date.parse(query.to);
|
|
646
|
+
const types = new Set(query.types ?? ["release", "gate", "automation"]);
|
|
647
|
+
const inRange = (at ) => {
|
|
648
|
+
if (!at) return false;
|
|
649
|
+
const t = Date.parse(at);
|
|
650
|
+
return t >= from && t < to;
|
|
651
|
+
};
|
|
652
|
+
const out = [];
|
|
653
|
+
if (types.has("release")) {
|
|
654
|
+
const releases = await deps.store.listReleases(siteId, { limit: 200 });
|
|
655
|
+
const overlaps = await deps.store.overlaps(siteId);
|
|
656
|
+
for (const r of releases) {
|
|
657
|
+
const at = r.when?.kind === "at" ? canonicalInstant(r.when.at) : undefined;
|
|
658
|
+
const when = at ?? (r.state === "draft" ? undefined : r.updatedAt);
|
|
659
|
+
if (!inRange(when)) continue;
|
|
660
|
+
const nodeIds = r.items.map((i) => i.nodeId);
|
|
661
|
+
const flagged = nodeIds.filter((n) => (overlaps.get(n) ?? []).length > 1);
|
|
662
|
+
out.push({
|
|
663
|
+
id: r.id,
|
|
664
|
+
kind: "release",
|
|
665
|
+
at: when ,
|
|
666
|
+
title: r.title ?? `${nodeIds.length} item(s)`,
|
|
667
|
+
target: { releaseId: r.id, nodeIds },
|
|
668
|
+
origin: r.createdBy.kind === "agent" ? "agent" : "person",
|
|
669
|
+
approval: approvalState(r),
|
|
670
|
+
state: r.state,
|
|
671
|
+
...(flagged.length > 0 ? { overlaps: flagged } : {}),
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (types.has("gate")) {
|
|
676
|
+
const nodes = [
|
|
677
|
+
...(await graph.listContentEntries(siteId)),
|
|
678
|
+
...(await graph.listPages(siteId)),
|
|
679
|
+
];
|
|
680
|
+
for (const n of nodes) {
|
|
681
|
+
if (query.collection && (n ).collection !== query.collection) continue;
|
|
682
|
+
for (const edge of ["from", "until"] ) {
|
|
683
|
+
const at = n.availability?.[edge];
|
|
684
|
+
if (!at || !inRange(at)) continue;
|
|
685
|
+
const author = (n ).author;
|
|
686
|
+
out.push({
|
|
687
|
+
id: `gate:${n.id}:${edge}`,
|
|
688
|
+
kind: "gate",
|
|
689
|
+
at: canonicalInstant(at),
|
|
690
|
+
title: n.title,
|
|
691
|
+
target: { nodeId: n.id },
|
|
692
|
+
origin: author?.kind === "agent" ? "agent" : "person",
|
|
693
|
+
approval: "none",
|
|
694
|
+
state: edge === "from" ? "embargo" : "expiry",
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (types.has("automation")) {
|
|
700
|
+
for (const a of await deps.store.listAutomations(siteId)) {
|
|
701
|
+
if (a.state !== "active") continue;
|
|
702
|
+
const occ = occurrences(a.recurrence, {
|
|
703
|
+
after: from - 1,
|
|
704
|
+
before: to,
|
|
705
|
+
limit: CALENDAR_MAX_ITEMS,
|
|
706
|
+
});
|
|
707
|
+
for (const o of occ)
|
|
708
|
+
out.push({
|
|
709
|
+
id: `occ:${a.id}:${o.at}`,
|
|
710
|
+
kind: "automation",
|
|
711
|
+
at: o.at,
|
|
712
|
+
title: a.name,
|
|
713
|
+
target: { automationId: a.id },
|
|
714
|
+
origin: a.job.kind === "agent-task" ? "agent" : "person",
|
|
715
|
+
approval: "none",
|
|
716
|
+
state: "planned",
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const q = query.q?.toLocaleLowerCase();
|
|
721
|
+
const filtered = out
|
|
722
|
+
.filter((i) => !query.states || query.states.includes(i.state))
|
|
723
|
+
.filter((i) => !query.origin || i.origin === query.origin)
|
|
724
|
+
.filter((i) => !query.approval || i.approval === query.approval)
|
|
725
|
+
.filter((i) => !q || i.title.toLocaleLowerCase().includes(q))
|
|
726
|
+
.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || (a.id < b.id ? -1 : 1));
|
|
727
|
+
const offset = query.offset ?? 0;
|
|
728
|
+
const limit = Math.min(query.limit ?? CALENDAR_MAX_ITEMS, CALENDAR_MAX_ITEMS);
|
|
729
|
+
const page = filtered.slice(offset, offset + limit);
|
|
730
|
+
return {
|
|
731
|
+
items: page,
|
|
732
|
+
...(offset + limit < filtered.length ? { nextCursor: String(offset + limit) } : {}),
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ───────────────────────────── automation runs (§11.2–§11.5) ─────────────────────────────
|
|
737
|
+
|
|
738
|
+
/** Anomaly counters kept on the run's `counts` (§11.4 item 6). */
|
|
739
|
+
export const RUN_COUNTS = { reduce: "reduce-calls", refusals: "refusals" } ;
|
|
740
|
+
|
|
741
|
+
/** An automation key's open run, as one call sees it. */
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
export function taintStateOf(run ) {
|
|
749
|
+
return {
|
|
750
|
+
...(run.taint ? { taint: run.taint } : {}),
|
|
751
|
+
reduceCalls: run.counts?.[RUN_COUNTS.reduce] ?? 0,
|
|
752
|
+
consecutiveRefusals: run.counts?.[RUN_COUNTS.refusals] ?? 0,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* The principal an automation key works as inside its run (§11.2, §11.4): the mode is the key's,
|
|
758
|
+
* capped by the automation's `modeCap` (never `bypass`); an automation that reads untrusted text
|
|
759
|
+
* loses its write scopes on every call.
|
|
760
|
+
*/
|
|
761
|
+
export function runPrincipal(
|
|
762
|
+
principal ,
|
|
763
|
+
automation ,
|
|
764
|
+
) {
|
|
765
|
+
const mode = clampMode(effectiveMode(principal) ?? "edits", automation.job.modeCap);
|
|
766
|
+
return {
|
|
767
|
+
...principal,
|
|
768
|
+
mode: mode === "bypass" ? "edits" : mode,
|
|
769
|
+
local: false,
|
|
770
|
+
scopes: automation.job.readsUntrustedText
|
|
771
|
+
? (untrustedEffectiveScopes(principal.scopes) )
|
|
772
|
+
: principal.scopes,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Resolves an automation key's call (§11.2): its automation (active, its own key, schedules and
|
|
783
|
+
* automations on) and its open run. `none` for other principals.
|
|
784
|
+
*/
|
|
785
|
+
export async function resolveRun(
|
|
786
|
+
deps ,
|
|
787
|
+
principal ,
|
|
788
|
+
now ,
|
|
789
|
+
) {
|
|
790
|
+
if (!principal.automationId) return { kind: "none" };
|
|
791
|
+
if (!schedulesAvailable(deps) || deps.automationsEnabled === false)
|
|
792
|
+
return { kind: "outside", message: "automations are turned off on this deployment" };
|
|
793
|
+
const automation = principal.keyId
|
|
794
|
+
? await deps.store.automationByKey(principal.keyId)
|
|
795
|
+
: undefined;
|
|
796
|
+
if (
|
|
797
|
+
!automation ||
|
|
798
|
+
automation.id !== principal.automationId ||
|
|
799
|
+
automation.job.kind !== "agent-task"
|
|
800
|
+
)
|
|
801
|
+
return { kind: "outside", message: "this key's automation no longer exists" };
|
|
802
|
+
if (automation.state !== "active")
|
|
803
|
+
return {
|
|
804
|
+
kind: "outside",
|
|
805
|
+
message: `the automation is ${automation.state}${automation.pausedReason ? ` (${automation.pausedReason})` : ""}; a person resumes it in Studio`,
|
|
806
|
+
automation,
|
|
807
|
+
};
|
|
808
|
+
const run = await deps.store.openAgentRun(automation.siteId, automation.id, now.toISOString());
|
|
809
|
+
if (!run)
|
|
810
|
+
return {
|
|
811
|
+
kind: "outside",
|
|
812
|
+
message: "this key works only inside an open run of its automation: call begin_run first",
|
|
813
|
+
automation,
|
|
814
|
+
};
|
|
815
|
+
const ctx = {
|
|
816
|
+
run,
|
|
817
|
+
automation: automation ,
|
|
818
|
+
state: taintStateOf(run),
|
|
819
|
+
};
|
|
820
|
+
return { kind: "run", run: ctx, principal: runPrincipal(principal, ctx.automation) };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ───────────────────────────── the scheduler's re-check (§10.6) ─────────────────────────────
|
|
824
|
+
|
|
825
|
+
/** Who is who now, for the re-check at run time. */
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* `checkApproval` for `runDue` / `createScheduler` (§10.6), after `baseApprovalVerdict`:
|
|
835
|
+
* - a person's approval holds while the approver is still a member who could approve it now
|
|
836
|
+
* (`releases:publish`, or `:own` with own-only items), else `approval_lost`;
|
|
837
|
+
* - an `auto` approval holds while the key exists, its creator is a member and its effective mode
|
|
838
|
+
* (for an automation's key: capped by the automation's current `modeCap`) is still `auto`
|
|
839
|
+
* (`creator_removed`, `auto_window_ended`); a gate-only reduction
|
|
840
|
+
* (`cloudhal.reduce`) skips the mode rule — any mode that may edit may hide;
|
|
841
|
+
* - without a rights source (no membership: local, tests) every approval holds.
|
|
842
|
+
*/
|
|
843
|
+
export function createReleaseApprovalCheck(input
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
) {
|
|
849
|
+
const lost = (reason ) => ({ ok: false, reason });
|
|
850
|
+
return async (release) => {
|
|
851
|
+
const approval = release.approval;
|
|
852
|
+
if (!approval) return lost("unapproved");
|
|
853
|
+
const rights = input.rights;
|
|
854
|
+
if (!rights) return { ok: true };
|
|
855
|
+
if (approval.kind === "person") {
|
|
856
|
+
if (approval.by.id === LOCAL_PERSON.id) return { ok: true };
|
|
857
|
+
const role = await rights.role(approval.by.id);
|
|
858
|
+
if (!role) return lost("approval_lost");
|
|
859
|
+
const scopes = roleScopes(role);
|
|
860
|
+
if (holds(scopes, "releases:publish")) return { ok: true };
|
|
861
|
+
if (!holds(scopes, "releases:publish:own")) return lost("approval_lost");
|
|
862
|
+
const rec = await input.store.getReleaseRecord(release.siteId, release.id);
|
|
863
|
+
const why = await notOwnRelease(
|
|
864
|
+
input.graph,
|
|
865
|
+
input.ownership,
|
|
866
|
+
approval.by.id,
|
|
867
|
+
rec?.items ?? [],
|
|
868
|
+
);
|
|
869
|
+
return why ? lost("approval_lost") : { ok: true };
|
|
870
|
+
}
|
|
871
|
+
const key = rights.key ? await rights.key(approval.keyId) : undefined;
|
|
872
|
+
if (!key) return lost("creator_removed");
|
|
873
|
+
if (release.metadata?.[RELEASE_META.reduce] === "1") return { ok: true };
|
|
874
|
+
// An automation's key works no higher than its automation's current modeCap (§11.3): lowering
|
|
875
|
+
// the cap below `auto` also ends the `auto` releases its runs scheduled.
|
|
876
|
+
const automation = await input.store.automationByKey(approval.keyId);
|
|
877
|
+
const cap = automation?.job.kind === "agent-task" ? automation.job.modeCap : "auto";
|
|
878
|
+
const mode = clampMode(
|
|
879
|
+
clampMode(key.mode, key.creatorRole === "contributor" ? "edits" : "auto"),
|
|
880
|
+
cap,
|
|
881
|
+
);
|
|
882
|
+
if (mode !== "auto") return lost("auto_window_ended");
|
|
883
|
+
return { ok: true };
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** Releases whose state a person may still change (not running, not finished). */
|
|
888
|
+
export function editable(r ) {
|
|
889
|
+
return r.state !== "running" && !isTerminalRelease(r.state);
|
|
890
|
+
}
|