@code-partner/codepipe-hub 0.14.1-dev.419.g21c1b62a → 0.14.1-dev.422.gf90f06c4

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/hub/dist/index.js CHANGED
@@ -13,7 +13,7 @@ var PLATFORM_VERSION;
13
13
  var init_version_generated = __esm({
14
14
  "../packages/shared/dist/version.generated.js"() {
15
15
  "use strict";
16
- PLATFORM_VERSION = "0.14.1-dev.419.g21c1b62a";
16
+ PLATFORM_VERSION = "0.14.1-dev.422.gf90f06c4";
17
17
  }
18
18
  });
19
19
 
@@ -10580,6 +10580,15 @@ function humanSubjectOf(actor) {
10580
10580
  return actor.principal.delegatedBy ?? null;
10581
10581
  return null;
10582
10582
  }
10583
+ function accountOf(actor) {
10584
+ const subjectId = humanSubjectOf(actor);
10585
+ if (subjectId === null)
10586
+ return null;
10587
+ return {
10588
+ subjectId,
10589
+ email: actor.principal.kind === "human" && actor.displayEmail !== void 0 ? actor.displayEmail : null
10590
+ };
10591
+ }
10583
10592
  function serviceOf(actor) {
10584
10593
  return actor.principal.kind === "service" ? actor.principal.service : null;
10585
10594
  }
@@ -11488,16 +11497,40 @@ function createReadOperations(port, authorizer) {
11488
11497
  return { projectId, items: rows, revision: valueRevision(rows) };
11489
11498
  }
11490
11499
  function projectVisibilityOf(actor) {
11491
- const subjectId = humanSubjectOf(actor);
11492
- if (subjectId === null)
11500
+ const account = accountOf(actor);
11501
+ if (account === null)
11493
11502
  return null;
11494
11503
  return {
11495
- subjectId,
11496
- ...actor.principal.kind === "human" && actor.displayEmail !== void 0 ? { email: actor.displayEmail } : {},
11504
+ subjectId: account.subjectId,
11505
+ ...account.email === null ? {} : { email: account.email },
11497
11506
  ...actor.projectScope === void 0 ? {} : { projectScope: actor.projectScope },
11498
11507
  includeUnowned: actor.authMethod !== "cli_token"
11499
11508
  };
11500
11509
  }
11510
+ function farmsView(rows) {
11511
+ return {
11512
+ items: rows.map((r) => ({
11513
+ id: r.farmId,
11514
+ name: r.name,
11515
+ publicKey: r.publicKey,
11516
+ keyFingerprint: r.keyFingerprint,
11517
+ online: r.online,
11518
+ lastSeenAt: toOptionalTimestamp(r.lastSeenAt)
11519
+ }))
11520
+ };
11521
+ }
11522
+ function projectDeliverablesView(projectId, rows) {
11523
+ return {
11524
+ projectId,
11525
+ items: rows.map((r) => ({
11526
+ taskKey: r.taskKey,
11527
+ title: r.title,
11528
+ phase: r.phase,
11529
+ updatedAt: toTimestamp(r.updatedAt),
11530
+ repos: r.repos.map((x) => ({ repoName: x.repoName, branchName: x.branchName, baseRef: x.baseRef }))
11531
+ }))
11532
+ };
11533
+ }
11501
11534
  async function visibleProjects(actor) {
11502
11535
  const visibility = projectVisibilityOf(actor);
11503
11536
  return visibility === null ? [] : await port.visibleProjectIds(visibility);
@@ -11966,6 +11999,15 @@ function createReadOperations(port, authorizer) {
11966
11999
  const rows = await port.listRepos(query.projectId);
11967
12000
  return reposView(query.projectId, rows);
11968
12001
  },
12002
+ async listProjectDeliverables(actor, query) {
12003
+ await require2(actor, "delivery.read", { type: "deliverable", projectId: query.projectId });
12004
+ return projectDeliverablesView(query.projectId, await port.listProjectDeliverables(query.projectId));
12005
+ },
12006
+ async listFarms(actor) {
12007
+ await require2(actor, "account.read", { type: "account" });
12008
+ const account = accountOf(actor);
12009
+ return farmsView(account === null ? [] : await port.listFarms(account));
12010
+ },
11969
12011
  async listConfigProposals(actor, query) {
11970
12012
  await require2(actor, "project.read", { type: "project", id: query.projectId, projectId: query.projectId });
11971
12013
  const limit = normalizeLimit(query.limit);
@@ -12195,8 +12237,16 @@ function createCommandOperations(deps) {
12195
12237
  });
12196
12238
  case "unprocessable":
12197
12239
  throw errors.unprocessable({ details: { reason: outcome.reason } });
12240
+ case "not_found":
12241
+ throw errors.notFound();
12198
12242
  }
12199
12243
  }
12244
+ function accountActorOf(actor) {
12245
+ const account = accountOf(actor);
12246
+ if (account === null)
12247
+ throw errors.forbidden({ details: { reason: "human_only" } });
12248
+ return account;
12249
+ }
12200
12250
  async function audited(actor, entry, run) {
12201
12251
  const policy = { decision: "not_evaluated" };
12202
12252
  const common = {
@@ -12685,6 +12735,107 @@ function createCommandOperations(deps) {
12685
12735
  return { value: outcome.result, replayed: outcome.replayed };
12686
12736
  });
12687
12737
  },
12738
+ async createProject(actor, command) {
12739
+ const { project } = command;
12740
+ const projectId = project.projectId;
12741
+ if (!PROJECT_ID_RE.test(projectId)) {
12742
+ throw errors.invalidInput({
12743
+ message: "A project id is lowercase letters, digits and dashes, starts with a letter, 2\u201331 characters.",
12744
+ details: { field: "projectId" }
12745
+ });
12746
+ }
12747
+ for (const repo of project.repos) {
12748
+ if (repo.defaultBranch !== void 0 && !isValidBranchName(repo.defaultBranch)) {
12749
+ throw errors.invalidInput({ message: "That is not a valid git branch name.", details: { field: "repos.defaultBranch", repo: repo.name } });
12750
+ }
12751
+ }
12752
+ const key = asIdempotencyKey(command.idempotencyKey);
12753
+ const scope = { subjectId: actor.principal.subjectId, operationId: "project.create", projectId, key };
12754
+ return await audited(actor, { operationId: "project.create", aggregateType: "project", aggregateId: projectId, projectId, data: { name: project.name, tracker: project.trackerProvider } }, async (policy) => {
12755
+ await require2(actor, "account.manage", { type: "account" }, policy);
12756
+ const run = await runIdempotent(idempotency, scope, { project }, async () => unwrap(await write2.createProject({ project, owner: accountActorOf(actor) })), now());
12757
+ return { value: run.result, replayed: run.replayed };
12758
+ });
12759
+ },
12760
+ async provisionOnFarm(actor, command) {
12761
+ const { farmId, projectId } = command;
12762
+ const key = asIdempotencyKey(command.idempotencyKey);
12763
+ const scope = { subjectId: actor.principal.subjectId, operationId: "farm.provision", projectId, key };
12764
+ return await audited(actor, { operationId: "farm.provision", aggregateType: "project", aggregateId: projectId, projectId, data: { farmId } }, async (policy) => {
12765
+ await require2(actor, "project.manage", { type: "project", id: projectId, projectId }, policy);
12766
+ const run = await runIdempotent(idempotency, scope, { farmId, projectId }, async () => {
12767
+ const row = unwrap(await write2.provisionOnFarm({ farmId, projectId, sealedBundle: command.sealedBundle, actor: accountActorOf(actor) }));
12768
+ return { projectId, farmId, ok: row.ok, daemonStatus: row.daemonStatus, error: row.error };
12769
+ }, now());
12770
+ return { value: run.result, replayed: run.replayed };
12771
+ });
12772
+ },
12773
+ async provisionGhRunners(actor, command) {
12774
+ const { runnerId, projectId } = command;
12775
+ const key = asIdempotencyKey(command.idempotencyKey);
12776
+ const scope = { subjectId: actor.principal.subjectId, operationId: "runner.provisionGhRunners", projectId, key };
12777
+ return await audited(actor, { operationId: "runner.provisionGhRunners", aggregateType: "project", aggregateId: projectId, projectId, data: { runnerId } }, async (policy) => {
12778
+ await require2(actor, "project.manage", { type: "project", id: projectId, projectId }, policy);
12779
+ const run = await runIdempotent(idempotency, scope, { runnerId, projectId }, async () => {
12780
+ const row = unwrap(await write2.provisionGhRunners({ runnerId, projectId, sealedBundle: command.sealedBundle, actor: accountActorOf(actor) }));
12781
+ return {
12782
+ projectId,
12783
+ runnerId,
12784
+ ok: row.ok,
12785
+ runners: row.runners.map((r) => ({ owner: r.owner, repo: r.repo, status: r.status, error: r.error, labels: r.labels })),
12786
+ error: row.error
12787
+ };
12788
+ }, now());
12789
+ return { value: run.result, replayed: run.replayed };
12790
+ });
12791
+ },
12792
+ async teardownGhRunners(actor, command) {
12793
+ const { runnerId, projectId } = command;
12794
+ const key = asIdempotencyKey(command.idempotencyKey);
12795
+ const scope = { subjectId: actor.principal.subjectId, operationId: "runner.teardownGhRunners", projectId, key };
12796
+ const repo = command.repo ?? null;
12797
+ return await audited(actor, {
12798
+ operationId: "runner.teardownGhRunners",
12799
+ aggregateType: "project",
12800
+ aggregateId: projectId,
12801
+ projectId,
12802
+ data: { runnerId, ...repo === null ? {} : { repo: `${repo.owner}/${repo.repo}` } }
12803
+ }, async (policy) => {
12804
+ await require2(actor, "project.manage", { type: "project", id: projectId, projectId }, policy);
12805
+ const run = await runIdempotent(idempotency, scope, { runnerId, projectId, repo }, async () => {
12806
+ const row = unwrap(await write2.teardownGhRunners({
12807
+ runnerId,
12808
+ projectId,
12809
+ sealedBundle: command.sealedBundle ?? null,
12810
+ repo,
12811
+ actor: accountActorOf(actor)
12812
+ }));
12813
+ return { projectId, runnerId, ok: row.ok, error: row.error };
12814
+ }, now());
12815
+ return { value: run.result, replayed: run.replayed };
12816
+ });
12817
+ },
12818
+ async requestProjectIndex(actor, command) {
12819
+ const { projectId, kind } = command;
12820
+ const key = asIdempotencyKey(command.idempotencyKey);
12821
+ const scope = { subjectId: actor.principal.subjectId, operationId: "project.index", projectId, key };
12822
+ return await audited(actor, { operationId: "project.index", aggregateType: "project", aggregateId: projectId, projectId, data: { kind } }, async (policy) => {
12823
+ await require2(actor, "project.manage", { type: "project", id: projectId, projectId }, policy);
12824
+ const run = await runIdempotent(idempotency, scope, { kind }, async () => {
12825
+ const { jobId } = unwrap(await write2.enqueueProjectIndex({ projectId, kind }));
12826
+ return { jobId, kind };
12827
+ }, now());
12828
+ return { value: run.result, replayed: run.replayed };
12829
+ });
12830
+ },
12831
+ async reportInitStatus(actor, command) {
12832
+ const { projectId, phase } = command;
12833
+ return await audited(actor, { operationId: "project.reportInitStatus", aggregateType: "project", aggregateId: projectId, projectId, data: { phase, failed: command.error !== void 0 } }, async (policy) => {
12834
+ await require2(actor, "project.manage", { type: "project", id: projectId, projectId }, policy);
12835
+ const { recorded } = unwrap(await write2.reportInitStatus({ projectId, phase, error: command.error ?? null }));
12836
+ return { value: { recorded }, replayed: false };
12837
+ });
12838
+ },
12688
12839
  async setRepoDefaultBranch(actor, command) {
12689
12840
  const { projectId, name } = command;
12690
12841
  const revision = asRevision(command.revision);
@@ -25849,155 +26000,8 @@ var init_sandbox2 = __esm({
25849
26000
  }
25850
26001
  });
25851
26002
 
