@microck/canonfig 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/agent/agent-resolution.errors.js +42 -0
- package/dist/agent/agent-resolution.layer.js +204 -0
- package/dist/agent/agent-resolution.service.js +2259 -0
- package/dist/agent/agent-resolution.types.js +1 -0
- package/dist/agent/controlled-executor.js +704 -0
- package/dist/agent/harness-adapters.js +85 -0
- package/dist/cli/cli.js +618 -0
- package/dist/cli/exit-codes.js +28 -0
- package/dist/cli/follower-commands.js +3 -0
- package/dist/cli/render.js +56 -0
- package/dist/cli/source-commands.js +5 -0
- package/dist/domain/brand.js +29 -0
- package/dist/domain/identity.js +31 -0
- package/dist/domain/npm-package-spec.js +186 -0
- package/dist/domain/profile.js +950 -0
- package/dist/domain/recipe-versions.js +297 -0
- package/dist/domain/resource.js +259 -0
- package/dist/domain/synchronization.js +346 -0
- package/dist/enrollment/enrollment.errors.js +43 -0
- package/dist/enrollment/enrollment.layer.js +724 -0
- package/dist/enrollment/enrollment.service.js +3 -0
- package/dist/enrollment/enrollment.types.js +59 -0
- package/dist/enrollment/follower-client.js +585 -0
- package/dist/enrollment/source-server.js +313 -0
- package/dist/machine/linux.layer.js +1183 -0
- package/dist/machine/machine-state.errors.js +52 -0
- package/dist/machine/machine-state.service.js +3 -0
- package/dist/machine/machine-state.types.js +1 -0
- package/dist/machine/macos.layer.js +470 -0
- package/dist/machine/windows.layer.js +879 -0
- package/dist/profile/discovery.js +740 -0
- package/dist/profile/profile-catalog.errors.js +50 -0
- package/dist/profile/profile-catalog.layer.js +20 -0
- package/dist/profile/profile-catalog.service.js +7 -0
- package/dist/profile/profile-codec.js +153 -0
- package/dist/profile/publication.js +298 -0
- package/dist/profile/tool-catalog.js +384 -0
- package/dist/runtime/doctor.js +306 -0
- package/dist/runtime/layers.js +706 -0
- package/dist/runtime/main.js +38 -0
- package/dist/schedule/linux-schedule.js +24 -0
- package/dist/schedule/macos-schedule.js +25 -0
- package/dist/schedule/schedule-manager.errors.js +17 -0
- package/dist/schedule/schedule-manager.layer.js +205 -0
- package/dist/schedule/schedule-manager.service.js +3 -0
- package/dist/schedule/schedule-manager.types.js +114 -0
- package/dist/schedule/windows-schedule.js +25 -0
- package/dist/state/state-repository.errors.js +55 -0
- package/dist/state/state-repository.layer.js +1507 -0
- package/dist/state/state-repository.service.js +3 -0
- package/dist/state/state-repository.types.js +1 -0
- package/dist/state/state-schema.js +298 -0
- package/dist/synchronization/config-codec.js +97 -0
- package/dist/synchronization/executor.js +700 -0
- package/dist/synchronization/follower-orchestration.js +939 -0
- package/dist/synchronization/follower-sync-config.js +81 -0
- package/dist/synchronization/npm-artifact.js +670 -0
- package/dist/synchronization/planner.js +378 -0
- package/dist/synchronization/recovery.js +397 -0
- package/dist/synchronization/resource-executors.js +1198 -0
- package/dist/synchronization/resource-plans.js +645 -0
- package/dist/synchronization/synchronization.errors.js +102 -0
- package/dist/synchronization/synchronization.layer.js +97 -0
- package/dist/synchronization/synchronization.service.js +11 -0
- package/dist/synchronization/synchronization.types.js +1 -0
- package/package.json +66 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
export class DiscoveryFilesystemError extends Schema.TaggedError()("DiscoveryFilesystemError", {
|
|
3
|
+
path: Schema.String,
|
|
4
|
+
operation: Schema.Literals(["read", "stat"]),
|
|
5
|
+
reason: Schema.String,
|
|
6
|
+
}) {
|
|
7
|
+
}
|
|
8
|
+
export class DiscoveryParseError extends Schema.TaggedError()("DiscoveryParseError", {
|
|
9
|
+
path: Schema.String,
|
|
10
|
+
format: Schema.Literals(["json", "toml"]),
|
|
11
|
+
reason: Schema.String,
|
|
12
|
+
}) {
|
|
13
|
+
}
|
|
14
|
+
export class InvalidDiscoveryInputError extends Schema.TaggedError()("InvalidDiscoveryInputError", {
|
|
15
|
+
reason: Schema.String,
|
|
16
|
+
}) {
|
|
17
|
+
}
|
|
18
|
+
export class PublicationNotConfiguredError extends Schema.TaggedError()("PublicationNotConfiguredError", {
|
|
19
|
+
operation: Schema.Literals(["publish", "getRevision"]),
|
|
20
|
+
}) {
|
|
21
|
+
}
|
|
22
|
+
export class PublicationReviewRequiredError extends Schema.TaggedError()("PublicationReviewRequiredError", {
|
|
23
|
+
decision: Schema.String,
|
|
24
|
+
}) {
|
|
25
|
+
}
|
|
26
|
+
export class UnresolvedPublicationProposalError extends Schema.TaggedError()("UnresolvedPublicationProposalError", {
|
|
27
|
+
reasons: Schema.Array(Schema.String),
|
|
28
|
+
}) {
|
|
29
|
+
}
|
|
30
|
+
export class InvalidPublicationResourcesError extends Error {
|
|
31
|
+
errors;
|
|
32
|
+
constructor(errors) {
|
|
33
|
+
super(errors.map((error) => error._tag).join(", "));
|
|
34
|
+
this.name = "InvalidPublicationResourcesError";
|
|
35
|
+
this.errors = errors;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export class InvalidPublicationInputError extends Schema.TaggedError()("InvalidPublicationInputError", {
|
|
39
|
+
reason: Schema.String,
|
|
40
|
+
}) {
|
|
41
|
+
}
|
|
42
|
+
export class PublicationSigningError extends Schema.TaggedError()("PublicationSigningError", {
|
|
43
|
+
operation: Schema.Literals(["sign", "verify"]),
|
|
44
|
+
reason: Schema.String,
|
|
45
|
+
}) {
|
|
46
|
+
}
|
|
47
|
+
export class InvalidPublicationSignatureError extends Schema.TaggedError()("InvalidPublicationSignatureError", {
|
|
48
|
+
keyId: Schema.String,
|
|
49
|
+
}) {
|
|
50
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import { StateRepository } from "../state/state-repository.service.js";
|
|
3
|
+
import { scanDiscovery } from "./discovery.js";
|
|
4
|
+
import { PublicationNotConfiguredError } from "./profile-catalog.errors.js";
|
|
5
|
+
import { ProfileCatalog } from "./profile-catalog.service.js";
|
|
6
|
+
import { makePublication, } from "./publication.js";
|
|
7
|
+
export const ProfileCatalogLive = Layer.succeed(ProfileCatalog, ProfileCatalog.of({
|
|
8
|
+
scan: scanDiscovery,
|
|
9
|
+
publish: () => Effect.fail(new PublicationNotConfiguredError({ operation: "publish" })),
|
|
10
|
+
getRevision: () => Effect.fail(new PublicationNotConfiguredError({ operation: "getRevision" })),
|
|
11
|
+
}));
|
|
12
|
+
export const profileCatalogLayer = (signer) => Layer.effect(ProfileCatalog, Effect.gen(function* () {
|
|
13
|
+
const repository = yield* StateRepository;
|
|
14
|
+
const publication = makePublication(signer, repository);
|
|
15
|
+
return ProfileCatalog.of({
|
|
16
|
+
scan: scanDiscovery,
|
|
17
|
+
publish: publication.publish,
|
|
18
|
+
getRevision: repository.getRevision,
|
|
19
|
+
});
|
|
20
|
+
}));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* C7 profile boundary. Scanning itself never mutates or publishes a profile;
|
|
4
|
+
* publication requires an explicit accepted review.
|
|
5
|
+
*/
|
|
6
|
+
export class ProfileCatalog extends Context.Service()("canonfig/profile/ProfileCatalog") {
|
|
7
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Predicate, Schema as S } from "effect";
|
|
2
|
+
/** Canonical JSON encoding: sorted keys, no insignificant whitespace. */
|
|
3
|
+
export const canonicalJson = (value) => encodeCanonical(value);
|
|
4
|
+
const encodeCanonical = (value) => {
|
|
5
|
+
if (value === null)
|
|
6
|
+
return "null";
|
|
7
|
+
if (Predicate.isString(value))
|
|
8
|
+
return JSON.stringify(value);
|
|
9
|
+
if (Predicate.isNumber(value)) {
|
|
10
|
+
if (!Number.isFinite(value))
|
|
11
|
+
throw new TypeError("canonical JSON cannot encode a non-finite number");
|
|
12
|
+
return JSON.stringify(value);
|
|
13
|
+
}
|
|
14
|
+
if (Predicate.isBoolean(value))
|
|
15
|
+
return value ? "true" : "false";
|
|
16
|
+
if (Array.isArray(value))
|
|
17
|
+
return `[${value.map(encodeCanonical).join(",")}]`;
|
|
18
|
+
const keys = Object.keys(value).sort();
|
|
19
|
+
const parts = keys.map((key) => `${JSON.stringify(key)}:${encodeCanonical(value[key])}`);
|
|
20
|
+
return `{${parts.join(",")}}`;
|
|
21
|
+
};
|
|
22
|
+
/** Content digest over canonical bytes, hex encoded. */
|
|
23
|
+
export const digestOf = (value) => sha256Hex(canonicalJson(value));
|
|
24
|
+
export const sha256Hex = (text) => {
|
|
25
|
+
const bytes = new TextEncoder().encode(text);
|
|
26
|
+
return sha256BytesHex(bytes);
|
|
27
|
+
};
|
|
28
|
+
export const sha256BytesHex = (bytes) => {
|
|
29
|
+
// Lazy import boundary: node:crypto is the runtime; this codec runs on Node only.
|
|
30
|
+
const { createHash } = nodeCrypto();
|
|
31
|
+
// SAFETY: SHA-256 with hex encoding always returns a content digest string.
|
|
32
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
33
|
+
};
|
|
34
|
+
let cachedCrypto = null;
|
|
35
|
+
const nodeCrypto = () => {
|
|
36
|
+
if (cachedCrypto === null)
|
|
37
|
+
cachedCrypto = requireNodeCrypto();
|
|
38
|
+
return cachedCrypto;
|
|
39
|
+
};
|
|
40
|
+
const requireNodeCrypto = () => {
|
|
41
|
+
// SAFETY: process is available in the Node runtime this package targets; the
|
|
42
|
+
// dynamic import is resolved at first use and cached.
|
|
43
|
+
const load = globalThis.process?.getBuiltinModule;
|
|
44
|
+
if (load !== undefined)
|
|
45
|
+
return load("node:crypto");
|
|
46
|
+
throw new Error("node:crypto is unavailable in this runtime");
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Strip JSONC comments and trailing commas from authoring text.
|
|
50
|
+
* Handles line comments, block comments (not inside strings), and trailing commas.
|
|
51
|
+
* JSONC is parsed only at the authoring boundary; canonical forms never contain it.
|
|
52
|
+
*/
|
|
53
|
+
export const stripJsonc = (text) => {
|
|
54
|
+
let out = "";
|
|
55
|
+
let i = 0;
|
|
56
|
+
let inString = false;
|
|
57
|
+
let stringEscape = false;
|
|
58
|
+
while (i < text.length) {
|
|
59
|
+
const ch = text[i];
|
|
60
|
+
const next = i + 1 < text.length ? text[i + 1] : "";
|
|
61
|
+
if (inString) {
|
|
62
|
+
out += ch;
|
|
63
|
+
if (stringEscape) {
|
|
64
|
+
stringEscape = false;
|
|
65
|
+
}
|
|
66
|
+
else if (ch === "\\") {
|
|
67
|
+
stringEscape = true;
|
|
68
|
+
}
|
|
69
|
+
else if (ch === '"') {
|
|
70
|
+
inString = false;
|
|
71
|
+
}
|
|
72
|
+
i += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (ch === '"') {
|
|
76
|
+
inString = true;
|
|
77
|
+
out += ch;
|
|
78
|
+
i += 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (ch === "/" && next === "/") {
|
|
82
|
+
out += " ";
|
|
83
|
+
while (i < text.length && text[i] !== "\n")
|
|
84
|
+
i += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (ch === "/" && next === "*") {
|
|
88
|
+
out += " ";
|
|
89
|
+
i += 2;
|
|
90
|
+
let terminated = false;
|
|
91
|
+
while (i + 1 < text.length) {
|
|
92
|
+
if (text[i] === "*" && text[i + 1] === "/") {
|
|
93
|
+
terminated = true;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
i += 1;
|
|
97
|
+
}
|
|
98
|
+
if (!terminated)
|
|
99
|
+
throw new SyntaxError("unterminated JSONC block comment");
|
|
100
|
+
i += 2;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
out += ch;
|
|
104
|
+
i += 1;
|
|
105
|
+
}
|
|
106
|
+
return removeTrailingCommas(out);
|
|
107
|
+
};
|
|
108
|
+
const removeTrailingCommas = (text) => {
|
|
109
|
+
let out = "";
|
|
110
|
+
let inString = false;
|
|
111
|
+
let stringEscape = false;
|
|
112
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
113
|
+
const ch = text[index];
|
|
114
|
+
if (inString) {
|
|
115
|
+
out += ch;
|
|
116
|
+
if (stringEscape) {
|
|
117
|
+
stringEscape = false;
|
|
118
|
+
}
|
|
119
|
+
else if (ch === "\\") {
|
|
120
|
+
stringEscape = true;
|
|
121
|
+
}
|
|
122
|
+
else if (ch === '"') {
|
|
123
|
+
inString = false;
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (ch === '"') {
|
|
128
|
+
inString = true;
|
|
129
|
+
out += ch;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (ch === ",") {
|
|
133
|
+
let lookahead = index + 1;
|
|
134
|
+
while (lookahead < text.length && /\s/u.test(text[lookahead] ?? ""))
|
|
135
|
+
lookahead += 1;
|
|
136
|
+
const next = text[lookahead];
|
|
137
|
+
if (next === "}" || next === "]")
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
out += ch;
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
};
|
|
144
|
+
/** Parse JSONC authoring text into validated JSON data. Throws on invalid JSON. */
|
|
145
|
+
export const parseJsonc = (text) => {
|
|
146
|
+
const stripped = stripJsonc(text);
|
|
147
|
+
return S.decodeUnknownSync(S.MutableJson)(JSON.parse(stripped));
|
|
148
|
+
};
|
|
149
|
+
/** Parse and decode with a schema at the authoring boundary. */
|
|
150
|
+
export const decodeJsonc = (schema) => (text) => S.decodeUnknownSync(schema, {
|
|
151
|
+
errors: "all",
|
|
152
|
+
onExcessProperty: "error",
|
|
153
|
+
})(parseJsonc(text));
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { BlobId, ProfileRevisionId, ResourceId, Timestamp, ToolId, } from "../domain/brand.js";
|
|
3
|
+
import { normalizeMachineProfile, validateMachineProfile, } from "../domain/profile.js";
|
|
4
|
+
import { encodeMachineProfile, digestMachineProfile } from "../domain/profile.js";
|
|
5
|
+
import { RevisionImmutableError } from "../state/state-repository.errors.js";
|
|
6
|
+
import { InvalidPublicationInputError, InvalidPublicationResourcesError, InvalidPublicationSignatureError, PublicationReviewRequiredError, UnresolvedPublicationProposalError, } from "./profile-catalog.errors.js";
|
|
7
|
+
import { canonicalJson, digestOf, sha256Hex } from "./profile-codec.js";
|
|
8
|
+
const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
9
|
+
const asJson = (value) => Schema.decodeUnknownSync(Schema.MutableJson)(JSON.parse(JSON.stringify(value)));
|
|
10
|
+
/** Stable digest reviewers accept; it binds acceptance to the exact proposal. */
|
|
11
|
+
export const digestDiscoveryProposal = (proposal) => digestOf(asJson(proposal));
|
|
12
|
+
export const acceptPublicationProposal = (proposal, reviewer, reviewedAt) => ({
|
|
13
|
+
decision: "accepted",
|
|
14
|
+
reviewer,
|
|
15
|
+
reviewedAt,
|
|
16
|
+
proposalDigest: digestDiscoveryProposal(proposal),
|
|
17
|
+
});
|
|
18
|
+
const packageForRecipe = (recipe) => {
|
|
19
|
+
switch (recipe.method) {
|
|
20
|
+
case "npm":
|
|
21
|
+
case "uv":
|
|
22
|
+
return recipe.package;
|
|
23
|
+
case "homebrew":
|
|
24
|
+
return recipe.formula;
|
|
25
|
+
case "winget":
|
|
26
|
+
return recipe.id;
|
|
27
|
+
case "cargo":
|
|
28
|
+
return recipe.crate;
|
|
29
|
+
case "source":
|
|
30
|
+
return recipe.repository;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const platformsForRecipe = (recipe) => {
|
|
34
|
+
switch (recipe.method) {
|
|
35
|
+
case "homebrew":
|
|
36
|
+
return ["macos"];
|
|
37
|
+
case "winget":
|
|
38
|
+
return ["windows"];
|
|
39
|
+
case "npm":
|
|
40
|
+
case "uv":
|
|
41
|
+
case "cargo":
|
|
42
|
+
case "source":
|
|
43
|
+
return ["linux", "macos", "windows"];
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const resourceForTool = (tool) => ({
|
|
47
|
+
id: Schema.decodeUnknownSync(ResourceId)(tool.id),
|
|
48
|
+
kind: "tool",
|
|
49
|
+
policy: "ensure",
|
|
50
|
+
target: tool.executable,
|
|
51
|
+
dependsOn: [],
|
|
52
|
+
spec: {
|
|
53
|
+
kind: "tool",
|
|
54
|
+
toolId: Schema.decodeUnknownSync(ToolId)(tool.id),
|
|
55
|
+
recipes: tool.recipes
|
|
56
|
+
.flatMap((recipe) => platformsForRecipe(recipe).map((platform) => ({
|
|
57
|
+
platform,
|
|
58
|
+
method: recipe.method,
|
|
59
|
+
package: packageForRecipe(recipe),
|
|
60
|
+
version: recipe.version,
|
|
61
|
+
indexPolicy: recipe.indexPolicy,
|
|
62
|
+
source: recipe.integrity === undefined
|
|
63
|
+
? recipe.source
|
|
64
|
+
: {
|
|
65
|
+
source: recipe.source,
|
|
66
|
+
integrity: recipe.integrity,
|
|
67
|
+
},
|
|
68
|
+
buildPolicy: recipe.buildPolicy,
|
|
69
|
+
})))
|
|
70
|
+
.sort((left, right) => compareText(`${left.platform}\0${left.method}\0${left.package}\0${left.version}\0${JSON.stringify(left.source)}`, `${right.platform}\0${right.method}\0${right.package}\0${right.version}\0${JSON.stringify(right.source)}`)),
|
|
71
|
+
login: { required: false },
|
|
72
|
+
},
|
|
73
|
+
verify: {
|
|
74
|
+
method: "command",
|
|
75
|
+
command: [...tool.verify.command],
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const skillFilesDigest = (files) => sha256Hex([...files]
|
|
79
|
+
.sort((left, right) => compareText(left.path, right.path))
|
|
80
|
+
.map((file) => `${file.path}\0${sha256Hex(file.content)}\0${file.executable === true ? "x" : "-"}`)
|
|
81
|
+
.join("\n"));
|
|
82
|
+
const resourceForSkill = (skill) => {
|
|
83
|
+
const files = skill.files ?? [];
|
|
84
|
+
return {
|
|
85
|
+
id: Schema.decodeUnknownSync(ResourceId)(skill.id),
|
|
86
|
+
kind: "skill",
|
|
87
|
+
policy: "replace-if-unmodified",
|
|
88
|
+
target: skill.target ?? `skills/${skill.id}`,
|
|
89
|
+
dependsOn: [],
|
|
90
|
+
spec: {
|
|
91
|
+
kind: "skill",
|
|
92
|
+
name: skill.id,
|
|
93
|
+
files: files.map((file) => ({
|
|
94
|
+
path: file.path,
|
|
95
|
+
content: file.content,
|
|
96
|
+
executable: file.executable ?? false,
|
|
97
|
+
})),
|
|
98
|
+
},
|
|
99
|
+
verify: {
|
|
100
|
+
method: "digest",
|
|
101
|
+
digest: skillFilesDigest(files),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
const skillEvidenceId = (invocation) => {
|
|
106
|
+
const match = /^skills\/([A-Za-z0-9._-]+)\/SKILL\.md$/iu.exec(invocation ?? "");
|
|
107
|
+
return match?.[1]?.toLowerCase();
|
|
108
|
+
};
|
|
109
|
+
const unresolvedReasons = (proposal) => {
|
|
110
|
+
const reasons = [];
|
|
111
|
+
for (const task of proposal.agentTasks) {
|
|
112
|
+
reasons.push(`agent-task:${task.id}`);
|
|
113
|
+
}
|
|
114
|
+
for (const resource of proposal.resources) {
|
|
115
|
+
// Skills are reviewable discovery proposals and can be omitted without
|
|
116
|
+
// preventing unrelated reviewed tools from publishing.
|
|
117
|
+
if (resource.kind === "skill")
|
|
118
|
+
continue;
|
|
119
|
+
if (resource.reviewStatus !== "accepted") {
|
|
120
|
+
reasons.push(`resource-needs-review:${resource.kind}:${resource.id}`);
|
|
121
|
+
}
|
|
122
|
+
for (const evidence of resource.evidence) {
|
|
123
|
+
if (evidence.reviewStatus !== "accepted"
|
|
124
|
+
|| evidence.confidence === "review"
|
|
125
|
+
|| evidence.kind === "prose") {
|
|
126
|
+
reasons.push(`evidence-needs-review:${evidence.sourcePath}:${evidence.kind}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const evidence of proposal.evidence) {
|
|
131
|
+
if (skillEvidenceId(evidence.invocation[0]) !== undefined)
|
|
132
|
+
continue;
|
|
133
|
+
if (evidence.reviewStatus !== "accepted"
|
|
134
|
+
|| evidence.confidence === "review"
|
|
135
|
+
|| evidence.kind === "prose") {
|
|
136
|
+
reasons.push(`evidence-needs-review:${evidence.sourcePath}:${evidence.kind}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return [...new Set(reasons)].sort(compareText);
|
|
140
|
+
};
|
|
141
|
+
const validateReview = (input) => {
|
|
142
|
+
const review = input.review;
|
|
143
|
+
if (review.decision !== "accepted") {
|
|
144
|
+
return Effect.fail(new PublicationReviewRequiredError({ decision: review.decision }));
|
|
145
|
+
}
|
|
146
|
+
if (review.reviewer.trim().length === 0) {
|
|
147
|
+
return Effect.fail(new InvalidPublicationInputError({ reason: "reviewer is required" }));
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
Schema.decodeUnknownSync(Timestamp)(review.reviewedAt);
|
|
151
|
+
Schema.decodeUnknownSync(Timestamp)(input.publishedAt);
|
|
152
|
+
}
|
|
153
|
+
catch (cause) {
|
|
154
|
+
return Effect.fail(new InvalidPublicationInputError({
|
|
155
|
+
reason: `invalid publication timestamp: ${String(cause)}`,
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
if (review.proposalDigest !== digestDiscoveryProposal(input.proposal)) {
|
|
159
|
+
return Effect.fail(new InvalidPublicationInputError({
|
|
160
|
+
reason: "accepted proposal digest does not match publication proposal",
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
return Effect.void;
|
|
164
|
+
};
|
|
165
|
+
const validateProposal = (proposal) => {
|
|
166
|
+
const reasons = unresolvedReasons(proposal);
|
|
167
|
+
return reasons.length === 0
|
|
168
|
+
? Effect.void
|
|
169
|
+
: Effect.fail(new UnresolvedPublicationProposalError({ reasons }));
|
|
170
|
+
};
|
|
171
|
+
const machineProfileFor = (input) => {
|
|
172
|
+
const discoveryStatus = new Map(input.proposal.resources.map((resource) => [resource.id, resource.reviewStatus]));
|
|
173
|
+
const discoveryAccepted = (id, status) => status === "accepted" && (discoveryStatus.get(id) ?? "accepted") === "accepted";
|
|
174
|
+
const acceptedDiscoveryResources = [
|
|
175
|
+
...input.proposal.tools
|
|
176
|
+
.filter((tool) => discoveryAccepted(tool.id, tool.reviewStatus))
|
|
177
|
+
.map(resourceForTool),
|
|
178
|
+
...input.proposal.skills
|
|
179
|
+
.filter((skill) => discoveryAccepted(skill.id, skill.reviewStatus))
|
|
180
|
+
.map(resourceForSkill),
|
|
181
|
+
];
|
|
182
|
+
// An explicitly authored resource is authoritative when it shares an id
|
|
183
|
+
// with a discovered proposal. The map also makes the merge deterministic
|
|
184
|
+
// before normalizeMachineProfile applies its canonical id ordering.
|
|
185
|
+
const resources = new Map(acceptedDiscoveryResources.map((resource) => [resource.id, resource]));
|
|
186
|
+
for (const resource of input.profile.resources ?? []) {
|
|
187
|
+
resources.set(resource.id, resource);
|
|
188
|
+
}
|
|
189
|
+
return normalizeMachineProfile({
|
|
190
|
+
id: input.profile.id,
|
|
191
|
+
version: 2,
|
|
192
|
+
name: input.profile.name,
|
|
193
|
+
groups: input.profile.groups ?? [],
|
|
194
|
+
resources: [...resources.values()],
|
|
195
|
+
scheduleDefault: input.profile.scheduleDefault ?? {
|
|
196
|
+
type: "daily",
|
|
197
|
+
at: "00:00",
|
|
198
|
+
timezone: "local",
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
const publishedResources = (profile) => profile.resources.map((resource) => {
|
|
203
|
+
const blob = Schema.decodeUnknownSync(BlobId)(digestOf(asJson(resource.spec)));
|
|
204
|
+
const base = {
|
|
205
|
+
id: Schema.decodeUnknownSync(ResourceId)(resource.id),
|
|
206
|
+
kind: resource.kind,
|
|
207
|
+
policy: resource.policy ?? "ensure",
|
|
208
|
+
target: resource.target,
|
|
209
|
+
dependsOn: (resource.dependsOn ?? []).map((dependency) => Schema.decodeUnknownSync(ResourceId)(dependency)),
|
|
210
|
+
blobs: [blob],
|
|
211
|
+
};
|
|
212
|
+
return resource.groups === undefined
|
|
213
|
+
? base
|
|
214
|
+
: { ...base, groups: resource.groups };
|
|
215
|
+
});
|
|
216
|
+
export const revisionSigningPayload = (revision) => {
|
|
217
|
+
// The profile's canonical bytes already authenticate scheduleDefault. Keep
|
|
218
|
+
// it out of this legacy payload shape so revisions written before schedule
|
|
219
|
+
// metadata transport remain signature-compatible.
|
|
220
|
+
const { scheduleDefault: _, ...signed } = revision;
|
|
221
|
+
return canonicalJson(asJson(signed));
|
|
222
|
+
};
|
|
223
|
+
export const makePublication = (signer, repository) => {
|
|
224
|
+
const publish = (input) => Effect.gen(function* () {
|
|
225
|
+
yield* validateReview(input);
|
|
226
|
+
yield* validateProposal(input.proposal);
|
|
227
|
+
const profile = yield* Effect.try({
|
|
228
|
+
try: () => machineProfileFor(input),
|
|
229
|
+
catch: (cause) => new InvalidPublicationInputError({
|
|
230
|
+
reason: `invalid publication metadata: ${String(cause)}`,
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
233
|
+
const errors = validateMachineProfile(profile);
|
|
234
|
+
if (errors.length > 0) {
|
|
235
|
+
return yield* Effect.fail(new InvalidPublicationResourcesError(errors));
|
|
236
|
+
}
|
|
237
|
+
const encoded = yield* Effect.try({
|
|
238
|
+
try: () => ({
|
|
239
|
+
canonicalBytes: encodeMachineProfile(profile),
|
|
240
|
+
digest: digestMachineProfile(profile),
|
|
241
|
+
}),
|
|
242
|
+
catch: (cause) => new InvalidPublicationInputError({
|
|
243
|
+
reason: `profile cannot be canonically encoded: ${String(cause)}`,
|
|
244
|
+
}),
|
|
245
|
+
});
|
|
246
|
+
const { canonicalBytes, digest } = encoded;
|
|
247
|
+
const id = Schema.decodeUnknownSync(ProfileRevisionId)(`${profile.id}:${digest}`);
|
|
248
|
+
const existing = yield* repository.findRevision(id);
|
|
249
|
+
if (existing !== undefined) {
|
|
250
|
+
if (existing.profileId !== profile.id
|
|
251
|
+
|| existing.digest !== digest
|
|
252
|
+
|| existing.canonicalBytes !== canonicalBytes) {
|
|
253
|
+
return yield* new RevisionImmutableError({
|
|
254
|
+
revision: id,
|
|
255
|
+
message: "content-addressed revision identity names different content",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return existing;
|
|
259
|
+
}
|
|
260
|
+
const latest = yield* repository.getLatestRevision(profile.id);
|
|
261
|
+
const sequence = (latest?.sequence ?? 0) + 1;
|
|
262
|
+
const unsigned = {
|
|
263
|
+
id,
|
|
264
|
+
profileId: profile.id,
|
|
265
|
+
sequence,
|
|
266
|
+
canonicalBytes,
|
|
267
|
+
digest,
|
|
268
|
+
publishedAt: input.publishedAt,
|
|
269
|
+
resources: publishedResources(profile),
|
|
270
|
+
groups: profile.groups,
|
|
271
|
+
scheduleDefault: profile.scheduleDefault,
|
|
272
|
+
signingKeyId: signer.keyId,
|
|
273
|
+
};
|
|
274
|
+
const payload = revisionSigningPayload(unsigned);
|
|
275
|
+
const signature = yield* signer.sign(payload);
|
|
276
|
+
const verified = yield* signer.verify(payload, signature);
|
|
277
|
+
if (!verified) {
|
|
278
|
+
return yield* new InvalidPublicationSignatureError({
|
|
279
|
+
keyId: signer.keyId,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
const revision = {
|
|
283
|
+
id: unsigned.id,
|
|
284
|
+
profileId: unsigned.profileId,
|
|
285
|
+
sequence: unsigned.sequence,
|
|
286
|
+
canonicalBytes: unsigned.canonicalBytes,
|
|
287
|
+
digest: unsigned.digest,
|
|
288
|
+
signature,
|
|
289
|
+
publishedAt: unsigned.publishedAt,
|
|
290
|
+
resources: unsigned.resources,
|
|
291
|
+
groups: unsigned.groups,
|
|
292
|
+
scheduleDefault: unsigned.scheduleDefault,
|
|
293
|
+
};
|
|
294
|
+
yield* repository.publishRevision({ revision });
|
|
295
|
+
return revision;
|
|
296
|
+
});
|
|
297
|
+
return { publish };
|
|
298
|
+
};
|