@hivemagnus/magnus-agent 1.1.7 → 1.1.8

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/dist/agent.js CHANGED
@@ -7,6 +7,7 @@ import { controlApiFetch } from './tls.js';
7
7
  import { startSpan, formatTraceparent } from './collectors/otel-collector.js';
8
8
  import { collectHermesState } from './executors/hermes-manager.js';
9
9
  import { existsSync } from 'node:fs';
10
+ import { heartbeatDrift } from './upgrade/preflight.js';
10
11
  import { confirmTrial, controlApiUnreachable, recordTrialAlive, selfUpgradeStatus, trialOf } from './upgrade/self-upgrade.js';
11
12
  import { commandLedger } from './executors/command-executor.js';
12
13
  const HEARTBEAT_INTERVAL_MS = 30_000; // 30s
@@ -77,8 +78,11 @@ async function collectHealthWithDeadline() {
77
78
  }
78
79
  /** Self-upgrade status for the heartbeat, with the sticky local enrollment marker. */
79
80
  async function heartbeatSelfUpgrade() {
80
- const enrollment = await commandLedger.managedEnrollment().catch(() => null);
81
- return { ...selfUpgradeStatus(AGENT_VERSION), managedEnrollment: enrollment ? { nodeId: enrollment.nodeId, nodeFqdn: enrollment.nodeFqdn } : null };
81
+ const marker = await commandLedger.enrollmentMarker();
82
+ if (marker.error)
83
+ logger.warn(`Local enrollment marker could not be read: ${marker.error}`);
84
+ return { ...selfUpgradeStatus(AGENT_VERSION), ...heartbeatDrift(commandLedger),
85
+ managedEnrollment: marker.marker ? { nodeId: marker.marker.nodeId, nodeFqdn: marker.marker.nodeFqdn } : null, managedEnrollmentState: marker.state };
82
86
  }
83
87
  /** Stop the heartbeat loop. */
