instar 1.3.550 → 1.3.552
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/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +25 -0
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +24 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +32 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/RemoteAckStore.d.ts +71 -0
- package/dist/core/RemoteAckStore.d.ts.map +1 -0
- package/dist/core/RemoteAckStore.js +144 -0
- package/dist/core/RemoteAckStore.js.map +1 -0
- package/dist/core/types.d.ts +24 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +2 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/scheduler/JobScheduler.d.ts +33 -0
- package/dist/scheduler/JobScheduler.d.ts.map +1 -1
- package/dist/scheduler/JobScheduler.js +75 -0
- package/dist/scheduler/JobScheduler.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +186 -0
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +64 -64
- package/src/data/state-coherence-registry.json +12 -0
- package/src/scaffold/templates.ts +2 -0
- package/upgrades/1.3.551.md +23 -0
- package/upgrades/1.3.552.md +23 -0
- package/upgrades/side-effects/ws41-durable-ack.md +37 -0
- package/upgrades/side-effects/ws43-role-guard.md +40 -0
- package/upgrades/ws43-role-guard.eli16.md +47 -0
package/dist/server/routes.js
CHANGED
|
@@ -39,6 +39,7 @@ import { GuardRegistry } from '../monitoring/GuardRegistry.js';
|
|
|
39
39
|
import { isPeerUrlAllowedForCredentials } from './peerUrlGuard.js';
|
|
40
40
|
import { classifyMachineEmptyState } from './poolEmptyState.js';
|
|
41
41
|
import { RemoteCloseAudit } from '../core/RemoteCloseAudit.js';
|
|
42
|
+
import { RemoteAckStore } from '../core/RemoteAckStore.js';
|
|
42
43
|
import { FailureLedger } from '../monitoring/FailureLedger.js';
|
|
43
44
|
import { FailureAttributionEngine } from '../monitoring/FailureAttributionEngine.js';
|
|
44
45
|
import { FailureAnalyzer } from '../monitoring/FailureAnalyzer.js';
|
|
@@ -9235,6 +9236,80 @@ export function createRoutes(ctx) {
|
|
|
9235
9236
|
const attentionPoolCache = new Map();
|
|
9236
9237
|
const ATTENTION_POOL_CACHE_TTL_MS = 3_000;
|
|
9237
9238
|
const ATTENTION_PEER_TIMEOUT_MS = 5_000;
|
|
9239
|
+
// WS4.1 follow-up (CMT-1416): durable operator-bound /ack across machines.
|
|
9240
|
+
// Plain seamlessness boolean read LIVE (mirrors ws21PreferencesPool) — off ⇒
|
|
9241
|
+
// POST /attention/:id/remote-ack 503s, the PATCH precedence guard is inert,
|
|
9242
|
+
// and the store is never touched (strict no-op on a flag-off agent).
|
|
9243
|
+
const ws41DurableAckEnabled = () => (ctx.config.multiMachine?.seamlessness ?? {}).ws41DurableAck === true;
|
|
9244
|
+
const remoteAckStore = new RemoteAckStore(ctx.config.stateDir);
|
|
9245
|
+
const remoteAckLimiter = rateLimiter(60_000, 30);
|
|
9246
|
+
const REMOTE_ACK_GIVE_UP_ATTEMPTS = 50;
|
|
9247
|
+
// Best-effort delivery of one pending ack intent to its owning machine via the
|
|
9248
|
+
// peer's existing PATCH /attention/:id (carrying the X-Instar-Remote-Ack
|
|
9249
|
+
// annotation so the owner runs its precedence guard). Returns the terminal
|
|
9250
|
+
// disposition; 'queued' means the owner is still unreachable (keep the intent).
|
|
9251
|
+
async function deliverRemoteAck(intent) {
|
|
9252
|
+
const machine = (ctx.listPoolMachines?.() ?? []).find((m) => m.machineId === intent.targetMachineId);
|
|
9253
|
+
if (!machine || !machine.lastKnownUrl)
|
|
9254
|
+
return 'queued';
|
|
9255
|
+
const extraAllowlist = ctx.config.multiMachine?.peerUrlAllowlist;
|
|
9256
|
+
if (!isPeerUrlAllowedForCredentials(machine.lastKnownUrl, extraAllowlist).ok)
|
|
9257
|
+
return 'queued';
|
|
9258
|
+
try {
|
|
9259
|
+
const r = await fetch(`${machine.lastKnownUrl}/attention/${encodeURIComponent(intent.itemId)}`, {
|
|
9260
|
+
method: 'PATCH',
|
|
9261
|
+
headers: {
|
|
9262
|
+
Authorization: `Bearer ${ctx.config.authToken}`,
|
|
9263
|
+
'Content-Type': 'application/json',
|
|
9264
|
+
'X-Instar-Remote-Ack': intent.operatorUid || 'operator',
|
|
9265
|
+
},
|
|
9266
|
+
body: JSON.stringify({ status: intent.status }),
|
|
9267
|
+
signal: AbortSignal.timeout(ATTENTION_PEER_TIMEOUT_MS),
|
|
9268
|
+
});
|
|
9269
|
+
if (r.ok)
|
|
9270
|
+
return 'delivered';
|
|
9271
|
+
// 425 = the owner's precedence guard rejected a stale resolve (item since
|
|
9272
|
+
// escalated). 404 = item already gone on the owner. Both are TERMINAL —
|
|
9273
|
+
// drop the intent; retrying can never succeed.
|
|
9274
|
+
if (r.status === 425 || r.status === 404)
|
|
9275
|
+
return 'stale-superseded';
|
|
9276
|
+
return 'queued';
|
|
9277
|
+
}
|
|
9278
|
+
catch {
|
|
9279
|
+
// @silent-fallback-ok — unreachable owner (timeout/offline) keeps the
|
|
9280
|
+
// intent durable for the next drain; never a thrown error in the ack path.
|
|
9281
|
+
return 'queued';
|
|
9282
|
+
}
|
|
9283
|
+
}
|
|
9284
|
+
// Drain every still-pending intent (called on the route's enqueue path AND on
|
|
9285
|
+
// a peer-online signal). Gives up loudly past the attempt cap so a permanently
|
|
9286
|
+
// unreachable owner doesn't leave an intent retrying forever.
|
|
9287
|
+
async function drainRemoteAcks() {
|
|
9288
|
+
if (!ws41DurableAckEnabled())
|
|
9289
|
+
return { delivered: 0, dropped: 0, pending: 0 };
|
|
9290
|
+
let delivered = 0;
|
|
9291
|
+
let dropped = 0;
|
|
9292
|
+
for (const intent of remoteAckStore.list()) {
|
|
9293
|
+
if (intent.attempts >= REMOTE_ACK_GIVE_UP_ATTEMPTS) {
|
|
9294
|
+
remoteAckStore.resolve(intent.itemId, intent.targetMachineId);
|
|
9295
|
+
dropped += 1;
|
|
9296
|
+
continue;
|
|
9297
|
+
}
|
|
9298
|
+
const disposition = await deliverRemoteAck(intent);
|
|
9299
|
+
if (disposition === 'delivered') {
|
|
9300
|
+
remoteAckStore.resolve(intent.itemId, intent.targetMachineId);
|
|
9301
|
+
delivered += 1;
|
|
9302
|
+
}
|
|
9303
|
+
else if (disposition === 'stale-superseded') {
|
|
9304
|
+
remoteAckStore.resolve(intent.itemId, intent.targetMachineId);
|
|
9305
|
+
dropped += 1;
|
|
9306
|
+
}
|
|
9307
|
+
else {
|
|
9308
|
+
remoteAckStore.recordAttempt(intent.itemId, intent.targetMachineId, 'unreachable');
|
|
9309
|
+
}
|
|
9310
|
+
}
|
|
9311
|
+
return { delivered, dropped, pending: remoteAckStore.size };
|
|
9312
|
+
}
|
|
9238
9313
|
// P17 coalesce key: items sharing this key across machines collapse to ONE
|
|
9239
9314
|
// merged row (a pool-wide event whose per-machine tripwires each raised an
|
|
9240
9315
|
// item). Mirrors the WS3.3 episode-key pattern: prefer an explicit
|
|
@@ -9349,6 +9424,14 @@ export function createRoutes(ctx) {
|
|
|
9349
9424
|
// per-machine budgets remain the write-side bound). Plain GET stays a
|
|
9350
9425
|
// back-compatible object.
|
|
9351
9426
|
if (req.query.scope === 'pool') {
|
|
9427
|
+
// WS4.1 follow-up: opportunistically drain any pending durable remote-acks
|
|
9428
|
+
// — the pool read is exactly when a previously-dark owner is likely back
|
|
9429
|
+
// online (the dashboard polls this). Fire-and-forget; never blocks the read.
|
|
9430
|
+
if (ws41DurableAckEnabled() && remoteAckStore.size > 0) {
|
|
9431
|
+
void drainRemoteAcks().catch(() => {
|
|
9432
|
+
/* @silent-fallback-ok — drain is best-effort; failures keep intents durable */
|
|
9433
|
+
});
|
|
9434
|
+
}
|
|
9352
9435
|
const merged = await attentionPoolMerge(localItems, status);
|
|
9353
9436
|
res.json(merged);
|
|
9354
9437
|
return;
|
|
@@ -9377,6 +9460,35 @@ export function createRoutes(ctx) {
|
|
|
9377
9460
|
res.status(400).json({ error: `"status" must be one of: ${ATTENTION_STATUSES.join(', ')} (aliases: resolved, done, ack, in_progress, wontdo, reopen)` });
|
|
9378
9461
|
return;
|
|
9379
9462
|
}
|
|
9463
|
+
// WS4.1 follow-up (CMT-1416) receiver-side precedence guard. When a PATCH
|
|
9464
|
+
// arrives carrying X-Instar-Remote-Ack (a relayed operator ack from a peer,
|
|
9465
|
+
// possibly enqueued while THIS machine was offline), the OWNER's CURRENT
|
|
9466
|
+
// item state wins: a stale resolve against an item that has SINCE escalated
|
|
9467
|
+
// to HIGH/URGENT is rejected (and surfaced) rather than silently applied.
|
|
9468
|
+
// Gated on ws41DurableAck — off ⇒ this whole block is inert (the header is
|
|
9469
|
+
// ignored and the PATCH behaves exactly as before). The header is only an
|
|
9470
|
+
// ANNOTATION; the Bearer auth (already enforced upstream) is the authority.
|
|
9471
|
+
if (req.get('X-Instar-Remote-Ack') && ws41DurableAckEnabled()) {
|
|
9472
|
+
const current = ctx.telegram.getAttentionItem(req.params.id);
|
|
9473
|
+
if (!current) {
|
|
9474
|
+
res.status(404).json({ error: 'Attention item not found' });
|
|
9475
|
+
return;
|
|
9476
|
+
}
|
|
9477
|
+
const currentPriority = String(current.priority ?? 'NORMAL');
|
|
9478
|
+
const downgrades = status === 'DONE' || status === 'WONT_DO' || status === 'ACKNOWLEDGED';
|
|
9479
|
+
if ((currentPriority === 'HIGH' || currentPriority === 'URGENT') && downgrades) {
|
|
9480
|
+
// Reconcile precedence: never auto-resolve a since-escalated critical
|
|
9481
|
+
// item via a stale relayed ack. Surfaced to the relayer (425) so the
|
|
9482
|
+
// RemoteAckStore drops the stale intent rather than retrying forever.
|
|
9483
|
+
res.status(425).json({
|
|
9484
|
+
error: 'stale-superseded',
|
|
9485
|
+
reason: `item has escalated to ${currentPriority} since the ack was issued — current state wins`,
|
|
9486
|
+
itemId: req.params.id,
|
|
9487
|
+
currentPriority,
|
|
9488
|
+
});
|
|
9489
|
+
return;
|
|
9490
|
+
}
|
|
9491
|
+
}
|
|
9380
9492
|
const success = await ctx.telegram.updateAttentionStatus(req.params.id, status);
|
|
9381
9493
|
if (!success) {
|
|
9382
9494
|
res.status(404).json({ error: 'Attention item not found' });
|
|
@@ -9385,6 +9497,80 @@ export function createRoutes(ctx) {
|
|
|
9385
9497
|
const item = ctx.telegram.getAttentionItem(req.params.id);
|
|
9386
9498
|
res.json(item);
|
|
9387
9499
|
});
|
|
9500
|
+
// WS4.1 follow-up (CMT-1416): durable operator-bound /ack for an attention
|
|
9501
|
+
// item OWNED BY ANOTHER MACHINE. The dashboard sends this when the operator
|
|
9502
|
+
// acks a pooled (remote) item; the ack is delivered to the owner immediately
|
|
9503
|
+
// when reachable, else persisted (with the authenticated operator principal)
|
|
9504
|
+
// and re-delivered when the owner returns — so the operator's intent survives
|
|
9505
|
+
// a briefly-dark owner instead of evaporating. Bearer auth (already enforced)
|
|
9506
|
+
// is the operator authority; the topicId resolves the bound operator uid for
|
|
9507
|
+
// the owner's revalidation. Mutating mesh verb → rate-limited like remote-close.
|
|
9508
|
+
router.post('/attention/:id/remote-ack', remoteAckLimiter, async (req, res) => {
|
|
9509
|
+
if (!ws41DurableAckEnabled()) {
|
|
9510
|
+
res.status(503).json({ error: 'WS4.1 durable remote-ack is disabled (multiMachine.seamlessness.ws41DurableAck)' });
|
|
9511
|
+
return;
|
|
9512
|
+
}
|
|
9513
|
+
const { machineId, status: rawStatus, topicId } = (req.body ?? {});
|
|
9514
|
+
// machineId is a registry LOOKUP KEY ONLY — charset-clamped, never a URL.
|
|
9515
|
+
if (typeof machineId !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(machineId)) {
|
|
9516
|
+
res.status(400).json({ error: 'machineId required (the owning machine)' });
|
|
9517
|
+
return;
|
|
9518
|
+
}
|
|
9519
|
+
const status = normalizeAttentionStatus(rawStatus);
|
|
9520
|
+
if (!status) {
|
|
9521
|
+
res.status(400).json({ error: `"status" must be one of: ${ATTENTION_STATUSES.join(', ')}` });
|
|
9522
|
+
return;
|
|
9523
|
+
}
|
|
9524
|
+
// Self-ack is a plain local PATCH — refuse the mesh verb for own items so a
|
|
9525
|
+
// single-machine agent (or an item that lives here) never round-trips.
|
|
9526
|
+
if (ctx.meshSelfId && machineId === ctx.meshSelfId) {
|
|
9527
|
+
res.status(400).json({ error: 'item is owned by this machine — use PATCH /attention/:id' });
|
|
9528
|
+
return;
|
|
9529
|
+
}
|
|
9530
|
+
// Bind the authenticated operator principal from the topic-operator store
|
|
9531
|
+
// when a topicId is supplied (the same VERIFIED-binding source as Operator
|
|
9532
|
+
// Binding — never a name from content). Absent/unresolvable → 'operator'.
|
|
9533
|
+
let operatorUid = 'operator';
|
|
9534
|
+
let operatorDisplayName;
|
|
9535
|
+
if (typeof topicId === 'number' || (typeof topicId === 'string' && topicId.trim())) {
|
|
9536
|
+
const op = ctx.topicOperatorStore?.asVerifiedOperator(topicId);
|
|
9537
|
+
if (op) {
|
|
9538
|
+
operatorUid = op.uid;
|
|
9539
|
+
operatorDisplayName = op.names?.[0];
|
|
9540
|
+
}
|
|
9541
|
+
}
|
|
9542
|
+
const intent = { itemId: req.params.id, targetMachineId: machineId, status, operatorUid, operatorDisplayName };
|
|
9543
|
+
// Try immediate delivery; on success no durable row is needed.
|
|
9544
|
+
const disposition = await deliverRemoteAck(intent);
|
|
9545
|
+
if (disposition === 'delivered') {
|
|
9546
|
+
res.json({ ok: true, delivered: true, itemId: req.params.id, machineId });
|
|
9547
|
+
return;
|
|
9548
|
+
}
|
|
9549
|
+
if (disposition === 'stale-superseded') {
|
|
9550
|
+
res.status(409).json({ ok: false, staleSuperseded: true, itemId: req.params.id, machineId, reason: 'owner rejected — item has since escalated; current state wins' });
|
|
9551
|
+
return;
|
|
9552
|
+
}
|
|
9553
|
+
// Owner unreachable: persist the intent durably and report it queued.
|
|
9554
|
+
remoteAckStore.enqueue(intent);
|
|
9555
|
+
res.json({ ok: true, queued: true, itemId: req.params.id, machineId, reason: 'owner unreachable — ack persisted and will be delivered when it returns', pending: remoteAckStore.size });
|
|
9556
|
+
});
|
|
9557
|
+
// Pending durable remote-acks (observability + a manual drain trigger). Two
|
|
9558
|
+
// segments past /attention so it never collides with GET /attention/:id.
|
|
9559
|
+
router.get('/attention/_remote-ack/pending', (_req, res) => {
|
|
9560
|
+
if (!ws41DurableAckEnabled()) {
|
|
9561
|
+
res.status(503).json({ error: 'WS4.1 durable remote-ack is disabled (multiMachine.seamlessness.ws41DurableAck)' });
|
|
9562
|
+
return;
|
|
9563
|
+
}
|
|
9564
|
+
res.json({ pending: remoteAckStore.list(), count: remoteAckStore.size });
|
|
9565
|
+
});
|
|
9566
|
+
router.post('/attention/_remote-ack/drain', async (_req, res) => {
|
|
9567
|
+
if (!ws41DurableAckEnabled()) {
|
|
9568
|
+
res.status(503).json({ error: 'WS4.1 durable remote-ack is disabled (multiMachine.seamlessness.ws41DurableAck)' });
|
|
9569
|
+
return;
|
|
9570
|
+
}
|
|
9571
|
+
const result = await drainRemoteAcks();
|
|
9572
|
+
res.json({ ok: true, ...result });
|
|
9573
|
+
});
|
|
9388
9574
|
router.delete('/attention/:id', async (req, res) => {
|
|
9389
9575
|
if (!ctx.telegram) {
|
|
9390
9576
|
res.status(503).json({ error: 'Telegram not configured' });
|