@zhin.js/plugin-runtime 1.1.3 → 1.1.6
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/lib/admission.d.ts +31 -0
- package/lib/admission.js +133 -0
- package/lib/component-host.d.ts +50 -0
- package/lib/component-host.js +2 -0
- package/lib/handoff.d.ts +7 -13
- package/lib/handoff.js +10 -34
- package/lib/identity.d.ts +9 -0
- package/lib/identity.js +35 -3
- package/lib/index.d.ts +5 -2
- package/lib/index.js +5 -2
- package/lib/outbound-host.d.ts +17 -7
- package/lib/outbound-sender.d.ts +70 -0
- package/lib/outbound-sender.js +85 -0
- package/lib/plugin.d.ts +1 -0
- package/lib/root-controller.d.ts +8 -4
- package/lib/root-controller.js +73 -34
- package/lib/snapshot.d.ts +13 -3
- package/lib/snapshot.js +58 -14
- package/package.json +1 -1
- package/lib/agent-tools-host.d.ts +0 -38
- package/lib/agent-tools-host.js +0 -2
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ingress gate owned by a lifecycle Resource and switched only by SnapshotStore.
|
|
3
|
+
* External callbacks may enter while the owning Resource is present in the
|
|
4
|
+
* committed snapshot; candidate and retired Resources fail closed.
|
|
5
|
+
*/
|
|
6
|
+
export declare class GenerationAdmissionGate {
|
|
7
|
+
constructor();
|
|
8
|
+
get active(): boolean;
|
|
9
|
+
acquire(): (() => void) | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Observe retirement of the generation that owns this gate. The callback is
|
|
12
|
+
* synchronous and must not fail; it is intended for closing process-owned
|
|
13
|
+
* ingress transports such as WebSocket connections.
|
|
14
|
+
*/
|
|
15
|
+
onDeactivate(listener: () => void): () => void;
|
|
16
|
+
enter<T>(operation: () => T): T | undefined;
|
|
17
|
+
}
|
|
18
|
+
export declare function createGenerationAdmissionGate(): GenerationAdmissionGate;
|
|
19
|
+
/** Resource hook used by CapabilityContext to bind an ingress dependency. */
|
|
20
|
+
export declare const generationAdmissionBinder: unique symbol;
|
|
21
|
+
export interface GenerationAdmissionBindable<T> {
|
|
22
|
+
[generationAdmissionBinder](gate: GenerationAdmissionGate): T;
|
|
23
|
+
}
|
|
24
|
+
/** Snapshot value hook used to declare lifecycle-owned ingress gates. */
|
|
25
|
+
export declare const generationAdmissionSource: unique symbol;
|
|
26
|
+
export interface GenerationAdmissionSource {
|
|
27
|
+
readonly [generationAdmissionSource]: readonly GenerationAdmissionGate[];
|
|
28
|
+
}
|
|
29
|
+
export declare function bindGenerationAdmission<T>(value: T, gate: GenerationAdmissionGate): T;
|
|
30
|
+
export declare function collectGenerationAdmissions(values: Iterable<unknown>): ReadonlySet<GenerationAdmissionGate>;
|
|
31
|
+
export declare function replaceGenerationAdmissions(previous: ReadonlySet<GenerationAdmissionGate>, next: ReadonlySet<GenerationAdmissionGate>, owner: object, acquireNext?: () => () => void): void;
|
package/lib/admission.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ingress gate owned by a lifecycle Resource and switched only by SnapshotStore.
|
|
3
|
+
* External callbacks may enter while the owning Resource is present in the
|
|
4
|
+
* committed snapshot; candidate and retired Resources fail closed.
|
|
5
|
+
*/
|
|
6
|
+
export class GenerationAdmissionGate {
|
|
7
|
+
constructor() {
|
|
8
|
+
admissionState.set(this, { active: false });
|
|
9
|
+
admissionDeactivationListeners.set(this, new Set());
|
|
10
|
+
}
|
|
11
|
+
get active() {
|
|
12
|
+
return admissionState.get(this)?.active === true;
|
|
13
|
+
}
|
|
14
|
+
acquire() {
|
|
15
|
+
const state = admissionState.get(this);
|
|
16
|
+
return state?.active ? state.acquire?.() : undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Observe retirement of the generation that owns this gate. The callback is
|
|
20
|
+
* synchronous and must not fail; it is intended for closing process-owned
|
|
21
|
+
* ingress transports such as WebSocket connections.
|
|
22
|
+
*/
|
|
23
|
+
onDeactivate(listener) {
|
|
24
|
+
const listeners = admissionDeactivationListeners.get(this);
|
|
25
|
+
if (!listeners)
|
|
26
|
+
throw new Error('Unknown generation admission gate');
|
|
27
|
+
if (!this.active) {
|
|
28
|
+
listener();
|
|
29
|
+
return () => undefined;
|
|
30
|
+
}
|
|
31
|
+
listeners.add(listener);
|
|
32
|
+
return () => { listeners.delete(listener); };
|
|
33
|
+
}
|
|
34
|
+
enter(operation) {
|
|
35
|
+
const release = this.acquire();
|
|
36
|
+
if (!release)
|
|
37
|
+
return undefined;
|
|
38
|
+
try {
|
|
39
|
+
const result = operation();
|
|
40
|
+
if (isPromiseLike(result))
|
|
41
|
+
return Promise.resolve(result).finally(release);
|
|
42
|
+
release();
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
release();
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const admissionState = new WeakMap();
|
|
52
|
+
const admissionOwners = new WeakMap();
|
|
53
|
+
const admissionDeactivationListeners = new WeakMap();
|
|
54
|
+
export function createGenerationAdmissionGate() {
|
|
55
|
+
return new GenerationAdmissionGate();
|
|
56
|
+
}
|
|
57
|
+
/** Resource hook used by CapabilityContext to bind an ingress dependency. */
|
|
58
|
+
export const generationAdmissionBinder = Symbol('GenerationAdmissionBinder');
|
|
59
|
+
/** Snapshot value hook used to declare lifecycle-owned ingress gates. */
|
|
60
|
+
export const generationAdmissionSource = Symbol('GenerationAdmissionSource');
|
|
61
|
+
export function bindGenerationAdmission(value, gate) {
|
|
62
|
+
if (!isObject(value))
|
|
63
|
+
return value;
|
|
64
|
+
const binder = value[generationAdmissionBinder];
|
|
65
|
+
return typeof binder === 'function' ? binder.call(value, gate) : value;
|
|
66
|
+
}
|
|
67
|
+
export function collectGenerationAdmissions(values) {
|
|
68
|
+
const result = new Set();
|
|
69
|
+
for (const value of values) {
|
|
70
|
+
if (value instanceof GenerationAdmissionGate) {
|
|
71
|
+
result.add(value);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (!isObject(value))
|
|
75
|
+
continue;
|
|
76
|
+
const gates = value[generationAdmissionSource];
|
|
77
|
+
if (!Array.isArray(gates))
|
|
78
|
+
continue;
|
|
79
|
+
for (const gate of gates) {
|
|
80
|
+
if (!(gate instanceof GenerationAdmissionGate)) {
|
|
81
|
+
throw new TypeError('Generation admission source returned an invalid gate');
|
|
82
|
+
}
|
|
83
|
+
result.add(gate);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
export function replaceGenerationAdmissions(previous, next, owner, acquireNext) {
|
|
89
|
+
// Validate the complete switch before changing ownership or visibility. A
|
|
90
|
+
// rejected cross-Root gate must leave both generations exactly untouched.
|
|
91
|
+
for (const gate of next)
|
|
92
|
+
assertClaimableAdmission(gate, owner);
|
|
93
|
+
for (const gate of previous) {
|
|
94
|
+
assertAdmissionOwner(gate, owner);
|
|
95
|
+
}
|
|
96
|
+
for (const gate of next)
|
|
97
|
+
admissionOwners.set(gate, owner);
|
|
98
|
+
for (const gate of previous) {
|
|
99
|
+
if (next.has(gate))
|
|
100
|
+
continue;
|
|
101
|
+
admissionState.set(gate, { active: false });
|
|
102
|
+
for (const listener of admissionDeactivationListeners.get(gate) ?? []) {
|
|
103
|
+
try {
|
|
104
|
+
listener();
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Admission publication is an infallible pointer switch. Transport
|
|
108
|
+
// cleanup reports through its own lifecycle and cannot veto commit.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const gate of next) {
|
|
113
|
+
admissionState.set(gate, { active: true, acquire: acquireNext });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function assertClaimableAdmission(gate, owner) {
|
|
117
|
+
const current = admissionOwners.get(gate);
|
|
118
|
+
if (current && current !== owner) {
|
|
119
|
+
throw new Error('Generation admission gate belongs to another SnapshotStore');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function assertAdmissionOwner(gate, owner) {
|
|
123
|
+
if (admissionOwners.get(gate) !== owner) {
|
|
124
|
+
throw new Error('SnapshotStore does not own generation admission gate');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function isObject(value) {
|
|
128
|
+
return (typeof value === 'object' && value !== null) || typeof value === 'function';
|
|
129
|
+
}
|
|
130
|
+
function isPromiseLike(value) {
|
|
131
|
+
return isObject(value)
|
|
132
|
+
&& typeof value.then === 'function';
|
|
133
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound template context — conversation + incoming message metadata
|
|
3
|
+
* available as top-level variables inside `${expr}` template expressions.
|
|
4
|
+
*
|
|
5
|
+
* Templates can reference e.g. `${sender.name}`, `${adapter}`,
|
|
6
|
+
* `${content}`, `${messageId}`, `${timestamp}`.
|
|
7
|
+
*/
|
|
8
|
+
export interface TemplateContext {
|
|
9
|
+
/** Adapter feature name (e.g. 'qq', 'sandbox'). */
|
|
10
|
+
readonly adapter: string;
|
|
11
|
+
/** Endpoint instance id. */
|
|
12
|
+
readonly endpoint: string;
|
|
13
|
+
/** Conversation kind: 'private' | 'group' | 'channel'. */
|
|
14
|
+
readonly kind: string;
|
|
15
|
+
/** Native conversation id. */
|
|
16
|
+
readonly conversationId: string;
|
|
17
|
+
/** Sender info (absent for scheduled / cross-channel sends). */
|
|
18
|
+
readonly sender?: Readonly<{
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly name?: string;
|
|
21
|
+
readonly roles?: readonly string[];
|
|
22
|
+
}>;
|
|
23
|
+
/** Incoming message text content. */
|
|
24
|
+
readonly content?: string;
|
|
25
|
+
/** Structured message segments (images, mentions, quotes, etc.). */
|
|
26
|
+
readonly segments?: readonly Readonly<{
|
|
27
|
+
type: string;
|
|
28
|
+
data?: Record<string, unknown>;
|
|
29
|
+
}>[];
|
|
30
|
+
/** Platform native message id. */
|
|
31
|
+
readonly messageId?: string;
|
|
32
|
+
/** Message arrival timestamp in ms. */
|
|
33
|
+
readonly timestamp?: number;
|
|
34
|
+
/** Endpoint instance name (e.g. ICQQ uin, sandbox bot name). */
|
|
35
|
+
readonly endpointId?: string;
|
|
36
|
+
/** Whether the bot was @mentioned. */
|
|
37
|
+
readonly mentioned?: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Thin Host Resource for Plugin Runtime outbound template compilation.
|
|
41
|
+
* Evaluates `${expr}` template expressions in a sandboxed VM context
|
|
42
|
+
* (same security boundary as the legacy ComponentFeature pipeline).
|
|
43
|
+
*
|
|
44
|
+
* Implemented by the CLI Host; absent when not wired — consumers must
|
|
45
|
+
* pass text through unchanged.
|
|
46
|
+
*/
|
|
47
|
+
export interface ComponentHost {
|
|
48
|
+
compileTemplate(text: string, context: TemplateContext): string;
|
|
49
|
+
}
|
|
50
|
+
export declare const componentHostToken: import("./token.js").Token<ComponentHost>;
|
package/lib/handoff.d.ts
CHANGED
|
@@ -1,30 +1,24 @@
|
|
|
1
|
-
import type { RuntimeSnapshot } from './snapshot.js';
|
|
2
1
|
export interface GenerationHandoff {
|
|
3
|
-
|
|
4
|
-
activateNext(): void | Promise<void>;
|
|
2
|
+
activateNext(signal: AbortSignal): void | Promise<void>;
|
|
5
3
|
deactivateNext(): void | Promise<void>;
|
|
6
|
-
resumePrevious(): void | Promise<void>;
|
|
7
|
-
openNext(): void;
|
|
8
4
|
}
|
|
9
5
|
export interface GenerationHandoffParticipant {
|
|
10
|
-
|
|
11
|
-
activateNext?(): void | Promise<void>;
|
|
6
|
+
activateNext?(signal: AbortSignal): void | Promise<void>;
|
|
12
7
|
deactivateNext?(): void | Promise<void>;
|
|
13
|
-
resumePrevious?(): void | Promise<void>;
|
|
14
|
-
openNext?(): void;
|
|
15
8
|
}
|
|
16
9
|
export interface GenerationHandoffRegistry {
|
|
17
10
|
add(participant: GenerationHandoffParticipant): GenerationHandoffParticipant;
|
|
18
11
|
}
|
|
12
|
+
/** A transaction failed and its internal compensation also failed. */
|
|
13
|
+
export declare class GenerationCompensationError extends AggregateError {
|
|
14
|
+
constructor(errors: readonly unknown[], message: string, options?: ErrorOptions);
|
|
15
|
+
}
|
|
19
16
|
/** Composes owner-ordered Resource handoffs and compensates partial progress. */
|
|
20
17
|
export declare class GenerationHandoffStack implements GenerationHandoffRegistry, GenerationHandoff {
|
|
21
18
|
#private;
|
|
22
19
|
get size(): number;
|
|
23
20
|
add(participant: GenerationHandoffParticipant): GenerationHandoffParticipant;
|
|
24
21
|
seal(): GenerationHandoff | undefined;
|
|
25
|
-
|
|
26
|
-
activateNext(): Promise<void>;
|
|
22
|
+
activateNext(signal: AbortSignal): Promise<void>;
|
|
27
23
|
deactivateNext(): Promise<void>;
|
|
28
|
-
resumePrevious(): Promise<void>;
|
|
29
|
-
openNext(): void;
|
|
30
24
|
}
|
package/lib/handoff.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { DisposeStack } from './dispose.js';
|
|
2
|
+
/** A transaction failed and its internal compensation also failed. */
|
|
3
|
+
export class GenerationCompensationError extends AggregateError {
|
|
4
|
+
constructor(errors, message, options) {
|
|
5
|
+
super(errors, message, options);
|
|
6
|
+
this.name = 'GenerationCompensationError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
2
9
|
/** Composes owner-ordered Resource handoffs and compensates partial progress. */
|
|
3
10
|
export class GenerationHandoffStack {
|
|
4
11
|
#participants = [];
|
|
5
|
-
#quiesced = [];
|
|
6
12
|
#activated = [];
|
|
7
13
|
#sealed = false;
|
|
8
14
|
get size() {
|
|
@@ -18,27 +24,13 @@ export class GenerationHandoffStack {
|
|
|
18
24
|
this.#sealed = true;
|
|
19
25
|
return this.size > 0 ? this : undefined;
|
|
20
26
|
}
|
|
21
|
-
async
|
|
22
|
-
this.#assertSealed();
|
|
23
|
-
try {
|
|
24
|
-
for (const participant of [...this.#participants].reverse()) {
|
|
25
|
-
if (!participant.quiescePrevious)
|
|
26
|
-
continue;
|
|
27
|
-
await participant.quiescePrevious(previous);
|
|
28
|
-
this.#quiesced.push(participant);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
catch (error) {
|
|
32
|
-
await compensate(error, () => this.resumePrevious(), 'Quiesce and resume both failed');
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
async activateNext() {
|
|
27
|
+
async activateNext(signal) {
|
|
36
28
|
this.#assertSealed();
|
|
37
29
|
try {
|
|
38
30
|
for (const participant of this.#participants) {
|
|
39
31
|
if (!participant.activateNext)
|
|
40
32
|
continue;
|
|
41
|
-
await participant.activateNext();
|
|
33
|
+
await participant.activateNext(signal);
|
|
42
34
|
this.#activated.push(participant);
|
|
43
35
|
}
|
|
44
36
|
}
|
|
@@ -49,22 +41,6 @@ export class GenerationHandoffStack {
|
|
|
49
41
|
async deactivateNext() {
|
|
50
42
|
await unwind(this.#activated, (participant) => participant.deactivateNext?.(), 'One or more next-generation Resources failed to deactivate');
|
|
51
43
|
}
|
|
52
|
-
async resumePrevious() {
|
|
53
|
-
await unwind(this.#quiesced, (participant) => participant.resumePrevious?.(), 'One or more previous-generation Resources failed to resume');
|
|
54
|
-
}
|
|
55
|
-
openNext() {
|
|
56
|
-
const errors = [];
|
|
57
|
-
for (const participant of this.#participants) {
|
|
58
|
-
try {
|
|
59
|
-
participant.openNext?.();
|
|
60
|
-
}
|
|
61
|
-
catch (error) {
|
|
62
|
-
errors.push(error);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
if (errors.length > 0)
|
|
66
|
-
throw new AggregateError(errors, 'One or more Resources failed to open');
|
|
67
|
-
}
|
|
68
44
|
#assertSealed() {
|
|
69
45
|
if (!this.#sealed)
|
|
70
46
|
throw new Error('GenerationHandoffStack is not sealed');
|
|
@@ -75,7 +51,7 @@ async function compensate(primary, rollback, message) {
|
|
|
75
51
|
await rollback();
|
|
76
52
|
}
|
|
77
53
|
catch (rollbackError) {
|
|
78
|
-
throw new
|
|
54
|
+
throw new GenerationCompensationError([primary, rollbackError], message, { cause: rollbackError });
|
|
79
55
|
}
|
|
80
56
|
throw primary;
|
|
81
57
|
}
|
package/lib/identity.d.ts
CHANGED
|
@@ -18,6 +18,15 @@ export declare function tokenId(value: string): TokenId;
|
|
|
18
18
|
export declare function featureId(value: string): FeatureId;
|
|
19
19
|
export declare function rootPluginId(): PluginId;
|
|
20
20
|
export declare function childPluginId(parent: PluginId, instanceKey: string): PluginId;
|
|
21
|
+
/**
|
|
22
|
+
* Capability localName 的单段校验(`/` 分隔前的一段)。
|
|
23
|
+
*
|
|
24
|
+
* - `$name`:动态参数段(ASCII)
|
|
25
|
+
* - ASCII kebab / snake:`hello` / `lottery-today` / `voice_stt`
|
|
26
|
+
* - Unicode 名:须含至少一个非 ASCII 字符,且不得含 ASCII 大写(拉丁仍走 kebab/snake)
|
|
27
|
+
*/
|
|
28
|
+
export declare function isCapabilityLocalSegment(segment: string): boolean;
|
|
29
|
+
export declare function isCapabilityLocalName(localName: string): boolean;
|
|
21
30
|
/**
|
|
22
31
|
* Database-safe owner encoding used by process-wide plugin resources.
|
|
23
32
|
*
|
package/lib/identity.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
const namespacePattern = /^[a-z][a-z0-9.-]*$/;
|
|
2
|
+
/** Plugin instanceKey 仍限 ASCII kebab(配置键 / PluginId 路径段)。 */
|
|
2
3
|
const localNamePattern = /^[a-z0-9][a-z0-9-]*$/;
|
|
3
|
-
|
|
4
|
-
const
|
|
4
|
+
/** 动态参数段(`[name].ts` → `$name`),标识符仍为 ASCII。 */
|
|
5
|
+
const dynamicCapabilitySegment = /^\$[a-z][a-zA-Z0-9]*$/u;
|
|
6
|
+
/**
|
|
7
|
+
* 静态 Capability / 命令路径段:ASCII kebab 或 snake(Host / agent tools 如 `voice_stt`),
|
|
8
|
+
* 或含非 ASCII 字母的 Unicode 名(如 `赞我`)。
|
|
9
|
+
*/
|
|
10
|
+
const asciiNameSegment = /^[a-z0-9][a-z0-9_-]*$/u;
|
|
11
|
+
const unicodeNameSegment = /^[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*$/u;
|
|
5
12
|
function assertNamespace(value, label) {
|
|
6
13
|
if (!namespacePattern.test(value)) {
|
|
7
14
|
throw new TypeError(`Invalid ${label}: ${value}`);
|
|
@@ -24,6 +31,31 @@ export function childPluginId(parent, instanceKey) {
|
|
|
24
31
|
}
|
|
25
32
|
return `${parent}/${instanceKey}`;
|
|
26
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Capability localName 的单段校验(`/` 分隔前的一段)。
|
|
36
|
+
*
|
|
37
|
+
* - `$name`:动态参数段(ASCII)
|
|
38
|
+
* - ASCII kebab / snake:`hello` / `lottery-today` / `voice_stt`
|
|
39
|
+
* - Unicode 名:须含至少一个非 ASCII 字符,且不得含 ASCII 大写(拉丁仍走 kebab/snake)
|
|
40
|
+
*/
|
|
41
|
+
export function isCapabilityLocalSegment(segment) {
|
|
42
|
+
if (!segment || segment.includes('/') || segment.includes('\0'))
|
|
43
|
+
return false;
|
|
44
|
+
if (dynamicCapabilitySegment.test(segment))
|
|
45
|
+
return true;
|
|
46
|
+
if (asciiNameSegment.test(segment))
|
|
47
|
+
return true;
|
|
48
|
+
if (/[A-Z]/.test(segment))
|
|
49
|
+
return false;
|
|
50
|
+
if (!/[^\x00-\x7F]/u.test(segment))
|
|
51
|
+
return false;
|
|
52
|
+
return unicodeNameSegment.test(segment);
|
|
53
|
+
}
|
|
54
|
+
export function isCapabilityLocalName(localName) {
|
|
55
|
+
if (!localName)
|
|
56
|
+
return false;
|
|
57
|
+
return localName.split('/').every(isCapabilityLocalSegment);
|
|
58
|
+
}
|
|
27
59
|
/**
|
|
28
60
|
* Database-safe owner encoding used by process-wide plugin resources.
|
|
29
61
|
*
|
|
@@ -38,7 +70,7 @@ export function pluginOwnerResourceKey(owner) {
|
|
|
38
70
|
.join('_')}_`;
|
|
39
71
|
}
|
|
40
72
|
export function capabilityId(owner, feature, localName) {
|
|
41
|
-
if (!
|
|
73
|
+
if (!isCapabilityLocalName(localName)) {
|
|
42
74
|
throw new TypeError(`Invalid capability local name: ${localName}`);
|
|
43
75
|
}
|
|
44
76
|
return `${owner}\0${feature}\0${localName}`;
|
package/lib/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export * from './capability.js';
|
|
3
3
|
export * from './dispose.js';
|
|
4
4
|
export * from './identity.js';
|
|
5
|
+
export { GenerationAdmissionGate, bindGenerationAdmission, createGenerationAdmissionGate, generationAdmissionBinder, generationAdmissionSource, type GenerationAdmissionBindable, type GenerationAdmissionSource, } from './admission.js';
|
|
5
6
|
export * from './generation-store.js';
|
|
6
7
|
export * from './handoff.js';
|
|
7
8
|
/** @public 用户侧创作面:`definePlugin`(`plugin.ts` 约定入口,承诺 semver)。 */
|
|
@@ -16,10 +17,12 @@ export * from './token.js';
|
|
|
16
17
|
export * from './schedule-host.js';
|
|
17
18
|
/** @public Host token:`databaseHostToken`(承诺 semver)。 */
|
|
18
19
|
export * from './database-host.js';
|
|
19
|
-
/** @public Host token:`agentToolsHostToken`(experimental)。 */
|
|
20
|
-
export * from './agent-tools-host.js';
|
|
21
20
|
export * from './html-renderer.js';
|
|
21
|
+
/** @public Host token:`componentHostToken`(承诺 semver)。 */
|
|
22
|
+
export * from './component-host.js';
|
|
22
23
|
/** @public Host token:`outboundHostToken`(承诺 semver)。 */
|
|
23
24
|
export * from './outbound-host.js';
|
|
25
|
+
/** @public 跨通道发送辅助:`createOutboundSender` / `sendTo`(承诺 semver)。 */
|
|
26
|
+
export * from './outbound-sender.js';
|
|
24
27
|
export * from './inbox.js';
|
|
25
28
|
export * from './system-log.js';
|
package/lib/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export * from './capability.js';
|
|
3
3
|
export * from './dispose.js';
|
|
4
4
|
export * from './identity.js';
|
|
5
|
+
export { GenerationAdmissionGate, bindGenerationAdmission, createGenerationAdmissionGate, generationAdmissionBinder, generationAdmissionSource, } from './admission.js';
|
|
5
6
|
export * from './generation-store.js';
|
|
6
7
|
export * from './handoff.js';
|
|
7
8
|
/** @public 用户侧创作面:`definePlugin`(`plugin.ts` 约定入口,承诺 semver)。 */
|
|
@@ -16,10 +17,12 @@ export * from './token.js';
|
|
|
16
17
|
export * from './schedule-host.js';
|
|
17
18
|
/** @public Host token:`databaseHostToken`(承诺 semver)。 */
|
|
18
19
|
export * from './database-host.js';
|
|
19
|
-
/** @public Host token:`agentToolsHostToken`(experimental)。 */
|
|
20
|
-
export * from './agent-tools-host.js';
|
|
21
20
|
export * from './html-renderer.js';
|
|
21
|
+
/** @public Host token:`componentHostToken`(承诺 semver)。 */
|
|
22
|
+
export * from './component-host.js';
|
|
22
23
|
/** @public Host token:`outboundHostToken`(承诺 semver)。 */
|
|
23
24
|
export * from './outbound-host.js';
|
|
25
|
+
/** @public 跨通道发送辅助:`createOutboundSender` / `sendTo`(承诺 semver)。 */
|
|
26
|
+
export * from './outbound-sender.js';
|
|
24
27
|
export * from './inbox.js';
|
|
25
28
|
export * from './system-log.js';
|
package/lib/outbound-host.d.ts
CHANGED
|
@@ -19,28 +19,38 @@ export interface OutboundConversation {
|
|
|
19
19
|
*/
|
|
20
20
|
export interface OutboundSendInput {
|
|
21
21
|
readonly adapter: string;
|
|
22
|
-
readonly
|
|
22
|
+
readonly endpointKey: string;
|
|
23
23
|
readonly conversation: OutboundConversation;
|
|
24
24
|
readonly content: string;
|
|
25
25
|
}
|
|
26
26
|
export interface OutboundReactionInput {
|
|
27
27
|
readonly adapter: string;
|
|
28
|
-
readonly
|
|
29
|
-
readonly
|
|
28
|
+
readonly endpointKey: string;
|
|
29
|
+
readonly message: OutboundMessage;
|
|
30
30
|
readonly emoji: string;
|
|
31
31
|
readonly sceneType?: string;
|
|
32
32
|
readonly channelId?: string;
|
|
33
33
|
}
|
|
34
34
|
export interface OutboundRemoveReactionInput {
|
|
35
35
|
readonly adapter: string;
|
|
36
|
-
readonly
|
|
37
|
-
readonly
|
|
36
|
+
readonly endpointKey: string;
|
|
37
|
+
readonly message: OutboundMessage;
|
|
38
38
|
readonly reactionId: string;
|
|
39
39
|
}
|
|
40
40
|
export interface OutboundRecallInput {
|
|
41
41
|
readonly adapter: string;
|
|
42
|
-
readonly
|
|
43
|
-
readonly
|
|
42
|
+
readonly endpointKey: string;
|
|
43
|
+
readonly message: OutboundMessage;
|
|
44
|
+
}
|
|
45
|
+
/** Structured message identity; structurally compatible with IM MessageRef. */
|
|
46
|
+
export interface OutboundMessage {
|
|
47
|
+
readonly conversation: OutboundConversation & Readonly<{
|
|
48
|
+
readonly endpoint: Readonly<{
|
|
49
|
+
readonly id: string;
|
|
50
|
+
readonly adapter: string;
|
|
51
|
+
}>;
|
|
52
|
+
}>;
|
|
53
|
+
readonly id: string;
|
|
44
54
|
}
|
|
45
55
|
export interface OutboundHost {
|
|
46
56
|
/** Returns platform message id when available (activity-feedback needs it). */
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Token } from './token.js';
|
|
2
|
+
import { type OutboundConversation, type OutboundHost, type OutboundSendInput } from './outbound-host.js';
|
|
3
|
+
/**
|
|
4
|
+
* 构造群临时会话地址(如 QQ 群临时私聊)。
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* await message.$sendTo(tempSession('12345', '67890'), '通过群临时会话私信');
|
|
8
|
+
* await sender.send(tempSession('12345', '67890'), '通知');
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export declare function tempSession(userId: string, fromGroupId: string): OutboundConversation;
|
|
12
|
+
/**
|
|
13
|
+
* 构造频道子通道地址(如 QQ 频道内的子频道)。
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* await message.$sendTo(guildChannel('channel-1', 'guild-1'), '频道通知');
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export declare function guildChannel(channelId: string, guildId: string, threadId?: string): OutboundConversation;
|
|
20
|
+
/**
|
|
21
|
+
* 结构兼容 CapabilityContext.use()、Scope.use()、EndpointCommandUse 的最小访问器。
|
|
22
|
+
*/
|
|
23
|
+
export type UseAccessor = <T>(token: Token<T>) => T;
|
|
24
|
+
/**
|
|
25
|
+
* 预绑定 adapter + endpoint 的发送器。
|
|
26
|
+
*
|
|
27
|
+
* ```ts
|
|
28
|
+
* const sender = createOutboundSender(context.use, 'root/icqq', 'main');
|
|
29
|
+
* await sender.send('group', '67890', 'hello');
|
|
30
|
+
* await sender.send({ kind: 'private', id: '12345' }, 'DM');
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export interface OutboundSender {
|
|
34
|
+
send(kind: OutboundConversation['kind'], id: string, content: string): Promise<string | null>;
|
|
35
|
+
send(conversation: OutboundConversation, content: string): Promise<string | null>;
|
|
36
|
+
readonly host: OutboundHost;
|
|
37
|
+
readonly adapter: string;
|
|
38
|
+
readonly endpointKey: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 创建预绑定 adapter + endpoint 的发送器。
|
|
42
|
+
*
|
|
43
|
+
* 在 defineCommand 中:
|
|
44
|
+
* ```ts
|
|
45
|
+
* const sender = createOutboundSender(context.use, 'root/telegram', 'main');
|
|
46
|
+
* await sender.send('group', 'chat123', 'hello');
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* 在 definePlugin setup 中:
|
|
50
|
+
* ```ts
|
|
51
|
+
* const sender = createOutboundSender(
|
|
52
|
+
* (token) => context.resources.use(token),
|
|
53
|
+
* config.adapter,
|
|
54
|
+
* config.endpointKey,
|
|
55
|
+
* );
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export declare function createOutboundSender(use: UseAccessor, adapter: string, endpointKey: string): OutboundSender;
|
|
59
|
+
/**
|
|
60
|
+
* 一次性发送:解析 OutboundHost 并发送到指定目标。
|
|
61
|
+
*
|
|
62
|
+
* ```ts
|
|
63
|
+
* await sendTo(context.use, {
|
|
64
|
+
* adapter: 'root/icqq',
|
|
65
|
+
* endpointKey: 'main',
|
|
66
|
+
* conversation: { kind: 'group', id: '67890' },
|
|
67
|
+
* }, 'hello');
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export declare function sendTo(use: UseAccessor, target: Omit<OutboundSendInput, 'content'>, content: string): Promise<string | null>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { outboundHostToken } from './outbound-host.js';
|
|
2
|
+
// ── 会话地址构造器 ──────────────────────────────────────────────────
|
|
3
|
+
/**
|
|
4
|
+
* 构造群临时会话地址(如 QQ 群临时私聊)。
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* await message.$sendTo(tempSession('12345', '67890'), '通过群临时会话私信');
|
|
8
|
+
* await sender.send(tempSession('12345', '67890'), '通知');
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export function tempSession(userId, fromGroupId) {
|
|
12
|
+
return { kind: 'private', id: userId, parent: { kind: 'group', id: fromGroupId } };
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 构造频道子通道地址(如 QQ 频道内的子频道)。
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* await message.$sendTo(guildChannel('channel-1', 'guild-1'), '频道通知');
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function guildChannel(channelId, guildId, threadId) {
|
|
22
|
+
return {
|
|
23
|
+
kind: 'channel',
|
|
24
|
+
id: channelId,
|
|
25
|
+
parent: { kind: 'channel', id: guildId },
|
|
26
|
+
...(threadId ? { threadId } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 创建预绑定 adapter + endpoint 的发送器。
|
|
31
|
+
*
|
|
32
|
+
* 在 defineCommand 中:
|
|
33
|
+
* ```ts
|
|
34
|
+
* const sender = createOutboundSender(context.use, 'root/telegram', 'main');
|
|
35
|
+
* await sender.send('group', 'chat123', 'hello');
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* 在 definePlugin setup 中:
|
|
39
|
+
* ```ts
|
|
40
|
+
* const sender = createOutboundSender(
|
|
41
|
+
* (token) => context.resources.use(token),
|
|
42
|
+
* config.adapter,
|
|
43
|
+
* config.endpointKey,
|
|
44
|
+
* );
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function createOutboundSender(use, adapter, endpointKey) {
|
|
48
|
+
const host = use(outboundHostToken);
|
|
49
|
+
return Object.freeze({
|
|
50
|
+
host,
|
|
51
|
+
adapter,
|
|
52
|
+
endpointKey,
|
|
53
|
+
send(kindOrConversation, idOrContent, maybeContent) {
|
|
54
|
+
if (typeof kindOrConversation === 'string' && maybeContent !== undefined) {
|
|
55
|
+
return host.send({
|
|
56
|
+
adapter,
|
|
57
|
+
endpointKey,
|
|
58
|
+
conversation: { kind: kindOrConversation, id: idOrContent },
|
|
59
|
+
content: maybeContent,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return host.send({
|
|
63
|
+
adapter,
|
|
64
|
+
endpointKey,
|
|
65
|
+
conversation: kindOrConversation,
|
|
66
|
+
content: idOrContent,
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 一次性发送:解析 OutboundHost 并发送到指定目标。
|
|
73
|
+
*
|
|
74
|
+
* ```ts
|
|
75
|
+
* await sendTo(context.use, {
|
|
76
|
+
* adapter: 'root/icqq',
|
|
77
|
+
* endpointKey: 'main',
|
|
78
|
+
* conversation: { kind: 'group', id: '67890' },
|
|
79
|
+
* }, 'hello');
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export async function sendTo(use, target, content) {
|
|
83
|
+
const host = use(outboundHostToken);
|
|
84
|
+
return host.send({ ...target, content });
|
|
85
|
+
}
|
package/lib/plugin.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface PluginInstanceView {
|
|
|
18
18
|
readonly role: 'root' | 'child';
|
|
19
19
|
}
|
|
20
20
|
export interface PluginSetupContext<TConfig = unknown> {
|
|
21
|
+
readonly signal: AbortSignal;
|
|
21
22
|
readonly plugin: PluginInstanceView;
|
|
22
23
|
readonly config: ConfigView<TConfig>;
|
|
23
24
|
readonly resources: Scope;
|
package/lib/root-controller.d.ts
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
import type { PluginId } from './identity.js';
|
|
2
|
-
import {
|
|
2
|
+
import { type PreparedGeneration, type RuntimeSnapshot, type SnapshotReader, type SnapshotState } from './snapshot.js';
|
|
3
3
|
export type RootState = 'idle' | 'running' | 'stopping' | 'stopped' | 'failed';
|
|
4
|
-
export type PrepareGeneration = (current: RuntimeSnapshot) => PreparedGeneration | Promise<PreparedGeneration>;
|
|
5
|
-
export type PrepareTransaction = (current: RuntimeSnapshot) => PreparedGeneration | undefined | Promise<PreparedGeneration | undefined>;
|
|
4
|
+
export type PrepareGeneration = (current: RuntimeSnapshot, signal: AbortSignal) => PreparedGeneration | Promise<PreparedGeneration>;
|
|
5
|
+
export type PrepareTransaction = (current: RuntimeSnapshot, signal: AbortSignal) => PreparedGeneration | undefined | Promise<PreparedGeneration | undefined>;
|
|
6
6
|
export type ControlErrorHandler = (error: unknown) => void;
|
|
7
7
|
export interface GenerationCommitEvent {
|
|
8
8
|
readonly previous: RuntimeSnapshot;
|
|
9
9
|
readonly current: RuntimeSnapshot;
|
|
10
10
|
}
|
|
11
11
|
export type GenerationCommitListener = (event: GenerationCommitEvent) => void;
|
|
12
|
+
export declare class RootIntegrityError extends Error {
|
|
13
|
+
constructor(message: string, options?: ErrorOptions);
|
|
14
|
+
}
|
|
12
15
|
export declare class RootController {
|
|
13
16
|
#private;
|
|
14
17
|
private readonly onControlError;
|
|
15
|
-
readonly snapshots:
|
|
18
|
+
readonly snapshots: SnapshotReader;
|
|
16
19
|
constructor(initial: SnapshotState, onControlError?: ControlErrorHandler);
|
|
17
20
|
get state(): RootState;
|
|
18
21
|
get generation(): number;
|
|
22
|
+
get snapshot(): RuntimeSnapshot;
|
|
19
23
|
/** Observe successful generation commits, including the initial start. */
|
|
20
24
|
onGenerationCommit(listener: GenerationCommitListener): () => void;
|
|
21
25
|
start(prepare: PrepareGeneration): Promise<RuntimeSnapshot>;
|
package/lib/root-controller.js
CHANGED
|
@@ -1,19 +1,43 @@
|
|
|
1
|
+
import { GenerationCompensationError } from './handoff.js';
|
|
1
2
|
import { SnapshotStore, } from './snapshot.js';
|
|
3
|
+
export class RootIntegrityError extends Error {
|
|
4
|
+
constructor(message, options) {
|
|
5
|
+
super(message, options);
|
|
6
|
+
this.name = 'RootIntegrityError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
2
9
|
export class RootController {
|
|
3
10
|
onControlError;
|
|
4
11
|
snapshots;
|
|
12
|
+
#snapshotStore;
|
|
5
13
|
#state = 'idle';
|
|
6
14
|
#tail = Promise.resolve();
|
|
15
|
+
#stopResult;
|
|
16
|
+
#stopRequested = false;
|
|
17
|
+
#activeTransaction;
|
|
7
18
|
#generationCommitListeners = new Set();
|
|
8
19
|
constructor(initial, onControlError = () => undefined) {
|
|
9
20
|
this.onControlError = onControlError;
|
|
10
|
-
this
|
|
21
|
+
this.#snapshotStore = new SnapshotStore(initial, (error) => this.#failIntegrity(error));
|
|
22
|
+
const store = this.#snapshotStore;
|
|
23
|
+
this.snapshots = Object.freeze({
|
|
24
|
+
acquire: () => {
|
|
25
|
+
if (this.#state !== 'running' || this.#stopRequested) {
|
|
26
|
+
throw new RootIntegrityError(`Root is not accepting generation operations (${this.#state})`);
|
|
27
|
+
}
|
|
28
|
+
return store.acquire();
|
|
29
|
+
},
|
|
30
|
+
owns: (lease) => store.owns(lease),
|
|
31
|
+
});
|
|
11
32
|
}
|
|
12
33
|
get state() {
|
|
13
34
|
return this.#state;
|
|
14
35
|
}
|
|
15
36
|
get generation() {
|
|
16
|
-
return this.
|
|
37
|
+
return this.#snapshotStore.current.generation;
|
|
38
|
+
}
|
|
39
|
+
get snapshot() {
|
|
40
|
+
return this.#snapshotStore.current;
|
|
17
41
|
}
|
|
18
42
|
/** Observe successful generation commits, including the initial start. */
|
|
19
43
|
onGenerationCommit(listener) {
|
|
@@ -29,12 +53,15 @@ export class RootController {
|
|
|
29
53
|
}
|
|
30
54
|
let prepared;
|
|
31
55
|
let activated = false;
|
|
56
|
+
const transaction = new AbortController();
|
|
57
|
+
this.#activeTransaction = transaction;
|
|
32
58
|
try {
|
|
33
|
-
prepared = await prepare(this.
|
|
59
|
+
prepared = await prepare(this.#snapshotStore.current, transaction.signal);
|
|
34
60
|
if (prepared.handoff) {
|
|
35
61
|
activated = true;
|
|
36
|
-
await prepared.handoff.activateNext();
|
|
62
|
+
await prepared.handoff.activateNext(transaction.signal);
|
|
37
63
|
}
|
|
64
|
+
this.#assertPublishable('idle');
|
|
38
65
|
this.#state = 'running';
|
|
39
66
|
const snapshot = this.#commitGeneration(0, prepared);
|
|
40
67
|
prepared = undefined;
|
|
@@ -44,6 +71,10 @@ export class RootController {
|
|
|
44
71
|
this.#state = 'failed';
|
|
45
72
|
return this.#rollback(prepared, { activated }, error);
|
|
46
73
|
}
|
|
74
|
+
finally {
|
|
75
|
+
if (this.#activeTransaction === transaction)
|
|
76
|
+
this.#activeTransaction = undefined;
|
|
77
|
+
}
|
|
47
78
|
});
|
|
48
79
|
}
|
|
49
80
|
transact(prepare) {
|
|
@@ -53,26 +84,28 @@ export class RootController {
|
|
|
53
84
|
}
|
|
54
85
|
const previous = this.snapshots.acquire();
|
|
55
86
|
let prepared;
|
|
56
|
-
let quiesced = false;
|
|
57
87
|
let activated = false;
|
|
88
|
+
const transaction = new AbortController();
|
|
89
|
+
this.#activeTransaction = transaction;
|
|
58
90
|
try {
|
|
59
|
-
prepared = await prepare(previous.value);
|
|
91
|
+
prepared = await prepare(previous.value, transaction.signal);
|
|
60
92
|
if (!prepared)
|
|
61
93
|
return previous.value;
|
|
62
94
|
if (prepared.handoff) {
|
|
63
|
-
quiesced = true;
|
|
64
|
-
await prepared.handoff.quiescePrevious(previous.value);
|
|
65
95
|
activated = true;
|
|
66
|
-
await prepared.handoff.activateNext();
|
|
96
|
+
await prepared.handoff.activateNext(transaction.signal);
|
|
67
97
|
}
|
|
98
|
+
this.#assertPublishable('running');
|
|
68
99
|
const snapshot = this.#commitGeneration(previous.value.generation, prepared);
|
|
69
100
|
prepared = undefined;
|
|
70
101
|
return snapshot;
|
|
71
102
|
}
|
|
72
103
|
catch (error) {
|
|
73
|
-
return this.#rollback(prepared, {
|
|
104
|
+
return this.#rollback(prepared, { activated }, error);
|
|
74
105
|
}
|
|
75
106
|
finally {
|
|
107
|
+
if (this.#activeTransaction === transaction)
|
|
108
|
+
this.#activeTransaction = undefined;
|
|
76
109
|
previous.release();
|
|
77
110
|
}
|
|
78
111
|
});
|
|
@@ -81,16 +114,26 @@ export class RootController {
|
|
|
81
114
|
return this.transact(prepare);
|
|
82
115
|
}
|
|
83
116
|
stop() {
|
|
84
|
-
|
|
117
|
+
if (this.#stopResult)
|
|
118
|
+
return this.#stopResult;
|
|
119
|
+
this.#stopRequested = true;
|
|
120
|
+
this.#activeTransaction?.abort(new Error('Root lifecycle is stopping'));
|
|
121
|
+
const result = this.#enqueue(async () => {
|
|
85
122
|
if (this.#state === 'stopped')
|
|
86
123
|
return;
|
|
87
124
|
if (this.#state !== 'running' && this.#state !== 'failed') {
|
|
88
125
|
throw new Error(`Cannot stop RootController from ${this.#state}`);
|
|
89
126
|
}
|
|
90
127
|
this.#state = 'stopping';
|
|
91
|
-
|
|
92
|
-
|
|
128
|
+
try {
|
|
129
|
+
await this.#snapshotStore.close();
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
this.#state = 'stopped';
|
|
133
|
+
}
|
|
93
134
|
});
|
|
135
|
+
this.#stopResult = result;
|
|
136
|
+
return result;
|
|
94
137
|
}
|
|
95
138
|
#enqueue(operation) {
|
|
96
139
|
const result = this.#tail.then(operation, operation);
|
|
@@ -98,9 +141,8 @@ export class RootController {
|
|
|
98
141
|
return result;
|
|
99
142
|
}
|
|
100
143
|
#commitGeneration(expectedGeneration, prepared) {
|
|
101
|
-
const previous = this.
|
|
102
|
-
const snapshot = this.
|
|
103
|
-
this.#openNext(prepared.handoff);
|
|
144
|
+
const previous = this.#snapshotStore.current;
|
|
145
|
+
const snapshot = this.#snapshotStore.commit(expectedGeneration, prepared);
|
|
104
146
|
const event = Object.freeze({ previous, current: snapshot });
|
|
105
147
|
const listeners = [...this.#generationCommitListeners];
|
|
106
148
|
queueMicrotask(() => {
|
|
@@ -115,14 +157,6 @@ export class RootController {
|
|
|
115
157
|
});
|
|
116
158
|
return snapshot;
|
|
117
159
|
}
|
|
118
|
-
#openNext(handoff) {
|
|
119
|
-
try {
|
|
120
|
-
handoff?.openNext();
|
|
121
|
-
}
|
|
122
|
-
catch (error) {
|
|
123
|
-
this.#reportControlError(error);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
160
|
#reportControlError(error) {
|
|
127
161
|
try {
|
|
128
162
|
this.onControlError(error);
|
|
@@ -141,14 +175,6 @@ export class RootController {
|
|
|
141
175
|
errors.push(error);
|
|
142
176
|
}
|
|
143
177
|
}
|
|
144
|
-
if (prepared && state.quiesced) {
|
|
145
|
-
try {
|
|
146
|
-
await prepared.handoff?.resumePrevious();
|
|
147
|
-
}
|
|
148
|
-
catch (error) {
|
|
149
|
-
errors.push(error);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
178
|
if (prepared) {
|
|
153
179
|
try {
|
|
154
180
|
await prepared.dispose();
|
|
@@ -157,9 +183,22 @@ export class RootController {
|
|
|
157
183
|
errors.push(error);
|
|
158
184
|
}
|
|
159
185
|
}
|
|
160
|
-
if (errors.length > 1) {
|
|
161
|
-
|
|
186
|
+
if (errors.length > 1 || transactionError instanceof GenerationCompensationError) {
|
|
187
|
+
const aggregate = new AggregateError(errors, 'Generation transaction and rollback both failed');
|
|
188
|
+
this.#failIntegrity(aggregate);
|
|
189
|
+
throw new RootIntegrityError('Root integrity failed while rolling back a generation transaction', { cause: aggregate });
|
|
162
190
|
}
|
|
163
191
|
throw transactionError;
|
|
164
192
|
}
|
|
193
|
+
#failIntegrity(error) {
|
|
194
|
+
if (this.#state === 'stopped' || this.#state === 'stopping')
|
|
195
|
+
return;
|
|
196
|
+
this.#state = 'failed';
|
|
197
|
+
this.#reportControlError(error);
|
|
198
|
+
}
|
|
199
|
+
#assertPublishable(expected) {
|
|
200
|
+
if (this.#state !== expected || this.#stopRequested) {
|
|
201
|
+
throw new RootIntegrityError(`Root cannot publish a generation after admission entered ${this.#stopRequested ? 'stopping' : this.#state}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
165
204
|
}
|
package/lib/snapshot.d.ts
CHANGED
|
@@ -32,19 +32,29 @@ export declare class GenerationConflictError extends Error {
|
|
|
32
32
|
readonly actual: number;
|
|
33
33
|
constructor(expected: number, actual: number);
|
|
34
34
|
}
|
|
35
|
+
declare const snapshotLeaseAuthority: unique symbol;
|
|
35
36
|
export declare class SnapshotLease {
|
|
36
37
|
#private;
|
|
37
38
|
readonly value: RuntimeSnapshot;
|
|
38
39
|
private readonly releaseRecord;
|
|
39
|
-
constructor(value: RuntimeSnapshot, releaseRecord: () => void);
|
|
40
|
+
constructor(value: RuntimeSnapshot, releaseRecord: () => void, authority: typeof snapshotLeaseAuthority);
|
|
41
|
+
get active(): boolean;
|
|
40
42
|
release(): void;
|
|
41
43
|
}
|
|
42
|
-
|
|
44
|
+
/** Read-only operation seam exposed by a Root lifecycle. */
|
|
45
|
+
export interface SnapshotReader {
|
|
46
|
+
acquire(): SnapshotLease;
|
|
47
|
+
owns(lease: SnapshotLease): boolean;
|
|
48
|
+
}
|
|
49
|
+
export declare class SnapshotStore implements SnapshotReader {
|
|
43
50
|
#private;
|
|
44
|
-
|
|
51
|
+
private readonly onDisposalError;
|
|
52
|
+
constructor(initial: SnapshotState, onDisposalError?: (error: unknown) => void);
|
|
45
53
|
get current(): RuntimeSnapshot;
|
|
46
54
|
acquire(): SnapshotLease;
|
|
55
|
+
owns(lease: SnapshotLease): boolean;
|
|
47
56
|
commit(expectedGeneration: number, prepared: PreparedGeneration): RuntimeSnapshot;
|
|
48
57
|
close(): Promise<void>;
|
|
49
58
|
}
|
|
50
59
|
export declare function createSnapshotView(generation: number, state: SnapshotState): RuntimeSnapshot;
|
|
60
|
+
export {};
|
package/lib/snapshot.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { collectGenerationAdmissions, replaceGenerationAdmissions, } from './admission.js';
|
|
1
2
|
export class GenerationConflictError extends Error {
|
|
2
3
|
expected;
|
|
3
4
|
actual;
|
|
@@ -8,13 +9,19 @@ export class GenerationConflictError extends Error {
|
|
|
8
9
|
this.name = 'GenerationConflictError';
|
|
9
10
|
}
|
|
10
11
|
}
|
|
12
|
+
const snapshotLeaseAuthority = Symbol('SnapshotLeaseAuthority');
|
|
11
13
|
export class SnapshotLease {
|
|
12
14
|
value;
|
|
13
15
|
releaseRecord;
|
|
14
16
|
#released = false;
|
|
15
|
-
constructor(value, releaseRecord) {
|
|
17
|
+
constructor(value, releaseRecord, authority) {
|
|
16
18
|
this.value = value;
|
|
17
19
|
this.releaseRecord = releaseRecord;
|
|
20
|
+
if (authority !== snapshotLeaseAuthority)
|
|
21
|
+
throw new TypeError('SnapshotLease is owned by SnapshotStore');
|
|
22
|
+
}
|
|
23
|
+
get active() {
|
|
24
|
+
return !this.#released;
|
|
18
25
|
}
|
|
19
26
|
release() {
|
|
20
27
|
if (this.#released)
|
|
@@ -24,16 +31,24 @@ export class SnapshotLease {
|
|
|
24
31
|
}
|
|
25
32
|
}
|
|
26
33
|
export class SnapshotStore {
|
|
34
|
+
onDisposalError;
|
|
27
35
|
#current;
|
|
28
36
|
#retired = new Set();
|
|
37
|
+
#leases = new WeakSet();
|
|
29
38
|
#closed = false;
|
|
30
|
-
constructor(initial) {
|
|
31
|
-
this
|
|
32
|
-
|
|
39
|
+
constructor(initial, onDisposalError = () => undefined) {
|
|
40
|
+
this.onDisposalError = onDisposalError;
|
|
41
|
+
const snapshot = createSnapshotView(0, initial);
|
|
42
|
+
const admissions = collectSnapshotAdmissions(snapshot);
|
|
43
|
+
const record = {
|
|
44
|
+
snapshot,
|
|
33
45
|
dispose: () => undefined,
|
|
46
|
+
admissions,
|
|
34
47
|
leases: 0,
|
|
35
48
|
retired: false,
|
|
36
49
|
};
|
|
50
|
+
replaceGenerationAdmissions(new Set(), admissions, this, () => this.#retain(record));
|
|
51
|
+
this.#current = record;
|
|
37
52
|
}
|
|
38
53
|
get current() {
|
|
39
54
|
return this.#current.snapshot;
|
|
@@ -42,11 +57,12 @@ export class SnapshotStore {
|
|
|
42
57
|
if (this.#closed)
|
|
43
58
|
throw new Error('SnapshotStore is closed');
|
|
44
59
|
const record = this.#current;
|
|
45
|
-
record.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
const lease = new SnapshotLease(record.snapshot, this.#retain(record), snapshotLeaseAuthority);
|
|
61
|
+
this.#leases.add(lease);
|
|
62
|
+
return lease;
|
|
63
|
+
}
|
|
64
|
+
owns(lease) {
|
|
65
|
+
return this.#leases.has(lease);
|
|
50
66
|
}
|
|
51
67
|
commit(expectedGeneration, prepared) {
|
|
52
68
|
// The caller owns a candidate until this method succeeds. This lets the
|
|
@@ -58,14 +74,19 @@ export class SnapshotStore {
|
|
|
58
74
|
throw new GenerationConflictError(expectedGeneration, this.#current.snapshot.generation);
|
|
59
75
|
}
|
|
60
76
|
const previous = this.#current;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
snapshot
|
|
77
|
+
const snapshot = createSnapshotView(expectedGeneration + 1, prepared.snapshot);
|
|
78
|
+
const admissions = collectSnapshotAdmissions(snapshot);
|
|
79
|
+
const next = {
|
|
80
|
+
snapshot,
|
|
65
81
|
dispose: prepared.dispose,
|
|
82
|
+
admissions,
|
|
66
83
|
leases: 0,
|
|
67
84
|
retired: false,
|
|
68
85
|
};
|
|
86
|
+
replaceGenerationAdmissions(previous.admissions, admissions, this, () => this.#retain(next));
|
|
87
|
+
// Commit only swaps the active pointer. The previous record may still be
|
|
88
|
+
// serving in-flight work, so disposal is deferred to its final lease.
|
|
89
|
+
this.#current = next;
|
|
69
90
|
previous.retired = true;
|
|
70
91
|
this.#retired.add(previous);
|
|
71
92
|
this.#disposeIfReady(previous);
|
|
@@ -75,6 +96,7 @@ export class SnapshotStore {
|
|
|
75
96
|
if (this.#closed)
|
|
76
97
|
return;
|
|
77
98
|
this.#closed = true;
|
|
99
|
+
replaceGenerationAdmissions(this.#current.admissions, new Set(), this);
|
|
78
100
|
this.#current.retired = true;
|
|
79
101
|
this.#retired.add(this.#current);
|
|
80
102
|
this.#disposeIfReady(this.#current);
|
|
@@ -88,9 +110,25 @@ export class SnapshotStore {
|
|
|
88
110
|
return;
|
|
89
111
|
if (!record.disposing) {
|
|
90
112
|
record.disposing = Promise.resolve().then(record.dispose);
|
|
91
|
-
void record.disposing.then(() => record.drain?.resolve(), (error) =>
|
|
113
|
+
void record.disposing.then(() => record.drain?.resolve(), (error) => {
|
|
114
|
+
record.drain?.reject(error);
|
|
115
|
+
this.onDisposalError(error);
|
|
116
|
+
});
|
|
92
117
|
}
|
|
93
118
|
}
|
|
119
|
+
#retain(record) {
|
|
120
|
+
record.leases += 1;
|
|
121
|
+
let released = false;
|
|
122
|
+
return () => {
|
|
123
|
+
if (released)
|
|
124
|
+
return;
|
|
125
|
+
released = true;
|
|
126
|
+
record.leases -= 1;
|
|
127
|
+
if (record.leases < 0)
|
|
128
|
+
throw new Error('Snapshot lease underflow');
|
|
129
|
+
this.#disposeIfReady(record);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
94
132
|
#waitForDisposal(record) {
|
|
95
133
|
if (record.disposing)
|
|
96
134
|
return record.disposing;
|
|
@@ -106,6 +144,12 @@ export class SnapshotStore {
|
|
|
106
144
|
return record.drain.promise;
|
|
107
145
|
}
|
|
108
146
|
}
|
|
147
|
+
function collectSnapshotAdmissions(snapshot) {
|
|
148
|
+
return collectGenerationAdmissions([
|
|
149
|
+
...snapshot.projections.values(),
|
|
150
|
+
...[...snapshot.resources.values()].flatMap((resources) => [...resources.values()]),
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
109
153
|
export function createSnapshotView(generation, state) {
|
|
110
154
|
// Object.freeze(Map) does not disable Map#set. ReadonlyMapView removes the
|
|
111
155
|
// mutator interface at runtime, including from provider projection hooks.
|
package/package.json
CHANGED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Plugin-contributed Agent tool registered against the Agent Host.
|
|
3
|
-
* Captured at setup() time — execute closures must never call plugin locators.
|
|
4
|
-
*
|
|
5
|
-
* Access vocabulary (platforms / scopes / permissions / hidden) mirrors the
|
|
6
|
-
* `@zhin.js/tool` ToolIndex descriptor and is enforced per message by the
|
|
7
|
-
* Agent runtime via Core `canAccessTool` — one predicate for both paths.
|
|
8
|
-
*/
|
|
9
|
-
export interface AgentToolRegistration {
|
|
10
|
-
/** Runtime tool name exposed to the model (e.g. `lottery_sync`). */
|
|
11
|
-
readonly name: string;
|
|
12
|
-
readonly description: string;
|
|
13
|
-
/** zod object schema or JSON Schema; drives the deferred catalog + arg validation. */
|
|
14
|
-
readonly inputSchema?: unknown;
|
|
15
|
-
readonly keywords?: readonly string[];
|
|
16
|
-
readonly tags?: readonly string[];
|
|
17
|
-
/** Limit to these adapter platforms (e.g. `['icqq']`); empty = all. */
|
|
18
|
-
readonly platforms?: readonly string[];
|
|
19
|
-
/** Limit to these channel scopes; empty = all. */
|
|
20
|
-
readonly scopes?: readonly ('private' | 'group' | 'channel')[];
|
|
21
|
-
readonly permissions?: readonly string[];
|
|
22
|
-
/** Registered but never offered to the model (callable by name only). */
|
|
23
|
-
readonly hidden?: boolean;
|
|
24
|
-
/** Per-tool approval policy; stacks with ExecPolicy. */
|
|
25
|
-
readonly approval?: 'never' | 'always';
|
|
26
|
-
/** Plugin/tool origin label (e.g. plugin name) for diagnostics. */
|
|
27
|
-
readonly source?: string;
|
|
28
|
-
execute(input: Record<string, unknown>): unknown | Promise<unknown>;
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Generation-owned Agent tool registry provided by the Agent Host.
|
|
32
|
-
* Absent when AI is not installed/enabled — plugins must guard with `has()`.
|
|
33
|
-
*/
|
|
34
|
-
export interface AgentToolsHost {
|
|
35
|
-
/** Register a tool for every Agent turn of this generation; returns disposal. */
|
|
36
|
-
register(tool: AgentToolRegistration): () => void;
|
|
37
|
-
}
|
|
38
|
-
export declare const agentToolsHostToken: import("./token.js").Token<AgentToolsHost>;
|
package/lib/agent-tools-host.js
DELETED