stageflow 0.30.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 (79) hide show
  1. package/dist/agent/cursorProvider.d.ts +8 -0
  2. package/dist/agent/cursorProvider.js +63 -13
  3. package/dist/agent/piAdapter.js +21 -0
  4. package/dist/agent/port.d.ts +2 -0
  5. package/dist/agent/providerAuth.d.ts +7 -3
  6. package/dist/agent/providerAuth.js +5 -3
  7. package/dist/cli/doctorCommand.js +7 -0
  8. package/dist/cli/providersCommand.d.ts +1 -1
  9. package/dist/cli/providersCommand.js +4 -4
  10. package/dist/cli.js +1 -1
  11. package/dist/config/loadStage.js +10 -0
  12. package/dist/config/pipelineStageKeys.js +1 -0
  13. package/dist/email/accounts.d.ts +109 -0
  14. package/dist/email/accounts.js +285 -0
  15. package/dist/email/adapter.d.ts +69 -0
  16. package/dist/email/adapter.js +720 -0
  17. package/dist/email/attachments.d.ts +21 -0
  18. package/dist/email/attachments.js +146 -0
  19. package/dist/email/events.d.ts +95 -0
  20. package/dist/email/events.js +292 -0
  21. package/dist/email/host.d.ts +15 -0
  22. package/dist/email/host.js +119 -0
  23. package/dist/email/messages.d.ts +44 -0
  24. package/dist/email/messages.js +182 -0
  25. package/dist/email/port.d.ts +161 -0
  26. package/dist/email/port.js +12 -0
  27. package/dist/email/replies.d.ts +5 -0
  28. package/dist/email/replies.js +56 -0
  29. package/dist/email/submissions.d.ts +31 -0
  30. package/dist/email/submissions.js +123 -0
  31. package/dist/email/triggers.d.ts +140 -0
  32. package/dist/email/triggers.js +563 -0
  33. package/dist/package-meta.d.ts +1 -1
  34. package/dist/package-meta.js +1 -1
  35. package/dist/runstore/diskUsage.d.ts +1 -1
  36. package/dist/runstore/diskUsage.js +2 -7
  37. package/dist/runstore/port.d.ts +4 -0
  38. package/dist/runstore/sqlite/SqliteRunStore.d.ts +1 -0
  39. package/dist/runstore/sqlite/SqliteRunStore.js +24 -5
  40. package/dist/runstore/sqlite/migrations/011-email-dispatch-key.d.ts +7 -0
  41. package/dist/runstore/sqlite/migrations/011-email-dispatch-key.js +12 -0
  42. package/dist/runstore/sqlite/migrations/index.js +2 -0
  43. package/dist/runtime/credentialBinding.d.ts +7 -4
  44. package/dist/runtime/credentialBinding.js +20 -31
  45. package/dist/runtime/pipelineRunner.d.ts +3 -0
  46. package/dist/runtime/pipelineRunner.js +11 -0
  47. package/dist/runtime/pipelineScheduler.js +4 -0
  48. package/dist/runtime/runManager.d.ts +1 -0
  49. package/dist/runtime/runManager.js +36 -5
  50. package/dist/runtime/settingsFile.d.ts +2 -2
  51. package/dist/runtime/settingsFile.js +5 -2
  52. package/dist/runtime/stageAttemptBootstrap.js +14 -0
  53. package/dist/runtime/stageProcessLauncher.d.ts +5 -0
  54. package/dist/runtime/stageProcessLauncher.js +55 -3
  55. package/dist/server/bootstrap.d.ts +7 -0
  56. package/dist/server/bootstrap.js +23 -0
  57. package/dist/server/emailRoutes.d.ts +5 -0
  58. package/dist/server/emailRoutes.js +136 -0
  59. package/dist/server/http.d.ts +2 -0
  60. package/dist/server/http.js +16 -0
  61. package/dist/tools/readEmail.d.ts +77 -0
  62. package/dist/tools/readEmail.js +31 -0
  63. package/dist/tools/replyEmail.d.ts +31 -0
  64. package/dist/tools/replyEmail.js +25 -0
  65. package/dist/tools/sendEmail.d.ts +43 -0
  66. package/dist/tools/sendEmail.js +26 -0
  67. package/dist/types/stage.d.ts +4 -0
  68. package/dist/ui/assets/index-59cjF0OA.js +133 -0
  69. package/dist/ui/index.html +1 -1
  70. package/examples/conditional-fork/decide.yaml +1 -1
  71. package/examples/github-release/README.md +17 -3
  72. package/examples/hello-email/README.md +23 -0
  73. package/examples/hello-email/hello-email-reply.pipeline.yaml +6 -0
  74. package/examples/hello-email/hello-email.pipeline.yaml +6 -0
  75. package/examples/hello-email/hello-email.task.yaml +6 -0
  76. package/examples/hello-email/reply-hello-email.yaml +63 -0
  77. package/examples/hello-email/send-hello-email.yaml +37 -0
  78. package/package.json +10 -5
  79. package/dist/ui/assets/index-CntB4bZR.js +0 -133
