@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
@@ -1,11 +1,9 @@
1
- import { orgMembers } from "@agent-native/core/org";
1
+ import { readOrgMemberRole } from "@agent-native/core/server";
2
2
  import {
3
+ getRequestContext,
3
4
  getRequestUserEmail,
4
5
  getRequestOrgId,
5
- } from "@agent-native/core/server/request-context";
6
- import { and, eq, sql } from "drizzle-orm";
7
-
8
- import { getDb } from "../db/index.js";
6
+ } from "@agent-native/core/server";
9
7
 
10
8
  export class ForbiddenAuditError extends Error {
11
9
  readonly statusCode = 403;
@@ -36,22 +34,28 @@ export async function assertOrgAdmin(): Promise<AdminScope> {
36
34
 
37
35
  const orgId = getRequestOrgId();
38
36
  if (!orgId) {
37
+ if (getRequestContext()?.a2aIdentityAssurance === "org-member") {
38
+ throw new ForbiddenAuditError(
39
+ "Could not verify the delegated organization scope; refusing audit access.",
40
+ );
41
+ }
39
42
  return { ownerEmail: email };
40
43
  }
41
44
 
45
+ const request = getRequestContext();
46
+ if (
47
+ request?.a2aIdentityAssurance === "org-member" &&
48
+ (request.orgId?.trim() !== orgId.trim() ||
49
+ request.userEmail?.trim().toLowerCase() !== email.trim().toLowerCase())
50
+ ) {
51
+ throw new ForbiddenAuditError(
52
+ "Could not verify the delegated organization scope; refusing audit access.",
53
+ );
54
+ }
55
+
42
56
  let role: string | null = null;
43
57
  try {
44
- const [row] = await getDb()
45
- .select({ role: orgMembers.role })
46
- .from(orgMembers)
47
- .where(
48
- and(
49
- eq(orgMembers.orgId, orgId),
50
- sql`lower(${orgMembers.email}) = ${email.toLowerCase()}`,
51
- ),
52
- )
53
- .limit(1);
54
- role = row?.role?.toLowerCase() ?? null;
58
+ role = await readOrgMemberRole(orgId, email);
55
59
  } catch {
56
60
  throw new ForbiddenAuditError(
57
61
  "Could not verify org admin role; refusing audit access.",
@@ -1,6 +1,7 @@
1
1
  import { fail } from "@agent-native/core/action";
2
- import { getDbExec } from "@agent-native/core/db";
2
+ import { readOrgMemberRole } from "@agent-native/core/server";
3
3
  import {
4
+ getRequestContext,
4
5
  getRequestOrgId,
5
6
  getRequestUserEmail,
6
7
  } from "@agent-native/core/server/request-context";
@@ -21,11 +22,15 @@ export async function isBrainAdmin(
21
22
  const normalizedEmail = email?.trim();
22
23
  const normalizedOrgId = orgId?.trim();
23
24
  if (!normalizedEmail || !normalizedOrgId) return false;
24
- const { rows } = await getDbExec().execute({
25
- sql: `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = LOWER(?) AND federation_removal_pending_at IS NULL LIMIT 1`,
26
- args: [normalizedOrgId, normalizedEmail],
27
- });
28
- const role = (rows[0] as { role?: unknown } | undefined)?.role;
25
+ const request = getRequestContext();
26
+ if (
27
+ request?.a2aIdentityAssurance === "org-member" &&
28
+ (request.orgId?.trim() !== normalizedOrgId ||
29
+ request.userEmail?.trim().toLowerCase() !== normalizedEmail.toLowerCase())
30
+ ) {
31
+ return false;
32
+ }
33
+ const role = await readOrgMemberRole(normalizedOrgId, normalizedEmail);
29
34
  return role === "owner" || role === "admin";
30
35
  }
31
36
 
@@ -59,6 +59,7 @@ from the command menu (Cmd+K → "What's new") or from Settings.
59
59
 
60
60
  ### Improved
61
61
 
62
+ - Rewind settings now show whether Rewind is remembering screen only or screen + audio.
62
63
  - Clips detects browser meetings more reliably and keeps notes until the meeting stop and transcript save are confirmed.
63
64
  - Clips detects browser meetings sooner, asks before starting when the active tab is unclear, and retries failed stops.
64
65
  - Desktop dictation confirms when text is pasted or copied and keeps transcripts available when saving fails.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-10-16
4
+ ---
5
+
6
+ Rewind settings now show whether Rewind is remembering screen only or screen + audio.
@@ -96,6 +96,7 @@ import {
96
96
  RecordingRecoveryPage,
97
97
  type RecordingRecoveryProps,
98
98
  } from "./components/RecordingRecovery";
99
+ import { RewindCaptureStatus } from "./components/RewindCaptureStatus";
99
100
  import { ShortcutKeycaps } from "./components/ShortcutKeycaps";
100
101
  import { SourceRow, type CaptureSource } from "./components/SourceRow";
101
102
  import { Switch } from "./components/Switch";
@@ -8151,10 +8152,12 @@ function Setup({
8151
8152
  >
8152
8153
  {screenMemoryMessage.text}
8153
8154
  </p>
8154
- ) : rewindOn && rewindStatusPresentation.hasError ? (
8155
- <p className="text-xs text-destructive">
8156
- {rewindStatusPresentation.detail}
8157
- </p>
8155
+ ) : null}
8156
+ {rewindOn ? (
8157
+ <RewindCaptureStatus
8158
+ status={screenMemoryStatus}
8159
+ presentation={rewindStatusPresentation}
8160
+ />
8158
8161
  ) : null}
8159
8162
  </SettingsRow>
8160
8163
  {rewindOn ? (
@@ -0,0 +1,25 @@
1
+ import type { RewindStatusPresentation } from "../lib/rewind-status";
2
+ import type { ScreenMemoryStatus } from "../shared/config";
3
+
4
+ export function RewindCaptureStatus({
5
+ status,
6
+ presentation,
7
+ }: {
8
+ status: ScreenMemoryStatus | null;
9
+ presentation: RewindStatusPresentation;
10
+ }) {
11
+ if (!status || presentation.kind === "off") return null;
12
+ return (
13
+ <>
14
+ <p className="flex items-center gap-2" role="status">
15
+ {presentation.isLive ? (
16
+ <span className="rec-dot" aria-hidden="true" />
17
+ ) : null}
18
+ {presentation.title}
19
+ </p>
20
+ {presentation.hasError ? (
21
+ <p className="text-xs text-destructive">{presentation.detail}</p>
22
+ ) : null}
23
+ </>
24
+ );
25
+ }
@@ -70,9 +70,14 @@ export function getRewindStatusPresentation({
70
70
  }
71
71
 
72
72
  if (status?.state === "recording") {
73
+ // The active segment, not the setting: a clip or meeting notes can add
74
+ // audio while Remember is set to Screen only.
75
+ const capturesAudio = status.activeSegment?.captureMode === "visuals-audio";
73
76
  return {
74
77
  kind: "recording",
75
- title: "Rewind is remembering",
78
+ title: capturesAudio
79
+ ? "Rewind is remembering screen + audio"
80
+ : "Rewind is remembering screen only",
76
81
  detail:
77
82
  status.lastError ||
78
83
  status.coverage ||
@@ -70,6 +70,9 @@ export interface ScreenMemoryActiveSegment {
70
70
  durationMs: number;
71
71
  width?: number | null;
72
72
  height?: number | null;
73
+ // What this segment captures. A clip or meeting can add audio while the
74
+ // configured mode is screen only.
75
+ captureMode: RewindCaptureMode;
73
76
  }
74
77
 
75
78
  export interface ScreenMemoryStatus {
@@ -417,6 +417,7 @@ pub struct ScreenMemoryActiveSegment {
417
417
  pub duration_ms: u128,
418
418
  pub width: Option<u32>,
419
419
  pub height: Option<u32>,
420
+ pub capture_mode: RewindCaptureMode,
420
421
  }
421
422
 
422
423
  #[derive(Debug, Clone, Serialize)]
@@ -1134,6 +1135,7 @@ fn build_status(app: &AppHandle) -> Result<ScreenMemoryStatus, String> {
1134
1135
  duration_ms: active.started_at.elapsed().as_millis(),
1135
1136
  width: active.width,
1136
1137
  height: active.height,
1138
+ capture_mode: active.capture_mode,
1137
1139
  }),
1138
1140
  guard.last_error.clone(),
1139
1141
  guard.exclusion_active,
@@ -1,10 +1,6 @@
1
1
  import { readAppState } from "@agent-native/core/application-state";
2
- import {
3
- implicitServiceOrgRole,
4
- organizations,
5
- orgMembers,
6
- } from "@agent-native/core/org";
7
- import { getSession } from "@agent-native/core/server";
2
+ import { implicitServiceOrgRole, organizations } from "@agent-native/core/org";
3
+ import { getSession, readOrgMemberRole } from "@agent-native/core/server";
8
4
  import {
9
5
  getRequestUserEmail,
10
6
  getRequestOrgId,
@@ -175,20 +171,9 @@ export async function getOrganizationRoleForEmail(
175
171
  organizationId: string,
176
172
  email: string,
177
173
  ): Promise<OrganizationAccessRole | null> {
178
- const lowerEmail = email.toLowerCase();
179
-
180
174
  try {
181
- const [row] = await getDb()
182
- .select({ role: orgMembers.role })
183
- .from(orgMembers)
184
- .where(
185
- and(
186
- eq(orgMembers.orgId, organizationId),
187
- sql`lower(${orgMembers.email}) = ${lowerEmail}`,
188
- ),
189
- )
190
- .limit(1);
191
- if (row?.role) return normalizeOrganizationRole(row.role);
175
+ const role = await readOrgMemberRole(organizationId, email);
176
+ if (role) return normalizeOrganizationRole(role);
192
177
  } catch {
193
178
  // org_members table may not exist yet on first boot before migrations finish.
194
179
  }
@@ -11,7 +11,7 @@
11
11
  "typecheck": "tsc --noEmit"
12
12
  },
13
13
  "dependencies": {
14
- "@agent-native/core": "^0.210.2",
14
+ "@agent-native/core": "^0.213.1",
15
15
  "postgres": "^3.4.9",
16
16
  "qrcode": "^1.5.4",
17
17
  "zod": "^4.6.7"
@@ -3,6 +3,12 @@
3
3
  All notable user-facing changes to Agent-Native Content are documented here. Open it any
4
4
  time from the command menu (Cmd+K → "What's new").
5
5
 
6
+ ## 2026-10-21
7
+
8
+ ### Fixed
9
+
10
+ - Organization favorites include documents shared with the current member
11
+
6
12
  ## 2026-10-19
7
13
 
8
14
  ### Improved
@@ -24,6 +30,8 @@ time from the command menu (Cmd+K → "What's new").
24
30
 
25
31
  ### Added
26
32
 
33
+ - Connected AI assistants can make a collection from a GitHub folder, or link an empty one: each Markdown file becomes a view-only page, and a sync brings in the latest pushes
34
+ - Make a collection from a GitHub folder in the sidebar's New menu, or fill an empty one from its Sources settings, which say up front when GitHub can't link and what to do instead. A short preview comes before linking, the collection shows where its pages come from and when they last updated, and unlinking asks first
27
35
  - Connected AI assistants can link a new collection to a GitHub folder: each Markdown file becomes a view-only page, and a sync brings in the latest pushes
28
36
 
29
37
  ### Improved
@@ -1,6 +1,10 @@
1
1
  import { ActionContractError } from "@agent-native/core/action";
2
2
  import { getDbExec, type DbExec } from "@agent-native/core/db";
3
- import { getRequestUserEmail } from "@agent-native/core/server/request-context";
3
+ import { readOrgMemberRole } from "@agent-native/core/server";
4
+ import {
5
+ getRequestContext,
6
+ getRequestUserEmail,
7
+ } from "@agent-native/core/server/request-context";
4
8
  import { and, eq, isNull, sql } from "drizzle-orm";
5
9
 
6
10
  import { getDb, schema } from "../server/db/index.js";
@@ -13,6 +17,29 @@ export type ContentSpaceAccess = {
13
17
  role: ContentSpaceRole;
14
18
  };
15
19
 
20
+ function isDelegatedIdentityMismatch(orgId: string, email: string): boolean {
21
+ const request = getRequestContext();
22
+ return (
23
+ request?.a2aIdentityAssurance === "org-member" &&
24
+ (request.orgId?.trim() !== orgId.trim() ||
25
+ request.userEmail?.trim().toLowerCase() !== email.trim().toLowerCase())
26
+ );
27
+ }
28
+
29
+ function preserveDelegatedMemberRole(
30
+ orgId: string,
31
+ email: string,
32
+ validatedRole: string,
33
+ verifiedRole: string,
34
+ ): string {
35
+ const request = getRequestContext();
36
+ return request?.a2aIdentityAssurance === "org-member" &&
37
+ request.orgId?.trim() === orgId.trim() &&
38
+ request.userEmail?.trim().toLowerCase() === email.trim().toLowerCase()
39
+ ? verifiedRole
40
+ : validatedRole;
41
+ }
42
+
16
43
  type ContentOrganizationMembership = {
17
44
  role: string;
18
45
  name: string;
@@ -59,6 +86,11 @@ export async function getContentOrganizationMembership(
59
86
  userEmail: string,
60
87
  options: { db?: any } = {},
61
88
  ): Promise<{ role: string; name: string; createdBy: string } | null> {
89
+ if (isDelegatedIdentityMismatch(orgId, userEmail)) return null;
90
+
91
+ const verifiedRole = await readOrgMemberRole(orgId, userEmail);
92
+ if (!verifiedRole) return null;
93
+
62
94
  if (options.db) {
63
95
  const { organizations, orgMembers } =
64
96
  await import("@agent-native/core/org");
@@ -81,7 +113,7 @@ export async function getContentOrganizationMembership(
81
113
  )
82
114
  .limit(1);
83
115
  if (!row) return null;
84
- return revalidateFederatedMembership(orgId, userEmail, {
116
+ const validated = await revalidateFederatedMembership(orgId, userEmail, {
85
117
  role: (typeof row.role === "string"
86
118
  ? row.role
87
119
  : (JSON.stringify(row.role) ?? "member")
@@ -91,6 +123,17 @@ export async function getContentOrganizationMembership(
91
123
  identityAuthority: row.identityAuthority,
92
124
  identityId: row.identityId,
93
125
  });
126
+ return validated
127
+ ? {
128
+ ...validated,
129
+ role: preserveDelegatedMemberRole(
130
+ orgId,
131
+ userEmail,
132
+ validated.role,
133
+ verifiedRole,
134
+ ),
135
+ }
136
+ : null;
94
137
  }
95
138
  const result = await getDbExec().execute({
96
139
  sql: `SELECT m.role AS role, o.name AS name, o.created_by AS "createdBy",
@@ -119,7 +162,7 @@ export async function getContentOrganizationMembership(
119
162
  ) {
120
163
  return null;
121
164
  }
122
- return revalidateFederatedMembership(orgId, userEmail, {
165
+ const validated = await revalidateFederatedMembership(orgId, userEmail, {
123
166
  role: (typeof row.role === "string"
124
167
  ? row.role
125
168
  : (JSON.stringify(row.role) ?? "member")
@@ -129,12 +172,34 @@ export async function getContentOrganizationMembership(
129
172
  identityAuthority: row.identityAuthority,
130
173
  identityId: row.identityId,
131
174
  });
175
+ return validated
176
+ ? {
177
+ ...validated,
178
+ role: preserveDelegatedMemberRole(
179
+ orgId,
180
+ userEmail,
181
+ validated.role,
182
+ verifiedRole,
183
+ ),
184
+ }
185
+ : null;
132
186
  }
133
187
 
134
188
  export async function listContentOrganizationMemberships(
135
189
  userEmail: string,
136
190
  transaction?: DbExec,
137
191
  ) {
192
+ const request = getRequestContext();
193
+ const normalizedEmail = normalizeContentSpaceEmail(userEmail);
194
+ const delegated = request?.a2aIdentityAssurance === "org-member";
195
+ if (
196
+ delegated &&
197
+ (!request.orgId?.trim() ||
198
+ request.userEmail?.trim().toLowerCase() !== normalizedEmail)
199
+ ) {
200
+ return [];
201
+ }
202
+
138
203
  if (transaction) {
139
204
  const relation = await transaction.execute({
140
205
  sql: "SELECT to_regclass('org_members') AS relation",
@@ -153,8 +218,11 @@ export async function listContentOrganizationMemberships(
153
218
  INNER JOIN organizations o ON o.id = m.org_id
154
219
  WHERE LOWER(m.email) = $1
155
220
  AND m.federation_removal_pending_at IS NULL
221
+ ${delegated ? "AND m.org_id = $2" : ""}
156
222
  ORDER BY m.org_id ASC`,
157
- args: [normalizeContentSpaceEmail(userEmail)],
223
+ args: delegated
224
+ ? [normalizedEmail, request!.orgId!.trim()]
225
+ : [normalizedEmail],
158
226
  });
159
227
  } catch (error) {
160
228
  if (error instanceof Error && error.message.includes("does not exist")) {
@@ -174,11 +242,21 @@ export async function listContentOrganizationMemberships(
174
242
  identityAuthority: row.identityAuthority ?? row.identity_authority,
175
243
  identityId: row.identityId ?? row.identity_id,
176
244
  }))
245
+ .map((row: ContentOrganizationMembership & { orgId?: string }) => ({
246
+ ...row,
247
+ role:
248
+ row.role === "owner" || row.role === "admin" || row.role === "member"
249
+ ? delegated
250
+ ? "member"
251
+ : row.role
252
+ : null,
253
+ }))
177
254
  .filter(
178
255
  (row): row is ContentOrganizationMembership & { orgId: string } =>
179
256
  typeof row.orgId === "string" &&
180
257
  typeof row.name === "string" &&
181
- typeof row.createdBy === "string",
258
+ typeof row.createdBy === "string" &&
259
+ row.role !== null,
182
260
  );
183
261
  return Promise.all(
184
262
  memberships.map(async ({ orgId, ...membership }) => {
@@ -187,7 +265,18 @@ export async function listContentOrganizationMemberships(
187
265
  userEmail,
188
266
  membership,
189
267
  );
190
- return validated ? { orgId, ...validated } : null;
268
+ return validated
269
+ ? {
270
+ orgId,
271
+ ...validated,
272
+ role: preserveDelegatedMemberRole(
273
+ orgId,
274
+ normalizedEmail,
275
+ validated.role,
276
+ membership.role,
277
+ ),
278
+ }
279
+ : null;
191
280
  }),
192
281
  ).then((validated) =>
193
282
  validated.filter(
@@ -1018,23 +1018,27 @@ export async function getContentDatabasePageResponse(
1018
1018
  items.map((item) => item.documentId),
1019
1019
  ),
1020
1020
  isNull(schema.documents.trashedAt),
1021
- database.systemRole === "workspaces"
1022
- ? workspacesVisibleDocumentIds?.length
1023
- ? inArray(schema.documents.id, workspacesVisibleDocumentIds)
1021
+ database.systemRole === "favorites"
1022
+ ? favoritesVisibleDocumentQuery
1023
+ ? inArray(schema.documents.id, favoritesVisibleDocumentQuery)
1024
1024
  : sql`1 = 0`
1025
- : database.systemRole === "files" && database.orgId
1026
- ? and(
1027
- eq(schema.documents.orgId, database.orgId),
1028
- or(
1029
- eq(schema.documents.visibility, "org"),
1030
- eq(schema.documents.visibility, "public"),
1031
- ),
1032
- or(
1033
- eq(schema.documents.hideFromSearch, 0),
1034
- isNull(schema.documents.hideFromSearch),
1035
- ),
1036
- )
1037
- : eq(schema.documents.ownerEmail, database.ownerEmail),
1025
+ : database.systemRole === "workspaces"
1026
+ ? workspacesVisibleDocumentIds?.length
1027
+ ? inArray(schema.documents.id, workspacesVisibleDocumentIds)
1028
+ : sql`1 = 0`
1029
+ : database.systemRole === "files" && database.orgId
1030
+ ? and(
1031
+ eq(schema.documents.orgId, database.orgId),
1032
+ or(
1033
+ eq(schema.documents.visibility, "org"),
1034
+ eq(schema.documents.visibility, "public"),
1035
+ ),
1036
+ or(
1037
+ eq(schema.documents.hideFromSearch, 0),
1038
+ isNull(schema.documents.hideFromSearch),
1039
+ ),
1040
+ )
1041
+ : eq(schema.documents.ownerEmail, database.ownerEmail),
1038
1042
  ),
1039
1043
  )
1040
1044
  : [];
@@ -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(