@axiom-lattice/protocols 4.6.1 → 4.7.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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +53 -0
- package/dist/index.d.mts +123 -2
- package/dist/index.d.ts +123 -2
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +32 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/AgentAffinity.ts +50 -0
- package/src/LeaseStoreProtocol.ts +44 -0
- package/src/OutboxStoreProtocol.ts +30 -0
- package/src/ProjectRoomRealtimeProtocol.ts +1 -1
- package/src/ScheduleLatticeProtocol.ts +18 -0
- package/src/__tests__/agentAffinity.test.ts +45 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { MessageChunk } from "./MessageProtocol";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Request header carrying the per-thread affinity key (the thread id).
|
|
5
|
+
*
|
|
6
|
+
* A multi-replica ingress hashes this header (consistently) so every
|
|
7
|
+
* execution-driving request for a thread lands on the replica that owns its
|
|
8
|
+
* process-local chunk buffer. Inert when no affinity is configured.
|
|
9
|
+
*/
|
|
10
|
+
export const AGENT_AFFINITY_HEADER = "x-axiom-affinity";
|
|
11
|
+
|
|
12
|
+
/** Response header advertising the replica (`getReplicaId()`) that served a request. */
|
|
13
|
+
export const REPLICA_HEADER = "x-axiom-replica";
|
|
14
|
+
|
|
15
|
+
/** Response header echoing back the received `x-axiom-affinity` value (diagnostics). */
|
|
16
|
+
export const AFFINITY_ECHO_HEADER = "x-axiom-affinity-echo";
|
|
17
|
+
|
|
18
|
+
/** Builds the echo response header from a request's raw affinity value, or `{}` when absent/empty. */
|
|
19
|
+
export function affinityEchoHeaders(value: string | string[] | undefined): Record<string, string> {
|
|
20
|
+
const single = Array.isArray(value) ? value[0] : value;
|
|
21
|
+
return typeof single === "string" && single.length > 0 ? { [AFFINITY_ECHO_HEADER]: single } : {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Terminal error code emitted when a run is confirmed owned by another replica. */
|
|
25
|
+
export const AFFINITY_MISS_CODE = "AFFINITY_MISS";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds the request headers that pin a request to a thread's owner replica.
|
|
29
|
+
* Returns an empty object when no thread id is available.
|
|
30
|
+
*/
|
|
31
|
+
export function threadAffinityHeaders(threadId: string | undefined): Record<string, string> {
|
|
32
|
+
return threadId ? { [AGENT_AFFINITY_HEADER]: threadId } : {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One terminal error chunk telling the client the run is owned elsewhere.
|
|
37
|
+
* Reuses the existing `error` chunk type; clients surface it via their error branch.
|
|
38
|
+
*/
|
|
39
|
+
export function buildAffinityMissChunk(owner: string, threadId: string): MessageChunk {
|
|
40
|
+
return {
|
|
41
|
+
type: "error",
|
|
42
|
+
data: {
|
|
43
|
+
id: "",
|
|
44
|
+
content: "Run is owned by another replica",
|
|
45
|
+
code: AFFINITY_MISS_CODE,
|
|
46
|
+
owner,
|
|
47
|
+
thread_id: threadId,
|
|
48
|
+
},
|
|
49
|
+
} as MessageChunk;
|
|
50
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** One held distributed lease. */
|
|
2
|
+
export interface LeaseRecord {
|
|
3
|
+
/** Lease key (e.g. `task-run:<taskId>`); unique across replicas. */
|
|
4
|
+
key: string;
|
|
5
|
+
/** Replica (holder) currently owning the key. */
|
|
6
|
+
holder: string;
|
|
7
|
+
/** ISO timestamp the current holder acquired it. */
|
|
8
|
+
acquiredAt: string;
|
|
9
|
+
/** ISO timestamp after which the lease is considered free. */
|
|
10
|
+
expiresAt: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Cross-replica lease store used for mutual exclusion, run ownership, and
|
|
15
|
+
* leader election. There is no Redis in this deployment: the reference
|
|
16
|
+
* implementation is PostgreSQL-backed, but the contract is transport-neutral.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* - `tryAcquire` is atomic: exactly one concurrent caller wins for a free or
|
|
20
|
+
* expired key.
|
|
21
|
+
* - Leases are advisory. A holder that stops heartbeating loses the key at
|
|
22
|
+
* `expiresAt`; callers doing non-idempotent work MUST re-check ownership or
|
|
23
|
+
* use CAS-protected writes.
|
|
24
|
+
*/
|
|
25
|
+
export interface LeaseStore {
|
|
26
|
+
/**
|
|
27
|
+
* Atomically acquires `key` for `holder` for `ttlMs` when the key is free or
|
|
28
|
+
* its lease has expired (a holder may also re-acquire its own expired lease).
|
|
29
|
+
* @returns `true` when `holder` owns the lease after the call.
|
|
30
|
+
*/
|
|
31
|
+
tryAcquire(key: string, holder: string, ttlMs: number): Promise<boolean>;
|
|
32
|
+
/**
|
|
33
|
+
* Extends a lease already held by `holder`.
|
|
34
|
+
* @returns `false` when the key is not held by `holder` (lost or taken).
|
|
35
|
+
*/
|
|
36
|
+
renew(key: string, holder: string, ttlMs: number): Promise<boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* Releases a lease held by `holder`.
|
|
39
|
+
* @returns `true` when a lease held by `holder` was removed.
|
|
40
|
+
*/
|
|
41
|
+
release(key: string, holder: string): Promise<boolean>;
|
|
42
|
+
/** Reads the live lease for `key`, or `null` when free/expired/absent. */
|
|
43
|
+
get(key: string): Promise<LeaseRecord | null>;
|
|
44
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** One durable outbox event, totally ordered by `id`. */
|
|
2
|
+
export interface OutboxEvent {
|
|
3
|
+
/** Monotonic id (an integer serialized as a string). */
|
|
4
|
+
id: string;
|
|
5
|
+
/** Logical channel, e.g. `task-lifecycle`. */
|
|
6
|
+
topic: string;
|
|
7
|
+
/** JSON-safe payload delivered to local listeners. */
|
|
8
|
+
payload: unknown;
|
|
9
|
+
/** Replica that authored the event (skipped by its own poller). */
|
|
10
|
+
originReplicaId: string;
|
|
11
|
+
/** ISO timestamp. */
|
|
12
|
+
createdAt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Append-only durable event log used to fan process-local notifications across replicas. */
|
|
16
|
+
export interface OutboxStore {
|
|
17
|
+
/** Appends one event and returns it (with its assigned id). */
|
|
18
|
+
append(input: { topic: string; payload: unknown; originReplicaId: string }): Promise<OutboxEvent>;
|
|
19
|
+
/** Returns up to `limit` events with `id > cursor` (ascending); `null` cursor = from the start. */
|
|
20
|
+
listAfter(cursor: string | null, limit: number): Promise<OutboxEvent[]>;
|
|
21
|
+
/** Returns the newest id, or null when empty (used to skip history on poller start). */
|
|
22
|
+
latestId(): Promise<string | null>;
|
|
23
|
+
/**
|
|
24
|
+
* Deletes events older than `olderThanMs`.
|
|
25
|
+
*
|
|
26
|
+
* @param olderThanMs Age threshold in milliseconds.
|
|
27
|
+
* @returns The number of rows deleted.
|
|
28
|
+
*/
|
|
29
|
+
pruneOlderThan(olderThanMs: number): Promise<number>;
|
|
30
|
+
}
|
|
@@ -160,7 +160,7 @@ export interface ProjectRoomEventSubscription {
|
|
|
160
160
|
/** The narrow broker contract consumed by realtime publishers and services. */
|
|
161
161
|
export interface ProjectRoomEventBrokerProtocol {
|
|
162
162
|
readonly epoch: string;
|
|
163
|
-
publish(scope: ProjectRoomEventScope, draft: ProjectRoomBusinessEventDraft): ProjectRoomScopedBusinessEvent;
|
|
163
|
+
publish(scope: ProjectRoomEventScope, draft: ProjectRoomBusinessEventDraft, sourceKey?: string): ProjectRoomScopedBusinessEvent;
|
|
164
164
|
subscribe(
|
|
165
165
|
scope: ProjectRoomEventScope,
|
|
166
166
|
afterEventId: string | undefined,
|
|
@@ -151,6 +151,24 @@ export interface ScheduleStorage {
|
|
|
151
151
|
updates: Partial<ScheduledTaskDefinition>
|
|
152
152
|
): Promise<void>;
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Atomically claims one due fire by transitioning the task `pending -> running`.
|
|
156
|
+
*
|
|
157
|
+
* @param taskId Task to claim.
|
|
158
|
+
* @returns True when this caller won the claim (task was `pending`); false if
|
|
159
|
+
* another replica already claimed it or it is not pending.
|
|
160
|
+
*/
|
|
161
|
+
claimScheduledRun(taskId: string): Promise<boolean>;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Requeues `running` tasks whose `updatedAt` is older than `olderThanMs` back
|
|
165
|
+
* to `pending` (recovery after a crash that left a claim unsettled).
|
|
166
|
+
*
|
|
167
|
+
* @param olderThanMs Age threshold in milliseconds.
|
|
168
|
+
* @returns The number of tasks requeued.
|
|
169
|
+
*/
|
|
170
|
+
requeueStaleRunning(olderThanMs: number): Promise<number>;
|
|
171
|
+
|
|
154
172
|
/**
|
|
155
173
|
* Delete task
|
|
156
174
|
*/
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AGENT_AFFINITY_HEADER,
|
|
3
|
+
REPLICA_HEADER,
|
|
4
|
+
AFFINITY_MISS_CODE,
|
|
5
|
+
AFFINITY_ECHO_HEADER,
|
|
6
|
+
threadAffinityHeaders,
|
|
7
|
+
affinityEchoHeaders,
|
|
8
|
+
buildAffinityMissChunk,
|
|
9
|
+
} from "../AgentAffinity";
|
|
10
|
+
|
|
11
|
+
describe("AgentAffinity", () => {
|
|
12
|
+
it("exposes stable header/code names", () => {
|
|
13
|
+
expect(AGENT_AFFINITY_HEADER).toBe("x-axiom-affinity");
|
|
14
|
+
expect(REPLICA_HEADER).toBe("x-axiom-replica");
|
|
15
|
+
expect(AFFINITY_MISS_CODE).toBe("AFFINITY_MISS");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("builds thread affinity headers only for a non-empty thread id", () => {
|
|
19
|
+
expect(threadAffinityHeaders("t1")).toEqual({ "x-axiom-affinity": "t1" });
|
|
20
|
+
expect(threadAffinityHeaders(undefined)).toEqual({});
|
|
21
|
+
expect(threadAffinityHeaders("")).toEqual({});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("echoes the received affinity value", () => {
|
|
25
|
+
expect(AFFINITY_ECHO_HEADER).toBe("x-axiom-affinity-echo");
|
|
26
|
+
expect(affinityEchoHeaders("t1")).toEqual({ "x-axiom-affinity-echo": "t1" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("echoes the first value of an array affinity header", () => {
|
|
30
|
+
expect(affinityEchoHeaders(["t1", "t2"])).toEqual({ "x-axiom-affinity-echo": "t1" });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("echoes nothing for an absent or empty affinity header", () => {
|
|
34
|
+
expect(affinityEchoHeaders(undefined)).toEqual({});
|
|
35
|
+
expect(affinityEchoHeaders("")).toEqual({});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("builds one terminal error chunk carrying the owner", () => {
|
|
39
|
+
const chunk = buildAffinityMissChunk("replica-b", "t1") as { type: string; data: Record<string, unknown> };
|
|
40
|
+
expect(chunk.type).toBe("error");
|
|
41
|
+
expect(chunk.data.code).toBe("AFFINITY_MISS");
|
|
42
|
+
expect(chunk.data.owner).toBe("replica-b");
|
|
43
|
+
expect(chunk.data.thread_id).toBe("t1");
|
|
44
|
+
});
|
|
45
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -59,6 +59,8 @@ export * from "./ProjectBotMembershipStoreProtocol";
|
|
|
59
59
|
export * from "./ProjectRoomMessageStoreProtocol";
|
|
60
60
|
export * from "./ProjectRoomReadStateProtocol";
|
|
61
61
|
export * from "./UserPushSubscriptionStoreProtocol";
|
|
62
|
+
export * from "./LeaseStoreProtocol";
|
|
63
|
+
export * from "./OutboxStoreProtocol";
|
|
62
64
|
export * from "./ExactDataSnapshot";
|
|
63
65
|
export * from "./ProjectRoomRealtimeProtocol";
|
|
64
66
|
|
|
@@ -95,5 +97,6 @@ export * from "./SandboxPluginProtocol";
|
|
|
95
97
|
export * from "./types";
|
|
96
98
|
export * from "./TrustedRunContextProtocol";
|
|
97
99
|
export * from "./RunScope";
|
|
100
|
+
export * from "./AgentAffinity";
|
|
98
101
|
export * from "./OpenProtocol";
|
|
99
102
|
export * from "./McpUiProtocol";
|