@agent-native/core-corpus 0.212.1 → 0.214.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 (90) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/onboarding-journeys/SKILL.md +29 -14
  3. package/corpus/templates/analytics/CHANGELOG.md +11 -0
  4. package/corpus/templates/analytics/actions/estimate-onboarding-journey-cost.ts +23 -6
  5. package/corpus/templates/analytics/actions/get-onboarding-journey.ts +20 -5
  6. package/corpus/templates/analytics/actions/provider-api-register.ts +2 -7
  7. package/corpus/templates/analytics/changelog/2026-10-21-analytics-recognizes-questions-about-connected-data-product-.md +6 -0
  8. package/corpus/templates/analytics/changelog/2026-10-21-onboarding-journeys-now-honor-larger-event-row-budgets.md +6 -0
  9. package/corpus/templates/analytics/changelog/2026-10-21-preserve-unknown-replay-availability-in-partial-journeys.md +6 -0
  10. package/corpus/templates/analytics/package.json +6 -0
  11. package/corpus/templates/analytics/scripts/journey-capture-plan.ts +18 -7
  12. package/corpus/templates/analytics/scripts/journey-capture.ts +3 -1
  13. package/corpus/templates/analytics/server/lib/db-admin-connections.ts +19 -12
  14. package/corpus/templates/analytics/server/lib/journey-tree.ts +24 -9
  15. package/corpus/templates/analytics/server/lib/onboarding-journey-cost.ts +163 -94
  16. package/corpus/templates/analytics/server/lib/onboarding-journey.ts +162 -74
  17. package/corpus/templates/assets/server/lib/org-admin.ts +20 -16
  18. package/corpus/templates/brain/server/lib/zoom-governance.ts +11 -6
  19. package/corpus/templates/clips/CHANGELOG.md +1 -0
  20. package/corpus/templates/clips/changelog/2026-10-16-rewind-status-says-when-it-remembers-audio.md +6 -0
  21. package/corpus/templates/clips/desktop/src/app.tsx +7 -4
  22. package/corpus/templates/clips/desktop/src/components/RewindCaptureStatus.tsx +25 -0
  23. package/corpus/templates/clips/desktop/src/lib/rewind-status.ts +6 -1
  24. package/corpus/templates/clips/desktop/src/shared/config.ts +3 -0
  25. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2 -0
  26. package/corpus/templates/clips/server/lib/recordings.ts +4 -19
  27. package/corpus/templates/community/qr/package.json +1 -1
  28. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +15 -1
  29. package/corpus/templates/content/CHANGELOG.md +13 -0
  30. package/corpus/templates/content/actions/_content-space-access.ts +95 -6
  31. package/corpus/templates/content/actions/_database-utils.ts +20 -16
  32. package/corpus/templates/content/actions/_database-view-setup.ts +305 -11
  33. package/corpus/templates/content/actions/_github-source-freshness.ts +72 -17
  34. package/corpus/templates/content/actions/_github-source-metadata.ts +63 -17
  35. package/corpus/templates/content/actions/_github-source.ts +141 -77
  36. package/corpus/templates/content/actions/_property-utils.ts +3 -0
  37. package/corpus/templates/content/actions/check-github-connection.ts +15 -0
  38. package/corpus/templates/content/actions/create-github-collection.ts +180 -0
  39. package/corpus/templates/content/actions/disconnect-content-database-source.ts +5 -0
  40. package/corpus/templates/content/actions/link-github-folder.ts +104 -36
  41. package/corpus/templates/content/actions/refresh-content-database-source.ts +37 -4
  42. package/corpus/templates/content/actions/sync-github-folder.ts +23 -5
  43. package/corpus/templates/content/actions/update-content-database-view.ts +18 -2
  44. package/corpus/templates/content/actions/view-screen.ts +12 -0
  45. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +263 -35
  46. package/corpus/templates/content/app/components/editor/database/GitHubLinkDialog.tsx +938 -0
  47. package/corpus/templates/content/app/components/editor/database/GitHubSourcePanel.tsx +228 -0
  48. package/corpus/templates/content/app/components/editor/database/GitHubSourceStatusLine.tsx +561 -0
  49. package/corpus/templates/content/app/components/editor/database/GitHubUnlinkDialog.tsx +96 -0
  50. package/corpus/templates/content/app/components/editor/database/ListItemLayout.tsx +410 -0
  51. package/corpus/templates/content/app/components/editor/database/ListItemRow.tsx +426 -0
  52. package/corpus/templates/content/app/components/editor/database/ListView.tsx +136 -18
  53. package/corpus/templates/content/app/components/editor/database/github-availability.ts +147 -0
  54. package/corpus/templates/content/app/components/editor/database/github-source-text.ts +280 -0
  55. package/corpus/templates/content/app/components/editor/database/navigation-state.ts +3 -0
  56. package/corpus/templates/content/app/components/editor/database/settings.tsx +3 -3
  57. package/corpus/templates/content/app/components/editor/database/view-config.ts +4 -0
  58. package/corpus/templates/content/app/components/layout/Layout.tsx +2 -0
  59. package/corpus/templates/content/app/global.css +13 -0
  60. package/corpus/templates/content/app/hooks/content-action-refresh.ts +6 -0
  61. package/corpus/templates/content/app/hooks/use-content-database.ts +3 -1
  62. package/corpus/templates/content/app/hooks/use-github-folder.ts +199 -0
  63. package/corpus/templates/content/app/i18n-data.ts +186 -1
  64. package/corpus/templates/content/app/lib/github-link-dialog.ts +86 -0
  65. package/corpus/templates/content/changelog/2026-10-18-connected-ai-assistants-can-link-a-new-collection-to-a-github.md +1 -1
  66. package/corpus/templates/content/changelog/2026-10-18-link-a-new-collection-to-a-github-folder-from-its-sources.md +6 -0
  67. package/corpus/templates/content/changelog/2026-10-19-list-rows-can-be-customized.md +6 -0
  68. package/corpus/templates/content/changelog/2026-10-19-list-views-fit-phone-screens.md +6 -0
  69. package/corpus/templates/content/changelog/2026-10-21-organization-favorites-include-documents-shared-with-the-cur.md +6 -0
  70. package/corpus/templates/content/docs/product/capabilities/content.source.sync-policy.md +32 -7
  71. package/corpus/templates/content/docs/product/encyclopedia.md +3 -3
  72. package/corpus/templates/content/docs/product/features/content.feature.trust-your-connected-sources.md +2 -2
  73. package/corpus/templates/content/docs/product/roadmap.md +1 -1
  74. package/corpus/templates/content/parity/matrix.md +42 -42
  75. package/corpus/templates/content/parity/matrix.ts +14 -0
  76. package/corpus/templates/content/server/lib/github-source-client.ts +50 -1
  77. package/corpus/templates/content/shared/api.ts +21 -0
  78. package/corpus/templates/content/shared/database-item-layout.ts +591 -0
  79. package/corpus/templates/content/shared/github-source.ts +68 -0
  80. package/corpus/templates/design/CHANGELOG.md +1 -0
  81. package/corpus/templates/design/actions/connect-assets-mcp.ts +5 -9
  82. package/corpus/templates/design/changelog/2026-10-21-journey-canvases-accept-partial-analytics-samples-when-repla.md +6 -0
  83. package/corpus/templates/design/shared/journey-canvas.ts +6 -4
  84. package/corpus/templates/dispatch/.agents/skills/a2a-protocol/SKILL.md +16 -13
  85. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  86. package/corpus/templates/dispatch/changelog/2026-10-21-customer-ai-credit-questions-route-to-account-health-instead.md +6 -0
  87. package/corpus/templates/factory/server/lib/require-workspace-member.ts +5 -17
  88. package/corpus/templates/mail/server/lib/queued-drafts.ts +9 -4
  89. package/corpus/templates/slides/server/workspace-defaults.ts +12 -15
  90. package/package.json +1 -1
