@agent-native/core-corpus 0.212.1 → 0.213.1

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 (75) 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/CHANGELOG.md +8 -0
  29. package/corpus/templates/content/actions/_content-space-access.ts +95 -6
  30. package/corpus/templates/content/actions/_database-utils.ts +20 -16
  31. package/corpus/templates/content/actions/_github-source-freshness.ts +72 -17
  32. package/corpus/templates/content/actions/_github-source-metadata.ts +63 -17
  33. package/corpus/templates/content/actions/_github-source.ts +141 -77
  34. package/corpus/templates/content/actions/check-github-connection.ts +15 -0
  35. package/corpus/templates/content/actions/create-github-collection.ts +180 -0
  36. package/corpus/templates/content/actions/disconnect-content-database-source.ts +5 -0
  37. package/corpus/templates/content/actions/link-github-folder.ts +104 -36
  38. package/corpus/templates/content/actions/refresh-content-database-source.ts +37 -4
  39. package/corpus/templates/content/actions/sync-github-folder.ts +23 -5
  40. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +172 -19
  41. package/corpus/templates/content/app/components/editor/database/GitHubLinkDialog.tsx +938 -0
  42. package/corpus/templates/content/app/components/editor/database/GitHubSourcePanel.tsx +228 -0
  43. package/corpus/templates/content/app/components/editor/database/GitHubSourceStatusLine.tsx +561 -0
  44. package/corpus/templates/content/app/components/editor/database/GitHubUnlinkDialog.tsx +96 -0
  45. package/corpus/templates/content/app/components/editor/database/github-availability.ts +147 -0
  46. package/corpus/templates/content/app/components/editor/database/github-source-text.ts +280 -0
  47. package/corpus/templates/content/app/components/layout/Layout.tsx +2 -0
  48. package/corpus/templates/content/app/hooks/content-action-refresh.ts +6 -0
  49. package/corpus/templates/content/app/hooks/use-content-database.ts +3 -1
  50. package/corpus/templates/content/app/hooks/use-github-folder.ts +199 -0
  51. package/corpus/templates/content/app/i18n-data.ts +154 -1
  52. package/corpus/templates/content/app/lib/github-link-dialog.ts +86 -0
  53. package/corpus/templates/content/changelog/2026-10-18-connected-ai-assistants-can-link-a-new-collection-to-a-github.md +1 -1
  54. package/corpus/templates/content/changelog/2026-10-18-link-a-new-collection-to-a-github-folder-from-its-sources.md +6 -0
  55. package/corpus/templates/content/changelog/2026-10-21-organization-favorites-include-documents-shared-with-the-cur.md +6 -0
  56. package/corpus/templates/content/docs/product/capabilities/content.source.sync-policy.md +32 -7
  57. package/corpus/templates/content/docs/product/encyclopedia.md +3 -3
  58. package/corpus/templates/content/docs/product/features/content.feature.trust-your-connected-sources.md +2 -2
  59. package/corpus/templates/content/docs/product/roadmap.md +1 -1
  60. package/corpus/templates/content/parity/matrix.md +42 -42
  61. package/corpus/templates/content/parity/matrix.ts +14 -0
  62. package/corpus/templates/content/server/lib/github-source-client.ts +50 -1
  63. package/corpus/templates/content/shared/api.ts +3 -0
  64. package/corpus/templates/content/shared/github-source.ts +68 -0
  65. package/corpus/templates/design/CHANGELOG.md +1 -0
  66. package/corpus/templates/design/actions/connect-assets-mcp.ts +5 -9
  67. package/corpus/templates/design/changelog/2026-10-21-journey-canvases-accept-partial-analytics-samples-when-repla.md +6 -0
  68. package/corpus/templates/design/shared/journey-canvas.ts +6 -4
  69. package/corpus/templates/dispatch/.agents/skills/a2a-protocol/SKILL.md +16 -13
  70. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  71. package/corpus/templates/dispatch/changelog/2026-10-21-customer-ai-credit-questions-route-to-account-health-instead.md +6 -0
  72. package/corpus/templates/factory/server/lib/require-workspace-member.ts +5 -17
  73. package/corpus/templates/mail/server/lib/queued-drafts.ts +9 -4
  74. package/corpus/templates/slides/server/workspace-defaults.ts +12 -15
  75. package/package.json +1 -1
