@awak-app/simy-cli 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -4
- package/package.json +16 -3
- package/src/agent.js +744 -56
- package/src/backend-executable.js +44 -0
- package/src/browser.js +59 -0
- package/src/console/app.js +1042 -0
- package/src/console/commands.js +100 -0
- package/src/console/index.js +25 -0
- package/src/index.js +44 -3
- package/src/local-attachments.js +270 -0
- package/src/orchestrator/contract.js +1 -0
- package/src/orchestrator/independent-audit.js +25 -0
- package/src/orchestrator/index.js +1 -1
- package/src/orchestrator/instruction.js +27 -1
- package/src/orchestrator/loop.js +61 -25
- package/src/orchestrator/presentation.js +189 -0
- package/src/orchestrator/result.js +11 -0
- package/src/provider-stream.js +310 -0
- package/src/repository-inventory.js +186 -0
- package/src/run-registry.js +44 -0
- package/src/runner.js +525 -64
- package/src/session-store.js +46 -17
- package/src/web-api.js +66 -0
- package/src/web-origin.js +46 -0
- package/src/workspace-context.js +37 -0
package/src/agent.js
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
3
4
|
|
|
4
5
|
import {
|
|
6
|
+
applyLocalHumanDecision,
|
|
7
|
+
continueLocalCodingRun,
|
|
8
|
+
continueLocalCodingRunAfterRepositoryApproval,
|
|
5
9
|
createRun,
|
|
6
10
|
LocalRunRegistry,
|
|
11
|
+
isLocalRepositoryApprovalPending,
|
|
12
|
+
pauseLocalCodingRun,
|
|
13
|
+
queueLocalCodingGuidance,
|
|
7
14
|
recheckLocalCodingRun,
|
|
15
|
+
publishRestoredLocalCodingRun,
|
|
16
|
+
resolveRepositoryPath,
|
|
17
|
+
restoreRun,
|
|
18
|
+
resumeLocalCodingRun,
|
|
8
19
|
startLocalCodingRun,
|
|
20
|
+
stopLocalCodingRun,
|
|
9
21
|
toCompatibleCodingLoopState,
|
|
10
22
|
toLedgerSnapshot,
|
|
11
23
|
} from "./runner.js";
|
|
@@ -16,19 +28,211 @@ import {
|
|
|
16
28
|
sessionPath,
|
|
17
29
|
writeSession,
|
|
18
30
|
} from "./session-store.js";
|
|
31
|
+
import { isRequestOriginAllowed, resolveWebOrigin } from "./web-origin.js";
|
|
32
|
+
import {
|
|
33
|
+
cleanupExpiredRuns,
|
|
34
|
+
cleanupRunAttachments,
|
|
35
|
+
stageRunAttachments,
|
|
36
|
+
referenceLocalAttachmentPaths,
|
|
37
|
+
} from "./local-attachments.js";
|
|
38
|
+
import { discoverWorkspace } from "./workspace-context.js";
|
|
39
|
+
import { resolveBackendExecutable } from "./backend-executable.js";
|
|
40
|
+
import {
|
|
41
|
+
resolveWebApiBaseUrl,
|
|
42
|
+
sessionRequiresWebAuthorization,
|
|
43
|
+
webApiErrorMessage,
|
|
44
|
+
webApiHeaders,
|
|
45
|
+
webApiUrl,
|
|
46
|
+
} from "./web-api.js";
|
|
47
|
+
import {
|
|
48
|
+
defaultRepositoryScanRoot,
|
|
49
|
+
findRepository,
|
|
50
|
+
mergeRepositoryInventory,
|
|
51
|
+
readRepositoryInventory,
|
|
52
|
+
scanGitRepositories,
|
|
53
|
+
writeRepositoryInventory,
|
|
54
|
+
} from "./repository-inventory.js";
|
|
19
55
|
|
|
20
|
-
const
|
|
21
|
-
const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$|^https:\/\/app\.simy\.one$/;
|
|
56
|
+
const DEVICE_HEARTBEAT_INTERVAL_MS = 20_000;
|
|
22
57
|
|
|
23
|
-
export async function startAgent({
|
|
24
|
-
|
|
58
|
+
export async function startAgent({
|
|
59
|
+
requestedPort = 0,
|
|
60
|
+
daemon = false,
|
|
61
|
+
webOrigin = null,
|
|
62
|
+
sessionRoot,
|
|
63
|
+
dependencies = {},
|
|
64
|
+
quiet = false,
|
|
65
|
+
} = {}) {
|
|
66
|
+
const apiOrigin = resolveWebOrigin(webOrigin);
|
|
25
67
|
const registry = new LocalRunRegistry();
|
|
26
68
|
const authNonce = randomBytes(16).toString("base64url");
|
|
27
|
-
let session = await readSession();
|
|
69
|
+
let session = await readSession(apiOrigin, sessionRoot);
|
|
70
|
+
if (session && sessionRequiresWebAuthorization(session, apiOrigin)) session = null;
|
|
71
|
+
await cleanupExpiredRuns();
|
|
72
|
+
const workspace = dependencies.discoverWorkspace
|
|
73
|
+
? await dependencies.discoverWorkspace()
|
|
74
|
+
: await discoverWorkspace(dependencies.cwd ?? process.cwd());
|
|
75
|
+
const persistedInventory = await readRepositoryInventory(sessionRoot);
|
|
76
|
+
let authorizedRepositoryRoots = [...persistedInventory.authorizedRoots];
|
|
77
|
+
let repositoryInventory = mergeRepositoryInventory(
|
|
78
|
+
persistedInventory.repositories,
|
|
79
|
+
workspace.repository
|
|
80
|
+
? [
|
|
81
|
+
{
|
|
82
|
+
repository: workspace.repository,
|
|
83
|
+
branch: workspace.branch,
|
|
84
|
+
local_path: workspace.localPath,
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
: [],
|
|
88
|
+
);
|
|
89
|
+
const availableCapabilities = await readCapabilities(dependencies);
|
|
90
|
+
const heartbeatDevice = dependencies.heartbeatDevice || heartbeatLocalDevice;
|
|
91
|
+
const runOptions = dependencies.runOptions || {};
|
|
92
|
+
const repositoryScanRoot = resolve(
|
|
93
|
+
String(
|
|
94
|
+
dependencies.repositoryScanRoot ||
|
|
95
|
+
process.env.SIMY_REPO_ROOT ||
|
|
96
|
+
(workspace.repository ? dirname(workspace.localPath) : workspace.localPath) ||
|
|
97
|
+
defaultRepositoryScanRoot(),
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
let port = requestedPort;
|
|
101
|
+
let heartbeatTimer = null;
|
|
102
|
+
let heartbeatInFlight = null;
|
|
103
|
+
|
|
104
|
+
const restoreRemoteRuns = async () => {
|
|
105
|
+
if (!isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) return;
|
|
106
|
+
const snapshots = dependencies.listRemoteRuns
|
|
107
|
+
? await dependencies.listRemoteRuns({
|
|
108
|
+
apiOrigin,
|
|
109
|
+
apiBaseUrl: session.api_base_url,
|
|
110
|
+
token: session.token,
|
|
111
|
+
})
|
|
112
|
+
: await listRemoteRuns({
|
|
113
|
+
apiOrigin,
|
|
114
|
+
apiBaseUrl: session.api_base_url,
|
|
115
|
+
token: session.token,
|
|
116
|
+
});
|
|
117
|
+
for (const snapshot of snapshots) {
|
|
118
|
+
if (!snapshot?.id || registry.has(snapshot.id)) continue;
|
|
119
|
+
const repository = findRepository(repositoryInventory, snapshot.charter?.repository);
|
|
120
|
+
const restoredRun = restoreRun({
|
|
121
|
+
snapshot,
|
|
122
|
+
session,
|
|
123
|
+
apiOrigin,
|
|
124
|
+
localPath: repository?.local_path || null,
|
|
125
|
+
});
|
|
126
|
+
registry.create(restoredRun);
|
|
127
|
+
await publishRestoredLocalCodingRun(restoredRun);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const heartbeat = () => {
|
|
132
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) return Promise.resolve(false);
|
|
133
|
+
if (heartbeatInFlight) return heartbeatInFlight;
|
|
134
|
+
heartbeatInFlight = heartbeatDevice({
|
|
135
|
+
apiOrigin,
|
|
136
|
+
apiBaseUrl: session.api_base_url,
|
|
137
|
+
token: session.token,
|
|
138
|
+
port,
|
|
139
|
+
capabilities: availableCapabilities,
|
|
140
|
+
repoInventory: repositoryInventory,
|
|
141
|
+
})
|
|
142
|
+
.then(() => true)
|
|
143
|
+
.finally(() => {
|
|
144
|
+
heartbeatInFlight = null;
|
|
145
|
+
});
|
|
146
|
+
return heartbeatInFlight;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const startHeartbeatTimer = () => {
|
|
150
|
+
if (heartbeatTimer) return;
|
|
151
|
+
heartbeatTimer = setInterval(() => {
|
|
152
|
+
void heartbeat().catch((error) => reportHeartbeatError(error, quiet));
|
|
153
|
+
}, DEVICE_HEARTBEAT_INTERVAL_MS);
|
|
154
|
+
heartbeatTimer.unref();
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const synchronizeAuthorizedSession = async () => {
|
|
158
|
+
try {
|
|
159
|
+
await heartbeat();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
reportHeartbeatError(error, quiet);
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
await restoreRemoteRuns();
|
|
165
|
+
} catch (error) {
|
|
166
|
+
reportStartupConnectionError(error, quiet);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const scanRepositories = async (root) => {
|
|
171
|
+
const scan = dependencies.scanRepositories
|
|
172
|
+
? await dependencies.scanRepositories(root)
|
|
173
|
+
: await scanGitRepositories(root);
|
|
174
|
+
repositoryInventory = mergeRepositoryInventory(repositoryInventory, scan.repositories);
|
|
175
|
+
authorizedRepositoryRoots = [
|
|
176
|
+
...new Set([...authorizedRepositoryRoots, scan.root].filter(Boolean)),
|
|
177
|
+
];
|
|
178
|
+
await writeRepositoryInventory(
|
|
179
|
+
{
|
|
180
|
+
authorizedRoots: authorizedRepositoryRoots,
|
|
181
|
+
repositories: repositoryInventory,
|
|
182
|
+
scannedAt: scan.scannedAt,
|
|
183
|
+
},
|
|
184
|
+
sessionRoot,
|
|
185
|
+
);
|
|
186
|
+
await heartbeat().catch((error) => reportHeartbeatError(error, quiet));
|
|
187
|
+
return {
|
|
188
|
+
...scan,
|
|
189
|
+
discoveredRepositories: mergeRepositoryInventory(scan.repositories),
|
|
190
|
+
repositories: [...repositoryInventory],
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const isRepositoryScanAuthorized = () =>
|
|
195
|
+
authorizedRepositoryRoots.some((item) => resolve(item) === repositoryScanRoot);
|
|
196
|
+
|
|
197
|
+
const refreshAuthorizedRepositoryInventory = async () => {
|
|
198
|
+
if (!isRepositoryScanAuthorized()) return null;
|
|
199
|
+
return scanRepositories(repositoryScanRoot);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const ensureRepositoryIndexed = async (repository) => {
|
|
203
|
+
const indexed = findRepository(repositoryInventory, repository);
|
|
204
|
+
if (indexed || !isRepositoryScanAuthorized()) return indexed;
|
|
205
|
+
await refreshAuthorizedRepositoryInventory();
|
|
206
|
+
return findRepository(repositoryInventory, repository);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const selectRepository = async (run, repository) => {
|
|
210
|
+
const selected = repository?.local_path
|
|
211
|
+
? repositoryInventory.find(
|
|
212
|
+
(item) =>
|
|
213
|
+
item.local_path === repository.local_path &&
|
|
214
|
+
item.repository.toLowerCase() === repository.repository.toLowerCase(),
|
|
215
|
+
)
|
|
216
|
+
: findRepository(repositoryInventory, repository?.repository || repository);
|
|
217
|
+
if (!selected) throw new Error("Select a repository discovered by the authorized scan.");
|
|
218
|
+
if (run && selected.repository.toLowerCase() !== run.request.repository.toLowerCase()) {
|
|
219
|
+
throw new Error(`This run requires ${run.request.repository}; select that local repository.`);
|
|
220
|
+
}
|
|
221
|
+
if (!run) return selected;
|
|
222
|
+
return continueLocalCodingRunAfterRepositoryApproval(
|
|
223
|
+
run,
|
|
224
|
+
{ repository: selected.repository, localPath: selected.local_path },
|
|
225
|
+
runOptions,
|
|
226
|
+
);
|
|
227
|
+
};
|
|
28
228
|
|
|
29
229
|
const server = createServer(async (req, res) => {
|
|
30
230
|
try {
|
|
31
|
-
|
|
231
|
+
if (!isRequestOriginAllowed(req.headers.origin, apiOrigin)) {
|
|
232
|
+
json(res, 403, { error: "request origin does not match the configured SIMY Web host" });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
applyCors(req, res, apiOrigin);
|
|
32
236
|
if (req.method === "OPTIONS") {
|
|
33
237
|
res.writeHead(204).end();
|
|
34
238
|
return;
|
|
@@ -39,13 +243,43 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
|
|
|
39
243
|
json(res, 200, {
|
|
40
244
|
ok: true,
|
|
41
245
|
daemon,
|
|
42
|
-
|
|
246
|
+
web_origin: apiOrigin,
|
|
247
|
+
session_valid: isSessionValid(session, Date.now(), apiOrigin),
|
|
43
248
|
session_expires_at: session?.expires_at ?? null,
|
|
44
249
|
});
|
|
45
250
|
return;
|
|
46
251
|
}
|
|
47
252
|
if (req.method === "GET" && url.pathname === "/v1/capabilities") {
|
|
48
|
-
json(res, 200, await
|
|
253
|
+
json(res, 200, await readCapabilities(dependencies));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (req.method === "POST" && url.pathname === "/v1/coding-loop/preflight") {
|
|
257
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
258
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const body = await readJson(req);
|
|
262
|
+
const repository = String(body.repository || "").trim();
|
|
263
|
+
if (!repository) {
|
|
264
|
+
json(res, 400, { error: "repository is required" });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (body.backend !== "codex" && body.backend !== "claude") {
|
|
268
|
+
json(res, 400, { error: "backend must be codex or claude" });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const backend = body.backend;
|
|
272
|
+
const indexedRepository = await ensureRepositoryIndexed(repository);
|
|
273
|
+
const environment = await inspectCodingLoopEnvironment({
|
|
274
|
+
repository,
|
|
275
|
+
backend,
|
|
276
|
+
localPath:
|
|
277
|
+
typeof body.local_path === "string"
|
|
278
|
+
? body.local_path
|
|
279
|
+
: indexedRepository?.local_path || null,
|
|
280
|
+
dependencies,
|
|
281
|
+
});
|
|
282
|
+
json(res, 200, environment);
|
|
49
283
|
return;
|
|
50
284
|
}
|
|
51
285
|
if (req.method === "POST" && url.pathname === "/v1/auth/complete") {
|
|
@@ -54,24 +288,43 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
|
|
|
54
288
|
json(res, 401, { error: "invalid nonce" });
|
|
55
289
|
return;
|
|
56
290
|
}
|
|
291
|
+
if (body.api_origin !== apiOrigin) {
|
|
292
|
+
json(res, 403, { error: "authorization origin does not match the configured host" });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
let apiBaseUrl;
|
|
296
|
+
try {
|
|
297
|
+
apiBaseUrl = resolveWebApiBaseUrl(apiOrigin, body.api_base_url);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
json(res, 503, {
|
|
300
|
+
error: error instanceof Error ? error.message : "CLI API endpoint is unavailable",
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
57
304
|
session = {
|
|
58
305
|
token: String(body.token || ""),
|
|
59
306
|
device_id: typeof body.device_id === "string" ? body.device_id : null,
|
|
60
|
-
api_origin:
|
|
307
|
+
api_origin: apiOrigin,
|
|
308
|
+
api_base_url: apiBaseUrl,
|
|
61
309
|
expires_at: typeof body.expires_at === "string" ? body.expires_at : expiresAtFromNow(),
|
|
62
310
|
};
|
|
63
|
-
await writeSession(session);
|
|
311
|
+
await writeSession(apiOrigin, session, sessionRoot);
|
|
312
|
+
startHeartbeatTimer();
|
|
313
|
+
loginUrl = null;
|
|
314
|
+
registry.emit("change", registry.list());
|
|
64
315
|
json(res, 200, { ok: true, expires_at: session.expires_at });
|
|
316
|
+
void synchronizeAuthorizedSession();
|
|
65
317
|
return;
|
|
66
318
|
}
|
|
67
319
|
if (req.method === "POST" && url.pathname === "/v1/coding-loop/start") {
|
|
68
|
-
if (!isSessionValid(session)) {
|
|
320
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
69
321
|
json(res, 401, { error: "simy session expired; run simy again" });
|
|
70
322
|
return;
|
|
71
323
|
}
|
|
72
|
-
const body = await
|
|
324
|
+
const { body, attachments } = await readCodingLoopStart(req);
|
|
73
325
|
const verified = await verifyLaunchChallenge({
|
|
74
|
-
apiOrigin
|
|
326
|
+
apiOrigin,
|
|
327
|
+
apiBaseUrl: session.api_base_url,
|
|
75
328
|
token: session.token,
|
|
76
329
|
runId: String(body.run_id || ""),
|
|
77
330
|
challenge: String(body.challenge || ""),
|
|
@@ -89,6 +342,18 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
|
|
|
89
342
|
json(res, 409, { error: "coding loop run already exists" });
|
|
90
343
|
return;
|
|
91
344
|
}
|
|
345
|
+
let stagedAttachments = [];
|
|
346
|
+
try {
|
|
347
|
+
stagedAttachments = await stageRunAttachments({
|
|
348
|
+
runId,
|
|
349
|
+
attachments,
|
|
350
|
+
manifest: attachments.length > 0 ? body.attachment_manifest ?? [] : undefined,
|
|
351
|
+
});
|
|
352
|
+
} catch (error) {
|
|
353
|
+
json(res, 400, { error: error instanceof Error ? error.message : "attachment rejected" });
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const indexedRepository = await ensureRepositoryIndexed(body.repository);
|
|
92
357
|
const run = createRun({
|
|
93
358
|
runId,
|
|
94
359
|
request: {
|
|
@@ -99,7 +364,10 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
|
|
|
99
364
|
: null,
|
|
100
365
|
requirement: String(body.requirement || ""),
|
|
101
366
|
repository: String(body.repository || ""),
|
|
102
|
-
local_path:
|
|
367
|
+
local_path:
|
|
368
|
+
typeof body.local_path === "string"
|
|
369
|
+
? body.local_path
|
|
370
|
+
: indexedRepository?.local_path || null,
|
|
103
371
|
base_branch: typeof body.base_branch === "string" ? body.base_branch : "dev",
|
|
104
372
|
max_attempts: body.max_attempts,
|
|
105
373
|
ui_evidence_root:
|
|
@@ -123,16 +391,167 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
|
|
|
123
391
|
typeof body.design_review_url === "string" ? body.design_review_url : "",
|
|
124
392
|
must_not: Array.isArray(body.must_not) ? body.must_not : [],
|
|
125
393
|
proposal_id: typeof body.proposal_id === "string" ? body.proposal_id : null,
|
|
394
|
+
attachments: stagedAttachments,
|
|
126
395
|
},
|
|
127
396
|
session,
|
|
128
|
-
apiOrigin
|
|
397
|
+
apiOrigin,
|
|
129
398
|
});
|
|
130
|
-
|
|
131
|
-
|
|
399
|
+
try {
|
|
400
|
+
registry.create(run);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
await cleanupRunAttachments(stagedAttachments);
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
void startLocalCodingRun(run, dependencies.runOptions);
|
|
132
406
|
json(res, 202, { ok: true, run_id: run.id });
|
|
133
407
|
return;
|
|
134
408
|
}
|
|
135
409
|
|
|
410
|
+
const controlMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/control$/);
|
|
411
|
+
if (req.method === "POST" && controlMatch) {
|
|
412
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
413
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const run = registry.get(decodeURIComponent(controlMatch[1]));
|
|
417
|
+
if (!run) {
|
|
418
|
+
json(res, 404, { error: "run not found" });
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const body = await readJson(req);
|
|
422
|
+
if (body.action === "stop") {
|
|
423
|
+
await stopLocalCodingRun(run);
|
|
424
|
+
} else if (body.action === "pause") {
|
|
425
|
+
pauseLocalCodingRun(run);
|
|
426
|
+
} else if (body.action === "resume") {
|
|
427
|
+
resumeLocalCodingRun(run);
|
|
428
|
+
} else {
|
|
429
|
+
json(res, 400, { error: "action must be pause, resume, or stop" });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
json(res, 200, {
|
|
433
|
+
ok: true,
|
|
434
|
+
run_id: run.id,
|
|
435
|
+
action: body.action,
|
|
436
|
+
state: run.status,
|
|
437
|
+
control_state: run.controlState,
|
|
438
|
+
delivery_status: "delivered",
|
|
439
|
+
});
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const guidanceMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/guidance$/);
|
|
444
|
+
if (req.method === "POST" && guidanceMatch) {
|
|
445
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
446
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const run = registry.get(decodeURIComponent(guidanceMatch[1]));
|
|
450
|
+
if (!run) {
|
|
451
|
+
json(res, 404, { error: "run not found" });
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const body = await readJson(req);
|
|
455
|
+
const message = String(body.message || "").trim();
|
|
456
|
+
if (!message) {
|
|
457
|
+
json(res, 400, { error: "message is required" });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (!run.operation && !run.child) {
|
|
461
|
+
json(res, 409, { error: "run is not actively executing" });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
queueLocalCodingGuidance(run, message);
|
|
465
|
+
json(res, 202, {
|
|
466
|
+
ok: true,
|
|
467
|
+
run_id: run.id,
|
|
468
|
+
delivery_status: "queued",
|
|
469
|
+
});
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const hilMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/hil$/);
|
|
474
|
+
if (hilMatch) {
|
|
475
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
476
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const run = registry.get(decodeURIComponent(hilMatch[1]));
|
|
480
|
+
if (!run) {
|
|
481
|
+
json(res, 404, { error: "run not found" });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (!isLocalRepositoryApprovalPending(run)) {
|
|
485
|
+
json(res, 409, { error: "run is not waiting for local repository approval" });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (req.method === "GET") {
|
|
490
|
+
json(res, 200, {
|
|
491
|
+
request: localRepositoryHilRequest(
|
|
492
|
+
run,
|
|
493
|
+
repositoryScanRoot,
|
|
494
|
+
authorizedRepositoryRoots,
|
|
495
|
+
),
|
|
496
|
+
});
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (req.method === "POST") {
|
|
501
|
+
const body = await readJson(req);
|
|
502
|
+
const requestId = localRepositoryHilRequestId(run);
|
|
503
|
+
if (body.request_id !== requestId) {
|
|
504
|
+
json(res, 409, { error: "HIL request is stale or does not match this run" });
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (body.decision === "reject") {
|
|
508
|
+
json(res, 200, {
|
|
509
|
+
ok: true,
|
|
510
|
+
request_id: requestId,
|
|
511
|
+
decision: "rejected",
|
|
512
|
+
state: "waiting_human",
|
|
513
|
+
});
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (body.decision !== "approve") {
|
|
517
|
+
json(res, 400, { error: "decision must be approve or reject" });
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const scan = await scanRepositories(repositoryScanRoot);
|
|
522
|
+
const selected = findRepository(scan.discoveredRepositories, run.request.repository);
|
|
523
|
+
if (!selected) {
|
|
524
|
+
json(res, 404, {
|
|
525
|
+
error: `${run.request.repository} was not found under ${scan.root}`,
|
|
526
|
+
code: "repository_not_found_in_scan",
|
|
527
|
+
root: scan.root,
|
|
528
|
+
repository: run.request.repository,
|
|
529
|
+
discovered_count: scan.discoveredRepositories.length,
|
|
530
|
+
truncated: scan.truncated,
|
|
531
|
+
});
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const continuation = continueLocalCodingRunAfterRepositoryApproval(
|
|
536
|
+
run,
|
|
537
|
+
{ repository: selected.repository, localPath: selected.local_path },
|
|
538
|
+
runOptions,
|
|
539
|
+
);
|
|
540
|
+
json(res, 202, {
|
|
541
|
+
ok: true,
|
|
542
|
+
request_id: requestId,
|
|
543
|
+
decision: "approved",
|
|
544
|
+
run_id: run.id,
|
|
545
|
+
repository: selected.repository,
|
|
546
|
+
local_path: selected.local_path,
|
|
547
|
+
root: scan.root,
|
|
548
|
+
state: "starting",
|
|
549
|
+
});
|
|
550
|
+
void continuation.catch((error) => reportRepositoryResumeError(error, quiet));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
136
555
|
const recheckMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/recheck$/);
|
|
137
556
|
if (req.method === "POST" && recheckMatch) {
|
|
138
557
|
const run = registry.get(decodeURIComponent(recheckMatch[1]));
|
|
@@ -163,62 +582,305 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
|
|
|
163
582
|
|
|
164
583
|
await new Promise((resolve) => server.listen(requestedPort, "127.0.0.1", resolve));
|
|
165
584
|
const address = server.address();
|
|
166
|
-
|
|
167
|
-
|
|
585
|
+
port = typeof address === "object" && address ? address.port : requestedPort;
|
|
586
|
+
if (!quiet) {
|
|
587
|
+
console.log(`SIMY local agent listening on http://127.0.0.1:${port}`);
|
|
588
|
+
console.log(`SIMY Web host: ${apiOrigin}`);
|
|
589
|
+
}
|
|
168
590
|
|
|
169
|
-
|
|
170
|
-
|
|
591
|
+
let loginUrl = null;
|
|
592
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
593
|
+
loginUrl = new URL("/local-cli/connect", apiOrigin);
|
|
171
594
|
loginUrl.searchParams.set("port", String(port));
|
|
172
595
|
loginUrl.searchParams.set("nonce", authNonce);
|
|
173
|
-
|
|
174
|
-
|
|
596
|
+
if (!quiet) {
|
|
597
|
+
console.log(`Sign in to SIMY: ${loginUrl.toString()}`);
|
|
598
|
+
console.log(`Session file: ${sessionPath(apiOrigin, sessionRoot)}`);
|
|
599
|
+
}
|
|
175
600
|
}
|
|
176
601
|
|
|
177
|
-
|
|
602
|
+
if (isSessionValid(session, Date.now(), apiOrigin)) {
|
|
603
|
+
try {
|
|
604
|
+
await refreshAuthorizedRepositoryInventory();
|
|
605
|
+
await heartbeat();
|
|
606
|
+
await restoreRemoteRuns();
|
|
607
|
+
} catch (error) {
|
|
608
|
+
reportStartupConnectionError(error, quiet);
|
|
609
|
+
}
|
|
610
|
+
startHeartbeatTimer();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
server.on("close", () => {
|
|
614
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
615
|
+
registry.close();
|
|
616
|
+
});
|
|
617
|
+
return {
|
|
618
|
+
server,
|
|
619
|
+
port,
|
|
620
|
+
registry,
|
|
621
|
+
webOrigin: apiOrigin,
|
|
622
|
+
get loginUrl() {
|
|
623
|
+
return loginUrl?.toString() ?? null;
|
|
624
|
+
},
|
|
625
|
+
workspace,
|
|
626
|
+
repositoryScanRoot,
|
|
627
|
+
capabilities: availableCapabilities,
|
|
628
|
+
controls: {
|
|
629
|
+
repositoryInventory: () => [...repositoryInventory],
|
|
630
|
+
scanRepositories,
|
|
631
|
+
selectRepository,
|
|
632
|
+
create: async (input) => {
|
|
633
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
634
|
+
throw new Error("Sign in to SIMY before starting a coding task.");
|
|
635
|
+
}
|
|
636
|
+
const requirement = String(input.requirement || "").trim();
|
|
637
|
+
const repository = String(input.repository || "").trim();
|
|
638
|
+
const backend = input.backend === "claude" ? "claude" : "codex";
|
|
639
|
+
if (!requirement) throw new Error("Describe the coding task first.");
|
|
640
|
+
if (!repository) throw new Error("Set a repository with /repo owner/name.");
|
|
641
|
+
if (availableCapabilities?.backends?.[backend] !== true) {
|
|
642
|
+
throw new Error(`${backend === "claude" ? "Claude Code" : "Codex"} is not available on PATH.`);
|
|
643
|
+
}
|
|
644
|
+
const indexedRepository = await ensureRepositoryIndexed(repository);
|
|
645
|
+
const request = {
|
|
646
|
+
requirement,
|
|
647
|
+
repository,
|
|
648
|
+
local_path: indexedRepository?.local_path || input.localPath || workspace.localPath,
|
|
649
|
+
base_branch: String(input.baseBranch || "dev"),
|
|
650
|
+
backend,
|
|
651
|
+
max_attempts: 3,
|
|
652
|
+
acceptance_criteria: [],
|
|
653
|
+
expected_tests: [],
|
|
654
|
+
expected_evidence: [],
|
|
655
|
+
required_checks: [],
|
|
656
|
+
require_human_approval: true,
|
|
657
|
+
must_not: [],
|
|
658
|
+
attachments: await referenceLocalAttachmentPaths(input.attachmentPaths || []),
|
|
659
|
+
};
|
|
660
|
+
await resolveRepositoryPath(request);
|
|
661
|
+
const remoteRun = dependencies.createRemoteRun
|
|
662
|
+
? await dependencies.createRemoteRun(request)
|
|
663
|
+
: await createRemoteRun({
|
|
664
|
+
apiOrigin,
|
|
665
|
+
apiBaseUrl: session.api_base_url,
|
|
666
|
+
token: session.token,
|
|
667
|
+
request,
|
|
668
|
+
});
|
|
669
|
+
const runId = String(remoteRun?.id || "");
|
|
670
|
+
if (!runId) throw new Error("SIMY Web did not return a coding run id.");
|
|
671
|
+
const run = createRun({ runId, request, session, apiOrigin });
|
|
672
|
+
registry.create(run);
|
|
673
|
+
void startLocalCodingRun(run, runOptions);
|
|
674
|
+
return run;
|
|
675
|
+
},
|
|
676
|
+
continue: (run, guidance) => continueLocalCodingRun(run, guidance, runOptions),
|
|
677
|
+
applyDecision: (run, decision) => applyLocalHumanDecision(run, decision, runOptions),
|
|
678
|
+
queueGuidance: queueLocalCodingGuidance,
|
|
679
|
+
pause: pauseLocalCodingRun,
|
|
680
|
+
resume: resumeLocalCodingRun,
|
|
681
|
+
stop: stopLocalCodingRun,
|
|
682
|
+
recheck: (run) =>
|
|
683
|
+
recheckLocalCodingRun(run, { collectEvidence: runOptions.collectEvidence }),
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function createRemoteRun({ apiOrigin, apiBaseUrl, token, request }) {
|
|
689
|
+
const response = await fetch(webApiUrl("runs", { webOrigin: apiOrigin, apiBaseUrl }), {
|
|
690
|
+
method: "POST",
|
|
691
|
+
headers: webApiHeaders(
|
|
692
|
+
{ token },
|
|
693
|
+
{ "Content-Type": "application/json" },
|
|
694
|
+
),
|
|
695
|
+
body: JSON.stringify({
|
|
696
|
+
requirement: request.requirement,
|
|
697
|
+
repository: request.repository,
|
|
698
|
+
backend: request.backend,
|
|
699
|
+
base_branch: request.base_branch,
|
|
700
|
+
max_attempts: request.max_attempts,
|
|
701
|
+
acceptance_criteria: request.acceptance_criteria,
|
|
702
|
+
expected_tests: request.expected_tests,
|
|
703
|
+
expected_evidence: request.expected_evidence,
|
|
704
|
+
must_not: request.must_not,
|
|
705
|
+
}),
|
|
706
|
+
});
|
|
707
|
+
const payload = await response.json().catch(() => null);
|
|
708
|
+
if (!response.ok) {
|
|
709
|
+
throw new Error(webApiErrorMessage(payload, response.status));
|
|
710
|
+
}
|
|
711
|
+
return payload?.run;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function listRemoteRuns({ apiOrigin, apiBaseUrl, token }) {
|
|
715
|
+
const response = await fetch(webApiUrl("runs", { webOrigin: apiOrigin, apiBaseUrl }), {
|
|
716
|
+
method: "GET",
|
|
717
|
+
headers: webApiHeaders({ token }),
|
|
718
|
+
signal: AbortSignal.timeout(5_000),
|
|
719
|
+
});
|
|
720
|
+
const payload = await response.json().catch(() => null);
|
|
721
|
+
if (!response.ok) {
|
|
722
|
+
throw new Error(webApiErrorMessage(payload, response.status));
|
|
723
|
+
}
|
|
724
|
+
return Array.isArray(payload?.runs) ? payload.runs : [];
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export async function heartbeatLocalDevice({
|
|
728
|
+
apiOrigin,
|
|
729
|
+
apiBaseUrl,
|
|
730
|
+
token,
|
|
731
|
+
port,
|
|
732
|
+
capabilities,
|
|
733
|
+
repoInventory,
|
|
734
|
+
}) {
|
|
735
|
+
const response = await fetch(
|
|
736
|
+
webApiUrl("devices/heartbeat", { webOrigin: apiOrigin, apiBaseUrl }),
|
|
737
|
+
{
|
|
738
|
+
method: "POST",
|
|
739
|
+
headers: webApiHeaders(
|
|
740
|
+
{ token },
|
|
741
|
+
{ "Content-Type": "application/json" },
|
|
742
|
+
),
|
|
743
|
+
body: JSON.stringify({
|
|
744
|
+
port,
|
|
745
|
+
capabilities,
|
|
746
|
+
repo_inventory: repoInventory,
|
|
747
|
+
}),
|
|
748
|
+
signal: AbortSignal.timeout(5_000),
|
|
749
|
+
},
|
|
750
|
+
);
|
|
751
|
+
if (!response.ok) {
|
|
752
|
+
const payload = await response.json().catch(() => null);
|
|
753
|
+
throw new Error(webApiErrorMessage(payload, response.status));
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function reportHeartbeatError(error, quiet) {
|
|
758
|
+
if (quiet) return;
|
|
759
|
+
console.error(
|
|
760
|
+
`SIMY device heartbeat failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function reportStartupConnectionError(error, quiet) {
|
|
765
|
+
if (quiet) return;
|
|
766
|
+
console.error(
|
|
767
|
+
`SIMY startup synchronization failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function reportRepositoryResumeError(error, quiet) {
|
|
772
|
+
if (quiet) return;
|
|
773
|
+
console.error(
|
|
774
|
+
`SIMY repository-approved run failed to resume: ${error instanceof Error ? error.message : String(error)}`,
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function localRepositoryHilRequestId(run) {
|
|
779
|
+
return `local-repository-scan:${run.id}`;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function localRepositoryHilRequest(run, root, authorizedRoots) {
|
|
783
|
+
return {
|
|
784
|
+
id: localRepositoryHilRequestId(run),
|
|
785
|
+
kind: "local_repository_scan",
|
|
786
|
+
status: "pending",
|
|
787
|
+
title: "Allow SIMY CLI to find the local repository",
|
|
788
|
+
description: `Scan ${root} for the ${run.request.repository} Git checkout, then continue this run.`,
|
|
789
|
+
details: {
|
|
790
|
+
repository: run.request.repository,
|
|
791
|
+
root,
|
|
792
|
+
previously_authorized: authorizedRoots.some((item) => resolve(item) === root),
|
|
793
|
+
reads: ["directory names", "Git origin remotes", "current Git branches"],
|
|
794
|
+
excludes: [
|
|
795
|
+
"source file contents",
|
|
796
|
+
"hidden directories",
|
|
797
|
+
"dependency directories",
|
|
798
|
+
"cache directories",
|
|
799
|
+
"symbolic links",
|
|
800
|
+
],
|
|
801
|
+
},
|
|
802
|
+
actions: [
|
|
803
|
+
{ id: "approve", label: "Allow scan", tone: "primary" },
|
|
804
|
+
{ id: "reject", label: "Not now", tone: "secondary" },
|
|
805
|
+
],
|
|
806
|
+
};
|
|
178
807
|
}
|
|
179
808
|
|
|
180
809
|
async function capabilities() {
|
|
181
|
-
const [codex, claude] = await Promise.all([
|
|
810
|
+
const [codex, claude] = await Promise.all([
|
|
811
|
+
resolveBackendExecutable("codex"),
|
|
812
|
+
resolveBackendExecutable("claude"),
|
|
813
|
+
]);
|
|
182
814
|
return {
|
|
183
|
-
backends: { codex, claude },
|
|
815
|
+
backends: { codex: Boolean(codex), claude: Boolean(claude) },
|
|
816
|
+
features: { repository_scan_approval: true },
|
|
184
817
|
session_ttl_hours: 48,
|
|
185
818
|
};
|
|
186
819
|
}
|
|
187
820
|
|
|
188
|
-
async function
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
821
|
+
async function readCapabilities(dependencies) {
|
|
822
|
+
return dependencies.capabilities ? dependencies.capabilities() : capabilities();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function inspectCodingLoopEnvironment({ repository, backend, localPath, dependencies }) {
|
|
826
|
+
const checks = [
|
|
827
|
+
{
|
|
828
|
+
key: "session",
|
|
829
|
+
status: "passed",
|
|
830
|
+
summary: "Local CLI session is connected to this SIMY Web origin.",
|
|
831
|
+
},
|
|
832
|
+
];
|
|
833
|
+
let repositoryPath = null;
|
|
834
|
+
try {
|
|
835
|
+
repositoryPath = await resolveRepositoryPath({ repository, local_path: localPath });
|
|
836
|
+
checks.push({
|
|
837
|
+
key: "repository",
|
|
838
|
+
status: "passed",
|
|
839
|
+
summary: `Verified ${repository} against the local Git origin.`,
|
|
840
|
+
});
|
|
841
|
+
} catch (error) {
|
|
842
|
+
checks.push({
|
|
843
|
+
key: "repository",
|
|
844
|
+
status: "failed",
|
|
845
|
+
summary: error instanceof Error ? error.message : "Local repository could not be resolved.",
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const available = await readCapabilities(dependencies);
|
|
850
|
+
const backendAvailable = available?.backends?.[backend] === true;
|
|
851
|
+
checks.push({
|
|
852
|
+
key: "backend",
|
|
853
|
+
status: backendAvailable ? "passed" : "failed",
|
|
854
|
+
summary: backendAvailable
|
|
855
|
+
? `${backend === "claude" ? "Claude Code" : "Codex"} is available locally.`
|
|
856
|
+
: `${backend === "claude" ? "Claude Code" : "Codex"} is not available on PATH.`,
|
|
206
857
|
});
|
|
858
|
+
|
|
859
|
+
return {
|
|
860
|
+
ready: Boolean(repositoryPath && backendAvailable),
|
|
861
|
+
repository,
|
|
862
|
+
backend,
|
|
863
|
+
local_path: repositoryPath,
|
|
864
|
+
checks,
|
|
865
|
+
};
|
|
207
866
|
}
|
|
208
867
|
|
|
209
|
-
async function verifyLaunchChallenge({ apiOrigin, token, runId, challenge }) {
|
|
868
|
+
async function verifyLaunchChallenge({ apiOrigin, apiBaseUrl, token, runId, challenge }) {
|
|
210
869
|
try {
|
|
211
|
-
const response = await fetch(
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
870
|
+
const response = await fetch(
|
|
871
|
+
webApiUrl("challenges/verify", { webOrigin: apiOrigin, apiBaseUrl }),
|
|
872
|
+
{
|
|
873
|
+
method: "POST",
|
|
874
|
+
headers: webApiHeaders(
|
|
875
|
+
{ token },
|
|
876
|
+
{ "Content-Type": "application/json" },
|
|
877
|
+
),
|
|
878
|
+
body: JSON.stringify({ run_id: runId, challenge }),
|
|
216
879
|
},
|
|
217
|
-
|
|
218
|
-
});
|
|
880
|
+
);
|
|
219
881
|
if (response.ok) return { ok: true };
|
|
220
882
|
const payload = await response.json().catch(() => null);
|
|
221
|
-
return { ok: false, error:
|
|
883
|
+
return { ok: false, error: webApiErrorMessage(payload, response.status) };
|
|
222
884
|
} catch (err) {
|
|
223
885
|
return { ok: false, error: err instanceof Error ? err.message : "challenge check failed" };
|
|
224
886
|
}
|
|
@@ -248,11 +910,14 @@ function streamRun(res, run) {
|
|
|
248
910
|
res.on("close", () => run.emitter.off("event", listener));
|
|
249
911
|
}
|
|
250
912
|
|
|
251
|
-
function applyCors(req, res) {
|
|
913
|
+
function applyCors(req, res, webOrigin) {
|
|
252
914
|
const origin = req.headers.origin;
|
|
253
|
-
if (origin
|
|
254
|
-
res.setHeader("Access-Control-Allow-Origin",
|
|
915
|
+
if (origin === webOrigin) {
|
|
916
|
+
res.setHeader("Access-Control-Allow-Origin", webOrigin);
|
|
255
917
|
res.setHeader("Vary", "Origin");
|
|
918
|
+
if (req.headers["access-control-request-private-network"] === "true") {
|
|
919
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
920
|
+
}
|
|
256
921
|
}
|
|
257
922
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
258
923
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
@@ -268,3 +933,26 @@ async function readJson(req) {
|
|
|
268
933
|
for await (const chunk of req) raw += chunk;
|
|
269
934
|
return raw ? JSON.parse(raw) : {};
|
|
270
935
|
}
|
|
936
|
+
|
|
937
|
+
async function readCodingLoopStart(req) {
|
|
938
|
+
const contentType = String(req.headers["content-type"] || "");
|
|
939
|
+
if (!contentType.toLowerCase().startsWith("multipart/form-data")) {
|
|
940
|
+
return { body: await readJson(req), attachments: [] };
|
|
941
|
+
}
|
|
942
|
+
const chunks = [];
|
|
943
|
+
let size = 0;
|
|
944
|
+
for await (const chunk of req) {
|
|
945
|
+
size += chunk.length;
|
|
946
|
+
if (size > 52 * 1024 * 1024) throw new Error("attachment request exceeds the 52 MB limit");
|
|
947
|
+
chunks.push(Buffer.from(chunk));
|
|
948
|
+
}
|
|
949
|
+
const form = await new Response(Buffer.concat(chunks), {
|
|
950
|
+
headers: { "Content-Type": contentType },
|
|
951
|
+
}).formData();
|
|
952
|
+
const payload = form.get("payload");
|
|
953
|
+
if (typeof payload !== "string") throw new Error("multipart payload field is required");
|
|
954
|
+
const attachments = form
|
|
955
|
+
.getAll("attachments")
|
|
956
|
+
.filter((value) => value && typeof value === "object" && typeof value.arrayBuffer === "function");
|
|
957
|
+
return { body: JSON.parse(payload), attachments };
|
|
958
|
+
}
|