@@ -8,11 +8,30 @@ import { syncPrivateViewIconReferences } from "../server/lib/private-icon-refere
8
8
  import {
9
9
  CONTENT_DATABASE_FILTER_OPERATORS,
10
10
  type ContentDatabaseFilter,
11
+ type ContentDatabaseItemLayout,
11
12
  type ContentDatabaseView,
12
13
  type ContentDatabaseViewConfig,
13
14
  } from "../shared/api.js";
15
+ import {
16
+ CONTENT_DATABASE_ITEM_LAYOUT_PRESET_IDS,
17
+ CONTENT_DATABASE_ITEM_LEADING_KINDS,
18
+ CONTENT_DATABASE_ITEM_TITLE_WEIGHTS,
19
+ MAX_ITEM_LAYOUT_META_PROPERTIES,
20
+ contentDatabaseItemLayoutPresets,
21
+ contentDatabaseItemLayoutReferenceErrors,
22
+ dropDeletedContentDatabaseItemLayoutReferences,
23
+ itemLayoutLeadingCandidates,
24
+ normalizeContentDatabaseItemLayout,
25
+ pruneContentDatabaseItemLayout,
26
+ savableContentDatabaseItemLayout,
27
+ startingContentDatabaseItemLayout,
28
+ } from "../shared/database-item-layout.js";
14
29
  import { contentDatabaseFilterOperatorsForType } from "../shared/database-query.js";
