@aglyn/plugins-ai 1.0.0-beta.293 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/ai-job-links.d.ts +17 -3
  3. package/src/lib/components/ai-job-links.js +32 -5
  4. package/src/lib/components/ai-job-links.js.map +1 -1
  5. package/src/lib/components/ai-job-requests.d.ts +9 -0
  6. package/src/lib/components/ai-job-requests.js +34 -0
  7. package/src/lib/components/ai-job-requests.js.map +1 -1
  8. package/src/lib/components/ai-site-build-page.component.js +28 -2
  9. package/src/lib/components/ai-site-build-page.component.js.map +1 -1
  10. package/src/lib/components/assist-edit-canvas.d.ts +12 -6
  11. package/src/lib/components/assist-edit-canvas.js +86 -36
  12. package/src/lib/components/assist-edit-canvas.js.map +1 -1
  13. package/src/lib/components/assist-edit-card.component.js +9 -8
  14. package/src/lib/components/assist-edit-card.component.js.map +1 -1
  15. package/src/lib/components/assist-jobs-drawer.component.d.ts +7 -1
  16. package/src/lib/components/assist-jobs-drawer.component.js +20 -6
  17. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  18. package/src/lib/components/assist-page-change-card.component.d.ts +44 -0
  19. package/src/lib/components/assist-page-change-card.component.js +166 -0
  20. package/src/lib/components/assist-page-change-card.component.js.map +1 -0
  21. package/src/lib/components/assist-panel.component.d.ts +105 -1
  22. package/src/lib/components/assist-panel.component.js +242 -36
  23. package/src/lib/components/assist-panel.component.js.map +1 -1
  24. package/src/lib/jobs/ai-job-edit-step.d.ts +82 -16
  25. package/src/lib/jobs/ai-job-edit-step.js +209 -62
  26. package/src/lib/jobs/ai-job-edit-step.js.map +1 -1
  27. package/src/lib/jobs/ai-job-navigation-step.js +4 -2
  28. package/src/lib/jobs/ai-job-navigation-step.js.map +1 -1
  29. package/src/lib/model/ai-edit-job.d.ts +33 -16
  30. package/src/lib/model/ai-edit-job.js +50 -34
  31. package/src/lib/model/ai-edit-job.js.map +1 -1
  32. package/src/lib/model/ai-job-activity.d.ts +6 -11
  33. package/src/lib/model/ai-job-activity.js +8 -15
  34. package/src/lib/model/ai-job-activity.js.map +1 -1
  35. package/src/lib/model/ai-job-notice.js +3 -1
  36. package/src/lib/model/ai-job-notice.js.map +1 -1
  37. package/src/lib/model/ai-jobs.types.d.ts +6 -0
  38. package/src/lib/model/ai-jobs.types.js.map +1 -1
  39. package/src/lib/model/ai-site-inventory.d.ts +6 -0
  40. package/src/lib/model/ai-site-inventory.js.map +1 -1
  41. package/src/lib/model/assist-edit.d.ts +19 -1
  42. package/src/lib/model/assist-edit.js +7 -0
  43. package/src/lib/model/assist-edit.js.map +1 -1
  44. package/src/lib/model/assist-page-change.d.ts +100 -0
  45. package/src/lib/model/assist-page-change.js +107 -0
  46. package/src/lib/model/assist-page-change.js.map +1 -0
  47. package/src/lib/runtime/ai-eval.js +3 -1
  48. package/src/lib/runtime/ai-eval.js.map +1 -1
  49. package/src/lib/runtime/ai-node-tree.d.ts +5 -0
  50. package/src/lib/runtime/ai-node-tree.js +18 -3
  51. package/src/lib/runtime/ai-node-tree.js.map +1 -1
  52. package/src/lib/runtime/site-inventory.d.ts +8 -1
  53. package/src/lib/runtime/site-inventory.js +18 -0
  54. package/src/lib/runtime/site-inventory.js.map +1 -1
  55. package/src/lib/server/assist-build.js +2 -0
  56. package/src/lib/server/assist-build.js.map +1 -1
  57. package/src/lib/server/assist-chat.js +56 -3
  58. package/src/lib/server/assist-chat.js.map +1 -1
  59. package/src/lib/server/assist-edit.d.ts +61 -4
  60. package/src/lib/server/assist-edit.js +248 -38
  61. package/src/lib/server/assist-edit.js.map +1 -1
  62. package/src/lib/server/assist-page-change.d.ts +62 -0
  63. package/src/lib/server/assist-page-change.js +140 -0
  64. package/src/lib/server/assist-page-change.js.map +1 -0
