@aglyn/plugins-ai 1.0.0-beta.266 → 1.0.0-beta.269

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 (60) hide show
  1. package/package.json +18 -18
  2. package/src/lib/components/ai-job-site.d.ts +8 -1
  3. package/src/lib/components/ai-job-site.js +7 -0
  4. package/src/lib/components/ai-job-site.js.map +1 -1
  5. package/src/lib/components/ai-site-build-page.component.js +37 -0
  6. package/src/lib/components/ai-site-build-page.component.js.map +1 -1
  7. package/src/lib/components/ai-site-start-card.component.js +25 -6
  8. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  9. package/src/lib/components/ai-site-start-steps.component.d.ts +8 -2
  10. package/src/lib/components/ai-site-start-steps.component.js +55 -26
  11. package/src/lib/components/ai-site-start-steps.component.js.map +1 -1
  12. package/src/lib/components/ai-site-start-tracking.d.ts +11 -3
  13. package/src/lib/components/ai-site-start-tracking.js +28 -9
  14. package/src/lib/components/ai-site-start-tracking.js.map +1 -1
  15. package/src/lib/components/assist-jobs-drawer.component.js +24 -2
  16. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  17. package/src/lib/jobs/ai-empty-site-sweep.d.ts +154 -0
  18. package/src/lib/jobs/ai-empty-site-sweep.js +346 -0
  19. package/src/lib/jobs/ai-empty-site-sweep.js.map +1 -0
  20. package/src/lib/jobs/ai-job-page-language.d.ts +7 -1
  21. package/src/lib/jobs/ai-job-page-language.js +16 -3
  22. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  23. package/src/lib/jobs/ai-job-site-look.d.ts +2 -1
  24. package/src/lib/jobs/ai-job-site-look.js +6 -2
  25. package/src/lib/jobs/ai-job-site-look.js.map +1 -1
  26. package/src/lib/jobs/ai-job-site-step.js +11 -6
  27. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  28. package/src/lib/jobs/ai-jobs-beat.d.ts +11 -3
  29. package/src/lib/jobs/ai-jobs-beat.js +23 -6
  30. package/src/lib/jobs/ai-jobs-beat.js.map +1 -1
  31. package/src/lib/jobs/ai-jobs.js +46 -7
  32. package/src/lib/jobs/ai-jobs.js.map +1 -1
  33. package/src/lib/jobs/ai-layout-stock-photos.d.ts +43 -0
  34. package/src/lib/jobs/ai-layout-stock-photos.js +735 -22
  35. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  36. package/src/lib/jobs/fixtures/ai-stock-photo-agl3923-recordings.d.ts +35 -0
  37. package/src/lib/jobs/fixtures/ai-stock-photo-agl3923-recordings.js +379 -0
  38. package/src/lib/jobs/fixtures/ai-stock-photo-agl3923-recordings.js.map +1 -0
  39. package/src/lib/jobs/fixtures/ai-stock-photo-brightvale-recordings.d.ts +35 -0
  40. package/src/lib/jobs/fixtures/ai-stock-photo-brightvale-recordings.js +294 -0
  41. package/src/lib/jobs/fixtures/ai-stock-photo-brightvale-recordings.js.map +1 -0
  42. package/src/lib/jobs/fixtures/ai-stock-photo-mexicano-recordings.d.ts +31 -0
  43. package/src/lib/jobs/fixtures/ai-stock-photo-mexicano-recordings.js +325 -0
  44. package/src/lib/jobs/fixtures/ai-stock-photo-mexicano-recordings.js.map +1 -0
  45. package/src/lib/layout-language/ai-layout-design.js +5 -0
  46. package/src/lib/layout-language/ai-layout-design.js.map +1 -1
  47. package/src/lib/layout-language/ai-layout-motion.d.ts +60 -7
  48. package/src/lib/layout-language/ai-layout-motion.js +231 -4
  49. package/src/lib/layout-language/ai-layout-motion.js.map +1 -1
  50. package/src/lib/layout-language/ai-layout-pictures.d.ts +8 -0
  51. package/src/lib/layout-language/ai-layout-pictures.js +68 -4
  52. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  53. package/src/lib/model/ai-jobs.types.d.ts +20 -0
  54. package/src/lib/model/ai-jobs.types.js.map +1 -1
  55. package/src/lib/model/ai-site-look.d.ts +10 -1
  56. package/src/lib/model/ai-site-look.js +14 -3
  57. package/src/lib/model/ai-site-look.js.map +1 -1
  58. package/src/lib/model/ai-site-start-handoff.d.ts +49 -0
  59. package/src/lib/model/ai-site-start-handoff.js +88 -0
  60. package/src/lib/model/ai-site-start-handoff.js.map +1 -0
