@hivemagnus/magnus-server 1.1.1 → 1.1.3

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/cli.js CHANGED
@@ -56,9 +56,11 @@ async function migrate() {
56
56
  catch {
57
57
  return fatal('Migration error: the prisma CLI package is not installed next to magnus-server.');
58
58
  }
59
+ // MAGNUS_MIGRATE_DATABASE_URL: a direct connection for migrations when DATABASE_URL goes through a pooler.
60
+ const migrateUrl = process.env['MAGNUS_MIGRATE_DATABASE_URL']?.trim();
59
61
  const code = await new Promise((resolvePromise, reject) => {
60
62
  const child = spawn(process.execPath, [prismaCli, 'migrate', 'deploy', '--config', PRISMA_CONFIG], {
61
- cwd: PACKAGE_ROOT, env: process.env, stdio: 'inherit',
63
+ cwd: PACKAGE_ROOT, env: migrateUrl ? { ...process.env, DATABASE_URL: migrateUrl } : process.env, stdio: 'inherit',
62
64
  });
63
65
  child.on('error', reject);
64
66
  child.on('close', exitCode => resolvePromise(exitCode ?? 1));
@@ -141,13 +143,23 @@ async function commandMigrations(format) {
141
143
  try {
142
144
  const state = await readMigrationState(client, MIGRATIONS_DIR);
143
145
  if (format === 'json') {
144
- console.log(JSON.stringify({ version: await version(), packaged: state.packaged, applied: [...state.applied].sort(), pending: state.pending, fresh: state.fresh }, null, 2));
146
+ const size = await client.query('SELECT pg_database_size(current_database())::text AS bytes, current_setting(\'server_version_num\') AS version')
147
+ .then(result => result.rows[0]).catch(() => undefined);
148
+ console.log(JSON.stringify({
149
+ version: await version(), packaged: state.packaged, applied: [...state.applied].sort(), pending: state.pending, fresh: state.fresh,
150
+ failed: state.failed, unknown: state.unknown,
151
+ databaseBytes: size ? Number(size.bytes) : null, postgresMajor: size ? Math.floor(Number(size.version) / 10_000) : null,
152
+ }, null, 2));
145
153
  }
146
154
  else {
147
155
  for (const name of state.packaged) {
148
156
  const pending = state.pending.find(migration => migration.name === name);
149
157
  console.log(` ${pending ? '…' : '✓'} ${name}${pending ? ` (pending, ${pending.impact}${pending.note ? `: ${pending.note}` : ''})` : ''}`);
150
158
  }
159
+ for (const migration of state.unknown)
160
+ console.log(` ? ${migration.name} (applied by a newer release; unknown to this version)`);
161
+ for (const name of state.failed)
162
+ console.log(` ✗ ${name} (failed; see \`prisma migrate resolve\` or restore a dump)`);
151
163
  console.log(state.pending.length === 0 ? 'All migrations applied.' : `${state.pending.length} pending; run \`magnus-server migrate\`.`);
152
164
  }
153
165
  return 0;
@@ -159,17 +171,19 @@ async function commandMigrations(format) {
159
171
  /** `pg_dump -Fc` into MAGNUS_BACKUP_DIR; prints JSON with the file and restore command. */
160
172
  async function commandBackup(label) {
161
173
  const { resolveDatabaseUrl } = await import('./db/client.js');
162
- const { backupDatabase } = await import('./cli/migrations.js');
163
- const { defaultBackupDir } = await import('./cli/auto-migrate.js');
174
+ const { backupDatabase, backupKeep, readMigrationState } = await import('./cli/migrations.js');
175
+ const { defaultBackupDir, migrateDatabaseUrl } = await import('./cli/auto-migrate.js');
164
176
  const { magnusHome } = await import('./utils/magnus-home.js');
165
177
  const pg = (await import('pg')).default;
166
- const databaseUrl = resolveDatabaseUrl();
178
+ const databaseUrl = migrateDatabaseUrl(process.env, resolveDatabaseUrl());
167
179
  const client = new pg.Client({ connectionString: databaseUrl, connectionTimeoutMillis: 10_000 });
168
180
  await client.connect();
169
181
  try {
182
+ const state = await readMigrationState(client, MIGRATIONS_DIR).catch(() => null);
170
183
  const result = await backupDatabase({
171
184
  databaseUrl, client, backupDir: defaultBackupDir(process.env, magnusHome()),
172
185
  label: label ?? `manual-${await version()}`, pgDump: process.env['MAGNUS_PG_DUMP'],
186
+ lastApplied: state?.lastApplied ?? null, keep: backupKeep(),
173
187
  });
174
188
  console.log(JSON.stringify(result));
175
189
  return 0;
@@ -178,18 +192,76 @@ async function commandBackup(label) {
178
192
  await client.end().catch(() => undefined);
179
193
  }
180
194
  }
195
+ /** Replace the database with a dump; refuses while this host's server is running. */
196
+ async function commandRestore(file, yes) {
197
+ const { resolveDatabaseUrl } = await import('./db/client.js');
198
+ const { pgRestoreFor, restoreDatabase } = await import('./cli/migrations.js');
199
+ const { migrateDatabaseUrl } = await import('./cli/auto-migrate.js');
200
+ const { runningServer } = await import('./utils/runtime-file.js');
201
+ const running = runningServer();
202
+ if (running)
203
+ fatal(`The server (pid ${running.pid}, ${running.version}) is still running; stop it before restoring (for example: systemctl stop magnus-server, or docker compose stop magnus-server).`);
204
+ const path = resolve(file);
205
+ if (!yes) {
206
+ if (!process.stdin.isTTY)
207
+ fatal('Restoring replaces the whole database; pass --yes to confirm non-interactively.');
208
+ const { createInterface } = await import('node:readline/promises');
209
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
210
+ const answer = await rl.question(`Replace the database with ${path}? Changes made since the dump are lost [y/N] `).finally(() => rl.close());
211
+ if (!/^y(es)?$/i.test(answer.trim()))
212
+ return 1;
213
+ }
214
+ const pg = (await import('pg')).default;
215
+ const databaseUrl = migrateDatabaseUrl(process.env, resolveDatabaseUrl());
216
+ const client = new pg.Client({ connectionString: databaseUrl, connectionTimeoutMillis: 10_000 });
217
+ await client.connect();
218
+ try {
219
+ await restoreDatabase({ databaseUrl, file: path, client, pgRestore: pgRestoreFor(process.env['MAGNUS_PG_DUMP']), log: line => console.log(line) });
220
+ console.log(`Restored ${path}. Start the server version that matches the dump.`);
221
+ return 0;
222
+ }
223
+ catch (error) {
224
+ return fatal(`Restore failed: ${error instanceof Error ? error.message : String(error)}`);
225
+ }
226
+ finally {
227
+ await client.end().catch(() => undefined);
228
+ }
229
+ }
230
+ /** Warn at startup when a newer release migrated the database (best effort). */
231
+ async function warnSchemaAhead() {
232
+ try {
233
+ const { resolveDatabaseUrl } = await import('./db/client.js');
234
+ const { readMigrationState } = await import('./cli/migrations.js');
235
+ const pg = (await import('pg')).default;
236
+ const client = new pg.Client({ connectionString: resolveDatabaseUrl(), connectionTimeoutMillis: 5_000 });
237
+ await client.connect();
238
+ try {
239
+ const state = await readMigrationState(client, MIGRATIONS_DIR);
240
+ if (state.unknown.length > 0) {
241
+ console.warn(`[startup] warning: the database has ${state.unknown.length} migration(s) this version does not know (a newer release applied them): `
242
+ + `${state.unknown.map(m => m.name).join(', ')}. If this version misbehaves, upgrade again or restore the dump taken before that upgrade.`);
243
+ }
244
+ if (state.failed.length > 0)
245
+ console.warn(`[startup] warning: failed migration(s) ${state.failed.join(', ')}; run \`magnus-server migrations\`.`);
246
+ }
247
+ finally {
248
+ await client.end().catch(() => undefined);
249
+ }
250
+ }
251
+ catch { /* the server reports database problems itself */ }
252
+ }
181
253
  /** `MAGNUS_AUTO_MIGRATE=true`: back up and migrate under an advisory lock before listening. */
182
254
  async function startupAutoMigrate() {
183
255
  const { upgradeSettings } = await import('./config/features.js');
184
256
  const settings = upgradeSettings();
185
257
  if (!settings.autoMigrate)
186
- return;
258
+ return warnSchemaAhead();
187
259
  const { resolveDatabaseUrl } = await import('./db/client.js');
188
- const { autoMigrate, defaultBackupDir } = await import('./cli/auto-migrate.js');
260
+ const { autoMigrate, defaultBackupDir, migrateDatabaseUrl } = await import('./cli/auto-migrate.js');
189
261
  const { magnusHome } = await import('./utils/magnus-home.js');
190
262
  try {
191
263
  await autoMigrate({
192
- databaseUrl: resolveDatabaseUrl(),
264
+ databaseUrl: migrateDatabaseUrl(process.env, resolveDatabaseUrl()),
193
265
  migrationsDir: MIGRATIONS_DIR,
194
266
  backupDir: defaultBackupDir(process.env, magnusHome()),
195
267
  version: await version(),
@@ -243,6 +315,9 @@ async function run(argv) {
243
315
  case 'backup':
244
316
  loadEnvFiles(options.envFiles);
245
317
  return commandBackup(options.label);
318
+ case 'restore':
319
+ loadEnvFiles(options.envFiles);
320
+ return commandRestore(options.file, options.yes);
246
321
  case 'upgrade':
247
322
  case 'rollback': {
248
323
  const { delegateToProject } = await import('./cli/upgrade.js');
package/dist/index.js CHANGED
@@ -56,7 +56,7 @@ const { writeRuntimeFile } = await import('./utils/runtime-file.js');
56
56
  const { CONTROL_API_VERSION } = await import('./config/version.js');
57
57
  /** Background services shared by the TLS and development listeners. */
58
58
  function startBackgroundServices(listenPort) {
59
- writeRuntimeFile({ version: CONTROL_API_VERSION, port: listenPort });
59
+ writeRuntimeFile({ version: CONTROL_API_VERSION, port: listenPort, host: listener.hostname });
60
60
  startNodeIssueMonitor();
61
61
  startApplicationProbes();
62
62
  startLogRetention();
@@ -2156,13 +2156,16 @@ const paths = {
2156
2156
  get: {
2157
2157
  tags: ['Agent upgrades'],
2158
2158
  summary: 'Which agents can take a version',
2159
- description: 'For every node: its agent version and whether it can upgrade itself to `version` (default: the agent version this server ships with) now, or why not (offline, not set up with `magnus-agent install-service`, MAGNUS_AGENT_SELF_UPGRADE=false, another major version, already current).',
2159
+ description: 'For every node: its agent version and whether it can upgrade itself to `version` (default: the agent version bundled with this server) now, or why not: offline, not set up with `magnus-agent install-service`, MAGNUS_AGENT_SELF_UPGRADE=false, another major version, already current, already reverted this version, a closed release admission fence, a resource enrollment that is not complete (for example "stuck in draining since <date>"), held participant work or command leases, or an agent whose sticky managed-enrollment marker the server does not share. `release.verified` is false when the registry does not have the version.',
2160
2160
  security: apiSecretAuth,
2161
- parameters: [qp('version', 'Target agent version (X.Y.Z)')],
2161
+ parameters: [qp('version', 'Target agent version (X.Y.Z)'), qp('allowRetryReverted', '`true` to offer nodes that already reverted this version')],
2162
2162
  responses: { 200: success('Eligibility', { type: 'object', properties: {
2163
- version: { type: 'string' }, upgradesEnabled: { type: 'boolean' }, disabledReason: { type: ['string', 'null'] },
2163
+ version: { type: 'string' }, serverVersion: { type: 'string' }, upgradesEnabled: { type: 'boolean' }, disabledReason: { type: ['string', 'null'] },
2164
+ prerelease: { type: 'boolean' },
2165
+ release: { type: 'object', properties: { verified: { type: 'boolean' }, integrity: { type: ['string', 'null'] }, reason: { type: ['string', 'null'] } } },
2164
2166
  nodes: { type: 'array', items: { type: 'object', properties: { nodeId: { type: 'string' }, name: { type: 'string' },
2165
2167
  version: { type: 'string' }, eligible: { type: 'boolean' }, reason: { type: ['string', 'null'] } } } },
2168
+ latestRun: { type: ['object', 'null'] },
2166
2169
  } }), 400: err400, 401: err401 },
2167
2170
  },
2168
2171
  },
@@ -2170,12 +2173,15 @@ const paths = {
2170
2173
  post: {
2171
2174
  tags: ['Agent upgrades'],
2172
2175
  summary: 'Upgrade agents',
2173
- description: 'Starts an upgrade run (ADMIN, MAGNUS_AGENT_UPGRADES=true): one canary, then MAGNUS_AGENT_UPGRADE_CONCURRENCY agents at a time, stopping at the first agent that does not confirm. Each agent installs the exact version and npm integrity and switches back by itself if the new version does not report a heartbeat in time.',
2176
+ description: 'Starts an upgrade run (ADMIN, MAGNUS_AGENT_UPGRADES=true): one canary, then MAGNUS_AGENT_UPGRADE_CONCURRENCY agents at a time. Each agent installs the exact version and npm integrity and switches back by itself if the new version does not report a heartbeat in time. A revert, a release problem (integrity, install failure) or an agent that goes silent after the request stops the run. An agent that refuses before changing anything is recorded as `refused` and the run goes on; a refused canary hands the role to the next node. Only one run at a time. Selected nodes that are not eligible are listed in `excluded`; unknown node ids are a 400.',
2174
2177
  security: apiSecretAuth,
2175
2178
  requestBody: { required: false, content: { 'application/json': { schema: { type: 'object', properties: {
2176
- version: { type: 'string', description: 'Agent version (default: the one this server ships with)' },
2179
+ version: { type: 'string', description: 'Agent version (default: the one bundled with this server)' },
2177
2180
  integrity: { type: 'string', description: 'npm sha512 integrity (default: read from the registry)' },
2178
- nodeIds: { type: 'array', items: { type: 'string' }, description: 'Only these nodes' },
2181
+ nodeIds: { type: 'array', minItems: 1, items: { type: 'string' }, description: 'Only these nodes (leave out for every eligible agent)' },
2182
+ canaryNodeId: { type: 'string', description: 'Upgrade this node first' },
2183
+ allowPrerelease: { type: 'boolean', description: 'Required for a prerelease version' },
2184
+ allowRetryReverted: { type: 'boolean', description: 'Also upgrade nodes that already reverted this version' },
2179
2185
  } } } } },
2180
2186
  responses: { 202: success('Run started'), 400: err400, 401: err401, 403: err403, 409: err409 },
2181
2187
  },
@@ -2192,12 +2198,35 @@ const paths = {
2192
2198
  get: {
2193
2199
  tags: ['Agent upgrades'],
2194
2200
  summary: 'Upgrade run status',
2195
- description: 'Per-node state of a run: pending, requested, confirmed, reverted, failed or skipped.',
2201
+ description: 'Run status (running, succeeded, failed, stopped or interrupted) and per-node state: pending, requested, confirmed, reverted, failed, refused or skipped, with the agent error code and message. A run whose server stopped renewing its lease (restart, interrupted CLI) is shown as interrupted; its requested nodes are settled from their heartbeat reports.',
2196
2202
  security: apiSecretAuth,
2197
2203
  parameters: [pp('id', 'Run ID')],
2198
2204
  responses: { 200: success('Run'), 401: err401, 404: err404 },
2199
2205
  },
2200
2206
  },
2207
+ '/agents/upgrade/runs/{id}/stop': {
2208
+ post: {
2209
+ tags: ['Agent upgrades'],
2210
+ summary: 'Stop an upgrade run',
2211
+ description: 'ADMIN. The run stops before its next batch; nodes already asked to upgrade finish their trial.',
2212
+ security: apiSecretAuth,
2213
+ parameters: [pp('id', 'Run ID')],
2214
+ responses: { 202: success('Stop requested'), 401: err401, 403: err403, 404: err404, 409: err409 },
2215
+ },
2216
+ },
2217
+ '/agents/upgrade/runs/{id}/retry': {
2218
+ post: {
2219
+ tags: ['Agent upgrades'],
2220
+ summary: 'Retry an upgrade run',
2221
+ description: 'ADMIN. Starts a new run with the same version and integrity for the failed, refused, skipped and interrupted nodes of a finished run (reverted nodes only with allowRetryReverted).',
2222
+ security: apiSecretAuth,
2223
+ parameters: [pp('id', 'Run ID')],
2224
+ requestBody: { required: false, content: { 'application/json': { schema: { type: 'object', properties: {
2225
+ canaryNodeId: { type: 'string' }, allowRetryReverted: { type: 'boolean' },
2226
+ } } } } },
2227
+ responses: { 202: success('Run started'), 400: err400, 401: err401, 403: err403, 404: err404, 409: err409 },
2228
+ },
2229
+ },
2201
2230
  '/self-update/status': {
2202
2231
  get: {
2203
2232
  tags: ['Self-Update'],
@@ -4,32 +4,43 @@ import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
4
4
  import { HttpError } from '../middleware/error-handler.js';
5
5
  import { upgradeSettings } from '../config/features.js';
6
6
  import { AGENT_VERSION } from '../config/version.js';
7
- import { getUpgradeRun, latestUpgradeRun, listUpgradeCandidates, startUpgradeRun } from '../services/agent-upgrade-service.js';
7
+ import { getUpgradeRun, isPrerelease, latestUpgradeRun, listUpgradeCandidates, retryUpgradeRun, startUpgradeRun, stopUpgradeRun, verifyAgentRelease } from '../services/agent-upgrade-service.js';
8
8
  /** /api/v1/agents/upgrade: which agents can take a version, and fleet upgrade runs. */
9
9
  export const agentUpgradesRouter = new Hono();
10
10
  agentUpgradesRouter.use('*', authMiddleware);
11
11
  const version = z.string().regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/);
12
+ const nodeId = z.string().min(1).max(128);
12
13
  agentUpgradesRouter.get('/eligibility', async (c) => {
13
14
  const target = version.safeParse(c.req.query('version') ?? AGENT_VERSION);
14
15
  if (!target.success)
15
16
  throw new HttpError(400, 'INVALID_VERSION', 'version must be X.Y.Z');
16
- const nodes = await listUpgradeCandidates(target.data);
17
+ const [nodes, release, latestRun] = await Promise.all([
18
+ listUpgradeCandidates(target.data, undefined, { allowRetryReverted: c.req.query('allowRetryReverted') === 'true' }),
19
+ verifyAgentRelease(target.data), latestUpgradeRun()
20
+ ]);
17
21
  return c.json({ success: true, data: {
18
22
  version: target.data, serverVersion: AGENT_VERSION, upgradesEnabled: upgradeSettings().agentUpgrades,
19
23
  disabledReason: upgradeSettings().agentUpgrades ? null : 'MAGNUS_AGENT_UPGRADES=false on the server',
20
- nodes, latestRun: await latestUpgradeRun(),
24
+ prerelease: isPrerelease(target.data), release, nodes, latestRun,
21
25
  } });
22
26
  });
23
27
  const startSchema = z.object({
24
28
  version: version.optional(),
25
29
  integrity: z.string().regex(/^sha512-[A-Za-z0-9+/]+={0,2}$/).optional(),
26
- nodeIds: z.array(z.string().min(1).max(128)).max(1000).optional(),
30
+ nodeIds: z.array(nodeId).min(1, 'nodeIds must name at least one node; leave it out for every eligible agent').max(1000).optional(),
31
+ canaryNodeId: nodeId.optional(),
32
+ allowPrerelease: z.boolean().optional(),
33
+ allowRetryReverted: z.boolean().optional(),
27
34
  }).strict();
28
- agentUpgradesRouter.post('/', requireRole('ADMIN'), async (c) => {
29
- const parsed = startSchema.safeParse(await c.req.json().catch(() => ({})));
35
+ const parseBody = async (schema, raw) => {
36
+ const parsed = schema.safeParse(raw);
30
37
  if (!parsed.success)
31
38
  throw new HttpError(400, 'VALIDATION_ERROR', parsed.error.issues.map(issue => issue.message).join('; '));
32
- const { run } = await startUpgradeRun({ ...parsed.data, actorId: getUser(c).userId });
39
+ return parsed.data;
40
+ };
41
+ agentUpgradesRouter.post('/', requireRole('ADMIN'), async (c) => {
42
+ const body = await parseBody(startSchema, await c.req.json().catch(() => ({})));
43
+ const { run } = await startUpgradeRun({ ...body, actorId: getUser(c).userId });
33
44
  return c.json({ success: true, data: run }, 202);
34
45
  });
35
46
  agentUpgradesRouter.get('/runs/latest', async (c) => c.json({ success: true, data: await latestUpgradeRun() }));
@@ -39,4 +50,14 @@ agentUpgradesRouter.get('/runs/:id', async (c) => {
39
50
  throw new HttpError(404, 'NOT_FOUND', 'Upgrade run not found');
40
51
  return c.json({ success: true, data: run });
41
52
  });
53
+ agentUpgradesRouter.post('/runs/:id/stop', requireRole('ADMIN'), async (c) => c.json({ success: true, data: await stopUpgradeRun(c.req.param('id'), getUser(c).userId) }, 202));
54
+ const retrySchema = z.object({
55
+ canaryNodeId: nodeId.optional(),
56
+ allowRetryReverted: z.boolean().optional(),
57
+ }).strict();
58
+ agentUpgradesRouter.post('/runs/:id/retry', requireRole('ADMIN'), async (c) => {
59
+ const body = await parseBody(retrySchema, await c.req.json().catch(() => ({})));
60
+ const { run } = await retryUpgradeRun(c.req.param('id'), { ...body, actorId: getUser(c).userId });
61
+ return c.json({ success: true, data: run }, 202);
62
+ });
42
63
  //# sourceMappingURL=agent-upgrades.js.map
@@ -332,7 +332,8 @@ heartbeatRouter.post('/', async (c) => {
332
332
  }) },
333
333
  });
334
334
  }
335
- if (existing && body.selfUpgrade !== undefined) {
335
+ // Also without a (valid) report, so an agent that stopped reporting one does not stay UPDATING.
336
+ if (existing) {
336
337
  await recordSelfUpgradeReport({ id: nodeId, name: existing.name }, body).catch((err) => {
337
338
  console.error(`[agent-upgrade] could not record the self-upgrade report of ${existing.name}:`, err instanceof Error ? err.message : err);
338
339
  });
@@ -11,6 +11,7 @@ import { HttpError } from '../middleware/error-handler.js';
11
11
  import { NODE_MANAGER_SECRET } from '../utils/env.js';
12
12
  import { verifyWithRotation } from '../services/credential-rotation-service.js';
13
13
  import { resourceLeasesRequested } from '../services/command-resource-leases.js';
14
+ import { readOwnershipHistory, stickyEnrollmentObserved } from '../services/resource-ownership.js';
14
15
  import { activateArdentScope, ardentScopeActivationSchema, assertArdentScope, inspectArdentScope } from '../services/ardent-scope-enrollment.js';
15
16
  import { legacyAdmissionRequestSchema, resourceClaimSchema } from '../security/managed-http.js';
16
17
  import { participantWorkReconciliationSchema, historicalParticipantWorkReconciliationSchema } from '../schemas/resource-participant-reconciliation.js';
@@ -144,14 +145,11 @@ resourceLeasesRouter.post('/legacy-admission', async (c) => {
144
145
  if (current?.hostname !== body.nodeFqdn)
145
146
  throw new HttpError(409, 'RESOURCE_TARGET_INVALID', 'Inventory target changed');
146
147
  await assertNodeAdmissionOpen(tx, node.id);
147
- const [enrollment, leaseHistory, managedCronHistory, participantHistory] = await Promise.all([
148
- tx.resourceEnrollment.findUnique({ where: { nodeId: node.id } }),
149
- tx.commandResourceLease.findFirst({ where: { nodeId: node.id }, select: { id: true } }),
150
- tx.managedCronDefinition.findFirst({ where: { nodeId: node.id }, select: { cronJobId: true } }),
151
- tx.resourceParticipantWork.findFirst({ where: { nodeId: node.id }, select: { workId: true } }),
148
+ const [enrollment, history] = await Promise.all([
149
+ tx.resourceEnrollment.findUnique({ where: { nodeId: node.id } }), readOwnershipHistory(tx, node.id)
152
150
  ]);
153
- const enrolled = Boolean(enrollment && enrollment.state !== 'legacy')
154
- || Boolean(leaseHistory || managedCronHistory || participantHistory);
151
+ // Sticky rule (see resource-ownership.ts): the agent stores `enrolled` for good.
152
+ const enrolled = stickyEnrollmentObserved(enrollment, history);
155
153
  const gateEnabled = resourceLeasesRequested();
156
154
  return { ...body, nodeId: node.id, enrolled, gateEnabled, allowedLegacy: !enrolled && !gateEnabled };
157
155
  });
@@ -6,7 +6,7 @@ import { commandReconciliationSchema } from '../schemas/command-reconciliation.j
6
6
  import { admitResourceCommand, markResourceDispatched, failResourceCommand, settleResourceResult, settleResourceReconciliation } from '../services/command-resource-leases.js';
7
7
  import { commandFenceSchema, commandResultSchema } from '../schemas/node-responses.js';
8
8
  import { commandRequestDigest } from '../security/command-request.js';
9
- import ky from 'ky';
9
+ import ky, { HTTPError } from 'ky';
10
10
  import { HttpError } from '../middleware/error-handler.js';
11
11
  import { createHmac, timingSafeEqual } from 'node:crypto';
12
12
  import { z } from 'zod';
@@ -63,6 +63,82 @@ async function rejectUnprivilegedAgent({ response }) {
63
63
  export function isAgentNotPrivileged(error) {
64
64
  return error instanceof HttpError && error.code === AGENT_NOT_PRIVILEGED;
65
65
  }
66
+ /** `{ success: false, error: { code, message } }` from an agent reply (object or JSON text). */
67
+ export function agentErrorBody(raw) {
68
+ let value = raw;
69
+ if (typeof raw === 'string') {
70
+ try {
71
+ value = JSON.parse(raw);
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ const error = value?.error;
78
+ if (!error || typeof error.code !== 'string')
79
+ return null;
80
+ return { code: error.code.slice(0, 64), message: typeof error.message === 'string' ? error.message.slice(0, 1000) : error.code };
81
+ }
82
+ /** The agent answered a mutation with an error status. `agentStatus` is not an HTTP status for our callers. */
83
+ export class AgentResponseError extends Error {
84
+ agentStatus;
85
+ code;
86
+ agentMessage;
87
+ constructor(agentStatus, body) {
88
+ super(`Native mutation completed with HTTP ${agentStatus}${body ? `: ${body.code}: ${body.message}` : ''}`);
89
+ this.name = 'AgentResponseError';
90
+ this.agentStatus = agentStatus;
91
+ this.code = body?.code ?? null;
92
+ this.agentMessage = body?.message ?? null;
93
+ }
94
+ }
95
+ /** Thrown by `upgradeAgent` for every failure before the request left: the node is untouched. */
96
+ export class NotDispatchedError extends HttpError {
97
+ dispatched = false;
98
+ constructor(code, message, cause) {
99
+ super(409, code, message);
100
+ this.name = 'NotDispatchedError';
101
+ if (cause !== undefined)
102
+ this.cause = cause;
103
+ }
104
+ }
105
+ /** The request may have reached the agent but no answer came back. */
106
+ export class DispatchOutcomeUnknownError extends Error {
107
+ code = 'MUTATION_OUTCOME_UNKNOWN';
108
+ constructor(message, cause) {
109
+ super(message);
110
+ this.name = 'DispatchOutcomeUnknownError';
111
+ if (cause !== undefined)
112
+ this.cause = cause;
113
+ }
114
+ }
115
+ const CONNECT_ERRORS = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'ENETUNREACH', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT']);
116
+ /** A connection that never opened cannot have carried the request. */
117
+ export function isConnectError(error) {
118
+ for (let current = error, depth = 0; current && typeof current === 'object' && depth < 5; current = current.cause, depth++) {
119
+ const code = current.code;
120
+ if (typeof code === 'string' && CONNECT_ERRORS.has(code))
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+ function errorText(error) {
126
+ return (error instanceof Error ? error.message : String(error)).slice(0, 1000);
127
+ }
128
+ /** Sort an upgradeAgent failure by whether the request can have reached the agent. */
129
+ export function classifyAgentRequestError(error, dispatched) {
130
+ if (!dispatched)
131
+ return error instanceof NotDispatchedError ? error : new NotDispatchedError(error instanceof HttpError ? error.code : error instanceof AgentResponseError && error.code ? error.code : 'NOT_DISPATCHED', errorText(error), error);
132
+ if (error instanceof AgentResponseError)
133
+ return error;
134
+ if (error instanceof HTTPError)
135
+ return new AgentResponseError(error.response.status, agentErrorBody(error.data));
136
+ if (error instanceof HttpError && error.code === AGENT_NOT_PRIVILEGED)
137
+ return new AgentResponseError(403, { code: error.code, message: error.message });
138
+ if (isConnectError(error))
139
+ return new NotDispatchedError('AGENT_UNREACHABLE', `agent unreachable: ${errorText(error)}`, error);
140
+ return new DispatchOutcomeUnknownError(`no answer from the agent after the request was sent: ${errorText(error)}`, error);
141
+ }
66
142
  export class NodeApiClient {
67
143
  api;
68
144
  nodeToken;
@@ -85,7 +161,7 @@ export class NodeApiClient {
85
161
  });
86
162
  }
87
163
  /** All finite native mutations use the same central owner and node execution slot. */
88
- async mutateWithEvidence(method, path, body, timeout = this.defaultTimeout, commandId, skillPublication, requireManaged = false) {
164
+ async mutateWithEvidence(method, path, body, timeout = this.defaultTimeout, commandId, skillPublication, requireManaged = false, dispatch) {
89
165
  if (!this.resourceContext)
90
166
  throw new Error('Managed mutation requires canonical resource node identity');
91
167
  const serialized = body === undefined ? '' : JSON.stringify(body);
@@ -97,6 +173,11 @@ export class NodeApiClient {
97
173
  const admission = await admitResourceCommand(this.resourceContext, identity);
98
174
  if (!admission && requireManaged)
99
175
  throw new Error('This operation requires enrolled managed admission; direct node mutation is forbidden');
176
+ if (!admission) {
177
+ if (dispatch?.pingFirst)
178
+ await this.api.get('ping', { timeout: 10_000, retry: 0 });
179
+ dispatch?.onDispatch();
180
+ }
100
181
  if (!admission)
101
182
  return { response: await this.api(path, { method,
102
183
  ...(body === undefined ? {} : { body: serialized, headers: { 'content-type': 'application/json' } }), timeout, retry: 0 }).json() };
@@ -110,6 +191,7 @@ export class NodeApiClient {
110
191
  if (!capability.success || capability.data?.features?.managedMutations !== 1)
111
192
  throw new Error('Node does not support managed mutations; operation not dispatched');
112
193
  await markResourceDispatched(admission);
194
+ dispatch?.onDispatch();
113
195
  const managedRequest = managedHttpRequestSchema.parse({
114
196
  claim: { leaseId: admission.leaseId, nodeId: admission.nodeId, nodeFqdn: admission.nodeFqdn },
115
197
  commandId: admission.commandId, method, path: '/' + path, body: serialized,
@@ -132,7 +214,7 @@ export class NodeApiClient {
132
214
  bundleSha256: skillPublication.bundleSha256, responseBody: response.body,
133
215
  });
134
216
  if (response.status >= 400)
135
- throw new Error(`Native mutation completed with HTTP ${response.status}`);
217
+ throw new AgentResponseError(response.status, agentErrorBody(response.body));
136
218
  const commandSha256 = receipt.coordination?.requestSha256;
137
219
  if (!commandSha256 || commandSha256 !== commandRequestDigest(admission.payload))
138
220
  throw new Error('Managed mutation command evidence differs');
@@ -393,7 +475,17 @@ export class NodeApiClient {
393
475
  // ── Agent self-upgrade ──
394
476
  /** Stage an exact agent version; the agent restarts into it through its launcher. */
395
477
  async upgradeAgent(request) {
396
- return this.mutate('POST', 'agent-upgrade', request, 15 * 60_000);
478
+ // Every failure is typed: NotDispatchedError (node untouched), AgentResponseError
479
+ // (the agent answered with an error) or DispatchOutcomeUnknownError (sent, no answer).
480
+ let dispatched = false;
481
+ try {
482
+ return (await this.mutateWithEvidence('POST', 'agent-upgrade', request, 15 * 60_000,
483
+ // A stable command id lets an operator find (and reconcile) the lease of a request whose reply was lost.
484
+ /^[a-zA-Z0-9._-]{1,100}$/.test(request.requestId) ? `agent-upgrade-${request.requestId}` : undefined, undefined, false, { pingFirst: true, onDispatch: () => { dispatched = true; } })).response;
485
+ }
486
+ catch (error) {
487
+ throw classifyAgentRequestError(error, dispatched);
488
+ }
397
489
  }
398
490
  // ── Ping ──
399
491
  async ping() {
package/dist/server.js CHANGED
@@ -96,6 +96,7 @@ const v1 = new Hono();
96
96
  v1.use('/auth/*', rateLimitMiddleware({ windowMs: 60_000, max: 20, keyPrefix: 'rl:auth', failClosed: true }));
97
97
  v1.use('/deploy/*', rateLimitMiddleware({ windowMs: 60_000, max: 10, keyPrefix: 'rl:deploy', failClosed: true }));
98
98
  v1.use('/agents/upgrade', rateLimitMiddleware({ windowMs: 60_000, max: 10, keyPrefix: 'rl:agent-upgrade', failClosed: true }));
99
+ v1.use('/agents/upgrade/runs/:id/retry', rateLimitMiddleware({ windowMs: 60_000, max: 10, keyPrefix: 'rl:agent-upgrade', failClosed: true }));
99
100
  v1.use('/self-update/*', rateLimitMiddleware({ windowMs: 60_000, max: 10, keyPrefix: 'rl:selfupdate', failClosed: true }));
100
101
  v1.use('/commands/*', rateLimitMiddleware({ windowMs: 60_000, max: 60, keyPrefix: 'rl:commands' }));
101
102
  v1.use('/quick-command/*', rateLimitMiddleware({ windowMs: 60_000, max: 30, keyPrefix: 'rl:quickcmd' }));