@apexops/cli 0.10.0-next.3 → 0.10.0-next.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.
- package/README.md +40 -0
- package/assets/client-projection-metadata/copilot-cli-agent-tools.json +12 -0
- package/assets/client-projections/github-copilot-cli/.github/agents/apex-architect.agent.md +9 -8
- package/assets/client-projections/github-copilot-cli/.github/agents/apex-planner.agent.md +11 -8
- package/assets/client-projections/github-copilot-cli/.github/agents/apex-requirements.agent.md +23 -12
- package/assets/client-projections/github-copilot-cli/.github/agents/apex.agent.md +4 -0
- package/assets/client-projections/github-copilot-cli/.github/mcp.json +7 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-requirements/SKILL.md +7 -4
- package/assets/client-projections/github-copilot-vscode/.github/agents/apex-architect.agent.md +9 -8
- package/assets/client-projections/github-copilot-vscode/.github/agents/apex-planner.agent.md +11 -8
- package/assets/client-projections/github-copilot-vscode/.github/agents/apex-requirements.agent.md +23 -12
- package/assets/client-projections/github-copilot-vscode/.github/agents/apex-reviewer.agent.md +9 -5
- package/assets/client-projections/github-copilot-vscode/.github/agents/apex.agent.md +4 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-requirements/SKILL.md +7 -4
- package/assets/config/runtime-bundle.v1.json +7 -7
- package/assets/customizations/.github/agents/apex-architect.agent.md +9 -8
- package/assets/customizations/.github/agents/apex-planner.agent.md +11 -8
- package/assets/customizations/.github/agents/apex-requirements.agent.md +23 -12
- package/assets/customizations/.github/agents/apex-reviewer.agent.md +9 -5
- package/assets/customizations/.github/agents/apex.agent.md +4 -0
- package/assets/customizations/.github/mcp.json +7 -0
- package/assets/customizations/.github/skills/apex-requirements/SKILL.md +7 -4
- package/assets/customizations/manifest.json +1 -1
- package/assets/manifest.json +58 -58
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +42 -2
- package/dist/mcp.js +89 -16
- package/dist/service.d.ts +46 -1
- package/dist/service.js +466 -41
- package/dist/state-transfer.js +23 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -5
package/dist/service.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { ApprovalEvidenceV1Schema, ArchitectureAvailabilityV1Schema, ArchitectureV1Schema, CONTRACT_VERSION, CostEstimateV1Schema, DeploymentPreviewV1Schema, DiagnosisV1Schema, EnvironmentInputsV1Schema, EvidenceManifestV1Schema, ExecutionPlanAttestationV1Schema, GovernanceConstraintsV1Schema, IacBindingV1Schema, IacHandoffV1Schema, InputRequestV1Schema, InputValueV1Schema, InputSubmissionV1Schema, ImplementationIntentV1Schema, LogicalResourceManifestV1Schema, OperationRecordV1Schema, PolicyPropertyMapV1Schema, QualityMeasurementsV1Schema, QualityReportV1Schema, RequirementsV1Schema, ResourceInventoryV1Schema, ReviewFindingsV1Schema, RuntimeBundleLockV1Schema, WorkloadDecisionManifestV1Schema, hasOnlyTypedSecretReferences, hasValidInputRequestQuestions, hasValidCostArithmetic, hasValidLogicalResourceReferences, } from "@apexops/contracts";
|
|
2
2
|
import { Value } from "@sinclair/typebox/value";
|
|
3
3
|
import { CapabilityPackManager, FakeIaCProvider, ProcessRunner, generateBicepTree, generateTerraformTree, } from "@apexops/capabilities";
|
|
4
|
-
import { ContentCache, EvidencePolicy, EvidenceStore, EventJournal, ImprovementStore, ObjectStore, ProjectStore, RunRepository, ValidatorRegistry, WriterTransferStore, WorkflowEngine, assertTaskCurrent, atomicWriteBytes, atomicWriteJson, createTaskEnvelope, decideGate, inheritGate, openGate, sha256Bytes, sha256Json, validateInputAnswers, workflowValidatorOwnership, } from "@apexops/kernel";
|
|
4
|
+
import { ContentCache, EvidencePolicy, EvidenceStore, EventJournal, ImprovementStore, ObjectStore, ProjectStore, RunRepository, ValidatorRegistry, WriterTransferStore, WorkflowEngine, assertTaskCurrent, atomicWriteBytes, atomicWriteJson, createTaskEnvelope, decideGate, inheritGate, invalidateGate, openGate, sha256Bytes, sha256Json, validateInputAnswers, workflowValidatorOwnership, } from "@apexops/kernel";
|
|
5
5
|
import { DOCUMENT_REGISTRY, renderApprovalEvidence, renderDeploymentPreview, renderRequirementsDocument, renderResourceInventory, renderRunStatus, } from "@apexops/renderers";
|
|
6
6
|
import { constants } from "node:fs";
|
|
7
7
|
import { access, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat } from "node:fs/promises";
|
|
8
|
-
import { homedir } from "node:os";
|
|
8
|
+
import { homedir, userInfo } from "node:os";
|
|
9
9
|
import { basename, delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
10
|
import { resolveBundledAssets } from "./assets.js";
|
|
11
11
|
import { dependencyRevision as calculateDependencyRevision } from "./dependency-revision.js";
|
|
@@ -106,7 +106,7 @@ const TASKS = [
|
|
|
106
106
|
{ id: "diagnosis", role: "diagnostic-operator", outputs: ["diagnosis"] },
|
|
107
107
|
{ id: "quality", role: "quality-evaluator", outputs: ["quality-report"] },
|
|
108
108
|
];
|
|
109
|
-
const
|
|
109
|
+
const LEGACY_REQUIREMENTS_INTAKE = [
|
|
110
110
|
{
|
|
111
111
|
round: "business-discovery",
|
|
112
112
|
questions: [
|
|
@@ -129,6 +129,11 @@ const REQUIREMENTS_INTAKE = [
|
|
|
129
129
|
id: "delivery-scenario",
|
|
130
130
|
prompt: "Is this a greenfield build, migration, modernization, or extension?",
|
|
131
131
|
options: ["greenfield", "migration", "modernization", "extension"],
|
|
132
|
+
recommendation: {
|
|
133
|
+
value: "greenfield",
|
|
134
|
+
source: "default",
|
|
135
|
+
rationale: "Use greenfield unless an existing workload must be migrated, modernized, or extended.",
|
|
136
|
+
},
|
|
132
137
|
},
|
|
133
138
|
{
|
|
134
139
|
id: "target-environments",
|
|
@@ -136,6 +141,11 @@ const REQUIREMENTS_INTAKE = [
|
|
|
136
141
|
options: ["dev", "test", "staging", "prod"],
|
|
137
142
|
multiSelect: true,
|
|
138
143
|
valueType: "environment-set",
|
|
144
|
+
recommendation: {
|
|
145
|
+
value: ["dev", "prod"],
|
|
146
|
+
source: "default",
|
|
147
|
+
rationale: "Plan development and production intent; each environment remains a separate governed run.",
|
|
148
|
+
},
|
|
139
149
|
},
|
|
140
150
|
],
|
|
141
151
|
},
|
|
@@ -154,6 +164,11 @@ const REQUIREMENTS_INTAKE = [
|
|
|
154
164
|
prompt: "Choose the data classification or explicitly defer it.",
|
|
155
165
|
options: ["public", "internal", "confidential", "restricted"],
|
|
156
166
|
valueType: "data-classification",
|
|
167
|
+
recommendation: {
|
|
168
|
+
value: "internal",
|
|
169
|
+
source: "default",
|
|
170
|
+
rationale: "Use internal as the baseline unless the workload handles public-only or more sensitive data.",
|
|
171
|
+
},
|
|
157
172
|
},
|
|
158
173
|
{ id: "iac-preference", prompt: "Choose the preferred infrastructure tool.", options: ["bicep", "terraform"] },
|
|
159
174
|
],
|
|
@@ -161,7 +176,10 @@ const REQUIREMENTS_INTAKE = [
|
|
|
161
176
|
{
|
|
162
177
|
round: "service-preferences",
|
|
163
178
|
questions: [
|
|
164
|
-
{
|
|
179
|
+
{
|
|
180
|
+
id: "prohibited-services",
|
|
181
|
+
prompt: "List prohibited services, use 'none', or explicitly defer the constraint.",
|
|
182
|
+
},
|
|
165
183
|
{
|
|
166
184
|
id: "service-preferences",
|
|
167
185
|
prompt: "Select preferred Azure services. Select all that apply; architecture chooses the exact combination.",
|
|
@@ -209,17 +227,32 @@ const REQUIREMENTS_INTAKE = [
|
|
|
209
227
|
"diagnostic-logging",
|
|
210
228
|
],
|
|
211
229
|
multiSelect: true,
|
|
230
|
+
recommendation: {
|
|
231
|
+
value: ["managed-identity", "key-vault", "platform-managed-encryption", "diagnostic-logging"],
|
|
232
|
+
source: "default",
|
|
233
|
+
rationale: "APEX security baseline; confirm additions or exceptions for this workload.",
|
|
234
|
+
},
|
|
212
235
|
},
|
|
213
236
|
{
|
|
214
237
|
id: "authentication",
|
|
215
238
|
prompt: "Select the required authentication and authorization mechanisms.",
|
|
216
239
|
options: ["microsoft-entra-id", "managed-identity", "external-oidc", "api-key", "anonymous"],
|
|
217
240
|
multiSelect: true,
|
|
241
|
+
recommendation: {
|
|
242
|
+
value: ["microsoft-entra-id", "managed-identity"],
|
|
243
|
+
source: "default",
|
|
244
|
+
rationale: "Use Entra ID for people and managed identity for workload-to-service access.",
|
|
245
|
+
},
|
|
218
246
|
},
|
|
219
247
|
{
|
|
220
248
|
id: "region",
|
|
221
249
|
prompt: "Choose the Azure region, or explicitly defer the decision.",
|
|
222
250
|
options: ["swedencentral", "westeurope", "germanywestcentral"],
|
|
251
|
+
recommendation: {
|
|
252
|
+
value: "swedencentral",
|
|
253
|
+
source: "default",
|
|
254
|
+
rationale: "Repository default for EU data residency and service deployment.",
|
|
255
|
+
},
|
|
223
256
|
},
|
|
224
257
|
{
|
|
225
258
|
id: "availability-recovery",
|
|
@@ -248,6 +281,17 @@ const REQUIREMENTS_INTAKE = [
|
|
|
248
281
|
],
|
|
249
282
|
},
|
|
250
283
|
];
|
|
284
|
+
const REQUIREMENTS_INTAKE = [
|
|
285
|
+
LEGACY_REQUIREMENTS_INTAKE[0],
|
|
286
|
+
{
|
|
287
|
+
round: "workload-pattern",
|
|
288
|
+
questions: [
|
|
289
|
+
...LEGACY_REQUIREMENTS_INTAKE[1].questions.filter(({ id }) => id !== "iac-preference"),
|
|
290
|
+
...LEGACY_REQUIREMENTS_INTAKE[2].questions,
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
LEGACY_REQUIREMENTS_INTAKE[3],
|
|
294
|
+
];
|
|
251
295
|
const ARCHITECTURE_DECISIONS = [
|
|
252
296
|
{
|
|
253
297
|
id: "network-exposure",
|
|
@@ -379,6 +423,8 @@ export class ApexService {
|
|
|
379
423
|
await this.installCapabilityAssets(assets);
|
|
380
424
|
const runtimeLock = await this.createRuntimeLock(assets);
|
|
381
425
|
await atomicWriteJson(join(this.root, ".apex", "apex.lock.json"), runtimeLock);
|
|
426
|
+
await atomicWriteJson(join(this.root, ".apex", "runtime", "apex.lock.json"), runtimeLock);
|
|
427
|
+
await this.installRuntimeGeneration(runtimeLock);
|
|
382
428
|
return this.createProject(input);
|
|
383
429
|
}
|
|
384
430
|
catch (error) {
|
|
@@ -486,6 +532,8 @@ export class ApexService {
|
|
|
486
532
|
const selection = await this.selection();
|
|
487
533
|
await this.ensureLocalGitBoundary();
|
|
488
534
|
const assets = await resolveBundledAssets();
|
|
535
|
+
const previousRuntimeLock = JSON.parse(await readFile(join(this.root, ".apex", "apex.lock.json"), "utf8"));
|
|
536
|
+
await this.installRuntimeGeneration(previousRuntimeLock);
|
|
489
537
|
const lock = JSON.parse(await readFile(join(this.root, ".apex", "customizations.lock.json"), "utf8"));
|
|
490
538
|
const persisted = await this.customizationSelection(lock.clientId);
|
|
491
539
|
const clientId = persisted.clientId;
|
|
@@ -494,6 +542,11 @@ export class ApexService {
|
|
|
494
542
|
}
|
|
495
543
|
const source = customizationsSource ?? assets.clientProjections[clientId];
|
|
496
544
|
const updated = await this.installCustomizations(source, true, assets.config, false, clientId);
|
|
545
|
+
await this.installCapabilityAssets(assets);
|
|
546
|
+
const runtimeLock = await this.createRuntimeLock(assets);
|
|
547
|
+
await atomicWriteJson(join(this.root, ".apex", "apex.lock.json"), runtimeLock);
|
|
548
|
+
await atomicWriteJson(join(this.root, ".apex", "runtime", "apex.lock.json"), runtimeLock);
|
|
549
|
+
await this.installRuntimeGeneration(runtimeLock);
|
|
497
550
|
await this.append(await this.run(selection), "customizations.updated", { source: resolve(source), updated });
|
|
498
551
|
return { updated };
|
|
499
552
|
}
|
|
@@ -730,6 +783,9 @@ export class ApexService {
|
|
|
730
783
|
return { status: "task", task: await this.issueTask(run, TASKS[0], []) };
|
|
731
784
|
}
|
|
732
785
|
const route = await this.route(run, events);
|
|
786
|
+
if (route.blockers.length > 0 && route.reviewGate !== undefined) {
|
|
787
|
+
return { status: "needs_review", review: await this.pendingReview(events, route.reviewGate) };
|
|
788
|
+
}
|
|
733
789
|
if (route.blockers.length > 0)
|
|
734
790
|
throw new ApexError("APEX_AUTHORIZATION", route.blockers.join("; "), EXIT_CODES.authorization, route.blockers);
|
|
735
791
|
if (route.task === undefined)
|
|
@@ -847,12 +903,12 @@ export class ApexService {
|
|
|
847
903
|
const events = await this.journal(run).replay();
|
|
848
904
|
const route = await this.route(run, events);
|
|
849
905
|
const outputTemplates = {};
|
|
850
|
-
if (task.taskType === "requirements" || task.taskType === "plan") {
|
|
906
|
+
if (task.taskType === "requirements" || task.taskType === "plan" || task.taskType.endsWith("-review")) {
|
|
851
907
|
for (const kind of task.allowedOutputKinds) {
|
|
852
908
|
if (!SUPPORTED_ARTIFACT_KINDS.includes(kind)) {
|
|
853
909
|
throw new ApexError("APEX_INTERNAL", `Unsupported task output kind: ${kind}`, EXIT_CODES.internal);
|
|
854
910
|
}
|
|
855
|
-
outputTemplates[kind] = this.outputTemplate(kind, run, events);
|
|
911
|
+
outputTemplates[kind] = this.outputTemplate(kind, run, events, task.taskType);
|
|
856
912
|
}
|
|
857
913
|
}
|
|
858
914
|
const artifactHashes = this.acceptedArtifactHashes(events);
|
|
@@ -868,6 +924,35 @@ export class ApexService {
|
|
|
868
924
|
blockers: route.blockers,
|
|
869
925
|
};
|
|
870
926
|
}
|
|
927
|
+
async readTaskInput(taskId, offset = 0, limit = 6_000) {
|
|
928
|
+
const run = await this.currentRun();
|
|
929
|
+
const task = await this.readTask(run, taskId);
|
|
930
|
+
const descriptor = TASKS.find(({ id }) => id === task.taskType);
|
|
931
|
+
if (descriptor?.reviewSubject === undefined)
|
|
932
|
+
throw new ApexError("APEX_AUTHORIZATION", "Only review tasks may read their accepted input", EXIT_CODES.authorization);
|
|
933
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 6_000) {
|
|
934
|
+
throw new ApexError("APEX_VALIDATION", "Input read range is invalid", EXIT_CODES.validation);
|
|
935
|
+
}
|
|
936
|
+
const subjectKind = descriptor.reviewSubject === "governance-reconciliation" ? "policy-property-map" : descriptor.reviewSubject;
|
|
937
|
+
const events = await this.journal(run).replay();
|
|
938
|
+
const subjectHash = this.artifactHash(events, subjectKind);
|
|
939
|
+
if (subjectHash === undefined || !task.inputRefs.includes(subjectHash)) {
|
|
940
|
+
throw new ApexError("APEX_STALE", "Review task input is unavailable or stale", EXIT_CODES.stale);
|
|
941
|
+
}
|
|
942
|
+
const serialized = JSON.stringify(await this.objects.getJson(subjectHash));
|
|
943
|
+
if (offset >= serialized.length) {
|
|
944
|
+
throw new ApexError("APEX_VALIDATION", "Input read offset is outside the review subject", EXIT_CODES.validation);
|
|
945
|
+
}
|
|
946
|
+
const content = serialized.slice(offset, offset + limit);
|
|
947
|
+
const nextOffset = offset + content.length;
|
|
948
|
+
return {
|
|
949
|
+
subjectHash,
|
|
950
|
+
content,
|
|
951
|
+
offset,
|
|
952
|
+
...(offset === 0 ? { outputTemplate: this.outputTemplate("review-findings", run, events, task.taskType) } : {}),
|
|
953
|
+
...(nextOffset < serialized.length ? { nextOffset } : {}),
|
|
954
|
+
};
|
|
955
|
+
}
|
|
871
956
|
async stageArtifact(taskId, output) {
|
|
872
957
|
const run = await this.currentRun();
|
|
873
958
|
const task = await this.readTask(run, taskId);
|
|
@@ -1016,6 +1101,54 @@ export class ApexService {
|
|
|
1016
1101
|
async completeTaskOutputs(taskId, outputs) {
|
|
1017
1102
|
return this.acceptTaskOutputs(taskId, outputs, false);
|
|
1018
1103
|
}
|
|
1104
|
+
async completeRequirements(taskId, requirements) {
|
|
1105
|
+
await this.assertTaskType(taskId, "requirements");
|
|
1106
|
+
return this.completeTaskOutputs(taskId, [{ kind: "requirements", value: requirements }]);
|
|
1107
|
+
}
|
|
1108
|
+
async completeArchitecture(taskId, architecture, costEstimate, decisionManifest) {
|
|
1109
|
+
await this.assertTaskType(taskId, "architecture");
|
|
1110
|
+
return this.completeTaskOutputs(taskId, [
|
|
1111
|
+
{ kind: "architecture", value: architecture },
|
|
1112
|
+
{ kind: "cost-estimate", value: costEstimate },
|
|
1113
|
+
{ kind: "workload-decision-manifest", value: decisionManifest },
|
|
1114
|
+
]);
|
|
1115
|
+
}
|
|
1116
|
+
async completeReview(taskId, findings) {
|
|
1117
|
+
const context = await this.taskContext(taskId);
|
|
1118
|
+
if (!context.task.taskType.endsWith("-review")) {
|
|
1119
|
+
throw new ApexError("APEX_AUTHORIZATION", "Task is not a review task", EXIT_CODES.authorization);
|
|
1120
|
+
}
|
|
1121
|
+
const template = context.outputTemplates["review-findings"];
|
|
1122
|
+
if (template === undefined)
|
|
1123
|
+
throw new ApexError("APEX_STALE", "Review template is unavailable", EXIT_CODES.stale);
|
|
1124
|
+
return this.completeTaskOutputs(taskId, [
|
|
1125
|
+
{
|
|
1126
|
+
kind: "review-findings",
|
|
1127
|
+
value: {
|
|
1128
|
+
...template,
|
|
1129
|
+
findings: findings.map((finding) => ({
|
|
1130
|
+
...finding,
|
|
1131
|
+
disposition: "open",
|
|
1132
|
+
evidenceRefs: [template.subjectHash],
|
|
1133
|
+
})),
|
|
1134
|
+
},
|
|
1135
|
+
},
|
|
1136
|
+
]);
|
|
1137
|
+
}
|
|
1138
|
+
async completePlan(taskId, intent, binding, environmentInputs) {
|
|
1139
|
+
return this.completeTaskOutputs(taskId, [
|
|
1140
|
+
{ kind: "implementation-intent", value: intent },
|
|
1141
|
+
{ kind: "iac-binding", value: { ...binding, intentHash: sha256Json(intent) } },
|
|
1142
|
+
{ kind: "environment-inputs", value: environmentInputs },
|
|
1143
|
+
]);
|
|
1144
|
+
}
|
|
1145
|
+
async assertTaskType(taskId, expected) {
|
|
1146
|
+
const run = await this.currentRun();
|
|
1147
|
+
const task = await this.readTask(run, taskId);
|
|
1148
|
+
if (task.taskType !== expected) {
|
|
1149
|
+
throw new ApexError("APEX_AUTHORIZATION", `Task is not the ${expected} task`, EXIT_CODES.authorization);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1019
1152
|
async acceptTaskOutputs(taskId, outputs, legacy) {
|
|
1020
1153
|
const run = await this.currentRun();
|
|
1021
1154
|
const task = await this.readTask(run, taskId);
|
|
@@ -1389,6 +1522,114 @@ export class ApexService {
|
|
|
1389
1522
|
}
|
|
1390
1523
|
}
|
|
1391
1524
|
}
|
|
1525
|
+
async decideReview(reviewHash, decisions) {
|
|
1526
|
+
if (decisions.length === 0 || new Set(decisions.map(({ findingId }) => findingId)).size !== decisions.length) {
|
|
1527
|
+
throw new ApexError("APEX_VALIDATION", "Review decisions must be nonempty and unique", EXIT_CODES.validation);
|
|
1528
|
+
}
|
|
1529
|
+
const run = await this.currentRun();
|
|
1530
|
+
const events = await this.journal(run).replay();
|
|
1531
|
+
const reviewEvent = [...events]
|
|
1532
|
+
.reverse()
|
|
1533
|
+
.find((event) => event.type === "task.completed" && event.payload.reviewHash === reviewHash);
|
|
1534
|
+
if (reviewEvent === undefined)
|
|
1535
|
+
throw new ApexError("APEX_STALE", "Review is not current", EXIT_CODES.stale);
|
|
1536
|
+
const payload = reviewEvent.payload;
|
|
1537
|
+
const openIds = this.reviewBlockers(events, String(payload.nodeId)).map((blocker) => blocker.split(" ")[2]);
|
|
1538
|
+
if (decisions.some(({ findingId }) => !openIds.includes(findingId))) {
|
|
1539
|
+
throw new ApexError("APEX_STALE", "Review decisions do not match current open findings", EXIT_CODES.stale);
|
|
1540
|
+
}
|
|
1541
|
+
const revisions = decisions.filter(({ action }) => action === "revise");
|
|
1542
|
+
if (revisions.length > 0) {
|
|
1543
|
+
await this.reviseReview(reviewHash, revisions.map(({ findingId }) => findingId), revisions.map(({ rationale }) => rationale).join("; "));
|
|
1544
|
+
return { status: "revision_requested" };
|
|
1545
|
+
}
|
|
1546
|
+
if (decisions.length !== openIds.length) {
|
|
1547
|
+
throw new ApexError("APEX_VALIDATION", "Every open finding requires a decision", EXIT_CODES.validation);
|
|
1548
|
+
}
|
|
1549
|
+
for (const decision of decisions) {
|
|
1550
|
+
await this.resolveReview({
|
|
1551
|
+
findingId: decision.findingId,
|
|
1552
|
+
reviewHash,
|
|
1553
|
+
subjectHash: String(payload.subjectHash),
|
|
1554
|
+
dependencyHash: String(payload.dependencyHash),
|
|
1555
|
+
disposition: "accepted-risk",
|
|
1556
|
+
actor: userInfo().username,
|
|
1557
|
+
rationale: decision.rationale,
|
|
1558
|
+
evidenceRefs: [],
|
|
1559
|
+
...(decision.expiresAt === undefined ? {} : { expiresAt: decision.expiresAt }),
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
return { status: "resolved" };
|
|
1563
|
+
}
|
|
1564
|
+
async pendingReview(events, gate) {
|
|
1565
|
+
const reviewEvent = [...events]
|
|
1566
|
+
.reverse()
|
|
1567
|
+
.find((event) => event.type === "task.completed" &&
|
|
1568
|
+
typeof event.payload.reviewHash === "string" &&
|
|
1569
|
+
this.reviewBlockers(events, String(event.payload.nodeId)).length > 0);
|
|
1570
|
+
const reviewHash = reviewEvent?.payload?.reviewHash;
|
|
1571
|
+
if (typeof reviewHash !== "string")
|
|
1572
|
+
throw new ApexError("APEX_STALE", "Open review is unavailable", EXIT_CODES.stale);
|
|
1573
|
+
const review = await this.objects.getJson(reviewHash);
|
|
1574
|
+
const unresolved = new Set(this.reviewBlockers(events, String(reviewEvent.payload.nodeId)).map((blocker) => blocker.split(" ")[2]));
|
|
1575
|
+
return {
|
|
1576
|
+
gate,
|
|
1577
|
+
reviewHash,
|
|
1578
|
+
findings: review.findings
|
|
1579
|
+
.filter(({ id }) => unresolved.has(id))
|
|
1580
|
+
.map(({ id, severity, title, detail }) => ({
|
|
1581
|
+
id,
|
|
1582
|
+
severity,
|
|
1583
|
+
title,
|
|
1584
|
+
detail,
|
|
1585
|
+
actions: ["revise", ...(["critical", "high"].includes(severity) ? [] : ["accept-risk"])],
|
|
1586
|
+
})),
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
async reviseReview(reviewHash, findingIds, correction) {
|
|
1590
|
+
if (!/^[0-9a-f]{64}$/u.test(reviewHash) || findingIds.length === 0 || correction.trim().length === 0) {
|
|
1591
|
+
throw new ApexError("APEX_VALIDATION", "Review revision request is incomplete", EXIT_CODES.validation);
|
|
1592
|
+
}
|
|
1593
|
+
const run = await this.currentRun();
|
|
1594
|
+
const events = await this.journal(run).replay();
|
|
1595
|
+
const reviewEvent = [...events]
|
|
1596
|
+
.reverse()
|
|
1597
|
+
.find((event) => event.type === "task.completed" && event.payload.reviewHash === reviewHash);
|
|
1598
|
+
if (reviewEvent === undefined) {
|
|
1599
|
+
throw new ApexError("APEX_STALE", "Review is not current", EXIT_CODES.stale);
|
|
1600
|
+
}
|
|
1601
|
+
const reviewNode = String(reviewEvent.payload.nodeId);
|
|
1602
|
+
const descriptor = TASKS.find(({ id }) => id === reviewNode);
|
|
1603
|
+
const subjectNode = descriptor?.reviewSubject;
|
|
1604
|
+
if (subjectNode === undefined)
|
|
1605
|
+
throw new ApexError("APEX_STALE", "Review subject is unavailable", EXIT_CODES.stale);
|
|
1606
|
+
const openIds = new Set(this.reviewBlockers(events, reviewNode).map((blocker) => blocker.split(" ")[2]));
|
|
1607
|
+
if (findingIds.some((id) => !openIds.has(id))) {
|
|
1608
|
+
throw new ApexError("APEX_STALE", "Revision does not match current open findings", EXIT_CODES.stale);
|
|
1609
|
+
}
|
|
1610
|
+
const workflow = await this.lockedWorkflowEngine(run);
|
|
1611
|
+
const invalidatedNodes = [
|
|
1612
|
+
subjectNode,
|
|
1613
|
+
reviewNode,
|
|
1614
|
+
...workflow.invalidationPlan(subjectNode, correction.trim()).map(({ nodeId }) => nodeId),
|
|
1615
|
+
];
|
|
1616
|
+
const uniqueNodes = [...new Set(invalidatedNodes)];
|
|
1617
|
+
const artifactKinds = TASKS.filter(({ id }) => uniqueNodes.includes(id)).flatMap(({ outputs }) => outputs);
|
|
1618
|
+
await this.append(run, "workflow.invalidated", {
|
|
1619
|
+
reason: correction.trim(),
|
|
1620
|
+
findingIds,
|
|
1621
|
+
reviewHash,
|
|
1622
|
+
nodeIds: uniqueNodes,
|
|
1623
|
+
artifactKinds: [...new Set(artifactKinds)],
|
|
1624
|
+
});
|
|
1625
|
+
const dependencyHash = this.dependencyRevision(run, await this.journal(run).replay());
|
|
1626
|
+
await atomicWriteJson(this.runPath(run), {
|
|
1627
|
+
...run,
|
|
1628
|
+
gates: run.gates.map((gate) => uniqueNodes.includes(`gate-${gate.gate}`)
|
|
1629
|
+
? invalidateGate(gate, dependencyHash, `${subjectNode} revision requested`)
|
|
1630
|
+
: gate),
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1392
1633
|
assertReviewResolution(value) {
|
|
1393
1634
|
const hashes = [value.reviewHash, value.subjectHash, value.dependencyHash, ...value.evidenceRefs];
|
|
1394
1635
|
if (!value.findingId ||
|
|
@@ -1489,6 +1730,16 @@ export class ApexService {
|
|
|
1489
1730
|
await this.materializeOperationsApprovalPackage(run, approval, approvalHash);
|
|
1490
1731
|
return approval;
|
|
1491
1732
|
}
|
|
1733
|
+
async decideInteractiveGate(gateNumber, decision) {
|
|
1734
|
+
let actor = "local-user";
|
|
1735
|
+
try {
|
|
1736
|
+
actor = userInfo().username.trim() || actor;
|
|
1737
|
+
}
|
|
1738
|
+
catch {
|
|
1739
|
+
// Some minimal container identities do not have an OS user entry.
|
|
1740
|
+
}
|
|
1741
|
+
return this.decideGateNumber(gateNumber, decision, actor);
|
|
1742
|
+
}
|
|
1492
1743
|
async preview(options) {
|
|
1493
1744
|
const run = await this.currentRun();
|
|
1494
1745
|
await this.assertCurrentWriterAuthority(run, new WriterTransferStore(this.projects.runDirectory(run.projectId, run.runId), this.clock));
|
|
@@ -2437,17 +2688,22 @@ export class ApexService {
|
|
|
2437
2688
|
typeof event.payload.requestId === "string"
|
|
2438
2689
|
? [event.payload.requestId]
|
|
2439
2690
|
: []));
|
|
2691
|
+
const catalog = this.requirementsIntakeCatalog(requests);
|
|
2440
2692
|
const latest = requests.at(-1);
|
|
2441
2693
|
if (latest !== undefined && !recordedRequestIds.has(latest.payload.requestId)) {
|
|
2442
2694
|
const intake = latest.payload.intake;
|
|
2443
2695
|
const ordinal = intake.ordinal;
|
|
2444
|
-
const round = typeof ordinal === "number" && Number.isInteger(ordinal) ?
|
|
2445
|
-
if (round === undefined || intake.round !== round.round || intake.total !==
|
|
2696
|
+
const round = typeof ordinal === "number" && Number.isInteger(ordinal) ? catalog[ordinal - 1] : undefined;
|
|
2697
|
+
if (round === undefined || intake.round !== round.round || intake.total !== catalog.length) {
|
|
2446
2698
|
throw new ApexError("APEX_CONFLICT", "Requirements intake is incompatible; start a new run", EXIT_CODES.conflict);
|
|
2447
2699
|
}
|
|
2448
2700
|
return round;
|
|
2449
2701
|
}
|
|
2450
|
-
return
|
|
2702
|
+
return catalog[recordedRequestIds.size];
|
|
2703
|
+
}
|
|
2704
|
+
requirementsIntakeCatalog(requests) {
|
|
2705
|
+
const total = requests[0]?.payload?.intake?.total;
|
|
2706
|
+
return total === LEGACY_REQUIREMENTS_INTAKE.length ? LEGACY_REQUIREMENTS_INTAKE : REQUIREMENTS_INTAKE;
|
|
2451
2707
|
}
|
|
2452
2708
|
async issueRequirementsInput(run, round) {
|
|
2453
2709
|
const events = await this.journal(run).replay();
|
|
@@ -2463,36 +2719,129 @@ export class ApexService {
|
|
|
2463
2719
|
return this.inputRequest(latest, run.ownerEpoch);
|
|
2464
2720
|
}
|
|
2465
2721
|
}
|
|
2466
|
-
const
|
|
2722
|
+
const catalog = this.requirementsIntakeCatalog(events.filter((event) => event.type === "requirements.input-requested"));
|
|
2723
|
+
const ordinal = catalog.findIndex((candidate) => candidate === round) + 1;
|
|
2467
2724
|
const event = await this.append(run, "requirements.input-requested", {
|
|
2468
2725
|
requestId: this.idSource(),
|
|
2469
|
-
intake: { round: round.round, ordinal, total:
|
|
2726
|
+
intake: { round: round.round, ordinal, total: catalog.length },
|
|
2470
2727
|
questions,
|
|
2471
2728
|
});
|
|
2472
2729
|
return this.inputRequest(event, run.ownerEpoch);
|
|
2473
2730
|
}
|
|
2474
2731
|
requirementsIntakeQuestions(round, events) {
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2732
|
+
const hasServicePreferences = round.questions.some(({ id }) => id === "service-preferences");
|
|
2733
|
+
if (round.round === "service-preferences" || (round.round === "workload-pattern" && hasServicePreferences)) {
|
|
2734
|
+
const input = this.recordedRequirementsInput(events);
|
|
2735
|
+
const scenario = input?.["delivery-scenario"];
|
|
2736
|
+
const workload = typeof input?.workload === "string" ? input.workload : "";
|
|
2737
|
+
const pattern = this.recommendedWorkloadPattern(workload);
|
|
2738
|
+
const recommendationInput = { ...(input ?? {}), "workload-pattern": pattern };
|
|
2739
|
+
const questions = round.questions.map((question) => question.id === "service-preferences"
|
|
2740
|
+
? {
|
|
2741
|
+
...question,
|
|
2742
|
+
prompt: "Confirm the recommended Azure service candidates or select alternatives; Architecture makes the final choice.",
|
|
2743
|
+
recommendation: {
|
|
2744
|
+
value: this.recommendedServiceCandidates(recommendationInput),
|
|
2745
|
+
source: "prior-answer",
|
|
2746
|
+
rationale: "Derived from the confirmed workload description as non-binding Architecture candidates.",
|
|
2747
|
+
},
|
|
2748
|
+
}
|
|
2749
|
+
: question.id === "workload-pattern"
|
|
2750
|
+
? {
|
|
2751
|
+
...question,
|
|
2752
|
+
recommendation: {
|
|
2753
|
+
value: pattern,
|
|
2754
|
+
source: "derived",
|
|
2755
|
+
rationale: "Derived from the confirmed workload description; confirm or choose another pattern.",
|
|
2756
|
+
},
|
|
2757
|
+
}
|
|
2758
|
+
: question.id === "scale"
|
|
2759
|
+
? { ...question, prompt: this.workloadScalePrompt(pattern) }
|
|
2760
|
+
: question);
|
|
2761
|
+
const retained = scenario === "migration" || scenario === "modernization" || scenario === "extension"
|
|
2762
|
+
? [{ id: "retained-services", prompt: "List Azure services or integrations that must be retained." }]
|
|
2763
|
+
: [];
|
|
2764
|
+
if (round.round === "service-preferences")
|
|
2765
|
+
return [...retained, ...questions];
|
|
2766
|
+
const migration = scenario === "migration" || scenario === "modernization"
|
|
2767
|
+
? [
|
|
2768
|
+
{ id: "current-platform", prompt: "Describe the current platform and hosting model." },
|
|
2769
|
+
{
|
|
2770
|
+
id: "migration-pain-points",
|
|
2771
|
+
prompt: "Describe the problems the migration or modernization must address.",
|
|
2772
|
+
},
|
|
2773
|
+
{ id: "preserve-components", prompt: "List components, integrations, or data that must be preserved." },
|
|
2774
|
+
]
|
|
2775
|
+
: [];
|
|
2776
|
+
return [...questions, ...retained, ...migration];
|
|
2483
2777
|
}
|
|
2484
2778
|
if (round.round !== "workload-pattern")
|
|
2485
2779
|
return round.questions;
|
|
2486
|
-
const
|
|
2780
|
+
const input = this.recordedRequirementsInput(events);
|
|
2781
|
+
const scenario = input?.["delivery-scenario"];
|
|
2782
|
+
const workload = typeof input?.workload === "string" ? input.workload : "";
|
|
2783
|
+
const pattern = this.recommendedWorkloadPattern(workload);
|
|
2784
|
+
const questions = round.questions.map((question) => question.id === "workload-pattern"
|
|
2785
|
+
? {
|
|
2786
|
+
...question,
|
|
2787
|
+
recommendation: {
|
|
2788
|
+
value: pattern,
|
|
2789
|
+
source: "derived",
|
|
2790
|
+
rationale: "Derived from the confirmed workload description; confirm or choose another pattern.",
|
|
2791
|
+
},
|
|
2792
|
+
}
|
|
2793
|
+
: question.id === "scale"
|
|
2794
|
+
? { ...question, prompt: this.workloadScalePrompt(pattern) }
|
|
2795
|
+
: question);
|
|
2487
2796
|
if (scenario !== "migration" && scenario !== "modernization")
|
|
2488
|
-
return
|
|
2797
|
+
return questions;
|
|
2489
2798
|
return [
|
|
2490
|
-
...
|
|
2799
|
+
...questions,
|
|
2491
2800
|
{ id: "current-platform", prompt: "Describe the current platform and hosting model." },
|
|
2492
2801
|
{ id: "migration-pain-points", prompt: "Describe the problems the migration or modernization must address." },
|
|
2493
2802
|
{ id: "preserve-components", prompt: "List components, integrations, or data that must be preserved." },
|
|
2494
2803
|
];
|
|
2495
2804
|
}
|
|
2805
|
+
recommendedWorkloadPattern(workload) {
|
|
2806
|
+
const normalized = workload.toLowerCase();
|
|
2807
|
+
if (/\b(iot|device|sensor|telemetry)\b/u.test(normalized))
|
|
2808
|
+
return "iot";
|
|
2809
|
+
if (/\b(analytics|warehouse|lakehouse|reporting|etl)\b/u.test(normalized))
|
|
2810
|
+
return "data-analytics";
|
|
2811
|
+
if (/\b(batch|scheduled|nightly|cron)\b/u.test(normalized))
|
|
2812
|
+
return "batch";
|
|
2813
|
+
if (/\b(event|queue|stream|message|asynchronous)\b/u.test(normalized))
|
|
2814
|
+
return "event-driven";
|
|
2815
|
+
return "web-api";
|
|
2816
|
+
}
|
|
2817
|
+
workloadScalePrompt(pattern) {
|
|
2818
|
+
const prompts = {
|
|
2819
|
+
"web-api": "Describe normal and peak concurrency, request or order rate, and latency expectations.",
|
|
2820
|
+
"event-driven": "Describe normal and peak event rates, ordering requirements, and delivery guarantees.",
|
|
2821
|
+
"data-analytics": "Describe ingest rate, retained volume, growth, and data-freshness expectations.",
|
|
2822
|
+
iot: "Describe device count, message rate, payload size, and offline behavior.",
|
|
2823
|
+
batch: "Describe schedule, data volume, expected duration, and completion window.",
|
|
2824
|
+
};
|
|
2825
|
+
return prompts[pattern];
|
|
2826
|
+
}
|
|
2827
|
+
recommendedServiceCandidates(input) {
|
|
2828
|
+
const pattern = input?.["workload-pattern"];
|
|
2829
|
+
const budget = input?.budget;
|
|
2830
|
+
const lowBudget = typeof budget === "object" && budget !== null && !Array.isArray(budget) && budget.kind === "budget"
|
|
2831
|
+
? budget.amount < 1_000
|
|
2832
|
+
: false;
|
|
2833
|
+
const candidates = {
|
|
2834
|
+
"web-api": [lowBudget ? "azure-functions" : "app-service", "azure-sql", "storage", "service-bus"],
|
|
2835
|
+
"event-driven": ["azure-functions", "service-bus", "storage"],
|
|
2836
|
+
"data-analytics": ["azure-functions", "storage", "event-hubs"],
|
|
2837
|
+
iot: ["azure-functions", "event-hubs", "storage"],
|
|
2838
|
+
batch: ["azure-functions", "storage", "service-bus"],
|
|
2839
|
+
};
|
|
2840
|
+
const selected = typeof pattern === "string" && pattern in candidates
|
|
2841
|
+
? candidates[pattern]
|
|
2842
|
+
: candidates["web-api"];
|
|
2843
|
+
return [...selected, "azure-monitor", "application-insights"];
|
|
2844
|
+
}
|
|
2496
2845
|
async append(run, type, payload, expectedHead) {
|
|
2497
2846
|
const journal = this.journal(run);
|
|
2498
2847
|
return await journal.append({
|
|
@@ -2547,11 +2896,12 @@ export class ApexService {
|
|
|
2547
2896
|
throw new ApexError("APEX_CONFLICT", "Requirements intake is incompatible; start a new run", EXIT_CODES.conflict);
|
|
2548
2897
|
}
|
|
2549
2898
|
const intake = request.intake;
|
|
2550
|
-
const
|
|
2899
|
+
const catalog = intake?.total === LEGACY_REQUIREMENTS_INTAKE.length ? LEGACY_REQUIREMENTS_INTAKE : REQUIREMENTS_INTAKE;
|
|
2900
|
+
const round = intake === undefined ? undefined : catalog[intake.ordinal - 1];
|
|
2551
2901
|
if (round === undefined ||
|
|
2552
2902
|
intake === undefined ||
|
|
2553
2903
|
round.round !== intake.round ||
|
|
2554
|
-
intake.total !==
|
|
2904
|
+
intake.total !== catalog.length) {
|
|
2555
2905
|
throw new ApexError("APEX_CONFLICT", "Requirements intake is incompatible; start a new run", EXIT_CODES.conflict);
|
|
2556
2906
|
}
|
|
2557
2907
|
}
|
|
@@ -2578,6 +2928,10 @@ export class ApexService {
|
|
|
2578
2928
|
}
|
|
2579
2929
|
artifactHash(events, kind) {
|
|
2580
2930
|
for (const event of [...events].reverse()) {
|
|
2931
|
+
if (event.type === "workflow.invalidated" &&
|
|
2932
|
+
event.payload.artifactKinds?.includes?.(kind)) {
|
|
2933
|
+
return undefined;
|
|
2934
|
+
}
|
|
2581
2935
|
if (event.type !== "task.completed")
|
|
2582
2936
|
continue;
|
|
2583
2937
|
const hashes = event.payload.artifactHashes;
|
|
@@ -2598,6 +2952,14 @@ export class ApexService {
|
|
|
2598
2952
|
"implementation-intent": "intentHash",
|
|
2599
2953
|
};
|
|
2600
2954
|
for (const event of events) {
|
|
2955
|
+
if (event.type === "workflow.invalidated") {
|
|
2956
|
+
const kinds = event.payload.artifactKinds;
|
|
2957
|
+
if (Array.isArray(kinds))
|
|
2958
|
+
for (const kind of kinds)
|
|
2959
|
+
if (typeof kind === "string")
|
|
2960
|
+
delete hashes[kind];
|
|
2961
|
+
continue;
|
|
2962
|
+
}
|
|
2601
2963
|
if (event.type !== "task.completed")
|
|
2602
2964
|
continue;
|
|
2603
2965
|
const payload = event.payload;
|
|
@@ -2663,7 +3025,7 @@ export class ApexService {
|
|
|
2663
3025
|
}
|
|
2664
3026
|
return recorded ? result : null;
|
|
2665
3027
|
}
|
|
2666
|
-
outputTemplate(kind, run, events) {
|
|
3028
|
+
outputTemplate(kind, run, events, taskType) {
|
|
2667
3029
|
if (kind === "requirements") {
|
|
2668
3030
|
const input = this.recordedRequirementsInput(events);
|
|
2669
3031
|
return {
|
|
@@ -2671,7 +3033,34 @@ export class ApexService {
|
|
|
2671
3033
|
projectId: run.projectId,
|
|
2672
3034
|
workload: typeof input?.workload === "string" ? input.workload : "",
|
|
2673
3035
|
environment: run.environment,
|
|
2674
|
-
...this.requirementsTemplateFromIntake(input),
|
|
3036
|
+
...this.requirementsTemplateFromIntake({ ...(input ?? {}), "iac-preference": run.iacTool }),
|
|
3037
|
+
};
|
|
3038
|
+
}
|
|
3039
|
+
if (kind === "review-findings") {
|
|
3040
|
+
const review = TASKS.find(({ id }) => id === taskType);
|
|
3041
|
+
const subjectKind = review?.reviewSubject === "governance-reconciliation" ? "policy-property-map" : review?.reviewSubject;
|
|
3042
|
+
const artifactKind = subjectKind === "plan" ? "implementation-intent" : subjectKind;
|
|
3043
|
+
const subjectHash = artifactKind === undefined ? undefined : this.artifactHash(events, artifactKind);
|
|
3044
|
+
if (subjectKind === undefined || subjectHash === undefined) {
|
|
3045
|
+
throw new ApexError("APEX_STALE", "Review subject is unavailable", EXIT_CODES.stale);
|
|
3046
|
+
}
|
|
3047
|
+
return {
|
|
3048
|
+
schemaVersion: CONTRACT_VERSION,
|
|
3049
|
+
projectId: run.projectId,
|
|
3050
|
+
runId: run.runId,
|
|
3051
|
+
subjectKind,
|
|
3052
|
+
subjectHash,
|
|
3053
|
+
reviewedAt: this.clock().toISOString(),
|
|
3054
|
+
findings: [
|
|
3055
|
+
{
|
|
3056
|
+
id: "FINDING-001",
|
|
3057
|
+
severity: "medium",
|
|
3058
|
+
disposition: "open",
|
|
3059
|
+
title: "Concise finding title",
|
|
3060
|
+
detail: "Evidence, impact, and concrete remediation.",
|
|
3061
|
+
evidenceRefs: [subjectHash],
|
|
3062
|
+
},
|
|
3063
|
+
],
|
|
2675
3064
|
};
|
|
2676
3065
|
}
|
|
2677
3066
|
if (kind === "implementation-intent") {
|
|
@@ -2833,17 +3222,28 @@ export class ApexService {
|
|
|
2833
3222
|
}
|
|
2834
3223
|
}
|
|
2835
3224
|
completedNodeIds(events) {
|
|
2836
|
-
|
|
3225
|
+
const completed = new Set();
|
|
3226
|
+
for (const event of events) {
|
|
3227
|
+
if (event.type === "workflow.invalidated") {
|
|
3228
|
+
const nodeIds = event.payload.nodeIds;
|
|
3229
|
+
if (Array.isArray(nodeIds))
|
|
3230
|
+
for (const nodeId of nodeIds)
|
|
3231
|
+
if (typeof nodeId === "string")
|
|
3232
|
+
completed.delete(nodeId);
|
|
3233
|
+
continue;
|
|
3234
|
+
}
|
|
2837
3235
|
if (event.type !== "task.completed")
|
|
2838
|
-
|
|
3236
|
+
continue;
|
|
2839
3237
|
const nodeId = event.payload.nodeId;
|
|
2840
|
-
|
|
2841
|
-
|
|
3238
|
+
if (typeof nodeId === "string")
|
|
3239
|
+
completed.add(nodeId);
|
|
3240
|
+
}
|
|
3241
|
+
return completed;
|
|
2842
3242
|
}
|
|
2843
3243
|
async lockedWorkflowEngine(run) {
|
|
2844
|
-
const runtimeRoot =
|
|
3244
|
+
const runtimeRoot = await this.runtimeRootForRun(run);
|
|
2845
3245
|
const workflowBytes = await readFile(join(runtimeRoot, "workflow.v1.json"));
|
|
2846
|
-
const lock = JSON.parse(await readFile(join(
|
|
3246
|
+
const lock = JSON.parse(await readFile(join(runtimeRoot, "apex.lock.json"), "utf8"));
|
|
2847
3247
|
this.assertValid("runtime-lock", lock);
|
|
2848
3248
|
if (sha256Json(lock) !== run.runtimeLockHash || sha256Bytes(workflowBytes) !== lock.workflowHash) {
|
|
2849
3249
|
throw new ApexError("APEX_STALE", "Runtime workflow lock is not current", EXIT_CODES.stale);
|
|
@@ -3162,7 +3562,7 @@ export class ApexService {
|
|
|
3162
3562
|
...(availabilityEvidence === undefined ? {} : { availabilityEvidence }),
|
|
3163
3563
|
...(deploymentOperation === undefined ? {} : { deploymentOperation }),
|
|
3164
3564
|
...(deploymentInventory === undefined ? {} : { deploymentInventory }),
|
|
3165
|
-
...(nodeId === "quality" ? await this.qualityValidatorContext() : {}),
|
|
3565
|
+
...(nodeId === "quality" ? await this.qualityValidatorContext(run) : {}),
|
|
3166
3566
|
};
|
|
3167
3567
|
for (const id of validatorIds) {
|
|
3168
3568
|
this.assertValid(id, taskWorkflowValidatorInput(id, context));
|
|
@@ -3173,7 +3573,7 @@ export class ApexService {
|
|
|
3173
3573
|
evidenceModes: availabilityEvidence === undefined ? {} : { "business:availability-current": availabilityEvidence.mode },
|
|
3174
3574
|
};
|
|
3175
3575
|
}
|
|
3176
|
-
async qualityValidatorContext() {
|
|
3576
|
+
async qualityValidatorContext(run) {
|
|
3177
3577
|
let qualityMeasurements;
|
|
3178
3578
|
try {
|
|
3179
3579
|
qualityMeasurements = JSON.parse(await readFile(join(this.root, ".apex", "quality", "measurements.json"), "utf8"));
|
|
@@ -3185,8 +3585,9 @@ export class ApexService {
|
|
|
3185
3585
|
throw error;
|
|
3186
3586
|
}
|
|
3187
3587
|
this.assertValid("quality-measurements", qualityMeasurements);
|
|
3588
|
+
const runtimeRoot = await this.runtimeRootForRun(run);
|
|
3188
3589
|
return {
|
|
3189
|
-
scorecard: JSON.parse(await readFile(join(
|
|
3590
|
+
scorecard: JSON.parse(await readFile(join(runtimeRoot, "quality-scorecard.v1.json"), "utf8")),
|
|
3190
3591
|
qualityMeasurements,
|
|
3191
3592
|
};
|
|
3192
3593
|
}
|
|
@@ -3577,9 +3978,10 @@ export class ApexService {
|
|
|
3577
3978
|
this.improvementRuntime ??= new ImprovementStore(this.root, this.improvementPolicy, this.clock);
|
|
3578
3979
|
return this.improvementRuntime;
|
|
3579
3980
|
}
|
|
3981
|
+
const runtimeRoot = await this.runtimeRootForRun(run);
|
|
3580
3982
|
const [policyBytes, lockBytes] = await Promise.all([
|
|
3581
|
-
readFile(join(
|
|
3582
|
-
readFile(join(
|
|
3983
|
+
readFile(join(runtimeRoot, "improvement-policy.v1.json")),
|
|
3984
|
+
readFile(join(runtimeRoot, "apex.lock.json")),
|
|
3583
3985
|
]);
|
|
3584
3986
|
const lock = JSON.parse(lockBytes.toString("utf8"));
|
|
3585
3987
|
this.assertValid("runtime-lock", lock);
|
|
@@ -3644,6 +4046,28 @@ export class ApexService {
|
|
|
3644
4046
|
this.assertValid("runtime-lock", lock);
|
|
3645
4047
|
return lock;
|
|
3646
4048
|
}
|
|
4049
|
+
async installRuntimeGeneration(lock) {
|
|
4050
|
+
const hash = sha256Json(lock);
|
|
4051
|
+
const generationsRoot = join(this.root, ".apex", "runtime-generations");
|
|
4052
|
+
const destination = join(generationsRoot, hash);
|
|
4053
|
+
if (!(await this.exists(destination))) {
|
|
4054
|
+
await mkdir(generationsRoot, { recursive: true });
|
|
4055
|
+
const staging = join(generationsRoot, `.incoming-${this.idSource()}`);
|
|
4056
|
+
await cp(join(this.root, ".apex", "runtime"), staging, {
|
|
4057
|
+
recursive: true,
|
|
4058
|
+
dereference: false,
|
|
4059
|
+
errorOnExist: true,
|
|
4060
|
+
force: false,
|
|
4061
|
+
});
|
|
4062
|
+
await atomicWriteJson(join(staging, "apex.lock.json"), lock);
|
|
4063
|
+
await rename(staging, destination);
|
|
4064
|
+
}
|
|
4065
|
+
return destination;
|
|
4066
|
+
}
|
|
4067
|
+
async runtimeRootForRun(run) {
|
|
4068
|
+
const generation = join(this.root, ".apex", "runtime-generations", run.runtimeLockHash);
|
|
4069
|
+
return (await this.exists(join(generation, "apex.lock.json"))) ? generation : join(this.root, ".apex", "runtime");
|
|
4070
|
+
}
|
|
3647
4071
|
capabilityPacks(manifestPath) {
|
|
3648
4072
|
return new CapabilityPackManager({
|
|
3649
4073
|
root: this.root,
|
|
@@ -3652,11 +4076,11 @@ export class ApexService {
|
|
|
3652
4076
|
});
|
|
3653
4077
|
}
|
|
3654
4078
|
async runtimeLockChecks(run) {
|
|
3655
|
-
const path = join(this.root, ".apex", "apex.lock.json");
|
|
3656
4079
|
try {
|
|
3657
|
-
const
|
|
4080
|
+
const runtimeRoot = await this.runtimeRootForRun(run);
|
|
4081
|
+
const lockPath = join(runtimeRoot, "apex.lock.json");
|
|
4082
|
+
const lock = JSON.parse(await readFile(lockPath, "utf8"));
|
|
3658
4083
|
this.assertValid("runtime-lock", lock);
|
|
3659
|
-
const runtimeRoot = join(this.root, ".apex", "runtime");
|
|
3660
4084
|
const validatorPath = (await this.exists(join(runtimeRoot, "runtime-bundle.v1.json")))
|
|
3661
4085
|
? join(runtimeRoot, "runtime-bundle.v1.json")
|
|
3662
4086
|
: join(runtimeRoot, "toolchain.v1.json");
|
|
@@ -3884,7 +4308,8 @@ export class ApexService {
|
|
|
3884
4308
|
}
|
|
3885
4309
|
}
|
|
3886
4310
|
async evidenceStore(kind, contentType) {
|
|
3887
|
-
const
|
|
4311
|
+
const runtimeRoot = await this.runtimeRootForRun(await this.currentRun());
|
|
4312
|
+
const defaults = JSON.parse(await readFile(join(runtimeRoot, "defaults.v1.json"), "utf8"));
|
|
3888
4313
|
const maxBytes = defaults.evidence?.budgets?.perEntryBytes;
|
|
3889
4314
|
if (!Number.isInteger(maxBytes) || maxBytes === undefined || maxBytes < 1)
|
|
3890
4315
|
throw new ApexError("APEX_VALIDATION", "Runtime evidence byte budget is invalid", EXIT_CODES.validation);
|