@bugmole/cli 0.5.0 → 0.6.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 (153) hide show
  1. package/package.json +5 -2
  2. package/scripts/bugmole-admin.mjs +194 -0
  3. package/scripts/bugmole-admin.test.mjs +62 -0
  4. package/scripts/bugmole.test.ts +94 -2
  5. package/scripts/bugmole.ts +233 -46
  6. package/scripts/sync-byok.d.mts +2 -0
  7. package/scripts/sync-byok.mjs +18 -0
  8. package/scripts/sync-plan-catalog.d.mts +3 -0
  9. package/scripts/sync-plan-catalog.mjs +16 -6
  10. package/spec/domain_rules.yaml +139 -0
  11. package/spec/roles.yaml +20 -0
  12. package/spec/test-case-results.schema.json +20 -4
  13. package/spec/test-cases.schema.json +131 -16
  14. package/src/billing/plan-catalog.test.ts +82 -2
  15. package/src/billing/plan-catalog.ts +212 -9
  16. package/src/integrations/jira.ts +264 -0
  17. package/src/mcp/roles-and-review.test.ts +111 -0
  18. package/src/mcp/server.ts +358 -22
  19. package/src/mcp/write-test-cases.test.ts +68 -0
  20. package/src/registry/control-plane-client.ts +121 -6
  21. package/src/registry/migrations/0036_task_approval.sql +11 -0
  22. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  23. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  24. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  25. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  26. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  27. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  28. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  29. package/src/registry/migrations/0044_project_identity.sql +31 -0
  30. package/src/registry/project-identity.test.ts +99 -0
  31. package/src/registry/project-identity.ts +206 -0
  32. package/src/registry/roles.test.ts +66 -0
  33. package/src/registry/roles.ts +201 -0
  34. package/src/registry/task-scheduling.test.ts +94 -0
  35. package/src/registry/task-scheduling.ts +199 -2
  36. package/src/registry/test-case-revisions.test.ts +57 -0
  37. package/src/registry/test-case-revisions.ts +132 -0
  38. package/src/registry-worker/ai/routes.ts +3 -3
  39. package/src/registry-worker/artifacts.ts +23 -4
  40. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  41. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  42. package/src/registry-worker/billing/enforcement.ts +67 -12
  43. package/src/registry-worker/billing/paypal/api.ts +14 -0
  44. package/src/registry-worker/billing/paypal/client.ts +9 -0
  45. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  46. package/src/registry-worker/billing/paypal.test.ts +108 -1
  47. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  48. package/src/registry-worker/billing/provider.ts +8 -0
  49. package/src/registry-worker/billing/routes.ts +2 -1
  50. package/src/registry-worker/billing/subscriptions.ts +114 -4
  51. package/src/registry-worker/core.ts +22 -0
  52. package/src/registry-worker/devices/policy.ts +4 -5
  53. package/src/registry-worker/devices/routes.ts +8 -8
  54. package/src/registry-worker/feature-access.ts +165 -0
  55. package/src/registry-worker/feature-flags/admin.ts +300 -0
  56. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  57. package/src/registry-worker/feature-flags/routes.ts +102 -0
  58. package/src/registry-worker/features.ts +9 -0
  59. package/src/registry-worker/feedback/routes.ts +2 -2
  60. package/src/registry-worker/flags.ts +80 -18
  61. package/src/registry-worker/github/checks.ts +4 -4
  62. package/src/registry-worker/hooks.ts +9 -0
  63. package/src/registry-worker/identity/oidc.test.ts +103 -0
  64. package/src/registry-worker/identity/oidc.ts +176 -0
  65. package/src/registry-worker/index.ts +477 -174
  66. package/src/registry-worker/jira/connection.ts +111 -0
  67. package/src/registry-worker/jira/jira.test.ts +404 -0
  68. package/src/registry-worker/jira/routes.ts +432 -0
  69. package/src/registry-worker/jira/workflow.ts +577 -0
  70. package/src/registry-worker/jobs/retention.ts +21 -5
  71. package/src/registry-worker/mcp/tools.ts +2 -1
  72. package/src/registry-worker/notifications/alerts.ts +4 -4
  73. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  74. package/src/registry-worker/notifications/routes.ts +9 -10
  75. package/src/registry-worker/notifications/teams.ts +6 -2
  76. package/src/registry-worker/org/routes.ts +4 -4
  77. package/src/registry-worker/projects/identity.ts +194 -0
  78. package/src/registry-worker/projects/inactivity.ts +162 -0
  79. package/src/registry-worker/projects/projects.test.ts +283 -0
  80. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  81. package/src/registry-worker/proposals/routes.ts +183 -0
  82. package/src/registry-worker/roles/roles.test.ts +84 -0
  83. package/src/registry-worker/roles/routes.ts +141 -0
  84. package/src/registry-worker/runner/dispatch.ts +1 -0
  85. package/src/registry-worker/runner/routes.ts +22 -5
  86. package/src/registry-worker/runner/runner.test.ts +21 -0
  87. package/src/registry-worker/runner/tokens.ts +8 -0
  88. package/src/registry-worker/secrets/crypto.ts +135 -0
  89. package/src/registry-worker/secrets/routes.ts +296 -0
  90. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  91. package/src/registry-worker/signup/policy.ts +2 -2
  92. package/src/registry-worker/signup/routes.ts +12 -3
  93. package/src/registry-worker/sso/membership.ts +4 -2
  94. package/src/registry-worker/sso/routes.ts +24 -8
  95. package/src/registry-worker/sso/sso.test.ts +3 -2
  96. package/src/registry-worker/task-approval.test.ts +67 -0
  97. package/src/registry-worker/task-resume.test.ts +196 -0
  98. package/src/registry-worker/task-review.test.ts +121 -0
  99. package/src/runtime/appium-driver.ts +1 -1
  100. package/src/runtime/apply-proposals.test.ts +74 -0
  101. package/src/runtime/apply-proposals.ts +41 -0
  102. package/src/runtime/cloud-secrets.test.ts +201 -0
  103. package/src/runtime/cloud-secrets.ts +210 -0
  104. package/src/runtime/config-validate.ts +12 -9
  105. package/src/runtime/cursor-driver-run.ts +14 -3
  106. package/src/runtime/discovery-task.test.ts +26 -1
  107. package/src/runtime/discovery-task.ts +67 -6
  108. package/src/runtime/executor.ts +18 -10
  109. package/src/runtime/explorer.test.ts +32 -0
  110. package/src/runtime/explorer.ts +48 -0
  111. package/src/runtime/flow-language.ts +22 -8
  112. package/src/runtime/init-wizard.ts +112 -6
  113. package/src/runtime/journey-editor.ts +39 -2
  114. package/src/runtime/journey-graph.test.ts +18 -0
  115. package/src/runtime/local-registry-stub.test.ts +64 -0
  116. package/src/runtime/local-registry-stub.ts +255 -7
  117. package/src/runtime/local-vault.ts +65 -0
  118. package/src/runtime/pipeline.test.ts +23 -0
  119. package/src/runtime/pipeline.ts +61 -20
  120. package/src/runtime/planner.test.ts +24 -1
  121. package/src/runtime/planner.ts +88 -23
  122. package/src/runtime/playwright-driver.test.ts +21 -2
  123. package/src/runtime/playwright-driver.ts +49 -7
  124. package/src/runtime/project-identity.test.ts +161 -0
  125. package/src/runtime/project-identity.ts +232 -0
  126. package/src/runtime/project-roles.test.ts +107 -0
  127. package/src/runtime/project-roles.ts +158 -0
  128. package/src/runtime/propose-cli.ts +66 -0
  129. package/src/runtime/record-run-verdicts.test.ts +58 -0
  130. package/src/runtime/record-run-verdicts.ts +38 -4
  131. package/src/runtime/reporter.ts +1 -1
  132. package/src/runtime/reset.ts +2 -0
  133. package/src/runtime/roles-cli.test.ts +47 -0
  134. package/src/runtime/roles-cli.ts +60 -0
  135. package/src/runtime/run-once.ts +68 -5
  136. package/src/runtime/scenario-matrix.test.ts +41 -0
  137. package/src/runtime/scenario-matrix.ts +98 -0
  138. package/src/runtime/secret-driver.ts +77 -0
  139. package/src/runtime/secret-redaction.ts +69 -0
  140. package/src/runtime/secret-sources.test.ts +442 -0
  141. package/src/runtime/secret-sources.ts +176 -0
  142. package/src/runtime/secrets-cli.ts +146 -0
  143. package/src/runtime/serve-worker.ts +64 -6
  144. package/src/runtime/site-discovery.test.ts +47 -4
  145. package/src/runtime/site-discovery.ts +79 -9
  146. package/src/runtime/vault-federation.test.ts +37 -0
  147. package/src/runtime/vault-federation.ts +128 -0
  148. package/src/runtime/web-suite.ts +5 -2
  149. package/src/storage/create-object-store.ts +5 -1
  150. package/src/storage/object-store.ts +12 -1
  151. package/src/storage/test-case-results.test.ts +64 -0
  152. package/src/storage/test-case-results.ts +49 -0
  153. package/src/vendor/byok.ts +371 -0
