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

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 (57) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/ai-job-links.d.ts +15 -2
  3. package/src/lib/components/ai-job-links.js +24 -3
  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-jobs-drawer.component.d.ts +7 -1
  11. package/src/lib/components/assist-jobs-drawer.component.js +20 -6
  12. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  13. package/src/lib/components/assist-page-change-card.component.d.ts +44 -0
  14. package/src/lib/components/assist-page-change-card.component.js +166 -0
  15. package/src/lib/components/assist-page-change-card.component.js.map +1 -0
  16. package/src/lib/components/assist-panel.component.d.ts +105 -1
  17. package/src/lib/components/assist-panel.component.js +224 -29
  18. package/src/lib/components/assist-panel.component.js.map +1 -1
  19. package/src/lib/jobs/ai-job-edit-step.d.ts +62 -16
  20. package/src/lib/jobs/ai-job-edit-step.js +172 -61
  21. package/src/lib/jobs/ai-job-edit-step.js.map +1 -1
  22. package/src/lib/jobs/ai-job-navigation-step.js +4 -2
  23. package/src/lib/jobs/ai-job-navigation-step.js.map +1 -1
  24. package/src/lib/model/ai-edit-job.d.ts +33 -16
  25. package/src/lib/model/ai-edit-job.js +50 -34
  26. package/src/lib/model/ai-edit-job.js.map +1 -1
  27. package/src/lib/model/ai-job-activity.d.ts +6 -11
  28. package/src/lib/model/ai-job-activity.js +8 -15
  29. package/src/lib/model/ai-job-activity.js.map +1 -1
  30. package/src/lib/model/ai-jobs.types.d.ts +6 -0
  31. package/src/lib/model/ai-jobs.types.js.map +1 -1
  32. package/src/lib/model/ai-site-inventory.d.ts +6 -0
  33. package/src/lib/model/ai-site-inventory.js.map +1 -1
  34. package/src/lib/model/assist-edit.d.ts +18 -1
  35. package/src/lib/model/assist-edit.js +7 -0
  36. package/src/lib/model/assist-edit.js.map +1 -1
  37. package/src/lib/model/assist-page-change.d.ts +86 -0
  38. package/src/lib/model/assist-page-change.js +86 -0
  39. package/src/lib/model/assist-page-change.js.map +1 -0
  40. package/src/lib/runtime/ai-eval.js +3 -1
  41. package/src/lib/runtime/ai-eval.js.map +1 -1
  42. package/src/lib/runtime/ai-node-tree.d.ts +5 -0
  43. package/src/lib/runtime/ai-node-tree.js +18 -3
  44. package/src/lib/runtime/ai-node-tree.js.map +1 -1
  45. package/src/lib/runtime/site-inventory.d.ts +8 -1
  46. package/src/lib/runtime/site-inventory.js +18 -0
  47. package/src/lib/runtime/site-inventory.js.map +1 -1
  48. package/src/lib/server/assist-build.js +2 -0
  49. package/src/lib/server/assist-build.js.map +1 -1
  50. package/src/lib/server/assist-chat.js +51 -2
  51. package/src/lib/server/assist-chat.js.map +1 -1
  52. package/src/lib/server/assist-edit.d.ts +54 -2
  53. package/src/lib/server/assist-edit.js +219 -30
  54. package/src/lib/server/assist-edit.js.map +1 -1
  55. package/src/lib/server/assist-page-change.d.ts +62 -0
  56. package/src/lib/server/assist-page-change.js +140 -0
  57. package/src/lib/server/assist-page-change.js.map +1 -0
@@ -23,6 +23,8 @@ import type { AiJob, AiJobOutput } from '../model/ai-jobs.types';
23
23
  import { type AssistEditCanvasContext, type AssistEditOp, type AssistEditProposal, type AssistEditTarget } from '../model/assist-edit';
24
24
  import { type AiCustomGenerationInput } from '../runtime/ai-doctrine';
25
25
  import type { AiSystemBlock } from '../runtime/ai-runtime';
26
+ import { readAiSiteDatasets } from '../runtime/site-inventory';
27
+ import { type AssistEditDataset, type AssistEditDatasetScope } from '../server/assist-edit';
26
28
  import { type AiJobAdmission } from './ai-job-admission';
27
29
  import { type AiJobStepRunner } from './ai-job-text-step';
