@bugmole/cli 0.4.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.
- package/LICENSE +18 -4
- package/package.json +7 -4
- package/scripts/bugmole-admin.mjs +194 -0
- package/scripts/bugmole-admin.test.mjs +62 -0
- package/scripts/bugmole.test.ts +94 -2
- package/scripts/bugmole.ts +290 -43
- package/scripts/ensure-playwright.cjs +87 -0
- package/scripts/ensure-playwright.test.ts +36 -0
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-launcher-version.cjs +24 -0
- package/scripts/sync-launcher-version.test.ts +28 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -6
- package/spec/domain_rules.yaml +139 -0
- package/spec/roles.yaml +20 -0
- package/spec/test-case-results.schema.json +20 -4
- package/spec/test-cases.schema.json +131 -16
- package/src/billing/plan-catalog.test.ts +82 -2
- package/src/billing/plan-catalog.ts +212 -9
- package/src/integrations/jira.ts +264 -0
- package/src/mcp/roles-and-review.test.ts +111 -0
- package/src/mcp/server.ts +358 -22
- package/src/mcp/write-test-cases.test.ts +68 -0
- package/src/registry/control-plane-client.ts +121 -6
- package/src/registry/migrations/0034_signup_attribution.sql +19 -0
- package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
- package/src/registry/migrations/0036_task_approval.sql +11 -0
- package/src/registry/migrations/0037_spec_proposals.sql +27 -0
- package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
- package/src/registry/migrations/0039_project_secrets.sql +37 -0
- package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
- package/src/registry/migrations/0041_jira_integration.sql +75 -0
- package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
- package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
- package/src/registry/migrations/0044_project_identity.sql +31 -0
- package/src/registry/project-identity.test.ts +99 -0
- package/src/registry/project-identity.ts +206 -0
- package/src/registry/roles.test.ts +66 -0
- package/src/registry/roles.ts +201 -0
- package/src/registry/task-scheduling.test.ts +94 -0
- package/src/registry/task-scheduling.ts +199 -2
- package/src/registry/test-case-revisions.test.ts +57 -0
- package/src/registry/test-case-revisions.ts +132 -0
- package/src/registry-worker/ai/routes.ts +3 -3
- package/src/registry-worker/artifacts.ts +23 -4
- package/src/registry-worker/billing/billing-core.test.ts +1 -1
- package/src/registry-worker/billing/checkout-routes.ts +53 -4
- package/src/registry-worker/billing/enforcement.ts +67 -12
- package/src/registry-worker/billing/paypal/api.ts +14 -0
- package/src/registry-worker/billing/paypal/client.ts +9 -0
- package/src/registry-worker/billing/paypal/provider.ts +10 -1
- package/src/registry-worker/billing/paypal.test.ts +108 -1
- package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
- package/src/registry-worker/billing/provider.ts +8 -0
- package/src/registry-worker/billing/routes.ts +2 -1
- package/src/registry-worker/billing/subscriptions.ts +114 -4
- package/src/registry-worker/core.ts +24 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/domains/domains.test.ts +64 -1
- package/src/registry-worker/domains/routes.ts +32 -15
- package/src/registry-worker/domains.ts +40 -2
- package/src/registry-worker/feature-access.ts +165 -0
- package/src/registry-worker/feature-flags/admin.ts +300 -0
- package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
- package/src/registry-worker/feature-flags/routes.ts +102 -0
- package/src/registry-worker/features.ts +11 -0
- package/src/registry-worker/feedback/feedback.test.ts +47 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/feedback/website.ts +77 -0
- package/src/registry-worker/flags.ts +80 -18
- package/src/registry-worker/github/checks.ts +4 -4
- package/src/registry-worker/hooks.ts +9 -0
- package/src/registry-worker/identity/oidc.test.ts +103 -0
- package/src/registry-worker/identity/oidc.ts +176 -0
- package/src/registry-worker/index.ts +494 -173
- package/src/registry-worker/jira/connection.ts +111 -0
- package/src/registry-worker/jira/jira.test.ts +404 -0
- package/src/registry-worker/jira/routes.ts +432 -0
- package/src/registry-worker/jira/workflow.ts +577 -0
- package/src/registry-worker/jobs/retention.ts +21 -5
- package/src/registry-worker/mcp/tools.ts +2 -1
- package/src/registry-worker/notifications/alerts.ts +4 -4
- package/src/registry-worker/notifications/notifications.test.ts +11 -0
- package/src/registry-worker/notifications/routes.ts +9 -10
- package/src/registry-worker/notifications/teams.ts +6 -2
- package/src/registry-worker/org/routes.test.ts +24 -1
- package/src/registry-worker/org/routes.ts +9 -8
- package/src/registry-worker/projects/identity.ts +194 -0
- package/src/registry-worker/projects/inactivity.ts +162 -0
- package/src/registry-worker/projects/projects.test.ts +283 -0
- package/src/registry-worker/proposals/proposals.test.ts +80 -0
- package/src/registry-worker/proposals/routes.ts +183 -0
- package/src/registry-worker/roles/roles.test.ts +84 -0
- package/src/registry-worker/roles/routes.ts +141 -0
- package/src/registry-worker/runner/dispatch.ts +1 -0
- package/src/registry-worker/runner/routes.ts +22 -5
- package/src/registry-worker/runner/runner.test.ts +59 -0
- package/src/registry-worker/runner/tokens.ts +12 -1
- package/src/registry-worker/secrets/crypto.ts +135 -0
- package/src/registry-worker/secrets/routes.ts +296 -0
- package/src/registry-worker/secrets/secrets.test.ts +237 -0
- package/src/registry-worker/signup/attribution.test.ts +147 -0
- package/src/registry-worker/signup/attribution.ts +194 -0
- package/src/registry-worker/signup/policy.ts +2 -2
- package/src/registry-worker/signup/routes.ts +12 -3
- package/src/registry-worker/sso/membership.ts +4 -2
- package/src/registry-worker/sso/routes.ts +24 -8
- package/src/registry-worker/sso/sso.test.ts +3 -2
- package/src/registry-worker/task-approval.test.ts +67 -0
- package/src/registry-worker/task-resume.test.ts +196 -0
- package/src/registry-worker/task-review.test.ts +121 -0
- package/src/runtime/ai-exploration.test.ts +34 -1
- package/src/runtime/ai-exploration.ts +97 -3
- package/src/runtime/appium-driver.ts +7 -1
- package/src/runtime/apply-proposals.test.ts +74 -0
- package/src/runtime/apply-proposals.ts +41 -0
- package/src/runtime/cloud-secrets.test.ts +201 -0
- package/src/runtime/cloud-secrets.ts +210 -0
- package/src/runtime/config-validate.ts +12 -9
- package/src/runtime/cursor-driver-run.ts +14 -3
- package/src/runtime/discovery-task.test.ts +26 -1
- package/src/runtime/discovery-task.ts +87 -7
- package/src/runtime/executor.ts +18 -10
- package/src/runtime/explorer.test.ts +32 -0
- package/src/runtime/explorer.ts +48 -0
- package/src/runtime/flow-language.ts +43 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +78 -2
- package/src/runtime/journey-graph.test.ts +60 -0
- package/src/runtime/local-registry-stub.test.ts +64 -0
- package/src/runtime/local-registry-stub.ts +255 -7
- package/src/runtime/local-vault.ts +65 -0
- package/src/runtime/pipeline.test.ts +23 -0
- package/src/runtime/pipeline.ts +61 -20
- package/src/runtime/planner.test.ts +24 -1
- package/src/runtime/planner.ts +88 -23
- package/src/runtime/playwright-driver.test.ts +164 -0
- package/src/runtime/playwright-driver.ts +286 -15
- package/src/runtime/project-identity.test.ts +161 -0
- package/src/runtime/project-identity.ts +232 -0
- package/src/runtime/project-roles.test.ts +107 -0
- package/src/runtime/project-roles.ts +158 -0
- package/src/runtime/propose-cli.ts +66 -0
- package/src/runtime/record-run-verdicts.test.ts +58 -0
- package/src/runtime/record-run-verdicts.ts +38 -4
- package/src/runtime/reporter.ts +1 -1
- package/src/runtime/reset.ts +2 -0
- package/src/runtime/roles-cli.test.ts +47 -0
- package/src/runtime/roles-cli.ts +60 -0
- package/src/runtime/run-job.test.ts +21 -0
- package/src/runtime/run-job.ts +8 -0
- package/src/runtime/run-once.ts +68 -5
- package/src/runtime/scenario-matrix.test.ts +41 -0
- package/src/runtime/scenario-matrix.ts +98 -0
- package/src/runtime/secret-driver.ts +77 -0
- package/src/runtime/secret-redaction.ts +69 -0
- package/src/runtime/secret-sources.test.ts +442 -0
- package/src/runtime/secret-sources.ts +176 -0
- package/src/runtime/secrets-cli.ts +146 -0
- package/src/runtime/serve-worker.ts +64 -6
- package/src/runtime/site-discovery.test.ts +181 -4
- package/src/runtime/site-discovery.ts +248 -29
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.test.ts +65 -0
- package/src/runtime/web-suite.ts +8 -2
- package/src/storage/create-object-store.ts +5 -1
- package/src/storage/object-store.ts +12 -1
- package/src/storage/test-case-results.test.ts +64 -0
- package/src/storage/test-case-results.ts +49 -0
- package/src/vendor/byok.ts +371 -0
|
@@ -29,7 +29,8 @@ import { randomBytes } from "node:crypto";
|
|
|
29
29
|
import { createObjectStore, type ObjectStoreConfig } from "../storage/create-object-store.js";
|
|
30
30
|
import type { ObjectStore } from "../storage/object-store.js";
|
|
31
31
|
import { normalizeTargets } from "./browser-matrix.js";
|
|
32
|
-
import { MAX_TASK_ATTEMPTS, backoffUntil, isResumableTask, resumedTaskFields } from "../registry/task-scheduling.js";
|
|
32
|
+
import { IN_REVIEW, MAX_TASK_ATTEMPTS, REVIEW_NOTE_MAX, approvalModeOf, backoffUntil, completionStatus, inReviewMessage, isResumableTask, resumedTaskFields, sentBackTaskFields, type ApprovalMode } from "../registry/task-scheduling.js";
|
|
33
|
+
import { ROLE_DESCRIPTION_MAX, ROLE_EVIDENCE_MAX, normalizeRoleName, roleSourceOf, roleStatusOf, type RoleSource, type RoleStatus } from "../registry/roles.js";
|
|
33
34
|
|
|
34
35
|
export type LocalRegistryStub = {
|
|
35
36
|
origin: string;
|
|
@@ -39,6 +40,13 @@ export type LocalRegistryStub = {
|
|
|
39
40
|
export type LocalRegistryStubOptions = {
|
|
40
41
|
directory: string;
|
|
41
42
|
port?: number;
|
|
43
|
+
/**
|
|
44
|
+
* The approval mode this stand-in plays for completion review. It approves
|
|
45
|
+
* every task on creation, as "extreme" does, and by default reviews none;
|
|
46
|
+
* pass (or set BUGMOLE_APPROVAL to) manual or auto to have finished tasks
|
|
47
|
+
* wait in review the way the real registry does.
|
|
48
|
+
*/
|
|
49
|
+
reviewMode?: ApprovalMode;
|
|
42
50
|
};
|
|
43
51
|
|
|
44
52
|
type PlanDraft = {
|
|
@@ -112,6 +120,26 @@ type AgentTask = {
|
|
|
112
120
|
requestedAt: string;
|
|
113
121
|
startedAt?: string | null;
|
|
114
122
|
completedAt?: string | null;
|
|
123
|
+
approvedAt?: string | null;
|
|
124
|
+
approvedBy?: string | null;
|
|
125
|
+
reviewedAt?: string | null;
|
|
126
|
+
reviewedBy?: string | null;
|
|
127
|
+
reviewNote?: string | null;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
type Role = {
|
|
131
|
+
id: string;
|
|
132
|
+
projectId: string;
|
|
133
|
+
name: string;
|
|
134
|
+
description: string;
|
|
135
|
+
status: RoleStatus;
|
|
136
|
+
source: RoleSource;
|
|
137
|
+
evidence: string | null;
|
|
138
|
+
suggestedBy: string;
|
|
139
|
+
decidedBy: string | null;
|
|
140
|
+
decidedAt: string | null;
|
|
141
|
+
createdAt: string;
|
|
142
|
+
updatedAt: string;
|
|
115
143
|
};
|
|
116
144
|
|
|
117
145
|
type JourneyRevision = {
|
|
@@ -196,6 +224,7 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
|
|
|
196
224
|
localDirectory: options.directory,
|
|
197
225
|
} satisfies ObjectStoreConfig);
|
|
198
226
|
|
|
227
|
+
const reviewMode: ApprovalMode = options.reviewMode ?? approvalModeOf(process.env.BUGMOLE_APPROVAL?.trim()) ?? "extreme";
|
|
199
228
|
const server = http.createServer(async (request, response) => {
|
|
200
229
|
try {
|
|
201
230
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
@@ -210,6 +239,58 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
|
|
|
210
239
|
return;
|
|
211
240
|
}
|
|
212
241
|
|
|
242
|
+
// Proposals: the stand-in has no people to review them, so each one is
|
|
243
|
+
// accepted on arrival (like "extreme" mode) and left for the proposing
|
|
244
|
+
// CLI or worker to apply to this project's own storage.
|
|
245
|
+
const proposalsMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/proposals$/);
|
|
246
|
+
if (proposalsMatch) {
|
|
247
|
+
const projectId = decodeURIComponent(proposalsMatch[1]);
|
|
248
|
+
const proposals = await loadCollection<Record<string, unknown>>(store, projectId, "proposals");
|
|
249
|
+
if (method === "GET") {
|
|
250
|
+
const unapplied = url.searchParams.get("unapplied") === "1";
|
|
251
|
+
writeJson(response, 200, { proposals: proposals.filter((item) => !unapplied || (item.status === "accepted" && !item.appliedAt)) });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (method === "POST") {
|
|
255
|
+
const body = await readJsonBody(request) ?? {};
|
|
256
|
+
if (!["add", "change", "retire"].includes(String(body.action)) || typeof body.caseSetId !== "string" || typeof body.caseId !== "string" || typeof body.summary !== "string" || !body.summary.trim()) {
|
|
257
|
+
writeJson(response, 422, { error: "caseSetId, caseId, action and summary are required" });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const proposal = {
|
|
261
|
+
id: genId("prop"), projectId, kind: "test_case", caseSetId: body.caseSetId, caseId: body.caseId, action: body.action,
|
|
262
|
+
baseRevision: Number.isInteger(body.baseRevision) ? body.baseRevision : null, proposed: body.case ?? null,
|
|
263
|
+
summary: body.summary, source: body.source ?? "cli", proposedBy: "local-dev-bypass", status: "accepted",
|
|
264
|
+
decidedBy: "auto:local", decisionNote: null, appliedAt: null, appliedRevision: null, createdAt: new Date().toISOString(),
|
|
265
|
+
};
|
|
266
|
+
proposals.push(proposal);
|
|
267
|
+
await saveCollection(store, projectId, "proposals", proposals);
|
|
268
|
+
writeJson(response, 201, { proposal });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const proposalAppliedMatch = url.pathname.match(/^\/api\/proposals\/([^/]+)\/applied$/);
|
|
273
|
+
if (proposalAppliedMatch && method === "POST") {
|
|
274
|
+
const proposalId = decodeURIComponent(proposalAppliedMatch[1]);
|
|
275
|
+
const projectIds = [...new Set((await store.list("projects/"))
|
|
276
|
+
.map((object) => object.key.match(/^projects\/([^/]+)\/local-registry\/proposals\.json$/)?.[1])
|
|
277
|
+
.filter((value): value is string => Boolean(value)))].map(decodeURIComponent);
|
|
278
|
+
for (const projectId of projectIds) {
|
|
279
|
+
const proposals = await loadCollection<Record<string, unknown>>(store, projectId, "proposals");
|
|
280
|
+
const index = proposals.findIndex((item) => item.id === proposalId);
|
|
281
|
+
if (index < 0) continue;
|
|
282
|
+
const body = await readJsonBody(request) ?? {};
|
|
283
|
+
proposals[index] = body.outcome === "outdated"
|
|
284
|
+
? { ...proposals[index], status: "outdated", decisionNote: body.message ?? null }
|
|
285
|
+
: { ...proposals[index], appliedAt: new Date().toISOString(), appliedRevision: body.revision ?? null };
|
|
286
|
+
await saveCollection(store, projectId, "proposals", proposals);
|
|
287
|
+
writeJson(response, 200, { proposal: proposals[index] });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
writeJson(response, 404, { error: "Proposal not found" });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
213
294
|
const plansMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/plans$/);
|
|
214
295
|
if (plansMatch) {
|
|
215
296
|
const projectId = decodeURIComponent(plansMatch[1]);
|
|
@@ -248,6 +329,111 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
|
|
|
248
329
|
}
|
|
249
330
|
}
|
|
250
331
|
|
|
332
|
+
// Roles: same contract as src/registry-worker/roles/routes.ts. The
|
|
333
|
+
// stand-in has one caller, the local developer, so a `source` of
|
|
334
|
+
// "person" (the dashboard's add form) confirms and anything else is a
|
|
335
|
+
// suggestion that waits for a decision.
|
|
336
|
+
const rolesMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/roles$/);
|
|
337
|
+
if (rolesMatch) {
|
|
338
|
+
const projectId = decodeURIComponent(rolesMatch[1]);
|
|
339
|
+
const roles = await loadCollection<Role>(store, projectId, "roles");
|
|
340
|
+
if (method === "GET") {
|
|
341
|
+
const status = roleStatusOf(url.searchParams.get("status"));
|
|
342
|
+
const order = { suggested: 0, confirmed: 1, rejected: 2 };
|
|
343
|
+
writeJson(response, 200, {
|
|
344
|
+
roles: roles.filter((role) => !status || role.status === status)
|
|
345
|
+
.sort((a, b) => order[a.status] - order[b.status] || a.name.localeCompare(b.name)),
|
|
346
|
+
});
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (method === "POST") {
|
|
350
|
+
const body = await readJsonBody(request);
|
|
351
|
+
const name = normalizeRoleName(body?.name);
|
|
352
|
+
if (!name) {
|
|
353
|
+
writeJson(response, 422, { error: "name is required: letters, numbers and underscores, up to 60 characters" });
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const source = roleSourceOf(body?.source) ?? "agent";
|
|
357
|
+
const status: RoleStatus = source === "person" ? "confirmed" : "suggested";
|
|
358
|
+
const description = typeof body?.description === "string" ? body.description.trim().slice(0, ROLE_DESCRIPTION_MAX) : "";
|
|
359
|
+
const evidence = typeof body?.evidence === "string" && body.evidence.trim() ? body.evidence.trim().slice(0, ROLE_EVIDENCE_MAX) : null;
|
|
360
|
+
if (status === "suggested" && !evidence) {
|
|
361
|
+
writeJson(response, 422, { error: "Say what points at this role in evidence (a sign-in wall, a gated route or menu, a spec entry)" });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const now = new Date().toISOString();
|
|
365
|
+
const existing = roles.find((role) => role.name === name);
|
|
366
|
+
if (existing) {
|
|
367
|
+
if (existing.status === "suggested" && status === "suggested") {
|
|
368
|
+
existing.evidence = evidence;
|
|
369
|
+
existing.updatedAt = now;
|
|
370
|
+
} else if (existing.status !== "confirmed" && status === "confirmed") {
|
|
371
|
+
Object.assign(existing, { status: "confirmed", decidedBy: "local-dev-bypass", decidedAt: now, updatedAt: now });
|
|
372
|
+
}
|
|
373
|
+
await saveCollection(store, projectId, "roles", roles);
|
|
374
|
+
writeJson(response, 200, { role: existing, alreadyDecided: existing.status !== "suggested" && status === "suggested" });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (!description) {
|
|
378
|
+
writeJson(response, 422, { error: `description is required, up to ${ROLE_DESCRIPTION_MAX} characters` });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const role: Role = {
|
|
382
|
+
id: genId("role"), projectId, name, description, status, source, evidence, suggestedBy: "local-dev-bypass",
|
|
383
|
+
decidedBy: status === "confirmed" ? "local-dev-bypass" : null, decidedAt: status === "confirmed" ? now : null,
|
|
384
|
+
createdAt: now, updatedAt: now,
|
|
385
|
+
};
|
|
386
|
+
roles.push(role);
|
|
387
|
+
await saveCollection(store, projectId, "roles", roles);
|
|
388
|
+
writeJson(response, 201, { role });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const roleMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/roles\/([^/]+)$/);
|
|
393
|
+
if (roleMatch && (method === "PATCH" || method === "DELETE")) {
|
|
394
|
+
const projectId = decodeURIComponent(roleMatch[1]);
|
|
395
|
+
const roleId = decodeURIComponent(roleMatch[2]);
|
|
396
|
+
const roles = await loadCollection<Role>(store, projectId, "roles");
|
|
397
|
+
const index = roles.findIndex((role) => role.id === roleId);
|
|
398
|
+
if (index < 0) {
|
|
399
|
+
writeJson(response, 404, { error: "Not found" });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (method === "DELETE") {
|
|
403
|
+
roles.splice(index, 1);
|
|
404
|
+
await saveCollection(store, projectId, "roles", roles);
|
|
405
|
+
writeJson(response, 200, { deleted: true });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const body = await readJsonBody(request);
|
|
409
|
+
const action = body?.action === "confirm" || body?.action === "reject" ? body.action : body?.action === undefined || body?.action === "edit" ? "edit" : null;
|
|
410
|
+
if (!action) {
|
|
411
|
+
writeJson(response, 422, { error: "action must be confirm, reject or edit" });
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const name = body?.name !== undefined ? normalizeRoleName(body.name) : roles[index].name;
|
|
415
|
+
if (!name) {
|
|
416
|
+
writeJson(response, 422, { error: "name must be letters, numbers and underscores, up to 60 characters" });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (roles.some((role, other) => other !== index && role.name === name)) {
|
|
420
|
+
writeJson(response, 409, { error: "A role with that name already exists" });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const now = new Date().toISOString();
|
|
424
|
+
roles[index] = {
|
|
425
|
+
...roles[index],
|
|
426
|
+
name,
|
|
427
|
+
description: typeof body?.description === "string" && body.description.trim() ? body.description.trim().slice(0, ROLE_DESCRIPTION_MAX) : roles[index].description,
|
|
428
|
+
status: action === "confirm" ? "confirmed" : action === "reject" ? "rejected" : roles[index].status,
|
|
429
|
+
...(action !== "edit" ? { decidedBy: "local-dev-bypass", decidedAt: now } : {}),
|
|
430
|
+
updatedAt: now,
|
|
431
|
+
};
|
|
432
|
+
await saveCollection(store, projectId, "roles", roles);
|
|
433
|
+
writeJson(response, 200, { role: roles[index] });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
251
437
|
const personasMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/personas$/);
|
|
252
438
|
if (personasMatch) {
|
|
253
439
|
const projectId = decodeURIComponent(personasMatch[1]);
|
|
@@ -522,6 +708,10 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
|
|
|
522
708
|
requestedAt: new Date().toISOString(),
|
|
523
709
|
startedAt: null,
|
|
524
710
|
completedAt: null,
|
|
711
|
+
// The stand-in has no people to approve anything: every task
|
|
712
|
+
// it holds is approved on creation, as in "extreme" mode.
|
|
713
|
+
approvedAt: new Date().toISOString(),
|
|
714
|
+
approvedBy: "auto:local",
|
|
525
715
|
};
|
|
526
716
|
await saveCollection(store, projectId, "tasks", tasks);
|
|
527
717
|
writeJson(response, 200, { task: tasks[existingIndex] });
|
|
@@ -543,6 +733,8 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
|
|
|
543
733
|
attempts: 0,
|
|
544
734
|
nextAttemptAt: null,
|
|
545
735
|
requestedAt: new Date().toISOString(),
|
|
736
|
+
approvedAt: new Date().toISOString(),
|
|
737
|
+
approvedBy: "auto:local",
|
|
546
738
|
};
|
|
547
739
|
tasks.push(task);
|
|
548
740
|
await saveCollection(store, projectId, "tasks", tasks);
|
|
@@ -641,7 +833,9 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
|
|
|
641
833
|
writeJson(response, 409, {
|
|
642
834
|
error: tasks[index].status === "completed"
|
|
643
835
|
? "Task is already finished"
|
|
644
|
-
:
|
|
836
|
+
: tasks[index].status === IN_REVIEW
|
|
837
|
+
? "Task is waiting for review; accept it or send it back"
|
|
838
|
+
: "Task is still held by a live worker",
|
|
645
839
|
});
|
|
646
840
|
return;
|
|
647
841
|
}
|
|
@@ -659,6 +853,45 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
|
|
|
659
853
|
return;
|
|
660
854
|
}
|
|
661
855
|
|
|
856
|
+
// Completion review, as in the registry's acceptAgentTask/sendBackAgentTask.
|
|
857
|
+
const taskReviewMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/(accept|send-back)$/);
|
|
858
|
+
if (taskReviewMatch && method === "POST") {
|
|
859
|
+
const found = await findTaskAnyProject(store, decodeURIComponent(taskReviewMatch[1]));
|
|
860
|
+
if (!found) {
|
|
861
|
+
writeJson(response, 404, { error: "Task not found" });
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const { projectId, tasks, index } = found;
|
|
865
|
+
if (tasks[index].status !== IN_REVIEW) {
|
|
866
|
+
writeJson(response, 409, { error: "Only a task in review can be accepted or sent back" });
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
const body = await readJsonBody(request);
|
|
870
|
+
const note = typeof body?.note === "string" ? body.note.trim().slice(0, REVIEW_NOTE_MAX) : "";
|
|
871
|
+
const now = new Date().toISOString();
|
|
872
|
+
if (taskReviewMatch[2] === "accept") {
|
|
873
|
+
tasks[index] = { ...tasks[index], status: "completed", completedAt: now, reviewedAt: now, reviewedBy: "local-dev-bypass", reviewNote: note || null };
|
|
874
|
+
await saveCollection(store, projectId, "tasks", tasks);
|
|
875
|
+
writeJson(response, 200, { accepted: true, task: tasks[index] });
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (!note) {
|
|
879
|
+
writeJson(response, 422, { error: "Say what needs to change in note" });
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
tasks[index] = {
|
|
883
|
+
...tasks[index],
|
|
884
|
+
...sentBackTaskFields(note),
|
|
885
|
+
errorMessage: null,
|
|
886
|
+
completedAt: null,
|
|
887
|
+
reviewedAt: now,
|
|
888
|
+
reviewedBy: "local-dev-bypass",
|
|
889
|
+
};
|
|
890
|
+
await saveCollection(store, projectId, "tasks", tasks);
|
|
891
|
+
writeJson(response, 200, { sentBack: true, task: tasks[index] });
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
662
895
|
const taskUpdateMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)$/);
|
|
663
896
|
if (taskUpdateMatch && method === "POST") {
|
|
664
897
|
const taskId = decodeURIComponent(taskUpdateMatch[1]);
|
|
@@ -669,15 +902,26 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
|
|
|
669
902
|
}
|
|
670
903
|
const { projectId, tasks, index } = found;
|
|
671
904
|
const body = await readJsonBody(request);
|
|
672
|
-
const
|
|
673
|
-
if (!["running", "blocked", "completed", "error"].includes(
|
|
905
|
+
const reported = typeof body?.status === "string" ? body.status : "running";
|
|
906
|
+
if (!["running", "blocked", "completed", "error"].includes(reported)) {
|
|
674
907
|
writeJson(response, 422, { error: "Invalid task status" });
|
|
675
908
|
return;
|
|
676
909
|
}
|
|
677
|
-
if (
|
|
910
|
+
if (reported === "completed" && tasks[index].taskType === "ui" && (!body?.parityAuditId || typeof body?.parityMatchScore !== "number")) {
|
|
678
911
|
writeJson(response, 422, { error: "UI tasks require parityAuditId and parityMatchScore before completion" });
|
|
679
912
|
return;
|
|
680
913
|
}
|
|
914
|
+
// See registry-worker's updateAgentTask: a task in review only
|
|
915
|
+
// answers a repeated "completed", and the mode decides review.
|
|
916
|
+
if (tasks[index].status === IN_REVIEW) {
|
|
917
|
+
if (reported !== "completed") {
|
|
918
|
+
writeJson(response, 409, { error: "Task is waiting for review" });
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
writeJson(response, 200, { updated: false, inReview: true, message: "Task is already waiting for review.", task: tasks[index] });
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const status = reported === "completed" ? completionStatus(reviewMode, tasks[index].taskType) : reported;
|
|
681
925
|
const terminal = ["completed", "error"].includes(status);
|
|
682
926
|
tasks[index] = {
|
|
683
927
|
...tasks[index],
|
|
@@ -694,11 +938,15 @@ export async function startLocalRegistryStub(options: LocalRegistryStubOptions):
|
|
|
694
938
|
nextAttemptAt: status === "blocked"
|
|
695
939
|
? backoffUntil(tasks[index].attempts ?? 0)
|
|
696
940
|
: tasks[index].nextAttemptAt ?? null,
|
|
697
|
-
leaseExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
941
|
+
leaseExpiresAt: status === IN_REVIEW ? null : new Date(Date.now() + 60_000).toISOString(),
|
|
698
942
|
completedAt: terminal ? new Date().toISOString() : tasks[index].completedAt,
|
|
699
943
|
};
|
|
700
944
|
await saveCollection(store, projectId, "tasks", tasks);
|
|
701
|
-
writeJson(response, 200, {
|
|
945
|
+
writeJson(response, 200, {
|
|
946
|
+
updated: true,
|
|
947
|
+
...(status === IN_REVIEW ? { inReview: true, message: inReviewMessage(reviewMode) } : {}),
|
|
948
|
+
task: tasks[index],
|
|
949
|
+
});
|
|
702
950
|
return;
|
|
703
951
|
}
|
|
704
952
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// This machine's secure store for flow secrets: an encrypted vault file whose
|
|
2
|
+
// one key is kept in the OS credential store (macOS Keychain, Windows
|
|
3
|
+
// Credential Manager, Linux Secret Service). It is the open-source
|
|
4
|
+
// @theaiinc/arcana-vault package, in Bugmole's own namespace, so nothing else
|
|
5
|
+
// on the machine shares it. Where that package isn't installed (an
|
|
6
|
+
// unsupported platform), secret-sources.ts falls back to the OS keychain.
|
|
7
|
+
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
export type LocalVaultLike = {
|
|
12
|
+
get(name: string): string | null;
|
|
13
|
+
set(name: string, value: string): void;
|
|
14
|
+
delete(name: string): boolean;
|
|
15
|
+
has(name: string): boolean;
|
|
16
|
+
list(): string[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** The credential-store service holding Bugmole's vault key. */
|
|
20
|
+
export const VAULT_SERVICE = "com.bugmole.cli";
|
|
21
|
+
|
|
22
|
+
/** Where Bugmole keeps its data on this machine (BUGMOLE_DATA_DIR overrides it). */
|
|
23
|
+
export function bugmoleDataDir(env: Record<string, string | undefined> = process.env, platform: string = process.platform): string {
|
|
24
|
+
if (env.BUGMOLE_DATA_DIR) return env.BUGMOLE_DATA_DIR;
|
|
25
|
+
const home = env.HOME ?? env.USERPROFILE ?? os.homedir();
|
|
26
|
+
if (platform === "darwin") return path.join(home, "Library", "Application Support", "Bugmole");
|
|
27
|
+
if (platform === "win32") return path.join(env.APPDATA ?? path.join(home, "AppData", "Roaming"), "Bugmole");
|
|
28
|
+
return path.join(env.XDG_DATA_HOME ?? path.join(home, ".local", "share"), "bugmole");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let opened: Promise<LocalVaultLike | null> | undefined;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Opens Bugmole's vault, or null when the vault package isn't installed here.
|
|
35
|
+
* BUGMOLE_VAULT_MODULE points at another build of the package (development).
|
|
36
|
+
*/
|
|
37
|
+
export function openLocalVault(env: Record<string, string | undefined> = process.env): Promise<LocalVaultLike | null> {
|
|
38
|
+
opened ??= (async () => {
|
|
39
|
+
// A variable specifier: the package is optional, so it isn't resolved at build time.
|
|
40
|
+
const specifier = env.BUGMOLE_VAULT_MODULE || "@theaiinc/arcana-vault";
|
|
41
|
+
let module: { LocalVault?: { open(namespace: { service: string; dir: string }): LocalVaultLike } };
|
|
42
|
+
try {
|
|
43
|
+
module = await import(specifier);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
46
|
+
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || /Cannot find (module|native binding)/i.test(String(error))) return null;
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
const LocalVault = module.LocalVault ?? (module as { default?: { LocalVault?: typeof module.LocalVault } }).default?.LocalVault;
|
|
50
|
+
if (!LocalVault) return null;
|
|
51
|
+
try {
|
|
52
|
+
return LocalVault.open({ service: VAULT_SERVICE, dir: path.join(bugmoleDataDir(env), "vault") });
|
|
53
|
+
} catch {
|
|
54
|
+
// No credential store to keep the vault key in (a Linux machine with
|
|
55
|
+
// no Secret Service session, say): fall back as if it weren't installed.
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
})();
|
|
59
|
+
return opened;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** For tests: forget the opened vault. */
|
|
63
|
+
export function resetLocalVault(): void {
|
|
64
|
+
opened = undefined;
|
|
65
|
+
}
|
|
@@ -64,6 +64,29 @@ test("an actor missing from roles.yaml is reconciled instead of dead-ending in t
|
|
|
64
64
|
assert.match(state.next!.description, /user/);
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
test("a flow whose role is only suggested waits for a person instead of re-queueing the roles stage", () => {
|
|
68
|
+
const state = derivePipelineState(inputs({ knownActors: ["owner"], suggestedActors: ["user"] }));
|
|
69
|
+
assert.equal(state.status, "paused");
|
|
70
|
+
assert.equal(state.next, undefined);
|
|
71
|
+
assert.match(state.reason, /confirm or reject the suggested role user/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("the roles stage asks an agent to suggest, never to confirm", () => {
|
|
75
|
+
const state = derivePipelineState(inputs({ knownActors: ["owner"] }));
|
|
76
|
+
assert.match(state.next!.description, /bugmole_role_propose/);
|
|
77
|
+
assert.match(state.next!.description, /cannot confirm a role yourself/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a task waiting for review pauses the pipeline until someone accepts or sends it back", () => {
|
|
81
|
+
const state = derivePipelineState(inputs({
|
|
82
|
+
tasks: [{ status: "in_review", title: "Fix the checkout button", idempotencyKey: "pipeline:bugmole:audit:flow:login" }],
|
|
83
|
+
}));
|
|
84
|
+
assert.equal(state.status, "paused");
|
|
85
|
+
assert.equal(state.next, undefined);
|
|
86
|
+
assert.match(state.reason, /waiting for your review/);
|
|
87
|
+
assert.match(state.reason, /Fix the checkout button/);
|
|
88
|
+
});
|
|
89
|
+
|
|
67
90
|
test("once cases exist the flow gets planned", () => {
|
|
68
91
|
const state = derivePipelineState(inputs({
|
|
69
92
|
auditedJourneyIds: ["flow:login"],
|
package/src/runtime/pipeline.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
-
? `
|
|
241
|
-
: "every flow actor is
|
|
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: "
|
|
473
|
+
title: "Suggest the roles the discovered flows use",
|
|
442
474
|
description: [
|
|
443
|
-
`The discovered flows use actors that
|
|
444
|
-
"Planning
|
|
445
|
-
"Inspect the application's real roles
|
|
446
|
-
"
|
|
447
|
-
"
|
|
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
|
-
/**
|
|
525
|
-
export function knownActors(specDir: string): string[] {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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 =
|
|
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
|
+
});
|