@ai-agent-huddle/once 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +29 -2
- package/dist/index.d.ts +19 -0
- package/dist/index.js +52 -0
- package/dist/options.d.ts +21 -0
- package/dist/transport.js +119 -0
- package/package.json +15 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Global Talent Leaders LTD
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ai-agent-huddle/once
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Typed, dependency-free HTTP client for [ONCE by AI Agent Huddle](https://once.aiagenthuddle.com/docs.html). Node.js 22+, ESM. This package contains public client code; it contains no service implementation.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @ai-agent-huddle/once
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { OnceClient, HuddleError } from '@ai-agent-huddle/once';
|
|
11
|
+
|
|
12
|
+
const once = new OnceClient({ apiKey: process.env.ONCE_API_KEY });
|
|
13
|
+
const input = { provider: 'stripe', action: 'create_payment_intent',
|
|
14
|
+
parameters: { amount: 100, currency: 'gbp' } };
|
|
15
|
+
const credentials = { stripeTestKey: process.env.STRIPE_TEST_KEY };
|
|
16
|
+
// Persist this key before dispatch; always reuse identical inputs for this operation.
|
|
17
|
+
const operation = await once.create(process.env.ONCE_OPERATION_KEY, input, credentials);
|
|
18
|
+
const recorded = await once.get(operation.operation_id);
|
|
19
|
+
console.log(recorded.status);
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The Stripe action creates an **unconfirmed test PaymentIntent**; it never captures a live payment. Only sandbox secret keys starting `sk_test_` are accepted. GitHub supports `create_issue` with `{ title, body }` and `{ githubToken, githubRepository: 'owner/repository' }`; it creates a real issue. Both title and body must be strings; use an empty body string if needed. Use a repository-restricted fine-grained token with Issues read/write. There is no arbitrary action or universal exactly-once guarantee.
|
|
23
|
+
|
|
24
|
+
Methods: `create(key, input, credentials, options?)`, `get(id, options?)`, `reconcile(id, credentials, options?)`, `retry(id, credentials, options?)`. Reconciliation inspects provider evidence. `retry` is an explicit new attempt, permitted only when the recorded provider evidence establishes safety. `UNKNOWN`, `AMBIGUOUS`, `CREATED` or `EXECUTING` requires inspection; never blindly redispatch. A replay returning the same ID is not proof the provider effect succeeded. Preserve the key, inputs and returned ID durably. See the [recovery guide](https://once.aiagenthuddle.com/guides/recovering-uncertain-actions.html).
|
|
25
|
+
|
|
26
|
+
Errors are `HuddleError` with a safe `code`, optional HTTP `status` and numeric `retryAfterSeconds`. Raw server bodies, network errors and credentials are omitted. Timeouts, aborts and network failures leave mutation outcomes uncertain; the SDK never retries. HTTP 409 is an error. Inspect the existing operation before deciding a next action.
|
|
27
|
+
|
|
28
|
+
Client options: `{ apiKey, timeoutMs?: 30000, baseUrl?, fetch? }`. Request options: `{ signal?, timeoutMs? }`. Timeouts range from 1 to 120000 milliseconds and apply through response reading. HTTPS origins only; no paths, query, fragment or URL credentials. Redirects are rejected. A custom origin or fetch implementation receives your credentials, so use only one you trust. Keep clients and provider keys in server code, never a browser bundle. `get` sends only your ONCE key; provider credentials are scoped to each explicit provider method call. Avoid logging input objects, fetch headers or successful provider references if sensitive.
|
|
29
|
+
|
|
30
|
+
[Runnable source examples](https://github.com/londoncoding/ai-agent-huddle-examples/tree/main/sdk-examples) · [Support and service information](https://once.aiagenthuddle.com/service.html) · MIT licence.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { HuddleError } from './options.js';
|
|
2
|
+
export type { ClientOptions, RequestOptions, ErrorCode } from './options.js';
|
|
3
|
+
import type { ClientOptions, RequestOptions } from './options.js';
|
|
4
|
+
export type OperationStatus = 'CREATED' | 'EXECUTING' | 'COMMITTED' | 'NOT_COMMITTED' | 'UNKNOWN' | 'PROVEN_COMMITTED' | 'PROVEN_NOT_COMMITTED' | 'AMBIGUOUS';
|
|
5
|
+
export interface Operation { operation_id: string; status: OperationStatus; retry_safe: boolean; provider_reference?: string }
|
|
6
|
+
export interface Reconciliation extends Operation { previous_status: OperationStatus; evidence: { reason: string } }
|
|
7
|
+
export type GitHubCredentials = { githubToken: string; githubRepository: string; stripeTestKey?: never };
|
|
8
|
+
export type StripeTestCredentials = { stripeTestKey: string; githubToken?: never; githubRepository?: never };
|
|
9
|
+
export type ProviderCredentials = GitHubCredentials | StripeTestCredentials;
|
|
10
|
+
export interface GitHubIssue { provider: 'github'; action: 'create_issue'; parameters: { title: string; body: string }; metadata?: Record<string, unknown> }
|
|
11
|
+
export interface StripeTestPaymentIntent { provider: 'stripe'; action: 'create_payment_intent'; parameters: { amount: number; currency: string }; metadata?: Record<string, unknown> }
|
|
12
|
+
export declare class OnceClient {
|
|
13
|
+
constructor(options: ClientOptions);
|
|
14
|
+
create(key: string, input: GitHubIssue, provider: GitHubCredentials, options?: RequestOptions): Promise<Operation>;
|
|
15
|
+
create(key: string, input: StripeTestPaymentIntent, provider: StripeTestCredentials, options?: RequestOptions): Promise<Operation>;
|
|
16
|
+
get(id: string, options?: RequestOptions): Promise<Operation>;
|
|
17
|
+
reconcile(id: string, provider: ProviderCredentials, options?: RequestOptions): Promise<Reconciliation>;
|
|
18
|
+
retry(id: string, provider: ProviderCredentials, options?: RequestOptions): Promise<Operation>;
|
|
19
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Transport, text, segment, record, invalid, protocol, uuid, responseText } from './transport.js';
|
|
2
|
+
export { HuddleError } from './transport.js';
|
|
3
|
+
const STATES = new Set(['CREATED', 'EXECUTING', 'COMMITTED', 'NOT_COMMITTED', 'UNKNOWN', 'PROVEN_COMMITTED', 'PROVEN_NOT_COMMITTED', 'AMBIGUOUS']);
|
|
4
|
+
function operation(value) {
|
|
5
|
+
protocol(STATES.has(value.status) && typeof value.retry_safe === 'boolean');
|
|
6
|
+
const result = { operation_id: uuid(value.operation_id), status: value.status, retry_safe: value.retry_safe };
|
|
7
|
+
if (value.provider_reference !== undefined) result.provider_reference = responseText(value.provider_reference);
|
|
8
|
+
return result;
|
|
9
|
+
}
|
|
10
|
+
function credentials(value) {
|
|
11
|
+
if (!record(value)) invalid();
|
|
12
|
+
if (value.githubToken !== undefined && value.stripeTestKey !== undefined) invalid();
|
|
13
|
+
if (value.githubToken !== undefined) {
|
|
14
|
+
text(value.githubToken, 4096); text(value.githubRepository, 256);
|
|
15
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.githubRepository)) invalid();
|
|
16
|
+
return { 'Once-GitHub-Token': value.githubToken, 'Once-GitHub-Repository': value.githubRepository };
|
|
17
|
+
}
|
|
18
|
+
if (value.stripeTestKey !== undefined) {
|
|
19
|
+
text(value.stripeTestKey, 4096);
|
|
20
|
+
if (!/^sk_test_[A-Za-z0-9]+$/.test(value.stripeTestKey)) invalid();
|
|
21
|
+
return { 'Once-Stripe-Test-Key': value.stripeTestKey };
|
|
22
|
+
}
|
|
23
|
+
invalid();
|
|
24
|
+
}
|
|
25
|
+
export class OnceClient {
|
|
26
|
+
#transport;
|
|
27
|
+
constructor(options) { this.#transport = new Transport('https://once.aiagenthuddle.com', options); }
|
|
28
|
+
async create(key, input, provider, options) {
|
|
29
|
+
text(key, 200);
|
|
30
|
+
if (/\s/.test(key) || !record(input) || !record(input.parameters)) invalid();
|
|
31
|
+
if (!((input.provider === 'github' && input.action === 'create_issue' && provider?.githubToken !== undefined) ||
|
|
32
|
+
(input.provider === 'stripe' && input.action === 'create_payment_intent' && provider?.stripeTestKey !== undefined))) invalid();
|
|
33
|
+
if (input.provider === 'github' && (typeof input.parameters.title !== 'string' || typeof input.parameters.body !== 'string')) invalid();
|
|
34
|
+
const headers = { ...credentials(provider), 'Idempotency-Key': key };
|
|
35
|
+
const body = { provider: input.provider, action: input.action, parameters: input.parameters };
|
|
36
|
+
if (input.metadata !== undefined) { if (!record(input.metadata)) invalid(); body.metadata = input.metadata; }
|
|
37
|
+
return operation(await this.#transport.request('/v1/operations', 'POST', body, headers, options));
|
|
38
|
+
}
|
|
39
|
+
async get(id, options) { return operation(await this.#transport.request(`/v1/operations/${segment(id)}`, 'GET', undefined, {}, options)); }
|
|
40
|
+
async reconcile(id, provider, options) {
|
|
41
|
+
const value = await this.#transport.request(`/v1/operations/${segment(id)}/reconcile`, 'POST', {}, credentials(provider), options);
|
|
42
|
+
const result = operation(value);
|
|
43
|
+
protocol(STATES.has(value.previous_status) && record(value.evidence));
|
|
44
|
+
// Evidence descriptions are protocol codes; never relay arbitrary server text.
|
|
45
|
+
protocol(typeof value.evidence.reason === 'string' && /^[A-Z][A-Z0-9_]{0,127}$/.test(value.evidence.reason));
|
|
46
|
+
return { ...result, previous_status: value.previous_status, evidence: { reason: value.evidence.reason } };
|
|
47
|
+
}
|
|
48
|
+
/** Explicit new attempt. Call only after provider evidence establishes retry safety. */
|
|
49
|
+
async retry(id, provider, options) {
|
|
50
|
+
return operation(await this.#transport.request(`/v1/operations/${segment(id)}/retry`, 'POST', {}, credentials(provider), options));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type ErrorCode = 'INVALID_ARGUMENT' | 'INVALID_RESPONSE' | 'HTTP_ERROR' | 'NETWORK_ERROR' | 'TIMEOUT' | 'ABORTED';
|
|
2
|
+
export declare class HuddleError extends Error {
|
|
3
|
+
readonly code: ErrorCode;
|
|
4
|
+
readonly status?: number;
|
|
5
|
+
readonly retryAfterSeconds?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ClientOptions {
|
|
8
|
+
/** Project API key. Keep this client on the server. */
|
|
9
|
+
apiKey: string;
|
|
10
|
+
/** HTTPS origin only. Changing this sends credentials to that origin. */
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
/** Defaults to 30000; integer between 1 and 120000. */
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
/** Trusted fetch implementation; receives credentials. */
|
|
15
|
+
fetch?: typeof globalThis.fetch;
|
|
16
|
+
}
|
|
17
|
+
export interface RequestOptions {
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface Observation { request_id?: string; process_session_id?: string }
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Public HTTP transport only. No service implementation or storage access.
|
|
2
|
+
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
3
|
+
export class HuddleError extends Error {
|
|
4
|
+
constructor(code, status, retryAfterSeconds) {
|
|
5
|
+
super(status === undefined ? `AI Agent Huddle ${code}` : `AI Agent Huddle HTTP ${status}`);
|
|
6
|
+
this.name = 'HuddleError';
|
|
7
|
+
this.code = code;
|
|
8
|
+
if (status !== undefined) this.status = status;
|
|
9
|
+
if (retryAfterSeconds !== undefined) this.retryAfterSeconds = retryAfterSeconds;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function invalid() { throw new HuddleError('INVALID_ARGUMENT'); }
|
|
13
|
+
export function text(value, max = 512) {
|
|
14
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\u0000-\u001f\u007f]/.test(value)) invalid();
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
export function integer(value, min, max) {
|
|
18
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) invalid();
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
export function segment(value) {
|
|
22
|
+
text(value);
|
|
23
|
+
// Dot segments are normalized by URL/fetch even when percent encoded.
|
|
24
|
+
if (value === '.' || value === '..') invalid();
|
|
25
|
+
try { return encodeURIComponent(value); } catch { invalid(); }
|
|
26
|
+
}
|
|
27
|
+
export function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
28
|
+
export function protocol(condition) { if (!condition) throw new HuddleError('INVALID_RESPONSE'); }
|
|
29
|
+
export function uuid(value) {
|
|
30
|
+
protocol(typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value));
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
export function responseText(value, max = 512) {
|
|
34
|
+
protocol(typeof value === 'string' && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value));
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
export function nullableText(value) { return value === null ? null : responseText(value); }
|
|
38
|
+
export function observation(value = {}) {
|
|
39
|
+
if (!record(value)) invalid();
|
|
40
|
+
const output = {};
|
|
41
|
+
for (const field of ['request_id', 'process_session_id']) {
|
|
42
|
+
if (value[field] !== undefined) {
|
|
43
|
+
text(value[field], 36);
|
|
44
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value[field])) invalid();
|
|
45
|
+
output[field] = value[field];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return output;
|
|
49
|
+
}
|
|
50
|
+
function timeout(value) { return integer(value, 1, 120000); }
|
|
51
|
+
export class Transport {
|
|
52
|
+
#origin; #apiKey; #fetch; #timeoutMs;
|
|
53
|
+
constructor(defaultOrigin, options) {
|
|
54
|
+
if (!record(options)) invalid();
|
|
55
|
+
text(options.apiKey, 4096);
|
|
56
|
+
let endpoint;
|
|
57
|
+
try { endpoint = new URL(options.baseUrl ?? defaultOrigin); } catch { invalid(); }
|
|
58
|
+
if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash || endpoint.pathname !== '/') invalid();
|
|
59
|
+
this.#origin = endpoint.origin;
|
|
60
|
+
this.#apiKey = options.apiKey;
|
|
61
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
62
|
+
if (typeof this.#fetch !== 'function') invalid();
|
|
63
|
+
this.#timeoutMs = timeout(options.timeoutMs ?? 30000);
|
|
64
|
+
}
|
|
65
|
+
async request(path, method, body, providerHeaders = {}, options = {}) {
|
|
66
|
+
if (!record(options)) invalid();
|
|
67
|
+
const timeoutSignal = AbortSignal.timeout(timeout(options.timeoutMs ?? this.#timeoutMs));
|
|
68
|
+
let signal = timeoutSignal;
|
|
69
|
+
if (options.signal !== undefined) {
|
|
70
|
+
if (!(options.signal instanceof AbortSignal)) invalid();
|
|
71
|
+
signal = AbortSignal.any([signal, options.signal]);
|
|
72
|
+
}
|
|
73
|
+
if (signal.aborted) throw new HuddleError(timeoutSignal.aborted ? 'TIMEOUT' : 'ABORTED');
|
|
74
|
+
let serialized;
|
|
75
|
+
try { serialized = body === undefined ? undefined : JSON.stringify(body); } catch { invalid(); }
|
|
76
|
+
const headers = { Authorization: `Bearer ${this.#apiKey}`, Accept: 'application/json', ...providerHeaders };
|
|
77
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
78
|
+
let response;
|
|
79
|
+
try {
|
|
80
|
+
response = await this.#fetch(this.#origin + path, {
|
|
81
|
+
method, headers, body: serialized, signal, redirect: 'error', credentials: 'omit', cache: 'no-store'
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
throw new HuddleError(timeoutSignal.aborted ? 'TIMEOUT' : signal.aborted ? 'ABORTED' : 'NETWORK_ERROR');
|
|
85
|
+
}
|
|
86
|
+
protocol(Number.isInteger(response.status) && response.status >= 100 && response.status <= 599);
|
|
87
|
+
const retry = response.headers.get('retry-after');
|
|
88
|
+
const retryAfterSeconds = retry && /^\d{1,6}$/.test(retry) ? Number(retry) : undefined;
|
|
89
|
+
// A denial is a typed acquire result; all other non-2xx responses stay opaque.
|
|
90
|
+
if (!response.ok && !(method === 'POST' && path === '/v1/leases' && response.status === 409)) {
|
|
91
|
+
try { await response.body?.cancel(); } catch { /* Do not expose stream errors. */ }
|
|
92
|
+
throw new HuddleError('HTTP_ERROR', response.status, retryAfterSeconds);
|
|
93
|
+
}
|
|
94
|
+
protocol(response.headers.get('content-type')?.toLowerCase().includes('application/json'));
|
|
95
|
+
let value;
|
|
96
|
+
try {
|
|
97
|
+
const reader = response.body?.getReader();
|
|
98
|
+
if (!reader) throw new Error();
|
|
99
|
+
const chunks = []; let bytes = 0;
|
|
100
|
+
try {
|
|
101
|
+
while (true) {
|
|
102
|
+
const { value: chunk, done } = await reader.read();
|
|
103
|
+
if (done) break;
|
|
104
|
+
bytes += chunk.byteLength;
|
|
105
|
+
if (bytes > MAX_RESPONSE_BYTES) { await reader.cancel(); throw new Error(); }
|
|
106
|
+
chunks.push(chunk);
|
|
107
|
+
}
|
|
108
|
+
} finally { reader.releaseLock(); }
|
|
109
|
+
const content = new Uint8Array(bytes); let offset = 0;
|
|
110
|
+
for (const chunk of chunks) { content.set(chunk, offset); offset += chunk.byteLength; }
|
|
111
|
+
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(content));
|
|
112
|
+
} catch {
|
|
113
|
+
throw new HuddleError(timeoutSignal.aborted ? 'TIMEOUT' : signal.aborted ? 'ABORTED' : 'INVALID_RESPONSE');
|
|
114
|
+
}
|
|
115
|
+
protocol(record(value));
|
|
116
|
+
if (!response.ok && value.status !== 'DENIED') throw new HuddleError('HTTP_ERROR', response.status, retryAfterSeconds);
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-agent-huddle/once",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Typed HTTP client for AI Agent Huddle ONCE operation recovery",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
|
9
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
10
|
+
"engines": { "node": ">=22" },
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": { "type": "git", "url": "git+https://github.com/londoncoding/ai-agent-huddle-examples.git", "directory": "packages/once" },
|
|
13
|
+
"homepage": "https://once.aiagenthuddle.com/docs.html",
|
|
14
|
+
"bugs": { "url": "https://github.com/londoncoding/ai-agent-huddle-examples/issues" },
|
|
15
|
+
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
|
|
16
|
+
"keywords": ["ai-agent-huddle", "agents", "idempotency", "recovery", "sdk"]
|
|
17
|
+
}
|