28
30
  /**
@@ -39,13 +41,19 @@ import { type AiJobStepRunner } from './ai-job-text-step';
39
41
  *
40
42
  * ── Where the change lands, decided before anything is spent ────────────
41
43
  *
42
- * Never on a version a visitor sees. With version history, a NEW version
43
- * beside the one it started from — a copy of that version's stored fields with
44
- * the changed nodes — which becomes the page's current version only when the
45
- * page is not published; a published page's current version, and a layout's,
46
- * never move. Without it, in place, on a version no visitor reaches: an
47
- * unpublished page's, or one that is not the current version and no schedule
48
- * will publish. Anything else is refused as a plan limit, charged nothing.
44
+ * Never on a version a visitor sees. By default, on every plan, in the
45
+ * WORKING DRAFT of the version it started from (AGL-4116): the one document
46
+ * the Besigner saves to as an author works and offers with Open draft, which
47
+ * publishing moves into the version. The change is built on the draft the
48
+ * author already has, when the Besigner would still offer it, so their work
49
+ * in progress is never thrown away; otherwise on the version's own tree.
50
+ *
51
+ * Only when the request asks for a new version, and the plan keeps version
52
+ * history, a NEW version beside the one it started from — a copy of that
53
+ * version's stored fields with the changed nodes — which becomes the page's
54
+ * current version only when the page is not published; a published page's
55
+ * current version, and a layout's, never move. Asked for without version
56
+ * history, the change goes into the draft and the output says why.
49
57
  *
50
58
  * ── What a job cannot do that the editor can ────────────────────────────
51
59
  *
@@ -59,8 +67,8 @@ import { type AiJobStepRunner } from './ai-job-text-step';
59
67
  *
60
68
  * The new version is named by the job's id — a build's unit is named by its
61
69
  * plan item's id — so a run asked again finds what it wrote and spends
62
- * nothing. A change made in place stamps the version with the job's id for
63
- * the same reason.
70
+ * nothing. A draft the job writes is stamped with the job's id for the same
71
+ * reason.
64
72
  */
65
73
  /** The longest the step may take reading the page and writing it. */
66
74
  export declare const AI_EDIT_READS_MS = 2000;
@@ -76,11 +84,22 @@ export declare function aiJobEditModel(): string;
76
84
  export declare const AI_JOB_EDIT_RULES: AiSystemBlock;
77
85
  /** The system blocks for one kind of document. */
78
86
  export declare function aiJobEditInstructions(kind: AiEditTargetKind): AiSystemBlock[];
79
- /** What the change is asked from: the document's outline, then the request. */
87
+ /**
88
+ * What the change is asked from: the document's outline, the site's datasets
89
+ * an element may show the records of (AGL-3616), then the request.
90
+ */
80
91
  export declare function aiJobEditPrompt(input: {
81
92
  brief: string;
82
93
  context: AssistEditCanvasContext;
94
+ datasets?: readonly AssistEditDataset[];
83
95
  }): string;
96
+ /**
97
+ * The datasets an edit to this document knows (AGL-3616): every one the site
98
+ * holds, and the one whose record page the document is, if it is one.
99
+ */
100
+ export declare function aiEditDatasetScope(datasets: ReadonlyArray<AssistEditDataset & {
101
+ recordScreenId?: string;
102
+ }>, read: Pick<AiEditTargetRead, 'kind' | 'id'>): Required<AssistEditDatasetScope>;
84
103
  /** The edit as the doctrine's loop runs it: the rung's tool, held to the rung's check. */
85
104
  export declare function aiJobEditGeneration(request: {
86
105
  brief: string;
@@ -88,7 +107,7 @@ export declare function aiJobEditGeneration(request: {
88
107
  target: AssistEditTarget;
89
108
  model: string;
90
109
  signal?: AbortSignal;
91
- }): AiCustomGenerationInput<AssistEditProposal>;
110
+ } & AssistEditDatasetScope): AiCustomGenerationInput<AssistEditProposal>;
92
111
  /** A canvas over a stored node map, with the editor's own guarded mutators and no editor. */
93
112
  export declare function aiEditCanvas(nodes: NodesMap): CanvasManager;
94
113
  /**
@@ -115,7 +134,16 @@ export interface AiEditTargetRead {
115
134
  pointerVersionId: string | null;
116
135
  /** The version the change starts from. */
