@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.
- package/package.json +5 -2
- 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 +233 -46
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -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/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 +22 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- 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 +9 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- 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 +477 -174
- 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.ts +4 -4
- 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 +21 -0
- package/src/registry-worker/runner/tokens.ts +8 -0
- 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/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/appium-driver.ts +1 -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 +67 -6
- 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 +22 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +39 -2
- package/src/runtime/journey-graph.test.ts +18 -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 +21 -2
- package/src/runtime/playwright-driver.ts +49 -7
- 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-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 +47 -4
- package/src/runtime/site-discovery.ts +79 -9
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.ts +5 -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
|
@@ -82,6 +82,15 @@ function idPart(value: string): string {
|
|
|
82
82
|
return value.trim().replace(/[^a-zA-Z0-9._:-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Step and transition IDs keep the route in them (screen:/settings,
|
|
87
|
+
* screen:/->screen:/settings:dom-3), as exploration writes them; rewriting
|
|
88
|
+
* the slashes made the agent's next call name a step that didn't exist.
|
|
89
|
+
*/
|
|
90
|
+
function graphIdPart(value: string): string {
|
|
91
|
+
return value.trim().replace(/[^a-zA-Z0-9._:/>\[\]-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
92
|
+
}
|
|
93
|
+
|
|
85
94
|
function requireJourney(graph: JourneyGraph, journeyId: string): UserJourney {
|
|
86
95
|
const journey = graph.journeys.find((item) => item.id === journeyId);
|
|
87
96
|
if (!journey) throw new Error(`Journey not found: ${journeyId}`);
|
|
@@ -191,7 +200,7 @@ export async function addJourneyStep(
|
|
|
191
200
|
) {
|
|
192
201
|
const graph = readJourneyGraph(cfg);
|
|
193
202
|
const journey = requireJourney(graph, input.journeyId);
|
|
194
|
-
const nodeId =
|
|
203
|
+
const nodeId = graphIdPart(input.node.id || `step-${Date.now()}`);
|
|
195
204
|
if (!nodeId || graph.nodes.some((node) => node.id === nodeId)) throw new Error(`Node already exists: ${nodeId}`);
|
|
196
205
|
const node = { ...input.node, id: nodeId } as JourneyNode;
|
|
197
206
|
graph.nodes.push(node);
|
|
@@ -241,7 +250,7 @@ export async function addJourneyTransition(
|
|
|
241
250
|
if (!graph.nodes.some((node) => node.id === input.from) || !graph.nodes.some((node) => node.id === input.to)) {
|
|
242
251
|
throw new Error("Transition endpoints must reference existing nodes");
|
|
243
252
|
}
|
|
244
|
-
const edgeId =
|
|
253
|
+
const edgeId = graphIdPart(input.edge?.id || `${input.from}->${input.to}:${Date.now()}`);
|
|
245
254
|
if (graph.edges.some((edge) => edge.id === edgeId)) throw new Error(`Edge already exists: ${edgeId}`);
|
|
246
255
|
const edge: JourneyEdge = { ...input.edge, id: edgeId, from: input.from, to: input.to };
|
|
247
256
|
graph.edges.push(edge);
|
|
@@ -301,6 +310,34 @@ export async function removeJourneyItem(
|
|
|
301
310
|
export function nextJourneyTask(cfg: JourneyConfig, journeyId: string): JourneyNextTask {
|
|
302
311
|
const graph = readJourneyGraph(cfg);
|
|
303
312
|
const journey = requireJourney(graph, journeyId);
|
|
313
|
+
// An empty journey, or steps nothing connects, is not finished: there is
|
|
314
|
+
// nothing to plan from yet.
|
|
315
|
+
if (!journey.nodeIds.length) {
|
|
316
|
+
return {
|
|
317
|
+
status: "actionable",
|
|
318
|
+
journeyId,
|
|
319
|
+
reason: "The journey has no steps yet.",
|
|
320
|
+
action: "Add the screen the journey starts on",
|
|
321
|
+
nextSteps: [
|
|
322
|
+
"Open the app where this journey starts and record that screen with bugmole_journey_add_step",
|
|
323
|
+
"Call bugmole_journey_next again after recording it",
|
|
324
|
+
],
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
if (journey.nodeIds.length > 1 && !journey.edgeIds.length) {
|
|
328
|
+
return {
|
|
329
|
+
status: "actionable",
|
|
330
|
+
journeyId,
|
|
331
|
+
reason: "The journey's steps aren't connected: no transition says how a person gets from one to the next.",
|
|
332
|
+
nodeId: journey.nodeIds[0],
|
|
333
|
+
route: graph.nodes.find((node) => node.id === journey.nodeIds[0])?.route,
|
|
334
|
+
action: `Connect ${journey.nodeIds[0]} to the next step`,
|
|
335
|
+
nextSteps: [
|
|
336
|
+
"Perform the action that leads from each step to the next and record it with bugmole_journey_add_transition",
|
|
337
|
+
"Call bugmole_journey_next again after recording it",
|
|
338
|
+
],
|
|
339
|
+
};
|
|
340
|
+
}
|
|
304
341
|
const included = new Set(journey.nodeIds);
|
|
305
342
|
const includedEdges = new Set(journey.edgeIds);
|
|
306
343
|
const coveredTransitions = new Set(
|
|
@@ -297,3 +297,21 @@ test("a passing run promotes the explored draft it proved, from the stored graph
|
|
|
297
297
|
fs.rmSync(root, { recursive: true, force: true });
|
|
298
298
|
}
|
|
299
299
|
});
|
|
300
|
+
|
|
301
|
+
test("route-style step IDs are kept, and an empty or unconnected journey is not exhausted", async () => {
|
|
302
|
+
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "bugmole-flow-ids-"));
|
|
303
|
+
const cfg = {
|
|
304
|
+
project: { id: "demo" },
|
|
305
|
+
runtime: { spec_dir: path.join(root, "spec"), artifacts_dir: path.join(root, "artifacts") },
|
|
306
|
+
storage: { provider: "local", localDirectory: path.join(root, "artifacts") },
|
|
307
|
+
};
|
|
308
|
+
await createJourney(cfg, { id: "sign-up", name: "Sign up", actor: "guest" });
|
|
309
|
+
assert.equal(nextJourneyTask(cfg, "sign-up").status, "actionable");
|
|
310
|
+
const home = await addJourneyStep(cfg, { journeyId: "sign-up", node: { id: "screen:/", title: "Home", kind: "screen", route: "/" } });
|
|
311
|
+
const signup = await addJourneyStep(cfg, { journeyId: "sign-up", node: { id: "screen:/signup", title: "Sign up", kind: "screen", route: "/signup" } });
|
|
312
|
+
assert.deepEqual(signup.journey.nodeIds, ["screen:/", "screen:/signup"]);
|
|
313
|
+
assert.ok(home);
|
|
314
|
+
assert.match(nextJourneyTask(cfg, "sign-up").reason, /aren't connected/);
|
|
315
|
+
await addJourneyTransition(cfg, { journeyId: "sign-up", from: "screen:/", to: "screen:/signup" });
|
|
316
|
+
assert.match(readJourneyGraph(cfg).edges[0].id, /^screen:\/->screen:\/signup:/);
|
|
317
|
+
});
|
|
@@ -323,3 +323,67 @@ test("personas: adding, renaming, and removing, with at least one always kept",
|
|
|
323
323
|
assert.equal(removeLast.status, 409, "the last persona in a project can't be removed");
|
|
324
324
|
});
|
|
325
325
|
});
|
|
326
|
+
|
|
327
|
+
test("completion review mirrors the registry: in review, then accepted or sent back with a note", async () => {
|
|
328
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-local-registry-"));
|
|
329
|
+
const stub = await startLocalRegistryStub({ directory, reviewMode: "auto" });
|
|
330
|
+
const origin = stub.origin;
|
|
331
|
+
try {
|
|
332
|
+
const create = async (taskType: string, key: string) => {
|
|
333
|
+
const created = await postJson(origin, "/api/projects/lumis/tasks", { title: "t", description: "d", taskType, idempotencyKey: key });
|
|
334
|
+
const id = (created.body as { task: { id: string } }).task.id;
|
|
335
|
+
await postJson(origin, `/api/tasks/${id}/claim`, { workerId: "w" });
|
|
336
|
+
return id;
|
|
337
|
+
};
|
|
338
|
+
const investigation = await create("investigation", "a");
|
|
339
|
+
const done = await postJson(origin, `/api/tasks/${investigation}`, { workerId: "w", status: "completed" });
|
|
340
|
+
assert.equal((done.body as { task: { status: string } }).task.status, "completed", "auto doesn't review an investigation");
|
|
341
|
+
|
|
342
|
+
const code = await create("code", "b");
|
|
343
|
+
const review = await postJson(origin, `/api/tasks/${code}`, { workerId: "w", status: "completed" });
|
|
344
|
+
const reviewed = review.body as { inReview: boolean; message: string; task: { status: string } };
|
|
345
|
+
assert.equal(reviewed.task.status, "in_review");
|
|
346
|
+
assert.equal(reviewed.inReview, true);
|
|
347
|
+
assert.equal((await postJson(origin, `/api/tasks/${code}`, { workerId: "w", status: "running" })).status, 409);
|
|
348
|
+
assert.equal((await postJson(origin, `/api/tasks/${code}/resume`, {})).status, 409);
|
|
349
|
+
|
|
350
|
+
assert.equal((await postJson(origin, `/api/tasks/${code}/send-back`, {})).status, 422, "sending back needs a note");
|
|
351
|
+
const sent = await postJson(origin, `/api/tasks/${code}/send-back`, { note: "Also cover the empty cart" });
|
|
352
|
+
const sentTask = (sent.body as { task: { status: string; reviewNote: string; attempts: number } }).task;
|
|
353
|
+
assert.equal(sentTask.status, "queued");
|
|
354
|
+
assert.equal(sentTask.reviewNote, "Also cover the empty cart");
|
|
355
|
+
assert.equal(sentTask.attempts, 0);
|
|
356
|
+
|
|
357
|
+
await postJson(origin, `/api/tasks/${code}/claim`, { workerId: "w" });
|
|
358
|
+
await postJson(origin, `/api/tasks/${code}`, { workerId: "w", status: "completed" });
|
|
359
|
+
const accepted = await postJson(origin, `/api/tasks/${code}/accept`, {});
|
|
360
|
+
assert.equal((accepted.body as { task: { status: string; reviewedBy: string } }).task.status, "completed");
|
|
361
|
+
assert.equal((await postJson(origin, `/api/tasks/${code}/accept`, {})).status, 409);
|
|
362
|
+
} finally {
|
|
363
|
+
await stub.close();
|
|
364
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("roles: suggestions wait for a decision; the dashboard's add form confirms", async () => {
|
|
369
|
+
await withStub(async (origin) => {
|
|
370
|
+
const suggested = await postJson(origin, "/api/projects/lumis/roles", { name: "Admin", description: "Admin area.", source: "role_gated_route", evidence: "route /admin" });
|
|
371
|
+
assert.equal(suggested.status, 201);
|
|
372
|
+
const role = (suggested.body as { role: { id: string; name: string; status: string } }).role;
|
|
373
|
+
assert.equal(role.name, "admin");
|
|
374
|
+
assert.equal(role.status, "suggested");
|
|
375
|
+
assert.equal((await postJson(origin, "/api/projects/lumis/roles", { name: "x", description: "x" })).status, 422, "a suggestion needs evidence");
|
|
376
|
+
|
|
377
|
+
const rejected = await fetch(`${origin}/api/projects/lumis/roles/${role.id}`, {
|
|
378
|
+
method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "reject" }),
|
|
379
|
+
});
|
|
380
|
+
assert.equal(((await rejected.json()) as { role: { status: string } }).role.status, "rejected");
|
|
381
|
+
const again = await postJson(origin, "/api/projects/lumis/roles", { name: "admin", description: "Admin area.", source: "role_gated_menu", evidence: "menu Admin" });
|
|
382
|
+
assert.equal((again.body as { role: { status: string } }).role.status, "rejected", "a rejected role stays rejected");
|
|
383
|
+
|
|
384
|
+
const added = await postJson(origin, "/api/projects/lumis/roles", { name: "guest", description: "Not signed in.", source: "person" });
|
|
385
|
+
assert.equal((added.body as { role: { status: string } }).role.status, "confirmed");
|
|
386
|
+
const listed = await (await fetch(`${origin}/api/projects/lumis/roles?status=confirmed`)).json() as { roles: Array<{ name: string }> };
|
|
387
|
+
assert.deepEqual(listed.roles.map((item) => item.name), ["guest"]);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
@@ -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"],
|