evolcore 0.0.38 → 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 +64 -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 +54 -30
- 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/agentmd.js +21 -0
- package/dist/aun/aid/client.js +1 -1
- package/dist/aun/aid/control-aid.js +23 -98
- package/dist/aun/aid/ownership-operation.js +35 -12
- 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 +30 -6
- package/dist/cli/agent.js +30 -37
- package/dist/cli/aid-bind-command.js +16 -2
- package/dist/cli/aun-commands.js +31 -23
- package/dist/cli/config.js +15 -18
- package/dist/cli/contact.js +9 -12
- package/dist/cli/ctl-command.js +3 -3
- package/dist/cli/daemon-commands.js +242 -17
- package/dist/cli/diag.js +7 -3
- package/dist/cli/fs-command.js +5 -4
- package/dist/cli/handoff-command.js +11 -11
- package/dist/cli/help.js +18 -0
- package/dist/cli/index.js +25 -13
- package/dist/cli/init-channel.js +2 -1
- package/dist/cli/memory-command.js +47 -27
- package/dist/cli/model.js +5 -4
- package/dist/cli/queue-command.js +3 -3
- package/dist/cli/response.js +5 -4
- package/dist/cli/restart-monitor.js +35 -13
- package/dist/cli/skill-command.js +13 -13
- package/dist/cli/task-command.js +3 -3
- package/dist/cli/task-context.js +1 -0
- package/dist/cli/thread-command.js +5 -3
- package/dist/cli/trigger-command.js +3 -3
- package/dist/cli/version.js +6 -2
- 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 +17 -1
- package/dist/core/bootstrap-service.js +20 -12
- package/dist/core/command/cli-intent-parser.js +5 -2
- package/dist/core/command/command-handler.js +20 -5
- package/dist/core/command/menu-catalog.js +48 -12
- package/dist/core/command/menu-handler.js +74 -6
- 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 +465 -140
- package/dist/core/permission/ec-command-parser.js +19 -3
- package/dist/core/permission/protected-paths.js +51 -0
- 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/dist/utils/welcome.js +18 -24
- 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 +58 -8
- 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) {
|
|
@@ -117,7 +119,12 @@ export class OwnerResolver {
|
|
|
117
119
|
if (subject.client !== client || subject.generation !== generation)
|
|
118
120
|
return;
|
|
119
121
|
const record = this.record(payload);
|
|
120
|
-
if (!record
|
|
122
|
+
if (!record)
|
|
123
|
+
return;
|
|
124
|
+
this.trackBindingRequest(subjectAid, subject, event, record);
|
|
125
|
+
// When this AID is the prospective owner, the request concerns the
|
|
126
|
+
// remote sub's ownership, not this AID's own Owner authorization.
|
|
127
|
+
if (record.sub_aid !== subjectAid)
|
|
121
128
|
return;
|
|
122
129
|
if (typeof record.operation_id === 'string') {
|
|
123
130
|
const key = JSON.stringify([event, record.operation_id, record.step, record.status,
|
|
@@ -129,7 +136,7 @@ export class OwnerResolver {
|
|
|
129
136
|
subject.taskEvents.delete(subject.taskEvents.values().next().value);
|
|
130
137
|
}
|
|
131
138
|
this.invalidate(subject, 'Ownership changed; fresh consensus is required');
|
|
132
|
-
const task = this.task(record);
|
|
139
|
+
const task = this.task(record, event);
|
|
133
140
|
logger.info(`[ownership] ${event} ${JSON.stringify({ subjectAid, ownerAid: task?.owner_aid,
|
|
134
141
|
operationId: task?.operation_id, taskStatus: task?.status, step: task?.step,
|
|
135
142
|
code: task?.code, reason: task?.reason })}`);
|
|
@@ -139,10 +146,6 @@ export class OwnerResolver {
|
|
|
139
146
|
subject.view.task = task;
|
|
140
147
|
subject.view.requestStatus = 'submitted';
|
|
141
148
|
}
|
|
142
|
-
try {
|
|
143
|
-
this.persist(subjectAid, subject);
|
|
144
|
-
}
|
|
145
|
-
catch { }
|
|
146
149
|
this.schedule(subjectAid, subject);
|
|
147
150
|
}));
|
|
148
151
|
}
|
|
@@ -195,9 +198,6 @@ export class OwnerResolver {
|
|
|
195
198
|
if (subject.timer)
|
|
196
199
|
clearTimeout(subject.timer);
|
|
197
200
|
subject.timer = undefined;
|
|
198
|
-
if (subject.pendingRefreshTimer)
|
|
199
|
-
clearTimeout(subject.pendingRefreshTimer);
|
|
200
|
-
subject.pendingRefreshTimer = undefined;
|
|
201
201
|
}
|
|
202
202
|
subject.view.connected = connected;
|
|
203
203
|
if (connected)
|
|
@@ -211,10 +211,10 @@ export class OwnerResolver {
|
|
|
211
211
|
if (subject.timer)
|
|
212
212
|
clearTimeout(subject.timer);
|
|
213
213
|
subject.timer = undefined;
|
|
214
|
-
if (subject.pendingRefreshTimer)
|
|
215
|
-
clearTimeout(subject.pendingRefreshTimer);
|
|
216
|
-
subject.pendingRefreshTimer = undefined;
|
|
217
214
|
subject.taskEvents.clear();
|
|
215
|
+
subject.requests.clear();
|
|
216
|
+
subject.decisionTasks.clear();
|
|
217
|
+
delete subject.view.decisionTask;
|
|
218
218
|
for (const subscription of subject.subscriptions.splice(0)) {
|
|
219
219
|
try {
|
|
220
220
|
subscription.unsubscribe();
|
|
@@ -275,6 +275,7 @@ export class OwnerResolver {
|
|
|
275
275
|
snapshot(subjectAid) {
|
|
276
276
|
const subject = this.subject(subjectAid);
|
|
277
277
|
const view = JSON.parse(JSON.stringify(subject.view));
|
|
278
|
+
view.requests = [...subject.requests.values()].map(request => ({ ...request }));
|
|
278
279
|
if (view.consensusStatus === 'consensus' && !this.owner(subjectAid)) {
|
|
279
280
|
view.ownerAid = undefined;
|
|
280
281
|
view.synchronization = { status: 'unavailable' };
|
|
@@ -284,12 +285,6 @@ export class OwnerResolver {
|
|
|
284
285
|
&& view.checkedAt !== undefined && view.checkedAt <= now
|
|
285
286
|
? view.ownerAid && now - view.checkedAt < OWNERSHIP_AUTHORIZATION_MAX_AGE_MS ? 'verified' : 'expired'
|
|
286
287
|
: 'unavailable';
|
|
287
|
-
if (subject.persistenceError) {
|
|
288
|
-
view.requestStatus = 'error';
|
|
289
|
-
view.reason = subject.persistenceError;
|
|
290
|
-
view.synchronization.status = 'unavailable';
|
|
291
|
-
view.authorizationStatus = 'unavailable';
|
|
292
|
-
}
|
|
293
288
|
return view;
|
|
294
289
|
}
|
|
295
290
|
async refresh(subjectAid, options = {}) {
|
|
@@ -298,7 +293,10 @@ export class OwnerResolver {
|
|
|
298
293
|
if (subject.timer)
|
|
299
294
|
clearTimeout(subject.timer);
|
|
300
295
|
subject.timer = undefined;
|
|
301
|
-
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;
|
|
302
300
|
const maxAgeMs = options.maxAgeMs ?? this.options.lifetimeMs ?? OWNERSHIP_CACHE_MAX_AGE_MS;
|
|
303
301
|
const isFresh = () => {
|
|
304
302
|
const { checkedAt, validUntil, consensusStatus } = subject.view;
|
|
@@ -308,32 +306,48 @@ export class OwnerResolver {
|
|
|
308
306
|
&& now - checkedAt < maxAgeMs;
|
|
309
307
|
};
|
|
310
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
|
+
}
|
|
311
313
|
return this.snapshot(subjectAid);
|
|
312
314
|
}
|
|
313
|
-
const
|
|
314
|
-
|
|
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 };
|
|
315
319
|
try {
|
|
316
320
|
await promise;
|
|
317
|
-
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) {
|
|
318
327
|
this.invalidate(subject, 'Ownership cache expired while disconnected');
|
|
319
328
|
}
|
|
329
|
+
if (!options.allowTransient && subject.view.refreshFailure?.attemptId === attemptId
|
|
330
|
+
&& subject.view.refreshFailure.fallbackUsed) {
|
|
331
|
+
return this.unavailableSnapshot(subjectAid, subject.view.refreshFailure);
|
|
332
|
+
}
|
|
320
333
|
return this.snapshot(subjectAid);
|
|
321
334
|
}
|
|
322
335
|
finally {
|
|
323
336
|
if (subject.refreshing?.promise === promise)
|
|
324
337
|
subject.refreshing = undefined;
|
|
325
|
-
this.schedulePendingRefresh(subjectAid, subject);
|
|
326
338
|
}
|
|
327
339
|
}
|
|
328
|
-
async refreshSubject(subjectAid, subject) {
|
|
340
|
+
async refreshSubject(subjectAid, subject, attemptId, attemptedAt) {
|
|
329
341
|
const client = subject.client;
|
|
342
|
+
subject.lastRefreshAttemptAt = attemptedAt;
|
|
330
343
|
if (!client || !subject.view.connected) {
|
|
331
|
-
|
|
344
|
+
const reason = 'Subject AID has no connected SDK client';
|
|
345
|
+
this.handleRefreshFailure(subjectAid, subject, { code: 'CLIENT_DISCONNECTED', message: reason }, attemptedAt, attemptId);
|
|
332
346
|
return this.snapshot(subjectAid);
|
|
333
347
|
}
|
|
334
348
|
const generation = subject.generation;
|
|
335
349
|
const epoch = subject.epoch;
|
|
336
|
-
const query =
|
|
350
|
+
const query = attemptId;
|
|
337
351
|
let timeout;
|
|
338
352
|
try {
|
|
339
353
|
const consensus = await Promise.race([
|
|
@@ -352,6 +366,16 @@ export class OwnerResolver {
|
|
|
352
366
|
if (consensus.sub_aid !== subjectAid || (consensus.status === 'consensus' && !ownerAid)) {
|
|
353
367
|
throw new Error('Ownership consensus contains a mismatched subject or invalid owner');
|
|
354
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
|
+
}
|
|
355
379
|
const previousOwner = this.owner(subjectAid);
|
|
356
380
|
const previousStatus = subject.view.consensusStatus;
|
|
357
381
|
const checkedAt = this.now();
|
|
@@ -372,6 +396,7 @@ export class OwnerResolver {
|
|
|
372
396
|
|| subject.view.consensus?.relation_version !== consensus.relation_version
|
|
373
397
|
|| subject.view.consensus?.lock !== consensus.lock ? 1 : 0),
|
|
374
398
|
reason: consensus.reason,
|
|
399
|
+
refreshFailure: undefined,
|
|
375
400
|
synchronization: {
|
|
376
401
|
status: consensus.status === 'unavailable' || consensus.status === 'not_applicable' ? 'unavailable'
|
|
377
402
|
: consensus.status === 'invalid' ? 'conflict'
|
|
@@ -384,12 +409,12 @@ export class OwnerResolver {
|
|
|
384
409
|
this.notify(subjectAid, ownerAid);
|
|
385
410
|
if (ownerAid === subject.view.requestedOwnerAid && subject.view.requestStatus === 'interrupted') {
|
|
386
411
|
subject.view.requestStatus = 'submitted';
|
|
387
|
-
this.persist(subjectAid, subject);
|
|
388
412
|
}
|
|
389
413
|
}
|
|
390
414
|
catch (error) {
|
|
391
415
|
if (subject.client === client && subject.generation === generation && subject.epoch === epoch && subject.query === query) {
|
|
392
|
-
|
|
416
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
417
|
+
this.handleRefreshFailure(subjectAid, subject, error, attemptedAt, attemptId);
|
|
393
418
|
}
|
|
394
419
|
}
|
|
395
420
|
finally {
|
|
@@ -398,14 +423,75 @@ export class OwnerResolver {
|
|
|
398
423
|
}
|
|
399
424
|
return this.snapshot(subjectAid);
|
|
400
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
|
+
}
|
|
401
489
|
async bind(subjectAid, ownerAid) {
|
|
402
|
-
this.
|
|
490
|
+
this.assertAid(subjectAid);
|
|
403
491
|
this.assertAid(ownerAid);
|
|
404
492
|
if (ownerAid === subjectAid)
|
|
405
493
|
throw new OwnershipError('INVALID_OWNER', 'Subject cannot own itself');
|
|
406
494
|
const subject = this.subject(subjectAid);
|
|
407
|
-
if (subject.persistenceError)
|
|
408
|
-
throw new OwnershipError('persistence_unavailable', subject.persistenceError);
|
|
409
495
|
if (subject.binding) {
|
|
410
496
|
if (subject.binding.ownerAid !== ownerAid)
|
|
411
497
|
throw new OwnershipError('BIND_CONFLICT', 'Another owner binding is in progress');
|
|
@@ -421,93 +507,261 @@ export class OwnerResolver {
|
|
|
421
507
|
subject.binding = undefined;
|
|
422
508
|
}
|
|
423
509
|
}
|
|
510
|
+
async decide(subjectAid, operationId, decision, reason) {
|
|
511
|
+
const subject = this.subject(subjectAid);
|
|
512
|
+
const generation = subject.generation;
|
|
513
|
+
logger.info(`[ownership] ${decision} requested local=${subjectAid} operationId=${operationId}`);
|
|
514
|
+
try {
|
|
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
|
+
});
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
logger.warn(`[ownership] ${decision} failed ${JSON.stringify({
|
|
547
|
+
localAid: subjectAid, operationId, error: error instanceof Error ? error.message : String(error),
|
|
548
|
+
})}`);
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
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
|
+
}
|
|
424
604
|
stop() {
|
|
425
605
|
for (const subjectAid of this.subjects.keys())
|
|
426
606
|
this.detachClient(subjectAid);
|
|
427
607
|
this.configuredOwners.clear();
|
|
428
608
|
}
|
|
429
609
|
async submitBinding(subjectAid, ownerAid, subject) {
|
|
430
|
-
const current = await this.refresh(subjectAid, { force: true });
|
|
431
|
-
if (current.consensusStatus === 'consensus') {
|
|
432
|
-
if (current.ownerAid === ownerAid)
|
|
433
|
-
return current;
|
|
434
|
-
throw new OwnershipError('OWNER_ALREADY_BOUND', 'Subject already has a protocol owner; transfer is not supported');
|
|
435
|
-
}
|
|
436
|
-
const recovering = (subject.view.task?.status === 'pending' || subject.view.requestStatus === 'interrupted')
|
|
437
|
-
&& subject.view.requestedOwnerAid === ownerAid;
|
|
438
|
-
if (!['unbound', 'released'].includes(current.consensusStatus)
|
|
439
|
-
&& !(recovering && current.consensusStatus === 'single_sided')) {
|
|
440
|
-
throw new OwnershipError('CONSENSUS_REQUIRED', 'Binding requires a fresh unbound or released consensus result');
|
|
441
|
-
}
|
|
442
|
-
if (subject.view.task?.status === 'pending') {
|
|
443
|
-
if (subject.view.task.owner_aid !== ownerAid)
|
|
444
|
-
throw new OwnershipError('BIND_CONFLICT', 'A different binding task is pending');
|
|
445
|
-
}
|
|
446
|
-
if (subject.view.requestStatus === 'interrupted' && subject.view.requestedOwnerAid !== ownerAid) {
|
|
447
|
-
throw new OwnershipError('BIND_UNCERTAIN', 'Retry the interrupted request with its original owner before changing the target');
|
|
448
|
-
}
|
|
449
610
|
const client = subject.client;
|
|
450
|
-
if (!client || !subject.view.connected)
|
|
451
|
-
throw new OwnershipError('SUBJECT_UNAVAILABLE', 'Subject AID SDK client is not ready');
|
|
452
611
|
const generation = subject.generation;
|
|
453
612
|
subject.view = { ...subject.view, requestedOwnerAid: ownerAid, requestStatus: 'requesting', task: undefined };
|
|
454
|
-
this.persist(subjectAid, subject);
|
|
455
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
|
+
}
|
|
456
636
|
const task = this.task(await client.bindOwneraid(ownerAid));
|
|
457
637
|
if (subject.client !== client || subject.generation !== generation) {
|
|
458
638
|
throw new OwnershipError('CLIENT_REPLACED', 'Subject SDK client changed during binding; the request may have been submitted');
|
|
459
639
|
}
|
|
460
640
|
if (!task || task.sub_aid !== subjectAid || task.owner_aid !== ownerAid || task.operation !== 'bind') {
|
|
461
|
-
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');
|
|
462
642
|
}
|
|
463
643
|
subject.view.task = task;
|
|
464
|
-
subject.view.requestStatus = 'submitted';
|
|
465
|
-
|
|
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
|
+
}
|
|
466
651
|
this.invalidate(subject, 'Binding submitted; waiting for explicit owner approval and fresh consensus');
|
|
467
652
|
this.schedule(subjectAid, subject);
|
|
468
653
|
return this.snapshot(subjectAid);
|
|
469
654
|
}
|
|
470
655
|
catch (error) {
|
|
471
|
-
if (subject.client === client && subject.generation === generation) {
|
|
656
|
+
if (!client || (subject.client === client && subject.generation === generation)) {
|
|
472
657
|
subject.view.requestStatus = 'interrupted';
|
|
473
658
|
subject.view.reason = error instanceof Error ? error.message : String(error);
|
|
474
|
-
this.persist(subjectAid, subject);
|
|
475
659
|
}
|
|
476
660
|
throw error;
|
|
477
661
|
}
|
|
478
662
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
let
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
let saved = null;
|
|
485
|
-
let persistenceError;
|
|
663
|
+
async tryLocalBind(subjectAid, ownerAid) {
|
|
664
|
+
let store;
|
|
665
|
+
let ownerClient;
|
|
666
|
+
let subClient;
|
|
667
|
+
let invoked = false;
|
|
486
668
|
try {
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
||
|
|
493
|
-
|
|
494
|
-
|
|
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 });
|
|
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');
|
|
495
692
|
}
|
|
693
|
+
return { task, consensus };
|
|
496
694
|
}
|
|
497
695
|
catch (error) {
|
|
498
|
-
|
|
499
|
-
|
|
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;
|
|
500
701
|
}
|
|
501
|
-
|
|
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 { }
|
|
719
|
+
}
|
|
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;
|
|
502
760
|
subject = {
|
|
503
|
-
generation: 0, epoch: 0, query: 0, subscriptions: [], taskEvents: new Set(),
|
|
761
|
+
generation: 0, epoch: 0, query: 0, subscriptions: [], taskEvents: new Set(),
|
|
762
|
+
requests: new Map(), decisionTasks: new Map(),
|
|
504
763
|
view: {
|
|
505
764
|
subjectAid, consensusStatus: 'unavailable', authorizationStatus: 'unavailable', connected: false, revision: 0,
|
|
506
|
-
task: task?.sub_aid === subjectAid ? task : undefined,
|
|
507
|
-
requestedOwnerAid: saved?.subjectAid === subjectAid && typeof saved.requestedOwnerAid === 'string'
|
|
508
|
-
&& isValidAid(saved.requestedOwnerAid) ? saved.requestedOwnerAid : undefined,
|
|
509
|
-
requestStatus: saved?.requestStatus === 'requesting' || saved?.requestStatus === 'interrupted' ? 'interrupted'
|
|
510
|
-
: task ? 'submitted' : undefined,
|
|
511
765
|
synchronization: { status: 'unavailable' },
|
|
512
766
|
},
|
|
513
767
|
};
|
|
@@ -519,7 +773,8 @@ export class OwnerResolver {
|
|
|
519
773
|
subject.epoch += 1;
|
|
520
774
|
subject.view = {
|
|
521
775
|
...subject.view, ownerAid: undefined, consensusStatus: 'unavailable', authorizationStatus: 'unavailable', validUntil: undefined,
|
|
522
|
-
revision: subject.view.revision + 1, reason,
|
|
776
|
+
revision: subject.view.revision + 1, reason, refreshFailure: undefined,
|
|
777
|
+
synchronization: { status: 'unavailable' },
|
|
523
778
|
};
|
|
524
779
|
if (previousOwner)
|
|
525
780
|
this.notify(subject.view.subjectAid);
|
|
@@ -544,63 +799,17 @@ export class OwnerResolver {
|
|
|
544
799
|
}
|
|
545
800
|
}
|
|
546
801
|
}
|
|
547
|
-
schedule(subjectAid, subject) {
|
|
802
|
+
schedule(subjectAid, subject, allowTransient = true) {
|
|
548
803
|
if (subject.timer || !subject.client || !subject.view.connected)
|
|
549
804
|
return;
|
|
550
805
|
subject.timer = setTimeout(() => {
|
|
551
806
|
subject.timer = undefined;
|
|
552
|
-
void this.refresh(subjectAid, { force: true }).catch(error => {
|
|
807
|
+
void this.refresh(subjectAid, { force: true, allowTransient }).catch(error => {
|
|
553
808
|
logger.warn(`[ownership] Event refresh failed for ${subjectAid}: ${error instanceof Error ? error.message : String(error)}`);
|
|
554
809
|
});
|
|
555
810
|
}, 0);
|
|
556
811
|
subject.timer.unref?.();
|
|
557
812
|
}
|
|
558
|
-
schedulePendingRefresh(subjectAid, subject) {
|
|
559
|
-
// The owner's final publication can become readable after the last SDK
|
|
560
|
-
// event. A cached unbound/single-sided observation must not strand an
|
|
561
|
-
// approved creation in ownerpending until the next connection or command.
|
|
562
|
-
// Only fresh SDK consensus can authorize the existing owner-bound handler;
|
|
563
|
-
// this never resubmits a binding or infers ownership from task completion.
|
|
564
|
-
const needsRefresh = subject.client && subject.view.connected
|
|
565
|
-
&& subject.view.requestedOwnerAid && subject.view.consensusStatus !== 'consensus'
|
|
566
|
-
&& (subject.view.task?.status === 'pending' || subject.view.task?.status === 'completed'
|
|
567
|
-
|| subject.view.requestStatus === 'requesting' || subject.view.requestStatus === 'interrupted');
|
|
568
|
-
if (!needsRefresh) {
|
|
569
|
-
if (subject.pendingRefreshTimer)
|
|
570
|
-
clearTimeout(subject.pendingRefreshTimer);
|
|
571
|
-
subject.pendingRefreshTimer = undefined;
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
if (subject.pendingRefreshTimer)
|
|
575
|
-
return;
|
|
576
|
-
subject.pendingRefreshTimer = setTimeout(() => {
|
|
577
|
-
subject.pendingRefreshTimer = undefined;
|
|
578
|
-
void this.refresh(subjectAid, { force: true }).catch(error => {
|
|
579
|
-
logger.warn(`[ownership] Pending consensus refresh failed for ${subjectAid}: ${error instanceof Error ? error.message : String(error)}`);
|
|
580
|
-
});
|
|
581
|
-
}, OWNERSHIP_PENDING_REFRESH_MS);
|
|
582
|
-
subject.pendingRefreshTimer.unref?.();
|
|
583
|
-
}
|
|
584
|
-
statePath(subjectAid) {
|
|
585
|
-
return path.join(this.options.stateDirectory?.() ?? path.join(resolvePaths().root, 'data', 'ownership'), `${subjectAid}.json`);
|
|
586
|
-
}
|
|
587
|
-
persist(subjectAid, subject) {
|
|
588
|
-
if (!subject.persistenceError) {
|
|
589
|
-
try {
|
|
590
|
-
const filename = this.statePath(subjectAid);
|
|
591
|
-
fs.mkdirSync(path.dirname(filename), { recursive: true });
|
|
592
|
-
atomicWriteJson(filename, {
|
|
593
|
-
version: 1, subjectAid, requestedOwnerAid: subject.view.requestedOwnerAid,
|
|
594
|
-
requestStatus: subject.view.requestStatus, task: subject.view.task,
|
|
595
|
-
});
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
|
-
catch (error) {
|
|
599
|
-
subject.persistenceError = `Ownership task storage is unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
throw new OwnershipError('persistence_unavailable', subject.persistenceError);
|
|
603
|
-
}
|
|
604
813
|
now() {
|
|
605
814
|
return this.options.now?.() ?? Date.now();
|
|
606
815
|
}
|
|
@@ -639,15 +848,107 @@ export class OwnerResolver {
|
|
|
639
848
|
record(value) {
|
|
640
849
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
641
850
|
}
|
|
642
|
-
|
|
851
|
+
trackBindingRequest(localAid, subject, event, record) {
|
|
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);
|
|
859
|
+
if (!task || (task.sub_aid !== localAid && task.owner_aid !== localAid))
|
|
860
|
+
return;
|
|
861
|
+
const request = subject.requests.get(task.operation_id);
|
|
862
|
+
const decisionTask = subject.decisionTasks.get(task.operation_id);
|
|
863
|
+
if ((request && !this.sameBindingTask(task, request))
|
|
864
|
+
|| (decisionTask && !this.sameBindingTask(task, decisionTask)))
|
|
865
|
+
return;
|
|
866
|
+
if (event === 'ownership.request') {
|
|
867
|
+
const requester = record.requester_aid;
|
|
868
|
+
const remote = task.sub_aid === localAid ? task.owner_aid : task.sub_aid;
|
|
869
|
+
if (task.status !== 'pending' || task.step !== 'approval' || requester !== remote || decisionTask)
|
|
870
|
+
return;
|
|
871
|
+
subject.requests.set(task.operation_id, {
|
|
872
|
+
...task, requester_aid: remote, local_role: task.sub_aid === localAid ? 'sub' : 'owner',
|
|
873
|
+
});
|
|
874
|
+
logger.info(`[ownership] inbound binding ${JSON.stringify({
|
|
875
|
+
localAid, operationId: task.operation_id, subAid: task.sub_aid, ownerAid: task.owner_aid,
|
|
876
|
+
})}`);
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
const awaitingApproval = task.step === 'approval'
|
|
880
|
+
&& (task.status === 'pending' || (task.status === 'error' && task.retryable === true))
|
|
881
|
+
&& !['ownership.completed', 'ownership.rejected', 'ownership.stopped'].includes(event);
|
|
882
|
+
if (request && awaitingApproval)
|
|
883
|
+
subject.requests.set(task.operation_id, { ...request, ...task });
|
|
884
|
+
else
|
|
885
|
+
subject.requests.delete(task.operation_id);
|
|
886
|
+
}
|
|
887
|
+
const retryableDecision = task.step !== 'approval'
|
|
888
|
+
&& (task.status === 'pending' || (task.status === 'error' && task.retryable === true))
|
|
889
|
+
&& !['ownership.completed', 'ownership.rejected', 'ownership.stopped'].includes(event);
|
|
890
|
+
if (decisionTask || retryableDecision) {
|
|
891
|
+
subject.decisionTasks.set(task.operation_id, task);
|
|
892
|
+
subject.view.decisionTask = task;
|
|
893
|
+
}
|
|
894
|
+
else if (subject.view.decisionTask?.operation_id === task.operation_id) {
|
|
895
|
+
subject.view.decisionTask = task;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
sameBindingTask(left, right) {
|
|
899
|
+
return left.operation_id === right.operation_id && left.sub_aid === right.sub_aid
|
|
900
|
+
&& left.owner_aid === right.owner_aid && left.new_owner_aid === right.new_owner_aid
|
|
901
|
+
&& left.base_relation_version === right.base_relation_version
|
|
902
|
+
&& left.target_relation_version === right.target_relation_version;
|
|
903
|
+
}
|
|
904
|
+
/** Only public task fields are exposed; raw SDK records remain SDK-owned. */
|
|
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)
|
|
908
|
+
|| task.sub_aid === task.owner_aid || !Number.isSafeInteger(task.base_relation_version)
|
|
909
|
+
|| task.base_relation_version < 0 || !Number.isSafeInteger(task.target_relation_version)
|
|
910
|
+
|| task.target_relation_version !== task.base_relation_version + 1)
|
|
911
|
+
return undefined;
|
|
912
|
+
if (task.new_owner_aid !== undefined)
|
|
913
|
+
return undefined;
|
|
914
|
+
return {
|
|
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 } : {}),
|
|
917
|
+
base_relation_version: task.base_relation_version, target_relation_version: task.target_relation_version,
|
|
918
|
+
status: task.status, step: task.step,
|
|
919
|
+
...(typeof task.code === 'string' ? { code: task.code } : {}),
|
|
920
|
+
...(typeof task.reason === 'string' ? { reason: task.reason } : {}),
|
|
921
|
+
...(typeof task.retryable === 'boolean' ? { retryable: task.retryable } : {}),
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
task(value, event) {
|
|
643
925
|
const record = this.record(value);
|
|
926
|
+
const baseRelationVersion = record?.base_relation_version;
|
|
927
|
+
const targetRelationVersion = record?.target_relation_version;
|
|
644
928
|
if (!record || typeof record.operation_id !== 'string' || !record.operation_id
|
|
645
|
-
|| record.operation
|
|
929
|
+
|| !['bind', 'transfer'].includes(String(record.operation)) || typeof record.sub_aid !== 'string' || !isValidAid(record.sub_aid)
|
|
646
930
|
|| typeof record.owner_aid !== 'string' || !isValidAid(record.owner_aid)
|
|
647
|
-
||
|
|
648
|
-
|| 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)
|
|
649
935
|
return undefined;
|
|
650
|
-
|
|
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
|
+
};
|
|
651
952
|
}
|
|
652
953
|
}
|
|
653
954
|
export const ownerResolver = new OwnerResolver();
|
|
@@ -662,10 +963,17 @@ export async function refreshOwnershipForAuthorization(subjectAid, options = {})
|
|
|
662
963
|
const ownershipSubjectAid = daemonAid && subjectAid === `${daemonAid}#daemon` ? daemonAid : subjectAid;
|
|
663
964
|
const subjects = new Set([ownershipSubjectAid, ...(!ownershipSubjectAid || options.includeDaemon !== false ? [daemonAid] : [])]
|
|
664
965
|
.filter((aid) => !!aid));
|
|
665
|
-
await Promise.all([...subjects].map(aid => ownerResolver.refresh(aid, {
|
|
966
|
+
const views = await Promise.all([...subjects].map(aid => ownerResolver.refresh(aid, {
|
|
666
967
|
maxAgeMs: options.maxAgeMs ?? OWNERSHIP_AUTHORIZATION_MAX_AGE_MS,
|
|
667
968
|
force: options.force,
|
|
969
|
+
allowTransient: options.allowTransient,
|
|
668
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
|
+
}
|
|
669
977
|
}
|
|
670
978
|
export async function assertOwnershipAuthorization(context) {
|
|
671
979
|
const authorization = context?.executionPermission;
|
|
@@ -673,15 +981,32 @@ export async function assertOwnershipAuthorization(context) {
|
|
|
673
981
|
const ownerActor = hasTrustedPrincipal(context?.actor) ? context.actor.principalId : undefined;
|
|
674
982
|
if (!authorization && !ownerRole)
|
|
675
983
|
return;
|
|
676
|
-
await refreshOwnershipForAuthorization(context?.selfAid ?? authorization?.agentAid);
|
|
984
|
+
await refreshOwnershipForAuthorization(context?.selfAid ?? authorization?.agentAid, { allowTransient: true });
|
|
985
|
+
const subjectAid = context?.selfAid ?? authorization?.agentAid;
|
|
677
986
|
if (authorization) {
|
|
678
987
|
if (!getDaemonOwners().includes(authorization.authorizedBy)
|
|
679
988
|
|| (authorization.source === 'agent-policy'
|
|
680
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
|
+
})}`);
|
|
681
998
|
throw new OwnershipError('OWNER_UNAVAILABLE', 'Current Owner no longer authorizes this execution');
|
|
682
999
|
}
|
|
683
1000
|
}
|
|
684
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
|
+
})}`);
|
|
685
1010
|
throw new OwnershipError('OWNER_UNAVAILABLE', 'Current Owner could not be verified; start a new task with the current role');
|
|
686
1011
|
}
|
|
687
1012
|
}
|