@bugmole/cli 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/scripts/bugmole-admin.mjs +194 -0
- package/scripts/bugmole-admin.test.mjs +62 -0
- package/scripts/bugmole.test.ts +94 -2
- package/scripts/bugmole.ts +233 -46
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -6
- package/spec/domain_rules.yaml +139 -0
- package/spec/roles.yaml +20 -0
- package/spec/test-case-results.schema.json +20 -4
- package/spec/test-cases.schema.json +131 -16
- package/src/billing/plan-catalog.test.ts +82 -2
- package/src/billing/plan-catalog.ts +212 -9
- package/src/integrations/jira.ts +264 -0
- package/src/mcp/roles-and-review.test.ts +111 -0
- package/src/mcp/server.ts +358 -22
- package/src/mcp/write-test-cases.test.ts +68 -0
- package/src/registry/control-plane-client.ts +121 -6
- package/src/registry/migrations/0036_task_approval.sql +11 -0
- package/src/registry/migrations/0037_spec_proposals.sql +27 -0
- package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
- package/src/registry/migrations/0039_project_secrets.sql +37 -0
- package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
- package/src/registry/migrations/0041_jira_integration.sql +75 -0
- package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
- package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
- package/src/registry/migrations/0044_project_identity.sql +31 -0
- package/src/registry/project-identity.test.ts +99 -0
- package/src/registry/project-identity.ts +206 -0
- package/src/registry/roles.test.ts +66 -0
- package/src/registry/roles.ts +201 -0
- package/src/registry/task-scheduling.test.ts +94 -0
- package/src/registry/task-scheduling.ts +199 -2
- package/src/registry/test-case-revisions.test.ts +57 -0
- package/src/registry/test-case-revisions.ts +132 -0
- package/src/registry-worker/ai/routes.ts +3 -3
- package/src/registry-worker/artifacts.ts +23 -4
- package/src/registry-worker/billing/billing-core.test.ts +1 -1
- package/src/registry-worker/billing/checkout-routes.ts +53 -4
- package/src/registry-worker/billing/enforcement.ts +67 -12
- package/src/registry-worker/billing/paypal/api.ts +14 -0
- package/src/registry-worker/billing/paypal/client.ts +9 -0
- package/src/registry-worker/billing/paypal/provider.ts +10 -1
- package/src/registry-worker/billing/paypal.test.ts +108 -1
- package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
- package/src/registry-worker/billing/provider.ts +8 -0
- package/src/registry-worker/billing/routes.ts +2 -1
- package/src/registry-worker/billing/subscriptions.ts +114 -4
- package/src/registry-worker/core.ts +22 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/feature-access.ts +165 -0
- package/src/registry-worker/feature-flags/admin.ts +300 -0
- package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
- package/src/registry-worker/feature-flags/routes.ts +102 -0
- package/src/registry-worker/features.ts +9 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/flags.ts +80 -18
- package/src/registry-worker/github/checks.ts +4 -4
- package/src/registry-worker/hooks.ts +9 -0
- package/src/registry-worker/identity/oidc.test.ts +103 -0
- package/src/registry-worker/identity/oidc.ts +176 -0
- package/src/registry-worker/index.ts +477 -174
- package/src/registry-worker/jira/connection.ts +111 -0
- package/src/registry-worker/jira/jira.test.ts +404 -0
- package/src/registry-worker/jira/routes.ts +432 -0
- package/src/registry-worker/jira/workflow.ts +577 -0
- package/src/registry-worker/jobs/retention.ts +21 -5
- package/src/registry-worker/mcp/tools.ts +2 -1
- package/src/registry-worker/notifications/alerts.ts +4 -4
- package/src/registry-worker/notifications/notifications.test.ts +11 -0
- package/src/registry-worker/notifications/routes.ts +9 -10
- package/src/registry-worker/notifications/teams.ts +6 -2
- package/src/registry-worker/org/routes.ts +4 -4
- package/src/registry-worker/projects/identity.ts +194 -0
- package/src/registry-worker/projects/inactivity.ts +162 -0
- package/src/registry-worker/projects/projects.test.ts +283 -0
- package/src/registry-worker/proposals/proposals.test.ts +80 -0
- package/src/registry-worker/proposals/routes.ts +183 -0
- package/src/registry-worker/roles/roles.test.ts +84 -0
- package/src/registry-worker/roles/routes.ts +141 -0
- package/src/registry-worker/runner/dispatch.ts +1 -0
- package/src/registry-worker/runner/routes.ts +22 -5
- package/src/registry-worker/runner/runner.test.ts +21 -0
- package/src/registry-worker/runner/tokens.ts +8 -0
- package/src/registry-worker/secrets/crypto.ts +135 -0
- package/src/registry-worker/secrets/routes.ts +296 -0
- package/src/registry-worker/secrets/secrets.test.ts +237 -0
- package/src/registry-worker/signup/policy.ts +2 -2
- package/src/registry-worker/signup/routes.ts +12 -3
- package/src/registry-worker/sso/membership.ts +4 -2
- package/src/registry-worker/sso/routes.ts +24 -8
- package/src/registry-worker/sso/sso.test.ts +3 -2
- package/src/registry-worker/task-approval.test.ts +67 -0
- package/src/registry-worker/task-resume.test.ts +196 -0
- package/src/registry-worker/task-review.test.ts +121 -0
- package/src/runtime/appium-driver.ts +1 -1
- package/src/runtime/apply-proposals.test.ts +74 -0
- package/src/runtime/apply-proposals.ts +41 -0
- package/src/runtime/cloud-secrets.test.ts +201 -0
- package/src/runtime/cloud-secrets.ts +210 -0
- package/src/runtime/config-validate.ts +12 -9
- package/src/runtime/cursor-driver-run.ts +14 -3
- package/src/runtime/discovery-task.test.ts +26 -1
- package/src/runtime/discovery-task.ts +67 -6
- package/src/runtime/executor.ts +18 -10
- package/src/runtime/explorer.test.ts +32 -0
- package/src/runtime/explorer.ts +48 -0
- package/src/runtime/flow-language.ts +22 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +39 -2
- package/src/runtime/journey-graph.test.ts +18 -0
- package/src/runtime/local-registry-stub.test.ts +64 -0
- package/src/runtime/local-registry-stub.ts +255 -7
- package/src/runtime/local-vault.ts +65 -0
- package/src/runtime/pipeline.test.ts +23 -0
- package/src/runtime/pipeline.ts +61 -20
- package/src/runtime/planner.test.ts +24 -1
- package/src/runtime/planner.ts +88 -23
- package/src/runtime/playwright-driver.test.ts +21 -2
- package/src/runtime/playwright-driver.ts +49 -7
- package/src/runtime/project-identity.test.ts +161 -0
- package/src/runtime/project-identity.ts +232 -0
- package/src/runtime/project-roles.test.ts +107 -0
- package/src/runtime/project-roles.ts +158 -0
- package/src/runtime/propose-cli.ts +66 -0
- package/src/runtime/record-run-verdicts.test.ts +58 -0
- package/src/runtime/record-run-verdicts.ts +38 -4
- package/src/runtime/reporter.ts +1 -1
- package/src/runtime/reset.ts +2 -0
- package/src/runtime/roles-cli.test.ts +47 -0
- package/src/runtime/roles-cli.ts +60 -0
- package/src/runtime/run-once.ts +68 -5
- package/src/runtime/scenario-matrix.test.ts +41 -0
- package/src/runtime/scenario-matrix.ts +98 -0
- package/src/runtime/secret-driver.ts +77 -0
- package/src/runtime/secret-redaction.ts +69 -0
- package/src/runtime/secret-sources.test.ts +442 -0
- package/src/runtime/secret-sources.ts +176 -0
- package/src/runtime/secrets-cli.ts +146 -0
- package/src/runtime/serve-worker.ts +64 -6
- package/src/runtime/site-discovery.test.ts +47 -4
- package/src/runtime/site-discovery.ts +79 -9
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.ts +5 -2
- package/src/storage/create-object-store.ts +5 -1
- package/src/storage/object-store.ts +12 -1
- package/src/storage/test-case-results.test.ts +64 -0
- package/src/storage/test-case-results.ts +49 -0
- package/src/vendor/byok.ts +371 -0
|
@@ -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,29 +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
|
-
-- A project that already has an environment keeps it: pairing
|
|
188
|
-
-- another machine must not add a same-named twin beside it.
|
|
189
|
-
INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
|
|
190
|
-
SELECT ?, ?, 'default', ?, '', 'active', 1
|
|
191
|
-
WHERE NOT EXISTS (SELECT 1 FROM project_environments WHERE project_id = ?)
|
|
192
|
-
`).bind(environmentId, attempt.projectId, attempt.environment, attempt.projectId),
|
|
193
|
-
env.DB.prepare(`
|
|
194
|
-
INSERT INTO project_memberships (project_id, aegis_id, role, status)
|
|
195
|
-
VALUES (?, ?, 'owner', 'active')
|
|
196
|
-
ON CONFLICT(project_id, aegis_id) DO UPDATE SET status = 'active'
|
|
197
|
-
`).bind(attempt.projectId, principal.aegisId),
|
|
299
|
+
...projectRows,
|
|
198
300
|
env.DB.prepare(`
|
|
199
301
|
UPDATE device_authorizations SET status = 'completed', aegis_id = ?, api_key_id = ?, completed_at = ?
|
|
200
302
|
WHERE attempt_id = ? AND status = 'claimed'
|
|
@@ -204,7 +306,7 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
|
|
|
204
306
|
await releaseClaimedDeviceAuthorization(env, body.attemptId);
|
|
205
307
|
throw error;
|
|
206
308
|
}
|
|
207
|
-
return response({ claimed: true, alreadyInitialized: true }, 200);
|
|
309
|
+
return response({ claimed: true, alreadyInitialized: true, projectId, joined: placement.joined }, 200);
|
|
208
310
|
}
|
|
209
311
|
|
|
210
312
|
const id = `key_${randomToken(12)}`;
|
|
@@ -223,32 +325,12 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
|
|
|
223
325
|
VALUES (?, ?, 'owner', 'active')
|
|
224
326
|
ON CONFLICT(workspace_id, aegis_id) DO UPDATE SET status = 'active'
|
|
225
327
|
`).bind(workspaceId, principal.aegisId),
|
|
226
|
-
|
|
227
|
-
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, created_at)
|
|
228
|
-
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
229
|
-
ON CONFLICT(id) DO UPDATE SET name = excluded.name, environment = excluded.environment,
|
|
230
|
-
storage_provider = excluded.storage_provider, workspace_id = excluded.workspace_id
|
|
231
|
-
`).bind(attempt.projectId, workspaceId, workspaceId, attempt.projectName, attempt.environment, currentStorageProvider(attempt.storageProvider)),
|
|
232
|
-
env.DB.prepare(`
|
|
233
|
-
INSERT INTO project_apps (id, project_id, name, type, is_default)
|
|
234
|
-
VALUES ('default', ?, ?, 'web', 1)
|
|
235
|
-
ON CONFLICT(project_id, id) DO UPDATE SET name = excluded.name
|
|
236
|
-
`).bind(attempt.projectId, attempt.projectName),
|
|
237
|
-
env.DB.prepare(`
|
|
238
|
-
INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
|
|
239
|
-
SELECT ?, ?, 'default', ?, '', 'active', 1
|
|
240
|
-
WHERE NOT EXISTS (SELECT 1 FROM project_environments WHERE project_id = ?)
|
|
241
|
-
`).bind(environmentId, attempt.projectId, attempt.environment, attempt.projectId),
|
|
242
|
-
env.DB.prepare(`
|
|
243
|
-
INSERT INTO project_memberships (project_id, aegis_id, role, status)
|
|
244
|
-
VALUES (?, ?, 'owner', 'active')
|
|
245
|
-
ON CONFLICT(project_id, aegis_id) DO UPDATE SET status = 'active'
|
|
246
|
-
`).bind(attempt.projectId, principal.aegisId),
|
|
328
|
+
...projectRows,
|
|
247
329
|
env.DB.prepare(`
|
|
248
330
|
INSERT INTO api_keys (id, aegis_id, name, token_hash, scopes_json, project_ids_json, expires_at)
|
|
249
331
|
VALUES (?, ?, ?, ?, ?, ?, NULL)
|
|
250
332
|
`).bind(id, principal.aegisId, `${attempt.projectName} CLI`, await sha256(token),
|
|
251
|
-
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])),
|
|
252
334
|
env.DB.prepare(`
|
|
253
335
|
UPDATE device_authorizations
|
|
254
336
|
SET status = 'completed', aegis_id = ?, api_key_id = ?, encrypted_token = ?, token_iv = ?, completed_at = ?
|
|
@@ -259,7 +341,7 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
|
|
|
259
341
|
await releaseClaimedDeviceAuthorization(env, body.attemptId);
|
|
260
342
|
throw error;
|
|
261
343
|
}
|
|
262
|
-
return response({ claimed: true, alreadyInitialized: false }, 200);
|
|
344
|
+
return response({ claimed: true, alreadyInitialized: false, projectId, joined: placement.joined }, 200);
|
|
263
345
|
}
|
|
264
346
|
|
|
265
347
|
async function releaseClaimedDeviceAuthorization(env: Env, attemptId: string): Promise<void> {
|
|
@@ -285,15 +367,18 @@ async function pollDeviceAuthorization(request: Request, env: Env): Promise<Resp
|
|
|
285
367
|
if (!body?.attemptId || !body.pollSecret) return response({ error: "attemptId and pollSecret are required" }, 422);
|
|
286
368
|
const attempt = await env.DB.prepare(`
|
|
287
369
|
SELECT status, expires_at AS expiresAt, encrypted_token AS encryptedToken, token_iv AS tokenIv,
|
|
288
|
-
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
|
|
289
372
|
FROM device_authorizations WHERE attempt_id = ?
|
|
290
373
|
`).bind(body.attemptId).first<{
|
|
374
|
+
projectId: string;
|
|
291
375
|
status: string;
|
|
292
376
|
expiresAt: string;
|
|
293
377
|
encryptedToken: string | null;
|
|
294
378
|
tokenIv: string | null;
|
|
295
379
|
pollSecretHash: string;
|
|
296
380
|
aegisEmail: string | null;
|
|
381
|
+
refusalJson: string | null;
|
|
297
382
|
}>();
|
|
298
383
|
if (!attempt || !constantTimeEqual(await sha256(body.pollSecret), attempt.pollSecretHash)) {
|
|
299
384
|
return response({ error: "Device authorization not found" }, 404);
|
|
@@ -304,92 +389,22 @@ async function pollDeviceAuthorization(request: Request, env: Env): Promise<Resp
|
|
|
304
389
|
return response({ status: "expired" }, 410);
|
|
305
390
|
}
|
|
306
391
|
if (attempt.status === "pending") return response({ status: "pending" }, 202);
|
|
307
|
-
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);
|
|
308
397
|
const result = await env.DB.prepare(`
|
|
309
398
|
UPDATE device_authorizations SET encrypted_token = NULL, token_iv = NULL
|
|
310
399
|
WHERE attempt_id = ? AND status = 'completed' AND encrypted_token = ?
|
|
311
400
|
`).bind(body.attemptId, attempt.encryptedToken).run() as { meta?: { changes?: number } };
|
|
312
|
-
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);
|
|
313
402
|
const token = await decryptForSecret(
|
|
314
403
|
attempt.encryptedToken,
|
|
315
404
|
attempt.tokenIv,
|
|
316
405
|
await sha256(body.pollSecret),
|
|
317
406
|
);
|
|
318
|
-
return response({ status: "completed", alreadyInitialized: false, token, email: attempt.aegisEmail }, 200);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
async function registerProject(request: Request, env: Env, principal: Principal): Promise<Response> {
|
|
322
|
-
if (!can(principal, "projects:write")) return response({ error: "Project registration is not allowed" }, 403);
|
|
323
|
-
const body = await request.json().catch(() => null) as {
|
|
324
|
-
id?: string;
|
|
325
|
-
name?: string;
|
|
326
|
-
appId?: string;
|
|
327
|
-
appName?: string;
|
|
328
|
-
workspaceId?: string;
|
|
329
|
-
workspaceName?: string;
|
|
330
|
-
tenantId?: string;
|
|
331
|
-
environment?: string;
|
|
332
|
-
storageProvider?: string;
|
|
333
|
-
} | null;
|
|
334
|
-
if (!body?.id || !body.name || !body.environment || !body.storageProvider) {
|
|
335
|
-
return response({ error: "id, name, environment, and storageProvider are required" }, 422);
|
|
336
|
-
}
|
|
337
|
-
if (!projectAllowed(principal, body.id)) return response({ error: "Project scope is not allowed" }, 403);
|
|
338
|
-
const workspaceId = body.workspaceId ?? body.tenantId ?? `workspace_${principal.aegisId}`;
|
|
339
|
-
const tenantId = body.tenantId ?? workspaceId;
|
|
340
|
-
const existingWorkspace = await env.DB.prepare(
|
|
341
|
-
"SELECT id FROM workspaces WHERE id = ?",
|
|
342
|
-
).bind(workspaceId).first<{ id: string }>();
|
|
343
|
-
if (existingWorkspace && !(await memberOfWorkspace(env, principal, workspaceId, "workspace.write"))) {
|
|
344
|
-
return response({ error: "Workspace scope is not allowed" }, 403);
|
|
345
|
-
}
|
|
346
|
-
// Re-registering a project is how the CLI refreshes it, but only for people
|
|
347
|
-
// who may manage it; otherwise an id could be used to move someone else's
|
|
348
|
-
// project into another workspace.
|
|
349
|
-
const existingProject = await env.DB.prepare("SELECT workspace_id AS workspaceId FROM projects WHERE id = ?")
|
|
350
|
-
.bind(body.id).first<{ workspaceId: string }>();
|
|
351
|
-
if (existingProject && !(await memberOfProject(env, principal, body.id, "project.manage"))) {
|
|
352
|
-
return response({ error: "A project with this id already exists" }, 409);
|
|
353
|
-
}
|
|
354
|
-
if (existingProject && existingProject.workspaceId !== workspaceId) {
|
|
355
|
-
return response({ error: "This project belongs to another workspace" }, 409);
|
|
356
|
-
}
|
|
357
|
-
const joinedAt = new Date().toISOString();
|
|
358
|
-
await env.DB.batch([
|
|
359
|
-
env.DB.prepare(`
|
|
360
|
-
INSERT INTO aegis_users (id) VALUES (?) ON CONFLICT(id) DO NOTHING
|
|
361
|
-
`).bind(principal.aegisId),
|
|
362
|
-
env.DB.prepare(`
|
|
363
|
-
INSERT INTO workspaces (id, name, created_by)
|
|
364
|
-
VALUES (?, ?, ?)
|
|
365
|
-
ON CONFLICT(id) DO NOTHING
|
|
366
|
-
`).bind(workspaceId, body.workspaceName ?? workspaceId, principal.aegisId),
|
|
367
|
-
// Only the creator of a brand-new workspace becomes its owner; an existing
|
|
368
|
-
// membership (including a removed one) is left alone.
|
|
369
|
-
env.DB.prepare(`
|
|
370
|
-
INSERT INTO workspace_memberships (workspace_id, aegis_id, role, status, created_at)
|
|
371
|
-
SELECT ?, ?, 'owner', 'active', ?
|
|
372
|
-
WHERE NOT EXISTS (SELECT 1 FROM workspace_memberships WHERE workspace_id = ?)
|
|
373
|
-
`).bind(workspaceId, principal.aegisId, joinedAt, workspaceId),
|
|
374
|
-
env.DB.prepare(`
|
|
375
|
-
INSERT INTO project_apps (id, project_id, name, type, is_default)
|
|
376
|
-
VALUES (?, ?, ?, 'web', 1)
|
|
377
|
-
ON CONFLICT(project_id, id) DO NOTHING
|
|
378
|
-
`).bind(body.appId?.trim() || "default", body.id, body.appName?.trim() || body.name),
|
|
379
|
-
env.DB.prepare(`
|
|
380
|
-
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, created_at)
|
|
381
|
-
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
382
|
-
ON CONFLICT(id) DO UPDATE SET name = excluded.name, environment = excluded.environment,
|
|
383
|
-
storage_provider = excluded.storage_provider, workspace_id = excluded.workspace_id
|
|
384
|
-
`).bind(body.id, workspaceId, tenantId, body.name, body.environment, currentStorageProvider(body.storageProvider)),
|
|
385
|
-
env.DB.prepare(`
|
|
386
|
-
INSERT INTO project_memberships (project_id, aegis_id, role, status)
|
|
387
|
-
VALUES (?, ?, 'owner', 'active')
|
|
388
|
-
ON CONFLICT(project_id, aegis_id) DO NOTHING
|
|
389
|
-
`).bind(body.id, principal.aegisId),
|
|
390
|
-
]);
|
|
391
|
-
await auditProject(env, principal, body.id, { action: "project.registered", targetType: "project", targetId: body.id, data: { environment: body.environment } }, request);
|
|
392
|
-
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);
|
|
393
408
|
}
|
|
394
409
|
|
|
395
410
|
async function listWorkspaces(env: Env, principal: Principal): Promise<Response> {
|
|
@@ -402,7 +417,7 @@ async function listWorkspaces(env: Env, principal: Principal): Promise<Response>
|
|
|
402
417
|
`).bind(principal.aegisId).all();
|
|
403
418
|
const workspaces = await Promise.all(rows.results.map(async (workspace) => {
|
|
404
419
|
const projects = await env.DB.prepare(`
|
|
405
|
-
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
|
|
406
421
|
FROM projects p
|
|
407
422
|
WHERE p.workspace_id = ? AND p.deleted_at IS NULL
|
|
408
423
|
ORDER BY p.created_at ASC
|
|
@@ -463,6 +478,10 @@ async function createManagedProject(request: Request, env: Env, principal: Princ
|
|
|
463
478
|
if (!can(principal, "projects:write") && !can(principal, "*")) return response({ error: "Project creation is not allowed" }, 403);
|
|
464
479
|
const body = await request.json().catch(() => null) as {
|
|
465
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;
|
|
466
485
|
} | null;
|
|
467
486
|
const projectId = body?.id?.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
|
|
468
487
|
const name = body?.name?.trim();
|
|
@@ -471,14 +490,32 @@ async function createManagedProject(request: Request, env: Env, principal: Princ
|
|
|
471
490
|
return response({ error: "id, name, workspaceId, environment, and storageProvider are required" }, 422);
|
|
472
491
|
}
|
|
473
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
|
+
}
|
|
474
510
|
const projectLimit = await checkProjectLimit(env, workspaceId);
|
|
475
511
|
if (projectLimit) return projectLimit;
|
|
476
512
|
try {
|
|
477
513
|
await env.DB.batch([
|
|
478
514
|
env.DB.prepare(`
|
|
479
|
-
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, storage_mode,
|
|
480
|
-
|
|
481
|
-
|
|
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)),
|
|
482
519
|
env.DB.prepare("INSERT INTO project_memberships (project_id, aegis_id, role, status) VALUES (?, ?, 'owner', 'active')")
|
|
483
520
|
.bind(projectId, principal.aegisId),
|
|
484
521
|
env.DB.prepare("INSERT INTO project_apps (id, project_id, name, type, is_default) VALUES ('default', ?, ?, 'web', 1)")
|
|
@@ -487,8 +524,12 @@ async function createManagedProject(request: Request, env: Env, principal: Princ
|
|
|
487
524
|
} catch {
|
|
488
525
|
return response({ error: "A project with this id already exists" }, 409);
|
|
489
526
|
}
|
|
490
|
-
|
|
491
|
-
|
|
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);
|
|
492
533
|
}
|
|
493
534
|
|
|
494
535
|
async function updateProject(request: Request, env: Env, principal: Principal, projectId: string): Promise<Response> {
|
|
@@ -588,7 +629,8 @@ async function listProjects(env: Env, principal: Principal): Promise<Response> {
|
|
|
588
629
|
END AS workspaceName,
|
|
589
630
|
p.tenant_id AS tenantId, p.name, p.environment,
|
|
590
631
|
p.storage_provider AS storageProvider, p.storage_mode AS storageMode,
|
|
591
|
-
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
|
|
592
634
|
FROM projects p
|
|
593
635
|
JOIN workspace_memberships wm ON wm.workspace_id = p.workspace_id
|
|
594
636
|
JOIN workspaces w ON w.id = p.workspace_id
|
|
@@ -624,8 +666,10 @@ async function listProjects(env: Env, principal: Principal): Promise<Response> {
|
|
|
624
666
|
status: string;
|
|
625
667
|
isDefault: number;
|
|
626
668
|
}>();
|
|
669
|
+
const { identityKind, identityKey, ...rest } = project as Record<string, unknown> & { identityKind?: string | null; identityKey?: string | null };
|
|
627
670
|
return {
|
|
628
|
-
...
|
|
671
|
+
...rest,
|
|
672
|
+
identity: identityView({ identityKind, identityKey }),
|
|
629
673
|
apps: apps.results.map((app) => ({
|
|
630
674
|
...app,
|
|
631
675
|
location: app.location ?? undefined,
|
|
@@ -724,6 +768,9 @@ async function createEnvironment(
|
|
|
724
768
|
"SELECT id FROM project_environments WHERE project_id = ? AND COALESCE(app_id, 'default') = ? AND lower(trim(name)) = lower(?)",
|
|
725
769
|
).bind(projectId, appId, body.name.trim()).first<{ id: string }>();
|
|
726
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;
|
|
727
774
|
try {
|
|
728
775
|
await env.DB.prepare(`
|
|
729
776
|
INSERT INTO project_environments (id, project_id, app_id, name, base_url, api_url)
|
|
@@ -780,6 +827,9 @@ async function updateEnvironment(
|
|
|
780
827
|
.bind(projectId, body.appId.trim()).first<{ id: string }>();
|
|
781
828
|
if (!app) return response({ error: "App does not belong to this project" }, 422);
|
|
782
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;
|
|
783
833
|
const result = await env.DB.prepare(`
|
|
784
834
|
UPDATE project_environments
|
|
785
835
|
SET app_id = COALESCE(?, app_id), name = ?, base_url = ?, api_url = ?, status = COALESCE(?, status),
|
|
@@ -918,7 +968,7 @@ function parseJsonColumn(value: unknown): unknown {
|
|
|
918
968
|
}
|
|
919
969
|
|
|
920
970
|
function hydrateRun<T extends Record<string, unknown>>(row: T) {
|
|
921
|
-
const { targetsJson, targetResultsJson, fixFromJson, ...rest } = row as Record<string, unknown>;
|
|
971
|
+
const { targetsJson, targetResultsJson, fixFromJson, caseScopeJson, ...rest } = row as Record<string, unknown>;
|
|
922
972
|
const started = typeof rest.startedAt === "string" ? Date.parse(`${rest.startedAt.replace(" ", "T")}Z`) : NaN;
|
|
923
973
|
const completed = typeof rest.completedAt === "string" ? Date.parse(`${rest.completedAt.replace(" ", "T")}Z`) : NaN;
|
|
924
974
|
return {
|
|
@@ -926,10 +976,34 @@ function hydrateRun<T extends Record<string, unknown>>(row: T) {
|
|
|
926
976
|
targets: parseJsonColumn(targetsJson),
|
|
927
977
|
targetResults: parseJsonColumn(targetResultsJson),
|
|
928
978
|
fixFrom: parseJsonColumn(fixFromJson),
|
|
979
|
+
caseScope: parseJsonColumn(caseScopeJson),
|
|
929
980
|
durationMs: Number.isFinite(started) && Number.isFinite(completed) ? Math.max(0, completed - started) : null,
|
|
930
981
|
};
|
|
931
982
|
}
|
|
932
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
|
+
|
|
933
1007
|
async function listRuns(env: Env, principal: Principal, projectId: string): Promise<Response> {
|
|
934
1008
|
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
935
1009
|
const cutoff = await historyCutoffFor(env, await workspaceOfProject(env, projectId));
|
|
@@ -945,7 +1019,7 @@ async function listRuns(env: Env, principal: Principal, projectId: string): Prom
|
|
|
945
1019
|
manifest_key AS manifestKey, requested_at AS requestedAt,
|
|
946
1020
|
started_at AS startedAt, completed_at AS completedAt,
|
|
947
1021
|
targets_json AS targetsJson, environment_base_url AS environmentBaseUrl,
|
|
948
|
-
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,
|
|
949
1023
|
execution_mode AS executionMode, blocked_reason AS blockedReason, billed_minutes AS billedMinutes,
|
|
950
1024
|
trigger_source AS triggerSource, repo_full_name AS repoFullName, commit_sha AS commitSha, pr_number AS prNumber, check_run_id AS checkRunId,
|
|
951
1025
|
gitlab_binding_id AS gitlabBindingId
|
|
@@ -959,6 +1033,8 @@ async function listRuns(env: Env, principal: Principal, projectId: string): Prom
|
|
|
959
1033
|
async function createRun(request: Request, env: Env, principal: Principal, projectId: string, ctx: ExecutionContext, internal: InternalRunFields = {}): Promise<Response> {
|
|
960
1034
|
if (!can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Run creation is not allowed" }, 403);
|
|
961
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;
|
|
962
1038
|
const body = await requestJson(request);
|
|
963
1039
|
const planId = typeof body?.planId === "string" ? body.planId.trim() : "";
|
|
964
1040
|
const idempotencyKey = typeof body?.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
|
|
@@ -1032,8 +1108,8 @@ async function createRun(request: Request, env: Env, principal: Principal, proje
|
|
|
1032
1108
|
const targets = matrix.targets.length > 0 ? matrix.targets : null;
|
|
1033
1109
|
await env.DB.prepare(`
|
|
1034
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,
|
|
1035
|
-
trigger_source, repo_full_name, commit_sha, pr_number, github_installation_id, gitlab_binding_id)
|
|
1036
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1111
|
+
trigger_source, repo_full_name, commit_sha, pr_number, github_installation_id, gitlab_binding_id, case_scope_json)
|
|
1112
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1037
1113
|
`).bind(
|
|
1038
1114
|
runId, projectId, project?.tenantId ?? `tenant_${principal.aegisId}`, planId, draft.journeyId,
|
|
1039
1115
|
Number.isFinite(Number(body?.journeyRevision)) ? Number(body?.journeyRevision) : null,
|
|
@@ -1051,6 +1127,7 @@ async function createRun(request: Request, env: Env, principal: Principal, proje
|
|
|
1051
1127
|
internal.prNumber ?? null,
|
|
1052
1128
|
internal.githubInstallationId ?? null,
|
|
1053
1129
|
internal.gitlabBindingId ?? null,
|
|
1130
|
+
internal.caseScope ? JSON.stringify(internal.caseScope) : null,
|
|
1054
1131
|
).run();
|
|
1055
1132
|
await appendRunEvent(env, runId, "queued", { status: "queued", targets, environmentBaseUrl, executionMode });
|
|
1056
1133
|
await broadcastProjectEvent(env, projectId, { type: "job_queued", runId, projectId, planId, executionMode });
|
|
@@ -1060,6 +1137,7 @@ async function createRun(request: Request, env: Env, principal: Principal, proje
|
|
|
1060
1137
|
run: {
|
|
1061
1138
|
id: runId, projectId, planId, journeyId: draft.journeyId, status: "queued", executionMode,
|
|
1062
1139
|
environmentId, environmentBaseUrl, targets, targetResults: null, durationMs: null, fixFrom,
|
|
1140
|
+
caseScope: internal.caseScope ?? null,
|
|
1063
1141
|
},
|
|
1064
1142
|
}, 201);
|
|
1065
1143
|
}
|
|
@@ -1076,11 +1154,15 @@ async function getRun(env: Env, principal: Principal, runId: string): Promise<Re
|
|
|
1076
1154
|
manifest_key AS manifestKey, requested_at AS requestedAt,
|
|
1077
1155
|
started_at AS startedAt, completed_at AS completedAt,
|
|
1078
1156
|
targets_json AS targetsJson, environment_base_url AS environmentBaseUrl,
|
|
1079
|
-
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,
|
|
1080
1158
|
execution_mode AS executionMode, blocked_reason AS blockedReason, billed_minutes AS billedMinutes
|
|
1081
1159
|
FROM runs WHERE id = ?
|
|
1082
1160
|
`).bind(runId).first<{ projectId: string } & Record<string, unknown>>();
|
|
1083
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
|
+
}
|
|
1084
1166
|
const events = await env.DB.prepare(`
|
|
1085
1167
|
SELECT sequence, type, payload_json AS payload, created_at AS createdAt
|
|
1086
1168
|
FROM run_events WHERE run_id = ? ORDER BY sequence ASC
|
|
@@ -1095,10 +1177,12 @@ async function claimRun(request: Request, env: Env, principal: Principal, runId:
|
|
|
1095
1177
|
if (!workerId) return response({ error: "workerId is required" }, 422);
|
|
1096
1178
|
const run = await env.DB.prepare(`
|
|
1097
1179
|
SELECT project_id AS projectId, targets_json AS targetsJson, environment_base_url AS environmentBaseUrl,
|
|
1098
|
-
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
|
|
1099
1181
|
FROM runs WHERE id = ?
|
|
1100
|
-
`).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 }>();
|
|
1101
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;
|
|
1102
1186
|
// Bugmole Cloud runs are billed; only Bugmole's own runners may take them.
|
|
1103
1187
|
if (run.executionMode === "cloud") return response({ error: "This run is queued for Bugmole Cloud" }, 409);
|
|
1104
1188
|
const lease = new Date(Date.now() + 60_000).toISOString();
|
|
@@ -1123,6 +1207,7 @@ async function claimRun(request: Request, env: Env, principal: Principal, runId:
|
|
|
1123
1207
|
environmentBaseUrl: run.environmentBaseUrl,
|
|
1124
1208
|
targets: parseJsonColumn(run.targetsJson),
|
|
1125
1209
|
fixFrom: parseJsonColumn(run.fixFromJson),
|
|
1210
|
+
caseScope: parseJsonColumn(run.caseScopeJson),
|
|
1126
1211
|
});
|
|
1127
1212
|
}
|
|
1128
1213
|
|
|
@@ -1208,16 +1293,35 @@ const taskSelect = `
|
|
|
1208
1293
|
idempotency_key AS idempotencyKey, requested_at AS requestedAt,
|
|
1209
1294
|
started_at AS startedAt, completed_at AS completedAt, device_session_id AS deviceSessionId,
|
|
1210
1295
|
attempts, next_attempt_at AS nextAttemptAt,
|
|
1211
|
-
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
|
|
1212
1299
|
FROM agent_tasks
|
|
1213
1300
|
`;
|
|
1214
1301
|
|
|
1215
1302
|
|
|
1216
|
-
|
|
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> {
|
|
1217
1317
|
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
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 });
|
|
1221
1325
|
}
|
|
1222
1326
|
|
|
1223
1327
|
function extractDiscoveryUrl(description: string): string | null {
|
|
@@ -1283,6 +1387,8 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1283
1387
|
return response({ error: "Task creation is not allowed" }, 403);
|
|
1284
1388
|
}
|
|
1285
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;
|
|
1286
1392
|
const body = await requestJson(request) as {
|
|
1287
1393
|
title?: string; description?: string; taskType?: string; idempotencyKey?: string; priority?: number;
|
|
1288
1394
|
journeyId?: string; journeyRevision?: number; deviceSessionId?: string; stepId?: string;
|
|
@@ -1315,7 +1421,14 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1315
1421
|
if (domainProblem) return domainProblem;
|
|
1316
1422
|
}
|
|
1317
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);
|
|
1318
1429
|
const queued = (taskId: string) => {
|
|
1430
|
+
// Waiting for approval isn't queued for a runner yet; approving it is.
|
|
1431
|
+
if (!approvedBy) return;
|
|
1319
1432
|
for (const hook of taskQueuedHooks) ctx.waitUntil(hook(env, { taskId, projectId, workspaceId: taskWorkspaceId, executionMode }));
|
|
1320
1433
|
};
|
|
1321
1434
|
const existing = await env.DB.prepare(`${taskSelect} WHERE project_id = ? AND idempotency_key = ?`)
|
|
@@ -1335,7 +1448,8 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1335
1448
|
attempts = 0, next_attempt_at = NULL,
|
|
1336
1449
|
execution_mode = ?, workspace_id = ?, claimed_at = NULL, billed_at = NULL, billed_minutes = NULL,
|
|
1337
1450
|
blocked_reason = NULL, dispatch_attempts = 0, next_dispatch_at = NULL,
|
|
1338
|
-
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 = ?
|
|
1339
1453
|
WHERE id = ?
|
|
1340
1454
|
`).bind(
|
|
1341
1455
|
title, description, taskType,
|
|
@@ -1344,6 +1458,7 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1344
1458
|
typeof body?.deviceSessionId === "string" ? body.deviceSessionId : null,
|
|
1345
1459
|
typeof body?.stepId === "string" ? body.stepId : null,
|
|
1346
1460
|
executionMode, taskWorkspaceId,
|
|
1461
|
+
approvedBy, approvedBy,
|
|
1347
1462
|
existing.id,
|
|
1348
1463
|
).run();
|
|
1349
1464
|
await broadcastProjectEvent(env, projectId, { type: "task_event", taskId: existing.id, projectId, status: "queued" });
|
|
@@ -1355,8 +1470,8 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1355
1470
|
const taskId = id("task");
|
|
1356
1471
|
await env.DB.prepare(`
|
|
1357
1472
|
INSERT INTO agent_tasks
|
|
1358
|
-
(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)
|
|
1359
|
-
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, ?)
|
|
1360
1475
|
`).bind(
|
|
1361
1476
|
taskId, projectId, project?.tenantId ?? `tenant_${principal.aegisId}`, title, description, taskType,
|
|
1362
1477
|
Number.isFinite(Number(body?.priority)) ? Number(body?.priority) : 0, principal.aegisId, idempotencyKey,
|
|
@@ -1364,6 +1479,7 @@ async function createAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1364
1479
|
typeof body?.deviceSessionId === "string" ? body.deviceSessionId : null,
|
|
1365
1480
|
typeof body?.stepId === "string" ? body.stepId : null,
|
|
1366
1481
|
executionMode, taskWorkspaceId,
|
|
1482
|
+
approvedBy, approvedBy,
|
|
1367
1483
|
).run();
|
|
1368
1484
|
await broadcastProjectEvent(env, projectId, { type: "task_event", taskId, projectId, status: "queued" });
|
|
1369
1485
|
queued(taskId);
|
|
@@ -1380,6 +1496,8 @@ async function claimAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1380
1496
|
.bind(taskId).first<{ projectId: string; executionMode: string }>();
|
|
1381
1497
|
if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
|
|
1382
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;
|
|
1383
1501
|
const lease = new Date(Date.now() + 60_000).toISOString();
|
|
1384
1502
|
const deviceSessionId = typeof body?.deviceSessionId === "string" ? body.deviceSessionId : null;
|
|
1385
1503
|
// Claiming is what counts as an attempt: it's the only point both the
|
|
@@ -1393,8 +1511,11 @@ async function claimAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1393
1511
|
WHERE id = ? AND attempts < ${MAX_TASK_ATTEMPTS}
|
|
1394
1512
|
AND (next_attempt_at IS NULL OR next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
1395
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
|
|
1396
1515
|
`).bind(workerId, lease, deviceSessionId, taskId).run() as { meta?: { changes?: number } };
|
|
1397
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);
|
|
1398
1519
|
return response({ error: "Task is already claimed, complete, or has exhausted its attempts" }, 409);
|
|
1399
1520
|
}
|
|
1400
1521
|
await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId, status: "running", workerId });
|
|
@@ -1411,11 +1532,25 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1411
1532
|
`).bind(taskId).first<{ projectId: string; workerId: string | null; taskType: string; attempts: number; status: string; executionMode: string; workspaceId: string | null; description: string | null }>();
|
|
1412
1533
|
if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
|
|
1413
1534
|
if (task.workerId && body?.workerId !== task.workerId) return response({ error: "Worker lease does not match" }, 409);
|
|
1414
|
-
const
|
|
1415
|
-
if (!["running", "blocked", "completed", "error"].includes(
|
|
1416
|
-
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")) {
|
|
1417
1538
|
return response({ error: "UI tasks require parityAuditId and parityMatchScore before completion" }, 422);
|
|
1418
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;
|
|
1419
1554
|
// Falling back to blocked schedules the next attempt rather than making
|
|
1420
1555
|
// the task immediately eligible again on the worker's next 5s poll.
|
|
1421
1556
|
const nextAttemptAt = status === "blocked" ? backoffUntil(task.attempts ?? 0) : null;
|
|
@@ -1427,7 +1562,7 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1427
1562
|
completed_at = CASE WHEN ? IN ('completed', 'error') THEN CURRENT_TIMESTAMP ELSE completed_at END
|
|
1428
1563
|
WHERE id = ?
|
|
1429
1564
|
`).bind(
|
|
1430
|
-
status, new Date(Date.now() + 60_000).toISOString(),
|
|
1565
|
+
status, status === IN_REVIEW ? null : new Date(Date.now() + 60_000).toISOString(),
|
|
1431
1566
|
typeof body?.progressMessage === "string" ? body.progressMessage : null,
|
|
1432
1567
|
body?.continuation ? JSON.stringify(body.continuation) : null,
|
|
1433
1568
|
typeof body?.parityMatchScore === "number" ? body.parityMatchScore : null,
|
|
@@ -1440,8 +1575,10 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1440
1575
|
if (status === "completed" && task.taskType === "discovery") {
|
|
1441
1576
|
await fillDiscoveredEnvironmentUrl(env, task.projectId, task.description);
|
|
1442
1577
|
}
|
|
1443
|
-
await broadcastProjectEvent(env, task.projectId, { type: "task_event", taskId, projectId: task.projectId,
|
|
1444
|
-
|
|
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;
|
|
1445
1582
|
if (status !== task.status) {
|
|
1446
1583
|
const event = {
|
|
1447
1584
|
taskId, projectId: task.projectId, workspaceId: task.workspaceId, executionMode: task.executionMode,
|
|
@@ -1458,34 +1595,170 @@ async function updateAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1458
1595
|
}
|
|
1459
1596
|
return response({
|
|
1460
1597
|
updated: true,
|
|
1598
|
+
...(status === IN_REVIEW ? { inReview: true, message: inReviewMessage(mode) } : {}),
|
|
1461
1599
|
task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first(),
|
|
1462
1600
|
...(stop ? { stop: true, reason: stop.reason, message: stop.message } : {}),
|
|
1463
1601
|
});
|
|
1464
1602
|
}
|
|
1465
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
|
+
|
|
1466
1674
|
// The counterpart to cancelling: a human answering what a parked task asked
|
|
1467
1675
|
// for and putting it back in the queue. Same trust boundary as cancel (no
|
|
1468
1676
|
// worker-lease check — the worker that gave up is exactly who we are
|
|
1469
1677
|
// overriding), and the attempt cap is deliberately not consulted, since
|
|
1470
1678
|
// being over it is the normal reason to be here.
|
|
1471
|
-
|
|
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> {
|
|
1472
1689
|
if (!can(principal, "tasks:write") && !can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Task resume is not allowed" }, 403);
|
|
1473
|
-
const task = await env.DB.prepare(
|
|
1474
|
-
|
|
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
|
+
}>();
|
|
1475
1699
|
if (!task || !(await memberOfProject(env, principal, task.projectId, "project.write"))) return response({ error: "Task not found" }, 404);
|
|
1476
1700
|
if (!isResumableTask(task)) {
|
|
1477
|
-
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);
|
|
1478
1706
|
}
|
|
1479
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
|
+
}
|
|
1480
1738
|
const note = typeof body?.note === "string" && body.note.trim() ? body.note.trim() : "Resumed by user";
|
|
1481
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.
|
|
1482
1742
|
await env.DB.prepare(`
|
|
1483
1743
|
UPDATE agent_tasks SET status = ?, attempts = ?, next_attempt_at = NULL, lease_expires_at = NULL,
|
|
1484
1744
|
worker_id = NULL, error_message = NULL, completed_at = NULL,
|
|
1485
|
-
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 = ?
|
|
1486
1753
|
WHERE id = ?
|
|
1487
|
-
`).bind(fields.status, fields.attempts, note, taskId).run();
|
|
1754
|
+
`).bind(fields.status, fields.attempts, note, executionMode, workspaceId, approvedBy, approvedBy, approvedBy, taskId).run();
|
|
1488
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
|
+
}
|
|
1489
1762
|
return response({ resumed: true, task: await env.DB.prepare(`${taskSelect} WHERE id = ?`).bind(taskId).first() });
|
|
1490
1763
|
}
|
|
1491
1764
|
|
|
@@ -1494,6 +1767,29 @@ async function resumeAgentTask(request: Request, env: Env, principal: Principal,
|
|
|
1494
1767
|
// lease check (`body.workerId` must match `worker_id`) deliberately doesn't
|
|
1495
1768
|
// apply here, since the whole point is overriding a task that's stuck
|
|
1496
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
|
+
|
|
1497
1793
|
async function cancelAgentTask(request: Request, env: Env, principal: Principal, taskId: string): Promise<Response> {
|
|
1498
1794
|
if (!can(principal, "tasks:write") && !can(principal, "runs:write") && !can(principal, "*")) return response({ error: "Task cancellation is not allowed" }, 403);
|
|
1499
1795
|
const task = await env.DB.prepare("SELECT project_id AS projectId, status FROM agent_tasks WHERE id = ?")
|
|
@@ -2024,9 +2320,10 @@ const registryWorker = {
|
|
|
2024
2320
|
if (artifactsMatch && request.method === "GET") {
|
|
2025
2321
|
const projectId = decodeURIComponent(artifactsMatch[1]);
|
|
2026
2322
|
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
2323
|
+
const visible = await runEvidenceVisibility(env, projectId);
|
|
2027
2324
|
return artifactsMatch[2]
|
|
2028
|
-
? getProjectArtifact(env.ARTIFACTS, projectId, url)
|
|
2029
|
-
: listProjectArtifacts(env.ARTIFACTS, projectId, url);
|
|
2325
|
+
? getProjectArtifact(env.ARTIFACTS, projectId, url, visible)
|
|
2326
|
+
: listProjectArtifacts(env.ARTIFACTS, projectId, url, visible);
|
|
2030
2327
|
}
|
|
2031
2328
|
const runMatch = url.pathname.match(/^\/api\/runs\/([^/]+)$/);
|
|
2032
2329
|
if (runMatch && request.method === "GET") return getRun(env, principal, decodeURIComponent(runMatch[1]));
|
|
@@ -2034,14 +2331,20 @@ const registryWorker = {
|
|
|
2034
2331
|
const claimMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/claim$/);
|
|
2035
2332
|
if (claimMatch && request.method === "POST") return claimRun(request, env, principal, decodeURIComponent(claimMatch[1]), context);
|
|
2036
2333
|
const tasksMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/tasks$/);
|
|
2037
|
-
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);
|
|
2038
2335
|
if (tasksMatch && request.method === "POST") return createAgentTask(request, env, principal, decodeURIComponent(tasksMatch[1]), context);
|
|
2039
2336
|
const taskClaimMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/claim$/);
|
|
2040
2337
|
if (taskClaimMatch && request.method === "POST") return claimAgentTask(request, env, principal, decodeURIComponent(taskClaimMatch[1]));
|
|
2041
2338
|
const taskCancelMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/cancel$/);
|
|
2042
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);
|
|
2043
2346
|
const taskResumeMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/resume$/);
|
|
2044
|
-
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);
|
|
2045
2348
|
const taskMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)$/);
|
|
2046
2349
|
if (taskMatch && request.method === "POST") return updateAgentTask(request, env, principal, decodeURIComponent(taskMatch[1]), context);
|
|
2047
2350
|
if (request.method === "GET" && url.pathname === "/api/device-sessions") return listDeviceSessions(env, principal);
|