@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,645 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { AgentTaskId, ContentDigest as ContentDigestSchema, } from "../domain/brand.js";
|
|
3
|
+
import { isNestedCommandLauncher } from "../agent/agent-resolution.service.js";
|
|
4
|
+
import { sha256Hex } from "../profile/profile-codec.js";
|
|
5
|
+
import { isMissingAutomaticRecipeVersion, recipeSourceDetails, } from "../domain/recipe-versions.js";
|
|
6
|
+
import { InvalidObservedStateError } from "./synchronization.errors.js";
|
|
7
|
+
const compareText = (left, right) => {
|
|
8
|
+
if (left < right)
|
|
9
|
+
return -1;
|
|
10
|
+
if (left > right)
|
|
11
|
+
return 1;
|
|
12
|
+
return 0;
|
|
13
|
+
};
|
|
14
|
+
const sortedUnique = (values) => [...new Set(values)].sort(compareText);
|
|
15
|
+
const pendingAgentTaskId = Schema.decodeUnknownSync(AgentTaskId)("pending");
|
|
16
|
+
const filesDigest = (files) => {
|
|
17
|
+
const encoded = [...files]
|
|
18
|
+
.sort((left, right) => compareText(left.path, right.path))
|
|
19
|
+
.map((file) => `${file.path}\0${file.digest}\0${file.executable === true ? "x" : "-"}`)
|
|
20
|
+
.join("\n");
|
|
21
|
+
return sha256Hex(encoded);
|
|
22
|
+
};
|
|
23
|
+
const presentObservedFiles = (files) => files.flatMap((file) => "state" in file || file.digest === undefined
|
|
24
|
+
? []
|
|
25
|
+
: [{ path: file.path, digest: file.digest, executable: file.executable }]);
|
|
26
|
+
const fileSignature = (file) => `${file.digest}\0${file.executable === true ? "x" : "-"}`;
|
|
27
|
+
export const desiredResourceDigest = (desired) => {
|
|
28
|
+
switch (desired.kind) {
|
|
29
|
+
case "file":
|
|
30
|
+
case "config":
|
|
31
|
+
case "skill":
|
|
32
|
+
case "schedule":
|
|
33
|
+
return desired.digest;
|
|
34
|
+
case "directory":
|
|
35
|
+
return filesDigest(desired.files);
|
|
36
|
+
case "tool":
|
|
37
|
+
case "credential":
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const observedDigest = (context) => {
|
|
42
|
+
switch (context.observed.state) {
|
|
43
|
+
case "absent":
|
|
44
|
+
case "unverifiable":
|
|
45
|
+
return undefined;
|
|
46
|
+
case "present":
|
|
47
|
+
return Schema.decodeUnknownSync(ContentDigestSchema)(context.observed.digest);
|
|
48
|
+
case "directory":
|
|
49
|
+
return filesDigest(presentObservedFiles(context.observed.files));
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
export const detectSkillDrift = (input) => {
|
|
53
|
+
const observed = input.observedDigest;
|
|
54
|
+
const applied = input.lastAppliedDigest;
|
|
55
|
+
const observedMatchesDesired = observed === input.desiredDigest
|
|
56
|
+
&& (input.desiredExecutable === undefined
|
|
57
|
+
|| input.observedExecutable === input.desiredExecutable);
|
|
58
|
+
const observedMatchesApplied = observed === applied
|
|
59
|
+
&& (input.lastAppliedExecutable === undefined
|
|
60
|
+
|| input.observedExecutable === input.lastAppliedExecutable);
|
|
61
|
+
const desiredMatchesApplied = input.desiredDigest === applied
|
|
62
|
+
&& (input.desiredExecutable === undefined
|
|
63
|
+
|| input.lastAppliedExecutable === input.desiredExecutable);
|
|
64
|
+
if (observedMatchesDesired) {
|
|
65
|
+
return desiredMatchesApplied ? "unchanged" : "converged";
|
|
66
|
+
}
|
|
67
|
+
if (applied === undefined) {
|
|
68
|
+
return observed === undefined ? "remote-only" : "conflicting";
|
|
69
|
+
}
|
|
70
|
+
if (observedMatchesApplied)
|
|
71
|
+
return "remote-only";
|
|
72
|
+
if (desiredMatchesApplied)
|
|
73
|
+
return "local-only";
|
|
74
|
+
return "conflicting";
|
|
75
|
+
};
|
|
76
|
+
const noOp = () => ({
|
|
77
|
+
kind: "no-op",
|
|
78
|
+
detail: { kind: "no-op" },
|
|
79
|
+
});
|
|
80
|
+
const observedMatchesDesired = (desired, observed) => {
|
|
81
|
+
if (observed.state === "absent" || observed.state === "unverifiable")
|
|
82
|
+
return false;
|
|
83
|
+
const observedObjectKind = observed.state === "present" || observed.state === "directory"
|
|
84
|
+
? observed.objectKind
|
|
85
|
+
: undefined;
|
|
86
|
+
switch (desired.kind) {
|
|
87
|
+
case "file":
|
|
88
|
+
return observed.state === "present"
|
|
89
|
+
&& observed.digest === desired.digest
|
|
90
|
+
&& observed.executable === desired.executable
|
|
91
|
+
&& (desired.symlinkTo === undefined
|
|
92
|
+
? observed.symlinkTo === undefined
|
|
93
|
+
&& (observedObjectKind === undefined || observedObjectKind === "regular")
|
|
94
|
+
: observed.symlinkTo !== undefined
|
|
95
|
+
&& (observedObjectKind === undefined || observedObjectKind === "symlink"));
|
|
96
|
+
case "config":
|
|
97
|
+
case "schedule":
|
|
98
|
+
return observed.state === "present"
|
|
99
|
+
&& observed.digest === desired.digest
|
|
100
|
+
&& (observedObjectKind === undefined || observedObjectKind === "regular");
|
|
101
|
+
case "directory":
|
|
102
|
+
case "skill":
|
|
103
|
+
return observed.state === "directory"
|
|
104
|
+
&& (observed.objectKind === undefined || observed.objectKind === "directory")
|
|
105
|
+
&& observed.files.every((file) => !("state" in file))
|
|
106
|
+
&& filesDigest(presentObservedFiles(observed.files)) === desiredResourceDigest(desired);
|
|
107
|
+
case "tool":
|
|
108
|
+
case "credential":
|
|
109
|
+
return observed.state === "present";
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const observedMatchesApplied = (context) => {
|
|
113
|
+
const applied = context.applied;
|
|
114
|
+
if (applied === undefined || context.observed.state === "absent")
|
|
115
|
+
return true;
|
|
116
|
+
if ((context.resource.kind === "directory" || context.resource.kind === "skill")
|
|
117
|
+
&& context.observed.state === "directory"
|
|
118
|
+
&& applied.ownedFiles !== undefined) {
|
|
119
|
+
const ownedByPath = new Map(applied.ownedFiles.map((file) => [file.path, fileSignature(file)]));
|
|
120
|
+
return context.observed.files.every((file) => "state" in file
|
|
121
|
+
|| ownedByPath.get(file.path) === undefined
|
|
122
|
+
|| ownedByPath.get(file.path) === fileSignature(file));
|
|
123
|
+
}
|
|
124
|
+
const currentDigest = observedDigest(context);
|
|
125
|
+
if (currentDigest !== applied.digest)
|
|
126
|
+
return false;
|
|
127
|
+
if (context.resource.kind === "file"
|
|
128
|
+
&& context.observed.state === "present"
|
|
129
|
+
&& (applied.executable !== undefined
|
|
130
|
+
&& context.observed.executable !== applied.executable
|
|
131
|
+
|| applied.symlinkTo === undefined
|
|
132
|
+
&& context.observed.objectKind !== undefined
|
|
133
|
+
&& context.observed.objectKind !== "regular"
|
|
134
|
+
|| applied.symlinkTo !== undefined
|
|
135
|
+
&& context.observed.objectKind !== undefined
|
|
136
|
+
&& context.observed.objectKind !== "symlink")) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
if (context.resource.kind === "file"
|
|
140
|
+
&& context.observed.state === "present"
|
|
141
|
+
&& applied.symlinkTo === undefined
|
|
142
|
+
&& context.observed.symlinkTo !== undefined) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
};
|
|
147
|
+
const writeFile = (context, digest, executable) => {
|
|
148
|
+
const detail = {
|
|
149
|
+
kind: "write-file",
|
|
150
|
+
target: context.resource.target,
|
|
151
|
+
digest,
|
|
152
|
+
};
|
|
153
|
+
if (executable !== undefined)
|
|
154
|
+
detail.executable = executable;
|
|
155
|
+
return { kind: "write-file", detail };
|
|
156
|
+
};
|
|
157
|
+
const replaceDirectory = (context, desired) => {
|
|
158
|
+
const observedFiles = context.observed.state === "directory"
|
|
159
|
+
? presentObservedFiles(context.observed.files)
|
|
160
|
+
: [];
|
|
161
|
+
const observedByPath = new Map(observedFiles.map((file) => [
|
|
162
|
+
file.path,
|
|
163
|
+
`${file.digest}\0${file.executable === true ? "x" : "-"}`,
|
|
164
|
+
]));
|
|
165
|
+
const desiredPaths = new Set(desired.files.map((file) => file.path));
|
|
166
|
+
return {
|
|
167
|
+
kind: "mirror-directory",
|
|
168
|
+
detail: {
|
|
169
|
+
kind: "mirror-directory",
|
|
170
|
+
target: context.resource.target,
|
|
171
|
+
adds: desired.files
|
|
172
|
+
.filter((file) => observedByPath.get(file.path)
|
|
173
|
+
!== `${file.digest}\0${file.executable ? "x" : "-"}`)
|
|
174
|
+
.map((file) => file.path)
|
|
175
|
+
.sort(compareText),
|
|
176
|
+
removes: observedFiles
|
|
177
|
+
.filter((file) => !desiredPaths.has(file.path))
|
|
178
|
+
.map((file) => file.path)
|
|
179
|
+
.sort(compareText),
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
const driftConflict = (context, desiredDigest, currentDigest, desiredExecutable, observedExecutable) => {
|
|
184
|
+
const detail = {
|
|
185
|
+
kind: "drift-conflict",
|
|
186
|
+
target: context.resource.target,
|
|
187
|
+
desiredDigest,
|
|
188
|
+
observedDigest: currentDigest,
|
|
189
|
+
};
|
|
190
|
+
if (desiredExecutable !== undefined)
|
|
191
|
+
detail.desiredExecutable = desiredExecutable;
|
|
192
|
+
if (observedExecutable !== undefined)
|
|
193
|
+
detail.observedExecutable = observedExecutable;
|
|
194
|
+
return { kind: "drift-conflict", detail };
|
|
195
|
+
};
|
|
196
|
+
const directoryRootConflict = (context) => {
|
|
197
|
+
if (context.desired.kind !== "directory" && context.desired.kind !== "skill") {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
if (context.observed.state === "directory"
|
|
201
|
+
&& (context.observed.objectKind === undefined
|
|
202
|
+
|| context.observed.objectKind === "directory")) {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
if (context.observed.state === "present" && context.observed.objectKind === undefined) {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
if (context.observed.state === "absent")
|
|
209
|
+
return undefined;
|
|
210
|
+
const observedDigest = observedDigestForDirectoryRoot(context);
|
|
211
|
+
return driftConflict(context, desiredResourceDigest(context.desired), observedDigest);
|
|
212
|
+
};
|
|
213
|
+
const observedDigestForDirectoryRoot = (context) => context.observed.state === "present"
|
|
214
|
+
? Schema.decodeUnknownSync(ContentDigestSchema)(context.observed.digest)
|
|
215
|
+
: sha256Hex("canonfig:unverifiable-directory-root");
|
|
216
|
+
const unresolvedAgentTask = (context, summary) => {
|
|
217
|
+
const tool = context.desired.kind === "tool" ? context.desired : undefined;
|
|
218
|
+
const allowedExecutables = tool === undefined
|
|
219
|
+
? []
|
|
220
|
+
: sortedUnique([
|
|
221
|
+
tool.toolId,
|
|
222
|
+
...tool.recipes.map((recipe) => recipe.method),
|
|
223
|
+
]);
|
|
224
|
+
// Verification stays on the tool itself; package-manager recipe methods can
|
|
225
|
+
// be launcher-class names (`make`, `npx`, ...), so they are never granted an
|
|
226
|
+
// execution model. The bounded agent remains free to propose them, but any
|
|
227
|
+
// launcher-class action fails closed at authorization time.
|
|
228
|
+
const verifiable = tool === undefined || isNestedCommandLauncher(tool.toolId)
|
|
229
|
+
? undefined
|
|
230
|
+
: tool;
|
|
231
|
+
return {
|
|
232
|
+
kind: "agent-task",
|
|
233
|
+
detail: {
|
|
234
|
+
kind: "agent-task",
|
|
235
|
+
taskId: pendingAgentTaskId,
|
|
236
|
+
summary,
|
|
237
|
+
},
|
|
238
|
+
task: {
|
|
239
|
+
resource: context.resource.id,
|
|
240
|
+
summary,
|
|
241
|
+
desiredOutcome: `Converge ${context.resource.kind} ${context.resource.id}`,
|
|
242
|
+
observedEvidence: [`Observed state: ${context.observed.state}`],
|
|
243
|
+
allowedPaths: [context.resource.target],
|
|
244
|
+
allowedExecutables,
|
|
245
|
+
executableAuthorizations: verifiable === undefined
|
|
246
|
+
? []
|
|
247
|
+
: [{ executable: verifiable.toolId, behavior: "leaf" }],
|
|
248
|
+
allowedOrigins: [],
|
|
249
|
+
forbidden: ["elevation", "login", "restart", "reboot"],
|
|
250
|
+
timeLimitSeconds: 300,
|
|
251
|
+
outputLimitBytes: 65_536,
|
|
252
|
+
verification: {
|
|
253
|
+
command: verifiable === undefined ? [] : [verifiable.toolId, "--version"],
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
const planReplace = (context) => {
|
|
259
|
+
const desiredDigest = desiredResourceDigest(context.desired);
|
|
260
|
+
if (desiredDigest === undefined) {
|
|
261
|
+
return [unresolvedAgentTask(context, `Resolve ${context.resource.kind} ${context.resource.id}`)];
|
|
262
|
+
}
|
|
263
|
+
if (observedMatchesDesired(context.desired, context.observed))
|
|
264
|
+
return [noOp()];
|
|
265
|
+
if (context.desired.kind === "directory" || context.desired.kind === "skill") {
|
|
266
|
+
return [replaceDirectory(context, context.desired)];
|
|
267
|
+
}
|
|
268
|
+
return [
|
|
269
|
+
writeFile(context, desiredDigest, context.desired.kind === "file" ? context.desired.executable : undefined),
|
|
270
|
+
];
|
|
271
|
+
};
|
|
272
|
+
const planMerge = (context) => {
|
|
273
|
+
if (context.desired.kind !== "config") {
|
|
274
|
+
const desiredDigest = desiredResourceDigest(context.desired);
|
|
275
|
+
if (desiredDigest !== undefined
|
|
276
|
+
&& observedMatchesDesired(context.desired, context.observed))
|
|
277
|
+
return [noOp()];
|
|
278
|
+
return [{
|
|
279
|
+
kind: "write-config",
|
|
280
|
+
detail: {
|
|
281
|
+
kind: "write-config",
|
|
282
|
+
target: context.resource.target,
|
|
283
|
+
keys: [],
|
|
284
|
+
},
|
|
285
|
+
}];
|
|
286
|
+
}
|
|
287
|
+
const conflicts = context.desired.keys.filter((key) => context.overlayKeys.includes(key));
|
|
288
|
+
if (conflicts.length > 0) {
|
|
289
|
+
const keys = sortedUnique(conflicts);
|
|
290
|
+
return [{
|
|
291
|
+
kind: "human-action",
|
|
292
|
+
detail: {
|
|
293
|
+
kind: "human-action",
|
|
294
|
+
reason: `Local Overlay conflicts with canonical keys: ${keys.join(", ")}`,
|
|
295
|
+
instructions: `Remove or rename the conflicting Local Overlay keys for ${context.resource.id}, then rerun synchronization.`,
|
|
296
|
+
},
|
|
297
|
+
}];
|
|
298
|
+
}
|
|
299
|
+
if (observedMatchesDesired(context.desired, context.observed))
|
|
300
|
+
return [noOp()];
|
|
301
|
+
return [{
|
|
302
|
+
kind: "write-config",
|
|
303
|
+
detail: {
|
|
304
|
+
kind: "write-config",
|
|
305
|
+
target: context.resource.target,
|
|
306
|
+
keys: sortedUnique(context.desired.keys),
|
|
307
|
+
},
|
|
308
|
+
}];
|
|
309
|
+
};
|
|
310
|
+
const planMirror = (context) => {
|
|
311
|
+
const desiredFiles = context.desired.kind === "directory" || context.desired.kind === "skill"
|
|
312
|
+
? context.desired.files
|
|
313
|
+
: [{ path: context.resource.target, digest: desiredResourceDigest(context.desired) }];
|
|
314
|
+
const observedFiles = context.observed.state === "directory"
|
|
315
|
+
? presentObservedFiles(context.observed.files)
|
|
316
|
+
: [];
|
|
317
|
+
const currentByPath = new Map(observedFiles.map((file) => [
|
|
318
|
+
file.path,
|
|
319
|
+
`${file.digest}\0${file.executable === true ? "x" : "-"}`,
|
|
320
|
+
]));
|
|
321
|
+
const desiredPaths = new Set(desiredFiles.map((file) => file.path));
|
|
322
|
+
const ownedByPath = new Map((context.applied?.ownedFiles ?? []).map((file) => [
|
|
323
|
+
file.path,
|
|
324
|
+
`${file.digest}\0${file.executable === true ? "x" : "-"}`,
|
|
325
|
+
]));
|
|
326
|
+
const adds = desiredFiles
|
|
327
|
+
.filter((file) => file.digest !== undefined
|
|
328
|
+
&& currentByPath.get(file.path)
|
|
329
|
+
!== `${file.digest}\0${"executable" in file && file.executable ? "x" : "-"}`)
|
|
330
|
+
.map((file) => file.path)
|
|
331
|
+
.sort(compareText);
|
|
332
|
+
const removes = observedFiles
|
|
333
|
+
.filter((file) => !desiredPaths.has(file.path))
|
|
334
|
+
.filter((file) => ownedByPath.get(file.path)
|
|
335
|
+
=== `${file.digest}\0${file.executable === true ? "x" : "-"}`)
|
|
336
|
+
.map((file) => file.path)
|
|
337
|
+
.sort(compareText);
|
|
338
|
+
if (adds.length === 0
|
|
339
|
+
&& removes.length === 0
|
|
340
|
+
&& context.observed.state !== "absent") {
|
|
341
|
+
return [noOp()];
|
|
342
|
+
}
|
|
343
|
+
return [{
|
|
344
|
+
kind: "mirror-directory",
|
|
345
|
+
detail: {
|
|
346
|
+
kind: "mirror-directory",
|
|
347
|
+
target: context.resource.target,
|
|
348
|
+
adds,
|
|
349
|
+
removes,
|
|
350
|
+
},
|
|
351
|
+
}];
|
|
352
|
+
};
|
|
353
|
+
const planReplaceIfUnmodified = (context) => {
|
|
354
|
+
const desiredDigest = desiredResourceDigest(context.desired);
|
|
355
|
+
if (desiredDigest === undefined) {
|
|
356
|
+
return [unresolvedAgentTask(context, `Resolve ${context.resource.kind} ${context.resource.id}`)];
|
|
357
|
+
}
|
|
358
|
+
if (context.observed.state === "unverifiable"
|
|
359
|
+
|| (context.applied !== undefined
|
|
360
|
+
&& context.observed.state === "absent")) {
|
|
361
|
+
return [{
|
|
362
|
+
kind: "human-action",
|
|
363
|
+
detail: {
|
|
364
|
+
kind: "human-action",
|
|
365
|
+
reason: `Previously applied ${context.resource.kind} ${context.resource.id} is missing or unverifiable`,
|
|
366
|
+
instructions: `Restore ${context.resource.target} or explicitly review and remove the local change, then rerun synchronization. Canonfig will not rewrite this target under replace-if-unmodified.`,
|
|
367
|
+
},
|
|
368
|
+
}];
|
|
369
|
+
}
|
|
370
|
+
const currentDigest = observedDigest(context);
|
|
371
|
+
const observedStateMatchesDesired = observedMatchesDesired(context.desired, context.observed)
|
|
372
|
+
|| (context.desired.kind === "skill"
|
|
373
|
+
&& context.observed.state === "present"
|
|
374
|
+
&& context.observed.objectKind === undefined);
|
|
375
|
+
const drift = detectSkillDrift({
|
|
376
|
+
desiredDigest,
|
|
377
|
+
observedDigest: currentDigest,
|
|
378
|
+
lastAppliedDigest: context.applied === undefined
|
|
379
|
+
? undefined
|
|
380
|
+
: Schema.decodeUnknownSync(ContentDigestSchema)(context.applied.digest),
|
|
381
|
+
desiredExecutable: context.desired.kind === "file"
|
|
382
|
+
? context.desired.executable
|
|
383
|
+
: undefined,
|
|
384
|
+
observedExecutable: context.observed.state === "present"
|
|
385
|
+
? context.observed.executable
|
|
386
|
+
: undefined,
|
|
387
|
+
lastAppliedExecutable: context.applied?.executable,
|
|
388
|
+
});
|
|
389
|
+
switch (drift) {
|
|
390
|
+
case "unchanged":
|
|
391
|
+
case "converged":
|
|
392
|
+
return observedStateMatchesDesired
|
|
393
|
+
? [noOp()]
|
|
394
|
+
: [driftConflict(context, desiredDigest, currentDigest ?? sha256Hex("canonfig:unverifiable-observed-state"), context.desired.kind === "file" ? context.desired.executable : undefined, context.observed.state === "present"
|
|
395
|
+
? context.observed.executable
|
|
396
|
+
: undefined)];
|
|
397
|
+
case "remote-only":
|
|
398
|
+
if (context.desired.kind === "skill") {
|
|
399
|
+
return [replaceDirectory(context, context.desired)];
|
|
400
|
+
}
|
|
401
|
+
return [
|
|
402
|
+
writeFile(context, desiredDigest, context.desired.kind === "file" ? context.desired.executable : undefined),
|
|
403
|
+
];
|
|
404
|
+
case "local-only":
|
|
405
|
+
case "conflicting":
|
|
406
|
+
if (currentDigest === undefined) {
|
|
407
|
+
return [
|
|
408
|
+
writeFile(context, desiredDigest, context.desired.kind === "file" ? context.desired.executable : undefined),
|
|
409
|
+
];
|
|
410
|
+
}
|
|
411
|
+
return [driftConflict(context, desiredDigest, currentDigest, context.desired.kind === "file" ? context.desired.executable : undefined, context.observed.state === "present" ? context.observed.executable : undefined)];
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
const planEnsure = (context) => {
|
|
415
|
+
if (context.desired.kind !== "tool") {
|
|
416
|
+
throw new InvalidObservedStateError({
|
|
417
|
+
resource: context.resource.id,
|
|
418
|
+
kind: context.resource.kind,
|
|
419
|
+
observedState: context.observed.state,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
if (context.observed.state === "present")
|
|
423
|
+
return [noOp()];
|
|
424
|
+
if (context.observed.state === "unverifiable") {
|
|
425
|
+
return [unresolvedAgentTask(context, `Verify or install tool ${context.desired.toolId}`)];
|
|
426
|
+
}
|
|
427
|
+
const recipe = [...context.desired.recipes]
|
|
428
|
+
.filter((candidate) => candidate.platform === context.platform)
|
|
429
|
+
.sort((left, right) => compareText(`${left.method}\0${left.package}\0${left.version ?? ""}\0${JSON.stringify(left.source)}`, `${right.method}\0${right.package}\0${right.version ?? ""}\0${JSON.stringify(right.source)}`))[0];
|
|
430
|
+
if (recipe === undefined) {
|
|
431
|
+
return [unresolvedAgentTask(context, `Find an installation recipe for ${context.desired.toolId}`)];
|
|
432
|
+
}
|
|
433
|
+
if (isMissingAutomaticRecipeVersion(recipe)) {
|
|
434
|
+
return [{
|
|
435
|
+
kind: "human-action",
|
|
436
|
+
detail: {
|
|
437
|
+
kind: "human-action",
|
|
438
|
+
reason: `Installing ${context.desired.toolId} requires a deterministic ${recipe.method} version`,
|
|
439
|
+
instructions: `The reviewed ${recipe.method} recipe for ${recipe.package} has no exact version. Add a deterministic version to the profile, or install the tool manually, then rerun synchronization.`,
|
|
440
|
+
},
|
|
441
|
+
}];
|
|
442
|
+
}
|
|
443
|
+
if (recipe.method === "source") {
|
|
444
|
+
return [{
|
|
445
|
+
kind: "human-action",
|
|
446
|
+
detail: {
|
|
447
|
+
kind: "human-action",
|
|
448
|
+
reason: `Installing ${context.desired.toolId} from source requires Human Action Required`,
|
|
449
|
+
instructions: `Canonfig preserves the reviewed source recipe at revision ${recipe.version ?? "unknown"}, but it cannot automatically check out or build source code. Apply the bounded source build manually, then rerun synchronization.`,
|
|
450
|
+
},
|
|
451
|
+
}];
|
|
452
|
+
}
|
|
453
|
+
if (recipe.method === "cargo"
|
|
454
|
+
&& (recipe.buildPolicy?.mode ?? "scripts-disabled") === "scripts-disabled") {
|
|
455
|
+
return [{
|
|
456
|
+
kind: "human-action",
|
|
457
|
+
detail: {
|
|
458
|
+
kind: "human-action",
|
|
459
|
+
reason: `Installing ${context.desired.toolId} with Cargo requires Human Action Required`,
|
|
460
|
+
instructions: `Cargo may execute build.rs and procedural macros for ${recipe.package}, but Cargo has no disable-scripts mode. Review and apply this recipe with a separately bounded builder policy, then rerun synchronization.`,
|
|
461
|
+
},
|
|
462
|
+
}];
|
|
463
|
+
}
|
|
464
|
+
const sourceDetails = recipeSourceDetails(recipe.source);
|
|
465
|
+
if ((recipe.method === "npm" || recipe.method === "pnpm" || recipe.method === "bun")
|
|
466
|
+
&& sourceDetails.source?.startsWith("https://") === true
|
|
467
|
+
&& sourceDetails.integrity === undefined) {
|
|
468
|
+
return [{
|
|
469
|
+
kind: "human-action",
|
|
470
|
+
detail: {
|
|
471
|
+
kind: "human-action",
|
|
472
|
+
reason: `Installing ${context.desired.toolId} requires a reviewed npm artifact integrity`,
|
|
473
|
+
instructions: `The reviewed ${recipe.method} tarball for ${recipe.package}@${recipe.version ?? "the declared version"} has no supported sha256 or sha512 SRI value. Add a lockfile integrity value, or install the package manually and rerun synchronization.`,
|
|
474
|
+
},
|
|
475
|
+
}];
|
|
476
|
+
}
|
|
477
|
+
const detail = {
|
|
478
|
+
kind: "install-tool",
|
|
479
|
+
toolId: context.desired.toolId,
|
|
480
|
+
method: recipe.method,
|
|
481
|
+
package: recipe.package,
|
|
482
|
+
};
|
|
483
|
+
if (recipe.version !== undefined)
|
|
484
|
+
detail.version = recipe.version;
|
|
485
|
+
if (recipe.indexPolicy !== undefined)
|
|
486
|
+
detail.indexPolicy = recipe.indexPolicy;
|
|
487
|
+
if (recipe.source !== undefined)
|
|
488
|
+
detail.source = recipe.source;
|
|
489
|
+
if (recipe.buildPolicy !== undefined)
|
|
490
|
+
detail.buildPolicy = recipe.buildPolicy;
|
|
491
|
+
if (detail.buildPolicy?.mode === "required") {
|
|
492
|
+
return [{
|
|
493
|
+
kind: "human-action",
|
|
494
|
+
detail: {
|
|
495
|
+
kind: "human-action",
|
|
496
|
+
reason: `Installing ${context.desired.toolId} requires reviewed build hooks`,
|
|
497
|
+
instructions: `The ${recipe.method} recipe has a reviewed build policy, but Canonfig's current process executor cannot confine lifecycle descendants. Run the bounded build plan manually within its declared executable, path, origin, and capability bounds, then rerun synchronization.`,
|
|
498
|
+
},
|
|
499
|
+
}];
|
|
500
|
+
}
|
|
501
|
+
const actions = [{
|
|
502
|
+
kind: "install-tool",
|
|
503
|
+
detail,
|
|
504
|
+
}];
|
|
505
|
+
if (context.desired.loginRequired) {
|
|
506
|
+
actions.push({
|
|
507
|
+
kind: "human-action",
|
|
508
|
+
detail: {
|
|
509
|
+
kind: "human-action",
|
|
510
|
+
reason: `${context.desired.toolId} requires a local login`,
|
|
511
|
+
instructions: context.desired.loginInstructions ?? `Log in to ${context.desired.toolId}, then rerun synchronization.`,
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
return actions;
|
|
516
|
+
};
|
|
517
|
+
const planRequireLocal = (context) => {
|
|
518
|
+
if (context.desired.kind !== "credential") {
|
|
519
|
+
throw new InvalidObservedStateError({
|
|
520
|
+
resource: context.resource.id,
|
|
521
|
+
kind: context.resource.kind,
|
|
522
|
+
observedState: context.observed.state,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
if (context.observed.state === "present")
|
|
526
|
+
return [noOp()];
|
|
527
|
+
return [{
|
|
528
|
+
kind: "human-action",
|
|
529
|
+
detail: {
|
|
530
|
+
kind: "human-action",
|
|
531
|
+
reason: `Local credential ${context.desired.reference} is unavailable`,
|
|
532
|
+
instructions: context.desired.instructions,
|
|
533
|
+
},
|
|
534
|
+
}];
|
|
535
|
+
};
|
|
536
|
+
const planRemovedResource = (context) => {
|
|
537
|
+
const applied = context.applied;
|
|
538
|
+
if (applied === undefined
|
|
539
|
+
|| applied.kind !== context.resource.kind
|
|
540
|
+
|| applied.policy !== context.resource.policy
|
|
541
|
+
|| applied.target === undefined) {
|
|
542
|
+
return [];
|
|
543
|
+
}
|
|
544
|
+
if (!observedMatchesApplied(context))
|
|
545
|
+
return [];
|
|
546
|
+
switch (context.resource.kind) {
|
|
547
|
+
case "file":
|
|
548
|
+
if (context.resource.policy !== "replace"
|
|
549
|
+
&& context.resource.policy !== "replace-if-unmodified") {
|
|
550
|
+
return [];
|
|
551
|
+
}
|
|
552
|
+
return [{
|
|
553
|
+
kind: "remove-resource",
|
|
554
|
+
detail: {
|
|
555
|
+
kind: "remove-resource",
|
|
556
|
+
target: applied.target,
|
|
557
|
+
paths: [],
|
|
558
|
+
keys: [],
|
|
559
|
+
},
|
|
560
|
+
}];
|
|
561
|
+
case "config":
|
|
562
|
+
if (context.resource.policy === "replace") {
|
|
563
|
+
return [{
|
|
564
|
+
kind: "remove-resource",
|
|
565
|
+
detail: {
|
|
566
|
+
kind: "remove-resource",
|
|
567
|
+
target: applied.target,
|
|
568
|
+
paths: [],
|
|
569
|
+
keys: [],
|
|
570
|
+
},
|
|
571
|
+
}];
|
|
572
|
+
}
|
|
573
|
+
if (context.resource.policy !== "merge"
|
|
574
|
+
|| applied.ownedKeys === undefined
|
|
575
|
+
|| applied.configFormat === undefined) {
|
|
576
|
+
return [];
|
|
577
|
+
}
|
|
578
|
+
return [{
|
|
579
|
+
kind: "remove-resource",
|
|
580
|
+
detail: {
|
|
581
|
+
kind: "remove-resource",
|
|
582
|
+
target: applied.target,
|
|
583
|
+
paths: [],
|
|
584
|
+
keys: sortedUnique(applied.ownedKeys),
|
|
585
|
+
},
|
|
586
|
+
}];
|
|
587
|
+
case "directory":
|
|
588
|
+
case "skill":
|
|
589
|
+
if ((context.resource.policy !== "mirror-owned"
|
|
590
|
+
&& context.resource.policy !== "replace"
|
|
591
|
+
&& context.resource.policy !== "replace-if-unmodified")
|
|
592
|
+
|| applied.ownedFiles === undefined) {
|
|
593
|
+
return [];
|
|
594
|
+
}
|
|
595
|
+
return [{
|
|
596
|
+
kind: "remove-resource",
|
|
597
|
+
detail: {
|
|
598
|
+
kind: "remove-resource",
|
|
599
|
+
target: applied.target,
|
|
600
|
+
paths: sortedUnique(applied.ownedFiles.map((file) => file.path)),
|
|
601
|
+
keys: [],
|
|
602
|
+
},
|
|
603
|
+
}];
|
|
604
|
+
case "schedule":
|
|
605
|
+
if (context.resource.policy !== "replace" || applied.schedule === undefined) {
|
|
606
|
+
return [];
|
|
607
|
+
}
|
|
608
|
+
return [{
|
|
609
|
+
kind: "remove-resource",
|
|
610
|
+
detail: {
|
|
611
|
+
kind: "remove-resource",
|
|
612
|
+
target: applied.target,
|
|
613
|
+
paths: [],
|
|
614
|
+
keys: [],
|
|
615
|
+
schedule: applied.schedule,
|
|
616
|
+
},
|
|
617
|
+
}];
|
|
618
|
+
case "tool":
|
|
619
|
+
case "credential":
|
|
620
|
+
// Ensure and require-local intentionally do not claim ownership of
|
|
621
|
+
// follower software or secrets, so removal is never automatic.
|
|
622
|
+
return [];
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
/** Exhaustive Apply Policy dispatch. Transfer planning is intentionally absent. */
|
|
626
|
+
export const planResource = (context) => {
|
|
627
|
+
const rootConflict = directoryRootConflict(context);
|
|
628
|
+
if (rootConflict !== undefined)
|
|
629
|
+
return [rootConflict];
|
|
630
|
+
switch (context.resource.policy) {
|
|
631
|
+
case "replace":
|
|
632
|
+
return planReplace(context);
|
|
633
|
+
case "mirror-owned":
|
|
634
|
+
return planMirror(context);
|
|
635
|
+
case "merge":
|
|
636
|
+
return planMerge(context);
|
|
637
|
+
case "replace-if-unmodified":
|
|
638
|
+
return planReplaceIfUnmodified(context);
|
|
639
|
+
case "ensure":
|
|
640
|
+
return planEnsure(context);
|
|
641
|
+
case "require-local":
|
|
642
|
+
return planRequireLocal(context);
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
export const planRemoved = planRemovedResource;
|