@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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -0
  3. package/dist/agent/agent-resolution.errors.js +42 -0
  4. package/dist/agent/agent-resolution.layer.js +204 -0
  5. package/dist/agent/agent-resolution.service.js +2259 -0
  6. package/dist/agent/agent-resolution.types.js +1 -0
  7. package/dist/agent/controlled-executor.js +704 -0
  8. package/dist/agent/harness-adapters.js +85 -0
  9. package/dist/cli/cli.js +618 -0
  10. package/dist/cli/exit-codes.js +28 -0
  11. package/dist/cli/follower-commands.js +3 -0
  12. package/dist/cli/render.js +56 -0
  13. package/dist/cli/source-commands.js +5 -0
  14. package/dist/domain/brand.js +29 -0
  15. package/dist/domain/identity.js +31 -0
  16. package/dist/domain/npm-package-spec.js +186 -0
  17. package/dist/domain/profile.js +950 -0
  18. package/dist/domain/recipe-versions.js +297 -0
  19. package/dist/domain/resource.js +259 -0
  20. package/dist/domain/synchronization.js +346 -0
  21. package/dist/enrollment/enrollment.errors.js +43 -0
  22. package/dist/enrollment/enrollment.layer.js +724 -0
  23. package/dist/enrollment/enrollment.service.js +3 -0
  24. package/dist/enrollment/enrollment.types.js +59 -0
  25. package/dist/enrollment/follower-client.js +585 -0
  26. package/dist/enrollment/source-server.js +313 -0
  27. package/dist/machine/linux.layer.js +1183 -0
  28. package/dist/machine/machine-state.errors.js +52 -0
  29. package/dist/machine/machine-state.service.js +3 -0
  30. package/dist/machine/machine-state.types.js +1 -0
  31. package/dist/machine/macos.layer.js +470 -0
  32. package/dist/machine/windows.layer.js +879 -0
  33. package/dist/profile/discovery.js +740 -0
  34. package/dist/profile/profile-catalog.errors.js +50 -0
  35. package/dist/profile/profile-catalog.layer.js +20 -0
  36. package/dist/profile/profile-catalog.service.js +7 -0
  37. package/dist/profile/profile-codec.js +153 -0
  38. package/dist/profile/publication.js +298 -0
  39. package/dist/profile/tool-catalog.js +384 -0
  40. package/dist/runtime/doctor.js +306 -0
  41. package/dist/runtime/layers.js +706 -0
  42. package/dist/runtime/main.js +38 -0
  43. package/dist/schedule/linux-schedule.js +24 -0
  44. package/dist/schedule/macos-schedule.js +25 -0
  45. package/dist/schedule/schedule-manager.errors.js +17 -0
  46. package/dist/schedule/schedule-manager.layer.js +205 -0
  47. package/dist/schedule/schedule-manager.service.js +3 -0
  48. package/dist/schedule/schedule-manager.types.js +114 -0
  49. package/dist/schedule/windows-schedule.js +25 -0
  50. package/dist/state/state-repository.errors.js +55 -0
  51. package/dist/state/state-repository.layer.js +1507 -0
  52. package/dist/state/state-repository.service.js +3 -0
  53. package/dist/state/state-repository.types.js +1 -0
  54. package/dist/state/state-schema.js +298 -0
  55. package/dist/synchronization/config-codec.js +97 -0
  56. package/dist/synchronization/executor.js +700 -0
  57. package/dist/synchronization/follower-orchestration.js +939 -0
  58. package/dist/synchronization/follower-sync-config.js +81 -0
  59. package/dist/synchronization/npm-artifact.js +670 -0
  60. package/dist/synchronization/planner.js +378 -0
  61. package/dist/synchronization/recovery.js +397 -0
  62. package/dist/synchronization/resource-executors.js +1198 -0
  63. package/dist/synchronization/resource-plans.js +645 -0
  64. package/dist/synchronization/synchronization.errors.js +102 -0
  65. package/dist/synchronization/synchronization.layer.js +97 -0
  66. package/dist/synchronization/synchronization.service.js +11 -0
  67. package/dist/synchronization/synchronization.types.js +1 -0
  68. package/package.json +66 -0