@@ -17,22 +17,26 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  */ import { checkEntitlement } from "@aglyn/aglyn/app-utils/plan-entitlements";
18
18
  import { SCREEN_KIND_TEMPLATE } from "@aglyn/aglyn/app-utils/screen-route";
19
19
  import { screenSeoStageKey, SCREEN_SEO_TEXT_FIELDS } from "@aglyn/aglyn/app-utils/screen-seo-fields";
20
+ import { hasConcurrentWrite, versionStamp } from "@aglyn/aglyn/app-utils/guarded-version-save";
20
21
  import { decodeStoredNodes, encodeStoredNodes } from "@aglyn/aglyn/app-utils/stored-nodes";
22
+ import { coeditMirrorRoomPath, VERSION_DRAFT_COLLECTION, VERSION_DRAFT_DOC_ID } from "@aglyn/aglyn/app-utils/version-draft";
21
23
  import { CanvasManager } from "@aglyn/aglyn/canvas-manager/canvas-manager";
22
24
  import { FEATURE_FLAG } from "@aglyn/aglyn/foundation/constants/shared";
25
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
23
26
  import { resolveOrgIdForHost } from "@aglyn/tenant-data-admin/server/organizations";
24
27
  import { applyAssistEdit } from "../components/assist-edit-canvas.js";
25
- import { AI_EDIT_COMPONENT_LEFT_OUT, AI_EDIT_EMAIL_COPY, AI_EDIT_GONE_COPY, AI_EDIT_NEEDS_VERSIONING_COPY, AI_EDIT_NO_NODES_COPY, AI_EDIT_NO_SITE_COPY, AI_EDIT_NOTHING_COPY, AI_EDIT_REFUSED_COPY, AI_EDIT_SEO_LEFT_OUT, AI_EDIT_STALE_COPY, AI_EDIT_VERSION_GONE_COPY, aiEditJobOutline, aiEditOutputNote, aiEditVersionName, parseAiEditJobInputs } from "../model/ai-edit-job.js";
28
+ import { AI_EDIT_COMPONENT_LEFT_OUT, AI_EDIT_EMAIL_COPY, AI_EDIT_GONE_COPY, AI_EDIT_NO_NODES_COPY, AI_EDIT_NO_SITE_COPY, AI_EDIT_NOTHING_COPY, AI_EDIT_REFUSED_COPY, AI_EDIT_SEO_LEFT_OUT, AI_EDIT_STALE_COPY, AI_EDIT_VERSION_GONE_COPY, aiEditJobOutline, aiEditOutputNote, aiEditVersionName, parseAiEditJobInputs } from "../model/ai-edit-job.js";
26
29
  import { ASSIST_EDIT_TOOL_NAME } from "../model/assist-edit.js";
27
30
  import { AI_STEP_TIERS } from "../providers/catalog.js";
28
31
  import { AI_ROUTING_TABLE, aiModelForStep } from "../providers/routing.js";
29
32
  import { runValidatedGeneration, validateStreamedGeneration } from "../runtime/ai-doctrine.js";
30
33
  import { AI_PALETTE } from "../runtime/ai-palette.generated.js";
31
- import { assistEditTool, checkAssistEditAnswer, editCanvasBlock, editSelectionBlock, parseAssistEditContext } from "../server/assist-edit.js";
34
+ import { readAiSiteDatasets } from "../runtime/site-inventory.js";
35
+ import { assistEditTool, checkAssistEditAnswer, editCanvasBlock, editDatasetsBlock, editSelectionBlock, parseAssistEditContext } from "../server/assist-edit.js";
32
36
  import { registerAiJobAdmission } from "./ai-job-admission.js";
33
37
  import { aiJobStepBudget } from "./ai-job-budget.js";
34
38
  import { aiOriginJobId } from "./ai-job-draft-ids.js";