@@ -15,6 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import { type SweepAiJobsResult } from './ai-jobs';
18
+ import { type AiEmptySiteSweepResult } from './ai-empty-site-sweep';
18
19
  /**
19
20
  * Resume AI generation jobs on the AI jobs beat (AGL-2904, AGL-3026).
20
21
  *
@@ -69,10 +70,17 @@ export type AiJobsBeatResult = {
69
70
  held: true;
70
71
  } | ({
71
72
  held: false;
73
+ emptySites: AiEmptySiteSweepResult | null;
72
74
  } & SweepAiJobsResult);
73
75
  /**
74
- * One beat: the switch, then one sweep under its own lease owner. The owner
75
- * is fresh per beat and fixed for the whole sweep, so two beats overlapping
76
- * inside a lease never read each other's lease as their own.
76
+ * One beat: the switch, then the empty sites a guided start left that are
77
+ * due for removal (AGL-3936), then one sweep under its own lease owner. The
78
+ * owner is fresh per beat and fixed for the whole sweep, so two beats
79
+ * overlapping inside a lease never read each other's lease as their own.
80
+ *
81
+ * The removals come first, a few a beat inside
82
+ * `AI_EMPTY_SITE_SWEEP_BUDGET_MS`, and whatever they took comes off the
83
+ * sweep's budget, so the route's arithmetic inside `maxDuration` holds. A
84
+ * removal that fails never holds up a job's step.
77
85
  */
78
86
  export declare function runAiJobsBeat(): Promise<AiJobsBeatResult>;
@@ -17,6 +17,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  */ import { randomUUID } from "crypto";
18
18
  import { featureLockdownRefusal, firebaseAdmin } from "@aglyn/tenant-data-admin";
19
19
  import { AI_JOB_SWEEP_BUDGET_MS, sweepAiJobs } from "./ai-jobs.js";
20
+ import { sweepAiEmptySites } from "./ai-empty-site-sweep.js";
20
21
  /**
21
22
  * Resume AI generation jobs on the AI jobs beat (AGL-2904, AGL-3026).
22
23
  *
@@ -64,9 +65,15 @@ import { AI_JOB_SWEEP_BUDGET_MS, sweepAiJobs } from "./ai-jobs.js";
64
65
  * with the sweep's function time, so the plugin dispatcher never serves it.
65
66
  */ export const AI_JOBS_BEAT_PATH = 'admin/ai-jobs-beat';
66
67
  /**
67
- * One beat: the switch, then one sweep under its own lease owner. The owner
68
- * is fresh per beat and fixed for the whole sweep, so two beats overlapping
69
- * inside a lease never read each other's lease as their own.
68
+ * One beat: the switch, then the empty sites a guided start left that are
69
+ * due for removal (AGL-3936), then one sweep under its own lease owner. The
70
+ * owner is fresh per beat and fixed for the whole sweep, so two beats
71
+ * overlapping inside a lease never read each other's lease as their own.
72
+ *
73
+ * The removals come first, a few a beat inside
74
+ * `AI_EMPTY_SITE_SWEEP_BUDGET_MS`, and whatever they took comes off the
75
+ * sweep's budget, so the route's arithmetic inside `maxDuration` holds. A
76
+ * removal that fails never holds up a job's step.
70
77
  */ export async function runAiJobsBeat() {
71
78
  const locked = await featureLockdownRefusal({
72
79
  feature: 'ai-generate'
@@ -77,16 +84,26 @@ import { AI_JOB_SWEEP_BUDGET_MS, sweepAiJobs } from "./ai-jobs.js";
77
84
  held: true
78
85
  };
79
86
  }
87
+ const firestore = firebaseAdmin.app().firestore();
88
+ const startedAt = Date.now();
89
+ const emptySites = await sweepAiEmptySites({
90
+ firestore
91
+ }).catch((error)=>{
92
+ console.error('ai jobs beat: the empty site sweep failed', error);
93
+ return null;
94
+ });
95
+ if (emptySites == null ? void 0 : emptySites.removed) console.info(`ai jobs: ${emptySites.removed} empty site(s) removed`);
80
96
  const result = await sweepAiJobs({
81
- firestore: firebaseAdmin.app().firestore(),
97
+ firestore,
82
98
  owner: `beat:${randomUUID()}`,
83
- budgetMs: AI_JOB_SWEEP_BUDGET_MS
99
+ budgetMs: Math.max(0, AI_JOB_SWEEP_BUDGET_MS - (Date.now() - startedAt))
84
100
  });
85
101
  if (result.due) {
86
102
  console.info(`ai jobs: ${result.ran} step(s) run, ${result.skipped} skipped, ` + `${result.paused} held by a workspace's AI pause, ` + `${result.remaining} left for the next beat` + (result.budgetExhausted ? ' (budget exhausted)' : ''));
87
103
  }
88
104
  return _extends({
89
- held: false
105
+ held: false,
106
+ emptySites
90
107
  }, result);
91
108
  }
