@love-moon/conductor-sdk 0.2.42 → 0.3.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/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 +8 -3
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { ProjectsApi, type Project, type CreateProjectInput, type ListProjectsOptions, type ResolveProjectInput, } from './projects.js';
|
|
2
|
+
export { IssuesApi, type Issue, type ListIssuesInput, type CreateIssueInput, type UpdateIssueInput, type UpdateIssueStatusOptions, } from './issues.js';
|
|
3
|
+
export { TasksApi, type Task, type Message, type ListTasksInput, type SendTaskMessageOptions, type ListTaskMessagesOptions, } from './tasks.js';
|
|
4
|
+
export { ProjectNotResolvedError, buildAuditMetadata, isBackendApiError, type ApiClient, type SdkClientOptions, } from './shared.js';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ApiClient, SdkClientOptions } from './shared.js';
|
|
2
|
+
export interface Issue {
|
|
3
|
+
id: string;
|
|
4
|
+
projectId: string;
|
|
5
|
+
title: string;
|
|
6
|
+
description?: string | null;
|
|
7
|
+
status: string;
|
|
8
|
+
priority?: string | null;
|
|
9
|
+
position?: number | null;
|
|
10
|
+
metadata?: Record<string, unknown> | null;
|
|
11
|
+
createdAt?: string | null;
|
|
12
|
+
updatedAt?: string | null;
|
|
13
|
+
/** Unmodified server payload, used by callers that need fields we haven't typed. */
|
|
14
|
+
raw: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
export interface ListIssuesInput {
|
|
17
|
+
projectId: string;
|
|
18
|
+
status?: string | string[];
|
|
19
|
+
limit?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface CreateIssueInput {
|
|
22
|
+
projectId: string;
|
|
23
|
+
title: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
priority?: string;
|
|
26
|
+
status?: string;
|
|
27
|
+
metadata?: Record<string, unknown>;
|
|
28
|
+
clientRequestId?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface UpdateIssueInput {
|
|
31
|
+
title?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
priority?: string;
|
|
34
|
+
status?: string;
|
|
35
|
+
metadata?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
export interface UpdateIssueStatusOptions {
|
|
38
|
+
evidence?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Extra metadata merged into the patch; caller's `audit.*` fields win over
|
|
41
|
+
* SDK defaults (see shared.ts). Useful for the CLI to thread its
|
|
42
|
+
* `audit: { actor: "cli", ... }` namespace through without needing a custom
|
|
43
|
+
* call path.
|
|
44
|
+
*/
|
|
45
|
+
metadata?: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
export declare class IssuesApi {
|
|
48
|
+
private readonly client;
|
|
49
|
+
private readonly options;
|
|
50
|
+
constructor(client: ApiClient, options?: SdkClientOptions);
|
|
51
|
+
listIssues(input: ListIssuesInput): Promise<Issue[]>;
|
|
52
|
+
getIssue(issueId: string): Promise<Issue>;
|
|
53
|
+
createIssue(input: CreateIssueInput): Promise<Issue>;
|
|
54
|
+
updateIssue(issueId: string, patch: UpdateIssueInput): Promise<Issue>;
|
|
55
|
+
updateIssueStatus(issueId: string, status: string, options?: UpdateIssueStatusOptions): Promise<Issue>;
|
|
56
|
+
deleteIssue(issueId: string): Promise<void>;
|
|
57
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { buildAuditMetadata } from './shared.js';
|
|
2
|
+
const normalizeIssue = (payload) => {
|
|
3
|
+
const id = payload.id ? String(payload.id) : '';
|
|
4
|
+
if (!id) {
|
|
5
|
+
throw new Error('Issue payload missing id');
|
|
6
|
+
}
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
projectId: String(payload.projectId ?? payload.project_id ?? ''),
|
|
10
|
+
title: String(payload.title ?? ''),
|
|
11
|
+
description: payload.description ?? null,
|
|
12
|
+
status: String(payload.status ?? ''),
|
|
13
|
+
priority: payload.priority ?? null,
|
|
14
|
+
position: typeof payload.position === 'number' ? payload.position : null,
|
|
15
|
+
metadata: payload.metadata && typeof payload.metadata === 'object' && !Array.isArray(payload.metadata)
|
|
16
|
+
? payload.metadata
|
|
17
|
+
: null,
|
|
18
|
+
createdAt: payload.createdAt ?? payload.created_at ?? null,
|
|
19
|
+
updatedAt: payload.updatedAt ?? payload.updated_at ?? null,
|
|
20
|
+
raw: payload,
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
const extractIssue = (payload) => {
|
|
24
|
+
// The PATCH /issues/[issueId] route returns `{ issue, activeTask, ... }`.
|
|
25
|
+
// Other routes return the issue itself. Normalize both shapes here so
|
|
26
|
+
// facade callers always get the issue back regardless of source endpoint.
|
|
27
|
+
if (payload && typeof payload === 'object' && payload.issue && typeof payload.issue === 'object') {
|
|
28
|
+
return payload.issue;
|
|
29
|
+
}
|
|
30
|
+
return payload;
|
|
31
|
+
};
|
|
32
|
+
const mergeMetadataAtPath = (target, pathParts, value) => {
|
|
33
|
+
if (pathParts.length === 0) {
|
|
34
|
+
return target;
|
|
35
|
+
}
|
|
36
|
+
const [head, ...rest] = pathParts;
|
|
37
|
+
const next = { ...target };
|
|
38
|
+
if (rest.length === 0) {
|
|
39
|
+
next[head] = value;
|
|
40
|
+
return next;
|
|
41
|
+
}
|
|
42
|
+
const existing = target[head];
|
|
43
|
+
const child = existing && typeof existing === 'object' && !Array.isArray(existing)
|
|
44
|
+
? existing
|
|
45
|
+
: {};
|
|
46
|
+
next[head] = mergeMetadataAtPath(child, rest, value);
|
|
47
|
+
return next;
|
|
48
|
+
};
|
|
49
|
+
export class IssuesApi {
|
|
50
|
+
client;
|
|
51
|
+
options;
|
|
52
|
+
constructor(client, options = {}) {
|
|
53
|
+
this.client = client;
|
|
54
|
+
this.options = options;
|
|
55
|
+
}
|
|
56
|
+
async listIssues(input) {
|
|
57
|
+
if (!input.projectId) {
|
|
58
|
+
throw new Error('projectId is required');
|
|
59
|
+
}
|
|
60
|
+
// The server `/api/issues` route accepts a single `status` query string;
|
|
61
|
+
// it doesn't (yet) parse comma-separated lists, so we filter on the
|
|
62
|
+
// client side for the multi-status case to match RFC 0025 §3 wording.
|
|
63
|
+
const statusFilter = Array.isArray(input.status)
|
|
64
|
+
? input.status.map((value) => String(value).trim()).filter(Boolean)
|
|
65
|
+
: input.status
|
|
66
|
+
? [String(input.status).trim()].filter(Boolean)
|
|
67
|
+
: [];
|
|
68
|
+
const issues = await this.client.listIssues({
|
|
69
|
+
projectId: input.projectId,
|
|
70
|
+
status: statusFilter.length === 1 ? statusFilter[0] : undefined,
|
|
71
|
+
});
|
|
72
|
+
let normalized = issues.map((entry) => normalizeIssue(entry));
|
|
73
|
+
if (statusFilter.length > 1) {
|
|
74
|
+
const statusSet = new Set(statusFilter);
|
|
75
|
+
normalized = normalized.filter((issue) => statusSet.has(issue.status));
|
|
76
|
+
}
|
|
77
|
+
if (typeof input.limit === 'number' && Number.isFinite(input.limit)) {
|
|
78
|
+
normalized = normalized.slice(0, Math.max(0, Math.floor(input.limit)));
|
|
79
|
+
}
|
|
80
|
+
return normalized;
|
|
81
|
+
}
|
|
82
|
+
async getIssue(issueId) {
|
|
83
|
+
const trimmed = String(issueId ?? '').trim();
|
|
84
|
+
if (!trimmed) {
|
|
85
|
+
throw new Error('issueId is required');
|
|
86
|
+
}
|
|
87
|
+
const payload = await this.client.getIssue(trimmed);
|
|
88
|
+
return normalizeIssue(extractIssue(payload));
|
|
89
|
+
}
|
|
90
|
+
async createIssue(input) {
|
|
91
|
+
if (!input.projectId) {
|
|
92
|
+
throw new Error('projectId is required');
|
|
93
|
+
}
|
|
94
|
+
if (!input.title) {
|
|
95
|
+
throw new Error('title is required');
|
|
96
|
+
}
|
|
97
|
+
const userMetadata = input.clientRequestId
|
|
98
|
+
? { clientRequestId: input.clientRequestId, ...(input.metadata ?? {}) }
|
|
99
|
+
: input.metadata;
|
|
100
|
+
const metadata = buildAuditMetadata(userMetadata, this.options);
|
|
101
|
+
const params = {
|
|
102
|
+
projectId: input.projectId,
|
|
103
|
+
title: input.title,
|
|
104
|
+
metadata,
|
|
105
|
+
};
|
|
106
|
+
if (input.description !== undefined) {
|
|
107
|
+
params.description = input.description;
|
|
108
|
+
}
|
|
109
|
+
if (input.priority !== undefined) {
|
|
110
|
+
params.priority = input.priority;
|
|
111
|
+
}
|
|
112
|
+
if (input.status !== undefined) {
|
|
113
|
+
params.status = input.status;
|
|
114
|
+
}
|
|
115
|
+
const payload = await this.client.createIssue(params);
|
|
116
|
+
return normalizeIssue(extractIssue(payload));
|
|
117
|
+
}
|
|
118
|
+
async updateIssue(issueId, patch) {
|
|
119
|
+
const trimmed = String(issueId ?? '').trim();
|
|
120
|
+
if (!trimmed) {
|
|
121
|
+
throw new Error('issueId is required');
|
|
122
|
+
}
|
|
123
|
+
const params = {};
|
|
124
|
+
if (patch.title !== undefined) {
|
|
125
|
+
params.title = patch.title;
|
|
126
|
+
}
|
|
127
|
+
if (patch.description !== undefined) {
|
|
128
|
+
params.description = patch.description;
|
|
129
|
+
}
|
|
130
|
+
if (patch.priority !== undefined) {
|
|
131
|
+
params.priority = patch.priority;
|
|
132
|
+
}
|
|
133
|
+
if (patch.status !== undefined) {
|
|
134
|
+
params.status = patch.status;
|
|
135
|
+
}
|
|
136
|
+
// We always include audit metadata on writes. If the caller passes a
|
|
137
|
+
// `metadata` patch, we merge our audit keys with theirs (caller wins).
|
|
138
|
+
params.metadata = buildAuditMetadata(patch.metadata, this.options);
|
|
139
|
+
const payload = await this.client.patchIssue(trimmed, params);
|
|
140
|
+
return normalizeIssue(extractIssue(payload));
|
|
141
|
+
}
|
|
142
|
+
async updateIssueStatus(issueId, status, options = {}) {
|
|
143
|
+
const callerMetadata = options.metadata && typeof options.metadata === 'object' && !Array.isArray(options.metadata)
|
|
144
|
+
? { ...options.metadata }
|
|
145
|
+
: null;
|
|
146
|
+
if (!options.evidence) {
|
|
147
|
+
return this.updateIssue(issueId, {
|
|
148
|
+
status,
|
|
149
|
+
...(callerMetadata ? { metadata: callerMetadata } : {}),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// Pull the existing metadata so we can merge `qa.evidence` without
|
|
153
|
+
// clobbering unrelated keys. The PATCH body fully replaces metadata, so
|
|
154
|
+
// we have to round-trip the current state (matches RFC 0025 §3 wording
|
|
155
|
+
// for `done --evidence`).
|
|
156
|
+
const existing = await this.getIssue(issueId);
|
|
157
|
+
const existingMetadata = existing.metadata && typeof existing.metadata === 'object'
|
|
158
|
+
? existing.metadata
|
|
159
|
+
: {};
|
|
160
|
+
// Merge order: existing DB metadata < caller's patch metadata < qa.evidence.
|
|
161
|
+
// The `audit` key under `callerMetadata` is preserved when present so the
|
|
162
|
+
// CLI's `actor:"cli"` namespace flows through to `updateIssue` (and from
|
|
163
|
+
// there to `buildAuditMetadata`, where caller wins).
|
|
164
|
+
const baseMetadata = callerMetadata
|
|
165
|
+
? { ...existingMetadata, ...callerMetadata }
|
|
166
|
+
: { ...existingMetadata };
|
|
167
|
+
const merged = mergeMetadataAtPath(baseMetadata, ['qa', 'evidence'], options.evidence);
|
|
168
|
+
return this.updateIssue(issueId, { status, metadata: merged });
|
|
169
|
+
}
|
|
170
|
+
async deleteIssue(issueId) {
|
|
171
|
+
const trimmed = String(issueId ?? '').trim();
|
|
172
|
+
if (!trimmed) {
|
|
173
|
+
throw new Error('issueId is required');
|
|
174
|
+
}
|
|
175
|
+
await this.client.deleteIssue(trimmed);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ApiClient, SdkClientOptions } from './shared.js';
|
|
2
|
+
/**
|
|
3
|
+
* Project record as returned by the REST API. We deliberately don't subclass
|
|
4
|
+
* `ProjectSummary` here because the REST surface returns more fields (notably
|
|
5
|
+
* `hidden`, `isDefault`, `metadata`) than the WS-flavored summary, and we
|
|
6
|
+
* want CLI / AI consumers to see the full picture.
|
|
7
|
+
*/
|
|
8
|
+
export interface Project {
|
|
9
|
+
id: string;
|
|
10
|
+
name?: string | null;
|
|
11
|
+
daemonHost?: string | null;
|
|
12
|
+
workspacePath?: string | null;
|
|
13
|
+
repoRoot?: string | null;
|
|
14
|
+
worktreeBranch?: string | null;
|
|
15
|
+
lastCommit?: string | null;
|
|
16
|
+
fileCount?: number | null;
|
|
17
|
+
isDefault: boolean;
|
|
18
|
+
hidden: boolean;
|
|
19
|
+
hiddenAt?: string | null;
|
|
20
|
+
metadata?: Record<string, unknown> | null;
|
|
21
|
+
createdAt?: string | null;
|
|
22
|
+
updatedAt?: string | null;
|
|
23
|
+
/**
|
|
24
|
+
* The unmodified server payload, kept for callers that need fields the
|
|
25
|
+
* typed surface hasn't promoted yet (e.g. `gitRemoteUrl`, `mergeOptOut`).
|
|
26
|
+
*/
|
|
27
|
+
raw: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
export interface ResolveProjectInput {
|
|
30
|
+
id?: string;
|
|
31
|
+
name?: string;
|
|
32
|
+
cwd?: string;
|
|
33
|
+
env?: NodeJS.ProcessEnv;
|
|
34
|
+
/**
|
|
35
|
+
* Daemon host to use when resolving by `cwd`. Required for the path-match
|
|
36
|
+
* fallback (the server keys workspace bindings on `(daemonHost, path)`).
|
|
37
|
+
* Defaults to `env.CONDUCTOR_DAEMON_NAME` if unset.
|
|
38
|
+
*/
|
|
39
|
+
daemonHost?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface CreateProjectInput {
|
|
42
|
+
name?: string;
|
|
43
|
+
workspacePath?: string;
|
|
44
|
+
daemonHost?: string;
|
|
45
|
+
isDefault?: boolean;
|
|
46
|
+
clientRequestId?: string;
|
|
47
|
+
metadata?: Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
export interface ListProjectsOptions {
|
|
50
|
+
includeHidden?: boolean;
|
|
51
|
+
}
|
|
52
|
+
export interface ProjectWriteOptions {
|
|
53
|
+
/**
|
|
54
|
+
* Extra metadata merged into the request (caller-supplied audit fields
|
|
55
|
+
* under `metadata.audit` win over the SDK defaults, see shared.ts).
|
|
56
|
+
*/
|
|
57
|
+
metadata?: Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
export declare class ProjectsApi {
|
|
60
|
+
private readonly client;
|
|
61
|
+
private readonly options;
|
|
62
|
+
constructor(client: ApiClient, options?: SdkClientOptions);
|
|
63
|
+
listProjects(options?: ListProjectsOptions): Promise<Project[]>;
|
|
64
|
+
getProject(idOrName: string): Promise<Project>;
|
|
65
|
+
createProject(input: CreateProjectInput): Promise<Project>;
|
|
66
|
+
setDefaultProject(idOrName: string, options?: ProjectWriteOptions): Promise<Project>;
|
|
67
|
+
setProjectHidden(idOrName: string, hidden: boolean, options?: ProjectWriteOptions): Promise<Project>;
|
|
68
|
+
resolveProject(input?: ResolveProjectInput): Promise<Project>;
|
|
69
|
+
private findUniqueByName;
|
|
70
|
+
private resolveById;
|
|
71
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { BackendApiError } from '../backend/index.js';
|
|
2
|
+
import { ProjectNotResolvedError, buildAuditMetadata, } from './shared.js';
|
|
3
|
+
const stripTrailingSlash = (value) => value.replace(/\/+$/, '');
|
|
4
|
+
/**
|
|
5
|
+
* Heuristic for "this string looks like a project id, not a name".
|
|
6
|
+
*
|
|
7
|
+
* The server uses cuid-like ids (e.g. `clxk1y0fz0000abc123`) and UUIDs. Names
|
|
8
|
+
* are arbitrary strings, but in practice users name their projects with
|
|
9
|
+
* spaces, slashes, accents, etc. — i.e. they almost never match the
|
|
10
|
+
* `^[a-zA-Z0-9_-]{12,}$` shape we treat as an id below. This lets the SDK
|
|
11
|
+
* skip a redundant `getProject(id)` round-trip when the caller (CLI) has
|
|
12
|
+
* already resolved to an id (review L-NEW-1). On a miss we fall back to the
|
|
13
|
+
* full resolve, so the only downside of a false positive is one extra
|
|
14
|
+
* 404 → re-list round trip.
|
|
15
|
+
*/
|
|
16
|
+
const looksLikeProjectId = (value) => {
|
|
17
|
+
if (!value || typeof value !== 'string')
|
|
18
|
+
return false;
|
|
19
|
+
const trimmed = value.trim();
|
|
20
|
+
if (trimmed.length < 12)
|
|
21
|
+
return false;
|
|
22
|
+
return /^[A-Za-z0-9_-]+$/.test(trimmed);
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Local longest-prefix match used by `resolveProject` step 4 when no daemon
|
|
26
|
+
* host is available to delegate to the server-side matcher. We treat each
|
|
27
|
+
* project's `workspacePath` as a prefix and pick the longest one that's a
|
|
28
|
+
* proper directory ancestor of `cwd`. Mirrors the server's
|
|
29
|
+
* match-path/route.ts logic for the simple single-daemon case.
|
|
30
|
+
*/
|
|
31
|
+
const matchProjectByLocalPrefix = (projects, cwd) => {
|
|
32
|
+
const target = stripTrailingSlash(cwd.trim());
|
|
33
|
+
if (!target)
|
|
34
|
+
return null;
|
|
35
|
+
let best = null;
|
|
36
|
+
for (const project of projects) {
|
|
37
|
+
const path = project.workspacePath;
|
|
38
|
+
if (typeof path !== 'string' || !path.trim())
|
|
39
|
+
continue;
|
|
40
|
+
const normalized = stripTrailingSlash(path.trim());
|
|
41
|
+
if (target === normalized || target.startsWith(`${normalized}/`)) {
|
|
42
|
+
if (!best || normalized.length > best.length) {
|
|
43
|
+
best = { project, length: normalized.length };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return best?.project ?? null;
|
|
48
|
+
};
|
|
49
|
+
const normalizeProject = (payload) => {
|
|
50
|
+
const id = payload.id ? String(payload.id) : '';
|
|
51
|
+
if (!id) {
|
|
52
|
+
throw new Error('Project payload missing id');
|
|
53
|
+
}
|
|
54
|
+
const hiddenAt = payload.hiddenAt ?? payload.hidden_at ?? null;
|
|
55
|
+
return {
|
|
56
|
+
id,
|
|
57
|
+
name: payload.name ?? null,
|
|
58
|
+
daemonHost: payload.daemonHost ?? payload.daemon_host ?? null,
|
|
59
|
+
workspacePath: payload.workspacePath ?? payload.workspace_path ?? null,
|
|
60
|
+
repoRoot: payload.repoRoot ?? payload.repo_root ?? null,
|
|
61
|
+
worktreeBranch: payload.worktreeBranch ?? payload.worktree_branch ?? null,
|
|
62
|
+
lastCommit: payload.lastCommit ?? payload.last_commit ?? null,
|
|
63
|
+
fileCount: payload.fileCount ?? payload.file_count ?? null,
|
|
64
|
+
isDefault: Boolean(payload.isDefault ?? payload.is_default ?? false),
|
|
65
|
+
hidden: Boolean(payload.hidden ?? Boolean(hiddenAt)),
|
|
66
|
+
hiddenAt: hiddenAt ?? null,
|
|
67
|
+
metadata: payload.metadata && typeof payload.metadata === 'object' && !Array.isArray(payload.metadata)
|
|
68
|
+
? payload.metadata
|
|
69
|
+
: null,
|
|
70
|
+
createdAt: payload.createdAt ?? payload.created_at ?? null,
|
|
71
|
+
updatedAt: payload.updatedAt ?? payload.updated_at ?? null,
|
|
72
|
+
raw: payload,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
export class ProjectsApi {
|
|
76
|
+
client;
|
|
77
|
+
options;
|
|
78
|
+
constructor(client, options = {}) {
|
|
79
|
+
this.client = client;
|
|
80
|
+
this.options = options;
|
|
81
|
+
}
|
|
82
|
+
async listProjects(options = {}) {
|
|
83
|
+
const summaries = await this.client.listProjects();
|
|
84
|
+
// `BackendApiClient.listProjects` already pulls /projects, but it loses
|
|
85
|
+
// the `hidden` and `isDefault` fields when collapsing to ProjectSummary.
|
|
86
|
+
// Promote each summary's `asObject()` payload back to a Project so we
|
|
87
|
+
// surface those fields. Hidden projects are filtered out here unless
|
|
88
|
+
// `includeHidden: true` is passed (RFC 0025 §3).
|
|
89
|
+
const promoted = summaries.map((summary) => normalizeProject(
|
|
90
|
+
// ProjectSummary `asObject()` always exists in the backend module.
|
|
91
|
+
summary.asObject ? summary.asObject() : summary));
|
|
92
|
+
if (options.includeHidden) {
|
|
93
|
+
return promoted;
|
|
94
|
+
}
|
|
95
|
+
return promoted.filter((project) => !project.hidden);
|
|
96
|
+
}
|
|
97
|
+
async getProject(idOrName) {
|
|
98
|
+
const trimmed = String(idOrName ?? '').trim();
|
|
99
|
+
if (!trimmed) {
|
|
100
|
+
throw new Error('idOrName is required');
|
|
101
|
+
}
|
|
102
|
+
// First try by id; if 404, fall back to a unique name match against the
|
|
103
|
+
// project list. Splitting these two paths keeps the happy-path single
|
|
104
|
+
// round-trip and only pulls the full list on miss.
|
|
105
|
+
try {
|
|
106
|
+
const payload = await this.client.getProject(trimmed);
|
|
107
|
+
return normalizeProject(payload);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (!(error instanceof BackendApiError) || error.statusCode !== 404) {
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return this.findUniqueByName(trimmed);
|
|
115
|
+
}
|
|
116
|
+
async createProject(input) {
|
|
117
|
+
const metadata = buildAuditMetadata(
|
|
118
|
+
// `clientRequestId` is stamped onto metadata per RFC 0025 §5 idempotency
|
|
119
|
+
// spec; the server treats it as the idempotency key.
|
|
120
|
+
input.clientRequestId
|
|
121
|
+
? { clientRequestId: input.clientRequestId, ...(input.metadata ?? {}) }
|
|
122
|
+
: input.metadata, this.options);
|
|
123
|
+
const params = {};
|
|
124
|
+
if (input.name) {
|
|
125
|
+
params.name = input.name;
|
|
126
|
+
}
|
|
127
|
+
if (input.isDefault) {
|
|
128
|
+
params.isDefault = true;
|
|
129
|
+
params.is_default = true;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
// Non-default projects need a binding. The server validates with the
|
|
133
|
+
// daemon synchronously when `daemonHost + workspacePath` are present,
|
|
134
|
+
// which is the path AI / CLI will use most of the time.
|
|
135
|
+
if (input.workspacePath) {
|
|
136
|
+
params.workspacePath = input.workspacePath;
|
|
137
|
+
}
|
|
138
|
+
if (input.daemonHost) {
|
|
139
|
+
params.daemonHost = input.daemonHost;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
params.metadata = metadata;
|
|
143
|
+
const summary = await this.client.createProject(params);
|
|
144
|
+
return normalizeProject(summary.asObject ? summary.asObject() : summary);
|
|
145
|
+
}
|
|
146
|
+
async setDefaultProject(idOrName, options = {}) {
|
|
147
|
+
// Short-circuit the pre-flight `getProject` when the caller already has
|
|
148
|
+
// a project id (review L-NEW-1). On a miss the server returns 404 and
|
|
149
|
+
// the caller can retry via `getProject(idOrName)` themselves, but the
|
|
150
|
+
// common CLI path passes the id directly.
|
|
151
|
+
const targetId = looksLikeProjectId(idOrName) ? idOrName.trim() : (await this.resolveById(idOrName)).id;
|
|
152
|
+
const metadata = buildAuditMetadata(options.metadata, this.options);
|
|
153
|
+
const payload = await this.client.setDefaultProject(targetId, { metadata });
|
|
154
|
+
return normalizeProject(payload);
|
|
155
|
+
}
|
|
156
|
+
async setProjectHidden(idOrName, hidden, options = {}) {
|
|
157
|
+
const targetId = looksLikeProjectId(idOrName) ? idOrName.trim() : (await this.resolveById(idOrName)).id;
|
|
158
|
+
const metadata = buildAuditMetadata(options.metadata, this.options);
|
|
159
|
+
const payload = await this.client.patchProjectByQuery(targetId, { hidden, metadata });
|
|
160
|
+
return normalizeProject(payload);
|
|
161
|
+
}
|
|
162
|
+
async resolveProject(input = {}) {
|
|
163
|
+
const env = input.env ?? this.options.env ?? process.env;
|
|
164
|
+
// 1. explicit id wins
|
|
165
|
+
if (input.id && input.id.trim()) {
|
|
166
|
+
try {
|
|
167
|
+
const payload = await this.client.getProject(input.id.trim());
|
|
168
|
+
return normalizeProject(payload);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (error instanceof BackendApiError && error.statusCode === 404) {
|
|
172
|
+
throw new ProjectNotResolvedError(`Project not found: id=${input.id}`, 'not_found');
|
|
173
|
+
}
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// 2. explicit name
|
|
178
|
+
if (input.name && input.name.trim()) {
|
|
179
|
+
return this.findUniqueByName(input.name.trim());
|
|
180
|
+
}
|
|
181
|
+
// 3. env override
|
|
182
|
+
const envId = env.CONDUCTOR_PROJECT_ID;
|
|
183
|
+
if (typeof envId === 'string' && envId.trim()) {
|
|
184
|
+
try {
|
|
185
|
+
const payload = await this.client.getProject(envId.trim());
|
|
186
|
+
return normalizeProject(payload);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (error instanceof BackendApiError && error.statusCode === 404) {
|
|
190
|
+
throw new ProjectNotResolvedError(`Project not found from CONDUCTOR_PROJECT_ID=${envId}`, 'not_found');
|
|
191
|
+
}
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// 4. cwd path match. We try two routes:
|
|
196
|
+
// a. If a daemon host is known, delegate to the server-side matcher
|
|
197
|
+
// (it understands binding-pending / repoRoot / multi-daemon rules).
|
|
198
|
+
// b. Otherwise, do a local longest-prefix match against the project
|
|
199
|
+
// list. This keeps the CLI usable when the user hasn't set
|
|
200
|
+
// CONDUCTOR_DAEMON_NAME — review finding M1: never silently skip
|
|
201
|
+
// cwd resolution just because a daemon hint is missing.
|
|
202
|
+
const projects = await this.listProjects({ includeHidden: true });
|
|
203
|
+
if (input.cwd && input.cwd.trim()) {
|
|
204
|
+
const daemonHost = input.daemonHost ?? env.CONDUCTOR_DAEMON_NAME ?? env.CONDUCTOR_AGENT_NAME;
|
|
205
|
+
if (daemonHost && daemonHost.trim()) {
|
|
206
|
+
const result = await this.client.matchProjectByPath({
|
|
207
|
+
daemon_host: daemonHost.trim(),
|
|
208
|
+
path: input.cwd,
|
|
209
|
+
});
|
|
210
|
+
if (result.project) {
|
|
211
|
+
return normalizeProject(result.project.asObject());
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const localMatch = matchProjectByLocalPrefix(projects, input.cwd);
|
|
216
|
+
if (localMatch) {
|
|
217
|
+
return localMatch;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// 5. default project — derived from the list we already fetched in (4).
|
|
222
|
+
// Every healthy account has at most one default; we return it when
|
|
223
|
+
// found.
|
|
224
|
+
const defaults = projects.filter((project) => project.isDefault);
|
|
225
|
+
if (defaults.length === 1) {
|
|
226
|
+
return defaults[0];
|
|
227
|
+
}
|
|
228
|
+
if (defaults.length > 1) {
|
|
229
|
+
throw new ProjectNotResolvedError('Multiple default projects exist; specify --project explicitly', 'multiple_matches');
|
|
230
|
+
}
|
|
231
|
+
throw new ProjectNotResolvedError('No project specified and no default project found. Pass --project <id|name>, set CONDUCTOR_PROJECT_ID, cd into a project directory, or configure a default project.', 'no_signal');
|
|
232
|
+
}
|
|
233
|
+
async findUniqueByName(name) {
|
|
234
|
+
const projects = await this.listProjects({ includeHidden: true });
|
|
235
|
+
const matches = projects.filter((project) => project.name === name);
|
|
236
|
+
if (matches.length === 1) {
|
|
237
|
+
return matches[0];
|
|
238
|
+
}
|
|
239
|
+
if (matches.length === 0) {
|
|
240
|
+
throw new ProjectNotResolvedError(`No project found with name "${name}"`, 'not_found');
|
|
241
|
+
}
|
|
242
|
+
throw new ProjectNotResolvedError(`Multiple projects found with name "${name}" (${matches.length}); pass --project <id> to disambiguate`, 'multiple_matches');
|
|
243
|
+
}
|
|
244
|
+
async resolveById(idOrName) {
|
|
245
|
+
return this.getProject(idOrName);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { BackendApiClient, BackendApiError } from '../backend/index.js';
|
|
2
|
+
export type ApiClient = Pick<BackendApiClient, 'listProjects' | 'createProject' | 'getProject' | 'updateProject' | 'patchProjectByQuery' | 'setDefaultProject' | 'matchProjectByPath' | 'listIssues' | 'getIssue' | 'createIssue' | 'patchIssue' | 'deleteIssue' | 'listTasks' | 'getTask' | 'listTaskMessages' | 'postTaskMessage'>;
|
|
3
|
+
export interface SdkClientOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Optional override for the SDK version stamped onto audit metadata. Falls
|
|
6
|
+
* back to the package.json version. Tests may want to pin this so output is
|
|
7
|
+
* stable.
|
|
8
|
+
*/
|
|
9
|
+
sdkVersion?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Optional environment provider for `CONDUCTOR_INVOKED_BY` lookup. Defaults
|
|
12
|
+
* to `process.env`. Useful for tests that want to inject env without
|
|
13
|
+
* mutating globals.
|
|
14
|
+
*/
|
|
15
|
+
env?: NodeJS.ProcessEnv;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build the metadata payload every SDK write attaches to the request.
|
|
19
|
+
*
|
|
20
|
+
* Audit fields are namespaced under `metadata.audit` (RFC 0025 §5.2 + review
|
|
21
|
+
* findings M3/H1). This avoids two failure modes the original top-level shape
|
|
22
|
+
* had:
|
|
23
|
+
* 1. A caller passing `--metadata-json '{"actor":"system"}'` could spoof the
|
|
24
|
+
* audit actor by colliding with our top-level key.
|
|
25
|
+
* 2. Server-side strippers had to know the full audit-key list to filter.
|
|
26
|
+
*
|
|
27
|
+
* Merge order:
|
|
28
|
+
* - Non-audit user keys are passed through verbatim.
|
|
29
|
+
* - Audit defaults (`actor:"sdk"`, `sdkVersion`, `invokedBy`) are layered
|
|
30
|
+
* UNDER the caller's `audit` (so the CLI's `actor:"cli"` wins).
|
|
31
|
+
* - The caller's top-level `audit` key, if any, takes precedence over the
|
|
32
|
+
* SDK defaults but never replaces all of them — `cliVersion`, etc., are
|
|
33
|
+
* additive.
|
|
34
|
+
*/
|
|
35
|
+
export declare const buildAuditMetadata: (userMetadata: Record<string, unknown> | undefined, options?: SdkClientOptions) => Record<string, unknown>;
|
|
36
|
+
export declare class ProjectNotResolvedError extends Error {
|
|
37
|
+
readonly reason: 'not_found' | 'multiple_matches' | 'no_default' | 'no_signal';
|
|
38
|
+
constructor(message: string, reason: 'not_found' | 'multiple_matches' | 'no_default' | 'no_signal');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Best-effort mapping for HTTP-shaped failures so the CLI exit-code matrix
|
|
42
|
+
* (RFC 0025 §4) has a stable signal. Callers can choose to inspect
|
|
43
|
+
* `BackendApiError.statusCode` directly instead.
|
|
44
|
+
*/
|
|
45
|
+
export declare const isBackendApiError: (value: unknown) => value is BackendApiError;
|
|
@@ -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,6 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@love-moon/conductor-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -8,7 +12,8 @@
|
|
|
8
12
|
"dist"
|
|
9
13
|
],
|
|
10
14
|
"publishConfig": {
|
|
11
|
-
"access": "public"
|
|
15
|
+
"access": "public",
|
|
16
|
+
"provenance": true
|
|
12
17
|
},
|
|
13
18
|
"scripts": {
|
|
14
19
|
"build": "tsc -p tsconfig.json",
|
|
@@ -27,5 +32,5 @@
|
|
|
27
32
|
"typescript": "^5.6.3",
|
|
28
33
|
"vitest": "^2.1.4"
|
|
29
34
|
},
|
|
30
|
-
"gitCommitId": "
|
|
35
|
+
"gitCommitId": "b9f33cd"
|
|
31
36
|
}
|