@apexops/cli 0.10.0-next.4 → 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 +8 -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 +7 -5
- package/assets/client-projections/github-copilot-cli/.github/agents/apex-requirements.agent.md +18 -11
- package/assets/client-projections/github-copilot-cli/.github/mcp.json +5 -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 +7 -5
- package/assets/client-projections/github-copilot-vscode/.github/agents/apex-requirements.agent.md +18 -11
- package/assets/client-projections/github-copilot-vscode/.github/agents/apex-reviewer.agent.md +9 -5
- 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 +7 -5
- package/assets/customizations/.github/agents/apex-requirements.agent.md +18 -11
- package/assets/customizations/.github/agents/apex-reviewer.agent.md +9 -5
- package/assets/customizations/.github/mcp.json +5 -0
- package/assets/customizations/.github/skills/apex-requirements/SKILL.md +7 -4
- package/assets/customizations/manifest.json +1 -1
- package/assets/manifest.json +50 -50
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +42 -2
- package/dist/mcp.js +58 -16
- package/dist/service.d.ts +41 -1
- package/dist/service.js +448 -40
- 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,7 +1,7 @@
|
|
|
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";
|
|
@@ -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,40 @@ 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
|
+
}
|
|
1019
1138
|
async completePlan(taskId, intent, binding, environmentInputs) {
|
|
1020
1139
|
return this.completeTaskOutputs(taskId, [
|
|
1021
1140
|
{ kind: "implementation-intent", value: intent },
|
|
@@ -1023,6 +1142,13 @@ export class ApexService {
|
|
|
1023
1142
|
{ kind: "environment-inputs", value: environmentInputs },
|
|
1024
1143
|
]);
|
|
1025
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
|
+
}
|
|
1026
1152
|
async acceptTaskOutputs(taskId, outputs, legacy) {
|
|
1027
1153
|
const run = await this.currentRun();
|
|
1028
1154
|
const task = await this.readTask(run, taskId);
|
|
@@ -1396,6 +1522,114 @@ export class ApexService {
|
|
|
1396
1522
|
}
|
|
1397
1523
|
}
|
|
1398
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
|
+
}
|
|
1399
1633
|
assertReviewResolution(value) {
|
|
1400
1634
|
const hashes = [value.reviewHash, value.subjectHash, value.dependencyHash, ...value.evidenceRefs];
|
|
1401
1635
|
if (!value.findingId ||
|
|
@@ -2454,17 +2688,22 @@ export class ApexService {
|
|
|
2454
2688
|
typeof event.payload.requestId === "string"
|
|
2455
2689
|
? [event.payload.requestId]
|
|
2456
2690
|
: []));
|
|
2691
|
+
const catalog = this.requirementsIntakeCatalog(requests);
|
|
2457
2692
|
const latest = requests.at(-1);
|
|
2458
2693
|
if (latest !== undefined && !recordedRequestIds.has(latest.payload.requestId)) {
|
|
2459
2694
|
const intake = latest.payload.intake;
|
|
2460
2695
|
const ordinal = intake.ordinal;
|
|
2461
|
-
const round = typeof ordinal === "number" && Number.isInteger(ordinal) ?
|
|
2462
|
-
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) {
|
|
2463
2698
|
throw new ApexError("APEX_CONFLICT", "Requirements intake is incompatible; start a new run", EXIT_CODES.conflict);
|
|
2464
2699
|
}
|
|
2465
2700
|
return round;
|
|
2466
2701
|
}
|
|
2467
|
-
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;
|
|
2468
2707
|
}
|
|
2469
2708
|
async issueRequirementsInput(run, round) {
|
|
2470
2709
|
const events = await this.journal(run).replay();
|
|
@@ -2480,36 +2719,129 @@ export class ApexService {
|
|
|
2480
2719
|
return this.inputRequest(latest, run.ownerEpoch);
|
|
2481
2720
|
}
|
|
2482
2721
|
}
|
|
2483
|
-
const
|
|
2722
|
+
const catalog = this.requirementsIntakeCatalog(events.filter((event) => event.type === "requirements.input-requested"));
|
|
2723
|
+
const ordinal = catalog.findIndex((candidate) => candidate === round) + 1;
|
|
2484
2724
|
const event = await this.append(run, "requirements.input-requested", {
|
|
2485
2725
|
requestId: this.idSource(),
|
|
2486
|
-
intake: { round: round.round, ordinal, total:
|
|
2726
|
+
intake: { round: round.round, ordinal, total: catalog.length },
|
|
2487
2727
|
questions,
|
|
2488
2728
|
});
|
|
2489
2729
|
return this.inputRequest(event, run.ownerEpoch);
|
|
2490
2730
|
}
|
|
2491
2731
|
requirementsIntakeQuestions(round, events) {
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
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];
|
|
2500
2777
|
}
|
|
2501
2778
|
if (round.round !== "workload-pattern")
|
|
2502
2779
|
return round.questions;
|
|
2503
|
-
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);
|
|
2504
2796
|
if (scenario !== "migration" && scenario !== "modernization")
|
|
2505
|
-
return
|
|
2797
|
+
return questions;
|
|
2506
2798
|
return [
|
|
2507
|
-
...
|
|
2799
|
+
...questions,
|
|
2508
2800
|
{ id: "current-platform", prompt: "Describe the current platform and hosting model." },
|
|
2509
2801
|
{ id: "migration-pain-points", prompt: "Describe the problems the migration or modernization must address." },
|
|
2510
2802
|
{ id: "preserve-components", prompt: "List components, integrations, or data that must be preserved." },
|
|
2511
2803
|
];
|
|
2512
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
|
+
}
|
|
2513
2845
|
async append(run, type, payload, expectedHead) {
|
|
2514
2846
|
const journal = this.journal(run);
|
|
2515
2847
|
return await journal.append({
|
|
@@ -2564,11 +2896,12 @@ export class ApexService {
|
|
|
2564
2896
|
throw new ApexError("APEX_CONFLICT", "Requirements intake is incompatible; start a new run", EXIT_CODES.conflict);
|
|
2565
2897
|
}
|
|
2566
2898
|
const intake = request.intake;
|
|
2567
|
-
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];
|
|
2568
2901
|
if (round === undefined ||
|
|
2569
2902
|
intake === undefined ||
|
|
2570
2903
|
round.round !== intake.round ||
|
|
2571
|
-
intake.total !==
|
|
2904
|
+
intake.total !== catalog.length) {
|
|
2572
2905
|
throw new ApexError("APEX_CONFLICT", "Requirements intake is incompatible; start a new run", EXIT_CODES.conflict);
|
|
2573
2906
|
}
|
|
2574
2907
|
}
|
|
@@ -2595,6 +2928,10 @@ export class ApexService {
|
|
|
2595
2928
|
}
|
|
2596
2929
|
artifactHash(events, kind) {
|
|
2597
2930
|
for (const event of [...events].reverse()) {
|
|
2931
|
+
if (event.type === "workflow.invalidated" &&
|
|
2932
|
+
event.payload.artifactKinds?.includes?.(kind)) {
|
|
2933
|
+
return undefined;
|
|
2934
|
+
}
|
|
2598
2935
|
if (event.type !== "task.completed")
|
|
2599
2936
|
continue;
|
|
2600
2937
|
const hashes = event.payload.artifactHashes;
|
|
@@ -2615,6 +2952,14 @@ export class ApexService {
|
|
|
2615
2952
|
"implementation-intent": "intentHash",
|
|
2616
2953
|
};
|
|
2617
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
|
+
}
|
|
2618
2963
|
if (event.type !== "task.completed")
|
|
2619
2964
|
continue;
|
|
2620
2965
|
const payload = event.payload;
|
|
@@ -2680,7 +3025,7 @@ export class ApexService {
|
|
|
2680
3025
|
}
|
|
2681
3026
|
return recorded ? result : null;
|
|
2682
3027
|
}
|
|
2683
|
-
outputTemplate(kind, run, events) {
|
|
3028
|
+
outputTemplate(kind, run, events, taskType) {
|
|
2684
3029
|
if (kind === "requirements") {
|
|
2685
3030
|
const input = this.recordedRequirementsInput(events);
|
|
2686
3031
|
return {
|
|
@@ -2688,7 +3033,34 @@ export class ApexService {
|
|
|
2688
3033
|
projectId: run.projectId,
|
|
2689
3034
|
workload: typeof input?.workload === "string" ? input.workload : "",
|
|
2690
3035
|
environment: run.environment,
|
|
2691
|
-
...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
|
+
],
|
|
2692
3064
|
};
|
|
2693
3065
|
}
|
|
2694
3066
|
if (kind === "implementation-intent") {
|
|
@@ -2850,17 +3222,28 @@ export class ApexService {
|
|
|
2850
3222
|
}
|
|
2851
3223
|
}
|
|
2852
3224
|
completedNodeIds(events) {
|
|
2853
|
-
|
|
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
|
+
}
|
|
2854
3235
|
if (event.type !== "task.completed")
|
|
2855
|
-
|
|
3236
|
+
continue;
|
|
2856
3237
|
const nodeId = event.payload.nodeId;
|
|
2857
|
-
|
|
2858
|
-
|
|
3238
|
+
if (typeof nodeId === "string")
|
|
3239
|
+
completed.add(nodeId);
|
|
3240
|
+
}
|
|
3241
|
+
return completed;
|
|
2859
3242
|
}
|
|
2860
3243
|
async lockedWorkflowEngine(run) {
|
|
2861
|
-
const runtimeRoot =
|
|
3244
|
+
const runtimeRoot = await this.runtimeRootForRun(run);
|
|
2862
3245
|
const workflowBytes = await readFile(join(runtimeRoot, "workflow.v1.json"));
|
|
2863
|
-
const lock = JSON.parse(await readFile(join(
|
|
3246
|
+
const lock = JSON.parse(await readFile(join(runtimeRoot, "apex.lock.json"), "utf8"));
|
|
2864
3247
|
this.assertValid("runtime-lock", lock);
|
|
2865
3248
|
if (sha256Json(lock) !== run.runtimeLockHash || sha256Bytes(workflowBytes) !== lock.workflowHash) {
|
|
2866
3249
|
throw new ApexError("APEX_STALE", "Runtime workflow lock is not current", EXIT_CODES.stale);
|
|
@@ -3179,7 +3562,7 @@ export class ApexService {
|
|
|
3179
3562
|
...(availabilityEvidence === undefined ? {} : { availabilityEvidence }),
|
|
3180
3563
|
...(deploymentOperation === undefined ? {} : { deploymentOperation }),
|
|
3181
3564
|
...(deploymentInventory === undefined ? {} : { deploymentInventory }),
|
|
3182
|
-
...(nodeId === "quality" ? await this.qualityValidatorContext() : {}),
|
|
3565
|
+
...(nodeId === "quality" ? await this.qualityValidatorContext(run) : {}),
|
|
3183
3566
|
};
|
|
3184
3567
|
for (const id of validatorIds) {
|
|
3185
3568
|
this.assertValid(id, taskWorkflowValidatorInput(id, context));
|
|
@@ -3190,7 +3573,7 @@ export class ApexService {
|
|
|
3190
3573
|
evidenceModes: availabilityEvidence === undefined ? {} : { "business:availability-current": availabilityEvidence.mode },
|
|
3191
3574
|
};
|
|
3192
3575
|
}
|
|
3193
|
-
async qualityValidatorContext() {
|
|
3576
|
+
async qualityValidatorContext(run) {
|
|
3194
3577
|
let qualityMeasurements;
|
|
3195
3578
|
try {
|
|
3196
3579
|
qualityMeasurements = JSON.parse(await readFile(join(this.root, ".apex", "quality", "measurements.json"), "utf8"));
|
|
@@ -3202,8 +3585,9 @@ export class ApexService {
|
|
|
3202
3585
|
throw error;
|
|
3203
3586
|
}
|
|
3204
3587
|
this.assertValid("quality-measurements", qualityMeasurements);
|
|
3588
|
+
const runtimeRoot = await this.runtimeRootForRun(run);
|
|
3205
3589
|
return {
|
|
3206
|
-
scorecard: JSON.parse(await readFile(join(
|
|
3590
|
+
scorecard: JSON.parse(await readFile(join(runtimeRoot, "quality-scorecard.v1.json"), "utf8")),
|
|
3207
3591
|
qualityMeasurements,
|
|
3208
3592
|
};
|
|
3209
3593
|
}
|
|
@@ -3594,9 +3978,10 @@ export class ApexService {
|
|
|
3594
3978
|
this.improvementRuntime ??= new ImprovementStore(this.root, this.improvementPolicy, this.clock);
|
|
3595
3979
|
return this.improvementRuntime;
|
|
3596
3980
|
}
|
|
3981
|
+
const runtimeRoot = await this.runtimeRootForRun(run);
|
|
3597
3982
|
const [policyBytes, lockBytes] = await Promise.all([
|
|
3598
|
-
readFile(join(
|
|
3599
|
-
readFile(join(
|
|
3983
|
+
readFile(join(runtimeRoot, "improvement-policy.v1.json")),
|
|
3984
|
+
readFile(join(runtimeRoot, "apex.lock.json")),
|
|
3600
3985
|
]);
|
|
3601
3986
|
const lock = JSON.parse(lockBytes.toString("utf8"));
|
|
3602
3987
|
this.assertValid("runtime-lock", lock);
|
|
@@ -3661,6 +4046,28 @@ export class ApexService {
|
|
|
3661
4046
|
this.assertValid("runtime-lock", lock);
|
|
3662
4047
|
return lock;
|
|
3663
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
|
+
}
|
|
3664
4071
|
capabilityPacks(manifestPath) {
|
|
3665
4072
|
return new CapabilityPackManager({
|
|
3666
4073
|
root: this.root,
|
|
@@ -3669,11 +4076,11 @@ export class ApexService {
|
|
|
3669
4076
|
});
|
|
3670
4077
|
}
|
|
3671
4078
|
async runtimeLockChecks(run) {
|
|
3672
|
-
const path = join(this.root, ".apex", "apex.lock.json");
|
|
3673
4079
|
try {
|
|
3674
|
-
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"));
|
|
3675
4083
|
this.assertValid("runtime-lock", lock);
|
|
3676
|
-
const runtimeRoot = join(this.root, ".apex", "runtime");
|
|
3677
4084
|
const validatorPath = (await this.exists(join(runtimeRoot, "runtime-bundle.v1.json")))
|
|
3678
4085
|
? join(runtimeRoot, "runtime-bundle.v1.json")
|
|
3679
4086
|
: join(runtimeRoot, "toolchain.v1.json");
|
|
@@ -3901,7 +4308,8 @@ export class ApexService {
|
|
|
3901
4308
|
}
|
|
3902
4309
|
}
|
|
3903
4310
|
async evidenceStore(kind, contentType) {
|
|
3904
|
-
const
|
|
4311
|
+
const runtimeRoot = await this.runtimeRootForRun(await this.currentRun());
|
|
4312
|
+
const defaults = JSON.parse(await readFile(join(runtimeRoot, "defaults.v1.json"), "utf8"));
|
|
3905
4313
|
const maxBytes = defaults.evidence?.budgets?.perEntryBytes;
|
|
3906
4314
|
if (!Number.isInteger(maxBytes) || maxBytes === undefined || maxBytes < 1)
|
|
3907
4315
|
throw new ApexError("APEX_VALIDATION", "Runtime evidence byte budget is invalid", EXIT_CODES.validation);
|