stageflow 0.31.0 → 0.32.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/agent/piAdapter.js +21 -0
- package/dist/agent/port.d.ts +2 -0
- package/dist/config/loadStage.js +10 -0
- package/dist/config/pipelineStageKeys.js +1 -0
- package/dist/email/accounts.d.ts +109 -0
- package/dist/email/accounts.js +285 -0
- package/dist/email/adapter.d.ts +69 -0
- package/dist/email/adapter.js +720 -0
- package/dist/email/attachments.d.ts +21 -0
- package/dist/email/attachments.js +146 -0
- package/dist/email/events.d.ts +95 -0
- package/dist/email/events.js +292 -0
- package/dist/email/host.d.ts +15 -0
- package/dist/email/host.js +119 -0
- package/dist/email/messages.d.ts +44 -0
- package/dist/email/messages.js +182 -0
- package/dist/email/port.d.ts +161 -0
- package/dist/email/port.js +12 -0
- package/dist/email/replies.d.ts +5 -0
- package/dist/email/replies.js +56 -0
- package/dist/email/submissions.d.ts +31 -0
- package/dist/email/submissions.js +123 -0
- package/dist/email/triggers.d.ts +140 -0
- package/dist/email/triggers.js +563 -0
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/runstore/port.d.ts +4 -0
- package/dist/runstore/sqlite/SqliteRunStore.d.ts +1 -0
- package/dist/runstore/sqlite/SqliteRunStore.js +24 -5
- package/dist/runstore/sqlite/migrations/011-email-dispatch-key.d.ts +7 -0
- package/dist/runstore/sqlite/migrations/011-email-dispatch-key.js +12 -0
- package/dist/runstore/sqlite/migrations/index.js +2 -0
- package/dist/runtime/pipelineRunner.d.ts +3 -0
- package/dist/runtime/pipelineRunner.js +11 -0
- package/dist/runtime/pipelineScheduler.js +3 -0
- package/dist/runtime/runManager.d.ts +1 -0
- package/dist/runtime/runManager.js +24 -3
- package/dist/runtime/stageAttemptBootstrap.js +14 -0
- package/dist/runtime/stageProcessLauncher.d.ts +4 -0
- package/dist/runtime/stageProcessLauncher.js +45 -1
- package/dist/server/bootstrap.d.ts +7 -0
- package/dist/server/bootstrap.js +17 -0
- package/dist/server/emailRoutes.d.ts +5 -0
- package/dist/server/emailRoutes.js +136 -0
- package/dist/server/http.d.ts +2 -0
- package/dist/server/http.js +16 -0
- package/dist/tools/readEmail.d.ts +77 -0
- package/dist/tools/readEmail.js +31 -0
- package/dist/tools/replyEmail.d.ts +31 -0
- package/dist/tools/replyEmail.js +25 -0
- package/dist/tools/sendEmail.d.ts +43 -0
- package/dist/tools/sendEmail.js +26 -0
- package/dist/types/stage.d.ts +4 -0
- package/dist/ui/assets/index-59cjF0OA.js +133 -0
- package/dist/ui/index.html +1 -1
- package/examples/hello-email/README.md +23 -0
- package/examples/hello-email/hello-email-reply.pipeline.yaml +6 -0
- package/examples/hello-email/hello-email.pipeline.yaml +6 -0
- package/examples/hello-email/hello-email.task.yaml +6 -0
- package/examples/hello-email/reply-hello-email.yaml +63 -0
- package/examples/hello-email/send-hello-email.yaml +37 -0
- package/package.json +6 -2
- package/dist/ui/assets/index-p8Iou3zk.js +0 -133
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { PACKAGE_VERSION } from "../../../package-meta.js";
|
|
2
|
+
export const MIGRATION_011 = {
|
|
3
|
+
version: 11,
|
|
4
|
+
name: "011_email_dispatch_key",
|
|
5
|
+
minStageflowVersion: PACKAGE_VERSION,
|
|
6
|
+
up(db) {
|
|
7
|
+
const columns = db.prepare("PRAGMA table_info(runs)").all();
|
|
8
|
+
if (!columns.some(column => column.name === "dispatch_key"))
|
|
9
|
+
db.exec("ALTER TABLE runs ADD COLUMN dispatch_key TEXT");
|
|
10
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_runs_dispatch_key ON runs(dispatch_key)");
|
|
11
|
+
},
|
|
12
|
+
};
|
|
@@ -9,6 +9,7 @@ import { MIGRATION_007 } from "./007-projects-registry.js";
|
|
|
9
9
|
import { MIGRATION_008 } from "./008-triggers-table.js";
|
|
10
10
|
import { MIGRATION_009 } from "./009-trigger-next-run.js";
|
|
11
11
|
import { MIGRATION_010 } from "./010-trigger-adapter-state.js";
|
|
12
|
+
import { MIGRATION_011 } from "./011-email-dispatch-key.js";
|
|
12
13
|
const DEFAULT_MIGRATIONS = [
|
|
13
14
|
MIGRATION_001,
|
|
14
15
|
MIGRATION_002,
|
|
@@ -20,6 +21,7 @@ const DEFAULT_MIGRATIONS = [
|
|
|
20
21
|
MIGRATION_008,
|
|
21
22
|
MIGRATION_009,
|
|
22
23
|
MIGRATION_010,
|
|
24
|
+
MIGRATION_011,
|
|
23
25
|
];
|
|
24
26
|
export const CURRENT_SCHEMA_VERSION = DEFAULT_MIGRATIONS[DEFAULT_MIGRATIONS.length - 1].version;
|
|
25
27
|
function throwStoreSchemaTooNew(db, version, maxKnown) {
|
|
@@ -41,6 +41,7 @@ export type PipelineRunResult = {
|
|
|
41
41
|
findings?: ValidationFinding[];
|
|
42
42
|
};
|
|
43
43
|
export type StartedPipeline = {
|
|
44
|
+
created?: boolean;
|
|
44
45
|
runId: string;
|
|
45
46
|
runDir: string;
|
|
46
47
|
done: Promise<PipelineRunResult>;
|
|
@@ -51,6 +52,7 @@ export type PreparedPipeline = {
|
|
|
51
52
|
run: {
|
|
52
53
|
runId: string;
|
|
53
54
|
workspaceDir: string;
|
|
55
|
+
created?: boolean;
|
|
54
56
|
};
|
|
55
57
|
agent: AgentPort;
|
|
56
58
|
store: RunStore;
|
|
@@ -97,6 +99,7 @@ export declare function runPipeline(options: {
|
|
|
97
99
|
}): Promise<PipelineRunResult>;
|
|
98
100
|
/** Create the run immediately, then execute stages in the returned promise. */
|
|
99
101
|
export declare function startPipeline(options: {
|
|
102
|
+
dispatchKey?: string;
|
|
100
103
|
submission?: RunSubmission;
|
|
101
104
|
agent: AgentPort;
|
|
102
105
|
store: RunStore;
|
|
@@ -197,6 +197,7 @@ async function preparePipeline(options) {
|
|
|
197
197
|
namedSecrets,
|
|
198
198
|
});
|
|
199
199
|
run = await options.store.createRun({
|
|
200
|
+
dispatchKey: options.dispatchKey,
|
|
200
201
|
submission: options.submission,
|
|
201
202
|
runId,
|
|
202
203
|
pipelineId: loaded.pipeline.id,
|
|
@@ -299,6 +300,7 @@ export async function startPipeline(options) {
|
|
|
299
300
|
const cwd = options.cwd ?? process.cwd();
|
|
300
301
|
const projectRoot = options.projectRoot ?? cwd;
|
|
301
302
|
const prepared = await preparePipeline({
|
|
303
|
+
dispatchKey: options.dispatchKey,
|
|
302
304
|
submission: options.submission,
|
|
303
305
|
agent: options.agent,
|
|
304
306
|
store: options.store,
|
|
@@ -327,6 +329,15 @@ export async function startPipeline(options) {
|
|
|
327
329
|
callerId: options.callerId,
|
|
328
330
|
...(options.skills !== undefined ? { skills: options.skills } : {}),
|
|
329
331
|
});
|
|
332
|
+
if (prepared.run.created === false) {
|
|
333
|
+
const meta = await prepared.store.readRunMeta(prepared.run.runId);
|
|
334
|
+
return {
|
|
335
|
+
created: false,
|
|
336
|
+
runId: prepared.run.runId,
|
|
337
|
+
runDir: prepared.run.workspaceDir,
|
|
338
|
+
done: Promise.resolve({ ok: meta.status === "succeeded", outcome: meta.status === "succeeded" ? "succeeded" : "failed", runId: prepared.run.runId, runDir: prepared.run.workspaceDir, reason: "Recovered existing dispatch; execution was not restarted" }),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
330
341
|
const done = executeStages(prepared, {
|
|
331
342
|
maxActiveStagesPerRun: options.maxActiveStagesPerRun,
|
|
332
343
|
executionMode: prepared.executionMode,
|
|
@@ -1153,6 +1153,15 @@ export class RunManager {
|
|
|
1153
1153
|
? (findProjectRoot(path.dirname(path.resolve(hostCwd, input.pipeline))) ?? this.projectRoot)
|
|
1154
1154
|
: this.projectRoot);
|
|
1155
1155
|
const cwd = wireRoot ?? hostCwd;
|
|
1156
|
+
if (input.dispatchKey) {
|
|
1157
|
+
if (!this.options.store.findRunByDispatchKey)
|
|
1158
|
+
throw new Error("Run store does not support durable dispatch");
|
|
1159
|
+
const existing = await this.options.store.findRunByDispatchKey(input.dispatchKey);
|
|
1160
|
+
if (existing) {
|
|
1161
|
+
const meta = await this.options.store.readRunMeta(existing.runId);
|
|
1162
|
+
return { ok: true, runId: existing.runId, done: Promise.resolve({ ok: meta.status === "succeeded", outcome: meta.status === "succeeded" ? "succeeded" : "failed", runId: existing.runId, runDir: existing.workspaceDir, reason: "Recovered existing dispatch; execution was not restarted" }) };
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1156
1165
|
let resolved;
|
|
1157
1166
|
try {
|
|
1158
1167
|
resolved = resolveStartTaskInput(input, cwd);
|
|
@@ -1187,7 +1196,7 @@ export class RunManager {
|
|
|
1187
1196
|
gitSha: input.gitSha,
|
|
1188
1197
|
ciPrUrl: input.ciPrUrl,
|
|
1189
1198
|
ciJobUrl: input.ciJobUrl,
|
|
1190
|
-
}, derivedProjectRoot, resolved.kind === "path" ? resolved.taskPath : undefined, submission, undefined, input.callerId, input.skills);
|
|
1199
|
+
}, derivedProjectRoot, resolved.kind === "path" ? resolved.taskPath : undefined, submission, undefined, input.callerId, input.skills, input.dispatchKey);
|
|
1191
1200
|
}
|
|
1192
1201
|
async rerun(runId, options) {
|
|
1193
1202
|
if (!this.acceptingWork) {
|
|
@@ -1972,6 +1981,9 @@ export class RunManager {
|
|
|
1972
1981
|
runId,
|
|
1973
1982
|
stageId,
|
|
1974
1983
|
rootDir: runProjectRoot,
|
|
1984
|
+
factoryCwd: runProjectRoot,
|
|
1985
|
+
stage: resumedStage,
|
|
1986
|
+
workspaceDir,
|
|
1975
1987
|
...(browserEnv !== undefined ? { browserEnv } : {}),
|
|
1976
1988
|
mode: "resume",
|
|
1977
1989
|
resumeAnswer: opaqueAnswer,
|
|
@@ -2135,7 +2147,7 @@ export class RunManager {
|
|
|
2135
2147
|
this.attachedWaiting.delete(`${runId}\0${stageId}`);
|
|
2136
2148
|
}
|
|
2137
2149
|
}
|
|
2138
|
-
async reserveAndStartPipeline(taskYaml, pipeline, taskLabel, cwd, checkoutOverride, skipGates, ciIdentity, projectRoot, taskPath, submission, pinned, callerId, skills) {
|
|
2150
|
+
async reserveAndStartPipeline(taskYaml, pipeline, taskLabel, cwd, checkoutOverride, skipGates, ciIdentity, projectRoot, taskPath, submission, pinned, callerId, skills, dispatchKey) {
|
|
2139
2151
|
const persistence = pipelinePersistenceForStart(pipeline);
|
|
2140
2152
|
if (!persistence.ok) {
|
|
2141
2153
|
return {
|
|
@@ -2308,6 +2320,7 @@ export class RunManager {
|
|
|
2308
2320
|
namedSecrets,
|
|
2309
2321
|
});
|
|
2310
2322
|
const created = await this.options.store.createRun({
|
|
2323
|
+
dispatchKey,
|
|
2311
2324
|
submission,
|
|
2312
2325
|
runId,
|
|
2313
2326
|
pipelineId,
|
|
@@ -2335,6 +2348,10 @@ export class RunManager {
|
|
|
2335
2348
|
runManifest,
|
|
2336
2349
|
skipGates,
|
|
2337
2350
|
});
|
|
2351
|
+
if (created.created === false) {
|
|
2352
|
+
const existing = await this.options.store.readRunMeta(created.runId);
|
|
2353
|
+
return { ok: true, runId: created.runId, done: Promise.resolve({ ok: existing.status === "succeeded", outcome: existing.status === "succeeded" ? "succeeded" : "failed", runId: created.runId, runDir: created.workspaceDir, reason: "Recovered existing dispatch; execution was not restarted" }) };
|
|
2354
|
+
}
|
|
2338
2355
|
if (hasSkills) {
|
|
2339
2356
|
await materializeRunSkills(created.workspaceDir, validatedSkills);
|
|
2340
2357
|
}
|
|
@@ -2387,6 +2404,7 @@ export class RunManager {
|
|
|
2387
2404
|
rollback = linkRollback;
|
|
2388
2405
|
const schedulingHalt = this.ensureSchedulingHalt(materialized.runId);
|
|
2389
2406
|
const started = await startPipeline({
|
|
2407
|
+
dispatchKey,
|
|
2390
2408
|
submission,
|
|
2391
2409
|
...(this.options.browser !== undefined
|
|
2392
2410
|
? { browser: this.options.browser }
|
|
@@ -2418,7 +2436,10 @@ export class RunManager {
|
|
|
2418
2436
|
callerId,
|
|
2419
2437
|
...(hasSkills ? { skills: validatedSkills } : {}),
|
|
2420
2438
|
});
|
|
2421
|
-
|
|
2439
|
+
if (started.created === false)
|
|
2440
|
+
this.clearReservation(admitted.provisionalId);
|
|
2441
|
+
else
|
|
2442
|
+
this.track(admitted.provisionalId, started.runId, started.done);
|
|
2422
2443
|
return { ok: true, runId: started.runId, done: started.done };
|
|
2423
2444
|
}
|
|
2424
2445
|
catch (err) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { emailHostFor, stageEmail, workerStageEmail, validateStageEmailAccounts } from "../email/host.js";
|
|
2
|
+
import { EmailError } from "../email/port.js";
|
|
1
3
|
import { bindingKindForOrigin, catalogOrSeededOrigin, decideWorkspaceConfigTrust, } from "../config/configOrigin.js";
|
|
2
4
|
import { loginCheckPromptBlock, readStageLoginCheck, } from "../browser/loginCheck.js";
|
|
3
5
|
import { humanLoginPromptBlock, isHumanLoginStage } from "../browser/humanLogin.js";
|
|
@@ -429,6 +431,16 @@ export async function openStageAttempt(input) {
|
|
|
429
431
|
const meta = await input.store.readRunMeta(input.runId);
|
|
430
432
|
const bindingKind = input.bindingKind ?? bindingKindFromMeta(meta);
|
|
431
433
|
const trustWorkspaceConfig = resolveTrustWorkspaceConfig(input.trustWorkspaceConfig);
|
|
434
|
+
if (process.env.SF_STAGE_WORKER !== "1" && input.stage.email?.length) {
|
|
435
|
+
try {
|
|
436
|
+
validateStageEmailAccounts(emailHostFor(input.factoryCwd ?? process.cwd()).accounts, input.stage);
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
if (error instanceof EmailError)
|
|
440
|
+
return { ok: false, reason: error.code };
|
|
441
|
+
throw error;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
432
444
|
const opened = await openStageWithOperatorCatalog(input.agent, {
|
|
433
445
|
roots,
|
|
434
446
|
stage: input.stage,
|
|
@@ -442,6 +454,8 @@ export async function openStageAttempt(input) {
|
|
|
442
454
|
? { priorEnvelopesByStage: priorResult.priorEnvelopesByStage }
|
|
443
455
|
: {}),
|
|
444
456
|
resumeToken,
|
|
457
|
+
email: input.stage.email?.length ? (process.env.SF_STAGE_WORKER === "1" ? workerStageEmail()
|
|
458
|
+
: stageEmail(emailHostFor(input.factoryCwd ?? process.cwd()).mailbox, input.stage, input.runId, { workspaceDir: input.workspaceDir, attempt })) : undefined,
|
|
445
459
|
...(input.sessionMode !== undefined ? { sessionMode: input.sessionMode } : {}),
|
|
446
460
|
onActivity: (event) => {
|
|
447
461
|
input.onActivity?.(input.stage.browser !== undefined
|
|
@@ -2,16 +2,20 @@ import * as childProcess from "node:child_process";
|
|
|
2
2
|
import { type Logger } from "../logging/logger.js";
|
|
3
3
|
import { type ResolvedStageGrants } from "./stageEnvironment.js";
|
|
4
4
|
import type { OperatorCatalog } from "./stageAttemptBootstrap.js";
|
|
5
|
+
import type { StageConfig } from "../types/stage.js";
|
|
5
6
|
import { type DerivedBindingKind } from "./stageRoots.js";
|
|
6
7
|
export type StageLaunchInput = {
|
|
7
8
|
runId: string;
|
|
8
9
|
stageId: string;
|
|
9
10
|
rootDir: string;
|
|
11
|
+
workspaceDir?: string;
|
|
10
12
|
mode?: "run" | "resume" | "feedback_resume" | "new_session";
|
|
11
13
|
resumeAnswer?: unknown;
|
|
12
14
|
attempt?: number;
|
|
13
15
|
sessionFilePath?: string;
|
|
14
16
|
operatorCatalog?: OperatorCatalog;
|
|
17
|
+
factoryCwd?: string;
|
|
18
|
+
stage?: StageConfig;
|
|
15
19
|
skipGates?: boolean;
|
|
16
20
|
env?: Record<string, string>;
|
|
17
21
|
bindingKind?: DerivedBindingKind;
|
|
@@ -12,6 +12,8 @@ import { getContainerLimits } from "./containerLimits.js";
|
|
|
12
12
|
import { buildStageEnvironment, isAmbientBlockedEnv, isForeverDeniedSecret, } from "./stageEnvironment.js";
|
|
13
13
|
import { ensureStageCacheDirs } from "./stageCacheEnv.js";
|
|
14
14
|
import { SF_STAGE_WORKER, STAGE_WORKER_EXIT, } from "./stageWorkerProtocol.js";
|
|
15
|
+
import { emailHostFor, emailWorkerEnvironment, stageEmail, validateStageEmailAccounts } from "../email/host.js";
|
|
16
|
+
import { EmailError } from "../email/port.js";
|
|
15
17
|
import { overlayStageBindingEnv, } from "./stageRoots.js";
|
|
16
18
|
function defaultStageCliEntry() {
|
|
17
19
|
const source = import.meta.url.split("?")[0]?.endsWith(".ts") === true;
|
|
@@ -178,6 +180,16 @@ export class StageProcessLauncher {
|
|
|
178
180
|
}
|
|
179
181
|
}
|
|
180
182
|
async launch(input) {
|
|
183
|
+
try {
|
|
184
|
+
if (input.stage?.email?.length)
|
|
185
|
+
validateStageEmailAccounts(emailHostFor(input.factoryCwd ?? input.operatorCatalog?.cwd ?? input.rootDir).accounts, input.stage);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (error instanceof EmailError)
|
|
189
|
+
return { type: "failed", reason: error.code };
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
input = { ...input, stage: input.stage ? structuredClone(input.stage) : undefined };
|
|
181
193
|
const acquired = await this.waitForCapacity(input.runId);
|
|
182
194
|
if (!acquired) {
|
|
183
195
|
return { type: "failed", reason: "cancelled" };
|
|
@@ -279,6 +291,7 @@ export class StageProcessLauncher {
|
|
|
279
291
|
}
|
|
280
292
|
}
|
|
281
293
|
spawnAndWait(input) {
|
|
294
|
+
const factoryCwd = input.factoryCwd ?? input.operatorCatalog?.cwd ?? input.rootDir;
|
|
282
295
|
const mode = input.mode ?? (input.resumeAnswer !== undefined ? "resume" : "run");
|
|
283
296
|
const args = [
|
|
284
297
|
"internal",
|
|
@@ -350,7 +363,7 @@ export class StageProcessLauncher {
|
|
|
350
363
|
}
|
|
351
364
|
const child = this.forkFn(this.cliEntry, args, {
|
|
352
365
|
cwd: input.rootDir,
|
|
353
|
-
env: { ...childEnv, [SF_STAGE_WORKER]: "1" },
|
|
366
|
+
env: emailWorkerEnvironment({ ...childEnv, [SF_STAGE_WORKER]: "1" }, emailHostFor(factoryCwd).accounts),
|
|
354
367
|
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
|
355
368
|
detached: true,
|
|
356
369
|
// Explicit: do not inherit Host execArgv; set heap from cgroup budget.
|
|
@@ -388,6 +401,37 @@ export class StageProcessLauncher {
|
|
|
388
401
|
resolve(result);
|
|
389
402
|
};
|
|
390
403
|
child.on("message", (message) => {
|
|
404
|
+
const request = message;
|
|
405
|
+
if (!settled && ["email.send", "email.reply", "email.search", "email.getMessage", "email.downloadAttachment"].includes(request?.type ?? "")) {
|
|
406
|
+
void (async () => {
|
|
407
|
+
if (typeof request.requestId !== "string" || request.requestId.length > 100)
|
|
408
|
+
return;
|
|
409
|
+
let response;
|
|
410
|
+
try {
|
|
411
|
+
const stage = input.stage;
|
|
412
|
+
if (!stage || settled)
|
|
413
|
+
throw new EmailError("EMAIL_UNAUTHORIZED");
|
|
414
|
+
const email = stageEmail(emailHostFor(factoryCwd).mailbox, stage, input.runId, input.workspaceDir ? { workspaceDir: input.workspaceDir, attempt: input.attempt ?? 1 } : undefined);
|
|
415
|
+
if (request.type === "email.send")
|
|
416
|
+
response = { receipt: await email.send(request.input) };
|
|
417
|
+
else if (request.type === "email.reply")
|
|
418
|
+
response = { receipt: await email.reply(request.input) };
|
|
419
|
+
else if (request.type === "email.search")
|
|
420
|
+
response = { result: await email.search(request.input) };
|
|
421
|
+
else if (request.type === "email.getMessage")
|
|
422
|
+
response = { result: await email.getMessage(request.input) };
|
|
423
|
+
else
|
|
424
|
+
response = { result: await email.downloadAttachment(request.input) };
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
const fault = error instanceof EmailError ? error : new EmailError("EMAIL_UNAUTHORIZED");
|
|
428
|
+
response = { error: { code: fault.code, retryable: fault.retryable, ...(fault.unsupportedFields ? { unsupportedFields: fault.unsupportedFields } : {}) } };
|
|
429
|
+
}
|
|
430
|
+
if (child.connected && !settled)
|
|
431
|
+
child.send({ type: "email.response", requestId: request.requestId, ...response });
|
|
432
|
+
})();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
391
435
|
if (!isStageWorkerResult(message))
|
|
392
436
|
return;
|
|
393
437
|
finish(resultFromWorkerMessage(message));
|
|
@@ -5,6 +5,8 @@ import { type RunStoreKind } from "../runstore/createStore.js";
|
|
|
5
5
|
import type { RunStore } from "../runstore/port.js";
|
|
6
6
|
import type { StoreFilesystemClassification } from "../runstore/storeFilesystem.js";
|
|
7
7
|
import { RunManager } from "../runtime/runManager.js";
|
|
8
|
+
import { emailHostFor } from "../email/host.js";
|
|
9
|
+
import { EmailTriggers } from "../email/triggers.js";
|
|
8
10
|
import { type RunChangeBus } from "../runtime/runChangeBus.js";
|
|
9
11
|
import { type McpHttpHandler } from "../mcp/server.js";
|
|
10
12
|
import { type HostConfig } from "../config/hostConfig.js";
|
|
@@ -25,6 +27,7 @@ export type StageflowHostOptions = {
|
|
|
25
27
|
hostConfig?: HostConfig;
|
|
26
28
|
/** Skip HostConfig load (tests that inject store/manager pieces only). */
|
|
27
29
|
skipHostConfig?: boolean;
|
|
30
|
+
emailTriggerQueue?: ConstructorParameters<typeof EmailTriggers>[0]["queue"];
|
|
28
31
|
};
|
|
29
32
|
export type StageflowHostBootstrap = {
|
|
30
33
|
a2a?: A2aHost;
|
|
@@ -51,6 +54,10 @@ export type StageflowHostBootstrap = {
|
|
|
51
54
|
stopGithubPollSource: () => void;
|
|
52
55
|
/** Stops the email-trigger IMAP/IDLE listeners started at boot. */
|
|
53
56
|
stopEmailSource: () => void;
|
|
57
|
+
email?: ReturnType<typeof emailHostFor> & {
|
|
58
|
+
triggers: EmailTriggers;
|
|
59
|
+
stop: () => Promise<void>;
|
|
60
|
+
};
|
|
54
61
|
/** Filesystem classification for `$STAGEFLOW_HOME` (Slot 8). */
|
|
55
62
|
storeFilesystem?: StoreFilesystemClassification;
|
|
56
63
|
/**
|
package/dist/server/bootstrap.js
CHANGED
|
@@ -15,6 +15,8 @@ import { PI_CODING_AGENT_DIR_ENV } from "../runtime/stageRoots.js";
|
|
|
15
15
|
import { ScheduleSource, triggerTickIntervalMsFromEnv, } from "../runtime/scheduleSource.js";
|
|
16
16
|
import { GithubPollSource, githubPollIntervalMsFromEnv, } from "../runtime/githubPollSource.js";
|
|
17
17
|
import { EmailSource } from "../runtime/emailSource.js";
|
|
18
|
+
import { emailHostFor, releaseEmailHost } from "../email/host.js";
|
|
19
|
+
import { EmailTriggers } from "../email/triggers.js";
|
|
18
20
|
import { fireTrigger } from "../runtime/triggerRunner.js";
|
|
19
21
|
import { createRunChangeBus, getRunChangeBusFromWrappedStore, isRunStoreWrapped, wrapRunStoreWithChangeBus, } from "../runtime/runChangeBus.js";
|
|
20
22
|
import { createMcpHttpHandler, resolveMcpStateless, } from "../mcp/server.js";
|
|
@@ -111,6 +113,8 @@ export async function bootstrapStageflowHost(options) {
|
|
|
111
113
|
process.env[PI_CODING_AGENT_DIR_ENV] = path.join(ctx.globalHome, "agent");
|
|
112
114
|
const agentDir = options.agentDir ?? getAgentDir();
|
|
113
115
|
const rootDir = options.rootDir ?? ctx.projectRoot;
|
|
116
|
+
// Validate project account configuration before starting host resources.
|
|
117
|
+
const emailHost = emailHostFor(rootDir);
|
|
114
118
|
const isGitProject = options.rootDir !== undefined
|
|
115
119
|
? findProjectRoot(rootDir) !== null
|
|
116
120
|
: ctx.isGitProject;
|
|
@@ -303,8 +307,20 @@ export async function bootstrapStageflowHost(options) {
|
|
|
303
307
|
logError: (message) => bootLog.error("trigger.email_source_failed", message),
|
|
304
308
|
});
|
|
305
309
|
await emailSource.start(onTriggerFire);
|
|
310
|
+
const emailTriggers = new EmailTriggers({ cwd: rootDir, accounts: emailHost.accounts, mailbox: emailHost.mailbox, manager, store, queue: options.emailTriggerQueue });
|
|
311
|
+
let stoppingEmail;
|
|
312
|
+
const stopAccountEmail = () => stoppingEmail ??= Promise.all([emailTriggers.stop(), releaseEmailHost(rootDir)]).then(() => undefined);
|
|
313
|
+
void emailTriggers.recover().catch(() => undefined);
|
|
314
|
+
try {
|
|
315
|
+
await emailHost.mailbox.start(event => emailTriggers.accept(event));
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
await Promise.all([stopAccountEmail(), emailSource.stop(), scheduleSource.stop(), githubPollSource.stop()]);
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
306
321
|
const stopEmailSource = () => {
|
|
307
322
|
void emailSource.stop();
|
|
323
|
+
void stopAccountEmail();
|
|
308
324
|
};
|
|
309
325
|
const mcpStateless = resolveMcpStateless({
|
|
310
326
|
mcpStateless: options.mcpStateless,
|
|
@@ -344,6 +360,7 @@ export async function bootstrapStageflowHost(options) {
|
|
|
344
360
|
stopScheduleSource,
|
|
345
361
|
stopGithubPollSource,
|
|
346
362
|
stopEmailSource,
|
|
363
|
+
email: { ...emailHost, triggers: emailTriggers, stop: stopAccountEmail },
|
|
347
364
|
storeFilesystem,
|
|
348
365
|
};
|
|
349
366
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { EmailAccounts } from "../email/accounts.js";
|
|
3
|
+
import { type EmailMailbox } from "../email/port.js";
|
|
4
|
+
import type { EmailTriggers } from "../email/triggers.js";
|
|
5
|
+
export declare function handleEmailRoutes(req: IncomingMessage, res: ServerResponse, pathname: string, accounts: EmailAccounts, mailbox: EmailMailbox, triggers?: EmailTriggers): Promise<void>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { EmailError } from "../email/port.js";
|
|
2
|
+
function json(res, status, body) {
|
|
3
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
4
|
+
res.end(JSON.stringify(body));
|
|
5
|
+
}
|
|
6
|
+
async function body(req) {
|
|
7
|
+
const chunks = [];
|
|
8
|
+
let size = 0;
|
|
9
|
+
for await (const chunk of req) {
|
|
10
|
+
size += Buffer.byteLength(chunk);
|
|
11
|
+
if (size > 32768)
|
|
12
|
+
throw new EmailError("EMAIL_RESOURCE_LIMIT");
|
|
13
|
+
chunks.push(Buffer.from(chunk));
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function handleEmailRoutes(req, res, pathname, accounts, mailbox, triggers) {
|
|
23
|
+
try {
|
|
24
|
+
const method = req.method ?? "GET";
|
|
25
|
+
if (triggers) {
|
|
26
|
+
if (["/api/email/replay/preview", "/api/email/replay/execute"].includes(pathname) && method === "POST") {
|
|
27
|
+
json(res, 200, await triggers.replay(await body(req), pathname.endsWith("execute")));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const triggerMatch = pathname.match(/^\/api\/email\/triggers\/([^/]+)$/);
|
|
31
|
+
if (pathname === "/api/email/triggers" && method === "GET") {
|
|
32
|
+
json(res, 200, { triggers: triggers.list() });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (pathname === "/api/email/triggers" && method === "POST") {
|
|
36
|
+
json(res, 201, await triggers.create(await body(req)));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (pathname === "/api/email/dispatches" && method === "GET") {
|
|
40
|
+
json(res, 200, { dispatches: triggers.history() });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (pathname === "/api/email/dispatches/health" && method === "GET") {
|
|
44
|
+
json(res, 200, triggers.health());
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const dispatchMatch = pathname.match(/^\/api\/email\/dispatches\/([^/]+)\/(resume|cancel)$/);
|
|
48
|
+
if (dispatchMatch && method === "POST") {
|
|
49
|
+
const input = await body(req);
|
|
50
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).length)
|
|
51
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
52
|
+
const key = decodeURIComponent(dispatchMatch[1]);
|
|
53
|
+
if (dispatchMatch[2] === "resume")
|
|
54
|
+
json(res, 200, triggers.resume(key));
|
|
55
|
+
else {
|
|
56
|
+
triggers.cancel(key);
|
|
57
|
+
json(res, 200, { cancelled: true });
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (triggerMatch) {
|
|
62
|
+
const id = decodeURIComponent(triggerMatch[1]);
|
|
63
|
+
if (method === "GET") {
|
|
64
|
+
json(res, 200, triggers.get(id));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (method === "PATCH") {
|
|
68
|
+
json(res, 200, await triggers.update(id, await body(req)));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (method === "DELETE") {
|
|
72
|
+
triggers.remove(id);
|
|
73
|
+
json(res, 200, { removed: true });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (["/api/email/events", "/api/email/watchers"].includes(pathname) && method === "GET") {
|
|
79
|
+
const adapter = mailbox;
|
|
80
|
+
json(res, 200, pathname.endsWith("events") ? { events: adapter.events?.list() ?? [] } : { watchers: adapter.events?.health() ?? [] });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (pathname === "/api/email/submissions" && method === "GET") {
|
|
84
|
+
const adapter = mailbox;
|
|
85
|
+
json(res, 200, { submissions: adapter.submissions?.list() ?? [] });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const match = pathname.match(/^\/api\/email\/accounts\/([^/]+)(?:\/(test|health))?$/);
|
|
89
|
+
if (pathname === "/api/email/accounts" && method === "GET") {
|
|
90
|
+
json(res, 200, { accounts: accounts.list() });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (pathname === "/api/email/accounts" && method === "POST") {
|
|
94
|
+
json(res, 201, accounts.create(await body(req)));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (match) {
|
|
98
|
+
const accountId = decodeURIComponent(match[1]);
|
|
99
|
+
if (!match[2] && method === "GET") {
|
|
100
|
+
json(res, 200, accounts.get(accountId, false));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (!match[2] && method === "PATCH") {
|
|
104
|
+
json(res, 200, accounts.update(accountId, await body(req)));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!match[2] && method === "DELETE") {
|
|
108
|
+
accounts.remove(accountId);
|
|
109
|
+
json(res, 200, { removed: true });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (match[2] === "health" && method === "GET") {
|
|
113
|
+
json(res, 200, { health: accounts.health(accountId) ?? null });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (match[2] === "test" && method === "POST") {
|
|
117
|
+
const input = await body(req);
|
|
118
|
+
if (!input || typeof input !== "object" || Array.isArray(input) ||
|
|
119
|
+
Object.keys(input).some(key => key !== "protocol") ||
|
|
120
|
+
(input.protocol !== undefined && !["imap", "smtp", "both"].includes(String(input.protocol)))) {
|
|
121
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
122
|
+
}
|
|
123
|
+
json(res, 200, await mailbox.testAccount(accountId, input.protocol));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
json(res, 404, { error: "Not found" });
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
const fault = error instanceof EmailError ? error : new EmailError("EMAIL_CONNECTION_FAILED", true);
|
|
131
|
+
const status = fault.code === "EMAIL_ACCOUNT_NOT_FOUND" ? 404 :
|
|
132
|
+
fault.code === "EMAIL_ACCOUNT_DISABLED" ? 409 :
|
|
133
|
+
fault.code === "EMAIL_RESOURCE_LIMIT" ? 413 : 400;
|
|
134
|
+
json(res, status, { error: fault.code, code: fault.code, retryable: fault.retryable, ...(fault.unsupportedFields ? { unsupportedFields: fault.unsupportedFields } : {}) });
|
|
135
|
+
}
|
|
136
|
+
}
|
package/dist/server/http.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { RunStoreKind } from "../runstore/createStore.js";
|
|
|
6
6
|
import type { RunStore } from "../runstore/port.js";
|
|
7
7
|
import type { RunManager } from "../runtime/runManager.js";
|
|
8
8
|
import type { RunChangeBus } from "../runtime/runChangeBus.js";
|
|
9
|
+
import { EmailTriggers } from "../email/triggers.js";
|
|
9
10
|
import { type AllowedHosts } from "./allowedHosts.js";
|
|
10
11
|
import { type ControlTokens } from "./controlToken.js";
|
|
11
12
|
import { DEFAULT_PORT, type HttpHostEnvelope, type HttpHostRouteContext } from "./createHttpHost.js";
|
|
@@ -21,6 +22,7 @@ export type UiServerOptions = {
|
|
|
21
22
|
host?: string;
|
|
22
23
|
uiDistDir?: string;
|
|
23
24
|
maxConcurrent?: number;
|
|
25
|
+
emailTriggerQueue?: ConstructorParameters<typeof EmailTriggers>[0]["queue"];
|
|
24
26
|
providerAuthContext?: ProviderAuthContext;
|
|
25
27
|
mcpStateless?: boolean;
|
|
26
28
|
runChangeBus?: RunChangeBus;
|
package/dist/server/http.js
CHANGED
|
@@ -38,6 +38,7 @@ import { INVALID_SLOT_COUNT_MESSAGE, INVALID_WORKSHOP_MODEL_MESSAGE, parseSlotCo
|
|
|
38
38
|
import { isTaskFile } from "../runtime/taskInput.js";
|
|
39
39
|
import { findTokenShapedField, tokenRejectedPayload, } from "../runtime/startPayload.js";
|
|
40
40
|
import { parseAskOperatorAnswer } from "../tools/askOperator.js";
|
|
41
|
+
import { handleEmailRoutes } from "./emailRoutes.js";
|
|
41
42
|
import { bootstrapStageflowHost, } from "./bootstrap.js";
|
|
42
43
|
import { assertAllowedHttpAccess, isTrustedLocalHttpRequest, resolveAllowedHosts, } from "./allowedHosts.js";
|
|
43
44
|
import { enforceBearerAuth, loadControlTokens, requiredScopeFor, } from "./controlToken.js";
|
|
@@ -192,6 +193,17 @@ export function createOperatorRoutes(deps) {
|
|
|
192
193
|
return handleOperatorRequest();
|
|
193
194
|
async function handleOperatorRequest() {
|
|
194
195
|
try {
|
|
196
|
+
if (pathname.startsWith("/api/email/")) {
|
|
197
|
+
if (!assertAllowedHttpAccess({ entries: [] }, req, res, { requireOrigin: ["POST", "PATCH", "DELETE"].includes(method) }))
|
|
198
|
+
return true;
|
|
199
|
+
const email = boot.email;
|
|
200
|
+
if (!email) {
|
|
201
|
+
json(res, 503, { code: "EMAIL_CONNECTION_FAILED" });
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
await handleEmailRoutes(req, res, pathname, email.accounts, email.mailbox, email.triggers);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
195
207
|
if (method === "GET" && pathname === "/api/runs") {
|
|
196
208
|
const filter = {};
|
|
197
209
|
const status = url.searchParams.get("status");
|
|
@@ -2470,6 +2482,10 @@ export async function startUiServer(options) {
|
|
|
2470
2482
|
controlTokens,
|
|
2471
2483
|
routes,
|
|
2472
2484
|
});
|
|
2485
|
+
const closeServer = envelope.server.close.bind(envelope.server);
|
|
2486
|
+
envelope.server.close = ((callback) => closeServer(error => {
|
|
2487
|
+
void (boot.email?.stop() ?? Promise.resolve()).then(() => callback?.(error), () => callback?.(error ?? new Error("Email host shutdown failed")));
|
|
2488
|
+
}));
|
|
2473
2489
|
shutdown = installShutdownController({
|
|
2474
2490
|
server: envelope.server,
|
|
2475
2491
|
host: makeDrainableHostFromOptional(envelope.manager, envelope.store),
|