@love-moon/conductor-sdk 0.2.42 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/dist/api/index.d.ts +4 -0
- package/dist/api/index.js +4 -0
- package/dist/api/issues.d.ts +57 -0
- package/dist/api/issues.js +177 -0
- package/dist/api/projects.d.ts +71 -0
- package/dist/api/projects.js +247 -0
- package/dist/api/shared.d.ts +45 -0
- package/dist/api/shared.js +78 -0
- package/dist/api/tasks.d.ts +46 -0
- package/dist/api/tasks.js +118 -0
- package/dist/backend/client.d.ts +36 -0
- package/dist/backend/client.js +101 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +18 -0
- package/dist/context/project_context.d.ts +26 -0
- package/dist/context/project_context.js +64 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/ws/client.js +23 -0
- package/package.json +10 -4
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { BackendApiError } from '../backend/index.js';
|
|
3
|
+
// Loaded eagerly so we don't pay a require() on every audit metadata stamp.
|
|
4
|
+
// Falls back to a stable string when the package.json isn't reachable (eg.
|
|
5
|
+
// bundled scenarios). The version is only ever embedded into outbound
|
|
6
|
+
// metadata, so a fallback is fine.
|
|
7
|
+
let cachedSdkVersion = null;
|
|
8
|
+
const readSdkVersion = () => {
|
|
9
|
+
if (cachedSdkVersion !== null) {
|
|
10
|
+
return cachedSdkVersion;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const localRequire = createRequire(import.meta.url);
|
|
14
|
+
const pkg = localRequire('../../package.json');
|
|
15
|
+
cachedSdkVersion =
|
|
16
|
+
pkg && typeof pkg.version === 'string' ? pkg.version : 'unknown';
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
cachedSdkVersion = 'unknown';
|
|
20
|
+
}
|
|
21
|
+
return cachedSdkVersion;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Build the metadata payload every SDK write attaches to the request.
|
|
25
|
+
*
|
|
26
|
+
* Audit fields are namespaced under `metadata.audit` (RFC 0025 §5.2 + review
|
|
27
|
+
* findings M3/H1). This avoids two failure modes the original top-level shape
|
|
28
|
+
* had:
|
|
29
|
+
* 1. A caller passing `--metadata-json '{"actor":"system"}'` could spoof the
|
|
30
|
+
* audit actor by colliding with our top-level key.
|
|
31
|
+
* 2. Server-side strippers had to know the full audit-key list to filter.
|
|
32
|
+
*
|
|
33
|
+
* Merge order:
|
|
34
|
+
* - Non-audit user keys are passed through verbatim.
|
|
35
|
+
* - Audit defaults (`actor:"sdk"`, `sdkVersion`, `invokedBy`) are layered
|
|
36
|
+
* UNDER the caller's `audit` (so the CLI's `actor:"cli"` wins).
|
|
37
|
+
* - The caller's top-level `audit` key, if any, takes precedence over the
|
|
38
|
+
* SDK defaults but never replaces all of them — `cliVersion`, etc., are
|
|
39
|
+
* additive.
|
|
40
|
+
*/
|
|
41
|
+
export const buildAuditMetadata = (userMetadata, options = {}) => {
|
|
42
|
+
const env = options.env ?? process.env;
|
|
43
|
+
const invokedByRaw = env.CONDUCTOR_INVOKED_BY;
|
|
44
|
+
const invokedBy = typeof invokedByRaw === 'string' && invokedByRaw.trim()
|
|
45
|
+
? invokedByRaw.trim()
|
|
46
|
+
: null;
|
|
47
|
+
const sdkAudit = {
|
|
48
|
+
actor: 'sdk',
|
|
49
|
+
sdkVersion: options.sdkVersion ?? readSdkVersion(),
|
|
50
|
+
invokedBy,
|
|
51
|
+
};
|
|
52
|
+
const safeUserMetadata = userMetadata && typeof userMetadata === 'object' && !Array.isArray(userMetadata)
|
|
53
|
+
? { ...userMetadata }
|
|
54
|
+
: {};
|
|
55
|
+
const callerAuditRaw = safeUserMetadata.audit;
|
|
56
|
+
delete safeUserMetadata.audit;
|
|
57
|
+
const callerAudit = callerAuditRaw && typeof callerAuditRaw === 'object' && !Array.isArray(callerAuditRaw)
|
|
58
|
+
? callerAuditRaw
|
|
59
|
+
: {};
|
|
60
|
+
// Caller-supplied audit fields win over SDK defaults so a CLI invocation
|
|
61
|
+
// tagging `actor:"cli"` ends up authoritative.
|
|
62
|
+
const audit = { ...sdkAudit, ...callerAudit };
|
|
63
|
+
return { ...safeUserMetadata, audit };
|
|
64
|
+
};
|
|
65
|
+
export class ProjectNotResolvedError extends Error {
|
|
66
|
+
reason;
|
|
67
|
+
constructor(message, reason) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.reason = reason;
|
|
70
|
+
this.name = 'ProjectNotResolvedError';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Best-effort mapping for HTTP-shaped failures so the CLI exit-code matrix
|
|
75
|
+
* (RFC 0025 §4) has a stable signal. Callers can choose to inspect
|
|
76
|
+
* `BackendApiError.statusCode` directly instead.
|
|
77
|
+
*/
|
|
78
|
+
export const isBackendApiError = (value) => value instanceof BackendApiError;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ApiClient, SdkClientOptions } from './shared.js';
|
|
2
|
+
export interface Task {
|
|
3
|
+
id: string;
|
|
4
|
+
projectId: string | null;
|
|
5
|
+
issueId?: string | null;
|
|
6
|
+
title: string;
|
|
7
|
+
status: string;
|
|
8
|
+
backendType?: string | null;
|
|
9
|
+
sessionId?: string | null;
|
|
10
|
+
sessionFilePath?: string | null;
|
|
11
|
+
createdAt?: string | null;
|
|
12
|
+
updatedAt?: string | null;
|
|
13
|
+
raw: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
export interface Message {
|
|
16
|
+
id: string;
|
|
17
|
+
taskId?: string | null;
|
|
18
|
+
role: string;
|
|
19
|
+
content: string;
|
|
20
|
+
metadata?: Record<string, unknown> | null;
|
|
21
|
+
createdAt?: string | null;
|
|
22
|
+
raw: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
export interface ListTasksInput {
|
|
25
|
+
projectId?: string;
|
|
26
|
+
issueId?: string;
|
|
27
|
+
status?: string | string[];
|
|
28
|
+
}
|
|
29
|
+
export interface SendTaskMessageOptions {
|
|
30
|
+
role?: string;
|
|
31
|
+
metadata?: Record<string, unknown>;
|
|
32
|
+
clientRequestId?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ListTaskMessagesOptions {
|
|
35
|
+
limit?: number;
|
|
36
|
+
before?: string;
|
|
37
|
+
}
|
|
38
|
+
export declare class TasksApi {
|
|
39
|
+
private readonly client;
|
|
40
|
+
private readonly options;
|
|
41
|
+
constructor(client: ApiClient, options?: SdkClientOptions);
|
|
42
|
+
listTasks(input?: ListTasksInput): Promise<Task[]>;
|
|
43
|
+
getTask(taskId: string): Promise<Task>;
|
|
44
|
+
sendTaskMessage(taskId: string, content: string, options?: SendTaskMessageOptions): Promise<Message>;
|
|
45
|
+
listTaskMessages(taskId: string, options?: ListTaskMessagesOptions): Promise<Message[]>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { buildAuditMetadata } from './shared.js';
|
|
2
|
+
const normalizeTask = (payload) => {
|
|
3
|
+
const id = payload.id ? String(payload.id) : '';
|
|
4
|
+
if (!id) {
|
|
5
|
+
throw new Error('Task payload missing id');
|
|
6
|
+
}
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
projectId: payload.projectId !== undefined
|
|
10
|
+
? payload.projectId === null
|
|
11
|
+
? null
|
|
12
|
+
: String(payload.projectId)
|
|
13
|
+
: payload.project_id !== undefined
|
|
14
|
+
? payload.project_id === null
|
|
15
|
+
? null
|
|
16
|
+
: String(payload.project_id)
|
|
17
|
+
: null,
|
|
18
|
+
issueId: payload.issueId ?? payload.issue_id ?? null,
|
|
19
|
+
title: String(payload.title ?? ''),
|
|
20
|
+
status: String(payload.status ?? ''),
|
|
21
|
+
backendType: payload.backendType ?? payload.backend_type ?? null,
|
|
22
|
+
sessionId: payload.sessionId ?? payload.session_id ?? null,
|
|
23
|
+
sessionFilePath: payload.sessionFilePath ?? payload.session_file_path ?? null,
|
|
24
|
+
createdAt: payload.createdAt ?? payload.created_at ?? null,
|
|
25
|
+
updatedAt: payload.updatedAt ?? payload.updated_at ?? null,
|
|
26
|
+
raw: payload,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
const normalizeMessage = (payload) => {
|
|
30
|
+
const id = payload.id ? String(payload.id) : payload.message_id ? String(payload.message_id) : '';
|
|
31
|
+
if (!id) {
|
|
32
|
+
throw new Error('Message payload missing id');
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
taskId: payload.taskId ?? payload.task_id ?? null,
|
|
37
|
+
role: String(payload.role ?? ''),
|
|
38
|
+
content: typeof payload.content === 'string' ? payload.content : '',
|
|
39
|
+
metadata: payload.metadata && typeof payload.metadata === 'object' && !Array.isArray(payload.metadata)
|
|
40
|
+
? payload.metadata
|
|
41
|
+
: null,
|
|
42
|
+
createdAt: payload.createdAt ?? payload.created_at ?? null,
|
|
43
|
+
raw: payload,
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
export class TasksApi {
|
|
47
|
+
client;
|
|
48
|
+
options;
|
|
49
|
+
constructor(client, options = {}) {
|
|
50
|
+
this.client = client;
|
|
51
|
+
this.options = options;
|
|
52
|
+
}
|
|
53
|
+
async listTasks(input = {}) {
|
|
54
|
+
// The server `/api/tasks` only filters on `project_id`; status / issue
|
|
55
|
+
// filtering happens client-side here so we can support both string and
|
|
56
|
+
// array `status` shapes from the facade input.
|
|
57
|
+
const tasks = await this.client.listTasks({
|
|
58
|
+
projectId: input.projectId,
|
|
59
|
+
});
|
|
60
|
+
let normalized = tasks.map((entry) => normalizeTask(typeof entry.asObject === 'function' ? entry.asObject() : entry));
|
|
61
|
+
if (input.issueId) {
|
|
62
|
+
normalized = normalized.filter((task) => task.issueId === input.issueId);
|
|
63
|
+
}
|
|
64
|
+
const statusFilter = Array.isArray(input.status)
|
|
65
|
+
? input.status.map((value) => String(value).trim()).filter(Boolean)
|
|
66
|
+
: input.status
|
|
67
|
+
? [String(input.status).trim()].filter(Boolean)
|
|
68
|
+
: [];
|
|
69
|
+
if (statusFilter.length > 0) {
|
|
70
|
+
const statusSet = new Set(statusFilter);
|
|
71
|
+
normalized = normalized.filter((task) => statusSet.has(task.status));
|
|
72
|
+
}
|
|
73
|
+
return normalized;
|
|
74
|
+
}
|
|
75
|
+
async getTask(taskId) {
|
|
76
|
+
const trimmed = String(taskId ?? '').trim();
|
|
77
|
+
if (!trimmed) {
|
|
78
|
+
throw new Error('taskId is required');
|
|
79
|
+
}
|
|
80
|
+
const summary = await this.client.getTask(trimmed);
|
|
81
|
+
return normalizeTask(typeof summary.asObject === 'function'
|
|
82
|
+
? summary.asObject()
|
|
83
|
+
: summary);
|
|
84
|
+
}
|
|
85
|
+
async sendTaskMessage(taskId, content, options = {}) {
|
|
86
|
+
const trimmed = String(taskId ?? '').trim();
|
|
87
|
+
if (!trimmed) {
|
|
88
|
+
throw new Error('taskId is required');
|
|
89
|
+
}
|
|
90
|
+
if (typeof content !== 'string' || content.length === 0) {
|
|
91
|
+
throw new Error('content is required');
|
|
92
|
+
}
|
|
93
|
+
const userMetadata = options.clientRequestId
|
|
94
|
+
? { clientRequestId: options.clientRequestId, ...(options.metadata ?? {}) }
|
|
95
|
+
: options.metadata;
|
|
96
|
+
const metadata = buildAuditMetadata(userMetadata, this.options);
|
|
97
|
+
const body = {
|
|
98
|
+
content,
|
|
99
|
+
metadata,
|
|
100
|
+
};
|
|
101
|
+
if (options.role) {
|
|
102
|
+
body.role = options.role;
|
|
103
|
+
}
|
|
104
|
+
const payload = await this.client.postTaskMessage(trimmed, body);
|
|
105
|
+
return normalizeMessage(payload);
|
|
106
|
+
}
|
|
107
|
+
async listTaskMessages(taskId, options = {}) {
|
|
108
|
+
const trimmed = String(taskId ?? '').trim();
|
|
109
|
+
if (!trimmed) {
|
|
110
|
+
throw new Error('taskId is required');
|
|
111
|
+
}
|
|
112
|
+
const messages = await this.client.listTaskMessages(trimmed, {
|
|
113
|
+
limit: options.limit,
|
|
114
|
+
before: options.before,
|
|
115
|
+
});
|
|
116
|
+
return messages.map((entry) => normalizeMessage(entry));
|
|
117
|
+
}
|
|
118
|
+
}
|
package/dist/backend/client.d.ts
CHANGED
|
@@ -95,6 +95,7 @@ export declare class BackendApiClient {
|
|
|
95
95
|
agentHost?: string;
|
|
96
96
|
metadata?: Record<string, unknown>;
|
|
97
97
|
}): Promise<TaskSummary>;
|
|
98
|
+
getTask(taskId: string): Promise<TaskSummary>;
|
|
98
99
|
updateTask(taskId: string, params: {
|
|
99
100
|
projectId?: string;
|
|
100
101
|
title?: string;
|
|
@@ -176,6 +177,41 @@ export declare class BackendApiClient {
|
|
|
176
177
|
lastCommit?: string;
|
|
177
178
|
fileCount?: number;
|
|
178
179
|
}): Promise<ProjectSummary>;
|
|
180
|
+
/**
|
|
181
|
+
* PATCH /projects?projectId=... — used for fields the per-id PATCH endpoint
|
|
182
|
+
* doesn't accept (today: `hidden`). The two PATCH endpoints intentionally
|
|
183
|
+
* have different surfaces; see web/src/app/api/projects/route.ts vs
|
|
184
|
+
* web/src/app/api/projects/[projectId]/route.ts.
|
|
185
|
+
*/
|
|
186
|
+
patchProjectByQuery(projectId: string, params: Record<string, unknown>): Promise<Record<string, any>>;
|
|
187
|
+
/**
|
|
188
|
+
* POST /projects/default — switches the user's default project to
|
|
189
|
+
* `projectId`. Returns the serialized Project (with `is_default: true`).
|
|
190
|
+
* Extra body fields (e.g., `metadata`) are merged into the request so the
|
|
191
|
+
* facade can pass audit info through.
|
|
192
|
+
*/
|
|
193
|
+
setDefaultProject(projectId: string, extraBody?: Record<string, unknown>): Promise<Record<string, any>>;
|
|
194
|
+
listIssues(params?: {
|
|
195
|
+
projectId?: string;
|
|
196
|
+
projectIds?: string[];
|
|
197
|
+
status?: string;
|
|
198
|
+
}): Promise<Record<string, any>[]>;
|
|
199
|
+
getIssue(issueId: string): Promise<Record<string, any>>;
|
|
200
|
+
createIssue(params: Record<string, unknown>): Promise<Record<string, any>>;
|
|
201
|
+
patchIssue(issueId: string, params: Record<string, unknown>): Promise<Record<string, any>>;
|
|
202
|
+
deleteIssue(issueId: string): Promise<void>;
|
|
203
|
+
listTaskMessages(taskId: string, params?: {
|
|
204
|
+
limit?: number;
|
|
205
|
+
before?: string;
|
|
206
|
+
}): Promise<Record<string, any>[]>;
|
|
207
|
+
/**
|
|
208
|
+
* POST /tasks/[taskId]/messages — direct REST call. Note: `BackendApiClient`
|
|
209
|
+
* has a higher-level `commitSdkMessage` that goes through the agent-events
|
|
210
|
+
* pipeline (durable outbox + WS scope), but the CLI/AI Agent flow is a
|
|
211
|
+
* one-shot HTTP write with no agent host context, so we use the REST
|
|
212
|
+
* endpoint directly here. (RFC 0025 §2 picks the simpler path.)
|
|
213
|
+
*/
|
|
214
|
+
postTaskMessage(taskId: string, params: Record<string, unknown>): Promise<Record<string, any>>;
|
|
179
215
|
private request;
|
|
180
216
|
private buildUrl;
|
|
181
217
|
private sendRequest;
|
package/dist/backend/client.js
CHANGED
|
@@ -157,6 +157,11 @@ export class BackendApiClient {
|
|
|
157
157
|
const payload = await this.parseJson(response);
|
|
158
158
|
return TaskSummary.fromJSON(payload);
|
|
159
159
|
}
|
|
160
|
+
async getTask(taskId) {
|
|
161
|
+
const response = await this.request('GET', `/tasks/${taskId}`);
|
|
162
|
+
const payload = await this.parseJson(response);
|
|
163
|
+
return TaskSummary.fromJSON(payload);
|
|
164
|
+
}
|
|
160
165
|
async updateTask(taskId, params) {
|
|
161
166
|
const response = await this.request('PATCH', `/tasks/${taskId}`, {
|
|
162
167
|
body: JSON.stringify(params),
|
|
@@ -305,6 +310,102 @@ export class BackendApiClient {
|
|
|
305
310
|
const payload = await this.parseJson(response);
|
|
306
311
|
return ProjectSummary.fromJSON(payload);
|
|
307
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* PATCH /projects?projectId=... — used for fields the per-id PATCH endpoint
|
|
315
|
+
* doesn't accept (today: `hidden`). The two PATCH endpoints intentionally
|
|
316
|
+
* have different surfaces; see web/src/app/api/projects/route.ts vs
|
|
317
|
+
* web/src/app/api/projects/[projectId]/route.ts.
|
|
318
|
+
*/
|
|
319
|
+
async patchProjectByQuery(projectId, params) {
|
|
320
|
+
const query = new URLSearchParams();
|
|
321
|
+
query.set('projectId', projectId);
|
|
322
|
+
const response = await this.request('PATCH', '/projects', {
|
|
323
|
+
query,
|
|
324
|
+
body: JSON.stringify(params),
|
|
325
|
+
});
|
|
326
|
+
return this.parseJson(response);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* POST /projects/default — switches the user's default project to
|
|
330
|
+
* `projectId`. Returns the serialized Project (with `is_default: true`).
|
|
331
|
+
* Extra body fields (e.g., `metadata`) are merged into the request so the
|
|
332
|
+
* facade can pass audit info through.
|
|
333
|
+
*/
|
|
334
|
+
async setDefaultProject(projectId, extraBody = {}) {
|
|
335
|
+
const response = await this.request('POST', '/projects/default', {
|
|
336
|
+
body: JSON.stringify({ projectId, ...extraBody }),
|
|
337
|
+
});
|
|
338
|
+
return this.parseJson(response);
|
|
339
|
+
}
|
|
340
|
+
async listIssues(params = {}) {
|
|
341
|
+
const query = new URLSearchParams();
|
|
342
|
+
if (params.projectId) {
|
|
343
|
+
query.set('project_id', params.projectId);
|
|
344
|
+
}
|
|
345
|
+
if (params.projectIds && params.projectIds.length > 0) {
|
|
346
|
+
query.set('project_ids', params.projectIds.join(','));
|
|
347
|
+
}
|
|
348
|
+
if (params.status) {
|
|
349
|
+
query.set('status', params.status);
|
|
350
|
+
}
|
|
351
|
+
const response = await this.request('GET', '/issues', { query });
|
|
352
|
+
const payload = await this.parseJson(response);
|
|
353
|
+
if (!Array.isArray(payload)) {
|
|
354
|
+
throw new BackendApiError('Invalid issues response: expected list', response.status, payload);
|
|
355
|
+
}
|
|
356
|
+
return payload;
|
|
357
|
+
}
|
|
358
|
+
async getIssue(issueId) {
|
|
359
|
+
const response = await this.request('GET', `/issues/${issueId}`);
|
|
360
|
+
return this.parseJson(response);
|
|
361
|
+
}
|
|
362
|
+
async createIssue(params) {
|
|
363
|
+
const response = await this.request('POST', '/issues', {
|
|
364
|
+
body: JSON.stringify(params),
|
|
365
|
+
});
|
|
366
|
+
return this.parseJson(response);
|
|
367
|
+
}
|
|
368
|
+
async patchIssue(issueId, params) {
|
|
369
|
+
const response = await this.request('PATCH', `/issues/${issueId}`, {
|
|
370
|
+
body: JSON.stringify(params),
|
|
371
|
+
});
|
|
372
|
+
return this.parseJson(response);
|
|
373
|
+
}
|
|
374
|
+
async deleteIssue(issueId) {
|
|
375
|
+
await this.request('DELETE', `/issues/${issueId}`);
|
|
376
|
+
}
|
|
377
|
+
async listTaskMessages(taskId, params = {}) {
|
|
378
|
+
const query = new URLSearchParams();
|
|
379
|
+
query.set('pagination', '1');
|
|
380
|
+
if (typeof params.limit === 'number' && Number.isFinite(params.limit)) {
|
|
381
|
+
query.set('limit', String(params.limit));
|
|
382
|
+
}
|
|
383
|
+
if (params.before) {
|
|
384
|
+
query.set('before_id', params.before);
|
|
385
|
+
}
|
|
386
|
+
const response = await this.request('GET', `/tasks/${taskId}/messages`, { query });
|
|
387
|
+
const payload = await this.parseJson(response);
|
|
388
|
+
if (Array.isArray(payload)) {
|
|
389
|
+
return payload;
|
|
390
|
+
}
|
|
391
|
+
if (payload && typeof payload === 'object' && Array.isArray(payload.messages)) {
|
|
392
|
+
return payload.messages;
|
|
393
|
+
}
|
|
394
|
+
throw new BackendApiError('Invalid messages response', response.status, payload);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* POST /tasks/[taskId]/messages — direct REST call. Note: `BackendApiClient`
|
|
398
|
+
* has a higher-level `commitSdkMessage` that goes through the agent-events
|
|
399
|
+
* pipeline (durable outbox + WS scope), but the CLI/AI Agent flow is a
|
|
400
|
+
* one-shot HTTP write with no agent host context, so we use the REST
|
|
401
|
+
* endpoint directly here. (RFC 0025 §2 picks the simpler path.)
|
|
402
|
+
*/
|
|
403
|
+
async postTaskMessage(taskId, params) {
|
|
404
|
+
const response = await this.request('POST', `/tasks/${taskId}/messages`, {
|
|
405
|
+
body: JSON.stringify(params),
|
|
406
|
+
});
|
|
407
|
+
return this.parseJson(response);
|
|
408
|
+
}
|
|
308
409
|
async request(method, pathname, opts = {}) {
|
|
309
410
|
const url = this.buildUrl(pathname, opts.query);
|
|
310
411
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
package/dist/client.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { MessageRouter } from './message/index.js';
|
|
|
4
4
|
import { DownstreamCursorStore, DurableUpstreamOutboxStore } from './outbox/index.js';
|
|
5
5
|
import { SessionDiskStore, SessionManager } from './session/index.js';
|
|
6
6
|
import { ConductorWebSocketClient, WebSocketConnectedEvent, WebSocketDisconnectEvent, WebSocketPongEvent } from './ws/index.js';
|
|
7
|
-
type BackendApiLike = Pick<BackendApiClient, 'listProjects' | 'createProject' | 'listTasks' | 'createTask' | 'updateTask' | 'commitSdkMessage' | 'commitTaskStatusUpdate' | 'commitAgentCommandAck' | 'commitTaskStopAck' | 'matchProjectByPath' | 'getProject' | 'updateProject'>;
|
|
7
|
+
type BackendApiLike = Pick<BackendApiClient, 'listProjects' | 'createProject' | 'listTasks' | 'getTask' | 'createTask' | 'updateTask' | 'commitSdkMessage' | 'commitTaskStatusUpdate' | 'commitAgentCommandAck' | 'commitTaskStopAck' | 'matchProjectByPath' | 'getProject' | 'updateProject'>;
|
|
8
8
|
type RealtimeClientLike = Pick<ConductorWebSocketClient, 'registerHandler' | 'connect' | 'disconnect' | 'sendJson'> & Partial<Pick<ConductorWebSocketClient, 'forceReconnect'>>;
|
|
9
9
|
export interface ConductorClientConnectOptions {
|
|
10
10
|
config?: ConductorConfig;
|
|
@@ -126,6 +126,7 @@ export declare class ConductorClient {
|
|
|
126
126
|
createProject(name: string, metadata?: Record<string, unknown>): Promise<Record<string, any>>;
|
|
127
127
|
createProject(payload: Record<string, any>): Promise<Record<string, any>>;
|
|
128
128
|
listTasks(payload?: Record<string, any>): Promise<Record<string, any>>;
|
|
129
|
+
getTask(taskId: string): Promise<Record<string, any>>;
|
|
129
130
|
getLocalProjectRecord(payload?: Record<string, any>): Promise<Record<string, any>>;
|
|
130
131
|
getLocalTaskRecord(payload?: Record<string, any>): Promise<Record<string, any>>;
|
|
131
132
|
bindTaskSession(taskId: string, payload?: Record<string, any>): Promise<Record<string, any>>;
|
package/dist/client.js
CHANGED
|
@@ -376,6 +376,24 @@ export class ConductorClient {
|
|
|
376
376
|
})),
|
|
377
377
|
};
|
|
378
378
|
}
|
|
379
|
+
async getTask(taskId) {
|
|
380
|
+
const normalizedTaskId = String(taskId || '').trim();
|
|
381
|
+
if (!normalizedTaskId) {
|
|
382
|
+
throw new Error('task_id is required');
|
|
383
|
+
}
|
|
384
|
+
const task = await this.backendApi.getTask(normalizedTaskId);
|
|
385
|
+
return {
|
|
386
|
+
id: task.id,
|
|
387
|
+
project_id: task.projectId,
|
|
388
|
+
title: task.title,
|
|
389
|
+
status: task.status,
|
|
390
|
+
backend_type: task.backendType ?? null,
|
|
391
|
+
session_id: task.sessionId ?? null,
|
|
392
|
+
session_file_path: task.sessionFilePath ?? null,
|
|
393
|
+
created_at: task.createdAt ?? null,
|
|
394
|
+
updated_at: task.updatedAt ?? null,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
379
397
|
async getLocalProjectRecord(payload = {}) {
|
|
380
398
|
const projectPath = typeof payload.project_path === 'string' && payload.project_path
|
|
381
399
|
? payload.project_path
|
|
@@ -7,6 +7,13 @@ export interface WorkspaceSnapshot {
|
|
|
7
7
|
repoRoot?: string;
|
|
8
8
|
worktreeBranch?: string;
|
|
9
9
|
lastCommit?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Normalized origin remote URL (lower-cased, trailing `.git` stripped).
|
|
12
|
+
* Only populated when the workspace is a git repository AND has an
|
|
13
|
+
* `origin` remote configured. Used by the web UI to merge same-name
|
|
14
|
+
* projects across daemons that point to the same upstream repo.
|
|
15
|
+
*/
|
|
16
|
+
gitRemoteUrl?: string;
|
|
10
17
|
fileCount?: number;
|
|
11
18
|
}
|
|
12
19
|
export declare class ProjectContext {
|
|
@@ -20,6 +27,25 @@ export declare class ProjectContext {
|
|
|
20
27
|
private gitRoot;
|
|
21
28
|
private gitBranch;
|
|
22
29
|
private gitHead;
|
|
30
|
+
private gitRemoteUrl;
|
|
23
31
|
private gitFileCount;
|
|
24
32
|
private gitListFiles;
|
|
25
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Normalize a git remote URL so that variations of the same upstream
|
|
36
|
+
* compare equal. Examples:
|
|
37
|
+
* git@github.com:foo/bar.git -> github.com/foo/bar
|
|
38
|
+
* https://github.com/foo/bar/ -> github.com/foo/bar
|
|
39
|
+
* ssh://git@github.com/foo/bar -> github.com/foo/bar
|
|
40
|
+
* https://gitea.local:3000/foo/bar.git -> gitea.local:3000/foo/bar
|
|
41
|
+
* https://user:pass@gitlab.com/foo/bar -> gitlab.com/foo/bar
|
|
42
|
+
*
|
|
43
|
+
* Two normalization paths:
|
|
44
|
+
* 1. URL form (anything with `://`): parsed via WHATWG URL so host, port,
|
|
45
|
+
* and path are preserved correctly while user-info is dropped.
|
|
46
|
+
* 2. scp form (`user@host:path`): handled with a small regex; the colon
|
|
47
|
+
* after host becomes a `/` so it lines up with the URL form. IPv6 hosts
|
|
48
|
+
* should use the `ssh://` URL form; git's scp-like syntax does not
|
|
49
|
+
* reliably represent bracketed IPv6 hosts.
|
|
50
|
+
*/
|
|
51
|
+
export declare function normalizeGitRemoteUrl(raw: string | null | undefined): string | null;
|
|
@@ -26,6 +26,7 @@ export class ProjectContext {
|
|
|
26
26
|
repoRoot: guess.repoRoot,
|
|
27
27
|
worktreeBranch: this.gitBranch(guess.repoRoot) ?? undefined,
|
|
28
28
|
lastCommit: this.gitHead(guess.repoRoot) ?? undefined,
|
|
29
|
+
gitRemoteUrl: this.gitRemoteUrl(guess.repoRoot) ?? undefined,
|
|
29
30
|
fileCount: this.gitFileCount(guess.repoRoot) ?? undefined,
|
|
30
31
|
};
|
|
31
32
|
}
|
|
@@ -91,6 +92,15 @@ export class ProjectContext {
|
|
|
91
92
|
return null;
|
|
92
93
|
}
|
|
93
94
|
}
|
|
95
|
+
gitRemoteUrl(repoRoot) {
|
|
96
|
+
try {
|
|
97
|
+
const url = runGit(['config', '--get', 'remote.origin.url'], repoRoot).trim();
|
|
98
|
+
return normalizeGitRemoteUrl(url);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
94
104
|
gitFileCount(repoRoot) {
|
|
95
105
|
try {
|
|
96
106
|
return this.gitListFiles(repoRoot).length;
|
|
@@ -122,6 +132,60 @@ function runGit(args, cwd) {
|
|
|
122
132
|
}
|
|
123
133
|
return result.stdout;
|
|
124
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Normalize a git remote URL so that variations of the same upstream
|
|
137
|
+
* compare equal. Examples:
|
|
138
|
+
* git@github.com:foo/bar.git -> github.com/foo/bar
|
|
139
|
+
* https://github.com/foo/bar/ -> github.com/foo/bar
|
|
140
|
+
* ssh://git@github.com/foo/bar -> github.com/foo/bar
|
|
141
|
+
* https://gitea.local:3000/foo/bar.git -> gitea.local:3000/foo/bar
|
|
142
|
+
* https://user:pass@gitlab.com/foo/bar -> gitlab.com/foo/bar
|
|
143
|
+
*
|
|
144
|
+
* Two normalization paths:
|
|
145
|
+
* 1. URL form (anything with `://`): parsed via WHATWG URL so host, port,
|
|
146
|
+
* and path are preserved correctly while user-info is dropped.
|
|
147
|
+
* 2. scp form (`user@host:path`): handled with a small regex; the colon
|
|
148
|
+
* after host becomes a `/` so it lines up with the URL form. IPv6 hosts
|
|
149
|
+
* should use the `ssh://` URL form; git's scp-like syntax does not
|
|
150
|
+
* reliably represent bracketed IPv6 hosts.
|
|
151
|
+
*/
|
|
152
|
+
export function normalizeGitRemoteUrl(raw) {
|
|
153
|
+
if (!raw)
|
|
154
|
+
return null;
|
|
155
|
+
const trimmed = raw.trim();
|
|
156
|
+
if (!trimmed)
|
|
157
|
+
return null;
|
|
158
|
+
// URL form: ssh://, https://, http://, git://, ftp://, file://, etc.
|
|
159
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
|
160
|
+
let parsed;
|
|
161
|
+
try {
|
|
162
|
+
parsed = new URL(trimmed);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
// `host` keeps the explicit port when present (e.g. `gitea.local:3000`).
|
|
168
|
+
// `pathname` preserves the leading slash so `${host}${pathname}` reads
|
|
169
|
+
// naturally; we drop trailing `/` and trailing `.git` afterwards.
|
|
170
|
+
const path = parsed.pathname.replace(/\/+$/, '');
|
|
171
|
+
const combined = `${parsed.host}${path}`.replace(/\.git$/i, '');
|
|
172
|
+
return combined.toLowerCase() || null;
|
|
173
|
+
}
|
|
174
|
+
// scp form: `[user@]host:path`. The host segment must not contain `/`
|
|
175
|
+
// (otherwise the first `/` would have shown up before the `:` and we'd
|
|
176
|
+
// be looking at a relative-path URL, which git doesn't support as a
|
|
177
|
+
// remote URL anyway).
|
|
178
|
+
const scpMatch = trimmed.match(/^(?:[^@/:]+@)?([^/:]+):(?!\/)(.*)$/);
|
|
179
|
+
if (scpMatch) {
|
|
180
|
+
const host = scpMatch[1];
|
|
181
|
+
const path = scpMatch[2].replace(/\/+$/, '').replace(/\.git$/i, '');
|
|
182
|
+
return `${host}/${path}`.toLowerCase() || null;
|
|
183
|
+
}
|
|
184
|
+
// Anything else (e.g. plain path) — give up so we don't fabricate a
|
|
185
|
+
// misleading "normalized" form that could accidentally collide with a
|
|
186
|
+
// real upstream URL.
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
125
189
|
function* walkFiles(root) {
|
|
126
190
|
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
127
191
|
for (const entry of entries) {
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/ws/client.js
CHANGED
|
@@ -87,13 +87,27 @@ export class ConductorWebSocketClient {
|
|
|
87
87
|
await this.terminateConnection(conn);
|
|
88
88
|
}
|
|
89
89
|
async sendJson(payload) {
|
|
90
|
+
// Once disconnect() has flipped `stop`, sending is a no-op. Callers
|
|
91
|
+
// commonly queue sends via fire-and-forget helpers (`.catch(() => {})`),
|
|
92
|
+
// but late rejections that slip through were being promoted to
|
|
93
|
+
// unhandledRejection / uncaughtException and crashed the daemon during
|
|
94
|
+
// restart. Treat a send-after-disconnect as a silent no-op instead.
|
|
95
|
+
if (this.stop) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
90
98
|
await this.ensureConnection();
|
|
99
|
+
if (this.stop) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
91
102
|
await this.sendWithReconnect(JSON.stringify(payload));
|
|
92
103
|
}
|
|
93
104
|
async ensureConnection() {
|
|
94
105
|
if (this.conn && !this.isConnectionClosed(this.conn)) {
|
|
95
106
|
return;
|
|
96
107
|
}
|
|
108
|
+
if (this.stop) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
97
111
|
await this.openConnection(true);
|
|
98
112
|
}
|
|
99
113
|
async openConnection(force = false) {
|
|
@@ -261,10 +275,19 @@ export class ConductorWebSocketClient {
|
|
|
261
275
|
let attemptedReconnect = false;
|
|
262
276
|
// Loop at most twice: initial send, then one reconnect + retry
|
|
263
277
|
while (true) {
|
|
278
|
+
if (this.stop) {
|
|
279
|
+
// `disconnect()` has been called; swallow the send to avoid raising
|
|
280
|
+
// "WebSocket not connected" from an already-silenced client. Any
|
|
281
|
+
// send queued before disconnect() finishes is best-effort.
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
264
284
|
const conn = this.conn;
|
|
265
285
|
if (!conn || this.isConnectionClosed(conn)) {
|
|
266
286
|
await this.openConnection(true);
|
|
267
287
|
}
|
|
288
|
+
if (this.stop) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
268
291
|
if (!this.conn || this.isConnectionClosed(this.conn)) {
|
|
269
292
|
throw new Error('WebSocket not connected');
|
|
270
293
|
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@love-moon/conductor-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/lovemoon-ai/conductor.git"
|
|
7
|
+
},
|
|
4
8
|
"type": "module",
|
|
5
9
|
"main": "dist/index.js",
|
|
6
10
|
"types": "dist/index.d.ts",
|
|
7
11
|
"files": [
|
|
8
|
-
"dist"
|
|
12
|
+
"dist",
|
|
13
|
+
"CHANGELOG.md"
|
|
9
14
|
],
|
|
10
15
|
"publishConfig": {
|
|
11
|
-
"access": "public"
|
|
16
|
+
"access": "public",
|
|
17
|
+
"provenance": true
|
|
12
18
|
},
|
|
13
19
|
"scripts": {
|
|
14
20
|
"build": "tsc -p tsconfig.json",
|
|
@@ -27,5 +33,5 @@
|
|
|
27
33
|
"typescript": "^5.6.3",
|
|
28
34
|
"vitest": "^2.1.4"
|
|
29
35
|
},
|
|
30
|
-
"gitCommitId": "
|
|
36
|
+
"gitCommitId": "03b4582"
|
|
31
37
|
}
|