@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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { MAX_TASK_ATTEMPTS, backoffUntil, isResumableTask, resumedTaskFields } from "../registry/task-scheduling.js";
|
|
2
|
-
import { getProjectArtifact, isRunManifestKey, listProjectArtifacts } from "./artifacts.js";
|
|
1
|
+
import { DEFAULT_APPROVAL_MODE, IN_REVIEW, LOCAL_WORKER_SEEN_THROTTLE_MS, MAX_TASK_ATTEMPTS, REVIEW_NOTE_MAX, approvalModeOf, autoApprover, backoffUntil, completionStatus, inReviewMessage, isResumableTask, needsApproval, resumedApproval, resumedTaskFields, resumePlacement, sentBackTaskFields, strandedTaskMessage } from "../registry/task-scheduling.js";
|
|
2
|
+
import { getProjectArtifact, isRunManifestKey, listProjectArtifacts, runIdOfArtifactKey, type ArtifactVisibility } from "./artifacts.js";
|
|
3
3
|
import {
|
|
4
4
|
type Env,
|
|
5
5
|
type Principal,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
projectAllowed,
|
|
15
15
|
memberOfProject,
|
|
16
16
|
memberOfWorkspace,
|
|
17
|
+
projectLocked,
|
|
17
18
|
randomToken,
|
|
18
19
|
base64,
|
|
19
20
|
fromBase64,
|
|
@@ -25,7 +26,7 @@ export type { Env } from "./core.js";
|
|
|
25
26
|
import { detachedContext, handleExtendedRoutes, handlePublicRoutes } from "./routes/index.js";
|
|
26
27
|
import { runScheduledJobs } from "./jobs/index.js";
|
|
27
28
|
import { actorOf, auditProject, recordAudit } from "./org/audit.js";
|
|
28
|
-
import { checkCloudRun, checkProjectLimit, executionModeOf, historyCutoffFor } from "./billing/enforcement.js";
|
|
29
|
+
import { billingLinks, checkCloudRun, checkProjectLimit, executionModeOf, historyCutoffFor, runExpired } from "./billing/enforcement.js";
|
|
29
30
|
import { workspaceOfProject } from "./billing/entitlements.js";
|
|
30
31
|
import { heartbeatChecks, registryDispatch, runChangedHooks, runQueuedHooks, runService, taskChangedHooks, taskQueuedHooks, type InternalRunFields } from "./hooks.js";
|
|
31
32
|
import { appendRunEvent, broadcastProjectEvent } from "./run-events.js";
|
|
@@ -37,6 +38,8 @@ import { cloudTargetId, parseCloudDevice } from "../runtime/device-clouds/target
|
|
|
37
38
|
import type { CloudDevice } from "../runtime/device-clouds/types.js";
|
|
38
39
|
import { cloudAccountError, freeWorkspaceLimit } from "./signup/policy.js";
|
|
39
40
|
import { applyOrgMembership, isDeprovisioned } from "./sso/membership.js";
|
|
41
|
+
import { claimDomainIdentity, identityInUse, identityValues, identityView, invalidIdentity, memberProjectWithIdentity, readIdentity, workspaceProjectWithIdentity, type StoredIdentity } from "./projects/identity.js";
|
|
42
|
+
import { projectInactiveRefusal } from "./projects/inactivity.js";
|
|
40
43
|
import "./features.js";
|
|
41
44
|
|
|
42
45
|
function deviceCode(): string {
|
|
@@ -50,10 +53,14 @@ async function createDeviceAuthorization(request: Request, env: Env): Promise<Re
|
|
|
50
53
|
projectName?: string;
|
|
51
54
|
environment?: string;
|
|
52
55
|
storageProvider?: string;
|
|
56
|
+
/** Which app this is (src/registry/project-identity.ts), computed by the CLI. */
|
|
57
|
+
identity?: unknown;
|
|
53
58
|
} | null;
|
|
54
59
|
if (!body?.projectId || !body.projectName || !body.environment || !body.storageProvider) {
|
|
55
60
|
return response({ error: "projectId, projectName, environment, and storageProvider are required" }, 422);
|
|
56
61
|
}
|
|
62
|
+
const identity = await readIdentity(body.identity);
|
|
63
|
+
if (identity === "invalid") return invalidIdentity();
|
|
57
64
|
const sourceIp = request.headers.get("CF-Connecting-IP") ?? "unknown";
|
|
58
65
|
const recent = await env.DB.prepare(`
|
|
59
66
|
SELECT COUNT(*) AS count FROM device_authorizations
|
|
@@ -66,8 +73,8 @@ async function createDeviceAuthorization(request: Request, env: Env): Promise<Re
|
|
|
66
73
|
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
|
67
74
|
await env.DB.prepare(`
|
|
68
75
|
INSERT INTO device_authorizations
|
|
69
|
-
(attempt_id, code_hash, poll_secret_hash, project_id, project_name, environment, storage_provider, source_ip, expires_at)
|
|
70
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
76
|
+
(attempt_id, code_hash, poll_secret_hash, project_id, project_name, environment, storage_provider, source_ip, expires_at, identity_json)
|
|
77
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
71
78
|
`).bind(
|
|
72
79
|
attemptId,
|
|
73
80
|
await sha256(code),
|
|
@@ -78,6 +85,7 @@ async function createDeviceAuthorization(request: Request, env: Env): Promise<Re
|
|
|
78
85
|
currentStorageProvider(body.storageProvider),
|
|
79
86
|
sourceIp,
|
|
80
87
|
expiresAt,
|
|
88
|
+
identity ? JSON.stringify(identity) : null,
|
|
81
89
|
).run();
|
|
82
90
|
return response({ attemptId, code, pollSecret, expiresAt }, 201);
|
|
83
91
|
}
|
|
@@ -95,6 +103,77 @@ function workspaceNameFromEmail(email: string | null | undefined): string {
|
|
|
95
103
|
return domain.charAt(0).toUpperCase() + domain.slice(1);
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
type DeviceRefusal = { error: string; code: string; upgradeUrl?: string; billingUrl?: string; [key: string]: unknown };
|
|
107
|
+
|
|
108
|
+
type DevicePlacement = {
|
|
109
|
+
workspaceId: string;
|
|
110
|
+
projectId: string;
|
|
111
|
+
/** The app is already a project in a workspace this person works in (a teammate's clone of the same repo). */
|
|
112
|
+
joined: boolean;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Where a CLI-registered project goes, under the same plan checks as creating
|
|
117
|
+
* a workspace or project in the dashboard: one Free workspace per person (a
|
|
118
|
+
* second one is refused, so the project joins the Free workspace they already
|
|
119
|
+
* own) and the plan's project limit for a project that doesn't exist yet.
|
|
120
|
+
* One app is one project: when the app's identity is already a project in a
|
|
121
|
+
* workspace the person belongs to, the machine pairs with that project.
|
|
122
|
+
*/
|
|
123
|
+
async function deviceProjectPlacement(env: Env, principal: Principal, projectId: string, identity: StoredIdentity | null): Promise<DevicePlacement | { refusal: DeviceRefusal; status: number }> {
|
|
124
|
+
if (identity) {
|
|
125
|
+
const joined = await memberProjectWithIdentity(env, principal, identity.hash);
|
|
126
|
+
if (joined) return { workspaceId: joined.workspaceId, projectId: joined.id, joined: true };
|
|
127
|
+
}
|
|
128
|
+
const existingProject = await env.DB.prepare("SELECT workspace_id AS workspaceId FROM projects WHERE id = ? AND deleted_at IS NULL")
|
|
129
|
+
.bind(projectId).first<{ workspaceId: string }>();
|
|
130
|
+
if (existingProject && !(await memberOfProject(env, principal, projectId, "project.manage"))) {
|
|
131
|
+
if (await projectLocked(env, projectId) && await memberOfProject(env, principal, projectId)) {
|
|
132
|
+
return {
|
|
133
|
+
refusal: {
|
|
134
|
+
error: "This project is read-only: the workspace has more projects than its plan includes. Upgrade, or choose which projects stay active in Billing.",
|
|
135
|
+
code: "plan_limit",
|
|
136
|
+
limit: "projects",
|
|
137
|
+
...billingLinks(env, existingProject.workspaceId),
|
|
138
|
+
},
|
|
139
|
+
status: 402,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return { refusal: { error: "A project with this id already exists. Choose another project id.", code: "project_exists" }, status: 409 };
|
|
143
|
+
}
|
|
144
|
+
let workspaceId = `workspace_${principal.aegisId}`;
|
|
145
|
+
const workspace = await env.DB.prepare("SELECT id FROM workspaces WHERE id = ?").bind(workspaceId).first<{ id: string }>();
|
|
146
|
+
if (!workspace) {
|
|
147
|
+
const freeLimit = await freeWorkspaceLimit(env, principal);
|
|
148
|
+
if (freeLimit) {
|
|
149
|
+
const refusal = await freeLimit.clone().json() as DeviceRefusal & { workspaceId?: string };
|
|
150
|
+
if (!refusal.workspaceId || !(await memberOfWorkspace(env, principal, refusal.workspaceId, "workspace.write"))) {
|
|
151
|
+
return { refusal, status: freeLimit.status };
|
|
152
|
+
}
|
|
153
|
+
workspaceId = refusal.workspaceId;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!existingProject) {
|
|
157
|
+
if (identity) {
|
|
158
|
+
const inUse = await identityInUse(env, principal, workspaceId, identity);
|
|
159
|
+
if (inUse) return { refusal: await inUse.clone().json() as DeviceRefusal, status: inUse.status };
|
|
160
|
+
}
|
|
161
|
+
const projectLimit = await checkProjectLimit(env, workspaceId);
|
|
162
|
+
if (projectLimit) return { refusal: await projectLimit.clone().json() as DeviceRefusal, status: projectLimit.status };
|
|
163
|
+
}
|
|
164
|
+
return { workspaceId, projectId, joined: false };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function storedIdentityOf(json: string | null): StoredIdentity | null {
|
|
168
|
+
if (!json) return null;
|
|
169
|
+
try {
|
|
170
|
+
const value = JSON.parse(json) as StoredIdentity;
|
|
171
|
+
return value && typeof value.hash === "string" && typeof value.key === "string" ? value : null;
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
98
177
|
async function claimDeviceAuthorization(request: Request, env: Env, principal: Principal): Promise<Response> {
|
|
99
178
|
const body = await request.json().catch(() => null) as {
|
|
100
179
|
attemptId?: string;
|
|
@@ -108,9 +187,10 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
|
|
|
108
187
|
const attempt = await env.DB.prepare(`
|
|
109
188
|
SELECT attempt_id AS attemptId, code_hash AS codeHash, project_id AS projectId,
|
|
110
189
|
project_name AS projectName, environment, storage_provider AS storageProvider,
|
|
111
|
-
status, failed_attempts AS failedAttempts, expires_at AS expiresAt
|
|
190
|
+
status, failed_attempts AS failedAttempts, expires_at AS expiresAt, identity_json AS identityJson
|
|
112
191
|
FROM device_authorizations WHERE attempt_id = ?
|
|
113
192
|
`).bind(body.attemptId).first<{
|
|
193
|
+
identityJson: string | null;
|
|
114
194
|
attemptId: string;
|
|
115
195
|
codeHash: string;
|
|
116
196
|
projectId: string;
|
|
@@ -133,11 +213,23 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
|
|
|
133
213
|
if (body.deviceId !== attempt.projectId) {
|
|
134
214
|
return response({ error: "Device binding does not match the Bugmole project" }, 403);
|
|
135
215
|
}
|
|
216
|
+
const identity = storedIdentityOf(attempt.identityJson);
|
|
217
|
+
const placement = await deviceProjectPlacement(env, principal, attempt.projectId, identity);
|
|
218
|
+
if ("refusal" in placement) {
|
|
219
|
+
// Recorded on the attempt, so the waiting CLI prints why and where to upgrade.
|
|
220
|
+
await env.DB.prepare(`
|
|
221
|
+
UPDATE device_authorizations SET status = 'refused', refusal_json = ?, aegis_id = ?, claimed_at = CURRENT_TIMESTAMP
|
|
222
|
+
WHERE attempt_id = ? AND status = 'pending'
|
|
223
|
+
`).bind(JSON.stringify(placement.refusal), principal.aegisId, body.attemptId).run();
|
|
224
|
+
return response(placement.refusal, placement.status);
|
|
225
|
+
}
|
|
136
226
|
|
|
227
|
+
const projectId = placement.projectId;
|
|
137
228
|
const claimResult = await env.DB.prepare(`
|
|
138
|
-
UPDATE device_authorizations SET status = 'claimed', aegis_id = ?, aegis_email = ?, claimed_at = CURRENT_TIMESTAMP
|
|
229
|
+
UPDATE device_authorizations SET status = 'claimed', aegis_id = ?, aegis_email = ?, claimed_at = CURRENT_TIMESTAMP,
|
|
230
|
+
resolved_project_id = ?
|
|
139
231
|
WHERE attempt_id = ? AND status = 'pending'
|
|
140
|
-
`).bind(principal.aegisId, body.email?.trim() || null, body.attemptId).run() as { meta?: { changes?: number } };
|
|
232
|
+
`).bind(principal.aegisId, body.email?.trim() || null, projectId === attempt.projectId ? null : projectId, body.attemptId).run() as { meta?: { changes?: number } };
|
|
141
233
|
if (claimResult.meta?.changes !== 1) {
|
|
142
234
|
return response({ error: "Device authorization was claimed by another session" }, 409);
|
|
143
235
|
}
|
|
@@ -149,15 +241,47 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
|
|
|
149
241
|
const existingKey = existingKeys.results.find((key) => {
|
|
150
242
|
try {
|
|
151
243
|
const projectIds = JSON.parse(key.projectIds) as unknown;
|
|
152
|
-
return Array.isArray(projectIds) && projectIds.includes(
|
|
244
|
+
return Array.isArray(projectIds) && projectIds.includes(projectId);
|
|
153
245
|
} catch {
|
|
154
246
|
return false;
|
|
155
247
|
}
|
|
156
248
|
});
|
|
157
249
|
const now = new Date().toISOString();
|
|
158
|
-
const workspaceId =
|
|
250
|
+
const workspaceId = placement.workspaceId;
|
|
159
251
|
const workspaceName = workspaceNameFromEmail(body.email) || (attempt.projectName ? `${attempt.projectName} workspace` : "My workspace");
|
|
160
252
|
const environmentId = attempt.environment.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "default";
|
|
253
|
+
// Joining a teammate's project pairs this machine with it as it is: its
|
|
254
|
+
// name, apps, environments and the person's workspace role stay unchanged.
|
|
255
|
+
const projectRows = placement.joined ? [] : [
|
|
256
|
+
env.DB.prepare(`
|
|
257
|
+
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider,
|
|
258
|
+
identity_kind, identity_key, identity_hash, identity_origin, created_at)
|
|
259
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
260
|
+
ON CONFLICT(id) DO UPDATE SET name = excluded.name, environment = excluded.environment,
|
|
261
|
+
storage_provider = excluded.storage_provider, workspace_id = excluded.workspace_id,
|
|
262
|
+
identity_kind = COALESCE(projects.identity_kind, excluded.identity_kind),
|
|
263
|
+
identity_key = COALESCE(projects.identity_key, excluded.identity_key),
|
|
264
|
+
identity_hash = COALESCE(projects.identity_hash, excluded.identity_hash),
|
|
265
|
+
identity_origin = COALESCE(projects.identity_origin, excluded.identity_origin)
|
|
266
|
+
`).bind(projectId, workspaceId, workspaceId, attempt.projectName, attempt.environment, currentStorageProvider(attempt.storageProvider), ...identityValues(identity)),
|
|
267
|
+
env.DB.prepare(`
|
|
268
|
+
INSERT INTO project_apps (id, project_id, name, type, is_default)
|
|
269
|
+
VALUES ('default', ?, ?, 'web', 1)
|
|
270
|
+
ON CONFLICT(project_id, id) DO UPDATE SET name = excluded.name
|
|
271
|
+
`).bind(projectId, attempt.projectName),
|
|
272
|
+
env.DB.prepare(`
|
|
273
|
+
-- A project that already has an environment keeps it: pairing
|
|
274
|
+
-- another machine must not add a same-named twin beside it.
|
|
275
|
+
INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
|
|
276
|
+
SELECT ?, ?, 'default', ?, '', 'active', 1
|
|
277
|
+
WHERE NOT EXISTS (SELECT 1 FROM project_environments WHERE project_id = ?)
|
|
278
|
+
`).bind(environmentId, projectId, attempt.environment, projectId),
|
|
279
|
+
env.DB.prepare(`
|
|
280
|
+
INSERT INTO project_memberships (project_id, aegis_id, role, status)
|
|
281
|
+
VALUES (?, ?, 'owner', 'active')
|
|
282
|
+
ON CONFLICT(project_id, aegis_id) DO UPDATE SET status = 'active'
|
|
283
|
+
`).bind(projectId, principal.aegisId),
|
|
284
|
+
];
|
|
161
285
|
if (existingKey) {
|
|
162
286
|
try {
|
|
163
287
|
await env.DB.batch([
|
|
@@ -172,27 +296,7 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
|
|
|
172
296
|
VALUES (?, ?, 'owner', 'active')
|
|
173
297
|
ON CONFLICT(workspace_id, aegis_id) DO UPDATE SET status = 'active'
|
|
174
298
|
`).bind(workspaceId, principal.aegisId),
|
|
175
|
-
|
|
176
|
-
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, created_at)
|
|
177
|
-
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
178
|
-
ON CONFLICT(id) DO UPDATE SET name = excluded.name, environment = excluded.environment,
|
|
179
|
-
storage_provider = excluded.storage_provider, workspace_id = excluded.workspace_id
|
|
180
|
-
`).bind(attempt.projectId, workspaceId, workspaceId, attempt.projectName, attempt.environment, currentStorageProvider(attempt.storageProvider)),
|
|
181
|
-
env.DB.prepare(`
|
|
182
|
-
INSERT INTO project_apps (id, project_id, name, type, is_default)
|
|
183
|
-
VALUES ('default', ?, ?, 'web', 1)
|
|
184
|
-
ON CONFLICT(project_id, id) DO UPDATE SET name = excluded.name
|
|
185
|
-
`).bind(attempt.projectId, attempt.projectName),
|
|
186
|
-
env.DB.prepare(`
|
|
187
|
-
INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
|
|
188
|
-
VALUES (?, ?, 'default', ?, '', 'active', 1)
|
|
189
|
-
ON CONFLICT(project_id, id) DO UPDATE SET app_id = excluded.app_id, name = excluded.name, is_default = 1
|
|
190
|
-
`).bind(environmentId, attempt.projectId, attempt.environment),
|
|
191
|
-
env.DB.prepare(`
|
|
192
|
-
INSERT INTO project_memberships (project_id, aegis_id, role, status)
|
|
193
|
-
VALUES (?, ?, 'owner', 'active')
|
|
194
|
-
ON CONFLICT(project_id, aegis_id) DO UPDATE SET status = 'active'
|
|
195
|
-
`).bind(attempt.projectId, principal.aegisId),
|
|
299
|
+
...projectRows,
|
|
196
300
|
env.DB.prepare(`
|
|
197
301
|
UPDATE device_authorizations SET status = 'completed', aegis_id = ?, api_key_id = ?, completed_at = ?
|
|
198
302
|
WHERE attempt_id = ? AND status = 'claimed'
|
|
@@ -202,7 +306,7 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
|
|
|
202
306
|
await releaseClaimedDeviceAuthorization(env, body.attemptId);
|
|
203
307
|
throw error;
|
|
204
308
|
}
|
|
205
|
-
return response({ claimed: true, alreadyInitialized: true }, 200);
|
|
309
|
+
return response({ claimed: true, alreadyInitialized: true, projectId, joined: placement.joined }, 200);
|
|
206
310
|
}
|
|
207
311
|
|
|
208
312
|
const id = `key_${randomToken(12)}`;
|
|
@@ -221,32 +325,12 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
|
|
|
221
325
|
VALUES (?, ?, 'owner', 'active')
|
|
222
326
|
ON CONFLICT(workspace_id, aegis_id) DO UPDATE SET status = 'active'
|
|
223
327
|
`).bind(workspaceId, principal.aegisId),
|
|
224
|
-
|
|
225
|
-
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, created_at)
|
|
226
|
-
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
227
|
-
ON CONFLICT(id) DO UPDATE SET name = excluded.name, environment = excluded.environment,
|
|
228
|
-
storage_provider = excluded.storage_provider, workspace_id = excluded.workspace_id
|
|
229
|
-
`).bind(attempt.projectId, workspaceId, workspaceId, attempt.projectName, attempt.environment, currentStorageProvider(attempt.storageProvider)),
|
|
230
|
-
env.DB.prepare(`
|
|
231
|
-
INSERT INTO project_apps (id, project_id, name, type, is_default)
|
|
232
|
-
VALUES ('default', ?, ?, 'web', 1)
|
|
233
|
-
ON CONFLICT(project_id, id) DO UPDATE SET name = excluded.name
|
|
234
|
-
`).bind(attempt.projectId, attempt.projectName),
|
|
235
|
-
env.DB.prepare(`
|
|
236
|
-
INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
|
|
237
|
-
VALUES (?, ?, 'default', ?, '', 'active', 1)
|
|
238
|
-
ON CONFLICT(project_id, id) DO UPDATE SET app_id = excluded.app_id, name = excluded.name, is_default = 1
|
|
239
|
-
`).bind(environmentId, attempt.projectId, attempt.environment),
|
|
240
|
-
env.DB.prepare(`
|
|
241
|
-
INSERT INTO project_memberships (project_id, aegis_id, role, status)
|
|
242
|
-
VALUES (?, ?, 'owner', 'active')
|
|
243
|
-
ON CONFLICT(project_id, aegis_id) DO UPDATE SET status = 'active'
|
|
244
|
-
`).bind(attempt.projectId, principal.aegisId),
|
|
328
|
+
...projectRows,
|
|
245
329
|
env.DB.prepare(`
|
|
246
330
|
INSERT INTO api_keys (id, aegis_id, name, token_hash, scopes_json, project_ids_json, expires_at)
|
|
247
331
|
VALUES (?, ?, ?, ?, ?, ?, NULL)
|
|
248
332
|
`).bind(id, principal.aegisId, `${attempt.projectName} CLI`, await sha256(token),
|
|
249
|
-
JSON.stringify(["projects:read", "projects:write", "runs:write", "artifacts:write"]), JSON.stringify([
|
|
333
|
+
JSON.stringify(["projects:read", "projects:write", "runs:write", "artifacts:write"]), JSON.stringify([projectId])),
|
|
250
334
|
env.DB.prepare(`
|
|
251
335
|
UPDATE device_authorizations
|
|
252
336
|
SET status = 'completed', aegis_id = ?, api_key_id = ?, encrypted_token = ?, token_iv = ?, completed_at = ?
|
|
@@ -257,7 +341,7 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
|
|
|
257
341
|
await releaseClaimedDeviceAuthorization(env, body.attemptId);
|
|
258
342
|
throw error;
|
|
259
343
|
}
|
|
260
|
-
return response({ claimed: true, alreadyInitialized: false }, 200);
|
|
344
|
+
return response({ claimed: true, alreadyInitialized: false, projectId, joined: placement.joined }, 200);
|
|
261
345
|
}
|
|
262
346
|
|
|
263
347
|
async function releaseClaimedDeviceAuthorization(env: Env, attemptId: string): Promise<void> {
|
|
@@ -283,15 +367,18 @@ async function pollDeviceAuthorization(request: Request, env: Env): Promise<Resp
|
|
|
283
367
|
if (!body?.attemptId || !body.pollSecret) return response({ error: "attemptId and pollSecret are required" }, 422);
|
|
284
368
|
const attempt = await env.DB.prepare(`
|
|
285
369
|
SELECT status, expires_at AS expiresAt, encrypted_token AS encryptedToken, token_iv AS tokenIv,
|
|
286
|
-
poll_secret_hash AS pollSecretHash, aegis_email AS aegisEmail
|
|
370
|
+
poll_secret_hash AS pollSecretHash, aegis_email AS aegisEmail, refusal_json AS refusalJson,
|
|
371
|
+
COALESCE(resolved_project_id, project_id) AS projectId
|
|
287
372
|
FROM device_authorizations WHERE attempt_id = ?
|
|
288
373
|
`).bind(body.attemptId).first<{
|
|
374
|
+
projectId: string;
|
|
289
375
|
status: string;
|
|
290
376
|
expiresAt: string;
|
|
291
377
|
encryptedToken: string | null;
|
|
292
378
|
tokenIv: string | null;
|
|
293
379
|
pollSecretHash: string;
|
|
294
380
|
aegisEmail: string | null;
|
|
381
|
+
refusalJson: string | null;
|
|
295
382
|
}>();
|
|
296
383
|
if (!attempt || !constantTimeEqual(await sha256(body.pollSecret), attempt.pollSecretHash)) {
|
|
297
384
|
return response({ error: "Device authorization not found" }, 404);
|
|
@@ -302,92 +389,22 @@ async function pollDeviceAuthorization(request: Request, env: Env): Promise<Resp
|
|
|
302
389
|
return response({ status: "expired" }, 410);
|
|
303
390
|
}
|
|
304
391
|
if (attempt.status === "pending") return response({ status: "pending" }, 202);
|
|
305
|
-
if (
|
|
392
|
+
if (attempt.status === "refused") {
|
|
393
|
+
const refusal = attempt.refusalJson ? JSON.parse(attempt.refusalJson) as Record<string, unknown> : { error: "Device registration was refused" };
|
|
394
|
+
return response({ status: "refused", ...refusal }, refusal.code === "plan_limit" || refusal.code === "free_workspace_exists" ? 402 : 409);
|
|
395
|
+
}
|
|
396
|
+
if (!attempt.encryptedToken || !attempt.tokenIv) return response({ status: "completed", alreadyInitialized: true, projectId: attempt.projectId }, 200);
|
|
306
397
|
const result = await env.DB.prepare(`
|
|
307
398
|
UPDATE device_authorizations SET encrypted_token = NULL, token_iv = NULL
|
|
308
399
|
WHERE attempt_id = ? AND status = 'completed' AND encrypted_token = ?
|
|
309
400
|
`).bind(body.attemptId, attempt.encryptedToken).run() as { meta?: { changes?: number } };
|
|
310
|
-
if (result.meta?.changes !== 1) return response({ status: "completed", alreadyInitialized: true }, 200);
|
|
401
|
+
if (result.meta?.changes !== 1) return response({ status: "completed", alreadyInitialized: true, projectId: attempt.projectId }, 200);
|
|
311
402
|
const token = await decryptForSecret(
|
|
312
403
|
attempt.encryptedToken,
|
|
313
404
|
attempt.tokenIv,
|
|
314
405
|
await sha256(body.pollSecret),
|
|
315
406
|
);
|
|
316
|
-
return response({ status: "completed", alreadyInitialized: false, token, email: attempt.aegisEmail }, 200);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
async function registerProject(request: Request, env: Env, principal: Principal): Promise<Response> {
|
|
320
|
-
if (!can(principal, "projects:write")) return response({ error: "Project registration is not allowed" }, 403);
|
|
321
|
-
const body = await request.json().catch(() => null) as {
|
|
322
|
-
id?: string;
|
|
323
|
-
name?: string;
|
|
324
|
-
appId?: string;
|
|
325
|
-
appName?: string;
|
|
326
|
-
workspaceId?: string;
|
|
327
|
-
workspaceName?: string;
|
|
328
|
-
tenantId?: string;
|
|
329
|
-
environment?: string;
|
|
330
|
-
storageProvider?: string;
|
|
331
|
-
} | null;
|
|
332
|
-
if (!body?.id || !body.name || !body.environment || !body.storageProvider) {
|
|
333
|
-
return response({ error: "id, name, environment, and storageProvider are required" }, 422);
|
|
334
|
-
}
|
|
335
|
-
if (!projectAllowed(principal, body.id)) return response({ error: "Project scope is not allowed" }, 403);
|
|
336
|
-
const workspaceId = body.workspaceId ?? body.tenantId ?? `workspace_${principal.aegisId}`;
|
|
337
|
-
const tenantId = body.tenantId ?? workspaceId;
|
|
338
|
-
const existingWorkspace = await env.DB.prepare(
|
|
339
|
-
"SELECT id FROM workspaces WHERE id = ?",
|
|
340
|
-
).bind(workspaceId).first<{ id: string }>();
|
|
341
|
-
if (existingWorkspace && !(await memberOfWorkspace(env, principal, workspaceId, "workspace.write"))) {
|
|
342
|
-
return response({ error: "Workspace scope is not allowed" }, 403);
|
|
343
|
-
}
|
|
344
|
-
// Re-registering a project is how the CLI refreshes it, but only for people
|
|
345
|
-
// who may manage it; otherwise an id could be used to move someone else's
|
|
346
|
-
// project into another workspace.
|
|
347
|
-
const existingProject = await env.DB.prepare("SELECT workspace_id AS workspaceId FROM projects WHERE id = ?")
|
|
348
|
-
.bind(body.id).first<{ workspaceId: string }>();
|
|
349
|
-
if (existingProject && !(await memberOfProject(env, principal, body.id, "project.manage"))) {
|
|
350
|
-
return response({ error: "A project with this id already exists" }, 409);
|
|
351
|
-
}
|
|
352
|
-
if (existingProject && existingProject.workspaceId !== workspaceId) {
|
|
353
|
-
return response({ error: "This project belongs to another workspace" }, 409);
|
|
354
|
-
}
|
|
355
|
-
const joinedAt = new Date().toISOString();
|
|
356
|
-
await env.DB.batch([
|
|
357
|
-
env.DB.prepare(`
|
|
358
|
-
INSERT INTO aegis_users (id) VALUES (?) ON CONFLICT(id) DO NOTHING
|
|
359
|
-
`).bind(principal.aegisId),
|
|
360
|
-
env.DB.prepare(`
|
|
361
|
-
INSERT INTO workspaces (id, name, created_by)
|
|
362
|
-
VALUES (?, ?, ?)
|
|
363
|
-
ON CONFLICT(id) DO NOTHING
|
|
364
|
-
`).bind(workspaceId, body.workspaceName ?? workspaceId, principal.aegisId),
|
|
365
|
-
// Only the creator of a brand-new workspace becomes its owner; an existing
|
|
366
|
-
// membership (including a removed one) is left alone.
|
|
367
|
-
env.DB.prepare(`
|
|
368
|
-
INSERT INTO workspace_memberships (workspace_id, aegis_id, role, status, created_at)
|
|
369
|
-
SELECT ?, ?, 'owner', 'active', ?
|
|
370
|
-
WHERE NOT EXISTS (SELECT 1 FROM workspace_memberships WHERE workspace_id = ?)
|
|
371
|
-
`).bind(workspaceId, principal.aegisId, joinedAt, workspaceId),
|
|
372
|
-
env.DB.prepare(`
|
|
373
|
-
INSERT INTO project_apps (id, project_id, name, type, is_default)
|
|
374
|
-
VALUES (?, ?, ?, 'web', 1)
|
|
375
|
-
ON CONFLICT(project_id, id) DO NOTHING
|
|
376
|
-
`).bind(body.appId?.trim() || "default", body.id, body.appName?.trim() || body.name),
|
|
377
|
-
env.DB.prepare(`
|
|
378
|
-
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, created_at)
|
|
379
|
-
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
380
|
-
ON CONFLICT(id) DO UPDATE SET name = excluded.name, environment = excluded.environment,
|
|
381
|
-
storage_provider = excluded.storage_provider, workspace_id = excluded.workspace_id
|
|
382
|
-
`).bind(body.id, workspaceId, tenantId, body.name, body.environment, currentStorageProvider(body.storageProvider)),
|
|
383
|
-
env.DB.prepare(`
|
|
384
|
-
INSERT INTO project_memberships (project_id, aegis_id, role, status)
|
|
385
|
-
VALUES (?, ?, 'owner', 'active')
|
|
386
|
-
ON CONFLICT(project_id, aegis_id) DO NOTHING
|
|
387
|
-
`).bind(body.id, principal.aegisId),
|
|
388
|
-
]);
|
|
389
|
-
await auditProject(env, principal, body.id, { action: "project.registered", targetType: "project", targetId: body.id, data: { environment: body.environment } }, request);
|
|
390
|
-
return response({ registered: true, projectId: body.id, workspaceId, tenantId }, 201);
|
|
407
|
+
return response({ status: "completed", alreadyInitialized: false, token, email: attempt.aegisEmail, projectId: attempt.projectId }, 200);
|
|
391
408
|
}
|
|
392
409
|
|
|
393
410
|
async function listWorkspaces(env: Env, principal: Principal): Promise<Response> {
|
|
@@ -400,7 +417,7 @@ async function listWorkspaces(env: Env, principal: Principal): Promise<Response>
|
|
|
400
417
|
`).bind(principal.aegisId).all();
|
|
401
418
|
const workspaces = await Promise.all(rows.results.map(async (workspace) => {
|
|
402
419
|
const projects = await env.DB.prepare(`
|
|
403
|
-
SELECT p.id, p.name, p.environment, p.storage_provider AS storageProvider
|
|
420
|
+
SELECT p.id, p.name, p.environment, p.storage_provider AS storageProvider, p.locked_at AS lockedAt, p.inactive_at AS inactiveAt
|
|
404
421
|
FROM projects p
|
|
405
422
|
WHERE p.workspace_id = ? AND p.deleted_at IS NULL
|
|
406
423
|
ORDER BY p.created_at ASC
|
|
@@ -461,6 +478,10 @@ async function createManagedProject(request: Request, env: Env, principal: Princ
|
|
|
461
478
|
if (!can(principal, "projects:write") && !can(principal, "*")) return response({ error: "Project creation is not allowed" }, 403);
|
|
462
479
|
const body = await request.json().catch(() => null) as {
|
|
463
480
|
id?: string; name?: string; workspaceId?: string; environment?: string; storageProvider?: string;
|
|
481
|
+
/** Which app this is (src/registry/project-identity.ts): a git remote plus the app's folder, or a domain. */
|
|
482
|
+
identity?: unknown;
|
|
483
|
+
/** The app's site; a public one identifies the project by its domain when no identity is sent. */
|
|
484
|
+
appUrl?: unknown;
|
|
464
485
|
} | null;
|
|
465
486
|
const projectId = body?.id?.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
|
|
466
487
|
const name = body?.name?.trim();
|
|
@@ -469,14 +490,32 @@ async function createManagedProject(request: Request, env: Env, principal: Princ
|
|
|
469
490
|
return response({ error: "id, name, workspaceId, environment, and storageProvider are required" }, 422);
|
|
470
491
|
}
|
|
471
492
|
if (!(await memberOfWorkspace(env, principal, workspaceId, "workspace.write"))) return response({ error: "Workspace scope is not allowed" }, 403);
|
|
493
|
+
const identity = body.identity !== undefined && body.identity !== null
|
|
494
|
+
? await readIdentity(body.identity)
|
|
495
|
+
: await readIdentity(body.appUrl ? { appUrl: body.appUrl } : null);
|
|
496
|
+
if (identity === "invalid") return invalidIdentity();
|
|
497
|
+
if (identity) {
|
|
498
|
+
// One app is one project: the same app in this workspace is the project that's already here.
|
|
499
|
+
const existing = await workspaceProjectWithIdentity(env, workspaceId, identity.hash);
|
|
500
|
+
if (existing) {
|
|
501
|
+
return response({
|
|
502
|
+
project: { id: existing.id, name: existing.name, workspaceId, inactiveAt: existing.inactiveAt },
|
|
503
|
+
existing: true,
|
|
504
|
+
identity: identityView({ identityKind: identity.kind, identityKey: identity.key }),
|
|
505
|
+
}, 200);
|
|
506
|
+
}
|
|
507
|
+
const inUse = await identityInUse(env, principal, workspaceId, identity);
|
|
508
|
+
if (inUse) return inUse;
|
|
509
|
+
}
|
|
472
510
|
const projectLimit = await checkProjectLimit(env, workspaceId);
|
|
473
511
|
if (projectLimit) return projectLimit;
|
|
474
512
|
try {
|
|
475
513
|
await env.DB.batch([
|
|
476
514
|
env.DB.prepare(`
|
|
477
|
-
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, storage_mode,
|
|
478
|
-
|
|
479
|
-
|
|
515
|
+
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, storage_mode,
|
|
516
|
+
identity_kind, identity_key, identity_hash, identity_origin, created_at)
|
|
517
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
518
|
+
`).bind(projectId, workspaceId, workspaceId, name, body.environment.trim(), currentStorageProvider(body.storageProvider), storageModeOf(body.storageProvider), ...identityValues(identity)),
|
|
480
519
|
env.DB.prepare("INSERT INTO project_memberships (project_id, aegis_id, role, status) VALUES (?, ?, 'owner', 'active')")
|
|
481
520
|
.bind(projectId, principal.aegisId),
|
|
482
521
|
env.DB.prepare("INSERT INTO project_apps (id, project_id, name, type, is_default) VALUES ('default', ?, ?, 'web', 1)")
|
|
@@ -485,8 +524,12 @@ async function createManagedProject(request: Request, env: Env, principal: Princ
|
|
|
485
524
|
} catch {
|
|
486
525
|
return response({ error: "A project with this id already exists" }, 409);
|
|
487
526
|
}
|
|
488
|
-
|
|
489
|
-
|
|
527
|
+
const view = identity ? identityView({ identityKind: identity.kind, identityKey: identity.key }) : null;
|
|
528
|
+
await auditProject(env, principal, projectId, { action: "project.created", targetType: "project", targetId: projectId, ...(view ? { data: { identity: view.label } } : {}) }, request);
|
|
529
|
+
return response({
|
|
530
|
+
project: { id: projectId, name, workspaceId, environment: body.environment.trim(), storageProvider: body.storageProvider.trim() },
|
|
531
|
+
identity: view,
|
|
532
|
+
}, 201);
|
|
490
533
|
}
|
|
491
534
|
|
|
492
535
|
async function updateProject(request: Request, env: Env, principal: Principal, projectId: string): Promise<Response> {
|
|
@@ -586,7 +629,8 @@ async function listProjects(env: Env, principal: Principal): Promise<Response> {
|
|
|
586
629
|
END AS workspaceName,
|
|
587
630
|
p.tenant_id AS tenantId, p.name, p.environment,
|
|
588
631
|
p.storage_provider AS storageProvider, p.storage_mode AS storageMode,
|
|
589
|
-
p.default_execution_mode AS defaultExecutionMode, p.
|
|
632
|
+
p.default_execution_mode AS defaultExecutionMode, p.task_approval_mode AS taskApprovalMode, p.locked_at AS lockedAt,
|
|
633
|
+
p.inactive_at AS inactiveAt, p.identity_kind AS identityKind, p.identity_key AS identityKey, p.created_at AS createdAt
|
|
590
634
|
FROM projects p
|
|
591
635
|
JOIN workspace_memberships wm ON wm.workspace_id = p.workspace_id
|
|
592
636
|
JOIN workspaces w ON w.id = p.workspace_id
|
|
@@ -622,8 +666,10 @@ async function listProjects(env: Env, principal: Principal): Promise<Response> {
|
|
|
622
666
|
status: string;
|
|
623
667
|
isDefault: number;
|
|
624
668
|
}>();
|
|
669
|
+
const { identityKind, identityKey, ...rest } = project as Record<string, unknown> & { identityKind?: string | null; identityKey?: string | null };
|
|
625
670
|
return {
|
|
626
|
-
...
|
|
671
|
+
...rest,
|
|
672
|
+
identity: identityView({ identityKind, identityKey }),
|
|
627
673
|
apps: apps.results.map((app) => ({
|
|
628
674
|
...app,
|
|
629
675
|
location: app.location ?? undefined,
|
|
@@ -717,6 +763,14 @@ async function createEnvironment(
|
|
|
717
763
|
"SELECT id FROM project_apps WHERE project_id = ? AND id = ?",
|
|
718
764
|
).bind(projectId, appId).first<{ id: string }>();
|
|
719
765
|
if (!app) return response({ error: "App does not belong to this project" }, 422);
|
|
766
|
+
// Two environments with one name can't be told apart in the picker.
|
|
767
|
+
const sameName = await env.DB.prepare(
|
|
768
|
+
"SELECT id FROM project_environments WHERE project_id = ? AND COALESCE(app_id, 'default') = ? AND lower(trim(name)) = lower(?)",
|
|
769
|
+
).bind(projectId, appId, body.name.trim()).first<{ id: string }>();
|
|
770
|
+
if (sameName) return response({ error: `This app already has an environment named "${body.name.trim()}" (${sameName.id})` }, 409);
|
|
771
|
+
// A project with no identity yet is identified by its public site's domain.
|
|
772
|
+
const identityRefusal = await claimDomainIdentity(env, principal, projectId, body.baseUrl.trim());
|
|
773
|
+
if (identityRefusal) return identityRefusal;
|
|
720
774
|
try {
|
|
721
775
|
await env.DB.prepare(`
|
|
722
776
|
INSERT INTO project_environments (id, project_id, app_id, name, base_url, api_url)
|
|
@@ -773,6 +827,9 @@ async function updateEnvironment(
|
|
|
773
827
|
.bind(projectId, body.appId.trim()).first<{ id: string }>();
|
|
774
828
|
if (!app) return response({ error: "App does not belong to this project" }, 422);
|
|
775
829
|
}
|
|
830
|
+
// A project with no identity yet is identified by its public site's domain.
|
|
831
|
+
const identityRefusal = await claimDomainIdentity(env, principal, projectId, baseUrl);
|
|
832
|
+
if (identityRefusal) return identityRefusal;
|
|
776
833
|
const result = await env.DB.prepare(`
|
|
777
834
|
UPDATE project_environments
|
|
778
835
|
SET app_id = COALESCE(?, app_id), name = ?, base_url = ?, api_url = ?, status = COALESCE(?, status),
|
|
@@ -911,7 +968,7 @@ function parseJsonColumn(value: unknown): unknown {
|
|
|
911
968
|
}
|
|
912
969
|
|
|
913
970
|
function hydrateRun<T extends Record<string, unknown>>(row: T) {
|
|
914
|
-
const { targetsJson, targetResultsJson, fixFromJson, ...rest } = row as Record<string, unknown>;
|
|
971
|
+
const { targetsJson, targetResultsJson, fixFromJson, caseScopeJson, ...rest } = row as Record<string, unknown>;
|
|
915
972
|
const started = typeof rest.startedAt === "string" ? Date.parse(`${rest.startedAt.replace(" ", "T")}Z`) : NaN;
|
|
916
973
|
const completed = typeof rest.completedAt === "string" ? Date.parse(`${rest.completedAt.replace(" ", "T")}Z`) : NaN;
|
|
917
974
|
return {
|
|
@@ -919,10 +976,34 @@ function hydrateRun<T extends Record<string, unknown>>(row: T) {
|
|
|
919
976
|
targets: parseJsonColumn(targetsJson),
|
|
920
977
|
targetResults: parseJsonColumn(targetResultsJson),
|
|
921
978
|
fixFrom: parseJsonColumn(fixFromJson),
|
|
979
|
+
caseScope: parseJsonColumn(caseScopeJson),
|
|
922
980
|
durationMs: Number.isFinite(started) && Number.isFinite(completed) ? Math.max(0, completed - started) : null,
|
|
923
981
|
};
|
|
924
982
|
}
|
|
925
983
|
|
|
984
|
+
/**
|
|
985
|
+
* Run evidence follows its run: once the run is past the plan's history, its
|
|
986
|
+
* objects are hidden too (the retention job deletes both). Evidence of a run
|
|
987
|
+
* the registry no longer knows is judged by when it was written.
|
|
988
|
+
*/
|
|
989
|
+
async function runEvidenceVisibility(env: Env, projectId: string): Promise<ArtifactVisibility | undefined> {
|
|
990
|
+
const cutoff = await historyCutoffFor(env, await workspaceOfProject(env, projectId));
|
|
991
|
+
if (!cutoff) return undefined;
|
|
992
|
+
const expired = new Map<string, boolean | null>();
|
|
993
|
+
return async (key, uploaded) => {
|
|
994
|
+
const runId = runIdOfArtifactKey(projectId, key);
|
|
995
|
+
if (!runId) return true;
|
|
996
|
+
if (!expired.has(runId)) {
|
|
997
|
+
const run = await env.DB.prepare("SELECT status, completed_at AS completedAt, requested_at AS requestedAt FROM runs WHERE id = ? AND project_id = ?")
|
|
998
|
+
.bind(runId, projectId).first<{ status: string; completedAt: string | null; requestedAt: string | null }>();
|
|
999
|
+
expired.set(runId, run ? runExpired(run, cutoff) : null);
|
|
1000
|
+
}
|
|
1001
|
+
const known = expired.get(runId);
|
|
1002
|
+
if (known === null || known === undefined) return !uploaded || uploaded.getTime() >= Date.parse(cutoff);
|
|
1003
|
+
return !known;
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
926
1007
|
async function listRuns(env: Env, principal: Principal, projectId: string): Promise<Response> {
|
|
927
1008
|
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
928
1009
|
const cutoff = await historyCutoffFor(env, await workspaceOfProject(env, projectId));
|
|
@@ -938,9 +1019,9 @@ async function listRuns(env: Env, principal: Principal, projectId: string): Prom
|
|
|
938
1019
|
manifest_key AS manifestKey, requested_at AS requestedAt,
|
|
939
1020
|
started_at AS startedAt, completed_at AS completedAt,
|
|
940
1021
|
targets_json AS targetsJson, environment_base_url AS environmentBaseUrl,
|
|
941
|
-
target_results_json AS targetResultsJson, fix_from_json AS fixFromJson,
|
|
1022
|
+
target_results_json AS targetResultsJson, fix_from_json AS fixFromJson, case_scope_json AS caseScopeJson,
|
|
942
1023
|
execution_mode AS executionMode, blocked_reason AS blockedReason, billed_minutes AS billedMinutes,
|
|
943
|
-
trigger_source AS triggerSource, repo_full_name AS repoFullName, commit_sha AS commitSha, pr_number AS prNumber,
|
|
1024
|
+
trigger_source AS triggerSource, repo_full_name AS repoFullName, commit_sha AS commitSha, pr_number AS prNumber, check_run_id AS checkRunId,
|
|
944
1025
|
gitlab_binding_id AS gitlabBindingId
|
|
945
1026
|
FROM runs
|
|
946
1027
|
WHERE project_id = ? AND (? IS NULL OR COALESCE(completed_at, requested_at) >= ? OR status NOT IN ('success', 'partial', 'error', 'cancelled'))
|
|
@@ -952,6 +1033,8 @@ async function listRuns(env: Env, principal: Principal, projectId: string): Prom
|
|
|
952
1033
|
async function createRun(request: Request, env: Env, principal: Principal, projectId: string, ctx: ExecutionContext, internal: InternalRunFields = {}): Promise<Response> {
|
|
953
1034
|
if (!can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Run creation is not allowed" }, 403);
|
|
954
1035
|
if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
1036
|
+
const paused = await projectInactiveRefusal(env, projectId);
|
|
1037
|
+
if (paused) return paused;
|
|
955
1038
|
const body = await requestJson(request);
|
|
956
1039
|
const planId = typeof body?.planId === "string" ? body.planId.trim() : "";
|
|
957
1040
|
const idempotencyKey = typeof body?.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
|
|
@@ -1025,8 +1108,8 @@ async function createRun(request: Request, env: Env, principal: Principal, proje
|
|
|
1025
1108
|
const targets = matrix.targets.length > 0 ? matrix.targets : null;
|
|
1026
1109
|
await env.DB.prepare(`
|
|
1027
1110
|
INSERT INTO runs (id, project_id, tenant_id, plan_id, journey_id, journey_revision, environment_id, requested_by, idempotency_key, driver, platform, targets_json, environment_base_url, fix_from_json, execution_mode, workspace_id,
|
|
1028
|
-
trigger_source, repo_full_name, commit_sha, pr_number, github_installation_id, gitlab_binding_id)
|
|
1029
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1111
|
+
trigger_source, repo_full_name, commit_sha, pr_number, github_installation_id, gitlab_binding_id, case_scope_json)
|
|
1112
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1030
1113
|
`).bind(
|
|
1031
1114
|
runId, projectId, project?.tenantId ?? `tenant_${principal.aegisId}`, planId, draft.journeyId,
|
|
1032
1115
|
Number.isFinite(Number(body?.journeyRevision)) ? Number(body?.journeyRevision) : null,
|
|
@@ -1044,6 +1127,7 @@ async function createRun(request: Request, env: Env, principal: Principal, proje
|
|
|
1044
1127
|
internal.prNumber ?? null,
|
|
1045
1128
|
internal.githubInstallationId ?? null,
|
|
1046
1129
|
internal.gitlabBindingId ?? null,
|
|
1130
|
+
internal.caseScope ? JSON.stringify(internal.caseScope) : null,
|
|
1047
1131
|
).run();
|
|
1048
1132
|
await appendRunEvent(env, runId, "queued", { status: "queued", targets, environmentBaseUrl, executionMode });
|
|
1049
1133
|
await broadcastProjectEvent(env, projectId, { type: "job_queued", runId, projectId, planId, executionMode });
|
|
@@ -1053,6 +1137,7 @@ async function createRun(request: Request, env: Env, principal: Principal, proje
|
|
|
1053
1137
|
run: {
|
|
1054
1138
|
id: runId, projectId, planId, journeyId: draft.journeyId, status: "queued", executionMode,
|
|
1055
1139
|
environmentId, environmentBaseUrl, targets, targetResults: null, durationMs: null, fixFrom,
|
|
1140
|
+
caseScope: internal.caseScope ?? null,
|
|
1056
1141
|
},
|
|
1057
1142
|
}, 201);
|
|
1058
1143
|
}
|
|
@@ -1069,11 +1154,15 @@ async function getRun(env: Env, principal: Principal, runId: string): Promise<Re
|
|
|
1069
1154
|
manifest_key AS manifestKey, requested_at AS requestedAt,
|
|
1070
1155
|
started_at AS startedAt, completed_at AS completedAt,
|
|
1071
1156
|
targets_json AS targetsJson, environment_base_url AS environmentBaseUrl,
|
|
1072
|
-
target_results_json AS targetResultsJson, fix_from_json AS fixFromJson,
|
|
1157
|
+
target_results_json AS targetResultsJson, fix_from_json AS fixFromJson, case_scope_json AS caseScopeJson,
|
|
1073
1158
|
execution_mode AS executionMode, blocked_reason AS blockedReason, billed_minutes AS billedMinutes
|
|
1074
1159
|
FROM runs WHERE id = ?
|
|
1075
1160
|
`).bind(runId).first<{ projectId: string } & Record<string, unknown>>();
|
|
1076
1161
|
if (!run || !(await memberOfProject(env, principal, run.projectId))) return response({ error: "Run not found" }, 404);
|
|
1162
|
+
const workspaceId = await workspaceOfProject(env, run.projectId);
|
|
1163
|
+
if (runExpired({ status: String(run.status), completedAt: run.completedAt as string | null, requestedAt: run.requestedAt as string | null }, await historyCutoffFor(env, workspaceId))) {
|
|
1164
|
+
return response({ error: "This run is older than your plan's run history. Upgrade to keep history longer.", code: "history_expired", ...billingLinks(env, workspaceId) }, 404);
|
|
1165
|
+
}
|
|
1077
1166
|
const events = await env.DB.prepare(`
|
|
1078
1167
|
SELECT sequence, type, payload_json AS payload, created_at AS createdAt
|
|
1079
1168
|
FROM run_events WHERE run_id = ? ORDER BY sequence ASC
|
|
@@ -1088,10 +1177,12 @@ async function claimRun(request: Request, env: Env, principal: Principal, runId:
|
|
|
1088
1177
|
if (!workerId) return response({ error: "workerId is required" }, 422);
|
|
1089
1178
|
const run = await env.DB.prepare(`
|
|
1090
1179
|
SELECT project_id AS projectId, targets_json AS targetsJson, environment_base_url AS environmentBaseUrl,
|
|
1091
|
-
fix_from_json AS fixFromJson, execution_mode AS executionMode, workspace_id AS workspaceId, status
|
|
1180
|
+
fix_from_json AS fixFromJson, case_scope_json AS caseScopeJson, execution_mode AS executionMode, workspace_id AS workspaceId, status
|
|
1092
1181
|
FROM runs WHERE id = ?
|
|
1093
|
-
`).bind(runId).first<{ projectId: string; targetsJson: string | null; environmentBaseUrl: string | null; fixFromJson: string | null; executionMode: string; workspaceId: string | null; status: string }>();
|
|
1182
|
+
`).bind(runId).first<{ projectId: string; targetsJson: string | null; environmentBaseUrl: string | null; fixFromJson: string | null; caseScopeJson: string | null; executionMode: string; workspaceId: string | null; status: string }>();
|
|
1094
1183
|
if (!run || !(await memberOfProject(env, principal, run.projectId, "project.write"))) return response({ error: "Run not found" }, 404);
|
|
1184
|
+
const paused = await projectInactiveRefusal(env, run.projectId);
|
|
1185
|
+
if (paused) return paused;
|
|
1095
1186
|
// Bugmole Cloud runs are billed; only Bugmole's own runners may take them.
|
|
1096
1187
|
if (run.executionMode === "cloud") return response({ error: "This run is queued for Bugmole Cloud" }, 409);
|
|
1097
1188
|
const lease = new Date(Date.now() + 60_000).toISOString();
|
|
@@ -1116,6 +1207,7 @@ async function claimRun(request: Request, env: Env, principal: Principal, runId:
|
|
|
1116
1207
|
environmentBaseUrl: run.environmentBaseUrl,
|
|
1117
1208
|
targets: parseJsonColumn(run.targetsJson),
|
|
1118
1209
|
fixFrom: parseJsonColumn(run.fixFromJson),
|
|
1210
|
+
caseScope: parseJsonColumn(run.caseScopeJson),
|
|
1119
1211
|
});
|
|
1120
1212
|
}
|
|
1121
1213
|
|
|
@@ -1201,16 +1293,35 @@ const taskSelect = `
|
|
|
1201
1293
|
idempotency_key AS idempotencyKey, requested_at AS requestedAt,
|
|
1202
1294
|
started_at AS startedAt, completed_at AS completedAt, device_session_id AS deviceSessionId,
|
|
1203
1295
|
attempts, next_attempt_at AS nextAttemptAt,
|
|
1204
|
-
execution_mode AS executionMode, billed_minutes AS billedMinutes
|
|
1296
|
+
execution_mode AS executionMode, billed_minutes AS billedMinutes,
|
|
1297
|
+
approved_at AS approvedAt, approved_by AS approvedBy,
|
|
1298
|
+
reviewed_at AS reviewedAt, reviewed_by AS reviewedBy, review_note AS reviewNote
|
|
1205
1299
|
FROM agent_tasks
|
|
1206
1300
|
`;
|
|
1207
1301
|
|
|
1208
1302
|
|
|
1209
|
-
|
|
1303
|
+
// A worker on someone's machine lists its project's tasks on every poll, with
|
|
1304
|
+
// the machine's API key. That is how the registry knows a machine is
|
|
1305
|
+
// connected: resume won't queue a machine-only task on a cloud project with
|
|
1306
|
+
// none. Written at most once a minute, whatever the poll rate.
|
|
1307
|
+
async function noteLocalWorker(env: Env, principal: Principal, projectId: string): Promise<void> {
|
|
1308
|
+
if (!principal.apiKeyId || principal.runner) return;
|
|
1309
|
+
const now = Date.now();
|
|
1310
|
+
await env.DB.prepare(`
|
|
1311
|
+
UPDATE projects SET local_worker_seen_at = ?
|
|
1312
|
+
WHERE id = ? AND (local_worker_seen_at IS NULL OR local_worker_seen_at < ?)
|
|
1313
|
+
`).bind(new Date(now).toISOString(), projectId, new Date(now - LOCAL_WORKER_SEEN_THROTTLE_MS).toISOString()).run();
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async function listAgentTasks(env: Env, principal: Principal, projectId: string, ctx: ExecutionContext): Promise<Response> {
|
|
1210
1317
|
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1318
|
+
ctx.waitUntil(noteLocalWorker(env, principal, projectId).catch(() => undefined));
|
|
1319
|
+
const [rows, project] = await Promise.all([
|
|
1320
|
+
env.DB.prepare(`${taskSelect} WHERE project_id = ? ORDER BY priority DESC, requested_at ASC LIMIT 100`).bind(projectId).all(),
|
|
1321
|
+
env.DB.prepare("SELECT local_worker_seen_at AS localWorkerSeenAt FROM projects WHERE id = ?")
|
|
1322
|
+
.bind(projectId).first<{ localWorkerSeenAt: string | null }>(),
|
|
1323
|
+
]);
|
|
1324
|
+
return response({ tasks: rows.results, localWorkerSeenAt: project?.localWorkerSeenAt ?? null });
|
|
1214
1325
|
}
|
|
1215
1326
|
|
|
1216
1327
|
function extractDiscoveryUrl(description: string): string | null {
|
|
@@ -1251,6 +1362,17 @@ async function fillDiscoveredEnvironmentUrl(env: Env, projectId: string, descrip
|
|
|
1251
1362
|
.bind(origin, projectId, existing.id).run();
|
|
1252
1363
|
return;
|
|
1253
1364
|
}
|
|
1365
|
+
// An environment that isn't marked default (made by hand, or by an older
|
|
1366
|
+
// onboarding) is still the project's environment: promote it rather than
|
|
1367
|
+
// add a second "Production" beside it.
|
|
1368
|
+
const other = await env.DB.prepare(
|
|
1369
|
+
"SELECT id, base_url AS baseUrl FROM project_environments WHERE project_id = ? ORDER BY rowid LIMIT 1",
|
|
1370
|
+
).bind(projectId).first<{ id: string; baseUrl: string }>();
|
|
1371
|
+
if (other) {
|
|
1372
|
+
await env.DB.prepare("UPDATE project_environments SET is_default = 1, base_url = CASE WHEN base_url = '' THEN ? ELSE base_url END WHERE project_id = ? AND id = ?")
|
|
1373
|
+
.bind(origin, projectId, other.id).run();
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1254
1376
|
const app = await env.DB.prepare("SELECT id FROM project_apps WHERE project_id = ? AND is_default = 1")
|
|
1255
1377
|
.bind(projectId).first<{ id: string }>();
|
|
1256
1378
|
if (!app) return;
|
|
@@ -1265,6 +1387,8 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1265
1387
|
return response({ error: "Task creation is not allowed" }, 403);
|
|
1266
1388
|
}
|
|
1267
1389
|
if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
1390
|
+
const paused = await projectInactiveRefusal(env, projectId);
|
|
1391
|
+
if (paused) return paused;
|
|
1268
1392
|
const body = await requestJson(request) as {
|
|
1269
1393
|
title?: string; description?: string; taskType?: string; idempotencyKey?: string; priority?: number;
|
|
1270
1394
|
journeyId?: string; journeyRevision?: number; deviceSessionId?: string; stepId?: string;
|
|
@@ -1297,7 +1421,14 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1297
1421
|
if (domainProblem) return domainProblem;
|
|
1298
1422
|
}
|
|
1299
1423
|
}
|
|
1424
|
+
// The project's approval mode decides whether this task can start by
|
|
1425
|
+
// itself or waits for someone to press Approve.
|
|
1426
|
+
const approvalMode = approvalModeOf((await env.DB.prepare("SELECT task_approval_mode AS mode FROM projects WHERE id = ?")
|
|
1427
|
+
.bind(projectId).first<{ mode: string }>())?.mode) ?? DEFAULT_APPROVAL_MODE;
|
|
1428
|
+
const approvedBy = needsApproval(approvalMode, taskType) ? null : autoApprover(approvalMode);
|
|
1300
1429
|
const queued = (taskId: string) => {
|
|
1430
|
+
// Waiting for approval isn't queued for a runner yet; approving it is.
|
|
1431
|
+
if (!approvedBy) return;
|
|
1301
1432
|
for (const hook of taskQueuedHooks) ctx.waitUntil(hook(env, { taskId, projectId, workspaceId: taskWorkspaceId, executionMode }));
|
|
1302
1433
|
};
|
|
1303
1434
|
const existing = await env.DB.prepare(`${taskSelect} WHERE project_id = ? AND idempotency_key = ?`)
|
|
@@ -1317,7 +1448,8 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1317
1448
|
attempts = 0, next_attempt_at = NULL,
|
|
1318
1449
|
execution_mode = ?, workspace_id = ?, claimed_at = NULL, billed_at = NULL, billed_minutes = NULL,
|
|
1319
1450
|
blocked_reason = NULL, dispatch_attempts = 0, next_dispatch_at = NULL,
|
|
1320
|
-
requested_at = CURRENT_TIMESTAMP, started_at = NULL, completed_at = NULL
|
|
1451
|
+
requested_at = CURRENT_TIMESTAMP, started_at = NULL, completed_at = NULL,
|
|
1452
|
+
approved_at = CASE WHEN ? IS NULL THEN NULL ELSE CURRENT_TIMESTAMP END, approved_by = ?
|
|
1321
1453
|
WHERE id = ?
|
|
1322
1454
|
`).bind(
|
|
1323
1455
|
title, description, taskType,
|
|
@@ -1326,6 +1458,7 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1326
1458
|
typeof body?.deviceSessionId === "string" ? body.deviceSessionId : null,
|
|
1327
1459
|
typeof body?.stepId === "string" ? body.stepId : null,
|
|
1328
1460
|
executionMode, taskWorkspaceId,
|
|
1461
|
+
approvedBy, approvedBy,
|
|
1329
1462
|
existing.id,
|
|
1330
1463
|
).run();
|
|
1331
1464
|
await broadcastProjectEvent(env, projectId, { type: "task_event", taskId: existing.id, projectId, status: "queued" });
|
|
@@ -1337,8 +1470,8 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1337
1470
|
const taskId = id("task");
|
|
1338
1471
|
await env.DB.prepare(`
|
|
1339
1472
|
INSERT INTO agent_tasks
|
|
1340
|
-
(id, project_id, tenant_id, title, description, task_type, priority, requested_by, idempotency_key, journey_id, journey_revision, device_session_id, step_id, execution_mode, workspace_id)
|
|
1341
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1473
|
+
(id, project_id, tenant_id, title, description, task_type, priority, requested_by, idempotency_key, journey_id, journey_revision, device_session_id, step_id, execution_mode, workspace_id, approved_at, approved_by)
|
|
1474
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? IS NULL THEN NULL ELSE CURRENT_TIMESTAMP END, ?)
|
|
1342
1475
|
`).bind(
|
|
1343
1476
|
taskId, projectId, project?.tenantId ?? `tenant_${principal.aegisId}`, title, description, taskType,
|
|
1344
1477
|
Number.isFinite(Number(body?.priority)) ? Number(body?.priority) : 0, principal.aegisId, idempotencyKey,
|
|
@@ -1346,6 +1479,7 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1346
1479
|
typeof body?.deviceSessionId === "string" ? body.deviceSessionId : null,
|
|
1347
1480
|
typeof body?.stepId === "string" ? body.stepId : null,
|
|
1348
1481
|
executionMode, taskWorkspaceId,
|
|
1482
|
+
approvedBy, approvedBy,
|
|
1349
1483
|
).run();
|
|
1350
1484
|
await broadcastProjectEvent(env, projectId, { type: "task_event", taskId, projectId, status: "queued" });
|
|
1351
1485
|
queued(taskId);
|
|
@@ -1362,6 +1496,8 @@ async function claimAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1362
1496
|
.bind(taskId).first<{ projectId: string; executionMode: string }>();
|
|
1363
1497
|
if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
|
|
1364
1498
|
if (task.executionMode === "cloud") return response({ error: "This task is queued for Bugmole Cloud" }, 409);
|
|
1499
|
+
const paused = await projectInactiveRefusal(env, task.projectId);
|
|
1500
|
+
if (paused) return paused;
|
|
1365
1501
|
const lease = new Date(Date.now() + 60_000).toISOString();
|
|
1366
1502
|
const deviceSessionId = typeof body?.deviceSessionId === "string" ? body.deviceSessionId : null;
|
|
1367
1503
|
// Claiming is what counts as an attempt: it's the only point both the
|
|
@@ -1375,8 +1511,11 @@ async function claimAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1375
1511
|
WHERE id = ? AND attempts < ${MAX_TASK_ATTEMPTS}
|
|
1376
1512
|
AND (next_attempt_at IS NULL OR next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
1377
1513
|
AND (status = 'queued' OR status = 'blocked' OR (status = 'running' AND lease_expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))
|
|
1514
|
+
AND approved_at IS NOT NULL
|
|
1378
1515
|
`).bind(workerId, lease, deviceSessionId, taskId).run() as { meta?: { changes?: number } };
|
|
1379
1516
|
if (claimed.meta?.changes !== 1) {
|
|
1517
|
+
const waiting = await env.DB.prepare("SELECT 1 AS waiting FROM agent_tasks WHERE id = ? AND approved_at IS NULL").bind(taskId).first();
|
|
1518
|
+
if (waiting) return response({ error: "Task is waiting for approval" }, 409);
|
|
1380
1519
|
return response({ error: "Task is already claimed, complete, or has exhausted its attempts" }, 409);
|
|
1381
1520
|
}
|
|
1382
1521
|
await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status: "running", workerId });
|
|
@@ -1393,11 +1532,25 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1393
1532
|
`).bind(taskId).first<{ projectId: string; workerId: string | null; taskType: string; attempts: number; status: string; executionMode: string; workspaceId: string | null; description: string | null }>();
|
|
1394
1533
|
if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
|
|
1395
1534
|
if (task.workerId && body?.workerId !== task.workerId) return response({ error: "Worker lease does not match" }, 409);
|
|
1396
|
-
const
|
|
1397
|
-
if (!["running", "blocked", "completed", "error"].includes(
|
|
1398
|
-
if (
|
|
1535
|
+
const reported = typeof body?.status === "string" ? body.status : "running";
|
|
1536
|
+
if (!["running", "blocked", "completed", "error"].includes(reported)) return response({ error: "Invalid task status" }, 422);
|
|
1537
|
+
if (reported === "completed" && task.taskType === "ui" && (!body?.parityAuditId || typeof body?.parityMatchScore !== "number")) {
|
|
1399
1538
|
return response({ error: "UI tasks require parityAuditId and parityMatchScore before completion" }, 422);
|
|
1400
1539
|
}
|
|
1540
|
+
// A task in review is the worker's finished work waiting for a person.
|
|
1541
|
+
// Reporting completed again (the agent and its driver both close it) is a
|
|
1542
|
+
// no-op; anything else would overwrite what the reviewer is looking at.
|
|
1543
|
+
if (task.status === IN_REVIEW) {
|
|
1544
|
+
if (reported !== "completed") return response({ error: "Task is waiting for review" }, 409);
|
|
1545
|
+
return response({ updated: false, inReview: true, message: "Task is already waiting for review.", task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first() });
|
|
1546
|
+
}
|
|
1547
|
+
// Completion review follows the approval mode, the same risk model as
|
|
1548
|
+
// starting: the worker says completed, the project decides whether a
|
|
1549
|
+
// person looks at it first.
|
|
1550
|
+
const mode = reported === "completed"
|
|
1551
|
+
? approvalModeOf((await env.DB.prepare("SELECT task_approval_mode AS mode FROM projects WHERE id = ?").bind(task.projectId).first<{ mode: string }>())?.mode) ?? DEFAULT_APPROVAL_MODE
|
|
1552
|
+
: DEFAULT_APPROVAL_MODE;
|
|
1553
|
+
const status = reported === "completed" ? completionStatus(mode, task.taskType) : reported;
|
|
1401
1554
|
// Falling back to blocked schedules the next attempt rather than making
|
|
1402
1555
|
// the task immediately eligible again on the worker's next 5s poll.
|
|
1403
1556
|
const nextAttemptAt = status === "blocked" ? backoffUntil(task.attempts ?? 0) : null;
|
|
@@ -1409,7 +1562,7 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1409
1562
|
completed_at = CASE WHEN ? IN ('completed', 'error') THEN CURRENT_TIMESTAMP ELSE completed_at END
|
|
1410
1563
|
WHERE id = ?
|
|
1411
1564
|
`).bind(
|
|
1412
|
-
status, new Date(Date.now() + 60_000).toISOString(),
|
|
1565
|
+
status, status === IN_REVIEW ? null : new Date(Date.now() + 60_000).toISOString(),
|
|
1413
1566
|
typeof body?.progressMessage === "string" ? body.progressMessage : null,
|
|
1414
1567
|
body?.continuation ? JSON.stringify(body.continuation) : null,
|
|
1415
1568
|
typeof body?.parityMatchScore === "number" ? body.parityMatchScore : null,
|
|
@@ -1422,8 +1575,10 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1422
1575
|
if (status === "completed" && task.taskType === "discovery") {
|
|
1423
1576
|
await fillDiscoveredEnvironmentUrl(env, task.projectId, task.description);
|
|
1424
1577
|
}
|
|
1425
|
-
await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId,
|
|
1426
|
-
|
|
1578
|
+
await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, ...body, status });
|
|
1579
|
+
// In review, the worker's part is over: it stops being metered and frees
|
|
1580
|
+
// its cloud slot, though the task isn't completed until someone accepts it.
|
|
1581
|
+
const terminal = status === "completed" || status === "error" || status === IN_REVIEW;
|
|
1427
1582
|
if (status !== task.status) {
|
|
1428
1583
|
const event = {
|
|
1429
1584
|
taskId, projectId: task.projectId, workspaceId: task.workspaceId, executionMode: task.executionMode,
|
|
@@ -1440,34 +1595,170 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1440
1595
|
}
|
|
1441
1596
|
return response({
|
|
1442
1597
|
updated: true,
|
|
1598
|
+
...(status === IN_REVIEW ? { inReview: true, message: inReviewMessage(mode) } : {}),
|
|
1443
1599
|
task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first(),
|
|
1444
1600
|
...(stop ? { stop: true, reason: stop.reason, message: stop.message } : {}),
|
|
1445
1601
|
});
|
|
1446
1602
|
}
|
|
1447
1603
|
|
|
1604
|
+
// Reviewing is a person's call on work an AI worker says it finished. Only a
|
|
1605
|
+
// person may make it — never an API key or runner, or an agent could accept
|
|
1606
|
+
// its own work — and only someone who can manage the project.
|
|
1607
|
+
async function reviewTarget(env: Env, principal: Principal, taskId: string) {
|
|
1608
|
+
if (principal.apiKeyId || principal.runner) return { error: response({ error: "Only a person can review a task" }, 403) };
|
|
1609
|
+
const task = await env.DB.prepare(`
|
|
1610
|
+
SELECT project_id AS projectId, status, task_type AS taskType, description,
|
|
1611
|
+
execution_mode AS executionMode, workspace_id AS workspaceId, approved_at AS approvedAt
|
|
1612
|
+
FROM agent_tasks WHERE id = ?
|
|
1613
|
+
`).bind(taskId).first<{ projectId: string; status: string; taskType: string; description: string | null; executionMode: "self" | "cloud"; workspaceId: string | null; approvedAt: string | null }>();
|
|
1614
|
+
if (!task || !(await memberOfProject(env, principal, task.projectId))) return { error: response({ error: "Task not found" }, 404) };
|
|
1615
|
+
if (!(await memberOfProject(env, principal, task.projectId, "project.manage"))) return { error: response({ error: "Only project managers can review tasks" }, 403) };
|
|
1616
|
+
if (task.status !== IN_REVIEW) return { error: response({ error: "Only a task in review can be accepted or sent back" }, 409) };
|
|
1617
|
+
return { task };
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
async function acceptAgentTask(request: Request, env: Env, principal: Principal, taskId: string, ctx: ExecutionContext): Promise<Response> {
|
|
1621
|
+
const target = await reviewTarget(env, principal, taskId);
|
|
1622
|
+
if (target.error) return target.error;
|
|
1623
|
+
const { task } = target;
|
|
1624
|
+
const body = await requestJson(request);
|
|
1625
|
+
const note = typeof body?.note === "string" && body.note.trim() ? body.note.trim().slice(0, REVIEW_NOTE_MAX) : null;
|
|
1626
|
+
const reviewer = principal.email || principal.aegisId;
|
|
1627
|
+
const updated = await env.DB.prepare(`
|
|
1628
|
+
UPDATE agent_tasks SET status = 'completed', completed_at = CURRENT_TIMESTAMP,
|
|
1629
|
+
reviewed_at = CURRENT_TIMESTAMP, reviewed_by = ?, review_note = ?
|
|
1630
|
+
WHERE id = ? AND status = ?
|
|
1631
|
+
`).bind(reviewer, note, taskId, IN_REVIEW).run();
|
|
1632
|
+
if ((updated.meta?.changes ?? 0) === 0) return response({ error: "Only a task in review can be accepted or sent back" }, 409);
|
|
1633
|
+
if (task.taskType === "discovery") await fillDiscoveredEnvironmentUrl(env, task.projectId, task.description);
|
|
1634
|
+
await auditProject(env, principal, task.projectId, { action: "task.review_accepted", targetType: "task", targetId: taskId, ...(note ? { data: { note } } : {}) }, request);
|
|
1635
|
+
await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status: "completed" });
|
|
1636
|
+
const event = {
|
|
1637
|
+
taskId, projectId: task.projectId, workspaceId: task.workspaceId, executionMode: task.executionMode,
|
|
1638
|
+
// Already metered when it went into review; fromDispatcher keeps the
|
|
1639
|
+
// metering hook from dispatching again for a slot freed back then.
|
|
1640
|
+
status: "completed", previousStatus: IN_REVIEW, terminal: true, fromDispatcher: true,
|
|
1641
|
+
};
|
|
1642
|
+
for (const hook of taskChangedHooks) ctx.waitUntil(hook(env, event));
|
|
1643
|
+
return response({ accepted: true, task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first() });
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
async function sendBackAgentTask(request: Request, env: Env, principal: Principal, taskId: string, ctx: ExecutionContext): Promise<Response> {
|
|
1647
|
+
const target = await reviewTarget(env, principal, taskId);
|
|
1648
|
+
if (target.error) return target.error;
|
|
1649
|
+
const { task } = target;
|
|
1650
|
+
const body = await requestJson(request);
|
|
1651
|
+
const note = typeof body?.note === "string" ? body.note.trim().slice(0, REVIEW_NOTE_MAX) : "";
|
|
1652
|
+
if (!note) return response({ error: "Say what needs to change in note" }, 422);
|
|
1653
|
+
const fields = sentBackTaskFields(note);
|
|
1654
|
+
const reviewer = principal.email || principal.aegisId;
|
|
1655
|
+
// The approval stands: the reviewer is asking for more of the same work.
|
|
1656
|
+
const updated = await env.DB.prepare(`
|
|
1657
|
+
UPDATE agent_tasks SET status = ?, attempts = ?, next_attempt_at = NULL, lease_expires_at = NULL,
|
|
1658
|
+
worker_id = NULL, error_message = NULL, completed_at = NULL,
|
|
1659
|
+
progress_message = ?, heartbeat_at = CURRENT_TIMESTAMP,
|
|
1660
|
+
claimed_at = NULL, billed_at = NULL, billed_minutes = NULL,
|
|
1661
|
+
blocked_reason = NULL, dispatch_attempts = 0, next_dispatch_at = NULL,
|
|
1662
|
+
reviewed_at = CURRENT_TIMESTAMP, reviewed_by = ?, review_note = ?
|
|
1663
|
+
WHERE id = ? AND status = ?
|
|
1664
|
+
`).bind(fields.status, fields.attempts, fields.progressMessage, reviewer, fields.reviewNote, taskId, IN_REVIEW).run();
|
|
1665
|
+
if ((updated.meta?.changes ?? 0) === 0) return response({ error: "Only a task in review can be accepted or sent back" }, 409);
|
|
1666
|
+
await auditProject(env, principal, task.projectId, { action: "task.review_sent_back", targetType: "task", targetId: taskId, data: { note } }, request);
|
|
1667
|
+
await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status: "queued", progressMessage: fields.progressMessage });
|
|
1668
|
+
if (task.approvedAt) {
|
|
1669
|
+
for (const hook of taskQueuedHooks) ctx.waitUntil(hook(env, { taskId, projectId: task.projectId, workspaceId: task.workspaceId, executionMode: task.executionMode }));
|
|
1670
|
+
}
|
|
1671
|
+
return response({ sentBack: true, task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first() });
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1448
1674
|
// The counterpart to cancelling: a human answering what a parked task asked
|
|
1449
1675
|
// for and putting it back in the queue. Same trust boundary as cancel (no
|
|
1450
1676
|
// worker-lease check — the worker that gave up is exactly who we are
|
|
1451
1677
|
// overriding), and the attempt cap is deliberately not consulted, since
|
|
1452
1678
|
// being over it is the normal reason to be here.
|
|
1453
|
-
|
|
1679
|
+
//
|
|
1680
|
+
// Resuming used to only flip the row back to queued. A cloud task then sat
|
|
1681
|
+
// until the next cron tick (the dispatcher was never told), and a task only a
|
|
1682
|
+
// machine can run went back into a queue nothing on a cloud project ever
|
|
1683
|
+
// reads. Now a cloud task is handed to the dispatcher as approving does, a
|
|
1684
|
+
// machine-only task on a cloud project with no machine connected is refused
|
|
1685
|
+
// with what to do instead, and a discovery can be moved to Bugmole Cloud with
|
|
1686
|
+
// `executionMode: "cloud"`. Approval is decided again by the project's mode
|
|
1687
|
+
// (see resumedApproval).
|
|
1688
|
+
async function resumeAgentTask(request: Request, env: Env, principal: Principal, taskId: string, ctx: ExecutionContext): Promise<Response> {
|
|
1454
1689
|
if (!can(principal, "tasks:write") && !can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Task resume is not allowed" }, 403);
|
|
1455
|
-
const task = await env.DB.prepare(
|
|
1456
|
-
|
|
1690
|
+
const task = await env.DB.prepare(`
|
|
1691
|
+
SELECT project_id AS projectId, status, lease_expires_at AS leaseExpiresAt, task_type AS taskType,
|
|
1692
|
+
execution_mode AS executionMode, workspace_id AS workspaceId, description,
|
|
1693
|
+
approved_at AS approvedAt, approved_by AS approvedBy
|
|
1694
|
+
FROM agent_tasks WHERE id = ?
|
|
1695
|
+
`).bind(taskId).first<{
|
|
1696
|
+
projectId: string; status: string; leaseExpiresAt: string | null; taskType: string; executionMode: "self" | "cloud";
|
|
1697
|
+
workspaceId: string | null; description: string | null; approvedAt: string | null; approvedBy: string | null;
|
|
1698
|
+
}>();
|
|
1457
1699
|
if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
|
|
1458
1700
|
if (!isResumableTask(task)) {
|
|
1459
|
-
return response({
|
|
1701
|
+
return response({
|
|
1702
|
+
error: task.status === "completed" ? "Task is already finished"
|
|
1703
|
+
: task.status === IN_REVIEW ? "Task is waiting for review; accept it or send it back"
|
|
1704
|
+
: "Task is still held by a live worker",
|
|
1705
|
+
}, 409);
|
|
1460
1706
|
}
|
|
1461
1707
|
const body = await requestJson(request);
|
|
1708
|
+
const project = await env.DB.prepare(`
|
|
1709
|
+
SELECT storage_mode AS storageMode, default_execution_mode AS defaultExecutionMode,
|
|
1710
|
+
local_worker_seen_at AS localWorkerSeenAt, task_approval_mode AS approvalMode
|
|
1711
|
+
FROM projects WHERE id = ?
|
|
1712
|
+
`).bind(task.projectId).first<{ storageMode: string; defaultExecutionMode: string; localWorkerSeenAt: string | null; approvalMode: string }>();
|
|
1713
|
+
let executionMode = task.executionMode;
|
|
1714
|
+
let workspaceId = task.workspaceId;
|
|
1715
|
+
const requested = (body as { executionMode?: unknown } | null)?.executionMode;
|
|
1716
|
+
if (requested !== undefined && requested !== executionMode) {
|
|
1717
|
+
// Moving to the cloud passes the same checks as starting there.
|
|
1718
|
+
if (requested !== "cloud") return response({ error: "A task can only be moved to Bugmole Cloud" }, 422);
|
|
1719
|
+
if (task.taskType !== "discovery") return response({ error: "Only discovery can run on Bugmole Cloud" }, 422);
|
|
1720
|
+
workspaceId = await workspaceOfProject(env, task.projectId);
|
|
1721
|
+
if (!workspaceId) return response({ error: "This project isn't in a workspace" }, 422);
|
|
1722
|
+
const storageProblem = await cloudStorageError(env, task.projectId);
|
|
1723
|
+
if (storageProblem) return storageProblem;
|
|
1724
|
+
const accountProblem = await cloudAccountError(env, principal, workspaceId);
|
|
1725
|
+
if (accountProblem) return accountProblem;
|
|
1726
|
+
const blocked = await checkCloudRun(env, workspaceId);
|
|
1727
|
+
if (blocked) return blocked;
|
|
1728
|
+
const discoveryUrl = extractDiscoveryUrl(task.description ?? "");
|
|
1729
|
+
if (!discoveryUrl) return response({ error: "Discovery needs an http(s) URL to explore" }, 422);
|
|
1730
|
+
const domainProblem = await requireVerifiedDomainForCloud(env, workspaceId, discoveryUrl);
|
|
1731
|
+
if (domainProblem) return domainProblem;
|
|
1732
|
+
executionMode = "cloud";
|
|
1733
|
+
}
|
|
1734
|
+
const placement = resumePlacement({ executionMode, taskType: task.taskType }, project ?? {});
|
|
1735
|
+
if (placement.kind === "stranded") {
|
|
1736
|
+
return response({ error: strandedTaskMessage(placement.canRunInCloud), code: "needs_local_worker", canRunInCloud: placement.canRunInCloud }, 409);
|
|
1737
|
+
}
|
|
1462
1738
|
const note = typeof body?.note === "string" && body.note.trim() ? body.note.trim() : "Resumed by user";
|
|
1463
1739
|
const fields = resumedTaskFields();
|
|
1740
|
+
const { approvedBy } = resumedApproval(task, approvalModeOf(project?.approvalMode) ?? DEFAULT_APPROVAL_MODE);
|
|
1741
|
+
// SET expressions read the row as it was, so a person's approval keeps its time.
|
|
1464
1742
|
await env.DB.prepare(`
|
|
1465
1743
|
UPDATE agent_tasks SET status = ?, attempts = ?, next_attempt_at = NULL, lease_expires_at = NULL,
|
|
1466
1744
|
worker_id = NULL, error_message = NULL, completed_at = NULL,
|
|
1467
|
-
progress_message = ?, heartbeat_at = CURRENT_TIMESTAMP
|
|
1745
|
+
progress_message = ?, heartbeat_at = CURRENT_TIMESTAMP,
|
|
1746
|
+
execution_mode = ?, workspace_id = ?,
|
|
1747
|
+
claimed_at = NULL, billed_at = NULL, billed_minutes = NULL,
|
|
1748
|
+
blocked_reason = NULL, dispatch_attempts = 0, next_dispatch_at = NULL,
|
|
1749
|
+
approved_at = CASE WHEN ? IS NULL THEN NULL
|
|
1750
|
+
WHEN approved_by IS ? AND approved_at IS NOT NULL THEN approved_at
|
|
1751
|
+
ELSE CURRENT_TIMESTAMP END,
|
|
1752
|
+
approved_by = ?
|
|
1468
1753
|
WHERE id = ?
|
|
1469
|
-
`).bind(fields.status, fields.attempts, note, taskId).run();
|
|
1754
|
+
`).bind(fields.status, fields.attempts, note, executionMode, workspaceId, approvedBy, approvedBy, approvedBy, taskId).run();
|
|
1470
1755
|
await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status: fields.status, progressMessage: note });
|
|
1756
|
+
// A cloud task is only ever started by the dispatcher, so tell it now
|
|
1757
|
+
// rather than leaving the task to the next cron tick. One that waits for
|
|
1758
|
+
// approval again is handed over when someone approves it.
|
|
1759
|
+
if (approvedBy) {
|
|
1760
|
+
for (const hook of taskQueuedHooks) ctx.waitUntil(hook(env, { taskId, projectId: task.projectId, workspaceId, executionMode }));
|
|
1761
|
+
}
|
|
1471
1762
|
return response({ resumed: true, task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first() });
|
|
1472
1763
|
}
|
|
1473
1764
|
|
|
@@ -1476,6 +1767,29 @@ async function resumeAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1476
1767
|
// lease check (`body.workerId` must match `worker_id`) deliberately doesn't
|
|
1477
1768
|
// apply here, since the whole point is overriding a task that's stuck
|
|
1478
1769
|
// (claimed by a worker that died, or never should have been claimed).
|
|
1770
|
+
// Approving is a person's go-ahead for a task the project's approval mode held
|
|
1771
|
+
// back. Like cancel and resume it is a human action on the task, so it needs
|
|
1772
|
+
// project management rights rather than a worker lease.
|
|
1773
|
+
async function approveAgentTask(request: Request, env: Env, principal: Principal, taskId: string, ctx: ExecutionContext): Promise<Response> {
|
|
1774
|
+
if (!can(principal, "tasks:write") && !can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Task approval is not allowed" }, 403);
|
|
1775
|
+
const task = await env.DB.prepare(`
|
|
1776
|
+
SELECT project_id AS projectId, status, approved_at AS approvedAt, execution_mode AS executionMode, workspace_id AS workspaceId
|
|
1777
|
+
FROM agent_tasks WHERE id = ?
|
|
1778
|
+
`).bind(taskId).first<{ projectId: string; status: string; approvedAt: string | null; executionMode: "self" | "cloud"; workspaceId: string | null }>();
|
|
1779
|
+
if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
|
|
1780
|
+
if (!(await memberOfProject(env, principal, task.projectId, "project.manage"))) return response({ error: "Only project managers can approve tasks" }, 403);
|
|
1781
|
+
if (task.approvedAt) return response({ error: "Task is already approved" }, 409);
|
|
1782
|
+
if (!["queued", "blocked"].includes(task.status)) return response({ error: "Only a waiting task can be approved" }, 409);
|
|
1783
|
+
const approvedBy = principal.email || principal.aegisId;
|
|
1784
|
+
await env.DB.prepare("UPDATE agent_tasks SET approved_at = CURRENT_TIMESTAMP, approved_by = ? WHERE id = ? AND approved_at IS NULL")
|
|
1785
|
+
.bind(approvedBy, taskId).run();
|
|
1786
|
+
await auditProject(env, principal, task.projectId, { action: "task.approved", targetType: "task", targetId: taskId }, request);
|
|
1787
|
+
await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status: task.status });
|
|
1788
|
+
// A cloud task was never handed to a runner while it waited; do that now.
|
|
1789
|
+
for (const hook of taskQueuedHooks) ctx.waitUntil(hook(env, { taskId, projectId: task.projectId, workspaceId: task.workspaceId, executionMode: task.executionMode }));
|
|
1790
|
+
return response({ approved: true, task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first() });
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1479
1793
|
async function cancelAgentTask(request: Request, env: Env, principal: Principal, taskId: string): Promise<Response> {
|
|
1480
1794
|
if (!can(principal, "tasks:write") && !can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Task cancellation is not allowed" }, 403);
|
|
1481
1795
|
const task = await env.DB.prepare("SELECT project_id AS projectId, status FROM agent_tasks WHERE id = ?")
|
|
@@ -2006,9 +2320,10 @@ const registryWorker = {
|
|
|
2006
2320
|
if (artifactsMatch && request.method === "GET") {
|
|
2007
2321
|
const projectId = decodeURIComponent(artifactsMatch[1]);
|
|
2008
2322
|
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
2323
|
+
const visible = await runEvidenceVisibility(env, projectId);
|
|
2009
2324
|
return artifactsMatch[2]
|
|
2010
|
-
? getProjectArtifact(env.ARTIFACTS, projectId, url)
|
|
2011
|
-
: listProjectArtifacts(env.ARTIFACTS, projectId, url);
|
|
2325
|
+
? getProjectArtifact(env.ARTIFACTS, projectId, url, visible)
|
|
2326
|
+
: listProjectArtifacts(env.ARTIFACTS, projectId, url, visible);
|
|
2012
2327
|
}
|
|
2013
2328
|
const runMatch = url.pathname.match(/^\/api\/runs\/([^/]+)$/);
|
|
2014
2329
|
if (runMatch && request.method === "GET") return getRun(env, principal, decodeURIComponent(runMatch[1]));
|
|
@@ -2016,14 +2331,20 @@ const registryWorker = {
|
|
|
2016
2331
|
const claimMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/claim$/);
|
|
2017
2332
|
if (claimMatch && request.method === "POST") return claimRun(request, env, principal, decodeURIComponent(claimMatch[1]), context);
|
|
2018
2333
|
const tasksMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/tasks$/);
|
|
2019
|
-
if (tasksMatch && request.method === "GET") return listAgentTasks(env, principal, decodeURIComponent(tasksMatch[1]));
|
|
2334
|
+
if (tasksMatch && request.method === "GET") return listAgentTasks(env, principal, decodeURIComponent(tasksMatch[1]), context);
|
|
2020
2335
|
if (tasksMatch && request.method === "POST") return createAgentTask(request, env, principal, decodeURIComponent(tasksMatch[1]), context);
|
|
2021
2336
|
const taskClaimMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/claim$/);
|
|
2022
2337
|
if (taskClaimMatch && request.method === "POST") return claimAgentTask(request, env, principal, decodeURIComponent(taskClaimMatch[1]));
|
|
2023
2338
|
const taskCancelMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/cancel$/);
|
|
2024
2339
|
if (taskCancelMatch && request.method === "POST") return cancelAgentTask(request, env, principal, decodeURIComponent(taskCancelMatch[1]));
|
|
2340
|
+
const taskApproveMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/approve$/);
|
|
2341
|
+
if (taskApproveMatch && request.method === "POST") return approveAgentTask(request, env, principal, decodeURIComponent(taskApproveMatch[1]), context);
|
|
2342
|
+
const taskAcceptMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/accept$/);
|
|
2343
|
+
if (taskAcceptMatch && request.method === "POST") return acceptAgentTask(request, env, principal, decodeURIComponent(taskAcceptMatch[1]), context);
|
|
2344
|
+
const taskSendBackMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/send-back$/);
|
|
2345
|
+
if (taskSendBackMatch && request.method === "POST") return sendBackAgentTask(request, env, principal, decodeURIComponent(taskSendBackMatch[1]), context);
|
|
2025
2346
|
const taskResumeMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/resume$/);
|
|
2026
|
-
if (taskResumeMatch && request.method === "POST") return resumeAgentTask(request, env, principal, decodeURIComponent(taskResumeMatch[1]));
|
|
2347
|
+
if (taskResumeMatch && request.method === "POST") return resumeAgentTask(request, env, principal, decodeURIComponent(taskResumeMatch[1]), context);
|
|
2027
2348
|
const taskMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)$/);
|
|
2028
2349
|
if (taskMatch && request.method === "POST") return updateAgentTask(request, env, principal, decodeURIComponent(taskMatch[1]), context);
|
|
2029
2350
|
if (request.method === "GET" && url.pathname === "/api/device-sessions") return listDeviceSessions(env, principal);
|