ensemble-review 1.0.36 → 1.0.37

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.
Files changed (58) hide show
  1. package/dist/api/plugins/rate-limit.js +22 -0
  2. package/dist/api/routes/releases.js +181 -0
  3. package/dist/app.js +3 -0
  4. package/dist/coding/story-build/base-refs.js +10 -2
  5. package/dist/coding/story-build/loop/driver.js +1 -1
  6. package/dist/coding/story-build/loop/routes.js +9 -1
  7. package/dist/coding/story-build/persist.js +53 -7
  8. package/dist/coding/story-build/plan-persist.js +1 -1
  9. package/dist/coding/story-build/push-hotfix.js +293 -0
  10. package/dist/coding/story-build/push.js +5 -0
  11. package/dist/coding/story-build/routes-build.js +25 -3
  12. package/dist/db/erase-account.js +14 -0
  13. package/dist/db/hotfix-bases.js +31 -0
  14. package/dist/db/migrations/0195_releases.sql +128 -0
  15. package/dist/db/migrations/0196_story_build_hotfix.sql +54 -0
  16. package/dist/db/migrations/0197_release_items_live_tag.sql +11 -0
  17. package/dist/db/migrations/0198_story_build_port_report.sql +7 -0
  18. package/dist/db/migrations/meta/_journal.json +28 -0
  19. package/dist/db/queries.js +21 -0
  20. package/dist/db/release-check.js +378 -0
  21. package/dist/db/release-settings.js +94 -0
  22. package/dist/db/repo-tags.js +82 -0
  23. package/dist/db/schema.sqlite.js +246 -0
  24. package/dist/github/mutations.js +67 -0
  25. package/dist/github/queries.js +5 -0
  26. package/dist/github/settle-query.js +11 -1
  27. package/dist/github/tag-queries.js +101 -0
  28. package/dist/releases/create.js +539 -0
  29. package/dist/releases/github.js +78 -0
  30. package/dist/releases/notes.js +152 -0
  31. package/dist/releases/refresh.js +43 -0
  32. package/dist/sandbox/session/manager.js +207 -12
  33. package/dist/sandbox/session/plan.js +19 -7
  34. package/dist/sandbox/session/routes.js +12 -2
  35. package/dist/sandbox/workspace/hotfix.js +30 -0
  36. package/dist/sandbox/workspace/index.js +38 -2
  37. package/dist/sandbox/workspace/port-report.js +137 -0
  38. package/dist/sandbox/workspace/seed.js +40 -0
  39. package/dist/shared/hotfix.js +165 -0
  40. package/dist/shared/index.js +4 -0
  41. package/dist/shared/release-check.js +528 -0
  42. package/dist/shared/release-write.js +264 -0
  43. package/dist/shared/releases.js +89 -0
  44. package/dist/shared/story-build.js +2 -0
  45. package/dist/sync/repo-tags.js +368 -0
  46. package/dist/sync/settle-sweep.js +47 -2
  47. package/dist/sync/sync-repo.js +12 -0
  48. package/dist/sync/tag-ranges.js +350 -0
  49. package/dist/sync/tags-step.js +77 -0
  50. package/dist/sync/upsert.js +23 -0
  51. package/package.json +3 -3
  52. package/public/assets/{AiFixTab-CVIZl9I8.js → AiFixTab-BC3tDZ3z.js} +1 -1
  53. package/public/assets/{PrBotBehaviourTab-q0M7RU1e.js → PrBotBehaviourTab-C5j5uhLn.js} +1 -1
  54. package/public/assets/index-BrjUDY3j.js +138 -0
  55. package/public/assets/index-D1zhsqSF.css +1 -0
  56. package/public/index.html +2 -2
  57. package/public/assets/index-CbEfmqOD.css +0 -1
  58. package/public/assets/index-DBK4RWQu.js +0 -136
