@love-moon/app-sdk 0.3.2
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/CHANGELOG.md +135 -0
- package/README.md +173 -0
- package/dist/index.d.ts +299 -0
- package/dist/index.js +30 -0
- package/dist/react/index.d.ts +364 -0
- package/dist/react/index.js +814 -0
- package/dist/react/styles.css +264 -0
- package/dist/server/index.d.ts +376 -0
- package/dist/server/index.js +1387 -0
- package/examples/01_example/.env.example +17 -0
- package/examples/01_example/README.md +80 -0
- package/examples/01_example/chat-cli.mjs +125 -0
- package/examples/01_example/package-lock.json +52 -0
- package/examples/01_example/package.json +13 -0
- package/examples/02_bff/.env.example +16 -0
- package/examples/02_bff/README.md +63 -0
- package/examples/02_bff/app/api/conductor/[...path]/route.ts +277 -0
- package/examples/02_bff/app/api/conductor/bind/route.ts +45 -0
- package/examples/02_bff/app/layout.tsx +25 -0
- package/examples/02_bff/app/page.tsx +114 -0
- package/examples/02_bff/lib/conductor.ts +60 -0
- package/examples/02_bff/next.config.mjs +9 -0
- package/examples/02_bff/package-lock.json +1001 -0
- package/examples/02_bff/package.json +25 -0
- package/examples/02_bff/tsconfig.json +40 -0
- package/package.json +79 -0
|
@@ -0,0 +1,1387 @@
|
|
|
1
|
+
// src/types/errors.ts
|
|
2
|
+
var ConductorAppError = class extends Error {
|
|
3
|
+
name = "ConductorAppError";
|
|
4
|
+
code;
|
|
5
|
+
status;
|
|
6
|
+
details;
|
|
7
|
+
requestId;
|
|
8
|
+
constructor(args) {
|
|
9
|
+
super(args.message, args.cause ? { cause: args.cause } : void 0);
|
|
10
|
+
this.code = args.code;
|
|
11
|
+
this.status = args.status;
|
|
12
|
+
this.details = args.details;
|
|
13
|
+
this.requestId = args.requestId;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
function isConductorAppError(error) {
|
|
17
|
+
return typeof error === "object" && error !== null && error.name === "ConductorAppError";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/types/error-codes.ts
|
|
21
|
+
function extractErrorMarker(details, message) {
|
|
22
|
+
if (details && typeof details === "object" && "error" in details) {
|
|
23
|
+
const raw = details.error;
|
|
24
|
+
if (typeof raw === "string") return raw.toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
if (message) return message.toLowerCase();
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
function mapHttpStatusToErrorCode(status, details, message) {
|
|
30
|
+
const marker = extractErrorMarker(details, message);
|
|
31
|
+
if (status === 401) return "unauthorized";
|
|
32
|
+
if (status === 403) return "forbidden";
|
|
33
|
+
if (status === 404) {
|
|
34
|
+
if (marker.includes("project")) return "project_not_found";
|
|
35
|
+
if (marker.includes("task")) return "task_not_found";
|
|
36
|
+
if (marker.includes("message")) return "message_not_found";
|
|
37
|
+
return "task_not_found";
|
|
38
|
+
}
|
|
39
|
+
if (status === 408) return "timeout";
|
|
40
|
+
if (status === 409) {
|
|
41
|
+
if (marker.includes("fire owner")) return "task_fire_owner_offline";
|
|
42
|
+
if (marker.includes("task_type")) return "task_type_not_messageable";
|
|
43
|
+
if (marker.includes("not_running") || marker.includes("not running"))
|
|
44
|
+
return "task_not_running";
|
|
45
|
+
if (marker.includes("daemon") || marker.includes("offline")) return "daemon_offline";
|
|
46
|
+
if (marker.includes("binding")) return "binding_validation_failed";
|
|
47
|
+
return "binding_validation_failed";
|
|
48
|
+
}
|
|
49
|
+
if (status === 422) return "invalid_input";
|
|
50
|
+
if (status === 429) return "rate_limited";
|
|
51
|
+
if (status >= 500) return "server_error";
|
|
52
|
+
if (status >= 400) return "invalid_input";
|
|
53
|
+
return "server_error";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/index.ts
|
|
57
|
+
var SDK_VERSION = "0.3.2";
|
|
58
|
+
var SDK_NAME = "@love-moon/app-sdk";
|
|
59
|
+
var SDK_USER_AGENT = `conductor-app-sdk/${SDK_VERSION}`;
|
|
60
|
+
|
|
61
|
+
// src/server/fetcher.ts
|
|
62
|
+
var Fetcher = class {
|
|
63
|
+
constructor(options) {
|
|
64
|
+
this.options = options;
|
|
65
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
66
|
+
if (typeof this.fetchImpl !== "function") {
|
|
67
|
+
throw new ConductorAppError({
|
|
68
|
+
code: "invalid_input",
|
|
69
|
+
message: "No fetch implementation found. Provide options.fetch (Node \u226518 has globalThis.fetch built in)."
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
this.defaultTimeoutMs = options.timeoutMs ?? 3e4;
|
|
73
|
+
}
|
|
74
|
+
options;
|
|
75
|
+
fetchImpl;
|
|
76
|
+
defaultTimeoutMs;
|
|
77
|
+
async request(req) {
|
|
78
|
+
const url = this.buildUrl(req.path, req.query);
|
|
79
|
+
const token = await this.resolveToken();
|
|
80
|
+
const headers = {
|
|
81
|
+
Authorization: `Bearer ${token}`,
|
|
82
|
+
Accept: "application/json",
|
|
83
|
+
"User-Agent": SDK_USER_AGENT,
|
|
84
|
+
...req.headers ?? {}
|
|
85
|
+
};
|
|
86
|
+
const bodyType = req.bodyType ?? "json";
|
|
87
|
+
let body;
|
|
88
|
+
if (req.body !== void 0 && req.body !== null) {
|
|
89
|
+
if (bodyType === "json") {
|
|
90
|
+
headers["Content-Type"] ??= "application/json";
|
|
91
|
+
body = JSON.stringify(req.body);
|
|
92
|
+
} else {
|
|
93
|
+
body = req.body;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const timeoutMs = req.timeoutMs ?? this.defaultTimeoutMs;
|
|
97
|
+
const composedSignal = composeAbortSignals(req.signal, timeoutMs);
|
|
98
|
+
let response;
|
|
99
|
+
try {
|
|
100
|
+
response = await this.fetchImpl(url, {
|
|
101
|
+
method: req.method ?? "GET",
|
|
102
|
+
headers,
|
|
103
|
+
body,
|
|
104
|
+
signal: composedSignal.signal
|
|
105
|
+
});
|
|
106
|
+
} catch (cause) {
|
|
107
|
+
composedSignal.dispose();
|
|
108
|
+
if (isAbortError(cause)) {
|
|
109
|
+
throw new ConductorAppError({
|
|
110
|
+
code: composedSignal.timedOut ? "timeout" : "stream_aborted",
|
|
111
|
+
message: composedSignal.timedOut ? `Request timed out after ${timeoutMs}ms: ${req.method ?? "GET"} ${req.path}` : `Request aborted: ${req.method ?? "GET"} ${req.path}`,
|
|
112
|
+
cause
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
throw new ConductorAppError({
|
|
116
|
+
code: "network_error",
|
|
117
|
+
message: `Network error on ${req.method ?? "GET"} ${req.path}: ${cause?.message ?? String(cause)}`,
|
|
118
|
+
cause
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
composedSignal.dispose();
|
|
122
|
+
if (response.status === 401) {
|
|
123
|
+
try {
|
|
124
|
+
this.options.onUnauthorized?.();
|
|
125
|
+
} catch {
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (response.status === 404 && req.resolveOn404) {
|
|
129
|
+
return void 0;
|
|
130
|
+
}
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
const { details, message } = await readErrorBody(response);
|
|
133
|
+
const code = mapHttpStatusToErrorCode(response.status, details, message);
|
|
134
|
+
throw new ConductorAppError({
|
|
135
|
+
code,
|
|
136
|
+
status: response.status,
|
|
137
|
+
message: message || `Conductor returned ${response.status} on ${req.method ?? "GET"} ${req.path}`,
|
|
138
|
+
details,
|
|
139
|
+
requestId: response.headers.get("x-request-id") ?? void 0
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (response.status === 204) {
|
|
143
|
+
return void 0;
|
|
144
|
+
}
|
|
145
|
+
const text = await response.text();
|
|
146
|
+
if (!text) {
|
|
147
|
+
return void 0;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
return JSON.parse(text);
|
|
151
|
+
} catch (cause) {
|
|
152
|
+
throw new ConductorAppError({
|
|
153
|
+
code: "server_error",
|
|
154
|
+
message: `Conductor returned non-JSON body on ${req.method ?? "GET"} ${req.path}`,
|
|
155
|
+
details: { rawBody: text.slice(0, 1024) },
|
|
156
|
+
cause
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
buildUrl(path, query) {
|
|
161
|
+
const base = this.options.baseUrl.replace(/\/+$/, "");
|
|
162
|
+
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
|
163
|
+
const url = `${base}${cleanPath}`;
|
|
164
|
+
if (!query) return url;
|
|
165
|
+
const params = new URLSearchParams();
|
|
166
|
+
for (const [key, value] of Object.entries(query)) {
|
|
167
|
+
if (value === void 0 || value === null) continue;
|
|
168
|
+
params.append(key, String(value));
|
|
169
|
+
}
|
|
170
|
+
const queryString = params.toString();
|
|
171
|
+
return queryString ? `${url}?${queryString}` : url;
|
|
172
|
+
}
|
|
173
|
+
async resolveToken() {
|
|
174
|
+
const { bearerToken } = this.options;
|
|
175
|
+
if (typeof bearerToken === "string") return bearerToken;
|
|
176
|
+
const resolved = await bearerToken();
|
|
177
|
+
if (!resolved) {
|
|
178
|
+
throw new ConductorAppError({
|
|
179
|
+
code: "unauthorized",
|
|
180
|
+
message: "bearerToken provider returned empty string"
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return resolved;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
function composeAbortSignals(external, timeoutMs) {
|
|
187
|
+
const controller = new AbortController();
|
|
188
|
+
let timedOut = false;
|
|
189
|
+
let timer = null;
|
|
190
|
+
if (timeoutMs > 0) {
|
|
191
|
+
timer = setTimeout(() => {
|
|
192
|
+
timedOut = true;
|
|
193
|
+
controller.abort();
|
|
194
|
+
}, timeoutMs);
|
|
195
|
+
}
|
|
196
|
+
const onExternalAbort = () => controller.abort();
|
|
197
|
+
if (external) {
|
|
198
|
+
if (external.aborted) {
|
|
199
|
+
controller.abort();
|
|
200
|
+
} else {
|
|
201
|
+
external.addEventListener("abort", onExternalAbort);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const dispose = () => {
|
|
205
|
+
if (timer) clearTimeout(timer);
|
|
206
|
+
if (external) external.removeEventListener("abort", onExternalAbort);
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
signal: controller.signal,
|
|
210
|
+
dispose,
|
|
211
|
+
get timedOut() {
|
|
212
|
+
return timedOut;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function isAbortError(error) {
|
|
217
|
+
if (typeof error !== "object" || error === null) return false;
|
|
218
|
+
const name = error.name;
|
|
219
|
+
return name === "AbortError";
|
|
220
|
+
}
|
|
221
|
+
async function readErrorBody(response) {
|
|
222
|
+
const text = await response.text().catch(() => "");
|
|
223
|
+
if (!text) return { details: null, message: null };
|
|
224
|
+
try {
|
|
225
|
+
const parsed = JSON.parse(text);
|
|
226
|
+
const message = typeof parsed.error === "string" ? parsed.error : typeof parsed.message === "string" ? parsed.message : null;
|
|
227
|
+
return { details: parsed, message };
|
|
228
|
+
} catch {
|
|
229
|
+
return { details: { rawBody: text.slice(0, 1024) }, message: null };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/server/normalize.ts
|
|
234
|
+
var str = (value) => {
|
|
235
|
+
if (typeof value !== "string") return null;
|
|
236
|
+
const trimmed = value.trim();
|
|
237
|
+
return trimmed || null;
|
|
238
|
+
};
|
|
239
|
+
var num = (value) => {
|
|
240
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
241
|
+
return value;
|
|
242
|
+
};
|
|
243
|
+
var bool = (value) => Boolean(value);
|
|
244
|
+
var pick = (raw, ...keys) => {
|
|
245
|
+
for (const key of keys) {
|
|
246
|
+
if (Object.prototype.hasOwnProperty.call(raw, key)) return raw[key];
|
|
247
|
+
}
|
|
248
|
+
return void 0;
|
|
249
|
+
};
|
|
250
|
+
var isoDate = (value) => {
|
|
251
|
+
if (typeof value === "string") return value;
|
|
252
|
+
if (value instanceof Date) return value.toISOString();
|
|
253
|
+
return (/* @__PURE__ */ new Date(0)).toISOString();
|
|
254
|
+
};
|
|
255
|
+
function normalizeProject(raw) {
|
|
256
|
+
const r = raw ?? {};
|
|
257
|
+
const metadata = pick(r, "metadata") ?? null;
|
|
258
|
+
const ownership = metadata && typeof metadata === "object" ? metadata.ownership : void 0;
|
|
259
|
+
const audit = metadata && typeof metadata === "object" ? metadata.audit : void 0;
|
|
260
|
+
const createdByApp = Boolean(ownership && ownership.kind === "app") || Boolean(audit && audit.createdByApp);
|
|
261
|
+
return {
|
|
262
|
+
id: str(pick(r, "id")) ?? "",
|
|
263
|
+
name: str(pick(r, "name")) ?? "",
|
|
264
|
+
daemonHost: str(pick(r, "daemonHost", "daemon_host")),
|
|
265
|
+
workspacePath: str(pick(r, "workspacePath", "workspace_path")),
|
|
266
|
+
repoRoot: str(pick(r, "repoRoot", "repo_root")),
|
|
267
|
+
worktreeBranch: str(pick(r, "worktreeBranch", "worktree_branch")),
|
|
268
|
+
lastCommit: str(pick(r, "lastCommit", "last_commit")),
|
|
269
|
+
lastCommitAt: str(pick(r, "lastCommitAt", "last_commit_at")),
|
|
270
|
+
fileCount: num(pick(r, "fileCount", "file_count")),
|
|
271
|
+
isDefault: bool(pick(r, "isDefault", "is_default")),
|
|
272
|
+
createdByApp,
|
|
273
|
+
createdAt: isoDate(pick(r, "createdAt", "created_at")),
|
|
274
|
+
updatedAt: isoDate(pick(r, "updatedAt", "updated_at"))
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function normalizeTask(raw) {
|
|
278
|
+
const r = raw ?? {};
|
|
279
|
+
return {
|
|
280
|
+
id: str(pick(r, "id")) ?? "",
|
|
281
|
+
projectId: str(pick(r, "projectId", "project_id")) ?? "",
|
|
282
|
+
title: str(pick(r, "title")) ?? "",
|
|
283
|
+
status: str(pick(r, "status")) ?? "pending",
|
|
284
|
+
backendType: str(pick(r, "backendType", "backend_type")),
|
|
285
|
+
sessionId: str(pick(r, "sessionId", "session_id")),
|
|
286
|
+
sessionFilePath: str(pick(r, "sessionFilePath", "session_file_path")),
|
|
287
|
+
createdAt: isoDate(pick(r, "createdAt", "created_at")),
|
|
288
|
+
updatedAt: isoDate(pick(r, "updatedAt", "updated_at"))
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function normalizeMessage(raw) {
|
|
292
|
+
const r = raw ?? {};
|
|
293
|
+
const metadata = pick(r, "metadata");
|
|
294
|
+
const rawAttachments = pick(r, "attachments");
|
|
295
|
+
const attachments = Array.isArray(rawAttachments) ? rawAttachments.map((a) => normalizeAttachment(a)) : [];
|
|
296
|
+
return {
|
|
297
|
+
id: str(pick(r, "id")) ?? "",
|
|
298
|
+
taskId: str(pick(r, "taskId", "task_id")) ?? "",
|
|
299
|
+
role: str(pick(r, "role")) ?? "sdk",
|
|
300
|
+
content: typeof r.content === "string" ? r.content : "",
|
|
301
|
+
metadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : null,
|
|
302
|
+
attachments,
|
|
303
|
+
createdAt: isoDate(pick(r, "createdAt", "created_at"))
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function normalizeAttachment(raw) {
|
|
307
|
+
const r = raw ?? {};
|
|
308
|
+
return {
|
|
309
|
+
id: str(pick(r, "id")) ?? "",
|
|
310
|
+
filename: str(pick(r, "filename", "name")) ?? "",
|
|
311
|
+
mimeType: str(pick(r, "mimeType", "mime_type", "contentType", "content_type")) ?? "application/octet-stream",
|
|
312
|
+
sizeBytes: num(pick(r, "sizeBytes", "size_bytes", "size")) ?? 0,
|
|
313
|
+
url: str(pick(r, "url")) ?? ""
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/server/http/projects.ts
|
|
318
|
+
var ProjectsApi = class {
|
|
319
|
+
/** @internal — constructed by `AppClient`; not part of the public surface. */
|
|
320
|
+
constructor(fetcher) {
|
|
321
|
+
this.fetcher = fetcher;
|
|
322
|
+
}
|
|
323
|
+
fetcher;
|
|
324
|
+
/**
|
|
325
|
+
* Idempotent find-or-create on (daemonHost, workspacePath).
|
|
326
|
+
*
|
|
327
|
+
* Flow:
|
|
328
|
+
* 1. POST `/api/projects/match-path` with `{ daemonHost, path: workspacePath }`.
|
|
329
|
+
* If a project matches (or is a parent of) the path, return it.
|
|
330
|
+
* 2. Otherwise POST `/api/projects` with binding fields. The server
|
|
331
|
+
* validates the binding with the user's daemon. Success returns the
|
|
332
|
+
* new project; daemon-offline returns 409 → mapped to `daemon_offline`.
|
|
333
|
+
*
|
|
334
|
+
* The created project carries `metadata.audit.createdByApp` so it can be
|
|
335
|
+
* distinguished in the main Conductor UI later. No new schema is required.
|
|
336
|
+
*/
|
|
337
|
+
async bind(input, opts) {
|
|
338
|
+
const name = input.name?.trim();
|
|
339
|
+
const daemonHost = input.daemonHost?.trim();
|
|
340
|
+
const workspacePath = input.workspacePath?.trim();
|
|
341
|
+
if (!name || !daemonHost || !workspacePath) {
|
|
342
|
+
throw new ConductorAppError({
|
|
343
|
+
code: "invalid_input",
|
|
344
|
+
message: "projects.bind requires non-empty name, daemonHost, and workspacePath"
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
const match = await this.fetcher.request({
|
|
348
|
+
method: "POST",
|
|
349
|
+
path: "/api/projects/match-path",
|
|
350
|
+
body: { daemonHost, path: workspacePath },
|
|
351
|
+
signal: opts?.signal
|
|
352
|
+
});
|
|
353
|
+
if (match?.project) {
|
|
354
|
+
return normalizeProject(match.project);
|
|
355
|
+
}
|
|
356
|
+
const created = await this.fetcher.request({
|
|
357
|
+
method: "POST",
|
|
358
|
+
path: "/api/projects",
|
|
359
|
+
body: {
|
|
360
|
+
name,
|
|
361
|
+
daemonHost,
|
|
362
|
+
workspacePath,
|
|
363
|
+
metadata: {
|
|
364
|
+
audit: {
|
|
365
|
+
createdByApp: {
|
|
366
|
+
name: input.appLabel ?? name,
|
|
367
|
+
sdkName: SDK_NAME,
|
|
368
|
+
sdkVersion: SDK_VERSION
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
signal: opts?.signal
|
|
374
|
+
});
|
|
375
|
+
return normalizeProject(created);
|
|
376
|
+
}
|
|
377
|
+
async list(opts) {
|
|
378
|
+
const raw = await this.fetcher.request({
|
|
379
|
+
method: "GET",
|
|
380
|
+
path: "/api/projects",
|
|
381
|
+
signal: opts?.signal
|
|
382
|
+
});
|
|
383
|
+
const list = Array.isArray(raw) ? raw : Array.isArray(raw.projects) ? raw.projects : [];
|
|
384
|
+
return list.map((entry) => normalizeProject(entry));
|
|
385
|
+
}
|
|
386
|
+
async get(projectId, opts) {
|
|
387
|
+
if (!projectId) {
|
|
388
|
+
throw new ConductorAppError({
|
|
389
|
+
code: "invalid_input",
|
|
390
|
+
message: "projects.get requires a projectId"
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
const raw = await this.fetcher.request({
|
|
394
|
+
method: "GET",
|
|
395
|
+
path: "/api/projects",
|
|
396
|
+
query: { projectId },
|
|
397
|
+
signal: opts?.signal
|
|
398
|
+
});
|
|
399
|
+
if (raw && typeof raw === "object" && "projects" in raw) {
|
|
400
|
+
const list = raw.projects ?? [];
|
|
401
|
+
if (list.length === 0) {
|
|
402
|
+
throw new ConductorAppError({
|
|
403
|
+
code: "project_not_found",
|
|
404
|
+
message: `Project ${projectId} not found`
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return normalizeProject(list[0]);
|
|
408
|
+
}
|
|
409
|
+
return normalizeProject(raw);
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// src/server/id.ts
|
|
414
|
+
function generateRequestId() {
|
|
415
|
+
const c = globalThis.crypto;
|
|
416
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
417
|
+
if (c?.getRandomValues) {
|
|
418
|
+
const bytes = new Uint8Array(16);
|
|
419
|
+
c.getRandomValues(bytes);
|
|
420
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
421
|
+
}
|
|
422
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/server/http/tasks.ts
|
|
426
|
+
var TasksRestApi = class {
|
|
427
|
+
constructor(fetcher) {
|
|
428
|
+
this.fetcher = fetcher;
|
|
429
|
+
}
|
|
430
|
+
fetcher;
|
|
431
|
+
async create(input, opts) {
|
|
432
|
+
if (!input.projectId) {
|
|
433
|
+
throw new ConductorAppError({
|
|
434
|
+
code: "invalid_input",
|
|
435
|
+
message: "tasks.create requires projectId"
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
const trimmedTitle = input.title?.trim() ?? "";
|
|
439
|
+
if (!trimmedTitle) {
|
|
440
|
+
throw new ConductorAppError({
|
|
441
|
+
code: "invalid_input",
|
|
442
|
+
message: "tasks.create requires title"
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
const raw = await this.fetcher.request({
|
|
446
|
+
method: "POST",
|
|
447
|
+
path: "/api/tasks",
|
|
448
|
+
body: {
|
|
449
|
+
project_id: input.projectId,
|
|
450
|
+
title: trimmedTitle,
|
|
451
|
+
...input.initialMessage ? { initial_content: input.initialMessage } : {},
|
|
452
|
+
...input.backendType ? { backend_type: input.backendType } : {},
|
|
453
|
+
...input.metadata ? { metadata: input.metadata } : {},
|
|
454
|
+
task_type: "ai_task"
|
|
455
|
+
},
|
|
456
|
+
signal: opts?.signal
|
|
457
|
+
});
|
|
458
|
+
return normalizeTask(raw);
|
|
459
|
+
}
|
|
460
|
+
async get(taskId, opts) {
|
|
461
|
+
if (!taskId) {
|
|
462
|
+
throw new ConductorAppError({
|
|
463
|
+
code: "invalid_input",
|
|
464
|
+
message: "tasks.get requires a taskId"
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
const raw = await this.fetcher.request({
|
|
468
|
+
method: "GET",
|
|
469
|
+
path: `/api/tasks/${encodeURIComponent(taskId)}`,
|
|
470
|
+
signal: opts?.signal
|
|
471
|
+
});
|
|
472
|
+
return normalizeTask(raw);
|
|
473
|
+
}
|
|
474
|
+
async list(filter = {}, opts) {
|
|
475
|
+
const raw = await this.fetcher.request({
|
|
476
|
+
method: "GET",
|
|
477
|
+
path: "/api/tasks",
|
|
478
|
+
query: {
|
|
479
|
+
...filter.projectId ? { project_id: filter.projectId } : {},
|
|
480
|
+
...filter.status ? { status: filter.status } : {}
|
|
481
|
+
},
|
|
482
|
+
signal: opts?.signal
|
|
483
|
+
});
|
|
484
|
+
const list = Array.isArray(raw) ? raw : Array.isArray(raw.tasks) ? raw.tasks : [];
|
|
485
|
+
return list.map((entry) => normalizeTask(entry));
|
|
486
|
+
}
|
|
487
|
+
async sendMessage(taskId, input, opts) {
|
|
488
|
+
if (!taskId) {
|
|
489
|
+
throw new ConductorAppError({
|
|
490
|
+
code: "invalid_input",
|
|
491
|
+
message: "tasks.sendMessage requires a taskId"
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
const normalized = typeof input === "string" ? { content: input } : input;
|
|
495
|
+
if (!normalized.content || !normalized.content.trim()) {
|
|
496
|
+
throw new ConductorAppError({
|
|
497
|
+
code: "invalid_input",
|
|
498
|
+
message: "tasks.sendMessage requires non-empty content"
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
const clientRequestId = normalized.clientRequestId ?? generateRequestId();
|
|
502
|
+
const raw = await this.fetcher.request({
|
|
503
|
+
method: "POST",
|
|
504
|
+
path: `/api/tasks/${encodeURIComponent(taskId)}/messages`,
|
|
505
|
+
body: {
|
|
506
|
+
content: normalized.content,
|
|
507
|
+
role: normalized.role ?? "sdk",
|
|
508
|
+
clientRequestId,
|
|
509
|
+
metadata: buildOutboundMetadata(normalized.metadata)
|
|
510
|
+
},
|
|
511
|
+
signal: opts?.signal
|
|
512
|
+
});
|
|
513
|
+
return normalizeMessage(raw);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Paginated history of messages for a task. Returns newest page first
|
|
517
|
+
* (most recent messages have largest createdAt).
|
|
518
|
+
*/
|
|
519
|
+
async history(taskId, paging = {}, opts) {
|
|
520
|
+
if (!taskId) {
|
|
521
|
+
throw new ConductorAppError({
|
|
522
|
+
code: "invalid_input",
|
|
523
|
+
message: "tasks.history requires a taskId"
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
const raw = await this.fetcher.request({
|
|
527
|
+
method: "GET",
|
|
528
|
+
path: `/api/tasks/${encodeURIComponent(taskId)}/messages`,
|
|
529
|
+
query: {
|
|
530
|
+
pagination: "1",
|
|
531
|
+
...paging.beforeId ? { before_id: paging.beforeId } : {},
|
|
532
|
+
...paging.limit ? { limit: String(paging.limit) } : {}
|
|
533
|
+
},
|
|
534
|
+
signal: opts?.signal
|
|
535
|
+
});
|
|
536
|
+
if (!raw || typeof raw !== "object") {
|
|
537
|
+
return { messages: [], hasMoreBefore: false, oldestMessageId: null };
|
|
538
|
+
}
|
|
539
|
+
const messages = Array.isArray(raw.messages) ? raw.messages.map((entry) => normalizeMessage(entry)) : [];
|
|
540
|
+
const pagination = raw.pagination ?? {};
|
|
541
|
+
return {
|
|
542
|
+
messages,
|
|
543
|
+
hasMoreBefore: Boolean(pagination.has_more_before),
|
|
544
|
+
oldestMessageId: typeof pagination.oldest_message_id === "string" ? pagination.oldest_message_id : null
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
async interrupt(taskId, opts) {
|
|
548
|
+
if (!taskId) {
|
|
549
|
+
throw new ConductorAppError({
|
|
550
|
+
code: "invalid_input",
|
|
551
|
+
message: "tasks.interrupt requires a taskId"
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
if (!opts.targetReplyTo) {
|
|
555
|
+
throw new ConductorAppError({
|
|
556
|
+
code: "invalid_input",
|
|
557
|
+
message: "tasks.interrupt requires targetReplyTo"
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
await this.fetcher.request({
|
|
561
|
+
method: "POST",
|
|
562
|
+
path: `/api/tasks/${encodeURIComponent(taskId)}/interrupt`,
|
|
563
|
+
body: { target_reply_to: opts.targetReplyTo },
|
|
564
|
+
signal: opts.signal
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
function buildOutboundMetadata(callerMetadata) {
|
|
569
|
+
const base = callerMetadata && typeof callerMetadata === "object" ? { ...callerMetadata } : {};
|
|
570
|
+
const callerAudit = base.audit && typeof base.audit === "object" && !Array.isArray(base.audit) ? { ...base.audit } : {};
|
|
571
|
+
delete callerAudit.actor;
|
|
572
|
+
delete callerAudit.sdkName;
|
|
573
|
+
delete callerAudit.sdkVersion;
|
|
574
|
+
return {
|
|
575
|
+
...base,
|
|
576
|
+
audit: {
|
|
577
|
+
// Caller-supplied audit fields first…
|
|
578
|
+
...callerAudit,
|
|
579
|
+
// …then SDK fields LAST so they always win.
|
|
580
|
+
actor: "app",
|
|
581
|
+
sdkName: SDK_NAME,
|
|
582
|
+
sdkVersion: SDK_VERSION
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// src/server/ws/socket.ts
|
|
588
|
+
import WebSocket from "ws";
|
|
589
|
+
var AppWebSocket = class {
|
|
590
|
+
constructor(options) {
|
|
591
|
+
this.options = options;
|
|
592
|
+
}
|
|
593
|
+
options;
|
|
594
|
+
socket = null;
|
|
595
|
+
reconnectAttempts = 0;
|
|
596
|
+
closed = false;
|
|
597
|
+
reconnectTimer = null;
|
|
598
|
+
rawListeners = /* @__PURE__ */ new Set();
|
|
599
|
+
stateListeners = /* @__PURE__ */ new Set();
|
|
600
|
+
closeListeners = /* @__PURE__ */ new Set();
|
|
601
|
+
currentState = "offline";
|
|
602
|
+
/** Resolved when at least one connection has opened successfully. */
|
|
603
|
+
readyPromise = null;
|
|
604
|
+
readyResolver = null;
|
|
605
|
+
readyRejector = null;
|
|
606
|
+
/** Tracks whether we've ever opened a connection — gates state replay. */
|
|
607
|
+
hasEverConnected = false;
|
|
608
|
+
onEnvelope(listener) {
|
|
609
|
+
this.rawListeners.add(listener);
|
|
610
|
+
return {
|
|
611
|
+
unsubscribe: () => {
|
|
612
|
+
this.rawListeners.delete(listener);
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Subscribe to the terminal "client closed" event. Fires exactly once when
|
|
618
|
+
* `close()` is invoked (or zero times if the socket is GC'd without close).
|
|
619
|
+
*
|
|
620
|
+
* Iterators that have already passed `connect()` need this signal — the
|
|
621
|
+
* regular `rawListeners` are cleared at close time, so the inner
|
|
622
|
+
* `subscribeToTask` loop would otherwise wedge waiting on `next()`. Late
|
|
623
|
+
* subscribers (after `close()` has run) get fired synchronously so they
|
|
624
|
+
* can't miss the signal.
|
|
625
|
+
*
|
|
626
|
+
* Returns an unsubscribe callback. Idempotent.
|
|
627
|
+
*/
|
|
628
|
+
onClose(listener) {
|
|
629
|
+
if (this.closed) {
|
|
630
|
+
try {
|
|
631
|
+
listener();
|
|
632
|
+
} catch {
|
|
633
|
+
}
|
|
634
|
+
return () => void 0;
|
|
635
|
+
}
|
|
636
|
+
this.closeListeners.add(listener);
|
|
637
|
+
return () => {
|
|
638
|
+
this.closeListeners.delete(listener);
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
onConnectionState(listener) {
|
|
642
|
+
if (this.hasEverConnected || this.currentState !== "offline") {
|
|
643
|
+
try {
|
|
644
|
+
listener(this.currentState);
|
|
645
|
+
} catch {
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
this.stateListeners.add(listener);
|
|
649
|
+
return {
|
|
650
|
+
unsubscribe: () => {
|
|
651
|
+
this.stateListeners.delete(listener);
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Open the connection (idempotent). Returns when the first OPEN event fires.
|
|
657
|
+
*
|
|
658
|
+
* The returned promise rejects only if the *initial* connection attempt
|
|
659
|
+
* fails (e.g. the token provider throws). Once we've ever connected, the
|
|
660
|
+
* promise resolves and subsequent disconnects are handled by the
|
|
661
|
+
* auto-reconnect loop (visible via `onConnectionState`).
|
|
662
|
+
*/
|
|
663
|
+
async connect() {
|
|
664
|
+
if (this.closed) {
|
|
665
|
+
throw new ConductorAppError({
|
|
666
|
+
code: "subscribe_failed",
|
|
667
|
+
message: "AppWebSocket was closed; create a new one."
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
if (this.socket && this.socket.readyState === WebSocket.OPEN) return;
|
|
671
|
+
if (this.readyPromise) return this.readyPromise;
|
|
672
|
+
const readyPromise = new Promise((resolve, reject) => {
|
|
673
|
+
this.readyResolver = resolve;
|
|
674
|
+
this.readyRejector = reject;
|
|
675
|
+
});
|
|
676
|
+
readyPromise.catch(() => void 0);
|
|
677
|
+
this.readyPromise = readyPromise;
|
|
678
|
+
try {
|
|
679
|
+
await this.openOnce();
|
|
680
|
+
} catch (cause) {
|
|
681
|
+
const rejector = this.readyRejector;
|
|
682
|
+
this.readyPromise = null;
|
|
683
|
+
this.readyResolver = null;
|
|
684
|
+
this.readyRejector = null;
|
|
685
|
+
const error = cause instanceof ConductorAppError ? cause : new ConductorAppError({
|
|
686
|
+
code: "subscribe_failed",
|
|
687
|
+
message: `Failed to open WebSocket: ${cause?.message ?? String(cause)}`,
|
|
688
|
+
cause
|
|
689
|
+
});
|
|
690
|
+
if (rejector) rejector(error);
|
|
691
|
+
throw error;
|
|
692
|
+
}
|
|
693
|
+
return readyPromise;
|
|
694
|
+
}
|
|
695
|
+
close() {
|
|
696
|
+
if (this.closed) return;
|
|
697
|
+
const pendingRejector = this.readyRejector;
|
|
698
|
+
const closeListenersSnapshot = Array.from(this.closeListeners);
|
|
699
|
+
this.closed = true;
|
|
700
|
+
this.rawListeners.clear();
|
|
701
|
+
this.stateListeners.clear();
|
|
702
|
+
this.closeListeners.clear();
|
|
703
|
+
if (this.reconnectTimer) {
|
|
704
|
+
clearTimeout(this.reconnectTimer);
|
|
705
|
+
this.reconnectTimer = null;
|
|
706
|
+
}
|
|
707
|
+
if (this.socket) {
|
|
708
|
+
try {
|
|
709
|
+
this.socket.close();
|
|
710
|
+
} catch {
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
this.socket = null;
|
|
714
|
+
this.readyPromise = null;
|
|
715
|
+
this.readyResolver = null;
|
|
716
|
+
this.readyRejector = null;
|
|
717
|
+
this.setState("offline");
|
|
718
|
+
if (pendingRejector) {
|
|
719
|
+
pendingRejector(
|
|
720
|
+
new ConductorAppError({
|
|
721
|
+
code: "subscribe_failed",
|
|
722
|
+
message: "client closed before connect resolved"
|
|
723
|
+
})
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
for (const listener of closeListenersSnapshot) {
|
|
727
|
+
try {
|
|
728
|
+
listener();
|
|
729
|
+
} catch {
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
async openOnce() {
|
|
734
|
+
const url = await this.buildUrl();
|
|
735
|
+
const Ctor = this.options.webSocketImpl ?? WebSocket;
|
|
736
|
+
const socket = new Ctor(url);
|
|
737
|
+
this.socket = socket;
|
|
738
|
+
socket.addEventListener("open", this.handleOpen);
|
|
739
|
+
socket.addEventListener("message", this.handleMessage);
|
|
740
|
+
socket.addEventListener("close", this.handleClose);
|
|
741
|
+
socket.addEventListener("error", this.handleError);
|
|
742
|
+
}
|
|
743
|
+
handleOpen = () => {
|
|
744
|
+
this.reconnectAttempts = 0;
|
|
745
|
+
this.hasEverConnected = true;
|
|
746
|
+
this.setState("connected");
|
|
747
|
+
if (this.readyResolver) {
|
|
748
|
+
this.readyResolver();
|
|
749
|
+
this.readyResolver = null;
|
|
750
|
+
this.readyRejector = null;
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
handleMessage = (event) => {
|
|
754
|
+
const data = event.data;
|
|
755
|
+
let text;
|
|
756
|
+
if (typeof data === "string") {
|
|
757
|
+
text = data;
|
|
758
|
+
} else if (data instanceof Buffer) {
|
|
759
|
+
text = data.toString("utf8");
|
|
760
|
+
} else if (data instanceof ArrayBuffer) {
|
|
761
|
+
text = new TextDecoder().decode(data);
|
|
762
|
+
} else {
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
let envelope;
|
|
766
|
+
try {
|
|
767
|
+
envelope = JSON.parse(text);
|
|
768
|
+
} catch {
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
for (const listener of this.rawListeners) {
|
|
772
|
+
try {
|
|
773
|
+
listener(envelope);
|
|
774
|
+
} catch {
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
handleClose = () => {
|
|
779
|
+
this.socket = null;
|
|
780
|
+
if (this.closed) return;
|
|
781
|
+
const pendingRejector = this.readyRejector;
|
|
782
|
+
this.readyPromise = null;
|
|
783
|
+
this.readyResolver = null;
|
|
784
|
+
this.readyRejector = null;
|
|
785
|
+
if (pendingRejector) {
|
|
786
|
+
pendingRejector(
|
|
787
|
+
new ConductorAppError({
|
|
788
|
+
code: "subscribe_failed",
|
|
789
|
+
message: "socket closed before open"
|
|
790
|
+
})
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
this.scheduleReconnect();
|
|
794
|
+
};
|
|
795
|
+
handleError = () => {
|
|
796
|
+
};
|
|
797
|
+
scheduleReconnect() {
|
|
798
|
+
const max = this.options.maxReconnects ?? Number.POSITIVE_INFINITY;
|
|
799
|
+
if (this.reconnectAttempts >= max) {
|
|
800
|
+
this.setState("offline");
|
|
801
|
+
this.closed = true;
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
this.setState("reconnecting");
|
|
805
|
+
const backoff = computeBackoff(
|
|
806
|
+
this.reconnectAttempts,
|
|
807
|
+
this.options.initialBackoffMs ?? 250,
|
|
808
|
+
this.options.maxBackoffMs ?? 3e4
|
|
809
|
+
);
|
|
810
|
+
this.reconnectAttempts += 1;
|
|
811
|
+
this.reconnectTimer = setTimeout(() => {
|
|
812
|
+
this.reconnectTimer = null;
|
|
813
|
+
if (this.closed) return;
|
|
814
|
+
void this.openOnce().catch(() => this.scheduleReconnect());
|
|
815
|
+
}, backoff);
|
|
816
|
+
}
|
|
817
|
+
setState(state) {
|
|
818
|
+
if (this.currentState === state) return;
|
|
819
|
+
this.currentState = state;
|
|
820
|
+
for (const listener of this.stateListeners) {
|
|
821
|
+
try {
|
|
822
|
+
listener(state);
|
|
823
|
+
} catch {
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
async buildUrl() {
|
|
828
|
+
const token = typeof this.options.bearerToken === "string" ? this.options.bearerToken : await this.options.bearerToken();
|
|
829
|
+
const parsed = new URL(this.options.baseUrl);
|
|
830
|
+
parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:";
|
|
831
|
+
const pathname = parsed.pathname.replace(/\/+$/, "");
|
|
832
|
+
parsed.pathname = `${pathname}/ws/app`;
|
|
833
|
+
parsed.search = `?token=${encodeURIComponent(token)}`;
|
|
834
|
+
return parsed.toString();
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
function computeBackoff(attempt, initial, max) {
|
|
838
|
+
const exp = Math.min(initial * 2 ** attempt, max);
|
|
839
|
+
const jittered = Math.floor(Math.random() * exp);
|
|
840
|
+
const floor = Math.floor(initial / 2);
|
|
841
|
+
return Math.max(jittered, floor);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// src/server/ws/envelope.ts
|
|
845
|
+
function envelopeToChatEvent(raw, filterTaskId) {
|
|
846
|
+
if (!raw || typeof raw !== "object") return null;
|
|
847
|
+
const envelope = raw;
|
|
848
|
+
const type = envelope.type;
|
|
849
|
+
const payload = envelope.payload ?? {};
|
|
850
|
+
const taskId = readString(payload.task_id ?? payload.taskId);
|
|
851
|
+
if (filterTaskId && taskId !== filterTaskId) {
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
switch (type) {
|
|
855
|
+
case "task_user_message":
|
|
856
|
+
case "task_sdk_message": {
|
|
857
|
+
const message = normalizeMessage({ ...payload, taskId });
|
|
858
|
+
if (!message.id) return null;
|
|
859
|
+
return { type: "message_appended", message };
|
|
860
|
+
}
|
|
861
|
+
case "task_status_update": {
|
|
862
|
+
if (!taskId) return null;
|
|
863
|
+
const status = readString(payload.status);
|
|
864
|
+
if (status === "finished" || status === "completed" || status === "done") {
|
|
865
|
+
return { type: "task_finished", taskId };
|
|
866
|
+
}
|
|
867
|
+
if (status === "failed" || status === "errored") {
|
|
868
|
+
return {
|
|
869
|
+
type: "task_failed",
|
|
870
|
+
taskId,
|
|
871
|
+
error: {
|
|
872
|
+
code: "task_failed",
|
|
873
|
+
message: readString(payload.summary) ?? "Task failed"
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
case "task_runtime_status": {
|
|
880
|
+
if (!taskId) return null;
|
|
881
|
+
const status = {
|
|
882
|
+
taskId,
|
|
883
|
+
state: readString(payload.state) ?? "idle",
|
|
884
|
+
phase: readString(payload.phase),
|
|
885
|
+
source: readString(payload.source),
|
|
886
|
+
statusLine: readString(payload.status_line ?? payload.statusLine),
|
|
887
|
+
statusDoneLine: readString(payload.status_done_line ?? payload.statusDoneLine),
|
|
888
|
+
replyPreview: readString(payload.reply_preview ?? payload.replyPreview),
|
|
889
|
+
replyTo: readString(payload.reply_to ?? payload.replyTo),
|
|
890
|
+
replyInProgress: readBool(payload.reply_in_progress ?? payload.replyInProgress),
|
|
891
|
+
backend: readString(payload.backend),
|
|
892
|
+
threadId: readString(payload.thread_id ?? payload.threadId),
|
|
893
|
+
daemon: readString(payload.daemon),
|
|
894
|
+
pid: readNumber(payload.pid),
|
|
895
|
+
sessionId: readString(payload.session_id ?? payload.sessionId),
|
|
896
|
+
sessionFilePath: readString(payload.session_file_path ?? payload.sessionFilePath),
|
|
897
|
+
tokenUsagePercent: readNumber(payload.token_usage_percent ?? payload.tokenUsagePercent),
|
|
898
|
+
contextUsagePercent: readNumber(payload.context_usage_percent ?? payload.contextUsagePercent),
|
|
899
|
+
createdAt: readString(payload.created_at ?? payload.createdAt)
|
|
900
|
+
};
|
|
901
|
+
return { type: "runtime_status", status };
|
|
902
|
+
}
|
|
903
|
+
default:
|
|
904
|
+
if (typeof process !== "undefined" && process.env && process.env.NODE_ENV === "development") {
|
|
905
|
+
console.warn(
|
|
906
|
+
`[app-sdk] envelopeToChatEvent: unknown envelope type "${type ?? ""}" (dropped)`
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
function readString(value) {
|
|
913
|
+
if (typeof value !== "string") return null;
|
|
914
|
+
const trimmed = value.trim();
|
|
915
|
+
return trimmed || null;
|
|
916
|
+
}
|
|
917
|
+
function readNumber(value) {
|
|
918
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
919
|
+
return value;
|
|
920
|
+
}
|
|
921
|
+
function readBool(value) {
|
|
922
|
+
if (typeof value === "boolean") return value;
|
|
923
|
+
return void 0;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// src/server/tasks/subscribe.ts
|
|
927
|
+
function subscribeToTask(socket, taskId, opts) {
|
|
928
|
+
const bufferCap = opts?.bufferCap ?? 1024;
|
|
929
|
+
return {
|
|
930
|
+
[Symbol.asyncIterator]() {
|
|
931
|
+
const queue = [];
|
|
932
|
+
let pendingResolver = null;
|
|
933
|
+
let done = false;
|
|
934
|
+
const drain = () => {
|
|
935
|
+
if (!pendingResolver) return;
|
|
936
|
+
if (queue.length > 0) {
|
|
937
|
+
const value = queue.shift();
|
|
938
|
+
const resolver = pendingResolver;
|
|
939
|
+
pendingResolver = null;
|
|
940
|
+
resolver.resolve({ value, done: false });
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (done) {
|
|
944
|
+
const resolver = pendingResolver;
|
|
945
|
+
pendingResolver = null;
|
|
946
|
+
resolver.resolve({ value: void 0, done: true });
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
const evictForCap = () => {
|
|
950
|
+
while (queue.length > bufferCap) {
|
|
951
|
+
const idx = queue.findIndex((e) => e.type === "runtime_status");
|
|
952
|
+
if (idx >= 0) {
|
|
953
|
+
queue.splice(idx, 1);
|
|
954
|
+
} else {
|
|
955
|
+
queue.shift();
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
const envelopeSub = socket.onEnvelope((envelope) => {
|
|
960
|
+
if (done) return;
|
|
961
|
+
const event = envelopeToChatEvent(envelope, taskId);
|
|
962
|
+
if (!event) return;
|
|
963
|
+
queue.push(event);
|
|
964
|
+
evictForCap();
|
|
965
|
+
drain();
|
|
966
|
+
});
|
|
967
|
+
const stateSub = socket.onConnectionState((state) => {
|
|
968
|
+
if (done) return;
|
|
969
|
+
queue.push({ type: "connection_state", state });
|
|
970
|
+
evictForCap();
|
|
971
|
+
drain();
|
|
972
|
+
});
|
|
973
|
+
const closeUnsub = socket.onClose(() => {
|
|
974
|
+
if (done) return;
|
|
975
|
+
queue.push({
|
|
976
|
+
type: "task_failed",
|
|
977
|
+
taskId,
|
|
978
|
+
error: {
|
|
979
|
+
code: "subscribe_failed",
|
|
980
|
+
message: "client closed"
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
evictForCap();
|
|
984
|
+
drain();
|
|
985
|
+
finish();
|
|
986
|
+
});
|
|
987
|
+
const onAbort = () => finish();
|
|
988
|
+
const finish = () => {
|
|
989
|
+
if (done) return;
|
|
990
|
+
done = true;
|
|
991
|
+
envelopeSub.unsubscribe();
|
|
992
|
+
stateSub.unsubscribe();
|
|
993
|
+
closeUnsub();
|
|
994
|
+
if (opts?.signal) {
|
|
995
|
+
opts.signal.removeEventListener("abort", onAbort);
|
|
996
|
+
}
|
|
997
|
+
drain();
|
|
998
|
+
};
|
|
999
|
+
if (opts?.signal) {
|
|
1000
|
+
if (opts.signal.aborted) {
|
|
1001
|
+
finish();
|
|
1002
|
+
} else {
|
|
1003
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
return {
|
|
1007
|
+
next() {
|
|
1008
|
+
if (queue.length > 0) {
|
|
1009
|
+
const value = queue.shift();
|
|
1010
|
+
return Promise.resolve({ value, done: false });
|
|
1011
|
+
}
|
|
1012
|
+
if (done) {
|
|
1013
|
+
return Promise.resolve({ value: void 0, done: true });
|
|
1014
|
+
}
|
|
1015
|
+
return new Promise((resolve) => {
|
|
1016
|
+
pendingResolver = { resolve };
|
|
1017
|
+
});
|
|
1018
|
+
},
|
|
1019
|
+
return() {
|
|
1020
|
+
finish();
|
|
1021
|
+
return Promise.resolve({ value: void 0, done: true });
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// src/server/tasks/stream-reply.ts
|
|
1029
|
+
function isAppOriginEcho(metadata) {
|
|
1030
|
+
if (!metadata || typeof metadata !== "object") return false;
|
|
1031
|
+
const audit = metadata.audit;
|
|
1032
|
+
if (!audit || typeof audit !== "object") return false;
|
|
1033
|
+
return audit.actor === "app";
|
|
1034
|
+
}
|
|
1035
|
+
function isSyntheticSystemMessage(metadata) {
|
|
1036
|
+
if (!metadata || typeof metadata !== "object") return false;
|
|
1037
|
+
return metadata.synthetic === true;
|
|
1038
|
+
}
|
|
1039
|
+
function streamReplyForTask(socket, taskId, opts) {
|
|
1040
|
+
const emitInitial = opts?.emitInitialPreview ?? true;
|
|
1041
|
+
const idleTimeoutMs = opts?.idleTimeoutMs ?? 12e4;
|
|
1042
|
+
return {
|
|
1043
|
+
async *[Symbol.asyncIterator]() {
|
|
1044
|
+
let lastPreview = "";
|
|
1045
|
+
let lastStatusState = null;
|
|
1046
|
+
let bootstrapped = !emitInitial;
|
|
1047
|
+
let currentReplyTo = null;
|
|
1048
|
+
const idleController = new AbortController();
|
|
1049
|
+
let idleTimer = null;
|
|
1050
|
+
let idleFired = false;
|
|
1051
|
+
const armIdle = () => {
|
|
1052
|
+
if (idleTimeoutMs <= 0) return;
|
|
1053
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1054
|
+
idleTimer = setTimeout(() => {
|
|
1055
|
+
idleFired = true;
|
|
1056
|
+
idleController.abort();
|
|
1057
|
+
}, idleTimeoutMs);
|
|
1058
|
+
};
|
|
1059
|
+
const disarmIdle = () => {
|
|
1060
|
+
if (idleTimer) {
|
|
1061
|
+
clearTimeout(idleTimer);
|
|
1062
|
+
idleTimer = null;
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
let composedSignal;
|
|
1066
|
+
const externalSignal = opts?.signal;
|
|
1067
|
+
const onExternalAbort = () => idleController.abort();
|
|
1068
|
+
if (externalSignal && externalSignal.aborted) {
|
|
1069
|
+
composedSignal = externalSignal;
|
|
1070
|
+
} else if (externalSignal) {
|
|
1071
|
+
externalSignal.addEventListener("abort", onExternalAbort, { once: true });
|
|
1072
|
+
composedSignal = idleController.signal;
|
|
1073
|
+
} else {
|
|
1074
|
+
composedSignal = idleController.signal;
|
|
1075
|
+
}
|
|
1076
|
+
armIdle();
|
|
1077
|
+
try {
|
|
1078
|
+
for await (const event of subscribeToTask(socket, taskId, {
|
|
1079
|
+
signal: composedSignal
|
|
1080
|
+
})) {
|
|
1081
|
+
if (event.type === "runtime_status") {
|
|
1082
|
+
const status = event.status;
|
|
1083
|
+
const replyTo = status.replyTo ?? currentReplyTo ?? "";
|
|
1084
|
+
if (status.replyTo) currentReplyTo = status.replyTo;
|
|
1085
|
+
const preview = status.replyPreview ?? "";
|
|
1086
|
+
const previewAdvanced = preview !== "" && preview !== lastPreview;
|
|
1087
|
+
if (previewAdvanced) {
|
|
1088
|
+
const isContinuation = preview.startsWith(lastPreview);
|
|
1089
|
+
yield {
|
|
1090
|
+
type: "text",
|
|
1091
|
+
text: bootstrapped && isContinuation ? preview.slice(lastPreview.length) : preview,
|
|
1092
|
+
replyTo
|
|
1093
|
+
};
|
|
1094
|
+
lastPreview = preview;
|
|
1095
|
+
bootstrapped = true;
|
|
1096
|
+
}
|
|
1097
|
+
yield { type: "status", status };
|
|
1098
|
+
const stateChanged = status.state !== lastStatusState;
|
|
1099
|
+
if (previewAdvanced || stateChanged) {
|
|
1100
|
+
armIdle();
|
|
1101
|
+
lastStatusState = status.state;
|
|
1102
|
+
}
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
if (event.type === "message_appended") {
|
|
1106
|
+
const message = event.message;
|
|
1107
|
+
if (message.role === "user") {
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
if (isAppOriginEcho(message.metadata)) {
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
if (isSyntheticSystemMessage(message.metadata)) {
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
yield { type: "done", message };
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
if (event.type === "task_failed") {
|
|
1120
|
+
yield { type: "error", error: event.error };
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
if (event.type === "task_finished") {
|
|
1124
|
+
yield {
|
|
1125
|
+
type: "error",
|
|
1126
|
+
error: {
|
|
1127
|
+
code: "task_not_running",
|
|
1128
|
+
message: "task finished without reply"
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
if (idleFired) {
|
|
1135
|
+
yield {
|
|
1136
|
+
type: "error",
|
|
1137
|
+
error: {
|
|
1138
|
+
code: "stream_aborted",
|
|
1139
|
+
message: "idle timeout"
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
} finally {
|
|
1144
|
+
disarmIdle();
|
|
1145
|
+
if (externalSignal && !externalSignal.aborted) {
|
|
1146
|
+
externalSignal.removeEventListener("abort", onExternalAbort);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// src/server/client.ts
|
|
1154
|
+
var AppClient = class {
|
|
1155
|
+
projects;
|
|
1156
|
+
tasks;
|
|
1157
|
+
_fetcher;
|
|
1158
|
+
_socket = null;
|
|
1159
|
+
_closed = false;
|
|
1160
|
+
options;
|
|
1161
|
+
constructor(options) {
|
|
1162
|
+
validateOptions(options);
|
|
1163
|
+
this.options = options;
|
|
1164
|
+
this._fetcher = new Fetcher({
|
|
1165
|
+
baseUrl: options.baseUrl,
|
|
1166
|
+
bearerToken: options.bearerToken,
|
|
1167
|
+
fetch: options.fetch,
|
|
1168
|
+
timeoutMs: options.timeoutMs,
|
|
1169
|
+
onUnauthorized: options.onUnauthorized
|
|
1170
|
+
});
|
|
1171
|
+
this.projects = new ProjectsApi(this._fetcher);
|
|
1172
|
+
const rest = new TasksRestApi(this._fetcher);
|
|
1173
|
+
this.tasks = new TasksApi(
|
|
1174
|
+
rest,
|
|
1175
|
+
() => this.getOrCreateSocket(),
|
|
1176
|
+
() => this._closed
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Release the WS connection and mark the client closed. Idempotent —
|
|
1181
|
+
* calling twice is a no-op rather than a NPE. After close, any new
|
|
1182
|
+
* `tasks.subscribe()` / `tasks.streamReply()` / `tasks.*` REST call
|
|
1183
|
+
* throws synchronously instead of returning a hanging iterator.
|
|
1184
|
+
*/
|
|
1185
|
+
async close() {
|
|
1186
|
+
if (this._closed) return;
|
|
1187
|
+
this._closed = true;
|
|
1188
|
+
if (this._socket) {
|
|
1189
|
+
this._socket.close();
|
|
1190
|
+
this._socket = null;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
getOrCreateSocket() {
|
|
1194
|
+
if (this._socket) return this._socket;
|
|
1195
|
+
this._socket = new AppWebSocket({
|
|
1196
|
+
baseUrl: this.options.baseUrl,
|
|
1197
|
+
bearerToken: this.options.bearerToken,
|
|
1198
|
+
webSocketImpl: this.options.webSocketImpl
|
|
1199
|
+
});
|
|
1200
|
+
return this._socket;
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
async function connect(options) {
|
|
1204
|
+
return new AppClient(options);
|
|
1205
|
+
}
|
|
1206
|
+
var TasksApi = class {
|
|
1207
|
+
/** @internal — constructed by `AppClient`; not part of the public surface. */
|
|
1208
|
+
constructor(rest, getSocket, isClosed = () => false) {
|
|
1209
|
+
this.rest = rest;
|
|
1210
|
+
this.getSocket = getSocket;
|
|
1211
|
+
this.isClosed = isClosed;
|
|
1212
|
+
}
|
|
1213
|
+
rest;
|
|
1214
|
+
getSocket;
|
|
1215
|
+
isClosed;
|
|
1216
|
+
assertOpen() {
|
|
1217
|
+
if (this.isClosed()) {
|
|
1218
|
+
throw new ConductorAppError({
|
|
1219
|
+
code: "subscribe_failed",
|
|
1220
|
+
message: "client is closed"
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
create(input, opts) {
|
|
1225
|
+
this.assertOpen();
|
|
1226
|
+
return this.rest.create(input, opts);
|
|
1227
|
+
}
|
|
1228
|
+
get(taskId, opts) {
|
|
1229
|
+
this.assertOpen();
|
|
1230
|
+
return this.rest.get(taskId, opts);
|
|
1231
|
+
}
|
|
1232
|
+
list(filter, opts) {
|
|
1233
|
+
this.assertOpen();
|
|
1234
|
+
return this.rest.list(filter, opts);
|
|
1235
|
+
}
|
|
1236
|
+
sendMessage(taskId, input, opts) {
|
|
1237
|
+
this.assertOpen();
|
|
1238
|
+
return this.rest.sendMessage(taskId, input, opts);
|
|
1239
|
+
}
|
|
1240
|
+
history(taskId, paging, opts) {
|
|
1241
|
+
this.assertOpen();
|
|
1242
|
+
return this.rest.history(taskId, paging, opts);
|
|
1243
|
+
}
|
|
1244
|
+
interrupt(taskId, opts) {
|
|
1245
|
+
this.assertOpen();
|
|
1246
|
+
return this.rest.interrupt(taskId, opts);
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Subscribe to a task's event stream. Yields ChatEvents until the caller
|
|
1250
|
+
* breaks out of the `for await` loop, calls `signal.abort()`, or the
|
|
1251
|
+
* client is closed.
|
|
1252
|
+
*
|
|
1253
|
+
* The first call lazily opens a /ws/app connection; subsequent calls share
|
|
1254
|
+
* the same connection.
|
|
1255
|
+
*/
|
|
1256
|
+
subscribe(taskId, opts) {
|
|
1257
|
+
if (!taskId) {
|
|
1258
|
+
throw new ConductorAppError({
|
|
1259
|
+
code: "invalid_input",
|
|
1260
|
+
message: "tasks.subscribe requires a taskId"
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
this.assertOpen();
|
|
1264
|
+
const socket = this.getSocket();
|
|
1265
|
+
const inner = subscribeToTask(socket, taskId, opts);
|
|
1266
|
+
return {
|
|
1267
|
+
[Symbol.asyncIterator]() {
|
|
1268
|
+
const innerIter = inner[Symbol.asyncIterator]();
|
|
1269
|
+
const connectPromise = socket.connect().then(
|
|
1270
|
+
() => null,
|
|
1271
|
+
(err) => err
|
|
1272
|
+
);
|
|
1273
|
+
let connectChecked = false;
|
|
1274
|
+
return {
|
|
1275
|
+
async next() {
|
|
1276
|
+
if (!connectChecked) {
|
|
1277
|
+
connectChecked = true;
|
|
1278
|
+
const err = await connectPromise;
|
|
1279
|
+
if (err) {
|
|
1280
|
+
try {
|
|
1281
|
+
await innerIter.return?.();
|
|
1282
|
+
} catch {
|
|
1283
|
+
}
|
|
1284
|
+
const event = {
|
|
1285
|
+
type: "task_failed",
|
|
1286
|
+
taskId,
|
|
1287
|
+
error: errorToChatError(err, "subscribe_failed")
|
|
1288
|
+
};
|
|
1289
|
+
return { value: event, done: false };
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
return innerIter.next();
|
|
1293
|
+
},
|
|
1294
|
+
async return() {
|
|
1295
|
+
return await innerIter.return?.() ?? { value: void 0, done: true };
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Higher-level convenience: yield only AI reply deltas. Internally consumes
|
|
1303
|
+
* `subscribe()` and selects runtime_status preview chunks + the final
|
|
1304
|
+
* assistant message.
|
|
1305
|
+
*/
|
|
1306
|
+
streamReply(taskId, opts) {
|
|
1307
|
+
if (!taskId) {
|
|
1308
|
+
throw new ConductorAppError({
|
|
1309
|
+
code: "invalid_input",
|
|
1310
|
+
message: "tasks.streamReply requires a taskId"
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
this.assertOpen();
|
|
1314
|
+
const socket = this.getSocket();
|
|
1315
|
+
const inner = streamReplyForTask(socket, taskId, opts);
|
|
1316
|
+
return {
|
|
1317
|
+
[Symbol.asyncIterator]() {
|
|
1318
|
+
const innerIter = inner[Symbol.asyncIterator]();
|
|
1319
|
+
const connectPromise = socket.connect().then(
|
|
1320
|
+
() => null,
|
|
1321
|
+
(err) => err
|
|
1322
|
+
);
|
|
1323
|
+
let connectChecked = false;
|
|
1324
|
+
return {
|
|
1325
|
+
async next() {
|
|
1326
|
+
if (!connectChecked) {
|
|
1327
|
+
connectChecked = true;
|
|
1328
|
+
const err = await connectPromise;
|
|
1329
|
+
if (err) {
|
|
1330
|
+
try {
|
|
1331
|
+
await innerIter.return?.();
|
|
1332
|
+
} catch {
|
|
1333
|
+
}
|
|
1334
|
+
const delta = {
|
|
1335
|
+
type: "error",
|
|
1336
|
+
error: errorToChatError(err, "stream_aborted")
|
|
1337
|
+
};
|
|
1338
|
+
return { value: delta, done: false };
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
return innerIter.next();
|
|
1342
|
+
},
|
|
1343
|
+
async return() {
|
|
1344
|
+
return await innerIter.return?.() ?? { value: void 0, done: true };
|
|
1345
|
+
}
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
function errorToChatError(err, defaultCode = "subscribe_failed") {
|
|
1352
|
+
if (err && typeof err === "object") {
|
|
1353
|
+
const code = String(err.code ?? defaultCode);
|
|
1354
|
+
const message = String(
|
|
1355
|
+
err.message ?? "WebSocket connection failed"
|
|
1356
|
+
);
|
|
1357
|
+
const detailsRaw = err.details;
|
|
1358
|
+
const result = {
|
|
1359
|
+
code,
|
|
1360
|
+
message
|
|
1361
|
+
};
|
|
1362
|
+
if (detailsRaw !== void 0) result.details = detailsRaw;
|
|
1363
|
+
if (err instanceof Error) result.cause = err;
|
|
1364
|
+
return result;
|
|
1365
|
+
}
|
|
1366
|
+
return { code: defaultCode, message: String(err) };
|
|
1367
|
+
}
|
|
1368
|
+
function validateOptions(options) {
|
|
1369
|
+
if (!options.baseUrl) {
|
|
1370
|
+
throw new ConductorAppError({
|
|
1371
|
+
code: "invalid_input",
|
|
1372
|
+
message: "connect(): baseUrl is required"
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
if (!options.bearerToken) {
|
|
1376
|
+
throw new ConductorAppError({
|
|
1377
|
+
code: "invalid_input",
|
|
1378
|
+
message: "connect(): bearerToken is required"
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
export {
|
|
1383
|
+
AppClient,
|
|
1384
|
+
ConductorAppError,
|
|
1385
|
+
connect,
|
|
1386
|
+
isConductorAppError
|
|
1387
|
+
};
|