@@ -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
  };
@@ -31,6 +31,7 @@ import {
31
31
  GITHUB_SOURCE_MAX_IMAGE_BYTES,
32
32
  GITHUB_SOURCE_TYPE,
33
33
  githubBranchCandidates,
34
+ githubFieldExamples,
34
35
  githubFieldPropertyInput,
35
36
  githubFolderLabel,
36
37
  githubFolderWebUrl,
@@ -41,6 +42,7 @@ import {
41
42
  parseGitHubFolderUrl,
42
43
  type GitHubFieldType,
43
44
  type GitHubFolderLocation,
45
+ type GitHubFolderPreview,
44
46
  type GitHubSourceProblemCode,
45
47
  type GitHubSourceSkipReason,
46
48
  } from "../shared/github-source.js";
@@ -78,6 +80,7 @@ import {
78
80
  githubSourceMetadataRecord,
79
81
  readGitHubSourceState,
80
82
  rememberedSkips,
83
+ type GitHubReadSkip,
81
84
  type GitHubSourceState,
82
85
  } from "./_github-source-metadata.js";
83
86
  import {
@@ -201,6 +204,7 @@ const PROBLEM_STATUS: Record<GitHubSourceProblemCode, number> = {
201
204
  rate_limited: 429,
202
205
  too_large: 413,
203
206
  unavailable: 502,
207
+ sync_failed: 500,
204
208
  storage_unavailable: 503,
205
209
  };
206
210
 
@@ -337,37 +341,6 @@ function frontmatterValues(
337
341
  return draft.frontmatter.unmapped ?? {};
338
342
  }
339
343
 
340
- export interface GitHubFolderPreview {
341
- location: GitHubFolderLocation;
342
- label: string;
343
- folderUrl: string;
344
- commitSha: string;
345
- markdownFiles: number;
346
- skipped: Array<{ path: string; reason: GitHubSourceSkipReason }>;
347
- /** Columns the frontmatter would become, with the type each would get. */
348
- fields: Array<{ key: string; type: GitHubFieldType; examples: string[] }>;
349
- sample: Array<{ path: string; title: string }>;
350
- /** How many files the field types were read from. */
351
- sampledFiles: number;
352
- /** Image files in the folder, which pages show from Content's file storage. */
353
- images: number;
354
- /** Whether file storage is set up to hold those images. */
355
- storageReady: boolean;
356
- }
357
-
358
- function exampleText(value: unknown): string {
359
- if (typeof value === "string") return value.slice(0, 80);
360
- if (Array.isArray(value)) {
361
- return value
362
- .map((entry) =>
363
- typeof entry === "string" ? entry : JSON.stringify(entry),
364
- )
365
- .join(", ")
366
- .slice(0, 80);
367
- }
368
- return JSON.stringify(value).slice(0, 80);
369
- }
370
-
371
344
  /**
372
345
  * Reports what linking the folder would create without creating anything:
373
346
  * how many files become rows, which are skipped and why, and the columns the
@@ -439,10 +412,7 @@ export async function previewGitHubFolder(
439
412
  fields: [...valuesByKey].map(([key, values]) => ({
440
413
  key,
441
414
  type: inferGitHubFieldType(key, values),
442
- examples: values
443
- .filter((value) => value !== null && value !== "")
444
- .slice(0, 3)
445
- .map(exampleText),
415
+ examples: githubFieldExamples(values),
446
416
  })),
447
417
  sample,
448
418
  sampledFiles: sample.length,
@@ -788,6 +758,26 @@ function emptyResult(
788
758
  };
789
759
  }
790
760
 
761
+ /**
762
+ * The problem a sync records when it fails for a reason it can't name, like a
763
+ * database error. The GitHub client names its own failures, a dropped
764
+ * connection included, so this one isn't GitHub's.
765
+ */
766
+ function unexpectedProblem(
767
+ at: string,
768
+ ): NonNullable<GitHubSourceState["problem"]> {
769
+ return {
770
+ code: "sync_failed",
771
+ message: "The sync failed. The background sync tries again.",
772
+ at,
773
+ };
774
+ }
775
+
776
+ /** A sync that failed before it could record why. */
777
+ export function unexpectedSyncFailure(at: string): GitHubSyncResult {
778
+ return emptyResult("error", null, unexpectedProblem(at));
779
+ }
780
+
791
781
  /** The status problem for images a sync couldn't store, if there are any. */
792
782
  function storageProblem(
793
783
  imagesWaiting: number,
@@ -1643,6 +1633,11 @@ export interface SyncGitHubSourceOptions {
1643
1633
  force?: boolean;
1644
1634
  /** Rethrow a missing GitHub connection so an interactive caller can offer to connect. */
1645
1635
  throwConnectionErrors?: boolean;
1636
+ /**
1637
+ * Rethrow a failure the sync can't name, like a database error, once it's
1638
+ * on the link, for a caller that reports failures as errors.
1639
+ */
1640
+ throwUnexpectedErrors?: boolean;
1646
1641
  /** Ends the sync early, as its time budget would, such as when the server stops. */
1647
1642
  signal?: AbortSignal;
1648
1643
  }
@@ -1704,6 +1699,19 @@ export async function syncGitHubSource(
1704
1699
  () => budget.abort(),
1705
1700
  Math.max(0, deadline - Date.now()),
1706
1701
  );
1702
+ // Counts what this run writes. A run that fails partway still reports the
1703
+ // rows it changed first, so readers hear about them.
1704
+ const result = emptyResult("synced", null);
1705
+ // Files this run read and had to skip. A run that fails partway keeps
1706
+ // them too, so the next one doesn't read them again.
1707
+ let readSkipsSoFar: GitHubReadSkip[] | null = null;
1708
+ const failed = (problem: GitHubSourceState["problem"]): GitHubSyncResult => ({
1709
+ ...emptyResult("error", state.pendingCommitSha, problem),
1710
+ created: result.created,
1711
+ updated: result.updated,
1712
+ renamed: result.renamed,
1713
+ trashed: result.trashed,
1714
+ });
1707
1715
  const endBudget = () => budget.abort();
1708
1716
  if (options.signal?.aborted) endBudget();
1709
1717
  options.signal?.addEventListener("abort", endBudget, { once: true });
@@ -1862,7 +1870,18 @@ export async function syncGitHubSource(
1862
1870
  run.documentIdByPath.set(folderRelative(run, path), row.documentId);
1863
1871
  }
1864
1872
 
1873
+ // A file skipped once it was read is skipped again while its blob is the
1874
+ // same, so it isn't read again. It has no row to record that on.
1875
+ const readSkipsByPath = new Map(
1876
+ state.readSkips.map((skip) => [skip.path, skip] as const),
1877
+ );
1878
+ const stillSkipped: GitHubReadSkip[] = [];
1865
1879
  const work = planned.filter((file) => {
1880
+ const skip = readSkipsByPath.get(file.entry.path);
1881
+ if (skip?.blobSha === file.entry.sha) {
1882
+ stillSkipped.push(skip);
1883
+ return false;
1884
+ }
1866
1885
  const previous = file.row ? rowState(file.row) : null;
1867
1886
  return (
1868
1887
  !previous ||
@@ -1880,9 +1899,10 @@ export async function syncGitHubSource(
1880
1899
  (a, b) =>
1881
1900
  (cutShort.get(a.repoPath) ?? 0) - (cutShort.get(b.repoPath) ?? 0),
1882
1901
  );
1883
- const result = emptyResult("synced", head.sha);
1884
- result.unchanged = planned.length - work.length;
1885
- const skippedFiles = [...files.skipped];
1902
+ result.commitSha = head.sha;
1903
+ result.unchanged = planned.length - work.length - stillSkipped.length;
1904
+ const readSkips: GitHubReadSkip[] = [...stillSkipped];
1905
+ readSkipsSoFar = readSkips;
1886
1906
  let done = 0;
1887
1907
  let cutShortPath: string | null = null;
1888
1908
  for (const file of work) {
@@ -1923,7 +1943,11 @@ export async function syncGitHubSource(
1923
1943
  }
1924
1944
  if (typeof outcome === "string") result[outcome] += 1;
1925
1945
  else
1926
- skippedFiles.push({ path: file.entry.path, reason: outcome.skipped });
1946
+ readSkips.push({
1947
+ path: file.entry.path,
1948
+ reason: outcome.skipped,
1949
+ blobSha: file.entry.sha,
1950
+ });
1927
1951
  done += 1;
1928
1952
  }
1929
1953
  const remainingFiles = work.length - done;
@@ -1957,40 +1981,55 @@ export async function syncGitHubSource(
1957
1981
  // moved it for two minutes. One that got nowhere waits its turn, as a
1958
1982
  // failed check does.
1959
1983
  const countsAsCheck = complete || (done === 0 && result.trashed === 0);
1960
- const skipped = rememberedSkips(
1961
- skippedFiles.sort((a, b) => (a.path < b.path ? -1 : 1)),
1962
- );
1984
+ const skippedFiles = [...files.skipped, ...readSkips];
1985
+ const skipped = rememberedSkips(skippedFiles);
1963
1986
  await release(
1964
- (current) => ({
1965
- ...current,
1966
- checkedAt: now,
1967
- problem: result.problem,
1968
- files: files.markdown.length,
1969
- skipped,
1970
- imagesWaiting: result.imagesWaiting,
1971
- ...(complete
1972
- ? {
1973
- syncedCommitSha: head.sha,
1974
- syncedAt: new Date().toISOString(),
1975
- headEtag: head.etag,
1976
- pendingCommitSha: null,
1977
- progress: null,
1978
- cutShort: [],
1979
- }
1980
- : {
1981
- pendingCommitSha: head.sha,
1982
- cutShort: cutShortPath
1983
- ? [
1984
- ...current.cutShort.filter((path) => path !== cutShortPath),
1985
- cutShortPath,
1986
- ]
1987
- : current.cutShort,
1988
- progress: {
1989
- done: work.length - remainingFiles + result.trashed,
1990
- total: work.length + result.trashed + gone.size,
1991
- },
1992
- }),
1993
- }),
1987
+ (current) => {
1988
+ // A run that carries on a push sees only the files left, so it keeps
1989
+ // the total the push started with and counts what's left against it.
1990
+ // The count never goes back.
1991
+ const before =
1992
+ current.pendingCommitSha === head.sha ? current.progress : null;
1993
+ const total = before?.total ?? work.length + result.trashed + gone.size;
1994
+ const progress = {
1995
+ done: Math.min(
1996
+ total,
1997
+ Math.max(before?.done ?? 0, total - result.remaining),
1998
+ ),
1999
+ total,
2000
+ };
2001
+ return {
2002
+ ...current,
2003
+ checkedAt: now,
2004
+ problem: result.problem,
2005
+ files: files.markdown.length,
2006
+ skipped,
2007
+ skippedCount: skippedFiles.length,
2008
+ readSkips,
2009
+ imagesWaiting: result.imagesWaiting,
2010
+ ...(complete
2011
+ ? {
2012
+ syncedCommitSha: head.sha,
2013
+ syncedAt: new Date().toISOString(),
2014
+ headEtag: head.etag,
2015
+ pendingCommitSha: null,
2016
+ progress: null,
2017
+ cutShort: [],
2018
+ }
2019
+ : {
2020
+ pendingCommitSha: head.sha,
2021
+ cutShort: cutShortPath
2022
+ ? [
2023
+ ...current.cutShort.filter(
2024
+ (path) => path !== cutShortPath,
2025
+ ),
2026
+ cutShortPath,
2027
+ ]
2028
+ : current.cutShort,
2029
+ progress,
2030
+ }),
2031
+ };
2032
+ },
1994
2033
  () => ({
1995
2034
  syncState: complete ? "linked" : "refreshing",
1996
2035
  freshness: complete ? "fresh" : "stale",
@@ -2017,16 +2056,33 @@ export async function syncGitHubSource(
2017
2056
  lastRefreshedAt: now,
2018
2057
  }),
2019
2058
  ).catch(warnLeaseNotReleased);
2020
- return emptyResult("error", state.pendingCommitSha, {
2059
+ return failed({
2021
2060
  code: "unavailable",
2022
2061
  message:
2023
2062
  "GitHub didn't answer in time. The background sync tries again.",
2024
2063
  at: now,
2025
2064
  });
2026
2065
  }
2027
- const problem = problemFrom(error, now);
2066
+ // A contract error, like the link going away mid-sync, is the caller's to
2067
+ // see. Anything else the sync can't name is still a failed check: the
2068
+ // status line shows it and the next sync tries again.
2069
+ const named = problemFrom(error, now);
2070
+ const problem =
2071
+ named ??
2072
+ (error instanceof ActionContractError ? null : unexpectedProblem(now));
2073
+ if (!named && problem) {
2074
+ console.warn(
2075
+ "[github-source] sync failed:",
2076
+ error instanceof Error ? error.message : String(error),
2077
+ );
2078
+ }
2028
2079
  await release(
2029
- (current) => ({ ...current, checkedAt: now, problem }),
2080
+ (current) => ({
2081
+ ...current,
2082
+ checkedAt: now,
2083
+ problem,
2084
+ ...(readSkipsSoFar ? { readSkips: readSkipsSoFar } : {}),
2085
+ }),
2030
2086
  () => ({
2031
2087
  syncState: "error",
2032
2088
  freshness: "stale",
@@ -2038,7 +2094,8 @@ export async function syncGitHubSource(
2038
2094
  if (problem.code === "connection" && options.throwConnectionErrors) {
2039
2095
  throw error;
2040
2096
  }
2041
- return emptyResult("error", state.pendingCommitSha, problem);
2097
+ if (!named && options.throwUnexpectedErrors) throw error;
2098
+ return failed(problem);
2042
2099
  } finally {
2043
2100
  clearTimeout(budgetTimer);
2044
2101
  options.signal?.removeEventListener("abort", endBudget);
@@ -2078,6 +2135,8 @@ export async function createGitHubSourceLink(args: {
2078
2135
  cutShort: [],
2079
2136
  files: preview.markdownFiles,
2080
2137
  skipped: rememberedSkips(preview.skipped),
2138
+ skippedCount: preview.skipped.length,
2139
+ readSkips: [],
2081
2140
  problem: null,
2082
2141
  claim: null,
2083
2142
  };
@@ -2161,6 +2220,11 @@ export function githubPreviewSummary(preview: GitHubFolderPreview) {
2161
2220
  ),
2162
2221
  )}.`
2163
2222
  : " It has no frontmatter columns.";
2223
+ // Columns come from the first files only; later ones can differ.
2224
+ const sampled =
2225
+ preview.sampledFiles < preview.markdownFiles
2226
+ ? ` That frontmatter is read from ${preview.sampledFiles} of its ${preview.markdownFiles} files, so the others can add columns or change their types.`
2227
+ : "";
2164
2228
  const skipped = preview.skipped.length
2165
2229
  ? ` ${preview.skipped.length} skipped: ${listWithMore(
2166
2230
  preview.skipped.map(
@@ -2172,7 +2236,7 @@ export function githubPreviewSummary(preview: GitHubFolderPreview) {
2172
2236
  preview.images > 0 && !preview.storageReady
2173
2237
  ? ` Its ${preview.images === 1 ? "image won't" : `${preview.images} images won't`} show until file storage is set up in Settings → File uploads.`
2174
2238
  : "";
2175
- return `${preview.label} at ${preview.commitSha.slice(0, 7)} has ${preview.markdownFiles} Markdown ${preview.markdownFiles === 1 ? "file" : "files"}.${columns}${skipped}${images}`;
2239
+ return `${preview.label} at ${preview.commitSha.slice(0, 7)} has ${preview.markdownFiles} Markdown ${preview.markdownFiles === 1 ? "file" : "files"}.${columns}${sampled}${skipped}${images}`;
2176
2240
  }
2177
2241
 
2178
2242
  export function githubSyncSummary(label: string, result: GitHubSyncResult) {
@@ -0,0 +1,15 @@
1
+ import { defineAction } from "@agent-native/core/action";
2
+ import { z } from "zod";
3
+
4
+ import { readGitHubConnected } from "../server/lib/github-source-client.js";
5
+
6
+ export default defineAction({
7
+ description:
8
+ "Check whether the current user has a GitHub connection Content can read folders through, before linking one. Reads no repository. `connected` is null when GitHub couldn't be asked.",
9
+ schema: z.object({}).strict(),
10
+ http: { method: "GET" },
11
+ readOnly: true,
12
+ run: async (): Promise<{ connected: boolean | null }> => ({
13
+ connected: await readGitHubConnected(),
14
+ }),
15
+ });
@@ -0,0 +1,180 @@
1
+ import { ActionContractError, defineAction } from "@agent-native/core/action";
2
+ import { getRequestUserEmail } from "@agent-native/core/server/request-context";
3
+ import { assertAccess } from "@agent-native/core/sharing";
4
+ import { z } from "zod";
5
+
6
+ import { documentChangeResource } from "../server/lib/document-change-resource.js";
7
+ import { githubFolderName } from "../shared/github-source.js";
8
+ import { resolveContentSpaceAccess } from "./_content-space-access.js";
9
+ import type { DatabaseSetupReceipt } from "./_database-setup-mutation.js";
10
+ import { resolveDatabaseForSourceMutation } from "./_database-source-utils.js";
11
+ import {
12
+ githubActionError,
13
+ githubPreviewSummary,
14
+ previewGitHubFolder,
15
+ readLinkedGitHubSource,
16
+ resolveGitHubFolder,
17
+ } from "./_github-source.js";
18
+ import { nanoid, serializeDatabase } from "./_property-utils.js";
19
+ import createContentDatabase from "./create-content-database.js";
20
+ import deleteContentDatabase from "./delete-content-database.js";
21
+ import {
22
+ bringInGitHubLink,
23
+ linkPreviewedGitHubFolder,
24
+ } from "./link-github-folder.js";
25
+
26
+ const createGitHubCollectionSchema = z
27
+ .object({
28
+ url: z
29
+ .string()
30
+ .min(1)
31
+ .describe(
32
+ "GitHub folder link, like https://github.com/owner/repo/tree/main/docs, or owner/repo for the whole repository",
33
+ ),
34
+ spaceId: z
35
+ .string()
36
+ .min(1)
37
+ .describe("Exact Content space ID for the new collection"),
38
+ parentId: z
39
+ .string()
40
+ .min(1)
41
+ .nullish()
42
+ .describe("Page to put the collection under; leave out for the space"),
43
+ connectionId: z
44
+ .string()
45
+ .min(1)
46
+ .optional()
47
+ .describe(
48
+ "GitHub connection to read through when there is more than one",
49
+ ),
50
+ dryRun: z
51
+ .boolean()
52
+ .optional()
53
+ .describe("Preview only: read the folder without making the collection"),
54
+ idempotencyKey: z
55
+ .string()
56
+ .min(1)
57
+ .max(200)
58
+ .optional()
59
+ .describe(
60
+ "Creation intent key; reuse it unchanged after a lost response",
61
+ ),
62
+ })
63
+ .strict();
64
+
65
+ export default defineAction({
66
+ description:
67
+ "Make a new collection from a GitHub folder, named after the folder and linked to it view only: each Markdown file becomes a row and pushes to the branch arrive on their own. Run with dryRun first to see the files, skipped files and columns without making anything.",
68
+ mcpTool: true,
69
+ // It only makes a new collection, so it never replaces anyone's content.
70
+ mcpAnnotations: {
71
+ readOnlyHint: false,
72
+ destructiveHint: false,
73
+ openWorldHint: true,
74
+ },
75
+ agentInputSchema: createGitHubCollectionSchema,
76
+ schema: createGitHubCollectionSchema,
77
+ run: async (args) => {
78
+ const userEmail = getRequestUserEmail();
79
+ if (!userEmail) {
80
+ throw new ActionContractError("Sign in to link a GitHub folder.", {
81
+ errorCode: "AUTH_REQUIRED",
82
+ statusCode: 401,
83
+ });
84
+ }
85
+ // The same checks the new collection gets, so a preview doesn't promise
86
+ // a collection the person can't make.
87
+ await resolveContentSpaceAccess(args.spaceId, "contributor");
88
+ if (args.parentId) await assertAccess("document", args.parentId, "editor");
89
+
90
+ const connectionId = args.connectionId ?? null;
91
+ let resolved: Awaited<ReturnType<typeof resolveGitHubFolder>>;
92
+ let preview: Awaited<ReturnType<typeof previewGitHubFolder>>;
93
+ try {
94
+ resolved = await resolveGitHubFolder({ url: args.url, connectionId });
95
+ preview = await previewGitHubFolder(resolved, connectionId);
96
+ } catch (error) {
97
+ throw githubActionError(error);
98
+ }
99
+ const title = githubFolderName(preview.location);
100
+
101
+ if (args.dryRun) {
102
+ return {
103
+ dryRun: true as const,
104
+ preview,
105
+ title,
106
+ summary: `${githubPreviewSummary(preview)} Run again without dryRun to make the "${title}" collection and link it.`,
107
+ };
108
+ }
109
+
110
+ const created = await createContentDatabase.run({
111
+ spaceId: args.spaceId,
112
+ parentId: args.parentId ?? null,
113
+ title,
114
+ idempotencyKey: args.idempotencyKey ?? `github-collection-${nanoid()}`,
115
+ });
116
+ const database = await resolveDatabaseForSourceMutation({
117
+ databaseId: created.database.id,
118
+ });
119
+ if (!database) {
120
+ throw new ActionContractError("The new collection can't be found.", {
121
+ errorCode: "DATABASE_NOT_FOUND",
122
+ statusCode: 404,
123
+ });
124
+ }
125
+ // A retry after a lost response gets back the collection the first try
126
+ // made, which may be linked already.
127
+ const { receipt } = created as { receipt?: DatabaseSetupReceipt };
128
+ const madeEarlier = receipt?.idempotency.result === "replayed";
129
+ const earlierLink = madeEarlier
130
+ ? await readLinkedGitHubSource(database.id)
131
+ : null;
132
+ if (earlierLink && earlierLink.sourceName === preview.label) {
133
+ return {
134
+ dryRun: false as const,
135
+ database: serializeDatabase(database),
136
+ preview,
137
+ title,
138
+ ...(await bringInGitHubLink({
139
+ database,
140
+ preview,
141
+ sourceId: earlierLink.id,
142
+ })),
143
+ };
144
+ }
145
+ let linked: Awaited<ReturnType<typeof linkPreviewedGitHubFolder>>;
146
+ try {
147
+ linked = await linkPreviewedGitHubFolder({
148
+ database,
149
+ resolved,
150
+ preview,
151
+ connectionId,
152
+ userEmail,
153
+ });
154
+ } catch (error) {
155
+ // The collection was made for this folder, so it doesn't stay behind
156
+ // empty when the link can't be saved. One an earlier try made is left
157
+ // alone.
158
+ if (madeEarlier) throw error;
159
+ try {
160
+ await deleteContentDatabase.run({ databaseId: database.id });
161
+ } catch (cleanupError) {
162
+ console.warn(
163
+ "[create-github-collection] could not remove the new collection",
164
+ cleanupError,
165
+ );
166
+ }
167
+ throw error;
168
+ }
169
+ return {
170
+ dryRun: false as const,
171
+ database: serializeDatabase(database),
172
+ preview,
173
+ title,
174
+ ...linked,
175
+ };
176
+ },
177
+ // Everyone reading the new collection gets its rows, not just the caller.
178
+ changeResource: (_input, result) =>
179
+ result.dryRun ? null : documentChangeResource(result.database.documentId),
180
+ });
@@ -4,6 +4,7 @@ import { and, eq, inArray } from "drizzle-orm";
4
4
  import { z } from "zod";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
7
+ import { documentChangeResource } from "../server/lib/document-change-resource.js";
7
8
  import type {
8
9
  ContentDatabaseResponse,
9
10
  DisconnectContentDatabaseSourceRequest,
@@ -125,4 +126,8 @@ export default defineAction({
125
126
 
126
127
  return getContentDatabaseResponse(database.id, { limit: 100, offset: 0 });
127
128
  },
129
+ // Everyone reading the collection sees the source go, and a GitHub
130
+ // collection's pages become editable for them too.
131
+ changeResource: (_input, result) =>
132
+ documentChangeResource(result.database.documentId),
128
133
  });