15
- import type { DocumentPropertyType } from "../shared/properties.js";
30
+ import {
31
+ normalizePropertyVisibility,
32
+ parsePropertyOptions,
33
+ type DocumentPropertyType,
34
+ } from "../shared/properties.js";
16
35
  import {
17
36
  runDatabaseSetupMutation,
18
37
  setupError,
@@ -76,6 +95,77 @@ const tablePatchFields = {
76
95
  openPagesIn: z.enum(["preview", "full_page"]).optional(),
77
96
  };
78
97
 
98
+ const itemLayoutPropertyIdSchema = z.string().trim().min(1).max(200);
99
+ const itemLayoutLinesSchema = z.union([
100
+ z.literal(1),
101
+ z.literal(2),
102
+ z.literal(3),
103
+ ]);
104
+ const itemLayoutMetaSchema = z
105
+ .array(itemLayoutPropertyIdSchema)
106
+ .max(MAX_ITEM_LAYOUT_META_PROPERTIES);
107
+
108
+ export const contentDatabaseItemLayoutSchema = z
109
+ .object({
110
+ leading: z
111
+ .object({
112
+ kind: z.enum(CONTENT_DATABASE_ITEM_LEADING_KINDS),
113
+ propertyId: itemLayoutPropertyIdSchema.nullable(),
114
+ })
115
+ .strict(),
116
+ titleWeight: z.enum(CONTENT_DATABASE_ITEM_TITLE_WEIGHTS),
117
+ line2: z
118
+ .object({
119
+ propertyId: itemLayoutPropertyIdSchema.nullable(),
120
+ lines: itemLayoutLinesSchema,
121
+ })
122
+ .strict(),
123
+ line3: itemLayoutMetaSchema,
124
+ trailing: itemLayoutMetaSchema,
125
+ })
126
+ .strict();
127
+
128
+ const itemLayoutPatchSchema = z
129
+ .object({
130
+ leading: z
131
+ .object({
132
+ kind: z.enum(CONTENT_DATABASE_ITEM_LEADING_KINDS),
133
+ propertyId: itemLayoutPropertyIdSchema.nullable().optional(),
134
+ })
135
+ .strict()
136
+ .optional(),
137
+ titleWeight: z.enum(CONTENT_DATABASE_ITEM_TITLE_WEIGHTS).optional(),
138
+ line2: z
139
+ .object({
140
+ propertyId: itemLayoutPropertyIdSchema.nullable().optional(),
141
+ lines: itemLayoutLinesSchema.optional(),
142
+ })
143
+ .strict()
144
+ .optional(),
145
+ line3: itemLayoutMetaSchema.optional(),
146
+ trailing: itemLayoutMetaSchema.optional(),
147
+ })
148
+ .strict()
149
+ .describe(
150
+ "List views only. Slots to change in each row; omitted slots keep their saved value. leading.kind checkbox or status uses the first property of that type unless propertyId names one. line2 shows a text, URL, or Blocks property; line3 and trailing list property IDs in order. Trailing values move under the title on narrow screens. Pass null to restore the default rows.",
151
+ );
152
+
153
+ const listPatchFields = {
154
+ itemLayout: itemLayoutPatchSchema.nullable().optional(),
155
+ itemLayoutPreset: z
156
+ .enum(CONTENT_DATABASE_ITEM_LAYOUT_PRESET_IDS)
157
+ .optional()
158
+ .describe(
159
+ "List views only. Starts the rows from a preset before itemLayout applies: checklist (checkbox, bold title, two lines of text), reminders (checkbox, one line of text, date, person and tag under it), tracker (status dot, tag, person and date on the right), digest (page icon, bold title, three lines of text, details under it), or default.",
160
+ ),
161
+ };
162
+
163
+ const LIST_VIEW_PATCH_KEYS = new Set([
164
+ "name",
165
+ "icon",
166
+ ...Object.keys(listPatchFields),
167
+ ]);
168
+
79
169
  const boundedTablePatchSchema = z
80
170
  .object(tablePatchFields)
81
171
  .strict()
@@ -91,11 +181,12 @@ const boundedTablePatchSchema = z
91
181
  }
92
182
  });
93
183
 
94
- const updateTablePatchSchema = z
184
+ const updateViewPatchSchema = z
95
185
  .object({
96
186
  name: z.string().trim().min(1).max(500).optional(),
97
187
  icon: iconValueSchema.nullable().optional(),
98
188
  ...tablePatchFields,
189
+ ...listPatchFields,
99
190
  })
100
191
  .strict()
101
192
  .refine(
@@ -125,7 +216,7 @@ export const updateContentDatabaseViewAgentSchema = z.discriminatedUnion(
125
216
  expectedConfigurationRevision: z.string().min(1),
126
217
  operation: z.literal("update"),
127
218
  viewId: z.string().trim().min(1).max(200),
128
- patch: updateTablePatchSchema,
219
+ patch: updateViewPatchSchema,
129
220
  })