@@ -471,6 +471,26 @@ function tierFor(method, path) {
471
471
  // GET /api/ticket-prs — the ticket tab: stored ticket rows joined to PRs, DB-only.
472
472
  if (!mutating && path === '/api/ticket-prs')
473
473
  return [TIERS.read];
474
+ // GET /api/releases — This Sprint → Releases: the release check over stored tags, ranges and ticket
475
+ // rows (db/release-check.ts). DB only, never a GitHub or tracker call. `read`, DECIDED; EXACT `===`
476
+ // (the release WRITES will be their own paths with their own tiers).
477
+ if (!mutating && path === '/api/releases')
478
+ return [TIERS.read];
479
+ // GET /api/releases/:id — one stored release (its notes and step results). DB only.
480
+ if (!mutating && /^\/api\/releases\/\d+$/.test(path))
481
+ return [TIERS.read];
482
+ // POST /api/releases/refresh — Refresh: re-reads the held tickets from the tracker (the customer's
483
+ // quota, as every tracker call → `search`). No GitHub call.
484
+ if (method === 'POST' && path === '/api/releases/refresh')
485
+ return [TIERS.search];
486
+ // POST /api/releases/preview — the "Create release…" dialog: a model summary per story when AI is on
487
+ // (model dollars, the reader's click) → `ai` + `ai_hourly`; no GitHub or tracker call.
488
+ if (method === 'POST' && path === '/api/releases/preview')
489
+ return [TIERS.ai, TIERS.aiHourly];
490
+ // POST /api/releases — Confirm: re-reads every ticket from the tracker (the customer's quota, as every
491
+ // tracker call → `search`) and writes release tags, pre-releases and tracker comments (`github_write`).
492
+ if (method === 'POST' && path === '/api/releases')
493
+ return [TIERS.search, TIERS.githubWrite];
474
494
  // A TRACKER WRITE (tracker/status-change.ts): GET /api/ticket-status/transitions (one or two
475
495
  // Jira calls, on the picker's open) and POST /api/ticket-status (the transition + its re-read) both
476
496
  // spend the customer's Jira quota with the saved token → `search`, as every other tracker call.
@@ -602,6 +622,8 @@ function tierFor(method, path) {
602
622
  // GET list / :id / stream / diff / repos/:repoId/diff / prs/:prId/diff / plan · POST interrupt /
603
623
  // network/allow / discard / plan/decisions / auto-push → read
604
624
  // (auto-push only stores the reader's opt-in; the push it arms runs once, on the host, after a build turn)
625
+ // (a HOTFIX build's push is the same route: the hotfix branch, two PRs and one PR-body edit → github_write)
626
+ // GET /api/story-builds/hotfix-bases → read (DB only: the synced tags)
605
627
  // POST …/:id/auto-approve · …/:id/ticket-review · GET /api/story-builds/active → read (DB only: the
606
628
  // automatic approval's build turn is started by the plan turn's end and asks the spend gate itself)
607
629
  // POST …/:id/loop/continue THE REVIEW LOOP's Continue: may start reviews, a fix turn AND pushes
@@ -0,0 +1,181 @@
1
+ import { RELEASE_NOTES_SECTION_MAX, RELEASE_SUFFIX_MAX, validateReleaseSettings, } from '../../shared/index.js';
2
+ import { ReleaseRequestError, createRelease, getReleaseDetail, previewRelease } from '../../releases/create.js';
3
+ import { getReleases } from '../../db/release-check.js';
4
+ import { refreshReleases } from '../../releases/refresh.js';
5
+ import { getReleaseSettingsResponse, setReleaseSettings } from '../../db/release-settings.js';
6
+ import { accountIdOf } from '../plugins/auth.js';
7
+ // RELEASES (docs/RELEASES.md; docs/API.md § Releases). CORE, always registered.
8
+ //
9
+ // GET /api/workspaces/:id/release-settings the workspace's release + hotfix suffixes (null = not
10
+ // set; there is no default), `configured` (both set) and
11
+ // `hasReleases` (the form warns before a change).
12
+ // PUT /api/workspaces/:id/release-settings the WHOLE setting: `{ releaseSuffix, hotfixSuffix }`,
13
+ // null or "" clears one. 400 with the SAME sentence
14
+ // shared `validateReleaseSettings` gives the Settings form.
15
+ // GET /api/releases?workspace=<id> This Sprint → Releases: the release check (shared
16
+ // `foldReleaseCheck` over db/release-check.ts's stored
17
+ // inputs), the newest release, whether both suffixes are
18
+ // set. DB only, NEVER a GitHub or tracker call; tier `read`.
19
+ // POST /api/releases/refresh?workspace=<id> Refresh (a reader's press): the tickets the check holds by
20
+ // their own state read again from the tracker
21
+ // (releases/refresh.ts), then the check. Tier `search`.
22
+ // POST /api/releases/preview?workspace=<id> "Create release…" opening: `{repoIds}` → the snapshot,
23
+ // the notes (a model summary per story when AI is on — a
24
+ // reader's click, never refused by the spend gate) and the
25
+ // comment preview. No GitHub or tracker call; tier `ai`.
26
+ // POST /api/releases?workspace=<id> Confirm: `{repoIds, snapshot, sections?}` → re-reads the
27
+ // tickets and the tags, then the writes (releases/create.ts).
28
+ // Tracker reads + GitHub writes: tier `search` + `github_write`.
29
+ // GET /api/releases/:id one stored release (its notes and per-step results).
30
+ //
31
+ // Both are DB only — the workspace-CRUD `read` tier (api/plugins/rate-limit.ts). 404 on a workspace
32
+ // that is not this account's, like every id-addressed workspace route.
33
+ const idParamSchema = {
34
+ type: 'object',
35
+ required: ['id'],
36
+ properties: { id: { type: 'integer' } },
37
+ };
38
+ const suffixSchema = { type: ['string', 'null'], maxLength: RELEASE_SUFFIX_MAX * 4 };
39
+ const repoIdsSchema = { type: 'array', minItems: 1, maxItems: 200, items: { type: 'integer' } };
40
+ const snapshotSchema = {
41
+ type: 'object',
42
+ required: ['repos', 'tickets'],
43
+ additionalProperties: false,
44
+ properties: {
45
+ repos: {
46
+ type: 'array',
47
+ maxItems: 200,
48
+ items: {
49
+ type: 'object',
50
+ required: ['repoId', 'tagName', 'sha'],
51
+ additionalProperties: false,
52
+ properties: {
53
+ repoId: { type: 'integer' },
54
+ tagName: { type: 'string', minLength: 1, maxLength: 300 },
55
+ sha: { type: 'string', minLength: 1, maxLength: 64 },
56
+ },
57
+ },
58
+ },
59
+ tickets: {
60
+ type: 'array',
61
+ maxItems: 2000,
62
+ items: {
63
+ type: 'object',
64
+ required: ['ident', 'key'],
65
+ additionalProperties: false,
66
+ properties: { ident: { type: 'string', minLength: 1, maxLength: 600 }, key: { type: 'string', minLength: 1, maxLength: 200 } },
67
+ },
68
+ },
69
+ },
70
+ };
71
+ export async function releaseRoutes(app) {
72
+ app.get('/api/releases', async (req) => {
73
+ const q = req.query;
74
+ return getReleases(accountIdOf(req), q.workspace);
75
+ });
76
+ const refusal = (reply, err) => {
77
+ if (err instanceof ReleaseRequestError) {
78
+ reply.status(err.status);
79
+ return { error: err.code, message: err.message };
80
+ }
81
+ throw err;
82
+ };
83
+ app.post('/api/releases/refresh', async (req) => {
84
+ const q = req.query;
85
+ return refreshReleases(accountIdOf(req), q.workspace);
86
+ });
87
+ app.post('/api/releases/preview', {
88
+ schema: {
89
+ body: { type: 'object', required: ['repoIds'], additionalProperties: false, properties: { repoIds: repoIdsSchema } },
90
+ },
91
+ }, async (req, reply) => {
92
+ const q = req.query;
93
+ try {
94
+ return await previewRelease(accountIdOf(req), q.workspace, req.body);
95
+ }
96
+ catch (err) {
97
+ return refusal(reply, err);
98
+ }
99
+ });
100
+ app.post('/api/releases', {
101
+ schema: {
102
+ body: {
103
+ type: 'object',
104
+ required: ['repoIds', 'snapshot'],
105
+ additionalProperties: false,
106
+ properties: {
107
+ repoIds: repoIdsSchema,
108
+ snapshot: snapshotSchema,
109
+ sections: {
110
+ type: 'array',
111
+ maxItems: 200,
112
+ items: {
113
+ type: 'object',
114
+ required: ['repoId', 'markdown'],
115
+ additionalProperties: false,
116
+ properties: { repoId: { type: 'integer' }, markdown: { type: 'string', maxLength: RELEASE_NOTES_SECTION_MAX } },
117
+ },
118
+ },
119
+ },
120
+ },
121
+ },
122
+ }, async (req, reply) => {
123
+ const q = req.query;
124
+ try {
125
+ return await createRelease(accountIdOf(req), q.workspace, req.body);
126
+ }
127
+ catch (err) {
128
+ return refusal(reply, err);
129
+ }
130
+ });
131
+ app.get('/api/releases/:id', { schema: { params: idParamSchema } }, async (req, reply) => {
132
+ const { id } = req.params;
133
+ const detail = await getReleaseDetail(accountIdOf(req), id);
134
+ if (detail == null) {
135
+ reply.status(404);
136
+ return { error: 'NotFound', message: `Release ${id} not found` };
137
+ }
138
+ return detail;
139
+ });
140
+ app.get('/api/workspaces/:id/release-settings', { schema: { params: idParamSchema } }, async (req, reply) => {
141
+ const accountId = accountIdOf(req);
142
+ const { id } = req.params;
143
+ const res = await getReleaseSettingsResponse(accountId, id);
144
+ if (!res) {
145
+ reply.status(404);
146
+ return { error: 'NotFound', message: `Workspace ${id} not found` };
147
+ }
148
+ return res;
149
+ });
150
+ app.put('/api/workspaces/:id/release-settings', {
151
+ schema: {
152
+ params: idParamSchema,
153
+ body: {
154
+ type: 'object',
155
+ additionalProperties: false,
156
+ required: ['releaseSuffix', 'hotfixSuffix'],
157
+ properties: { releaseSuffix: suffixSchema, hotfixSuffix: suffixSchema },
158
+ },
159
+ },
160
+ }, async (req, reply) => {
161
+ const accountId = accountIdOf(req);
162
+ const { id } = req.params;
163
+ const body = req.body;
164
+ const problem = validateReleaseSettings(body);
165
+ if (problem) {
166
+ reply.status(400);
167
+ return { error: 'InvalidReleaseSettings', message: problem };
168
+ }
169
+ if (!(await setReleaseSettings(accountId, id, body))) {
170
+ reply.status(404);
171
+ return { error: 'NotFound', message: `Workspace ${id} not found` };
172
+ }
173
+ const res = await getReleaseSettingsResponse(accountId, id);
174
+ if (!res) {
175
+ reply.status(404);
176
+ return { error: 'NotFound', message: `Workspace ${id} not found` };
177
+ }
178
+ return res;
179
+ });
180
+ }
181
+ //# sourceMappingURL=releases.js.map
package/dist/app.js CHANGED
@@ -18,6 +18,7 @@ import { registerTracker } from './tracker/index.js';
18
18
  import { registerSonar } from './sonar/index.js';
19
19
  import { repoRoutes } from './api/routes/repos.js';
20
20
  import { workspaceRoutes } from './api/routes/workspaces.js';
21
+ import { releaseRoutes } from './api/routes/releases.js';
21
22
  import { sandboxSettingsRoutes } from './api/routes/sandbox-settings.js';
22
23
  import { userRoutes } from './api/routes/users.js';
23
24
  import { timelineRoutes } from './api/routes/timeline.js';
@@ -161,6 +162,8 @@ export async function buildApp() {
161
162
  // a repo belongs to exactly one — so assignment is a MOVE and there is no "unassign" route.
162
163
  // Account-scoped, no AI, no GitHub calls.
163
164
  await app.register(workspaceRoutes);
165
+ // Releases (CORE, docs/RELEASES.md): the per-workspace release + hotfix suffixes. DB only.
166
+ await app.register(releaseRoutes);
164
167
  await app.register(userRoutes);
165
168
  await app.register(timelineRoutes);
166
169
  await app.register(prRoutes);
@@ -20,6 +20,11 @@ export const STORY_BUILD_REF_PREFIX = 'refs/pierre/story-build/';
20
20
  export function storyBuildBaseRef(buildId, repoId) {
21
21
  return `${STORY_BUILD_REF_PREFIX}${buildId}/${repoId}`;
22
22
  }
23
+ /** HOTFIX: the ref pinning one repository's MAIN checkout base (the default branch at the build's start). It
24
+ * shares the repository's sweep key (`buildId/repoId`), so it is kept and dropped with that base. */
25
+ export function storyBuildMainRef(buildId, repoId) {
26
+ return `${STORY_BUILD_REF_PREFIX}${buildId}/main/${repoId}`;
27
+ }
23
28
  /** The sweep's keep-list spelling of a key: `buildId/repoId` or `buildId/stack/prRowId`. */
24
29
  export function storyBuildRefKey(k) {
25
30
  return k.stackPrId != null ? `${k.buildId}/stack/${k.stackPrId}` : `${k.buildId}/${k.repoId}`;
@@ -32,6 +37,9 @@ export function parseStoryBuildBaseRef(ref) {
32
37
  const stack = /^(\d+)\/stack\/(\d+)$/.exec(rest);
33
38
  if (stack != null)
34
39
  return { buildId: Number(stack[1]), stackPrId: Number(stack[2]) };
40
+ const main = /^(\d+)\/main\/(\d+)$/.exec(rest);
41
+ if (main != null)
42
+ return { buildId: Number(main[1]), repoId: Number(main[2]) };
35
43
  const m = /^(\d+)\/(\d+)$/.exec(rest);
36
44
  if (m == null)
37
45
  return null;
@@ -58,8 +66,8 @@ export async function dropStoryBuildBaseRefs(fullName, buildId, repoId) {
58
66
  if (!existsSync(join(dir, '.git')))
59
67
  return;
60
68
  await withRepoLock(`${owner}/${name}`, async () => {
61
- const prefix = repoId != null ? storyBuildBaseRef(buildId, repoId) : `${STORY_BUILD_REF_PREFIX}${buildId}/`;
62
- const refs = (await gitOut(dir, ['for-each-ref', '--format=%(refname)', prefix]).catch(() => ''))
69
+ const prefixes = repoId != null ? [storyBuildBaseRef(buildId, repoId), storyBuildMainRef(buildId, repoId)] : [`${STORY_BUILD_REF_PREFIX}${buildId}/`];
70
+ const refs = (await gitOut(dir, ['for-each-ref', '--format=%(refname)', ...prefixes]).catch(() => ''))
63
71
  .split('\n')
64
72
  .map((l) => l.trim())
65
73
  .filter(Boolean);
@@ -181,7 +181,7 @@ export async function maybeStartLoop(ctx, input) {
181
181
  if (running.has(buildId))
182
182
  return 'skipped';
183
183
  const b = await getStoryBuildRow(ctx, accountId, buildId);
184
- if (!b || !b.autoPush || b.loopClaimedAt != null || b.autoPushClaimedAt != null || b.status !== 'ready')
184
+ if (!b || !b.autoPush || b.hotfix === true || b.loopClaimedAt != null || b.autoPushClaimedAt != null || b.status !== 'ready')
185
185
  return 'skipped';
186
186
  const targets = (await loopTargets(ctx, accountId, buildId)).filter(isLive);
187
187
  if (targets.length === 0)
@@ -1,4 +1,4 @@
1
- import { STORY_BUILD_API, STORY_BUILD_BRANCH_MAX, STORY_BUILD_LOOP_ROUNDS_MAX, STORY_BUILD_LOOP_ROUNDS_MIN, } from '../../../shared/index.js';
1
+ import { HOTFIX_NO_LOOP_LINE, STORY_BUILD_API, STORY_BUILD_BRANCH_MAX, STORY_BUILD_LOOP_ROUNDS_MAX, STORY_BUILD_LOOP_ROUNDS_MIN, } from '../../../shared/index.js';
2
2
  import { getStoryBuildRow } from '../persist.js';
3
3
  import { continueLoop, setLoopSettings, stopLoop, storyLoopWire } from './driver.js';
4
4
  const NOT_FOUND = { error: 'NotFound', message: 'Not found.' };
@@ -73,6 +73,14 @@ export function registerStoryBuildLoopRoutes(app, ctx) {
73
73
  if (!body) {
74
74
  return reply.code(400).send({ error: 'BadRequest', message: `Send \`on\` (true or false) and/or \`rounds\` (${STORY_BUILD_LOOP_ROUNDS_MIN} to ${STORY_BUILD_LOOP_ROUNDS_MAX}).` });
75
75
  }
76
+ // A HOTFIX build never runs the review loop (v1; docs/STORY-BUILD.md § Hotfix builds).
77
+ if (body.on === true) {
78
+ const row = await getStoryBuildRow(ctx, accountId, id);
79
+ if (row == null)
80
+ return reply.code(404).send(NOT_FOUND);
81
+ if (row.hotfix === true)
82
+ return reply.code(409).send({ error: 'HotfixNoLoop', message: HOTFIX_NO_LOOP_LINE });
83
+ }
76
84
  const r = await setLoopSettings(ctx, accountId, id, body);
77
85
  if (r.status === 'not_found')
78
86
  return reply.code(404).send(NOT_FOUND);
@@ -1,5 +1,5 @@
1
1
  import { and, desc, eq, inArray, asc, notInArray } from 'drizzle-orm';
2
- import { aiRunProvider, isFlowDiagram, isLegacyDesignDiagram, isStoryBuildOpen, isStoryBuildRunning, storyBuildDocsEffectiveDecisions, } from '../../shared/index.js';
2
+ import { aiRunProvider, hotfixBranchName, hotfixPortOf, isFlowDiagram, isLegacyDesignDiagram, isStoryBuildOpen, isStoryBuildRunning, storyBuildDocsEffectiveDecisions, } from '../../shared/index.js';
3
3
  /* eslint-disable @typescript-eslint/no-explicit-any */
4
4
  const B = (ctx) => ctx.schema.storyBuilds;
5
5
  const R = (ctx) => ctx.schema.storyBuildRepos;
@@ -99,6 +99,7 @@ export async function insertStoryBuild(ctx, a) {
99
99
  ...(a.session ? { sessionPhase: 'booting', effort: a.session.effort, lastActiveAt: a.session.lastActiveAt } : {}),
100
100
  wantDiagram: a.wantDiagram,
101
101
  wantDocs: a.wantDocs,
102
+ ...(a.hotfix === true ? { hotfix: true } : {}),
102
103
  createdAt: new Date(),
103
104
  startedAt: new Date(),
104
105
  })
@@ -146,13 +147,14 @@ export async function upsertStoryBuildRepo(ctx, a) {
146
147
  stackedOnPrId: a.stackedOnPrId,
147
148
  branch: a.branch,
148
149
  };
150
+ const hotfix = () => a.hotfix ? { hotfixTag: a.hotfix.tagName, readOnly: a.hotfix.readOnly, mainBaseRef: a.hotfix.mainBaseRef, mainBaseSha: a.hotfix.mainBaseSha } : {};
149
151
  const rows = (await ctx.db
150
152
  .insert(r)
151
- .values(values)
153
+ .values({ ...values, ...hotfix() })
152
154
  .onConflictDoUpdate({
153
155
  target: [r.buildId, r.repoId],
154
156
  // A separate object from `values` (CLAUDE.md: never share one between insert and `set:`).
155
- set: { ref: a.ref, buildOrder: a.buildOrder, include: a.include, status: a.status, baseRef: a.baseRef, baseSha: a.baseSha, stackedOnPrId: a.stackedOnPrId, branch: a.branch },
157
+ set: { ref: a.ref, buildOrder: a.buildOrder, include: a.include, status: a.status, baseRef: a.baseRef, baseSha: a.baseSha, stackedOnPrId: a.stackedOnPrId, branch: a.branch, ...hotfix() },
156
158
  })
157
159
  .returning({ id: r.id })
158
160
  .execute());
@@ -185,13 +187,55 @@ export async function deleteStoryBuildRepoRowsExcept(ctx, accountId, buildId, ke
185
187
  .execute());
186
188
  return rows.length;
187
189
  }
188
- /** The stored patch of one repository (the `…/diff` route; never part of `StoryBuild`). */
189
- export async function getStoryBuildRepoPatch(ctx, accountId, buildId, repoId) {
190
+ /** The stored patch of one repository (the `…/diff` route; never part of `StoryBuild`). `target: 'main'` = a
191
+ * hotfix build's MAIN FIX (null on a row without a main checkout). */
192
+ export async function getStoryBuildRepoPatch(ctx, accountId, buildId, repoId, target = 'release') {
190
193
  const row = await getStoryBuildRepoRow(ctx, accountId, buildId, repoId);
191
194
  if (row == null)
192
195
  return null;
196
+ if (target === 'main') {
197
+ if (row.mainBaseSha == null)
198
+ return null;
199
+ return { baseSha: row.mainBaseSha, patch: row.mainPatch ?? null, filesChanged: row.mainFilesChangedJson ?? [] };
200
+ }
193
201
  return { baseSha: row.baseSha, patch: row.patch ?? null, filesChanged: row.filesChangedJson ?? [] };
194
202
  }
203
+ const MAIN_STATUSES = new Set(['ready', 'skipped', 'failed', 'pushed']);
204
+ /** A hotfix build's per-repository facts on the wire (null on an ordinary build). */
205
+ export function toStoryBuildRepoHotfix(row, hotfixBuild) {
206
+ if (!hotfixBuild)
207
+ return null;
208
+ return {
209
+ tagName: row.hotfixTag ?? null,
210
+ readOnly: row.readOnly === true,
211
+ hotfixBranch: row.hotfixTag != null ? hotfixBranchName(row.hotfixTag) : null,
212
+ mainBaseRef: row.mainBaseRef ?? null,
213
+ mainBaseSha: row.mainBaseSha ?? null,
214
+ mainStatus: row.mainStatus != null && MAIN_STATUSES.has(row.mainStatus) ? row.mainStatus : null,
215
+ mainHasPatch: row.mainPatch != null && row.mainPatch.length > 0,
216
+ mainFilesChanged: row.mainFilesChangedJson ?? [],
217
+ mainInsertions: row.mainStatJson?.insertions ?? null,
218
+ mainDeletions: row.mainStatJson?.deletions ?? null,
219
+ mainError: row.mainError ?? null,
220
+ mainPushedBranch: row.mainPushedBranch ?? null,
221
+ mainPushedPrNumber: row.mainPushedPrNumber ?? null,
222
+ mainPushedPrUrl: row.mainPushedPrUrl ?? null,
223
+ mainPushError: row.mainPushError ?? null,
224
+ mainPushErrorCode: row.mainPushErrorCode ?? null,
225
+ branch: row.hotfixBranchJson ?? null,
226
+ // THE PORT REPORT: the agent's claim, checked against the two stored patches (derived on read).
227
+ port: row.readOnly === true ? null : storyBuildRepoPort(row),
228
+ };
229
+ }
230
+ /** One hotfix repository's port report (shared `hotfixPortOf` over the stored claim and the two patches). */
231
+ export function storyBuildRepoPort(row) {
232
+ return hotfixPortOf({
233
+ claim: row.portReportJson ?? null,
234
+ releaseFiles: row.patch != null && row.patch.length > 0 ? (row.filesChangedJson ?? []) : [],
235
+ mainFiles: row.mainPatch != null && row.mainPatch.length > 0 ? (row.mainFilesChangedJson ?? []) : [],
236
+ mainChanged: row.mainPatch != null && row.mainPatch.length > 0,
237
+ });
238
+ }
195
239
  // ---- the wire mapper --------------------------------------------------------------------------------
196
240
  const iso = (d) => d == null ? null : (d instanceof Date ? d : new Date(typeof d === 'number' && d < 1e12 ? d * 1000 : d)).toISOString();
197
241
  const sumOrNull = (xs) => {
@@ -199,7 +243,7 @@ const sumOrNull = (xs) => {
199
243
  return got.length === 0 ? null : got.reduce((a, b) => a + b, 0);
200
244
  };
201
245
  /** One repository row → wire. `candidate` supplies the one-line base note; `fullName` the repo's name. */
202
- export function toStoryBuildRepo(row, fullName, candidate) {
246
+ export function toStoryBuildRepo(row, fullName, candidate, hotfixBuild = false) {
203
247
  return {
204
248
  id: row.id,
205
249
  repoId: row.repoId,
@@ -228,6 +272,7 @@ export function toStoryBuildRepo(row, fullName, candidate) {
228
272
  pushedAt: iso(row.pushedAt),
229
273
  pushError: row.pushError ?? null,
230
274
  pushErrorCode: row.pushErrorCode ?? null,
275
+ ...(hotfixBuild ? { hotfix: toStoryBuildRepoHotfix(row, true) } : {}),
231
276
  ...(row.model != null || row.provider != null ? { provider: aiRunProvider(row.provider, row.model) } : {}),
232
277
  model: row.model ?? null,
233
278
  effort: row.effort ?? null,
@@ -244,7 +289,7 @@ export function toStoryBuild(row, repoRows, opts) {
244
289
  const byRepo = new Map(candidates.map((c) => [c.repoId, c]));
245
290
  const repos = [...repoRows]
246
291
  .sort((a, b) => a.buildOrder - b.buildOrder || a.id - b.id)
247
- .map((r) => toStoryBuildRepo(r, opts.repoNames.get(r.repoId) ?? byRepo.get(r.repoId)?.fullName ?? `#${r.repoId}`, byRepo.get(r.repoId) ?? null));
292
+ .map((r) => toStoryBuildRepo(r, opts.repoNames.get(r.repoId) ?? byRepo.get(r.repoId)?.fullName ?? `#${r.repoId}`, byRepo.get(r.repoId) ?? null, row.hotfix === true));
248
293
  const built = repos.filter((r) => r.include && (r.status === 'ready' || r.status === 'pushed'));
249
294
  const running = isStoryBuildRunning(row.status) || row.docsStatus === 'running';
250
295
  return {
@@ -289,6 +334,7 @@ export function toStoryBuild(row, repoRows, opts) {
289
334
  ticketReviewDoneAt: iso(row.ticketReviewDoneAt),
290
335
  endToEnd: row.endToEnd === true,
291
336
  endToEndTicket: row.e2eTicketJson ?? null,
337
+ hotfix: row.hotfix === true,
292
338
  createdAt: iso(row.createdAt) ?? new Date(0).toISOString(),
293
339
  startedAt: iso(row.startedAt),
294
340
  completedAt: iso(row.completedAt),
@@ -171,7 +171,7 @@ export async function approvePlanRevision(ctx, a) {
171
171
  if (rows.length === 0 || rows[0].approvedPlanId != null)
172
172
  return false;
173
173
  await db.update(b).set({ approvedPlanId: a.plan.id, approvedAt: a.now }).where(and(eq(b.id, a.buildId), eq(b.accountId, a.accountId))).execute();
174
- for (const [position, p] of revision.stack.entries()) {
174
+ for (const [position, p] of a.writePrs === false ? [] : revision.stack.entries()) {
175
175
  await db
176
176
  .insert(pr)
177
177
  .values({