evolcore 0.0.39 → 0.0.40
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/CHANGELOG.md +49 -0
- package/README.md +31 -20
- package/bin/ec-safe-output.js +14 -2
- package/dist/agents/baseagent-availability.js +1 -0
- package/dist/agents/claude-runner.js +32 -46
- package/dist/agents/codex-runner.js +51 -27
- package/dist/agents/ecagent-runner.js +18 -7
- package/dist/agents/gemini-runner.js +1 -2
- package/dist/agents/runner-types.js +28 -6
- package/dist/aun/aid/client.js +1 -1
- package/dist/aun/aid/control-aid.js +23 -98
- package/dist/aun/aid/ownership-operation.js +1 -3
- package/dist/aun/group/invite-service.js +6 -0
- package/dist/aun/msg/p2p.js +8 -12
- package/dist/channels/aun.js +66 -48
- package/dist/channels/dingtalk.js +8 -2
- package/dist/channels/feishu.js +6 -1
- package/dist/channels/wecom.js +7 -3
- package/dist/cli/agent-create.js +23 -4
- package/dist/cli/aid-bind-command.js +4 -1
- package/dist/cli/daemon-commands.js +242 -17
- package/dist/cli/index.js +25 -13
- package/dist/cli/memory-command.js +33 -14
- package/dist/cli/restart-monitor.js +35 -13
- package/dist/cli/task-context.js +1 -0
- package/dist/config/config-field-policy.js +1 -0
- package/dist/contracts/ecweb-service-protocol.js +44 -0
- package/dist/core/agent-runtime.js +6 -0
- package/dist/core/auth/agent-delegation.js +7 -4
- package/dist/core/auth/auth-gateway.js +3 -1
- package/dist/core/auth/authorization-audit.js +5 -1
- package/dist/core/auth/debug-context.js +5 -1
- package/dist/core/auth/operation-catalog.js +16 -0
- package/dist/core/command/command-handler.js +7 -1
- package/dist/core/command/menu-catalog.js +48 -12
- package/dist/core/command/menu-handler.js +72 -4
- package/dist/core/command/menu-protocol.js +20 -1
- package/dist/core/command/slash-gate.js +17 -3
- package/dist/core/command/slash-handler.js +90 -7
- package/dist/core/event-catalog.js +2 -0
- package/dist/core/interaction-router.js +2 -1
- package/dist/core/ipc-registration.js +26 -7
- package/dist/core/memory/batch-meta.js +2 -0
- package/dist/core/memory/consolidation-context.js +42 -51
- package/dist/core/memory/consolidation-ops-records.js +39 -7
- package/dist/core/memory/consolidation-trigger.js +71 -64
- package/dist/core/memory/consolidation.js +226 -20
- package/dist/core/memory/memory-writer.js +160 -34
- package/dist/core/memory/relation-idle-monitor.js +287 -0
- package/dist/core/memory/relation-sweep.js +206 -0
- package/dist/core/message/aun-ipc-sender.js +5 -0
- package/dist/core/message/message-bridge.js +64 -55
- package/dist/core/message/message-log.js +17 -0
- package/dist/core/message/message-queue.js +6 -0
- package/dist/core/message/outbound.js +70 -0
- package/dist/core/message/peer-mode.js +7 -2
- package/dist/core/message/response-engine.js +341 -73
- package/dist/core/ownership/owner-resolver.js +384 -178
- package/dist/core/permission/ec-command-parser.js +19 -3
- package/dist/core/permission/tool-policy.js +7 -7
- package/dist/core/role/runtime-policy.js +64 -0
- package/dist/core/session/session-manager.js +25 -5
- package/dist/core/system-session/memory.js +3 -18
- package/dist/ecweb/basic-settings-service.js +709 -0
- package/dist/ecweb/providers-service.js +172 -0
- package/dist/index.js +92 -4
- package/dist/ipc.js +78 -11
- package/dist/stats/db.js +33 -0
- package/dist/stats/index.js +2 -2
- package/dist/stats/query.js +246 -0
- package/dist/stats/writer.js +41 -1
- package/dist/trigger/application-service.js +12 -0
- package/dist/trigger/authorization.js +2 -2
- package/dist/trigger/history.js +36 -0
- package/dist/trigger/scheduler.js +3 -0
- package/dist/utils/ecweb-supervisor.js +31 -10
- package/dist/utils/error-utils.js +8 -4
- package/dist/utils/evolcore-version.js +3 -2
- package/dist/utils/host-info.js +52 -0
- package/ecagent/dist/harness/agent-harness.js +4 -1
- package/kits/docs/evolcore/INDEX.md +1 -1
- package/kits/docs/evolcore/agent.md +1 -1
- package/kits/docs/evolcore/aid.md +29 -12
- package/kits/docs/evolcore/config.md +9 -7
- package/kits/docs/evolcore/memory-architecture.md +3 -4
- package/kits/docs/evolcore/memory-consolidation.md +28 -7
- package/kits/docs/evolcore/memory-tags.md +1 -1
- package/kits/docs/evolcore/memory.md +1 -1
- package/kits/docs/evolcore/menu-client.md +1 -1
- package/kits/docs/evolcore/menu-protocol.md +6 -0
- package/kits/docs/evolcore/msg.md +1 -1
- package/kits/eck_manifest.json +3 -3
- package/kits/rules/04-relation.md +3 -2
- package/kits/schemas/agent-config.schema.13.json +5 -1
- package/kits/templates/memory-consolidation/generalize.md +32 -4
- package/kits/templates/memory-consolidation/notes.md +2 -2
- package/kits/templates/memory-consolidation/promote.md +10 -2
- package/kits/templates/system-fragments/baseagent.md +5 -0
- package/kits/templates/system-fragments/memory.md +12 -3
- package/kits/templates/system-fragments/session.md +1 -1
- package/kits/{rules/07-memory-and-thread.md → templates/system-fragments/thread.md} +17 -80
- package/package.json +3 -3
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
3
4
|
import { resolvePaths } from '../../paths.js';
|
|
4
5
|
import { isValidAid } from '../../aun/aid/validation.js';
|
|
5
|
-
import { atomicReadJson
|
|
6
|
+
import { atomicReadJson } from '../../utils/atomic-write.js';
|
|
6
7
|
import { configuredOwnerList as readConfiguredOwnerList } from '../../config/owner-config.js';
|
|
7
8
|
import { logger } from '../../utils/logger.js';
|
|
8
9
|
import { hasTrustedPrincipal } from '../auth/authenticated-actor.js';
|
|
9
10
|
export const OWNERSHIP_CACHE_MAX_AGE_MS = 30 * 60_000;
|
|
10
11
|
export const OWNERSHIP_AUTHORIZATION_MAX_AGE_MS = 5 * 60_000;
|
|
11
|
-
|
|
12
|
+
// Background refreshes should heal a short outage without retrying on every tool call.
|
|
13
|
+
const OWNERSHIP_BACKGROUND_REFRESH_MIN_INTERVAL_MS = 60_000;
|
|
12
14
|
const CREATION_BIND_OPERATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
13
15
|
/** Only use the task returned by this create's bind call. Never select a latest task. */
|
|
14
16
|
export function creationOwnershipBinding(task, subjectAid, ownerAid) {
|
|
@@ -134,7 +136,7 @@ export class OwnerResolver {
|
|
|
134
136
|
subject.taskEvents.delete(subject.taskEvents.values().next().value);
|
|
135
137
|
}
|
|
136
138
|
this.invalidate(subject, 'Ownership changed; fresh consensus is required');
|
|
137
|
-
const task = this.task(record);
|
|
139
|
+
const task = this.task(record, event);
|
|
138
140
|
logger.info(`[ownership] ${event} ${JSON.stringify({ subjectAid, ownerAid: task?.owner_aid,
|
|
139
141
|
operationId: task?.operation_id, taskStatus: task?.status, step: task?.step,
|
|
140
142
|
code: task?.code, reason: task?.reason })}`);
|
|
@@ -144,10 +146,6 @@ export class OwnerResolver {
|
|
|
144
146
|
subject.view.task = task;
|
|
145
147
|
subject.view.requestStatus = 'submitted';
|
|
146
148
|
}
|
|
147
|
-
try {
|
|
148
|
-
this.persist(subjectAid, subject);
|
|
149
|
-
}
|
|
150
|
-
catch { }
|
|
151
149
|
this.schedule(subjectAid, subject);
|
|
152
150
|
}));
|
|
153
151
|
}
|
|
@@ -200,9 +198,6 @@ export class OwnerResolver {
|
|
|
200
198
|
if (subject.timer)
|
|
201
199
|
clearTimeout(subject.timer);
|
|
202
200
|
subject.timer = undefined;
|
|
203
|
-
if (subject.pendingRefreshTimer)
|
|
204
|
-
clearTimeout(subject.pendingRefreshTimer);
|
|
205
|
-
subject.pendingRefreshTimer = undefined;
|
|
206
201
|
}
|
|
207
202
|
subject.view.connected = connected;
|
|
208
203
|
if (connected)
|
|
@@ -216,9 +211,6 @@ export class OwnerResolver {
|
|
|
216
211
|
if (subject.timer)
|
|
217
212
|
clearTimeout(subject.timer);
|
|
218
213
|
subject.timer = undefined;
|
|
219
|
-
if (subject.pendingRefreshTimer)
|
|
220
|
-
clearTimeout(subject.pendingRefreshTimer);
|
|
221
|
-
subject.pendingRefreshTimer = undefined;
|
|
222
214
|
subject.taskEvents.clear();
|
|
223
215
|
subject.requests.clear();
|
|
224
216
|
subject.decisionTasks.clear();
|
|
@@ -293,12 +285,6 @@ export class OwnerResolver {
|
|
|
293
285
|
&& view.checkedAt !== undefined && view.checkedAt <= now
|
|
294
286
|
? view.ownerAid && now - view.checkedAt < OWNERSHIP_AUTHORIZATION_MAX_AGE_MS ? 'verified' : 'expired'
|
|
295
287
|
: 'unavailable';
|
|
296
|
-
if (subject.persistenceError) {
|
|
297
|
-
view.requestStatus = 'error';
|
|
298
|
-
view.reason = subject.persistenceError;
|
|
299
|
-
view.synchronization.status = 'unavailable';
|
|
300
|
-
view.authorizationStatus = 'unavailable';
|
|
301
|
-
}
|
|
302
288
|
return view;
|
|
303
289
|
}
|
|
304
290
|
async refresh(subjectAid, options = {}) {
|
|
@@ -307,7 +293,10 @@ export class OwnerResolver {
|
|
|
307
293
|
if (subject.timer)
|
|
308
294
|
clearTimeout(subject.timer);
|
|
309
295
|
subject.timer = undefined;
|
|
310
|
-
const
|
|
296
|
+
const inFlight = subject.refreshing?.epoch === subject.epoch ? subject.refreshing : undefined;
|
|
297
|
+
const refreshing = inFlight?.promise;
|
|
298
|
+
const initialEpoch = subject.epoch;
|
|
299
|
+
const initialCheckedAt = subject.view.checkedAt;
|
|
311
300
|
const maxAgeMs = options.maxAgeMs ?? this.options.lifetimeMs ?? OWNERSHIP_CACHE_MAX_AGE_MS;
|
|
312
301
|
const isFresh = () => {
|
|
313
302
|
const { checkedAt, validUntil, consensusStatus } = subject.view;
|
|
@@ -317,32 +306,48 @@ export class OwnerResolver {
|
|
|
317
306
|
&& now - checkedAt < maxAgeMs;
|
|
318
307
|
};
|
|
319
308
|
if (!refreshing && !options.force && isFresh()) {
|
|
309
|
+
if (options.allowTransient
|
|
310
|
+
&& this.now() - (subject.lastRefreshAttemptAt ?? 0) >= OWNERSHIP_BACKGROUND_REFRESH_MIN_INTERVAL_MS) {
|
|
311
|
+
this.schedule(subjectAid, subject, true);
|
|
312
|
+
}
|
|
320
313
|
return this.snapshot(subjectAid);
|
|
321
314
|
}
|
|
322
|
-
const
|
|
323
|
-
|
|
315
|
+
const attemptId = inFlight?.attemptId ?? ++subject.query;
|
|
316
|
+
const attemptedAt = inFlight?.attemptedAt ?? this.now();
|
|
317
|
+
const promise = refreshing ?? this.refreshSubject(subjectAid, subject, attemptId, attemptedAt);
|
|
318
|
+
subject.refreshing = { epoch: subject.epoch, attemptId, attemptedAt, promise };
|
|
324
319
|
try {
|
|
325
320
|
await promise;
|
|
326
|
-
if (
|
|
321
|
+
if (subject.epoch !== initialEpoch && subject.view.consensusStatus === 'consensus'
|
|
322
|
+
&& subject.view.checkedAt === initialCheckedAt) {
|
|
323
|
+
this.handleRefreshFailure(subjectAid, subject, Object.assign(new Error('Ownership refresh was interrupted by a connection state change'), { code: 'CLIENT_DISCONNECTED' }), attemptedAt, attemptId);
|
|
324
|
+
}
|
|
325
|
+
if (!subject.view.connected && subject.view.consensusStatus === 'consensus' && !isFresh()
|
|
326
|
+
&& subject.view.refreshFailure?.fallbackUsed !== true) {
|
|
327
327
|
this.invalidate(subject, 'Ownership cache expired while disconnected');
|
|
328
328
|
}
|
|
329
|
+
if (!options.allowTransient && subject.view.refreshFailure?.attemptId === attemptId
|
|
330
|
+
&& subject.view.refreshFailure.fallbackUsed) {
|
|
331
|
+
return this.unavailableSnapshot(subjectAid, subject.view.refreshFailure);
|
|
332
|
+
}
|
|
329
333
|
return this.snapshot(subjectAid);
|
|
330
334
|
}
|
|
331
335
|
finally {
|
|
332
336
|
if (subject.refreshing?.promise === promise)
|
|
333
337
|
subject.refreshing = undefined;
|
|
334
|
-
this.schedulePendingRefresh(subjectAid, subject);
|
|
335
338
|
}
|
|
336
339
|
}
|
|
337
|
-
async refreshSubject(subjectAid, subject) {
|
|
340
|
+
async refreshSubject(subjectAid, subject, attemptId, attemptedAt) {
|
|
338
341
|
const client = subject.client;
|
|
342
|
+
subject.lastRefreshAttemptAt = attemptedAt;
|
|
339
343
|
if (!client || !subject.view.connected) {
|
|
340
|
-
|
|
344
|
+
const reason = 'Subject AID has no connected SDK client';
|
|
345
|
+
this.handleRefreshFailure(subjectAid, subject, { code: 'CLIENT_DISCONNECTED', message: reason }, attemptedAt, attemptId);
|
|
341
346
|
return this.snapshot(subjectAid);
|
|
342
347
|
}
|
|
343
348
|
const generation = subject.generation;
|
|
344
349
|
const epoch = subject.epoch;
|
|
345
|
-
const query =
|
|
350
|
+
const query = attemptId;
|
|
346
351
|
let timeout;
|
|
347
352
|
try {
|
|
348
353
|
const consensus = await Promise.race([
|
|
@@ -361,6 +366,16 @@ export class OwnerResolver {
|
|
|
361
366
|
if (consensus.sub_aid !== subjectAid || (consensus.status === 'consensus' && !ownerAid)) {
|
|
362
367
|
throw new Error('Ownership consensus contains a mismatched subject or invalid owner');
|
|
363
368
|
}
|
|
369
|
+
if (consensus.status === 'consensus' && consensus.lock !== 'locked') {
|
|
370
|
+
throw Object.assign(new OwnershipError('CONSENSUS_MISMATCH', 'Ownership consensus reports an unlocked relation'), { retryable: false });
|
|
371
|
+
}
|
|
372
|
+
if (consensus.status === 'unavailable' && consensus.retryable === true) {
|
|
373
|
+
const reason = consensus.reason ?? 'Ownership consensus is temporarily unavailable';
|
|
374
|
+
this.handleRefreshFailure(subjectAid, subject, {
|
|
375
|
+
code: consensus.code ?? 'UNAVAILABLE', message: reason, retryable: true,
|
|
376
|
+
}, attemptedAt, attemptId);
|
|
377
|
+
return this.snapshot(subjectAid);
|
|
378
|
+
}
|
|
364
379
|
const previousOwner = this.owner(subjectAid);
|
|
365
380
|
const previousStatus = subject.view.consensusStatus;
|
|
366
381
|
const checkedAt = this.now();
|
|
@@ -381,6 +396,7 @@ export class OwnerResolver {
|
|
|
381
396
|
|| subject.view.consensus?.relation_version !== consensus.relation_version
|
|
382
397
|
|| subject.view.consensus?.lock !== consensus.lock ? 1 : 0),
|
|
383
398
|
reason: consensus.reason,
|
|
399
|
+
refreshFailure: undefined,
|
|
384
400
|
synchronization: {
|
|
385
401
|
status: consensus.status === 'unavailable' || consensus.status === 'not_applicable' ? 'unavailable'
|
|
386
402
|
: consensus.status === 'invalid' ? 'conflict'
|
|
@@ -393,12 +409,12 @@ export class OwnerResolver {
|
|
|
393
409
|
this.notify(subjectAid, ownerAid);
|
|
394
410
|
if (ownerAid === subject.view.requestedOwnerAid && subject.view.requestStatus === 'interrupted') {
|
|
395
411
|
subject.view.requestStatus = 'submitted';
|
|
396
|
-
this.persist(subjectAid, subject);
|
|
397
412
|
}
|
|
398
413
|
}
|
|
399
414
|
catch (error) {
|
|
400
415
|
if (subject.client === client && subject.generation === generation && subject.epoch === epoch && subject.query === query) {
|
|
401
|
-
|
|
416
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
417
|
+
this.handleRefreshFailure(subjectAid, subject, error, attemptedAt, attemptId);
|
|
402
418
|
}
|
|
403
419
|
}
|
|
404
420
|
finally {
|
|
@@ -407,14 +423,75 @@ export class OwnerResolver {
|
|
|
407
423
|
}
|
|
408
424
|
return this.snapshot(subjectAid);
|
|
409
425
|
}
|
|
426
|
+
handleRefreshFailure(subjectAid, subject, error, attemptedAt, attemptId) {
|
|
427
|
+
const details = error && typeof error === 'object' ? error : {};
|
|
428
|
+
const reason = error instanceof Error ? error.message
|
|
429
|
+
: typeof details.message === 'string' ? details.message : String(error);
|
|
430
|
+
const code = String(details.stringCode ?? details.code ?? 'OWNERSHIP_REFRESH_FAILED');
|
|
431
|
+
const transient = this.isTransientRefreshFailure(error);
|
|
432
|
+
// Keep the shared cache intact for ordinary callers; strict waiters receive
|
|
433
|
+
// an unavailable view for this attempt and cannot consume the fallback.
|
|
434
|
+
const fallbackUsed = transient && this.canUseTransientAuthorization(subject, attemptedAt);
|
|
435
|
+
if (!fallbackUsed)
|
|
436
|
+
this.invalidate(subject, reason);
|
|
437
|
+
subject.view.refreshFailure = {
|
|
438
|
+
kind: transient ? 'transient' : 'permanent', code, reason, attemptedAt, attemptId, fallbackUsed,
|
|
439
|
+
};
|
|
440
|
+
if (fallbackUsed) {
|
|
441
|
+
logger.warn(`[ownership] transient refresh failure subject=${subjectAid} attempt=${attemptId} code=${code} fallback=true reason=${reason}`);
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
logger.warn(`[ownership] refresh failed subject=${subjectAid} attempt=${attemptId} code=${code} transient=${transient} reason=${reason}`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
unavailableSnapshot(subjectAid, failure) {
|
|
448
|
+
const view = this.snapshot(subjectAid);
|
|
449
|
+
view.ownerAid = undefined;
|
|
450
|
+
view.consensusStatus = 'unavailable';
|
|
451
|
+
view.authorizationStatus = 'unavailable';
|
|
452
|
+
view.validUntil = undefined;
|
|
453
|
+
view.reason = failure.reason;
|
|
454
|
+
view.synchronization = { status: 'unavailable' };
|
|
455
|
+
return view;
|
|
456
|
+
}
|
|
457
|
+
canUseTransientAuthorization(subject, attemptedAt) {
|
|
458
|
+
const view = subject.view;
|
|
459
|
+
const now = this.now();
|
|
460
|
+
return view.consensusStatus === 'consensus'
|
|
461
|
+
&& !!view.ownerAid
|
|
462
|
+
&& view.checkedAt !== undefined
|
|
463
|
+
&& view.checkedAt <= attemptedAt
|
|
464
|
+
&& view.checkedAt <= now
|
|
465
|
+
&& now - view.checkedAt < OWNERSHIP_AUTHORIZATION_MAX_AGE_MS
|
|
466
|
+
&& (view.validUntil ?? 0) > now;
|
|
467
|
+
}
|
|
468
|
+
isTransientRefreshFailure(error) {
|
|
469
|
+
const details = error && typeof error === 'object' ? error : {};
|
|
470
|
+
const code = String(details.code ?? details.stringCode ?? '').toUpperCase();
|
|
471
|
+
const message = error instanceof Error ? error.message
|
|
472
|
+
: typeof details.message === 'string' ? details.message : String(error);
|
|
473
|
+
if (['INVALID', 'CONFLICT', 'FORBIDDEN', 'NOT_APPLICABLE', 'OWNER_LOCKED', 'TASK_MISMATCH',
|
|
474
|
+
'CONSENSUS_MISMATCH', 'UNSUPPORTED_PROTOCOL', 'PERSISTENCE_UNAVAILABLE'].includes(code))
|
|
475
|
+
return false;
|
|
476
|
+
if (/projection.*(?:does not match|conflict)|(?:agent|owner)\.md changed (?:while|during)|NS relation changed .*consensus check|digest does not match authoritative observation|Storage material changed during authoritative read/i.test(message))
|
|
477
|
+
return false;
|
|
478
|
+
if (details.retryable === false)
|
|
479
|
+
return false;
|
|
480
|
+
if (details.retryable === true)
|
|
481
|
+
return true;
|
|
482
|
+
return code === 'TIMEOUT' || code === 'NETWORK_ERROR' || code === 'UNAVAILABLE'
|
|
483
|
+
|| code === 'CLIENT_DISCONNECTED' || code === 'CONNECTION_FAILED'
|
|
484
|
+
|| code === 'GATEWAY_UNAVAILABLE' || code === 'CONNECTION_UNAVAILABLE'
|
|
485
|
+
|| code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'ETIMEDOUT'
|
|
486
|
+
|| code === 'ENETUNREACH' || code === 'EAI_AGAIN' || code === 'ENOTFOUND' || code === 'EHOSTUNREACH'
|
|
487
|
+
|| /timed out|timeout|connection reset|connection refused|connection closed|socket hang up|not connected|network error|fetch failed|gateway unavailable|gateway timeout|temporarily unavailable/i.test(message);
|
|
488
|
+
}
|
|
410
489
|
async bind(subjectAid, ownerAid) {
|
|
411
|
-
this.
|
|
490
|
+
this.assertAid(subjectAid);
|
|
412
491
|
this.assertAid(ownerAid);
|
|
413
492
|
if (ownerAid === subjectAid)
|
|
414
493
|
throw new OwnershipError('INVALID_OWNER', 'Subject cannot own itself');
|
|
415
494
|
const subject = this.subject(subjectAid);
|
|
416
|
-
if (subject.persistenceError)
|
|
417
|
-
throw new OwnershipError('persistence_unavailable', subject.persistenceError);
|
|
418
495
|
if (subject.binding) {
|
|
419
496
|
if (subject.binding.ownerAid !== ownerAid)
|
|
420
497
|
throw new OwnershipError('BIND_CONFLICT', 'Another owner binding is in progress');
|
|
@@ -431,43 +508,39 @@ export class OwnerResolver {
|
|
|
431
508
|
}
|
|
432
509
|
}
|
|
433
510
|
async decide(subjectAid, operationId, decision, reason) {
|
|
434
|
-
this.assertLocalSubject(subjectAid);
|
|
435
511
|
const subject = this.subject(subjectAid);
|
|
436
|
-
const client = subject.client;
|
|
437
|
-
if (!client || !subject.view.connected) {
|
|
438
|
-
throw new OwnershipError('SUBJECT_UNAVAILABLE', 'Subject AID SDK client is not ready');
|
|
439
|
-
}
|
|
440
|
-
const request = subject.requests.get(operationId) ?? subject.decisionTasks.get(operationId);
|
|
441
|
-
if (!request) {
|
|
442
|
-
throw new OwnershipError('REQUEST_NOT_FOUND', 'No SDK-verified binding request matches this operation ID; use bind status');
|
|
443
|
-
}
|
|
444
512
|
const generation = subject.generation;
|
|
445
513
|
logger.info(`[ownership] ${decision} requested local=${subjectAid} operationId=${operationId}`);
|
|
446
514
|
try {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
515
|
+
return await this.withSubjectClient(subjectAid, async (client, attached) => {
|
|
516
|
+
// SDK 0.5.24 can recover an authenticated request from message history;
|
|
517
|
+
// this in-memory projection is only used to detect mismatched results.
|
|
518
|
+
const request = subject.requests.get(operationId) ?? subject.decisionTasks.get(operationId);
|
|
519
|
+
const result = decision === 'accept'
|
|
520
|
+
? await client.acceptOwnershipRequest(operationId)
|
|
521
|
+
: await client.rejectOwnershipRequest(operationId, reason);
|
|
522
|
+
if (subject.generation !== generation || (attached && subject.client !== client)) {
|
|
523
|
+
throw new OwnershipError('CLIENT_REPLACED', 'Subject SDK client changed during approval; query status before retrying');
|
|
524
|
+
}
|
|
525
|
+
const task = this.bindingTask(result);
|
|
526
|
+
if (!task || (request && !this.sameBindingTask(task, request))) {
|
|
527
|
+
throw new OwnershipError('TASK_MISMATCH', 'SDK returned a different ownership decision task');
|
|
528
|
+
}
|
|
529
|
+
subject.view.decisionTask = task;
|
|
530
|
+
subject.decisionTasks.set(operationId, task);
|
|
531
|
+
subject.requests.delete(operationId);
|
|
532
|
+
delete subject.view.decisionConsensus;
|
|
533
|
+
if (task.sub_aid === subjectAid) {
|
|
534
|
+
this.invalidate(subject, 'Binding decision submitted; fresh consensus is required');
|
|
535
|
+
this.schedule(subjectAid, subject);
|
|
536
|
+
}
|
|
537
|
+
logger.info(`[ownership] ${decision} result ${JSON.stringify(task)}`);
|
|
538
|
+
if (task.status === 'error' || task.status === 'stopped' || (decision === 'accept' && task.status === 'rejected')) {
|
|
539
|
+
throw Object.assign(new OwnershipError(task.code ?? 'OWNERSHIP_DECISION_FAILED', task.reason ?? `Ownership task ${task.status}`), { retryable: task.retryable });
|
|
540
|
+
}
|
|
541
|
+
subject.view.decisionConsensus = await client.checkOwnershipConsensus(task.sub_aid);
|
|
542
|
+
return this.snapshot(subjectAid);
|
|
543
|
+
});
|
|
471
544
|
}
|
|
472
545
|
catch (error) {
|
|
473
546
|
logger.warn(`[ownership] ${decision} failed ${JSON.stringify({
|
|
@@ -476,94 +549,219 @@ export class OwnerResolver {
|
|
|
476
549
|
throw error;
|
|
477
550
|
}
|
|
478
551
|
}
|
|
552
|
+
async status(subjectAid) {
|
|
553
|
+
const subject = this.subject(subjectAid);
|
|
554
|
+
const generation = subject.generation;
|
|
555
|
+
return this.withSubjectClient(subjectAid, async (client, attached) => {
|
|
556
|
+
if (attached)
|
|
557
|
+
return this.refresh(subjectAid, { force: true });
|
|
558
|
+
const consensus = await client.checkOwnershipConsensus(subjectAid);
|
|
559
|
+
if (subject.generation !== generation) {
|
|
560
|
+
throw new OwnershipError('CLIENT_REPLACED', 'Subject state changed during status query; retry');
|
|
561
|
+
}
|
|
562
|
+
this.applyConsensus(subjectAid, subject, consensus);
|
|
563
|
+
return this.snapshot(subjectAid);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
async withSubjectClient(subjectAid, operation) {
|
|
567
|
+
const subject = this.subject(subjectAid);
|
|
568
|
+
if (subject.client && subject.view.connected)
|
|
569
|
+
return operation(subject.client, true);
|
|
570
|
+
let store;
|
|
571
|
+
let client;
|
|
572
|
+
try {
|
|
573
|
+
const sdkStore = await import('../../aun/aid/store.js');
|
|
574
|
+
store = await sdkStore.getAidStore({ slotId: `ownership-${randomUUID()}` });
|
|
575
|
+
const loaded = store.load(subjectAid);
|
|
576
|
+
if (!loaded.ok || !loaded.data.aid.isCertValid() || !loaded.data.aid.isPrivateKeyValid()) {
|
|
577
|
+
throw new OwnershipError('SUBJECT_UNAVAILABLE', loaded.ok
|
|
578
|
+
? 'Subject local certificate or private key is unavailable'
|
|
579
|
+
: `Subject local credentials are unavailable: ${loaded.error.message}`);
|
|
580
|
+
}
|
|
581
|
+
client = await sdkStore.loadClient(store, subjectAid);
|
|
582
|
+
if (client.currentAid?.aid !== subjectAid || !client.currentAid.isCertValid() || !client.currentAid.isPrivateKeyValid()) {
|
|
583
|
+
throw new OwnershipError('SUBJECT_MISMATCH', 'Loaded SDK identity does not match the requested subject');
|
|
584
|
+
}
|
|
585
|
+
await client.connect({
|
|
586
|
+
connection_kind: 'short', short_ttl_ms: 120_000, auto_reconnect: false, background_sync: false,
|
|
587
|
+
connect_timeout: 10, call_timeout: 30,
|
|
588
|
+
});
|
|
589
|
+
return await operation(client, false);
|
|
590
|
+
}
|
|
591
|
+
finally {
|
|
592
|
+
if (client) {
|
|
593
|
+
try {
|
|
594
|
+
await client.close();
|
|
595
|
+
}
|
|
596
|
+
catch { }
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
store?.close();
|
|
600
|
+
}
|
|
601
|
+
catch { }
|
|
602
|
+
}
|
|
603
|
+
}
|
|
479
604
|
stop() {
|
|
480
605
|
for (const subjectAid of this.subjects.keys())
|
|
481
606
|
this.detachClient(subjectAid);
|
|
482
607
|
this.configuredOwners.clear();
|
|
483
608
|
}
|
|
484
609
|
async submitBinding(subjectAid, ownerAid, subject) {
|
|
485
|
-
const current = await this.refresh(subjectAid, { force: true });
|
|
486
|
-
if (current.consensusStatus === 'consensus') {
|
|
487
|
-
if (current.ownerAid === ownerAid)
|
|
488
|
-
return current;
|
|
489
|
-
throw new OwnershipError('OWNER_ALREADY_BOUND', 'Subject already has a protocol owner; transfer is not supported');
|
|
490
|
-
}
|
|
491
|
-
const recovering = (subject.view.task?.status === 'pending' || subject.view.requestStatus === 'interrupted')
|
|
492
|
-
&& subject.view.requestedOwnerAid === ownerAid;
|
|
493
|
-
if (!['unbound', 'released'].includes(current.consensusStatus)
|
|
494
|
-
&& !(recovering && current.consensusStatus === 'single_sided')) {
|
|
495
|
-
throw new OwnershipError('CONSENSUS_REQUIRED', 'Binding requires a fresh unbound or released consensus result');
|
|
496
|
-
}
|
|
497
|
-
if (subject.view.task?.status === 'pending') {
|
|
498
|
-
if (subject.view.task.owner_aid !== ownerAid)
|
|
499
|
-
throw new OwnershipError('BIND_CONFLICT', 'A different binding task is pending');
|
|
500
|
-
}
|
|
501
|
-
if (subject.view.requestStatus === 'interrupted' && subject.view.requestedOwnerAid !== ownerAid) {
|
|
502
|
-
throw new OwnershipError('BIND_UNCERTAIN', 'Retry the interrupted request with its original owner before changing the target');
|
|
503
|
-
}
|
|
504
610
|
const client = subject.client;
|
|
505
|
-
if (!client || !subject.view.connected)
|
|
506
|
-
throw new OwnershipError('SUBJECT_UNAVAILABLE', 'Subject AID SDK client is not ready');
|
|
507
611
|
const generation = subject.generation;
|
|
508
612
|
subject.view = { ...subject.view, requestedOwnerAid: ownerAid, requestStatus: 'requesting', task: undefined };
|
|
509
|
-
this.persist(subjectAid, subject);
|
|
510
613
|
try {
|
|
614
|
+
const local = await this.tryLocalBind(subjectAid, ownerAid);
|
|
615
|
+
if (local) {
|
|
616
|
+
if (client && (subject.client !== client || subject.generation !== generation)) {
|
|
617
|
+
throw new OwnershipError('CLIENT_REPLACED', 'Subject SDK client changed during local binding');
|
|
618
|
+
}
|
|
619
|
+
subject.view.task = local.task;
|
|
620
|
+
this.applyConsensus(subjectAid, subject, local.consensus);
|
|
621
|
+
subject.view.requestStatus = undefined;
|
|
622
|
+
subject.view.reason = undefined;
|
|
623
|
+
return this.snapshot(subjectAid);
|
|
624
|
+
}
|
|
625
|
+
if (!client || !subject.view.connected)
|
|
626
|
+
throw new OwnershipError('SUBJECT_UNAVAILABLE', 'Subject AID SDK client is not ready');
|
|
627
|
+
const current = await this.refresh(subjectAid, { force: true });
|
|
628
|
+
if (current.consensusStatus === 'consensus') {
|
|
629
|
+
if (current.ownerAid === ownerAid)
|
|
630
|
+
return current;
|
|
631
|
+
throw new OwnershipError('OWNER_ALREADY_BOUND', 'Subject already has a protocol owner; transfer is not supported');
|
|
632
|
+
}
|
|
633
|
+
if (!['unbound', 'released', 'single_sided'].includes(current.consensusStatus)) {
|
|
634
|
+
throw new OwnershipError('CONSENSUS_REQUIRED', 'Binding requires a fresh readable ownership baseline');
|
|
635
|
+
}
|
|
511
636
|
const task = this.task(await client.bindOwneraid(ownerAid));
|
|
512
637
|
if (subject.client !== client || subject.generation !== generation) {
|
|
513
638
|
throw new OwnershipError('CLIENT_REPLACED', 'Subject SDK client changed during binding; the request may have been submitted');
|
|
514
639
|
}
|
|
515
640
|
if (!task || task.sub_aid !== subjectAid || task.owner_aid !== ownerAid || task.operation !== 'bind') {
|
|
516
|
-
throw new OwnershipError('TASK_MISMATCH', 'SDK returned a binding
|
|
641
|
+
throw new OwnershipError('TASK_MISMATCH', 'SDK returned a binding result for a different subject or owner');
|
|
517
642
|
}
|
|
518
643
|
subject.view.task = task;
|
|
519
|
-
subject.view.requestStatus = 'submitted';
|
|
520
|
-
|
|
644
|
+
subject.view.requestStatus = task.status === 'error' ? 'error' : 'submitted';
|
|
645
|
+
subject.view.reason = task.reason;
|
|
646
|
+
if (task.status === 'error' || task.status === 'rejected' || task.status === 'stopped') {
|
|
647
|
+
throw Object.assign(new OwnershipError(task.code ?? 'BIND_FAILED', task.reason ?? `Binding ended with ${task.status}`), {
|
|
648
|
+
retryable: task.retryable,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
521
651
|
this.invalidate(subject, 'Binding submitted; waiting for explicit owner approval and fresh consensus');
|
|
522
652
|
this.schedule(subjectAid, subject);
|
|
523
653
|
return this.snapshot(subjectAid);
|
|
524
654
|
}
|
|
525
655
|
catch (error) {
|
|
526
|
-
if (subject.client === client && subject.generation === generation) {
|
|
656
|
+
if (!client || (subject.client === client && subject.generation === generation)) {
|
|
527
657
|
subject.view.requestStatus = 'interrupted';
|
|
528
658
|
subject.view.reason = error instanceof Error ? error.message : String(error);
|
|
529
|
-
this.persist(subjectAid, subject);
|
|
530
659
|
}
|
|
531
660
|
throw error;
|
|
532
661
|
}
|
|
533
662
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
let
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
let saved = null;
|
|
540
|
-
let persistenceError;
|
|
663
|
+
async tryLocalBind(subjectAid, ownerAid) {
|
|
664
|
+
let store;
|
|
665
|
+
let ownerClient;
|
|
666
|
+
let subClient;
|
|
667
|
+
let invoked = false;
|
|
541
668
|
try {
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
||
|
|
548
|
-
|
|
549
|
-
|
|
669
|
+
const sdkStore = await import('../../aun/aid/store.js');
|
|
670
|
+
store = await sdkStore.getAidStore({ slotId: sdkStore.SLOT.cli });
|
|
671
|
+
const ownerIdentity = store.load(ownerAid);
|
|
672
|
+
const subIdentity = store.load(subjectAid);
|
|
673
|
+
if (!ownerIdentity.ok || !subIdentity.ok
|
|
674
|
+
|| !ownerIdentity.data.aid.isPrivateKeyValid() || !subIdentity.data.aid.isPrivateKeyValid())
|
|
675
|
+
return undefined;
|
|
676
|
+
ownerClient = await sdkStore.loadClient(store, ownerAid);
|
|
677
|
+
subClient = await sdkStore.loadClient(store, subjectAid);
|
|
678
|
+
if (typeof ownerClient.bindLocalSubaid !== 'function'
|
|
679
|
+
|| ownerClient.getType() !== 'agent' || subClient.getType() !== 'agent')
|
|
680
|
+
return undefined;
|
|
681
|
+
invoked = true;
|
|
682
|
+
const task = this.task(await ownerClient.bindLocalSubaid(subClient));
|
|
683
|
+
if (!task || task.operation !== 'bind' || task.sub_aid !== subjectAid || task.owner_aid !== ownerAid
|
|
684
|
+
|| task.status !== 'completed') {
|
|
685
|
+
throw Object.assign(new OwnershipError(task?.code ?? 'LOCAL_BIND_FAILED', task?.reason ?? 'Local binding did not complete'), { retryable: task?.retryable });
|
|
550
686
|
}
|
|
687
|
+
const consensus = await ownerClient.checkOwnershipConsensus(subjectAid);
|
|
688
|
+
if (consensus.status !== 'consensus' || consensus.owner_aid !== ownerAid
|
|
689
|
+
|| consensus.sub_aid !== subjectAid || consensus.relation_version !== task.target_relation_version
|
|
690
|
+
|| consensus.lock !== 'locked') {
|
|
691
|
+
throw new OwnershipError('CONSENSUS_REQUIRED', 'Local binding did not produce the expected fresh ownership consensus');
|
|
692
|
+
}
|
|
693
|
+
return { task, consensus };
|
|
551
694
|
}
|
|
552
695
|
catch (error) {
|
|
553
|
-
|
|
554
|
-
|
|
696
|
+
if (!invoked) {
|
|
697
|
+
logger.debug(`[ownership] local identity pair unavailable for ${subjectAid}/${ownerAid}: ${error instanceof Error ? error.message : String(error)}`);
|
|
698
|
+
return undefined;
|
|
699
|
+
}
|
|
700
|
+
throw error;
|
|
701
|
+
}
|
|
702
|
+
finally {
|
|
703
|
+
if (ownerClient) {
|
|
704
|
+
try {
|
|
705
|
+
await ownerClient.close();
|
|
706
|
+
}
|
|
707
|
+
catch { }
|
|
708
|
+
}
|
|
709
|
+
if (subClient) {
|
|
710
|
+
try {
|
|
711
|
+
await subClient.close();
|
|
712
|
+
}
|
|
713
|
+
catch { }
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
store?.close();
|
|
717
|
+
}
|
|
718
|
+
catch { }
|
|
555
719
|
}
|
|
556
|
-
|
|
720
|
+
}
|
|
721
|
+
applyConsensus(subjectAid, subject, consensus) {
|
|
722
|
+
if (consensus.sub_aid !== subjectAid)
|
|
723
|
+
throw new OwnershipError('TASK_MISMATCH', 'Ownership consensus subject does not match');
|
|
724
|
+
const ownerAid = consensus.status === 'consensus' && typeof consensus.owner_aid === 'string'
|
|
725
|
+
&& isValidAid(consensus.owner_aid) && consensus.owner_aid !== subjectAid ? consensus.owner_aid : undefined;
|
|
726
|
+
if (consensus.status === 'consensus' && !ownerAid)
|
|
727
|
+
throw new OwnershipError('TASK_MISMATCH', 'Ownership consensus owner is invalid');
|
|
728
|
+
const previousOwner = this.owner(subjectAid);
|
|
729
|
+
const previousStatus = subject.view.consensusStatus;
|
|
730
|
+
const checkedAt = this.now();
|
|
731
|
+
const pending = subject.view.task?.status === 'pending' || subject.view.requestStatus === 'requesting';
|
|
732
|
+
subject.view = {
|
|
733
|
+
...subject.view, ownerAid,
|
|
734
|
+
relation: consensus.status === 'consensus' || consensus.status === 'unbound' || consensus.status === 'released'
|
|
735
|
+
? { consensusStatus: consensus.status, ownerAid, checkedAt, relationVersion: consensus.relation_version }
|
|
736
|
+
: subject.view.relation,
|
|
737
|
+
authorizationStatus: ownerAid ? 'verified' : 'unavailable', consensusStatus: consensus.status,
|
|
738
|
+
consensus: JSON.parse(JSON.stringify(consensus)),
|
|
739
|
+
checkedAt, validUntil: checkedAt + (this.options.lifetimeMs ?? OWNERSHIP_CACHE_MAX_AGE_MS),
|
|
740
|
+
revision: subject.view.revision + (previousOwner !== ownerAid || subject.view.consensusStatus !== consensus.status
|
|
741
|
+
|| subject.view.consensus?.relation_version !== consensus.relation_version
|
|
742
|
+
|| subject.view.consensus?.lock !== consensus.lock ? 1 : 0),
|
|
743
|
+
reason: consensus.reason,
|
|
744
|
+
synchronization: {
|
|
745
|
+
status: consensus.status === 'unavailable' || consensus.status === 'not_applicable' ? 'unavailable'
|
|
746
|
+
: consensus.status === 'invalid' ? 'conflict'
|
|
747
|
+
: ownerAid && subject.view.requestedOwnerAid && subject.view.requestedOwnerAid !== ownerAid ? 'conflict'
|
|
748
|
+
: consensus.status === 'single_sided' || (!ownerAid && pending) ? 'pending' : 'synced',
|
|
749
|
+
runtimeOwnerAid: ownerAid,
|
|
750
|
+
},
|
|
751
|
+
};
|
|
752
|
+
if (previousOwner !== ownerAid || previousStatus !== consensus.status)
|
|
753
|
+
this.notify(subjectAid, ownerAid);
|
|
754
|
+
}
|
|
755
|
+
subject(subjectAid) {
|
|
756
|
+
this.assertAid(subjectAid);
|
|
757
|
+
let subject = this.subjects.get(subjectAid);
|
|
758
|
+
if (subject)
|
|
759
|
+
return subject;
|
|
557
760
|
subject = {
|
|
558
761
|
generation: 0, epoch: 0, query: 0, subscriptions: [], taskEvents: new Set(),
|
|
559
|
-
requests: new Map(), decisionTasks: new Map(),
|
|
762
|
+
requests: new Map(), decisionTasks: new Map(),
|
|
560
763
|
view: {
|
|
561
764
|
subjectAid, consensusStatus: 'unavailable', authorizationStatus: 'unavailable', connected: false, revision: 0,
|
|
562
|
-
task: task?.sub_aid === subjectAid ? task : undefined,
|
|
563
|
-
requestedOwnerAid: saved?.subjectAid === subjectAid && typeof saved.requestedOwnerAid === 'string'
|
|
564
|
-
&& isValidAid(saved.requestedOwnerAid) ? saved.requestedOwnerAid : undefined,
|
|
565
|
-
requestStatus: saved?.requestStatus === 'requesting' || saved?.requestStatus === 'interrupted' ? 'interrupted'
|
|
566
|
-
: task ? 'submitted' : undefined,
|
|
567
765
|
synchronization: { status: 'unavailable' },
|
|
568
766
|
},
|
|
569
767
|
};
|
|
@@ -575,7 +773,8 @@ export class OwnerResolver {
|
|
|
575
773
|
subject.epoch += 1;
|
|
576
774
|
subject.view = {
|
|
577
775
|
...subject.view, ownerAid: undefined, consensusStatus: 'unavailable', authorizationStatus: 'unavailable', validUntil: undefined,
|
|
578
|
-
revision: subject.view.revision + 1, reason,
|
|
776
|
+
revision: subject.view.revision + 1, reason, refreshFailure: undefined,
|
|
777
|
+
synchronization: { status: 'unavailable' },
|
|
579
778
|
};
|
|
580
779
|
if (previousOwner)
|
|
581
780
|
this.notify(subject.view.subjectAid);
|
|
@@ -600,63 +799,17 @@ export class OwnerResolver {
|
|
|
600
799
|
}
|
|
601
800
|
}
|
|
602
801
|
}
|
|
603
|
-
schedule(subjectAid, subject) {
|
|
802
|
+
schedule(subjectAid, subject, allowTransient = true) {
|
|
604
803
|
if (subject.timer || !subject.client || !subject.view.connected)
|
|
605
804
|
return;
|
|
606
805
|
subject.timer = setTimeout(() => {
|
|
607
806
|
subject.timer = undefined;
|
|
608
|
-
void this.refresh(subjectAid, { force: true }).catch(error => {
|
|
807
|
+
void this.refresh(subjectAid, { force: true, allowTransient }).catch(error => {
|
|
609
808
|
logger.warn(`[ownership] Event refresh failed for ${subjectAid}: ${error instanceof Error ? error.message : String(error)}`);
|
|
610
809
|
});
|
|
611
810
|
}, 0);
|
|
612
811
|
subject.timer.unref?.();
|
|
613
812
|
}
|
|
614
|
-
schedulePendingRefresh(subjectAid, subject) {
|
|
615
|
-
// The owner's final publication can become readable after the last SDK
|
|
616
|
-
// event. A cached unbound/single-sided observation must not strand an
|
|
617
|
-
// approved creation in ownerpending until the next connection or command.
|
|
618
|
-
// Only fresh SDK consensus can authorize the existing owner-bound handler;
|
|
619
|
-
// this never resubmits a binding or infers ownership from task completion.
|
|
620
|
-
const needsRefresh = subject.client && subject.view.connected
|
|
621
|
-
&& subject.view.requestedOwnerAid && subject.view.consensusStatus !== 'consensus'
|
|
622
|
-
&& (subject.view.task?.status === 'pending' || subject.view.task?.status === 'completed'
|
|
623
|
-
|| subject.view.requestStatus === 'requesting' || subject.view.requestStatus === 'interrupted');
|
|
624
|
-
if (!needsRefresh) {
|
|
625
|
-
if (subject.pendingRefreshTimer)
|
|
626
|
-
clearTimeout(subject.pendingRefreshTimer);
|
|
627
|
-
subject.pendingRefreshTimer = undefined;
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
if (subject.pendingRefreshTimer)
|
|
631
|
-
return;
|
|
632
|
-
subject.pendingRefreshTimer = setTimeout(() => {
|
|
633
|
-
subject.pendingRefreshTimer = undefined;
|
|
634
|
-
void this.refresh(subjectAid, { force: true }).catch(error => {
|
|
635
|
-
logger.warn(`[ownership] Pending consensus refresh failed for ${subjectAid}: ${error instanceof Error ? error.message : String(error)}`);
|
|
636
|
-
});
|
|
637
|
-
}, OWNERSHIP_PENDING_REFRESH_MS);
|
|
638
|
-
subject.pendingRefreshTimer.unref?.();
|
|
639
|
-
}
|
|
640
|
-
statePath(subjectAid) {
|
|
641
|
-
return path.join(this.options.stateDirectory?.() ?? path.join(resolvePaths().root, 'data', 'ownership'), `${subjectAid}.json`);
|
|
642
|
-
}
|
|
643
|
-
persist(subjectAid, subject) {
|
|
644
|
-
if (!subject.persistenceError) {
|
|
645
|
-
try {
|
|
646
|
-
const filename = this.statePath(subjectAid);
|
|
647
|
-
fs.mkdirSync(path.dirname(filename), { recursive: true });
|
|
648
|
-
atomicWriteJson(filename, {
|
|
649
|
-
version: 1, subjectAid, requestedOwnerAid: subject.view.requestedOwnerAid,
|
|
650
|
-
requestStatus: subject.view.requestStatus, task: subject.view.task,
|
|
651
|
-
});
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
catch (error) {
|
|
655
|
-
subject.persistenceError = `Ownership task storage is unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
throw new OwnershipError('persistence_unavailable', subject.persistenceError);
|
|
659
|
-
}
|
|
660
813
|
now() {
|
|
661
814
|
return this.options.now?.() ?? Date.now();
|
|
662
815
|
}
|
|
@@ -696,7 +849,13 @@ export class OwnerResolver {
|
|
|
696
849
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
697
850
|
}
|
|
698
851
|
trackBindingRequest(localAid, subject, event, record) {
|
|
699
|
-
const
|
|
852
|
+
const requestAction = record.action === 'bind.request' || record.action === 'transfer.request';
|
|
853
|
+
if (event === 'ownership.request'
|
|
854
|
+
&& (record.operation !== 'bind' || (record.action !== undefined && !requestAction)))
|
|
855
|
+
return;
|
|
856
|
+
const task = this.bindingTask(event === 'ownership.request'
|
|
857
|
+
? { ...record, status: 'pending', step: 'approval' }
|
|
858
|
+
: record, event);
|
|
700
859
|
if (!task || (task.sub_aid !== localAid && task.owner_aid !== localAid))
|
|
701
860
|
return;
|
|
702
861
|
const request = subject.requests.get(task.operation_id);
|
|
@@ -738,19 +897,23 @@ export class OwnerResolver {
|
|
|
738
897
|
}
|
|
739
898
|
sameBindingTask(left, right) {
|
|
740
899
|
return left.operation_id === right.operation_id && left.sub_aid === right.sub_aid
|
|
741
|
-
&& left.owner_aid === right.owner_aid && left.
|
|
900
|
+
&& left.owner_aid === right.owner_aid && left.new_owner_aid === right.new_owner_aid
|
|
901
|
+
&& left.base_relation_version === right.base_relation_version
|
|
742
902
|
&& left.target_relation_version === right.target_relation_version;
|
|
743
903
|
}
|
|
744
904
|
/** Only public task fields are exposed; raw SDK records remain SDK-owned. */
|
|
745
|
-
bindingTask(value) {
|
|
746
|
-
const task = this.task(value);
|
|
747
|
-
if (!task || !CREATION_BIND_OPERATION_ID.test(task.operation_id)
|
|
905
|
+
bindingTask(value, event) {
|
|
906
|
+
const task = this.task(value, event);
|
|
907
|
+
if (!task || task.operation !== 'bind' || !CREATION_BIND_OPERATION_ID.test(task.operation_id)
|
|
748
908
|
|| task.sub_aid === task.owner_aid || !Number.isSafeInteger(task.base_relation_version)
|
|
749
909
|
|| task.base_relation_version < 0 || !Number.isSafeInteger(task.target_relation_version)
|
|
750
910
|
|| task.target_relation_version !== task.base_relation_version + 1)
|
|
751
911
|
return undefined;
|
|
912
|
+
if (task.new_owner_aid !== undefined)
|
|
913
|
+
return undefined;
|
|
752
914
|
return {
|
|
753
|
-
operation_id: task.operation_id, operation:
|
|
915
|
+
operation_id: task.operation_id, operation: task.operation, sub_aid: task.sub_aid, owner_aid: task.owner_aid,
|
|
916
|
+
...(task.new_owner_aid ? { new_owner_aid: task.new_owner_aid } : {}),
|
|
754
917
|
base_relation_version: task.base_relation_version, target_relation_version: task.target_relation_version,
|
|
755
918
|
status: task.status, step: task.step,
|
|
756
919
|
...(typeof task.code === 'string' ? { code: task.code } : {}),
|
|
@@ -758,15 +921,34 @@ export class OwnerResolver {
|
|
|
758
921
|
...(typeof task.retryable === 'boolean' ? { retryable: task.retryable } : {}),
|
|
759
922
|
};
|
|
760
923
|
}
|
|
761
|
-
task(value) {
|
|
924
|
+
task(value, event) {
|
|
762
925
|
const record = this.record(value);
|
|
926
|
+
const baseRelationVersion = record?.base_relation_version;
|
|
927
|
+
const targetRelationVersion = record?.target_relation_version;
|
|
763
928
|
if (!record || typeof record.operation_id !== 'string' || !record.operation_id
|
|
764
|
-
|| record.operation
|
|
929
|
+
|| !['bind', 'transfer'].includes(String(record.operation)) || typeof record.sub_aid !== 'string' || !isValidAid(record.sub_aid)
|
|
765
930
|
|| typeof record.owner_aid !== 'string' || !isValidAid(record.owner_aid)
|
|
766
|
-
||
|
|
767
|
-
|| typeof
|
|
931
|
+
|| (record.new_owner_aid !== undefined && (typeof record.new_owner_aid !== 'string' || !isValidAid(record.new_owner_aid)))
|
|
932
|
+
|| typeof baseRelationVersion !== 'number' || !Number.isSafeInteger(baseRelationVersion) || baseRelationVersion < 0
|
|
933
|
+
|| typeof targetRelationVersion !== 'number' || !Number.isSafeInteger(targetRelationVersion)
|
|
934
|
+
|| targetRelationVersion !== baseRelationVersion + 1)
|
|
768
935
|
return undefined;
|
|
769
|
-
|
|
936
|
+
const status = ['pending', 'completed', 'rejected', 'stopped', 'error'].includes(String(record.status))
|
|
937
|
+
? record.status
|
|
938
|
+
: event === 'ownership.rejected' ? 'rejected'
|
|
939
|
+
: event === 'ownership.completed' ? 'completed' : 'pending';
|
|
940
|
+
const step = typeof record.step === 'string' ? record.step
|
|
941
|
+
: event === 'ownership.request' ? 'approval' : 'local_actions';
|
|
942
|
+
return {
|
|
943
|
+
operation_id: record.operation_id, operation: record.operation,
|
|
944
|
+
sub_aid: record.sub_aid, owner_aid: record.owner_aid,
|
|
945
|
+
...(typeof record.new_owner_aid === 'string' ? { new_owner_aid: record.new_owner_aid } : {}),
|
|
946
|
+
base_relation_version: baseRelationVersion, target_relation_version: targetRelationVersion,
|
|
947
|
+
status, step,
|
|
948
|
+
...(typeof record.code === 'string' ? { code: record.code } : {}),
|
|
949
|
+
...(typeof record.reason === 'string' ? { reason: record.reason } : {}),
|
|
950
|
+
...(typeof record.retryable === 'boolean' ? { retryable: record.retryable } : {}),
|
|
951
|
+
};
|
|
770
952
|
}
|
|
771
953
|
}
|
|
772
954
|
export const ownerResolver = new OwnerResolver();
|
|
@@ -781,10 +963,17 @@ export async function refreshOwnershipForAuthorization(subjectAid, options = {})
|
|
|
781
963
|
const ownershipSubjectAid = daemonAid && subjectAid === `${daemonAid}#daemon` ? daemonAid : subjectAid;
|
|
782
964
|
const subjects = new Set([ownershipSubjectAid, ...(!ownershipSubjectAid || options.includeDaemon !== false ? [daemonAid] : [])]
|
|
783
965
|
.filter((aid) => !!aid));
|
|
784
|
-
await Promise.all([...subjects].map(aid => ownerResolver.refresh(aid, {
|
|
966
|
+
const views = await Promise.all([...subjects].map(aid => ownerResolver.refresh(aid, {
|
|
785
967
|
maxAgeMs: options.maxAgeMs ?? OWNERSHIP_AUTHORIZATION_MAX_AGE_MS,
|
|
786
968
|
force: options.force,
|
|
969
|
+
allowTransient: options.allowTransient,
|
|
787
970
|
})));
|
|
971
|
+
if (!options.allowTransient) {
|
|
972
|
+
const failure = views.find(view => view.consensusStatus === 'unavailable' && view.refreshFailure);
|
|
973
|
+
if (failure?.refreshFailure) {
|
|
974
|
+
throw new OwnershipError('OWNER_REFRESH_FAILED', `Ownership refresh failed (${failure.refreshFailure.code}): ${failure.refreshFailure.reason}`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
788
977
|
}
|
|
789
978
|
export async function assertOwnershipAuthorization(context) {
|
|
790
979
|
const authorization = context?.executionPermission;
|
|
@@ -792,15 +981,32 @@ export async function assertOwnershipAuthorization(context) {
|
|
|
792
981
|
const ownerActor = hasTrustedPrincipal(context?.actor) ? context.actor.principalId : undefined;
|
|
793
982
|
if (!authorization && !ownerRole)
|
|
794
983
|
return;
|
|
795
|
-
await refreshOwnershipForAuthorization(context?.selfAid ?? authorization?.agentAid);
|
|
984
|
+
await refreshOwnershipForAuthorization(context?.selfAid ?? authorization?.agentAid, { allowTransient: true });
|
|
985
|
+
const subjectAid = context?.selfAid ?? authorization?.agentAid;
|
|
796
986
|
if (authorization) {
|
|
797
987
|
if (!getDaemonOwners().includes(authorization.authorizedBy)
|
|
798
988
|
|| (authorization.source === 'agent-policy'
|
|
799
989
|
&& !ownerResolver.isOwner(authorization.agentAid ?? '', authorization.authorizedBy))) {
|
|
990
|
+
const view = subjectAid ? ownerResolver.snapshot(subjectAid) : undefined;
|
|
991
|
+
logger.warn(`[ownership] authorization denied ${JSON.stringify({
|
|
992
|
+
subjectAid, authorizedBy: authorization.authorizedBy,
|
|
993
|
+
consensusStatus: view?.consensusStatus,
|
|
994
|
+
failureCode: view?.refreshFailure?.code,
|
|
995
|
+
refreshFailure: view?.refreshFailure?.reason,
|
|
996
|
+
refreshFailureKind: view?.refreshFailure?.kind,
|
|
997
|
+
})}`);
|
|
800
998
|
throw new OwnershipError('OWNER_UNAVAILABLE', 'Current Owner no longer authorizes this execution');
|
|
801
999
|
}
|
|
802
1000
|
}
|
|
803
1001
|
else if (!context?.selfAid || !ownerResolver.isOwner(context.selfAid, ownerActor)) {
|
|
1002
|
+
const view = context?.selfAid ? ownerResolver.snapshot(context.selfAid) : undefined;
|
|
1003
|
+
logger.warn(`[ownership] authorization denied ${JSON.stringify({
|
|
1004
|
+
subjectAid: context?.selfAid, actorAid: ownerActor,
|
|
1005
|
+
consensusStatus: view?.consensusStatus,
|
|
1006
|
+
failureCode: view?.refreshFailure?.code,
|
|
1007
|
+
refreshFailure: view?.refreshFailure?.reason,
|
|
1008
|
+
refreshFailureKind: view?.refreshFailure?.kind,
|
|
1009
|
+
})}`);
|
|
804
1010
|
throw new OwnershipError('OWNER_UNAVAILABLE', 'Current Owner could not be verified; start a new task with the current role');
|
|
805
1011
|
}
|
|
806
1012
|
}
|