@kungfu-tech/buildchain 3.0.5-alpha.4 → 3.0.5-alpha.5

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.
@@ -0,0 +1,308 @@
1
+ import crypto from "node:crypto";
2
+ import { parseWorkflowDocument } from "./workflow-yaml-contract.js";
3
+
4
+ export const WORKFLOW_CALL_CONTRACT =
5
+ "kungfu-buildchain-workflow-call-contract/v1";
6
+ export const WORKFLOW_CALL_RECEIPT =
7
+ "kungfu-buildchain-workflow-call-receipt/v1";
8
+
9
+ function stableJson(value) {
10
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
11
+ if (value && typeof value === "object") {
12
+ return `{${Object.keys(value)
13
+ .sort()
14
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
15
+ .join(",")}}`;
16
+ }
17
+ return JSON.stringify(value);
18
+ }
19
+
20
+ function sha256(value) {
21
+ return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`;
22
+ }
23
+
24
+ function exactSha(value) {
25
+ return /^[0-9a-f]{40}$/.test(String(value || "").toLowerCase());
26
+ }
27
+
28
+ function coordinate(uses) {
29
+ const match = String(uses || "").match(
30
+ /^([^/]+\/[^/]+)\/(\.github\/workflows\/[^@]+)@([^@]+)$/,
31
+ );
32
+ return match ? { repository: match[1], path: match[2], ref: match[3] } : null;
33
+ }
34
+
35
+ function permissionLevel(value) {
36
+ if (value === "write" || value === "write-all") return 2;
37
+ if (value === "read" || value === "read-all") return 1;
38
+ if (value === "none" || value === "{}") return 0;
39
+ return -1;
40
+ }
41
+
42
+ function callerPermission(permissions, name) {
43
+ return permissions[name] || permissions["*"] || "none";
44
+ }
45
+
46
+ function validatePermissions(caller, callee) {
47
+ const failures = [];
48
+ for (const [name, required] of Object.entries(callee)) {
49
+ if (name === "*") continue;
50
+ const available = callerPermission(caller, name);
51
+ if (
52
+ permissionLevel(required) < 0 ||
53
+ permissionLevel(available) < permissionLevel(required)
54
+ ) {
55
+ failures.push({
56
+ code: "permission-drift",
57
+ message: `caller permission ${name}=${available} does not satisfy callee ${required}`,
58
+ });
59
+ }
60
+ }
61
+ return failures;
62
+ }
63
+
64
+ function validateInputs(job, reusable) {
65
+ const failures = [];
66
+ const declared = new Map(reusable.inputs.map((entry) => [entry.name, entry]));
67
+ for (const [name, supplied] of Object.entries(job.with)) {
68
+ const input = declared.get(name);
69
+ if (!input) {
70
+ failures.push({
71
+ code: "undeclared-input",
72
+ message: `caller supplies undeclared input: ${name}`,
73
+ });
74
+ continue;
75
+ }
76
+ if (supplied.kind !== "expression" && supplied.kind !== input.type) {
77
+ failures.push({
78
+ code: "input-type-drift",
79
+ message: `caller input ${name} is ${supplied.kind}, callee requires ${input.type}`,
80
+ });
81
+ }
82
+ }
83
+ for (const input of reusable.inputs.filter((entry) => entry.required)) {
84
+ if (!(input.name in job.with)) {
85
+ failures.push({
86
+ code: "missing-required-input",
87
+ message: `caller omits required input: ${input.name}`,
88
+ });
89
+ }
90
+ }
91
+ return failures;
92
+ }
93
+
94
+ function validateSecrets(job, reusable) {
95
+ if (job.secrets === "inherit") {
96
+ return [
97
+ {
98
+ code: "secret-inheritance-not-exact",
99
+ message:
100
+ "secrets: inherit cannot prove an exact reusable-workflow secret contract",
101
+ },
102
+ ];
103
+ }
104
+ const failures = [];
105
+ const declared = new Map(
106
+ reusable.secrets.map((entry) => [entry.name, entry]),
107
+ );
108
+ for (const name of Object.keys(job.secrets)) {
109
+ if (!declared.has(name)) {
110
+ failures.push({
111
+ code: "undeclared-secret",
112
+ message: `caller supplies undeclared secret: ${name}`,
113
+ });
114
+ }
115
+ }
116
+ for (const secret of reusable.secrets.filter((entry) => entry.required)) {
117
+ if (!(secret.name in job.secrets)) {
118
+ failures.push({
119
+ code: "missing-required-secret",
120
+ message: `caller omits required secret: ${secret.name}`,
121
+ });
122
+ }
123
+ }
124
+ return failures;
125
+ }
126
+
127
+ function validateEvents(actual, trusted) {
128
+ const trustedSet = new Set(trusted);
129
+ return actual
130
+ .filter((event) => !trustedSet.has(event))
131
+ .map((event) => ({
132
+ code: "untrusted-event-drift",
133
+ message: `caller admits untrusted event class: ${event}`,
134
+ }));
135
+ }
136
+
137
+ function interfaceProjection(reusable) {
138
+ return {
139
+ inputs: reusable.inputs,
140
+ secrets: reusable.secrets,
141
+ outputs: reusable.outputs,
142
+ permissions: reusable.permissions,
143
+ };
144
+ }
145
+
146
+ function validateCoordinate(job, { repository, workflowPath, sha }) {
147
+ const failures = [];
148
+ const pinned = coordinate(job?.uses);
149
+ if (!pinned) {
150
+ return [
151
+ {
152
+ code: "callee-coordinate-invalid",
153
+ message:
154
+ "caller uses must name owner/repo/.github/workflows/file.yml@ref",
155
+ },
156
+ ];
157
+ }
158
+ if (!exactSha(pinned.ref)) {
159
+ failures.push({
160
+ code: "floating-callee-ref",
161
+ message: `callee ref is not an exact commit SHA: ${pinned.ref}`,
162
+ });
163
+ }
164
+ if (pinned.repository !== repository || pinned.path !== workflowPath) {
165
+ failures.push({
166
+ code: "callee-coordinate-drift",
167
+ message: `caller uses ${pinned.repository}/${pinned.path}, expected ${repository}/${workflowPath}`,
168
+ });
169
+ }
170
+ if (pinned.ref.toLowerCase() !== String(sha || "").toLowerCase()) {
171
+ failures.push({
172
+ code: "stale-pinned-ref",
173
+ message: `caller pin ${pinned.ref} does not match checked callee ${sha}`,
174
+ });
175
+ }
176
+ return failures;
177
+ }
178
+
179
+ export function evaluateWorkflowCallContract({
180
+ callerText,
181
+ calleeText,
182
+ callerRepository,
183
+ callerWorkflowPath,
184
+ callerSha,
185
+ callerTree,
186
+ callerSourceState = "clean",
187
+ calleeRepository,
188
+ calleeWorkflowPath,
189
+ calleeSha,
190
+ jobId,
191
+ trustedEventClasses = [],
192
+ expectedContractRoot = "",
193
+ } = {}) {
194
+ const caller = parseWorkflowDocument(callerText);
195
+ const callee = parseWorkflowDocument(calleeText);
196
+ const job = caller.callJobs.find((entry) => entry.id === jobId);
197
+ const failures = [];
198
+ if (!job)
199
+ failures.push({
200
+ code: "call-job-missing",
201
+ message: `reusable-workflow call job is missing: ${jobId}`,
202
+ });
203
+ if (!callee.interface.reusable)
204
+ failures.push({
205
+ code: "callee-not-reusable",
206
+ message: "callee does not declare on.workflow_call",
207
+ });
208
+
209
+ failures.push(
210
+ ...validateCoordinate(job, {
211
+ repository: calleeRepository,
212
+ workflowPath: calleeWorkflowPath,
213
+ sha: calleeSha,
214
+ }),
215
+ );
216
+ if (!exactSha(calleeSha))
217
+ failures.push({
218
+ code: "callee-sha-invalid",
219
+ message: "callee SHA must be an exact 40-character commit",
220
+ });
221
+ if (!exactSha(callerSha) || !exactSha(callerTree))
222
+ failures.push({
223
+ code: "caller-source-invalid",
224
+ message: "caller SHA and tree must be exact 40-character Git object ids",
225
+ });
226
+
227
+ if (job && callee.interface.reusable) {
228
+ failures.push(...validateInputs(job, callee.interface));
229
+ failures.push(...validateSecrets(job, callee.interface));
230
+ failures.push(
231
+ ...validatePermissions(job.permissions, callee.interface.permissions),
232
+ );
233
+ }
234
+ failures.push(...validateEvents(caller.triggers, trustedEventClasses));
235
+
236
+ const contract = {
237
+ schema: WORKFLOW_CALL_CONTRACT,
238
+ caller: {
239
+ repository: callerRepository,
240
+ workflowPath: callerWorkflowPath,
241
+ workflowDigest: sha256(String(callerText || "")),
242
+ jobId,
243
+ eventClasses: caller.triggers,
244
+ uses: job?.uses || "",
245
+ inputs: job?.with || {},
246
+ secretNames:
247
+ job?.secrets === "inherit"
248
+ ? ["*"]
249
+ : Object.keys(job?.secrets || {}).sort(),
250
+ permissions: job?.permissions || {},
251
+ },
252
+ callee: {
253
+ repository: calleeRepository,
254
+ workflowPath: calleeWorkflowPath,
255
+ sha: String(calleeSha || "").toLowerCase(),
256
+ workflowDigest: sha256(String(calleeText || "")),
257
+ interface: interfaceProjection(callee.interface),
258
+ },
259
+ trustedEventClasses: [...new Set(trustedEventClasses)].sort(),
260
+ };
261
+ const contractRoot = sha256(stableJson(contract));
262
+ if (expectedContractRoot && expectedContractRoot !== contractRoot) {
263
+ failures.push({
264
+ code: "contract-root-mismatch",
265
+ message: `workflow call contract root ${contractRoot} does not match accepted ${expectedContractRoot}`,
266
+ });
267
+ }
268
+ const receipt = {
269
+ schema: WORKFLOW_CALL_RECEIPT,
270
+ contractRoot,
271
+ caller: {
272
+ repository: callerRepository,
273
+ sha: String(callerSha || "").toLowerCase(),
274
+ tree: String(callerTree || "").toLowerCase(),
275
+ sourceState: callerSourceState,
276
+ workflowPath: callerWorkflowPath,
277
+ workflowDigest: contract.caller.workflowDigest,
278
+ },
279
+ callee: {
280
+ repository: calleeRepository,
281
+ sha: String(calleeSha || "").toLowerCase(),
282
+ workflowPath: calleeWorkflowPath,
283
+ workflowDigest: contract.callee.workflowDigest,
284
+ },
285
+ eventClasses: caller.triggers,
286
+ };
287
+ return {
288
+ schema: "kungfu-buildchain-workflow-call-check/v1",
289
+ ok: failures.length === 0,
290
+ status: failures.length === 0 ? "passed" : "failed",
291
+ failures,
292
+ contract,
293
+ contractRoot,
294
+ receipt,
295
+ receiptRoot: sha256(stableJson(receipt)),
296
+ receiptReusable: failures.length === 0 && callerSourceState === "clean",
297
+ };
298
+ }
299
+
300
+ export function assertWorkflowCallContract(options) {
301
+ const report = evaluateWorkflowCallContract(options);
302
+ if (!report.ok) {
303
+ throw new Error(
304
+ `Reusable workflow call contract failed:\n${report.failures.map((entry) => `${entry.code}: ${entry.message}`).join("\n")}`,
305
+ );
306
+ }
307
+ return report;
308
+ }
@@ -0,0 +1,272 @@
1
+ function indentation(line) {
2
+ return line.match(/^(\s*)/)?.[1].length || 0;
3
+ }
4
+
5
+ function stripComment(value) {
6
+ let quote = "";
7
+ for (let index = 0; index < value.length; index += 1) {
8
+ const char = value[index];
9
+ if ((char === '"' || char === "'") && value[index - 1] !== "\\") {
10
+ quote = quote === char ? "" : quote || char;
11
+ }
12
+ if (
13
+ char === "#" &&
14
+ !quote &&
15
+ (index === 0 || /\s/.test(value[index - 1]))
16
+ ) {
17
+ return value.slice(0, index).trimEnd();
18
+ }
19
+ }
20
+ return value.trimEnd();
21
+ }
22
+
23
+ function unquote(value) {
24
+ const trimmed = stripComment(String(value || "").trim());
25
+ if (
26
+ trimmed.length >= 2 &&
27
+ trimmed[0] === trimmed.at(-1) &&
28
+ ['"', "'"].includes(trimmed[0])
29
+ ) {
30
+ return trimmed.slice(1, -1);
31
+ }
32
+ return trimmed;
33
+ }
34
+
35
+ function blockValue(lines, entry) {
36
+ if (!/^[>|][+-]?$/.test(entry.value)) return entry.value;
37
+ const values = [];
38
+ for (let index = entry.line + 1; index < lines.length; index += 1) {
39
+ const line = lines[index];
40
+ if (line.trim() && indentation(line) <= entry.indent) break;
41
+ values.push(line);
42
+ }
43
+ const content = values.filter((line) => line.trim());
44
+ const contentIndent = content.length
45
+ ? Math.min(...content.map(indentation))
46
+ : entry.indent + 2;
47
+ return values
48
+ .map((line) => line.slice(Math.min(contentIndent, line.length)))
49
+ .join("\n")
50
+ .trimEnd();
51
+ }
52
+
53
+ function directEntries(lines, parentLine) {
54
+ const parentIndent = indentation(lines[parentLine]);
55
+ let childIndent = null;
56
+ for (let index = parentLine + 1; index < lines.length; index += 1) {
57
+ const line = lines[index];
58
+ if (!line.trim() || line.trim().startsWith("#")) continue;
59
+ const indent = indentation(line);
60
+ if (indent <= parentIndent) break;
61
+ childIndent = childIndent === null ? indent : Math.min(childIndent, indent);
62
+ }
63
+ if (childIndent === null) return [];
64
+ const entries = [];
65
+ for (let index = parentLine + 1; index < lines.length; index += 1) {
66
+ const line = lines[index];
67
+ const trimmed = line.trim();
68
+ const indent = indentation(line);
69
+ if (trimmed && indent <= parentIndent) break;
70
+ if (!trimmed || trimmed.startsWith("#") || indent !== childIndent)
71
+ continue;
72
+ const match = line.match(/^\s*([A-Za-z0-9_.-]+):(?:\s*(.*))?$/);
73
+ if (match) {
74
+ entries.push({
75
+ key: match[1],
76
+ value: stripComment(match[2] || ""),
77
+ line: index,
78
+ indent,
79
+ });
80
+ }
81
+ }
82
+ return entries;
83
+ }
84
+
85
+ function compareCodeUnits(left, right) {
86
+ return left < right ? -1 : left > right ? 1 : 0;
87
+ }
88
+
89
+ function topLevelEntry(lines, key) {
90
+ const pattern = new RegExp(
91
+ `^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:(?:\\s*(.*))?$`,
92
+ );
93
+ for (let index = 0; index < lines.length; index += 1) {
94
+ const match = lines[index].match(pattern);
95
+ if (match)
96
+ return {
97
+ key,
98
+ value: stripComment(match[1] || ""),
99
+ line: index,
100
+ indent: 0,
101
+ };
102
+ }
103
+ return null;
104
+ }
105
+
106
+ function childEntry(lines, parent, key) {
107
+ return (
108
+ directEntries(lines, parent.line).find((entry) => entry.key === key) || null
109
+ );
110
+ }
111
+
112
+ function scalar(value) {
113
+ const raw = stripComment(String(value || "").trim());
114
+ if (raw.length >= 2 && raw[0] === raw.at(-1) && ['"', "'"].includes(raw[0])) {
115
+ return { kind: "string", value: raw.slice(1, -1) };
116
+ }
117
+ const text = unquote(raw);
118
+ if (/^\$\{\{[\s\S]*\}\}$/.test(text))
119
+ return { kind: "expression", value: text };
120
+ if (/^(true|false)$/i.test(text))
121
+ return { kind: "boolean", value: text.toLowerCase() === "true" };
122
+ if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(text))
123
+ return { kind: "number", value: Number(text) };
124
+ if (/^(null|~)$/i.test(text)) return { kind: "null", value: null };
125
+ return { kind: "string", value: text };
126
+ }
127
+
128
+ function parsePermissions(lines, entry) {
129
+ if (!entry) return {};
130
+ if (entry.value) return { "*": unquote(entry.value) };
131
+ return Object.fromEntries(
132
+ directEntries(lines, entry.line).map((item) => [
133
+ item.key,
134
+ unquote(item.value),
135
+ ]),
136
+ );
137
+ }
138
+
139
+ function parseTriggers(lines) {
140
+ const on = topLevelEntry(lines, "on");
141
+ if (!on) return [];
142
+ if (on.value) {
143
+ const inline = unquote(on.value);
144
+ if (inline.startsWith("[") && inline.endsWith("]")) {
145
+ return inline
146
+ .slice(1, -1)
147
+ .split(",")
148
+ .map((value) => unquote(value.trim()))
149
+ .filter(Boolean)
150
+ .sort();
151
+ }
152
+ return inline ? [inline] : [];
153
+ }
154
+ const events = [];
155
+ for (const event of directEntries(lines, on.line)) {
156
+ const types = childEntry(lines, event, "types");
157
+ const rawTypes = types ? unquote(types.value) : "";
158
+ if (rawTypes.startsWith("[") && rawTypes.endsWith("]")) {
159
+ for (const type of rawTypes
160
+ .slice(1, -1)
161
+ .split(",")
162
+ .map((value) => unquote(value.trim()))
163
+ .filter(Boolean)) {
164
+ events.push(`${event.key}:${type}`);
165
+ }
166
+ } else {
167
+ events.push(event.key);
168
+ }
169
+ }
170
+ return [...new Set(events)].sort();
171
+ }
172
+
173
+ function definition(lines, entry) {
174
+ const properties = Object.fromEntries(
175
+ directEntries(lines, entry.line).map((item) => [
176
+ item.key,
177
+ blockValue(lines, item),
178
+ ]),
179
+ );
180
+ return {
181
+ name: entry.key,
182
+ type: unquote(properties.type || "string"),
183
+ required: scalar(properties.required || "false").value === true,
184
+ default:
185
+ properties.default === undefined ? null : scalar(properties.default),
186
+ };
187
+ }
188
+
189
+ export function parseReusableWorkflowInterface(text) {
190
+ const lines = String(text || "").split(/\r?\n/);
191
+ const on = topLevelEntry(lines, "on");
192
+ const workflowCall = on && childEntry(lines, on, "workflow_call");
193
+ if (!workflowCall) {
194
+ return {
195
+ reusable: false,
196
+ inputs: [],
197
+ secrets: [],
198
+ outputs: [],
199
+ permissions: parsePermissions(lines, topLevelEntry(lines, "permissions")),
200
+ };
201
+ }
202
+ const inputs = childEntry(lines, workflowCall, "inputs");
203
+ const secrets = childEntry(lines, workflowCall, "secrets");
204
+ const outputs = childEntry(lines, workflowCall, "outputs");
205
+ return {
206
+ reusable: true,
207
+ inputs: inputs
208
+ ? directEntries(lines, inputs.line)
209
+ .map((entry) => definition(lines, entry))
210
+ .sort((a, b) => compareCodeUnits(a.name, b.name))
211
+ : [],
212
+ secrets: secrets
213
+ ? directEntries(lines, secrets.line)
214
+ .map((entry) => definition(lines, entry))
215
+ .sort((a, b) => compareCodeUnits(a.name, b.name))
216
+ : [],
217
+ outputs: outputs
218
+ ? directEntries(lines, outputs.line)
219
+ .map((entry) => entry.key)
220
+ .sort()
221
+ : [],
222
+ permissions: parsePermissions(lines, topLevelEntry(lines, "permissions")),
223
+ };
224
+ }
225
+
226
+ function parseJobMap(lines, entry) {
227
+ if (!entry) return {};
228
+ return Object.fromEntries(
229
+ directEntries(lines, entry.line).map((item) => [
230
+ item.key,
231
+ scalar(blockValue(lines, item)),
232
+ ]),
233
+ );
234
+ }
235
+
236
+ export function parseWorkflowCallJobs(text) {
237
+ const lines = String(text || "").split(/\r?\n/);
238
+ const jobs = topLevelEntry(lines, "jobs");
239
+ if (!jobs) return [];
240
+ const topPermissions = parsePermissions(
241
+ lines,
242
+ topLevelEntry(lines, "permissions"),
243
+ );
244
+ return directEntries(lines, jobs.line)
245
+ .map((job) => {
246
+ const properties = directEntries(lines, job.line);
247
+ const property = (name) =>
248
+ properties.find((entry) => entry.key === name) || null;
249
+ const secrets = property("secrets");
250
+ return {
251
+ id: job.key,
252
+ uses: unquote(property("uses")?.value || ""),
253
+ with: parseJobMap(lines, property("with")),
254
+ secrets:
255
+ secrets?.value === "inherit"
256
+ ? "inherit"
257
+ : parseJobMap(lines, secrets),
258
+ permissions: property("permissions")
259
+ ? parsePermissions(lines, property("permissions"))
260
+ : topPermissions,
261
+ };
262
+ })
263
+ .filter((job) => job.uses);
264
+ }
265
+
266
+ export function parseWorkflowDocument(text) {
267
+ return {
268
+ triggers: parseTriggers(String(text || "").split(/\r?\n/)),
269
+ interface: parseReusableWorkflowInterface(text),
270
+ callJobs: parseWorkflowCallJobs(text),
271
+ };
272
+ }
@@ -38,6 +38,18 @@ function number(value) {
38
38
  return { N: String(value) };
39
39
  }
40
40
 
41
+ function money(value, label) {
42
+ const normalized = String(value ?? "").trim();
43
+ if (!normalized) {
44
+ throw new Error(`${label} is required`);
45
+ }
46
+ const parsed = Number(normalized);
47
+ if (!Number.isFinite(parsed) || parsed < 0) {
48
+ throw new Error(`${label} must be a non-negative finite number`);
49
+ }
50
+ return Math.round(parsed * 100_000_000) / 100_000_000;
51
+ }
52
+
41
53
  function string(value) {
42
54
  return { S: String(value) };
43
55
  }
@@ -56,6 +68,22 @@ export function createWindowsJitCampaignArmPlan(values = {}) {
56
68
  (WINDOWS_EC2_JIT.pricePerHourUsd *
57
69
  WINDOWS_EC2_JIT.maximumInstanceLifetimeMinutes) /
58
70
  60;
71
+ const phaseSpendBaselineUsd = money(
72
+ values.phaseSpendBaselineUsd,
73
+ "phaseSpendBaselineUsd",
74
+ );
75
+ const campaignReservationCeilingUsd =
76
+ reservationUsd * WINDOWS_EC2_JIT.maxAcceptedInstances;
77
+ const campaignSafetyCeilingUsd =
78
+ campaignReservationCeilingUsd +
79
+ reservationUsd * WINDOWS_EC2_JIT.maxConcurrentInstances;
80
+ const remainingPhaseBudgetUsd =
81
+ WINDOWS_EC2_JIT.budgetLimitUsd - phaseSpendBaselineUsd;
82
+ if (campaignSafetyCeilingUsd >= remainingPhaseBudgetUsd) {
83
+ throw new Error(
84
+ "campaign safety envelope must remain below the remaining Windows phase budget",
85
+ );
86
+ }
59
87
  return {
60
88
  schemaVersion: 1,
61
89
  contract: AWS_WINDOWS_JIT_CAMPAIGN_CONTRACT,
@@ -78,6 +106,11 @@ export function createWindowsJitCampaignArmPlan(values = {}) {
78
106
  maxAcceptedInstances: WINDOWS_EC2_JIT.maxAcceptedInstances,
79
107
  reservationUsd,
80
108
  budgetLimitUsd: WINDOWS_EC2_JIT.budgetLimitUsd,
109
+ phaseSpendBaselineUsd,
110
+ remainingPhaseBudgetUsd,
111
+ campaignReservationCeilingUsd,
112
+ campaignSafetyCeilingUsd,
113
+ reservationLimitUsd: remainingPhaseBudgetUsd - reservationUsd,
81
114
  },
82
115
  };
83
116
  }
@@ -97,6 +130,8 @@ export function windowsCampaignArmItems(plan) {
97
130
  state: string("ARMED"),
98
131
  campaign_id: string(plan.campaign.id),
99
132
  source_sha: string(plan.source.sha),
133
+ phase_spend_baseline_usd: number(plan.limits.phaseSpendBaselineUsd),
134
+ budget_limit_usd: number(plan.limits.budgetLimitUsd),
100
135
  armed_at: number(plan.campaign.armedAt),
101
136
  expires_epoch: number(plan.campaign.expiresAt),
102
137
  },
@@ -115,6 +150,17 @@ export function windowsCampaignArmItems(plan) {
115
150
  max_accepted_instances: number(plan.limits.maxAcceptedInstances),
116
151
  reservation_usd: number(plan.limits.reservationUsd),
117
152
  budget_limit_usd: number(plan.limits.budgetLimitUsd),
153
+ phase_spend_baseline_usd: number(plan.limits.phaseSpendBaselineUsd),
154
+ remaining_phase_budget_usd: number(
155
+ plan.limits.remainingPhaseBudgetUsd,
156
+ ),
157
+ campaign_reservation_ceiling_usd: number(
158
+ plan.limits.campaignReservationCeilingUsd,
159
+ ),
160
+ campaign_safety_ceiling_usd: number(
161
+ plan.limits.campaignSafetyCeilingUsd,
162
+ ),
163
+ reservation_limit_usd: number(plan.limits.reservationLimitUsd),
118
164
  armed_at: number(plan.campaign.armedAt),
119
165
  expires_epoch: number(plan.campaign.expiresAt),
120
166
  },
@@ -128,7 +174,6 @@ export function windowsCampaignReservationItems(plan, observedAt) {
128
174
  const now = epoch(observedAt, "observedAt");
129
175
  const runPk = runKey(plan);
130
176
  const campaignPk = `CAMPAIGN#${plan.campaign.id}`;
131
- const maxAccepted = plan.safety.campaignAcceptedInstanceCeiling;
132
177
  const reservation = plan.safety.campaignReservationUsd;
133
178
  return [
134
179
  {
@@ -171,17 +216,13 @@ export function windowsCampaignReservationItems(plan, observedAt) {
171
216
  UpdateExpression:
172
217
  "ADD accepted_instances :one, reserved_usd :reservation SET updated_at = :now",
173
218
  ConditionExpression:
174
- "#state = :armed AND source_sha = :source AND accepted_instances < :max AND reserved_usd <= :remaining",
219
+ "#state = :armed AND source_sha = :source AND accepted_instances < max_accepted_instances AND reserved_usd <= reservation_limit_usd",
175
220
  ExpressionAttributeNames: { "#state": "state" },
176
221
  ExpressionAttributeValues: {
177
222
  ":armed": string("ARMED"),
178
223
  ":source": string(plan.source.sha),
179
224
  ":one": number(1),
180
225
  ":reservation": number(reservation),
181
- ":max": number(maxAccepted),
182
- ":remaining": number(
183
- plan.safety.campaignBudgetLimitUsd - reservation,
184
- ),
185
226
  ":now": number(now),
186
227
  },
187
228
  },
@@ -230,9 +271,7 @@ export function windowsCampaignKillArgs(stateTable, reason, observedAt) {
230
271
  "--expression-attribute-values",
231
272
  JSON.stringify({
232
273
  ":killed": string("KILLED"),
233
- ":reason": string(
234
- exact(reason, /^[a-z0-9][a-z0-9-]{2,63}$/, "reason"),
235
- ),
274
+ ":reason": string(exact(reason, /^[a-z0-9][a-z0-9-]{2,63}$/, "reason")),
236
275
  ":now": number(epoch(observedAt, "observedAt")),
237
276
  }),
238
277
  "--output",