@@ -189,9 +189,13 @@ export class SqliteRunStore {
189
189
  }
190
190
  async createRun(input) {
191
191
  await this.ready();
192
+ const existing = input.dispatchKey ? await this.findRunByDispatchKey(input.dispatchKey) : undefined;
193
+ if (existing)
194
+ return existing;
192
195
  const runId = input.runId ?? newRunId();
193
196
  const workspaceDir = this.getWorkspaceDir(runId);
194
- await mkdir(path.join(workspaceDir, "stages"), { recursive: true });
197
+ if (!input.dispatchKey)
198
+ await mkdir(path.join(workspaceDir, "stages"), { recursive: true });
195
199
  const now = new Date().toISOString();
196
200
  const pipelinePath = input.pipelinePath
197
201
  ? normalizeCatalogPath(input.pipelinePath)
@@ -214,13 +218,15 @@ export class SqliteRunStore {
214
218
  if (existing)
215
219
  throw new RunSubmissionExistsError(existing);
216
220
  }
217
- this.db
221
+ const inserted = this.db
218
222
  .prepare(`INSERT INTO runs
219
- (run_id, pipeline_id, task_id, task_yaml, status, created_at, updated_at, checkout_root, pipeline_dag_json, git_sha, ci_pr_url, ci_job_url, pipeline_path, task_path, project_root, repository, ref, resolved_sha, run_branch, git_author_name, git_author_email, pipeline_source, pipeline_body, caller_id, run_manifest, skip_gates)
223
+ (run_id, pipeline_id, task_id, task_yaml, status, created_at, updated_at, checkout_root, pipeline_dag_json, git_sha, ci_pr_url, ci_job_url, pipeline_path, task_path, project_root, repository, ref, resolved_sha, run_branch, git_author_name, git_author_email, pipeline_source, pipeline_body, caller_id, run_manifest, skip_gates, dispatch_key)
220
224
  VALUES
221
- (@run_id, @pipeline_id, @task_id, @task_yaml, @status, @created_at, @updated_at, @checkout_root, @pipeline_dag_json, @git_sha, @ci_pr_url, @ci_job_url, @pipeline_path, @task_path, @project_root, @repository, @ref, @resolved_sha, @run_branch, @git_author_name, @git_author_email, @pipeline_source, @pipeline_body, @caller_id, @run_manifest, @skip_gates)`)
225
+ (@run_id, @pipeline_id, @task_id, @task_yaml, @status, @created_at, @updated_at, @checkout_root, @pipeline_dag_json, @git_sha, @ci_pr_url, @ci_job_url, @pipeline_path, @task_path, @project_root, @repository, @ref, @resolved_sha, @run_branch, @git_author_name, @git_author_email, @pipeline_source, @pipeline_body, @caller_id, @run_manifest, @skip_gates, @dispatch_key)
226
+ ON CONFLICT(dispatch_key) DO NOTHING`)
222
227
  .run({
223
228
  run_id: runId,
229
+ dispatch_key: input.dispatchKey ?? null,
224
230
  pipeline_id: input.pipelineId,
225
231
  task_id: input.taskId ?? null,
226
232
  task_yaml: input.taskYaml,
@@ -251,12 +257,25 @@ export class SqliteRunStore {
251
257
  : null,
252
258
  skip_gates: input.skipGates === undefined ? null : input.skipGates ? 1 : 0,
253
259
  });
260
+ if (!inserted.changes)
261
+ return;
254
262
  if (input.submission) {
255
263
  this.db.prepare("INSERT INTO run_submissions (submission_key, request_hash, run_id) VALUES (?, ?, ?)")
256
264
  .run(input.submission.key, input.submission.requestHash, runId);
257
265
  }
258
266
  })();
259
- return { runId, workspaceDir };
267
+ if (input.dispatchKey) {
268
+ const selected = await this.findRunByDispatchKey(input.dispatchKey);
269
+ if (selected && selected.runId !== runId)
270
+ return selected;
271
+ await mkdir(path.join(workspaceDir, "stages"), { recursive: true });
272
+ }
273
+ return { runId, workspaceDir, ...(input.dispatchKey ? { created: true } : {}) };
274
+ }
275
+ async findRunByDispatchKey(dispatchKey) {
276
+ await this.ready();
277
+ const row = this.db.prepare("SELECT run_id FROM runs WHERE dispatch_key = ?").get(dispatchKey);
278
+ return row ? { runId: row.run_id, workspaceDir: this.getWorkspaceDir(row.run_id), created: false } : undefined;
260
279
  }
261
280
  readSubmission(key) {
262
281
  const row = this.db.prepare("SELECT submission_key AS key, request_hash AS requestHash, run_id AS runId FROM run_submissions WHERE submission_key = ?")
@@ -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) {
@@ -7,14 +7,17 @@ export type CredentialBinding = {
7
7
  provisional: boolean;
8
8
  };
9
9
  export type ResolveCredentialBindingOptions = {
10
- piHomeAuthPath?: string;
10
+ agentAuthPath?: string;
11
11
  };
12
12
  export declare function sfOwnedAgentDir(): string;
13
13
  export declare function sfOwnedAuthPath(): string;
14
- export declare const PI_HOME_AUTH_PATH_ENV = "STAGEFLOW_PI_HOME_AUTH_PATH";
15
- /** A stage worker's HOME is an empty attempt dir, so the Host passes its own Pi auth path through the environment. */
14
+ export declare const STAGEFLOW_AGENT_AUTH_PATH_ENV = "STAGEFLOW_AGENT_AUTH_PATH";
15
+ /** @deprecated Use STAGEFLOW_AGENT_AUTH_PATH_ENV. */
16
+ export declare const PI_HOME_AUTH_PATH_ENV = "STAGEFLOW_AGENT_AUTH_PATH";
17
+ export declare function stageflowAgentAuthPath(override?: string): string;
18
+ /** @deprecated Use stageflowAgentAuthPath */
16
19
  export declare function piHomeAuthPath(override?: string): string;
17
20
  export declare function isUsableAuthFile(authPath: string): boolean;
18
21
  export declare function ensureSfOwnedAuthStore(): string;
19
- export declare function resolveCredentialBinding(ctx: ProjectContext | string, options?: ResolveCredentialBindingOptions): CredentialBinding;
22
+ export declare function resolveCredentialBinding(ctx: ProjectContext | string, _options?: ResolveCredentialBindingOptions): CredentialBinding;
20
23
  export { readCredentialSourceFromFile, writeCredentialSourceToFile, } from "./settingsFile.js";
@@ -1,5 +1,4 @@
1
1
  import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
2
- import os from "node:os";
3
2
  import path from "node:path";
4
3
  import { ensureGlobalHome } from "../project/globalHome.js";
5
4
  import { resolveProjectContext, } from "../project/resolveProjectContext.js";
@@ -11,12 +10,23 @@ export function sfOwnedAgentDir() {
11
10
  export function sfOwnedAuthPath() {
12
11
  return path.join(sfOwnedAgentDir(), "auth.json");
13
12
  }
14
- export const PI_HOME_AUTH_PATH_ENV = "STAGEFLOW_PI_HOME_AUTH_PATH";
15
- /** A stage worker's HOME is an empty attempt dir, so the Host passes its own Pi auth path through the environment. */
13
+ export const STAGEFLOW_AGENT_AUTH_PATH_ENV = "STAGEFLOW_AGENT_AUTH_PATH";
14
+ /** @deprecated Use STAGEFLOW_AGENT_AUTH_PATH_ENV. */
15
+ export const PI_HOME_AUTH_PATH_ENV = STAGEFLOW_AGENT_AUTH_PATH_ENV;
16
+ export function stageflowAgentAuthPath(override) {
17
+ if (override !== undefined) {
18
+ return path.resolve(override);
19
+ }
20
+ const fromEnv = process.env[STAGEFLOW_AGENT_AUTH_PATH_ENV]?.trim() ||
21
+ process.env.STAGEFLOW_PI_HOME_AUTH_PATH?.trim();
22
+ if (fromEnv) {
23
+ return path.resolve(fromEnv);
24
+ }
25
+ return sfOwnedAuthPath();
26
+ }
27
+ /** @deprecated Use stageflowAgentAuthPath */
16
28
  export function piHomeAuthPath(override) {
17
- return (override ??
18
- (process.env[PI_HOME_AUTH_PATH_ENV] || undefined) ??
19
- path.join(os.homedir(), ".pi", "agent", "auth.json"));
29
+ return stageflowAgentAuthPath(override);
20
30
  }
21
31
  export function isUsableAuthFile(authPath) {
22
32
  if (!existsSync(authPath))
@@ -43,35 +53,14 @@ export function ensureSfOwnedAuthStore() {
43
53
  }
44
54
  return authPath;
45
55
  }
46
- export function resolveCredentialBinding(ctx, options = {}) {
56
+ export function resolveCredentialBinding(ctx, _options = {}) {
47
57
  const projectCtx = typeof ctx === "string" ? resolveProjectContext(ctx) : ctx;
48
- const piHome = piHomeAuthPath(options.piHomeAuthPath);
49
58
  const persisted = readCredentialSourceFromContext(projectCtx);
50
- if (persisted !== undefined) {
51
- if (persisted === "sf_owned") {
52
- return {
53
- source: "sf_owned",
54
- authPath: ensureSfOwnedAuthStore(),
55
- provisional: false,
56
- };
57
- }
58
- return {
59
- source: "pi_home",
60
- authPath: piHome,
61
- provisional: false,
62
- };
63
- }
64
- if (isUsableAuthFile(piHome)) {
65
- return {
66
- source: "pi_home",
67
- authPath: piHome,
68
- provisional: true,
69
- };
70
- }
59
+ const authPath = ensureSfOwnedAuthStore();
71
60
  return {
72
61
  source: "sf_owned",
73
- authPath: ensureSfOwnedAuthStore(),
74
- provisional: true,
62
+ authPath,
63
+ provisional: persisted === undefined,
75
64
  };
76
65
  }
77
66
  export { readCredentialSourceFromFile, writeCredentialSourceToFile, } from "./settingsFile.js";
@@ -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,9 +968,13 @@ 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,
977
+ model: stage.model,
974
978
  grants,
975
979
  attemptHome,
976
980
  ...(browserEnv !== undefined ? { browserEnv } : {}),
@@ -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,
@@ -1979,6 +1991,7 @@ export class RunManager {
1979
1991
  sessionFilePath,
1980
1992
  env: stageBinding.env,
1981
1993
  bindingKind: stageBinding.kind,
1994
+ ...(resumedStage !== undefined ? { model: resumedStage.model } : {}),
1982
1995
  ...(this.options.operatorCatalog !== undefined
1983
1996
  ? { operatorCatalog: this.options.operatorCatalog }
1984
1997
  : {}),
@@ -2134,7 +2147,7 @@ export class RunManager {
2134
2147
  this.attachedWaiting.delete(`${runId}\0${stageId}`);
2135
2148
  }
2136
2149
  }
2137
- 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) {
2138
2151
  const persistence = pipelinePersistenceForStart(pipeline);
2139
2152
  if (!persistence.ok) {
2140
2153
  return {
@@ -2307,6 +2320,7 @@ export class RunManager {
2307
2320
  namedSecrets,
2308
2321
  });
2309
2322
  const created = await this.options.store.createRun({
2323
+ dispatchKey,
2310
2324
  submission,
2311
2325
  runId,
2312
2326
  pipelineId,
@@ -2334,6 +2348,10 @@ export class RunManager {
2334
2348
  runManifest,
2335
2349
  skipGates,
2336
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
+ }
2337
2355
  if (hasSkills) {
2338
2356
  await materializeRunSkills(created.workspaceDir, validatedSkills);
2339
2357
  }
@@ -2386,6 +2404,7 @@ export class RunManager {
2386
2404
  rollback = linkRollback;
2387
2405
  const schedulingHalt = this.ensureSchedulingHalt(materialized.runId);
2388
2406
  const started = await startPipeline({
2407
+ dispatchKey,
2389
2408
  submission,
2390
2409
  ...(this.options.browser !== undefined
2391
2410
  ? { browser: this.options.browser }
@@ -2417,7 +2436,10 @@ export class RunManager {
2417
2436
  callerId,
2418
2437
  ...(hasSkills ? { skills: validatedSkills } : {}),
2419
2438
  });
2420
- 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);
2421
2443
  return { ok: true, runId: started.runId, done: started.done };
2422
2444
  }
2423
2445
  catch (err) {
@@ -2506,17 +2528,26 @@ export class RunManager {
2506
2528
  };
2507
2529
  }
2508
2530
  async checkDiskFloorAdmission() {
2531
+ const raw = process.env.STAGEFLOW_MIN_FREE_DISK_BYTES;
2532
+ if (raw === undefined || raw.trim() === "") {
2533
+ return undefined;
2534
+ }
2509
2535
  try {
2510
2536
  const readFree = this.options.freeSpaceReader ?? readFilesystemSize;
2511
2537
  const size = await readFree(globalStageflowHome());
2512
- const minFreeBytes = resolveMinFreeDiskFloor(process.env.STAGEFLOW_MIN_FREE_DISK_BYTES, size.totalBytes);
2538
+ const minFreeBytes = resolveMinFreeDiskFloor(raw, size.totalBytes);
2539
+ if (minFreeBytes === 0) {
2540
+ return undefined;
2541
+ }
2513
2542
  if (size.freeBytes < minFreeBytes) {
2514
2543
  return this.insufficientDiskFailure(size.freeBytes, minFreeBytes);
2515
2544
  }
2516
2545
  return undefined;
2517
2546
  }
2518
2547
  catch (err) {
2519
- // Default floor is always active via resolveMinFreeDiskFloor — fail closed.
2548
+ if (resolveMinFreeDiskFloor(raw, 0) === 0 && !raw.trim().endsWith("%")) {
2549
+ return undefined;
2550
+ }
2520
2551
  return {
2521
2552
  ok: false,
2522
2553
  reason: `Disk free-space check failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -2,9 +2,9 @@ import type { ProjectContext } from "../project/resolveProjectContext.js";
2
2
  import type { StageflowContext } from "../project/resolveStageflowContext.js";
3
3
  export declare const SETTINGS_FILE_NAME = "settings.json";
4
4
  export declare const INVALID_SLOT_COUNT_MESSAGE = "maxConcurrent must be an integer >= 1";
5
- export declare const INVALID_CREDENTIAL_SOURCE_MESSAGE = "credentialSource must be \"pi_home\" or \"sf_owned\"";
5
+ export declare const INVALID_CREDENTIAL_SOURCE_MESSAGE = "credentialSource must be \"sf_owned\"";
6
6
  export declare const INVALID_WORKSHOP_MODEL_MESSAGE = "workshopModel must be a non-empty string";
7
- export type CredentialSource = "pi_home" | "sf_owned";
7
+ export type CredentialSource = "sf_owned";
8
8
  export type FactorySettings = {
9
9
  maxConcurrent?: number;
10
10
  credentialSource?: CredentialSource;
@@ -5,7 +5,7 @@ import { globalStageflowHome } from "../project/globalHome.js";
5
5
  import { storeRootFor } from "../runstore/paths.js";
6
6
  export const SETTINGS_FILE_NAME = "settings.json";
7
7
  export const INVALID_SLOT_COUNT_MESSAGE = "maxConcurrent must be an integer >= 1";
8
- export const INVALID_CREDENTIAL_SOURCE_MESSAGE = 'credentialSource must be "pi_home" or "sf_owned"';
8
+ export const INVALID_CREDENTIAL_SOURCE_MESSAGE = 'credentialSource must be "sf_owned"';
9
9
  export const INVALID_WORKSHOP_MODEL_MESSAGE = "workshopModel must be a non-empty string";
10
10
  export function globalSettingsFilePath() {
11
11
  return path.join(globalStageflowHome(), SETTINGS_FILE_NAME);
@@ -29,9 +29,12 @@ export function parseSlotCount(value) {
29
29
  return value;
30
30
  }
31
31
  export function parseCredentialSource(value) {
32
- if (value === "pi_home" || value === "sf_owned") {
32
+ if (value === "sf_owned") {
33
33
  return value;
34
34
  }
35
+ if (value === "pi_home") {
36
+ return "sf_owned";
37
+ }
35
38
  return undefined;
36
39
  }
37
40
  export function parseWorkshopModelSetting(value) {
@@ -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,22 +2,27 @@ 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;
18
22
  grants?: ResolvedStageGrants;
19
23
  attemptHome?: string;
20
24
  browserEnv?: Record<string, string>;
25
+ model?: string;
21
26
  };
22
27
  export type StageLaunchResult = {
23
28
  type: "succeeded";
@@ -1,9 +1,10 @@
1
1
  import * as childProcess from "node:child_process";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { logger as rootLogger, resolveLogMaxLineBytes, } from "../logging/logger.js";
4
+ import { isCursorModelRef, readCursorApiKey } from "../agent/cursorProvider.js";
4
5
  import { BROWSER_ENV_PREFIX } from "../browser/browserHost.js";
5
6
  import { PACKAGE_VERSION } from "../package-meta.js";
6
- import { PI_HOME_AUTH_PATH_ENV, piHomeAuthPath } from "./credentialBinding.js";
7
+ import { PI_HOME_AUTH_PATH_ENV, stageflowAgentAuthPath } from "./credentialBinding.js";
7
8
  import { redactString } from "../logging/redact.js";
8
9
  import { getNamedSecrets } from "../logging/namedSecrets.js";
9
10
  import { readMaxActiveStageProcesses, } from "./stageConcurrency.js";
@@ -11,6 +12,8 @@ import { getContainerLimits } from "./containerLimits.js";
11
12
  import { buildStageEnvironment, isAmbientBlockedEnv, isForeverDeniedSecret, } from "./stageEnvironment.js";
12
13
  import { ensureStageCacheDirs } from "./stageCacheEnv.js";
13
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";
14
17
  import { overlayStageBindingEnv, } from "./stageRoots.js";
15
18
  function defaultStageCliEntry() {
16
19
  const source = import.meta.url.split("?")[0]?.endsWith(".ts") === true;
@@ -177,6 +180,16 @@ export class StageProcessLauncher {
177
180
  }
178
181
  }
179
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 };
180
193
  const acquired = await this.waitForCapacity(input.runId);
181
194
  if (!acquired) {
182
195
  return { type: "failed", reason: "cancelled" };
@@ -278,6 +291,7 @@ export class StageProcessLauncher {
278
291
  }
279
292
  }
280
293
  spawnAndWait(input) {
294
+ const factoryCwd = input.factoryCwd ?? input.operatorCatalog?.cwd ?? input.rootDir;
281
295
  const mode = input.mode ?? (input.resumeAnswer !== undefined ? "resume" : "run");
282
296
  const args = [
283
297
  "internal",
@@ -338,11 +352,18 @@ export class StageProcessLauncher {
338
352
  : overlaid;
339
353
  const childEnv = {
340
354
  ...withBrowser,
341
- [PI_HOME_AUTH_PATH_ENV]: piHomeAuthPath(),
355
+ [PI_HOME_AUTH_PATH_ENV]: stageflowAgentAuthPath(),
342
356
  };
357
+ const cursorApiKey = readCursorApiKey(hostEnv);
358
+ if (isCursorModelRef(input.model ?? "") &&
359
+ cursorApiKey !== undefined &&
360
+ cursorApiKey !== "" &&
361
+ childEnv.CURSOR_API_KEY === undefined) {
362
+ childEnv.CURSOR_API_KEY = cursorApiKey;
363
+ }
343
364
  const child = this.forkFn(this.cliEntry, args, {
344
365
  cwd: input.rootDir,
345
- env: { ...childEnv, [SF_STAGE_WORKER]: "1" },
366
+ env: emailWorkerEnvironment({ ...childEnv, [SF_STAGE_WORKER]: "1" }, emailHostFor(factoryCwd).accounts),
346
367
  stdio: ["pipe", "pipe", "pipe", "ipc"],
347
368
  detached: true,
348
369
  // Explicit: do not inherit Host execArgv; set heap from cgroup budget.
@@ -380,6 +401,37 @@ export class StageProcessLauncher {
380
401
  resolve(result);
381
402
  };
382
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
+ }
383
435
  if (!isStageWorkerResult(message))
384
436
  return;
385
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
  /**