130
221
  .strict(),
131
222
  ],
@@ -147,7 +238,7 @@ function uniqueReferences(values: readonly string[], field: string) {
147
238
 
148
239
  function validateTablePatch(
149
240
  patch:
150
- | z.infer<typeof updateTablePatchSchema>
241
+ | z.infer<typeof updateViewPatchSchema>
151
242
  | z.infer<typeof boundedTablePatchSchema>,
152
243
  definitions: readonly (typeof schema.documentPropertyDefinitions.$inferSelect)[],
153
244
  ) {
@@ -228,6 +319,174 @@ function validateTablePatch(
228
319
  }
229
320
  }
230
321
 
322
+ type PropertyDefinitionRow =
323
+ typeof schema.documentPropertyDefinitions.$inferSelect;
324
+
325
+ function itemLayoutProperties(definitions: readonly PropertyDefinitionRow[]) {
326
+ return definitions.map((definition) => ({
327
+ definition: {
328
+ id: definition.id,
329
+ type: definition.type as DocumentPropertyType,
330
+ options: parsePropertyOptions(definition.optionsJson),
331
+ visibility: normalizePropertyVisibility(definition.visibility),
332
+ },
333
+ }));
334
+ }
335
+
336
+ /**
337
+ * Checks the layout a full-view save carries against the view's saved one:
338
+ * new slot references the rows can't show are dropped, saved ones stay.
339
+ */
340
+ export function withSavableItemLayout<
341
+ T extends { itemLayout?: unknown; hiddenPropertyIds?: unknown },
342
+ >(
343
+ view: T,
344
+ savedLayout: unknown,
345
+ definitions: readonly PropertyDefinitionRow[],
346
+ ): T {
347
+ const layout = normalizeContentDatabaseItemLayout(view.itemLayout);
348
+ return layout
349
+ ? {
350
+ ...view,
351
+ itemLayout: savableContentDatabaseItemLayout(
352
+ layout,
353
+ normalizeContentDatabaseItemLayout(savedLayout),
354
+ itemLayoutProperties(definitions),
355
+ Array.isArray(view.hiddenPropertyIds) ? view.hiddenPropertyIds : [],
356
+ ),
357
+ }
358
+ : view;
359
+ }
360
+
361
+ /**
362
+ * A view's properties in the order its UI lists them, so presets and slot
363
+ * defaults pick the same first checkbox, status or text field the UI does.
364
+ */
365
+ function viewOrderedDefinitions(
366
+ view: ContentDatabaseView,
367
+ definitions: readonly PropertyDefinitionRow[],
368
+ ) {
369
+ const order = view.propertyOrderIds ?? [];
370
+ const rank = (id: string) => {
371
+ const index = order.indexOf(id);
372
+ return index < 0 ? order.length : index;
373
+ };
374
+ return [...definitions].sort(
375
+ (a, b) => rank(a.id) - rank(b.id) || a.position - b.position,
376
+ );
377
+ }
378
+
379
+ /**
380
+ * The default rows' properties as the view's settings define them: those not
381
+ * hidden, in its order. The rows also leave out a "hide when empty" property
382
+ * that none of the loaded rows fills; the server doesn't see which rows the
383
+ * view loads, so it keeps those, and their blank values take no space.
384
+ */
385
+ function defaultListRowProperties(
386
+ view: ContentDatabaseView,
387
+ definitions: readonly PropertyDefinitionRow[],
388
+ ) {
389
+ const hidden = new Set(view.hiddenPropertyIds ?? []);
390
+ return itemLayoutProperties(
391
+ viewOrderedDefinitions(view, definitions).filter(
392
+ (definition) =>
393
+ definition.visibility !== "always_hide" && !hidden.has(definition.id),
394
+ ),
395
+ );
396
+ }
397
+
398
+ /**
399
+ * Applies an agent's item layout patch to a list view. Returns undefined
400
+ * when the patch leaves the layout alone and null to restore default rows.
401
+ */
402
+ function nextListItemLayout(
403
+ patch: z.infer<typeof updateViewPatchSchema>,
404
+ view: ContentDatabaseView,
405
+ definitions: readonly PropertyDefinitionRow[],
406
+ ): ContentDatabaseItemLayout | null | undefined {
407
+ const { itemLayout, itemLayoutPreset } = patch;
408
+ if (itemLayout === undefined && itemLayoutPreset === undefined) {
409
+ return undefined;
410
+ }
411
+ if (itemLayout === null && itemLayoutPreset !== undefined) {
412
+ setupError(
413
+ "INVALID_VIEW_REFERENCE",
414
+ "Pass itemLayoutPreset or itemLayout: null, not both.",
415
+ 400,
416
+ );
417
+ }
418
+ if (itemLayout === null) return null;
419
+ const allProperties = itemLayoutProperties(
420
+ viewOrderedDefinitions(view, definitions),
421
+ );
422
+ // Properties hidden in this view can't be put in a slot, as in the UI.
423
+ const hidden = new Set(view.hiddenPropertyIds ?? []);
424
+ const properties = allProperties.filter(
425
+ (property) => !hidden.has(property.definition.id),
426
+ );
427
+ let base = view.itemLayout ?? null;
428
+ if (itemLayoutPreset !== undefined) {
429
+ const preset = contentDatabaseItemLayoutPresets(properties).find(
430
+ (candidate) => candidate.id === itemLayoutPreset,
431
+ )!;
432
+ if (preset.missingPropertyType) {
433
+ setupError(
434
+ "INVALID_VIEW_REFERENCE",
435
+ `The ${preset.id} preset needs a ${preset.missingPropertyType} property.`,
436
+ 400,
437
+ );
438
+ }
439
+ base = preset.layout;
440
+ }
441
+ if (!itemLayout) {
442
+ return (
443
+ base &&
444
+ dropDeletedContentDatabaseItemLayoutReferences(base, allProperties)
445
+ );
446
+ }
447
+ base ??= startingContentDatabaseItemLayout(
448
+ defaultListRowProperties(view, definitions),
449
+ );
450
+ for (const slot of ["line3", "trailing"] as const) {
451
+ const ids = itemLayout[slot];
452
+ if (ids) uniqueReferences(ids, slot);
453
+ }
454
+ const leading = itemLayout.leading && {
455
+ kind: itemLayout.leading.kind,
456
+ propertyId:
457
+ itemLayout.leading.propertyId ??
458
+ itemLayoutLeadingCandidates(properties, itemLayout.leading.kind)[0]
459
+ ?.definition.id ??
460
+ null,
461
+ };
462
+ const withPatch = (from: ContentDatabaseItemLayout) =>
463
+ normalizeContentDatabaseItemLayout({
464
+ leading: leading ?? from.leading,
465
+ titleWeight: itemLayout.titleWeight ?? from.titleWeight,
466
+ line2: {
467
+ propertyId:
468
+ itemLayout.line2?.propertyId !== undefined
469
+ ? itemLayout.line2.propertyId
470
+ : from.line2.propertyId,
471
+ lines: itemLayout.line2?.lines ?? from.line2.lines,
472
+ },
473
+ line3: itemLayout.line3 ?? from.line3,
474
+ trailing: itemLayout.trailing ?? from.trailing,
475
+ })!;
476
+ // Only the slots this patch names are checked. Saved slots that name a
477
+ // hidden property keep it for when it's shown again, and saved slots that
478
+ // name a deleted one drop it; neither fails an unrelated edit.
479
+ const [error] = contentDatabaseItemLayoutReferenceErrors(
480
+ withPatch(pruneContentDatabaseItemLayout(base, properties)),
481
+ properties,
482
+ );
483
+ if (error) setupError("INVALID_VIEW_REFERENCE", error, 400);
484
+ return dropDeletedContentDatabaseItemLayoutReferences(
485
+ withPatch(base),
486
+ allProperties,
487
+ );
488
+ }
489
+
231
490
  function parseRawConfig(value: string | null) {
232
491
  if (!value) return {} as Record<string, unknown>;
233
492
  try {
@@ -373,14 +632,43 @@ export async function runUpdateContentDatabaseView(
373
632
  404,
374
633
  );
375
634
  }
376
- if (existing.type !== "table") {
635
+ const patchKeys = Object.keys(input.patch);
636
+ let patch: Record<string, unknown> = input.patch;
637
+ if (existing.type === "list") {
638
+ const unsupported = patchKeys.find(
639
+ (key) => !LIST_VIEW_PATCH_KEYS.has(key),
640
+ );
641
+ if (unsupported) {
642
+ setupError(
643
+ "UNSUPPORTED_SCHEMA_CHANGE",
644
+ `List views accept only name, icon, itemLayout, and itemLayoutPreset here, not ${unsupported}.`,
645
+ 400,
646
+ );
647
+ }
648
+ const { itemLayoutPreset: _preset, ...rest } = input.patch;
649
+ const itemLayout = nextListItemLayout(
650
+ input.patch,
651
+ existing,
652
+ context.definitions,
653
+ );
654
+ patch = itemLayout === undefined ? rest : { ...rest, itemLayout };
655
+ } else if (existing.type === "table") {
656
+ const listKey = patchKeys.find((key) => key in listPatchFields);
657
+ if (listKey) {
658
+ setupError(
659
+ "UNSUPPORTED_SCHEMA_CHANGE",
660
+ `${listKey} applies only to list views.`,
661
+ 400,
662
+ );
663
+ }
664
+ validateTablePatch(input.patch, context.definitions);
665
+ } else {
377
666
  setupError(
378
667
  "UNSUPPORTED_SCHEMA_CHANGE",
379
- "Ordinary database setup can update only table views.",
668
+ "Ordinary database setup can update only table and list views.",
380
669
  400,
381
670
  );
382
671
  }
383
- validateTablePatch(input.patch, context.definitions);
384
672
  viewId = existing.id;
385
673
  const index = rawViews.findIndex(
386
674
  (view) =>
@@ -398,7 +686,7 @@ export async function runUpdateContentDatabaseView(
398
686
  nextViews = [...rawViews];
399
687
  nextViews[index] = {
400
688
  ...(rawViews[index] as Record<string, unknown>),
401
- ...input.patch,
689
+ ...patch,
402
690
  };
403
691
  }
404
692
 
@@ -480,9 +768,15 @@ export async function runReplaceContentDatabaseViews(input: {
480
768
  typeof candidate === "object" &&
481
769
  (candidate as { id?: unknown }).id === view.id,
482
770
  );
483
- return saved && typeof saved === "object"
484
- ? { ...(saved as Record<string, unknown>), ...view }
485
- : view;
771
+ const savedView =
772
+ saved && typeof saved === "object"
773
+ ? (saved as Record<string, unknown>)
774
+ : null;
775
+ return withSavableItemLayout(
776
+ savedView ? { ...savedView, ...view } : view,
777
+ savedView?.itemLayout,
778
+ context.definitions,
779
+ );
486
780
  });
487
781
  const nextRaw: Record<string, unknown> = {
488
782
  ...rawConfig,
@@ -65,19 +65,56 @@ function checkedWithin(lastRefreshedAt: string | null, ms: number) {
65
65
  return Number.isFinite(at) && Date.now() - at < ms;
66
66
  }
67
67
 
68
- function linkState(link: GitHubLink) {
68
+ function linkState(metadataJson: string | undefined) {
69
69
  let metadata: unknown;
70
70
  try {
71
- metadata = JSON.parse(link.metadataJson);
71
+ metadata = JSON.parse(metadataJson ?? "");
72
72
  } catch {
73
- // coercion-ok: a link whose metadata can't be read has no one to read GitHub as, so the sweep passes it by.
73
+ // coercion-ok: a link whose metadata can't be read has no state; the sweep passes it by.
74
74
  return null;
75
75
  }
76
76
  return readGitHubSourceState(metadata);
77
77
  }
78
78
 
79
79
  function linkedBy(link: GitHubLink): string | null {
80
- return linkState(link)?.linkedBy || null;
80
+ return linkState(link.metadataJson)?.linkedBy || null;
81
+ }
82
+
83
+ /** What the collection's status line shows about a link, as one comparable string. */
84
+ function statusShown(metadataJson: string | undefined) {
85
+ const state = linkState(metadataJson);
86
+ return JSON.stringify([
87
+ state?.syncedCommitSha ?? null,
88
+ state?.pendingCommitSha ?? null,
89
+ state?.progress?.done ?? null,
90
+ state?.progress?.total ?? null,
91
+ state?.problem?.code ?? null,
92
+ state?.skippedCount ?? 0,
93
+ state?.skipped.map((file) => `${file.reason}:${file.path}`) ?? [],
94
+ state?.imagesWaiting ?? 0,
95
+ ]);
96
+ }
97
+
98
+ /**
99
+ * Reads what a link's status line shows now. Compare a reading from before a
100
+ * sync with one from after to learn whether the sync changed it, like a new
101
+ * problem or a few more files done.
102
+ */
103
+ export async function readGitHubStatusShown(sourceId: string) {
104
+ const [source] = await getDb()
105
+ .select({ metadataJson: schema.contentDatabaseSources.metadataJson })
106
+ .from(schema.contentDatabaseSources)
107
+ .where(eq(schema.contentDatabaseSources.id, sourceId))
108
+ .limit(1);
109
+ return statusShown(source?.metadataJson);
110
+ }
111
+
112
+ /** Whether a sync changed what the status line shows, like a new problem. */
113
+ async function statusChanged(link: GitHubLink) {
114
+ return (
115
+ statusShown(link.metadataJson) !==
116
+ (await readGitHubStatusShown(link.sourceId))
117
+ );
81
118
  }
82
119
 
83
120
  export function githubSyncChangedRows(result: GitHubSyncResult) {
@@ -85,30 +122,40 @@ export function githubSyncChangedRows(result: GitHubSyncResult) {
85
122
  }
86
123
 
87
124
  /**
88
- * Tells everyone who can read the collection that a sync nobody asked for
89
- * changed its rows. It is the event the sync action sends, so open pages,
90
- * tables, page lists and Trash update in place.
125
+ * Tells everyone who can read the collection that its rows or status changed.
126
+ * It is the event the sync action sends, so open pages, tables, page lists,
127
+ * Trash and the status line update in place. An action whose sync failed
128
+ * sends it too, since a failed action sends no change event of its own.
91
129
  */
92
- function announceSync(
93
- link: GitHubLink,
130
+ export function announceGitHubSyncChange(
131
+ databaseDocumentId: string,
94
132
  owner: string | null,
95
- result: GitHubSyncResult,
96
133
  ) {
97
- if (!githubSyncChangedRows(result)) return;
98
134
  recordChange({
99
135
  source: "action",
100
136
  type: "change",
101
137
  key: "sync-github-folder",
102
138
  ...(owner ? { owner } : {}),
103
- ...documentChangeResource(link.databaseDocumentId),
139
+ ...documentChangeResource(databaseDocumentId),
104
140
  });
105
141
  }
106
142
 
143
+ /** For a sync nobody asked for that changed rows or the status line. */
144
+ async function announceSync(
145
+ link: GitHubLink,
146
+ owner: string | null,
147
+ result: GitHubSyncResult,
148
+ ) {
149
+ if (result.state === "busy") return;
150
+ if (!githubSyncChangedRows(result) && !(await statusChanged(link))) return;
151
+ announceGitHubSyncChange(link.databaseDocumentId, owner);
152
+ }
153
+
107
154
  async function syncBeforeRead(link: GitHubLink | undefined) {
108
155
  if (!link || checkedWithin(link.lastRefreshedAt, GITHUB_SOURCE_STALE_MS)) {
109
156
  return false;
110
157
  }
111
- const state = linkState(link);
158
+ const state = linkState(link.metadataJson);
112
159
  if (
113
160
  state?.pendingCommitSha &&
114
161
  checkedWithin(state.checkedAt, PUSH_LEFT_TO_SWEEP_MS)
@@ -116,8 +163,9 @@ async function syncBeforeRead(link: GitHubLink | undefined) {
116
163
  return false;
117
164
  }
118
165
  const until = Date.now() + READ_SYNC_BUDGET_MS;
166
+ let result: GitHubSyncResult;
119
167
  try {
120
- const result = await syncGitHubSource({
168
+ result = await syncGitHubSource({
121
169
  sourceId: link.sourceId,
122
170
  budgetMs: READ_SYNC_BUDGET_MS,
123
171
  });
@@ -129,8 +177,6 @@ async function syncBeforeRead(link: GitHubLink | undefined) {
129
177
  if (!result.commitSha) await waitForGitHubSync(link.sourceId, until);
130
178
  return true;
131
179
  }
132
- announceSync(link, linkedBy(link), result);
133
- return githubSyncChangedRows(result);
134
180
  } catch (error) {
135
181
  // coercion-ok: the read goes on with the rows Content has; the next read or background sync tries GitHub again.
136
182
  console.warn(
@@ -139,6 +185,15 @@ async function syncBeforeRead(link: GitHubLink | undefined) {
139
185
  );
140
186
  return false;
141
187
  }
188
+ // The rows changed either way, so the read rereads them even when telling
189
+ // open pages fails.
190
+ await announceSync(link, linkedBy(link), result).catch((error: unknown) => {
191
+ console.warn(
192
+ "[github-source] change event failed:",
193
+ error instanceof Error ? error.message : String(error),
194
+ );
195
+ });
196
+ return githubSyncChangedRows(result);
142
197
  }
143
198
 
144
199
  /**
@@ -237,7 +292,7 @@ export async function runGitHubSourceSweep(context: RecurringSweepContext) {
237
292
  signal: context.signal,
238
293
  }),
239
294
  );
240
- announceSync(link, owner, result);
295
+ await announceSync(link, owner, result);
241
296
  } catch (error) {
242
297
  // coercion-ok: one link's failure is recorded on that link by the sync; the rest still run.
243
298
  console.warn(
@@ -14,6 +14,17 @@ import {
14
14
  /** Most skipped files a link remembers for its status line. */
15
15
  const MAX_REMEMBERED_SKIPS = 50;
16
16
 
17
+ /** A file the folder has that no row shows, and why. */
18
+ export interface GitHubSkippedFile {
19
+ path: string;
20
+ reason: GitHubSourceSkipReason;
21
+ }
22
+
23
+ /** A file skipped once it was read, with the blob it was read at. */
24
+ export interface GitHubReadSkip extends GitHubSkippedFile {
25
+ blobSha: string;
26
+ }
27
+
17
28
  /**
18
29
  * Everything a GitHub link keeps between syncs, stored under `github` in the
19
30
  * source's metadata. Rows keep their own blob SHA, so a sync that stops
@@ -40,7 +51,15 @@ export interface GitHubSourceState extends GitHubFolderLocation {
40
51
  */
41
52
  cutShort: string[];
42
53
  files: number;
43
- skipped: Array<{ path: string; reason: GitHubSourceSkipReason }>;
54
+ /** Up to 50 skipped files; `skippedCount` says how many there are. */
55
+ skipped: GitHubSkippedFile[];
56
+ skippedCount: number;
57
+ /**
58
+ * Every file skipped once it was read, so later syncs pass each by while
59
+ * its blob is unchanged rather than reading it again. One per Markdown
60
+ * file at most, so a link never keeps more than 2,000.
61
+ */
62
+ readSkips: GitHubReadSkip[];
44
63
  /** Images rows couldn't store yet because file storage isn't set up. */
45
64
  imagesWaiting: number;
46
65
  problem: {
@@ -103,6 +122,24 @@ function stringOrNull(value: unknown): string | null {
103
122
  return typeof value === "string" && value ? value : null;
104
123
  }
105
124
 
125
+ function readSkippedFiles(
126
+ value: unknown,
127
+ ): Array<GitHubSkippedFile & { blobSha?: unknown }> {
128
+ return (Array.isArray(value) ? value : []).flatMap((raw) => {
129
+ const skip = asRecord(raw);
130
+ if (
131
+ !skip ||
132
+ typeof skip.path !== "string" ||
133
+ (skip.reason !== "mdx" &&
134
+ skip.reason !== "too-large" &&
135
+ skip.reason !== "not-text")
136
+ ) {
137
+ return [];
138
+ }
139
+ return [{ path: skip.path, reason: skip.reason, blobSha: skip.blobSha }];
140
+ });
141
+ }
142
+
106
143
  export function readGitHubSourceState(
107
144
  metadata: unknown,
108
145
  ): GitHubSourceState | null {
@@ -119,6 +156,16 @@ export function readGitHubSourceState(
119
156
  const progress = asRecord(github.progress);
120
157
  const problem = asRecord(github.problem);
121
158
  const claim = asRecord(github.claim);
159
+ const skipped = readSkippedFiles(github.skipped).map(({ path, reason }) => ({
160
+ path,
161
+ reason,
162
+ }));
163
+ const readSkips = readSkippedFiles(github.readSkips).flatMap(
164
+ (skip): GitHubReadSkip[] =>
165
+ typeof skip.blobSha === "string"
166
+ ? [{ path: skip.path, reason: skip.reason, blobSha: skip.blobSha }]
167
+ : [],
168
+ );
122
169
  return {
123
170
  owner: github.owner,
124
171
  repo: github.repo,
@@ -143,18 +190,12 @@ export function readGitHubSourceState(
143
190
  (path): path is string => typeof path === "string",
144
191
  ),
145
192
  files: typeof github.files === "number" ? github.files : 0,
146
- skipped: (Array.isArray(github.skipped) ? github.skipped : []).flatMap(
147
- (raw) => {
148
- const skip = asRecord(raw);
149
- return skip &&
150
- typeof skip.path === "string" &&
151
- (skip.reason === "mdx" ||
152
- skip.reason === "too-large" ||
153
- skip.reason === "not-text")
154
- ? [{ path: skip.path, reason: skip.reason }]
155
- : [];
156
- },
157
- ),
193
+ skipped,
194
+ skippedCount:
195
+ typeof github.skippedCount === "number"
196
+ ? github.skippedCount
197
+ : skipped.length,
198
+ readSkips,
158
199
  imagesWaiting:
159
200
  typeof github.imagesWaiting === "number" ? github.imagesWaiting : 0,
160
201
  problem:
@@ -174,10 +215,14 @@ export function readGitHubSourceState(
174
215
  };
175
216
  }
176
217
 
218
+ /** The first 50 skipped files in path order, which the status line lists. */
177
219
  export function rememberedSkips(
178
- skipped: Array<{ path: string; reason: GitHubSourceSkipReason }>,
179
- ) {
180
- return skipped.slice(0, MAX_REMEMBERED_SKIPS);
220
+ skipped: GitHubSkippedFile[],
221
+ ): GitHubSkippedFile[] {
222
+ return [...skipped]
223
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
224
+ .slice(0, MAX_REMEMBERED_SKIPS)
225
+ .map(({ path, reason }) => ({ path, reason }));
181
226
  }
182
227
 
183
228
  /** The status agents and the status line read; never includes the claim. */
@@ -199,7 +244,8 @@ export function githubSourceStatus(
199
244
  pendingCommitSha: state.pendingCommitSha,
200
245
  progress: state.progress,
201
246
  files: state.files,
202
- skipped: state.skipped,
247
+ skipped: state.skipped.map(({ path, reason }) => ({ path, reason })),
248
+ skippedCount: state.skippedCount,
203
249
  imagesWaiting: state.imagesWaiting,
204
250
  problem: state.problem,
205
251
  };