@envarai/connect 2026.1107.2 → 2026.1110.1
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/README.md +2 -2
- package/dist/adapters/a2a-runtime.d.ts +2 -0
- package/dist/adapters/a2a-runtime.js +20 -2
- package/dist/adapters/native-a2a.d.ts +18 -0
- package/dist/adapters/native-a2a.js +24 -2
- package/dist/adapters/types.d.ts +19 -0
- package/dist/connector/cli.js +4 -1
- package/dist/connector/client.d.ts +12 -0
- package/dist/connector/client.js +65 -2
- package/dist/connector/loop.d.ts +1 -0
- package/dist/connector/loop.js +8 -2
- package/dist/connector/relay-loop.d.ts +25 -0
- package/dist/connector/relay-loop.js +339 -0
- package/dist/connector/relay-types.d.ts +62 -0
- package/dist/connector/relay-types.js +19 -0
- package/dist/connector/runtime-report.d.ts +18 -0
- package/dist/connector/runtime-report.js +25 -0
- package/dist/connector/state.d.ts +5 -0
- package/dist/connector/state.js +31 -0
- package/docs/a2a-relay.md +35 -0
- package/docs/compatibility.md +1 -1
- package/docs/native-connector.md +1 -1
- package/docs/runtime-diagnostics.md +59 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ For the independent seller-side developer path, start with [EnvarPay](https://gi
|
|
|
28
28
|
## Install
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
|
-
npm install --global --ignore-scripts @envarai/connect@2026.
|
|
31
|
+
npm install --global --ignore-scripts @envarai/connect@2026.1110.1
|
|
32
32
|
envar-connect --version
|
|
33
33
|
```
|
|
34
34
|
|
|
@@ -70,7 +70,7 @@ bundled package with integrity verification; it installs without fetching npm
|
|
|
70
70
|
dependencies. A mirror returning 404 does not establish that the public package
|
|
71
71
|
is missing.
|
|
72
72
|
|
|
73
|
-
See [native setup](docs/native-connector.md), [migration](docs/migration.md), [release automation](docs/releasing.md), and [contributing](CONTRIBUTING.md). The non-root container is `ghcr.io/envarai/connect`; it contains the Connector, not a native Agent runtime.
|
|
73
|
+
See [native setup](docs/native-connector.md), [runtime diagnostics](docs/runtime-diagnostics.md), [migration](docs/migration.md), [release automation](docs/releasing.md), and [contributing](CONTRIBUTING.md). The non-root container is `ghcr.io/envarai/connect`; it contains the Connector, not a native Agent runtime.
|
|
74
74
|
GHCR package access is separate from repository visibility. Anonymous image pulls
|
|
75
75
|
are not currently part of the verified public installation path. Use the npm
|
|
76
76
|
package, the official bundled download, or build the checked-out `Dockerfile`
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RelayRuntime } from './types.js';
|
|
1
2
|
import type { Work } from '../connector/contracts.generated.js';
|
|
2
3
|
import { type NativeA2ASettings } from './native-a2a.js';
|
|
3
4
|
import { type RuntimeAdapter, type RuntimeObservation, type RuntimeReadiness, type RuntimeReference } from './types.js';
|
|
@@ -8,6 +9,7 @@ export declare class A2ARuntimeAdapter implements RuntimeAdapter {
|
|
|
8
9
|
readonly key: string;
|
|
9
10
|
private readonly settings;
|
|
10
11
|
private readonly client;
|
|
12
|
+
readonly relay: RelayRuntime;
|
|
11
13
|
constructor(key: string, settings: NativeA2ASettings);
|
|
12
14
|
probe(): Promise<RuntimeReadiness>;
|
|
13
15
|
private reference;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { SendMessageRequest, TaskState, Task } from '@a2a-js/sdk';
|
|
1
|
+
import { AgentCard, SendMessageRequest, TaskState, Task } from '@a2a-js/sdk';
|
|
2
|
+
import { digest } from '../shared/json.js';
|
|
2
3
|
import { NativeRequestNotStarted, NativeA2AClient } from './native-a2a.js';
|
|
3
4
|
import { RuntimeOutcomeUnknown } from './types.js';
|
|
4
5
|
export class RuntimeControlUnsupported extends Error {
|
|
@@ -8,15 +9,32 @@ export class A2ARuntimeAdapter {
|
|
|
8
9
|
key;
|
|
9
10
|
settings;
|
|
10
11
|
client;
|
|
12
|
+
relay;
|
|
11
13
|
constructor(key, settings) {
|
|
12
14
|
this.key = key;
|
|
13
15
|
this.settings = settings;
|
|
14
16
|
this.client = new NativeA2AClient(settings);
|
|
17
|
+
const convert = (result) => ({
|
|
18
|
+
...(result.task ? { task: result.task } : {}), ...(result.message ? { message: result.message } : {}),
|
|
19
|
+
...(result.reference ? { reference: { origin: result.reference.origin, interface: result.reference.interfaceUrl, task_id: result.reference.taskId, context_id: result.reference.contextId } } : {}),
|
|
20
|
+
});
|
|
21
|
+
this.relay = {
|
|
22
|
+
cardDigest: async () => digest(AgentCard.toJSON(await this.client.probe())),
|
|
23
|
+
send: async (params) => convert(await this.client.sendStandard(params)),
|
|
24
|
+
prepareSend: async (params) => {
|
|
25
|
+
const dispatch = await this.client.prepareStandard(params);
|
|
26
|
+
return async () => convert(await dispatch());
|
|
27
|
+
},
|
|
28
|
+
read: async (reference) => Task.toJSON((await this.client.read({ origin: reference.origin, interfaceUrl: reference.interface, taskId: reference.task_id, contextId: reference.context_id })).task),
|
|
29
|
+
};
|
|
15
30
|
}
|
|
16
31
|
async probe() {
|
|
17
32
|
const card = await this.client.probe();
|
|
18
33
|
// The currently supported native plugins retain tasks in memory. Their restart loses those handles.
|
|
19
|
-
return { adapter: this.key, runtimeVersion: card.version || 'unknown',
|
|
34
|
+
return { adapter: this.key, runtimeVersion: card.version || 'unknown',
|
|
35
|
+
capabilities: { recover: true, input: false, cancel: false },
|
|
36
|
+
recovery: { taskLookupAfterRestart: false, executionKeyLookup: false, resultPersistence: false },
|
|
37
|
+
maxConcurrency: 1 };
|
|
20
38
|
}
|
|
21
39
|
reference(reference) {
|
|
22
40
|
if (reference.adapter !== this.key || reference.version !== 1 || !reference.value ||
|
|
@@ -32,5 +32,23 @@ export declare class NativeA2AClient {
|
|
|
32
32
|
task: Task;
|
|
33
33
|
reference: NativeTaskReference;
|
|
34
34
|
}>;
|
|
35
|
+
sendStandard(params: Record<string, unknown>): Promise<{
|
|
36
|
+
task: Record<string, unknown>;
|
|
37
|
+
reference: NativeTaskReference;
|
|
38
|
+
message?: undefined;
|
|
39
|
+
} | {
|
|
40
|
+
message: Record<string, unknown>;
|
|
41
|
+
task?: undefined;
|
|
42
|
+
reference?: undefined;
|
|
43
|
+
}>;
|
|
44
|
+
prepareStandard(params: Record<string, unknown>): Promise<() => Promise<{
|
|
45
|
+
task: Record<string, unknown>;
|
|
46
|
+
reference: NativeTaskReference;
|
|
47
|
+
message?: undefined;
|
|
48
|
+
} | {
|
|
49
|
+
message: Record<string, unknown>;
|
|
50
|
+
task?: undefined;
|
|
51
|
+
reference?: undefined;
|
|
52
|
+
}>>;
|
|
35
53
|
}
|
|
36
54
|
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Shared official A2A transport for local runtime adapters and the legacy seller gate. */
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { AgentCard, GetTaskRequest } from '@a2a-js/sdk';
|
|
3
|
+
import { AgentCard, GetTaskRequest, Message, SendMessageRequest, Task } from '@a2a-js/sdk';
|
|
4
4
|
import { TaskNotFoundError, RequestMalformedError } from '@a2a-js/sdk/errors';
|
|
5
5
|
import { ClientFactory, JsonRpcTransportFactory } from '@a2a-js/sdk/client';
|
|
6
6
|
import { validateUrl } from '../shared/json.js';
|
|
@@ -107,6 +107,28 @@ export class NativeA2AClient {
|
|
|
107
107
|
}
|
|
108
108
|
async read(remote) {
|
|
109
109
|
const prepared = await this.prepare(remote);
|
|
110
|
-
|
|
110
|
+
const result = this.observed(await prepared.client.getTask(GetTaskRequest.fromJSON({ id: remote.taskId })), prepared.interfaceUrl, remote.taskId);
|
|
111
|
+
if (remote.contextId && result.reference.contextId !== remote.contextId)
|
|
112
|
+
throw new CommerceError('remote_context_conflict', 'Native runtime returned another original context');
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
async sendStandard(params) {
|
|
116
|
+
return (await this.prepareStandard(params))();
|
|
117
|
+
}
|
|
118
|
+
async prepareStandard(params) {
|
|
119
|
+
const prepared = await this.prepare();
|
|
120
|
+
const request = SendMessageRequest.fromJSON(params);
|
|
121
|
+
// Preparation only reads and validates the Card. The caller commits its
|
|
122
|
+
// dispatch marker immediately before invoking this one-POST closure.
|
|
123
|
+
return async () => {
|
|
124
|
+
const response = await prepared.client.sendMessage(request);
|
|
125
|
+
if (response && 'status' in response) {
|
|
126
|
+
const result = this.observed(response, prepared.interfaceUrl);
|
|
127
|
+
return { task: Task.toJSON(result.task), reference: result.reference };
|
|
128
|
+
}
|
|
129
|
+
if (!response || !('role' in response))
|
|
130
|
+
throw new CommerceError('upstream_result', 'Native runtime returned no standard result');
|
|
131
|
+
return { message: Message.toJSON(response) };
|
|
132
|
+
};
|
|
111
133
|
}
|
|
112
134
|
}
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
/** Runtime boundary: the Connector owns delivery; the user/vendor owns execution. */
|
|
2
2
|
import type { Work } from '../connector/contracts.generated.js';
|
|
3
|
+
import type { RelayReference } from '../connector/relay-types.js';
|
|
4
|
+
export interface RelayRuntime {
|
|
5
|
+
cardDigest(): Promise<string>;
|
|
6
|
+
prepareSend?(params: Record<string, unknown>): Promise<() => ReturnType<RelayRuntime['send']>>;
|
|
7
|
+
send(params: Record<string, unknown>): Promise<{
|
|
8
|
+
task?: Record<string, unknown>;
|
|
9
|
+
message?: Record<string, unknown>;
|
|
10
|
+
reference?: RelayReference;
|
|
11
|
+
}>;
|
|
12
|
+
read(reference: RelayReference): Promise<Record<string, unknown>>;
|
|
13
|
+
}
|
|
3
14
|
export interface RuntimeReference {
|
|
4
15
|
adapter: string;
|
|
5
16
|
version: 1;
|
|
@@ -14,6 +25,13 @@ export interface RuntimeReadiness {
|
|
|
14
25
|
input: boolean;
|
|
15
26
|
cancel: boolean;
|
|
16
27
|
};
|
|
28
|
+
/** Separate guarantees; a live Task lookup does not prove restart recovery. */
|
|
29
|
+
recovery?: {
|
|
30
|
+
taskLookupAfterRestart: boolean;
|
|
31
|
+
executionKeyLookup: boolean;
|
|
32
|
+
resultPersistence: boolean;
|
|
33
|
+
};
|
|
34
|
+
maxConcurrency?: number;
|
|
17
35
|
}
|
|
18
36
|
export interface RuntimeObservation {
|
|
19
37
|
reference: RuntimeReference;
|
|
@@ -23,6 +41,7 @@ export interface RuntimeObservation {
|
|
|
23
41
|
}
|
|
24
42
|
export interface RuntimeAdapter {
|
|
25
43
|
readonly key: string;
|
|
44
|
+
readonly relay?: RelayRuntime;
|
|
26
45
|
probe(): Promise<RuntimeReadiness>;
|
|
27
46
|
/** Called once after durable local preparation and Envar admission. Never retries an unknown POST. */
|
|
28
47
|
start(work: Work, executionKey: string): Promise<RuntimeObservation>;
|
package/dist/connector/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { privateDirectory, privateJson, writePrivate, writePrivateJson } from '.
|
|
|
8
8
|
import { ConnectorLoop } from './loop.js';
|
|
9
9
|
import { connectorMcp } from './mcp.js';
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
+
import { CONNECTOR_VERSION } from './runtime-report.js';
|
|
11
12
|
const usageFor = (command) => `Envar Connector\n${command.join(' ')} connect --agent UUID --framework hermes|openclaw [--home PROFILE] [--native-agent main]\n${command.join(' ')} run --agent UUID\n${command.join(' ')} doctor --agent UUID\n${command.join(' ')} status --agent UUID\n${command.join(' ')} disconnect --agent UUID\nAdvanced: use --directory PATH for an existing private state directory. Keep its journal to recover original tasks.`;
|
|
12
13
|
const output = (value) => console.log(JSON.stringify(value, null, 2));
|
|
13
14
|
export async function runConnectorCli(args, commandPrefix = ['envar-connect']) {
|
|
@@ -86,7 +87,9 @@ export async function runConnectorCli(args, commandPrefix = ['envar-connect']) {
|
|
|
86
87
|
}
|
|
87
88
|
if (command === 'doctor') {
|
|
88
89
|
const connection = await client.status(), runtime = await runtimeAdapter(directory, config).probe();
|
|
89
|
-
|
|
90
|
+
const reporting = await client.reportRuntime(runtime);
|
|
91
|
+
output({ connection_id: connection.id, agent_id: connection.agent_id, connector_version: CONNECTOR_VERSION, runtime,
|
|
92
|
+
runtime_version_source: 'agent_card', diagnostic_reporting: reporting,
|
|
90
93
|
readiness: connection.readiness, next: 'Send the free test from the Envar Agent page and inspect its returned result.' });
|
|
91
94
|
return;
|
|
92
95
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Claim, ConfirmOrderResult, ExecutionRef, Inbox, Order, Report, ServiceConfirmation, ServiceQuoteRequest, Submit, Work } from './contracts.generated.js';
|
|
2
|
+
import type { RuntimeReadiness } from '../adapters/types.js';
|
|
3
|
+
import { type RelayCommand, type RelayEvent } from './relay-types.js';
|
|
2
4
|
export declare function validate<T>(kind: string, value: unknown): T;
|
|
3
5
|
export declare class WorkApiError extends Error {
|
|
4
6
|
readonly status: number;
|
|
@@ -35,7 +37,17 @@ export declare class EnvarWorkClient {
|
|
|
35
37
|
credential_revoked: boolean;
|
|
36
38
|
connection_revoked: boolean;
|
|
37
39
|
}>;
|
|
40
|
+
relayHello(cardDigest: string): Promise<boolean>;
|
|
41
|
+
relayCommands(): Promise<RelayCommand[]>;
|
|
42
|
+
relayCommand(commandId: string): Promise<RelayCommand>;
|
|
43
|
+
relayOperation(commandId: string, operation: 'claim' | 'admit' | 'lease' | 'recover', body: unknown): Promise<RelayCommand>;
|
|
44
|
+
relayEvent(commandId: string, event: RelayEvent): Promise<{
|
|
45
|
+
result_digest: string;
|
|
46
|
+
state: string;
|
|
47
|
+
}>;
|
|
48
|
+
relayAck(commandId: string, body: unknown): Promise<void>;
|
|
38
49
|
status(): Promise<ConnectionStatus>;
|
|
50
|
+
reportRuntime(runtime: RuntimeReadiness): Promise<'accepted' | 'unsupported' | 'unavailable'>;
|
|
39
51
|
peers(): Promise<{
|
|
40
52
|
items: {
|
|
41
53
|
agent_id: string;
|
package/dist/connector/client.js
CHANGED
|
@@ -3,6 +3,8 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
4
4
|
import addFormatsImport from 'ajv-formats';
|
|
5
5
|
import { digest } from '../shared/json.js';
|
|
6
|
+
import { runtimeReport } from './runtime-report.js';
|
|
7
|
+
import { relayCommand } from './relay-types.js';
|
|
6
8
|
const ajv = new Ajv2020({ allErrors: false, strict: true });
|
|
7
9
|
addFormatsImport(ajv);
|
|
8
10
|
const schema = JSON.parse(readFileSync(new URL('../../contracts/agent-work/v1/schema.json', import.meta.url), 'utf8'));
|
|
@@ -45,14 +47,14 @@ export class EnvarWorkClient {
|
|
|
45
47
|
this.token = token;
|
|
46
48
|
this.fetchImpl = options.fetch ?? fetch;
|
|
47
49
|
}
|
|
48
|
-
async request(path, body, key, method) {
|
|
50
|
+
async request(path, body, key, method, maximum = 262144) {
|
|
49
51
|
if (!path.startsWith('/api/v1/'))
|
|
50
52
|
throw new Error('Invalid application route');
|
|
51
53
|
const headers = new Headers({ Authorization: `Bearer ${this.token}`, Accept: 'application/json' });
|
|
52
54
|
if (key)
|
|
53
55
|
headers.set('Idempotency-Key', id(key));
|
|
54
56
|
const encoded = body === undefined ? undefined : JSON.stringify(body);
|
|
55
|
-
if (encoded && Buffer.byteLength(encoded) >
|
|
57
|
+
if (encoded && Buffer.byteLength(encoded) > maximum)
|
|
56
58
|
throw new Error('Work request exceeds the size limit');
|
|
57
59
|
if (encoded)
|
|
58
60
|
headers.set('Content-Type', 'application/json');
|
|
@@ -89,12 +91,73 @@ export class EnvarWorkClient {
|
|
|
89
91
|
async disconnect() {
|
|
90
92
|
return await this.request('/api/v1/agent-connections/current/credential', {}, undefined, 'DELETE');
|
|
91
93
|
}
|
|
94
|
+
async relayHello(cardDigest) {
|
|
95
|
+
try {
|
|
96
|
+
const value = await this.request('/api/v1/connector/a2a-relay/hello', {
|
|
97
|
+
relay_version: '1', a2a_wire_version: '1.0', runtime_card_digest: cardDigest,
|
|
98
|
+
capabilities: { get: true, input: false, cancel: false, artifacts: false, streaming: false,
|
|
99
|
+
task_lookup_after_restart: false, execution_key_lookup: false }, max_concurrency: 1,
|
|
100
|
+
});
|
|
101
|
+
if (value.relay_version !== '1')
|
|
102
|
+
throw new Error('Unsupported relay handshake');
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error instanceof WorkApiError && [404, 405, 409].includes(error.status))
|
|
107
|
+
return false;
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async relayCommands() {
|
|
112
|
+
const result = await this.request('/api/v1/connector/a2a-relay/commands?limit=1');
|
|
113
|
+
if (!Array.isArray(result?.items) || result.items.length > 1)
|
|
114
|
+
throw new Error('Invalid relay command page');
|
|
115
|
+
return result.items.map(relayCommand);
|
|
116
|
+
}
|
|
117
|
+
async relayCommand(commandId) {
|
|
118
|
+
const result = relayCommand(await this.request(`/api/v1/connector/a2a-relay/commands/${id(commandId)}`));
|
|
119
|
+
if (result.command_id !== commandId)
|
|
120
|
+
throw new Error('Envar returned another original relay command');
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
async relayOperation(commandId, operation, body) {
|
|
124
|
+
const result = relayCommand(await this.request(`/api/v1/connector/a2a-relay/commands/${id(commandId)}/${operation}`, body));
|
|
125
|
+
if (result.command_id !== commandId)
|
|
126
|
+
throw new Error('Envar returned another original relay command');
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
async relayEvent(commandId, event) {
|
|
130
|
+
const result = await this.request(`/api/v1/connector/a2a-relay/commands/${id(commandId)}/events`, event, undefined, undefined, 5243904);
|
|
131
|
+
if (result.event_id !== event.event_id || result.last_committed_sequence < event.sequence)
|
|
132
|
+
throw new Error('Relay did not persist the original event');
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
async relayAck(commandId, body) {
|
|
136
|
+
const result = await this.request(`/api/v1/connector/a2a-relay/commands/${id(commandId)}/ack`, body);
|
|
137
|
+
if (result.command_id !== commandId || result.acknowledged !== true)
|
|
138
|
+
throw new Error('Relay did not acknowledge the original result');
|
|
139
|
+
}
|
|
92
140
|
async status() {
|
|
93
141
|
const value = await this.request('/api/v1/agent-connections/current');
|
|
94
142
|
if (!value || !uuid.test(value.id) || !uuid.test(value.agent_id) || value.mode !== 'inbox' || value.status !== 'active')
|
|
95
143
|
throw new Error('Reconnect this Agent to an active inbox');
|
|
96
144
|
return value;
|
|
97
145
|
}
|
|
146
|
+
async reportRuntime(runtime) {
|
|
147
|
+
const report = runtimeReport(runtime);
|
|
148
|
+
try {
|
|
149
|
+
await this.request('/api/v1/agent-connections/current/runtime', report);
|
|
150
|
+
return 'accepted';
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof WorkApiError && [401, 403].includes(error.status))
|
|
154
|
+
throw error;
|
|
155
|
+
if (error instanceof WorkApiError && [404, 405].includes(error.status))
|
|
156
|
+
return 'unsupported';
|
|
157
|
+
// Optional diagnostics must not block recovery of an already admitted task.
|
|
158
|
+
return 'unavailable';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
98
161
|
async peers() {
|
|
99
162
|
const value = await this.request('/api/v1/agent-connections/peers');
|
|
100
163
|
if (!value || !Array.isArray(value.items) || value.items.length > 100 || value.items.some(item => !uuid.test(item.agent_id) || !uuid.test(item.connection_id) || typeof item.display_name !== 'string'))
|
package/dist/connector/loop.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export declare class ConnectorLoop {
|
|
|
7
7
|
readonly journal: ConnectorJournal;
|
|
8
8
|
private running;
|
|
9
9
|
private verified;
|
|
10
|
+
private readonly relay?;
|
|
10
11
|
constructor(client: EnvarWorkClient, runtime: RuntimeAdapter, journal: ConnectorJournal);
|
|
11
12
|
private lease;
|
|
12
13
|
private withHeartbeat;
|
package/dist/connector/loop.js
CHANGED
|
@@ -3,6 +3,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
|
|
3
3
|
import { WorkApiError } from './client.js';
|
|
4
4
|
import { NativeRequestNotStarted } from '../adapters/native-a2a.js';
|
|
5
5
|
import { RuntimeControlUnsupported } from '../adapters/a2a-runtime.js';
|
|
6
|
+
import { RelayLoop, relayBlocksDispatch } from './relay-loop.js';
|
|
6
7
|
const terminal = new Set(['completed', 'failed', 'canceled']);
|
|
7
8
|
const claimValue = (record) => ({ id: record.claim.id, fence: record.claim.fence });
|
|
8
9
|
export class ConnectorLoop {
|
|
@@ -11,10 +12,13 @@ export class ConnectorLoop {
|
|
|
11
12
|
journal;
|
|
12
13
|
running = false;
|
|
13
14
|
verified = false;
|
|
15
|
+
relay;
|
|
14
16
|
constructor(client, runtime, journal) {
|
|
15
17
|
this.client = client;
|
|
16
18
|
this.runtime = runtime;
|
|
17
19
|
this.journal = journal;
|
|
20
|
+
if (runtime.relay)
|
|
21
|
+
this.relay = new RelayLoop(client, runtime.relay, journal);
|
|
18
22
|
}
|
|
19
23
|
async lease(record, recover = false) {
|
|
20
24
|
if (!record.claim)
|
|
@@ -222,7 +226,8 @@ export class ConnectorLoop {
|
|
|
222
226
|
if (status.id !== this.journal.binding.connectionId || status.agent_id !== this.journal.binding.agentId ||
|
|
223
227
|
this.runtime.key !== this.journal.binding.adapter)
|
|
224
228
|
throw new Error('Restore the original Agent/runtime binding');
|
|
225
|
-
await this.runtime.probe();
|
|
229
|
+
const readiness = await this.runtime.probe();
|
|
230
|
+
await this.client.reportRuntime(readiness);
|
|
226
231
|
this.verified = true;
|
|
227
232
|
}
|
|
228
233
|
let after = 0;
|
|
@@ -240,7 +245,8 @@ export class ConnectorLoop {
|
|
|
240
245
|
const active = this.journal.pending().filter(record => record.phase !== 'prepared');
|
|
241
246
|
for (const record of active)
|
|
242
247
|
await this.advance(record);
|
|
243
|
-
|
|
248
|
+
await this.relay?.tick(!this.journal.pending().some(record => record.phase !== 'prepared'));
|
|
249
|
+
if (!this.journal.pending().some(record => record.phase !== 'prepared') && !this.journal.pendingRelay().some(relayBlocksDispatch)) {
|
|
244
250
|
const next = this.journal.pending().find(record => record.phase === 'prepared');
|
|
245
251
|
if (next)
|
|
246
252
|
await this.advance(next);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RelayRuntime } from '../adapters/types.js';
|
|
2
|
+
import { EnvarWorkClient } from './client.js';
|
|
3
|
+
import type { ConnectorJournal } from './state.js';
|
|
4
|
+
import type { RelayRecord } from './relay-types.js';
|
|
5
|
+
export declare const relayBlocksDispatch: (record: RelayRecord) => boolean;
|
|
6
|
+
/** Uses the existing journal and process lock; unknown sends never get another POST. */
|
|
7
|
+
export declare class RelayLoop {
|
|
8
|
+
readonly client: EnvarWorkClient;
|
|
9
|
+
readonly runtime: RelayRuntime;
|
|
10
|
+
readonly journal: ConnectorJournal;
|
|
11
|
+
private lastHello;
|
|
12
|
+
private enabled;
|
|
13
|
+
constructor(client: EnvarWorkClient, runtime: RelayRuntime, journal: ConnectorJournal);
|
|
14
|
+
private queue;
|
|
15
|
+
private lease;
|
|
16
|
+
private heartbeat;
|
|
17
|
+
private flush;
|
|
18
|
+
private acknowledge;
|
|
19
|
+
private observed;
|
|
20
|
+
private unknown;
|
|
21
|
+
private claim;
|
|
22
|
+
private adoptOriginal;
|
|
23
|
+
private advance;
|
|
24
|
+
tick(allowNew: boolean): Promise<void>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { digest } from '../shared/json.js';
|
|
3
|
+
import { WorkApiError } from './client.js';
|
|
4
|
+
const terminal = new Set(['TASK_STATE_COMPLETED', 'TASK_STATE_FAILED', 'TASK_STATE_CANCELED', 'TASK_STATE_REJECTED']);
|
|
5
|
+
const taskState = (task) => String(task.status?.state);
|
|
6
|
+
export const relayBlocksDispatch = (record) => !['received', 'events_acked'].includes(record.phase) &&
|
|
7
|
+
!(record.phase === 'terminal_saved' && !record.pending.length && record.command.state === 'completed' && Boolean(record.finalDigest));
|
|
8
|
+
/** Uses the existing journal and process lock; unknown sends never get another POST. */
|
|
9
|
+
export class RelayLoop {
|
|
10
|
+
client;
|
|
11
|
+
runtime;
|
|
12
|
+
journal;
|
|
13
|
+
lastHello = 0;
|
|
14
|
+
enabled = false;
|
|
15
|
+
constructor(client, runtime, journal) {
|
|
16
|
+
this.client = client;
|
|
17
|
+
this.runtime = runtime;
|
|
18
|
+
this.journal = journal;
|
|
19
|
+
}
|
|
20
|
+
queue(record, kind, payload, observation) {
|
|
21
|
+
if (!record.command.claim)
|
|
22
|
+
throw new Error('The original relay claim is required');
|
|
23
|
+
record.pending.push({ claim: record.command.claim, event_id: randomUUID(), sequence: record.nextEvent++,
|
|
24
|
+
control_generation: record.command.control_generation, kind, payload, payload_digest: digest(payload),
|
|
25
|
+
...(observation === undefined ? {} : { observation_seq: observation }) });
|
|
26
|
+
this.journal.saveRelay(record);
|
|
27
|
+
}
|
|
28
|
+
async lease(record) {
|
|
29
|
+
const current = await this.client.relayCommand(record.command.command_id);
|
|
30
|
+
if (current.request_digest !== record.command.request_digest || current.claim?.id !== record.command.claim?.id || current.claim?.fence !== record.command.claim?.fence)
|
|
31
|
+
throw new Error('The original relay execution fence changed');
|
|
32
|
+
if (record.reference && current.native_reference && digest(record.reference) !== digest(current.native_reference))
|
|
33
|
+
throw new Error('The platform returned another original native reference');
|
|
34
|
+
const command = await this.client.relayOperation(record.command.command_id, 'recover', {
|
|
35
|
+
claim: record.command.claim, execution_key: record.executionKey, journal_phase: record.phase,
|
|
36
|
+
last_event_sequence: Math.max(record.nextEvent - 1, current.last_event_sequence),
|
|
37
|
+
});
|
|
38
|
+
if (command.request_digest !== record.command.request_digest || command.claim?.id !== record.command.claim?.id || command.claim?.fence !== record.command.claim?.fence)
|
|
39
|
+
throw new Error('The original relay execution fence changed');
|
|
40
|
+
record.command = command;
|
|
41
|
+
record.nextEvent = Math.max(record.nextEvent, command.last_event_sequence + 1);
|
|
42
|
+
record.nextObservation = Math.max(record.nextObservation, (command.last_observation_sequence ?? 0) + 1);
|
|
43
|
+
this.journal.saveRelay(record);
|
|
44
|
+
}
|
|
45
|
+
async heartbeat(record, operation) {
|
|
46
|
+
let active;
|
|
47
|
+
const timer = setInterval(() => {
|
|
48
|
+
if (!active)
|
|
49
|
+
active = this.lease(record).catch(() => {
|
|
50
|
+
// The native result is still preserved; flush revalidates the original authority.
|
|
51
|
+
}).finally(() => { active = undefined; });
|
|
52
|
+
}, 40000);
|
|
53
|
+
try {
|
|
54
|
+
return await operation();
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
clearInterval(timer);
|
|
58
|
+
await active;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async flush(record) {
|
|
62
|
+
if (!record.pending.length)
|
|
63
|
+
return;
|
|
64
|
+
await this.lease(record);
|
|
65
|
+
while (record.pending.length) {
|
|
66
|
+
const event = record.pending[0];
|
|
67
|
+
let receipt;
|
|
68
|
+
try {
|
|
69
|
+
receipt = await this.client.relayEvent(record.command.command_id, event);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (!(error instanceof WorkApiError) || ![400, 413].includes(error.status) || event.kind === 'outcome_unknown')
|
|
73
|
+
throw error;
|
|
74
|
+
// A confirmed schema/size rejection did not commit this event. Keep
|
|
75
|
+
// the rejected payload locally and report a bounded uncertainty at
|
|
76
|
+
// the same sequence so original Task reads can continue.
|
|
77
|
+
record.rejectedEvents ??= [];
|
|
78
|
+
if (!record.rejectedEvents.some(item => item.payload_digest === event.payload_digest))
|
|
79
|
+
record.rejectedEvents = [...record.rejectedEvents.slice(-19), event];
|
|
80
|
+
const payload = { reason: 'receipt_incomplete' };
|
|
81
|
+
record.pending = [{ claim: event.claim, event_id: randomUUID(), sequence: event.sequence,
|
|
82
|
+
control_generation: event.control_generation, kind: 'outcome_unknown', payload, payload_digest: digest(payload) }];
|
|
83
|
+
record.nextEvent = event.sequence + 1;
|
|
84
|
+
record.phase = 'outcome_unknown';
|
|
85
|
+
record.finalReadPending = false;
|
|
86
|
+
this.journal.saveRelay(record);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
record.pending.shift();
|
|
90
|
+
if (receipt.result_digest)
|
|
91
|
+
record.finalDigest = receipt.result_digest;
|
|
92
|
+
if (receipt.state)
|
|
93
|
+
record.command.state = receipt.state;
|
|
94
|
+
if (receipt.state === 'unknown' && record.phase === 'terminal_saved')
|
|
95
|
+
record.phase = 'outcome_unknown';
|
|
96
|
+
this.journal.saveRelay(record);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async acknowledge(record) {
|
|
100
|
+
if (!record.finalDigest || record.pending.length || !['terminal_saved', 'outcome_unknown'].includes(record.phase))
|
|
101
|
+
return;
|
|
102
|
+
if (record.finalReadPending && record.reference) {
|
|
103
|
+
await this.lease(record);
|
|
104
|
+
const observation = record.nextObservation++;
|
|
105
|
+
this.journal.saveRelay(record);
|
|
106
|
+
let original;
|
|
107
|
+
try {
|
|
108
|
+
original = await this.heartbeat(record, () => this.runtime.read(record.reference));
|
|
109
|
+
}
|
|
110
|
+
catch { /* Retry this original verification read; do not repeat its send. */ }
|
|
111
|
+
if (original) {
|
|
112
|
+
// The obligation and its evidence share one durable journal write.
|
|
113
|
+
record.finalReadPending = false;
|
|
114
|
+
this.queue(record, 'task_snapshot', original, observation);
|
|
115
|
+
await this.flush(record);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const sequence = record.nextEvent - 1;
|
|
119
|
+
if (record.acknowledgedSequence === sequence)
|
|
120
|
+
return;
|
|
121
|
+
await this.client.relayAck(record.command.command_id, { claim: record.command.claim, last_event_sequence: sequence, result_digest: record.finalDigest });
|
|
122
|
+
record.acknowledgedSequence = sequence;
|
|
123
|
+
if (record.phase === 'terminal_saved' && !record.finalReadPending)
|
|
124
|
+
record.phase = 'events_acked';
|
|
125
|
+
this.journal.saveRelay(record);
|
|
126
|
+
}
|
|
127
|
+
async observed(record, result, observation) {
|
|
128
|
+
if (result.reference) {
|
|
129
|
+
if (record.reference && digest(record.reference) !== digest(result.reference))
|
|
130
|
+
throw new Error('Native reference changed');
|
|
131
|
+
if (!record.reference) {
|
|
132
|
+
record.reference = result.reference;
|
|
133
|
+
this.queue(record, 'native_reference', { ...result.reference });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
record.result = result.task ? { task: result.task } : { message: result.message };
|
|
137
|
+
record.phase = 'native_ref_saved';
|
|
138
|
+
this.journal.saveRelay(record);
|
|
139
|
+
if (result.task) {
|
|
140
|
+
if (!record.reference || result.task.id !== record.reference.task_id || result.task.contextId !== record.reference.context_id)
|
|
141
|
+
throw new Error('Native response does not belong to the original Task and context');
|
|
142
|
+
this.queue(record, 'task_snapshot', result.task, observation);
|
|
143
|
+
record.phase = terminal.has(taskState(result.task)) ? 'terminal_saved' : 'observing';
|
|
144
|
+
}
|
|
145
|
+
else if (result.message) {
|
|
146
|
+
this.queue(record, 'delivery_receipt', { message: result.message }, observation);
|
|
147
|
+
record.phase = 'terminal_saved';
|
|
148
|
+
}
|
|
149
|
+
else
|
|
150
|
+
throw new Error('Native response contains no original Task or Message');
|
|
151
|
+
this.journal.saveRelay(record);
|
|
152
|
+
await this.flush(record);
|
|
153
|
+
}
|
|
154
|
+
async unknown(record, reason) {
|
|
155
|
+
if (record.phase !== 'outcome_unknown') {
|
|
156
|
+
record.phase = 'outcome_unknown';
|
|
157
|
+
this.queue(record, 'outcome_unknown', { reason });
|
|
158
|
+
}
|
|
159
|
+
await this.flush(record);
|
|
160
|
+
await this.acknowledge(record);
|
|
161
|
+
}
|
|
162
|
+
async claim(record) {
|
|
163
|
+
try {
|
|
164
|
+
record.command = await this.client.relayOperation(record.command.command_id, 'claim', { request_id: record.claimRequestId });
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
if (!(error instanceof WorkApiError) || error.status !== 409)
|
|
168
|
+
throw error;
|
|
169
|
+
const current = await this.client.relayCommand(record.command.command_id);
|
|
170
|
+
if (current.request_digest !== record.command.request_digest)
|
|
171
|
+
throw new Error('The original relay command changed');
|
|
172
|
+
if (current.state !== 'canceled')
|
|
173
|
+
throw error;
|
|
174
|
+
record.phase = 'events_acked';
|
|
175
|
+
this.journal.saveRelay(record);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
adoptOriginal(record) {
|
|
181
|
+
if (record.command.native_reference)
|
|
182
|
+
record.reference = record.command.native_reference;
|
|
183
|
+
record.finalDigest = record.command.result_digest || record.finalDigest;
|
|
184
|
+
record.phase = record.command.state === 'canceled' ? 'events_acked'
|
|
185
|
+
: record.command.state === 'completed' ? 'terminal_saved'
|
|
186
|
+
: record.reference ? 'observing' : 'outcome_unknown';
|
|
187
|
+
// A stale local checkpoint cannot prove that the original lookup evidence
|
|
188
|
+
// was durably reported. Another GET is safe; another SendMessage is not.
|
|
189
|
+
if (record.reference && (record.phase === 'terminal_saved' || !record.command.last_observation_sequence))
|
|
190
|
+
record.finalReadPending = true;
|
|
191
|
+
this.journal.saveRelay(record);
|
|
192
|
+
}
|
|
193
|
+
async advance(record) {
|
|
194
|
+
let preparedDispatch;
|
|
195
|
+
if (record.command.kind !== 'send')
|
|
196
|
+
throw new Error('This Connector cannot execute unverified native controls');
|
|
197
|
+
if (record.pending.length) {
|
|
198
|
+
await this.flush(record);
|
|
199
|
+
await this.acknowledge(record);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (record.phase === 'terminal_saved') {
|
|
203
|
+
await this.acknowledge(record);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (record.phase === 'outcome_unknown')
|
|
207
|
+
await this.acknowledge(record);
|
|
208
|
+
if (record.phase === 'received') {
|
|
209
|
+
if (!await this.claim(record))
|
|
210
|
+
return;
|
|
211
|
+
record.phase = 'intent_persisted';
|
|
212
|
+
this.journal.saveRelay(record);
|
|
213
|
+
}
|
|
214
|
+
if (record.phase === 'intent_persisted') {
|
|
215
|
+
// Check the native Card before the first financial admission. This GET
|
|
216
|
+
// is safely repeatable and has not started an Agent task.
|
|
217
|
+
preparedDispatch = this.runtime.prepareSend ? await this.runtime.prepareSend(record.command.a2a.params) : undefined;
|
|
218
|
+
try {
|
|
219
|
+
record.command = await this.client.relayOperation(record.command.command_id, 'admit', { claim: record.command.claim, execution_key: record.executionKey });
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (!(error instanceof WorkApiError) || error.status !== 409)
|
|
223
|
+
throw error;
|
|
224
|
+
// Admission may have succeeded before its response was lost. Recover
|
|
225
|
+
// that exact execution; a changed key/fence cannot be substituted.
|
|
226
|
+
try {
|
|
227
|
+
await this.lease(record);
|
|
228
|
+
}
|
|
229
|
+
catch (recoveryError) {
|
|
230
|
+
if (!(recoveryError instanceof WorkApiError) || recoveryError.status !== 409)
|
|
231
|
+
throw recoveryError;
|
|
232
|
+
// A pre-admission lease may expire. The server permits a new fence
|
|
233
|
+
// only while this same command has never crossed admission.
|
|
234
|
+
record.claimRequestId = randomUUID();
|
|
235
|
+
this.journal.saveRelay(record);
|
|
236
|
+
if (!await this.claim(record))
|
|
237
|
+
return;
|
|
238
|
+
this.journal.saveRelay(record);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
record.phase = 'admitted';
|
|
243
|
+
this.journal.saveRelay(record);
|
|
244
|
+
}
|
|
245
|
+
if (record.phase === 'admitted') {
|
|
246
|
+
await this.lease(record);
|
|
247
|
+
// The platform may already know a result beyond this local checkpoint.
|
|
248
|
+
// Adopt only its original reference/receipt; never replay a known dispatch.
|
|
249
|
+
// Restored backups may predate the durable dispatch marker. An expired
|
|
250
|
+
// admission with no native evidence cannot prove that no POST occurred.
|
|
251
|
+
// Report and acknowledge uncertainty without making another send.
|
|
252
|
+
if (record.command.state === 'unknown' && !record.command.native_reference && !record.command.result_digest) {
|
|
253
|
+
await this.unknown(record, 'dispatch_uncertain');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (record.command.native_reference || record.command.state !== 'admitted') {
|
|
257
|
+
this.adoptOriginal(record);
|
|
258
|
+
await this.acknowledge(record);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const dispatch = preparedDispatch ?? (this.runtime.prepareSend ? await this.heartbeat(record, () => this.runtime.prepareSend(record.command.a2a.params))
|
|
262
|
+
: () => this.runtime.send(record.command.a2a.params));
|
|
263
|
+
await this.lease(record);
|
|
264
|
+
record.phase = 'dispatch_started';
|
|
265
|
+
const observation = record.nextObservation++;
|
|
266
|
+
this.journal.saveRelay(record); // FULL synchronous SQLite commit precedes POST.
|
|
267
|
+
let result;
|
|
268
|
+
try {
|
|
269
|
+
result = await this.heartbeat(record, dispatch);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
await this.unknown(record, 'dispatch_uncertain');
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Even an immediately terminal Task gets one original GetTask readback.
|
|
276
|
+
// This also supplies the explicit native lookup evidence for enrollment.
|
|
277
|
+
record.finalReadPending = Boolean(result.task && terminal.has(taskState(result.task)));
|
|
278
|
+
await this.observed(record, result, observation);
|
|
279
|
+
await this.acknowledge(record);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (record.phase === 'dispatch_started' && !record.reference && !record.result) {
|
|
283
|
+
await this.lease(record);
|
|
284
|
+
if (record.command.native_reference || record.command.result_digest || record.command.state === 'canceled') {
|
|
285
|
+
this.adoptOriginal(record);
|
|
286
|
+
await this.acknowledge(record);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
await this.unknown(record, 'dispatch_uncertain');
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (record.phase === 'native_ref_saved' && record.result) {
|
|
293
|
+
await this.observed(record, { ...record.result, ...(record.reference ? { reference: record.reference } : {}) }, record.nextObservation - 1);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (record.reference) {
|
|
297
|
+
await this.lease(record);
|
|
298
|
+
const observation = record.nextObservation++;
|
|
299
|
+
this.journal.saveRelay(record);
|
|
300
|
+
let task;
|
|
301
|
+
try {
|
|
302
|
+
task = await this.heartbeat(record, () => this.runtime.read(record.reference));
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
await this.unknown(record, 'native_task_unavailable');
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
await this.observed(record, { task, reference: record.reference }, observation);
|
|
309
|
+
await this.acknowledge(record);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async tick(allowNew) {
|
|
313
|
+
let handshakeError;
|
|
314
|
+
if (Date.now() - this.lastHello > 20000) {
|
|
315
|
+
try {
|
|
316
|
+
this.enabled = await this.client.relayHello(await this.runtime.cardDigest());
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (error instanceof WorkApiError && [401, 403].includes(error.status))
|
|
320
|
+
throw error;
|
|
321
|
+
this.enabled = false;
|
|
322
|
+
handshakeError = error;
|
|
323
|
+
}
|
|
324
|
+
this.lastHello = Date.now();
|
|
325
|
+
}
|
|
326
|
+
if (this.enabled)
|
|
327
|
+
for (const command of await this.client.relayCommands())
|
|
328
|
+
this.journal.ingestRelay(command);
|
|
329
|
+
for (const record of this.journal.pendingRelay().filter(value => value.phase !== 'received'))
|
|
330
|
+
await this.advance(record);
|
|
331
|
+
if (allowNew && !this.journal.pendingRelay().some(relayBlocksDispatch)) {
|
|
332
|
+
const next = this.journal.pendingRelay().find(value => value.phase === 'received');
|
|
333
|
+
if (next && this.enabled)
|
|
334
|
+
await this.advance(next);
|
|
335
|
+
}
|
|
336
|
+
if (handshakeError)
|
|
337
|
+
throw handshakeError;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export interface RelayClaim {
|
|
2
|
+
id: string;
|
|
3
|
+
fence: number;
|
|
4
|
+
}
|
|
5
|
+
export interface RelayReference {
|
|
6
|
+
origin: string;
|
|
7
|
+
interface: string;
|
|
8
|
+
task_id: string;
|
|
9
|
+
context_id: string;
|
|
10
|
+
}
|
|
11
|
+
export interface RelayCommand {
|
|
12
|
+
relay_version: '1';
|
|
13
|
+
command_id: string;
|
|
14
|
+
connection_id: string;
|
|
15
|
+
binding_id: string;
|
|
16
|
+
work_id: string;
|
|
17
|
+
sequence: number;
|
|
18
|
+
kind: 'send' | 'input' | 'cancel' | 'get';
|
|
19
|
+
request_digest: string;
|
|
20
|
+
control_generation: number;
|
|
21
|
+
a2a: {
|
|
22
|
+
method: string;
|
|
23
|
+
params: Record<string, unknown>;
|
|
24
|
+
};
|
|
25
|
+
state: string;
|
|
26
|
+
last_event_sequence: number;
|
|
27
|
+
lease_until: string | null;
|
|
28
|
+
result_digest: string;
|
|
29
|
+
claim: RelayClaim | null;
|
|
30
|
+
admission_ref: string | null;
|
|
31
|
+
native_reference?: RelayReference | null;
|
|
32
|
+
last_observation_sequence?: number;
|
|
33
|
+
}
|
|
34
|
+
export interface RelayEvent {
|
|
35
|
+
claim: RelayClaim;
|
|
36
|
+
event_id: string;
|
|
37
|
+
sequence: number;
|
|
38
|
+
control_generation: number;
|
|
39
|
+
observation_seq?: number;
|
|
40
|
+
kind: 'native_reference' | 'task_snapshot' | 'delivery_receipt' | 'outcome_unknown';
|
|
41
|
+
payload: Record<string, unknown>;
|
|
42
|
+
payload_digest: string;
|
|
43
|
+
}
|
|
44
|
+
export interface RelayRecord {
|
|
45
|
+
command: RelayCommand;
|
|
46
|
+
phase: 'received' | 'intent_persisted' | 'admitted' | 'dispatch_started' | 'native_ref_saved' | 'observing' | 'outcome_unknown' | 'terminal_saved' | 'events_acked';
|
|
47
|
+
claimRequestId: string;
|
|
48
|
+
executionKey: string;
|
|
49
|
+
reference?: RelayReference;
|
|
50
|
+
nextEvent: number;
|
|
51
|
+
nextObservation: number;
|
|
52
|
+
pending: RelayEvent[];
|
|
53
|
+
result?: {
|
|
54
|
+
task?: Record<string, unknown>;
|
|
55
|
+
message?: Record<string, unknown>;
|
|
56
|
+
};
|
|
57
|
+
finalDigest?: string;
|
|
58
|
+
acknowledgedSequence?: number;
|
|
59
|
+
finalReadPending?: boolean;
|
|
60
|
+
rejectedEvents?: RelayEvent[];
|
|
61
|
+
}
|
|
62
|
+
export declare function relayCommand(value: unknown): RelayCommand;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** The relay envelope carries standard A2A 1.0; it is not another agent protocol. */
|
|
2
|
+
import { digest } from '../shared/json.js';
|
|
3
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4
|
+
export function relayCommand(value) {
|
|
5
|
+
const command = value;
|
|
6
|
+
if (!command || command.relay_version !== '1' ||
|
|
7
|
+
![command.command_id, command.connection_id, command.binding_id, command.work_id].every(id => typeof id === 'string' && uuid.test(id)) ||
|
|
8
|
+
!Number.isSafeInteger(command.sequence) || command.sequence < 1 ||
|
|
9
|
+
!Number.isSafeInteger(command.control_generation) || command.control_generation < 0 ||
|
|
10
|
+
!Number.isSafeInteger(command.last_event_sequence) || command.last_event_sequence < 0 ||
|
|
11
|
+
!['pending', 'claimed', 'admitted', 'observing', 'completed', 'unknown', 'canceled'].includes(command.state) ||
|
|
12
|
+
!command.a2a || typeof command.a2a.params !== 'object' || command.a2a.params === null ||
|
|
13
|
+
({ send: 'SendMessage', input: 'SendMessage', cancel: 'CancelTask', get: 'GetTask' })[command.kind] !== command.a2a.method ||
|
|
14
|
+
digest({ kind: command.kind, method: command.a2a.method, params: command.a2a.params, binding: command.binding_id }) !== command.request_digest)
|
|
15
|
+
throw new Error('Invalid original A2A relay command');
|
|
16
|
+
if (command.claim && (!uuid.test(command.claim.id) || !Number.isSafeInteger(command.claim.fence) || command.claim.fence < 1))
|
|
17
|
+
throw new Error('Invalid original relay claim');
|
|
18
|
+
return command;
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { RuntimeReadiness } from '../adapters/types.js';
|
|
2
|
+
export declare const CONNECTOR_VERSION: string;
|
|
3
|
+
export declare function runtimeReport(runtime: RuntimeReadiness): {
|
|
4
|
+
schema_version: number;
|
|
5
|
+
connector_version: string;
|
|
6
|
+
adapter: string;
|
|
7
|
+
runtime_version: string;
|
|
8
|
+
version_source: string;
|
|
9
|
+
capabilities: {
|
|
10
|
+
task_lookup: boolean;
|
|
11
|
+
task_lookup_after_restart: boolean;
|
|
12
|
+
execution_key_lookup: boolean;
|
|
13
|
+
result_persistence: boolean;
|
|
14
|
+
input: boolean;
|
|
15
|
+
cancel: boolean;
|
|
16
|
+
max_concurrency: number;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Diagnostics are receiver reports, never a grant or proof of task completion. */
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
export const CONNECTOR_VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
4
|
+
const version = (value) => /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$/.test(value) ? value : 'unknown';
|
|
5
|
+
export function runtimeReport(runtime) {
|
|
6
|
+
const concurrency = runtime.maxConcurrency ?? 1;
|
|
7
|
+
if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 64)
|
|
8
|
+
throw new Error('Use a bounded runtime concurrency limit');
|
|
9
|
+
return {
|
|
10
|
+
schema_version: 1,
|
|
11
|
+
connector_version: CONNECTOR_VERSION,
|
|
12
|
+
adapter: runtime.adapter,
|
|
13
|
+
runtime_version: version(runtime.runtimeVersion),
|
|
14
|
+
version_source: 'agent_card',
|
|
15
|
+
capabilities: {
|
|
16
|
+
task_lookup: runtime.capabilities.recover,
|
|
17
|
+
task_lookup_after_restart: runtime.recovery?.taskLookupAfterRestart ?? false,
|
|
18
|
+
execution_key_lookup: runtime.recovery?.executionKeyLookup ?? false,
|
|
19
|
+
result_persistence: runtime.recovery?.resultPersistence ?? false,
|
|
20
|
+
input: runtime.capabilities.input,
|
|
21
|
+
cancel: runtime.capabilities.cancel,
|
|
22
|
+
max_concurrency: concurrency,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Claim, Delivery, Report, Work } from './contracts.generated.js';
|
|
2
2
|
import type { RuntimeObservation, RuntimeReference } from '../adapters/types.js';
|
|
3
|
+
import { type RelayCommand, type RelayRecord } from './relay-types.js';
|
|
3
4
|
export type Phase = 'prepared' | 'claimed' | 'admitted' | 'dispatching' | 'running' | 'waiting_input' | 'report_pending' | 'unknown' | 'terminal';
|
|
4
5
|
export interface LocalWork {
|
|
5
6
|
work: Work;
|
|
@@ -53,5 +54,9 @@ export declare class ConnectorJournal {
|
|
|
53
54
|
saveControl(control: LocalControl): void;
|
|
54
55
|
queueReport(record: LocalWork, kind: Report['kind'], text: string, afterReport: Phase, observation?: unknown, eventId?: string): void;
|
|
55
56
|
sameObservation(record: LocalWork, observation: unknown): boolean;
|
|
57
|
+
ingestRelay(value: RelayCommand): RelayRecord;
|
|
58
|
+
getRelay(id: string): RelayRecord | undefined;
|
|
59
|
+
saveRelay(record: RelayRecord): void;
|
|
60
|
+
pendingRelay(): RelayRecord[];
|
|
56
61
|
close(): void;
|
|
57
62
|
}
|
package/dist/connector/state.js
CHANGED
|
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
4
4
|
import { dirname } from 'node:path';
|
|
5
5
|
import { randomUUID, createHash } from 'node:crypto';
|
|
6
6
|
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
import { relayCommand } from './relay-types.js';
|
|
7
8
|
const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
8
9
|
function parseStored(value) {
|
|
9
10
|
try {
|
|
@@ -42,6 +43,7 @@ export class ConnectorJournal {
|
|
|
42
43
|
CREATE TABLE IF NOT EXISTS connector_outgoing (id TEXT PRIMARY KEY,request TEXT NOT NULL,work TEXT);
|
|
43
44
|
CREATE TABLE IF NOT EXISTS connector_control (id INTEGER PRIMARY KEY,value TEXT NOT NULL);
|
|
44
45
|
CREATE TABLE IF NOT EXISTS connector_order_request (id TEXT PRIMARY KEY,request TEXT NOT NULL,order_id TEXT);
|
|
46
|
+
CREATE TABLE IF NOT EXISTS connector_relay (id TEXT PRIMARY KEY,phase TEXT NOT NULL,value TEXT NOT NULL,created INTEGER NOT NULL);
|
|
45
47
|
CREATE INDEX IF NOT EXISTS connector_work_phase ON connector_work(phase,created);`);
|
|
46
48
|
const existing = this.db.prepare("SELECT value FROM connector_meta WHERE key='binding'").get();
|
|
47
49
|
if (existing && existing.value !== JSON.stringify(binding)) {
|
|
@@ -228,6 +230,35 @@ export class ConnectorJournal {
|
|
|
228
230
|
this.save(record);
|
|
229
231
|
}
|
|
230
232
|
sameObservation(record, observation) { return record.lastObservation === digest(observation); }
|
|
233
|
+
ingestRelay(value) {
|
|
234
|
+
const command = relayCommand(value);
|
|
235
|
+
if (command.connection_id !== this.binding.connectionId)
|
|
236
|
+
throw new Error('Relay belongs to another original connection');
|
|
237
|
+
const prior = this.getRelay(command.command_id);
|
|
238
|
+
if (prior) {
|
|
239
|
+
if (prior.command.request_digest !== command.request_digest || prior.command.binding_id !== command.binding_id)
|
|
240
|
+
throw new Error('The original relay command changed');
|
|
241
|
+
return prior;
|
|
242
|
+
}
|
|
243
|
+
if (command.claim || !['pending', 'canceled'].includes(command.state))
|
|
244
|
+
throw new Error('Restore the original relay journal before recovering an admitted command');
|
|
245
|
+
const created = { command, phase: command.state === 'canceled' ? 'events_acked' : 'received', claimRequestId: randomUUID(),
|
|
246
|
+
executionKey: command.command_id, nextEvent: 1, nextObservation: 1, pending: [] };
|
|
247
|
+
this.saveRelay(created);
|
|
248
|
+
return created;
|
|
249
|
+
}
|
|
250
|
+
getRelay(id) {
|
|
251
|
+
const row = this.db.prepare('SELECT value FROM connector_relay WHERE id=?').get(id);
|
|
252
|
+
return row ? parseStored(String(row.value)) : undefined;
|
|
253
|
+
}
|
|
254
|
+
saveRelay(record) {
|
|
255
|
+
this.db.prepare('INSERT INTO connector_relay VALUES (?,?,?,?) ON CONFLICT(id) DO UPDATE SET phase=excluded.phase,value=excluded.value')
|
|
256
|
+
.run(record.command.command_id, record.phase, JSON.stringify(record), Date.now());
|
|
257
|
+
}
|
|
258
|
+
pendingRelay() {
|
|
259
|
+
return this.db.prepare("SELECT value FROM connector_relay WHERE phase!='events_acked' ORDER BY created").all()
|
|
260
|
+
.map(row => parseStored(String(row.value)));
|
|
261
|
+
}
|
|
231
262
|
close() {
|
|
232
263
|
this.db.close();
|
|
233
264
|
try {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Outbound standard A2A relay
|
|
2
|
+
|
|
3
|
+
A compatible Envar server can negotiate `a2a-relay.v1` alongside Agent Work v1.
|
|
4
|
+
The transport carries standard A2A 1.0 requests and Task/Message responses;
|
|
5
|
+
it is not an arbitrary TCP tunnel and requires no public port on the Agent.
|
|
6
|
+
The receiver validates the original command, persists its execution checkpoint
|
|
7
|
+
before a native send, and preserves ordered receipts and the original Task ID
|
|
8
|
+
in the existing private SQLite journal.
|
|
9
|
+
|
|
10
|
+
Only one native execution runs at a time. Unknown outcomes block new execution.
|
|
11
|
+
Lost responses are recovered with the original Task reference when available,
|
|
12
|
+
never by creating another Task. Restoring an old journal backup is not proof
|
|
13
|
+
that an admitted task was never dispatched: an expired admission without native
|
|
14
|
+
evidence is reported as unknown and requires original-task recovery or an
|
|
15
|
+
explicit audited resolution. Do not delete the journal or clear unknown records
|
|
16
|
+
to make an upgrade appear successful.
|
|
17
|
+
|
|
18
|
+
A native Card lookup that fails before admission is retryable. Once a send may
|
|
19
|
+
have occurred, only its durable receipts and original GetTask can advance the
|
|
20
|
+
record. Permanent oversized/schema-rejected snapshots remain in the private
|
|
21
|
+
journal and produce a bounded incomplete-receipt report, rather than silently
|
|
22
|
+
claiming completion. Immediately completed Tasks and recovered reference-only
|
|
23
|
+
Tasks retain the required original lookup evidence for platform enrollment.
|
|
24
|
+
|
|
25
|
+
The owner selects and tests an explicitly free service before the platform
|
|
26
|
+
advertises it through its public Card. Marketplace orders use the same frozen
|
|
27
|
+
order authorization; the connection itself grants no spending authority.
|
|
28
|
+
Private inventory and legacy paid workflows retain Agent Work v1 until their
|
|
29
|
+
buyer-only artifact contract is separately verified.
|
|
30
|
+
|
|
31
|
+
This release declares original Task lookup only. Native input, admitted-task
|
|
32
|
+
cancellation, durable lookup after native runtime restart, media artifacts and
|
|
33
|
+
streaming remain unverified and disabled. A package version or Card claim alone
|
|
34
|
+
cannot enable these capabilities. Upgrade through the existing connection
|
|
35
|
+
instructions, preserving credentials, native binding, SQLite journal and lock.
|
package/docs/compatibility.md
CHANGED
package/docs/native-connector.md
CHANGED
|
@@ -18,7 +18,7 @@ Replace `CONTAINER` with your container name. Run `node --version` there. On a l
|
|
|
18
18
|
Create a private Envar Agent and copy its connection command. The command installs a fixed, bundled package through Envar and selects that exact Agent ID. The official npm package is also available:
|
|
19
19
|
|
|
20
20
|
```sh
|
|
21
|
-
npm install --prefix "$HOME/.envarpay/cli" --ignore-scripts --no-audit --no-fund @envarai/connect@2026.
|
|
21
|
+
npm install --prefix "$HOME/.envarpay/cli" --ignore-scripts --no-audit --no-fund @envarai/connect@2026.1110.1
|
|
22
22
|
"$HOME/.envarpay/cli/node_modules/.bin/envar-connect" connect --agent AGENT_UUID --framework hermes
|
|
23
23
|
```
|
|
24
24
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Runtime diagnostics
|
|
2
|
+
|
|
3
|
+
`envar-connect doctor` and receiver startup report the installed Connector version,
|
|
4
|
+
the native Agent Card's version and separate recovery capabilities to the original
|
|
5
|
+
Envar connection. They do not invoke a model, create a task or grant permissions.
|
|
6
|
+
`doctor` now writes this bounded diagnostic report to Envar as well as printing it
|
|
7
|
+
locally. A real task/result test is still required to verify delivery.
|
|
8
|
+
|
|
9
|
+
The authenticated endpoint is `POST /api/v1/agent-connections/current/runtime`.
|
|
10
|
+
It requires the original native inbox connection's `connection:read`,
|
|
11
|
+
`work:receive` and `work:report` scopes. The report contains no native URL,
|
|
12
|
+
credential, local profile path, prompt or result. Envar timestamps it and labels it
|
|
13
|
+
as a receiver report; the report cannot set online/verified state, change a
|
|
14
|
+
spending grant or enable concurrency.
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"schema_version": 1,
|
|
19
|
+
"connector_version": "<installed package version>",
|
|
20
|
+
"adapter": "hermes",
|
|
21
|
+
"runtime_version": "<version reported by the Agent Card, or unknown>",
|
|
22
|
+
"version_source": "agent_card",
|
|
23
|
+
"capabilities": {
|
|
24
|
+
"task_lookup": true,
|
|
25
|
+
"task_lookup_after_restart": false,
|
|
26
|
+
"execution_key_lookup": false,
|
|
27
|
+
"result_persistence": false,
|
|
28
|
+
"input": false,
|
|
29
|
+
"cancel": false,
|
|
30
|
+
"max_concurrency": 1
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The Card version may describe its A2A implementation rather than the native CLI
|
|
36
|
+
release. Do not present it as a separately verified installation version. Missing
|
|
37
|
+
or invalid version text is reported as `unknown`.
|
|
38
|
+
|
|
39
|
+
The original `RuntimeReadiness.capabilities.recover` flag means that the adapter
|
|
40
|
+
can query a saved original Task reference. It does not imply that the gateway
|
|
41
|
+
preserves that Task after a restart. Optional `recovery` fields describe the
|
|
42
|
+
additional guarantees independently. Unspecified guarantees are false. The
|
|
43
|
+
current adapters keep these additional guarantees unconfirmed until their exact
|
|
44
|
+
runtime versions and failure windows have been validated; some newer upstream
|
|
45
|
+
implementations already persist terminal results, which is not the same as
|
|
46
|
+
recovering an interrupted in-flight execution.
|
|
47
|
+
|
|
48
|
+
Older Envar deployments may return 404/405; `doctor` then prints
|
|
49
|
+
`diagnostic_reporting: unsupported`. A temporary diagnostics failure prints
|
|
50
|
+
`unavailable` and does not block existing task recovery. Revoked or forbidden
|
|
51
|
+
credentials still stop the receiver. Reports are sampled at receiver startup or
|
|
52
|
+
an explicit `doctor` run, not treated as a continuously refreshed health signal.
|
|
53
|
+
|
|
54
|
+
When upgrading, stop the original receiver, preserve its private state directory,
|
|
55
|
+
install the reviewed package and restart against that same directory. Check
|
|
56
|
+
`doctor`, then verify the original pending task/result before starting new work.
|
|
57
|
+
Do not delete the journal, replace the Agent identity or replay an unknown native
|
|
58
|
+
request to make an upgrade appear successful. Roll back only to a version that
|
|
59
|
+
has passed journal/contract compatibility checks for the same state.
|