@@ -22,7 +22,10 @@ import path from "node:path";
22
22
  import YAML from "yaml";
23
23
  import { createProjectObjectStore } from "../storage/create-object-store.js";
24
24
  import { projectPrefix } from "../storage/keys.js";
25
- import { MAX_TASK_ATTEMPTS } from "../registry/task-scheduling.js";
25
+ import { testCaseResultsKey } from "../storage/test-case-results.js";
26
+ import { IN_REVIEW, MAX_TASK_ATTEMPTS } from "../registry/task-scheduling.js";
27
+ import { suggestedRoleNames, usableRoleNames, type RoleRecord } from "../registry/roles.js";
28
+ import { readSpecRoles } from "./project-roles.js";
26
29
  import { driverForPlatform } from "./planner.js";
27
30
 
28
31
  export type PipelineStage = "explore" | "roles" | "audit" | "testcases" | "plan" | "run";
@@ -50,8 +53,10 @@ export type PipelineTask = {
50
53
  export type PipelineInputs = {
51
54
  projectId: string;
52
55
  journeys: PipelineJourney[];
53
- /** Actor names defined in roles.yaml. */
56
+ /** Confirmed roles: the only actors a flow may be planned for. */
54
57
  knownActors: string[];
58
+ /** Roles Bugmole suggested that still wait for a person to confirm or reject. */
59
+ suggestedActors?: string[];
55
60
  /** Journey ids that already have a written test-case set. */
56
61
  caseSetJourneyIds: string[];
57
62
  /** Journey ids that already have an App Audit artifact. */
@@ -168,7 +173,7 @@ function activeTask(tasks: PipelineTask[]): PipelineTask | undefined {
168
173
  */
169
174
  export function derivePipelineState(inputs: PipelineInputs): PipelineState {
170
175
  const {
171
- projectId, journeys, knownActors, caseSetJourneyIds,
176
+ projectId, journeys, knownActors, suggestedActors = [], caseSetJourneyIds,
172
177
  plannedJourneyIds, tasks, hardStopCategories, recordedBlockerCategories,
173
178
  } = inputs;
174
179
 
@@ -237,8 +242,8 @@ export function derivePipelineState(inputs: PipelineInputs): PipelineState {
237
242
  stage: "roles",
238
243
  satisfied: missingActors.length === 0,
239
244
  detail: missingActors.length
240
- ? `undefined actor${missingActors.length === 1 ? "" : "s"}: ${missingActors.join(", ")}`
241
- : "every flow actor is defined",
245
+ ? `unconfirmed role${missingActors.length === 1 ? "" : "s"}: ${missingActors.join(", ")}`
246
+ : "every flow actor is a confirmed role",
242
247
  },
243
248
  {
244
249
  // The audit inventories each screen's controls and runs the component
@@ -331,6 +336,33 @@ export function derivePipelineState(inputs: PipelineInputs): PipelineState {
331
336
  };
332
337
  }
333
338
 
339
+ // Finished work waiting for a person is not in flight, but the next stage
340
+ // would build on it before anyone looked. Wait for the review instead.
341
+ const inReview = tasks.filter((task) => task.status === IN_REVIEW);
342
+ if (inReview.length) {
343
+ const first = inReview[0];
344
+ return {
345
+ status: "paused",
346
+ reason: `${inReview.length} task${inReview.length === 1 ? " is" : "s are"} waiting for your review — `
347
+ + `accept or send back "${first.title ?? first.idempotencyKey ?? "the task"}" on the Agent Tasks board.`,
348
+ stages,
349
+ orphanedArtifacts,
350
+ };
351
+ }
352
+
353
+ // A flow's actor that Bugmole suggested but nobody has confirmed yet is a
354
+ // question for a person, not work for an agent: queueing the roles stage
355
+ // again would only re-suggest it.
356
+ const awaitingDecision = missingActors.filter((actor) => suggestedActors.includes(actor));
357
+ if (awaitingDecision.length && awaitingDecision.length === missingActors.length) {
358
+ return {
359
+ status: "paused",
360
+ reason: `confirm or reject the suggested role${awaitingDecision.length === 1 ? "" : "s"} ${awaitingDecision.join(", ")} on the Roles page (Manage → Roles). Only confirmed roles are planned for.`,
361
+ stages,
362
+ orphanedArtifacts,
363
+ };
364
+ }
365
+
334
366
  // Only one stage runs at a time; the worker already serialises task
335
367
  // execution, and queueing ahead would just create work that races.
336
368
  const inFlight = activeTask(tasks);
@@ -438,13 +470,14 @@ function buildNextTask(
438
470
  return {
439
471
  stage,
440
472
  taskType: "code",
441
- title: "Define the actors the discovered flows use",
473
+ title: "Suggest the roles the discovered flows use",
442
474
  description: [
443
- `The discovered flows use actors that roles.yaml does not define: ${context.missingActors.join(", ")}.`,
444
- "Planning refuses an undefined actor, so the pipeline cannot proceed until this is reconciled.",
445
- "Inspect the application's real roles and either add the missing actor to spec/roles.yaml with",
446
- "accurate permissions, or update the flow to use an existing role that genuinely matches.",
447
- "Do not invent permissions to make the check pass.",
475
+ `The discovered flows use actors that are not confirmed roles: ${context.missingActors.join(", ")}.`,
476
+ "Planning only uses roles a person confirmed, so the pipeline cannot proceed until this is reconciled.",
477
+ "Inspect the application's real roles (sign-in walls, role-gated routes and menus) and either suggest",
478
+ "the missing role with bugmole_role_propose (with the evidence that points at it) so a person can",
479
+ "confirm, rename or reject it, or update the flow to use a confirmed role that genuinely matches.",
480
+ "You cannot confirm a role yourself. Do not invent permissions to make the check pass.",
448
481
  ].join(" "),
449
482
  idempotencyKey: pipelineIdempotencyKey(projectId, stage),
450
483
  };
@@ -521,12 +554,14 @@ function readYaml(filePath: string): any {
521
554
  }
522
555
  }
523
556
 
524
- /** Actor names defined in roles.yaml, which the planner validates against. */
525
- export function knownActors(specDir: string): string[] {
526
- const roles = readYaml(path.join(specDir, "roles.yaml"));
527
- return Array.isArray(roles?.roles)
528
- ? roles.roles.map((role: any) => role?.name).filter((name: unknown): name is string => typeof name === "string")
529
- : [];
557
+ /** Confirmed roles, which the planner validates against: roles.yaml, overridden by the registry's decisions. */
558
+ export function knownActors(specDir: string, registryRoles: RoleRecord[] = []): string[] {
559
+ return usableRoleNames(readSpecRoles(specDir), registryRoles);
560
+ }
561
+
562
+ /** Roles still waiting for a person's decision. */
563
+ export function suggestedActors(specDir: string, registryRoles: RoleRecord[] = []): string[] {
564
+ return suggestedRoleNames(readSpecRoles(specDir), registryRoles);
530
565
  }
531
566
 
532
567
  /**
@@ -569,7 +604,7 @@ async function listJourneyIdsWithVerdicts(cfg: any, projectId: string): Promise<
569
604
  ? caseSet.cases.map((item: any) => item?.caseId).filter((id: unknown) => typeof id === "string")
570
605
  : [];
571
606
  if (!journeyId || caseIds.length === 0) return null;
572
- const resultKey = `${projectPrefix(projectId)}/test-case-results/${caseSet.caseSetId}.json`;
607
+ const resultKey = testCaseResultsKey(projectId, caseSet.caseSetId);
573
608
  const results = JSON.parse(await store(cfg).getText(resultKey));
574
609
  // A verdict recorded against a superseded revision is not evidence
575
610
  // about the cases as they stand now, matching how the dashboard
@@ -706,6 +741,8 @@ export async function loadPipelineInputs(
706
741
  registry?: {
707
742
  runs?: Array<{ journeyId?: string; status?: string }>;
708
743
  plans?: Array<{ planId: string; journeyId?: string }>;
744
+ /** The registry's role decisions, which override roles.yaml. */
745
+ roles?: RoleRecord[];
709
746
  },
710
747
  ): Promise<PipelineInputs> {
711
748
  const projectId = cfg.project?.id ?? "default";
@@ -727,7 +764,8 @@ export async function loadPipelineInputs(
727
764
  return {
728
765
  projectId,
729
766
  journeys,
730
- knownActors: knownActors(specDir),
767
+ knownActors: knownActors(specDir, registry?.roles ?? []),
768
+ suggestedActors: suggestedActors(specDir, registry?.roles ?? []),
731
769
  caseSetJourneyIds,
732
770
  auditedJourneyIds,
733
771
  plannedJourneyIds: plannedJourneyIds(specDir, journeys.map((journey) => journey.id)),
@@ -758,6 +796,7 @@ type ReconcileClient = {
758
796
  }): Promise<unknown>;
759
797
  listRuns?(): Promise<{ runs: any[] }>;
760
798
  listPlans?(): Promise<{ plans: any[] }>;
799
+ listRoles?(): Promise<{ roles: Array<{ name: string; status: RoleRecord["status"] }> }>;
761
800
  createRun?(input: {
762
801
  planId: string;
763
802
  idempotencyKey: string;
@@ -799,14 +838,16 @@ export async function publishPipelineState(cfg: any, state: PipelineState): Prom
799
838
  }
800
839
 
801
840
  export async function reconcilePipeline(cfg: any, client: ReconcileClient): Promise<ReconcileResult> {
802
- const [listed, runs, plans] = await Promise.all([
841
+ const [listed, runs, plans, roles] = await Promise.all([
803
842
  client.listTasks(),
804
843
  client.listRuns?.().catch(() => ({ runs: [] })) ?? Promise.resolve({ runs: [] }),
805
844
  client.listPlans?.().catch(() => ({ plans: [] })) ?? Promise.resolve({ plans: [] }),
845
+ client.listRoles?.().catch(() => ({ roles: [] })) ?? Promise.resolve({ roles: [] }),
806
846
  ]);
807
847
  const inputs = await loadPipelineInputs(cfg, listed.tasks ?? [], {
808
848
  runs: runs.runs ?? [],
809
849
  plans: plans.plans ?? [],
850
+ roles: (roles.roles ?? []).map((role) => ({ name: role.name, status: role.status })),
810
851
  });
811
852
  const state = derivePipelineState(inputs);
812
853
  // Published on every pass so the dashboard reflects the live position,
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { driverForPlatform } from "./planner.js";
3
+ import { driverForPlatform, journeyFlow } from "./planner.js";
4
+ import { parseMaestroStyleFlow } from "./flow-language.js";
4
5
 
5
6
  test("a web plan is never pinned to the mobile driver", () => {
6
7
  // bugmole.config defaults execution.driver to "maestro" while the plan
@@ -35,3 +36,25 @@ test("an unknown platform is treated as web rather than defaulting to mobile", (
35
36
  // first place: the driver that cannot run without a device file.
36
37
  assert.equal(driverForPlatform(undefined, "desktop"), "playwright");
37
38
  });
39
+
40
+ test("a journey becomes a flow that opens its first screen and follows each transition", () => {
41
+ const flow = journeyFlow(
42
+ { id: "sign-up", name: "Sign up", startNodeId: "screen:/", nodeIds: ["screen:/", "screen:/signup", "screen:/projects/:id"], edgeIds: ["a", "b"] },
43
+ [
44
+ { id: "screen:/", route: "/" },
45
+ { id: "screen:/signup", route: "/signup" },
46
+ { id: "screen:/projects/:id", route: "/projects/:id" },
47
+ ],
48
+ [
49
+ { id: "a", from: "screen:/", to: "screen:/signup" },
50
+ { id: "b", from: "screen:/signup", to: "screen:/projects/:id", label: "Create account" },
51
+ ],
52
+ );
53
+ assert.ok(flow);
54
+ const parsed = parseMaestroStyleFlow(flow, "http://localhost:3000");
55
+ assert.deepEqual(parsed.steps.map((step) => step.type), ["launchApp", "assertLayout", "openLink", "assertLayout", "tapOn", "assertLayout"]);
56
+ });
57
+
58
+ test("a journey with no openable screen has no flow", () => {
59
+ assert.equal(journeyFlow({ id: "x", nodeIds: ["n"] }, [{ id: "n", route: "/projects/:id" }], []), null);
60
+ });
@@ -5,6 +5,7 @@ import { createProjectObjectStore } from "../storage/create-object-store.js";
5
5
  import { projectPrefix } from "../storage/keys.js";
6
6
  import { ControlPlaneClient } from "../registry/control-plane-client.js";
7
7
  import { nextJourneyTask } from "./journey-editor.js";
8
+ import { projectActorProblem, projectRoles, rolesClient } from "./project-roles.js";
8
9
 
9
10
  /** Drivers that can actually operate each kind of target. */
10
11
  const DRIVERS_BY_PLATFORM: Record<string, string[]> = {
@@ -36,6 +37,8 @@ interface PlanOptions {
36
37
  journeyId: string;
37
38
  actor: string;
38
39
  revision?: number;
40
+ /** Where the registry's role decisions come from; null skips the registry. Defaults to this machine's API key. */
41
+ rolesClient?: Pick<ControlPlaneClient, "listRoles"> | null;
39
42
  }
40
43
 
41
44
  interface PlanResult {
@@ -82,17 +85,10 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
82
85
  nextSteps: ["Select an existing journey or record the selected journey before planning."],
83
86
  };
84
87
  }
85
- if (selectedJourney.stability !== "stable" || selectedJourney.linkedToGraph !== true) {
86
- return {
87
- status: "error",
88
- error: `Journey "${journeyId}" is not stabilized. Execute and adjust the canonical flow until it is stable before planning.`,
89
- nextSteps: [
90
- `Run the canonical flow "${journeyId}"`,
91
- "Adjust the flow for any failures and rerun it",
92
- "Mark the verified run as the stabilized canonical flow",
93
- ],
94
- };
95
- }
88
+ // A draft journey can be planned: running its plan is how it becomes
89
+ // stable (bugmole_journey_stabilize takes that run's ID). Requiring
90
+ // "stable" first left no way to make the run that proves it.
91
+ const isStable = selectedJourney.stability === "stable" && selectedJourney.linkedToGraph === true;
96
92
  const flowState = nextJourneyTask(cfg, journeyId);
97
93
  if (flowState.status === "actionable") {
98
94
  return {
@@ -123,17 +119,18 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
123
119
  nextSteps: ["Create roles.yaml from the guided testing workflow, then retry planning."],
124
120
  };
125
121
  }
126
- const roles = YAML.parse(fs.readFileSync(rolesPath, "utf-8"));
127
- const knownActors = Array.isArray(roles?.roles)
128
- ? roles.roles.map((role: any) => role?.name).filter((name: unknown): name is string => typeof name === "string")
129
- : [];
130
- if (!knownActors.includes(actor)) {
122
+ // Only a role a person confirmed may be planned for: Bugmole suggests
123
+ // roles, people decide them (src/registry/roles.ts, docs/public/roles.md).
124
+ const roles = await projectRoles(cfg, options.rolesClient === undefined ? rolesClient(cfg) : options.rolesClient);
125
+ const actorIssue = projectActorProblem(roles, actor);
126
+ if (actorIssue) {
131
127
  return {
132
128
  status: "error",
133
- error: `Actor "${actor}" is not defined in roles.yaml.`,
129
+ error: actorIssue,
134
130
  nextSteps: [
135
- `Read roles.yaml and choose one of: ${knownActors.join(", ") || "(none defined)"}`,
136
- "Retry planning with a defined actor",
131
+ `Plan with a confirmed role: ${roles.usable.join(", ") || "(none confirmed yet)"}`,
132
+ ...(roles.suggested.length ? [`Ask a person to confirm or reject the suggested roles on the dashboard's Roles page: ${roles.suggested.join(", ")}`] : []),
133
+ "If the journey really needs a new role, suggest it with bugmole_role_propose and wait for a person to confirm it",
137
134
  ],
138
135
  };
139
136
  }
@@ -169,6 +166,25 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
169
166
  const steps = graphSteps;
170
167
  const planId = journeyId.replace(/[^a-z0-9._-]/gi, "_").toLowerCase();
171
168
  const planPath = path.join(plansDir, `${planId}.flow.yaml`);
169
+ const platform = cfg.project?.platform || "web";
170
+ // Without a flow a plan has nothing to run. On the web, write one from
171
+ // the journey itself: open its first screen, then follow each recorded
172
+ // transition, checking every screen it reaches.
173
+ const configuredFlow: string | undefined = cfg.execution?.maestro?.flow || undefined;
174
+ let generatedFlow: string | null = null;
175
+ if (!configuredFlow && platform === "web") {
176
+ const reference = `flows/journeys/${planId}.yaml`;
177
+ if (fs.existsSync(path.join(specDir, reference))) {
178
+ generatedFlow = reference;
179
+ } else {
180
+ const content = journeyFlow(selectedJourney, discoveredNodes, discoveredEdges);
181
+ if (content) {
182
+ generatedFlow = reference;
183
+ fs.mkdirSync(path.join(specDir, "flows", "journeys"), { recursive: true });
184
+ fs.writeFileSync(path.join(specDir, reference), content, "utf-8");
185
+ }
186
+ }
187
+ }
172
188
 
173
189
  const plan = {
174
190
  version: 2,
@@ -176,14 +192,14 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
176
192
  name: `Journey: ${journeyId}`,
177
193
  description: `Generated plan for journey ${journeyId} with actor ${actor}`,
178
194
  driver: driverForPlatform(cfg.execution?.driver, cfg.project?.platform || "web"),
179
- platform: cfg.project?.platform || "web",
195
+ platform,
180
196
  target: {
181
197
  base_url: cfg.project?.base_url || cfg.execution?.playwright?.base_url,
182
198
  app_id: cfg.project?.app_id,
183
199
  device_id: cfg.project?.device_id,
184
200
  serial: cfg.project?.serial,
185
201
  },
186
- flow: cfg.execution?.maestro?.flow || null,
202
+ flow: configuredFlow || generatedFlow,
187
203
  recovery: {
188
204
  enabled: cfg.execution?.recovery?.enabled === true,
189
205
  max_attempts: cfg.execution?.recovery?.max_attempts ?? 1,
@@ -200,7 +216,7 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
200
216
  actor: actor,
201
217
  goal: `Complete journey: ${journeyId}`,
202
218
  capabilities_required: [],
203
- preconditions: ["Test tenant / sandbox is active", "Actor account exists"],
219
+ preconditions: [`Signed in as ${actor}, or signed out if that's who the journey is for`],
204
220
  steps,
205
221
  },
206
222
  ],
@@ -263,7 +279,13 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
263
279
  status: "success",
264
280
  planId: planId,
265
281
  planPath: `plans/${planId}.flow.yaml`,
266
- message: `Plan created at ${planPath}. Review and customize as needed.${apiKey && registryUrl ? " Published to the dashboard." : ""}`,
282
+ ...(plan.flow ? { flow: plan.flow } : {}),
283
+ message: `Plan created at ${planPath}${generatedFlow ? `, with its flow at ${path.join(specDir, generatedFlow)}` : ""}.`
284
+ + (plan.flow ? "" : " It has no flow to run yet: set execution.maestro.flow, or record the journey's screens with their routes and plan again.")
285
+ + (apiKey && registryUrl ? " Published to the dashboard." : ""),
286
+ nextSteps: isStable
287
+ ? [`Run it: bugmole_run with planId ${planId}`]
288
+ : [`Run it: bugmole_run with planId ${planId}`, `When that run passes, call bugmole_journey_stabilize with journeyId ${journeyId} and the run's ID`],
267
289
  };
268
290
  } catch (error: any) {
269
291
  return {
@@ -272,3 +294,46 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
272
294
  };
273
295
  }
274
296
  }
297
+
298
+ /**
299
+ * A web flow that walks a journey: open the first screen, then follow each
300
+ * transition in order, tapping its label when it has one and opening the
301
+ * next screen's route otherwise. Null when there is nothing to open.
302
+ */
303
+ export function journeyFlow(
304
+ journey: { id: string; name?: string; startNodeId?: string; nodeIds?: string[]; edgeIds?: string[] },
305
+ nodes: Array<{ id: string; route?: string; title?: string }>,
306
+ edges: Array<{ id: string; from: string; to: string; label?: string }>,
307
+ ): string | null {
308
+ const byId = new Map(nodes.map((node) => [node.id, node]));
309
+ const opens = (route?: string) => typeof route === "string" && route.startsWith("/") && !/[:\[*]/.test(route);
310
+ const quote = (text: string) => JSON.stringify(text);
311
+ const startId = journey.startNodeId || journey.nodeIds?.[0];
312
+ const start = startId ? byId.get(startId) : undefined;
313
+ const steps: string[] = ["- launchApp"];
314
+ if (start && opens(start.route) && start.route !== "/") steps.push(`- openLink: ${quote(start.route!)}`);
315
+ else if (!start || !opens(start.route)) {
316
+ const first = (journey.nodeIds ?? []).map((id) => byId.get(id)).find((node) => opens(node?.route));
317
+ if (!first) return null;
318
+ if (first.route !== "/") steps.push(`- openLink: ${quote(first.route!)}`);
319
+ }
320
+ steps.push("- assertLayout");
321
+ const journeyEdges = (journey.edgeIds ?? []).map((id) => edges.find((edge) => edge.id === id)).filter((edge): edge is NonNullable<typeof edge> => Boolean(edge));
322
+ for (const edge of journeyEdges) {
323
+ const target = byId.get(edge.to);
324
+ const label = typeof edge.label === "string" ? edge.label.trim() : "";
325
+ if (label && label.length <= 80 && !/^(navigate|dom|link)[:-]/i.test(label)) steps.push(`- tapOn: ${quote(label)}`);
326
+ else if (opens(target?.route)) steps.push(`- openLink: ${quote(target!.route!)}`);
327
+ else continue;
328
+ steps.push("- assertLayout");
329
+ }
330
+ return [
331
+ `# Written by bugmole_plan from journey ${journey.id}. Edit it freely:`,
332
+ "# the plan runs this file, and planning again won't overwrite your changes",
333
+ "# unless you delete it.",
334
+ `name: ${quote(journey.name || journey.id)}`,
335
+ "---",
336
+ ...steps,
337
+ "",
338
+ ].join("\n");
339
+ }
@@ -97,7 +97,7 @@ test("a worker without Playwright's ffmpeg loses the video, not the run", () =>
97
97
  import http from "node:http";
98
98
  import type { AddressInfo } from "node:net";
99
99
  import { chromium } from "playwright";
100
- import { brokenStatus, checkLayout, checkLinks } from "./playwright-driver.js";
100
+ import { brokenStatus, checkLayout, checkLinks, hideFocusedFieldText } from "./playwright-driver.js";
101
101
 
102
102
  async function browserOrSkip(t: { skip: (m: string) => void }) {
103
103
  try {
@@ -152,7 +152,10 @@ test("assertLinks reports links that don't answer, and doesn't count robots bein
152
152
  assert.equal(brokenStatus(403, false), false);
153
153
  assert.equal(brokenStatus(429, false), false);
154
154
  assert.equal(brokenStatus(410, false), true);
155
- assert.equal(brokenStatus(503, false), true);
155
+ assert.equal(brokenStatus(503, false), false);
156
+ assert.equal(brokenStatus(504, false), false);
157
+ assert.equal(brokenStatus(500, false), true);
158
+ assert.equal(brokenStatus(503, true), true);
156
159
  const browser = await browserOrSkip(t);
157
160
  if (!browser) return;
158
161
  const unsafeHits: string[] = [];
@@ -236,3 +239,19 @@ test("assertClickable scrolls to a button below the fold, but still fails one th
236
239
  }
237
240
  });
238
241
 
242
+
243
+ test("a secret typed into a plain text field is invisible on screen but keeps its value", async (t) => {
244
+ const browser = await browserOrSkip(t);
245
+ if (!browser) return;
246
+ try {
247
+ const page = await browser.newPage();
248
+ await page.setContent(`<input id="token" type="text" style="color:#111">`);
249
+ await page.focus("#token");
250
+ await hideFocusedFieldText(page);
251
+ await page.keyboard.type("s3cr3t-value");
252
+ assert.equal(await page.inputValue("#token"), "s3cr3t-value");
253
+ assert.equal(await page.evaluate(`getComputedStyle(document.getElementById("token")).color`), "rgba(0, 0, 0, 0)");
254
+ } finally {
255
+ await browser.close();
256
+ }
257
+ });
@@ -20,11 +20,13 @@ import {
20
20
  describeStepLabel,
21
21
  isPatternText,
22
22
  parseMaestroStyleFlow,
23
+ referencedSecretNames,
23
24
  resolveFlowUrl,
24
25
  type FlowStep,
25
26
  type ParsedFlow,
26
27
  } from "./flow-language.js";
27
28
  import { analyzeFailure, rawFlowSteps, type FailureContext } from "./failure-analysis.js";
29
+ import { redactSecrets } from "./secret-redaction.js";
28
30
  import { createTempEmailInbox, deleteTempEmailInbox, extractFirstLink, waitForTempEmailMessage, type TempEmailInbox } from "../integrations/temp-email.js";
29
31
 
30
32
  export { parseMaestroStyleFlow } from "./flow-language.js";
@@ -363,9 +365,30 @@ export function isUnsafeLink(href: string, text: string): boolean {
363
365
  /** Answers that mean a link is broken, not that the site turned a robot away. */
364
366
  export function brokenStatus(status: number, sameSite: boolean): boolean {
365
367
  if (sameSite) return status >= 400;
368
+ // 502-504 from someone else's site is their gateway having a bad moment
369
+ // (GitHub answers 504 to bots now and then), not a link the page got wrong.
370
+ if (status >= 502 && status <= 504) return false;
366
371
  return status === 404 || status === 410 || status >= 500;
367
372
  }
368
373
 
374
+ /**
375
+ * Makes the focused field's text invisible on screen (screenshots, video)
376
+ * without changing its value. Password fields are already masked.
377
+ */
378
+ export async function hideFocusedFieldText(page: Page): Promise<void> {
379
+ await page.evaluate(`(() => {
380
+ const field = document.activeElement;
381
+ if (!field || field === document.body) return;
382
+ field.setAttribute("data-bugmole-secret", "");
383
+ if (!document.getElementById("bugmole-secret-style")) {
384
+ const style = document.createElement("style");
385
+ style.id = "bugmole-secret-style";
386
+ style.textContent = "[data-bugmole-secret]{color:transparent!important;-webkit-text-fill-color:transparent!important;text-shadow:none!important;caret-color:currentColor}";
387
+ (document.head || document.documentElement).appendChild(style);
388
+ }
389
+ })()`).catch(() => undefined);
390
+ }
391
+
369
392
  /** Requests every link on the page (same-site only unless `external`). Returns the broken ones. */
370
393
  export async function checkLinks(page: Page, external: boolean, limit = 80): Promise<string[]> {
371
394
  const origin = new URL(page.url()).origin;
@@ -388,6 +411,11 @@ export async function checkLinks(page: Page, external: boolean, limit = 80): Pro
388
411
  let response = await request.head(url, { timeout: 10_000, maxRedirects: 5, failOnStatusCode: false });
389
412
  // Plenty of servers answer HEAD badly; a GET is what a visitor sends.
390
413
  if (response.status() >= 400) response = await request.get(url, { timeout: 10_000, maxRedirects: 5, failOnStatusCode: false });
414
+ // A server error can be a blip: ask once more before calling it broken.
415
+ if (response.status() >= 500) {
416
+ await new Promise((resolve) => setTimeout(resolve, 1_500));
417
+ response = await request.get(url, { timeout: 10_000, maxRedirects: 5, failOnStatusCode: false });
418
+ }
391
419
  if (brokenStatus(response.status(), sameSite)) broken.push(`${url} (${response.status()})`);
392
420
  } catch (error) {
393
421
  if (sameSite) broken.push(`${url} (no answer: ${error instanceof Error ? error.message.split("\n")[0] : String(error)})`);
@@ -502,6 +530,8 @@ export class PlaywrightDriver implements AppDriver {
502
530
  const videoDir = path.join(artifactsDir, "video");
503
531
  await fs.mkdir(videoDir, { recursive: true });
504
532
  const log: string[] = [];
533
+ const typesSecret = referencedSecretNames(flowContent).length > 0;
534
+ if (typesSecret) log.push("This flow types a secret, so no Playwright trace is recorded.");
505
535
  const steps: DriverStepResult[] = [];
506
536
  const pageErrors: string[] = [];
507
537
  const consoleErrors: string[] = [];
@@ -519,7 +549,7 @@ export class PlaywrightDriver implements AppDriver {
519
549
 
520
550
  const writeLog = async () => {
521
551
  const logPath = path.join(artifactsDir, "playwright.log");
522
- await fs.writeFile(logPath, log.join("\n"), "utf8").catch(() => undefined);
552
+ await fs.writeFile(logPath, redactSecrets(log.join("\n")), "utf8").catch(() => undefined);
523
553
  return logPath;
524
554
  };
525
555
  const stopTrace = async (): Promise<string | undefined> => {
@@ -549,6 +579,7 @@ export class PlaywrightDriver implements AppDriver {
549
579
  };
550
580
  }
551
581
  const storageState = context.sessionStatePath && (await fileExists(context.sessionStatePath)) ? context.sessionStatePath : undefined;
582
+ if (storageState && !typesSecret) log.push("This flow starts signed in, so no Playwright trace is recorded (it would hold the session cookies).");
552
583
  // Record at the page's real size; Playwright's default shrinks to 800px.
553
584
  const videoSize = flow.viewport ?? emulation.options.viewport ?? { width: 1280, height: 720 };
554
585
  const contextOptions = {
@@ -561,7 +592,11 @@ export class PlaywrightDriver implements AppDriver {
561
592
  withVideo ? { ...contextOptions, recordVideo: { dir: videoDir, size: videoSize } } : contextOptions,
562
593
  );
563
594
  // The trace is the step-by-step record a person opens to debug a run.
564
- await opened.tracing.start({ screenshots: true, snapshots: true }).catch(() => undefined);
595
+ // It records every typed character and field value, so a flow that
596
+ // types a secret gets no trace: redaction can't reach inside it.
597
+ // A flow that starts signed in has the session cookies in every
598
+ // request it records, so it gets no trace either.
599
+ if (!typesSecret && !storageState) await opened.tracing.start({ screenshots: true, snapshots: true }).catch(() => undefined);
565
600
  return opened;
566
601
  };
567
602
  browserContext = await openContext(true);
@@ -597,7 +632,7 @@ export class PlaywrightDriver implements AppDriver {
597
632
  };
598
633
 
599
634
  const runStep = async (rawStep: FlowStep): Promise<void> => {
600
- const step = interpolateStep(rawStep, flowContext);
635
+ const step = interpolateStep(rawStep, flowContext, { env: { ...process.env, ...context.environment } });
601
636
  switch (step.type) {
602
637
  case "launchApp":
603
638
  await page.goto(flow.url, { waitUntil: "load" });
@@ -622,6 +657,9 @@ export class PlaywrightDriver implements AppDriver {
622
657
  return;
623
658
  }
624
659
  case "inputText":
660
+ // A secret typed into a plain text field would show in every
661
+ // screenshot and the video. Hide that field's text before typing.
662
+ if (rawStep.type === "inputText" && referencedSecretNames(rawStep.text).length) await hideFocusedFieldText(page);
625
663
  await page.keyboard.type(step.text);
626
664
  return;
627
665
  case "eraseText":
@@ -726,7 +764,11 @@ export class PlaywrightDriver implements AppDriver {
726
764
  }
727
765
 
728
766
  const finalScreenshot = await capture("playwright-final");
729
- if (context.sessionStatePath) await browserContext.storageState({ path: context.sessionStatePath });
767
+ if (context.sessionStatePath) {
768
+ // The session file holds the app's cookies: readable by this user only.
769
+ await browserContext.storageState({ path: context.sessionStatePath });
770
+ await fs.chmod(context.sessionStatePath, 0o600).catch(() => undefined);
771
+ }
730
772
  const tracePath = await stopTrace();
731
773
  const logPath = await writeLog();
732
774
  const videoPath = await page.video()?.path();
@@ -760,7 +802,7 @@ export class PlaywrightDriver implements AppDriver {
760
802
  steps,
761
803
  };
762
804
  } catch (error) {
763
- const message = error instanceof Error ? error.message : "Playwright flow failed.";
805
+ const message = redactSecrets(error instanceof Error ? error.message : "Playwright flow failed.");
764
806
  log.push(`ERROR: ${message}`);
765
807
  // The step whose action threw failed; steps after it never ran and must
766
808
  // not be reported as either passed or failed.
@@ -797,12 +839,12 @@ export class PlaywrightDriver implements AppDriver {
797
839
  invalidFields: snapshot?.invalidFields ?? [],
798
840
  };
799
841
  const contextPath = path.join(artifactsDir, "failure-context.json");
800
- await fs.writeFile(contextPath, `${JSON.stringify(failureContext, null, 2)}\n`, "utf8");
842
+ await fs.writeFile(contextPath, `${redactSecrets(JSON.stringify(failureContext, null, 2))}\n`, "utf8");
801
843
  artifacts.push({ path: contextPath, contentType: "application/json", name: "failure-context.json" });
802
844
  try {
803
845
  const analysis = analyzeFailure(flow.steps, rawFlowSteps(flowContent), failureContext);
804
846
  const analysisPath = path.join(artifactsDir, "analysis.json");
805
- await fs.writeFile(analysisPath, `${JSON.stringify(analysis, null, 2)}\n`, "utf8");
847
+ await fs.writeFile(analysisPath, `${redactSecrets(JSON.stringify(analysis, null, 2))}\n`, "utf8");
806
848
  artifacts.push({ path: analysisPath, contentType: "application/json", name: "analysis.json" });
807
849
  log.push(`ANALYSIS: ${analysis.title}`);
808
850
  } catch (analysisError) {