35
- import { aiGenerationSpent, aiLimitReview, aiUnspentOutcome } from "./ai-job-generation.js";
39
+ import { aiGenerationSpent, aiUnspentOutcome } from "./ai-job-generation.js";
36
40
  import { AI_JOB_BRIEF_MAX_CHARS } from "./ai-job-text-step.js";
37
41
  import { registerAiJobStep } from "./ai-jobs.js";
38
42
  /**
@@ -49,13 +53,19 @@ import { registerAiJobStep } from "./ai-jobs.js";
49
53
  *
50
54
  * ── Where the change lands, decided before anything is spent ────────────
51
55
  *
52
- * Never on a version a visitor sees. With version history, a NEW version
53
- * beside the one it started from — a copy of that version's stored fields with
54
- * the changed nodes — which becomes the page's current version only when the
55
- * page is not published; a published page's current version, and a layout's,
56
- * never move. Without it, in place, on a version no visitor reaches: an
57
- * unpublished page's, or one that is not the current version and no schedule
58
- * will publish. Anything else is refused as a plan limit, charged nothing.
56
+ * Never on a version a visitor sees. By default, on every plan, in the
57
+ * WORKING DRAFT of the version it started from (AGL-4116): the one document
58
+ * the Besigner saves to as an author works and offers with Open draft, which
59
+ * publishing moves into the version. The change is built on the draft the
60
+ * author already has, when the Besigner would still offer it, so their work
61
+ * in progress is never thrown away; otherwise on the version's own tree.
62
+ *
63
+ * Only when the request asks for a new version, and the plan keeps version
64
+ * history, a NEW version beside the one it started from — a copy of that
65
+ * version's stored fields with the changed nodes — which becomes the page's
66
+ * current version only when the page is not published; a published page's
67
+ * current version, and a layout's, never move. Asked for without version
68
+ * history, the change goes into the draft and the output says why.
59
69
  *
60
70
  * ── What a job cannot do that the editor can ────────────────────────────
61
71
  *
@@ -69,8 +79,8 @@ import { registerAiJobStep } from "./ai-jobs.js";
69
79
  *
70
80
  * The new version is named by the job's id — a build's unit is named by its
71
81
  * plan item's id — so a run asked again finds what it wrote and spends
72
- * nothing. A change made in place stamps the version with the job's id for
73
- * the same reason.
82
+ * nothing. A draft the job writes is stamped with the job's id for the same
83
+ * reason.
74
84
  */ /** The longest the step may take reading the page and writing it. */ export const AI_EDIT_READS_MS = 2000;