25852
- // src/farm/pool-spec.ts
25853
- var pool_spec_exports = {};
25854
- __export(pool_spec_exports, {
25855
- buildSyncWorkerPoolMessage: () => buildSyncWorkerPoolMessage,
25856
- computeFarmPoolSpec: () => computeFarmPoolSpec,
25857
- reconcileFarmPools: () => reconcileFarmPools
25858
- });
25859
- import { nanoid as nanoid25 } from "nanoid";
25860
- async function computeFarmPoolSpec(db, farmId) {
25861
- const rows = await db.all(
25862
- `SELECT fa.user_id as userId, fa.max_sandboxes as quota,
25863
- p.id as projectId
25864
- FROM farm_assignments fa
25865
- JOIN projects p ON p.farm_id = fa.farm_id AND p.owner_user_id = fa.user_id
25866
- WHERE fa.farm_id = ?
25867
- ORDER BY fa.assigned_at ASC, p.id ASC`,
25868
- farmId
25869
- );
25870
- const ceilingRow = await db.get(
25871
- `SELECT max_sandboxes as ceiling FROM farms WHERE id = ?`,
25872
- farmId
25873
- );
25874
- const ceiling = ceilingRow ? Number(ceilingRow.ceiling) : 16;
25875
- const byAccount = /* @__PURE__ */ new Map();
25876
- for (const r of rows) {
25877
- const acc = byAccount.get(r.userId) ?? { quota: Number(r.quota), projectIds: [] };
25878
- acc.projectIds.push(r.projectId);
25879
- byAccount.set(r.userId, acc);
25880
- }
25881
- const spec = [];
25882
- let remaining = ceiling;
25883
- for (const [userId, acc] of byAccount) {
25884
- const quota = Math.max(0, Math.min(acc.quota, remaining));
25885
- remaining -= quota;
25886
- spec.push({ userId, projectIds: acc.projectIds, quota });
25887
- }
25888
- return spec;
25889
- }
25890
- function buildSyncWorkerPoolMessage(accounts) {
25891
- return { type: "sync_worker_pool", requestId: nanoid25(16), accounts };
25892
- }
25893
- async function reconcileFarmPools(db, send, log) {
25894
- const farms = await db.all(`SELECT id FROM farms`);
25895
- const live = new Set(farms.map((f) => f.id));
25896
- for (const id of [...starvationReported]) {
25897
- if (!live.has(id)) starvationReported.delete(id);
25898
- }
25899
- const starved = [];
25900
- let pushed = 0;
25901
- for (const farm of farms) {
25902
- const accounts = await computeFarmPoolSpec(db, farm.id);
25903
- if (accounts.length > 0) {
25904
- const sent = send(farm.id, buildSyncWorkerPoolMessage(accounts));
25905
- if (sent !== false) pushed++;
25906
- starvationReported.delete(farm.id);
25907
- continue;
25908
- }
25909
- const queued = await db.get(
25910
- `SELECT COUNT(*) AS n FROM jobs j
25911
- JOIN projects p ON p.id = j.project_id
25912
- WHERE j.state = 'queued' AND p.farm_id = ?`,
25913
- farm.id
25914
- );
25915
- if (Number(queued.n) === 0) {
25916
- starvationReported.delete(farm.id);
25917
- continue;
25918
- }
25919
- starved.push(farm.id);
25920
- if (!starvationReported.has(farm.id)) {
25921
- starvationReported.add(farm.id);
25922
- log.warn(
25923
- { farmId: farm.id, queued: Number(queued.n) },
25924
- "farm has NO worker-pool accounts while jobs are queued \u2014 assignments missing?"
25925
- );
25926
- }
25927
- }
25928
- return { farms: farms.length, pushed, starved };
25929
- }
25930
- var starvationReported;
25931
- var init_pool_spec = __esm({
25932
- "src/farm/pool-spec.ts"() {
25933
- "use strict";
25934
- starvationReported = /* @__PURE__ */ new Set();
25935
- }
25936
- });
25937
-
25938
- // src/farm/provision.ts
25939
- import { nanoid as nanoid26 } from "nanoid";
25940
- function handleFarmResult(msg) {
25941
- const type = msg["type"];
25942
- if (typeof type !== "string" || !RESULT_TYPES.has(type)) return;
25943
- const requestId = typeof msg["requestId"] === "string" ? msg["requestId"] : "";
25944
- const p = pending2.get(requestId);
25945
- if (!p) return;
25946
- clearTimeout(p.timer);
25947
- pending2.delete(requestId);
25948
- p.resolve(msg);
25949
- }
25950
- function awaitResult(requestId, send, timeoutMs) {
25951
- if (!send()) return Promise.reject(new Error("farm_offline"));
25952
- return new Promise((resolve4, reject) => {
25953
- const timer = setTimeout(() => {
25954
- pending2.delete(requestId);
25955
- reject(new Error("farm_timeout"));
25956
- }, timeoutMs);
25957
- pending2.set(requestId, { resolve: resolve4, reject, timer });
25958
- });
25959
- }
25960
- function requestProvision(farmId, projectId, sealedBundle, timeoutMs) {
25961
- const requestId = nanoid26(16);
25962
- return awaitResult(
25963
- requestId,
25964
- () => sendToFarm(farmId, { type: "provision_project", requestId, projectId, sealedBundle }),
25965
- timeoutMs
25966
- );
25967
- }
25968
- function requestDelete(farmId, projectId, timeoutMs) {
25969
- const requestId = nanoid26(16);
25970
- return awaitResult(
25971
- requestId,
25972
- () => sendToFarm(farmId, { type: "delete_project", requestId, projectId }),
25973
- timeoutMs
25974
- );
25975
- }
25976
- function requestRespawn(farmId, projectId, timeoutMs) {
25977
- const requestId = nanoid26(16);
25978
- return awaitResult(
25979
- requestId,
25980
- () => sendToFarm(farmId, { type: "respawn_project", requestId, projectId }),
25981
- timeoutMs
25982
- );
25983
- }
25984
- async function requestFarmPoolSync(db, farmId) {
25985
- const accounts = await computeFarmPoolSpec(db, farmId);
25986
- sendToFarm(farmId, buildSyncWorkerPoolMessage(accounts));
25987
- }
25988
- var pending2, RESULT_TYPES;
25989
- var init_provision = __esm({
25990
- "src/farm/provision.ts"() {
25991
- "use strict";
25992
- init_ws();
25993
- init_pool_spec();
25994
- pending2 = /* @__PURE__ */ new Map();
25995
- RESULT_TYPES = /* @__PURE__ */ new Set(["provision_result", "delete_result", "respawn_result"]);
25996
- }
25997
- });
25998
-
25999
26003
  // src/routes/farm.ts