117
136
  sourceVersionId: string;
137
+ /** The version's own tree. */
118
138
  nodes: NodesMap;
139
+ /**
140
+ * The version's working draft, when it has one the Besigner would still
141
+ * offer: an author's saved work in progress, which a change to the draft
142
+ * is built on. Null when there is none, or the version was saved since.
143
+ */
144
+ draft: {
145
+ nodes: NodesMap;
146
+ } | null;
119
147
  /** Whether visitors see the document's current version: a published page, a collection template, any layout. */
120
148
  served: boolean;
121
149
  /** The version a pending publish schedule will make current, if any. */
@@ -152,14 +180,29 @@ export declare function aiEditPlacement(input: {
152
180
  sourceIsPointer: boolean;
153
181
  sourceScheduled: boolean;
154
182
  }): AiEditPlacement | 'refused';
183
+ /**
184
+ * Where an edit job's change lands (AGL-4116): the version's working draft,
185
+ * unless the request asked for a new version and the plan keeps version
186
+ * history — then where {@link aiEditPlacement} puts a new one. Never refused:
187
+ * a draft is on every plan, and no visitor sees it.
188
+ */
189
+ export declare function aiEditJobPlacement(input: {
190
+ newVersion: boolean;
191
+ versioning: boolean;
192
+ served: boolean;
193
+ sourceIsPointer: boolean;
194
+ sourceScheduled: boolean;
195
+ }): AiEditPlacement;
155
196
  export interface AiEditWriteInput {
156
197
  hostId: string;
157
198
  kind: AiEditTargetKind;
158
199
  id: string;
159
200
  sourceVersionId: string;
160
- /** The new version's id, which is the job's; also the stamp a change in place carries. */
201
+ /** The new version's id, which is the job's; also the stamp a draft it writes carries. */
161
202
  jobVersionId: string;
162
203
  versioning: boolean;
204
+ /** The request asked for a new version (see {@link aiEditJobPlacement}). */
205
+ newVersion: boolean;
163
206
  proposal: AssistEditProposal;
164
207
  /** Whether the page's search fields may be written: a page no visitor reaches. */
165
208
  writeSeo: boolean;
@@ -189,12 +232,15 @@ export declare function writeAiEdit(firestore: Firestore, input: AiEditWriteInpu
189
232
  export interface AiJobEditStepDeps {
190
233
  readTarget?: typeof readAiEditTarget;
191
234
  writeEdit?: typeof writeAiEdit;
235
+ readDatasets?: typeof readAiSiteDatasets;
192
236
  }
193
237
  /** What an edit wrote, as the job's output: a link into the Besigner on the version written. */
194
238
  export declare function aiEditOutput(read: Pick<AiEditTargetRead, 'kind' | 'id' | 'name' | 'hostSubdomain'>, hostId: string, written: {
195
239
  versionId: string;
196
240
  placement: AiEditPlacement;
197
- }, leftOut: readonly string[]): AiJobOutput;
241
+ }, leftOut: readonly string[], options?: {
242
+ versionsUnavailable?: boolean;
243
+ }): AiJobOutput;
198
244
  /** The new version's id: the job's own, which a build's unit takes from its plan item. */
199
245
  export declare function aiEditVersionId(job: Pick<AiJob, '$id'>): string;
200
246
  /**
@@ -214,9 +260,9 @@ export interface AiEditJobAdmissionDeps {
214
260
  ownerOf?: typeof resolveOrgIdForHost;
215
261
  }
216
262
  /**
217
- * An edit job is admitted for a page or layout of a site of its own org,
218
- * and only where the change has somewhere to land that no visitor sees on
219
- * this workspace's plan — refused in the plan's words before anything exists.
263
+ * An edit job is admitted for a page or layout of a site of its own org that
264
+ * has a version to start from. Every plan has somewhere to put the change
265
+ * that no visitor sees — the version's draft — so no plan is refused.
220
266
  */
221
267
  export declare function createAiEditJobAdmission(deps?: AiEditJobAdmissionDeps): AiJobAdmission;
222
268
  export declare const aiEditJobAdmission: AiJobAdmission;