75
85
  /** The step's time: its answer and its re-ask at the `job.edit` ceiling, with its own reads. */ export const AI_JOB_EDIT_STEP_BUDGET = aiJobStepBudget({
76
86
  tier: AI_STEP_TIERS['job.edit'],
@@ -89,8 +99,8 @@ export function aiJobEditModel() {
89
99
  */ export const AI_JOB_EDIT_RULES = {
90
100
  text: [
91
101
  'You change a page or a layout the person already has, as a job that runs on its own. No editor is open, nothing is selected, and nobody can answer a question before you finish.',
92
- `- Make exactly the change the request asks for, and keep everything else as it is: other elements, their text, their styles and their order. Never rewrite, restyle, move or remove what the request does not ask about.`,
93
- '- Find the elements the request means in the canvas outline the request carries, by what they say and what they are. Name only ids that outline lists.',
102
+ `- Make exactly the change the request asks for, and keep everything else as it is: other elements, their text, their styles and their order. Never rewrite, restyle, move or remove what the request does not ask about, beyond what making that change needs.`,
103
+ '- Find the elements the request means in the outline of the whole document the request carries, by what they say, what they are and where they sit on the page. Name only ids that outline lists.',
94
104
  `- If the outline does not show an element the request is about, change nothing for that part: never guess at an element.`,
95
105
  `- Call ${ASSIST_EDIT_TOOL_NAME} exactly once, with every operation. Its summary is one short line saying what changed, in the language of the request; it names the version the change is saved in.`,
96
106
  "- Fill in a page's search title or description only when the request asks for it.",
@@ -107,13 +117,43 @@ export function aiJobEditModel() {
107
117
  }
108
118
  ];
109
119
  }
110
- /** What the change is asked from: the document's outline, then the request. */ export function aiJobEditPrompt(input) {
120
+ /**
121
+ * What the change is asked from: the document's outline, the site's datasets
122
+ * an element may show the records of (AGL-3616), then the request.
123
+ */ export function aiJobEditPrompt(input) {
124
+ var _input_datasets;
125
+ const datasets = editDatasetsBlock((_input_datasets = input.datasets) != null ? _input_datasets : []);
111
126
  return [
112
- editSelectionBlock(input.context),
127
+ editSelectionBlock(input.context, {
128
+ job: true
129
+ }),
130
+ ...datasets ? [
131
+ '',
132
+ datasets
133
+ ] : [],
113
134
  '',
114
135
  `Request: ${input.brief.slice(0, AI_JOB_BRIEF_MAX_CHARS)}`
115
136
  ].join('\n');
116
137
  }
138
+ /**
139
+ * The datasets an edit to this document knows (AGL-3616): every one the site
140
+ * holds, and the one whose record page the document is, if it is one.
141
+ */ export function aiEditDatasetScope(datasets, read) {
142
+ var _ref;
143
+ const record = read.kind === 'screen' ? datasets.find((dataset)=>dataset.recordScreenId === read.id) : undefined;
144
+ return {
145
+ datasets: datasets.map(({ id, name, fields, fieldIds, imageField })=>_extends({
146
+ id,
147
+ name,
148
+ fields
149
+ }, fieldIds ? {
150
+ fieldIds
151
+ } : {}, imageField ? {
152
+ imageField
153
+ } : {})),
154
+ recordDatasetId: (_ref = record == null ? void 0 : record.id) != null ? _ref : null
155
+ };
156
+ }
117
157
  /** The edit as the doctrine's loop runs it: the rung's tool, held to the rung's check. */ export function aiJobEditGeneration(request) {
118
158
  const row = AI_ROUTING_TABLE['job.edit'];
119
159
  const kind = request.target.kind;
@@ -136,10 +176,14 @@ export function aiJobEditModel() {
136
176
  } : {}, request.signal ? {
137
177
  signal: request.signal
138
178
  } : {}, {
139
- check: (answer)=>checkAssistEditAnswer(answer, {
179
+ check: (answer)=>checkAssistEditAnswer(answer, _extends({
140
180
  context: request.context,
141
181
  target: request.target
142
- })
182
+ }, request.datasets ? {
183
+ datasets: request.datasets
184
+ } : {}, request.recordDatasetId ? {
185
+ recordDatasetId: request.recordDatasetId
186
+ } : {}))
143
187
  });
144
188
  }
145
189
  // ── The document, headless ────────────────────────────────────────────────
@@ -214,6 +258,25 @@ function pendingScheduleVersion(doc) {
214
258
  if (!schedule || schedule['status'] !== 'pending' || schedule['action'] === 'unpublish') return null;
215
259
  return text(schedule['versionId']) || null;
216
260
  }
261
+ /**
262
+ * A version's working draft as the Besigner would offer it: a tree, taken
263
+ * against the version as it is stored now. A draft the version has been
264
+ * saved past is not offered there (it would undo that save), so a change is
265
+ * not built on it here either. `aiEditJobId` names the job that wrote it.
266
+ */ function usableDraft(draft, version) {
267
+ if (!draft.exists) return null;
268
+ const nodes = decodeStoredNodes(draft.get('nodes'));
269
+ if (!nodes || !Object.keys(nodes).length) return null;
270
+ const baseStamp = typeof draft.get('baseStamp') === 'string' ? draft.get('baseStamp') : null;
271
+ if (hasConcurrentWrite(baseStamp, versionStamp(version.get('updatedAt')))) return null;
272
+ return {
273
+ nodes,
274
+ aiEditJobId: text(draft.get('aiEditJobId')) || null
275
+ };
276
+ }
277
+ /** A version's working draft document: the Besigner's, at the core's one address. */ function draftRef(versions, versionId) {
278
+ return versions.doc(versionId).collection(VERSION_DRAFT_COLLECTION).doc(VERSION_DRAFT_DOC_ID);
279
+ }
217
280
  /**
218
281
  * The document an edit job changes, on the job's own site: the page or
219
282
  * layout named, not deleted, not an email design, and the version it starts
@@ -265,6 +328,7 @@ function pendingScheduleVersion(doc) {
265
328
  return _extends({}, base, {
266
329
  sourceVersionId: sourceVersionId != null ? sourceVersionId : input.jobId,
267
330
  nodes: {},
331
+ draft: null,
268
332
  written: {
269
333
  versionId: input.jobId,
270
334
  placement
@@ -275,7 +339,10 @@ function pendingScheduleVersion(doc) {
275
339
  status: 404,
276
340
  error: AI_EDIT_VERSION_GONE_COPY
277
341
  };
278
- const source = await versions.doc(sourceVersionId).get();
342
+ const [source, draftDoc] = await Promise.all([
343
+ versions.doc(sourceVersionId).get(),
344
+ draftRef(versions, sourceVersionId).get()
345
+ ]);
279
346
  if (!source.exists) return {
280
347
  status: 404,
281
348
  error: AI_EDIT_VERSION_GONE_COPY
@@ -284,12 +351,25 @@ function pendingScheduleVersion(doc) {
284
351
  return _extends({}, base, {
285
352
  sourceVersionId,
286
353
  nodes: {},
354
+ draft: null,
287
355
  written: {
288
356
  versionId: sourceVersionId,
289
357
  placement: 'in-place'
290
358
  }
291
359
  });
292
360
  }
361
+ const draft = usableDraft(draftDoc, source);
362
+ if (input.jobId && (draft == null ? void 0 : draft.aiEditJobId) === input.jobId) {
363
+ return _extends({}, base, {
364
+ sourceVersionId,
365
+ nodes: {},
366
+ draft: null,
367
+ written: {
368
+ versionId: sourceVersionId,
369
+ placement: 'draft'
370
+ }
371
+ });
372
+ }
293
373
  const nodes = decodeStoredNodes(source.get('nodes'));
294
374
  if (!nodes || !Object.keys(nodes).length) return {
295
375
  status: 400,
@@ -298,6 +378,9 @@ function pendingScheduleVersion(doc) {
298
378
  return _extends({}, base, {
299
379
  sourceVersionId,
300
380
  nodes,
381
+ draft: draft ? {
382
+ nodes: draft.nodes
383
+ } : null,
301
384
  written: null
302
385
  });
303
386
  }
@@ -314,6 +397,16 @@ function pendingScheduleVersion(doc) {
314
397
  const seen = input.sourceScheduled || input.sourceIsPointer && input.served;
315
398
  return seen ? 'refused' : 'in-place';
316
399
  }
400
+ /**
401
+ * Where an edit job's change lands (AGL-4116): the version's working draft,
402
+ * unless the request asked for a new version and the plan keeps version
403
+ * history — then where {@link aiEditPlacement} puts a new one. Never refused:
404
+ * a draft is on every plan, and no visitor sees it.
405
+ */ export function aiEditJobPlacement(input) {
406
+ if (!input.newVersion || !input.versioning) return 'draft';
407
+ const placed = aiEditPlacement(input);
408
+ return placed === 'refused' ? 'draft' : placed;
409
+ }
317
410
  /**
318
411
  * The change written, in one transaction that reads the document as it is
319
412
  * stored NOW — a member may have saved, published or changed plan's history
@@ -326,12 +419,15 @@ function pendingScheduleVersion(doc) {
326
419
  const versions = docRef.collection('versions');
327
420
  const sourceRef = versions.doc(input.sourceVersionId);
328
421
  const newRef = versions.doc(input.jobVersionId);
422
+ const sourceDraftRef = draftRef(versions, input.sourceVersionId);
329
423
  return firestore.runTransaction(async (tx)=>{
330
- const [host, doc, source, already] = await Promise.all([
424
+ var _ref;
425
+ const [host, doc, source, already, draftDoc] = await Promise.all([
331
426
  tx.get(hostRef),
332
427
  tx.get(docRef),
333
428
  tx.get(sourceRef),
334
- tx.get(newRef)
429
+ tx.get(newRef),
430
+ tx.get(sourceDraftRef)
335
431
  ]);
336
432
  const pointer = text(doc.get('versionId')) || null;
337
433
  if (already.exists) {
@@ -351,22 +447,30 @@ function pendingScheduleVersion(doc) {
351
447
  error: AI_EDIT_VERSION_GONE_COPY
352
448
  };
353
449
  const served = input.kind === 'layout' || screenServed(doc, host);
354
- const placement = aiEditPlacement({
450
+ const placement = aiEditJobPlacement({
451
+ newVersion: input.newVersion,
355
452
  versioning: input.versioning,
356
453
  served,
357
454
  sourceIsPointer: pointer === input.sourceVersionId,
358
455
  sourceScheduled: pendingScheduleVersion(doc) === input.sourceVersionId
359
456
  });
360
- if (placement === 'refused') return {
361
- ok: false,
362
- error: AI_EDIT_NEEDS_VERSIONING_COPY
363
- };
364
- const nodes = decodeStoredNodes(source.get('nodes'));
457
+ // Read again here, as the version is: an author who saved their draft,
458
+ // or published it, since the read is what the change is built on now.
459
+ const draft = placement === 'draft' ? usableDraft(draftDoc, source) : null;
460
+ if ((draft == null ? void 0 : draft.aiEditJobId) === input.jobVersionId) {
461
+ return {
462
+ ok: true,
463
+ versionId: input.sourceVersionId,
464
+ placement,
465
+ seoWritten: false
466
+ };
467
+ }
468
+ const nodes = (_ref = draft == null ? void 0 : draft.nodes) != null ? _ref : decodeStoredNodes(source.get('nodes'));
365
469
  if (!nodes) return {
366
470
  ok: false,
367
471
  error: AI_EDIT_NO_NODES_COPY
368
472
  };
369
- const versionId = placement === 'in-place' ? input.sourceVersionId : input.jobVersionId;
473
+ const versionId = placement === 'draft' ? input.sourceVersionId : input.jobVersionId;
370
474
  const seo = {};
371
475
  const writeSeo = input.writeSeo && input.kind === 'screen' && !served;
372
476
  const proposal = _extends({}, input.proposal, {
@@ -387,9 +491,14 @@ function pendingScheduleVersion(doc) {
387
491
  };
388
492
  const stored = Buffer.from(packed);
389
493
  const docPatch = {};
390
- if (placement === 'in-place') {
391
- tx.update(sourceRef, {
494
+ if (placement === 'draft') {
495
+ // The whole working tree, as the Besigner writes a draft, taken against
496
+ // the version as stored so the Besigner offers it with Open draft.
497
+ tx.set(sourceDraftRef, {
392
498
  nodes: stored,
499
+ baseStamp: versionStamp(source.get('updatedAt')),
500
+ updatedByUid: input.uid,
501
+ updatedByEmail: null,
393
502
  updatedAt: input.now,
394
503
  aiEditJobId: input.jobVersionId
395
504
  });
@@ -423,20 +532,45 @@ function pendingScheduleVersion(doc) {
423
532
  };
424
533
  });
425
534
  }
426
- /** What an edit wrote, as the job's output: a link into the Besigner on the version written. */ export function aiEditOutput(read, hostId, written, leftOut) {
427
- return {
535
+ /**
536
+ * Clears the co-edit mirror of the version whose draft a job just wrote
537
+ * (AGL-4116), as the Besigner's own Save draft does.
538
+ *
539
+ * The mirror holds unsaved canvas work and replays everything newer than the
540
+ * version's last save to whoever opens it. A draft write does not move the
541
+ * version's save, so without this the canvas of someone who had opened the
542
+ * older draft replays over the change the job just saved, and a room with
543
+ * replayed work offers no draft at all: the newer draft can only be reached by
544
+ * a Discard that deletes it. Best effort: a mirror that cannot be cleared
545
+ * leaves the draft saved, and the job reports what it wrote.
546
+ */ export async function clearAiEditMirror(input) {
547
+ await firebaseAdmin.database().ref(coeditMirrorRoomPath({
548
+ orgId: input.orgId,
549
+ hostId: input.hostId,
550
+ docType: input.kind,
551
+ docId: input.id,
552
+ versionId: input.versionId
553
+ })).remove();
554
+ }
555
+ /** What an edit wrote, as the job's output: a link into the Besigner on the version written. */ export function aiEditOutput(read, hostId, written, leftOut, options = {}) {
556
+ return _extends({
428
557
  resource: read.kind,
429
558
  id: read.id,
430
559
  versionId: written.versionId,
431
560
  hostId,
432
561
  hostSubdomain: read.hostSubdomain,
433
- label: read.name,
434
- note: aiEditOutputNote({
562
+ label: read.name
563
+ }, written.placement === 'draft' ? {
564
+ inDraft: true
565
+ } : {}, {
566
+ note: aiEditOutputNote(_extends({
435
567
  kind: read.kind,
436
568
  placement: written.placement,
437
569
  leftOut
438
- })
439
- };
570
+ }, options.versionsUnavailable ? {
571
+ versionsUnavailable: true
572
+ } : {}))
573
+ });
440
574
  }
441
575
  /** The new version's id: the job's own, which a build's unit takes from its plan item. */ export function aiEditVersionId(job) {
442
576
  return job.$id.slice(0, 64);
@@ -460,9 +594,11 @@ function pendingScheduleVersion(doc) {
460
594
  };
461
595
  }
462
596
  export function createAiJobEditStep(deps = {}) {
463
- var _deps_readTarget, _deps_writeEdit;
597
+ var _deps_readTarget, _deps_writeEdit, _deps_readDatasets, _deps_clearMirror;
464
598
  const readTarget = (_deps_readTarget = deps.readTarget) != null ? _deps_readTarget : readAiEditTarget;
465
599
  const writeEdit = (_deps_writeEdit = deps.writeEdit) != null ? _deps_writeEdit : writeAiEdit;
600
+ const readDatasets = (_deps_readDatasets = deps.readDatasets) != null ? _deps_readDatasets : readAiSiteDatasets;
601
+ const clearMirror = (_deps_clearMirror = deps.clearMirror) != null ? _deps_clearMirror : clearAiEditMirror;
466
602
  return async ({ job, now, signal, firestore, org, modelFor })=>{
467
603
  var _ref, _data;
468
604
  const model = (_ref = modelFor == null ? void 0 : modelFor('job.edit')) != null ? _ref : aiJobEditModel();
@@ -487,30 +623,34 @@ export function createAiJobEditStep(deps = {}) {
487
623
  });
488
624
  const orgData = org != null ? org : (_data = (await firestore.collection('orgs').doc(job.orgId).get()).data()) != null ? _data : null;
489
625
  const versioning = checkEntitlement(orgData, 'versioning');
490
- const placement = aiEditPlacement({
626
+ const placement = aiEditJobPlacement({
627
+ newVersion: inputs.newVersion,
491
628
  versioning,
492
629
  served: read.served,
493
630
  sourceIsPointer: read.sourceVersionId === read.pointerVersionId,
494
631
  sourceScheduled: read.scheduledVersionId === read.sourceVersionId
495
632
  });
496
- if (placement === 'refused') return aiUnspentOutcome(model, {
497
- review: aiLimitReview(AI_EDIT_NEEDS_VERSIONING_COPY)
498
- });
499
- const outline = aiEditJobOutline(read.nodes);
500
- const context = outline ? parseAssistEditContext(outline) : null;
633
+ // A change to the draft is made on the draft the author already has.
634
+ const outline = aiEditJobOutline(placement === 'draft' && read.draft ? read.draft.nodes : read.nodes);
635
+ const context = outline ? parseAssistEditContext(outline, {
636
+ whole: true
637
+ }) : null;
501
638
  if (!context) return unspent(AI_EDIT_NO_NODES_COPY);
502
639
  const target = {
503
640
  kind: read.kind,
504
641
  documentId: read.id,
505
- versionId: placement === 'in-place' ? read.sourceVersionId : aiEditVersionId(job),
642
+ versionId: placement === 'draft' ? read.sourceVersionId : aiEditVersionId(job),
506
643
  hostId
507
644
  };
645
+ // The site's datasets, so a request to show records binds a repeat to a
646
+ // real one instead of writing tokens nothing fills (AGL-3616).
647
+ const datasets = aiEditDatasetScope(await readDatasets(job.orgId, hostId), read);
508
648
  const generation = await runValidatedGeneration('edit', aiJobEditGeneration(_extends({
509
649
  brief: job.brief,
510
650
  context,
511
651
  target,
512
652
  model
513
- }, signal ? {
653
+ }, datasets, signal ? {
514
654
  signal
515
655
  } : {})));
516
656
  const spent = _extends({}, aiGenerationSpent(generation), generation.effort ? {
@@ -527,10 +667,10 @@ export function createAiJobEditStep(deps = {}) {
527
667
  if (!proposal && refused) {
528
668
  const salvaged = validateStreamedGeneration('edit', {
529
669
  answer: refused,
530
- check: (answer)=>checkAssistEditAnswer(answer, {
670
+ check: (answer)=>checkAssistEditAnswer(answer, _extends({
531
671
  context,
532
672
  target
533
- })
673
+ }, datasets))
534
674
  });
535
675
  proposal = salvaged.status === 'ok' ? salvaged.value : null;
536
676
  }
@@ -557,6 +697,7 @@ export function createAiJobEditStep(deps = {}) {
557
697
  sourceVersionId: read.sourceVersionId,
558
698
  jobVersionId: aiEditVersionId(job),
559
699
  versioning,
700
+ newVersion: inputs.newVersion,
560
701
  proposal: _extends({}, proposal, {
561
702
  ops: kept.ops
562
703
  }),
@@ -569,18 +710,33 @@ export function createAiJobEditStep(deps = {}) {
569
710
  if (write.ok === false) return _extends({}, spent, {
570
711
  failure: write.error
571
712
  });
713
+ if (write.placement === 'draft') {
714
+ await clearMirror({
715
+ orgId: job.orgId,
716
+ hostId,
717
+ kind: read.kind,
718
+ id: read.id,
719
+ versionId: write.versionId
720
+ }).catch((error)=>console.warn('[ai edit] the co-edit mirror could not be cleared', {
721
+ jobId: job.$id,
722
+ error
723
+ }));
724
+ }
725
+ const versionsUnavailable = inputs.newVersion && !versioning;
572
726
  return _extends({}, spent, {
573
727
  outputs: [
574
- aiEditOutput(read, hostId, write, leftOut)
728
+ aiEditOutput(read, hostId, write, leftOut, {
729
+ versionsUnavailable
730
+ })
575
731
  ]
576
732
  });
577
733
  };
578
734
  }
579
735
  export const runAiJobEditStep = createAiJobEditStep();
580
736
  /**
581
- * An edit job is admitted for a page or layout of a site of its own org,
582
- * and only where the change has somewhere to land that no visitor sees on
583
- * this workspace's plan — refused in the plan's words before anything exists.
737
+ * An edit job is admitted for a page or layout of a site of its own org that
738
+ * has a version to start from. Every plan has somewhere to put the change
739
+ * that no visitor sees — the version's draft — so no plan is refused.
584
740
  */ export function createAiEditJobAdmission(deps = {}) {
585
741
  var _deps_readTarget, _deps_ownerOf;
586
742
  const readTarget = (_deps_readTarget = deps.readTarget) != null ? _deps_readTarget : readAiEditTarget;
@@ -607,17 +763,8 @@ export const runAiJobEditStep = createAiJobEditStep();
607
763
  inputs,
608
764
  jobId: null
609
765
  });
610
- if ('error' in read) return read;
611
- const placement = aiEditPlacement({
612
- versioning: checkEntitlement(context.org, 'versioning'),
613
- served: read.served,
614
- sourceIsPointer: read.sourceVersionId === read.pointerVersionId,
615
- sourceScheduled: read.scheduledVersionId === read.sourceVersionId
616
- });
617
- return placement === 'refused' ? {
618
- status: 403,
619
- error: AI_EDIT_NEEDS_VERSIONING_COPY
620
- } : null;
766
+ // Every plan has somewhere to put the change: the page's draft (AGL-4116).
767
+ return 'error' in read ? read : null;
621
768
  };
622
769
  }
623
770
  export const aiEditJobAdmission = createAiEditJobAdmission();