26000
- import { nanoid as nanoid27 } from "nanoid";
26004
+ import { nanoid as nanoid25 } from "nanoid";
26001
26005
  function viewerInAssignments(viewer, assignments) {
26002
26006
  return assignments.some(
26003
26007
  (a) => viewer.userId !== null && a.userId === viewer.userId || a.email !== null && a.email === viewer.email
@@ -26082,7 +26086,7 @@ async function farmRoutes(app, deps) {
26082
26086
  if (req.body.registrationToken !== config.farmRegistrationToken) {
26083
26087
  return reply.status(401).send({ error: "bad_registration_token" });
26084
26088
  }
26085
- const token = nanoid27(48);
26089
+ const token = nanoid25(48);
26086
26090
  const now = Date.now();
26087
26091
  const publicKey = req.body.publicKey ?? null;
26088
26092
  const fingerprint2 = publicKey ? publicKeyFingerprint(publicKey) : null;
@@ -26121,7 +26125,7 @@ async function farmRoutes(app, deps) {
26121
26125
  "farm registered under an existing name with a DIFFERENT key \u2014 new identity; assignments stay with the old farm and must be moved deliberately"
26122
26126
  );
26123
26127
  }
26124
- const id = `farm_${nanoid27(16)}`;
26128
+ const id = `farm_${nanoid25(16)}`;
26125
26129
  await db.run(`INSERT INTO farms
26126
26130
  (id, name, token, capabilities_json, public_key, key_fingerprint, registered_at, last_seen_at, max_sandboxes)
26127
26131
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, req.body.name, token, JSON.stringify(req.body.capabilities), publicKey, fingerprint2, now, now, maxSandboxes);
@@ -26129,104 +26133,25 @@ async function farmRoutes(app, deps) {
26129
26133
  return { farmId: id, token };
26130
26134
  }
26131
26135
  );
26132
- app.get("/farms", async (req, reply) => {
26133
- const auth = await resolveCliAuth(db, config, req.headers.authorization);
26134
- if (auth) {
26135
- return await listFarms(db, {
26136
- email: auth.email,
26137
- userId: auth.userId,
26138
- isSuperAdmin: isSuperUser(config, auth.email)
26139
- });
26140
- }
26141
- const session = await requireSession(db, req, reply);
26142
- if (!session) return reply;
26143
- return await listFarms(db, {
26144
- email: session.email,
26145
- userId: session.userId,
26146
- isSuperAdmin: isSuperUser(config, session.email)
26147
- });
26148
- });
26149
- app.post(
26150
- "/farms/:farmId/provision",
26151
- {
26152
- schema: {
26153
- body: {
26154
- type: "object",
26155
- required: ["projectId", "sealedBundle"],
26156
- properties: {
26157
- projectId: { type: "string" },
26158
- sealedBundle: { type: "string", minLength: 1 }
26159
- }
26160
- }
26161
- }
26162
- },
26163
- async (req, reply) => {
26164
- const auth = await resolveCliAuth(db, config, req.headers.authorization);
26165
- if (!auth) {
26166
- return reply.status(401).send({ error: "unauthorized" });
26167
- }
26168
- const { farmId } = req.params;
26169
- const { projectId, sealedBundle } = req.body;
26170
- if (!await canActOnProject(db, config, auth, projectId)) {
26171
- return reply.status(403).send({ error: "forbidden" });
26172
- }
26173
- const farm = await db.get(`SELECT id FROM farms WHERE id = ?`, farmId);
26174
- if (!farm) return reply.status(404).send({ error: "unknown_farm" });
26175
- if (!isSuperUser(config, auth.email) && !await viewerOwnsFarm(db, { email: auth.email, userId: auth.userId }, farmId)) {
26176
- return reply.status(403).send({ error: "farm_not_assigned" });
26177
- }
26178
- if (!await db.get(`SELECT id FROM projects WHERE id = ?`, projectId)) {
26179
- return reply.status(404).send({ error: "unknown_project" });
26180
- }
26181
- await beginInitTracking(db, projectId, "farm_provisioning");
26182
- try {
26183
- const result = await requestProvision(farmId, projectId, sealedBundle, 6e4);
26184
- if (result.ok) {
26185
- await db.run(`UPDATE projects SET farm_id = ? WHERE id = ?`, farmId, projectId);
26186
- await advanceInitPhase(db, projectId, "daemon_starting");
26187
- requestFarmPoolSync(db, farmId).catch(
26188
- (err) => app.log.warn({ farmId, err }, "farm pool sync after provision failed")
26189
- );
26190
- } else {
26191
- await recordInitError(db, projectId, `farm provisioning failed: ${result.error ?? "unknown error"}`);
26192
- }
26193
- return result;
26194
- } catch (err) {
26195
- const reason = err instanceof Error ? err.message : "provision_failed";
26196
- await recordInitError(
26197
- db,
26198
- projectId,
26199
- reason === "farm_offline" ? "the hosting farm is offline" : reason === "farm_timeout" ? "the hosting farm did not respond in time" : `farm provisioning failed: ${reason}`
26200
- );
26201
- const status = reason === "farm_offline" ? 409 : reason === "farm_timeout" ? 504 : 500;
26202
- return reply.status(status).send({ error: reason });
26203
- }
26204
- }
26205
- );
26206
26136
  }
26207
26137
  var init_farm2 = __esm({
26208
26138
  "src/routes/farm.ts"() {
26209
26139
  "use strict";
26210
26140
  init_sealedbox();
26211
26141
  init_abuse_throttle();
26212
- init_cli_auth();
26213
- init_auth();
26214
- init_users();
26215
- init_init_status();
26216
26142
  init_ws();
26217
- init_provision();
26218
26143
  }
26219
26144
  });
26220
26145
 
26221
26146
  // src/runner-reg-tokens.ts
26222
26147
  import { createHash as createHash8 } from "node:crypto";
26223
- import { nanoid as nanoid28 } from "nanoid";
26148
+ import { nanoid as nanoid26 } from "nanoid";
26224
26149
  function hashRunnerRegToken(secret) {
26225
26150
  return createHash8("sha256").update(secret, "utf8").digest("hex");
26226
26151
  }
26227
26152
  async function issueRunnerRegToken(db, email) {
26228
26153
  const normalized = email.trim().toLowerCase();
26229
- const secret = `rnrreg_${nanoid28(48)}`;
26154
+ const secret = `rnrreg_${nanoid26(48)}`;
26230
26155
  await db.tx(async (db2) => {
26231
26156
  await db2.run(`UPDATE runner_reg_tokens SET revoked_at = ? WHERE email = ? AND revoked_at IS NULL`, Date.now(), normalized);
26232
26157
  await db2.run(`INSERT INTO runner_reg_tokens (token_hash, email, user_id, created_at)
@@ -26262,130 +26187,8 @@ var init_runner_reg_tokens = __esm({
26262
26187
  }
26263
26188
  });
26264
26189
 
26265
- // src/runner/provision.ts
26266
- import { nanoid as nanoid29 } from "nanoid";
26267
- function handleRunnerResult(msg) {
26268
- const type = msg["type"];
26269
- if (typeof type !== "string" || !RESULT_TYPES2.has(type)) return;
26270
- const requestId = typeof msg["requestId"] === "string" ? msg["requestId"] : "";
26271
- const p = pending3.get(requestId);
26272
- if (!p) return;
26273
- clearTimeout(p.timer);
26274
- pending3.delete(requestId);
26275
- p.resolve(msg);
26276
- }
26277
- function awaitResult2(requestId, send, timeoutMs) {
26278
- if (!send()) return Promise.reject(new Error("runner_offline"));
26279
- return new Promise((resolve4, reject) => {
26280
- const timer = setTimeout(() => {
26281
- pending3.delete(requestId);
26282
- reject(new Error("runner_timeout"));
26283
- }, timeoutMs);
26284
- pending3.set(requestId, { resolve: resolve4, reject, timer });
26285
- });
26286
- }
26287
- function requestGhRunnersProvision(runnerId, projectId, sealedBundle, timeoutMs) {
26288
- const requestId = nanoid29(16);
26289
- return awaitResult2(
26290
- requestId,
26291
- () => sendToRunner(runnerId, { type: "provision_gh_runners", requestId, projectId, sealedBundle }),
26292
- timeoutMs
26293
- );
26294
- }
26295
- function requestGhRunnersDelete(runnerId, projectId, sealedBundle, timeoutMs) {
26296
- const requestId = nanoid29(16);
26297
- return awaitResult2(
26298
- requestId,
26299
- () => sendToRunner(runnerId, {
26300
- type: "delete_gh_runners",
26301
- requestId,
26302
- projectId,
26303
- ...sealedBundle ? { sealedBundle } : {}
26304
- }),
26305
- timeoutMs
26306
- );
26307
- }
26308
- var pending3, RESULT_TYPES2;
26309
- var init_provision2 = __esm({
26310
- "src/runner/provision.ts"() {
26311
- "use strict";
26312
- init_ws();
26313
- pending3 = /* @__PURE__ */ new Map();
26314
- RESULT_TYPES2 = /* @__PURE__ */ new Set(["provision_gh_runners_result", "delete_gh_runners_result"]);
26315
- }
26316
- });
26317
-
26318
- // src/runner/bindings.ts
26319
- function parseGhRunnerBindings(json) {
26320
- if (!json) return [];
26321
- try {
26322
- const parsed = JSON.parse(json);
26323
- return Array.isArray(parsed.bindings) ? parsed.bindings : [];
26324
- } catch {
26325
- return [];
26326
- }
26327
- }
26328
- function serialize(bindings) {
26329
- return JSON.stringify({ bindings });
26330
- }
26331
- async function upsertGhRunnerBinding(db, projectId, binding) {
26332
- await db.tx(async (tx) => {
26333
- const row = await tx.get(
26334
- `SELECT gh_runner_bindings_json as bindingsJson FROM projects WHERE id = ?`,
26335
- projectId
26336
- );
26337
- if (!row) return;
26338
- const bindings = parseGhRunnerBindings(row.bindingsJson).filter(
26339
- (b) => !(b.owner === binding.owner && b.repo === binding.repo)
26340
- );
26341
- bindings.push(binding);
26342
- await tx.run(
26343
- `UPDATE projects SET gh_runner_bindings_json = ? WHERE id = ?`,
26344
- serialize(bindings),
26345
- projectId
26346
- );
26347
- });
26348
- }
26349
- async function removeGhRunnerBinding(db, projectId, owner, repo) {
26350
- return db.tx(async (tx) => {
26351
- const row = await tx.get(
26352
- `SELECT gh_runner_bindings_json as bindingsJson FROM projects WHERE id = ?`,
26353
- projectId
26354
- );
26355
- if (!row) return false;
26356
- const bindings = parseGhRunnerBindings(row.bindingsJson);
26357
- const kept = bindings.filter((b) => !(b.owner === owner && b.repo === repo));
26358
- if (kept.length === bindings.length) return false;
26359
- await tx.run(
26360
- `UPDATE projects SET gh_runner_bindings_json = ? WHERE id = ?`,
26361
- serialize(kept),
26362
- projectId
26363
- );
26364
- return true;
26365
- });
26366
- }
26367
- async function runnerBoundProjectCount(db, runnerId) {
26368
- const rows = await db.all(
26369
- `SELECT gh_runner_bindings_json AS json FROM projects WHERE gh_runner_bindings_json IS NOT NULL`
26370
- );
26371
- let n = 0;
26372
- for (const r of rows) {
26373
- try {
26374
- const bindings = JSON.parse(r.json).bindings ?? [];
26375
- if (bindings.some((b) => b.runnerId === runnerId)) n++;
26376
- } catch {
26377
- }
26378
- }
26379
- return n;
26380
- }
26381
- var init_bindings = __esm({
26382
- "src/runner/bindings.ts"() {
26383
- "use strict";
26384
- }
26385
- });
26386
-
26387
26190
  // src/routes/runner.ts
26388
- import { nanoid as nanoid30 } from "nanoid";
26191
+ import { nanoid as nanoid27 } from "nanoid";
26389
26192
  async function listRunners(db, viewer) {
26390
26193
  const online = new Set(listConnectedRunnerIds());
26391
26194
  const rows = await db.all(`SELECT id, name, public_key as publicKey,
@@ -26462,8 +26265,8 @@ async function runnerRoutes(app, deps) {
26462
26265
  if (!owner) {
26463
26266
  return reply.status(401).send({ error: "bad_registration_token" });
26464
26267
  }
26465
- const id = `rnr_${nanoid30(16)}`;
26466
- const token = nanoid30(48);
26268
+ const id = `rnr_${nanoid27(16)}`;
26269
+ const token = nanoid27(48);
26467
26270
  const now = Date.now();
26468
26271
  const publicKey = req.body.publicKey ?? null;
26469
26272
  const fingerprint2 = publicKey ? publicKeyFingerprint(publicKey) : null;
@@ -26531,108 +26334,6 @@ async function runnerRoutes(app, deps) {
26531
26334
  return { ok: true };
26532
26335
  }
26533
26336
  );
26534
- const ghRunnerGuard = async (authorization, reply, runnerId, projectId) => {
26535
- const auth = await resolveCliAuth(db, config, authorization);
26536
- if (!auth) {
26537
- reply.status(401).send({ error: "unauthorized" });
26538
- return null;
26539
- }
26540
- if (!await canActOnProject(db, config, auth, projectId)) {
26541
- reply.status(403).send({ error: "forbidden" });
26542
- return null;
26543
- }
26544
- if (!await db.get(`SELECT id FROM runners WHERE id = ?`, runnerId)) {
26545
- reply.status(404).send({ error: "unknown_runner" });
26546
- return null;
26547
- }
26548
- if (!isSuperUser(config, auth.email) && !await viewerOwnsRunner(db, { email: auth.email, userId: auth.userId }, runnerId)) {
26549
- reply.status(403).send({ error: "runner_not_owned" });
26550
- return null;
26551
- }
26552
- if (!await db.get(`SELECT id FROM projects WHERE id = ?`, projectId)) {
26553
- reply.status(404).send({ error: "unknown_project" });
26554
- return null;
26555
- }
26556
- return auth;
26557
- };
26558
- const ghRelayFailure = (reply, err) => {
26559
- const reason = err instanceof Error ? err.message : "gh_runner_relay_failed";
26560
- const status = reason === "runner_offline" ? 409 : reason === "runner_timeout" ? 504 : 500;
26561
- return reply.status(status).send({ error: reason });
26562
- };
26563
- app.post(
26564
- "/runners/:runnerId/gh-runners",
26565
- {
26566
- schema: {
26567
- body: {
26568
- type: "object",
26569
- required: ["projectId", "sealedBundle"],
26570
- properties: {
26571
- projectId: { type: "string" },
26572
- sealedBundle: { type: "string", minLength: 1 }
26573
- }
26574
- }
26575
- }
26576
- },
26577
- async (req, reply) => {
26578
- const { runnerId } = req.params;
26579
- const { projectId, sealedBundle } = req.body;
26580
- if (!await ghRunnerGuard(req.headers.authorization, reply, runnerId, projectId)) return reply;
26581
- try {
26582
- const result = await requestGhRunnersProvision(runnerId, projectId, sealedBundle, 12e4);
26583
- if (result.ok) {
26584
- const connectedAt = Date.now();
26585
- for (const r of result.runners ?? []) {
26586
- await upsertGhRunnerBinding(db, projectId, {
26587
- owner: r.owner,
26588
- repo: r.repo,
26589
- runnerId,
26590
- connectedAt,
26591
- status: r.status,
26592
- ...r.error ? { error: r.error } : {},
26593
- ...r.labels ? { labels: r.labels } : {}
26594
- });
26595
- }
26596
- }
26597
- return result;
26598
- } catch (err) {
26599
- return ghRelayFailure(reply, err);
26600
- }
26601
- }
26602
- );
26603
- app.post(
26604
- "/runners/:runnerId/gh-runners/teardown",
26605
- {
26606
- schema: {
26607
- body: {
26608
- type: "object",
26609
- required: ["projectId"],
26610
- properties: {
26611
- projectId: { type: "string" },
26612
- sealedBundle: { type: "string", minLength: 1 },
26613
- // DEV-530: the one repo being disconnected — plaintext public
26614
- // identifiers (not secrets), so the HUB can drop its binding.
26615
- owner: { type: "string", minLength: 1 },
26616
- repo: { type: "string", minLength: 1 }
26617
- }
26618
- }
26619
- }
26620
- },
26621
- async (req, reply) => {
26622
- const { runnerId } = req.params;
26623
- const { projectId, sealedBundle, owner, repo } = req.body;
26624
- if (!await ghRunnerGuard(req.headers.authorization, reply, runnerId, projectId)) return reply;
26625
- try {
26626
- const result = await requestGhRunnersDelete(runnerId, projectId, sealedBundle, 12e4);
26627
- if (result.ok && owner && repo) {
26628
- await removeGhRunnerBinding(db, projectId, owner, repo);
26629
- }
26630
- return result;
26631
- } catch (err) {
26632
- return ghRelayFailure(reply, err);
26633
- }
26634
- }
26635
- );
26636
26337
  }
26637
26338
  var init_runner2 = __esm({
26638
26339
  "src/routes/runner.ts"() {
@@ -26644,8 +26345,92 @@ var init_runner2 = __esm({
26644
26345
  init_users();
26645
26346
  init_runner_reg_tokens();
26646
26347
  init_ws();
26647
- init_provision2();
26648
- init_bindings();
26348
+ }
26349
+ });
26350
+
26351
+ // src/farm/pool-spec.ts
26352
+ var pool_spec_exports = {};
26353
+ __export(pool_spec_exports, {
26354
+ buildSyncWorkerPoolMessage: () => buildSyncWorkerPoolMessage,
26355
+ computeFarmPoolSpec: () => computeFarmPoolSpec,
26356
+ reconcileFarmPools: () => reconcileFarmPools
26357
+ });
26358
+ import { nanoid as nanoid28 } from "nanoid";
26359
+ async function computeFarmPoolSpec(db, farmId) {
26360
+ const rows = await db.all(
26361
+ `SELECT fa.user_id as userId, fa.max_sandboxes as quota,
26362
+ p.id as projectId
26363
+ FROM farm_assignments fa
26364
+ JOIN projects p ON p.farm_id = fa.farm_id AND p.owner_user_id = fa.user_id
26365
+ WHERE fa.farm_id = ?
26366
+ ORDER BY fa.assigned_at ASC, p.id ASC`,
26367
+ farmId
26368
+ );
26369
+ const ceilingRow = await db.get(
26370
+ `SELECT max_sandboxes as ceiling FROM farms WHERE id = ?`,
26371
+ farmId
26372
+ );
26373
+ const ceiling = ceilingRow ? Number(ceilingRow.ceiling) : 16;
26374
+ const byAccount = /* @__PURE__ */ new Map();
26375
+ for (const r of rows) {
26376
+ const acc = byAccount.get(r.userId) ?? { quota: Number(r.quota), projectIds: [] };
26377
+ acc.projectIds.push(r.projectId);
26378
+ byAccount.set(r.userId, acc);
26379
+ }
26380
+ const spec = [];
26381
+ let remaining = ceiling;
26382
+ for (const [userId, acc] of byAccount) {
26383
+ const quota = Math.max(0, Math.min(acc.quota, remaining));
26384
+ remaining -= quota;
26385
+ spec.push({ userId, projectIds: acc.projectIds, quota });
26386
+ }
26387
+ return spec;
26388
+ }
26389
+ function buildSyncWorkerPoolMessage(accounts) {
26390
+ return { type: "sync_worker_pool", requestId: nanoid28(16), accounts };
26391
+ }
26392
+ async function reconcileFarmPools(db, send, log) {
26393
+ const farms = await db.all(`SELECT id FROM farms`);
26394
+ const live = new Set(farms.map((f) => f.id));
26395
+ for (const id of [...starvationReported]) {
26396
+ if (!live.has(id)) starvationReported.delete(id);
26397
+ }
26398
+ const starved = [];
26399
+ let pushed = 0;
26400
+ for (const farm of farms) {
26401
+ const accounts = await computeFarmPoolSpec(db, farm.id);
26402
+ if (accounts.length > 0) {
26403
+ const sent = send(farm.id, buildSyncWorkerPoolMessage(accounts));
26404
+ if (sent !== false) pushed++;
26405
+ starvationReported.delete(farm.id);
26406
+ continue;
26407
+ }
26408
+ const queued = await db.get(
26409
+ `SELECT COUNT(*) AS n FROM jobs j
26410
+ JOIN projects p ON p.id = j.project_id
26411
+ WHERE j.state = 'queued' AND p.farm_id = ?`,
26412
+ farm.id
26413
+ );
26414
+ if (Number(queued.n) === 0) {
26415
+ starvationReported.delete(farm.id);
26416
+ continue;
26417
+ }
26418
+ starved.push(farm.id);
26419
+ if (!starvationReported.has(farm.id)) {
26420
+ starvationReported.add(farm.id);
26421
+ log.warn(
26422
+ { farmId: farm.id, queued: Number(queued.n) },
26423
+ "farm has NO worker-pool accounts while jobs are queued \u2014 assignments missing?"
26424
+ );
26425
+ }
26426
+ }
26427
+ return { farms: farms.length, pushed, starved };
26428
+ }
26429
+ var starvationReported;
26430
+ var init_pool_spec = __esm({
26431
+ "src/farm/pool-spec.ts"() {
26432
+ "use strict";
26433
+ starvationReported = /* @__PURE__ */ new Set();
26649
26434
  }
26650
26435
  });
26651
26436
 
@@ -26905,25 +26690,25 @@ async function requestGrantsFrom(spec, recipient, bindingVersion, log, timeoutMs
26905
26690
  const requestId = msg.requestId;
26906
26691
  return await new Promise((resolve4) => {
26907
26692
  const timer = setTimeout(() => {
26908
- pending4.delete(requestId);
26693
+ pending2.delete(requestId);
26909
26694
  log.warn({ projectId: spec.projectId, jobId: spec.jobId, sandboxId: recipient.sandboxId }, "credential grants: CLI did not answer in time");
26910
26695
  resolve4(null);
26911
26696
  }, timeoutMs);
26912
- pending4.set(requestId, {
26697
+ pending2.set(requestId, {
26913
26698
  projectId: spec.projectId,
26914
26699
  jobId: spec.jobId,
26915
26700
  sandboxId: recipient.sandboxId,
26916
26701
  holder,
26917
26702
  resolve: (answer) => {
26918
26703
  clearTimeout(timer);
26919
- pending4.delete(requestId);
26704
+ pending2.delete(requestId);
26920
26705
  resolve4(answer);
26921
26706
  }
26922
26707
  });
26923
26708
  const sent = holder.kind === "provider" ? sendToAgentLoginProvider(holder.ownerKey, holder.providerId, msg) : sendToProjectCli(spec.projectId, msg);
26924
26709
  if (!sent) {
26925
26710
  clearTimeout(timer);
26926
- pending4.delete(requestId);
26711
+ pending2.delete(requestId);
26927
26712
  resolve4(null);
26928
26713
  }
26929
26714
  });
@@ -26934,7 +26719,7 @@ function acceptCredentialGrants(msg, from) {
26934
26719
  return { ok: false, reason: "malformed" };
26935
26720
  }
26936
26721
  if (typeof from === "string" && m.projectId !== from) return { ok: false, reason: "cross-project" };
26937
- const req = pending4.get(m.requestId);
26722
+ const req = pending2.get(m.requestId);
26938
26723
  if (!req) return { ok: false, reason: NO_PENDING_REQUEST };
26939
26724
  if (typeof from === "string") {
26940
26725
  if (req.holder.kind !== "client") return { ok: false, reason: "answer from a holder that was not asked" };
@@ -27140,15 +26925,15 @@ async function sweepExpiredGrants(db, now = Date.now()) {
27140
26925
  return changes;
27141
26926
  }
27142
26927
  function resolvePendingGrantRequest(requestId, answer) {
27143
- const req = pending4.get(requestId);
26928
+ const req = pending2.get(requestId);
27144
26929
  if (!req) return false;
27145
26930
  req.resolve(answer);
27146
26931
  return true;
27147
26932
  }
27148
26933
  function pendingGrantRequests() {
27149
- return pending4.size;
26934
+ return pending2.size;
27150
26935
  }
27151
- var GRANT_REQUEST_TIMEOUT_MS, pending4, NO_PENDING_REQUEST, LATE_ANSWER_TTL_MS, lateAnswers;
26936
+ var GRANT_REQUEST_TIMEOUT_MS, pending2, NO_PENDING_REQUEST, LATE_ANSWER_TTL_MS, lateAnswers;
27152
26937
  var init_credential_grants = __esm({
27153
26938
  "src/tracker/credential-grants.ts"() {
27154
26939
  "use strict";
@@ -27159,7 +26944,7 @@ var init_credential_grants = __esm({
27159
26944
  init_ws();
27160
26945
  init_sandbox_keys();
27161
26946
  GRANT_REQUEST_TIMEOUT_MS = 6e3;
27162
- pending4 = /* @__PURE__ */ new Map();
26947
+ pending2 = /* @__PURE__ */ new Map();
27163
26948
  NO_PENDING_REQUEST = "no pending request";
27164
26949
  LATE_ANSWER_TTL_MS = 6e4;
27165
26950
  lateAnswers = /* @__PURE__ */ new Map();
@@ -29078,7 +28863,7 @@ function installOfflineSweeper(deps) {
29078
28863
  init_dist5();
29079
28864
  import { existsSync as existsSync4 } from "node:fs";
29080
28865
  import staticPlugin from "@fastify/static";
29081
- import { nanoid as nanoid31 } from "nanoid";
28866
+ import { nanoid as nanoid29 } from "nanoid";
29082
28867
 
29083
28868
  // src/http/browser-routes.ts
29084
28869
  init_route_definition();
@@ -29239,7 +29024,7 @@ async function registerSpaHosts(app, opts) {
29239
29024
  return onConsole ? reply.sendFile("index.html", adminDist) : reply.sendFile("index.html");
29240
29025
  }
29241
29026
  const failure = isRetiredClientPath(req.url, opts.registeredPaths ?? []) ? clientTooOldError() : errors.notFound();
29242
- const rendered = renderError(failure, `req_${nanoid31(16)}`);
29027
+ const rendered = renderError(failure, `req_${nanoid29(16)}`);
29243
29028
  return reply.status(rendered.status).send(rendered.body);
29244
29029
  });
29245
29030
  }
@@ -29253,7 +29038,7 @@ import { createHash as createHash9, randomBytes as randomBytes3 } from "node:cry
29253
29038
  import { existsSync as existsSync5, readFileSync as readFileSync3 } from "node:fs";
29254
29039
  import { join as join4 } from "node:path";
29255
29040
  init_dist5();
29256
- import { nanoid as nanoid32 } from "nanoid";
29041
+ import { nanoid as nanoid30 } from "nanoid";
29257
29042
  var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
29258
29043
  var nonces = /* @__PURE__ */ new WeakMap();
29259
29044
  function cspNonce(req) {
@@ -29342,7 +29127,7 @@ function installBrowserSecurity(app, config, options = {}) {
29342
29127
  message: "This request did not come from an allowed origin.",
29343
29128
  details: { reason: verdict.reason }
29344
29129
  }),
29345
- `req_${nanoid32(16)}`
29130
+ `req_${nanoid30(16)}`
29346
29131
  );
29347
29132
  await reply.status(rendered.status).send(rendered.body);
29348
29133
  });
@@ -29470,7 +29255,7 @@ init_route_registry();
29470
29255
 
29471
29256
  // src/routes/api-v1/context.ts
29472
29257
  init_dist5();
29473
- import { nanoid as nanoid33 } from "nanoid";
29258
+ import { nanoid as nanoid31 } from "nanoid";
29474
29259
 
29475
29260
  // src/auth/resolve-principal.ts
29476
29261
  init_auth();
@@ -29596,7 +29381,7 @@ async function resolveSession(db, req, surface, base) {
29596
29381
 
29597
29382
  // src/routes/api-v1/context.ts
29598
29383
  function requestIdOf(_req) {
29599
- return `req_${nanoid33(16)}`;
29384
+ return `req_${nanoid31(16)}`;
29600
29385
  }
29601
29386
  async function actorOr401(deps, req, reply, requestId) {
29602
29387
  const resolution = await resolvePrincipal({
@@ -30009,6 +29794,49 @@ async function apiV1ReadRoutes(app, deps) {
30009
29794
  }
30010
29795
  }
30011
29796
  );
29797
+ app.get(
29798
+ "/projects/:projectId/deliverables",
29799
+ routeMeta({
29800
+ surface: "application",
29801
+ operationId: "project.deliverables",
29802
+ authPolicy: "application_actor",
29803
+ resourcePolicy: "delivery.read",
29804
+ stability: "preview",
29805
+ summary: "Tasks whose branches a developer's clone can pull"
29806
+ }),
29807
+ async (req, reply) => {
29808
+ const requestId = requestIdOf(req);
29809
+ try {
29810
+ const actor = await actorOr401({ db, config }, req, reply, requestId);
29811
+ if (!actor) return reply;
29812
+ const { projectId } = req.params;
29813
+ return await read.listProjectDeliverables(actor, { projectId });
29814
+ } catch (err) {
29815
+ return sendError(reply, err, requestId);
29816
+ }
29817
+ }
29818
+ );
29819
+ app.get(
29820
+ "/farms",
29821
+ routeMeta({
29822
+ surface: "application",
29823
+ operationId: "farm.list",
29824
+ authPolicy: "application_actor",
29825
+ resourcePolicy: "account.read",
29826
+ stability: "preview",
29827
+ summary: "Farms assigned to the caller's account"
29828
+ }),
29829
+ async (req, reply) => {
29830
+ const requestId = requestIdOf(req);
29831
+ try {
29832
+ const actor = await actorOr401({ db, config }, req, reply, requestId);
29833
+ if (!actor) return reply;
29834
+ return await read.listFarms(actor);
29835
+ } catch (err) {
29836
+ return sendError(reply, err, requestId);
29837
+ }
29838
+ }
29839
+ );
30012
29840
  app.get(
30013
29841
  "/projects/:projectId/config-proposals",
30014
29842
  routeMeta({
@@ -30558,6 +30386,101 @@ var OBSERVER_PASS_BODY = {
30558
30386
  additionalProperties: false,
30559
30387
  properties: { note: { type: "string", maxLength: 4e3 } }
30560
30388
  };
30389
+ var CREATE_PROJECT_BODY = {
30390
+ type: "object",
30391
+ additionalProperties: false,
30392
+ required: ["projectId", "name", "automationLevel", "repos", "contextRepo"],
30393
+ properties: {
30394
+ projectId: { type: "string", minLength: 2, maxLength: 31 },
30395
+ name: { type: "string", minLength: 1, maxLength: 200 },
30396
+ automationLevel: { type: "string", enum: ["assisted", "full"] },
30397
+ trackerProvider: { type: "string", enum: ["internal", "youtrack"] },
30398
+ trackerProjectKey: { type: "string", minLength: 1, maxLength: 64 },
30399
+ repos: {
30400
+ type: "array",
30401
+ maxItems: 100,
30402
+ items: {
30403
+ ...REPO_BODY,
30404
+ properties: {
30405
+ ...REPO_BODY.properties,
30406
+ mirror: {
30407
+ type: "object",
30408
+ additionalProperties: false,
30409
+ required: ["host", "owner", "repoName"],
30410
+ properties: {
30411
+ host: { type: "string", enum: ["github", "bitbucket", "gitlab"] },
30412
+ owner: { type: "string", minLength: 1, maxLength: 200 },
30413
+ repoName: { type: "string", minLength: 1, maxLength: 200 }
30414
+ },
30415
+ description: "ADR-0041: the mirror the pipeline pushes this repository's branches to."
30416
+ }
30417
+ }
30418
+ }
30419
+ },
30420
+ contextRepo: {
30421
+ type: "object",
30422
+ additionalProperties: false,
30423
+ required: ["host", "owner", "name", "cloneUrl", "branch"],
30424
+ properties: {
30425
+ host: { type: "string", enum: ["github", "bitbucket", "gitlab", "local"] },
30426
+ owner: { type: "string", minLength: 1, maxLength: 200 },
30427
+ name: { type: "string", minLength: 1, maxLength: 200 },
30428
+ cloneUrl: { type: "string", minLength: 1, maxLength: 2e3 },
30429
+ branch: { type: "string", minLength: 1, maxLength: 250 }
30430
+ }
30431
+ },
30432
+ language: {
30433
+ type: "object",
30434
+ maxProperties: 20,
30435
+ additionalProperties: { type: "string", maxLength: 64 },
30436
+ description: "DEV-551: per-section language chosen at init."
30437
+ }
30438
+ }
30439
+ };
30440
+ var SEALED_HANDOFF_BODY = {
30441
+ type: "object",
30442
+ additionalProperties: false,
30443
+ required: ["projectId", "sealedBundle"],
30444
+ properties: {
30445
+ projectId: { type: "string", minLength: 1, maxLength: 64 },
30446
+ sealedBundle: { type: "string", minLength: 1 }
30447
+ }
30448
+ };
30449
+ var GH_RUNNERS_TEARDOWN_BODY = {
30450
+ type: "object",
30451
+ additionalProperties: false,
30452
+ required: ["projectId"],
30453
+ properties: {
30454
+ projectId: { type: "string", minLength: 1, maxLength: 64 },
30455
+ sealedBundle: { type: "string", minLength: 1 },
30456
+ repo: {
30457
+ type: "object",
30458
+ additionalProperties: false,
30459
+ required: ["owner", "repo"],
30460
+ properties: {
30461
+ owner: { type: "string", minLength: 1, maxLength: 200 },
30462
+ repo: { type: "string", minLength: 1, maxLength: 200 }
30463
+ },
30464
+ description: "DEV-530: the one repository being disconnected, so its binding is dropped."
30465
+ }
30466
+ }
30467
+ };
30468
+ var PROJECT_INDEX_BODY = {
30469
+ type: "object",
30470
+ additionalProperties: false,
30471
+ properties: {
30472
+ kind: { type: "string", enum: ["index", "backfill"], description: "Defaults to the ordinary index." }
30473
+ }
30474
+ };
30475
+ var INIT_STATUS_BODY = {
30476
+ type: "object",
30477
+ additionalProperties: false,
30478
+ required: ["phase"],
30479
+ properties: {
30480
+ phase: { type: "string", enum: ["context_cloning"] },
30481
+ error: { type: "string", minLength: 1, maxLength: 500 }
30482
+ }
30483
+ };
30561
30484
  function responseOf(body) {
30562
30485
  switch (body.decision) {
30563
30486
  case "answer":
@@ -30962,6 +30885,169 @@ async function apiV1MutationRoutes(app, deps) {
30962
30885
  }
30963
30886
  }
30964
30887
  );
30888
+ app.post(
30889
+ "/projects",
30890
+ routeMeta({
30891
+ surface: "application",
30892
+ operationId: "project.create",
30893
+ authPolicy: "application_actor",
30894
+ resourcePolicy: "account.manage",
30895
+ stability: "preview",
30896
+ summary: "Register a project under the caller's account",
30897
+ requestSchema: CREATE_PROJECT_BODY
30898
+ }),
30899
+ async (req, reply) => {
30900
+ const requestId = requestIdOf(req);
30901
+ try {
30902
+ const actor = await actorOr401({ db, config }, req, reply, requestId);
30903
+ if (!actor) return reply;
30904
+ const body = req.body;
30905
+ const created = await commands.createProject(actor, {
30906
+ project: {
30907
+ projectId: body.projectId,
30908
+ name: body.name,
30909
+ automationLevel: body.automationLevel,
30910
+ trackerProvider: body.trackerProvider ?? "youtrack",
30911
+ trackerProjectKey: body.trackerProjectKey ?? null,
30912
+ repos: body.repos,
30913
+ contextRepo: body.contextRepo,
30914
+ language: body.language ?? null
30915
+ },
30916
+ idempotencyKey: requiredIdempotencyKey(req)
30917
+ });
30918
+ return reply.status(201).header("location", `/api/v1/projects/${encodeURIComponent(created.projectId)}`).send(created);
30919
+ } catch (err) {
30920
+ return sendError(reply, err, requestId);
30921
+ }
30922
+ }
30923
+ );
30924
+ app.post(
30925
+ "/farms/:farmId/provision",
30926
+ routeMeta({
30927
+ surface: "application",
30928
+ operationId: "farm.provision",
30929
+ authPolicy: "application_actor",
30930
+ resourcePolicy: "project.manage",
30931
+ stability: "preview",
30932
+ summary: "Hand a sealed project bundle to a farm and wait for its verdict",
30933
+ requestSchema: SEALED_HANDOFF_BODY
30934
+ }),
30935
+ async (req, reply) => {
30936
+ const requestId = requestIdOf(req);
30937
+ try {
30938
+ const actor = await actorOr401({ db, config }, req, reply, requestId);
30939
+ if (!actor) return reply;
30940
+ const { farmId } = req.params;
30941
+ const { projectId, sealedBundle } = req.body;
30942
+ return await commands.provisionOnFarm(actor, { farmId, projectId, sealedBundle, idempotencyKey: requiredIdempotencyKey(req) });
30943
+ } catch (err) {
30944
+ return sendError(reply, err, requestId);
30945
+ }
30946
+ }
30947
+ );
30948
+ app.post(
30949
+ "/runners/:runnerId/gh-runners",
30950
+ routeMeta({
30951
+ surface: "application",
30952
+ operationId: "runner.provisionGhRunners",
30953
+ authPolicy: "application_actor",
30954
+ resourcePolicy: "project.manage",
30955
+ stability: "preview",
30956
+ summary: "Relay a sealed GitHub-runner bundle to a runner host of the caller's",
30957
+ requestSchema: SEALED_HANDOFF_BODY
30958
+ }),
30959
+ async (req, reply) => {
30960
+ const requestId = requestIdOf(req);
30961
+ try {
30962
+ const actor = await actorOr401({ db, config }, req, reply, requestId);
30963
+ if (!actor) return reply;
30964
+ const { runnerId } = req.params;
30965
+ const { projectId, sealedBundle } = req.body;
30966
+ return await commands.provisionGhRunners(actor, { runnerId, projectId, sealedBundle, idempotencyKey: requiredIdempotencyKey(req) });
30967
+ } catch (err) {
30968
+ return sendError(reply, err, requestId);
30969
+ }
30970
+ }
30971
+ );
30972
+ app.post(
30973
+ "/runners/:runnerId/gh-runners/teardown",
30974
+ routeMeta({
30975
+ surface: "application",
30976
+ operationId: "runner.teardownGhRunners",
30977
+ authPolicy: "application_actor",
30978
+ resourcePolicy: "project.manage",
30979
+ stability: "preview",
30980
+ summary: "Tear the project's GitHub runners down on that host",
30981
+ requestSchema: GH_RUNNERS_TEARDOWN_BODY
30982
+ }),
30983
+ async (req, reply) => {
30984
+ const requestId = requestIdOf(req);
30985
+ try {
30986
+ const actor = await actorOr401({ db, config }, req, reply, requestId);
30987
+ if (!actor) return reply;
30988
+ const { runnerId } = req.params;
30989
+ const body = req.body;
30990
+ return await commands.teardownGhRunners(actor, {
30991
+ runnerId,
30992
+ projectId: body.projectId,
30993
+ sealedBundle: body.sealedBundle,
30994
+ repo: body.repo,
30995
+ idempotencyKey: requiredIdempotencyKey(req)
30996
+ });
30997
+ } catch (err) {
30998
+ return sendError(reply, err, requestId);
30999
+ }
31000
+ }
31001
+ );
31002
+ app.post(
31003
+ "/projects/:projectId/index",
31004
+ routeMeta({
31005
+ surface: "application",
31006
+ operationId: "project.index",
31007
+ authPolicy: "application_actor",
31008
+ resourcePolicy: "project.manage",
31009
+ stability: "preview",
31010
+ summary: "Queue the project's one-shot index, or its retrospective ADR backfill",
31011
+ requestSchema: PROJECT_INDEX_BODY
31012
+ }),
31013
+ async (req, reply) => {
31014
+ const requestId = requestIdOf(req);
31015
+ try {
31016
+ const actor = await actorOr401({ db, config }, req, reply, requestId);
31017
+ if (!actor) return reply;
31018
+ const { projectId } = req.params;
31019
+ const { kind } = req.body ?? {};
31020
+ const queued = await commands.requestProjectIndex(actor, { projectId, kind: kind ?? "index", idempotencyKey: requiredIdempotencyKey(req) });
31021
+ return reply.status(202).send(queued);
31022
+ } catch (err) {
31023
+ return sendError(reply, err, requestId);
31024
+ }
31025
+ }
31026
+ );
31027
+ app.post(
31028
+ "/projects/:projectId/init-status",
31029
+ routeMeta({
31030
+ surface: "application",
31031
+ operationId: "project.reportInitStatus",
31032
+ authPolicy: "application_actor",
31033
+ resourcePolicy: "project.manage",
31034
+ stability: "preview",
31035
+ summary: "Report an init step the project's client took before it connected",
31036
+ requestSchema: INIT_STATUS_BODY
31037
+ }),
31038
+ async (req, reply) => {
31039
+ const requestId = requestIdOf(req);
31040
+ try {
31041
+ const actor = await actorOr401({ db, config }, req, reply, requestId);
31042
+ if (!actor) return reply;
31043
+ const { projectId } = req.params;
31044
+ const { phase, error } = req.body;
31045
+ return await commands.reportInitStatus(actor, { projectId, phase, error });
31046
+ } catch (err) {
31047
+ return sendError(reply, err, requestId);
31048
+ }
31049
+ }
31050
+ );
30965
31051
  app.post(
30966
31052
  "/projects/:projectId/repos",
30967
31053
  routeMeta({
@@ -31766,7 +31852,7 @@ init_regimen_config();
31766
31852
  // src/projects/cred-updates.ts
31767
31853
  init_dist();
31768
31854
  init_ws();
31769
- import { nanoid as nanoid34 } from "nanoid";
31855
+ import { nanoid as nanoid32 } from "nanoid";
31770
31856
  var CRED_KIND_NOT_REPLACEABLE = "cred_kind_not_replaceable";
31771
31857
  function checkCredKind(kind) {
31772
31858
  if (isCredKind(kind)) return { ok: true, kind };
@@ -31789,7 +31875,7 @@ async function retireNonReplaceableCredUpdates(db, log) {
31789
31875
  return changes;
31790
31876
  }
31791
31877
  async function enqueueCredUpdate(db, projectId, kind, sealed, requestedBy, log) {
31792
- const id = `cred_${nanoid34(16)}`;
31878
+ const id = `cred_${nanoid32(16)}`;
31793
31879
  const now = Date.now();
31794
31880
  await db.run(
31795
31881
  `UPDATE cred_updates SET state = 'rejected', detail = 'superseded by a newer replacement', updated_at = ?
@@ -31908,6 +31994,7 @@ function installCredUpdateHandlers(db, log) {
31908
31994
 
31909
31995
  // src/application/read-model.ts
31910
31996
  init_ws();
31997
+ init_farm2();
31911
31998
  init_queue_view();
31912
31999
  init_state_machine2();
31913
32000
  init_delivery_decision();
@@ -32119,7 +32206,7 @@ function configProposalRow(stored) {
32119
32206
  }
32120
32207
 
32121
32208
  // src/config-proposals/state-machine.ts
32122
- import { nanoid as nanoid35 } from "nanoid";
32209
+ import { nanoid as nanoid33 } from "nanoid";
32123
32210
  function rowToProposal(raw) {
32124
32211
  return {
32125
32212
  id: raw["id"],
@@ -32144,7 +32231,7 @@ var PROPOSAL_COLUMNS = `id, project_id as projectId, user_input as userInput, st
32144
32231
  created_at as createdAt, updated_at as updatedAt`;
32145
32232
  var PROPOSAL_SELECT = `SELECT ${PROPOSAL_COLUMNS} FROM config_proposals`;
32146
32233
  async function createProposal(db, projectId, userInput) {
32147
- const id = `cfp_${nanoid35(16)}`;
32234
+ const id = `cfp_${nanoid33(16)}`;
32148
32235
  const now = Date.now();
32149
32236
  await db.run(
32150
32237
  `INSERT INTO config_proposals
@@ -32793,7 +32880,7 @@ async function projectDetailOf(db, projectId, raw) {
32793
32880
  actionErrors: getProjectActions(projectId)?.actionErrors ?? []
32794
32881
  };
32795
32882
  }
32796
- function createReadModel(db) {
32883
+ function createReadModel(db, options = {}) {
32797
32884
  const SUBMISSION_COLUMNS = `${DRAFT_COLUMNS},
32798
32885
  (SELECT COUNT(*) FROM draft_tasks c WHERE c.parent_draft_id = draft_tasks.id) as childCount,
32799
32886
  EXISTS (SELECT 1 FROM tasks t
@@ -33485,6 +33572,35 @@ function createReadModel(db) {
33485
33572
  const p = await getPresence(db, { email: viewer.email ?? "", userId: viewer.subjectId });
33486
33573
  return presenceRowOf(p, new Set(viewer.projectIds));
33487
33574
  },
33575
+ // --- the account's fleet (DEV-927) ---
33576
+ async listFarms(viewer) {
33577
+ const isOperator = viewer.email !== null && (options.isOperator?.(viewer.email) ?? false);
33578
+ return await listFarms(db, { email: viewer.email ?? "", userId: viewer.subjectId, isSuperAdmin: isOperator });
33579
+ },
33580
+ async listProjectDeliverables(projectId) {
33581
+ const rows = await db.all(
33582
+ `SELECT id, tracker_issue_key AS "taskKey", title, phase, updated_at AS "updatedAt"
33583
+ FROM tasks
33584
+ WHERE project_id = ? AND (archived IS NULL OR archived = 0)
33585
+ AND phase IN ('TRACKING', 'AWAITING_CLUSTER', 'DONE', 'NEEDS_MANUAL')
33586
+ ORDER BY CASE WHEN phase IN ('TRACKING', 'AWAITING_CLUSTER') THEN 0 ELSE 1 END,
33587
+ updated_at DESC
33588
+ LIMIT 50`,
33589
+ projectId
33590
+ );
33591
+ const out = [];
33592
+ for (const r of rows) {
33593
+ const byRepo = await loadTaskBranchesByRepo(db, r.id);
33594
+ const repos = Object.entries(byRepo).map(([repoName, b]) => ({
33595
+ repoName,
33596
+ branchName: b.branchName,
33597
+ baseRef: b.baseRef
33598
+ }));
33599
+ if (repos.length === 0) continue;
33600
+ out.push({ taskKey: r.taskKey, title: r.title, phase: r.phase, updatedAt: Number(r.updatedAt), repos });
33601
+ }
33602
+ return out;
33603
+ },
33488
33604
  async readNotificationSettings(subjectId) {
33489
33605
  const user = await db.get(`SELECT 1 as hit FROM users WHERE id = ?`, subjectId);
33490
33606
  if (!user) return null;
@@ -33573,7 +33689,7 @@ init_state_machine();
33573
33689
  // src/drafts/publish.ts
33574
33690
  init_ws();
33575
33691
  init_state_machine();
33576
- import { nanoid as nanoid36 } from "nanoid";
33692
+ import { nanoid as nanoid34 } from "nanoid";
33577
33693
  function firstNonBlank(...vals) {
33578
33694
  for (const v of vals) {
33579
33695
  if (v != null && v.trim() !== "") return v;
@@ -33581,7 +33697,7 @@ function firstNonBlank(...vals) {
33581
33697
  return "";
33582
33698
  }
33583
33699
  async function dispatchPublish(db, draft, targetStatus, log) {
33584
- const publishActionId = `dpub_${nanoid36(16)}`;
33700
+ const publishActionId = `dpub_${nanoid34(16)}`;
33585
33701
  const title = firstNonBlank(draft.editedTitle, draft.generatedTitle);
33586
33702
  const body = firstNonBlank(draft.editedBody, draft.generatedBody);
33587
33703
  const project = await db.get(`SELECT youtrack_project_key as youtrackProjectKey FROM projects WHERE id = ?`, draft.projectId);
@@ -33727,7 +33843,7 @@ async function maybeAdvancePackage(db, parentDraftId, log) {
33727
33843
  log.info({ draftId: parentDraftId }, "package published (no links) \u2192 DONE");
33728
33844
  return;
33729
33845
  }
33730
- const linkActionId = `dpkg_${nanoid36(16)}`;
33846
+ const linkActionId = `dpkg_${nanoid34(16)}`;
33731
33847
  const action = {
33732
33848
  type: "apply_tracker_links",
33733
33849
  draftId: parentDraftId,
@@ -33922,7 +34038,113 @@ async function discardDraft(db, draft, log) {
33922
34038
  init_dist5();
33923
34039
  init_regimen_write();
33924
34040
  init_ws();
33925
- init_provision();
34041
+
34042
+ // src/farm/provision.ts
34043
+ init_ws();
34044
+ init_pool_spec();
34045
+ import { nanoid as nanoid35 } from "nanoid";
34046
+ var pending3 = /* @__PURE__ */ new Map();
34047
+ var RESULT_TYPES = /* @__PURE__ */ new Set(["provision_result", "delete_result", "respawn_result"]);
34048
+ function handleFarmResult(msg) {
34049
+ const type = msg["type"];
34050
+ if (typeof type !== "string" || !RESULT_TYPES.has(type)) return;
34051
+ const requestId = typeof msg["requestId"] === "string" ? msg["requestId"] : "";
34052
+ const p = pending3.get(requestId);
34053
+ if (!p) return;
34054
+ clearTimeout(p.timer);
34055
+ pending3.delete(requestId);
34056
+ p.resolve(msg);
34057
+ }
34058
+ function awaitResult(requestId, send, timeoutMs) {
34059
+ if (!send()) return Promise.reject(new Error("farm_offline"));
34060
+ return new Promise((resolve4, reject) => {
34061
+ const timer = setTimeout(() => {
34062
+ pending3.delete(requestId);
34063
+ reject(new Error("farm_timeout"));
34064
+ }, timeoutMs);
34065
+ pending3.set(requestId, { resolve: resolve4, reject, timer });
34066
+ });
34067
+ }
34068
+ function requestProvision(farmId, projectId, sealedBundle, timeoutMs) {
34069
+ const requestId = nanoid35(16);
34070
+ return awaitResult(
34071
+ requestId,
34072
+ () => sendToFarm(farmId, { type: "provision_project", requestId, projectId, sealedBundle }),
34073
+ timeoutMs
34074
+ );
34075
+ }
34076
+ function requestDelete(farmId, projectId, timeoutMs) {
34077
+ const requestId = nanoid35(16);
34078
+ return awaitResult(
34079
+ requestId,
34080
+ () => sendToFarm(farmId, { type: "delete_project", requestId, projectId }),
34081
+ timeoutMs
34082
+ );
34083
+ }
34084
+ function requestRespawn(farmId, projectId, timeoutMs) {
34085
+ const requestId = nanoid35(16);
34086
+ return awaitResult(
34087
+ requestId,
34088
+ () => sendToFarm(farmId, { type: "respawn_project", requestId, projectId }),
34089
+ timeoutMs
34090
+ );
34091
+ }
34092
+ async function requestFarmPoolSync(db, farmId) {
34093
+ const accounts = await computeFarmPoolSpec(db, farmId);
34094
+ sendToFarm(farmId, buildSyncWorkerPoolMessage(accounts));
34095
+ }
34096
+
34097
+ // src/runner/provision.ts
34098
+ init_ws();
34099
+ import { nanoid as nanoid36 } from "nanoid";
34100
+ var pending4 = /* @__PURE__ */ new Map();
34101
+ var RESULT_TYPES2 = /* @__PURE__ */ new Set(["provision_gh_runners_result", "delete_gh_runners_result"]);
34102
+ function handleRunnerResult(msg) {
34103
+ const type = msg["type"];
34104
+ if (typeof type !== "string" || !RESULT_TYPES2.has(type)) return;
34105
+ const requestId = typeof msg["requestId"] === "string" ? msg["requestId"] : "";
34106
+ const p = pending4.get(requestId);
34107
+ if (!p) return;
34108
+ clearTimeout(p.timer);
34109
+ pending4.delete(requestId);
34110
+ p.resolve(msg);
34111
+ }
34112
+ function awaitResult2(requestId, send, timeoutMs) {
34113
+ if (!send()) return Promise.reject(new Error("runner_offline"));
34114
+ return new Promise((resolve4, reject) => {
34115
+ const timer = setTimeout(() => {
34116
+ pending4.delete(requestId);
34117
+ reject(new Error("runner_timeout"));
34118
+ }, timeoutMs);
34119
+ pending4.set(requestId, { resolve: resolve4, reject, timer });
34120
+ });
34121
+ }
34122
+ function requestGhRunnersProvision(runnerId, projectId, sealedBundle, timeoutMs) {
34123
+ const requestId = nanoid36(16);
34124
+ return awaitResult2(
34125
+ requestId,
34126
+ () => sendToRunner(runnerId, { type: "provision_gh_runners", requestId, projectId, sealedBundle }),
34127
+ timeoutMs
34128
+ );
34129
+ }
34130
+ function requestGhRunnersDelete(runnerId, projectId, sealedBundle, timeoutMs) {
34131
+ const requestId = nanoid36(16);
34132
+ return awaitResult2(
34133
+ requestId,
34134
+ () => sendToRunner(runnerId, {
34135
+ type: "delete_gh_runners",
34136
+ requestId,
34137
+ projectId,
34138
+ ...sealedBundle ? { sealedBundle } : {}
34139
+ }),
34140
+ timeoutMs
34141
+ );
34142
+ }
34143
+
34144
+ // src/application/write-model.ts
34145
+ init_farm2();
34146
+ init_runner2();
34147
+ init_init_status();
33926
34148
 
33927
34149
  // src/projects/forget.ts
33928
34150
  init_legacy_cred_bundles();
@@ -33987,8 +34209,71 @@ async function sweepOrphanedProjectRows(db) {
33987
34209
  return removed;
33988
34210
  }
33989
34211
 
34212
+ // src/runner/bindings.ts
34213
+ function parseGhRunnerBindings(json) {
34214
+ if (!json) return [];
34215
+ try {
34216
+ const parsed = JSON.parse(json);
34217
+ return Array.isArray(parsed.bindings) ? parsed.bindings : [];
34218
+ } catch {
34219
+ return [];
34220
+ }
34221
+ }
34222
+ function serialize(bindings) {
34223
+ return JSON.stringify({ bindings });
34224
+ }
34225
+ async function upsertGhRunnerBinding(db, projectId, binding) {
34226
+ await db.tx(async (tx) => {
34227
+ const row = await tx.get(
34228
+ `SELECT gh_runner_bindings_json as bindingsJson FROM projects WHERE id = ?`,
34229
+ projectId
34230
+ );
34231
+ if (!row) return;
34232
+ const bindings = parseGhRunnerBindings(row.bindingsJson).filter(
34233
+ (b) => !(b.owner === binding.owner && b.repo === binding.repo)
34234
+ );
34235
+ bindings.push(binding);
34236
+ await tx.run(
34237
+ `UPDATE projects SET gh_runner_bindings_json = ? WHERE id = ?`,
34238
+ serialize(bindings),
34239
+ projectId
34240
+ );
34241
+ });
34242
+ }
34243
+ async function removeGhRunnerBinding(db, projectId, owner, repo) {
34244
+ return db.tx(async (tx) => {
34245
+ const row = await tx.get(
34246
+ `SELECT gh_runner_bindings_json as bindingsJson FROM projects WHERE id = ?`,
34247
+ projectId
34248
+ );
34249
+ if (!row) return false;
34250
+ const bindings = parseGhRunnerBindings(row.bindingsJson);
34251
+ const kept = bindings.filter((b) => !(b.owner === owner && b.repo === repo));
34252
+ if (kept.length === bindings.length) return false;
34253
+ await tx.run(
34254
+ `UPDATE projects SET gh_runner_bindings_json = ? WHERE id = ?`,
34255
+ serialize(kept),
34256
+ projectId
34257
+ );
34258
+ return true;
34259
+ });
34260
+ }
34261
+ async function runnerBoundProjectCount(db, runnerId) {
34262
+ const rows = await db.all(
34263
+ `SELECT gh_runner_bindings_json AS json FROM projects WHERE gh_runner_bindings_json IS NOT NULL`
34264
+ );
34265
+ let n = 0;
34266
+ for (const r of rows) {
34267
+ try {
34268
+ const bindings = JSON.parse(r.json).bindings ?? [];
34269
+ if (bindings.some((b) => b.runnerId === runnerId)) n++;
34270
+ } catch {
34271
+ }
34272
+ }
34273
+ return n;
34274
+ }
34275
+
33990
34276
  // src/application/write-model.ts
33991
- init_bindings();
33992
34277
  init_tracking_commands();
33993
34278
 
33994
34279
  // src/config-proposals/apply.ts
@@ -34122,10 +34407,26 @@ function conflict(reason) {
34122
34407
  function stale() {
34123
34408
  return { ok: false, refusal: "stale_revision" };
34124
34409
  }
34410
+ function notFound() {
34411
+ return { ok: false, refusal: "not_found" };
34412
+ }
34413
+ var FARM_PROVISION_TIMEOUT_MS = 6e4;
34414
+ var GH_RUNNER_RELAY_TIMEOUT_MS = 12e4;
34415
+ function nodeUnavailable(capability, err) {
34416
+ const reason = err instanceof Error ? err.message : `${capability}_error`;
34417
+ const what = capability === "farm" ? "The farm" : "The runner host";
34418
+ return {
34419
+ ok: false,
34420
+ refusal: "capability_unavailable",
34421
+ capability,
34422
+ message: reason === `${capability}_timeout` ? `${what} did not answer in time. Try again once it is responsive.` : reason === `${capability}_offline` ? `${what} is offline. Start it and try again.` : `${what} could not be reached for this. Try again in a moment.`,
34423
+ retryable: true
34424
+ };
34425
+ }
34125
34426
  function taskState(row) {
34126
34427
  return { ...row, revision: String(row.updatedAt) };
34127
34428
  }
34128
- function createWriteModel(db, log) {
34429
+ function createWriteModel(db, log, hooks = {}) {
34129
34430
  async function readTaskRow(projectId, key) {
34130
34431
  const row = await db.get(
34131
34432
  `SELECT project_id as projectId, tracker_issue_key as key, phase,
@@ -34200,6 +34501,13 @@ function createWriteModel(db, log) {
34200
34501
  if (sandboxId !== null) sendToSandbox(sandboxId, { type: "pause_job", jobId: stored.sourceJobId });
34201
34502
  await cancelActiveJobs(db, `config::${stored.id}`);
34202
34503
  }
34504
+ function isOperator(actor) {
34505
+ return actor.email !== null && (hooks.isOperator?.(actor.email) ?? false);
34506
+ }
34507
+ async function ghRunnerHost(runnerId, actor) {
34508
+ const owns = isOperator(actor) ? await db.get(`SELECT id FROM runners WHERE id = ?`, runnerId) !== void 0 : await viewerOwnsRunner(db, { email: actor.email ?? "", userId: actor.subjectId }, runnerId);
34509
+ return owns ? ok(true) : notFound();
34510
+ }
34203
34511
  return {
34204
34512
  async readTask(projectId, taskKey) {
34205
34513
  const row = await readTaskRow(projectId, taskKey);
@@ -34671,6 +34979,143 @@ function createWriteModel(db, log) {
34671
34979
  * daemon behind it. The Context Repo remote is not touched: what the
34672
34980
  * pipeline wrote about the work outlives the record of the project.
34673
34981
  */
34982
+ // --- registration and the account's fleet (DEV-927) ---
34983
+ async createProject({ project, owner }) {
34984
+ const trackerProvider = project.trackerProvider;
34985
+ const inserted = await db.tx(async (tx) => {
34986
+ const existing = await tx.get(`SELECT id FROM projects WHERE id = ?`, project.projectId);
34987
+ if (existing) return conflict("project_already_exists");
34988
+ const now = Date.now();
34989
+ await tx.run(
34990
+ `INSERT INTO projects (id, name, automation_level, youtrack_project_key, tracker_provider,
34991
+ repos_json, owner_email, owner_user_id, language_json,
34992
+ provisioning_status, context_repo_json, provisioned_at, provisioning_error, created_at)
34993
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'provisioned', ?, ?, NULL, ?)`,
34994
+ project.projectId,
34995
+ project.name,
34996
+ project.automationLevel,
34997
+ project.trackerProjectKey,
34998
+ trackerProvider,
34999
+ JSON.stringify(
35000
+ project.repos.map((r) => ({
35001
+ name: r.name,
35002
+ host: r.host,
35003
+ owner: r.owner,
35004
+ repoName: r.repoName,
35005
+ ...r.defaultBranch ? { defaultBranch: r.defaultBranch } : {},
35006
+ ...r.mountAs ? { mountAs: r.mountAs } : {},
35007
+ ...r.mirror ? { mirror: { host: r.mirror.host, owner: r.mirror.owner, repoName: r.mirror.repoName } } : {}
35008
+ }))
35009
+ ),
35010
+ owner.email,
35011
+ owner.subjectId,
35012
+ project.language === null ? null : JSON.stringify(project.language),
35013
+ JSON.stringify(project.contextRepo),
35014
+ now,
35015
+ now
35016
+ );
35017
+ return ok({ projectId: project.projectId });
35018
+ });
35019
+ if (!inserted.ok) return inserted;
35020
+ await beginInitTracking(db, project.projectId, "registered");
35021
+ log.info({ projectId: project.projectId, contextRepo: project.contextRepo }, "project registered, Context Repo provisioned by the client");
35022
+ hooks.projectRegistered?.({
35023
+ projectId: project.projectId,
35024
+ name: project.name,
35025
+ automationLevel: project.automationLevel,
35026
+ ownerEmail: owner.email,
35027
+ repoCount: project.repos.length
35028
+ });
35029
+ return inserted;
35030
+ },
35031
+ async provisionOnFarm({ farmId, projectId, sealedBundle, actor }) {
35032
+ const usable = isOperator(actor) ? await db.get(`SELECT id FROM farms WHERE id = ?`, farmId) !== void 0 : await viewerOwnsFarm(db, { email: actor.email ?? "", userId: actor.subjectId }, farmId);
35033
+ if (!usable) return notFound();
35034
+ await beginInitTracking(db, projectId, "farm_provisioning");
35035
+ let result;
35036
+ try {
35037
+ result = await requestProvision(farmId, projectId, sealedBundle, FARM_PROVISION_TIMEOUT_MS);
35038
+ } catch (err) {
35039
+ const reason = err instanceof Error ? err.message : "provision_failed";
35040
+ await recordInitError(
35041
+ db,
35042
+ projectId,
35043
+ reason === "farm_offline" ? "the hosting farm is offline" : reason === "farm_timeout" ? "the hosting farm did not respond in time" : `farm provisioning failed: ${reason}`
35044
+ );
35045
+ return nodeUnavailable("farm", err);
35046
+ }
35047
+ if (result.ok) {
35048
+ await db.run(`UPDATE projects SET farm_id = ? WHERE id = ?`, farmId, projectId);
35049
+ await advanceInitPhase(db, projectId, "daemon_starting");
35050
+ requestFarmPoolSync(db, farmId).catch(
35051
+ (err) => log.warn({ farmId, err }, "farm pool sync after provision failed")
35052
+ );
35053
+ } else {
35054
+ await recordInitError(db, projectId, `farm provisioning failed: ${result.error ?? "unknown error"}`);
35055
+ }
35056
+ return ok({ ok: result.ok, daemonStatus: result.daemonStatus ?? null, error: result.error ?? null });
35057
+ },
35058
+ async provisionGhRunners({ runnerId, projectId, sealedBundle, actor }) {
35059
+ const guard = await ghRunnerHost(runnerId, actor);
35060
+ if (!guard.ok) return guard;
35061
+ let result;
35062
+ try {
35063
+ result = await requestGhRunnersProvision(runnerId, projectId, sealedBundle, GH_RUNNER_RELAY_TIMEOUT_MS);
35064
+ } catch (err) {
35065
+ return nodeUnavailable("runner", err);
35066
+ }
35067
+ const runners = result.runners ?? [];
35068
+ if (result.ok) {
35069
+ const connectedAt = Date.now();
35070
+ for (const r of runners) {
35071
+ await upsertGhRunnerBinding(db, projectId, {
35072
+ owner: r.owner,
35073
+ repo: r.repo,
35074
+ runnerId,
35075
+ connectedAt,
35076
+ status: r.status,
35077
+ ...r.error ? { error: r.error } : {},
35078
+ ...r.labels ? { labels: r.labels } : {}
35079
+ });
35080
+ }
35081
+ }
35082
+ return ok({
35083
+ ok: result.ok,
35084
+ runners: runners.map((r) => ({ owner: r.owner, repo: r.repo, status: r.status, error: r.error ?? null, labels: r.labels ?? [] })),
35085
+ error: result.error ?? null
35086
+ });
35087
+ },
35088
+ async teardownGhRunners({ runnerId, projectId, sealedBundle, repo, actor }) {
35089
+ const guard = await ghRunnerHost(runnerId, actor);
35090
+ if (!guard.ok) return guard;
35091
+ let result;
35092
+ try {
35093
+ result = await requestGhRunnersDelete(runnerId, projectId, sealedBundle ?? void 0, GH_RUNNER_RELAY_TIMEOUT_MS);
35094
+ } catch (err) {
35095
+ return nodeUnavailable("runner", err);
35096
+ }
35097
+ if (result.ok && repo !== null) {
35098
+ await removeGhRunnerBinding(db, projectId, repo.owner, repo.repo);
35099
+ }
35100
+ return ok({ ok: result.ok, error: result.error ?? null });
35101
+ },
35102
+ async enqueueProjectIndex({ projectId, kind }) {
35103
+ const p = await db.get(`SELECT id, context_repo_json as contextRepoJson FROM projects WHERE id = ?`, projectId);
35104
+ if (!p) return notFound();
35105
+ if (!p.contextRepoJson) return conflict("context_repo_not_provisioned");
35106
+ const jobId = kind === "backfill" ? await enqueueBackfillJob(db, projectId) : await enqueueIndexJob(db, projectId);
35107
+ await advanceInitPhase(db, projectId, "indexing");
35108
+ log.info({ projectId, jobId, kind }, kind === "backfill" ? "backfill job enqueued" : "index job enqueued");
35109
+ return ok({ jobId });
35110
+ },
35111
+ async reportInitStatus({ projectId, phase, error }) {
35112
+ const recorded = await advanceInitPhase(db, projectId, phase);
35113
+ if (error !== null) {
35114
+ const cur = await getInitStatus(db, projectId);
35115
+ if (cur && cur.phase === phase) await recordInitError(db, projectId, error);
35116
+ }
35117
+ return ok({ recorded });
35118
+ },
34674
35119
  async deleteProject({ projectId }) {
34675
35120
  const row = await db.get(`SELECT farm_id as farmId FROM projects WHERE id = ?`, projectId);
34676
35121
  if (!row) return conflict("project_not_found");
@@ -36884,8 +37329,6 @@ init_settings();
36884
37329
  init_users();
36885
37330
  init_ws();
36886
37331
  init_sandbox2();
36887
- init_provision();
36888
- init_bindings();
36889
37332
  async function adminConsoleRoutes(app, deps) {
36890
37333
  const { db, config, mailer, logFile } = deps;
36891
37334
  app.get("/api/admin/users", async (req, reply) => {
@@ -39299,9 +39742,7 @@ init_dist();
39299
39742
  init_auth();
39300
39743
  init_project_access();
39301
39744
  init_users();
39302
- init_cli_auth();
39303
39745
  init_lifecycle();
39304
- init_queue();
39305
39746
  init_context_outbox();
39306
39747
  init_init_status();
39307
39748
  init_project_config();
@@ -39510,7 +39951,6 @@ function requestTrackerFields(projectId, projectKey, timeoutMs) {
39510
39951
 
39511
39952
  // src/routes/projects.ts
39512
39953
  init_ws();
39513
- init_provision();
39514
39954
 
39515
39955
  // src/farm/move.ts
39516
39956
  init_ws();
@@ -39594,227 +40034,10 @@ function requestGhRunnerTeardown(projectId, runner, target, timeoutMs) {
39594
40034
  }
39595
40035
 
39596
40036
  // src/routes/projects.ts
39597
- init_bindings();
39598
40037
  init_farm2();
39599
40038
  init_runner2();
39600
- var createProjectBodySchema = {
39601
- type: "object",
39602
- required: ["projectId", "name", "automationLevel", "repos"],
39603
- properties: {
39604
- projectId: { type: "string" },
39605
- name: { type: "string", minLength: 1, maxLength: 200 },
39606
- automationLevel: { type: "string", enum: ["assisted", "full"] },
39607
- // ADR-0034 (DEV-456): tracker provider chosen at init; absent (older CLI)
39608
- // = youtrack. For `internal` youtrackProjectKey carries the key prefix.
39609
- trackerProvider: { type: "string", enum: ["internal", "youtrack"] },
39610
- youtrackProjectKey: { type: "string" },
39611
- // DEV-200: legacy owner-email field. Still accepted so an older CLI's
39612
- // request validates, but ignored — ownership always comes from the CLI
39613
- // token's identity (DEV-377).
39614
- ownerEmail: { type: "string" },
39615
- repos: {
39616
- type: "array",
39617
- minItems: 0,
39618
- items: {
39619
- type: "object",
39620
- required: ["name", "host", "owner", "repoName"],
39621
- properties: {
39622
- name: { type: "string", minLength: 1 },
39623
- host: { type: "string", enum: ["github", "bitbucket", "gitlab"] },
39624
- owner: { type: "string", minLength: 1 },
39625
- repoName: { type: "string", minLength: 1 },
39626
- defaultBranch: { type: "string" },
39627
- mountAs: { type: "string" }
39628
- }
39629
- }
39630
- },
39631
- // DEV-65 (ADR-0008): the CLI provisions the Context Repo and reports the
39632
- // resolved location here. The HUB only records it — it never holds git
39633
- // credentials. Absent only on the legacy HUB-provisioning path.
39634
- contextRepo: {
39635
- type: "object",
39636
- required: ["host", "owner", "name", "cloneUrl", "branch"],
39637
- properties: {
39638
- // DEV-875: `local` = a bare repository on the CLI's machine, reported
39639
- // with its filesystem path as `cloneUrl`. The HUB records it like any
39640
- // other location — it has no git access to either kind.
39641
- host: { type: "string", enum: ["github", "bitbucket", "gitlab", "local"] },
39642
- owner: { type: "string", minLength: 1 },
39643
- name: { type: "string", minLength: 1 },
39644
- cloneUrl: { type: "string", minLength: 1 },
39645
- branch: { type: "string", minLength: 1 }
39646
- }
39647
- }
39648
- }
39649
- };
39650
- var repoSpecSchema = {
39651
- type: "object",
39652
- required: ["name", "host", "owner", "repoName"],
39653
- additionalProperties: false,
39654
- properties: {
39655
- name: { type: "string", minLength: 1, maxLength: 100 },
39656
- host: { type: "string", enum: ["github", "bitbucket", "gitlab"] },
39657
- owner: { type: "string", minLength: 1 },
39658
- repoName: { type: "string", minLength: 1 },
39659
- defaultBranch: { type: "string" },
39660
- mountAs: { type: "string" }
39661
- }
39662
- };
39663
- async function resolveRepoMutator(db, config, req, reply, projectId) {
39664
- const cliAuth = await resolveCliAuth(db, config, req.headers.authorization);
39665
- if (cliAuth) {
39666
- if (!await canActOnProject(db, config, cliAuth, projectId)) {
39667
- reply.status(403).send({ error: "forbidden" });
39668
- return null;
39669
- }
39670
- return { email: cliAuth.email, userId: cliAuth.userId, viaCli: true };
39671
- }
39672
- const session = await requireProjectAccess(db, config, req, reply, projectId);
39673
- if (!session) return null;
39674
- return { email: session.email, userId: session.userId, viaCli: false };
39675
- }
39676
- async function loadRow(db, projectId) {
39677
- const row = await db.get(
39678
- `SELECT id, repos_json as reposJson, context_repo_json as contextRepoJson,
39679
- provisioning_status as provisioningStatus
39680
- FROM projects WHERE id = ?`,
39681
- projectId
39682
- );
39683
- return {
39684
- projectId: row.id,
39685
- provisioningStatus: row.provisioningStatus,
39686
- ...row.contextRepoJson ? { contextRepo: JSON.parse(row.contextRepoJson) } : {},
39687
- repos: JSON.parse(row.reposJson)
39688
- };
39689
- }
39690
40039
  async function projectsRoutes(app, deps) {
39691
- const { db, config, mailer } = deps;
39692
- app.post(
39693
- "/projects",
39694
- { schema: { body: createProjectBodySchema } },
39695
- async (req, reply) => {
39696
- const auth = await resolveCliAuth(db, config, req.headers.authorization);
39697
- if (!auth) {
39698
- return reply.status(401).send({ error: "unauthorized" });
39699
- }
39700
- const body = req.body;
39701
- if (!PROJECT_ID_RE.test(body.projectId)) {
39702
- return reply.status(400).send({
39703
- error: "invalid_project_id",
39704
- hint: "lowercase letters, digits and dashes; starts with a letter; 2\u201331 chars"
39705
- });
39706
- }
39707
- const existing = await db.get(`SELECT id FROM projects WHERE id = ?`, body.projectId);
39708
- if (existing) {
39709
- return reply.status(409).send({ error: "project_already_exists", projectId: body.projectId });
39710
- }
39711
- const now = Date.now();
39712
- const ownerEmail = auth.email;
39713
- const ownerUserId = auth.userId;
39714
- const trackerProvider = body.trackerProvider === "internal" ? "internal" : "youtrack";
39715
- await db.run(`INSERT INTO projects (id, name, automation_level, youtrack_project_key,
39716
- tracker_provider, repos_json, owner_email, owner_user_id,
39717
- provisioning_status, created_at)
39718
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)`, body.projectId, body.name, body.automationLevel, body.youtrackProjectKey ?? null, trackerProvider, JSON.stringify(body.repos), ownerEmail, ownerUserId, now);
39719
- if (!body.contextRepo) {
39720
- await db.run(`DELETE FROM projects WHERE id = ?`, body.projectId);
39721
- return reply.status(400).send({
39722
- error: "context_repo_required",
39723
- hint: "the CLI provisions the Context Repo and must send `contextRepo` (upgrade the CLI)"
39724
- });
39725
- }
39726
- app.log.info(
39727
- { projectId: body.projectId, contextRepo: body.contextRepo },
39728
- "project registered, Context Repo provisioned by CLI"
39729
- );
39730
- await db.run(`UPDATE projects
39731
- SET provisioning_status = 'provisioned', context_repo_json = ?,
39732
- provisioned_at = ?, provisioning_error = NULL
39733
- WHERE id = ?`, JSON.stringify(body.contextRepo), Date.now(), body.projectId);
39734
- await beginInitTracking(db, body.projectId, "registered");
39735
- void mailer.sendProjectRegistered(config.registrationNotifyEmail, {
39736
- projectName: body.name,
39737
- projectId: body.projectId,
39738
- automationLevel: body.automationLevel,
39739
- ownerEmail: ownerEmail ?? void 0,
39740
- repoCount: body.repos.length,
39741
- projectUrl: `${config.baseUrl}/?project=${encodeURIComponent(body.projectId)}`
39742
- }).catch(
39743
- (err) => app.log.error(
39744
- { err, projectId: body.projectId },
39745
- "failed to send project-registered notification"
39746
- )
39747
- );
39748
- return reply.status(201).send(await loadRow(db, body.projectId));
39749
- }
39750
- );
39751
- app.post(
39752
- "/projects/:projectId/index",
39753
- {
39754
- schema: {
39755
- body: {
39756
- type: "object",
39757
- properties: { reindex: { type: "boolean" }, backfill: { type: "boolean" } }
39758
- }
39759
- }
39760
- },
39761
- async (req, reply) => {
39762
- const auth = await resolveCliAuth(db, config, req.headers.authorization);
39763
- if (!auth) {
39764
- return reply.status(401).send({ error: "unauthorized" });
39765
- }
39766
- if (!await canActOnProject(db, config, auth, req.params.projectId)) {
39767
- return reply.status(403).send({ error: "forbidden" });
39768
- }
39769
- const p = await db.get(`SELECT id, context_repo_json as contextRepoJson FROM projects WHERE id = ?`, req.params.projectId);
39770
- if (!p) return reply.status(404).send({ error: "project_not_found" });
39771
- if (!p.contextRepoJson) {
39772
- return reply.status(409).send({ error: "context_repo_not_provisioned" });
39773
- }
39774
- const backfill = req.body?.backfill === true;
39775
- const jobId = backfill ? await enqueueBackfillJob(db, req.params.projectId) : await enqueueIndexJob(db, req.params.projectId);
39776
- await advanceInitPhase(db, req.params.projectId, "indexing");
39777
- app.log.info(
39778
- { projectId: req.params.projectId, jobId, reindex: req.body?.reindex === true, backfill },
39779
- backfill ? "backfill job enqueued" : "index job enqueued"
39780
- );
39781
- return reply.status(202).send({ jobId });
39782
- }
39783
- );
39784
- app.post(
39785
- "/projects/:projectId/init-status",
39786
- {
39787
- schema: {
39788
- body: {
39789
- type: "object",
39790
- required: ["phase"],
39791
- properties: {
39792
- phase: { type: "string", enum: ["context_cloning"] },
39793
- error: { type: "string", minLength: 1, maxLength: 500 }
39794
- }
39795
- }
39796
- }
39797
- },
39798
- async (req, reply) => {
39799
- const auth = await resolveCliAuth(db, config, req.headers.authorization);
39800
- if (!auth) {
39801
- return reply.status(401).send({ error: "unauthorized" });
39802
- }
39803
- if (!await canActOnProject(db, config, auth, req.params.projectId)) {
39804
- return reply.status(403).send({ error: "forbidden" });
39805
- }
39806
- const exists = await db.get(`SELECT id FROM projects WHERE id = ?`, req.params.projectId);
39807
- if (!exists) return reply.status(404).send({ error: "project_not_found" });
39808
- const advanced = await advanceInitPhase(db, req.params.projectId, req.body.phase);
39809
- if (req.body.error) {
39810
- const cur = await getInitStatus(db, req.params.projectId);
39811
- if (cur && cur.phase === req.body.phase) {
39812
- await recordInitError(db, req.params.projectId, req.body.error);
39813
- }
39814
- }
39815
- return { recorded: advanced };
39816
- }
39817
- );
40040
+ const { db, config } = deps;
39818
40041
  app.get(
39819
40042
  "/projects/:projectId/pause",
39820
40043
  async (req, reply) => {
@@ -40201,50 +40424,6 @@ async function projectsRoutes(app, deps) {
40201
40424
  return { id, delivered };
40202
40425
  }
40203
40426
  );
40204
- app.get(
40205
- "/projects/:projectId/deliverables",
40206
- async (req, reply) => {
40207
- const projectId = req.params.projectId;
40208
- const cliAuth = await resolveCliAuth(db, config, req.headers.authorization);
40209
- if (cliAuth) {
40210
- if (!await canActOnProject(db, config, cliAuth, projectId)) {
40211
- return reply.status(403).send({ error: "forbidden" });
40212
- }
40213
- const exists = await db.get(`SELECT 1 FROM projects WHERE id = ?`, projectId);
40214
- if (!exists) return reply.status(404).send({ error: "project not found" });
40215
- } else if (!await requireProjectAccess(db, config, req, reply, projectId)) {
40216
- return reply;
40217
- }
40218
- const rows = await db.all(
40219
- `SELECT id, tracker_issue_key AS taskKey, title, phase, updated_at AS updatedAt
40220
- FROM tasks
40221
- WHERE project_id = ? AND (archived IS NULL OR archived = 0)
40222
- AND phase IN ('TRACKING', 'AWAITING_CLUSTER', 'DONE', 'NEEDS_MANUAL')
40223
- ORDER BY CASE WHEN phase IN ('TRACKING', 'AWAITING_CLUSTER') THEN 0 ELSE 1 END,
40224
- updated_at DESC
40225
- LIMIT 50`,
40226
- projectId
40227
- );
40228
- const deliverables = [];
40229
- for (const r of rows) {
40230
- const byRepo = await loadTaskBranchesByRepo(db, r.id);
40231
- const repos = Object.entries(byRepo).map(([repoName, b]) => ({
40232
- repoName,
40233
- branchName: b.branchName,
40234
- baseRef: b.baseRef
40235
- }));
40236
- if (repos.length === 0) continue;
40237
- deliverables.push({
40238
- taskKey: r.taskKey,
40239
- title: r.title,
40240
- phase: r.phase,
40241
- updatedAt: r.updatedAt,
40242
- repos
40243
- });
40244
- }
40245
- return { deliverables };
40246
- }
40247
- );
40248
40427
  app.get(
40249
40428
  "/projects/:projectId/prompts",
40250
40429
  async (req, reply) => {
@@ -40662,88 +40841,6 @@ async function projectsRoutes(app, deps) {
40662
40841
  return reply.status(202).send({ queued: true });
40663
40842
  }
40664
40843
  );
40665
- app.post(
40666
- "/projects/:projectId/repos",
40667
- { schema: { body: repoSpecSchema } },
40668
- async (req, reply) => {
40669
- const actor = await resolveRepoMutator(db, config, req, reply, req.params.projectId);
40670
- if (!actor) return reply;
40671
- const { projectId } = req.params;
40672
- const repo = req.body;
40673
- const mountAs = repo.mountAs ?? repo.name;
40674
- const outcome = await db.tx(async (tx) => {
40675
- const row = await tx.get(`SELECT repos_json as reposJson FROM projects WHERE id = ?`, projectId);
40676
- if (!row) return { status: 404, error: "project_not_found" };
40677
- const repos = JSON.parse(row.reposJson);
40678
- if (!actor.viaCli && repos.length > 0 && !repos.some((r) => r.host === repo.host)) {
40679
- return {
40680
- status: 409,
40681
- error: "unknown_host",
40682
- hint: "add a repo on a new git host from the CLI (`codepipe repo add`), where its token is entered locally"
40683
- };
40684
- }
40685
- if (repos.some((r) => r.name === repo.name)) return { status: 409, error: "duplicate_repo_name" };
40686
- if (repos.some((r) => (r.mountAs ?? r.name) === mountAs)) {
40687
- return { status: 409, error: "duplicate_mount_as" };
40688
- }
40689
- const added = {
40690
- name: repo.name,
40691
- host: repo.host,
40692
- owner: repo.owner,
40693
- repoName: repo.repoName,
40694
- ...repo.defaultBranch ? { defaultBranch: repo.defaultBranch } : {},
40695
- mountAs
40696
- };
40697
- await tx.run(`UPDATE projects SET repos_json = ? WHERE id = ?`, JSON.stringify([...repos, added]), projectId);
40698
- return { ok: true, added };
40699
- });
40700
- if (!("ok" in outcome)) {
40701
- return reply.status(outcome.status).send({ error: outcome.error, ...outcome.hint ? { hint: outcome.hint } : {} });
40702
- }
40703
- if (!actor.viaCli) {
40704
- const msg = { type: "provision_repo", projectId, repo: outcome.added };
40705
- const delivered = sendToProjectCli(projectId, msg);
40706
- app.log.info({ projectId, repo: repo.name, by: actor.email, delivered }, "repo added, provision_repo dispatched");
40707
- } else {
40708
- app.log.info({ projectId, repo: repo.name, by: actor.email }, "repo added by CLI");
40709
- }
40710
- return reply.status(201).send(await loadRow(db, projectId));
40711
- }
40712
- );
40713
- app.delete(
40714
- "/projects/:projectId/repos/:name",
40715
- async (req, reply) => {
40716
- const actor = await resolveRepoMutator(db, config, req, reply, req.params.projectId);
40717
- if (!actor) return reply;
40718
- const { projectId, name } = req.params;
40719
- const purge = req.query.purge === "true" || req.query.purge === "1";
40720
- const outcome = await db.tx(async (tx) => {
40721
- const row = await tx.get(`SELECT repos_json as reposJson FROM projects WHERE id = ?`, projectId);
40722
- if (!row) return { status: 404, error: "project_not_found" };
40723
- const repos = JSON.parse(row.reposJson);
40724
- const removed = repos.find((r) => r.name === name);
40725
- if (!removed) return { status: 404, error: "repo_not_found" };
40726
- await tx.run(
40727
- `UPDATE projects SET repos_json = ? WHERE id = ?`,
40728
- JSON.stringify(repos.filter((r) => r.name !== name)),
40729
- projectId
40730
- );
40731
- return { ok: true, removed };
40732
- });
40733
- if (!("ok" in outcome)) return reply.status(outcome.status).send({ error: outcome.error });
40734
- if (outcome.removed.host === "github") {
40735
- await removeGhRunnerBinding(db, projectId, outcome.removed.owner, outcome.removed.repoName);
40736
- }
40737
- if (!actor.viaCli) {
40738
- const msg = { type: "remove_repo", projectId, repoName: name, ...purge ? { purge: true } : {} };
40739
- const delivered = sendToProjectCli(projectId, msg);
40740
- app.log.info({ projectId, repo: name, by: actor.email, purge, delivered }, "repo removed, remove_repo dispatched");
40741
- } else {
40742
- app.log.info({ projectId, repo: name, by: actor.email }, "repo removed by CLI");
40743
- }
40744
- return reply.status(200).send(await loadRow(db, projectId));
40745
- }
40746
- );
40747
40844
  app.get(
40748
40845
  "/projects/:projectId/tracker-status",
40749
40846
  async (req, reply) => {
@@ -41268,8 +41365,6 @@ async function bugReportRoutes(app, deps) {
41268
41365
 
41269
41366
  // src/server.ts
41270
41367
  init_ws();
41271
- init_provision();
41272
- init_provision2();
41273
41368
  init_context_read();
41274
41369
  var __dirname = dirname4(fileURLToPath3(import.meta.url));
41275
41370
  var DASHBOARD_DIST = resolve3(__dirname, "../../dashboard/dist");
@@ -41532,13 +41627,28 @@ async function buildServer(config, db) {
41532
41627
  await apiV1ReadRoutes(instance, {
41533
41628
  db,
41534
41629
  config,
41535
- read: createReadOperations(createReadModel(db), authorizer)
41630
+ read: createReadOperations(createReadModel(db, { isOperator: (email) => isSuperUser(config, email) }), authorizer)
41536
41631
  });
41537
41632
  await apiV1MutationRoutes(instance, {
41538
41633
  db,
41539
41634
  config,
41540
41635
  commands: createCommandOperations({
41541
- write: createWriteModel(db, app.log),
41636
+ write: createWriteModel(db, app.log, {
41637
+ isOperator: (email) => isSuperUser(config, email),
41638
+ // DEV-216: tell the operator a project was registered. Fire-and-
41639
+ // forget — a mail failure must not fail the registration the
41640
+ // client is waiting on.
41641
+ projectRegistered: (n) => {
41642
+ void mailer.sendProjectRegistered(config.registrationNotifyEmail, {
41643
+ projectName: n.name,
41644
+ projectId: n.projectId,
41645
+ automationLevel: n.automationLevel,
41646
+ ownerEmail: n.ownerEmail ?? void 0,
41647
+ repoCount: n.repoCount,
41648
+ projectUrl: `${config.baseUrl}/?project=${encodeURIComponent(n.projectId)}`
41649
+ }).catch((err) => app.log.error({ err, projectId: n.projectId }, "failed to send project-registered notification"));
41650
+ }
41651
+ }),
41542
41652
  idempotency: createIdempotencyPort(db),
41543
41653
  authorizer,
41544
41654
  audit: createCommandAudit(db)