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.
Files changed (63) hide show
  1. package/dist/agent/piAdapter.js +21 -0
  2. package/dist/agent/port.d.ts +2 -0
  3. package/dist/config/loadStage.js +10 -0
  4. package/dist/config/pipelineStageKeys.js +1 -0
  5. package/dist/email/accounts.d.ts +109 -0
  6. package/dist/email/accounts.js +285 -0
  7. package/dist/email/adapter.d.ts +69 -0
  8. package/dist/email/adapter.js +720 -0
  9. package/dist/email/attachments.d.ts +21 -0
  10. package/dist/email/attachments.js +146 -0
  11. package/dist/email/events.d.ts +95 -0
  12. package/dist/email/events.js +292 -0
  13. package/dist/email/host.d.ts +15 -0
  14. package/dist/email/host.js +119 -0
  15. package/dist/email/messages.d.ts +44 -0
  16. package/dist/email/messages.js +182 -0
  17. package/dist/email/port.d.ts +161 -0
  18. package/dist/email/port.js +12 -0
  19. package/dist/email/replies.d.ts +5 -0
  20. package/dist/email/replies.js +56 -0
  21. package/dist/email/submissions.d.ts +31 -0
  22. package/dist/email/submissions.js +123 -0
  23. package/dist/email/triggers.d.ts +140 -0
  24. package/dist/email/triggers.js +563 -0
  25. package/dist/package-meta.d.ts +1 -1
  26. package/dist/package-meta.js +1 -1
  27. package/dist/runstore/port.d.ts +4 -0
  28. package/dist/runstore/sqlite/SqliteRunStore.d.ts +1 -0
  29. package/dist/runstore/sqlite/SqliteRunStore.js +24 -5
  30. package/dist/runstore/sqlite/migrations/011-email-dispatch-key.d.ts +7 -0
  31. package/dist/runstore/sqlite/migrations/011-email-dispatch-key.js +12 -0
  32. package/dist/runstore/sqlite/migrations/index.js +2 -0
  33. package/dist/runtime/pipelineRunner.d.ts +3 -0
  34. package/dist/runtime/pipelineRunner.js +11 -0
  35. package/dist/runtime/pipelineScheduler.js +3 -0
  36. package/dist/runtime/runManager.d.ts +1 -0
  37. package/dist/runtime/runManager.js +24 -3
  38. package/dist/runtime/stageAttemptBootstrap.js +14 -0
  39. package/dist/runtime/stageProcessLauncher.d.ts +4 -0
  40. package/dist/runtime/stageProcessLauncher.js +45 -1
  41. package/dist/server/bootstrap.d.ts +7 -0
  42. package/dist/server/bootstrap.js +17 -0
  43. package/dist/server/emailRoutes.d.ts +5 -0
  44. package/dist/server/emailRoutes.js +136 -0
  45. package/dist/server/http.d.ts +2 -0
  46. package/dist/server/http.js +16 -0
  47. package/dist/tools/readEmail.d.ts +77 -0
  48. package/dist/tools/readEmail.js +31 -0
  49. package/dist/tools/replyEmail.d.ts +31 -0
  50. package/dist/tools/replyEmail.js +25 -0
  51. package/dist/tools/sendEmail.d.ts +43 -0
  52. package/dist/tools/sendEmail.js +26 -0
  53. package/dist/types/stage.d.ts +4 -0
  54. package/dist/ui/assets/index-59cjF0OA.js +133 -0
  55. package/dist/ui/index.html +1 -1
  56. package/examples/hello-email/README.md +23 -0
  57. package/examples/hello-email/hello-email-reply.pipeline.yaml +6 -0
  58. package/examples/hello-email/hello-email.pipeline.yaml +6 -0
  59. package/examples/hello-email/hello-email.task.yaml +6 -0
  60. package/examples/hello-email/reply-hello-email.yaml +63 -0
  61. package/examples/hello-email/send-hello-email.yaml +37 -0
  62. package/package.json +6 -2
  63. package/dist/ui/assets/index-p8Iou3zk.js +0 -133
@@ -0,0 +1,7 @@
1
+ import type Database from "better-sqlite3";
2
+ export declare const MIGRATION_011: {
3
+ version: number;
4
+ name: string;
5
+ minStageflowVersion: string;
6
+ up(db: Database.Database): void;
7
+ };
@@ -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,
@@ -968,6 +968,9 @@ export async function runPipelineDag(options) {
968
968
  runId: run.runId,
969
969
  stageId,
970
970
  rootDir: factoryCwd,
971
+ stage,
972
+ factoryCwd,
973
+ workspaceDir: run.workspaceDir,
971
974
  attempt,
972
975
  env: stageBinding.env,
973
976
  bindingKind: stageBinding.kind,
@@ -285,6 +285,7 @@ export declare class RunManager {
285
285
  pipeline: string | InlinePipelineDefinition;
286
286
  task?: string | TaskFile;
287
287
  checkoutOverride?: string;
288
+ dispatchKey?: string;
288
289
  skipGates?: boolean;
289
290
  gitSha?: string;
290
291
  ciPrUrl?: string;
@@ -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
- this.track(admitted.provisionalId, started.runId, started.done);
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
  /**
@@ -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
+ }
@@ -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;
@@ -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),