84
88
  export function stopHeartbeatLoop() {
@@ -2,14 +2,16 @@ import { managedExecution } from '../security/managed-context.js';
2
2
  import { Hono } from 'hono';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { z } from 'zod';
5
- import { RESULT_SIGNING_SECRET, MAGNUS_EXECUTION_SUPERVISOR_ENABLED } from '../env.js';
5
+ import { HOST_FQDN, RESULT_SIGNING_SECRET, MAGNUS_EXECUTION_SUPERVISOR_ENABLED } from '../env.js';
6
6
  import { startSpan } from '../collectors/otel-collector.js';
7
- import { CommandLedger, CommandAdmissionError, commandFenceSchema, commandReconciliationSchema } from './command-ledger.js';
8
- import { signCommandResult } from '../security/command-result-signature.js';
7
+ import { admissionCause, CommandLedger, CommandAdmissionError, commandFenceSchema, commandReconciliationSchema, commandRequestDigest } from './command-ledger.js';
8
+ import { commandAbsenceSigningPayload, signCommandEvidence, signCommandResult } from '../security/command-result-signature.js';
9
+ import { refusalError, refusalStatus } from './command-refusal.js';
9
10
  import { ExecutionSupervisor } from './execution-supervisor.js';
10
11
  import { mutationBarrier } from './mutation-barrier.js';
11
12
  import { COMMAND_COMPLETION_REGISTRY, COMMAND_COMPLETION_REGISTRY_SHA256, commandCompletionDescriptor } from '../security/command-completion-registry.js';
12
13
  import { childEnvironment } from '../utils/child-env.js';
14
+ import { logger } from '../utils/logger.js';
13
15
  export const commandRoutes = new Hono();
14
16
  export const commandLedger = new CommandLedger();
15
17
  export const executionSupervisor = new ExecutionSupervisor();
@@ -119,7 +121,8 @@ commandRoutes.post('/execute', async (c) => {
119
121
  catch (error) {
120
122
  if (!(error instanceof CommandAdmissionError))
121
123
  throw error;
122
- return c.json({ success: false, error: { code: error.code, message: error.message } }, error.code === 'LEDGER_UNAVAILABLE' ? 503 : 409);
124
+ logger.warn(`Command ${parsed.data.commandId} refused: ${error.code}: ${error.message}${admissionCause(error)}`);
125
+ return c.json({ success: false, error: await refusalError({ commandId: parsed.data.commandId, requestSha256: commandRequestDigest(parsed.data), leaseId: managedExecution.getStore()?.claim?.leaseId }, error.code, error.message, { notStarted: error.notStarted, ...(error.highWater ? { highWater: error.highWater } : {}) }) }, refusalStatus(error.code));
123
126
  }
124
127
  });
125
128
  /**
@@ -138,11 +141,11 @@ export async function executeCommand(input) {
138
141
  // Parse/snapshot even internal callers before awaiting or executing effects.
139
142
  const opts = executeBodySchema.parse(input);
140
143
  if (draining)
141
- throw new CommandAdmissionError('LEDGER_UNAVAILABLE', 'Node is draining; no command executed');
144
+ throw new CommandAdmissionError('LEDGER_UNAVAILABLE', 'Node is draining; no command executed', { notStarted: true });
142
145
  if (RESULT_SIGNING_SECRET.length < 32)
143
146
  throw new CommandAdmissionError('LEDGER_UNAVAILABLE', 'Command signing is unavailable; no command executed');
144
147
  if (opts.coordination && !MAGNUS_EXECUTION_SUPERVISOR_ENABLED)
145
- throw new CommandAdmissionError('LEDGER_UNAVAILABLE', 'Managed commands require the node execution supervisor; no command executed');
148
+ throw new CommandAdmissionError('LEDGER_UNAVAILABLE', 'Managed commands require the node execution supervisor; no command executed', { notStarted: true });
146
149
  const pending = commandLedger.execute(opts, (digest) => MAGNUS_EXECUTION_SUPERVISOR_ENABLED
147
150
  ? executeSupervised(opts, digest) : executeProcess(opts, digest), managedExecution.getStore()?.claim);
148
151
  pendingExecutions.add(pending);
@@ -293,10 +296,33 @@ commandRoutes.get('/receipts/:commandId', async (c) => {
293
296
  return c.json({ success: false, error: { code: 'NOT_FOUND', message: 'Command receipt not found' } }, 404);
294
297
  return c.json({ success: true, data: row });
295
298
  }
296
- catch {
299
+ catch (error) {
300
+ logger.warn(`Command receipt ${id} could not be read: ${error instanceof Error ? error.message : String(error)}`);
297
301
  return c.json({ success: false, error: { code: 'LEDGER_UNAVAILABLE', message: 'Command receipt unavailable; do not replay' } }, 503);
298
302
  }
299
303
  });
304
+ // Whether anything local refers to a command, for leases the server holds as
305
+ // `unknown`. Read only and signed with a nonce: it says what this ledger holds
306
+ // now, and the server decides what that proves.
307
+ commandRoutes.get('/receipts/:commandId/trace', async (c) => {
308
+ const id = c.req.param('commandId');
309
+ const nonce = c.req.query('nonce') ?? '';
310
+ const resourceKey = c.req.query('resourceKey') ?? '';
311
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(id) || !/^[a-zA-Z0-9-]{16,64}$/.test(nonce) || !/^[a-zA-Z0-9][a-zA-Z0-9:._-]{0,199}$/.test(resourceKey))
312
+ return c.json({ success: false, error: { code: 'VALIDATION_ERROR', message: 'Invalid command identifier, nonce or resource key' } }, 400);
313
+ try {
314
+ const trace = await commandLedger.commandTrace(id, resourceKey);
315
+ if (trace.marker.error)
316
+ logger.warn(`Enrollment marker could not be read: ${trace.marker.error}`);
317
+ const unsigned = { commandId: id, nodeFqdn: HOST_FQDN, nonce, ledgerExists: trace.ledgerExists, receipt: trace.receipt, activeLock: trace.activeLock, resourceKey,
318
+ fenceHighWater: trace.fenceHighWater, markerState: trace.marker.state, markerNodeId: trace.marker.marker?.nodeId ?? null, admissionFailedFence: trace.admissionFailedFence };
319
+ return c.json({ success: true, data: { ...unsigned, signature: signCommandEvidence(commandAbsenceSigningPayload(unsigned), RESULT_SIGNING_SECRET) } });
320
+ }
321
+ catch (error) {
322
+ logger.warn(`Command trace ${id} could not be read: ${error instanceof Error ? error.message : String(error)}`);
323
+ return c.json({ success: false, error: { code: 'LEDGER_UNAVAILABLE', message: 'Command ledger unavailable' } }, 503);
324
+ }
325
+ });
300
326
  // Existing node authentication is necessary but insufficient: only the separate
301
327
  // service reconciliation key can attest that an operator reviewed the evidence.
302
328
  // This releases a receipt claim, never executes or authorizes remediation.
@@ -311,6 +337,7 @@ commandRoutes.post('/receipts/:commandId/reconcile', async (c) => {
311
337
  catch (error) {
312
338
  if (!(error instanceof CommandAdmissionError))
313
339
  throw error;
340
+ logger.warn(`Reconciliation of ${parsed.data.commandId} refused: ${error.code}: ${error.message}${admissionCause(error)}`);
314
341
  return c.json({ success: false, error: { code: error.code, message: error.message } }, error.code === 'LEDGER_UNAVAILABLE' ? 503 : error.code === 'INVALID_RECONCILIATION' ? 403 : 409);
315
342
  }
316
343
  });
@@ -55,9 +55,59 @@ export declare const commandReconciliationSchema: z.ZodObject<{
55
55
  expiresAt: z.ZodNumber;
56
56
  signature: z.ZodString;
57
57
  }, z.core.$strict>;
58
+ export type CommandAdmissionCode = 'RESOURCE_BUSY' | 'OUTCOME_UNKNOWN' | 'IDEMPOTENCY_CONFLICT' | 'STALE_FENCE' | 'LEASE_EXPIRED' | 'LEDGER_UNAVAILABLE' | 'INVALID_RECONCILIATION' | 'UNMANAGED_MUTATION' | 'RESOURCE_CLAIM_INVALID';
59
+ /** What went wrong underneath an admission error, for a WARN line. */
60
+ export declare const admissionCause: (error: CommandAdmissionError) => string;
58
61
  export declare class CommandAdmissionError extends Error {
59
- readonly code: 'RESOURCE_BUSY' | 'OUTCOME_UNKNOWN' | 'IDEMPOTENCY_CONFLICT' | 'STALE_FENCE' | 'LEASE_EXPIRED' | 'LEDGER_UNAVAILABLE' | 'INVALID_RECONCILIATION' | 'UNMANAGED_MUTATION';
60
- constructor(code: 'RESOURCE_BUSY' | 'OUTCOME_UNKNOWN' | 'IDEMPOTENCY_CONFLICT' | 'STALE_FENCE' | 'LEASE_EXPIRED' | 'LEDGER_UNAVAILABLE' | 'INVALID_RECONCILIATION' | 'UNMANAGED_MUTATION', message: string);
62
+ readonly code: CommandAdmissionCode;
63
+ /** The callback demonstrably never ran and this command id has no receipt: a certain refusal. */
64
+ notStarted: boolean;
65
+ /** STALE_FENCE: the fence this ledger already accepted for the key, so the server can move past it. */
66
+ readonly highWater?: {
67
+ resourceKey: string;
68
+ fence: string;
69
+ };
70
+ constructor(code: CommandAdmissionCode, message: string, details?: {
71
+ notStarted?: boolean;
72
+ highWater?: {
73
+ resourceKey: string;
74
+ fence: string;
75
+ };
76
+ cause?: unknown;
77
+ });
78
+ }
79
+ declare const enrollmentMarkerSchema: z.ZodObject<{
80
+ version: z.ZodLiteral<1>;
81
+ nodeId: z.ZodString;
82
+ nodeFqdn: z.ZodString;
83
+ }, z.core.$strict>;
84
+ export type EnrollmentMarker = {
85
+ state: 'absent' | 'present' | 'unknown';
86
+ marker: z.infer<typeof enrollmentMarkerSchema> | null;
87
+ error?: string;
88
+ };
89
+ export interface CommandTrace {
90
+ ledgerExists: boolean;
91
+ receipt: boolean;
92
+ activeLock: boolean;
93
+ fenceHighWater: string | null;
94
+ marker: EnrollmentMarker;
95
+ /** This request passed the fence, then a later write failed, and the callback did not start. */
96
+ admissionFailed: boolean;
97
+ admissionFailedFence: string | null;
98
+ }
99
+ /** Read-only view of the ledger for preflight checks and heartbeats. */
100
+ export interface LedgerHealth {
101
+ readable: boolean;
102
+ activeLock: {
103
+ commandId: string;
104
+ receipt: string;
105
+ } | null;
106
+ nonTerminalReceipts: number;
107
+ unresolvedReceipts: number;
108
+ truncated: boolean;
109
+ fenceHighWater: Record<string, string>;
110
+ error: string | null;
61
111
  }
62
112
  export declare function commandRequestDigest(input: CommandIdentity): string;
63
113
  export declare class CommandLedger {
@@ -82,6 +132,15 @@ export declare class CommandLedger {
82
132
  private read;
83
133
  private write;
84
134
  private receiptName;
135
+ private failedAdmissionName;
136
+ /**
137
+ * A write after the fence failed, so the fence is ahead with no receipt, and the
138
+ * callback did not start. Say so durably, or that state looks like a receipt that
139
+ * went missing. If not even this can be written, keep the claim instead: it is
140
+ * already on disk, needs no new space, and says the same (recoverClaim clears it).
141
+ * A receipt that did land must keep its claim too, or it can never be reconciled.
142
+ */
143
+ private keepEvidenceOfFailedAdmission;
85
144
  receipt(id: string): Promise<{
86
145
  state: string;
87
146
  version: 1;
@@ -309,8 +368,28 @@ export declare class CommandLedger {
309
368
  } | undefined;
310
369
  } | undefined;
311
370
  }>;
371
+ /**
372
+ * The sticky marker as it is, without creating anything. An unreadable marker
373
+ * is `unknown`, not absent: reporting nothing could hide a mismatch.
374
+ */
375
+ enrollmentMarker(): Promise<EnrollmentMarker>;
376
+ /**
377
+ * What this ledger holds about a command, read only and without the mutex. On its
378
+ * own "no receipt" proves nothing: a ledger that is gone holds nothing either. So
379
+ * this also says whether there is a ledger, what fence it last accepted for the
380
+ * key and what its marker says. Ordering in execute(): active.lock, fence, durable
381
+ * running receipt, and only then the callback, so a claim without a receipt
382
+ * never started.
383
+ */
384
+ commandTrace(id: string, resourceKey?: string): Promise<CommandTrace>;
385
+ /**
386
+ * Read-only health for preflight checks and heartbeats. Takes no mutex and
387
+ * creates nothing, so asking never competes with a running command.
388
+ */
389
+ health(): Promise<LedgerHealth>;
312
390
  /** Persist an authoritative observation; never clears an active/unknown claim. */
313
391
  observeManagedEnrollment(identity: Pick<ManagedClaim, 'nodeId' | 'nodeFqdn'>): Promise<void>;
314
392
  execute(input: CommandIdentity, run: (digest: string) => Promise<CommandReceiptResult>, enrollment?: ManagedClaim): Promise<CommandReceiptResult>;
315
393
  }
394
+ export {};
316
395
  //# sourceMappingURL=command-ledger.d.ts.map
@@ -2,7 +2,7 @@
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { constants } from 'node:fs';
4
4
  import { spawn } from 'node:child_process';
5
- import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises';
5
+ import { lstat, mkdir, open, readdir, rename, unlink } from 'node:fs/promises';
6
6
  import { join, resolve } from 'node:path';
7
7
  import { z } from 'zod';
8
8
  import { HOST_FQDN, NODE_MANAGER_SECRET, RESULT_SIGNING_SECRET } from '../env.js';
@@ -48,13 +48,29 @@ const receiptSchema = z.object({
48
48
  finishedAt: z.number().int().nonnegative().optional(), coordination: commandFenceSchema.optional(),
49
49
  result: resultSchema.optional(), reconciliation: commandReconciliationSchema.optional(),
50
50
  }).strict();
51
+ /** What went wrong underneath an admission error, for a WARN line. */
52
+ export const admissionCause = (error) => error.cause === undefined ? '' : ` (cause: ${error.cause instanceof Error ? error.cause.message : String(error.cause)})`;
51
53
  export class CommandAdmissionError extends Error {
52
54
  code;
53
- constructor(code, message) {
54
- super(message);
55
+ /** The callback demonstrably never ran and this command id has no receipt: a certain refusal. */
56
+ notStarted;
57
+ /** STALE_FENCE: the fence this ledger already accepted for the key, so the server can move past it. */
58
+ highWater;
59
+ constructor(code, message, details = {}) {
60
+ // The reply keeps a fixed code and message; the cause stays here for the caller's log.
61
+ super(message, details.cause === undefined ? undefined : { cause: details.cause });
55
62
  this.code = code;
63
+ this.notStarted = details.notStarted ?? false;
64
+ if (details.highWater)
65
+ this.highWater = details.highWater;
56
66
  }
57
67
  }
68
+ const enrollmentMarkerSchema = z.object({ version: z.literal(1), nodeId: z.string().min(1).max(128), nodeFqdn: z.string().min(1).max(253) }).strict();
69
+ /** Bounds one scan: receipts are never pruned, and a heartbeat must stay cheap. */
70
+ const HEALTH_SCAN_LIMIT = 5000;
71
+ const HEALTH_FENCE_LIMIT = 64;
72
+ /** `state` precedes the potentially large result in every receipt this ledger writes. */
73
+ const RECEIPT_STATE = /"state":"([a-z]+)"/;
58
74
  const hash = (value) => createHash('sha256').update(value).digest('hex');
59
75
  export function commandRequestDigest(input) {
60
76
  return hash(JSON.stringify({ commandId: input.commandId, command: input.command, args: input.args,
@@ -175,6 +191,25 @@ export class CommandLedger {
175
191
  }
176
192
  }
177
193
  receiptName(id) { return 'receipt-' + hash(id) + '.json'; }
194
+ failedAdmissionName(id) { return 'admission-failed-' + hash(id) + '.json'; }
195
+ /**
196
+ * A write after the fence failed, so the fence is ahead with no receipt, and the
197
+ * callback did not start. Say so durably, or that state looks like a receipt that
198
+ * went missing. If not even this can be written, keep the claim instead: it is
199
+ * already on disk, needs no new space, and says the same (recoverClaim clears it).
200
+ * A receipt that did land must keep its claim too, or it can never be reconciled.
201
+ */
202
+ async keepEvidenceOfFailedAdmission(input) {
203
+ try {
204
+ if (await this.read(this.receiptName(input.commandId)) !== undefined)
205
+ return true;
206
+ await this.write(this.failedAdmissionName(input.commandId), { commandId: input.commandId, fence: input.coordination.fence });
207
+ return false;
208
+ }
209
+ catch { // no-reason-needed: the original failure is what is reported; keeping the claim is the fallback
210
+ return true;
211
+ }
212
+ }
178
213
  async receipt(id) {
179
214
  if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(id))
180
215
  throw new Error('Invalid command identifier');
@@ -250,7 +285,7 @@ export class CommandLedger {
250
285
  catch (error) {
251
286
  if (error instanceof CommandAdmissionError)
252
287
  throw error;
253
- throw new CommandAdmissionError('LEDGER_UNAVAILABLE', 'Reconciliation could not be persisted; inspect the receipt before retrying the same decision');
288
+ throw new CommandAdmissionError('LEDGER_UNAVAILABLE', 'Reconciliation could not be persisted; inspect the receipt before retrying the same decision', { cause: error });
254
289
  }
255
290
  finally {
256
291
  await mutex?.close();
@@ -261,7 +296,7 @@ export class CommandLedger {
261
296
  const value = await this.read('managed-enrollment.json');
262
297
  if (value === undefined)
263
298
  return null;
264
- return z.object({ version: z.literal(1), nodeId: z.string().min(1).max(128), nodeFqdn: z.string().min(1).max(253) }).strict().parse(value);
299
+ return enrollmentMarkerSchema.parse(value);
265
300
  }
266
301
  /** A drain cannot equate an idle process or expired fence with settled effects. */
267
302
  async drainSnapshot() {
@@ -351,7 +386,7 @@ export class CommandLedger {
351
386
  if (!prior || BigInt(fence.fence) > BigInt(prior.fence))
352
387
  await this.write(fenceName, { resourceKey: fence.resourceKey, fence: fence.fence });
353
388
  else if (prior && BigInt(fence.fence) < BigInt(prior.fence))
354
- throw new CommandAdmissionError('STALE_FENCE', 'A successor fence already owns this resource');
389
+ throw new CommandAdmissionError('STALE_FENCE', 'A successor fence already owns this resource', { highWater: { resourceKey: fence.resourceKey, fence: prior.fence } });
355
390
  if (!enrolled)
356
391
  await this.write('managed-enrollment.json', { version: 1, nodeId: enrollment.nodeId, nodeFqdn: enrollment.nodeFqdn });
357
392
  this.checkResult(result, input.commandId, digest, fence);
@@ -364,6 +399,113 @@ export class CommandLedger {
364
399
  await mutex.close();
365
400
  }
366
401
  }
402
+ /**
403
+ * The sticky marker as it is, without creating anything. An unreadable marker
404
+ * is `unknown`, not absent: reporting nothing could hide a mismatch.
405
+ */
406
+ async enrollmentMarker() {
407
+ try {
408
+ const raw = await this.read('managed-enrollment.json');
409
+ if (raw === undefined)
410
+ return { state: 'absent', marker: null };
411
+ return { state: 'present', marker: enrollmentMarkerSchema.parse(raw) };
412
+ }
413
+ catch (error) {
414
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR')
415
+ return { state: 'absent', marker: null };
416
+ return { state: 'unknown', marker: null, error: error instanceof Error ? error.message : String(error) };
417
+ }
418
+ }
419
+ /**
420
+ * What this ledger holds about a command, read only and without the mutex. On its
421
+ * own "no receipt" proves nothing: a ledger that is gone holds nothing either. So
422
+ * this also says whether there is a ledger, what fence it last accepted for the
423
+ * key and what its marker says. Ordering in execute(): active.lock, fence, durable
424
+ * running receipt, and only then the callback, so a claim without a receipt
425
+ * never started.
426
+ */
427
+ async commandTrace(id, resourceKey) {
428
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(id))
429
+ throw new Error('Invalid command identifier');
430
+ let ledgerExists = true;
431
+ try {
432
+ ledgerExists = (await lstat(this.root)).isDirectory();
433
+ }
434
+ catch (error) {
435
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR')
436
+ throw error;
437
+ ledgerExists = false;
438
+ }
439
+ const marker = await this.enrollmentMarker();
440
+ if (!ledgerExists)
441
+ return { ledgerExists, receipt: false, activeLock: false, fenceHighWater: null, marker, admissionFailed: false, admissionFailedFence: null };
442
+ const raw = await this.read('active.lock');
443
+ const receipt = await this.read(this.receiptName(id));
444
+ const fence = resourceKey ? await this.read('fence-' + hash(resourceKey) + '.json') : undefined;
445
+ const failed = await this.read(this.failedAdmissionName(id));
446
+ const admissionFailedFence = failed?.commandId === id && typeof failed.fence === 'string' && /^[0-9]{1,20}$/.test(failed.fence) ? failed.fence : null;
447
+ return { ledgerExists, receipt: receipt !== undefined, activeLock: raw !== undefined && lockSchema.parse(raw).commandId === id,
448
+ admissionFailed: admissionFailedFence !== null, admissionFailedFence,
449
+ fenceHighWater: fence?.resourceKey === resourceKey && typeof fence?.fence === 'string' && /^[0-9]{1,20}$/.test(fence.fence) ? fence.fence : null, marker };
450
+ }
451
+ /**
452
+ * Read-only health for preflight checks and heartbeats. Takes no mutex and
453
+ * creates nothing, so asking never competes with a running command.
454
+ */
455
+ async health() {
456
+ const health = { readable: true, activeLock: null, nonTerminalReceipts: 0, unresolvedReceipts: 0, truncated: false, fenceHighWater: {}, error: null };
457
+ let names;
458
+ try {
459
+ names = await readdir(this.root);
460
+ }
461
+ catch (error) {
462
+ if (error.code === 'ENOENT')
463
+ return health;
464
+ return { ...health, readable: false, error: 'the ledger directory cannot be read' };
465
+ }
466
+ try {
467
+ const receiptState = async (name) => {
468
+ const file = await open(join(this.root, name), constants.O_RDONLY | constants.O_NOFOLLOW);
469
+ try {
470
+ const { buffer, bytesRead } = await file.read({ buffer: Buffer.alloc(1024), position: 0 });
471
+ return RECEIPT_STATE.exec(buffer.toString('utf8', 0, bytesRead))?.[1] ?? null;
472
+ }
473
+ finally {
474
+ await file.close();
475
+ }
476
+ };
477
+ const running = new Set([...this.active].map(id => this.receiptName(id)));
478
+ const receipts = names.filter(name => name.startsWith('receipt-'));
479
+ health.truncated = receipts.length > HEALTH_SCAN_LIMIT;
480
+ for (const name of receipts.slice(0, HEALTH_SCAN_LIMIT)) {
481
+ const state = await receiptState(name);
482
+ if (state === 'completed' || state === 'reconciled')
483
+ continue;
484
+ health.nonTerminalReceipts += 1;
485
+ if (!running.has(name))
486
+ health.unresolvedReceipts += 1;
487
+ }
488
+ for (const name of names.filter(entry => entry.startsWith('fence-')).slice(0, HEALTH_FENCE_LIMIT)) {
489
+ const record = await this.read(name);
490
+ if (typeof record?.resourceKey === 'string' && typeof record.fence === 'string' && /^[0-9]{1,20}$/.test(record.fence))
491
+ health.fenceHighWater[record.resourceKey] = record.fence;
492
+ }
493
+ const raw = await this.read('active.lock');
494
+ if (raw !== undefined) {
495
+ const claim = lockSchema.parse(raw);
496
+ const state = await receiptState(this.receiptName(claim.commandId)).catch((error) => {
497
+ if (error.code === 'ENOENT')
498
+ return 'none';
499
+ throw error;
500
+ });
501
+ health.activeLock = { commandId: claim.commandId, receipt: state === 'running' ? (this.active.has(claim.commandId) ? 'running' : 'unknown') : state ?? 'invalid' };
502
+ }
503
+ return health;
504
+ }
505
+ catch (error) {
506
+ return { ...health, readable: false, error: `the ledger holds an unreadable record (${error instanceof Error ? error.name : 'error'})` };
507
+ }
508
+ }
367
509
  /** Persist an authoritative observation; never clears an active/unknown claim. */
368
510
  async observeManagedEnrollment(identity) {
369
511
  const value = z.object({ nodeId: z.string().min(1).max(128), nodeFqdn: z.string().min(1).max(253) }).strict().parse(identity);
@@ -385,7 +527,7 @@ export class CommandLedger {
385
527
  // Snapshot before awaiting; callers cannot mutate accepted scope or identity.
386
528
  input = structuredClone(input);
387
529
  const digest = commandRequestDigest(input);
388
- let held = false, started = false, admitted = false;
530
+ let held = false, started = false, admitted = false, noReceipt = false, fenceWritten = false, keepClaim = false;
389
531
  let mutex;
390
532
  const previous = async () => {
391
533
  const row = await this.receipt(input.commandId);
@@ -403,6 +545,7 @@ export class CommandLedger {
403
545
  const replay = await previous();
404
546
  if (replay)
405
547
  return replay;
548
+ noReceipt = true;
406
549
  mutex = await this.acquire();
407
550
  await this.recoverClaim();
408
551
  const enrolled = await this.managedEnrollment();
@@ -425,8 +568,9 @@ export class CommandLedger {
425
568
  if (prior && (prior.resourceKey !== fence.resourceKey || !/^[1-9][0-9]{0,18}$/.test(prior.fence)))
426
569
  throw new Error('Invalid fencing record');
427
570
  if (prior && BigInt(fence.fence) <= BigInt(prior.fence))
428
- throw new CommandAdmissionError('STALE_FENCE', 'Stale resource fence cannot execute');
571
+ throw new CommandAdmissionError('STALE_FENCE', 'Stale resource fence cannot execute', { highWater: { resourceKey: fence.resourceKey, fence: prior.fence } });
429
572
  await this.write(name, { resourceKey: fence.resourceKey, fence: fence.fence });
573
+ fenceWritten = true;
430
574
  }
431
575
  if (enrollment && !enrolled)
432
576
  await this.write('managed-enrollment.json', { version: 1, nodeId: enrollment.nodeId, nodeFqdn: enrollment.nodeFqdn });
@@ -443,15 +587,22 @@ export class CommandLedger {
443
587
  return result;
444
588
  }
445
589
  catch (error) {
446
- if (error instanceof CommandAdmissionError)
590
+ // `started` stays true from just before the callback until its terminal receipt is durable.
591
+ const certain = !started && noReceipt;
592
+ if (error instanceof CommandAdmissionError) {
593
+ if (certain && !['OUTCOME_UNKNOWN', 'IDEMPOTENCY_CONFLICT'].includes(error.code))
594
+ error.notStarted = true;
447
595
  throw error;
448
- throw new CommandAdmissionError(started ? 'OUTCOME_UNKNOWN' : 'LEDGER_UNAVAILABLE', started ? 'Execution receipt is uncertain; retained for reconciliation and never replayed' : 'Command ledger unavailable; inspect its receipt before retrying');
596
+ }
597
+ if (certain && fenceWritten)
598
+ keepClaim = await this.keepEvidenceOfFailedAdmission(input);
599
+ throw new CommandAdmissionError(started ? 'OUTCOME_UNKNOWN' : 'LEDGER_UNAVAILABLE', started ? 'Execution receipt is uncertain; retained for reconciliation and never replayed' : 'Command ledger unavailable; inspect its receipt before retrying', { notStarted: certain, cause: error });
449
600
  }
450
601
  finally {
451
602
  if (admitted)
452
603
  this.active.delete(input.commandId);
453
604
  try {
454
- if (held && !started)
605
+ if (held && !started && !keepClaim)
455
606
  await this.releaseClaim();
456
607
  }
457
608
  catch {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The error body of a refusal. Only with a local proof that the request did
3
+ * not start, nothing local for its id and a lease to name, does it carry
4
+ * `executed: false` and a signature bound to the request, the lease and this node;
5
+ * the server settles the lease on that alone. Anything less stays uncertain, the
6
+ * safe answer for an older server too. A claim this command still holds counts as
7
+ * something local: the request may be between its claim and its receipt.
8
+ */
9
+ export declare function refusalError(request: {
10
+ commandId: string;
11
+ requestSha256: string;
12
+ leaseId?: string | undefined;
13
+ }, code: string, message: string, details: {
14
+ notStarted: boolean;
15
+ highWater?: {
16
+ resourceKey: string;
17
+ fence: string;
18
+ };
19
+ }): Promise<{
20
+ highWater?: string | undefined;
21
+ resourceKey?: string | undefined;
22
+ code: string;
23
+ message: string;
24
+ } | {
25
+ dispatched: boolean;
26
+ executed: boolean;
27
+ refusal: {
28
+ signature: string;
29
+ commandId: string;
30
+ requestSha256: string;
31
+ code: string;
32
+ highWater: string | null;
33
+ nodeFqdn: string;
34
+ leaseId: string;
35
+ };
36
+ highWater?: string | undefined;
37
+ resourceKey?: string | undefined;
38
+ code: string;
39
+ message: string;
40
+ }>;
41
+ /** Same statuses as /commands/execute. */
42
+ export declare const refusalStatus: (code: string) => 409 | 503;
43
+ //# sourceMappingURL=command-refusal.d.ts.map
@@ -0,0 +1,32 @@
1
+ import { HOST_FQDN, RESULT_SIGNING_SECRET } from '../env.js';
2
+ import { commandRefusalSigningPayload, signCommandEvidence } from '../security/command-result-signature.js';
3
+ import { logger } from '../utils/logger.js';
4
+ import { commandLedger } from './command-executor.js';
5
+ /**
6
+ * The error body of a refusal. Only with a local proof that the request did
7
+ * not start, nothing local for its id and a lease to name, does it carry
8
+ * `executed: false` and a signature bound to the request, the lease and this node;
9
+ * the server settles the lease on that alone. Anything less stays uncertain, the
10
+ * safe answer for an older server too. A claim this command still holds counts as
11
+ * something local: the request may be between its claim and its receipt.
12
+ */
13
+ export async function refusalError(request, code, message, details) {
14
+ const base = { code, message, ...(details.highWater ? { highWater: details.highWater.fence, resourceKey: details.highWater.resourceKey } : {}) };
15
+ if (!details.notStarted || !request.leaseId)
16
+ return base;
17
+ try {
18
+ const trace = await commandLedger.commandTrace(request.commandId);
19
+ if (trace.receipt || trace.activeLock)
20
+ return base;
21
+ const unsigned = { commandId: request.commandId, requestSha256: request.requestSha256, code, highWater: details.highWater?.fence ?? null, nodeFqdn: HOST_FQDN, leaseId: request.leaseId };
22
+ return { ...base, dispatched: false, executed: false,
23
+ refusal: { ...unsigned, signature: signCommandEvidence(commandRefusalSigningPayload(unsigned), RESULT_SIGNING_SECRET) } };
24
+ }
25
+ catch (error) {
26
+ logger.warn(`Refusal of ${request.commandId} (${code}) stays uncertain: ${error instanceof Error ? error.message : String(error)}`);
27
+ return base;
28
+ }
29
+ }
30
+ /** Same statuses as /commands/execute. */
31
+ export const refusalStatus = (code) => code === 'LEDGER_UNAVAILABLE' ? 503 : 409;
32
+ //# sourceMappingURL=command-refusal.js.map