team 0.1.2 → 0.2.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/README.md +22 -11
- package/dist/approve/approval.js +14 -9
- package/dist/approve/fingerprint.d.ts +14 -2
- package/dist/approve/fingerprint.js +21 -21
- package/dist/budgets/gate.js +7 -2
- package/dist/budgets/readings.d.ts +6 -2
- package/dist/budgets/readings.js +28 -2
- package/dist/budgets/run.js +8 -2
- package/dist/budgets/table.d.ts +2 -0
- package/dist/budgets/table.js +10 -5
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +8 -5
- package/dist/commands/add.d.ts +0 -1
- package/dist/commands/add.js +7 -5
- package/dist/commands/approve.js +17 -1
- package/dist/commands/doctor.d.ts +15 -1
- package/dist/commands/doctor.js +126 -8
- package/dist/commands/down.d.ts +4 -0
- package/dist/commands/down.js +32 -13
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.js +4 -2
- package/dist/commands/remove.js +5 -2
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +19 -1
- package/dist/commands/up.js +3 -1
- package/dist/commands/watch.d.ts +2 -0
- package/dist/commands/watch.js +28 -4
- package/dist/conformance/adapter.d.ts +5 -0
- package/dist/conformance/adapter.js +44 -0
- package/dist/file/check.d.ts +20 -0
- package/dist/file/check.js +112 -0
- package/dist/file/lines.js +13 -2
- package/dist/file/sections/budgets.d.ts +15 -0
- package/dist/file/sections/budgets.js +180 -0
- package/dist/file/sections/coordinator.d.ts +2 -0
- package/dist/file/sections/coordinator.js +11 -0
- package/dist/file/sections/format.d.ts +2 -0
- package/dist/file/sections/format.js +13 -0
- package/dist/file/sections/identity.d.ts +2 -0
- package/dist/file/sections/identity.js +103 -0
- package/dist/file/sections/index.d.ts +9 -0
- package/dist/file/sections/index.js +43 -0
- package/dist/file/sections/lead.d.ts +4 -0
- package/dist/file/sections/lead.js +18 -0
- package/dist/file/sections/limits.d.ts +2 -0
- package/dist/file/sections/limits.js +43 -0
- package/dist/file/sections/machine.d.ts +2 -0
- package/dist/file/sections/machine.js +38 -0
- package/dist/file/sections/operator.d.ts +2 -0
- package/dist/file/sections/operator.js +11 -0
- package/dist/file/sections/project.d.ts +2 -0
- package/dist/file/sections/project.js +13 -0
- package/dist/file/sections/rules.d.ts +2 -0
- package/dist/file/sections/rules.js +9 -0
- package/dist/file/sections/seats.d.ts +7 -0
- package/dist/file/sections/seats.js +161 -0
- package/dist/file/sections/section.d.ts +32 -0
- package/dist/file/sections/section.js +7 -0
- package/dist/file/sections/session.d.ts +2 -0
- package/dist/file/sections/session.js +16 -0
- package/dist/file/sections/tools.d.ts +2 -0
- package/dist/file/sections/tools.js +43 -0
- package/dist/file/sections/trust.d.ts +2 -0
- package/dist/file/sections/trust.js +20 -0
- package/dist/file/sections/units.d.ts +16 -0
- package/dist/file/sections/units.js +8 -0
- package/dist/file/sections/visibility.d.ts +2 -0
- package/dist/file/sections/visibility.js +12 -0
- package/dist/file/sections/watch-checks.d.ts +9 -0
- package/dist/file/sections/watch-checks.js +14 -0
- package/dist/file/sections/watch.d.ts +14 -0
- package/dist/file/sections/watch.js +111 -0
- package/dist/file/sections/workspace.d.ts +4 -0
- package/dist/file/sections/workspace.js +77 -0
- package/dist/file/types.d.ts +1 -0
- package/dist/file/validate.d.ts +4 -12
- package/dist/file/validate.js +45 -688
- package/dist/herdr.d.ts +1 -0
- package/dist/herdr.js +11 -1
- package/dist/launch/agent.js +4 -3
- package/dist/launch/deliver.d.ts +4 -0
- package/dist/launch/deliver.js +151 -8
- package/dist/launch/execute.d.ts +2 -0
- package/dist/launch/execute.js +21 -11
- package/dist/launch/plan.js +16 -2
- package/dist/profiles/antigravity.yaml +12 -2
- package/dist/profiles/claude-code.yaml +21 -6
- package/dist/profiles/codex.yaml +14 -1
- package/dist/profiles/cursor.yaml +45 -5
- package/dist/profiles/overrides.d.ts +67 -0
- package/dist/profiles/overrides.js +190 -0
- package/dist/profiles/profile.d.ts +3 -0
- package/dist/profiles/profile.js +5 -1
- package/dist/state.d.ts +2 -0
- package/dist/status/compare.js +7 -4
- package/dist/store/store.d.ts +5 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +7 -0
- package/dist/watch/checks/budget.js +49 -53
- package/dist/watch/dialect.d.ts +6 -1
- package/dist/watch/dialect.js +68 -12
- package/dist/watch/pass.d.ts +8 -1
- package/dist/watch/pass.js +19 -9
- package/dist/watch/screen-core.d.ts +41 -4
- package/dist/watch/screen-core.js +458 -93
- package/dist/watch/screen-data.d.ts +25 -0
- package/dist/watch/screen-file.d.ts +8 -2
- package/dist/watch/screen-file.js +325 -38
- package/dist/watch/screen-profile.d.ts +19 -0
- package/dist/watch/screen-profile.js +1 -0
- package/dist/watch/screen.d.ts +22 -4
- package/dist/watch/screen.js +39 -5
- package/package.json +9 -3
- package/schema/team.schema.json +498 -0
package/dist/file/validate.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { CLIS } from "../clis.js";
|
|
2
|
-
import { ALWAYS_ON, CHECK_NAMES } from "../watch/check.js";
|
|
3
1
|
import { YamlError, parseYaml } from "../yaml.js";
|
|
4
2
|
import { findSecrets } from "./secrets.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
import { Check } from "./check.js";
|
|
4
|
+
import { SECTIONS } from "./sections/index.js";
|
|
5
|
+
import { valueOf } from "./sections/section.js";
|
|
6
|
+
export { defaultLabel } from "./sections/seats.js";
|
|
7
|
+
export { defaultWatch } from "./sections/watch.js";
|
|
8
|
+
export { defaultBudgets } from "./sections/budgets.js";
|
|
9
9
|
// Validates the text of a team file. Every problem is collected, each with its line.
|
|
10
10
|
export function validateTeamFile(text) {
|
|
11
11
|
let root;
|
|
@@ -27,696 +27,53 @@ export function validateTeamFile(text) {
|
|
|
27
27
|
}
|
|
28
28
|
return { ok: true, team, warnings: check.warnings };
|
|
29
29
|
}
|
|
30
|
-
class Check {
|
|
31
|
-
problems = [];
|
|
32
|
-
warnings = [];
|
|
33
|
-
fail(line, message) {
|
|
34
|
-
this.problems.push({ line, message });
|
|
35
|
-
}
|
|
36
|
-
// The fields of a map, with every field outside `known` reported.
|
|
37
|
-
fields(node, where, known) {
|
|
38
|
-
const fields = new Map();
|
|
39
|
-
if (!node)
|
|
40
|
-
return fields;
|
|
41
|
-
if (node.kind !== 'map') {
|
|
42
|
-
if (!(node.kind === 'scalar' && node.value === null))
|
|
43
|
-
this.fail(node.line, `${where} must be a map of fields`);
|
|
44
|
-
return fields;
|
|
45
|
-
}
|
|
46
|
-
for (const entry of node.entries) {
|
|
47
|
-
if (known.includes(entry.key))
|
|
48
|
-
fields.set(entry.key, entry);
|
|
49
|
-
else
|
|
50
|
-
this.fail(entry.line, `unknown field "${entry.key}" in ${where}`);
|
|
51
|
-
}
|
|
52
|
-
return fields;
|
|
53
|
-
}
|
|
54
|
-
text(entry, name) {
|
|
55
|
-
if (!entry)
|
|
56
|
-
return undefined;
|
|
57
|
-
const node = entry.value;
|
|
58
|
-
if (node.kind === 'scalar' && typeof node.value === 'string' && node.value !== '')
|
|
59
|
-
return node.value;
|
|
60
|
-
this.fail(node.line, `${name} must be text${node.kind === 'scalar' && node.value !== null && node.value !== '' ? ': quote it' : ''}`);
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
required(entry, name, line) {
|
|
64
|
-
if (!entry)
|
|
65
|
-
this.fail(line, `${name} is required`);
|
|
66
|
-
return this.text(entry, name);
|
|
67
|
-
}
|
|
68
|
-
oneOf(entry, name, values) {
|
|
69
|
-
if (!entry)
|
|
70
|
-
return undefined;
|
|
71
|
-
const node = entry.value;
|
|
72
|
-
if (node.kind === 'scalar' && typeof node.value === 'string' && values.includes(node.value)) {
|
|
73
|
-
return node.value;
|
|
74
|
-
}
|
|
75
|
-
this.fail(node.line, `${name} must be one of: ${values.join(', ')}`);
|
|
76
|
-
return undefined;
|
|
77
|
-
}
|
|
78
|
-
flag(entry, name) {
|
|
79
|
-
if (!entry)
|
|
80
|
-
return false;
|
|
81
|
-
const node = entry.value;
|
|
82
|
-
if (node.kind === 'scalar' && typeof node.value === 'boolean' && !node.quoted)
|
|
83
|
-
return node.value;
|
|
84
|
-
this.fail(node.line, `${name} must be true or false`);
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
whole(entry, name, least) {
|
|
88
|
-
if (!entry)
|
|
89
|
-
return undefined;
|
|
90
|
-
const node = entry.value;
|
|
91
|
-
if (node.kind === 'scalar' && typeof node.value === 'number' && Number.isInteger(node.value) && node.value >= least) {
|
|
92
|
-
return node.value;
|
|
93
|
-
}
|
|
94
|
-
this.fail(node.line, `${name} must be a whole number, ${least} or more`);
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
number(entry, name) {
|
|
98
|
-
if (!entry)
|
|
99
|
-
return undefined;
|
|
100
|
-
const node = entry.value;
|
|
101
|
-
if (node.kind === 'scalar' && typeof node.value === 'number' && Number.isFinite(node.value) && node.value > 0) {
|
|
102
|
-
return node.value;
|
|
103
|
-
}
|
|
104
|
-
this.fail(node.line, `${name} must be a number above 0`);
|
|
105
|
-
return undefined;
|
|
106
|
-
}
|
|
107
|
-
// A value with its unit, such as 120s, 25% or 10GB.
|
|
108
|
-
measure(entry, name, units, example) {
|
|
109
|
-
if (!entry)
|
|
110
|
-
return undefined;
|
|
111
|
-
const node = entry.value;
|
|
112
|
-
const raw = node.kind === 'scalar' ? node.raw : '';
|
|
113
|
-
const match = /^([0-9]+(?:\.[0-9]+)?)([A-Za-z%]+)$/.exec(raw);
|
|
114
|
-
const unit = match?.[2];
|
|
115
|
-
if (match && unit !== undefined && unit in units && node.kind === 'scalar' && !node.quoted) {
|
|
116
|
-
return Number(match[1]) * units[unit];
|
|
117
|
-
}
|
|
118
|
-
this.fail(node.line, `${name} needs a value with its unit (${Object.keys(units).join(', ')}), such as ${example}`);
|
|
119
|
-
return undefined;
|
|
120
|
-
}
|
|
121
|
-
list(entry, name) {
|
|
122
|
-
if (!entry)
|
|
123
|
-
return [];
|
|
124
|
-
const node = entry.value;
|
|
125
|
-
if (node.kind !== 'seq') {
|
|
126
|
-
if (!(node.kind === 'scalar' && node.value === null))
|
|
127
|
-
this.fail(node.line, `${name} must be a list`);
|
|
128
|
-
return [];
|
|
129
|
-
}
|
|
130
|
-
const out = [];
|
|
131
|
-
for (const item of node.items) {
|
|
132
|
-
if (item.kind === 'scalar' && typeof item.value === 'string' && item.value !== '') {
|
|
133
|
-
out.push({ value: item.value, line: item.line });
|
|
134
|
-
}
|
|
135
|
-
else {
|
|
136
|
-
this.fail(item.line, `each item of ${name} must be text${item.kind === 'map' ? ': quote a line that contains ": "' : ''}`);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return out;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
const DURATION = { s: 1, m: 60, h: 3600 };
|
|
143
|
-
const PERCENT = { '%': 1 };
|
|
144
|
-
const SIZE = { MB: 1e6, GB: 1e9, TB: 1e12 };
|
|
145
30
|
function readTeam(root, check) {
|
|
146
31
|
if (root.kind !== 'map') {
|
|
147
32
|
check.fail(root.line, 'the file must be a map of fields, starting with "format: 1"');
|
|
148
33
|
return null;
|
|
149
34
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (problem)
|
|
175
|
-
check.fail(item.line, `trust: "${item.value}" ${problem}`);
|
|
176
|
-
}
|
|
177
|
-
const trust = trustItems.map((item) => item.value);
|
|
178
|
-
// Both are read before the seats: a seat's `account:` must be a key of the budgets' accounts,
|
|
179
|
-
// and the check needs the names the file declares.
|
|
180
|
-
const watched = readWatch(top.get('watch'), check);
|
|
181
|
-
const budget = readBudgets(top.get('budgets'), check, watched.watch.quotaMarks, watched.legacyMarks);
|
|
182
|
-
const broken = new Set();
|
|
183
|
-
const seats = readSeats(top.get('seats'), check, trust, root.line, broken, budget.declared);
|
|
184
|
-
const workspace = readWorkspace(top.get('workspace'), check, project, trust, seats, root.line);
|
|
35
|
+
// `watch.checks` names a line inside `watch`, not a top-level key, so it is no field of the file.
|
|
36
|
+
const top = check.fields(root, 'the file', SECTIONS.filter((section) => !section.name.includes('.')).map((section) => section.name));
|
|
37
|
+
const ctx = { check, root, top, broken: new Set(), values: new Map() };
|
|
38
|
+
// One wave over the list at a time: a section runs once every section it reads (`after`) has
|
|
39
|
+
// run, so the code moved into the modules reports in the order it reported when it lived here.
|
|
40
|
+
const pending = new Map(SECTIONS.map((section) => [section.name, section]));
|
|
41
|
+
while (pending.size > 0) {
|
|
42
|
+
let ran = 0;
|
|
43
|
+
for (const section of SECTIONS) {
|
|
44
|
+
if (!pending.has(section.name))
|
|
45
|
+
continue;
|
|
46
|
+
if (section.after.some((name) => pending.has(name)))
|
|
47
|
+
continue;
|
|
48
|
+
ctx.values.set(section.name, section.validate(top.get(section.name), ctx));
|
|
49
|
+
pending.delete(section.name);
|
|
50
|
+
ran++;
|
|
51
|
+
}
|
|
52
|
+
if (ran === 0)
|
|
53
|
+
throw new Error(`circular section dependencies: ${[...pending.keys()].join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
// A seat that sets no mode works the way the workspace says. Kept here: it reads two sections'
|
|
56
|
+
// values and belongs to neither.
|
|
57
|
+
const seats = valueOf(ctx, 'seats');
|
|
58
|
+
const mode = valueOf(ctx, 'workspace').mode;
|
|
185
59
|
for (const seat of seats)
|
|
186
|
-
seat.mode ??=
|
|
187
|
-
const coordinator = readLead(top.get('coordinator'), 'coordinator', seats, broken, check);
|
|
188
|
-
const operator = readLead(top.get('operator'), 'operator', seats, broken, check);
|
|
189
|
-
const limits = readLimits(top.get('limits'), check, seats.length);
|
|
60
|
+
seat.mode ??= mode;
|
|
190
61
|
return {
|
|
191
62
|
format: 1,
|
|
192
|
-
project,
|
|
193
|
-
visibility,
|
|
194
|
-
session,
|
|
195
|
-
coordinator,
|
|
196
|
-
operator,
|
|
197
|
-
tools:
|
|
198
|
-
identity,
|
|
199
|
-
rules:
|
|
200
|
-
trust,
|
|
201
|
-
workspace,
|
|
202
|
-
watch:
|
|
203
|
-
budgets:
|
|
204
|
-
machine:
|
|
205
|
-
limits,
|
|
63
|
+
project: valueOf(ctx, 'project'),
|
|
64
|
+
visibility: valueOf(ctx, 'visibility'),
|
|
65
|
+
session: valueOf(ctx, 'session'),
|
|
66
|
+
coordinator: valueOf(ctx, 'coordinator'),
|
|
67
|
+
operator: valueOf(ctx, 'operator'),
|
|
68
|
+
tools: valueOf(ctx, 'tools'),
|
|
69
|
+
identity: valueOf(ctx, 'identity'),
|
|
70
|
+
rules: valueOf(ctx, 'rules'),
|
|
71
|
+
trust: valueOf(ctx, 'trust'),
|
|
72
|
+
workspace: valueOf(ctx, 'workspace'),
|
|
73
|
+
watch: valueOf(ctx, 'watch').watch,
|
|
74
|
+
budgets: valueOf(ctx, 'budgets').budgets,
|
|
75
|
+
machine: valueOf(ctx, 'machine'),
|
|
76
|
+
limits: valueOf(ctx, 'limits'),
|
|
206
77
|
seats: seats,
|
|
207
78
|
};
|
|
208
79
|
}
|
|
209
|
-
function readTools(entry, check) {
|
|
210
|
-
const tools = {};
|
|
211
|
-
if (!entry)
|
|
212
|
-
return tools;
|
|
213
|
-
if (entry.value.kind !== 'map') {
|
|
214
|
-
check.fail(entry.value.line, 'tools must be a map: a name, then its kind and where it is');
|
|
215
|
-
return tools;
|
|
216
|
-
}
|
|
217
|
-
for (const tool of entry.value.entries) {
|
|
218
|
-
const fields = {};
|
|
219
|
-
if (tool.value.kind !== 'map') {
|
|
220
|
-
check.fail(tool.value.line, `tools.${tool.key} must be a map with a kind`);
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
for (const field of tool.value.entries) {
|
|
224
|
-
const value = check.text(field, `tools.${tool.key}.${field.key}`);
|
|
225
|
-
if (value !== undefined)
|
|
226
|
-
fields[field.key] = value;
|
|
227
|
-
}
|
|
228
|
-
if (!('kind' in fields))
|
|
229
|
-
check.fail(tool.line, `tools.${tool.key} needs a kind`);
|
|
230
|
-
tools[tool.key] = fields;
|
|
231
|
-
}
|
|
232
|
-
return tools;
|
|
233
|
-
}
|
|
234
|
-
function readIdentity(entry, check) {
|
|
235
|
-
const identity = check.fields(entry?.value, 'identity', ['signature', 'humans', 'since', 'forbidden', 'forbidden_public']);
|
|
236
|
-
const signature = check.fields(identity.get('signature')?.value, 'identity.signature', ['template', 'commits', 'pull_requests']);
|
|
237
|
-
const commits = check.fields(signature.get('commits')?.value, 'identity.signature.commits', ['template', 'position', 'exempt']);
|
|
238
|
-
const prs = check.fields(signature.get('pull_requests')?.value, 'identity.signature.pull_requests', ['template', 'position', 'exempt']);
|
|
239
|
-
const base = readTemplate(signature.get('template'), 'identity.signature.template', check) ?? DEFAULT_COMMIT_TEMPLATE;
|
|
240
|
-
const commitTemplate = readTemplate(commits.get('template'), 'identity.signature.commits.template', check) ?? base;
|
|
241
|
-
const prTemplate = readTemplate(prs.get('template'), 'identity.signature.pull_requests.template', check) ?? DEFAULT_PR_TEMPLATE;
|
|
242
|
-
const commitPosition = check.oneOf(commits.get('position'), 'identity.signature.commits.position', POSITIONS) ?? 'trailer';
|
|
243
|
-
const prPosition = check.oneOf(prs.get('position'), 'identity.signature.pull_requests.position', POSITIONS) ?? 'last-line';
|
|
244
|
-
if (commitPosition === 'trailer' && !isTrailerTemplate(commitTemplate)) {
|
|
245
|
-
const line = commits.get('position')?.line ?? commits.get('template')?.line ?? signature.get('template')?.line ?? entry?.line ?? 1;
|
|
246
|
-
check.fail(line, 'position "trailer" needs a template git reads as a trailer: "<token>: <value>", the token without spaces');
|
|
247
|
-
}
|
|
248
|
-
const exemptEntry = commits.get('exempt');
|
|
249
|
-
const exempt = exemptEntry ? check.list(exemptEntry, 'identity.signature.commits.exempt') : [{ value: 'merge', line: 0 }];
|
|
250
|
-
for (const item of exempt) {
|
|
251
|
-
if (item.value !== 'merge')
|
|
252
|
-
check.fail(item.line, `exempt takes only "merge" in this version, found "${item.value}"`);
|
|
253
|
-
}
|
|
254
|
-
const prExempt = prs.get('exempt');
|
|
255
|
-
if (prExempt)
|
|
256
|
-
check.fail(prExempt.line, 'pull_requests has no exempt: it applies to commits only');
|
|
257
|
-
const forbidden = readPatterns(identity.get('forbidden'), 'identity.forbidden', check);
|
|
258
|
-
const forbiddenPublic = readPatterns(identity.get('forbidden_public'), 'identity.forbidden_public', check);
|
|
259
|
-
return {
|
|
260
|
-
signature: {
|
|
261
|
-
commits: { template: commitTemplate, position: commitPosition, exempt: exempt.some((item) => item.value === 'merge') ? ['merge'] : [] },
|
|
262
|
-
pullRequests: { template: prTemplate, position: prPosition },
|
|
263
|
-
},
|
|
264
|
-
humans: check.list(identity.get('humans'), 'identity.humans').map((item) => item.value),
|
|
265
|
-
since: check.text(identity.get('since'), 'identity.since') ?? null,
|
|
266
|
-
forbidden: [...DEFAULT_FORBIDDEN, ...forbidden.filter((pattern) => !DEFAULT_FORBIDDEN.includes(pattern))],
|
|
267
|
-
forbiddenPublic,
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
function readTemplate(entry, name, check) {
|
|
271
|
-
const template = check.text(entry, name);
|
|
272
|
-
if (template === undefined || !entry)
|
|
273
|
-
return undefined;
|
|
274
|
-
const problem = templateProblem(template);
|
|
275
|
-
if (problem)
|
|
276
|
-
check.fail(entry.value.line, `${name}: ${problem}`);
|
|
277
|
-
return template;
|
|
278
|
-
}
|
|
279
|
-
function readPatterns(entry, name, check) {
|
|
280
|
-
const out = [];
|
|
281
|
-
for (const item of check.list(entry, name)) {
|
|
282
|
-
try {
|
|
283
|
-
new RegExp(item.value);
|
|
284
|
-
out.push(item.value);
|
|
285
|
-
}
|
|
286
|
-
catch (error) {
|
|
287
|
-
check.fail(item.line, `${name}: not a regular expression (${error.message})`);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
return out;
|
|
291
|
-
}
|
|
292
|
-
// `broken` collects the names of seats left out for their own problems, so that a reference to
|
|
293
|
-
// one is not reported a second time. `accounts` is the names the file's budgets declare: a seat
|
|
294
|
-
// that names an `account:` must name one of these, or it would silently spend nothing budgeted.
|
|
295
|
-
function readSeats(entry, check, trust, topLine, broken, accounts) {
|
|
296
|
-
if (!entry) {
|
|
297
|
-
check.fail(topLine, 'seats is required: at least the coordinator\'s and the operator\'s seat');
|
|
298
|
-
return [];
|
|
299
|
-
}
|
|
300
|
-
if (entry.value.kind !== 'seq' || !entry.value.items.length) {
|
|
301
|
-
check.fail(entry.value.line, 'seats must be a list with at least one seat');
|
|
302
|
-
return [];
|
|
303
|
-
}
|
|
304
|
-
const seats = [];
|
|
305
|
-
for (const item of entry.value.items) {
|
|
306
|
-
const fields = check.fields(item, 'a seat', [
|
|
307
|
-
'role', 'name', 'cli', 'vendor', 'account', 'model', 'version', 'display', 'launch', 'cwd', 'label', 'mode',
|
|
308
|
-
'parked', 'stopped', 'count',
|
|
309
|
-
]);
|
|
310
|
-
if (item.kind !== 'map')
|
|
311
|
-
continue;
|
|
312
|
-
const line = item.line;
|
|
313
|
-
const role = check.required(fields.get('role'), 'a seat\'s role', line);
|
|
314
|
-
const name = check.required(fields.get('name'), 'a seat\'s name', line);
|
|
315
|
-
const at = name ? `seat "${name}"` : 'a seat';
|
|
316
|
-
if (name && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
|
|
317
|
-
check.fail(fields.get('name')?.line ?? line, `${at}: the name takes letters, digits, ".", "_" and "-"`);
|
|
318
|
-
}
|
|
319
|
-
if (!fields.get('cli'))
|
|
320
|
-
check.fail(line, `${at}: cli is required`);
|
|
321
|
-
const cli = check.oneOf(fields.get('cli'), `${at}: cli`, CLIS);
|
|
322
|
-
const vendor = check.required(fields.get('vendor'), `${at}: vendor`, line);
|
|
323
|
-
// The account whose budget the seat spends, when the vendor's name is not it (§ 3b). Optional:
|
|
324
|
-
// absent, the seat spends its vendor, and the fingerprint of every file written so far is
|
|
325
|
-
// unchanged. Named, it must be one of the budgets' accounts: a typo would take the seat out of
|
|
326
|
-
// every budget with no warning at all.
|
|
327
|
-
const account = check.text(fields.get('account'), `${at}: account`);
|
|
328
|
-
if (account !== undefined && !accounts.has(account)) {
|
|
329
|
-
check.fail(fields.get('account')?.line ?? line, `${at}: account "${account}" is not in budgets.accounts`);
|
|
330
|
-
}
|
|
331
|
-
const model = check.required(fields.get('model'), `${at}: model`, line);
|
|
332
|
-
const version = readVersion(fields.get('version'), at, line, check);
|
|
333
|
-
const launch = check.required(fields.get('launch'), `${at}: launch`, line);
|
|
334
|
-
const display = check.text(fields.get('display'), `${at}: display`) ?? (model && version ? `${model} ${version}` : undefined);
|
|
335
|
-
if (display && version && fields.get('display') && !hasVersionToken(display, version)) {
|
|
336
|
-
check.fail(fields.get('display')?.line ?? line, `${at}: display must contain the version "${version}" as a token of its own`);
|
|
337
|
-
}
|
|
338
|
-
const cwd = readCwd(fields.get('cwd'), at, trust, check);
|
|
339
|
-
const mode = check.oneOf(fields.get('mode'), `${at}: mode`, MODES);
|
|
340
|
-
const parked = check.flag(fields.get('parked'), `${at}: parked`);
|
|
341
|
-
const stopped = check.flag(fields.get('stopped'), `${at}: stopped`);
|
|
342
|
-
const count = check.whole(fields.get('count'), `${at}: count`, 1) ?? 1;
|
|
343
|
-
const label = check.text(fields.get('label'), `${at}: label`);
|
|
344
|
-
if (!role || !name || !cli || !vendor || !model || !version || !launch || !display) {
|
|
345
|
-
if (name)
|
|
346
|
-
broken.add(name);
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
for (let instance = 1; instance <= count; instance++) {
|
|
350
|
-
const suffix = instance === 1 ? '' : `-${instance}`;
|
|
351
|
-
seats.push({
|
|
352
|
-
role, cli, vendor, model, version, display, launch, cwd, parked, stopped, line,
|
|
353
|
-
name: name + suffix,
|
|
354
|
-
label: (label ?? name) + suffix,
|
|
355
|
-
declared: name,
|
|
356
|
-
count,
|
|
357
|
-
instance,
|
|
358
|
-
...(account ? { account } : {}),
|
|
359
|
-
...(mode ? { mode } : {}),
|
|
360
|
-
});
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
reportCollisions(seats, 'name', check);
|
|
364
|
-
reportCollisions(seats, 'label', check);
|
|
365
|
-
return seats;
|
|
366
|
-
}
|
|
367
|
-
function readVersion(entry, at, line, check) {
|
|
368
|
-
if (!entry) {
|
|
369
|
-
check.fail(line, `${at}: version is required`);
|
|
370
|
-
return undefined;
|
|
371
|
-
}
|
|
372
|
-
const node = entry.value;
|
|
373
|
-
if (node.kind === 'scalar' && typeof node.value === 'string' && node.value !== '')
|
|
374
|
-
return node.value;
|
|
375
|
-
if (node.kind === 'scalar' && typeof node.value === 'number') {
|
|
376
|
-
check.fail(node.line, `${at}: version must be quoted ("${node.raw}"): unquoted, YAML reads it as a number and 4.10 becomes 4.1`);
|
|
377
|
-
}
|
|
378
|
-
else
|
|
379
|
-
check.fail(node.line, `${at}: version must be quoted text`);
|
|
380
|
-
return undefined;
|
|
381
|
-
}
|
|
382
|
-
function readCwd(entry, at, trust, check) {
|
|
383
|
-
const cwd = check.text(entry, `${at}: cwd`);
|
|
384
|
-
if (cwd === undefined || !entry)
|
|
385
|
-
return '.';
|
|
386
|
-
if (cwd.startsWith('/') || cwd.startsWith('~') || /^[A-Za-z]:[\\/]/.test(cwd)) {
|
|
387
|
-
check.fail(entry.value.line, `${at}: cwd must be relative to the project, never absolute and never "~"`);
|
|
388
|
-
return '.';
|
|
389
|
-
}
|
|
390
|
-
const path = normalize(cwd);
|
|
391
|
-
if (!insideProject(path) && !insideTrust(path, trust)) {
|
|
392
|
-
check.fail(entry.value.line, `${at}: cwd "${cwd}" is outside the project and matches no trust path`);
|
|
393
|
-
}
|
|
394
|
-
return path;
|
|
395
|
-
}
|
|
396
|
-
function reportCollisions(seats, field, check) {
|
|
397
|
-
const seen = new Map();
|
|
398
|
-
for (const seat of seats) {
|
|
399
|
-
const first = seen.get(seat[field]);
|
|
400
|
-
if (!first)
|
|
401
|
-
seen.set(seat[field], seat);
|
|
402
|
-
else if (first.line !== seat.line || first.instance === seat.instance) {
|
|
403
|
-
check.fail(seat.line, `two seats have the ${field} "${seat[field]}" (the other is on line ${first.line}${first.count > 1 || seat.count > 1 ? ', after count is expanded' : ''})`);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
function readLead(entry, field, seats, broken, check) {
|
|
408
|
-
const name = check.required(entry, field, 1);
|
|
409
|
-
if (!name || !entry)
|
|
410
|
-
return '';
|
|
411
|
-
const seat = seats.find((candidate) => candidate.name === name);
|
|
412
|
-
const line = entry.value.line;
|
|
413
|
-
if (!seat) {
|
|
414
|
-
if (!broken.has(name))
|
|
415
|
-
check.fail(line, `${field} "${name}" names no declared seat`);
|
|
416
|
-
}
|
|
417
|
-
else if (seat.count > 1)
|
|
418
|
-
check.fail(line, `${field} "${name}" can't be a seat with count`);
|
|
419
|
-
else if (seat.parked)
|
|
420
|
-
check.fail(line, `${field} "${name}" can't be a parked seat`);
|
|
421
|
-
else if (seat.stopped)
|
|
422
|
-
check.fail(line, `${field} "${name}" can't be a stopped seat`);
|
|
423
|
-
return name;
|
|
424
|
-
}
|
|
425
|
-
function readWorkspace(entry, check, project, trust, seats, topLine) {
|
|
426
|
-
const fields = check.fields(entry?.value, 'workspace', ['mode', 'path', 'branch', 'base', 'setup', 'remove', 'protected', 'limit']);
|
|
427
|
-
const mode = check.oneOf(fields.get('mode'), 'workspace.mode', MODES) ?? 'worktree';
|
|
428
|
-
const usesWorktrees = seats.some((seat) => (seat.mode ?? mode) === 'worktree');
|
|
429
|
-
const line = entry?.line ?? topLine;
|
|
430
|
-
const path = check.text(fields.get('path'), 'workspace.path') ?? null;
|
|
431
|
-
const base = check.text(fields.get('base'), 'workspace.base') ?? null;
|
|
432
|
-
if (usesWorktrees && !base)
|
|
433
|
-
check.fail(line, 'workspace.base is required when a seat works in worktrees');
|
|
434
|
-
if (usesWorktrees && !path)
|
|
435
|
-
check.fail(line, 'workspace.path is required when a seat works in worktrees');
|
|
436
|
-
if (path) {
|
|
437
|
-
const at = fields.get('path')?.value.line ?? line;
|
|
438
|
-
const problem = worktreePathProblem(path, project, trust);
|
|
439
|
-
if (problem)
|
|
440
|
-
check.fail(at, `workspace.path ${problem}`);
|
|
441
|
-
}
|
|
442
|
-
const protectedPaths = fields.get('protected') ? check.list(fields.get('protected'), 'workspace.protected').map((item) => item.value) : ['.'];
|
|
443
|
-
return {
|
|
444
|
-
mode,
|
|
445
|
-
path,
|
|
446
|
-
branch: check.text(fields.get('branch'), 'workspace.branch') ?? '{task}',
|
|
447
|
-
base,
|
|
448
|
-
setup: check.list(fields.get('setup'), 'workspace.setup').map((item) => item.value),
|
|
449
|
-
remove: check.oneOf(fields.get('remove'), 'workspace.remove', ['on-merge', 'manual']) ?? 'on-merge',
|
|
450
|
-
protected: protectedPaths,
|
|
451
|
-
limit: check.whole(fields.get('limit'), 'workspace.limit', 1) ?? 8,
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
// A worktree goes under a fixed folder of its own, and inside a trust path.
|
|
455
|
-
function worktreePathProblem(path, project, trust) {
|
|
456
|
-
if (path.startsWith('/') || path.startsWith('~'))
|
|
457
|
-
return 'must be relative to the project';
|
|
458
|
-
const segments = path.split('/').filter((segment) => segment && segment !== '.');
|
|
459
|
-
const last = segments[segments.length - 1];
|
|
460
|
-
if (last !== '{task}')
|
|
461
|
-
return 'must end with the segment {task}';
|
|
462
|
-
if (segments.slice(0, -1).some((segment) => segment.includes('{task}')))
|
|
463
|
-
return 'takes {task} only as its last segment';
|
|
464
|
-
const folder = segments.slice(0, -1).map((segment) => segment.replaceAll('{repo}', project || 'project'));
|
|
465
|
-
if (folder.every((segment) => segment === '..')) {
|
|
466
|
-
return 'needs a fixed folder of its own before {task}: this one puts worktrees straight into the project or one of its parents';
|
|
467
|
-
}
|
|
468
|
-
const sample = normalize([...folder, 'task'].join('/'));
|
|
469
|
-
if (!insideTrust(sample, trust))
|
|
470
|
-
return 'matches no trust pattern';
|
|
471
|
-
return null;
|
|
472
|
-
}
|
|
473
|
-
/**
|
|
474
|
-
* The watch values a file that sets none runs with — and, since the section is the owner's, the
|
|
475
|
-
* values a file runs with until the owner approves what it sets instead.
|
|
476
|
-
*/
|
|
477
|
-
export function defaultWatch() {
|
|
478
|
-
return {
|
|
479
|
-
interval: 120,
|
|
480
|
-
idleFirst: 600,
|
|
481
|
-
idleRepeat: 1200,
|
|
482
|
-
teamIdle: 600,
|
|
483
|
-
nudgeWait: 600,
|
|
484
|
-
unsentAfter: 60,
|
|
485
|
-
quotaMarks: [50, 75, 90],
|
|
486
|
-
checks: [],
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
function readWatch(entry, check) {
|
|
490
|
-
const fields = check.fields(entry?.value, 'watch', [
|
|
491
|
-
'interval', 'idle_first', 'idle_repeat', 'team_idle', 'nudge_wait', 'unsent_after', 'quota_marks', 'checks',
|
|
492
|
-
]);
|
|
493
|
-
const time = (name, fallback) => check.measure(fields.get(name), `watch.${name}`, DURATION, '120s or 10m') ?? fallback;
|
|
494
|
-
const base = defaultWatch();
|
|
495
|
-
let quotaMarks = base.quotaMarks;
|
|
496
|
-
const marks = fields.get('quota_marks');
|
|
497
|
-
if (marks) {
|
|
498
|
-
const node = marks.value;
|
|
499
|
-
const numbers = node.kind === 'seq' ? node.items.map((item) => (item.kind === 'scalar' ? item.value : null)) : null;
|
|
500
|
-
if (numbers && numbers.every((n) => typeof n === 'number' && n > 0 && n <= 100))
|
|
501
|
-
quotaMarks = numbers;
|
|
502
|
-
else
|
|
503
|
-
check.fail(node.line, 'watch.quota_marks must be a list of percentages, such as [50, 75, 90]');
|
|
504
|
-
check.warnings.push({ line: node.line, message: 'watch.quota_marks is now budgets.marks, and is still read' });
|
|
505
|
-
}
|
|
506
|
-
return {
|
|
507
|
-
legacyMarks: Boolean(marks),
|
|
508
|
-
watch: {
|
|
509
|
-
interval: time('interval', base.interval),
|
|
510
|
-
idleFirst: time('idle_first', base.idleFirst),
|
|
511
|
-
idleRepeat: time('idle_repeat', base.idleRepeat),
|
|
512
|
-
teamIdle: time('team_idle', base.teamIdle),
|
|
513
|
-
nudgeWait: time('nudge_wait', base.nudgeWait),
|
|
514
|
-
unsentAfter: time('unsent_after', base.unsentAfter),
|
|
515
|
-
quotaMarks,
|
|
516
|
-
checks: readChecks(fields.get('checks'), check),
|
|
517
|
-
},
|
|
518
|
-
};
|
|
519
|
-
}
|
|
520
|
-
// The checks a file turns off: a map of check name to `off`. A name that doesn't exist is refused
|
|
521
|
-
// with its line, and so is one of the four that always run (RFC 0002 § 4.2) — the schema refuses
|
|
522
|
-
// them, and `team approve` validates before it fingerprints, so neither can approve them either.
|
|
523
|
-
function readChecks(entry, check) {
|
|
524
|
-
const off = [];
|
|
525
|
-
if (!entry)
|
|
526
|
-
return off;
|
|
527
|
-
const node = entry.value;
|
|
528
|
-
if (node.kind !== 'map') {
|
|
529
|
-
if (!(node.kind === 'scalar' && node.value === null)) {
|
|
530
|
-
check.fail(node.line, 'watch.checks must be a map: a check named, and set off');
|
|
531
|
-
}
|
|
532
|
-
return off;
|
|
533
|
-
}
|
|
534
|
-
for (const item of node.entries) {
|
|
535
|
-
if (!CHECK_NAMES.includes(item.key)) {
|
|
536
|
-
check.fail(item.line, `unknown check "${item.key}" in watch.checks: the checks are ${CHECK_NAMES.join(', ')}`);
|
|
537
|
-
continue;
|
|
538
|
-
}
|
|
539
|
-
if (ALWAYS_ON.includes(item.key)) {
|
|
540
|
-
check.fail(item.line, `watch.checks can't turn off ${item.key}: ${ALWAYS_ON.join(', ')} always run`);
|
|
541
|
-
continue;
|
|
542
|
-
}
|
|
543
|
-
if (item.value.kind === 'scalar' && item.value.value === 'off')
|
|
544
|
-
off.push(item.key);
|
|
545
|
-
else
|
|
546
|
-
check.fail(item.line, `watch.checks.${item.key} must be off: the only setting is off`);
|
|
547
|
-
}
|
|
548
|
-
return off;
|
|
549
|
-
}
|
|
550
|
-
/**
|
|
551
|
-
* The budget values a file that sets none runs with — and, since the section is the owner's, the
|
|
552
|
-
* values a file runs with until the owner approves what it sets instead. No accounts: nothing is
|
|
553
|
-
* budgeted until the owner names one.
|
|
554
|
-
*/
|
|
555
|
-
export function defaultBudgets() {
|
|
556
|
-
return { staleAfter: 30 * 60, checkEvery: 10 * 60, marks: defaultWatch().quotaMarks, accounts: {} };
|
|
557
|
-
}
|
|
558
|
-
function readBudgets(entry, check, fallbackMarks, legacyMarks) {
|
|
559
|
-
const base = { ...defaultBudgets(), marks: fallbackMarks };
|
|
560
|
-
if (!entry)
|
|
561
|
-
return { budgets: base, declared: new Set() };
|
|
562
|
-
const fields = check.fields(entry.value, 'budgets', ['stale_after', 'check_every', 'marks', 'accounts']);
|
|
563
|
-
// Every name the accounts map writes, whether or not the account itself reads cleanly: a seat
|
|
564
|
-
// naming one of these is not also told it named nothing.
|
|
565
|
-
const accounts = fields.get('accounts');
|
|
566
|
-
const declared = new Set(accounts?.value.kind === 'map' ? accounts.value.entries.map((account) => account.key) : []);
|
|
567
|
-
const marks = percentList(fields.get('marks'), 'budgets.marks', check);
|
|
568
|
-
if (marks && legacyMarks) {
|
|
569
|
-
check.warnings.push({ line: fields.get('marks')?.line ?? entry.line, message: 'budgets.marks replaces watch.quota_marks' });
|
|
570
|
-
}
|
|
571
|
-
return {
|
|
572
|
-
budgets: {
|
|
573
|
-
staleAfter: check.measure(fields.get('stale_after'), 'budgets.stale_after', DURATION, '30m') ?? base.staleAfter,
|
|
574
|
-
checkEvery: check.measure(fields.get('check_every'), 'budgets.check_every', DURATION, '10m') ?? base.checkEvery,
|
|
575
|
-
marks: marks ?? fallbackMarks,
|
|
576
|
-
accounts: readAccounts(fields.get('accounts'), check),
|
|
577
|
-
},
|
|
578
|
-
declared,
|
|
579
|
-
};
|
|
580
|
-
}
|
|
581
|
-
function percentList(entry, name, check) {
|
|
582
|
-
if (!entry)
|
|
583
|
-
return null;
|
|
584
|
-
const node = entry.value;
|
|
585
|
-
const numbers = node.kind === 'seq' ? node.items.map((item) => (item.kind === 'scalar' ? item.value : null)) : null;
|
|
586
|
-
if (numbers && numbers.length > 0 && numbers.every((n) => typeof n === 'number' && n > 0 && n <= 100))
|
|
587
|
-
return numbers;
|
|
588
|
-
check.fail(node.line, `${name} must be a list of percentages, such as [50, 75, 90]`);
|
|
589
|
-
return null;
|
|
590
|
-
}
|
|
591
|
-
function readAccounts(entry, check) {
|
|
592
|
-
const accounts = {};
|
|
593
|
-
if (!entry)
|
|
594
|
-
return accounts;
|
|
595
|
-
if (entry.value.kind !== 'map') {
|
|
596
|
-
check.fail(entry.value.line, 'budgets.accounts must be a map');
|
|
597
|
-
return accounts;
|
|
598
|
-
}
|
|
599
|
-
for (const account of entry.value.entries) {
|
|
600
|
-
const fields = check.fields(account.value, `budgets.accounts.${account.key}`, ['kind', 'shared', 'reserve', 'floor', 'sources', 'check']);
|
|
601
|
-
const kind = check.oneOf(fields.get('kind'), `budgets.accounts.${account.key}.kind`, ['subscription', 'spend']);
|
|
602
|
-
if (!kind) {
|
|
603
|
-
check.fail(account.line, `budgets.accounts.${account.key} needs kind`);
|
|
604
|
-
continue;
|
|
605
|
-
}
|
|
606
|
-
const command = check.text(fields.get('check'), `budgets.accounts.${account.key}.check`) ?? null;
|
|
607
|
-
if (command && /\s/.test(command)) {
|
|
608
|
-
check.fail(fields.get('check')?.line ?? account.line, `budgets.accounts.${account.key}.check names one command, with no arguments; use a wrapper`);
|
|
609
|
-
}
|
|
610
|
-
const sources = sourcesOf(fields.get('sources'), account.key, kind, Boolean(command) && !/\s/.test(command ?? ''), check, account.line);
|
|
611
|
-
if (sources.includes('check') && !command)
|
|
612
|
-
check.fail(account.line, `budgets.accounts.${account.key} needs check`);
|
|
613
|
-
if (command && !/\s/.test(command) && !sources.includes('check')) {
|
|
614
|
-
check.fail(fields.get('check')?.line ?? account.line, `budgets.accounts.${account.key}.check needs sources to list check`);
|
|
615
|
-
}
|
|
616
|
-
const measured = kind === 'subscription'
|
|
617
|
-
? check.measure(fields.get('reserve'), `budgets.accounts.${account.key}.reserve`, PERCENT, '20%')
|
|
618
|
-
: undefined;
|
|
619
|
-
const reserve = measured !== undefined && measured > 0 && measured <= 100 ? measured : null;
|
|
620
|
-
if (kind === 'subscription' && measured !== undefined && (measured <= 0 || measured > 100)) {
|
|
621
|
-
check.fail(fields.get('reserve')?.line ?? account.line, `budgets.accounts.${account.key}.reserve must be a percentage above 0 and at most 100, such as 20%`);
|
|
622
|
-
}
|
|
623
|
-
if (kind === 'subscription' && !fields.get('reserve'))
|
|
624
|
-
check.fail(account.line, `budgets.accounts.${account.key} needs reserve`);
|
|
625
|
-
if (kind === 'spend' && fields.get('reserve'))
|
|
626
|
-
check.fail(fields.get('reserve')?.line ?? account.line, 'a spend account has a floor, not a reserve');
|
|
627
|
-
const floor = kind === 'spend' ? money(fields.get('floor'), account.key, check) : null;
|
|
628
|
-
if (kind === 'spend' && !fields.get('floor'))
|
|
629
|
-
check.fail(account.line, `budgets.accounts.${account.key} needs floor`);
|
|
630
|
-
if (kind === 'subscription' && fields.get('floor'))
|
|
631
|
-
check.fail(fields.get('floor')?.line ?? account.line, 'a subscription has a reserve, not a floor');
|
|
632
|
-
accounts[account.key] = {
|
|
633
|
-
kind,
|
|
634
|
-
shared: check.flag(fields.get('shared'), `budgets.accounts.${account.key}.shared`),
|
|
635
|
-
reserve,
|
|
636
|
-
floor,
|
|
637
|
-
sources,
|
|
638
|
-
check: command,
|
|
639
|
-
};
|
|
640
|
-
}
|
|
641
|
-
return accounts;
|
|
642
|
-
}
|
|
643
|
-
function sourcesOf(entry, account, kind, hasCheck, check, line) {
|
|
644
|
-
if (!entry) {
|
|
645
|
-
if (hasCheck)
|
|
646
|
-
return ['check'];
|
|
647
|
-
if (kind === 'subscription')
|
|
648
|
-
return ['status_line'];
|
|
649
|
-
check.fail(line, `budgets.accounts.${account} needs check`);
|
|
650
|
-
return [];
|
|
651
|
-
}
|
|
652
|
-
const items = check.list(entry, `budgets.accounts.${account}.sources`);
|
|
653
|
-
if (items.length === 0) {
|
|
654
|
-
check.fail(entry.line, `budgets.accounts.${account} needs a source`);
|
|
655
|
-
return [];
|
|
656
|
-
}
|
|
657
|
-
const sources = [];
|
|
658
|
-
for (const item of items) {
|
|
659
|
-
if (item.value !== 'check' && item.value !== 'status_line') {
|
|
660
|
-
check.fail(item.line, `budgets.accounts.${account}.sources must be check or status_line`);
|
|
661
|
-
continue;
|
|
662
|
-
}
|
|
663
|
-
if (kind === 'spend' && item.value === 'status_line') {
|
|
664
|
-
check.fail(item.line, 'a spend account never reads a screen');
|
|
665
|
-
continue;
|
|
666
|
-
}
|
|
667
|
-
if (sources.includes(item.value))
|
|
668
|
-
check.fail(item.line, `budgets.accounts.${account}.sources lists ${item.value} twice`);
|
|
669
|
-
else
|
|
670
|
-
sources.push(item.value);
|
|
671
|
-
}
|
|
672
|
-
return sources;
|
|
673
|
-
}
|
|
674
|
-
function money(entry, account, check) {
|
|
675
|
-
if (!entry)
|
|
676
|
-
return null;
|
|
677
|
-
const raw = entry.value.kind === 'scalar' ? entry.value.raw : '';
|
|
678
|
-
const match = /^([0-9]+(?:\.[0-9]{1,4})?) ([A-Z]{3})$/.exec(raw);
|
|
679
|
-
if (!match || match[1] === undefined || match[2] === undefined) {
|
|
680
|
-
check.fail(entry.line, `budgets.accounts.${account}.floor is an amount and a currency, such as 5 USD`);
|
|
681
|
-
return null;
|
|
682
|
-
}
|
|
683
|
-
return { amount: Number(match[1]), currency: match[2] };
|
|
684
|
-
}
|
|
685
|
-
function readMachine(entry, check) {
|
|
686
|
-
const fields = check.fields(entry?.value, 'machine', [
|
|
687
|
-
'load_start', 'load_max', 'memory_start', 'memory_min', 'disk_min', 'swap_free_min', 'swap_growth_max', 'swap_growth_window',
|
|
688
|
-
]);
|
|
689
|
-
return {
|
|
690
|
-
loadStart: check.number(fields.get('load_start'), 'machine.load_start') ?? 3,
|
|
691
|
-
loadMax: check.number(fields.get('load_max'), 'machine.load_max') ?? 6,
|
|
692
|
-
memoryStart: check.measure(fields.get('memory_start'), 'machine.memory_start', PERCENT, '25%') ?? 25,
|
|
693
|
-
memoryMin: check.measure(fields.get('memory_min'), 'machine.memory_min', PERCENT, '15%') ?? 15,
|
|
694
|
-
diskMin: check.measure(fields.get('disk_min'), 'machine.disk_min', SIZE, '10GB') ?? 10e9,
|
|
695
|
-
swapFreeMin: check.measure(fields.get('swap_free_min'), 'machine.swap_free_min', SIZE, '2GB') ?? 2e9,
|
|
696
|
-
swapGrowthMax: check.measure(fields.get('swap_growth_max'), 'machine.swap_growth_max', SIZE, '1GB') ?? 1e9,
|
|
697
|
-
swapGrowthWindow: check.measure(fields.get('swap_growth_window'), 'machine.swap_growth_window', DURATION, '10m') ?? 600,
|
|
698
|
-
};
|
|
699
|
-
}
|
|
700
|
-
function readLimits(entry, check, declared) {
|
|
701
|
-
const fields = check.fields(entry?.value, 'limits', ['seats', 'temporary', 'vendors']);
|
|
702
|
-
const temporary = check.whole(fields.get('temporary'), 'limits.temporary', 0) ?? 2;
|
|
703
|
-
const vendors = {};
|
|
704
|
-
const vendorsEntry = fields.get('vendors');
|
|
705
|
-
if (vendorsEntry) {
|
|
706
|
-
const node = vendorsEntry.value;
|
|
707
|
-
if (node.kind !== 'map')
|
|
708
|
-
check.fail(node.line, 'limits.vendors must be a map: a vendor, then its ceiling');
|
|
709
|
-
else {
|
|
710
|
-
for (const vendor of node.entries) {
|
|
711
|
-
const ceiling = check.whole(vendor, `limits.vendors.${vendor.key}`, 0);
|
|
712
|
-
if (ceiling !== undefined)
|
|
713
|
-
vendors[vendor.key] = ceiling;
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
return {
|
|
718
|
-
seats: check.whole(fields.get('seats'), 'limits.seats', 1) ?? declared + temporary,
|
|
719
|
-
temporary,
|
|
720
|
-
vendors,
|
|
721
|
-
};
|
|
722
|
-
}
|