92
109
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-jobs-beat.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { randomUUID } from 'crypto'\nimport { featureLockdownRefusal, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { AI_JOB_SWEEP_BUDGET_MS, sweepAiJobs, type SweepAiJobsResult } from './ai-jobs'\n\n/**\n * Resume AI generation jobs on the AI jobs beat (AGL-2904, AGL-3026).\n *\n * A job's first step runs inline in the console route when it can; every\n * step after it, every step too long for an inline door, and any step a\n * budget cut short, runs here. The sweep bounds itself at\n * `AI_JOB_SWEEP_BUDGET_MS` of wall clock and hands the rest to the next beat\n * — the ordering of the queue is the cursor, so nothing is stored between\n * beats and nothing can be skipped.\n *\n * ## Why the console runs it\n *\n * A step calls the AI provider, and the provider's key is held by the\n * console alone: the tenant app serves every published site, and a\n * credential it holds is one request-handling bug away from a visitor. So\n * the beat is a console route (`server/ai-jobs-beat-route.ts`, registered\n * by the console surface), driven every minute by its own Cloud Scheduler\n * job in `cloud/functions` on the console's cron secret — the reason the\n * sending-domain sweep is a console route and not a job on the tenant's\n * platform beat. A beat must resolve to the minute: a step handed back by\n * a budget, or a page pass waiting on the next, should resume on the next\n * minute, not the next quarter hour.\n *\n * ## The kill switch\n *\n * `ai-generate` on the staff lockdown page is the lever for a provider\n * incident or a spend runaway on the generative doors, and this beat is the\n * one generative caller with no request to refuse. So it asks the same\n * feature verdict a door would — the platform scope and then the feature\n * document — and does nothing while either is locked. A job it leaves\n * queued stays queued: its lease is not taken, so nothing expires and\n * nothing is lost, and the first beat after the lock lifts picks it up.\n *\n * The same switch paused for ONE workspace (AGL-3037) holds that workspace's\n * jobs and no other's: `runAiJobStep` asks the verdict with the job's org\n * before it claims a step, and leaves a paused workspace's job queued.\n */\n\n/** The beat's row in `SCHEDULED_JOBS`, and the mark it leaves for `/api/health/crons`. */\nexport const AI_JOBS_BEAT_CRON_ID = 'ai-jobs-beat'\n\n/**\n * The console path the beat is registered at: the one path this plugin\n * registers outside its own prefixes. `/api/admin/` is where the console's\n * scheduled sweeps live, and the console's edge lets a request past its bot\n * challenge there when it carries the cron secret's header — a route\n * anywhere else would need a firewall rule of its own before a scheduler\n * could reach it. The console app serves this exact path from a named route\n * with the sweep's function time, so the plugin dispatcher never serves it.\n */\nexport const AI_JOBS_BEAT_PATH = 'admin/ai-jobs-beat'\n\n/** What one beat did: nothing while `ai-generate` is locked, else the sweep's account. */\nexport type AiJobsBeatResult =\n | { held: true }\n | ({ held: false } & SweepAiJobsResult)\n\n/**\n * One beat: the switch, then one sweep under its own lease owner. The owner\n * is fresh per beat and fixed for the whole sweep, so two beats overlapping\n * inside a lease never read each other's lease as their own.\n */\nexport async function runAiJobsBeat(): Promise<AiJobsBeatResult> {\n const locked = await featureLockdownRefusal({ feature: 'ai-generate' })\n if (locked) {\n console.warn('ai jobs beat held: ai-generate is locked')\n return { held: true }\n }\n const result = await sweepAiJobs({\n firestore: firebaseAdmin.app().firestore(),\n owner: `beat:${randomUUID()}`,\n budgetMs: AI_JOB_SWEEP_BUDGET_MS,\n })\n if (result.due) {\n console.info(\n `ai jobs: ${result.ran} step(s) run, ${result.skipped} skipped, ` +\n `${result.paused} held by a workspace's AI pause, ` +\n `${result.remaining} left for the next beat` +\n (result.budgetExhausted ? ' (budget exhausted)' : ''),\n )\n }\n return { held: false, ...result }\n}\n"],"names":["randomUUID","featureLockdownRefusal","firebaseAdmin","AI_JOB_SWEEP_BUDGET_MS","sweepAiJobs","AI_JOBS_BEAT_CRON_ID","AI_JOBS_BEAT_PATH","runAiJobsBeat","locked","feature","console","warn","held","result","firestore","app","owner","budgetMs","due","info","ran","skipped","paused","remaining","budgetExhausted"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,SAAQ;AACnC,SAASC,sBAAsB,EAAEC,aAAa,QAAQ,2BAA0B;AAChF,SAASC,sBAAsB,EAAEC,WAAW,QAAgC,eAAW;AAEvF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCC,GAED,wFAAwF,GACxF,OAAO,MAAMC,uBAAuB,eAAc;AAElD;;;;;;;;CAQC,GACD,OAAO,MAAMC,oBAAoB,qBAAoB;AAOrD;;;;CAIC,GACD,OAAO,eAAeC;IACpB,MAAMC,SAAS,MAAMP,uBAAuB;QAAEQ,SAAS;IAAc;IACrE,IAAID,QAAQ;QACVE,QAAQC,IAAI,CAAC;QACb,OAAO;YAAEC,MAAM;QAAK;IACtB;IACA,MAAMC,SAAS,MAAMT,YAAY;QAC/BU,WAAWZ,cAAca,GAAG,GAAGD,SAAS;QACxCE,OAAO,CAAC,KAAK,EAAEhB,cAAc;QAC7BiB,UAAUd;IACZ;IACA,IAAIU,OAAOK,GAAG,EAAE;QACdR,QAAQS,IAAI,CACV,CAAC,SAAS,EAAEN,OAAOO,GAAG,CAAC,cAAc,EAAEP,OAAOQ,OAAO,CAAC,UAAU,CAAC,GAC/D,GAAGR,OAAOS,MAAM,CAAC,iCAAiC,CAAC,GACnD,GAAGT,OAAOU,SAAS,CAAC,uBAAuB,CAAC,GAC3CV,CAAAA,OAAOW,eAAe,GAAG,wBAAwB,EAAC;IAEzD;IACA,OAAO;QAAEZ,MAAM;OAAUC;AAC3B"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-jobs-beat.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { randomUUID } from 'crypto'\nimport { featureLockdownRefusal, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { AI_JOB_SWEEP_BUDGET_MS, sweepAiJobs, type SweepAiJobsResult } from './ai-jobs'\nimport { sweepAiEmptySites, type AiEmptySiteSweepResult } from './ai-empty-site-sweep'\n\n/**\n * Resume AI generation jobs on the AI jobs beat (AGL-2904, AGL-3026).\n *\n * A job's first step runs inline in the console route when it can; every\n * step after it, every step too long for an inline door, and any step a\n * budget cut short, runs here. The sweep bounds itself at\n * `AI_JOB_SWEEP_BUDGET_MS` of wall clock and hands the rest to the next beat\n * — the ordering of the queue is the cursor, so nothing is stored between\n * beats and nothing can be skipped.\n *\n * ## Why the console runs it\n *\n * A step calls the AI provider, and the provider's key is held by the\n * console alone: the tenant app serves every published site, and a\n * credential it holds is one request-handling bug away from a visitor. So\n * the beat is a console route (`server/ai-jobs-beat-route.ts`, registered\n * by the console surface), driven every minute by its own Cloud Scheduler\n * job in `cloud/functions` on the console's cron secret — the reason the\n * sending-domain sweep is a console route and not a job on the tenant's\n * platform beat. A beat must resolve to the minute: a step handed back by\n * a budget, or a page pass waiting on the next, should resume on the next\n * minute, not the next quarter hour.\n *\n * ## The kill switch\n *\n * `ai-generate` on the staff lockdown page is the lever for a provider\n * incident or a spend runaway on the generative doors, and this beat is the\n * one generative caller with no request to refuse. So it asks the same\n * feature verdict a door would — the platform scope and then the feature\n * document — and does nothing while either is locked. A job it leaves\n * queued stays queued: its lease is not taken, so nothing expires and\n * nothing is lost, and the first beat after the lock lifts picks it up.\n *\n * The same switch paused for ONE workspace (AGL-3037) holds that workspace's\n * jobs and no other's: `runAiJobStep` asks the verdict with the job's org\n * before it claims a step, and leaves a paused workspace's job queued.\n */\n\n/** The beat's row in `SCHEDULED_JOBS`, and the mark it leaves for `/api/health/crons`. */\nexport const AI_JOBS_BEAT_CRON_ID = 'ai-jobs-beat'\n\n/**\n * The console path the beat is registered at: the one path this plugin\n * registers outside its own prefixes. `/api/admin/` is where the console's\n * scheduled sweeps live, and the console's edge lets a request past its bot\n * challenge there when it carries the cron secret's header — a route\n * anywhere else would need a firewall rule of its own before a scheduler\n * could reach it. The console app serves this exact path from a named route\n * with the sweep's function time, so the plugin dispatcher never serves it.\n */\nexport const AI_JOBS_BEAT_PATH = 'admin/ai-jobs-beat'\n\n/** What one beat did: nothing while `ai-generate` is locked, else the sweep's account. */\nexport type AiJobsBeatResult =\n | { held: true }\n | ({ held: false; emptySites: AiEmptySiteSweepResult | null } & SweepAiJobsResult)\n\n/**\n * One beat: the switch, then the empty sites a guided start left that are\n * due for removal (AGL-3936), then one sweep under its own lease owner. The\n * owner is fresh per beat and fixed for the whole sweep, so two beats\n * overlapping inside a lease never read each other's lease as their own.\n *\n * The removals come first, a few a beat inside\n * `AI_EMPTY_SITE_SWEEP_BUDGET_MS`, and whatever they took comes off the\n * sweep's budget, so the route's arithmetic inside `maxDuration` holds. A\n * removal that fails never holds up a job's step.\n */\nexport async function runAiJobsBeat(): Promise<AiJobsBeatResult> {\n const locked = await featureLockdownRefusal({ feature: 'ai-generate' })\n if (locked) {\n console.warn('ai jobs beat held: ai-generate is locked')\n return { held: true }\n }\n const firestore = firebaseAdmin.app().firestore()\n const startedAt = Date.now()\n const emptySites = await sweepAiEmptySites({ firestore }).catch((error: unknown) => {\n console.error('ai jobs beat: the empty site sweep failed', error)\n return null\n })\n if (emptySites?.removed) console.info(`ai jobs: ${emptySites.removed} empty site(s) removed`)\n const result = await sweepAiJobs({\n firestore,\n owner: `beat:${randomUUID()}`,\n budgetMs: Math.max(0, AI_JOB_SWEEP_BUDGET_MS - (Date.now() - startedAt)),\n })\n if (result.due) {\n console.info(\n `ai jobs: ${result.ran} step(s) run, ${result.skipped} skipped, ` +\n `${result.paused} held by a workspace's AI pause, ` +\n `${result.remaining} left for the next beat` +\n (result.budgetExhausted ? ' (budget exhausted)' : ''),\n )\n }\n return { held: false, emptySites, ...result }\n}\n"],"names":["randomUUID","featureLockdownRefusal","firebaseAdmin","AI_JOB_SWEEP_BUDGET_MS","sweepAiJobs","sweepAiEmptySites","AI_JOBS_BEAT_CRON_ID","AI_JOBS_BEAT_PATH","runAiJobsBeat","locked","feature","console","warn","held","firestore","app","startedAt","Date","now","emptySites","catch","error","removed","info","result","owner","budgetMs","Math","max","due","ran","skipped","paused","remaining","budgetExhausted"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,SAAQ;AACnC,SAASC,sBAAsB,EAAEC,aAAa,QAAQ,2BAA0B;AAChF,SAASC,sBAAsB,EAAEC,WAAW,QAAgC,eAAW;AACvF,SAASC,iBAAiB,QAAqC,2BAAuB;AAEtF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCC,GAED,wFAAwF,GACxF,OAAO,MAAMC,uBAAuB,eAAc;AAElD;;;;;;;;CAQC,GACD,OAAO,MAAMC,oBAAoB,qBAAoB;AAOrD;;;;;;;;;;CAUC,GACD,OAAO,eAAeC;IACpB,MAAMC,SAAS,MAAMR,uBAAuB;QAAES,SAAS;IAAc;IACrE,IAAID,QAAQ;QACVE,QAAQC,IAAI,CAAC;QACb,OAAO;YAAEC,MAAM;QAAK;IACtB;IACA,MAAMC,YAAYZ,cAAca,GAAG,GAAGD,SAAS;IAC/C,MAAME,YAAYC,KAAKC,GAAG;IAC1B,MAAMC,aAAa,MAAMd,kBAAkB;QAAES;IAAU,GAAGM,KAAK,CAAC,CAACC;QAC/DV,QAAQU,KAAK,CAAC,6CAA6CA;QAC3D,OAAO;IACT;IACA,IAAIF,8BAAAA,WAAYG,OAAO,EAAEX,QAAQY,IAAI,CAAC,CAAC,SAAS,EAAEJ,WAAWG,OAAO,CAAC,sBAAsB,CAAC;IAC5F,MAAME,SAAS,MAAMpB,YAAY;QAC/BU;QACAW,OAAO,CAAC,KAAK,EAAEzB,cAAc;QAC7B0B,UAAUC,KAAKC,GAAG,CAAC,GAAGzB,yBAA0Bc,CAAAA,KAAKC,GAAG,KAAKF,SAAQ;IACvE;IACA,IAAIQ,OAAOK,GAAG,EAAE;QACdlB,QAAQY,IAAI,CACV,CAAC,SAAS,EAAEC,OAAOM,GAAG,CAAC,cAAc,EAAEN,OAAOO,OAAO,CAAC,UAAU,CAAC,GAC/D,GAAGP,OAAOQ,MAAM,CAAC,iCAAiC,CAAC,GACnD,GAAGR,OAAOS,SAAS,CAAC,uBAAuB,CAAC,GAC3CT,CAAAA,OAAOU,eAAe,GAAG,wBAAwB,EAAC;IAEzD;IACA,OAAO;QAAErB,MAAM;QAAOM;OAAeK;AACvC"}
@@ -47,6 +47,7 @@ import { AI_SITE_GUIDED_BUILD_FAILED_COPY, aiCustomerSafeCopy, aiCustomerSafeIte
47
47
  import { aiSiteStartInputsOf } from "../model/ai-site-start.js";
48
48
  import { addAdminAudit } from "@aglyn/tenant-data-admin/server/admin-audit-write";
49
49
  import { AI_JOB_CANCELED_ITEM_NOTE, aiJobCancelPending, isAiJobCanceledError, watchAiJobCancel } from "./ai-job-cancel.js";
50
+ import { clearAiEmptySiteSweep, scheduleAiEmptySiteSweep } from "./ai-empty-site-sweep.js";
50
51
  /**
51
52
  * AI generation jobs — the Firestore state machine (AGL-2904).
52
53
  *
@@ -256,6 +257,16 @@ let transitionListener = null;
256
257
  /** Whether a transition listener is registered: what a surface that runs jobs must have. */ export function aiJobTransitionListenerRegistered() {
257
258
  return transitionListener !== null;
258
259
  }
260
+ /**
261
+ * A job just ENDED `canceled` or `failed`, in the write the caller made
262
+ * (AGL-3936): a site start that built nothing schedules the removal of the
263
+ * empty site it was for, an hour out (`ai-empty-site-sweep.ts`). Every write
264
+ * that ends a job calls this once, after its transaction commits. Never
265
+ * throws.
266
+ */ async function noteAiJobEnded(firestore, job, now) {
267
+ if (!job || job.status !== 'canceled' && job.status !== 'failed') return;
268
+ await scheduleAiEmptySiteSweep(firestore, job, now);
269
+ }
259
270
  /** Tells the listener; a listener that throws never fails the write it follows. */ async function announceAiJobTransition(job, to, failure) {
260
271
  if (!transitionListener) return;
261
272
  try {
@@ -408,6 +419,8 @@ function jobFrom(snapshot) {
408
419
  creditsConfirmed: input.creditsConfirmed
409
420
  } : {});
410
421
  await ref.set(data);
422
+ // A new job on the site — a guided start begun again — keeps it (AGL-3936).
423
+ await clearAiEmptySiteSweep(firestore, input.hostId);
411
424
  await logAiJobCreated(input.orgId, {
412
425
  uid: input.createdBy,
413
426
  email: (_input_createdByEmail = input.createdByEmail) != null ? _input_createdByEmail : null
@@ -527,6 +540,8 @@ export async function getAiJob(firestore, orgId, jobId) {
527
540
  publishAsked: true
528
541
  } : {}, aiJobCancelPending(job) ? {
529
542
  cancelRequested: true
543
+ } : {}, job.siteRemoved ? {
544
+ siteRemoved: true
530
545
  } : {});
531
546
  }
532
547
  function leaseIsLive(job, owner, nowMs) {
@@ -607,16 +622,20 @@ function nextStepIndex(job) {
607
622
  * stale lease is released by being taken, not by a sweep of its own.
608
623
  */ export async function claimNextStep(firestore, orgId, jobId, owner, now = new Date()) {
609
624
  const ref = jobsCollection(firestore, orgId).doc(jobId);
610
- return firestore.runTransaction(async (tx)=>{
625
+ let ended = null;
626
+ const claimed = await firestore.runTransaction(async (tx)=>{
627
+ ended = null;
611
628
  const job = jobFrom(await tx.get(ref));
612
629
  if (!job) return null;
613
630
  // A cancel asked while a step ran, whose runner never came back to end
614
631
  // it (AGL-3616): the lease ran out, so the job ends here, unclaimed.
615
632
  if (aiJobCancelPending(job)) {
616
633
  if (!leaseIsLive(job, owner, now.getTime())) {
617
- tx.set(ref, aiJobCanceledPatch(job, now, cancelRequestOf(job, now)), {
634
+ const patch = aiJobCanceledPatch(job, now, cancelRequestOf(job, now));
635
+ tx.set(ref, patch, {
618
636
  merge: true
619
637
  });
638
+ ended = _extends({}, job, patch);
620
639
  }
621
640
  return null;
622
641
  }
@@ -652,6 +671,8 @@ function nextStepIndex(job) {
652
671
  stepIndex
653
672
  };
654
673
  });
674
+ await noteAiJobEnded(firestore, ended, now);
675
+ return claimed;
655
676
  }
656
677
  /**
657
678
  * Extend the lease while a long step is still running. `false` when the
@@ -689,15 +710,19 @@ function nextStepIndex(job) {
689
710
  * parked, no step left, or another owner's live lease.
690
711
  */ export async function holdPausedAiJob(firestore, orgId, jobId, owner, now = new Date()) {
691
712
  const ref = jobsCollection(firestore, orgId).doc(jobId);
692
- return firestore.runTransaction(async (tx)=>{
713
+ let ended = null;
714
+ const held = await firestore.runTransaction(async (tx)=>{
715
+ ended = null;
693
716
  const job = jobFrom(await tx.get(ref));
694
717
  if (!job) return null;
695
718
  // A paused workspace's job a person canceled ends canceled (AGL-3616).
696
719
  if (aiJobCancelPending(job)) {
697
720
  if (!leaseIsLive(job, owner, now.getTime())) {
698
- tx.set(ref, aiJobCanceledPatch(job, now, cancelRequestOf(job, now)), {
721
+ const patch = aiJobCanceledPatch(job, now, cancelRequestOf(job, now));
722
+ tx.set(ref, patch, {
699
723
  merge: true
700
724
  });
725
+ ended = _extends({}, job, patch);
701
726
  }
702
727
  return null;
703
728
  }
@@ -711,6 +736,8 @@ function nextStepIndex(job) {
711
736
  updatedAt: now
712
737
  });
713
738
  });
739
+ await noteAiJobEnded(firestore, ended, now);
740
+ return held;
714
741
  }
715
742
  /**
716
743
  * A build's ledger with one pass applied (AGL-3616): the pass's credits on
@@ -874,9 +901,12 @@ function nextStepIndex(job) {
874
901
  job: _extends({}, job, patch),
875
902
  remaining,
876
903
  // Entered `needs_review` in this write, rather than parked again.
877
- parked: parksForReview && job.status !== 'needs_review'
904
+ parked: parksForReview && job.status !== 'needs_review',
905
+ // Ended `canceled` in this write (AGL-3936).
906
+ ended: canceling && !isAiJobTerminal(job.status)
878
907
  };
879
908
  });
909
+ if (recorded.ended) await noteAiJobEnded(firestore, recorded.job, now);
880
910
  if (recorded.parked && !input.quiet) await announceAiJobTransition(recorded.job, 'needs-review');
881
911
  return {
882
912
  job: recorded.job,
@@ -912,6 +942,7 @@ async function transition(firestore, orgId, jobId, apply) {
912
942
  updatedAt: now
913
943
  });
914
944
  if (changed && job.status === 'done') await announceAiJobTransition(job, 'done');
945
+ if (changed) await noteAiJobEnded(firestore, job, now);
915
946
  return job;
916
947
  }
917
948
  /**
@@ -946,6 +977,7 @@ async function transition(firestore, orgId, jobId, apply) {
946
977
  error: (detail == null ? void 0 : detail.error) instanceof Error ? detail.error.message : (detail == null ? void 0 : detail.error) == null ? null : String(detail.error)
947
978
  });
948
979
  }
980
+ if (changed) await noteAiJobEnded(firestore, job, now);
949
981
  return job;
950
982
  }
951
983
  /**
@@ -953,7 +985,7 @@ async function transition(firestore, orgId, jobId, apply) {
953
985
  * step goes back to `pending` so the same step is what runs when the beat
954
986
  * tries again; its attempt is not counted, because nothing was attempted.
955
987
  */ export async function markAiJobNeedsInput(firestore, orgId, jobId, reason, now = new Date()) {
956
- const { job } = await transition(firestore, orgId, jobId, (current)=>isAiJobTerminal(current.status) ? null : aiJobCancelPending(current) ? aiJobCanceledPatch(current, now, cancelRequestOf(current, now)) : {
988
+ const { job, changed } = await transition(firestore, orgId, jobId, (current)=>isAiJobTerminal(current.status) ? null : aiJobCancelPending(current) ? aiJobCanceledPatch(current, now, cancelRequestOf(current, now)) : {
957
989
  status: 'needs_input',
958
990
  error: reason,
959
991
  lease: null,
@@ -966,6 +998,7 @@ async function transition(firestore, orgId, jobId, apply) {
966
998
  }),
967
999
  updatedAt: now
968
1000
  });
1001
+ if (changed) await noteAiJobEnded(firestore, job, now);
969
1002
  return job;
970
1003
  }
971
1004
  /**
@@ -1016,6 +1049,7 @@ async function transition(firestore, orgId, jobId, apply) {
1016
1049
  kind: result.job.kind
1017
1050
  });
1018
1051
  }
1052
+ if (result.changed) await noteAiJobEnded(firestore, result.job, now);
1019
1053
  return result;
1020
1054
  }
1021
1055
  /**
@@ -1372,6 +1406,8 @@ async function transition(firestore, orgId, jobId, apply) {
1372
1406
  updatedAt: now
1373
1407
  });
1374
1408
  });
1409
+ // A site start tried again keeps its site (AGL-3936).
1410
+ if (result.changed) await clearAiEmptySiteSweep(firestore, result.job.hostId);
1375
1411
  return _extends({}, result, {
1376
1412
  retried: result.changed ? retried : []
1377
1413
  });
@@ -1388,7 +1424,7 @@ async function transition(firestore, orgId, jobId, apply) {
1388
1424
  * and a confirmed plan is held at what the whole job is estimated to cost
1389
1425
  * (AGL-3031), creations included, where that is more.
1390
1426
  */ export async function resumeAiJob(firestore, orgId, jobId, actor, now = new Date(), options = {}) {
1391
- return transition(firestore, orgId, jobId, (current)=>{
1427
+ const resumed = await transition(firestore, orgId, jobId, (current)=>{
1392
1428
  var _options_reducePlan;
1393
1429
  var _current_review, _current_inputs;
1394
1430
  // A job the meter paused (AGL-3660) carries on from the step it paused
@@ -1435,6 +1471,9 @@ async function transition(firestore, orgId, jobId, apply) {
1435
1471
  updatedAt: now
1436
1472
  });
1437
1473
  });
1474
+ // A site start resumed keeps its site (AGL-3936).
1475
+ if (resumed.changed) await clearAiEmptySiteSweep(firestore, resumed.job.hostId);
1476
+ return resumed;
1438
1477
  }
1439
1478
  /**
1440
1479
  * Record what a person applied from a finished job's outputs (AGL-2910): the