@love-moon/conductor-sdk 0.8.0 → 0.10.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/CHANGELOG.md +19 -0
- package/dist/agent-host.d.ts +17 -0
- package/dist/agent-host.js +32 -0
- package/dist/attachments/index.d.ts +1 -0
- package/dist/attachments/index.js +1 -0
- package/dist/attachments/materializer.d.ts +28 -0
- package/dist/attachments/materializer.js +258 -0
- package/dist/backend/client.js +23 -5
- package/dist/client.d.ts +20 -1
- package/dist/client.js +158 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/outbox/downstream-inbox-store.d.ts +20 -0
- package/dist/outbox/downstream-inbox-store.js +90 -0
- package/dist/outbox/index.d.ts +1 -0
- package/dist/outbox/index.js +1 -0
- package/dist/session/manager.d.ts +1 -0
- package/dist/session/manager.js +4 -0
- package/dist/ws/client.js +2 -3
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @love-moon/conductor-sdk
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 43c4f87: Runtime health preflight and agent schedule access control. The daemon now
|
|
8
|
+
advertises positive backend runtime health (`x-conductor-runtime-health`) so the
|
|
9
|
+
backend can reject task creation with `503 runtime_unavailable` before any
|
|
10
|
+
timeline activity, and adds `disable_built_in_cli_list` to opt out of built-in
|
|
11
|
+
SDK backends. The SDK attributes agent-originated scheduled-message calls with
|
|
12
|
+
`X-Conductor-Actor: agent` so per-task `agent_schedule_access`
|
|
13
|
+
(full/read_only/blocked) can govern `conductor task schedule` from agents while
|
|
14
|
+
human/UI calls stay unrestricted.
|
|
15
|
+
|
|
16
|
+
## 0.9.0
|
|
17
|
+
|
|
18
|
+
### Minor Changes
|
|
19
|
+
|
|
20
|
+
- a15b55d: Add per-turn multi-image and local context-file inputs, plus authenticated attachment materialization from Conductor Web to the executing daemon.
|
|
21
|
+
|
|
3
22
|
## 0.8.0
|
|
4
23
|
|
|
5
24
|
### Minor Changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the WebSocket identity of a fire process.
|
|
3
|
+
*
|
|
4
|
+
* Two properties are load-bearing:
|
|
5
|
+
*
|
|
6
|
+
* 1. It must be identical across restarts of the same task. The server pins
|
|
7
|
+
* undelivered commands to `agentOutbox.agentHost` and signs attachment
|
|
8
|
+
* transfer tokens against this exact string, so a per-process identity
|
|
9
|
+
* strands anything still in flight when the process dies.
|
|
10
|
+
* 2. It must stay unique per concurrent fire. One daemon runs a fire per task
|
|
11
|
+
* and the realtime hub evicts an existing connection that reuses a name, so
|
|
12
|
+
* a shared identity would make each new fire kick the previous one offline.
|
|
13
|
+
*
|
|
14
|
+
* The `conductor-fire-` prefix is also meaningful: it selects the `manual_fire`
|
|
15
|
+
* plan bucket and keeps fire hosts out of daemon task routing.
|
|
16
|
+
*/
|
|
17
|
+
export declare function buildFireHostName(env: Record<string, string | undefined>, pid?: number): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const FIRE_HOST_PREFIX = 'conductor-fire-';
|
|
2
|
+
const firstNonEmpty = (...values) => {
|
|
3
|
+
for (const value of values) {
|
|
4
|
+
if (typeof value === 'string' && value.trim())
|
|
5
|
+
return value.trim();
|
|
6
|
+
}
|
|
7
|
+
return null;
|
|
8
|
+
};
|
|
9
|
+
const sanitizeHostSegment = (value) => value.replace(/[^A-Za-z0-9._-]+/g, '-');
|
|
10
|
+
/**
|
|
11
|
+
* Build the WebSocket identity of a fire process.
|
|
12
|
+
*
|
|
13
|
+
* Two properties are load-bearing:
|
|
14
|
+
*
|
|
15
|
+
* 1. It must be identical across restarts of the same task. The server pins
|
|
16
|
+
* undelivered commands to `agentOutbox.agentHost` and signs attachment
|
|
17
|
+
* transfer tokens against this exact string, so a per-process identity
|
|
18
|
+
* strands anything still in flight when the process dies.
|
|
19
|
+
* 2. It must stay unique per concurrent fire. One daemon runs a fire per task
|
|
20
|
+
* and the realtime hub evicts an existing connection that reuses a name, so
|
|
21
|
+
* a shared identity would make each new fire kick the previous one offline.
|
|
22
|
+
*
|
|
23
|
+
* The `conductor-fire-` prefix is also meaningful: it selects the `manual_fire`
|
|
24
|
+
* plan bucket and keeps fire hosts out of daemon task routing.
|
|
25
|
+
*/
|
|
26
|
+
export function buildFireHostName(env, pid = process.pid) {
|
|
27
|
+
const owner = firstNonEmpty(env.CONDUCTOR_DAEMON_NAME, env.HOSTNAME, env.COMPUTERNAME) ?? 'unknown-host';
|
|
28
|
+
// Before a task exists there is nothing durable to key on, so an unattached
|
|
29
|
+
// fire keeps a per-process name until it owns a task.
|
|
30
|
+
const scope = firstNonEmpty(env.CONDUCTOR_TASK_ID) ?? `pid-${pid}`;
|
|
31
|
+
return `${FIRE_HOST_PREFIX}${sanitizeHostSegment(owner)}-${sanitizeHostSegment(scope)}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './materializer.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './materializer.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ConductorConfig } from '../config/index.js';
|
|
2
|
+
type FetchLike = typeof fetch;
|
|
3
|
+
type JsonRecord = Record<string, unknown>;
|
|
4
|
+
export interface AttachmentMaterializerOptions {
|
|
5
|
+
config: ConductorConfig;
|
|
6
|
+
projectPath: string;
|
|
7
|
+
agentHost: string;
|
|
8
|
+
fetchImpl?: FetchLike;
|
|
9
|
+
transferTimeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
export declare class AttachmentMaterializer {
|
|
12
|
+
private readonly config;
|
|
13
|
+
private readonly projectPath;
|
|
14
|
+
private readonly agentHost;
|
|
15
|
+
private readonly fetchImpl;
|
|
16
|
+
private readonly transferTimeoutMs;
|
|
17
|
+
private readonly inFlight;
|
|
18
|
+
private readonly shutdownController;
|
|
19
|
+
constructor(options: AttachmentMaterializerOptions);
|
|
20
|
+
abortAll(): void;
|
|
21
|
+
validateEnvelopeForStaging(envelope: JsonRecord): boolean;
|
|
22
|
+
materializeEnvelope(envelope: JsonRecord): Promise<JsonRecord>;
|
|
23
|
+
private materializeAttachments;
|
|
24
|
+
private materializeOne;
|
|
25
|
+
private reportMaterialized;
|
|
26
|
+
private fetchWithTimeout;
|
|
27
|
+
}
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Readable, Transform } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
const MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024;
|
|
7
|
+
const MAX_ATTACHMENTS = 20;
|
|
8
|
+
const DEFAULT_TRANSFER_TIMEOUT_MS = 120_000;
|
|
9
|
+
function cleanSegment(value, fallback) {
|
|
10
|
+
const basename = path.basename(typeof value === 'string' ? value : '').replace(/[^A-Za-z0-9._-]+/g, '-');
|
|
11
|
+
return basename || fallback;
|
|
12
|
+
}
|
|
13
|
+
function normalizeSha256(value) {
|
|
14
|
+
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
15
|
+
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : '';
|
|
16
|
+
}
|
|
17
|
+
function withoutTransferToken(attachment) {
|
|
18
|
+
const { transferToken: _transferToken, ...safe } = attachment;
|
|
19
|
+
return safe;
|
|
20
|
+
}
|
|
21
|
+
function atomicWriteJson(filePath, value) {
|
|
22
|
+
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
23
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
24
|
+
fs.renameSync(temporary, filePath);
|
|
25
|
+
}
|
|
26
|
+
async function hashFile(filePath) {
|
|
27
|
+
const hash = crypto.createHash('sha256');
|
|
28
|
+
for await (const chunk of fs.createReadStream(filePath))
|
|
29
|
+
hash.update(chunk);
|
|
30
|
+
return hash.digest('hex');
|
|
31
|
+
}
|
|
32
|
+
export class AttachmentMaterializer {
|
|
33
|
+
config;
|
|
34
|
+
projectPath;
|
|
35
|
+
agentHost;
|
|
36
|
+
fetchImpl;
|
|
37
|
+
transferTimeoutMs;
|
|
38
|
+
inFlight = new Map();
|
|
39
|
+
shutdownController = new AbortController();
|
|
40
|
+
constructor(options) {
|
|
41
|
+
this.config = options.config;
|
|
42
|
+
this.projectPath = path.resolve(options.projectPath);
|
|
43
|
+
this.agentHost = options.agentHost;
|
|
44
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
45
|
+
this.transferTimeoutMs = options.transferTimeoutMs ?? DEFAULT_TRANSFER_TIMEOUT_MS;
|
|
46
|
+
}
|
|
47
|
+
abortAll() {
|
|
48
|
+
this.shutdownController.abort(new Error('Attachment materializer closed'));
|
|
49
|
+
}
|
|
50
|
+
validateEnvelopeForStaging(envelope) {
|
|
51
|
+
if (envelope.type !== 'task_user_message')
|
|
52
|
+
return false;
|
|
53
|
+
const payload = envelope.payload && typeof envelope.payload === 'object' ? envelope.payload : null;
|
|
54
|
+
if (!payload || !Array.isArray(payload.attachments) || payload.attachments.length === 0)
|
|
55
|
+
return false;
|
|
56
|
+
const attachments = payload.attachments;
|
|
57
|
+
if (attachments.some((entry) => !entry || typeof entry !== 'object' || Array.isArray(entry)
|
|
58
|
+
|| !normalizeSha256(entry.sha256)))
|
|
59
|
+
return false;
|
|
60
|
+
if (attachments.length > MAX_ATTACHMENTS)
|
|
61
|
+
throw new Error('Too many message attachments');
|
|
62
|
+
const ids = attachments.map((entry) => typeof entry.id === 'string'
|
|
63
|
+
? String(entry.id).trim() : '');
|
|
64
|
+
if (ids.some((id) => !id) || new Set(ids).size !== ids.length)
|
|
65
|
+
throw new Error('Message attachments must have unique IDs');
|
|
66
|
+
for (const raw of attachments) {
|
|
67
|
+
const size = typeof raw.sizeBytes === 'number' ? raw.sizeBytes : Number.NaN;
|
|
68
|
+
const transferToken = typeof raw.transferToken === 'string' ? raw.transferToken.trim() : '';
|
|
69
|
+
if (!transferToken || (raw.kind !== 'image' && raw.kind !== 'file') || !Number.isSafeInteger(size) || size < 0 || size > MAX_ATTACHMENT_BYTES) {
|
|
70
|
+
throw new Error(`Invalid attachment descriptor for ${String(raw.id || 'attachment')}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const taskId = typeof payload.task_id === 'string' ? payload.task_id.trim() : '';
|
|
74
|
+
const messageId = typeof payload.message_id === 'string' ? payload.message_id.trim() : '';
|
|
75
|
+
if (!taskId || !messageId)
|
|
76
|
+
throw new Error('Attachment message is missing task_id or message_id');
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
async materializeEnvelope(envelope) {
|
|
80
|
+
if (envelope.type !== 'task_user_message')
|
|
81
|
+
return envelope;
|
|
82
|
+
const payload = envelope.payload && typeof envelope.payload === 'object'
|
|
83
|
+
? { ...envelope.payload }
|
|
84
|
+
: null;
|
|
85
|
+
if (!payload || !Array.isArray(payload.attachments) || payload.attachments.length === 0)
|
|
86
|
+
return envelope;
|
|
87
|
+
const attachments = payload.attachments;
|
|
88
|
+
// Attachments created before the reliable-transfer protocol did not carry a
|
|
89
|
+
// digest. Preserve those legacy display-only descriptors instead of
|
|
90
|
+
// turning an old message into a permanently unacknowledgeable command.
|
|
91
|
+
if (payload.attachments.some((entry) => !entry || typeof entry !== 'object' || Array.isArray(entry) || !normalizeSha256(entry.sha256)))
|
|
92
|
+
return envelope;
|
|
93
|
+
if (payload.attachments.length > MAX_ATTACHMENTS)
|
|
94
|
+
throw new Error('Too many message attachments');
|
|
95
|
+
const attachmentIds = payload.attachments.map((entry) => typeof entry.id === 'string' ? String(entry.id).trim() : '');
|
|
96
|
+
if (attachmentIds.some((id) => !id) || new Set(attachmentIds).size !== attachmentIds.length) {
|
|
97
|
+
throw new Error('Message attachments must have unique IDs');
|
|
98
|
+
}
|
|
99
|
+
const taskId = typeof payload.task_id === 'string' ? payload.task_id.trim() : '';
|
|
100
|
+
const messageId = typeof payload.message_id === 'string' ? payload.message_id.trim() : '';
|
|
101
|
+
if (!taskId || !messageId)
|
|
102
|
+
throw new Error('Attachment message is missing task_id or message_id');
|
|
103
|
+
const key = `${taskId}:${messageId}`;
|
|
104
|
+
const current = this.inFlight.get(key);
|
|
105
|
+
if (current)
|
|
106
|
+
return current;
|
|
107
|
+
const operation = this.materializeAttachments(envelope, payload, attachments, taskId, messageId);
|
|
108
|
+
this.inFlight.set(key, operation);
|
|
109
|
+
try {
|
|
110
|
+
return await operation;
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
this.inFlight.delete(key);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async materializeAttachments(envelope, payload, attachments, taskId, messageId) {
|
|
117
|
+
const messageDir = path.join(this.projectPath, '.conductor', 'attachments', cleanSegment(messageId, 'message'));
|
|
118
|
+
fs.mkdirSync(messageDir, { recursive: true, mode: 0o700 });
|
|
119
|
+
const manifestPath = path.join(messageDir, 'manifest.json');
|
|
120
|
+
atomicWriteJson(manifestPath, {
|
|
121
|
+
taskId,
|
|
122
|
+
messageId,
|
|
123
|
+
status: 'materializing',
|
|
124
|
+
attachments: attachments.map((entry) => withoutTransferToken(entry)),
|
|
125
|
+
updatedAt: new Date().toISOString(),
|
|
126
|
+
});
|
|
127
|
+
const localAttachments = [];
|
|
128
|
+
for (const raw of attachments) {
|
|
129
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
130
|
+
throw new Error('Invalid attachment descriptor');
|
|
131
|
+
localAttachments.push(await this.materializeOne(taskId, messageDir, raw));
|
|
132
|
+
}
|
|
133
|
+
atomicWriteJson(manifestPath, {
|
|
134
|
+
taskId,
|
|
135
|
+
messageId,
|
|
136
|
+
status: 'ready',
|
|
137
|
+
attachments: localAttachments,
|
|
138
|
+
updatedAt: new Date().toISOString(),
|
|
139
|
+
});
|
|
140
|
+
await this.reportMaterialized(taskId, messageId, localAttachments, attachments);
|
|
141
|
+
const metadata = payload.metadata && typeof payload.metadata === 'object' && !Array.isArray(payload.metadata)
|
|
142
|
+
? { ...payload.metadata, attachments: localAttachments }
|
|
143
|
+
: { attachments: localAttachments };
|
|
144
|
+
return { ...envelope, payload: { ...payload, metadata, attachments: localAttachments } };
|
|
145
|
+
}
|
|
146
|
+
async materializeOne(taskId, messageDir, attachment) {
|
|
147
|
+
const id = typeof attachment.id === 'string' ? attachment.id.trim() : '';
|
|
148
|
+
const name = cleanSegment(attachment.name, 'attachment');
|
|
149
|
+
const sha256 = normalizeSha256(attachment.sha256);
|
|
150
|
+
const sizeBytes = typeof attachment.sizeBytes === 'number' ? attachment.sizeBytes : Number.NaN;
|
|
151
|
+
const kind = attachment.kind === 'image' ? 'image' : attachment.kind === 'file' ? 'file' : '';
|
|
152
|
+
if (!id || !sha256 || !kind || !Number.isSafeInteger(sizeBytes) || sizeBytes < 0 || sizeBytes > MAX_ATTACHMENT_BYTES) {
|
|
153
|
+
throw new Error(`Invalid attachment descriptor for ${id || name}`);
|
|
154
|
+
}
|
|
155
|
+
const localPath = path.join(messageDir, `${cleanSegment(id, 'attachment')}--${name}`);
|
|
156
|
+
if (fs.existsSync(localPath)) {
|
|
157
|
+
const stat = fs.statSync(localPath);
|
|
158
|
+
if (stat.isFile() && stat.size === sizeBytes && await hashFile(localPath) === sha256) {
|
|
159
|
+
return { ...withoutTransferToken(attachment), path: localPath, localPath, status: 'ready' };
|
|
160
|
+
}
|
|
161
|
+
fs.rmSync(localPath, { force: true });
|
|
162
|
+
}
|
|
163
|
+
const url = new URL(`/api/agent/tasks/${encodeURIComponent(taskId)}/attachments/${encodeURIComponent(id)}/content`, this.config.backendUrl);
|
|
164
|
+
const transferToken = typeof attachment.transferToken === 'string' ? attachment.transferToken.trim() : '';
|
|
165
|
+
if (!transferToken)
|
|
166
|
+
throw new Error(`Attachment transfer token missing for ${id}`);
|
|
167
|
+
const transferHeaders = {
|
|
168
|
+
Authorization: `Bearer ${this.config.agentToken}`,
|
|
169
|
+
'X-Conductor-Host': this.agentHost,
|
|
170
|
+
'X-Conductor-Attachment-Token': transferToken,
|
|
171
|
+
Accept: 'application/octet-stream',
|
|
172
|
+
};
|
|
173
|
+
const transferSignal = AbortSignal.any([
|
|
174
|
+
AbortSignal.timeout(this.transferTimeoutMs),
|
|
175
|
+
this.shutdownController.signal,
|
|
176
|
+
]);
|
|
177
|
+
const response = await this.fetchImpl(url, {
|
|
178
|
+
headers: transferHeaders,
|
|
179
|
+
signal: transferSignal,
|
|
180
|
+
});
|
|
181
|
+
if (!response.ok || !response.body) {
|
|
182
|
+
const error = new Error(`Attachment download failed with HTTP ${response.status}`);
|
|
183
|
+
// The attachment was released, deleted or is no longer readable by this
|
|
184
|
+
// host. Retrying cannot fix that, so let the caller stop instead of
|
|
185
|
+
// looping on a 404 until the process dies.
|
|
186
|
+
if (response.status === 403 || response.status === 404 || response.status === 410)
|
|
187
|
+
error.permanent = true;
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
const contentLength = response.headers.get('content-length');
|
|
191
|
+
const declaredLength = contentLength === null ? null : Number(contentLength);
|
|
192
|
+
if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength !== sizeBytes) {
|
|
193
|
+
throw new Error('Attachment Content-Length mismatch');
|
|
194
|
+
}
|
|
195
|
+
const temporaryPath = `${localPath}.${process.pid}.${crypto.randomUUID()}.part`;
|
|
196
|
+
const hash = crypto.createHash('sha256');
|
|
197
|
+
let received = 0;
|
|
198
|
+
const verifier = new Transform({
|
|
199
|
+
transform(chunk, _encoding, callback) {
|
|
200
|
+
received += chunk.length;
|
|
201
|
+
if (received > sizeBytes || received > MAX_ATTACHMENT_BYTES) {
|
|
202
|
+
callback(new Error('Attachment exceeded declared size'));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
hash.update(chunk);
|
|
206
|
+
callback(null, chunk);
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
try {
|
|
210
|
+
await pipeline(Readable.fromWeb(response.body), verifier, fs.createWriteStream(temporaryPath, { mode: 0o600 }), { signal: transferSignal });
|
|
211
|
+
if (received !== sizeBytes)
|
|
212
|
+
throw new Error('Attachment size mismatch');
|
|
213
|
+
if (hash.digest('hex') !== sha256)
|
|
214
|
+
throw new Error('Attachment SHA-256 mismatch');
|
|
215
|
+
fs.renameSync(temporaryPath, localPath);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
return { ...withoutTransferToken(attachment), path: localPath, localPath, status: 'ready' };
|
|
222
|
+
}
|
|
223
|
+
async reportMaterialized(taskId, messageId, attachments, sourceAttachments) {
|
|
224
|
+
const url = new URL(`/api/agent/tasks/${encodeURIComponent(taskId)}/messages/${encodeURIComponent(messageId)}/materialized`, this.config.backendUrl);
|
|
225
|
+
const response = await this.fetchWithTimeout(url, {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: {
|
|
228
|
+
Authorization: `Bearer ${this.config.agentToken}`,
|
|
229
|
+
'X-Conductor-Host': this.agentHost,
|
|
230
|
+
'Content-Type': 'application/json',
|
|
231
|
+
},
|
|
232
|
+
body: JSON.stringify({
|
|
233
|
+
attachments: attachments.map((attachment, index) => ({
|
|
234
|
+
id: attachment.id,
|
|
235
|
+
sha256: attachment.sha256,
|
|
236
|
+
status: 'ready',
|
|
237
|
+
transferToken: sourceAttachments[index]?.transferToken,
|
|
238
|
+
})),
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
if (!response.ok)
|
|
242
|
+
throw new Error(`Attachment materialization report failed with HTTP ${response.status}`);
|
|
243
|
+
}
|
|
244
|
+
async fetchWithTimeout(input, init) {
|
|
245
|
+
const controller = new AbortController();
|
|
246
|
+
const timeout = setTimeout(() => controller.abort(new Error('Attachment transfer timed out')), this.transferTimeoutMs);
|
|
247
|
+
const abortForShutdown = () => controller.abort(this.shutdownController.signal.reason);
|
|
248
|
+
this.shutdownController.signal.addEventListener('abort', abortForShutdown, { once: true });
|
|
249
|
+
timeout.unref?.();
|
|
250
|
+
try {
|
|
251
|
+
return await this.fetchImpl(input, { ...init, signal: controller.signal });
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
clearTimeout(timeout);
|
|
255
|
+
this.shutdownController.signal.removeEventListener('abort', abortForShutdown);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
package/dist/backend/client.js
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mark a request as originating from an autonomous agent turn. The daemon
|
|
3
|
+
* exports `CONDUCTOR_LAUNCHED_BY_DAEMON=1` into the fire process (and the shell
|
|
4
|
+
* commands it runs), so any scheduling call made while the AI is driving the
|
|
5
|
+
* turn is attributed to the agent. The backend uses this to enforce the
|
|
6
|
+
* per-task agent schedule access level; ordinary human/app calls omit it and
|
|
7
|
+
* are never restricted.
|
|
8
|
+
*/
|
|
9
|
+
function agentActorHeaders() {
|
|
10
|
+
const launchedByDaemon = typeof process !== 'undefined' && process.env?.CONDUCTOR_LAUNCHED_BY_DAEMON === '1';
|
|
11
|
+
return launchedByDaemon ? { 'X-Conductor-Actor': 'agent' } : {};
|
|
12
|
+
}
|
|
1
13
|
export class BackendApiError extends Error {
|
|
2
14
|
statusCode;
|
|
3
15
|
details;
|
|
@@ -463,7 +475,9 @@ export class BackendApiClient {
|
|
|
463
475
|
return this.parseJson(response);
|
|
464
476
|
}
|
|
465
477
|
async listScheduledMessages(taskId) {
|
|
466
|
-
const response = await this.request('GET', `/tasks/${taskId}/scheduled-messages
|
|
478
|
+
const response = await this.request('GET', `/tasks/${taskId}/scheduled-messages`, {
|
|
479
|
+
headers: agentActorHeaders(),
|
|
480
|
+
});
|
|
467
481
|
const payload = await this.parseJson(response);
|
|
468
482
|
if (payload && typeof payload === 'object' && Array.isArray(payload.schedules)) {
|
|
469
483
|
return payload.schedules;
|
|
@@ -476,22 +490,25 @@ export class BackendApiClient {
|
|
|
476
490
|
async createScheduledMessage(taskId, params) {
|
|
477
491
|
const response = await this.request('POST', `/tasks/${taskId}/scheduled-messages`, {
|
|
478
492
|
body: JSON.stringify(params),
|
|
493
|
+
headers: agentActorHeaders(),
|
|
479
494
|
});
|
|
480
495
|
return this.parseJson(response);
|
|
481
496
|
}
|
|
482
497
|
async deleteScheduledMessage(taskId, scheduleId) {
|
|
483
|
-
await this.request('DELETE', `/tasks/${taskId}/scheduled-messages/${scheduleId}
|
|
498
|
+
await this.request('DELETE', `/tasks/${taskId}/scheduled-messages/${scheduleId}`, {
|
|
499
|
+
headers: agentActorHeaders(),
|
|
500
|
+
});
|
|
484
501
|
}
|
|
485
502
|
async request(method, pathname, opts = {}) {
|
|
486
503
|
const url = this.buildUrl(pathname, opts.query);
|
|
487
504
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
488
505
|
const timeout = controller ? setTimeout(() => controller.abort(), this.timeoutMs) : null;
|
|
489
506
|
try {
|
|
490
|
-
let response = await this.sendRequest(url, method, opts.body, controller);
|
|
507
|
+
let response = await this.sendRequest(url, method, opts.body, controller, opts.headers);
|
|
491
508
|
if (response.status === 404 &&
|
|
492
509
|
this.shouldRetryWithApiPrefix(pathname, url)) {
|
|
493
510
|
const retryUrl = this.buildUrl(this.withApiPrefix(pathname), opts.query);
|
|
494
|
-
response = await this.sendRequest(retryUrl, method, opts.body, controller);
|
|
511
|
+
response = await this.sendRequest(retryUrl, method, opts.body, controller, opts.headers);
|
|
495
512
|
}
|
|
496
513
|
if (!response.ok) {
|
|
497
514
|
const details = await this.safeJson(response);
|
|
@@ -518,13 +535,14 @@ export class BackendApiClient {
|
|
|
518
535
|
}
|
|
519
536
|
return url;
|
|
520
537
|
}
|
|
521
|
-
async sendRequest(url, method, body, controller) {
|
|
538
|
+
async sendRequest(url, method, body, controller, extraHeaders) {
|
|
522
539
|
return this.fetchImpl(url.toString(), {
|
|
523
540
|
method,
|
|
524
541
|
headers: {
|
|
525
542
|
Authorization: `Bearer ${this.config.agentToken}`,
|
|
526
543
|
Accept: 'application/json',
|
|
527
544
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
545
|
+
...(extraHeaders ?? {}),
|
|
528
546
|
},
|
|
529
547
|
body,
|
|
530
548
|
signal: controller?.signal,
|
package/dist/client.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { BackendApiClient } from './backend/index.js';
|
|
2
|
+
import { AttachmentMaterializer } from './attachments/index.js';
|
|
2
3
|
import { ConductorConfig } from './config/index.js';
|
|
3
4
|
import { MessageRouter } from './message/index.js';
|
|
4
|
-
import { DownstreamCursorStore, DurableUpstreamOutboxStore } from './outbox/index.js';
|
|
5
|
+
import { DownstreamInboxStore, DownstreamCursorStore, DurableUpstreamOutboxStore } from './outbox/index.js';
|
|
5
6
|
import { SessionDiskStore, SessionManager } from './session/index.js';
|
|
6
7
|
import { ConductorWebSocketClient, WebSocketConnectedEvent, WebSocketDisconnectEvent, WebSocketPongEvent } from './ws/index.js';
|
|
7
8
|
type BackendApiLike = Pick<BackendApiClient, 'listProjects' | 'createProject' | 'listTasks' | 'getTask' | 'createTask' | 'updateTask' | 'commitSdkMessage' | 'commitTaskStatusUpdate' | 'commitAgentCommandAck' | 'commitTaskStopAck' | 'matchProjectByPath' | 'getProject' | 'updateProject'>;
|
|
@@ -20,6 +21,8 @@ export interface ConductorClientConnectOptions {
|
|
|
20
21
|
messageRouter?: MessageRouter;
|
|
21
22
|
upstreamOutbox?: DurableUpstreamOutboxStore;
|
|
22
23
|
downstreamCursorStore?: DownstreamCursorStore;
|
|
24
|
+
downstreamInboxStore?: DownstreamInboxStore;
|
|
25
|
+
attachmentMaterializer?: AttachmentMaterializer;
|
|
23
26
|
agentHost?: string;
|
|
24
27
|
extraHeaders?: Record<string, string>;
|
|
25
28
|
onConnected?: (event: WebSocketConnectedEvent) => void;
|
|
@@ -41,6 +44,8 @@ interface ConductorClientInit {
|
|
|
41
44
|
messageRouter: MessageRouter;
|
|
42
45
|
upstreamOutbox: DurableUpstreamOutboxStore;
|
|
43
46
|
downstreamCursorStore: DownstreamCursorStore;
|
|
47
|
+
downstreamInboxStore: DownstreamInboxStore;
|
|
48
|
+
attachmentMaterializer: AttachmentMaterializer;
|
|
44
49
|
agentHost: string;
|
|
45
50
|
onStopTask?: (event: StopTaskEvent) => Promise<void> | void;
|
|
46
51
|
onInterruptTurn?: (event: InterruptTurnEvent) => Promise<boolean | void> | boolean | void;
|
|
@@ -79,6 +84,8 @@ export declare class ConductorClient {
|
|
|
79
84
|
private readonly messageRouter;
|
|
80
85
|
private upstreamOutbox;
|
|
81
86
|
private downstreamCursorStore;
|
|
87
|
+
private downstreamInboxStore;
|
|
88
|
+
private readonly attachmentMaterializer;
|
|
82
89
|
private readonly agentHost;
|
|
83
90
|
private readonly onStopTask?;
|
|
84
91
|
private readonly onInterruptTurn?;
|
|
@@ -88,6 +95,11 @@ export declare class ConductorClient {
|
|
|
88
95
|
private durableOutboxFlushPromise;
|
|
89
96
|
private durableOutboxTimer;
|
|
90
97
|
private durableOutboxTimerDueAt;
|
|
98
|
+
private readonly downstreamTasks;
|
|
99
|
+
private readonly queuedDownstreamRequests;
|
|
100
|
+
private readonly cancelledDownstreamTasks;
|
|
101
|
+
private readonly downstreamWorkerAbort;
|
|
102
|
+
private downstreamClosing;
|
|
91
103
|
constructor(init: ConductorClientInit);
|
|
92
104
|
static connect(options?: ConductorClientConnectOptions): Promise<ConductorClient>;
|
|
93
105
|
close(): Promise<void>;
|
|
@@ -133,6 +145,13 @@ export declare class ConductorClient {
|
|
|
133
145
|
matchProjectByPath(payload?: Record<string, any>): Promise<Record<string, any>>;
|
|
134
146
|
bindProjectPath(projectId: string, payload?: Record<string, any>): Promise<Record<string, any>>;
|
|
135
147
|
private readonly handleBackendEvent;
|
|
148
|
+
private replayDurableDownstreamInbox;
|
|
149
|
+
private enqueueDurableDownstreamCommand;
|
|
150
|
+
/**
|
|
151
|
+
* Tell the user why an attachment message will never run. Silently dropping
|
|
152
|
+
* it would leave a question in the chat that never gets an answer.
|
|
153
|
+
*/
|
|
154
|
+
private reportUnrecoverableAttachmentCommand;
|
|
136
155
|
private extractDownstreamCommandContext;
|
|
137
156
|
private handleStopTaskCommand;
|
|
138
157
|
private invokeStopTaskHandler;
|
package/dist/client.js
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { buildFireHostName } from './agent-host.js';
|
|
4
5
|
import { BackendApiClient, BackendApiError } from './backend/index.js';
|
|
6
|
+
import { AttachmentMaterializer } from './attachments/index.js';
|
|
5
7
|
import { loadConfig } from './config/index.js';
|
|
6
8
|
import { ProjectContext } from './context/index.js';
|
|
7
9
|
import { getPlanLimitMessageFromError } from './limits/index.js';
|
|
8
10
|
import { MessageRouter } from './message/index.js';
|
|
9
|
-
import { DownstreamCursorStore, DurableUpstreamOutboxStore, normalizeDownstreamCommandCursor, } from './outbox/index.js';
|
|
11
|
+
import { DownstreamInboxStore, DownstreamCursorStore, DurableUpstreamOutboxStore, normalizeDownstreamCommandCursor, } from './outbox/index.js';
|
|
10
12
|
import { SessionDiskStore, SessionManager, currentHostname } from './session/index.js';
|
|
11
13
|
import { ConductorWebSocketClient, } from './ws/index.js';
|
|
12
14
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
const sleepWithSignal = (ms, signal) => new Promise((resolve) => {
|
|
16
|
+
if (signal.aborted)
|
|
17
|
+
return resolve();
|
|
18
|
+
const timer = setTimeout(resolve, Math.max(0, ms));
|
|
19
|
+
timer.unref?.();
|
|
20
|
+
signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
21
|
+
});
|
|
13
22
|
export class ConductorClient {
|
|
14
23
|
config;
|
|
15
24
|
env;
|
|
@@ -21,6 +30,8 @@ export class ConductorClient {
|
|
|
21
30
|
messageRouter;
|
|
22
31
|
upstreamOutbox;
|
|
23
32
|
downstreamCursorStore;
|
|
33
|
+
downstreamInboxStore;
|
|
34
|
+
attachmentMaterializer;
|
|
24
35
|
agentHost;
|
|
25
36
|
onStopTask;
|
|
26
37
|
onInterruptTurn;
|
|
@@ -30,6 +41,11 @@ export class ConductorClient {
|
|
|
30
41
|
durableOutboxFlushPromise = null;
|
|
31
42
|
durableOutboxTimer = null;
|
|
32
43
|
durableOutboxTimerDueAt = null;
|
|
44
|
+
downstreamTasks = new Map();
|
|
45
|
+
queuedDownstreamRequests = new Set();
|
|
46
|
+
cancelledDownstreamTasks = new Set();
|
|
47
|
+
downstreamWorkerAbort = new AbortController();
|
|
48
|
+
downstreamClosing = false;
|
|
33
49
|
constructor(init) {
|
|
34
50
|
this.config = init.config;
|
|
35
51
|
this.env = init.env;
|
|
@@ -41,6 +57,8 @@ export class ConductorClient {
|
|
|
41
57
|
this.messageRouter = init.messageRouter;
|
|
42
58
|
this.upstreamOutbox = init.upstreamOutbox;
|
|
43
59
|
this.downstreamCursorStore = init.downstreamCursorStore;
|
|
60
|
+
this.downstreamInboxStore = init.downstreamInboxStore;
|
|
61
|
+
this.attachmentMaterializer = init.attachmentMaterializer;
|
|
44
62
|
this.agentHost = init.agentHost;
|
|
45
63
|
this.onStopTask = init.onStopTask;
|
|
46
64
|
this.onInterruptTurn = init.onInterruptTurn;
|
|
@@ -60,6 +78,12 @@ export class ConductorClient {
|
|
|
60
78
|
const deliveryScopeId = normalizeDeliveryScopeId(options.deliveryScopeId ?? `agent:${agentHost}`);
|
|
61
79
|
const upstreamOutbox = options.upstreamOutbox ?? DurableUpstreamOutboxStore.forProjectPath(projectPath, deliveryScopeId);
|
|
62
80
|
const downstreamCursorStore = options.downstreamCursorStore ?? DownstreamCursorStore.forProjectPath(projectPath, deliveryScopeId);
|
|
81
|
+
const downstreamInboxStore = options.downstreamInboxStore ?? DownstreamInboxStore.forProjectPath(projectPath, deliveryScopeId);
|
|
82
|
+
const attachmentMaterializer = options.attachmentMaterializer ?? new AttachmentMaterializer({
|
|
83
|
+
config,
|
|
84
|
+
projectPath,
|
|
85
|
+
agentHost,
|
|
86
|
+
});
|
|
63
87
|
const wsClient = options.wsClient ??
|
|
64
88
|
new ConductorWebSocketClient(config, {
|
|
65
89
|
hostName: agentHost,
|
|
@@ -85,12 +109,23 @@ export class ConductorClient {
|
|
|
85
109
|
messageRouter,
|
|
86
110
|
upstreamOutbox,
|
|
87
111
|
downstreamCursorStore,
|
|
112
|
+
downstreamInboxStore,
|
|
113
|
+
attachmentMaterializer,
|
|
88
114
|
agentHost,
|
|
89
115
|
onStopTask: options.onStopTask,
|
|
90
116
|
onInterruptTurn: options.onInterruptTurn,
|
|
91
117
|
onRefreshSession: options.onRefreshSession,
|
|
92
118
|
});
|
|
93
|
-
|
|
119
|
+
client.replayDurableDownstreamInbox();
|
|
120
|
+
try {
|
|
121
|
+
await client.wsClient.connect();
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
client.downstreamClosing = true;
|
|
125
|
+
client.downstreamWorkerAbort.abort();
|
|
126
|
+
client.attachmentMaterializer.abortAll();
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
94
129
|
if (client.shouldAutoFlushDurableOutbox()) {
|
|
95
130
|
void client.requestDurableOutboxFlush(true);
|
|
96
131
|
}
|
|
@@ -100,6 +135,9 @@ export class ConductorClient {
|
|
|
100
135
|
if (this.closed) {
|
|
101
136
|
return;
|
|
102
137
|
}
|
|
138
|
+
this.downstreamClosing = true;
|
|
139
|
+
this.downstreamWorkerAbort.abort();
|
|
140
|
+
this.attachmentMaterializer.abortAll();
|
|
103
141
|
this.clearDurableOutboxTimer();
|
|
104
142
|
await this.flushPendingUpstreamEvents({
|
|
105
143
|
timeoutMs: 2_000,
|
|
@@ -576,12 +614,28 @@ export class ConductorClient {
|
|
|
576
614
|
}
|
|
577
615
|
const command = this.extractDownstreamCommandContext(payload);
|
|
578
616
|
if (command?.eventType === 'stop_task') {
|
|
617
|
+
this.cancelledDownstreamTasks.add(command.taskId);
|
|
579
618
|
await this.handleStopTaskCommand(payload, command);
|
|
580
619
|
return;
|
|
581
620
|
}
|
|
582
621
|
const alreadyApplied = command ? this.downstreamCursorStore.hasApplied(this.agentHost, command.cursor) : false;
|
|
583
622
|
if (!alreadyApplied) {
|
|
584
|
-
|
|
623
|
+
if (command && this.attachmentMaterializer.validateEnvelopeForStaging(payload)) {
|
|
624
|
+
const durable = this.downstreamInboxStore.upsert({
|
|
625
|
+
requestId: command.requestId,
|
|
626
|
+
taskId: command.taskId,
|
|
627
|
+
cursor: command.cursor,
|
|
628
|
+
envelope: payload,
|
|
629
|
+
});
|
|
630
|
+
this.enqueueDurableDownstreamCommand(durable);
|
|
631
|
+
await this.maybeAckInboundCommand(payload, { accepted: true });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (command) {
|
|
635
|
+
await this.downstreamTasks.get(command.taskId);
|
|
636
|
+
}
|
|
637
|
+
const materializedPayload = await this.attachmentMaterializer.materializeEnvelope(payload);
|
|
638
|
+
await this.messageRouter.handleBackendEvent(materializedPayload);
|
|
585
639
|
if (command) {
|
|
586
640
|
this.downstreamCursorStore.advance(this.agentHost, command.cursor);
|
|
587
641
|
}
|
|
@@ -590,6 +644,93 @@ export class ConductorClient {
|
|
|
590
644
|
accepted: true,
|
|
591
645
|
});
|
|
592
646
|
};
|
|
647
|
+
replayDurableDownstreamInbox() {
|
|
648
|
+
const entries = this.downstreamInboxStore.list().sort((left, right) => {
|
|
649
|
+
const byTime = Date.parse(left.cursor.createdAt) - Date.parse(right.cursor.createdAt);
|
|
650
|
+
return byTime || left.cursor.requestId.localeCompare(right.cursor.requestId);
|
|
651
|
+
});
|
|
652
|
+
for (const entry of entries)
|
|
653
|
+
this.enqueueDurableDownstreamCommand(entry);
|
|
654
|
+
}
|
|
655
|
+
enqueueDurableDownstreamCommand(entry) {
|
|
656
|
+
if (this.queuedDownstreamRequests.has(entry.requestId))
|
|
657
|
+
return;
|
|
658
|
+
this.queuedDownstreamRequests.add(entry.requestId);
|
|
659
|
+
const previous = this.downstreamTasks.get(entry.taskId) ?? Promise.resolve();
|
|
660
|
+
const current = previous.catch(() => undefined).then(async () => {
|
|
661
|
+
let attempt = entry.attemptCount;
|
|
662
|
+
while (!this.closed && !this.downstreamClosing) {
|
|
663
|
+
if (this.cancelledDownstreamTasks.has(entry.taskId)) {
|
|
664
|
+
this.downstreamInboxStore.remove(entry.requestId);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const nextAttemptMs = Date.parse(entry.nextAttemptAt);
|
|
668
|
+
if (Number.isFinite(nextAttemptMs) && nextAttemptMs > Date.now()) {
|
|
669
|
+
await sleepWithSignal(nextAttemptMs - Date.now(), this.downstreamWorkerAbort.signal);
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
const materialized = await this.attachmentMaterializer.materializeEnvelope(entry.envelope);
|
|
673
|
+
if (this.cancelledDownstreamTasks.has(entry.taskId)) {
|
|
674
|
+
this.downstreamInboxStore.remove(entry.requestId);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
// A shutdown after download must leave the already-ACKed command in
|
|
678
|
+
// the durable inbox so the next process can route it. Removing it
|
|
679
|
+
// here would make the Web outbox and the Daemon both forget it.
|
|
680
|
+
if (this.closed || this.downstreamClosing)
|
|
681
|
+
return;
|
|
682
|
+
await this.messageRouter.handleBackendEvent(materialized);
|
|
683
|
+
this.downstreamCursorStore.advance(this.agentHost, entry.cursor);
|
|
684
|
+
this.downstreamInboxStore.remove(entry.requestId);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
if (error?.permanent) {
|
|
689
|
+
const reported = await this.reportUnrecoverableAttachmentCommand(entry, error);
|
|
690
|
+
// Only drop the command once the user-facing explanation is durably
|
|
691
|
+
// queued; otherwise keep retrying so the failure cannot vanish.
|
|
692
|
+
if (reported) {
|
|
693
|
+
this.downstreamCursorStore.advance(this.agentHost, entry.cursor);
|
|
694
|
+
this.downstreamInboxStore.remove(entry.requestId);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
attempt += 1;
|
|
699
|
+
const delayMs = Math.min(1_000 * (2 ** Math.min(attempt, 5)), 30_000);
|
|
700
|
+
try {
|
|
701
|
+
this.downstreamInboxStore.markRetry(entry.requestId, delayMs);
|
|
702
|
+
}
|
|
703
|
+
catch (persistError) {
|
|
704
|
+
console.warn(`[sdk] failed to persist downstream retry state for ${entry.requestId}: ${persistError instanceof Error ? persistError.message : String(persistError)}`);
|
|
705
|
+
}
|
|
706
|
+
console.warn(`[sdk] downstream attachment command ${entry.requestId} failed; retrying in ${delayMs}ms: ${error instanceof Error ? error.message : String(error)}`);
|
|
707
|
+
await sleepWithSignal(delayMs, this.downstreamWorkerAbort.signal);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}).catch((error) => {
|
|
711
|
+
console.warn(`[sdk] downstream worker ${entry.requestId} stopped unexpectedly: ${error instanceof Error ? error.message : String(error)}`);
|
|
712
|
+
}).finally(() => {
|
|
713
|
+
this.queuedDownstreamRequests.delete(entry.requestId);
|
|
714
|
+
if (this.downstreamTasks.get(entry.taskId) === current)
|
|
715
|
+
this.downstreamTasks.delete(entry.taskId);
|
|
716
|
+
});
|
|
717
|
+
this.downstreamTasks.set(entry.taskId, current);
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Tell the user why an attachment message will never run. Silently dropping
|
|
721
|
+
* it would leave a question in the chat that never gets an answer.
|
|
722
|
+
*/
|
|
723
|
+
async reportUnrecoverableAttachmentCommand(entry, error) {
|
|
724
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
725
|
+
try {
|
|
726
|
+
await this.sendMessage(entry.taskId, `The attachments on this message are no longer available, so it could not be processed (${reason}). Please send them again.`, { attachment_delivery: 'failed', reply_to: entry.requestId });
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
catch (reportError) {
|
|
730
|
+
console.warn(`[sdk] failed to report unrecoverable attachment command ${entry.requestId}: ${reportError instanceof Error ? reportError.message : String(reportError)}`);
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
593
734
|
extractDownstreamCommandContext(payload) {
|
|
594
735
|
const eventType = typeof payload?.type === 'string' ? payload.type : '';
|
|
595
736
|
if (eventType !== 'task_user_message' && eventType !== 'task_action' && eventType !== 'stop_task') {
|
|
@@ -966,8 +1107,10 @@ export class ConductorClient {
|
|
|
966
1107
|
}
|
|
967
1108
|
const currentUpstream = this.upstreamOutbox;
|
|
968
1109
|
const currentCursorStore = this.downstreamCursorStore;
|
|
1110
|
+
const currentInboxStore = this.downstreamInboxStore;
|
|
969
1111
|
const nextUpstream = DurableUpstreamOutboxStore.forProjectPath(this.projectPath, nextScopeId);
|
|
970
1112
|
const nextCursorStore = DownstreamCursorStore.forProjectPath(this.projectPath, nextScopeId);
|
|
1113
|
+
const nextInboxStore = DownstreamInboxStore.forProjectPath(this.projectPath, nextScopeId);
|
|
971
1114
|
for (const entry of currentUpstream.load()) {
|
|
972
1115
|
nextUpstream.upsert({
|
|
973
1116
|
stableId: entry.stableId,
|
|
@@ -977,11 +1120,16 @@ export class ConductorClient {
|
|
|
977
1120
|
currentUpstream.remove(entry.stableId);
|
|
978
1121
|
}
|
|
979
1122
|
const currentCursor = currentCursorStore.get(this.agentHost);
|
|
980
|
-
if (currentCursor
|
|
1123
|
+
if (currentCursor) {
|
|
981
1124
|
nextCursorStore.advance(this.agentHost, currentCursor);
|
|
982
1125
|
}
|
|
1126
|
+
for (const entry of currentInboxStore.list()) {
|
|
1127
|
+
nextInboxStore.import(entry);
|
|
1128
|
+
currentInboxStore.remove(entry.requestId);
|
|
1129
|
+
}
|
|
983
1130
|
this.upstreamOutbox = nextUpstream;
|
|
984
1131
|
this.downstreamCursorStore = nextCursorStore;
|
|
1132
|
+
this.downstreamInboxStore = nextInboxStore;
|
|
985
1133
|
this.deliveryScopeId = nextScopeId;
|
|
986
1134
|
void this.requestDurableOutboxFlush(true);
|
|
987
1135
|
}
|
|
@@ -1059,13 +1207,12 @@ function resolveAgentHost(env, explicit) {
|
|
|
1059
1207
|
if (typeof fromAgent === 'string' && fromAgent.trim()) {
|
|
1060
1208
|
return fromAgent.trim();
|
|
1061
1209
|
}
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
return `conductor-fire-${host}-${pid}`;
|
|
1210
|
+
// CONDUCTOR_DAEMON_NAME is inherited from the daemon that spawned this fire,
|
|
1211
|
+
// so it names the daemon, not this process. Returning it verbatim would make
|
|
1212
|
+
// the fire impersonate its own daemon: same identity on the hub (mutual
|
|
1213
|
+
// eviction) and no `conductor-fire-` prefix (wrong plan bucket, eligible for
|
|
1214
|
+
// daemon task routing). Use it as the owner segment instead.
|
|
1215
|
+
return buildFireHostName(env);
|
|
1069
1216
|
}
|
|
1070
1217
|
function normalizeDeliveryScopeId(scopeId) {
|
|
1071
1218
|
const normalized = String(scopeId || '').trim();
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export * from './agent-host.js';
|
|
1
2
|
export * from './paths.js';
|
|
2
3
|
export * from './config/index.js';
|
|
3
4
|
export * from './backend/index.js';
|
|
@@ -9,3 +10,4 @@ export * from './context/index.js';
|
|
|
9
10
|
export * from './limits/index.js';
|
|
10
11
|
export * from './outbox/index.js';
|
|
11
12
|
export * from './api/index.js';
|
|
13
|
+
export * from './attachments/index.js';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export * from './agent-host.js';
|
|
1
2
|
export * from './paths.js';
|
|
2
3
|
export * from './config/index.js';
|
|
3
4
|
export * from './backend/index.js';
|
|
@@ -9,3 +10,4 @@ export * from './context/index.js';
|
|
|
9
10
|
export * from './limits/index.js';
|
|
10
11
|
export * from './outbox/index.js';
|
|
11
12
|
export * from './api/index.js';
|
|
13
|
+
export * from './attachments/index.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DownstreamCommandCursor } from './downstream-cursor-store.js';
|
|
2
|
+
export interface DurableDownstreamCommand {
|
|
3
|
+
requestId: string;
|
|
4
|
+
taskId: string;
|
|
5
|
+
cursor: DownstreamCommandCursor;
|
|
6
|
+
envelope: Record<string, unknown>;
|
|
7
|
+
attemptCount: number;
|
|
8
|
+
nextAttemptAt: string;
|
|
9
|
+
}
|
|
10
|
+
export declare class DownstreamInboxStore {
|
|
11
|
+
private readonly filePath;
|
|
12
|
+
constructor(filePath: string);
|
|
13
|
+
static forProjectPath(projectPath: string, scopeId: string): DownstreamInboxStore;
|
|
14
|
+
list(): DurableDownstreamCommand[];
|
|
15
|
+
upsert(command: Omit<DurableDownstreamCommand, 'attemptCount' | 'nextAttemptAt'>): DurableDownstreamCommand;
|
|
16
|
+
import(entry: DurableDownstreamCommand): DurableDownstreamCommand;
|
|
17
|
+
markRetry(requestId: string, delayMs: number): void;
|
|
18
|
+
remove(requestId: string): void;
|
|
19
|
+
private save;
|
|
20
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const STATE_DIR = path.join('.conductor', 'state');
|
|
4
|
+
const BASENAME = 'agent-downstream-inbox';
|
|
5
|
+
function isDurableCommand(value) {
|
|
6
|
+
if (!value || typeof value !== 'object')
|
|
7
|
+
return false;
|
|
8
|
+
const entry = value;
|
|
9
|
+
return typeof entry.requestId === 'string' && Boolean(entry.requestId.trim())
|
|
10
|
+
&& typeof entry.taskId === 'string' && Boolean(entry.taskId.trim())
|
|
11
|
+
&& Boolean(entry.cursor) && typeof entry.cursor?.createdAt === 'string' && typeof entry.cursor?.requestId === 'string'
|
|
12
|
+
&& Boolean(entry.envelope) && typeof entry.envelope === 'object'
|
|
13
|
+
&& Number.isSafeInteger(entry.attemptCount) && (entry.attemptCount ?? -1) >= 0
|
|
14
|
+
&& typeof entry.nextAttemptAt === 'string';
|
|
15
|
+
}
|
|
16
|
+
const sanitizeScopeId = (value) => value.trim().replace(/[^A-Za-z0-9._-]+/g, '_') || 'default';
|
|
17
|
+
export class DownstreamInboxStore {
|
|
18
|
+
filePath;
|
|
19
|
+
constructor(filePath) {
|
|
20
|
+
this.filePath = filePath;
|
|
21
|
+
}
|
|
22
|
+
static forProjectPath(projectPath, scopeId) {
|
|
23
|
+
return new DownstreamInboxStore(path.join(projectPath, STATE_DIR, `${BASENAME}.${sanitizeScopeId(scopeId)}.json`));
|
|
24
|
+
}
|
|
25
|
+
list() {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
|
|
28
|
+
if (!Array.isArray(parsed.entries))
|
|
29
|
+
throw new Error(`Invalid downstream inbox: ${this.filePath}`);
|
|
30
|
+
if (!parsed.entries.every(isDurableCommand))
|
|
31
|
+
throw new Error(`Invalid downstream inbox entry: ${this.filePath}`);
|
|
32
|
+
return parsed.entries;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code === 'ENOENT')
|
|
36
|
+
return [];
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
upsert(command) {
|
|
41
|
+
const entries = this.list();
|
|
42
|
+
const existing = entries.find((entry) => entry.requestId === command.requestId);
|
|
43
|
+
if (existing)
|
|
44
|
+
return existing;
|
|
45
|
+
const entry = { ...command, attemptCount: 0, nextAttemptAt: new Date().toISOString() };
|
|
46
|
+
this.save([...entries, entry]);
|
|
47
|
+
return entry;
|
|
48
|
+
}
|
|
49
|
+
import(entry) {
|
|
50
|
+
const entries = this.list();
|
|
51
|
+
const existing = entries.find((candidate) => candidate.requestId === entry.requestId);
|
|
52
|
+
if (existing)
|
|
53
|
+
return existing;
|
|
54
|
+
this.save([...entries, entry]);
|
|
55
|
+
return entry;
|
|
56
|
+
}
|
|
57
|
+
markRetry(requestId, delayMs) {
|
|
58
|
+
const entries = this.list().map((entry) => entry.requestId === requestId ? {
|
|
59
|
+
...entry,
|
|
60
|
+
attemptCount: entry.attemptCount + 1,
|
|
61
|
+
nextAttemptAt: new Date(Date.now() + delayMs).toISOString(),
|
|
62
|
+
} : entry);
|
|
63
|
+
this.save(entries);
|
|
64
|
+
}
|
|
65
|
+
remove(requestId) {
|
|
66
|
+
this.save(this.list().filter((entry) => entry.requestId !== requestId));
|
|
67
|
+
}
|
|
68
|
+
save(entries) {
|
|
69
|
+
const resolved = path.resolve(this.filePath);
|
|
70
|
+
const directory = path.dirname(resolved);
|
|
71
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
72
|
+
const temporary = `${resolved}.${process.pid}.${Date.now()}.tmp`;
|
|
73
|
+
const fd = fs.openSync(temporary, 'wx', 0o600);
|
|
74
|
+
try {
|
|
75
|
+
fs.writeFileSync(fd, JSON.stringify({ entries }, null, 2));
|
|
76
|
+
fs.fsyncSync(fd);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
fs.closeSync(fd);
|
|
80
|
+
}
|
|
81
|
+
fs.renameSync(temporary, resolved);
|
|
82
|
+
const directoryFd = fs.openSync(directory, 'r');
|
|
83
|
+
try {
|
|
84
|
+
fs.fsyncSync(directoryFd);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
fs.closeSync(directoryFd);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
package/dist/outbox/index.d.ts
CHANGED
package/dist/outbox/index.js
CHANGED
|
@@ -25,6 +25,7 @@ export declare class SessionState {
|
|
|
25
25
|
lastMessageAt: Date;
|
|
26
26
|
status: string;
|
|
27
27
|
pendingMessages: MessageRecord[];
|
|
28
|
+
readonly seenMessageIds: Set<string>;
|
|
28
29
|
ackToken: string | null;
|
|
29
30
|
constructor(taskId: string, sessionId: string, projectId: string, createdAt?: Date);
|
|
30
31
|
}
|
package/dist/session/manager.js
CHANGED
|
@@ -39,6 +39,7 @@ export class SessionState {
|
|
|
39
39
|
lastMessageAt;
|
|
40
40
|
status = 'ACTIVE';
|
|
41
41
|
pendingMessages = [];
|
|
42
|
+
seenMessageIds = new Set();
|
|
42
43
|
ackToken = null;
|
|
43
44
|
constructor(taskId, sessionId, projectId, createdAt = new Date()) {
|
|
44
45
|
this.taskId = taskId;
|
|
@@ -102,7 +103,10 @@ export class SessionManager {
|
|
|
102
103
|
if (!session) {
|
|
103
104
|
return;
|
|
104
105
|
}
|
|
106
|
+
if (session.seenMessageIds.has(message.messageId))
|
|
107
|
+
return;
|
|
105
108
|
const record = new MessageRecord(message);
|
|
109
|
+
session.seenMessageIds.add(record.messageId);
|
|
106
110
|
session.pendingMessages.push(record);
|
|
107
111
|
session.lastMessageAt = record.createdAt;
|
|
108
112
|
this.ensureEvent(taskId).signal();
|
package/dist/ws/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildFireHostName } from '../agent-host.js';
|
|
1
2
|
class AsyncLock {
|
|
2
3
|
tail = Promise.resolve();
|
|
3
4
|
async runExclusive(fn) {
|
|
@@ -443,9 +444,7 @@ async function defaultConnectImpl(url, options) {
|
|
|
443
444
|
});
|
|
444
445
|
}
|
|
445
446
|
function defaultHostName() {
|
|
446
|
-
|
|
447
|
-
const host = process.env.HOSTNAME || process.env.COMPUTERNAME || 'unknown-host';
|
|
448
|
-
return `conductor-fire-${host}-${pid}`;
|
|
447
|
+
return buildFireHostName(process.env);
|
|
449
448
|
}
|
|
450
449
|
class WsAdapter {
|
|
451
450
|
ws;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@love-moon/conductor-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/lovemoon-ai/conductor.git"
|
|
@@ -33,5 +33,5 @@
|
|
|
33
33
|
"typescript": "^5.6.3",
|
|
34
34
|
"vitest": "^2.1.4"
|
|
35
35
|
},
|
|
36
|
-
"gitCommitId": "
|
|
36
|
+
"gitCommitId": "bc7b3a5"
|
|
37
37
|
}
|