@@ -0,0 +1,700 @@
1
+ import { Clock, Effect, Option, Schema } from "effect";
2
+ import { ContentDigest, FollowerId, ProfileRevisionId, ResourceId, } from "../domain/brand.js";
3
+ import { MachineState } from "../machine/machine-state.service.js";
4
+ import { canonicalJson, sha256Hex } from "../profile/profile-codec.js";
5
+ import { desiredResourceDigest } from "./resource-plans.js";
6
+ import { prepareResourceAction, verifyResource, } from "./resource-executors.js";
7
+ import { StateRepository } from "../state/state-repository.service.js";
8
+ import { ScheduleManager } from "../schedule/schedule-manager.service.js";
9
+ import { InvalidExecutionPlanError, MissingExecutionResourceError, } from "./synchronization.errors.js";
10
+ export const defaultSynchronizationExecutionLimits = {
11
+ maximumFileBytes: 16 * 1024 * 1024,
12
+ processTimeoutMilliseconds: 10 * 60 * 1000,
13
+ maximumProcessOutputBytes: 1024 * 1024,
14
+ verificationConcurrency: 4,
15
+ };
16
+ const profileScheduleResourceId = Schema.decodeUnknownSync(ResourceId)("canonfig.schedule-default");
17
+ const profileScheduleResource = {
18
+ id: profileScheduleResourceId,
19
+ kind: "schedule",
20
+ policy: "replace",
21
+ target: "canonfig.schedule-default",
22
+ dependsOn: [],
23
+ blobs: [],
24
+ };
25
+ const profileScheduleDesired = (schedule) => ({
26
+ kind: "schedule",
27
+ digest: Schema.decodeUnknownSync(ContentDigest)(sha256Hex(canonicalJson(Schema.decodeUnknownSync(Schema.MutableJson)(JSON.parse(JSON.stringify(schedule)))))),
28
+ schedule,
29
+ });
30
+ const SchedulerSnapshotSchema = Schema.Union([
31
+ Schema.Struct({
32
+ state: Schema.Literal("absent"),
33
+ platform: Schema.Literals(["linux", "macos", "windows"]),
34
+ mechanism: Schema.Literals([
35
+ "systemd-user-timer",
36
+ "launchd-user-agent",
37
+ "task-scheduler",
38
+ ]),
39
+ serviceName: Schema.NonEmptyString,
40
+ }),
41
+ Schema.Struct({
42
+ state: Schema.Literal("present"),
43
+ platform: Schema.Literals(["linux", "macos", "windows"]),
44
+ mechanism: Schema.Literals([
45
+ "systemd-user-timer",
46
+ "launchd-user-agent",
47
+ "task-scheduler",
48
+ ]),
49
+ serviceName: Schema.NonEmptyString,
50
+ enabled: Schema.Boolean,
51
+ active: Schema.optional(Schema.Boolean),
52
+ servicePresent: Schema.Boolean,
53
+ schedulePresent: Schema.Boolean,
54
+ service: Schema.optional(Schema.String),
55
+ schedule: Schema.optional(Schema.String),
56
+ serviceMode: Schema.optional(Schema.Int),
57
+ scheduleMode: Schema.optional(Schema.Int),
58
+ native: Schema.optional(Schema.String),
59
+ }),
60
+ ]);
61
+ const scheduleRollbackReference = (context) => Effect.gen(function* () {
62
+ const machine = yield* MachineState;
63
+ const directories = yield* machine.userDirectories();
64
+ const directory = yield* machine.normalizePath({
65
+ path: `canonfig/rollback/${context.run}`,
66
+ base: directories.cache,
67
+ });
68
+ yield* machine.ensureDirectory({ path: directory });
69
+ return (yield* machine.normalizePath({
70
+ path: `${sha256Hex(context.action.id)}.schedule.json`,
71
+ base: directory,
72
+ })).absolute;
73
+ });
74
+ const captureScheduleRollback = (context, scheduleManager) => Effect.gen(function* () {
75
+ const reference = yield* scheduleRollbackReference(context);
76
+ const snapshot = yield* scheduleManager.snapshot({
77
+ schedule: context.action.detail.kind === "schedule-default"
78
+ ? context.action.detail.schedule
79
+ : undefined,
80
+ });
81
+ const machine = yield* MachineState;
82
+ yield* machine.atomicWrite({
83
+ path: yield* machine.normalizePath({ path: reference }),
84
+ content: new TextEncoder().encode(JSON.stringify(snapshot)),
85
+ });
86
+ return {
87
+ reference,
88
+ snapshot,
89
+ restore: scheduleManager.restore({
90
+ schedule: context.action.detail.kind === "schedule-default"
91
+ ? context.action.detail.schedule
92
+ : undefined,
93
+ }, snapshot),
94
+ };
95
+ });
96
+ export const restoreScheduleRollbackReference = (context, reference, scheduleManager) => Effect.gen(function* () {
97
+ const expected = yield* scheduleRollbackReference(context);
98
+ const machine = yield* MachineState;
99
+ const actual = yield* machine.normalizePath({ path: reference });
100
+ if (actual.absolute !== expected) {
101
+ return yield* new InvalidExecutionPlanError({
102
+ message: `schedule rollback reference does not belong to action ${context.action.id}`,
103
+ });
104
+ }
105
+ const bytes = yield* machine.readFile({
106
+ path: actual,
107
+ maximumBytes: 16 * 1024 * 1024,
108
+ });
109
+ const snapshot = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SchedulerSnapshotSchema))(new TextDecoder().decode(bytes)).pipe(Effect.mapError((error) => new InvalidExecutionPlanError({
110
+ message: `invalid schedule rollback material for action ${context.action.id}: ${String(error)}`,
111
+ })));
112
+ if (context.action.detail.kind !== "schedule-default") {
113
+ return yield* new InvalidExecutionPlanError({
114
+ message: `schedule rollback reference targets a non-schedule action ${context.action.id}`,
115
+ });
116
+ }
117
+ yield* scheduleManager.restore({ schedule: context.action.detail.schedule }, snapshot);
118
+ });
119
+ const now = () => Effect.map(Clock.currentTimeMillis, (milliseconds) => new Date(milliseconds).toISOString());
120
+ const redact = (value, secrets) => {
121
+ let message = value instanceof Error
122
+ ? value.message || value.constructor.name
123
+ : String(value);
124
+ for (const secret of secrets) {
125
+ if (secret.length > 0)
126
+ message = message.replaceAll(secret, "[REDACTED]");
127
+ }
128
+ return message.slice(0, 2048);
129
+ };
130
+ export const executionLimits = (input) => ({
131
+ ...defaultSynchronizationExecutionLimits,
132
+ ...input.limits,
133
+ });
134
+ /**
135
+ * Remove only the rollback files derived from this immutable run/action set.
136
+ * The exact paths make cleanup idempotent and prevent a terminal run from
137
+ * touching another run's material. Cleanup is intentionally separate from
138
+ * repository completion so a cleanup failure cannot erase the primary
139
+ * terminal outcome.
140
+ */
141
+ export const cleanupRollbackSnapshots = (run, actions) => Effect.gen(function* () {
142
+ const machine = yield* MachineState;
143
+ const directories = yield* machine.userDirectories();
144
+ const directory = yield* machine.normalizePath({
145
+ path: `canonfig/rollback/${run}`,
146
+ base: directories.cache,
147
+ });
148
+ const references = [...new Set(actions)].flatMap((action) => [
149
+ `${sha256Hex(action)}.json`,
150
+ `${sha256Hex(action)}.schedule.json`,
151
+ ]);
152
+ for (const reference of references) {
153
+ const path = yield* machine.normalizePath({
154
+ path: reference,
155
+ base: directory,
156
+ });
157
+ yield* machine.removeFile({ path }).pipe(Effect.catchTag("MachineFilesystemError", (error) => /\b(?:ENOENT|ENOTDIR)\b/u.test(error.message)
158
+ ? Effect.void
159
+ : Effect.fail(error)));
160
+ }
161
+ yield* machine.removeEmptyDirectory({ path: directory }).pipe(Effect.catchTag("MachineFilesystemError", (error) => /\b(?:ENOENT|ENOTDIR)\b/u.test(error.message)
162
+ ? Effect.void
163
+ : Effect.fail(error)));
164
+ });
165
+ const validateLimits = (limits) => {
166
+ if (!Number.isSafeInteger(limits.maximumFileBytes)
167
+ || limits.maximumFileBytes <= 0
168
+ || !Number.isSafeInteger(limits.processTimeoutMilliseconds)
169
+ || limits.processTimeoutMilliseconds <= 0
170
+ || !Number.isSafeInteger(limits.maximumProcessOutputBytes)
171
+ || limits.maximumProcessOutputBytes < 0
172
+ || !Number.isSafeInteger(limits.verificationConcurrency)
173
+ || limits.verificationConcurrency <= 0) {
174
+ return Effect.fail(new InvalidExecutionPlanError({
175
+ message: "execution limits must be positive safe integers",
176
+ }));
177
+ }
178
+ return Effect.void;
179
+ };
180
+ const verificationCompatibleWithDesired = (kind, desired, method) => {
181
+ if (kind === "file") {
182
+ return desired.kind === "file"
183
+ && (desired.symlinkTo === undefined ? method === "digest" : method === "symlink");
184
+ }
185
+ switch (kind) {
186
+ case "directory":
187
+ case "config":
188
+ case "skill":
189
+ return method === "digest" || method === "command";
190
+ case "tool":
191
+ return method === "executable-present" || method === "command";
192
+ case "credential":
193
+ return method === "credential-present" || method === "command";
194
+ case "schedule":
195
+ return method === "command";
196
+ }
197
+ };
198
+ export const executionContexts = (input, limits) => Effect.gen(function* () {
199
+ yield* validateLimits(limits);
200
+ const encodedBody = canonicalJson(Schema.decodeUnknownSync(Schema.MutableJson)(JSON.parse(JSON.stringify({
201
+ revision: input.plan.revision,
202
+ follower: input.plan.follower,
203
+ requiredBlobs: input.plan.requiredBlobs,
204
+ actions: input.plan.actions,
205
+ agentTasks: input.plan.agentTasks,
206
+ }))));
207
+ if (input.plan.revision !== input.revision.id
208
+ || input.plan.follower.length === 0
209
+ || input.plan.digest !== sha256Hex(input.plan.encoded)
210
+ || input.plan.encoded !== encodedBody) {
211
+ return yield* new InvalidExecutionPlanError({
212
+ message: "plan identity or digest does not match its hydrated content",
213
+ });
214
+ }
215
+ const resources = new Map(input.revision.resources.map((resource) => [
216
+ resource.id,
217
+ resource,
218
+ ]));
219
+ const desired = new Map(input.revision.desired.map((entry) => [
220
+ entry.resource,
221
+ entry,
222
+ ]));
223
+ const artifacts = new Map(input.artifacts.map((entry) => [
224
+ entry.digest,
225
+ entry,
226
+ ]));
227
+ const seen = new Set();
228
+ const completed = new Set();
229
+ const ordered = [];
230
+ const remaining = [...input.plan.actions];
231
+ while (remaining.length > 0) {
232
+ const index = remaining.findIndex((action) => action.before.every((dependency) => completed.has(dependency)));
233
+ if (index < 0) {
234
+ return yield* new InvalidExecutionPlanError({
235
+ message: "plan actions are cyclic or reference an unknown prerequisite",
236
+ });
237
+ }
238
+ const action = remaining.splice(index, 1)[0];
239
+ if (seen.has(action.id) || action.kind !== action.detail.kind) {
240
+ return yield* new InvalidExecutionPlanError({
241
+ message: `invalid or duplicate action ${action.id}`,
242
+ });
243
+ }
244
+ if (action.detail.kind === "schedule-default") {
245
+ if (action.resource !== profileScheduleResourceId) {
246
+ return yield* new InvalidExecutionPlanError({
247
+ message: `profile schedule action ${action.id} has an invalid resource`,
248
+ });
249
+ }
250
+ seen.add(action.id);
251
+ completed.add(action.id);
252
+ ordered.push({
253
+ action,
254
+ context: {
255
+ run: input.id,
256
+ action,
257
+ resource: profileScheduleResource,
258
+ desired: profileScheduleDesired(action.detail.schedule),
259
+ verification: { method: "command", command: [] },
260
+ artifacts,
261
+ limits,
262
+ previousSchedule: action.detail.previousSchedule,
263
+ },
264
+ });
265
+ continue;
266
+ }
267
+ const resource = resources.get(action.resource);
268
+ const desiredEntry = desired.get(action.resource);
269
+ if (resource === undefined || desiredEntry === undefined) {
270
+ return yield* new MissingExecutionResourceError({
271
+ resource: action.resource,
272
+ });
273
+ }
274
+ if (!verificationCompatibleWithDesired(resource.kind, desiredEntry.desired, desiredEntry.verification.method)) {
275
+ return yield* new InvalidExecutionPlanError({
276
+ message: `verification method ${desiredEntry.verification.method} is incompatible with ${resource.kind} resource ${resource.id}`,
277
+ });
278
+ }
279
+ if (action.detail.kind === "write-file"
280
+ && (desiredEntry.desired.kind === "file"
281
+ ? action.detail.executable !== desiredEntry.desired.executable
282
+ : action.detail.executable !== undefined)) {
283
+ return yield* new InvalidExecutionPlanError({
284
+ message: `write-file executable intent does not match ${resource.id}`,
285
+ });
286
+ }
287
+ seen.add(action.id);
288
+ completed.add(action.id);
289
+ ordered.push({
290
+ action,
291
+ context: {
292
+ run: input.id,
293
+ action,
294
+ resource,
295
+ desired: desiredEntry.desired,
296
+ verification: desiredEntry.verification,
297
+ artifacts,
298
+ limits,
299
+ previousSchedule: input.appliedResources?.find((record) => record.resource === action.resource)?.schedule,
300
+ },
301
+ });
302
+ }
303
+ return ordered;
304
+ });
305
+ const verificationEvidence = (result) => {
306
+ const base = {
307
+ status: result.passed ? "passed" : "failed",
308
+ method: result.method,
309
+ };
310
+ const withDigest = result.observedDigest === undefined
311
+ ? base
312
+ : {
313
+ ...base,
314
+ observedDigest: Schema.decodeUnknownSync(ContentDigest)(result.observedDigest),
315
+ };
316
+ return result.exitCode === undefined
317
+ ? withDigest
318
+ : { ...withDigest, exitCode: result.exitCode };
319
+ };
320
+ const journal = (run, action, state, verification, rollbackReference, attempt = 1, appliedResource, removedResource, removedResourceRecord) => Effect.gen(function* () {
321
+ const repository = yield* StateRepository;
322
+ const recordedAt = yield* now();
323
+ const base = {
324
+ run,
325
+ action,
326
+ state,
327
+ recordedAt,
328
+ attempt,
329
+ };
330
+ yield* repository.journalAction({
331
+ ...base,
332
+ verification,
333
+ rollbackReference,
334
+ appliedResource,
335
+ removedResource,
336
+ removedResourceRecord,
337
+ });
338
+ });
339
+ const rollbackPrepared = (prepared) => prepared?.rollback ?? Effect.void;
340
+ const appliedResourceFor = (input, state, appliedAt) => {
341
+ const desired = state.context.desired;
342
+ const digest = desiredResourceDigest(desired);
343
+ if (digest === undefined)
344
+ return undefined;
345
+ return {
346
+ resource: state.action.resource,
347
+ revision: input.revision.id,
348
+ digest,
349
+ appliedAt,
350
+ kind: state.context.resource.kind,
351
+ policy: state.context.resource.policy,
352
+ target: state.context.resource.target,
353
+ executable: desired.kind === "file" ? desired.executable : undefined,
354
+ symlinkTo: desired.kind === "file" ? desired.symlinkTo : undefined,
355
+ ownedFiles: desired.kind === "directory" || desired.kind === "skill"
356
+ ? desired.files.map((file) => ({
357
+ path: file.path,
358
+ digest: file.digest,
359
+ executable: file.executable,
360
+ }))
361
+ : undefined,
362
+ ownedKeys: desired.kind === "config" ? desired.keys : undefined,
363
+ configFormat: desired.kind === "config" ? desired.format : undefined,
364
+ schedule: desired.kind === "schedule" ? desired.schedule : undefined,
365
+ };
366
+ };
367
+ export const driftResult = (input, state) => {
368
+ const detail = state.action.detail;
369
+ if (detail.kind !== "drift-conflict") {
370
+ return { kind: "failed", reason: "invalid drift action" };
371
+ }
372
+ const previous = input.appliedResources?.find((record) => record.resource === state.action.resource);
373
+ return {
374
+ kind: "drift",
375
+ drift: {
376
+ resource: state.action.resource,
377
+ target: detail.target,
378
+ desiredDigest: detail.desiredDigest,
379
+ observedDigest: detail.observedDigest,
380
+ lastAppliedDigest: previous?.digest ?? detail.desiredDigest,
381
+ desiredExecutable: detail.desiredExecutable,
382
+ observedExecutable: detail.observedExecutable,
383
+ },
384
+ };
385
+ };
386
+ const executeScheduleDefaultAction = (input, state, attempt) => Effect.gen(function* () {
387
+ const detail = state.action.detail;
388
+ if (detail.kind !== "schedule-default") {
389
+ return {
390
+ kind: "failed",
391
+ reason: `invalid profile schedule action ${state.action.id}`,
392
+ };
393
+ }
394
+ const scheduleManager = Option.getOrUndefined(yield* Effect.serviceOption(ScheduleManager));
395
+ if (scheduleManager === undefined) {
396
+ yield* journal(input.id, state.action.id, "failed", { status: "not-run", method: "native-scheduler-unavailable" }, undefined, attempt).pipe(Effect.ignore);
397
+ return {
398
+ kind: "failed",
399
+ reason: "native scheduler is unavailable for the profile schedule default",
400
+ };
401
+ }
402
+ let rollback = Effect.void;
403
+ let rollbackReference;
404
+ const work = Effect.gen(function* () {
405
+ const captured = yield* captureScheduleRollback(state.context, scheduleManager);
406
+ rollback = captured.restore;
407
+ rollbackReference = captured.reference;
408
+ yield* journal(input.id, state.action.id, "running", undefined, rollbackReference, attempt);
409
+ if (detail.operation === "remove") {
410
+ yield* scheduleManager.remove({ schedule: detail.schedule });
411
+ }
412
+ else {
413
+ const change = yield* scheduleManager.update({ schedule: detail.schedule });
414
+ const verification = {
415
+ status: change.status.state === "current"
416
+ ? "passed"
417
+ : "failed",
418
+ method: `native-scheduler:${change.status.platform}`,
419
+ };
420
+ if (verification.status === "failed") {
421
+ yield* rollback.pipe(Effect.ignore);
422
+ yield* journal(input.id, state.action.id, "failed", verification, rollbackReference, attempt);
423
+ return {
424
+ kind: "failed",
425
+ reason: "native scheduler did not verify the profile schedule default",
426
+ };
427
+ }
428
+ }
429
+ const verification = {
430
+ status: "passed",
431
+ method: detail.operation === "remove"
432
+ ? "native-scheduler:removed"
433
+ : "native-scheduler:current",
434
+ };
435
+ yield* journal(input.id, state.action.id, "succeeded", verification, rollbackReference, attempt);
436
+ return { kind: "verified" };
437
+ });
438
+ return yield* work.pipe(Effect.onInterrupt(() => rollback.pipe(Effect.catch(() => Effect.void), Effect.andThen(journal(input.id, state.action.id, "failed", { status: "not-run", method: "interrupted" }, rollbackReference, attempt)), Effect.ignore)), Effect.catch((error) => rollback.pipe(Effect.catch(() => Effect.void), Effect.andThen(journal(input.id, state.action.id, "failed", { status: "not-run", method: "action-failed" }, rollbackReference, attempt)), Effect.ignore, Effect.andThen(Effect.succeed({
439
+ kind: "failed",
440
+ reason: `native scheduler mutation failed for profile schedule default: ${redact(error, input.knownSecrets ?? [])}`,
441
+ })))));
442
+ }).pipe(Effect.catch((error) => Effect.succeed({
443
+ kind: "failed",
444
+ reason: redact(error, input.knownSecrets ?? []),
445
+ })));
446
+ const agentActionResult = (input, state, attempt = 1) => Effect.gen(function* () {
447
+ const detail = state.action.detail;
448
+ if (detail.kind !== "agent-task") {
449
+ return { kind: "failed", reason: "invalid agent action" };
450
+ }
451
+ const task = input.plan.agentTasks.find((candidate) => candidate.id === detail.taskId);
452
+ const agent = input.agent;
453
+ const resolution = input.agentResolution;
454
+ if (task === undefined
455
+ || agent?.policy !== "agent-apply"
456
+ || agent.harness === undefined
457
+ || resolution === undefined) {
458
+ yield* journal(input.id, state.action.id, "skipped", undefined, undefined, attempt);
459
+ return {
460
+ kind: "human",
461
+ human: {
462
+ reason: `Bounded agent task requires an apply-authorized harness: ${detail.summary}`,
463
+ instructions: "Configure an agent-apply harness, or resolve the task manually, then rerun synchronization.",
464
+ resource: state.action.resource,
465
+ },
466
+ };
467
+ }
468
+ yield* journal(input.id, state.action.id, "running", undefined, undefined, attempt);
469
+ const outcome = yield* resolution.resolve({
470
+ policy: agent.policy,
471
+ task,
472
+ harness: agent.harness,
473
+ scheduled: agent.scheduled,
474
+ signal: agent.signal,
475
+ });
476
+ if (outcome.outcome !== "applied") {
477
+ yield* journal(input.id, state.action.id, "skipped", undefined, undefined, attempt);
478
+ return {
479
+ kind: "human",
480
+ human: {
481
+ reason: `Agent did not apply the requested task: ${detail.summary}`,
482
+ instructions: "Review the agent proposal or complete the task manually, then rerun synchronization.",
483
+ resource: state.action.resource,
484
+ },
485
+ };
486
+ }
487
+ const scheduleManager = state.context.resource.kind === "schedule"
488
+ ? Option.getOrUndefined(yield* Effect.serviceOption(ScheduleManager))
489
+ : undefined;
490
+ const verification = yield* verifyResource(state.context, scheduleManager);
491
+ const evidence = verificationEvidence(verification);
492
+ if (!verification.passed) {
493
+ yield* journal(input.id, state.action.id, "failed", evidence, undefined, attempt);
494
+ return {
495
+ kind: "failed",
496
+ reason: `verification failed for agent task ${state.action.resource}`,
497
+ };
498
+ }
499
+ yield* journal(input.id, state.action.id, "succeeded", evidence, undefined, attempt);
500
+ return {
501
+ kind: "verified",
502
+ resource: state.action.resource,
503
+ };
504
+ }).pipe(Effect.onInterrupt(() => journal(input.id, state.action.id, "failed", { status: "not-run", method: "interrupted" }, undefined, attempt).pipe(Effect.ignore)), Effect.catch((error) => journal(input.id, state.action.id, "failed", { status: "not-run", method: "action-failed" }, undefined, attempt).pipe(Effect.ignore, Effect.andThen(Effect.succeed({
505
+ kind: "failed",
506
+ reason: redact(error, input.knownSecrets ?? []),
507
+ })))));
508
+ export const executeSynchronizationAction = (input, state, attempt = 1) => Effect.gen(function* () {
509
+ const detail = state.action.detail;
510
+ if (detail.kind === "human-action") {
511
+ yield* journal(input.id, state.action.id, "skipped", undefined, undefined, attempt);
512
+ return {
513
+ kind: "human",
514
+ human: {
515
+ reason: detail.reason,
516
+ instructions: detail.instructions,
517
+ resource: state.action.resource,
518
+ },
519
+ };
520
+ }
521
+ if (detail.kind === "agent-task") {
522
+ return yield* agentActionResult(input, state, attempt);
523
+ }
524
+ if (detail.kind === "schedule-default") {
525
+ return yield* executeScheduleDefaultAction(input, state, attempt);
526
+ }
527
+ if (detail.kind === "drift-conflict") {
528
+ const result = driftResult(input, state);
529
+ if (result.drift !== undefined) {
530
+ const repository = yield* StateRepository;
531
+ yield* repository.recordDrift({
532
+ run: input.id,
533
+ conflict: result.drift,
534
+ recordedAt: yield* now(),
535
+ });
536
+ }
537
+ yield* journal(input.id, state.action.id, "skipped", undefined, undefined, attempt);
538
+ return result;
539
+ }
540
+ let prepared;
541
+ const scheduleManager = state.context.resource.kind === "schedule"
542
+ ? Option.getOrUndefined(yield* Effect.serviceOption(ScheduleManager))
543
+ : undefined;
544
+ const work = Effect.gen(function* () {
545
+ prepared = yield* prepareResourceAction(state.context, scheduleManager);
546
+ yield* journal(input.id, state.action.id, "running", undefined, prepared.rollbackReference, attempt);
547
+ yield* prepared.execute;
548
+ if (detail.kind === "transfer-blob") {
549
+ yield* journal(input.id, state.action.id, "succeeded", { status: "passed", method: "sha256-and-size" }, prepared.rollbackReference, attempt);
550
+ return { kind: "verified" };
551
+ }
552
+ if (detail.kind === "remove-resource") {
553
+ const removedResourceRecord = input.appliedResources?.find((record) => record.resource === state.action.resource);
554
+ yield* journal(input.id, state.action.id, "succeeded", { status: "passed", method: "owned-resource-removed" }, prepared.rollbackReference, attempt, undefined, state.action.resource, removedResourceRecord);
555
+ return {
556
+ kind: "verified",
557
+ resource: state.action.resource,
558
+ };
559
+ }
560
+ const verification = yield* verifyResource(state.context, scheduleManager);
561
+ const evidence = verificationEvidence(verification);
562
+ if (!verification.passed) {
563
+ yield* rollbackPrepared(prepared);
564
+ yield* journal(input.id, state.action.id, "failed", evidence, prepared.rollbackReference, attempt);
565
+ return {
566
+ kind: "failed",
567
+ reason: `verification failed for resource ${state.action.resource}`,
568
+ };
569
+ }
570
+ const appliedResource = appliedResourceFor(input, state, yield* now());
571
+ yield* journal(input.id, state.action.id, "succeeded", evidence, prepared.rollbackReference, attempt, appliedResource);
572
+ return {
573
+ kind: "verified",
574
+ resource: state.action.resource,
575
+ };
576
+ }).pipe(Effect.onInterrupt(() => rollbackPrepared(prepared).pipe(Effect.andThen(journal(input.id, state.action.id, "failed", { status: "not-run", method: "interrupted" }, prepared?.rollbackReference, attempt)), Effect.ignore)));
577
+ return yield* work.pipe(Effect.catch((error) => rollbackPrepared(prepared).pipe(Effect.andThen(journal(input.id, state.action.id, "failed", { status: "not-run", method: "action-failed" }, prepared?.rollbackReference, attempt)), Effect.as({
578
+ kind: "failed",
579
+ reason: redact(error, input.knownSecrets ?? []),
580
+ }), Effect.catch((journalError) => Effect.succeed({
581
+ kind: "failed",
582
+ reason: redact(journalError, input.knownSecrets ?? []),
583
+ })))));
584
+ }).pipe(Effect.catch((error) => Effect.succeed({
585
+ kind: "failed",
586
+ reason: redact(error, input.knownSecrets ?? []),
587
+ })));
588
+ const completeSkipped = (input, states) => Effect.forEach(states, (state) => journal(input.id, state.action.id, "skipped").pipe(Effect.ignore), { discard: true });
589
+ /** Execute one already-recorded plan. The caller owns startRun ordering. */
590
+ export const executeSynchronizationPlan = (input) => Effect.gen(function* () {
591
+ const states = yield* executionContexts(input, executionLimits(input));
592
+ const completedActions = [];
593
+ const verified = new Set();
594
+ const applied = [];
595
+ const human = [];
596
+ const drift = [];
597
+ const removedResources = new Set();
598
+ let failedReason;
599
+ const runActions = Effect.gen(function* () {
600
+ for (let index = 0; index < states.length; index += 1) {
601
+ const state = states[index];
602
+ const result = yield* executeSynchronizationAction(input, state);
603
+ completedActions.push(state.action.id);
604
+ if (result.resource !== undefined)
605
+ verified.add(result.resource);
606
+ if (result.resource !== undefined
607
+ && input.revision.removedResources?.includes(result.resource) === true) {
608
+ removedResources.add(result.resource);
609
+ }
610
+ if (result.human !== undefined)
611
+ human.push(result.human);
612
+ if (result.drift !== undefined)
613
+ drift.push(result.drift);
614
+ if (result.reason !== undefined)
615
+ failedReason = result.reason;
616
+ if (result.kind !== "verified") {
617
+ yield* completeSkipped(input, states.slice(index + 1));
618
+ break;
619
+ }
620
+ }
621
+ }).pipe(Effect.onInterrupt(() => Effect.gen(function* () {
622
+ const repository = yield* StateRepository;
623
+ const outcome = {
624
+ outcome: "Interrupted",
625
+ run: input.id,
626
+ completedActions,
627
+ };
628
+ yield* repository.completeRun({
629
+ run: input.id,
630
+ completedAt: yield* now(),
631
+ outcome,
632
+ appliedResources: [],
633
+ removedResources: [],
634
+ });
635
+ }).pipe(Effect.ignore)));
636
+ yield* runActions;
637
+ const outcome = failedReason !== undefined
638
+ ? { outcome: "Failed", run: input.id, reason: failedReason }
639
+ : drift.length > 0
640
+ ? { outcome: "FollowerDrift", run: input.id, conflicts: drift }
641
+ : human.length > 0
642
+ ? { outcome: "HumanActionRequired", run: input.id, actions: human }
643
+ : {
644
+ outcome: "Converged",
645
+ run: input.id,
646
+ verified: [...verified].sort(),
647
+ };
648
+ if (outcome.outcome === "Converged") {
649
+ const appliedAt = yield* now();
650
+ const desiredByResource = new Map(input.revision.desired.map((entry) => [
651
+ entry.resource,
652
+ entry.desired,
653
+ ]));
654
+ const resourceById = new Map(input.revision.resources.map((resource) => [
655
+ resource.id,
656
+ resource,
657
+ ]));
658
+ for (const resource of outcome.verified) {
659
+ if (removedResources.has(resource))
660
+ continue;
661
+ const desired = desiredByResource.get(resource);
662
+ const digest = desired === undefined ? undefined : desiredResourceDigest(desired);
663
+ if (digest !== undefined) {
664
+ const ownedFiles = desired?.kind === "directory" || desired?.kind === "skill"
665
+ ? desired.files.map((file) => ({
666
+ path: file.path,
667
+ digest: file.digest,
668
+ executable: file.executable,
669
+ }))
670
+ : undefined;
671
+ applied.push({
672
+ resource,
673
+ revision: input.revision.id,
674
+ digest,
675
+ appliedAt,
676
+ kind: resourceById.get(resource)?.kind,
677
+ policy: resourceById.get(resource)?.policy,
678
+ target: resourceById.get(resource)?.target,
679
+ executable: desired?.kind === "file" ? desired.executable : undefined,
680
+ symlinkTo: desired?.kind === "file" ? desired.symlinkTo : undefined,
681
+ ownedFiles,
682
+ ownedKeys: desired?.kind === "config" ? desired.keys : undefined,
683
+ configFormat: desired?.kind === "config" ? desired.format : undefined,
684
+ schedule: desired?.kind === "schedule"
685
+ ? desired.schedule
686
+ : undefined,
687
+ });
688
+ }
689
+ }
690
+ }
691
+ return {
692
+ outcome,
693
+ appliedResources: applied,
694
+ removedResources: outcome.outcome === "Converged"
695
+ ? [...removedResources].sort()
696
+ : [],
697
+ };
698
+ });
699
+ export const executionFollower = (input) => Schema.decodeUnknownEffect(FollowerId)(input.plan.follower).pipe(Effect.mapError((error) => new InvalidExecutionPlanError({ message: String(error) })));
700
+ export const executionRevision = (input) => Schema.decodeUnknownEffect(ProfileRevisionId)(input.plan.revision).pipe(Effect.mapError((error) => new InvalidExecutionPlanError({ message: String(error) })));