@agentic-kit/run-log-client 0.2.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 +23 -0
- package/README.md +82 -0
- package/client.d.ts +185 -0
- package/client.js +291 -0
- package/documents.d.ts +35 -0
- package/documents.js +117 -0
- package/esm/client.d.ts +185 -0
- package/esm/client.js +284 -0
- package/esm/documents.d.ts +35 -0
- package/esm/documents.js +107 -0
- package/esm/follow-run.d.ts +38 -0
- package/esm/follow-run.js +91 -0
- package/esm/index.d.ts +22 -0
- package/esm/index.js +21 -0
- package/esm/local-run.d.ts +49 -0
- package/esm/local-run.js +69 -0
- package/esm/names.d.ts +47 -0
- package/esm/names.js +69 -0
- package/esm/realtime.d.ts +68 -0
- package/esm/realtime.js +89 -0
- package/esm/surface.d.ts +17 -0
- package/esm/surface.js +21 -0
- package/esm/transport.d.ts +23 -0
- package/esm/transport.js +42 -0
- package/esm/view.d.ts +52 -0
- package/esm/view.js +59 -0
- package/follow-run.d.ts +38 -0
- package/follow-run.js +94 -0
- package/index.d.ts +22 -0
- package/index.js +60 -0
- package/local-run.d.ts +49 -0
- package/local-run.js +76 -0
- package/names.d.ts +47 -0
- package/names.js +73 -0
- package/package.json +42 -0
- package/realtime.d.ts +68 -0
- package/realtime.js +95 -0
- package/surface.d.ts +17 -0
- package/surface.js +24 -0
- package/transport.d.ts +23 -0
- package/transport.js +45 -0
- package/view.d.ts +52 -0
- package/view.js +68 -0
package/documents.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The documents this lane needs, built from a module's names.
|
|
4
|
+
*
|
|
5
|
+
* Written as strings rather than generated: the run surface belongs to a
|
|
6
|
+
* tenant's schema, so there is no committed SDK target that carries it, and the
|
|
7
|
+
* fields queried here are exactly the columns the run generator emits.
|
|
8
|
+
*
|
|
9
|
+
* The shapes follow the conventions the generated targets in `sdk/` use against
|
|
10
|
+
* this platform's GraphQL: a collection takes `where: <Type>Filter` (not
|
|
11
|
+
* `filter`), there is no single-node query field — one row is a `first: 1`
|
|
12
|
+
* collection read — and an update takes `{ id, <singular>Patch }`.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.advanceRunDocument = exports.createThreadDocument = exports.createRunDocument = exports.appendDocument = exports.eventsDocument = exports.runDocument = exports.runsDocument = exports.EVENT_FIELDS = exports.RUN_FIELDS = void 0;
|
|
16
|
+
exports.RUN_FIELDS = `
|
|
17
|
+
id
|
|
18
|
+
threadId
|
|
19
|
+
actorId
|
|
20
|
+
entityId
|
|
21
|
+
status
|
|
22
|
+
placement
|
|
23
|
+
executionId
|
|
24
|
+
repoUrl
|
|
25
|
+
branch
|
|
26
|
+
baseCommit
|
|
27
|
+
headCommit
|
|
28
|
+
lastEventSeq
|
|
29
|
+
attempt
|
|
30
|
+
parentRunId
|
|
31
|
+
error
|
|
32
|
+
artifacts
|
|
33
|
+
deadlineAt
|
|
34
|
+
startedAt
|
|
35
|
+
finishedAt
|
|
36
|
+
createdAt`;
|
|
37
|
+
// The columns are named for what they describe rather than for the harness that
|
|
38
|
+
// writes them, which is the shape `@agentic-kit/run-log`'s record asserts.
|
|
39
|
+
exports.EVENT_FIELDS = `
|
|
40
|
+
runId
|
|
41
|
+
seq
|
|
42
|
+
recordedAt
|
|
43
|
+
transcriptFormat
|
|
44
|
+
transcriptVersion
|
|
45
|
+
entry`;
|
|
46
|
+
const runsDocument = (names) => `
|
|
47
|
+
query Runs($first: Int!, $where: ${names.runType}Filter) {
|
|
48
|
+
${names.runs}(first: $first, where: $where, orderBy: [CREATED_AT_DESC]) {
|
|
49
|
+
nodes {${exports.RUN_FIELDS}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}`;
|
|
53
|
+
exports.runsDocument = runsDocument;
|
|
54
|
+
const runDocument = (names) => `
|
|
55
|
+
query Run($where: ${names.runType}Filter!) {
|
|
56
|
+
${names.runs}(first: 1, where: $where) {
|
|
57
|
+
nodes {${exports.RUN_FIELDS}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}`;
|
|
61
|
+
exports.runDocument = runDocument;
|
|
62
|
+
const eventsDocument = (names) => `
|
|
63
|
+
query RunEvents($first: Int!, $where: ${names.eventType}Filter!) {
|
|
64
|
+
${names.events}(first: $first, where: $where, orderBy: [SEQ_ASC]) {
|
|
65
|
+
nodes {${exports.EVENT_FIELDS}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}`;
|
|
69
|
+
exports.eventsDocument = eventsDocument;
|
|
70
|
+
const appendDocument = (names) => `
|
|
71
|
+
mutation AppendRunEvent($input: Create${names.eventType}Input!) {
|
|
72
|
+
${names.createEvent}(input: $input) {
|
|
73
|
+
${names.event} {${exports.EVENT_FIELDS}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}`;
|
|
77
|
+
exports.appendDocument = appendDocument;
|
|
78
|
+
/**
|
|
79
|
+
* Open a run.
|
|
80
|
+
*
|
|
81
|
+
* A cloud run is opened by the worker on the platform's side of the callback
|
|
82
|
+
* lane; a local run has no worker, so the host that executes it opens its own
|
|
83
|
+
* row here — as the user, over the tenant API, which is what makes `actor_id`,
|
|
84
|
+
* `principal_id` and `entity_id` the session's claims rather than anything this
|
|
85
|
+
* client could pass. Only the columns a host legitimately chooses are sent.
|
|
86
|
+
*/
|
|
87
|
+
const createRunDocument = (names) => `
|
|
88
|
+
mutation CreateRun($input: Create${names.runType}Input!) {
|
|
89
|
+
${names.createRun}(input: $input) {
|
|
90
|
+
${names.run} {${exports.RUN_FIELDS}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}`;
|
|
94
|
+
exports.createRunDocument = createRunDocument;
|
|
95
|
+
/**
|
|
96
|
+
* Open the thread a run hangs from. `agent_run.thread_id` is required and a
|
|
97
|
+
* local host is the first writer of its own conversation, so it creates one.
|
|
98
|
+
*/
|
|
99
|
+
const createThreadDocument = (names) => `
|
|
100
|
+
mutation CreateThread($input: Create${names.threadType}Input!) {
|
|
101
|
+
${names.createThread}(input: $input) {
|
|
102
|
+
${names.thread} {
|
|
103
|
+
id
|
|
104
|
+
title
|
|
105
|
+
createdAt
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}`;
|
|
109
|
+
exports.createThreadDocument = createThreadDocument;
|
|
110
|
+
const advanceRunDocument = (names) => `
|
|
111
|
+
mutation AdvanceRun($input: Update${names.runType}Input!) {
|
|
112
|
+
${names.updateRun}(input: $input) {
|
|
113
|
+
${names.run} {${exports.RUN_FIELDS}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}`;
|
|
117
|
+
exports.advanceRunDocument = advanceRunDocument;
|
package/esm/client.d.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run log, as a browser reads and writes it.
|
|
3
|
+
*
|
|
4
|
+
* A Job writes through the worker's callback lane because it holds no tenant
|
|
5
|
+
* credential; a user's browser is the opposite case — it already authenticates
|
|
6
|
+
* as the user against the tenant API, and RLS on `agent_run`/`agent_event` is
|
|
7
|
+
* exactly the visibility rule a run surface should obey. So this store speaks
|
|
8
|
+
* the tenant's own GraphQL and implements the same `RunLogStore` contract as
|
|
9
|
+
* every other host, which is what lets one renderer draw a local run, a cloud
|
|
10
|
+
* run and a replayed run without knowing which it has.
|
|
11
|
+
*/
|
|
12
|
+
import { type AppendOptions, type ApprovalResolutionInput, type PiMessage, type RunEventRecord, type RunLogCursor, type RunLogPage, type RunLogStore, type TranscriptEntry, type TranscriptReaderRegistry } from '@agentic-kit/run-log';
|
|
13
|
+
import { type RunLogNames } from './names';
|
|
14
|
+
import type { GraphqlRequest } from './transport';
|
|
15
|
+
export type RunStatus = 'pending' | 'running' | 'waiting' | 'idle' | 'succeeded' | 'failed' | 'cancelled';
|
|
16
|
+
/** A run holding, or able to hold, compute. */
|
|
17
|
+
export declare const ATTACHABLE_STATUSES: readonly RunStatus[];
|
|
18
|
+
/** A run that will never append again. */
|
|
19
|
+
export declare const TERMINAL_STATUSES: readonly RunStatus[];
|
|
20
|
+
export declare const isTerminalStatus: (status: string) => boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The run row as a surface reads it. `executionId` is the *currently attached*
|
|
23
|
+
* execution and is null for an idle run — the attach/detach history lives in the
|
|
24
|
+
* log, so a null here means "holds no compute right now", never "never ran".
|
|
25
|
+
*/
|
|
26
|
+
export interface RunSummary {
|
|
27
|
+
id: string;
|
|
28
|
+
threadId: string | null;
|
|
29
|
+
/** Who invoked the run. */
|
|
30
|
+
actorId: string | null;
|
|
31
|
+
/** Who is billed for it — the actor themselves for a personal run. */
|
|
32
|
+
entityId: string | null;
|
|
33
|
+
status: string;
|
|
34
|
+
placement: string | null;
|
|
35
|
+
executionId: string | null;
|
|
36
|
+
repoUrl: string | null;
|
|
37
|
+
branch: string | null;
|
|
38
|
+
baseCommit: string | null;
|
|
39
|
+
headCommit: string | null;
|
|
40
|
+
lastEventSeq: number;
|
|
41
|
+
attempt: number | null;
|
|
42
|
+
parentRunId: string | null;
|
|
43
|
+
error: string | null;
|
|
44
|
+
artifacts: unknown;
|
|
45
|
+
deadlineAt: string | null;
|
|
46
|
+
startedAt: string | null;
|
|
47
|
+
finishedAt: string | null;
|
|
48
|
+
createdAt: string | null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* What a host chooses when it opens a run.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately holds no identity: `actor_id`, `principal_id` and `entity_id`
|
|
54
|
+
* default from the claims of the transaction the tenant API opens for this
|
|
55
|
+
* request, so who the run belongs to is the authenticated session and not
|
|
56
|
+
* something a caller can name.
|
|
57
|
+
*/
|
|
58
|
+
export interface CreateRunInput {
|
|
59
|
+
threadId: string;
|
|
60
|
+
/**
|
|
61
|
+
* `local` for a run executed by a host on the user's machine, `cloud` for a
|
|
62
|
+
* Job. The column defaults to `cloud`, so a local host states it.
|
|
63
|
+
*/
|
|
64
|
+
placement: 'cloud' | 'local';
|
|
65
|
+
status?: RunStatus;
|
|
66
|
+
repoUrl?: string;
|
|
67
|
+
branch?: string;
|
|
68
|
+
}
|
|
69
|
+
/** The lifecycle columns a host that owns a run moves as it runs. */
|
|
70
|
+
export interface RunPatch {
|
|
71
|
+
status?: RunStatus;
|
|
72
|
+
error?: string | null;
|
|
73
|
+
startedAt?: string | null;
|
|
74
|
+
finishedAt?: string | null;
|
|
75
|
+
}
|
|
76
|
+
export interface CreateThreadInput {
|
|
77
|
+
title?: string;
|
|
78
|
+
/** `agent` for a tool-enabled run, which is what a coding host opens. */
|
|
79
|
+
mode?: 'ask' | 'agent';
|
|
80
|
+
}
|
|
81
|
+
export interface ThreadSummary {
|
|
82
|
+
id: string;
|
|
83
|
+
title: string | null;
|
|
84
|
+
createdAt: string | null;
|
|
85
|
+
}
|
|
86
|
+
export interface ListRunsArgs {
|
|
87
|
+
first?: number;
|
|
88
|
+
threadId?: string;
|
|
89
|
+
status?: string;
|
|
90
|
+
}
|
|
91
|
+
export interface RunLogClientOptions {
|
|
92
|
+
request: GraphqlRequest;
|
|
93
|
+
/** From the module loader; defaults to the app-scope names. */
|
|
94
|
+
runTable?: string;
|
|
95
|
+
eventTable?: string;
|
|
96
|
+
threadTable?: string;
|
|
97
|
+
/**
|
|
98
|
+
* A browser has no row lock, so two writers can pick the same next `seq`;
|
|
99
|
+
* `UNIQUE (run_id, seq)` rejects the loser and it re-reads and retries. Bounded
|
|
100
|
+
* so a genuinely broken write surfaces as an error instead of spinning.
|
|
101
|
+
*/
|
|
102
|
+
maxAppendAttempts?: number;
|
|
103
|
+
newId?: () => string;
|
|
104
|
+
now?: () => Date;
|
|
105
|
+
/**
|
|
106
|
+
* Readers that validate the entries this client writes and reads. Defaults to
|
|
107
|
+
* the process registry, so a host that registered a second harness's reader
|
|
108
|
+
* at startup reads that harness's runs without passing anything here.
|
|
109
|
+
*/
|
|
110
|
+
readers?: TranscriptReaderRegistry;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A conflict on `(run_id, seq)` — the only error this store retries. Matched on
|
|
114
|
+
* the message because the tenant API reports it as a GraphQL error string; any
|
|
115
|
+
* other failure is rethrown untouched.
|
|
116
|
+
*/
|
|
117
|
+
export declare const isSeqConflict: (error: unknown) => boolean;
|
|
118
|
+
export declare class GraphqlRunLogClient implements RunLogStore {
|
|
119
|
+
readonly names: RunLogNames;
|
|
120
|
+
/**
|
|
121
|
+
* Read by a follower so the view projects exactly the formats this client
|
|
122
|
+
* accepts — one registry decides both what a run may store and what a surface
|
|
123
|
+
* can draw.
|
|
124
|
+
*/
|
|
125
|
+
readonly readers: TranscriptReaderRegistry;
|
|
126
|
+
private readonly request;
|
|
127
|
+
private readonly maxAppendAttempts;
|
|
128
|
+
private readonly newId;
|
|
129
|
+
private readonly now;
|
|
130
|
+
constructor(options: RunLogClientOptions);
|
|
131
|
+
listRuns(args?: ListRunsArgs): Promise<RunSummary[]>;
|
|
132
|
+
/**
|
|
133
|
+
* Open a thread. A cloud run is opened against a thread the web UI already
|
|
134
|
+
* created; a host on the user's machine is the first writer of its own
|
|
135
|
+
* conversation, so it creates one and then hangs its runs off it.
|
|
136
|
+
*/
|
|
137
|
+
createThread(input?: CreateThreadInput): Promise<ThreadSummary>;
|
|
138
|
+
/** Open a run. See {@link CreateRunInput} on why identity is not an argument. */
|
|
139
|
+
createRun(input: CreateRunInput): Promise<RunSummary>;
|
|
140
|
+
/**
|
|
141
|
+
* Move a run's lifecycle columns. The transcript is the history; this row is
|
|
142
|
+
* the current state a list view reads without replaying it.
|
|
143
|
+
*/
|
|
144
|
+
updateRun(runId: string, patch: RunPatch): Promise<RunSummary>;
|
|
145
|
+
getRun(runId: string): Promise<RunSummary>;
|
|
146
|
+
read(runId: string, cursor?: RunLogCursor, limit?: number): Promise<RunLogPage>;
|
|
147
|
+
/**
|
|
148
|
+
* Append entries, allocating `seq` from the run's cursor and advancing it.
|
|
149
|
+
*
|
|
150
|
+
* Two writers racing is normal here (a user sends a message while the Job
|
|
151
|
+
* appends a result), so the sequence is not reserved up front: each insert is
|
|
152
|
+
* attempted at the next free position and a rejected one re-reads the run.
|
|
153
|
+
* Entries already present by entry id are skipped, so a retried call is a
|
|
154
|
+
* no-op rather than a duplicated turn.
|
|
155
|
+
*/
|
|
156
|
+
append(runId: string, entries: readonly TranscriptEntry[], options?: AppendOptions): Promise<RunEventRecord[]>;
|
|
157
|
+
/**
|
|
158
|
+
* Append one pi message as an entry, chained to the run's current tail.
|
|
159
|
+
*
|
|
160
|
+
* The composer's own turn is the one place this store still writes a
|
|
161
|
+
* transcript rather than relaying one, so it writes the format it declares in
|
|
162
|
+
* `insertEvent`; a second harness contributes its own message builder beside
|
|
163
|
+
* this one and appends through the neutral `append`.
|
|
164
|
+
*/
|
|
165
|
+
appendMessage(runId: string, message: PiMessage, options?: AppendOptions): Promise<RunEventRecord[]>;
|
|
166
|
+
/** The user's turn: what a run surface's composer sends. */
|
|
167
|
+
prompt(runId: string, text: string): Promise<RunEventRecord[]>;
|
|
168
|
+
/**
|
|
169
|
+
* Answer a pending approval. The decision is a log entry like any other, so
|
|
170
|
+
* the Job sees it on its next cursor read and the history records who allowed
|
|
171
|
+
* what — there is no side channel to reconcile.
|
|
172
|
+
*/
|
|
173
|
+
resolveApproval(runId: string, input: ApprovalResolutionInput): Promise<RunEventRecord[]>;
|
|
174
|
+
private insertEvent;
|
|
175
|
+
private advanceRun;
|
|
176
|
+
/**
|
|
177
|
+
* Skip entries a previous attempt already wrote. Only the tail is inspected —
|
|
178
|
+
* as many records as the call is trying to append — because that is where a
|
|
179
|
+
* partially applied attempt of *this* call can be, and scanning the whole run
|
|
180
|
+
* to make a retry idempotent would make every append cost the run's length.
|
|
181
|
+
*/
|
|
182
|
+
private dropAlreadyWritten;
|
|
183
|
+
private tailEntryId;
|
|
184
|
+
}
|
|
185
|
+
export declare const createRunLogClient: (options: RunLogClientOptions) => GraphqlRunLogClient;
|
package/esm/client.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run log, as a browser reads and writes it.
|
|
3
|
+
*
|
|
4
|
+
* A Job writes through the worker's callback lane because it holds no tenant
|
|
5
|
+
* credential; a user's browser is the opposite case — it already authenticates
|
|
6
|
+
* as the user against the tenant API, and RLS on `agent_run`/`agent_event` is
|
|
7
|
+
* exactly the visibility rule a run surface should obey. So this store speaks
|
|
8
|
+
* the tenant's own GraphQL and implements the same `RunLogStore` contract as
|
|
9
|
+
* every other host, which is what lets one renderer draw a local run, a cloud
|
|
10
|
+
* run and a replayed run without knowing which it has.
|
|
11
|
+
*/
|
|
12
|
+
import { approvalResolutionMessage, assertRunEventRecord, PI_TRANSCRIPT_FORMAT, START, SUPPORTED_PI_TRANSCRIPT_VERSION, transcriptReaders, } from '@agentic-kit/run-log';
|
|
13
|
+
import { advanceRunDocument, appendDocument, createRunDocument, createThreadDocument, eventsDocument, runDocument, runsDocument, } from './documents';
|
|
14
|
+
import { runLogNames } from './names';
|
|
15
|
+
/** A run holding, or able to hold, compute. */
|
|
16
|
+
export const ATTACHABLE_STATUSES = [
|
|
17
|
+
'pending',
|
|
18
|
+
'running',
|
|
19
|
+
'waiting',
|
|
20
|
+
'idle',
|
|
21
|
+
];
|
|
22
|
+
/** A run that will never append again. */
|
|
23
|
+
export const TERMINAL_STATUSES = [
|
|
24
|
+
'succeeded',
|
|
25
|
+
'failed',
|
|
26
|
+
'cancelled',
|
|
27
|
+
];
|
|
28
|
+
export const isTerminalStatus = (status) => TERMINAL_STATUSES.includes(status);
|
|
29
|
+
const DEFAULT_MAX_APPEND_ATTEMPTS = 5;
|
|
30
|
+
const DEFAULT_READ_LIMIT = 200;
|
|
31
|
+
const DEFAULT_RUN_LIMIT = 50;
|
|
32
|
+
/**
|
|
33
|
+
* A conflict on `(run_id, seq)` — the only error this store retries. Matched on
|
|
34
|
+
* the message because the tenant API reports it as a GraphQL error string; any
|
|
35
|
+
* other failure is rethrown untouched.
|
|
36
|
+
*/
|
|
37
|
+
export const isSeqConflict = (error) => {
|
|
38
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
39
|
+
return /duplicate key|unique constraint|already exists/i.test(message);
|
|
40
|
+
};
|
|
41
|
+
const entryId = (entry) => typeof entry.id === 'string' ? entry.id : undefined;
|
|
42
|
+
export class GraphqlRunLogClient {
|
|
43
|
+
names;
|
|
44
|
+
/**
|
|
45
|
+
* Read by a follower so the view projects exactly the formats this client
|
|
46
|
+
* accepts — one registry decides both what a run may store and what a surface
|
|
47
|
+
* can draw.
|
|
48
|
+
*/
|
|
49
|
+
readers;
|
|
50
|
+
request;
|
|
51
|
+
maxAppendAttempts;
|
|
52
|
+
newId;
|
|
53
|
+
now;
|
|
54
|
+
constructor(options) {
|
|
55
|
+
this.request = options.request;
|
|
56
|
+
this.names = runLogNames(options.runTable, options.eventTable, options.threadTable);
|
|
57
|
+
this.maxAppendAttempts =
|
|
58
|
+
options.maxAppendAttempts ?? DEFAULT_MAX_APPEND_ATTEMPTS;
|
|
59
|
+
this.newId = options.newId ?? (() => globalThis.crypto.randomUUID());
|
|
60
|
+
this.now = options.now ?? (() => new Date());
|
|
61
|
+
this.readers = options.readers ?? transcriptReaders;
|
|
62
|
+
}
|
|
63
|
+
async listRuns(args = {}) {
|
|
64
|
+
const where = {};
|
|
65
|
+
if (args.threadId)
|
|
66
|
+
where.threadId = { equalTo: args.threadId };
|
|
67
|
+
if (args.status)
|
|
68
|
+
where.status = { equalTo: args.status };
|
|
69
|
+
const data = await this.request(runsDocument(this.names), {
|
|
70
|
+
first: args.first ?? DEFAULT_RUN_LIMIT,
|
|
71
|
+
where: Object.keys(where).length > 0 ? where : undefined,
|
|
72
|
+
});
|
|
73
|
+
return data[this.names.runs].nodes;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Open a thread. A cloud run is opened against a thread the web UI already
|
|
77
|
+
* created; a host on the user's machine is the first writer of its own
|
|
78
|
+
* conversation, so it creates one and then hangs its runs off it.
|
|
79
|
+
*/
|
|
80
|
+
async createThread(input = {}) {
|
|
81
|
+
const data = await this.request(createThreadDocument(this.names), {
|
|
82
|
+
input: {
|
|
83
|
+
[this.names.thread]: {
|
|
84
|
+
...(input.title === undefined ? {} : { title: input.title }),
|
|
85
|
+
mode: input.mode ?? 'agent',
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const thread = data[this.names.createThread]?.[this.names.thread];
|
|
90
|
+
if (!thread) {
|
|
91
|
+
throw new Error(`${this.names.createThread} returned no thread; the run surface is not writable by this user`);
|
|
92
|
+
}
|
|
93
|
+
return thread;
|
|
94
|
+
}
|
|
95
|
+
/** Open a run. See {@link CreateRunInput} on why identity is not an argument. */
|
|
96
|
+
async createRun(input) {
|
|
97
|
+
const data = await this.request(createRunDocument(this.names), {
|
|
98
|
+
input: {
|
|
99
|
+
[this.names.run]: {
|
|
100
|
+
threadId: input.threadId,
|
|
101
|
+
placement: input.placement,
|
|
102
|
+
...(input.status === undefined ? {} : { status: input.status }),
|
|
103
|
+
...(input.repoUrl === undefined ? {} : { repoUrl: input.repoUrl }),
|
|
104
|
+
...(input.branch === undefined ? {} : { branch: input.branch }),
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
const run = data[this.names.createRun]?.[this.names.run];
|
|
109
|
+
if (!run) {
|
|
110
|
+
throw new Error(`${this.names.createRun} returned no run; the run surface is not writable by this user`);
|
|
111
|
+
}
|
|
112
|
+
return run;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Move a run's lifecycle columns. The transcript is the history; this row is
|
|
116
|
+
* the current state a list view reads without replaying it.
|
|
117
|
+
*/
|
|
118
|
+
async updateRun(runId, patch) {
|
|
119
|
+
if (Object.keys(patch).length === 0) {
|
|
120
|
+
throw new Error(`run ${runId}: updateRun called with an empty patch`);
|
|
121
|
+
}
|
|
122
|
+
const data = await this.request(advanceRunDocument(this.names), {
|
|
123
|
+
input: { id: runId, [this.names.runPatch]: patch },
|
|
124
|
+
});
|
|
125
|
+
const run = data[this.names.updateRun]?.[this.names.run];
|
|
126
|
+
if (!run) {
|
|
127
|
+
throw new Error(`run ${runId} was not updated; it is not visible or not writable by this user`);
|
|
128
|
+
}
|
|
129
|
+
return run;
|
|
130
|
+
}
|
|
131
|
+
async getRun(runId) {
|
|
132
|
+
const data = await this.request(runDocument(this.names), { where: { id: { equalTo: runId } } });
|
|
133
|
+
const run = data[this.names.runs].nodes[0];
|
|
134
|
+
if (!run) {
|
|
135
|
+
throw new Error(`run ${runId} not found, or not visible to this user`);
|
|
136
|
+
}
|
|
137
|
+
return run;
|
|
138
|
+
}
|
|
139
|
+
async read(runId, cursor = START, limit) {
|
|
140
|
+
const data = await this.request(eventsDocument(this.names), {
|
|
141
|
+
first: limit ?? DEFAULT_READ_LIMIT,
|
|
142
|
+
where: {
|
|
143
|
+
runId: { equalTo: runId },
|
|
144
|
+
seq: { greaterThan: cursor.afterSeq },
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
const records = data[this.names.events].nodes.map((node) => assertRunEventRecord(node, this.readers));
|
|
148
|
+
return {
|
|
149
|
+
records,
|
|
150
|
+
cursor: {
|
|
151
|
+
afterSeq: records.length > 0
|
|
152
|
+
? records[records.length - 1].seq
|
|
153
|
+
: cursor.afterSeq,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Append entries, allocating `seq` from the run's cursor and advancing it.
|
|
159
|
+
*
|
|
160
|
+
* Two writers racing is normal here (a user sends a message while the Job
|
|
161
|
+
* appends a result), so the sequence is not reserved up front: each insert is
|
|
162
|
+
* attempted at the next free position and a rejected one re-reads the run.
|
|
163
|
+
* Entries already present by entry id are skipped, so a retried call is a
|
|
164
|
+
* no-op rather than a duplicated turn.
|
|
165
|
+
*/
|
|
166
|
+
async append(runId, entries, options) {
|
|
167
|
+
if (entries.length === 0)
|
|
168
|
+
return [];
|
|
169
|
+
const written = [];
|
|
170
|
+
let pending = [...entries];
|
|
171
|
+
for (let attempt = 0; attempt < this.maxAppendAttempts; attempt += 1) {
|
|
172
|
+
const run = await this.getRun(runId);
|
|
173
|
+
pending = await this.dropAlreadyWritten(runId, run, pending);
|
|
174
|
+
if (pending.length === 0)
|
|
175
|
+
break;
|
|
176
|
+
let seq = run.lastEventSeq;
|
|
177
|
+
try {
|
|
178
|
+
while (pending.length > 0) {
|
|
179
|
+
seq += 1;
|
|
180
|
+
written.push(await this.insertEvent(runId, seq, pending[0], options));
|
|
181
|
+
pending = pending.slice(1);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (!isSeqConflict(error))
|
|
186
|
+
throw error;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
await this.advanceRun(runId, seq);
|
|
190
|
+
return written;
|
|
191
|
+
}
|
|
192
|
+
if (pending.length > 0) {
|
|
193
|
+
throw new Error(`run ${runId}: could not append ${pending.length} entr${pending.length === 1 ? 'y' : 'ies'} after ${this.maxAppendAttempts} attempts — the run's sequence is contended`);
|
|
194
|
+
}
|
|
195
|
+
return written;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Append one pi message as an entry, chained to the run's current tail.
|
|
199
|
+
*
|
|
200
|
+
* The composer's own turn is the one place this store still writes a
|
|
201
|
+
* transcript rather than relaying one, so it writes the format it declares in
|
|
202
|
+
* `insertEvent`; a second harness contributes its own message builder beside
|
|
203
|
+
* this one and appends through the neutral `append`.
|
|
204
|
+
*/
|
|
205
|
+
async appendMessage(runId, message, options) {
|
|
206
|
+
const parentId = await this.tailEntryId(runId);
|
|
207
|
+
const entry = {
|
|
208
|
+
id: this.newId(),
|
|
209
|
+
parentId,
|
|
210
|
+
timestamp: this.now().toISOString(),
|
|
211
|
+
type: 'message',
|
|
212
|
+
message,
|
|
213
|
+
};
|
|
214
|
+
return this.append(runId, [entry], options);
|
|
215
|
+
}
|
|
216
|
+
/** The user's turn: what a run surface's composer sends. */
|
|
217
|
+
async prompt(runId, text) {
|
|
218
|
+
if (!text.trim())
|
|
219
|
+
throw new Error('cannot append an empty prompt');
|
|
220
|
+
return this.appendMessage(runId, {
|
|
221
|
+
role: 'user',
|
|
222
|
+
content: text,
|
|
223
|
+
timestamp: this.now().getTime(),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Answer a pending approval. The decision is a log entry like any other, so
|
|
228
|
+
* the Job sees it on its next cursor read and the history records who allowed
|
|
229
|
+
* what — there is no side channel to reconcile.
|
|
230
|
+
*/
|
|
231
|
+
async resolveApproval(runId, input) {
|
|
232
|
+
return this.appendMessage(runId, approvalResolutionMessage(input));
|
|
233
|
+
}
|
|
234
|
+
async insertEvent(runId, seq, entry, options) {
|
|
235
|
+
const data = await this.request(appendDocument(this.names), {
|
|
236
|
+
input: {
|
|
237
|
+
[this.names.event]: {
|
|
238
|
+
runId,
|
|
239
|
+
seq,
|
|
240
|
+
recordedAt: options?.recordedAt ?? this.now().toISOString(),
|
|
241
|
+
// A caller that read its entries out of a harness names that
|
|
242
|
+
// harness's format; only the composer path below leaves it unset,
|
|
243
|
+
// which is why the fallback is pi's.
|
|
244
|
+
transcriptFormat: options?.transcriptFormat ?? PI_TRANSCRIPT_FORMAT,
|
|
245
|
+
transcriptVersion: options?.transcriptVersion ?? SUPPORTED_PI_TRANSCRIPT_VERSION,
|
|
246
|
+
entry,
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
return assertRunEventRecord(data[this.names.createEvent][this.names.event], this.readers);
|
|
251
|
+
}
|
|
252
|
+
async advanceRun(runId, seq) {
|
|
253
|
+
await this.request(advanceRunDocument(this.names), {
|
|
254
|
+
input: { id: runId, [this.names.runPatch]: { lastEventSeq: seq } },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Skip entries a previous attempt already wrote. Only the tail is inspected —
|
|
259
|
+
* as many records as the call is trying to append — because that is where a
|
|
260
|
+
* partially applied attempt of *this* call can be, and scanning the whole run
|
|
261
|
+
* to make a retry idempotent would make every append cost the run's length.
|
|
262
|
+
*/
|
|
263
|
+
async dropAlreadyWritten(runId, run, pending) {
|
|
264
|
+
const ids = pending.map(entryId).filter((id) => !!id);
|
|
265
|
+
if (ids.length === 0 || run.lastEventSeq === 0)
|
|
266
|
+
return [...pending];
|
|
267
|
+
const window = Math.max(0, run.lastEventSeq - pending.length);
|
|
268
|
+
const page = await this.read(runId, { afterSeq: window }, pending.length);
|
|
269
|
+
const present = new Set(page.records.map((record) => entryId(record.entry)).filter(Boolean));
|
|
270
|
+
return pending.filter((entry) => {
|
|
271
|
+
const id = entryId(entry);
|
|
272
|
+
return !id || !present.has(id);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
async tailEntryId(runId) {
|
|
276
|
+
const run = await this.getRun(runId);
|
|
277
|
+
if (run.lastEventSeq === 0)
|
|
278
|
+
return null;
|
|
279
|
+
const page = await this.read(runId, { afterSeq: run.lastEventSeq - 1 }, 1);
|
|
280
|
+
const last = page.records[page.records.length - 1];
|
|
281
|
+
return last ? (entryId(last.entry) ?? null) : null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
export const createRunLogClient = (options) => new GraphqlRunLogClient(options);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The documents this lane needs, built from a module's names.
|
|
3
|
+
*
|
|
4
|
+
* Written as strings rather than generated: the run surface belongs to a
|
|
5
|
+
* tenant's schema, so there is no committed SDK target that carries it, and the
|
|
6
|
+
* fields queried here are exactly the columns the run generator emits.
|
|
7
|
+
*
|
|
8
|
+
* The shapes follow the conventions the generated targets in `sdk/` use against
|
|
9
|
+
* this platform's GraphQL: a collection takes `where: <Type>Filter` (not
|
|
10
|
+
* `filter`), there is no single-node query field — one row is a `first: 1`
|
|
11
|
+
* collection read — and an update takes `{ id, <singular>Patch }`.
|
|
12
|
+
*/
|
|
13
|
+
import type { RunLogNames } from './names';
|
|
14
|
+
export declare const RUN_FIELDS = "\n id\n threadId\n actorId\n entityId\n status\n placement\n executionId\n repoUrl\n branch\n baseCommit\n headCommit\n lastEventSeq\n attempt\n parentRunId\n error\n artifacts\n deadlineAt\n startedAt\n finishedAt\n createdAt";
|
|
15
|
+
export declare const EVENT_FIELDS = "\n runId\n seq\n recordedAt\n transcriptFormat\n transcriptVersion\n entry";
|
|
16
|
+
export declare const runsDocument: (names: RunLogNames) => string;
|
|
17
|
+
export declare const runDocument: (names: RunLogNames) => string;
|
|
18
|
+
export declare const eventsDocument: (names: RunLogNames) => string;
|
|
19
|
+
export declare const appendDocument: (names: RunLogNames) => string;
|
|
20
|
+
/**
|
|
21
|
+
* Open a run.
|
|
22
|
+
*
|
|
23
|
+
* A cloud run is opened by the worker on the platform's side of the callback
|
|
24
|
+
* lane; a local run has no worker, so the host that executes it opens its own
|
|
25
|
+
* row here — as the user, over the tenant API, which is what makes `actor_id`,
|
|
26
|
+
* `principal_id` and `entity_id` the session's claims rather than anything this
|
|
27
|
+
* client could pass. Only the columns a host legitimately chooses are sent.
|
|
28
|
+
*/
|
|
29
|
+
export declare const createRunDocument: (names: RunLogNames) => string;
|
|
30
|
+
/**
|
|
31
|
+
* Open the thread a run hangs from. `agent_run.thread_id` is required and a
|
|
32
|
+
* local host is the first writer of its own conversation, so it creates one.
|
|
33
|
+
*/
|
|
34
|
+
export declare const createThreadDocument: (names: RunLogNames) => string;
|
|
35
|
+
export declare const advanceRunDocument: (names: RunLogNames) => string;
|