@@ -17,22 +17,25 @@ 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 { 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";
23
25
  import { resolveOrgIdForHost } from "@aglyn/tenant-data-admin/server/organizations";
24
26
  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";
27
+ 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
28
  import { ASSIST_EDIT_TOOL_NAME } from "../model/assist-edit.js";
27
29
  import { AI_STEP_TIERS } from "../providers/catalog.js";
28
30
  import { AI_ROUTING_TABLE, aiModelForStep } from "../providers/routing.js";
29
31
  import { runValidatedGeneration, validateStreamedGeneration } from "../runtime/ai-doctrine.js";
30
32
  import { AI_PALETTE } from "../runtime/ai-palette.generated.js";
31
- import { assistEditTool, checkAssistEditAnswer, editCanvasBlock, editSelectionBlock, parseAssistEditContext } from "../server/assist-edit.js";
33
+ import { readAiSiteDatasets } from "../runtime/site-inventory.js";
34
+ import { assistEditTool, checkAssistEditAnswer, editCanvasBlock, editDatasetsBlock, editSelectionBlock, parseAssistEditContext } from "../server/assist-edit.js";
32
35
  import { registerAiJobAdmission } from "./ai-job-admission.js";
33
36
  import { aiJobStepBudget } from "./ai-job-budget.js";
34
37
  import { aiOriginJobId } from "./ai-job-draft-ids.js";
35
- import { aiGenerationSpent, aiLimitReview, aiUnspentOutcome } from "./ai-job-generation.js";
38
+ import { aiGenerationSpent, aiUnspentOutcome } from "./ai-job-generation.js";
36
39
  import { AI_JOB_BRIEF_MAX_CHARS } from "./ai-job-text-step.js";
37
40
  import { registerAiJobStep } from "./ai-jobs.js";
38
41
  /**
@@ -49,13 +52,19 @@ import { registerAiJobStep } from "./ai-jobs.js";
49
52
  *
50
53
  * ── Where the change lands, decided before anything is spent ────────────
51
54
  *
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.
55
+ * Never on a version a visitor sees. By default, on every plan, in the
56
+ * WORKING DRAFT of the version it started from (AGL-4116): the one document
57
+ * the Besigner saves to as an author works and offers with Open draft, which
58
+ * publishing moves into the version. The change is built on the draft the
59
+ * author already has, when the Besigner would still offer it, so their work
60
+ * in progress is never thrown away; otherwise on the version's own tree.
61
+ *
62
+ * Only when the request asks for a new version, and the plan keeps version
63
+ * history, a NEW version beside the one it started from — a copy of that
64
+ * version's stored fields with the changed nodes — which becomes the page's
65
+ * current version only when the page is not published; a published page's
66
+ * current version, and a layout's, never move. Asked for without version
67
+ * history, the change goes into the draft and the output says why.
59
68
  *
60
69
  * ── What a job cannot do that the editor can ────────────────────────────
61
70
  *
@@ -69,8 +78,8 @@ import { registerAiJobStep } from "./ai-jobs.js";
69
78
  *
70
79
  * The new version is named by the job's id — a build's unit is named by its
71
80
  * 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.
81
+ * nothing. A draft the job writes is stamped with the job's id for the same
82
+ * reason.
74
83
  */ /** The longest the step may take reading the page and writing it. */ export const AI_EDIT_READS_MS = 2000;
75
84
  /** 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
85
  tier: AI_STEP_TIERS['job.edit'],
@@ -89,8 +98,8 @@ export function aiJobEditModel() {
89
98
  */ export const AI_JOB_EDIT_RULES = {
90
99
  text: [
91
100
  '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.',
101
+ `- 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.`,
102
+ '- 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
103
  `- If the outline does not show an element the request is about, change nothing for that part: never guess at an element.`,
95
104
  `- 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
105
  "- Fill in a page's search title or description only when the request asks for it.",
@@ -107,13 +116,41 @@ export function aiJobEditModel() {
107
116
  }
108
117
  ];
109
118
  }
110
- /** What the change is asked from: the document's outline, then the request. */ export function aiJobEditPrompt(input) {
119
+ /**
120
+ * What the change is asked from: the document's outline, the site's datasets
121
+ * an element may show the records of (AGL-3616), then the request.
122
+ */ export function aiJobEditPrompt(input) {
123
+ var _input_datasets;
124
+ const datasets = editDatasetsBlock((_input_datasets = input.datasets) != null ? _input_datasets : []);
111
125
  return [
112
126
  editSelectionBlock(input.context),
127
+ ...datasets ? [
128
+ '',
129
+ datasets
130
+ ] : [],
113
131
  '',
114
132
  `Request: ${input.brief.slice(0, AI_JOB_BRIEF_MAX_CHARS)}`
115
133
  ].join('\n');
116
134
  }
135
+ /**
136
+ * The datasets an edit to this document knows (AGL-3616): every one the site
137
+ * holds, and the one whose record page the document is, if it is one.
138
+ */ export function aiEditDatasetScope(datasets, read) {
139
+ var _ref;
140
+ const record = read.kind === 'screen' ? datasets.find((dataset)=>dataset.recordScreenId === read.id) : undefined;
141
+ return {
142
+ datasets: datasets.map(({ id, name, fields, fieldIds, imageField })=>_extends({
143
+ id,
144
+ name,
145
+ fields
146
+ }, fieldIds ? {
147
+ fieldIds
148
+ } : {}, imageField ? {
149
+ imageField
150
+ } : {})),
151
+ recordDatasetId: (_ref = record == null ? void 0 : record.id) != null ? _ref : null
152
+ };
153
+ }
117
154
  /** The edit as the doctrine's loop runs it: the rung's tool, held to the rung's check. */ export function aiJobEditGeneration(request) {
118
155
  const row = AI_ROUTING_TABLE['job.edit'];
119
156
  const kind = request.target.kind;
@@ -136,10 +173,14 @@ export function aiJobEditModel() {
136
173
  } : {}, request.signal ? {
137
174
  signal: request.signal
138
175
  } : {}, {
139
- check: (answer)=>checkAssistEditAnswer(answer, {
176
+ check: (answer)=>checkAssistEditAnswer(answer, _extends({
140
177
  context: request.context,
141
178
  target: request.target
142
- })
179
+ }, request.datasets ? {
180
+ datasets: request.datasets
181
+ } : {}, request.recordDatasetId ? {
182
+ recordDatasetId: request.recordDatasetId
183
+ } : {}))
143
184
  });
144
185
  }
145
186
  // ── The document, headless ────────────────────────────────────────────────
@@ -214,6 +255,25 @@ function pendingScheduleVersion(doc) {
214
255
  if (!schedule || schedule['status'] !== 'pending' || schedule['action'] === 'unpublish') return null;
215
256
  return text(schedule['versionId']) || null;
216
257
  }
258
+ /**
259
+ * A version's working draft as the Besigner would offer it: a tree, taken
260
+ * against the version as it is stored now. A draft the version has been
261
+ * saved past is not offered there (it would undo that save), so a change is
262
+ * not built on it here either. `aiEditJobId` names the job that wrote it.
263
+ */ function usableDraft(draft, version) {
264
+ if (!draft.exists) return null;
265
+ const nodes = decodeStoredNodes(draft.get('nodes'));
266
+ if (!nodes || !Object.keys(nodes).length) return null;
267
+ const baseStamp = typeof draft.get('baseStamp') === 'string' ? draft.get('baseStamp') : null;
268
+ if (hasConcurrentWrite(baseStamp, versionStamp(version.get('updatedAt')))) return null;
269
+ return {
270
+ nodes,
271
+ aiEditJobId: text(draft.get('aiEditJobId')) || null
272
+ };
273
+ }
274
+ /** A version's working draft document: the Besigner's, at the core's one address. */ function draftRef(versions, versionId) {
275
+ return versions.doc(versionId).collection(VERSION_DRAFT_COLLECTION).doc(VERSION_DRAFT_DOC_ID);
276
+ }
217
277
  /**
218
278
  * The document an edit job changes, on the job's own site: the page or
219
279
  * layout named, not deleted, not an email design, and the version it starts
@@ -265,6 +325,7 @@ function pendingScheduleVersion(doc) {
265
325
  return _extends({}, base, {
266
326
  sourceVersionId: sourceVersionId != null ? sourceVersionId : input.jobId,
267
327
  nodes: {},
328
+ draft: null,
268
329
  written: {
269
330
  versionId: input.jobId,
270
331
  placement
@@ -275,7 +336,10 @@ function pendingScheduleVersion(doc) {
275
336
  status: 404,
276
337
  error: AI_EDIT_VERSION_GONE_COPY
277
338
  };
278
- const source = await versions.doc(sourceVersionId).get();
339
+ const [source, draftDoc] = await Promise.all([
340
+ versions.doc(sourceVersionId).get(),
341
+ draftRef(versions, sourceVersionId).get()
342
+ ]);
279
343
  if (!source.exists) return {
280
344
  status: 404,
281
345
  error: AI_EDIT_VERSION_GONE_COPY
@@ -284,12 +348,25 @@ function pendingScheduleVersion(doc) {
284
348
  return _extends({}, base, {
285
349
  sourceVersionId,
286
350
  nodes: {},
351
+ draft: null,
287
352
  written: {
288
353
  versionId: sourceVersionId,
289
354
  placement: 'in-place'
290
355
  }
291
356
  });
292
357
  }
358
+ const draft = usableDraft(draftDoc, source);
359
+ if (input.jobId && (draft == null ? void 0 : draft.aiEditJobId) === input.jobId) {
360
+ return _extends({}, base, {
361
+ sourceVersionId,
362
+ nodes: {},
363
+ draft: null,
364
+ written: {
365
+ versionId: sourceVersionId,
366
+ placement: 'draft'
367
+ }
368
+ });
369
+ }
293
370
  const nodes = decodeStoredNodes(source.get('nodes'));
294
371
  if (!nodes || !Object.keys(nodes).length) return {
295
372
  status: 400,
@@ -298,6 +375,9 @@ function pendingScheduleVersion(doc) {
298
375
  return _extends({}, base, {
299
376
  sourceVersionId,
300
377
  nodes,
378
+ draft: draft ? {
379
+ nodes: draft.nodes
380
+ } : null,
301
381
  written: null
302
382
  });
303
383
  }
@@ -314,6 +394,16 @@ function pendingScheduleVersion(doc) {
314
394
  const seen = input.sourceScheduled || input.sourceIsPointer && input.served;
315
395
  return seen ? 'refused' : 'in-place';
316
396
  }
397
+ /**
398
+ * Where an edit job's change lands (AGL-4116): the version's working draft,
399
+ * unless the request asked for a new version and the plan keeps version
400
+ * history — then where {@link aiEditPlacement} puts a new one. Never refused:
401
+ * a draft is on every plan, and no visitor sees it.
402
+ */ export function aiEditJobPlacement(input) {
403
+ if (!input.newVersion || !input.versioning) return 'draft';
404
+ const placed = aiEditPlacement(input);
405
+ return placed === 'refused' ? 'draft' : placed;
406
+ }
317
407
  /**
318
408
  * The change written, in one transaction that reads the document as it is
319
409
  * stored NOW — a member may have saved, published or changed plan's history
@@ -326,12 +416,15 @@ function pendingScheduleVersion(doc) {
326
416
  const versions = docRef.collection('versions');
327
417
  const sourceRef = versions.doc(input.sourceVersionId);
328
418
  const newRef = versions.doc(input.jobVersionId);
419
+ const sourceDraftRef = draftRef(versions, input.sourceVersionId);
329
420
  return firestore.runTransaction(async (tx)=>{
330
- const [host, doc, source, already] = await Promise.all([
421
+ var _ref;
422
+ const [host, doc, source, already, draftDoc] = await Promise.all([
331
423
  tx.get(hostRef),
332
424
  tx.get(docRef),
333
425
  tx.get(sourceRef),
334
- tx.get(newRef)
426
+ tx.get(newRef),
427
+ tx.get(sourceDraftRef)
335
428
  ]);
336
429
  const pointer = text(doc.get('versionId')) || null;
337
430
  if (already.exists) {
@@ -351,22 +444,30 @@ function pendingScheduleVersion(doc) {
351
444
  error: AI_EDIT_VERSION_GONE_COPY
352
445
  };
353
446
  const served = input.kind === 'layout' || screenServed(doc, host);
354
- const placement = aiEditPlacement({
447
+ const placement = aiEditJobPlacement({
448
+ newVersion: input.newVersion,
355
449
  versioning: input.versioning,
356
450
  served,
357
451
  sourceIsPointer: pointer === input.sourceVersionId,
358
452
  sourceScheduled: pendingScheduleVersion(doc) === input.sourceVersionId
359
453
  });
360
- if (placement === 'refused') return {
361
- ok: false,
362
- error: AI_EDIT_NEEDS_VERSIONING_COPY
363
- };
364
- const nodes = decodeStoredNodes(source.get('nodes'));
454
+ // Read again here, as the version is: an author who saved their draft,
455
+ // or published it, since the read is what the change is built on now.
456
+ const draft = placement === 'draft' ? usableDraft(draftDoc, source) : null;
457
+ if ((draft == null ? void 0 : draft.aiEditJobId) === input.jobVersionId) {
458
+ return {
459
+ ok: true,
460
+ versionId: input.sourceVersionId,
461
+ placement,
462
+ seoWritten: false
463
+ };
464
+ }
465
+ const nodes = (_ref = draft == null ? void 0 : draft.nodes) != null ? _ref : decodeStoredNodes(source.get('nodes'));
365
466
  if (!nodes) return {
366
467
  ok: false,
367
468
  error: AI_EDIT_NO_NODES_COPY
368
469
  };
369
- const versionId = placement === 'in-place' ? input.sourceVersionId : input.jobVersionId;
470
+ const versionId = placement === 'draft' ? input.sourceVersionId : input.jobVersionId;
370
471
  const seo = {};
371
472
  const writeSeo = input.writeSeo && input.kind === 'screen' && !served;
372
473
  const proposal = _extends({}, input.proposal, {
@@ -387,9 +488,14 @@ function pendingScheduleVersion(doc) {
387
488
  };
388
489
  const stored = Buffer.from(packed);
389
490
  const docPatch = {};
390
- if (placement === 'in-place') {
391
- tx.update(sourceRef, {
491
+ if (placement === 'draft') {
492
+ // The whole working tree, as the Besigner writes a draft, taken against
493
+ // the version as stored so the Besigner offers it with Open draft.
494
+ tx.set(sourceDraftRef, {
392
495
  nodes: stored,
496
+ baseStamp: versionStamp(source.get('updatedAt')),
497
+ updatedByUid: input.uid,
498
+ updatedByEmail: null,
393
499
  updatedAt: input.now,
394
500
  aiEditJobId: input.jobVersionId
395
501
  });
@@ -423,20 +529,25 @@ function pendingScheduleVersion(doc) {
423
529
  };
424
530
  });
425
531
  }
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 {
532
+ /** 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 = {}) {
533
+ return _extends({
428
534
  resource: read.kind,
429
535
  id: read.id,
430
536
  versionId: written.versionId,
431
537
  hostId,
432
538
  hostSubdomain: read.hostSubdomain,
433
- label: read.name,
434
- note: aiEditOutputNote({
539
+ label: read.name
540
+ }, written.placement === 'draft' ? {
541
+ inDraft: true
542
+ } : {}, {
543
+ note: aiEditOutputNote(_extends({
435
544
  kind: read.kind,
436
545
  placement: written.placement,
437
546
  leftOut
438
- })
439
- };
547
+ }, options.versionsUnavailable ? {
548
+ versionsUnavailable: true
549
+ } : {}))
550
+ });
440
551
  }
441
552
  /** The new version's id: the job's own, which a build's unit takes from its plan item. */ export function aiEditVersionId(job) {
442
553
  return job.$id.slice(0, 64);
@@ -460,9 +571,10 @@ function pendingScheduleVersion(doc) {
460
571
  };
461
572
  }
462
573
  export function createAiJobEditStep(deps = {}) {
463
- var _deps_readTarget, _deps_writeEdit;
574
+ var _deps_readTarget, _deps_writeEdit, _deps_readDatasets;
464
575
  const readTarget = (_deps_readTarget = deps.readTarget) != null ? _deps_readTarget : readAiEditTarget;
465
576
  const writeEdit = (_deps_writeEdit = deps.writeEdit) != null ? _deps_writeEdit : writeAiEdit;
577
+ const readDatasets = (_deps_readDatasets = deps.readDatasets) != null ? _deps_readDatasets : readAiSiteDatasets;
466
578
  return async ({ job, now, signal, firestore, org, modelFor })=>{
467
579
  var _ref, _data;
468
580
  const model = (_ref = modelFor == null ? void 0 : modelFor('job.edit')) != null ? _ref : aiJobEditModel();
@@ -487,30 +599,34 @@ export function createAiJobEditStep(deps = {}) {
487
599
  });
488
600
  const orgData = org != null ? org : (_data = (await firestore.collection('orgs').doc(job.orgId).get()).data()) != null ? _data : null;
489
601
  const versioning = checkEntitlement(orgData, 'versioning');
490
- const placement = aiEditPlacement({
602
+ const placement = aiEditJobPlacement({
603
+ newVersion: inputs.newVersion,
491
604
  versioning,
492
605
  served: read.served,
493
606
  sourceIsPointer: read.sourceVersionId === read.pointerVersionId,
494
607
  sourceScheduled: read.scheduledVersionId === read.sourceVersionId
495
608
  });
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;
609
+ // A change to the draft is made on the draft the author already has.
610
+ const outline = aiEditJobOutline(placement === 'draft' && read.draft ? read.draft.nodes : read.nodes);
611
+ const context = outline ? parseAssistEditContext(outline, {
612
+ whole: true
613
+ }) : null;
501
614
  if (!context) return unspent(AI_EDIT_NO_NODES_COPY);
502
615
  const target = {
503
616
  kind: read.kind,
504
617
  documentId: read.id,
505
- versionId: placement === 'in-place' ? read.sourceVersionId : aiEditVersionId(job),
618
+ versionId: placement === 'draft' ? read.sourceVersionId : aiEditVersionId(job),
506
619
  hostId
507
620
  };
621
+ // The site's datasets, so a request to show records binds a repeat to a
622
+ // real one instead of writing tokens nothing fills (AGL-3616).
623
+ const datasets = aiEditDatasetScope(await readDatasets(job.orgId, hostId), read);
508
624
  const generation = await runValidatedGeneration('edit', aiJobEditGeneration(_extends({
509
625
  brief: job.brief,
510
626
  context,
511
627
  target,
512
628
  model
513
- }, signal ? {
629
+ }, datasets, signal ? {
514
630
  signal
515
631
  } : {})));
516
632
  const spent = _extends({}, aiGenerationSpent(generation), generation.effort ? {
@@ -527,10 +643,10 @@ export function createAiJobEditStep(deps = {}) {
527
643
  if (!proposal && refused) {
528
644
  const salvaged = validateStreamedGeneration('edit', {
529
645
  answer: refused,
530
- check: (answer)=>checkAssistEditAnswer(answer, {
646
+ check: (answer)=>checkAssistEditAnswer(answer, _extends({
531
647
  context,
532
648
  target
533
- })
649
+ }, datasets))
534
650
  });
535
651
  proposal = salvaged.status === 'ok' ? salvaged.value : null;
536
652
  }
@@ -557,6 +673,7 @@ export function createAiJobEditStep(deps = {}) {
557
673
  sourceVersionId: read.sourceVersionId,
558
674
  jobVersionId: aiEditVersionId(job),
559
675
  versioning,
676
+ newVersion: inputs.newVersion,
560
677
  proposal: _extends({}, proposal, {
561
678
  ops: kept.ops
562
679
  }),
@@ -569,18 +686,21 @@ export function createAiJobEditStep(deps = {}) {
569
686
  if (write.ok === false) return _extends({}, spent, {
570
687
  failure: write.error
571
688
  });
689
+ const versionsUnavailable = inputs.newVersion && !versioning;
572
690
  return _extends({}, spent, {
573
691
  outputs: [
574
- aiEditOutput(read, hostId, write, leftOut)
692
+ aiEditOutput(read, hostId, write, leftOut, {
693
+ versionsUnavailable
694
+ })
575
695
  ]
576
696
  });
577
697
  };
578
698
  }
579
699
  export const runAiJobEditStep = createAiJobEditStep();
580
700
  /**
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.
701
+ * An edit job is admitted for a page or layout of a site of its own org that
702
+ * has a version to start from. Every plan has somewhere to put the change
703
+ * that no visitor sees — the version's draft — so no plan is refused.
584
704
  */ export function createAiEditJobAdmission(deps = {}) {
585
705
  var _deps_readTarget, _deps_ownerOf;
586
706
  const readTarget = (_deps_readTarget = deps.readTarget) != null ? _deps_readTarget : readAiEditTarget;
@@ -607,17 +727,8 @@ export const runAiJobEditStep = createAiJobEditStep();
607
727
  inputs,
608
728
  jobId: null
609
729
  });
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;
730
+ // Every plan has somewhere to put the change: the page's draft (AGL-4116).
731
+ return 'error' in read ? read : null;
621
732
  };
622
733
  }
623
734
  export const aiEditJobAdmission = createAiEditJobAdmission();